Skip to content

JS-2366 Implement rule 9834: no identical links - #7931

Closed
guillemsarda wants to merge 1 commit into
masterfrom
JS-2366-new-rule-9384-no-identical-links
Closed

guillemsarda wants to merge 1 commit into
masterfrom
JS-2366-new-rule-9384-no-identical-links

Conversation

@guillemsarda

@guillemsarda guillemsarda commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Part of


Summary by Gitar

  • New rules:
    • Implemented rule S9384 to detect anchor elements with identical accessible names pointing to different destinations
    • Added fixtures and test suite for rule S9384 covering various link scenarios and normalization rules

This will update automatically on new commits.

@hashicorp-vault-sonar-prod

hashicorp-vault-sonar-prod Bot commented Sep 9, 2026 •

Copy link
Copy Markdown

JS-2366

Comment on lines +336 to +347
function normalizeDestination(href: string): string {
const hasScheme = /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(href);
let url: URL;
try {
url = new URL(href, DUMMY_BASE);
} catch {
return href;
}
const scheme = hasScheme ? (url.protocol === 'https:' ? 'http:' : url.protocol) : '';
const keepFragment = ROUTING_FRAGMENT_PATTERN.test(url.hash);
return `${scheme}${url.pathname}${url.search}${keepFragment ? url.hash : ''}`;
}

@gitar-bot gitar-bot Bot Sep 9, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Bug: normalizeDestination discards the host, conflating distinct URLs

normalizeDestination builds the comparison key from scheme + pathname + search + hash and never includes url.host, so https://example.com/about and https://evil.com/about both normalize to http:/about. Two anchors with the same text pointing at different domains are therefore silently treated as the same destination (false negative), and //cdn.example.com/about collapses to /about, colliding with a root-relative link. Conversely /about ("/about") and https://example.com/about ("http:/about") compare as different, so same-origin links written in mixed relative/absolute form are reported as a false positive. The fixture's http/https case on lines 12-13 passes only because the host is dropped, so it does not actually exercise scheme equivalence.

Keep the authority in the normalized key when the href supplies one:

function normalizeDestination(href: string): string {
  const hasScheme = /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(href);
  const hasAuthority = hasScheme || href.startsWith('//');
  let url: URL;
  try {
    url = new URL(href, DUMMY_BASE);
  } catch {
    return href;
  }
  const scheme = hasScheme ? (url.protocol === 'https:' ? 'http:' : url.protocol) : '';
  const authority = hasAuthority && url.host ? `//${url.host}` : '';
  const keepFragment = ROUTING_FRAGMENT_PATTERN.test(url.hash);
  return `${scheme}${authority}${url.pathname}${url.search}${keepFragment ? url.hash : ''}`;
}

Was this helpful? React with 👍 / 👎

Comment on lines +322 to +330
function getStaticTextFromExpression(expression: estree.Expression): string | undefined {
if (expression.type === 'Literal') {
return typeof expression.value === 'string' ? expression.value : '';
}
if (expression.type === 'TemplateLiteral' && expression.expressions.length === 0) {
return expression.quasis.map(quasi => quasi.value.cooked ?? '').join('');
}
return undefined;
}

@gitar-bot gitar-bot Bot Sep 9, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Bug: Numeric JSX children are dropped from the accessible name

getStaticTextFromExpression returns '' for every non-string Literal, and computeChildContribution uses it for JSXExpressionContainer children. A numeric literal renders as visible text in JSX, so <a href="/step/1">Step {1}</a> and <a href="/step/2">Step {2}</a> both compute the accessible name "step", the hrefs differ, and the rule raises a false positive on two links whose real accessible names are "Step 1" and "Step 2". Only null, true and false render as nothing; numbers and bigints should be stringified (which is also the correct behaviour on the attribute-value path in getStaticText).

Stringify numeric literals; only null/boolean render as empty:

function getStaticTextFromExpression(expression: estree.Expression): string | undefined {
  if (expression.type === 'Literal') {
    // null and booleans render nothing in JSX; numbers render their string form.
    return expression.value === null || typeof expression.value === 'boolean'
      ? ''
      : String(expression.value);
  }
  if (expression.type === 'TemplateLiteral' && expression.expressions.length === 0) {
    return expression.quasis.map(quasi => quasi.value.cooked ?? '').join('');
  }
  return undefined;
}

Was this helpful? React with 👍 / 👎

Comment on lines +169 to +183
/**
* Computes the anchor's accessible name following aria-label > text content
* (skipping aria-hidden subtrees, using alt on nested images) > title, as established by
* S6827's decorator. Returns null when the name cannot be resolved statically, or is empty.
*/
function computeAccessibleName(
element: TSESTree.JSXElement,
attributes: JsxAttributes,
context: Rule.RuleContext,
elementType: (node: TSESTree.JSXOpeningElement) => string,
): string | null {
const ariaLabelAttribute = getProp(attributes, 'aria-label') as JSXAttribute | undefined;
if (ariaLabelAttribute) {
const staticValue = getStaticText(ariaLabelAttribute.value);
if (staticValue === undefined) {

@gitar-bot gitar-bot Bot Sep 9, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Edge Case: aria-labelledby is ignored when computing the accessible name

computeAccessibleName only consults aria-label, text content and title. Per the accessible-name computation aria-labelledby takes precedence over all of them, so <a href="/a" aria-labelledby="lbl1">Details</a> and <a href="/b" aria-labelledby="lbl2">Details</a> are reported as duplicate-text links even though their real accessible names come from different referenced elements. Since the referenced text is not statically resolvable, the conservative behaviour matching the rest of the rule is to bail out (return null) when aria-labelledby is present.

Exclude anchors whose name is delegated to aria-labelledby:

): string | null {
  // aria-labelledby wins over every other naming mechanism, but the referenced
  // text cannot be resolved statically, so the anchor's identity is unknown.
  if (getProp(attributes, 'aria-labelledby')) {
    return null;
  }

  const ariaLabelAttribute = getProp(attributes, 'aria-label') as JSXAttribute | undefined;

Was this helpful? React with 👍 / 👎

@gitar-bot

gitar-bot Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
CI failed: Build failure during rspec refresh due to a missing S9384 rule metadata JSON file for the newly implemented rule.

Overview

1 build failure encountered across 1 analyzed log, caused by a missing rule definition file during the rule data synchronization step.

Failures

Missing Rule Metadata File (confidence: high)

  • Type: build
  • Affected jobs: 102540745075
  • Related to change: yes
  • Root cause: The new rule was implemented in the PR, but its corresponding rule definition or metadata JSON file (resources/rule-data/javascript/S9384.json) was missing, causing deploy-rule-data.ts to throw an ENOENT error when attempting to read it.
  • Suggested fix: Add the missing rule metadata file resources/rule-data/javascript/S9384.json for rule S9384.

Summary

  • Change-related failures: 1 build failure due to missing rule metadata file.
  • Infrastructure/flaky failures: 0 failures.
  • Recommended action: Add the required JSON file for rule S9384 to successfully pass the rule data synchronization step.
Code Review ⚠️ Changes requested 0 resolved / 3 findings

Implements rule S9384 to detect anchor elements with identical accessible names pointing to different destinations, but three issues must be resolved before merge.

The normalizeDestination function discards the host, causing https://example.com/about and https://evil.com/about to normalize identically and produce false negatives; it also conflates root-relative and scheme-relative URLs. Numeric JSX children are dropped from accessible name computation, so <a>Step {1}</a> and <a>Step {2}</a> incorrectly report as duplicates. Additionally, aria-labelledby is not consulted when computing accessible names, leading to false positives when different elements are referenced for labeling.

⚠️ Bug: normalizeDestination discards the host, conflating distinct URLs

📄 packages/analysis/src/jsts/rules/S9384/rule.ts:336-347 📄 packages/analysis/src/jsts/rules/S9384/cb.fixture.tsx:11-13

normalizeDestination builds the comparison key from scheme + pathname + search + hash and never includes url.host, so https://example.com/about and https://evil.com/about both normalize to http:/about. Two anchors with the same text pointing at different domains are therefore silently treated as the same destination (false negative), and //cdn.example.com/about collapses to /about, colliding with a root-relative link. Conversely /about ("/about") and https://example.com/about ("http:/about") compare as different, so same-origin links written in mixed relative/absolute form are reported as a false positive. The fixture's http/https case on lines 12-13 passes only because the host is dropped, so it does not actually exercise scheme equivalence.

Keep the authority in the normalized key when the href supplies one
function normalizeDestination(href: string): string {
  const hasScheme = /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(href);
  const hasAuthority = hasScheme || href.startsWith('//');
  let url: URL;
  try {
    url = new URL(href, DUMMY_BASE);
  } catch {
    return href;
  }
  const scheme = hasScheme ? (url.protocol === 'https:' ? 'http:' : url.protocol) : '';
  const authority = hasAuthority && url.host ? `//${url.host}` : '';
  const keepFragment = ROUTING_FRAGMENT_PATTERN.test(url.hash);
  return `${scheme}${authority}${url.pathname}${url.search}${keepFragment ? url.hash : ''}`;
}
⚠️ Bug: Numeric JSX children are dropped from the accessible name

📄 packages/analysis/src/jsts/rules/S9384/rule.ts:322-330 📄 packages/analysis/src/jsts/rules/S9384/rule.ts:240-250

getStaticTextFromExpression returns '' for every non-string Literal, and computeChildContribution uses it for JSXExpressionContainer children. A numeric literal renders as visible text in JSX, so <a href="/step/1">Step {1}</a> and <a href="/step/2">Step {2}</a> both compute the accessible name "step", the hrefs differ, and the rule raises a false positive on two links whose real accessible names are "Step 1" and "Step 2". Only null, true and false render as nothing; numbers and bigints should be stringified (which is also the correct behaviour on the attribute-value path in getStaticText).

Stringify numeric literals; only null/boolean render as empty
function getStaticTextFromExpression(expression: estree.Expression): string | undefined {
  if (expression.type === 'Literal') {
    // null and booleans render nothing in JSX; numbers render their string form.
    return expression.value === null || typeof expression.value === 'boolean'
      ? ''
      : String(expression.value);
  }
  if (expression.type === 'TemplateLiteral' && expression.expressions.length === 0) {
    return expression.quasis.map(quasi => quasi.value.cooked ?? '').join('');
  }
  return undefined;
}
💡 Edge Case: aria-labelledby is ignored when computing the accessible name

📄 packages/analysis/src/jsts/rules/S9384/rule.ts:169-183

computeAccessibleName only consults aria-label, text content and title. Per the accessible-name computation aria-labelledby takes precedence over all of them, so <a href="/a" aria-labelledby="lbl1">Details</a> and <a href="/b" aria-labelledby="lbl2">Details</a> are reported as duplicate-text links even though their real accessible names come from different referenced elements. Since the referenced text is not statically resolvable, the conservative behaviour matching the rest of the rule is to bail out (return null) when aria-labelledby is present.

Exclude anchors whose name is delegated to aria-labelledby
): string | null {
  // aria-labelledby wins over every other naming mechanism, but the referenced
  // text cannot be resolved statically, so the anchor's identity is unknown.
  if (getProp(attributes, 'aria-labelledby')) {
    return null;
  }

  const ariaLabelAttribute = getProp(attributes, 'aria-label') as JSXAttribute | undefined;
🤖 Prompt for agents
Code Review: Implements rule S9384 to detect anchor elements with identical accessible names pointing to different destinations, but three issues must be resolved before merge.
  
  The `normalizeDestination` function discards the host, causing `https://example.com/about` and `https://evil.com/about` to normalize identically and produce false negatives; it also conflates root-relative and scheme-relative URLs. Numeric JSX children are dropped from accessible name computation, so `<a>Step {1}</a>` and `<a>Step {2}</a>` incorrectly report as duplicates. Additionally, `aria-labelledby` is not consulted when computing accessible names, leading to false positives when different elements are referenced for labeling.

1. ⚠️ Bug: normalizeDestination discards the host, conflating distinct URLs
   Files: packages/analysis/src/jsts/rules/S9384/rule.ts:336-347, packages/analysis/src/jsts/rules/S9384/cb.fixture.tsx:11-13

   `normalizeDestination` builds the comparison key from `scheme + pathname + search + hash` and never includes `url.host`, so `https://example.com/about` and `https://evil.com/about` both normalize to `http:/about`. Two anchors with the same text pointing at different domains are therefore silently treated as the same destination (false negative), and `//cdn.example.com/about` collapses to `/about`, colliding with a root-relative link. Conversely `/about` (`"/about"`) and `https://example.com/about` (`"http:/about"`) compare as different, so same-origin links written in mixed relative/absolute form are reported as a false positive. The fixture's http/https case on lines 12-13 passes only because the host is dropped, so it does not actually exercise scheme equivalence.

   Fix (Keep the authority in the normalized key when the href supplies one):
   function normalizeDestination(href: string): string {
     const hasScheme = /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(href);
     const hasAuthority = hasScheme || href.startsWith('//');
     let url: URL;
     try {
       url = new URL(href, DUMMY_BASE);
     } catch {
       return href;
     }
     const scheme = hasScheme ? (url.protocol === 'https:' ? 'http:' : url.protocol) : '';
     const authority = hasAuthority && url.host ? `//${url.host}` : '';
     const keepFragment = ROUTING_FRAGMENT_PATTERN.test(url.hash);
     return `${scheme}${authority}${url.pathname}${url.search}${keepFragment ? url.hash : ''}`;
   }

2. ⚠️ Bug: Numeric JSX children are dropped from the accessible name
   Files: packages/analysis/src/jsts/rules/S9384/rule.ts:322-330, packages/analysis/src/jsts/rules/S9384/rule.ts:240-250

   `getStaticTextFromExpression` returns `''` for every non-string `Literal`, and `computeChildContribution` uses it for `JSXExpressionContainer` children. A numeric literal renders as visible text in JSX, so `<a href="/step/1">Step {1}</a>` and `<a href="/step/2">Step {2}</a>` both compute the accessible name `"step"`, the hrefs differ, and the rule raises a false positive on two links whose real accessible names are "Step 1" and "Step 2". Only `null`, `true` and `false` render as nothing; numbers and bigints should be stringified (which is also the correct behaviour on the attribute-value path in `getStaticText`).

   Fix (Stringify numeric literals; only null/boolean render as empty):
   function getStaticTextFromExpression(expression: estree.Expression): string | undefined {
     if (expression.type === 'Literal') {
       // null and booleans render nothing in JSX; numbers render their string form.
       return expression.value === null || typeof expression.value === 'boolean'
         ? ''
         : String(expression.value);
     }
     if (expression.type === 'TemplateLiteral' && expression.expressions.length === 0) {
       return expression.quasis.map(quasi => quasi.value.cooked ?? '').join('');
     }
     return undefined;
   }

3. 💡 Edge Case: aria-labelledby is ignored when computing the accessible name
   Files: packages/analysis/src/jsts/rules/S9384/rule.ts:169-183

   `computeAccessibleName` only consults `aria-label`, text content and `title`. Per the accessible-name computation `aria-labelledby` takes precedence over all of them, so `<a href="/a" aria-labelledby="lbl1">Details</a>` and `<a href="/b" aria-labelledby="lbl2">Details</a>` are reported as duplicate-text links even though their real accessible names come from different referenced elements. Since the referenced text is not statically resolvable, the conservative behaviour matching the rest of the rule is to bail out (return `null`) when `aria-labelledby` is present.

   Fix (Exclude anchors whose name is delegated to aria-labelledby):
   ): string | null {
     // aria-labelledby wins over every other naming mechanism, but the referenced
     // text cannot be resolved statically, so the anchor's identity is unknown.
     if (getProp(attributes, 'aria-labelledby')) {
       return null;
     }
   
     const ariaLabelAttribute = getProp(attributes, 'aria-label') as JSXAttribute | undefined;

Implementation Status ✅ 2 of 2 objectives covered
✅ JS-2366 - 2 of 2 objectives covered

This PR covers the implementation of rule 9834 and its configuration in Sonar way.

✅ 2 covered here
  • ✅ Implement rule 9834 to flag native or JSX a anchors with identical accessible names and different destinations within the same file
  • ✅ Configure rule 9834 to ship in Sonar way by default with Code Smell type, Minor severity, and accessibility tag

Tip

Comment Gitar fix CI or enable auto-apply: gitar auto-apply:on

Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Counting what did not apply, without listing it.
Unblock → Override a blocking verdict and allow merging.

Comment with these commands to change the behavior for this request:

Auto-apply Compact Unblock
gitar auto-apply:on         
gitar display:verbose         
gitar unblock         

Was this helpful? React with 👍 / 👎 | Gitar

@guillemsarda
guillemsarda deleted the JS-2366-new-rule-9384-no-identical-links branch September 11, 2026 14:08
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.

1 participant