mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
Reference in new issue
Block a user