From 918b750c4edea56412985c2c09a91b2b5ede3662 Mon Sep 17 00:00:00 2001 From: galuis116 Date: Sun, 5 Jul 2026 12:17:27 -0400 Subject: [PATCH] feat(enrichment): flag accessibility regressions in added markup (#2026) Adds a new local REES analyzer (a11y-regression) that scans added JSX/HTML/Vue markup for four common accessibility regressions: an without alt text, an onClick handler added to a non-interactive element with no keyboard handler or role, a form control with no way to associate a label, and a positive tabindex that breaks natural tab order. Pure diff-local compute, no network, follows the existing local-analyzer descriptor pattern. --- .env.example | 8 +- apps/gittensory-ui/src/lib/rees-analyzers.ts | 23 +++ review-enrichment/analyzer-metadata.json | 26 +++ .../src/analyzers/a11y-regression.ts | 160 ++++++++++++++++++ review-enrichment/src/analyzers/registry.ts | 42 +++++ review-enrichment/src/render.ts | 1 + review-enrichment/src/types.ts | 13 ++ .../test/a11y-regression.test.ts | 148 ++++++++++++++++ .../test/analyzer-registry.test.ts | 1 + src/review/enrichment-analyzer-names.ts | 1 + 10 files changed, 419 insertions(+), 4 deletions(-) create mode 100644 review-enrichment/src/analyzers/a11y-regression.ts create mode 100644 review-enrichment/test/a11y-regression.test.ts diff --git a/.env.example b/.env.example index dc561a2300..f9a70e30b1 100644 --- a/.env.example +++ b/.env.example @@ -68,25 +68,25 @@ GITTENSORY_REVIEW_ENRICHMENT=false # commitSignature,iacMisconfig,nativeBuild,history,docCommentDrift,duplication,churnHotspot # blameLink,approvalIntegrity,ciCheckSignals,undocumentedExport,staleBranch,commitHygiene # pendingReviewRequests,testRatio,migrationSafety,looseRange,terminology,todoMarker,magicNumber -# conflictMarker,debugLeftover,sizeSmell,commitLint +# conflictMarker,debugLeftover,sizeSmell,commitLint,a11y # # Profile defaults: # fast: dependency,dependencyDiff,lockfileDrift,secret,license,installScript,heavyDependency # hardcodedUrl,actionPin,eol,redos,provenance,secretLog,typosquat,iacMisconfig,nativeBuild # testRatio,migrationSafety,looseRange,terminology,todoMarker,magicNumber,conflictMarker -# debugLeftover,sizeSmell +# debugLeftover,sizeSmell,a11y # balanced (default): dependency,dependencyDiff,lockfileDrift,secret,license,installScript # heavyDependency,hardcodedUrl,actionPin,eol,redos,provenance,codeowners,secretLog,assetWeight # typosquat,commitSignature,iacMisconfig,nativeBuild,history,docCommentDrift,duplication # churnHotspot,blameLink,approvalIntegrity,ciCheckSignals,undocumentedExport,staleBranch # commitHygiene,pendingReviewRequests,testRatio,migrationSafety,looseRange,terminology -# todoMarker,magicNumber,conflictMarker,debugLeftover,sizeSmell,commitLint +# todoMarker,magicNumber,conflictMarker,debugLeftover,sizeSmell,commitLint,a11y # deep: dependency,dependencyDiff,lockfileDrift,secret,license,installScript,heavyDependency # hardcodedUrl,actionPin,eol,redos,provenance,codeowners,secretLog,assetWeight,typosquat # commitSignature,iacMisconfig,nativeBuild,history,docCommentDrift,duplication,churnHotspot # blameLink,approvalIntegrity,ciCheckSignals,undocumentedExport,staleBranch,commitHygiene # pendingReviewRequests,testRatio,migrationSafety,looseRange,terminology,todoMarker,magicNumber -# conflictMarker,debugLeftover,sizeSmell,commitLint +# conflictMarker,debugLeftover,sizeSmell,commitLint,a11y # END GENERATED REES ANALYZERS # Submitter-reputation spend control (internal-only): downgrades new/burst/low-rep diff --git a/apps/gittensory-ui/src/lib/rees-analyzers.ts b/apps/gittensory-ui/src/lib/rees-analyzers.ts index 730e7c655f..5772ba8d76 100644 --- a/apps/gittensory-ui/src/lib/rees-analyzers.ts +++ b/apps/gittensory-ui/src/lib/rees-analyzers.ts @@ -1004,6 +1004,29 @@ export const REES_ANALYZERS = [ "Structured-fields-only: reads commit.message subjects, linted independently, never cross-line state. Fail-safe on missing token/fetch error.", }, }, + { + name: "a11y", + title: "Accessibility regressions", + category: "quality", + cost: "local", + defaultEnabled: true, + profiles: ["fast", "balanced", "deep"], + requires: ["files"], + limits: { + maxFindings: 25, + maxLineChars: 2000, + }, + docs: { + summary: + "Flags common accessibility regressions in added JSX/HTML markup: an without alt, a clickable non-interactive element with no keyboard handler or role, a form control with no label association, and a positive tabindex.", + looksAt: + "Self-contained added tags (open through close on one line) in .jsx, .tsx, .html, and .vue files.", + reports: "File, line, and public-safe rule kind — never markup content.", + network: "Pure local analyzer. No external network call.", + notes: + "Only matches tags whose full opening tag appears on a single added line; multi-line attribute lists are not scanned.", + }, + }, ] as const satisfies readonly ReesAnalyzerDoc[]; export const REES_ANALYZER_NAMES = REES_ANALYZERS.map((analyzer) => analyzer.name); diff --git a/review-enrichment/analyzer-metadata.json b/review-enrichment/analyzer-metadata.json index 27e07e1ed7..6b37c59184 100644 --- a/review-enrichment/analyzer-metadata.json +++ b/review-enrichment/analyzer-metadata.json @@ -1131,6 +1131,32 @@ "network": "Calls the GitHub PR-commits API once, bounded to one page.", "notes": "Structured-fields-only: reads commit.message subjects, linted independently, never cross-line state. Fail-safe on missing token/fetch error." } + }, + { + "name": "a11y", + "title": "Accessibility regressions", + "category": "quality", + "cost": "local", + "defaultEnabled": true, + "profiles": [ + "fast", + "balanced", + "deep" + ], + "requires": [ + "files" + ], + "limits": { + "maxFindings": 25, + "maxLineChars": 2000 + }, + "docs": { + "summary": "Flags common accessibility regressions in added JSX/HTML markup: an without alt, a clickable non-interactive element with no keyboard handler or role, a form control with no label association, and a positive tabindex.", + "looksAt": "Self-contained added tags (open through close on one line) in .jsx, .tsx, .html, and .vue files.", + "reports": "File, line, and public-safe rule kind — never markup content.", + "network": "Pure local analyzer. No external network call.", + "notes": "Only matches tags whose full opening tag appears on a single added line; multi-line attribute lists are not scanned." + } } ] } diff --git a/review-enrichment/src/analyzers/a11y-regression.ts b/review-enrichment/src/analyzers/a11y-regression.ts new file mode 100644 index 0000000000..3dae4a14c7 --- /dev/null +++ b/review-enrichment/src/analyzers/a11y-regression.ts @@ -0,0 +1,160 @@ +// Accessibility-regression analyzer (#2026, the a11y half of the #1499 epic 'accessibility and i18n regression' +// idea; the i18n half is a separate bounty). Flags common accessibility regressions in added JSX/HTML markup: +// an without alt, an onClick handler added to a non-interactive element without a keyboard handler or +// role, a form control with no way to associate a label, and a positive tabindex (which breaks natural tab +// order). Pure compute over added diff lines, no network. Only self-contained tags (opening `<` through closing +// `>` on the same added line) are matched, so a tag whose attributes wrap across lines is not scanned — this +// keeps the analyzer diff-local and free of false positives from partial tags. +import type { A11yFinding, EnrichRequest } from "../types.js"; +import { isTestPath } from "./test-ratio.js"; + +const MAX_FINDINGS = 25; +const MAX_LINE_CHARS = 2000; + +const MARKUP_PATH_RE = /\.(?:jsx|tsx|html?|vue)$/i; + +// Elements with built-in interactive semantics — an onClick here needs no extra keyboard wiring. +const INTERACTIVE_TAGS = new Set([ + "a", + "button", + "input", + "select", + "option", + "textarea", + "summary", + "label", + "audio", + "video", + "details", + "dialog", + "menuitem", +]); + +// types that are never associated with a visible label (hidden fields, buttons that carry their own text). +const LABELLESS_INPUT_TYPES = new Set(["hidden", "submit", "button", "reset", "image"]); + +const TAG_RE = /<([a-zA-Z][\w-]*)\b([^>]*)>/g; +const ALT_RE = /\balt\s*=/i; +const ONCLICK_RE = /\bonClick\s*=/; +const KEY_HANDLER_OR_ROLE_RE = /\bonKeyDown\s*=|\bonKeyUp\s*=|\bonKeyPress\s*=|\brole\s*=/; +const TYPE_ATTR_RE = /\btype\s*=\s*["']?(\w+)/i; +const LABEL_ASSOC_RE = /\bid\s*=|\baria-label\s*=|\baria-labelledby\s*=/i; +const TABINDEX_RE = /\btabindex\s*=\s*["'{]?\s*(-?\d+)/i; + +function isCommentLine(line: string): boolean { + const trimmed = line.trimStart(); + return /^(?:\/\/|\/\*|\*|