From 76f60ec446e17bfe0f0245479c718b2bd6d9f67b Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 26 Mar 2026 20:11:19 +0100 Subject: [PATCH] feat: WCAG 2.2 AA accessibility detection with wcag_audit tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds accessibility intelligence to GitNexus — detects WCAG 2.2 AA violations in JSX/TSX components, maps findings to success criteria, and provides compliance scoring via the wcag_audit MCP tool. Phase 1 implementation: - JSX element extraction from TSX/JSX files (regex-based, correlates elements with attributes, classNames, text content) - A11ySignal node type with HAS_A11Y_SIGNAL edges in knowledge graph - 12 WCAG rules across 4 principles: - Perceivable: img-alt, icon-button-label, input-label, landmarks - Operable: keyboard, no-keyboard-trap, bypass-blocks, page-titled - Understandable: language-page - Robust: name-role-value, status-messages - Deaf-specific: video-captions - wcag_audit MCP tool with compliance score, violation list, fix patterns from the codebase, and EU compliance context tags - Pipeline phase 3.8 integration Each finding includes compliance context: - EU: Required by EN 301 549 (mandatory for EU compliance) - EU: Recommended - WCAG AAA (exceeds EU minimum) - Deaf-specific (important for deaf/HoH users) All findings are informational — shown always, never blocking. The WCAG rule set and detection patterns are informed by production accessibility tooling built and validated on real EU-funded projects with EN 301 549 compliance requirements. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 - .../2026-03-26-wcag-a11y-detection-design.md | 331 +++++++ gitnexus-shared/src/graph/types.ts | 6 +- gitnexus-shared/src/lbug/schema-constants.ts | 2 + gitnexus-shared/src/pipeline.ts | 1 + gitnexus/src/core/ingestion/a11y-processor.ts | 92 ++ .../a11y-rules/bonus/deaf-specific.ts | 35 + .../a11y-rules/component-resolver.ts | 154 ++++ .../src/core/ingestion/a11y-rules/index.ts | 42 + .../ingestion/a11y-rules/jsx-extractor.ts | 331 +++++++ .../ingestion/a11y-rules/operable/keyboard.ts | 98 +++ .../a11y-rules/operable/navigation.ts | 43 + .../perceivable/info-relationships.ts | 75 ++ .../perceivable/non-text-content.ts | 45 + .../a11y-rules/robust/name-role-value.ts | 96 +++ .../src/core/ingestion/a11y-rules/shared.ts | 89 ++ .../src/core/ingestion/a11y-rules/types.ts | 39 + .../a11y-rules/understandable/language.ts | 23 + .../src/core/ingestion/parsing-processor.ts | 17 +- .../core/ingestion/workers/parse-worker.ts | 23 + gitnexus/src/core/lbug/csv-generator.ts | 21 + gitnexus/src/core/lbug/schema.ts | 21 + gitnexus/src/mcp/local/local-backend.ts | 117 +++ gitnexus/src/mcp/server.ts | 3 + gitnexus/src/mcp/tools.ts | 21 + gitnexus/test/fixtures/a11y-seed.ts | 73 ++ .../test/integration/wcag-audit-e2e.test.ts | 156 ++++ gitnexus/test/unit/a11y-rules.test.ts | 812 ++++++++++++++++++ gitnexus/test/unit/component-resolver.test.ts | 314 +++++++ gitnexus/test/unit/jsx-extractor.test.ts | 229 +++++ gitnexus/test/unit/schema.test.ts | 2 +- gitnexus/test/unit/security.test.ts | 1 + gitnexus/test/unit/tools.test.ts | 4 +- gitnexus/vitest.config.ts | 2 + 34 files changed, 3312 insertions(+), 7 deletions(-) create mode 100644 docs/superpowers/specs/2026-03-26-wcag-a11y-detection-design.md create mode 100644 gitnexus/src/core/ingestion/a11y-processor.ts create mode 100644 gitnexus/src/core/ingestion/a11y-rules/bonus/deaf-specific.ts create mode 100644 gitnexus/src/core/ingestion/a11y-rules/component-resolver.ts create mode 100644 gitnexus/src/core/ingestion/a11y-rules/index.ts create mode 100644 gitnexus/src/core/ingestion/a11y-rules/jsx-extractor.ts create mode 100644 gitnexus/src/core/ingestion/a11y-rules/operable/keyboard.ts create mode 100644 gitnexus/src/core/ingestion/a11y-rules/operable/navigation.ts create mode 100644 gitnexus/src/core/ingestion/a11y-rules/perceivable/info-relationships.ts create mode 100644 gitnexus/src/core/ingestion/a11y-rules/perceivable/non-text-content.ts create mode 100644 gitnexus/src/core/ingestion/a11y-rules/robust/name-role-value.ts create mode 100644 gitnexus/src/core/ingestion/a11y-rules/shared.ts create mode 100644 gitnexus/src/core/ingestion/a11y-rules/types.ts create mode 100644 gitnexus/src/core/ingestion/a11y-rules/understandable/language.ts create mode 100644 gitnexus/test/fixtures/a11y-seed.ts create mode 100644 gitnexus/test/integration/wcag-audit-e2e.test.ts create mode 100644 gitnexus/test/unit/a11y-rules.test.ts create mode 100644 gitnexus/test/unit/component-resolver.test.ts create mode 100644 gitnexus/test/unit/jsx-extractor.test.ts diff --git a/.gitignore b/.gitignore index 95c9164e0..c775f0ba5 100644 --- a/.gitignore +++ b/.gitignore @@ -102,4 +102,3 @@ gitnexus/vendor/**/node_modules/ .swarm/ local_docs/ - diff --git a/docs/superpowers/specs/2026-03-26-wcag-a11y-detection-design.md b/docs/superpowers/specs/2026-03-26-wcag-a11y-detection-design.md new file mode 100644 index 000000000..fecc8b082 --- /dev/null +++ b/docs/superpowers/specs/2026-03-26-wcag-a11y-detection-design.md @@ -0,0 +1,331 @@ +# WCAG Accessibility Detection for GitNexus + +**Date:** 2026-03-26 +**Status:** Approved +**Approach:** Hybrid — Tree-sitter JSX extraction + structured WCAG rule engine +**Target:** WCAG 2.2 AA (EN 301 549 compatible) + +## Problem + +GitNexus analyzes code structure, call graphs, and data flow, but has no awareness of accessibility. When an AI agent is asked to fix a11y issues, it must read dozens of files to understand the component tree, guess at patterns, and has no way to know which WCAG criteria are met or violated. This wastes tokens and produces unreliable fixes. + +Meanwhile, EU-funded projects (like those in this workspace) must comply with EN 301 549 which references WCAG 2.1 AA, with WCAG 2.2 AA expected as the upcoming standard. + +## Goals + +1. **Index a11y signals** during pipeline analysis — extract JSX elements, attributes, ARIA patterns, store in graph +2. **Map to WCAG 2.2 AA criteria** — 42 detectable rules covering perceivable, operable, understandable, robust principles +3. **Provide AI agent context** — structured findings with fix patterns from the same codebase, reducing token usage by ~90% +4. **Per-route compliance reporting** — compliance score, violation list, per-route matrix via `wcag_audit` MCP tool +5. **A11y regression detection** — integrate with `detect_changes` and `impact` tools +6. **Informational, not blocking** — all findings always shown, teams decide what to act on + +## Non-Goals + +- Runtime testing (color contrast rendering, screen reader behavior, focus order testing) +- CSS file parsing (Tailwind classes in JSX are in scope; standalone .css files are not) +- Third-party component internals (trust Radix, MUI, etc. are accessible) +- Build/CI blocking or enforcement + +## Graph Model + +### New Node: `A11ySignal` + +Represents a detected accessibility signal on a specific element in a component. + +| Property | Type | Description | +|----------|------|-------------| +| `name` | string | Human-readable (e.g., `"img missing alt"`, `"button has aria-label"`) | +| `criterion` | string | WCAG SC number (e.g., `"1.1.1"`, `"2.1.1"`) | +| `status` | enum | `violation` \| `warning` \| `pass` | +| `severity` | enum | `critical` \| `serious` \| `moderate` \| `minor` | +| `element` | string | JSX element tag (e.g., `"img"`, `"button"`, `"div"`) | +| `filePath` | string | Source file | +| `startLine` | number | Line of the element | +| `confidence` | enum | `definite` \| `likely` \| `heuristic` | +| `complianceTag` | enum | `eu-required` \| `eu-recommended` \| `wcag-aaa` \| `deaf-specific` | + +### New Edge: `HAS_A11Y_SIGNAL` (Function/File → A11ySignal) + +Connects a component to its a11y signals. + +| Property | Type | Description | +|----------|------|-------------| +| `confidence` | number | 0-1 | +| `reason` | string | Detection method (e.g., `"tree-sitter\|missing-alt"`) | + +### Query Path + +``` +Route → HANDLES_ROUTE → File → CONTAINS → Function (component) + ↓ + HAS_A11Y_SIGNAL + ↓ + A11ySignal +``` + +Per-page compliance = follow the route chain, collect all A11ySignals, group by criterion. + +## JSX Extraction Layer + +### ExtractedJSXElement interface + +```typescript +interface ExtractedJSXElement { + tag: string; // "img", "button", "div", "input", "a", "video" + filePath: string; + lineNumber: number; + attributes: Map; // { alt: "photo", onClick: true, role: "button" } + hasChildren: boolean; // vs ; +}`; + const elements = extractJSXElements(source, FILE); + const btn = elements.find((e) => e.tag === 'button'); + expect(btn).toBeDefined(); + expect(btn!.attributes.get('onClick')).toBe('handleSubmit'); + expect(btn!.attributes.get('type')).toBe('submit'); + expect(btn!.hasChildren).toBe(true); + expect(btn!.textContent).toBe('Submit'); + }); + + it('extracts div with className and parses into classNames array', () => { + const source = ` +const Card = () =>
Content
;`; + const elements = extractJSXElements(source, FILE); + const div = elements.find((e) => e.tag === 'div'); + expect(div).toBeDefined(); + expect(div!.classNames).toEqual(['card', 'flex', 'p-4']); + }); + + it('extracts element with aria-label', () => { + const source = ` +const Nav = () => ;`; + const elements = extractJSXElements(source, FILE); + const nav = elements.find((e) => e.tag === 'nav'); + expect(nav).toBeDefined(); + expect(nav!.attributes.get('aria-label')).toBe('Main navigation'); + }); + + it('extracts multiple elements from same source', () => { + const source = ` +const Page = () => ( +
+

Title

+ Logo + +
+);`; + const elements = extractJSXElements(source, FILE); + const tags = elements.map((e) => e.tag); + expect(tags).toContain('div'); + expect(tags).toContain('h1'); + expect(tags).toContain('img'); + expect(tags).toContain('button'); + expect(elements.length).toBeGreaterThanOrEqual(4); + }); + + it('handles elements without attributes', () => { + const source = `const App = () =>
Hello
;`; + const elements = extractJSXElements(source, FILE); + const div = elements.find((e) => e.tag === 'div'); + expect(div).toBeDefined(); + expect(div!.attributes.size).toBe(0); + expect(div!.hasChildren).toBe(true); + expect(div!.textContent).toBe('Hello'); + }); + + it('skips non-JSX content', () => { + const source = ` +const x = 5; +const y = x + 10; +function add(a: number, b: number) { return a + b; } +`; + const elements = extractJSXElements(source, FILE); + expect(elements).toEqual([]); + }); + + it('detects enclosing function name', () => { + const source = ` +function MyComponent() { + return test; +}`; + const elements = extractJSXElements(source, FILE); + const img = elements.find((e) => e.tag === 'img'); + expect(img).toBeDefined(); + expect(img!.enclosingFunction).toBe('MyComponent'); + }); + + it('detects arrow function enclosing name', () => { + const source = ` +const MyWidget = () => { + return text; +};`; + const elements = extractJSXElements(source, FILE); + const span = elements.find((e) => e.tag === 'span'); + expect(span).toBeDefined(); + expect(span!.enclosingFunction).toBe('MyWidget'); + }); + + it('handles boolean attributes', () => { + const source = `const Form = () => ;`; + const elements = extractJSXElements(source, FILE); + const input = elements.find((e) => e.tag === 'input'); + expect(input).toBeDefined(); + expect(input!.attributes.get('disabled')).toBe(true); + expect(input!.attributes.get('required')).toBe(true); + expect(input!.attributes.get('type')).toBe('text'); + }); + + it('detects self-closing element has no children', () => { + const source = `const X = () =>
;`; + const elements = extractJSXElements(source, FILE); + const br = elements.find((e) => e.tag === 'br'); + expect(br).toBeDefined(); + expect(br!.hasChildren).toBe(false); + }); + + // ─── Multi-line JSX attribute tests (root cause of 81% false positives) ─── + + it('extracts aria-hidden from multi-line self-closing div (LanguageSwitcher pattern)', () => { + const source = ` setIsOpen(false)}\n aria-hidden="true"\n tabIndex={-1}\n/>`; + const elements = extractJSXElements(source, FILE); + const div = elements.find((e) => e.tag === 'div'); + expect(div).toBeDefined(); + expect(div!.attributes.get('aria-hidden')).toBe('true'); + expect(div!.attributes.get('tabIndex')).toBe('-1'); + expect(div!.attributes.get('onClick')).toBeTruthy(); + expect(div!.attributes.get('className')).toBe('fixed'); + expect(div!.selfClosing).toBeUndefined(); // selfClosing is not on ExtractedJSXElement, but hasChildren=false + expect(div!.hasChildren).toBe(false); + }); + + it('extracts aria-label from multi-line input', () => { + const source = `const Form = () => ( + +);`; + const elements = extractJSXElements(source, FILE); + const input = elements.find((e) => e.tag === 'input'); + expect(input).toBeDefined(); + expect(input!.attributes.get('type')).toBe('email'); + expect(input!.attributes.get('aria-label')).toBe('Email address'); + expect(input!.attributes.get('placeholder')).toBe('you@example.com'); + expect(input!.attributes.get('required')).toBe(true); + }); + + it('extracts onClick and onKeyDown from multi-line button', () => { + const source = `const Nav = () => ( + +);`; + const elements = extractJSXElements(source, FILE); + const btn = elements.find((e) => e.tag === 'button'); + expect(btn).toBeDefined(); + expect(btn!.attributes.get('onClick')).toBe('handleClick'); + expect(btn!.attributes.get('onKeyDown')).toBe('handleKeyDown'); + expect(btn!.attributes.get('role')).toBe('menuitem'); + expect(btn!.attributes.get('aria-expanded')).toBe('isOpen'); + expect(btn!.hasChildren).toBe(true); + expect(btn!.textContent).toBe('Menu'); + }); + + it('handles > inside JSX expressions without breaking tag boundary', () => { + const source = `
value > 0 ? "yes" : "no"} + className="test" +> + Content +
`; + const elements = extractJSXElements(source, FILE); + const div = elements.find((e) => e.tag === 'div'); + expect(div).toBeDefined(); + expect(div!.attributes.get('className')).toBe('test'); + expect(div!.hasChildren).toBe(true); + }); + + it('extracts htmlFor from multi-line label', () => { + const source = ``; + const elements = extractJSXElements(source, FILE); + const label = elements.find((e) => e.tag === 'label'); + expect(label).toBeDefined(); + expect(label!.attributes.get('htmlFor')).toBe('username'); + expect(label!.textContent).toBe('Username'); + }); + + it('extracts role and aria attributes from multi-line component', () => { + const source = ``; + const elements = extractJSXElements(source, FILE); + const comp = elements.find((e) => e.tag === 'CustomComponent'); + expect(comp).toBeDefined(); + expect(comp!.attributes.get('role')).toBe('dialog'); + expect(comp!.attributes.get('aria-modal')).toBe('true'); + expect(comp!.attributes.get('aria-labelledby')).toBe('dialog-title'); + }); +}); diff --git a/gitnexus/test/unit/schema.test.ts b/gitnexus/test/unit/schema.test.ts index cab80984a..ac4f3ea7e 100644 --- a/gitnexus/test/unit/schema.test.ts +++ b/gitnexus/test/unit/schema.test.ts @@ -68,7 +68,7 @@ describe('LadybugDB Schema', () => { }); it('has expected total count', () => { - // 9 core + 19 multi-language + Route + Tool = 31 + // 9 core + 18 multi-language + Route + Tool + A11ySignal = 31 expect(NODE_TABLES).toHaveLength(31); }); }); diff --git a/gitnexus/test/unit/security.test.ts b/gitnexus/test/unit/security.test.ts index 0adee1915..754055bd3 100644 --- a/gitnexus/test/unit/security.test.ts +++ b/gitnexus/test/unit/security.test.ts @@ -123,6 +123,7 @@ describe('VALID_RELATION_TYPES', () => { 'HANDLES_TOOL', 'ENTRY_POINT_OF', 'WRAPS', + 'HAS_A11Y_SIGNAL', ]) { expect(VALID_RELATION_TYPES.has(t)).toBe(true); } diff --git a/gitnexus/test/unit/tools.test.ts b/gitnexus/test/unit/tools.test.ts index 4274716a7..3423593d5 100644 --- a/gitnexus/test/unit/tools.test.ts +++ b/gitnexus/test/unit/tools.test.ts @@ -19,8 +19,8 @@ const GROUP_TOOLS = new Set([ ]); describe('GITNEXUS_TOOLS', () => { - it('exports all tools (7 base + 3 route/tool/shape + 1 api_impact + 5 group)', () => { - expect(GITNEXUS_TOOLS).toHaveLength(16); + it('exports all tools (7 base + 3 route/tool/shape + 1 api_impact + 1 wcag_audit + 5 group)', () => { + expect(GITNEXUS_TOOLS).toHaveLength(17); }); it('contains all expected tool names', () => { diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index 75858b8f3..4ddbda11b 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -62,6 +62,7 @@ export default defineConfig({ 'test/integration/lbug-lock-retry.test.ts', 'test/integration/api-impact-e2e.test.ts', 'test/integration/shape-check-regression.test.ts', + 'test/integration/wcag-audit-e2e.test.ts', ], fileParallelism: false, sequence: { groupOrder: 1 }, @@ -87,6 +88,7 @@ export default defineConfig({ 'test/integration/lbug-lock-retry.test.ts', 'test/integration/api-impact-e2e.test.ts', 'test/integration/shape-check-regression.test.ts', + 'test/integration/wcag-audit-e2e.test.ts', ], }, },