chore(studio): add jsx-a11y ESLint ratchet rules for statically-detectable a11y issues (#47582)

## Summary
- Follow-up to the axe-core accessibility audit (FE-3781), which found
1,733 failing elements across 126 Studio surfaces deduplicating to 12
root-cause families. A subset of those (missing accessible names/labels,
invalid/redundant ARIA, empty headings/anchors) is statically detectable
— this adds ESLint coverage for it instead of relying solely on the
runtime axe-core CI gate.
- Adds 13 `jsx-a11y` rules to `apps/studio/eslint.config.cjs` at
`'warn'`: `aria-props`, `aria-proptypes`, `role-supports-aria-props`,
`anchor-has-content`, `control-has-associated-label`
(`controlComponents: ['Button', 'Switch']`),
`label-has-associated-control` (`labelComponents: ['Label']`,
`controlComponents: ['Input', 'Switch']`), `aria-role`,
`no-redundant-roles`, `no-aria-hidden-on-focusable`,
`tabindex-no-positive`, `anchor-is-valid`, `heading-has-content`,
`no-distracting-elements`.
- Wires all 13 into the existing `lint:ratchet` script and initializes
their baselines in `apps/studio/.github/eslint-rule-baselines.json`, so
any *new* violation fails `studio-lint-ratchet.yml` while the
pre-existing ones (mostly `control-has-associated-label`: 274,
`label-has-associated-control`: 37) are tracked and shrink over time via
the weekly baseline-decrease cron.

Resolves
[FE-3795](https://linear.app/supabase/issue/FE-3795/add-jsx-a11y-eslint-ratchet-rules-for-statically-detectable-a11y).

## Test plan
- [x] `pnpm --filter studio run lint:ratchet` passes (exit 0, no
regressions)
- [x] Spot-checked several flagged instances against source to confirm
true positives (e.g. an unlabeled save/cancel icon-button pair in
`AIAssistantChatSelector.tsx`, an empty `<h3>` in `PITRForm.tsx`)
- [x] CI (`studio-lint-ratchet.yml`, typecheck.yml lint step) green on
this PR

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Expanded Studio’s accessibility linting to cover additional ARIA prop
validation, label/control relationships, anchor/heading validity,
role/ARIA correctness, and focus/tab behavior (including distracting
markup).
* Updated accessibility lint baselines so tracked violations remain
accurate as rules expand.
* **New Features**
* Enhanced the Studio lint “ratchet” workflow to load ratchet rule IDs
from an external `rules-file` instead of a long inline command.
* **Tests**
* Added an integration test to verify rule IDs are read from the
`rules-file`.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Ali Waseem authored and GitHub committed 2026-07-03 20:33:12 +00:00
1 parent 4b7cb27ba9
commit 61078d2617
6 files changed
+377 -5

No files matched your search

@@ -83,6 +83,28 @@ describe('ratchet-eslint-rules integration', () => {
expect(combinedErrors).toContain(`${relativeToCwd('apps/studio/src/b.ts')} (+1)`)
})
it('reads rule ids from --rules-file', () => {
const tmp = createTempDir()
const metadataPath = path.join(tmp, 'baseline.json')
const rulesFilePath = path.join(tmp, 'rules.json')
writeFileSync(rulesFilePath, JSON.stringify(['no-console', 'no-debugger']))
const eslintResults = buildEslintResults([
{ filePath: repoPath('apps/studio/src/a.ts'), rules: { 'no-console': 1, 'no-debugger': 2 } },
])
const result = invokeRatchet(
['--metadata', metadataPath, '--rules-file', rulesFilePath, '--init'],
eslintResults
)
expect(result).toBe(0)
const metadata = JSON.parse(readFileSync(metadataPath, 'utf8'))
expect(metadata.rules['no-console']).toBe(1)
expect(metadata.rules['no-debugger']).toBe(2)
})
it('falls back gracefully when baseline is missing per-file data', () => {
const tmp = createTempDir()
const metadataPath = path.join(tmp, 'baseline.json')
+37 -2
View File
@@ -21,7 +21,9 @@
* --init Write current counts for the provided --rule(s) into metadata and exit 0
* --eslint "<cmd>" ESLint command to run (default "npx eslint"). Do not pass untrusted input.
* --eslint-args "<...>" Extra args/paths for ESLint (e.g., "."). Do not pass untrusted input.
* --rule <id>[,<id>...] Rule id(s). Repeat flag or comma-separate. REQUIRED.
* --rule <id>[,<id>...] Rule id(s). Repeat flag or comma-separate.
* --rules-file <path> Path to a JSON file containing an array of rule id strings.
* Combines with --rule if both are given. One of the two is REQUIRED.
* --decrease-baselines When improvements occur, lower stored baselines to match the new counts.
*
* Notes:
@@ -41,6 +43,7 @@ interface Args {
eslintArgs: string
decreaseBaselines: boolean
rules: string[]
rulesFile?: string
}
interface ESLintMessage {
@@ -67,6 +70,31 @@ interface RuleSnapshot {
files: Record<string, number>
}
function readRulesFile(filePath: string): string[] {
let raw: string
try {
raw = readFileSync(filePath, 'utf8')
} catch (e) {
console.error(`Error: Could not read --rules-file ${filePath}: ${e}`)
process.exit(2)
}
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch (e) {
console.error(`Error: Could not parse --rules-file ${filePath} as JSON: ${e}`)
process.exit(2)
}
if (!Array.isArray(parsed) || !parsed.every((r) => typeof r === 'string')) {
console.error(`Error: --rules-file ${filePath} must contain a JSON array of rule id strings.`)
process.exit(2)
}
return parsed
}
function parseArgs(argv: string[]): Args {
const args: Args = {
metadata: '.github/eslint-rule-baselines.json',
@@ -97,6 +125,8 @@ function parseArgs(argv: string[]): Args {
.filter(Boolean)
)
}
} else if (a === '--rules-file') {
args.rulesFile = argv[++i]
} else if (a === '--decrease-baselines') {
args.decreaseBaselines = true
} else {
@@ -104,9 +134,14 @@ function parseArgs(argv: string[]): Args {
}
}
if (args.rulesFile) {
args.rules.push(...readRulesFile(args.rulesFile))
}
if (args.rules.length === 0) {
console.error('Error: You must provide at least one --rule <rule-id>.')
console.error('Error: You must provide at least one --rule <rule-id> or a --rules-file.')
console.error('Example: --rule exhaustive-deps --rule no-console')
console.error('Example: --rules-file scripts/ratchet-rules.json')
process.exit(2)
}
+22
View File
@@ -0,0 +1,22 @@
[
"react-hooks/exhaustive-deps",
"import/no-anonymous-default-export",
"@tanstack/query/exhaustive-deps",
"@typescript-eslint/no-explicit-any",
"no-restricted-imports",
"no-restricted-exports",
"react/no-unstable-nested-components",
"jsx-a11y/aria-props",
"jsx-a11y/aria-proptypes",
"jsx-a11y/role-supports-aria-props",
"jsx-a11y/anchor-has-content",
"jsx-a11y/control-has-associated-label",
"jsx-a11y/label-has-associated-control",
"jsx-a11y/aria-role",
"jsx-a11y/no-redundant-roles",
"jsx-a11y/no-aria-hidden-on-focusable",
"jsx-a11y/tabindex-no-positive",
"jsx-a11y/anchor-is-valid",
"jsx-a11y/heading-has-content",
"jsx-a11y/no-distracting-elements"
]