Skip to content

Conversation

@kkartunov
Copy link
Collaborator

Related JIRA Ticket:

https://topcoder.atlassian.net/browse/

What's in this PR?

Moving V6 stuff to dev env to prepare the switch off "-v6" .

vas3a and others added 30 commits August 21, 2025 17:31
…corecard

Fix sortOrder for scorecardQuestions & make error more visible
Fixes view and css on Restricted page
fix(PM-1503): Weight column in view scorecard page
…corecard

PM-1504 - scorecard: Make category options dependent on project type
…corecard

PM-1504 - redirect to view scorecard
fix(PM-1503) Used requiresUpload from backend
fix(PM-1503): removed breadcrumb for non admin users
…corecard

PM-1504 edit create scorecard - sort scorecard data by sortOrder
* @returns Escaped string safe for interpolation in a regex.
*/
export function escapeRegexLiteral(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The escapeRegexLiteral function does not handle null or undefined values, which could lead to runtime errors if such values are passed. Consider adding a check to return an empty string or throw an error if the input is not a string.

}

const escapedTarget = escapeRegexLiteral(target)
.replace(/ /g, '\\ ')

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 maintainability]
The use of replace(/ /g, '\ ') in the regex construction could be simplified by using a single replace call with a regex that matches spaces, hyphens, and underscores. This would improve readability and maintainability.

* @returns True when the phase name is allowed for review operations.
*/
export function isPhaseAllowedForReview(phaseName?: string | null): boolean {
const normalizedAlpha = getNormalizedAlphaLowerCase(phaseName)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The function isPhaseAllowedForReview returns true when normalizedAlpha is falsy, which might not be the intended behavior if phaseName is null or undefined. Consider clarifying the logic to ensure it aligns with the intended use case.

legacyScorecardId?: string | number,
): { scorecardId?: string; phaseIds: Set<string> } {
const normalizedPhaseName = phaseName.toLowerCase()
const phaseIds = collectPhaseIdsForName(phases, reviewers, phaseName)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 performance]
The collectPhaseIdsForName function is called with phaseName as a parameter, but the function internally normalizes the phase name to lowercase. Consider passing the already normalized normalizedPhaseName to avoid redundant normalization and improve performance slightly.

}
})

const reviewMatch = reviews?.find(review => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ maintainability]
The logic for matching reviews based on scorecardId and phaseId is complex and involves multiple checks. Consider refactoring this block into smaller functions to improve readability and maintainability.

return { phaseIds, scorecardId: reviewerMatch.scorecardId }
}

const constraintValue = matchingPhases

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ performance]
The use of find followed by map could be replaced with a single find that directly returns the desired value, which would improve performance by avoiding unnecessary iterations.

if (assignmentReview) {
const existingReviewItems = reviewForResource?.reviewItems
reviewForResource = {
...(reviewForResource ?? {}),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The spread operator is used to merge reviewForResource and assignmentReview. If both objects have overlapping properties, assignmentReview will overwrite those in reviewForResource. Ensure this behavior is intended and does not lead to unintended data loss.

challengeReviewById: Map<string, BackendReview>,
): BackendReview {
const canonicalReview = reviewForResource.id
? challengeReviewById.get(reviewForResource.id)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
Accessing the canonicalReview from challengeReviewById using reviewForResource.id assumes that the ID is always present and valid. Consider handling cases where the ID might be missing or invalid to prevent potential runtime errors.

* @returns Normalized string representation of the phase identifier when present.
*/
export function resolveReviewPhaseId(review: BackendReview | undefined): string | undefined {
if (!review || review.phaseId === null || review.phaseId === undefined) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 readability]
Consider using review?.phaseId instead of checking for null and undefined separately. This can simplify the condition and improve readability.

matchesPhase: boolean,
matchesScorecard: boolean,
): boolean {
const matchedCriteria = [

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 readability]
The use of filter(Boolean) is correct but can be slightly confusing. Consider using a more explicit approach to filter out undefined values for clarity.

scorecardIdBeingChecked: scorecardId,
})

const normalizedPhaseName = getNormalizedLowerCase(phaseName)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The function getNormalizedLowerCase is used multiple times on potentially undefined values. Ensure this function handles undefined inputs gracefully to avoid unexpected errors.


if (typeof metadata === 'string') {
try {
const parsed = JSON.parse(metadata)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ security]
Consider specifying the reviver parameter in JSON.parse to handle potential data transformations or validations during parsing.

return 'PASS'
}

if (normalized === 'fail' || normalized === 'no pass' || normalized === 'no-pass' || normalized === 'nopass') {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ maintainability]
The normalization logic for 'no pass' variations could be simplified by using a regular expression to match these patterns, improving maintainability.

] as const

const normalizeReviewPhaseHint = (value?: string | null): string => (
typeof value === 'string'

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 readability]
The normalizeReviewPhaseHint function uses a regular expression to remove non-alphabetic characters. Consider adding a comment explaining the purpose of this transformation, as it may not be immediately clear to other developers.

return undefined
}

const matchingPhase = phases.find(phase => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The resolvePhaseNameFromId function uses both phase.id and phase.phaseId as candidates for matching. Ensure that these fields are consistently populated across all BackendPhase objects to avoid unexpected behavior.


const normalizedCandidates = collectReviewHints(submission, phases)
if (!normalizedCandidates.size) {
if (process.env.NODE_ENV !== 'production') {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ security]
The debug logging inside shouldIncludeInReviewPhase is conditioned on process.env.NODE_ENV !== 'production'. Ensure that this environment variable is correctly set in all deployment environments to avoid leaking debug information.

return true
}

const normalizedCandidateList = Array.from(normalizedCandidates)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 performance]
The conversion of normalizedCandidates to an array using Array.from could be avoided if Set.prototype.some is used directly. This would improve performance slightly by avoiding the creation of an intermediate array.

metadata?: BackendReview['metadata'],
): Screening['result'] {
if (typeof numericScore === 'number' && typeof minPass === 'number') {
return numericScore >= minPass ? 'PASS' : 'NO PASS'

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
Consider using strict equality checks (===) instead of loose equality checks (==) for comparing numeric values to ensure type safety.

}

if (baseResult) {
const normalizedBase = baseResult.toUpperCase()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The normalization of baseResult assumes it is always a string. Consider adding a type check to ensure baseResult is a string before calling .toUpperCase() to prevent runtime errors.

import { EnvironmentConfig } from '~/config'

export const DEBUG_CHECKPOINT_PHASES = Boolean(
(EnvironmentConfig as unknown as { DEBUG_CHECKPOINT_PHASES?: boolean }).DEBUG_CHECKPOINT_PHASES,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
Casting EnvironmentConfig to unknown and then to a specific type can lead to runtime errors if EnvironmentConfig does not match the expected shape. Consider adding runtime checks or using a safer approach to access DEBUG_CHECKPOINT_PHASES.

}

const globalTarget = globalThis as Record<string, unknown>
globalTarget[CHECKPOINT_DEBUG_EXPORT_KEY] = [...checkpointDebugEntries]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ maintainability]
Exporting logs to the global scope can lead to potential memory leaks or unintended side effects if not managed properly. Consider implementing a mechanism to clear or manage these logs periodically.

return
}

checkpointDebugEntries.push({ level, namespace, payload })

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ performance]
The checkpointDebugEntries array can grow indefinitely if DEBUG_CHECKPOINT_PHASES is true, potentially leading to memory issues. Ensure that the array size is controlled, as done with MAX_CHECKPOINT_DEBUG_ENTRIES, but also consider the implications of frequently shifting elements.

matchingSubmission,
review,
}: SubmissionIdResolutionArgs): string | undefined {
return review.submissionId

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The precedence order for resolving submission IDs could lead to unexpected results if review.submissionId is present but invalid. Consider validating review.submissionId before using it as the resolved ID.

baseSubmissionInfo,
matchingSubmission,
}: SubmitterMemberIdResolutionArgs): string {
return matchingSubmission?.memberId ?? baseSubmissionInfo?.memberId ?? ''

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
Returning an empty string for the submitter member ID might lead to confusion or errors downstream if consumers of this function expect a valid member ID. Consider returning undefined instead to clearly indicate the absence of a member ID.

Comment on lines 115 to 116
const escapedTarget = escapeRegexLiteral(target)
.replace(/ /g, '\\ ')

Check failure

Code scanning / CodeQL

Incomplete string escaping or encoding High

This does not escape backslash characters in the input.

Copilot Autofix

AI 2 days ago

To ensure all meta-characters are properly escaped for safe use in a RegExp, and to avoid edge cases where a backslash followed by a space or other meta-character could slip through, the best fix is as follows:

  • Refactor the regex escaping logic so that any transformations (e.g., replacements of spaces with regex patterns) are performed before escaping meta-characters, or combine them in one pass.
  • Consider using a trusted library for RegExp escaping, such as escape-string-regexp, but if unavailable, ensure our function covers all edge cases, including backslashes, and is never applied on a string that has already been manipulated.
  • In this file, update the code at and near line 115 so that the string is first transformed (spaces to intended regex) and then fully escaped, or use a tailored escape-for-regexp function for word-boundary matching.
  • No new methods are needed, but the code constructing the boundary regex should be restructured: first, replace spaces in the target with [-_\\s]+, then escape meta-characters, then interpolate inside the RegExp.

Suggested changeset 1
src/apps/review/src/lib/utils/metadataMatching.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/apps/review/src/lib/utils/metadataMatching.ts b/src/apps/review/src/lib/utils/metadataMatching.ts
--- a/src/apps/review/src/lib/utils/metadataMatching.ts
+++ b/src/apps/review/src/lib/utils/metadataMatching.ts
@@ -112,9 +112,9 @@
             return { source: 'stringExact' }
         }
 
-        const escapedTarget = escapeRegexLiteral(target)
-            .replace(/ /g, '\\ ')
-        const sepInsensitive = new RegExp(`\\b${escapedTarget.replace(/\\ /g, '[-_\\s]+')}\\b`)
+        // Replace space characters with a separator regex, then escape the whole for use in RegExp
+        const sepInsensitiveStr = escapeRegexLiteral(target.replace(/ /g, '[-_\\s]+'));
+        const sepInsensitive = new RegExp(`\\b${sepInsensitiveStr}\\b`);
         if (sepInsensitive.test(normalizedMetadata)) {
             return { source: 'stringBoundary' }
         }
EOF
@@ -112,9 +112,9 @@
return { source: 'stringExact' }
}

const escapedTarget = escapeRegexLiteral(target)
.replace(/ /g, '\\ ')
const sepInsensitive = new RegExp(`\\b${escapedTarget.replace(/\\ /g, '[-_\\s]+')}\\b`)
// Replace space characters with a separator regex, then escape the whole for use in RegExp
const sepInsensitiveStr = escapeRegexLiteral(target.replace(/ /g, '[-_\\s]+'));
const sepInsensitive = new RegExp(`\\b${sepInsensitiveStr}\\b`);
if (sepInsensitive.test(normalizedMetadata)) {
return { source: 'stringBoundary' }
}
Copilot is powered by AI and may make mistakes. Always verify output.
)
const approvalRows: SubmissionInfo[] = useMemo(
() => {
if (!props.reviews.length) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The check if (!props.reviews.length) assumes props.reviews is always defined. Consider adding a nullish coalescing operator to ensure safety: if (!(props.reviews ?? []).length).

const isSubmitterView = actionChallengeRole === SUBMITTER
const sourceRows = isSubmitterView ? props.submitterReviews : props.reviews
const myMemberIds = useMemo<Set<string>>(
() => new Set((myResources ?? []).map(resource => resource.memberId)),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 performance]
Using useMemo for myMemberIds is beneficial for performance, but ensure that myResources is not frequently changing. If it changes often, the memoization might not provide significant benefits.

},
[challengeInfo?.status],
)
const hasPassedPostMortemThreshold = useMemo(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 performance]
The useMemo hook for hasPassedPostMortemThreshold is appropriate here to avoid unnecessary recalculations. However, ensure that the sourceRows and myMemberIds dependencies are stable and do not change frequently, as this could negate the performance benefits.

const filteredScreening = useMemo<Screening[]>(
() => {
const baseRows = props.screening ?? []
const canSeeAll = isPrivilegedRole || hasReviewerRole

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ maintainability]
The variable canSeeAll is defined but not used in the first if condition. Consider using canSeeAll consistently to improve readability and maintainability.


return baseRows.filter(row => {
if (row.myReviewResourceId
&& (screenerResourceIds.has(row.myReviewResourceId)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The condition checks if row.myReviewResourceId is in either screenerResourceIds or reviewerResourceIds. Ensure that both sets are correctly populated and managed to avoid potential access issues.

}, [])

const openHistoryModalForKey = useCallback(
(memberId: string | undefined, submissionId: string): void => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The openHistoryModalForKey function does not handle the case where memberId is undefined and submissionId is also undefined. Consider adding a check to ensure submissionId is always defined before proceeding.

)

const getHistoryRestriction = useCallback(
(submission: SubmissionInfo) => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The getHistoryRestriction function assumes that submission.memberId is always defined when checking for restrictions. Consider handling the case where memberId might be undefined to avoid potential runtime errors.

type='button'
className={styles.historyButton}
data-submission-id={submission.id}
data-member-id={submission.memberId ?? ''}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 correctness]
The data-member-id attribute is set to an empty string when submission.memberId is undefined. This could lead to unexpected behavior if the attribute is used elsewhere. Consider omitting the attribute if memberId is not available.


const isInProgressStatus = (value: string | undefined): boolean => (
typeof value === 'string'
&& value.trim()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 readability]
Consider using value.trim().toUpperCase() === 'IN_PROGRESS' directly in the return statement for better readability and to avoid unnecessary line breaks.

)

const isReviewRowInProgress = (entry: Screening): boolean => (
isInProgressStatus(entry.reviewStatus)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The function isReviewRowInProgress checks both reviewStatus and myReviewStatus. Ensure that this logic is intentional and that both statuses need to be checked for the same condition.

label: 'Screening Result',
propertyName: 'result',
renderer: (data: Screening) => {
if (isReviewRowInProgress(data)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 readability]
Returning a hyphen (<span>-</span>) when the review row is in progress might not be informative. Consider providing a more descriptive placeholder or message to indicate the status.

const isOwnedSubmission = data.memberId
? ownedMemberIds.has(data.memberId)
: false
const canAccessReview = !isSubmitterView

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 style]
The condition Boolean(props.isChallengeCompleted && props.hasPassedThreshold) could be simplified to props.isChallengeCompleted && props.hasPassedThreshold since the && operator already returns a boolean value.


const isInProgressStatus = (value: string | undefined): boolean => (
typeof value === 'string'
&& value.trim()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 correctness]
Consider using localeCompare for case-insensitive string comparison to handle edge cases with different locales.

label: 'Screening Result',
propertyName: 'result',
renderer: (data: Screening) => {
if (isScreeningReviewInProgress(data)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 readability]
Returning a simple span with a dash for in-progress status might be misleading. Consider using a more descriptive placeholder or tooltip to indicate the status is in progress.

defaultMinimumPassingScore,
}: ResolveSubmissionReviewResultOptions = options

if (hasInProgressReviewStatus(submission)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The early return when hasInProgressReviewStatus(submission) is true could lead to unexpected behavior if the function is expected to handle other statuses as well. Ensure that this logic aligns with the intended business rules.

return 'FAIL'
}

if (scoreOutcome === 'FAIL' && metadataOutcome === 'PASS') {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The condition scoreOutcome === 'FAIL' && metadataOutcome === 'PASS' returning 'PASS' might be counterintuitive. Verify that this logic is intended and aligns with the business requirements.

// Current viewer's resource ids that grant Screening review access (Screener or Reviewer)
const myScreeningReviewerResourceIds = new Set<string>();
(myResources ?? []).forEach(resource => {
const normalizedRoleName = (resource.roleName || '').toLowerCase()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The use of toLowerCase() on normalizedRoleName assumes that all role names are in a case-insensitive format. If role names are case-sensitive, this could lead to incorrect role matching. Consider verifying the case sensitivity of role names in the system.


const matchesScreenerRole = normalizedRoleName.includes('screener')
&& !normalizedRoleName.includes('checkpoint')
const matchesReviewerRole = normalizedRoleName.replace(/[^a-z]/g, '') === 'reviewer'

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The regular expression replace(/[^a-z]/g, '') assumes that role names contain only alphabetic characters. If role names can contain numbers or special characters, this could lead to incorrect role matching. Consider revising the logic to handle such cases.

const sorted = orderBy(
reviews,
[
(review: BackendReview) => Boolean(review.committed),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The use of orderBy with a default value of -Infinity for scores could lead to unexpected behavior if getNumericScore returns non-numeric values. Consider adding validation to ensure that scores are numeric before sorting.

return typeof score === 'number' ? score : -Infinity
},
(review: BackendReview) => {
const updatedAt = review.updatedAt || review.reviewDate || review.createdAt

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The logic for determining the updatedAt date uses Date.parse, which can behave inconsistently across different browsers and environments. Consider using a more robust date parsing library to ensure consistent behavior.

return
}

const resourceIdValue = resource.id

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 maintainability]
The logic for determining resourceId involves multiple type checks and conversions. Consider refactoring this logic into a utility function to improve readability and maintainability.

const { actionChallengeRole, hasReviewerRole }: useRoleProps = useRole()

const reviewContextRole = useMemo(
() => getRoleForContext('review', { actionChallengeRole, hasReviewerRole }),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The getRoleForContext function is used to determine reviewContextRole, but it's unclear if this function handles all possible role combinations correctly. Ensure that getRoleForContext is well-tested to avoid potential logic errors in role determination.

.filter(resource => {
const normalizedRoleName = resource.roleName
?.toLowerCase()
.replace(/[^a-z]/g, '') ?? ''

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The use of replace(/[^a-z]/g, '') to normalize the role name could potentially strip out important characters if the role names are expected to contain non-alphabetic characters. Consider whether this is the intended behavior or if a more specific normalization is needed.

context: string | undefined,
{ actionChallengeRole, hasReviewerRole }: ContextRoleSource,
): ChallengeRole => {
if (context?.toLowerCase() === 'review' && hasReviewerRole) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The check context?.toLowerCase() === 'review' assumes that the context string will always be in a format that can be converted to lowercase and compared directly. Consider adding validation or handling for unexpected context values to prevent potential issues.


const {
actionChallengeRole,
hasReviewerRole,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ performance]
The hasReviewerRole variable is now being destructured from useRole(). Ensure that useRole() correctly provides this value, as it was previously computed using useMemo. This change could impact performance if useRole() does not memoize the result.

),
'checkpoint review': normalizedRoleName => normalizedRoleName === 'checkpointreviewer',
'checkpoint screening': normalizedRoleName => normalizedRoleName === 'checkpointscreener',
'post-mortem': normalizedRoleName => normalizedRoleName.includes('postmortem'),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ correctness]
The normalization of role names in PHASE_ROLE_MATCHERS has changed to remove spaces (e.g., 'checkpoint screener' to 'checkpointscreener'). Ensure that this change is intentional and that all relevant parts of the application are updated to match this new format.


type RoleMatcher = (normalizedRoleName: string) => boolean

const normalizeRoleName = (value: unknown): string => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The normalizeRoleName function replaces all non-alphabetic characters with an empty string. This could lead to unexpected behavior if role names contain numbers or special characters that are significant. Consider whether this normalization is too aggressive and if it might inadvertently alter valid role names.

@kkartunov kkartunov merged commit 3e38b26 into dev Oct 28, 2025
7 of 9 checks passed
@kkartunov kkartunov deleted the feat/v6 branch October 28, 2025 08:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants