test(a11y): the aria-label role guard was blind to the role word inside the t() default (#2979)

## What

[#2965](https://github.com/../pull/2965) fixed thirteen dialogs whose
accessible name restated its role, and shipped a source-scanning guard
so the next copy-paste could not sneak back in. Good fix, real ratchet —
its own header even documents catching one blind spot by mutation during
review.

It has a second one. The matcher looks for the role word at the **end**
of the `ariaLabel` value, which is where all thirteen had it. One
position over is invisible:

```tsx
ariaLabel={t("scripts.title", "Scripts dialog")}
```

That renders the accessible name **"Scripts dialog"** — identical
symptom, announced as *"Scripts dialog, dialog"* — but the value does
not END in the role word, because a `")` closes the call after it.

**Measured:** re-introducing this shape into `ScriptsModal` left the
shipped suite green at **13/13**.

## Why this shape matters more than the one already covered

The rendered label *is* the translator's default string. Whoever writes
the next modal naturally puts the word where the title lives, inside
`t()`, rather than appending it outside the call. The suffix form is
what the original thirteen happened to be; this is the form the
fourteenth takes.

## The fix

Every quoted literal that is actually rendered is checked with the same
matcher, not just the whole value.

i18n **keys** are excluded, or the guard fires on
`t("agents.onboarding.dialogLabel", "AI Interview")` — live in the tree
today, and it announces nothing of the sort. Key-shaped means dotted and
whitespace-free, which no real accessible name is. Keeping this narrow
is the whole difficulty: a scan that flags every translated title gets
deleted within a week.

## Measured

| run | result |
|---|---|
| baseline, unmutated `main` | **18/18 green** — no false positive
anywhere in the corpus |
| mutation A — role word inside the `t()` default | **1 failed / 17
passed** (was green before) |
| mutation B — original trailing-suffix shape | **1 failed / 17 passed**
— no regression |

Both defect shapes now fail the guard. Five matcher cases added, three
of them negative; the negatives are what keep the scan from flagging
every translated title.

## Note for the queue

**#2946 is fixed on `main` and can be closed** — I verified all thirteen
callers are clean there, the one grep hit being that i18n key. This PR
is about the guard behind it, not the fix.

Third time in this program an instrument has been blind to a case in its
own motivating class, now across three tools and three authors. The
pattern is not carelessness — each guard was checked against the shapes
its author had in mind. Mutation against a shape you did *not* have in
mind is the only thing that has caught any of them, which is an argument
for making it routine when a ratchet ships rather than when someone gets
suspicious later.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-30 22:51:45 -07:00
committed by GitHub
parent be79fe0db6
commit 0738fb1c8a

View File

@@ -72,6 +72,36 @@ function extractAriaLabelValues(text: string): string[] {
return values;
}
/*
FNXC:Accessibility 2026-07-30-21:10:
THE ROLE WORD CAN ALSO SIT INSIDE THE TRANSLATED DEFAULT, and the end-of-value scan cannot see it.
`ariaLabel={t("scripts.title", "Scripts dialog")}` renders the accessible name "Scripts dialog" —
the identical defect — but the value does not END in the role word: a `")` closes the call after it.
The suffix form above was the shape the original thirteen had; this is the shape the NEXT one takes,
because the rendered label is the translator's default string and "dialog" reads as part of the title
to whoever writes it.
Found by mutation against the shipped guard: re-adding the role word in this position left the suite
green. So each quoted literal that is actually RENDERED is checked with the same matcher, not just
the whole value.
I18N KEYS ARE EXCLUDED, or this would fire on every `t("agents.onboarding.dialogLabel", ...)` — the
key is an identifier, never announced. Key-shaped means dotted and whitespace-free, which no real
accessible name is.
*/
const KEY_SHAPED = /^[A-Za-z0-9_$-]+(\.[A-Za-z0-9_$-]+)+$/;
function renderedLiterals(value: string): string[] {
const literals = value.match(/"[^"]*"|'[^']*'/g) ?? [];
return literals.filter((literal) => !KEY_SHAPED.test(literal.slice(1, -1)));
}
/** Every string a screen reader could end up announcing for one `ariaLabel` call site. */
function announceableParts(value: string): string[] {
return [value, ...renderedLiterals(value)];
}
function componentSources(): { file: string; text: string }[] {
return readdirSync(COMPONENTS_DIR)
.filter((name) => name.endsWith(".tsx"))
@@ -105,13 +135,24 @@ describe("a FloatingWindow's accessible name never restates its role", () => {
["ariaLabel={`Settings dialog`}", true, "quote-free template literal"],
['ariaLabel="Git Manager dialog"', true, "plain string"],
["ariaLabel={`Node details modal`}", true, "'modal' is equally redundant"],
/* THE SECOND POSITION: inside the translated default, where the value does not end in the role
word. These are the cases the suffix-only matcher was blind to. */
['ariaLabel={t("scripts.title", "Scripts dialog")}', true, "role word inside the t() default"],
['ariaLabel={`${t("scripts.title", "Scripts modal")}`}', true, "inside the default, interpolated"],
['ariaLabel={t("git.title", "Git Manager dialog", { repo })}', true, "default followed by an options arg"],
['ariaLabel={t("settings.title", "Settings")}', false, "the fixed form — a bare t() call"],
/* The KEY of a t() call routinely contains the word and is never announced — flagging it would
make the guard unusable, and one such key is live in the tree today. */
['ariaLabel={t("agents.onboarding.dialogLabel", "AI Interview")}', false, "role word in the KEY only"],
['ariaLabel={t("nodes.detail.modal", "Node details")}', false, "key ends in a role word"],
['ariaLabel={`${t("nodes.addNode", "Add Node")}`}', false, "interpolation with no suffix"],
['ariaLabel="Settings"', false, "a clean literal"],
['ariaLabel="Dialog settings"', false, "role word present but not trailing"],
['ariaLabel="Windows update"', false, "'Windows' merely contains a role word"],
])("matcher: %s -> %s (%s)", (source, shouldFlag) => {
const flagged = extractAriaLabelValues(source).some((v) => ROLE_WORD_AT_END.test(v));
const flagged = extractAriaLabelValues(source)
.flatMap(announceableParts)
.some((part) => ROLE_WORD_AT_END.test(part));
expect(flagged).toBe(shouldFlag);
});
@@ -119,7 +160,9 @@ describe("a FloatingWindow's accessible name never restates its role", () => {
const offenders: string[] = [];
for (const { file, text } of componentSources()) {
for (const value of extractAriaLabelValues(text)) {
if (ROLE_WORD_AT_END.test(value)) offenders.push(`${file}: ${value.slice(0, 100)}`);
if (announceableParts(value).some((part) => ROLE_WORD_AT_END.test(part))) {
offenders.push(`${file}: ${value.slice(0, 100)}`);
}
}
}
expect(