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 /^(?:\/\/|\/\*|\*|