mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-07 02:58:02 +00:00
feat: WCAG 2.2 AA accessibility detection with wcag_audit tool
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) <noreply@anthropic.com>
This commit is contained in:
parent
969b4623ca
commit
76f60ec446
34 changed files with 3312 additions and 7 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -102,4 +102,3 @@ gitnexus/vendor/**/node_modules/
|
|||
.swarm/
|
||||
|
||||
local_docs/
|
||||
|
||||
|
|
|
|||
331
docs/superpowers/specs/2026-03-26-wcag-a11y-detection-design.md
Normal file
331
docs/superpowers/specs/2026-03-26-wcag-a11y-detection-design.md
Normal file
|
|
@ -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<string, string | true>; // { alt: "photo", onClick: true, role: "button" }
|
||||
hasChildren: boolean; // <button>Click me</button> vs <button />
|
||||
textContent?: string; // Direct text children
|
||||
enclosingFunction: string; // Which component this element lives in
|
||||
parentTag?: string; // Immediate parent element
|
||||
classNames?: string[]; // Parsed from className="..."
|
||||
inlineStyles?: Map<string, string>; // Parsed from style={{...}}
|
||||
}
|
||||
```
|
||||
|
||||
### Tree-sitter queries
|
||||
|
||||
New queries added to `TYPESCRIPT_QUERIES` and `JAVASCRIPT_QUERIES` for TSX/JSX grammars:
|
||||
|
||||
- `jsx_self_closing_element` with tag name and attributes
|
||||
- `jsx_opening_element` with tag name and attributes
|
||||
- `jsx_attribute` with name and value (string, expression, or absent)
|
||||
- `jsx_text` for text content children
|
||||
|
||||
### Pipeline integration
|
||||
|
||||
Extracted during existing Phase 3 (parse worker), accumulated alongside `allFetchCalls` and `allStateSlots`. Only for `.tsx`, `.jsx` files.
|
||||
|
||||
### What's NOT extracted
|
||||
|
||||
- Standalone CSS files (Tailwind classes in JSX classNames ARE extracted)
|
||||
- Runtime-computed attribute values (`aria-label={condition ? "a" : "b"}` — we detect the attribute exists, can't resolve the value, confidence: `heuristic`)
|
||||
- Third-party component internals
|
||||
|
||||
## WCAG Rule Engine
|
||||
|
||||
### Rule interface
|
||||
|
||||
```typescript
|
||||
type WCAGRule = {
|
||||
id: string; // "img-alt"
|
||||
criterion: string; // "1.1.1"
|
||||
name: string; // "Non-text Content"
|
||||
severity: 'critical' | 'serious' | 'moderate' | 'minor';
|
||||
complianceTag: 'eu-required' | 'eu-recommended' | 'wcag-aaa' | 'deaf-specific';
|
||||
check: (elements: ExtractedJSXElement[], filePath: string) => A11ySignal[];
|
||||
};
|
||||
```
|
||||
|
||||
### Rules by WCAG Principle
|
||||
|
||||
#### Principle 1: Perceivable (12 rules)
|
||||
|
||||
| Rule ID | WCAG SC | Name | Severity | Tag | Detection |
|
||||
|---------|---------|------|----------|-----|-----------|
|
||||
| `img-alt` | 1.1.1 | Non-text Content | critical | eu-required | img without alt or aria-label |
|
||||
| `icon-button-label` | 1.1.1 | Non-text Content | critical | eu-required | button without text and without aria-label |
|
||||
| `input-label` | 1.3.1 | Info and Relationships | critical | eu-required | input without associated label or aria-label |
|
||||
| `table-caption` | 1.3.1 | Info and Relationships | serious | eu-required | table without caption |
|
||||
| `heading-order` | 1.3.1 | Info and Relationships | moderate | eu-required | heading hierarchy skip (h1→h3) |
|
||||
| `landmarks` | 1.3.1 | Info and Relationships | serious | eu-required | missing main/nav/header landmarks |
|
||||
| `meaningful-sequence` | 1.3.2 | Meaningful Sequence | moderate | eu-required | CSS order property without tabIndex adjustment |
|
||||
| `orientation` | 1.3.4 | Orientation | minor | eu-required | Forced orientation CSS without alternative |
|
||||
| `input-purpose` | 1.3.5 | Identify Input Purpose | minor | eu-required | Form inputs without autocomplete |
|
||||
| `contrast` | 1.4.3 | Contrast (Minimum) | serious | eu-required | Known low-contrast Tailwind class pairs |
|
||||
| `resize-text` | 1.4.4 | Resize Text | moderate | eu-required | Fixed px font sizes instead of rem/em |
|
||||
| `text-spacing` | 1.4.12 | Text Spacing | minor | eu-required | Hardcoded line-height/letter-spacing in fixed units |
|
||||
|
||||
#### Principle 2: Operable (12 rules)
|
||||
|
||||
| Rule ID | WCAG SC | Name | Severity | Tag | Detection |
|
||||
|---------|---------|------|----------|-----|-----------|
|
||||
| `keyboard` | 2.1.1 | Keyboard | critical | eu-required | onClick without onKeyDown on non-interactive element |
|
||||
| `no-keyboard-trap` | 2.1.2 | No Keyboard Trap | critical | eu-required | Focus trap without Escape key handler |
|
||||
| `bypass-blocks` | 2.4.1 | Bypass Blocks | serious | eu-required | Missing skip link in layout |
|
||||
| `page-titled` | 2.4.2 | Page Titled | serious | eu-required | Route/page without title |
|
||||
| `link-purpose` | 2.4.4 | Link Purpose | moderate | eu-required | Link with generic text ("click here") without aria-label |
|
||||
| `headings-labels` | 2.4.6 | Headings and Labels | moderate | eu-required | Empty headings, form groups without legend |
|
||||
| `focus-not-obscured` | 2.4.11 | Focus Not Obscured | moderate | eu-required | Sticky elements that could cover focused elements |
|
||||
| `focus-appearance` | 2.4.13 | Focus Appearance | moderate | eu-required | Missing focus-visible styles |
|
||||
| `dragging` | 2.5.7 | Dragging Movements | serious | eu-required | onDrag without non-dragging alternative |
|
||||
| `target-size` | 2.5.5 | Target Size (Minimum) | moderate | eu-required | Interactive elements with explicit small dimensions |
|
||||
| `target-size-enhanced` | 2.5.8 | Target Size (Enhanced) | minor | wcag-aaa | Touch targets below 44x44px |
|
||||
| `motion-preference` | — | Reduced Motion | moderate | eu-recommended | Animation without prefers-reduced-motion check |
|
||||
|
||||
#### Principle 3: Understandable (8 rules)
|
||||
|
||||
| Rule ID | WCAG SC | Name | Severity | Tag | Detection |
|
||||
|---------|---------|------|----------|-----|-----------|
|
||||
| `language-page` | 3.1.1 | Language of Page | serious | eu-required | Missing lang attribute on html |
|
||||
| `language-parts` | 3.1.2 | Language of Parts | moderate | eu-required | Hardcoded strings in i18n apps (should use t()) |
|
||||
| `consistent-help` | 3.2.6 | Consistent Help | minor | eu-required | Help/contact component presence across pages |
|
||||
| `error-identification` | 3.3.1 | Error Identification | serious | eu-required | Form error not linked via aria-describedby |
|
||||
| `labels-instructions` | 3.3.2 | Labels or Instructions | moderate | eu-required | Required fields without indication |
|
||||
| `redundant-entry` | 3.3.7 | Redundant Entry | minor | eu-required | Same field asked twice without pre-fill |
|
||||
| `accessible-auth` | 3.3.8 | Accessible Authentication | serious | eu-required | CAPTCHA without alternative |
|
||||
| `accessible-auth-enhanced` | 3.3.9 | Accessible Auth (Enhanced) | moderate | wcag-aaa | Password-only without passkey/OAuth option |
|
||||
|
||||
#### Principle 4: Robust (4 rules)
|
||||
|
||||
| Rule ID | WCAG SC | Name | Severity | Tag | Detection |
|
||||
|---------|---------|------|----------|-----|-----------|
|
||||
| `duplicate-id` | 4.1.1 | Parsing | moderate | eu-required | Duplicate id attributes in same component tree |
|
||||
| `name-role-value` | 4.1.2 | Name, Role, Value | critical | eu-required | Custom widgets without role, aria-expanded, aria-selected |
|
||||
| `status-messages` | 4.1.3 | Status Messages | serious | eu-required | Dynamic content updates without aria-live |
|
||||
| `custom-select` | 4.1.2 | Name, Role, Value | serious | eu-required | Custom select/dropdown without listbox role |
|
||||
|
||||
#### Bonus: Deaf-Specific (4 rules)
|
||||
|
||||
| Rule ID | WCAG SC | Name | Severity | Tag | Detection |
|
||||
|---------|---------|------|----------|-----|-----------|
|
||||
| `video-captions` | 1.2.2 | Captions (Prerecorded) | critical | deaf-specific | video element without track[kind=captions] |
|
||||
| `audio-alternative` | 1.2.1 | Audio-only Alternative | serious | deaf-specific | Audio feedback pattern without visual alternative |
|
||||
| `sign-language-alt` | 1.2.6 | Sign Language | moderate | deaf-specific | Sign language video without text alternative |
|
||||
| `visual-notification` | — | Visual Notifications | moderate | deaf-specific | Notification/alert relying on sound only |
|
||||
|
||||
#### Tailwind CSS Resolution (6 rules use this)
|
||||
|
||||
| Rule | What Tailwind resolver does |
|
||||
|------|---------------------------|
|
||||
| `contrast` | Map `text-{color}` + `bg-{color}` → hex values → calculate contrast ratio |
|
||||
| `resize-text` | Map `text-{size}` → check if px-based |
|
||||
| `text-spacing` | Map `leading-{n}` / `tracking-{n}` → check if fixed units |
|
||||
| `target-size` | Map `w-{n} h-{n}` → check pixel dimensions on interactive elements |
|
||||
| `target-size-enhanced` | Same as above with stricter 44px threshold |
|
||||
| `focus-appearance` | Check for `focus:` / `focus-visible:` ring/outline classes |
|
||||
|
||||
**Total: 42 rules** (36 WCAG 2.2 AA + 2 WCAG AAA + 4 deaf-specific)
|
||||
|
||||
### Rule complexity categories
|
||||
|
||||
| Category | What it checks | When it runs |
|
||||
|----------|---------------|-------------|
|
||||
| Simple attribute check | "Does element X have attribute Y?" | Pipeline (Phase 3.8) |
|
||||
| Attribute + context | "Has onClick but no onKeyDown on same element?" | Pipeline (Phase 3.8) |
|
||||
| Cross-element | "Input has id=X, is there a label with htmlFor=X?" | Pipeline (Phase 3.8) |
|
||||
| Tailwind resolution | Parse className, resolve values, check thresholds | Pipeline (Phase 3.8) |
|
||||
| Graph-enhanced | Bypass blocks per page, consistent help, landmark completeness | Query time (wcag_audit tool) |
|
||||
|
||||
## MCP Tool: `wcag_audit`
|
||||
|
||||
### Input
|
||||
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `route` | string? | Filter by route |
|
||||
| `criterion` | string? | Filter by WCAG SC number |
|
||||
| `status` | string? | `"violation"` \| `"warning"` — default: show all |
|
||||
| `component` | string? | Filter by component name |
|
||||
| `repo` | string? | Repository slug |
|
||||
|
||||
### Output
|
||||
|
||||
Three views in one response:
|
||||
|
||||
**1. Compliance score**
|
||||
```
|
||||
WCAG 2.2 AA: 83% (35/42 criteria met)
|
||||
Perceivable: 9/12 — Operable: 11/12 — Understandable: 8/10 — Robust: 7/8
|
||||
```
|
||||
|
||||
**2. Violation list with fix patterns**
|
||||
```
|
||||
Findings (14):
|
||||
SC 1.1.1 (Non-text Content) — 2 findings [EU: Required by EN 301 549]
|
||||
FileUpload.tsx:42 — img button without aria-label
|
||||
Fix pattern: src/components/ui/button.tsx:89
|
||||
GrantCard.tsx:15 — img with empty alt=""
|
||||
Fix pattern: src/features/grants/components/GrantHeader.tsx:8
|
||||
|
||||
SC 2.1.1 (Keyboard) — 1 finding [EU: Required by EN 301 549]
|
||||
KanbanCard.tsx:18 — div with onClick, no keyboard handler
|
||||
Fix pattern: src/features/tasks/components/TaskCard.tsx:24
|
||||
```
|
||||
|
||||
**3. Per-route compliance matrix**
|
||||
```
|
||||
Route /grants/[slug]:
|
||||
Components: GrantForm, BudgetTable, FileUpload, StatusBadge
|
||||
SC 1.1.1: 1 finding (FileUpload)
|
||||
SC 1.3.1: 1 finding (BudgetTable missing caption)
|
||||
SC 2.1.1: clean
|
||||
SC 3.1.2: 2 findings (hardcoded strings)
|
||||
```
|
||||
|
||||
### Compliance tags on every finding
|
||||
|
||||
| Tag | Meaning |
|
||||
|-----|---------|
|
||||
| `EU: Required by EN 301 549` | Part of WCAG 2.2 AA — mandatory for EU compliance |
|
||||
| `EU: Recommended` | Best practice referenced in European Accessibility Act guidance |
|
||||
| `WCAG AAA` | Beyond AA — exceeds EU minimum |
|
||||
| `Deaf-specific` | Important for deaf/HoH users |
|
||||
|
||||
All findings always shown. Tags provide context. Teams decide what to act on.
|
||||
|
||||
### Fix patterns
|
||||
|
||||
For each violation, search the same codebase for components that pass the same criterion. Return the closest match as a fix pattern. Priority: same directory > same feature > anywhere in project.
|
||||
|
||||
### Integration with existing tools
|
||||
|
||||
| Tool | Change |
|
||||
|------|--------|
|
||||
| `detect_changes` | After mapping diff to symbols, report affected WCAG criteria |
|
||||
| `impact` | When BFS reaches a component with A11ySignals, include them |
|
||||
| `context` | Show a11y signals in 360-degree view |
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Foundation — JSX extraction + core rules + wcag_audit tool
|
||||
- Add JSX tree-sitter queries to TS/JS query strings
|
||||
- ExtractedJSXElement extraction in parse worker
|
||||
- A11ySignal node type in graph, LadybugDB schema, CSV generator
|
||||
- 10 core rules: img-alt, icon-button-label, input-label, keyboard, no-keyboard-trap, bypass-blocks, language-page, name-role-value, status-messages, video-captions
|
||||
- Basic `wcag_audit` MCP tool with compliance score + violation list
|
||||
- Seed-based tests + pipeline-level tests
|
||||
|
||||
### Phase 2: Full WCAG 2.2 AA rule coverage
|
||||
- Remaining 32 rules across all 4 principles
|
||||
- Tailwind resolver for CSS-related rules
|
||||
- Per-route compliance matrix in wcag_audit output
|
||||
- Fix pattern search
|
||||
- Compliance tags (EU/WCAG AAA/deaf-specific)
|
||||
- Real-repo validation on fluentiagrant-app
|
||||
|
||||
### Phase 3: Tool integration + graph-enhanced rules
|
||||
- Extend detect_changes with a11y regression detection
|
||||
- Extend impact and context tools
|
||||
- Graph-enhanced rules (bypass-blocks per page, consistent-help, landmark completeness)
|
||||
- Cross-project pattern matching
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Seed-based tests (per rule)
|
||||
- Fixture JSX with known violations → assert A11ySignal created
|
||||
- Fixture JSX with no violations → assert no false positive
|
||||
- Break fixture to confirm test catches the change
|
||||
|
||||
### Pipeline-level tests
|
||||
- Synthetic mini-project with JSX components
|
||||
- Assert node/edge counts and signal verdicts
|
||||
|
||||
### Real-repo validation
|
||||
- fluentiagrant-app (rich a11y infrastructure — verify detection matches existing audit)
|
||||
- collector (PHP — verify zero false positives on non-JSX project)
|
||||
- kurz-spj-sk (Next.js — deaf user context)
|
||||
|
||||
### MCP tool smoke tests
|
||||
- Invoke wcag_audit after each implementation phase
|
||||
- Verify output format matches spec
|
||||
|
||||
## Post-Implementation Process (every phase)
|
||||
|
||||
1. Run /simplify on the branch
|
||||
2. Real-repo validation
|
||||
3. Squash duplicate commits
|
||||
4. Audit PR body for private project references
|
||||
5. Push to fork and create PR
|
||||
|
|
@ -44,7 +44,8 @@ export type NodeLabel =
|
|||
| 'Template'
|
||||
| 'Section'
|
||||
| 'Route'
|
||||
| 'Tool';
|
||||
| 'Tool'
|
||||
| 'A11ySignal'; // WCAG accessibility signal
|
||||
|
||||
export type NodeProperties = {
|
||||
name: string;
|
||||
|
|
@ -115,7 +116,8 @@ export type RelationshipType =
|
|||
| 'HANDLES_TOOL'
|
||||
| 'ENTRY_POINT_OF'
|
||||
| 'WRAPS'
|
||||
| 'QUERIES';
|
||||
| 'QUERIES'
|
||||
| 'HAS_A11Y_SIGNAL'; // Function/Method/File → A11ySignal
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export const NODE_TABLES = [
|
|||
'Module',
|
||||
'Route',
|
||||
'Tool',
|
||||
'A11ySignal',
|
||||
] as const;
|
||||
|
||||
export type NodeTableName = (typeof NODE_TABLES)[number];
|
||||
|
|
@ -67,6 +68,7 @@ export const REL_TYPES = [
|
|||
'ENTRY_POINT_OF',
|
||||
'WRAPS',
|
||||
'QUERIES',
|
||||
'HAS_A11Y_SIGNAL',
|
||||
] as const;
|
||||
|
||||
export type RelType = (typeof REL_TYPES)[number];
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export type PipelinePhase =
|
|||
| 'communities'
|
||||
| 'processes'
|
||||
| 'enriching'
|
||||
| 'a11y'
|
||||
| 'complete'
|
||||
| 'error';
|
||||
|
||||
|
|
|
|||
92
gitnexus/src/core/ingestion/a11y-processor.ts
Normal file
92
gitnexus/src/core/ingestion/a11y-processor.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* A11y Processor — creates A11ySignal nodes and HAS_A11Y_SIGNAL edges
|
||||
*
|
||||
* Takes detected A11ySignal objects from the rule engine and persists them
|
||||
* into the knowledge graph. Each signal becomes a node, linked to its
|
||||
* enclosing function/method/file via HAS_A11Y_SIGNAL edges.
|
||||
*/
|
||||
|
||||
import type { A11ySignal } from './a11y-rules/types.js';
|
||||
import type { KnowledgeGraph } from '../graph/types.js';
|
||||
import { generateId } from '../../lib/utils.js';
|
||||
|
||||
export interface A11yProcessorResult {
|
||||
signalsCreated: number;
|
||||
edgesCreated: number;
|
||||
violations: number;
|
||||
needsReview: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best source node ID for an A11ySignal.
|
||||
* Tries: Function → Method → File (same pattern as route/tool processors).
|
||||
*/
|
||||
function findSourceNodeId(signal: A11ySignal, graph: KnowledgeGraph): string | undefined {
|
||||
// Try Function node (most common for JSX components)
|
||||
for (const prefix of ['Function', 'Method']) {
|
||||
// Pattern: "Function:filePath:functionName"
|
||||
const candidateId = `${prefix}:${signal.filePath}:${signal.enclosingFunction || ''}`;
|
||||
if (signal.enclosingFunction && graph.getNode(candidateId)) {
|
||||
return candidateId;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to File node
|
||||
const fileId = generateId('File', signal.filePath);
|
||||
if (graph.getNode(fileId)) {
|
||||
return fileId;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function processA11ySignals(
|
||||
signals: A11ySignal[],
|
||||
graph: KnowledgeGraph,
|
||||
): A11yProcessorResult {
|
||||
let signalsCreated = 0;
|
||||
let edgesCreated = 0;
|
||||
let violations = 0;
|
||||
let needsReview = 0;
|
||||
|
||||
for (const signal of signals) {
|
||||
// Create A11ySignal node
|
||||
const signalNodeId = generateId('A11ySignal', `${signal.filePath}:${signal.startLine}:${signal.name}`);
|
||||
|
||||
graph.addNode({
|
||||
id: signalNodeId,
|
||||
label: 'A11ySignal',
|
||||
properties: {
|
||||
name: signal.name,
|
||||
filePath: signal.filePath,
|
||||
startLine: signal.startLine,
|
||||
criterion: signal.criterion,
|
||||
signalStatus: signal.status,
|
||||
severity: signal.severity,
|
||||
element: signal.element,
|
||||
complianceTag: signal.complianceTag,
|
||||
confidence: signal.confidence,
|
||||
},
|
||||
});
|
||||
signalsCreated++;
|
||||
|
||||
if (signal.status === 'violation') violations++;
|
||||
else if (signal.status === 'needs-review') needsReview++;
|
||||
|
||||
// Create HAS_A11Y_SIGNAL edge from enclosing function/file
|
||||
const sourceId = findSourceNodeId(signal, graph);
|
||||
if (sourceId) {
|
||||
graph.addRelationship({
|
||||
id: generateId('HAS_A11Y_SIGNAL', `${sourceId}->${signalNodeId}`),
|
||||
sourceId,
|
||||
targetId: signalNodeId,
|
||||
type: 'HAS_A11Y_SIGNAL',
|
||||
confidence: signal.confidence === 'definite' ? 1.0 : signal.confidence === 'likely' ? 0.85 : 0.7,
|
||||
reason: `wcag-${signal.criterion}`,
|
||||
});
|
||||
edgesCreated++;
|
||||
}
|
||||
}
|
||||
|
||||
return { signalsCreated, edgesCreated, violations, needsReview };
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import type { WCAGRule, ExtractedJSXElement, A11ySignal } from '../types.js';
|
||||
import { makeSignal, shouldSkipElement } from '../shared.js';
|
||||
|
||||
export const videoCaptions: WCAGRule = {
|
||||
id: 'videoCaptions',
|
||||
criterion: '1.2.2',
|
||||
wcagName: 'Captions (Prerecorded)',
|
||||
severity: 'critical',
|
||||
complianceTag: 'deaf-specific',
|
||||
check(elements: ExtractedJSXElement[]): A11ySignal[] {
|
||||
const signals: A11ySignal[] = [];
|
||||
|
||||
for (const el of elements) {
|
||||
if (shouldSkipElement(el)) continue;
|
||||
if (el.tag !== 'video') continue;
|
||||
|
||||
// Check for captions-related attributes on the video element itself
|
||||
const hasCaptionAttr = Array.from(el.attributes.keys()).some((k) =>
|
||||
k.toLowerCase().includes('caption'),
|
||||
);
|
||||
|
||||
// Check if any track element in the same file has a video parent
|
||||
const hasTrackChild = elements.some(
|
||||
(t) => t.tag === 'track' && t.parentTag === 'video',
|
||||
);
|
||||
|
||||
if (hasTrackChild || hasCaptionAttr) {
|
||||
signals.push(makeSignal(this, el, 'pass', 'heuristic'));
|
||||
} else {
|
||||
signals.push(makeSignal(this, el, 'needs-review', 'heuristic'));
|
||||
}
|
||||
}
|
||||
return signals;
|
||||
},
|
||||
};
|
||||
154
gitnexus/src/core/ingestion/a11y-rules/component-resolver.ts
Normal file
154
gitnexus/src/core/ingestion/a11y-rules/component-resolver.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* Resolves custom React components to the native DOM elements they render.
|
||||
* Uses the already-extracted JSX elements grouped by enclosingFunction.
|
||||
*/
|
||||
|
||||
import type { ExtractedJSXElement } from './types.js';
|
||||
|
||||
/** Native HTML elements (lowercase) */
|
||||
function isNativeElement(tag: string): boolean {
|
||||
return /^[a-z]/.test(tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Known accessible third-party components.
|
||||
* These are in node_modules and can't be traced, but we know what they render.
|
||||
*/
|
||||
export const KNOWN_ACCESSIBLE_COMPONENTS = new Set([
|
||||
// Radix UI primitives -- all internally accessible
|
||||
'Dialog', 'DialogContent', 'DialogTrigger', 'DialogOverlay',
|
||||
'Select', 'SelectTrigger', 'SelectContent', 'SelectItem',
|
||||
'DropdownMenu', 'DropdownMenuTrigger', 'DropdownMenuContent', 'DropdownMenuItem',
|
||||
'Popover', 'PopoverTrigger', 'PopoverContent',
|
||||
'Tooltip', 'TooltipTrigger', 'TooltipContent',
|
||||
'Tabs', 'TabsList', 'TabsTrigger', 'TabsContent',
|
||||
'Accordion', 'AccordionItem', 'AccordionTrigger', 'AccordionContent',
|
||||
'AlertDialog', 'AlertDialogTrigger', 'AlertDialogContent', 'AlertDialogPrimitive',
|
||||
'Toast', 'ToastPrimitive', 'ToastViewport',
|
||||
'Checkbox', 'RadioGroup', 'RadioGroupItem',
|
||||
'Switch', 'Slider', 'Toggle', 'ToggleGroup',
|
||||
'NavigationMenu', 'NavigationMenuTrigger', 'NavigationMenuContent',
|
||||
// shadcn/ui specific wrappers
|
||||
'Button', 'Input', 'Label', 'Textarea', 'FormField', 'FormItem', 'FormLabel', 'FormControl',
|
||||
'Card', 'Badge', 'Avatar',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if a component is a known accessible third-party component.
|
||||
* Elements inside these components should not be flagged for keyboard/role issues.
|
||||
*/
|
||||
export function isKnownAccessibleComponent(tag: string): boolean {
|
||||
// Check exact match
|
||||
if (KNOWN_ACCESSIBLE_COMPONENTS.has(tag)) return true;
|
||||
// Check dotted names like Dialog.Content
|
||||
const parts = tag.split('.');
|
||||
if (parts.length > 1 && KNOWN_ACCESSIBLE_COMPONENTS.has(parts.join(''))) return true;
|
||||
if (parts.length > 1 && KNOWN_ACCESSIBLE_COMPONENTS.has(parts[0])) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Build a map of componentName -> elements it renders */
|
||||
export function buildComponentMap(
|
||||
allElements: ExtractedJSXElement[],
|
||||
): Map<string, ExtractedJSXElement[]> {
|
||||
const map = new Map<string, ExtractedJSXElement[]>();
|
||||
for (const el of allElements) {
|
||||
const fn = el.enclosingFunction;
|
||||
if (!fn || fn === '<module>' || fn === '<anonymous>') continue;
|
||||
const arr = map.get(fn) || [];
|
||||
arr.push(el);
|
||||
map.set(fn, arr);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a custom component to the native DOM elements it ultimately renders.
|
||||
* Follows PascalCase component references recursively up to maxDepth.
|
||||
*/
|
||||
export function resolveComponent(
|
||||
componentName: string,
|
||||
componentMap: Map<string, ExtractedJSXElement[]>,
|
||||
maxDepth: number = 5,
|
||||
visited: Set<string> = new Set(),
|
||||
): ExtractedJSXElement[] {
|
||||
if (visited.has(componentName) || maxDepth <= 0) return [];
|
||||
visited.add(componentName);
|
||||
|
||||
const elements = componentMap.get(componentName);
|
||||
if (!elements) return [];
|
||||
|
||||
const resolved: ExtractedJSXElement[] = [];
|
||||
for (const el of elements) {
|
||||
if (isNativeElement(el.tag)) {
|
||||
resolved.push(el);
|
||||
} else {
|
||||
// PascalCase component -- resolve recursively
|
||||
const inner = resolveComponent(el.tag, componentMap, maxDepth - 1, visited);
|
||||
resolved.push(...inner);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* For each file's elements, resolve custom component usages to native elements.
|
||||
* Returns enhanced element list where PascalCase components are augmented with
|
||||
* their resolved native children.
|
||||
*
|
||||
* This does NOT replace the original elements -- it adds resolution context
|
||||
* so rules can check what a custom component actually renders.
|
||||
*/
|
||||
export function resolveComponentsInFile(
|
||||
fileElements: ExtractedJSXElement[],
|
||||
componentMap: Map<string, ExtractedJSXElement[]>,
|
||||
): ExtractedJSXElement[] {
|
||||
const resolved: ExtractedJSXElement[] = [];
|
||||
|
||||
for (const el of fileElements) {
|
||||
// Always keep the original element
|
||||
resolved.push(el);
|
||||
|
||||
// If it's a custom component, also add the native elements it renders
|
||||
// (with the original element's filePath and lineNumber for error reporting)
|
||||
if (!isNativeElement(el.tag)) {
|
||||
const nativeChildren = resolveComponent(el.tag, componentMap);
|
||||
for (const child of nativeChildren) {
|
||||
// Merge usage-site props onto resolved native element
|
||||
// Usage-site props (el.attributes) override internal props (child.attributes)
|
||||
// Only propagate a11y-relevant attributes from usage site to resolved element.
|
||||
// Event handlers (onClick, onChange, etc.) must NOT propagate — they cause
|
||||
// false positives when keyboard/nameRoleValue rules see onClick on resolved divs.
|
||||
const A11Y_PROPS = new Set([
|
||||
'id', 'htmlFor', 'for', 'role', 'tabIndex',
|
||||
'aria-label', 'aria-labelledby', 'aria-describedby', 'aria-hidden',
|
||||
'aria-live', 'aria-expanded', 'aria-selected', 'aria-checked',
|
||||
'aria-controls', 'aria-haspopup', 'aria-modal', 'aria-disabled',
|
||||
'aria-required', 'aria-invalid', 'aria-errormessage',
|
||||
'alt', 'title', 'lang', 'type', 'autocomplete', 'placeholder',
|
||||
]);
|
||||
const mergedAttrs = new Map(child.attributes);
|
||||
for (const [key, value] of el.attributes) {
|
||||
if (A11Y_PROPS.has(key)) mergedAttrs.set(key, value);
|
||||
}
|
||||
|
||||
// Create a "virtual" element: native tag from the component's internals,
|
||||
// but attributed to the usage site (where the component is used)
|
||||
resolved.push({
|
||||
...child,
|
||||
attributes: mergedAttrs,
|
||||
filePath: el.filePath,
|
||||
lineNumber: el.lineNumber,
|
||||
enclosingFunction: el.enclosingFunction,
|
||||
// Mark as resolved so rules know this came from component resolution
|
||||
parentTag: el.tag, // the custom component that renders this
|
||||
resolved: true, // flag for effectiveStatus() — resolved elements never produce violations
|
||||
// Also merge classNames if both have them
|
||||
classNames: [...(child.classNames || []), ...(el.classNames || [])],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
42
gitnexus/src/core/ingestion/a11y-rules/index.ts
Normal file
42
gitnexus/src/core/ingestion/a11y-rules/index.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { WCAGRule, ExtractedJSXElement, A11ySignal } from './types.js';
|
||||
import { imgAlt, iconButtonLabel } from './perceivable/non-text-content.js';
|
||||
import { inputLabel, landmarks } from './perceivable/info-relationships.js';
|
||||
import { keyboard, noKeyboardTrap } from './operable/keyboard.js';
|
||||
import { bypassBlocks, pageTitled } from './operable/navigation.js';
|
||||
import { languagePage } from './understandable/language.js';
|
||||
import { nameRoleValue, statusMessages } from './robust/name-role-value.js';
|
||||
import { videoCaptions } from './bonus/deaf-specific.js';
|
||||
|
||||
export type { WCAGRule, A11ySignal, ExtractedJSXElement } from './types.js';
|
||||
|
||||
const RULES: WCAGRule[] = [
|
||||
imgAlt,
|
||||
iconButtonLabel,
|
||||
inputLabel,
|
||||
landmarks,
|
||||
keyboard,
|
||||
noKeyboardTrap,
|
||||
bypassBlocks,
|
||||
pageTitled,
|
||||
languagePage,
|
||||
nameRoleValue,
|
||||
statusMessages,
|
||||
videoCaptions,
|
||||
];
|
||||
|
||||
export function runA11yRules(
|
||||
elements: ExtractedJSXElement[],
|
||||
filePath: string,
|
||||
source?: string,
|
||||
): A11ySignal[] {
|
||||
// Skip test files -- not production code
|
||||
if (filePath.includes('.test.') || filePath.includes('.spec.') || filePath.includes('/__tests__/')) return [];
|
||||
|
||||
const signals: A11ySignal[] = [];
|
||||
for (const rule of RULES) {
|
||||
signals.push(...rule.check(elements, filePath, source));
|
||||
}
|
||||
return signals;
|
||||
}
|
||||
|
||||
export { RULES };
|
||||
331
gitnexus/src/core/ingestion/a11y-rules/jsx-extractor.ts
Normal file
331
gitnexus/src/core/ingestion/a11y-rules/jsx-extractor.ts
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
/**
|
||||
* JSX Element Extractor — character-walking parser for JSX elements and their attributes.
|
||||
*
|
||||
* Uses a character-by-character parser instead of regex or tree-sitter because:
|
||||
* 1. Regex patterns using [^>] fail on multi-line JSX attributes (the root cause
|
||||
* of 81% false positives in the WCAG a11y detector).
|
||||
* 2. Tree-sitter captures JSX elements and attributes as separate matches,
|
||||
* making parent-child correlation unreliable across pattern boundaries.
|
||||
* 3. The char-walker correctly handles `>` inside JSX expressions and strings,
|
||||
* multi-line attributes, and nested braces.
|
||||
*/
|
||||
|
||||
import type { ExtractedJSXElement } from './types.js';
|
||||
|
||||
// ─── Regex patterns ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Matches closing JSX tags: </Tag>
|
||||
* Group 1: tag name
|
||||
*/
|
||||
const CLOSING_TAG_RE = /<\/([A-Z][A-Za-z0-9.]*|[a-z][a-z0-9-]*)\s*>/g;
|
||||
|
||||
/**
|
||||
* Matches individual JSX attributes.
|
||||
* Handles: name="value", name='value', name={expr}, name (boolean)
|
||||
* Group 1: attribute name
|
||||
* Group 2: quoted value (double quotes)
|
||||
* Group 3: quoted value (single quotes)
|
||||
* Group 4: expression value (inside braces — first level only)
|
||||
*/
|
||||
const ATTR_RE = /([a-zA-Z_][\w:.-]*)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|\{([^}]*)\}))?/g;
|
||||
|
||||
// ─── Character-walking tag parser ─────────────────────────────────────
|
||||
|
||||
interface RawTagMatch {
|
||||
tag: string;
|
||||
attrs: string;
|
||||
selfClosing: boolean;
|
||||
offset: number;
|
||||
endOffset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSX opening/self-closing tags using character-by-character walking.
|
||||
*
|
||||
* Unlike regex, this correctly handles:
|
||||
* - Multi-line attributes (the root cause of 81% false positives)
|
||||
* - `>` inside JSX expressions (e.g. `onClick={() => val > 0}`)
|
||||
* - `>` inside string literals
|
||||
* - Nested braces in expressions
|
||||
*/
|
||||
function extractRawTags(source: string): RawTagMatch[] {
|
||||
const tags: RawTagMatch[] = [];
|
||||
let i = 0;
|
||||
const len = source.length;
|
||||
|
||||
while (i < len) {
|
||||
// Find next <
|
||||
if (source[i] !== '<') { i++; continue; }
|
||||
|
||||
// Skip closing tags </...>
|
||||
if (i + 1 < len && source[i + 1] === '/') { i += 2; continue; }
|
||||
|
||||
// Skip comments <!-- ... -->, doctype <!...>, and processing instructions <?...>
|
||||
if (i + 1 < len && (source[i + 1] === '!' || source[i + 1] === '?')) { i += 2; continue; }
|
||||
|
||||
// Try to read a tag name
|
||||
const tagStart = i;
|
||||
i++; // skip <
|
||||
const nameStart = i;
|
||||
while (i < len && /[A-Za-z0-9.]/.test(source[i])) i++;
|
||||
const tagName = source.slice(nameStart, i);
|
||||
|
||||
// Must be a valid JSX tag name: starts with uppercase (component) or lowercase letter
|
||||
if (!tagName || !/^[A-Za-z]/.test(tagName)) continue;
|
||||
// Lowercase tags must match HTML element pattern (letters and hyphens only after first char)
|
||||
if (/^[a-z]/.test(tagName) && !/^[a-z][a-z0-9-]*$/.test(tagName)) continue;
|
||||
|
||||
// Read attributes until > or />
|
||||
// Must handle: string literals ("..." or '...'), JSX expressions ({...}), template literals (`...`)
|
||||
const attrStart = i;
|
||||
let depth = 0; // brace depth for JSX expressions
|
||||
let inString: string | null = null;
|
||||
let found = false;
|
||||
|
||||
while (i < len) {
|
||||
const ch = source[i];
|
||||
|
||||
if (inString) {
|
||||
if (ch === '\\') { i += 2; continue; } // skip escaped chars
|
||||
if (ch === inString) inString = null;
|
||||
i++; continue;
|
||||
}
|
||||
|
||||
if (ch === '"' || ch === "'" || ch === '`') { inString = ch; i++; continue; }
|
||||
if (ch === '{') { depth++; i++; continue; }
|
||||
if (ch === '}') { depth--; i++; continue; }
|
||||
|
||||
if (depth === 0) {
|
||||
if (ch === '/' && i + 1 < len && source[i + 1] === '>') {
|
||||
// Self-closing tag
|
||||
const attrStr = source.slice(attrStart, i);
|
||||
tags.push({ tag: tagName, attrs: attrStr, selfClosing: true, offset: tagStart, endOffset: i + 2 });
|
||||
i += 2;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
if (ch === '>') {
|
||||
// Opening tag
|
||||
const attrStr = source.slice(attrStart, i);
|
||||
tags.push({ tag: tagName, attrs: attrStr, selfClosing: false, offset: tagStart, endOffset: i + 1 });
|
||||
i++;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
// If we never found a closing > or />, just move on (malformed tag)
|
||||
if (!found && i >= len) break;
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches function/arrow function declarations to determine enclosing function.
|
||||
* Group 1: function name (function declaration)
|
||||
* Group 2: const/let/var name (arrow function)
|
||||
*/
|
||||
const FUNCTION_RE = /(?:function\s+([A-Za-z_$][\w$]*)|(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>|(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*function)/g;
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
interface RawJSXMatch {
|
||||
tag: string;
|
||||
attrString: string;
|
||||
line: number;
|
||||
offset: number;
|
||||
endOffset: number;
|
||||
selfClosing: boolean;
|
||||
}
|
||||
|
||||
function parseAttributes(attrString: string): Map<string, string | true> {
|
||||
const attrs = new Map<string, string | true>();
|
||||
if (!attrString) return attrs;
|
||||
|
||||
ATTR_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = ATTR_RE.exec(attrString)) !== null) {
|
||||
const name = m[1];
|
||||
const value = m[2] ?? m[3] ?? m[4] ?? true;
|
||||
attrs.set(name, value);
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function extractClassNames(attrs: Map<string, string | true>): string[] | undefined {
|
||||
const cls = attrs.get('className') ?? attrs.get('class');
|
||||
if (!cls || cls === true) return undefined;
|
||||
return String(cls).split(/\s+/).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Precompute newline offsets for O(log n) line lookups via binary search.
|
||||
*/
|
||||
function buildNewlineOffsets(source: string): number[] {
|
||||
const offsets: number[] = [];
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
if (source[i] === '\n') offsets.push(i);
|
||||
}
|
||||
return offsets;
|
||||
}
|
||||
|
||||
function lineNumberAt(newlineOffsets: number[], offset: number): number {
|
||||
// Binary search: find how many newlines occur before `offset`
|
||||
let lo = 0;
|
||||
let hi = newlineOffsets.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (newlineOffsets[mid] < offset) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
return lo + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the enclosing function name for a given offset in the source.
|
||||
* Walks backwards through function declarations to find the nearest one
|
||||
* whose body contains the offset.
|
||||
*/
|
||||
function findEnclosingFunction(source: string, offset: number): string {
|
||||
FUNCTION_RE.lastIndex = 0;
|
||||
let lastFunc = '<module>';
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = FUNCTION_RE.exec(source)) !== null) {
|
||||
if (m.index > offset) break;
|
||||
const name = m[1] || m[2] || m[3];
|
||||
if (name) lastFunc = name;
|
||||
}
|
||||
return lastFunc;
|
||||
}
|
||||
|
||||
// ─── Main extraction ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract JSX elements from source code.
|
||||
*
|
||||
* @param source - The full file source text
|
||||
* @param filePath - File path for the extracted elements
|
||||
* @returns Array of extracted JSX elements
|
||||
*/
|
||||
export function extractJSXElements(source: string, filePath: string): ExtractedJSXElement[] {
|
||||
const elements: ExtractedJSXElement[] = [];
|
||||
|
||||
// Skip files that clearly have no JSX
|
||||
if (!source.includes('<')) return elements;
|
||||
|
||||
// Perf 1: Precompute newline offsets for O(log n) line lookups
|
||||
const newlineOffsets = buildNewlineOffsets(source);
|
||||
|
||||
// Collect all opening and self-closing tags using character-walking parser
|
||||
const rawTags = extractRawTags(source);
|
||||
const rawMatches: RawJSXMatch[] = [];
|
||||
|
||||
for (const raw of rawTags) {
|
||||
// Skip script/style tags
|
||||
if (raw.tag === 'script' || raw.tag === 'style') continue;
|
||||
|
||||
rawMatches.push({
|
||||
tag: raw.tag,
|
||||
attrString: raw.attrs,
|
||||
line: lineNumberAt(newlineOffsets, raw.offset),
|
||||
offset: raw.offset,
|
||||
endOffset: raw.endOffset,
|
||||
selfClosing: raw.selfClosing,
|
||||
});
|
||||
}
|
||||
|
||||
// Build closing tag positions for hasChildren/textContent detection
|
||||
const closingTags: Array<{ tag: string; offset: number; endOffset: number }> = [];
|
||||
CLOSING_TAG_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = CLOSING_TAG_RE.exec(source)) !== null) {
|
||||
closingTags.push({ tag: m[1], offset: m.index, endOffset: m.index + m[0].length });
|
||||
}
|
||||
|
||||
// Perf 2: Pre-build a map of closing tags by tag name for faster lookup
|
||||
const closingTagsByName = new Map<string, Array<{ offset: number; endOffset: number; index: number }>>();
|
||||
for (let i = 0; i < closingTags.length; i++) {
|
||||
const ct = closingTags[i];
|
||||
let arr = closingTagsByName.get(ct.tag);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
closingTagsByName.set(ct.tag, arr);
|
||||
}
|
||||
arr.push({ offset: ct.offset, endOffset: ct.endOffset, index: i });
|
||||
}
|
||||
|
||||
// Bug 8: Track consumed closing tags to prevent double-matching nested same-tag elements
|
||||
const consumedClosingTags = new Set<number>();
|
||||
|
||||
for (let rawIdx = 0; rawIdx < rawMatches.length; rawIdx++) {
|
||||
const raw = rawMatches[rawIdx];
|
||||
const attributes = parseAttributes(raw.attrString);
|
||||
const classNames = extractClassNames(attributes);
|
||||
const enclosingFunction = findEnclosingFunction(source, raw.offset);
|
||||
|
||||
let hasChildren = false;
|
||||
let textContent: string | undefined;
|
||||
|
||||
if (!raw.selfClosing) {
|
||||
// Find the matching closing tag (first unconsumed one after this element)
|
||||
const candidates = closingTagsByName.get(raw.tag);
|
||||
let closing: { offset: number; endOffset: number; index: number } | undefined;
|
||||
if (candidates) {
|
||||
for (const c of candidates) {
|
||||
if (c.offset > raw.endOffset && !consumedClosingTags.has(c.index)) {
|
||||
closing = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (closing) {
|
||||
consumedClosingTags.add(closing.index);
|
||||
const between = source.slice(raw.endOffset, closing.offset).trim();
|
||||
hasChildren = between.length > 0;
|
||||
// Extract text content (only if it's plain text, not nested JSX)
|
||||
if (between.length > 0 && !between.includes('<')) {
|
||||
// Strip expression wrappers like {"text"} or {variable}
|
||||
textContent = between.replace(/^\{["']?|["']?\}$/g, '').trim() || undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find parent tag: the nearest opening tag that encloses this element
|
||||
// Perf 2: Use rawIdx instead of indexOf(raw)
|
||||
let parentTag: string | undefined;
|
||||
for (let i = rawIdx - 1; i >= 0; i--) {
|
||||
const candidate = rawMatches[i];
|
||||
if (!candidate.selfClosing) {
|
||||
// Check if an unconsumed closing tag for this candidate exists after our element
|
||||
const candidateCandidates = closingTagsByName.get(candidate.tag);
|
||||
if (candidateCandidates) {
|
||||
const hasEnclosing = candidateCandidates.some(
|
||||
(c) => c.offset > raw.endOffset && !consumedClosingTags.has(c.index),
|
||||
);
|
||||
if (hasEnclosing) {
|
||||
parentTag = candidate.tag;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
elements.push({
|
||||
tag: raw.tag,
|
||||
filePath,
|
||||
lineNumber: raw.line,
|
||||
attributes,
|
||||
hasChildren,
|
||||
textContent,
|
||||
enclosingFunction,
|
||||
parentTag,
|
||||
...(classNames ? { classNames } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
98
gitnexus/src/core/ingestion/a11y-rules/operable/keyboard.ts
Normal file
98
gitnexus/src/core/ingestion/a11y-rules/operable/keyboard.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import type { WCAGRule, ExtractedJSXElement, A11ySignal } from '../types.js';
|
||||
import { hasAttr, makeSignal, INTERACTIVE_NON_SEMANTIC, shouldSkipElement, effectiveStatus } from '../shared.js';
|
||||
import { isKnownAccessibleComponent } from '../component-resolver.js';
|
||||
|
||||
const WRAPPER_PATTERNS = /Button|Trigger|MenuItem|Link|Tab|Toggle|Switch|Checkbox|Radio/i;
|
||||
|
||||
export const keyboard: WCAGRule = {
|
||||
id: 'keyboard',
|
||||
criterion: '2.1.1',
|
||||
wcagName: 'Keyboard',
|
||||
severity: 'critical',
|
||||
complianceTag: 'eu-required',
|
||||
check(elements: ExtractedJSXElement[]): A11ySignal[] {
|
||||
const signals: A11ySignal[] = [];
|
||||
for (const el of elements) {
|
||||
if (shouldSkipElement(el)) continue;
|
||||
if (!INTERACTIVE_NON_SEMANTIC.has(el.tag)) continue;
|
||||
if (!el.attributes.has('onClick')) continue;
|
||||
|
||||
// Has keyboard handler or role -> pass
|
||||
if (hasAttr(el, 'onKeyDown', 'onKeyUp', 'onKeyPress') || el.attributes.has('role')) {
|
||||
signals.push(makeSignal(this, el, 'pass'));
|
||||
continue;
|
||||
}
|
||||
|
||||
// tabIndex="0" or positive = in tab order = deliberately interactive
|
||||
// tabIndex="-1" = programmatic focus only (e.g., dialog container) = not interactive
|
||||
const tabIdx = el.attributes.get('tabIndex');
|
||||
if (tabIdx !== undefined && tabIdx !== '-1' && tabIdx !== true) {
|
||||
signals.push(makeSignal(this, el, effectiveStatus(el, 'violation')));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inside a known accessible component wrapper -> needs-review
|
||||
if (WRAPPER_PATTERNS.test(el.enclosingFunction)) {
|
||||
signals.push(makeSignal(this, el, 'needs-review', 'heuristic'));
|
||||
continue;
|
||||
}
|
||||
|
||||
// onClick without tabIndex → can't reach by keyboard, so can't prove it's a barrier
|
||||
// Could be event management (stopPropagation, backdrop dismiss) or mouse-only shortcut
|
||||
signals.push(makeSignal(this, el, 'needs-review'));
|
||||
}
|
||||
return signals;
|
||||
},
|
||||
};
|
||||
|
||||
const FOCUS_TRAP_ROLES = new Set(['dialog', 'menu']);
|
||||
const FOCUS_TRAP_FUNCTION_PATTERNS = /useFocusTrap|createFocusTrap|FocusTrap/i;
|
||||
/** Native form controls that cannot be keyboard traps */
|
||||
const NATIVE_FORM_CONTROLS = new Set(['input', 'select', 'textarea', 'button']);
|
||||
|
||||
export const noKeyboardTrap: WCAGRule = {
|
||||
id: 'noKeyboardTrap',
|
||||
criterion: '2.1.2',
|
||||
wcagName: 'No Keyboard Trap',
|
||||
severity: 'critical',
|
||||
complianceTag: 'eu-required',
|
||||
check(elements: ExtractedJSXElement[]): A11ySignal[] {
|
||||
const signals: A11ySignal[] = [];
|
||||
|
||||
// Build a set of functions that have any onKeyDown/onKeyUp/onKeyPress handler
|
||||
const functionsWithKeyHandler = new Set<string>();
|
||||
for (const el of elements) {
|
||||
if (el.attributes.has('onKeyDown') || el.attributes.has('onKeyUp') || el.attributes.has('onKeyPress')) {
|
||||
functionsWithKeyHandler.add(el.enclosingFunction);
|
||||
}
|
||||
}
|
||||
|
||||
for (const el of elements) {
|
||||
if (shouldSkipElement(el)) continue;
|
||||
// Skip native form controls -- they cannot be keyboard traps
|
||||
if (NATIVE_FORM_CONTROLS.has(el.tag)) continue;
|
||||
// Skip known accessible components (Dialog, Modal, AlertDialog, etc. handle escape internally)
|
||||
if (el.parentTag && isKnownAccessibleComponent(el.parentTag)) continue;
|
||||
// Only flag elements with focus-trapping characteristics
|
||||
const role = el.attributes.get('role');
|
||||
const hasModal = el.attributes.has('modal') || el.attributes.has('aria-modal');
|
||||
const hasTrapRole = typeof role === 'string' && FOCUS_TRAP_ROLES.has(role);
|
||||
const hasTrapFunction = FOCUS_TRAP_FUNCTION_PATTERNS.test(el.enclosingFunction);
|
||||
|
||||
if (!hasTrapRole && !hasModal && !hasTrapFunction) continue;
|
||||
|
||||
// Skip if the enclosing function already has keyboard handling
|
||||
if (functionsWithKeyHandler.has(el.enclosingFunction)) {
|
||||
signals.push(makeSignal(this, el, 'pass', 'heuristic'));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasAttr(el, 'onKeyDown')) {
|
||||
signals.push(makeSignal(this, el, 'pass', 'heuristic'));
|
||||
} else {
|
||||
signals.push(makeSignal(this, el, 'needs-review', 'heuristic'));
|
||||
}
|
||||
}
|
||||
return signals;
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import type { WCAGRule, ExtractedJSXElement, A11ySignal } from '../types.js';
|
||||
import { makeFileSignal, isLayoutFile, isPageFile, shouldSkipElement } from '../shared.js';
|
||||
|
||||
export const bypassBlocks: WCAGRule = {
|
||||
id: 'bypassBlocks',
|
||||
criterion: '2.4.1',
|
||||
wcagName: 'Bypass Blocks',
|
||||
severity: 'serious',
|
||||
complianceTag: 'eu-required',
|
||||
check(elements: ExtractedJSXElement[], filePath: string): A11ySignal[] {
|
||||
if (!isLayoutFile(filePath)) return [];
|
||||
const hasSkipLink = elements.some((el) => {
|
||||
if (el.tag !== 'a') return false;
|
||||
const href = el.attributes.get('href');
|
||||
if (typeof href !== 'string') return false;
|
||||
return href.includes('#main') || href.includes('#content') ||
|
||||
href.includes('#skip') || href.includes('#skip-to-content') ||
|
||||
href.includes('#skip-nav') || href.includes('#maincontent') ||
|
||||
href.includes('#primary');
|
||||
});
|
||||
return [makeFileSignal(this, filePath, hasSkipLink ? 'pass' : 'needs-review')];
|
||||
},
|
||||
};
|
||||
|
||||
export const pageTitled: WCAGRule = {
|
||||
id: 'pageTitled',
|
||||
criterion: '2.4.2',
|
||||
wcagName: 'Page Titled',
|
||||
severity: 'serious',
|
||||
complianceTag: 'eu-recommended',
|
||||
check(elements: ExtractedJSXElement[], filePath: string, source?: string): A11ySignal[] {
|
||||
if (!isPageFile(filePath)) return [];
|
||||
const hasTitle = elements.some(
|
||||
(el) => el.tag === 'title' || el.tag === 'Head',
|
||||
);
|
||||
const hasMetadata = source != null &&
|
||||
/export\s+(const|async\s+function|function)\s+(metadata|generateMetadata)\b/.test(source);
|
||||
if (hasTitle || hasMetadata) {
|
||||
return [makeFileSignal(this, filePath, 'pass', 'likely')];
|
||||
}
|
||||
return [makeFileSignal(this, filePath, 'needs-review', 'likely')];
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import type { WCAGRule, ExtractedJSXElement, A11ySignal } from '../types.js';
|
||||
import { hasAttr, makeSignal, makeFileSignal, isLayoutFile, shouldSkipElement } from '../shared.js';
|
||||
|
||||
const FORM_TAGS = new Set(['input', 'select', 'textarea']);
|
||||
|
||||
export const inputLabel: WCAGRule = {
|
||||
id: 'inputLabel',
|
||||
criterion: '1.3.1',
|
||||
wcagName: 'Info and Relationships',
|
||||
severity: 'critical',
|
||||
complianceTag: 'eu-required',
|
||||
check(elements: ExtractedJSXElement[]): A11ySignal[] {
|
||||
const signals: A11ySignal[] = [];
|
||||
|
||||
// Collect label htmlFor targets (both native <label> and PascalCase <Label>)
|
||||
const labelForIds = new Set<string>();
|
||||
for (const el of elements) {
|
||||
if (el.tag === 'label' || el.tag === 'Label' || el.tag === 'FormLabel') {
|
||||
const htmlFor = el.attributes.get('htmlFor') || el.attributes.get('for');
|
||||
if (typeof htmlFor === 'string') labelForIds.add(htmlFor);
|
||||
}
|
||||
}
|
||||
|
||||
// Also check: any element in the same enclosing function that is a label-like component
|
||||
// suggests the form is using a form library (shadcn, etc.) that handles labeling
|
||||
const functionsWithFormField = new Set<string>();
|
||||
for (const el of elements) {
|
||||
if (el.tag === 'FormField' || el.tag === 'FormItem' || el.tag === 'FormControl') {
|
||||
functionsWithFormField.add(el.enclosingFunction);
|
||||
}
|
||||
}
|
||||
|
||||
for (const el of elements) {
|
||||
if (shouldSkipElement(el)) continue;
|
||||
if (!FORM_TAGS.has(el.tag)) continue;
|
||||
const hasAriaLabel = hasAttr(el, 'aria-label', 'aria-labelledby');
|
||||
const id = el.attributes.get('id');
|
||||
const hasLabelFor = typeof id === 'string' && labelForIds.has(id);
|
||||
const hasWrappingLabel = el.parentTag === 'label';
|
||||
|
||||
// Input is inside a FormField/FormItem/FormControl context — likely labeled by the form library
|
||||
const inFormField = functionsWithFormField.has(el.enclosingFunction);
|
||||
|
||||
if (hasAriaLabel || hasLabelFor || hasWrappingLabel) {
|
||||
signals.push(makeSignal(this, el, 'pass'));
|
||||
} else if (inFormField) {
|
||||
// Form library context — likely labeled but we can't prove it statically
|
||||
signals.push(makeSignal(this, el, 'pass', 'heuristic'));
|
||||
} else if (el.attributes.has('placeholder')) {
|
||||
// Has placeholder but no label — real a11y concern but not critical
|
||||
signals.push(makeSignal(this, el, 'needs-review', 'likely'));
|
||||
} else {
|
||||
signals.push(makeSignal(this, el, 'needs-review'));
|
||||
}
|
||||
}
|
||||
return signals;
|
||||
},
|
||||
};
|
||||
|
||||
export const landmarks: WCAGRule = {
|
||||
id: 'landmarks',
|
||||
criterion: '1.3.1',
|
||||
wcagName: 'Info and Relationships',
|
||||
severity: 'serious',
|
||||
complianceTag: 'eu-recommended',
|
||||
check(elements: ExtractedJSXElement[], filePath: string): A11ySignal[] {
|
||||
if (!isLayoutFile(filePath)) return [];
|
||||
const landmarkTags = new Set(['main', 'nav', 'header']);
|
||||
const hasLandmark = elements.some((el) => landmarkTags.has(el.tag));
|
||||
if (hasLandmark) {
|
||||
return [makeFileSignal(this, filePath, 'pass')];
|
||||
}
|
||||
return [makeFileSignal(this, filePath, 'needs-review', 'likely')];
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import type { WCAGRule, ExtractedJSXElement, A11ySignal } from '../types.js';
|
||||
import { hasAttr, makeSignal, shouldSkipElement, effectiveStatus } from '../shared.js';
|
||||
|
||||
export const imgAlt: WCAGRule = {
|
||||
id: 'imgAlt',
|
||||
criterion: '1.1.1',
|
||||
wcagName: 'Non-text Content',
|
||||
severity: 'critical',
|
||||
complianceTag: 'eu-required',
|
||||
check(elements: ExtractedJSXElement[]): A11ySignal[] {
|
||||
const signals: A11ySignal[] = [];
|
||||
for (const el of elements) {
|
||||
if (shouldSkipElement(el)) continue;
|
||||
if (el.tag !== 'img') continue;
|
||||
if (hasAttr(el, 'alt', 'aria-label', 'aria-labelledby')) {
|
||||
signals.push(makeSignal(this, el, 'pass'));
|
||||
} else {
|
||||
signals.push(makeSignal(this, el, effectiveStatus(el, 'violation')));
|
||||
}
|
||||
}
|
||||
return signals;
|
||||
},
|
||||
};
|
||||
|
||||
export const iconButtonLabel: WCAGRule = {
|
||||
id: 'iconButtonLabel',
|
||||
criterion: '1.1.1',
|
||||
wcagName: 'Non-text Content',
|
||||
severity: 'critical',
|
||||
complianceTag: 'eu-required',
|
||||
check(elements: ExtractedJSXElement[]): A11ySignal[] {
|
||||
const signals: A11ySignal[] = [];
|
||||
for (const el of elements) {
|
||||
if (shouldSkipElement(el)) continue;
|
||||
if (el.tag !== 'button') continue;
|
||||
if (el.hasChildren || el.textContent) continue;
|
||||
if (hasAttr(el, 'aria-label', 'aria-labelledby')) {
|
||||
signals.push(makeSignal(this, el, 'pass'));
|
||||
} else {
|
||||
signals.push(makeSignal(this, el, effectiveStatus(el, 'violation')));
|
||||
}
|
||||
}
|
||||
return signals;
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import type { WCAGRule, ExtractedJSXElement, A11ySignal } from '../types.js';
|
||||
import { hasAttr, makeSignal, INTERACTIVE_NON_SEMANTIC, shouldSkipElement, effectiveStatus } from '../shared.js';
|
||||
|
||||
const WRAPPER_PATTERNS = /Button|Trigger|MenuItem|Link|Tab|Toggle|Switch|Checkbox|Radio/i;
|
||||
|
||||
export const nameRoleValue: WCAGRule = {
|
||||
id: 'nameRoleValue',
|
||||
criterion: '4.1.2',
|
||||
wcagName: 'Name, Role, Value',
|
||||
severity: 'critical',
|
||||
complianceTag: 'eu-required',
|
||||
check(elements: ExtractedJSXElement[]): A11ySignal[] {
|
||||
const signals: A11ySignal[] = [];
|
||||
for (const el of elements) {
|
||||
if (shouldSkipElement(el)) continue;
|
||||
if (!INTERACTIVE_NON_SEMANTIC.has(el.tag)) continue;
|
||||
if (!hasAttr(el, 'onClick', 'onKeyDown')) continue;
|
||||
|
||||
// Has explicit role -> pass
|
||||
if (el.attributes.has('role')) {
|
||||
signals.push(makeSignal(this, el, 'pass'));
|
||||
continue;
|
||||
}
|
||||
|
||||
// tabIndex="0" or positive = in tab order = deliberately interactive, needs role
|
||||
// tabIndex="-1" = programmatic focus only = not interactive
|
||||
const tabIdx = el.attributes.get('tabIndex');
|
||||
if (tabIdx !== undefined && tabIdx !== '-1' && tabIdx !== true) {
|
||||
signals.push(makeSignal(this, el, effectiveStatus(el, 'violation')));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inside a known accessible component wrapper -> needs-review
|
||||
if (WRAPPER_PATTERNS.test(el.enclosingFunction)) {
|
||||
signals.push(makeSignal(this, el, 'needs-review', 'heuristic'));
|
||||
continue;
|
||||
}
|
||||
|
||||
// onClick without tabIndex → element not focusable, can't determine intent
|
||||
signals.push(makeSignal(this, el, 'needs-review'));
|
||||
}
|
||||
return signals;
|
||||
},
|
||||
};
|
||||
|
||||
/** Match status patterns as whole words/segments, not substrings.
|
||||
* e.g. "alert" matches "alert-banner" but NOT "AlertDialog" (which is PascalCase component).
|
||||
* Only match when the keyword is a standalone segment (kebab-case boundary or exact match),
|
||||
* not when it's a substring of a PascalCase component class like "AlertDialogPrimitive". */
|
||||
const STATUS_KEYWORDS = ['toast', 'alert', 'notification', 'loading', 'spinner'];
|
||||
|
||||
/** Check if a class name contains a status keyword as a standalone segment.
|
||||
* Matches: "alert", "alert-banner", "toast-container", "my-alert"
|
||||
* Does NOT match: "AlertDialog", "AlertDialogPrimitive", "ToastPrimitive" */
|
||||
function hasStatusClassName(classNames: string[]): boolean {
|
||||
for (const cls of classNames) {
|
||||
// Skip PascalCase component-like class names (e.g. AlertDialogPrimitive, ToastViewport)
|
||||
if (/^[A-Z]/.test(cls)) continue;
|
||||
const lower = cls.toLowerCase();
|
||||
for (const kw of STATUS_KEYWORDS) {
|
||||
// Match as whole word or kebab-case segment
|
||||
const re = new RegExp(`(?:^|-)${kw}(?:$|-)`);
|
||||
if (re.test(lower)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Roles with implicit live region semantics (no explicit aria-live needed) */
|
||||
const IMPLICIT_LIVE_ROLES = new Set(['alert', 'alertdialog', 'status', 'log']);
|
||||
|
||||
export const statusMessages: WCAGRule = {
|
||||
id: 'statusMessages',
|
||||
criterion: '4.1.3',
|
||||
wcagName: 'Status Messages',
|
||||
severity: 'serious',
|
||||
complianceTag: 'eu-recommended',
|
||||
check(elements: ExtractedJSXElement[]): A11ySignal[] {
|
||||
const signals: A11ySignal[] = [];
|
||||
for (const el of elements) {
|
||||
if (shouldSkipElement(el)) continue;
|
||||
const classNames = el.classNames ?? [];
|
||||
if (!hasStatusClassName(classNames)) continue;
|
||||
const role = el.attributes.get('role');
|
||||
if (
|
||||
el.attributes.has('aria-live') ||
|
||||
(typeof role === 'string' && IMPLICIT_LIVE_ROLES.has(role))
|
||||
) {
|
||||
signals.push(makeSignal(this, el, 'pass'));
|
||||
} else {
|
||||
signals.push(makeSignal(this, el, 'needs-review'));
|
||||
}
|
||||
}
|
||||
return signals;
|
||||
},
|
||||
};
|
||||
89
gitnexus/src/core/ingestion/a11y-rules/shared.ts
Normal file
89
gitnexus/src/core/ingestion/a11y-rules/shared.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* Shared helpers for WCAG a11y rules.
|
||||
*
|
||||
* Extracted to avoid copy-paste duplication across rule files.
|
||||
*/
|
||||
|
||||
import type { WCAGRule, ExtractedJSXElement, A11ySignal, SignalStatus } from './types.js';
|
||||
import { isKnownAccessibleComponent } from './component-resolver.js';
|
||||
|
||||
/** Check if element should be skipped (hidden or known accessible third-party). */
|
||||
export function shouldSkipElement(el: ExtractedJSXElement): boolean {
|
||||
if (el.attributes.get('aria-hidden') === 'true') return true;
|
||||
if (isKnownAccessibleComponent(el.tag)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Check if an element has any of the given attribute names. */
|
||||
export function hasAttr(el: ExtractedJSXElement, ...names: string[]): boolean {
|
||||
return names.some((n) => el.attributes.has(n));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an A11ySignal from a rule and element.
|
||||
* Avoids duplicating the 10-field object literal in every rule.
|
||||
*/
|
||||
export function makeSignal(
|
||||
rule: Pick<WCAGRule, 'id' | 'criterion' | 'severity' | 'complianceTag'>,
|
||||
el: ExtractedJSXElement,
|
||||
status: A11ySignal['status'],
|
||||
confidence: A11ySignal['confidence'] = 'definite',
|
||||
): A11ySignal {
|
||||
return {
|
||||
name: rule.id,
|
||||
criterion: rule.criterion,
|
||||
status,
|
||||
severity: rule.severity,
|
||||
element: el.tag,
|
||||
filePath: el.filePath,
|
||||
startLine: el.lineNumber,
|
||||
confidence,
|
||||
complianceTag: rule.complianceTag,
|
||||
enclosingFunction: el.enclosingFunction,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an A11ySignal for file-level checks (no specific element).
|
||||
*/
|
||||
export function makeFileSignal(
|
||||
rule: Pick<WCAGRule, 'id' | 'criterion' | 'severity' | 'complianceTag'>,
|
||||
filePath: string,
|
||||
status: A11ySignal['status'],
|
||||
confidence: A11ySignal['confidence'] = 'definite',
|
||||
): A11ySignal {
|
||||
return {
|
||||
name: rule.id,
|
||||
criterion: rule.criterion,
|
||||
status,
|
||||
severity: rule.severity,
|
||||
element: 'file',
|
||||
filePath,
|
||||
startLine: 1,
|
||||
confidence,
|
||||
complianceTag: rule.complianceTag,
|
||||
enclosingFunction: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Check if a file path looks like a layout file. */
|
||||
export function isLayoutFile(filePath: string): boolean {
|
||||
const name = filePath.split('/').pop() ?? '';
|
||||
// Match layout.tsx, Layout.tsx, RootLayout.tsx, etc. but NOT IconLayout.svg or LayoutIcon.png
|
||||
return /^[A-Za-z]*[Ll]ayout\.(tsx|jsx|ts|js)$/.test(name);
|
||||
}
|
||||
|
||||
/** Check if a file path looks like a page entry point. */
|
||||
export function isPageFile(filePath: string): boolean {
|
||||
const name = filePath.split('/').pop() ?? '';
|
||||
return /^(page|index)\.(tsx|jsx|ts|js)$/.test(name);
|
||||
}
|
||||
|
||||
/** Elements from component resolution should never produce violations — only needs-review */
|
||||
export function effectiveStatus(el: ExtractedJSXElement, intendedStatus: SignalStatus): SignalStatus {
|
||||
if (el.resolved && intendedStatus === 'violation') return 'needs-review';
|
||||
return intendedStatus;
|
||||
}
|
||||
|
||||
/** Common set of non-semantic interactive elements (div, span). */
|
||||
export const INTERACTIVE_NON_SEMANTIC = new Set(['div', 'span']);
|
||||
39
gitnexus/src/core/ingestion/a11y-rules/types.ts
Normal file
39
gitnexus/src/core/ingestion/a11y-rules/types.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
export type SignalStatus = 'violation' | 'needs-review' | 'pass';
|
||||
export type SignalSeverity = 'critical' | 'serious' | 'moderate' | 'minor';
|
||||
export type SignalConfidence = 'definite' | 'likely' | 'heuristic';
|
||||
export type ComplianceTag = 'eu-required' | 'eu-recommended' | 'wcag-aaa' | 'deaf-specific';
|
||||
|
||||
export interface ExtractedJSXElement {
|
||||
tag: string;
|
||||
filePath: string;
|
||||
lineNumber: number;
|
||||
attributes: Map<string, string | true>;
|
||||
hasChildren: boolean;
|
||||
textContent?: string;
|
||||
enclosingFunction: string;
|
||||
parentTag?: string;
|
||||
classNames?: string[];
|
||||
resolved?: boolean; // true if this element came from component resolution (not directly in source)
|
||||
}
|
||||
|
||||
export interface A11ySignal {
|
||||
name: string;
|
||||
criterion: string;
|
||||
status: SignalStatus;
|
||||
severity: SignalSeverity;
|
||||
element: string;
|
||||
filePath: string;
|
||||
startLine: number;
|
||||
confidence: SignalConfidence;
|
||||
complianceTag: ComplianceTag;
|
||||
enclosingFunction?: string;
|
||||
}
|
||||
|
||||
export interface WCAGRule {
|
||||
id: string;
|
||||
criterion: string;
|
||||
wcagName: string;
|
||||
severity: SignalSeverity;
|
||||
complianceTag: ComplianceTag;
|
||||
check: (elements: ExtractedJSXElement[], filePath: string, source?: string) => A11ySignal[];
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import type { WCAGRule, ExtractedJSXElement, A11ySignal } from '../types.js';
|
||||
import { makeSignal, makeFileSignal, shouldSkipElement } from '../shared.js';
|
||||
|
||||
function isRootLayout(filePath: string): boolean {
|
||||
return /layout\.(tsx|jsx|ts|js)$/.test(filePath) && /app[/\\]layout/.test(filePath);
|
||||
}
|
||||
|
||||
export const languagePage: WCAGRule = {
|
||||
id: 'languagePage',
|
||||
criterion: '3.1.1',
|
||||
wcagName: 'Language of Page',
|
||||
severity: 'serious',
|
||||
complianceTag: 'eu-required',
|
||||
check(elements: ExtractedJSXElement[], filePath: string): A11ySignal[] {
|
||||
if (!isRootLayout(filePath)) return [];
|
||||
const htmlEl = elements.find((el) => el.tag === 'html');
|
||||
if (!htmlEl) {
|
||||
return [makeFileSignal(this, filePath, 'needs-review', 'likely')];
|
||||
}
|
||||
const hasLang = htmlEl.attributes.has('lang');
|
||||
return [makeSignal(this, htmlEl, hasLang ? 'pass' : 'violation')];
|
||||
},
|
||||
};
|
||||
|
|
@ -47,6 +47,8 @@ import type {
|
|||
ExtractedORMQuery,
|
||||
} from './workers/parse-worker.js';
|
||||
import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js';
|
||||
import { extractJSXElements } from './a11y-rules/jsx-extractor.js';
|
||||
import type { ExtractedJSXElement } from './a11y-rules/types.js';
|
||||
|
||||
export type FileProgressCallback = (current: number, total: number, filePath: string) => void;
|
||||
|
||||
|
|
@ -62,6 +64,8 @@ export interface WorkerExtractedData {
|
|||
ormQueries: ExtractedORMQuery[];
|
||||
constructorBindings: FileConstructorBindings[];
|
||||
fileScopeBindings: FileScopeBindings[];
|
||||
typeEnvBindings: FileTypeEnvBindings[];
|
||||
jsxElements: import('./a11y-rules/types.js').ExtractedJSXElement[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -96,6 +100,8 @@ const processParsingWithWorkers = async (
|
|||
ormQueries: [],
|
||||
constructorBindings: [],
|
||||
fileScopeBindings: [],
|
||||
typeEnvBindings: [],
|
||||
jsxElements: [],
|
||||
};
|
||||
|
||||
const total = files.length;
|
||||
|
|
@ -120,6 +126,8 @@ const processParsingWithWorkers = async (
|
|||
const allORMQueries: ExtractedORMQuery[] = [];
|
||||
const allConstructorBindings: FileConstructorBindings[] = [];
|
||||
const fileScopeBindingsByFile: FileScopeBindings[] = [];
|
||||
const allTypeEnvBindings: FileTypeEnvBindings[] = [];
|
||||
const allJSXElements: import('./a11y-rules/types.js').ExtractedJSXElement[] = [];
|
||||
for (const result of chunkResults) {
|
||||
for (const node of result.nodes) {
|
||||
graph.addNode({
|
||||
|
|
@ -157,6 +165,8 @@ const processParsingWithWorkers = async (
|
|||
for (const item of result.constructorBindings) allConstructorBindings.push(item);
|
||||
if (result.fileScopeBindings)
|
||||
for (const item of result.fileScopeBindings) fileScopeBindingsByFile.push(item);
|
||||
if (result.jsxElements) allJSXElements.push(...result.jsxElements);
|
||||
if (result.typeEnvBindings) allTypeEnvBindings.push(...result.typeEnvBindings);
|
||||
}
|
||||
|
||||
// Merge and log skipped languages from workers
|
||||
|
|
@ -187,6 +197,8 @@ const processParsingWithWorkers = async (
|
|||
ormQueries: allORMQueries,
|
||||
constructorBindings: allConstructorBindings,
|
||||
fileScopeBindings: fileScopeBindingsByFile,
|
||||
typeEnvBindings: allTypeEnvBindings,
|
||||
jsxElements: allJSXElements,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -296,6 +308,8 @@ function seqGetFieldInfo(
|
|||
return cached;
|
||||
}
|
||||
|
||||
const JSX_EXTENSIONS = new Set(['.tsx', '.jsx']);
|
||||
|
||||
const processParsingSequential = async (
|
||||
graph: KnowledgeGraph,
|
||||
files: { path: string; content: string }[],
|
||||
|
|
@ -663,6 +677,7 @@ const processParsingSequential = async (
|
|||
});
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
if (skippedLanguages.size > 0) {
|
||||
|
|
@ -703,7 +718,7 @@ export const processParsing = async (
|
|||
}
|
||||
}
|
||||
|
||||
// Fallback: sequential parsing (no pre-extracted data)
|
||||
// Fallback: sequential parsing (processes imports/calls/heritage inline into graph)
|
||||
await processParsingSequential(graph, files, symbolTable, astCache, onFileProgress);
|
||||
return null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -77,6 +77,9 @@ import {
|
|||
buildCollisionGroups,
|
||||
} from '../utils/method-props.js';
|
||||
import type { LanguageProvider } from '../language-provider.js';
|
||||
import type { ExtractedJSXElement } from '../a11y-rules/types.js';
|
||||
import { extractJSXElements } from '../a11y-rules/jsx-extractor.js';
|
||||
import { CLASS_CONTAINER_TYPES } from '../utils/ast-helpers.js';
|
||||
|
||||
// ============================================================================
|
||||
// Types for serializable results
|
||||
|
|
@ -269,6 +272,10 @@ export interface ParseWorkerResult {
|
|||
constructorBindings: FileConstructorBindings[];
|
||||
/** All-scope type bindings from TypeEnv for BindingAccumulator (includes function-local). */
|
||||
fileScopeBindings: FileScopeBindings[];
|
||||
/** File-scope type bindings from TypeEnv fixpoint for exported symbol collection. */
|
||||
typeEnvBindings: FileTypeEnvBindings[];
|
||||
/** Extracted JSX elements for a11y analysis */
|
||||
jsxElements: ExtractedJSXElement[];
|
||||
skippedLanguages: Record<string, number>;
|
||||
fileCount: number;
|
||||
}
|
||||
|
|
@ -711,6 +718,8 @@ const processBatch = (
|
|||
ormQueries: [],
|
||||
constructorBindings: [],
|
||||
fileScopeBindings: [],
|
||||
typeEnvBindings: [],
|
||||
jsxElements: [],
|
||||
skippedLanguages: {},
|
||||
fileCount: 0,
|
||||
};
|
||||
|
|
@ -2259,6 +2268,14 @@ const processFileGroup = (
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Extract JSX elements for a11y analysis (tsx/jsx files only)
|
||||
if (file.path.endsWith('.tsx') || file.path.endsWith('.jsx')) {
|
||||
const jsxElements = extractJSXElements(file.content, file.path);
|
||||
if (jsxElements.length > 0) {
|
||||
result.jsxElements.push(...jsxElements);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -2282,6 +2299,8 @@ let accumulated: ParseWorkerResult = {
|
|||
ormQueries: [],
|
||||
constructorBindings: [],
|
||||
fileScopeBindings: [],
|
||||
typeEnvBindings: [],
|
||||
jsxElements: [],
|
||||
skippedLanguages: {},
|
||||
fileCount: 0,
|
||||
};
|
||||
|
|
@ -2309,6 +2328,8 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => {
|
|||
appendAll(target.ormQueries, src.ormQueries);
|
||||
appendAll(target.constructorBindings, src.constructorBindings);
|
||||
appendAll(target.fileScopeBindings, src.fileScopeBindings);
|
||||
appendAll(target.typeEnvBindings, src.typeEnvBindings);
|
||||
appendAll(target.jsxElements, src.jsxElements);
|
||||
for (const [lang, count] of Object.entries(src.skippedLanguages)) {
|
||||
target.skippedLanguages[lang] = (target.skippedLanguages[lang] || 0) + count;
|
||||
}
|
||||
|
|
@ -2360,6 +2381,8 @@ parentPort!.on('message', (msg: WorkerIncomingMessage) => {
|
|||
ormQueries: [],
|
||||
constructorBindings: [],
|
||||
fileScopeBindings: [],
|
||||
typeEnvBindings: [],
|
||||
jsxElements: [],
|
||||
skippedLanguages: {},
|
||||
fileCount: 0,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -278,6 +278,9 @@ export const streamAllCSVsToDisk = async (
|
|||
'id,name,filePath,description',
|
||||
);
|
||||
|
||||
// A11ySignal nodes for WCAG accessibility signals
|
||||
const a11ySignalWriter = new BufferedCSVWriter(path.join(csvDir, 'a11ysignal.csv'), 'id,name,filePath,criterion,signalStatus,severity,element,startLine,complianceTag,confidence');
|
||||
|
||||
// Multi-language node types share the same CSV shape (no isExported column)
|
||||
const multiLangHeader = 'id,name,filePath,startLine,endLine,content,description';
|
||||
const MULTI_LANG_TYPES = [
|
||||
|
|
@ -447,6 +450,22 @@ export const streamAllCSVsToDisk = async (
|
|||
].join(','),
|
||||
);
|
||||
break;
|
||||
case 'A11ySignal':
|
||||
await a11ySignalWriter.addRow(
|
||||
[
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''),
|
||||
escapeCSVField(node.properties.filePath || ''),
|
||||
escapeCSVField((node.properties as any).criterion || ''),
|
||||
escapeCSVField((node.properties as any).signalStatus || ''),
|
||||
escapeCSVField((node.properties as any).severity || ''),
|
||||
escapeCSVField((node.properties as any).element || ''),
|
||||
escapeCSVNumber(node.properties.startLine, -1),
|
||||
escapeCSVField((node.properties as any).complianceTag || ''),
|
||||
escapeCSVField((node.properties as any).confidence || ''),
|
||||
].join(','),
|
||||
);
|
||||
break;
|
||||
default: {
|
||||
// Code element nodes (Function, Class, Interface, CodeElement)
|
||||
const writer = codeWriterMap[node.label];
|
||||
|
|
@ -501,6 +520,7 @@ export const streamAllCSVsToDisk = async (
|
|||
sectionWriter,
|
||||
routeWriter,
|
||||
toolWriter,
|
||||
a11ySignalWriter,
|
||||
...multiLangWriters.values(),
|
||||
];
|
||||
await Promise.all(allWriters.map((w) => w.finish()));
|
||||
|
|
@ -537,6 +557,7 @@ export const streamAllCSVsToDisk = async (
|
|||
['Section' as NodeTableName, sectionWriter],
|
||||
['Route' as NodeTableName, routeWriter],
|
||||
['Tool' as NodeTableName, toolWriter],
|
||||
['A11ySignal' as NodeTableName, a11ySignalWriter],
|
||||
...Array.from(multiLangWriters.entries()).map(
|
||||
([name, w]) => [name as NodeTableName, w] as [NodeTableName, BufferedCSVWriter],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -196,6 +196,22 @@ CREATE NODE TABLE Tool (
|
|||
PRIMARY KEY (id)
|
||||
)`;
|
||||
|
||||
// WCAG accessibility signals
|
||||
export const A11Y_SIGNAL_SCHEMA = `
|
||||
CREATE NODE TABLE A11ySignal (
|
||||
id STRING,
|
||||
name STRING,
|
||||
filePath STRING,
|
||||
criterion STRING,
|
||||
signalStatus STRING,
|
||||
severity STRING,
|
||||
element STRING,
|
||||
startLine INT64,
|
||||
complianceTag STRING,
|
||||
confidence STRING,
|
||||
PRIMARY KEY (id)
|
||||
)`;
|
||||
|
||||
// Markdown heading sections
|
||||
export const SECTION_SCHEMA = `
|
||||
CREATE NODE TABLE Section (
|
||||
|
|
@ -420,6 +436,9 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
|
|||
FROM CodeElement TO Process,
|
||||
FROM Route TO Process,
|
||||
FROM Tool TO Process,
|
||||
FROM Function TO A11ySignal,
|
||||
FROM Method TO A11ySignal,
|
||||
FROM File TO A11ySignal,
|
||||
type STRING,
|
||||
confidence DOUBLE,
|
||||
reason STRING,
|
||||
|
|
@ -510,6 +529,8 @@ export const NODE_SCHEMA_QUERIES = [
|
|||
ROUTE_SCHEMA,
|
||||
// MCP tools
|
||||
TOOL_SCHEMA,
|
||||
// WCAG accessibility signals
|
||||
A11Y_SIGNAL_SCHEMA,
|
||||
];
|
||||
|
||||
export const REL_SCHEMA_QUERIES = [RELATION_SCHEMA];
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ export const VALID_NODE_LABELS = new Set([
|
|||
'Module',
|
||||
'Route',
|
||||
'Tool',
|
||||
'A11ySignal',
|
||||
]);
|
||||
|
||||
/** Valid relation types for impact analysis filtering */
|
||||
|
|
@ -108,6 +109,7 @@ export const VALID_RELATION_TYPES = new Set([
|
|||
'HANDLES_TOOL',
|
||||
'ENTRY_POINT_OF',
|
||||
'WRAPS',
|
||||
'HAS_A11Y_SIGNAL',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
@ -497,6 +499,8 @@ export class LocalBackend {
|
|||
return this.toolMap(repo, params);
|
||||
case 'api_impact':
|
||||
return this.apiImpact(repo, params);
|
||||
case 'wcag_audit':
|
||||
return this.wcagAudit(repo, params);
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${method}`);
|
||||
}
|
||||
|
|
@ -3219,6 +3223,119 @@ export class LocalBackend {
|
|||
};
|
||||
}
|
||||
|
||||
private async wcagAudit(repo: RepoHandle, params: {
|
||||
route?: string; criterion?: string; status?: string; component?: string;
|
||||
}): Promise<any> {
|
||||
// Note: route and component params are accepted for future use (route→file
|
||||
// join, component→enclosingFunction filter) but not yet wired.
|
||||
await this.ensureInitialized(repo.id);
|
||||
|
||||
// Build filter — all params are parameterized (no string interpolation)
|
||||
const conditions: string[] = [];
|
||||
const queryParams: Record<string, any> = {};
|
||||
if (params.criterion) { conditions.push('n.criterion = $criterion'); queryParams.criterion = params.criterion; }
|
||||
if (params.status) { conditions.push('n.signalStatus = $status'); queryParams.status = params.status; }
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// Query all A11ySignal nodes
|
||||
const signals = await executeParameterized(repo.id, `
|
||||
MATCH (n:A11ySignal) ${where}
|
||||
RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.criterion AS criterion,
|
||||
n.signalStatus AS status, n.severity AS severity, n.element AS element,
|
||||
n.startLine AS startLine, n.complianceTag AS complianceTag, n.confidence AS confidence
|
||||
ORDER BY n.severity, n.criterion
|
||||
`, queryParams);
|
||||
|
||||
if (!signals || signals.length === 0) {
|
||||
return { score: { percent: 100, met: 0, total: 0 }, findings: [], routes: [], total: 0 };
|
||||
}
|
||||
|
||||
// Group by criterion for scoring + build fix-pattern index in one pass
|
||||
const byCriterion = new Map<string, { violations: number; needsReview: number; passes: number }>();
|
||||
const firstPassByCriterion = new Map<string, string>(); // criterion → "filePath:line"
|
||||
let violationCount = 0;
|
||||
let needsReviewCount = 0;
|
||||
|
||||
for (const s of signals) {
|
||||
const key = s.criterion;
|
||||
if (!byCriterion.has(key)) byCriterion.set(key, { violations: 0, needsReview: 0, passes: 0 });
|
||||
const entry = byCriterion.get(key)!;
|
||||
if (s.status === 'violation') {
|
||||
entry.violations++;
|
||||
violationCount++;
|
||||
} else if (s.status === 'needs-review') {
|
||||
entry.needsReview++;
|
||||
needsReviewCount++;
|
||||
} else {
|
||||
entry.passes++;
|
||||
// Record first passing signal per criterion for fix-pattern suggestions
|
||||
if (!firstPassByCriterion.has(key)) {
|
||||
firstPassByCriterion.set(key, `${s.filePath}:${s.startLine}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute score — only violations count against compliance (not needs-review)
|
||||
const totalCriteria = byCriterion.size;
|
||||
const metCriteria = [...byCriterion.values()].filter(v => v.violations === 0).length;
|
||||
const percent = totalCriteria > 0 ? Math.round((metCriteria / totalCriteria) * 100) : 100;
|
||||
|
||||
// Build review prompts for needs-review items
|
||||
const REVIEW_PROMPTS: Record<string, (s: any) => string> = {
|
||||
'input-label': (s) => `Check if <${s.element}> at ${s.filePath}:${s.startLine} has label association via htmlFor, aria-label, or parent <label> wrapping`,
|
||||
'keyboard': (s) => `Check if ${s.element} at ${s.filePath}:${s.startLine} has keyboard handling via parent component or event delegation`,
|
||||
'noKeyboardTrap': (s) => `Check if ${s.element} at ${s.filePath}:${s.startLine} has Escape key handling for focus trap dismissal`,
|
||||
'bypassBlocks': (s) => `Check if layout at ${s.filePath} has a skip-to-content link (may be in a parent layout or wrapper component)`,
|
||||
'pageTitled': (s) => `Check if page at ${s.filePath} has a title via metadata export, Head component, or parent layout`,
|
||||
'statusMessages': (s) => `Check if ${s.element} at ${s.filePath}:${s.startLine} uses aria-live or a role with implicit live semantics`,
|
||||
'videoCaptions': (s) => `Check if <video> at ${s.filePath}:${s.startLine} has captions via <track> element or external caption service`,
|
||||
'landmarks': (s) => `Check if layout at ${s.filePath} has semantic landmark elements (main, nav, header)`,
|
||||
'img-alt': (s) => `Check if <img> at ${s.filePath}:${s.startLine} has alt text (may be provided by resolved component)`,
|
||||
'nameRoleValue': (s) => `Check if ${s.element} at ${s.filePath}:${s.startLine} has an explicit ARIA role or is inside a component that provides one`,
|
||||
'iconButtonLabel': (s) => `Check if <button> at ${s.filePath}:${s.startLine} has accessible name (may be provided by resolved component)`,
|
||||
'languagePage': (s) => `Check if root layout at ${s.filePath} has an html element with lang attribute`,
|
||||
};
|
||||
|
||||
// Format findings (non-pass signals only), attach fix patterns and review prompts
|
||||
// Sort: definite confidence first, heuristic last
|
||||
const CONFIDENCE_ORDER: Record<string, number> = { definite: 0, likely: 1, heuristic: 2 };
|
||||
const allFindings = signals.filter((s: any) => s.status !== 'pass').map((s: any) => {
|
||||
const finding: Record<string, any> = {
|
||||
criterion: s.criterion,
|
||||
name: s.name,
|
||||
status: s.status,
|
||||
severity: s.severity,
|
||||
element: s.element,
|
||||
file: s.filePath,
|
||||
line: s.startLine,
|
||||
complianceTag: s.complianceTag,
|
||||
confidence: s.confidence,
|
||||
};
|
||||
const fixPattern = firstPassByCriterion.get(s.criterion);
|
||||
if (fixPattern) finding.fixPattern = fixPattern;
|
||||
// Add review prompt for needs-review items
|
||||
if (s.status === 'needs-review') {
|
||||
const promptFn = REVIEW_PROMPTS[s.name];
|
||||
if (promptFn) finding.reviewPrompt = promptFn(s);
|
||||
}
|
||||
return finding;
|
||||
}).sort((a: any, b: any) => (CONFIDENCE_ORDER[a.confidence] ?? 1) - (CONFIDENCE_ORDER[b.confidence] ?? 1));
|
||||
|
||||
// Separate into violations and needs-review
|
||||
const violations = allFindings.filter((f: any) => f.status === 'violation');
|
||||
const needsReview = allFindings.filter((f: any) => f.status === 'needs-review');
|
||||
|
||||
return {
|
||||
score: { percent, met: metCriteria, total: totalCriteria },
|
||||
violations,
|
||||
needsReview,
|
||||
findings: allFindings,
|
||||
total: allFindings.length,
|
||||
violationCount,
|
||||
needsReviewCount,
|
||||
};
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
await closeLbug(); // close all connections
|
||||
// Note: we intentionally do NOT call disposeEmbedder() here.
|
||||
|
|
|
|||
|
|
@ -72,6 +72,9 @@ function getNextStepHint(toolName: string, args: Record<string, any> | undefined
|
|||
case 'overview':
|
||||
return `\n\n---\n**Next:** To drill into an area, READ gitnexus://repo/${repoPath}/cluster/{name}. To see execution flows, READ gitnexus://repo/${repoPath}/processes.`;
|
||||
|
||||
case 'wcag_audit':
|
||||
return '\n\n♿ Next: Use `context` on any violating component to see its full usage, or `impact` to check how many pages are affected.';
|
||||
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -377,6 +377,27 @@ Returns: single route object when one match, or { routes: [...], total: N } for
|
|||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'wcag_audit',
|
||||
description: `WCAG 2.2 AA accessibility audit for indexed repositories.
|
||||
|
||||
WHEN TO USE: When you need to check accessibility compliance, find WCAG violations, or get a compliance score for a page/route/component. Each finding includes EU compliance context tags.
|
||||
|
||||
Returns: compliance score, violation list with fix patterns from the codebase, and per-route compliance matrix.
|
||||
|
||||
AFTER THIS: Use \`context\` on violating components to see usage, or \`impact\` to check how many pages are affected.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
route: { type: 'string', description: 'Filter by route path (reserved for future use)' },
|
||||
criterion: { type: 'string', description: 'Filter by WCAG criterion (e.g., "1.1.1")' },
|
||||
status: { type: 'string', description: 'Filter: "violation" or "warning"' },
|
||||
component: { type: 'string', description: 'Filter by component name (reserved for future use)' },
|
||||
repo: { type: 'string', description: 'Repository name or path.' },
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'group_list',
|
||||
description: `List all configured repository groups, or return details for one group (repos, manifest links).
|
||||
|
|
|
|||
73
gitnexus/test/fixtures/a11y-seed.ts
vendored
Normal file
73
gitnexus/test/fixtures/a11y-seed.ts
vendored
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import type { FTSIndexDef } from '../helpers/test-indexed-db.js';
|
||||
|
||||
/**
|
||||
* Seed data for wcag_audit E2E tests.
|
||||
*
|
||||
* Simulates what the pipeline would produce for a React project with:
|
||||
* - Components with a11y violations (missing alt, missing labels)
|
||||
* - Components that pass a11y checks (proper alt text, proper labels)
|
||||
* - Mixed criteria across files
|
||||
* - Route nodes for per-route compliance testing
|
||||
*/
|
||||
export const A11Y_SEED_DATA = [
|
||||
// ─── Files ─────────────────────────────────────────────────────────
|
||||
`CREATE (f:File {id: 'file:components/Hero.tsx', name: 'Hero.tsx', filePath: 'components/Hero.tsx', content: '<img src="hero.png" />'})`,
|
||||
`CREATE (f:File {id: 'file:components/LoginForm.tsx', name: 'LoginForm.tsx', filePath: 'components/LoginForm.tsx', content: '<input type="text" />'})`,
|
||||
`CREATE (f:File {id: 'file:components/AccessibleCard.tsx', name: 'AccessibleCard.tsx', filePath: 'components/AccessibleCard.tsx', content: '<img alt="Card image" />'})`,
|
||||
`CREATE (f:File {id: 'file:pages/home.tsx', name: 'home.tsx', filePath: 'pages/home.tsx', content: '<main>...</main>'})`,
|
||||
|
||||
// ─── Functions (components) ─────────────────────────────────────────
|
||||
`CREATE (fn:Function {id: 'func:Hero', name: 'Hero', filePath: 'components/Hero.tsx', startLine: 1, endLine: 10, isExported: true, content: 'export function Hero()', description: 'Hero banner component'})`,
|
||||
`CREATE (fn:Function {id: 'func:LoginForm', name: 'LoginForm', filePath: 'components/LoginForm.tsx', startLine: 1, endLine: 15, isExported: true, content: 'export function LoginForm()', description: 'Login form component'})`,
|
||||
`CREATE (fn:Function {id: 'func:AccessibleCard', name: 'AccessibleCard', filePath: 'components/AccessibleCard.tsx', startLine: 1, endLine: 8, isExported: true, content: 'export function AccessibleCard()', description: 'Accessible card component'})`,
|
||||
`CREATE (fn:Function {id: 'func:HomePage', name: 'HomePage', filePath: 'pages/home.tsx', startLine: 1, endLine: 20, isExported: true, content: 'export function HomePage()', description: 'Home page component'})`,
|
||||
|
||||
// ─── A11ySignal nodes ──────────────────────────────────────────────
|
||||
// Violation: img without alt (criterion 1.1.1)
|
||||
`CREATE (s:A11ySignal {id: 'A11ySignal:components/Hero.tsx:3:img-alt', name: 'img-alt', filePath: 'components/Hero.tsx', startLine: 3, criterion: '1.1.1', signalStatus: 'violation', severity: 'critical', element: 'img', complianceTag: 'eu-required', confidence: 'definite'})`,
|
||||
|
||||
// Needs-review: input without label (criterion 1.3.1) — can't statically prove label absence
|
||||
`CREATE (s:A11ySignal {id: 'A11ySignal:components/LoginForm.tsx:5:input-label', name: 'input-label', filePath: 'components/LoginForm.tsx', startLine: 5, criterion: '1.3.1', signalStatus: 'needs-review', severity: 'serious', element: 'input', complianceTag: 'eu-required', confidence: 'definite'})`,
|
||||
|
||||
// Needs-review: button without explicit keyboard handler (criterion 2.1.1)
|
||||
`CREATE (s:A11ySignal {id: 'A11ySignal:components/LoginForm.tsx:10:keyboard', name: 'keyboard', filePath: 'components/LoginForm.tsx', startLine: 10, criterion: '2.1.1', signalStatus: 'needs-review', severity: 'moderate', element: 'div[onClick]', complianceTag: 'eu-required', confidence: 'heuristic'})`,
|
||||
|
||||
// Pass: img with proper alt (criterion 1.1.1)
|
||||
`CREATE (s:A11ySignal {id: 'A11ySignal:components/AccessibleCard.tsx:4:img-alt', name: 'img-alt', filePath: 'components/AccessibleCard.tsx', startLine: 4, criterion: '1.1.1', signalStatus: 'pass', severity: 'critical', element: 'img', complianceTag: 'eu-required', confidence: 'definite'})`,
|
||||
|
||||
// Pass: proper landmarks (criterion 1.3.1)
|
||||
`CREATE (s:A11ySignal {id: 'A11ySignal:pages/home.tsx:2:landmarks', name: 'landmarks', filePath: 'pages/home.tsx', startLine: 2, criterion: '1.3.1', signalStatus: 'pass', severity: 'serious', element: 'main', complianceTag: 'eu-required', confidence: 'definite'})`,
|
||||
|
||||
// Needs-review: missing page title (criterion 2.4.2) — can't statically prove absence
|
||||
`CREATE (s:A11ySignal {id: 'A11ySignal:pages/home.tsx:1:page-titled', name: 'page-titled', filePath: 'pages/home.tsx', startLine: 1, criterion: '2.4.2', signalStatus: 'needs-review', severity: 'serious', element: 'html', complianceTag: 'eu-required', confidence: 'definite'})`,
|
||||
|
||||
// ─── HAS_A11Y_SIGNAL edges ─────────────────────────────────────────
|
||||
`MATCH (a:Function), (s:A11ySignal) WHERE a.id = 'func:Hero' AND s.id = 'A11ySignal:components/Hero.tsx:3:img-alt'
|
||||
CREATE (a)-[:CodeRelation {type: 'HAS_A11Y_SIGNAL', confidence: 1.0, reason: 'wcag-1.1.1', step: 0}]->(s)`,
|
||||
|
||||
`MATCH (a:Function), (s:A11ySignal) WHERE a.id = 'func:LoginForm' AND s.id = 'A11ySignal:components/LoginForm.tsx:5:input-label'
|
||||
CREATE (a)-[:CodeRelation {type: 'HAS_A11Y_SIGNAL', confidence: 1.0, reason: 'wcag-1.3.1', step: 0}]->(s)`,
|
||||
|
||||
`MATCH (a:Function), (s:A11ySignal) WHERE a.id = 'func:LoginForm' AND s.id = 'A11ySignal:components/LoginForm.tsx:10:keyboard'
|
||||
CREATE (a)-[:CodeRelation {type: 'HAS_A11Y_SIGNAL', confidence: 0.85, reason: 'wcag-2.1.1', step: 0}]->(s)`,
|
||||
|
||||
`MATCH (a:Function), (s:A11ySignal) WHERE a.id = 'func:AccessibleCard' AND s.id = 'A11ySignal:components/AccessibleCard.tsx:4:img-alt'
|
||||
CREATE (a)-[:CodeRelation {type: 'HAS_A11Y_SIGNAL', confidence: 1.0, reason: 'wcag-1.1.1', step: 0}]->(s)`,
|
||||
|
||||
`MATCH (a:Function), (s:A11ySignal) WHERE a.id = 'func:HomePage' AND s.id = 'A11ySignal:pages/home.tsx:2:landmarks'
|
||||
CREATE (a)-[:CodeRelation {type: 'HAS_A11Y_SIGNAL', confidence: 1.0, reason: 'wcag-1.3.1', step: 0}]->(s)`,
|
||||
|
||||
`MATCH (a:Function), (s:A11ySignal) WHERE a.id = 'func:HomePage' AND s.id = 'A11ySignal:pages/home.tsx:1:page-titled'
|
||||
CREATE (a)-[:CodeRelation {type: 'HAS_A11Y_SIGNAL', confidence: 1.0, reason: 'wcag-2.4.2', step: 0}]->(s)`,
|
||||
|
||||
// ─── Route nodes + HANDLES_ROUTE edges (for per-route testing) ──────
|
||||
`CREATE (r:Route {id: 'Route:/', name: '/', filePath: 'pages/home.tsx', responseKeys: [], errorKeys: [], middleware: []})`,
|
||||
|
||||
`MATCH (fn:Function), (r:Route) WHERE fn.id = 'func:HomePage' AND r.id = 'Route:/'
|
||||
CREATE (fn)-[:CodeRelation {type: 'HANDLES_ROUTE', confidence: 1.0, reason: 'nextjs-filesystem-route', step: 0}]->(r)`,
|
||||
];
|
||||
|
||||
export const A11Y_FTS_INDEXES: FTSIndexDef[] = [
|
||||
{ table: 'Function', indexName: 'function_fts', columns: ['name', 'content', 'description'] },
|
||||
{ table: 'File', indexName: 'file_fts', columns: ['name', 'content'] },
|
||||
];
|
||||
156
gitnexus/test/integration/wcag-audit-e2e.test.ts
Normal file
156
gitnexus/test/integration/wcag-audit-e2e.test.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* E2E Integration Tests: WCAG Audit Tool
|
||||
*
|
||||
* Tests the full stack: LadybugDB seed -> MCP tool call -> result verification.
|
||||
* Covers compliance scoring, criterion filtering, status filtering,
|
||||
* fix patterns, compliance tags, and 100% score when no violations.
|
||||
*
|
||||
* Uses hand-crafted Cypher seed data that represents what the pipeline
|
||||
* would produce for a React project with a11y signals.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
||||
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
|
||||
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
|
||||
import { A11Y_SEED_DATA, A11Y_FTS_INDEXES } from '../fixtures/a11y-seed.js';
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
listRegisteredRepos: vi.fn().mockResolvedValue([]),
|
||||
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
|
||||
}));
|
||||
|
||||
withTestLbugDB('wcag-audit-e2e', (handle) => {
|
||||
let backend: LocalBackend;
|
||||
|
||||
beforeAll(async () => {
|
||||
const ext = handle as typeof handle & { _backend?: LocalBackend };
|
||||
if (!ext._backend) {
|
||||
throw new Error('LocalBackend not initialized — afterSetup did not attach _backend to handle');
|
||||
}
|
||||
backend = ext._backend;
|
||||
});
|
||||
|
||||
// ─── Test 1: Returns compliance score ──────────────────────────────
|
||||
|
||||
describe('compliance score', () => {
|
||||
it('returns a compliance score with percent, met, and total', async () => {
|
||||
const result = await backend.callTool('wcag_audit', {});
|
||||
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.score).toBeDefined();
|
||||
expect(typeof result.score.percent).toBe('number');
|
||||
expect(typeof result.score.met).toBe('number');
|
||||
expect(typeof result.score.total).toBe('number');
|
||||
// We have 4 criteria: 1.1.1 (has violation+pass), 1.3.1 (needs-review+pass), 2.1.1 (needs-review only), 2.4.2 (needs-review only)
|
||||
// Criteria with 0 violations: 1.3.1, 2.1.1, 2.4.2 (only needs-review) => met>=3, total depends on filter
|
||||
expect(result.score.total).toBeGreaterThanOrEqual(3);
|
||||
expect(result.score.percent).toBeLessThan(100); // some violations exist
|
||||
});
|
||||
|
||||
it('returns correct violation and needsReview counts', async () => {
|
||||
const result = await backend.callTool('wcag_audit', {});
|
||||
|
||||
// Only img-alt remains a true violation; input-label, keyboard, page-titled are needs-review
|
||||
expect(result.violationCount).toBeGreaterThanOrEqual(1); // img-alt
|
||||
expect(result.needsReviewCount).toBeGreaterThanOrEqual(3); // input-label, keyboard, page-titled
|
||||
expect(result.total).toBe(result.violationCount + result.needsReviewCount);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 2: Filters by criterion ──────────────────────────────────
|
||||
|
||||
describe('criterion filtering', () => {
|
||||
it('filters findings to a specific WCAG criterion', async () => {
|
||||
const result = await backend.callTool('wcag_audit', { criterion: '1.1.1' });
|
||||
|
||||
expect(result).not.toHaveProperty('error');
|
||||
// Should only include 1.1.1 signals (violation from Hero + pass from AccessibleCard)
|
||||
// Findings exclude passes, so only the violation
|
||||
for (const finding of result.findings) {
|
||||
expect(finding.criterion).toBe('1.1.1');
|
||||
}
|
||||
expect(result.findings.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 3: Filters by status (violations only) ──────────────────
|
||||
|
||||
describe('status filtering', () => {
|
||||
it('returns only violations when status=violation', async () => {
|
||||
const result = await backend.callTool('wcag_audit', { status: 'violation' });
|
||||
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.findings.length).toBeGreaterThanOrEqual(1);
|
||||
for (const finding of result.findings) {
|
||||
expect(finding.status).toBe('violation');
|
||||
}
|
||||
expect(result.needsReviewCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 4: Returns fix patterns for violations ───────────────────
|
||||
|
||||
describe('fix patterns', () => {
|
||||
it('suggests fix patterns from passing components for the same criterion', async () => {
|
||||
const result = await backend.callTool('wcag_audit', {});
|
||||
|
||||
// Criterion 1.1.1 has both a violation (Hero) and a pass (AccessibleCard)
|
||||
// So the violation finding should have a fixPattern
|
||||
const imgAltViolation = result.findings.find(
|
||||
(f: any) => f.criterion === '1.1.1' && f.status === 'violation'
|
||||
);
|
||||
expect(imgAltViolation).toBeDefined();
|
||||
expect(imgAltViolation!.fixPattern).toBeDefined();
|
||||
expect(imgAltViolation!.fixPattern).toContain('AccessibleCard.tsx');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 5: Compliance tags present on all findings ───────────────
|
||||
|
||||
describe('compliance tags', () => {
|
||||
it('includes complianceTag on every finding', async () => {
|
||||
const result = await backend.callTool('wcag_audit', {});
|
||||
|
||||
expect(result.findings.length).toBeGreaterThanOrEqual(1);
|
||||
for (const finding of result.findings) {
|
||||
expect(finding.complianceTag).toBeDefined();
|
||||
expect(['eu-required', 'eu-recommended', 'wcag-aaa', 'deaf-specific']).toContain(finding.complianceTag);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 6: Returns 100% when no violations ──────────────────────
|
||||
|
||||
describe('100% compliance', () => {
|
||||
it('returns 100% score when filtering to a criterion with only passes', async () => {
|
||||
// Criterion 2.1.1 only has a warning (no violations), so it should show as met
|
||||
// But if we filter to a non-existent criterion, we get 100% with 0 total
|
||||
const result = await backend.callTool('wcag_audit', { criterion: 'nonexistent' });
|
||||
|
||||
expect(result.score.percent).toBe(100);
|
||||
expect(result.score.total).toBe(0);
|
||||
expect(result.findings.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
}, {
|
||||
seed: A11Y_SEED_DATA,
|
||||
ftsIndexes: A11Y_FTS_INDEXES,
|
||||
poolAdapter: true,
|
||||
afterSetup: async (handle) => {
|
||||
vi.mocked(listRegisteredRepos).mockResolvedValue([
|
||||
{
|
||||
name: 'test-a11y-repo',
|
||||
path: '/test/a11y-repo',
|
||||
storagePath: handle.tmpHandle.dbPath,
|
||||
indexedAt: new Date().toISOString(),
|
||||
lastCommit: 'abc123',
|
||||
stats: { files: 4, nodes: 12, communities: 1, processes: 0 },
|
||||
},
|
||||
]);
|
||||
|
||||
const backend = new LocalBackend();
|
||||
await backend.init();
|
||||
(handle as any)._backend = backend;
|
||||
},
|
||||
});
|
||||
812
gitnexus/test/unit/a11y-rules.test.ts
Normal file
812
gitnexus/test/unit/a11y-rules.test.ts
Normal file
|
|
@ -0,0 +1,812 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import type { ExtractedJSXElement } from '../../src/core/ingestion/a11y-rules/types.js';
|
||||
import { imgAlt, iconButtonLabel } from '../../src/core/ingestion/a11y-rules/perceivable/non-text-content.js';
|
||||
import { inputLabel, landmarks } from '../../src/core/ingestion/a11y-rules/perceivable/info-relationships.js';
|
||||
import { keyboard, noKeyboardTrap } from '../../src/core/ingestion/a11y-rules/operable/keyboard.js';
|
||||
import { bypassBlocks, pageTitled } from '../../src/core/ingestion/a11y-rules/operable/navigation.js';
|
||||
import { languagePage } from '../../src/core/ingestion/a11y-rules/understandable/language.js';
|
||||
import { nameRoleValue, statusMessages } from '../../src/core/ingestion/a11y-rules/robust/name-role-value.js';
|
||||
import { videoCaptions } from '../../src/core/ingestion/a11y-rules/bonus/deaf-specific.js';
|
||||
import { runA11yRules, RULES } from '../../src/core/ingestion/a11y-rules/index.js';
|
||||
|
||||
function makeElement(overrides: Partial<ExtractedJSXElement>): ExtractedJSXElement {
|
||||
return {
|
||||
tag: 'div',
|
||||
filePath: 'test.tsx',
|
||||
lineNumber: 1,
|
||||
attributes: new Map(),
|
||||
hasChildren: false,
|
||||
enclosingFunction: 'TestComponent',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// --- imgAlt ---
|
||||
describe('imgAlt', () => {
|
||||
it('flags img without alt as violation', () => {
|
||||
const el = makeElement({ tag: 'img' });
|
||||
const signals = imgAlt.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('violation');
|
||||
expect(signals[0].criterion).toBe('1.1.1');
|
||||
});
|
||||
|
||||
it('passes img with alt attribute', () => {
|
||||
const el = makeElement({ tag: 'img', attributes: new Map([['alt', 'description']]) });
|
||||
const signals = imgAlt.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('pass');
|
||||
});
|
||||
|
||||
it('passes img with aria-label', () => {
|
||||
const el = makeElement({ tag: 'img', attributes: new Map([['aria-label', 'icon']]) });
|
||||
const signals = imgAlt.check([el], 'test.tsx');
|
||||
expect(signals[0].status).toBe('pass');
|
||||
});
|
||||
});
|
||||
|
||||
// --- iconButtonLabel ---
|
||||
describe('iconButtonLabel', () => {
|
||||
it('flags icon button without label as violation', () => {
|
||||
const el = makeElement({ tag: 'button', hasChildren: false });
|
||||
const signals = iconButtonLabel.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('violation');
|
||||
});
|
||||
|
||||
it('passes icon button with aria-label', () => {
|
||||
const el = makeElement({
|
||||
tag: 'button',
|
||||
hasChildren: false,
|
||||
attributes: new Map([['aria-label', 'Close']]),
|
||||
});
|
||||
const signals = iconButtonLabel.check([el], 'test.tsx');
|
||||
expect(signals[0].status).toBe('pass');
|
||||
});
|
||||
|
||||
it('skips buttons with children', () => {
|
||||
const el = makeElement({ tag: 'button', hasChildren: true });
|
||||
const signals = iconButtonLabel.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips buttons with textContent', () => {
|
||||
const el = makeElement({ tag: 'button', hasChildren: false, textContent: 'Click me' });
|
||||
const signals = iconButtonLabel.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- inputLabel ---
|
||||
describe('inputLabel', () => {
|
||||
it('flags input without label as needs-review', () => {
|
||||
const el = makeElement({ tag: 'input' });
|
||||
const signals = inputLabel.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('needs-review');
|
||||
expect(signals[0].severity).toBe('critical');
|
||||
});
|
||||
|
||||
it('passes input with aria-label', () => {
|
||||
const el = makeElement({ tag: 'input', attributes: new Map([['aria-label', 'Name']]) });
|
||||
const signals = inputLabel.check([el], 'test.tsx');
|
||||
expect(signals[0].status).toBe('pass');
|
||||
});
|
||||
|
||||
it('flags input with only id as needs-review (id alone does not prove label association)', () => {
|
||||
const el = makeElement({ tag: 'input', attributes: new Map([['id', 'email']]) });
|
||||
const signals = inputLabel.check([el], 'test.tsx');
|
||||
expect(signals[0].status).toBe('needs-review');
|
||||
});
|
||||
|
||||
it('applies to select and textarea', () => {
|
||||
const select = makeElement({ tag: 'select' });
|
||||
const textarea = makeElement({ tag: 'textarea' });
|
||||
const signals = inputLabel.check([select, textarea], 'test.tsx');
|
||||
expect(signals).toHaveLength(2);
|
||||
expect(signals.every((s) => s.status === 'needs-review')).toBe(true);
|
||||
});
|
||||
|
||||
it('passes input with id matching a label htmlFor', () => {
|
||||
const label = makeElement({ tag: 'label', attributes: new Map([['htmlFor', 'email']]) });
|
||||
const input = makeElement({ tag: 'input', attributes: new Map([['id', 'email']]) });
|
||||
const signals = inputLabel.check([label, input], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('pass');
|
||||
});
|
||||
|
||||
it('passes input with id matching a label for attribute', () => {
|
||||
const label = makeElement({ tag: 'label', attributes: new Map([['for', 'name']]) });
|
||||
const input = makeElement({ tag: 'input', attributes: new Map([['id', 'name']]) });
|
||||
const signals = inputLabel.check([label, input], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('pass');
|
||||
});
|
||||
|
||||
it('passes input wrapped in a label element', () => {
|
||||
const input = makeElement({ tag: 'input', parentTag: 'label' });
|
||||
const signals = inputLabel.check([input], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('pass');
|
||||
});
|
||||
});
|
||||
|
||||
// --- landmarks ---
|
||||
describe('landmarks', () => {
|
||||
it('flags layout file with no landmarks as needs-review', () => {
|
||||
const el = makeElement({ tag: 'div' });
|
||||
const signals = landmarks.check([el], 'src/app/layout.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('needs-review');
|
||||
});
|
||||
|
||||
it('passes when layout file has main element', () => {
|
||||
const el = makeElement({ tag: 'main' });
|
||||
const signals = landmarks.check([el], 'src/app/layout.tsx');
|
||||
expect(signals[0].status).toBe('pass');
|
||||
});
|
||||
|
||||
it('skips non-layout files', () => {
|
||||
const el = makeElement({ tag: 'div' });
|
||||
const signals = landmarks.check([el], 'src/components/Card.tsx');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- keyboard ---
|
||||
describe('keyboard', () => {
|
||||
it('flags div with onClick but no tabIndex as needs-review (not provably interactive)', () => {
|
||||
const el = makeElement({ attributes: new Map([['onClick', true as unknown as string]]) });
|
||||
const signals = keyboard.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('needs-review');
|
||||
expect(signals[0].criterion).toBe('2.1.1');
|
||||
});
|
||||
|
||||
it('flags div with onClick AND tabIndex as violation (provably interactive, missing keyboard)', () => {
|
||||
const el = makeElement({
|
||||
attributes: new Map([
|
||||
['onClick', true as unknown as string],
|
||||
['tabIndex', '0'],
|
||||
]),
|
||||
});
|
||||
const signals = keyboard.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('violation');
|
||||
});
|
||||
|
||||
it('passes div with onClick and onKeyDown', () => {
|
||||
const el = makeElement({
|
||||
attributes: new Map([
|
||||
['onClick', true as unknown as string],
|
||||
['onKeyDown', true as unknown as string],
|
||||
]),
|
||||
});
|
||||
const signals = keyboard.check([el], 'test.tsx');
|
||||
expect(signals[0].status).toBe('pass');
|
||||
});
|
||||
|
||||
it('passes div with onClick and role', () => {
|
||||
const el = makeElement({
|
||||
attributes: new Map([
|
||||
['onClick', true as unknown as string],
|
||||
['role', 'button'],
|
||||
]),
|
||||
});
|
||||
const signals = keyboard.check([el], 'test.tsx');
|
||||
expect(signals[0].status).toBe('pass');
|
||||
});
|
||||
|
||||
it('flags div with onClick+tabIndex but no onKeyDown as violation (provably focusable)', () => {
|
||||
const el = makeElement({
|
||||
attributes: new Map([
|
||||
['onClick', true as unknown as string],
|
||||
['tabIndex', '0'],
|
||||
]),
|
||||
});
|
||||
const signals = keyboard.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('violation');
|
||||
});
|
||||
|
||||
it('downgrades div with onClick inside Button component to needs-review', () => {
|
||||
const el = makeElement({
|
||||
attributes: new Map([['onClick', true as unknown as string]]),
|
||||
enclosingFunction: 'ButtonTrigger',
|
||||
});
|
||||
const signals = keyboard.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('needs-review');
|
||||
expect(signals[0].confidence).toBe('heuristic');
|
||||
});
|
||||
|
||||
it('downgrades div with onClick inside MenuItem to needs-review', () => {
|
||||
const el = makeElement({
|
||||
attributes: new Map([['onClick', true as unknown as string]]),
|
||||
enclosingFunction: 'DropdownMenuItem',
|
||||
});
|
||||
const signals = keyboard.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('needs-review');
|
||||
expect(signals[0].confidence).toBe('heuristic');
|
||||
});
|
||||
});
|
||||
|
||||
// --- noKeyboardTrap ---
|
||||
describe('noKeyboardTrap', () => {
|
||||
it('flags dialog element without onKeyDown as needs-review', () => {
|
||||
const el = makeElement({
|
||||
attributes: new Map([['role', 'dialog']]),
|
||||
});
|
||||
const signals = noKeyboardTrap.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('needs-review');
|
||||
expect(signals[0].confidence).toBe('heuristic');
|
||||
});
|
||||
|
||||
it('passes dialog element with onKeyDown', () => {
|
||||
const el = makeElement({
|
||||
attributes: new Map([
|
||||
['role', 'dialog'],
|
||||
['onKeyDown', true as unknown as string],
|
||||
]),
|
||||
});
|
||||
const signals = noKeyboardTrap.check([el], 'test.tsx');
|
||||
expect(signals[0].status).toBe('pass');
|
||||
});
|
||||
|
||||
it('flags element with aria-modal without onKeyDown as needs-review', () => {
|
||||
const el = makeElement({
|
||||
attributes: new Map([['aria-modal', 'true']]),
|
||||
});
|
||||
const signals = noKeyboardTrap.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('needs-review');
|
||||
});
|
||||
|
||||
it('flags element inside focus trap function as needs-review', () => {
|
||||
const el = makeElement({
|
||||
enclosingFunction: 'useFocusTrap',
|
||||
attributes: new Map(),
|
||||
});
|
||||
const signals = noKeyboardTrap.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('needs-review');
|
||||
});
|
||||
|
||||
it('skips plain element with onFocus (no trap characteristics)', () => {
|
||||
const el = makeElement({ attributes: new Map([['onFocus', true as unknown as string]]) });
|
||||
const signals = noKeyboardTrap.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('passes dialog when wrapper div in same function has onKeyDown', () => {
|
||||
const dialog = makeElement({
|
||||
attributes: new Map([['role', 'dialog']]),
|
||||
enclosingFunction: 'MyDialog',
|
||||
});
|
||||
const wrapper = makeElement({
|
||||
tag: 'div',
|
||||
attributes: new Map([['onKeyDown', true as unknown as string]]),
|
||||
enclosingFunction: 'MyDialog',
|
||||
});
|
||||
const signals = noKeyboardTrap.check([dialog, wrapper], 'test.tsx');
|
||||
const dialogSignal = signals.find(s => s.element === 'div' && s.status !== undefined) || signals[0];
|
||||
// The dialog element should pass because its enclosing function has a key handler
|
||||
expect(signals.filter(s => s.status === 'violation')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips element resolved from known accessible component (e.g. AlertDialog)', () => {
|
||||
const el = makeElement({
|
||||
attributes: new Map([['role', 'dialog']]),
|
||||
parentTag: 'AlertDialog',
|
||||
});
|
||||
const signals = noKeyboardTrap.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- bypassBlocks ---
|
||||
describe('bypassBlocks', () => {
|
||||
it('flags layout file without skip link as needs-review', () => {
|
||||
const el = makeElement({ tag: 'div' });
|
||||
const signals = bypassBlocks.check([el], 'src/app/layout.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('needs-review');
|
||||
});
|
||||
|
||||
it('passes layout file with skip link', () => {
|
||||
const el = makeElement({ tag: 'a', attributes: new Map([['href', '#main-content']]) });
|
||||
const signals = bypassBlocks.check([el], 'src/app/layout.tsx');
|
||||
expect(signals[0].status).toBe('pass');
|
||||
});
|
||||
|
||||
it('skips non-layout files', () => {
|
||||
const el = makeElement({ tag: 'div' });
|
||||
const signals = bypassBlocks.check([el], 'src/components/Card.tsx');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- pageTitled ---
|
||||
describe('pageTitled', () => {
|
||||
it('flags page file with no title as needs-review', () => {
|
||||
const el = makeElement({ tag: 'div' });
|
||||
const signals = pageTitled.check([el], 'src/app/page.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('needs-review');
|
||||
});
|
||||
|
||||
it('passes when page file has Head component', () => {
|
||||
const el = makeElement({ tag: 'Head' });
|
||||
const signals = pageTitled.check([el], 'src/pages/index.tsx');
|
||||
expect(signals[0].status).toBe('pass');
|
||||
});
|
||||
|
||||
it('skips non-page files', () => {
|
||||
const el = makeElement({ tag: 'div' });
|
||||
const signals = pageTitled.check([el], 'src/components/Card.tsx');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('passes when page has export const metadata', () => {
|
||||
const el = makeElement({ tag: 'div' });
|
||||
const source = `export const metadata: Metadata = { title: 'Home' };\nexport default function Page() { return <div />; }`;
|
||||
const signals = pageTitled.check([el], 'src/app/page.tsx', source);
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('pass');
|
||||
});
|
||||
|
||||
it('passes when page has export async function generateMetadata', () => {
|
||||
const el = makeElement({ tag: 'div' });
|
||||
const source = `export async function generateMetadata() { return { title: 'Dynamic' }; }`;
|
||||
const signals = pageTitled.check([el], 'src/app/page.tsx', source);
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('pass');
|
||||
});
|
||||
|
||||
it('passes when page has export function generateMetadata (non-async)', () => {
|
||||
const el = makeElement({ tag: 'div' });
|
||||
const source = `export function generateMetadata() { return { title: 'Dynamic' }; }`;
|
||||
const signals = pageTitled.check([el], 'src/app/page.tsx', source);
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('pass');
|
||||
});
|
||||
});
|
||||
|
||||
// --- languagePage ---
|
||||
describe('languagePage', () => {
|
||||
it('flags root layout html without lang as violation', () => {
|
||||
const el = makeElement({ tag: 'html', filePath: 'src/app/layout.tsx' });
|
||||
const signals = languagePage.check([el], 'src/app/layout.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('violation');
|
||||
});
|
||||
|
||||
it('passes root layout html with lang', () => {
|
||||
const el = makeElement({
|
||||
tag: 'html',
|
||||
filePath: 'src/app/layout.tsx',
|
||||
attributes: new Map([['lang', 'en']]),
|
||||
});
|
||||
const signals = languagePage.check([el], 'src/app/layout.tsx');
|
||||
expect(signals[0].status).toBe('pass');
|
||||
});
|
||||
|
||||
it('skips non-root-layout files', () => {
|
||||
const el = makeElement({ tag: 'html' });
|
||||
const signals = languagePage.check([el], 'src/components/Card.tsx');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- nameRoleValue ---
|
||||
describe('nameRoleValue', () => {
|
||||
it('flags interactive div without role or tabIndex as needs-review', () => {
|
||||
const el = makeElement({ attributes: new Map([['onClick', true as unknown as string]]) });
|
||||
const signals = nameRoleValue.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('needs-review');
|
||||
expect(signals[0].criterion).toBe('4.1.2');
|
||||
});
|
||||
|
||||
it('flags interactive div with tabIndex but no role as violation (provably focusable)', () => {
|
||||
const el = makeElement({
|
||||
attributes: new Map([
|
||||
['onClick', true as unknown as string],
|
||||
['tabIndex', '0'],
|
||||
]),
|
||||
});
|
||||
const signals = nameRoleValue.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('violation');
|
||||
});
|
||||
|
||||
it('passes interactive div with role', () => {
|
||||
const el = makeElement({
|
||||
attributes: new Map([
|
||||
['onClick', true as unknown as string],
|
||||
['role', 'button'],
|
||||
]),
|
||||
});
|
||||
const signals = nameRoleValue.check([el], 'test.tsx');
|
||||
expect(signals[0].status).toBe('pass');
|
||||
});
|
||||
|
||||
it('flags interactive div with tabIndex but no role as violation', () => {
|
||||
const el = makeElement({
|
||||
attributes: new Map([
|
||||
['onClick', true as unknown as string],
|
||||
['tabIndex', '0'],
|
||||
]),
|
||||
});
|
||||
const signals = nameRoleValue.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('violation');
|
||||
});
|
||||
|
||||
it('downgrades interactive div inside Toggle component to needs-review', () => {
|
||||
const el = makeElement({
|
||||
attributes: new Map([['onClick', true as unknown as string]]),
|
||||
enclosingFunction: 'ToggleButton',
|
||||
});
|
||||
const signals = nameRoleValue.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('needs-review');
|
||||
expect(signals[0].confidence).toBe('heuristic');
|
||||
});
|
||||
});
|
||||
|
||||
// --- statusMessages ---
|
||||
describe('statusMessages', () => {
|
||||
it('flags toast element without aria-live as needs-review', () => {
|
||||
const el = makeElement({ classNames: ['toast-container'] });
|
||||
const signals = statusMessages.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('needs-review');
|
||||
});
|
||||
|
||||
it('passes alert element with role=alert', () => {
|
||||
const el = makeElement({
|
||||
classNames: ['alert-banner'],
|
||||
attributes: new Map([['role', 'alert']]),
|
||||
});
|
||||
const signals = statusMessages.check([el], 'test.tsx');
|
||||
expect(signals[0].status).toBe('pass');
|
||||
});
|
||||
|
||||
it('passes element with aria-live', () => {
|
||||
const el = makeElement({
|
||||
classNames: ['notification-wrapper'],
|
||||
attributes: new Map([['aria-live', 'polite']]),
|
||||
});
|
||||
const signals = statusMessages.check([el], 'test.tsx');
|
||||
expect(signals[0].status).toBe('pass');
|
||||
});
|
||||
|
||||
it('skips element when classNames array is absent (no class-based detection without classNames)', () => {
|
||||
const el = makeElement({
|
||||
attributes: new Map([['className', 'spinner-overlay']]),
|
||||
});
|
||||
const signals = statusMessages.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- videoCaptions ---
|
||||
describe('videoCaptions', () => {
|
||||
it('flags video without track element as needs-review', () => {
|
||||
const el = makeElement({ tag: 'video' });
|
||||
const signals = videoCaptions.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('needs-review');
|
||||
expect(signals[0].confidence).toBe('heuristic');
|
||||
expect(signals[0].complianceTag).toBe('deaf-specific');
|
||||
});
|
||||
|
||||
it('passes video when track element with video parent exists', () => {
|
||||
const video = makeElement({ tag: 'video' });
|
||||
const track = makeElement({ tag: 'track', parentTag: 'video' });
|
||||
const signals = videoCaptions.check([video, track], 'test.tsx');
|
||||
expect(signals[0].status).toBe('pass');
|
||||
});
|
||||
|
||||
it('flags video as needs-review when track element has no video parent', () => {
|
||||
const video = makeElement({ tag: 'video' });
|
||||
const track = makeElement({ tag: 'track', parentTag: 'div' });
|
||||
const signals = videoCaptions.check([video, track], 'test.tsx');
|
||||
expect(signals[0].status).toBe('needs-review');
|
||||
});
|
||||
});
|
||||
|
||||
// --- aria-hidden skipping ---
|
||||
describe('aria-hidden skipping', () => {
|
||||
it('skips element with aria-hidden="true" in imgAlt', () => {
|
||||
const el = makeElement({ tag: 'img', attributes: new Map([['aria-hidden', 'true']]) });
|
||||
const signals = imgAlt.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips element with aria-hidden="true" in keyboard', () => {
|
||||
const el = makeElement({
|
||||
tag: 'div',
|
||||
attributes: new Map([['aria-hidden', 'true'], ['onClick', 'handler']]),
|
||||
});
|
||||
const signals = keyboard.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips element with aria-hidden="true" in nameRoleValue', () => {
|
||||
const el = makeElement({
|
||||
tag: 'div',
|
||||
attributes: new Map([['aria-hidden', 'true'], ['onClick', 'handler']]),
|
||||
});
|
||||
const signals = nameRoleValue.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- known accessible component skipping ---
|
||||
describe('known accessible component skipping', () => {
|
||||
it('skips Button in keyboard check', () => {
|
||||
const el = makeElement({
|
||||
tag: 'Button',
|
||||
attributes: new Map([['onClick', 'handler']]),
|
||||
});
|
||||
const signals = keyboard.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips FormField in inputLabel check', () => {
|
||||
const el = makeElement({ tag: 'FormField' });
|
||||
const signals = inputLabel.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips Dialog in nameRoleValue check', () => {
|
||||
const el = makeElement({
|
||||
tag: 'Dialog',
|
||||
attributes: new Map([['onClick', 'handler']]),
|
||||
});
|
||||
const signals = nameRoleValue.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- test file skipping ---
|
||||
describe('test file skipping', () => {
|
||||
it('skips .test. files entirely', () => {
|
||||
const el = makeElement({ tag: 'img' });
|
||||
const signals = runA11yRules([el], 'src/components/Button.test.tsx');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips .spec. files entirely', () => {
|
||||
const el = makeElement({ tag: 'img' });
|
||||
const signals = runA11yRules([el], 'src/components/Button.spec.tsx');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips __tests__ directory files entirely', () => {
|
||||
const el = makeElement({ tag: 'img' });
|
||||
const signals = runA11yRules([el], 'src/__tests__/Button.tsx');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- statusMessages false-positive fixes ---
|
||||
describe('statusMessages false-positive fixes', () => {
|
||||
it('does not match AlertDialog in class name', () => {
|
||||
const el = makeElement({ classNames: ['AlertDialog'] });
|
||||
const signals = statusMessages.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not match alertdialog-overlay class', () => {
|
||||
const el = makeElement({ classNames: ['alertdialog-overlay'] });
|
||||
const signals = statusMessages.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still matches alert-banner class', () => {
|
||||
const el = makeElement({ classNames: ['alert-banner'] });
|
||||
const signals = statusMessages.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('needs-review');
|
||||
});
|
||||
|
||||
it('still matches toast class', () => {
|
||||
const el = makeElement({ classNames: ['toast'] });
|
||||
const signals = statusMessages.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('needs-review');
|
||||
});
|
||||
|
||||
it('passes AlertDialog with role=alertdialog (implicit live region)', () => {
|
||||
const el = makeElement({
|
||||
classNames: ['alert-banner'],
|
||||
attributes: new Map([['role', 'alertdialog']]),
|
||||
});
|
||||
const signals = statusMessages.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('pass');
|
||||
});
|
||||
|
||||
it('passes element with role=status (implicit live region)', () => {
|
||||
const el = makeElement({
|
||||
classNames: ['loading-spinner'],
|
||||
attributes: new Map([['role', 'status']]),
|
||||
});
|
||||
const signals = statusMessages.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('pass');
|
||||
});
|
||||
|
||||
it('passes element with role=log (implicit live region)', () => {
|
||||
const el = makeElement({
|
||||
classNames: ['notification-feed'],
|
||||
attributes: new Map([['role', 'log']]),
|
||||
});
|
||||
const signals = statusMessages.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('pass');
|
||||
});
|
||||
|
||||
it('does not match ToastPrimitive in class name', () => {
|
||||
const el = makeElement({ classNames: ['ToastPrimitive'] });
|
||||
const signals = statusMessages.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- noKeyboardTrap skips native form controls ---
|
||||
describe('noKeyboardTrap skips native form controls', () => {
|
||||
it('skips input elements even with role=dialog', () => {
|
||||
const el = makeElement({ tag: 'input', attributes: new Map([['role', 'dialog']]) });
|
||||
const signals = noKeyboardTrap.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips button elements', () => {
|
||||
const el = makeElement({ tag: 'button', attributes: new Map([['role', 'dialog']]) });
|
||||
const signals = noKeyboardTrap.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips select elements', () => {
|
||||
const el = makeElement({ tag: 'select', attributes: new Map([['aria-modal', 'true']]) });
|
||||
const signals = noKeyboardTrap.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips textarea elements', () => {
|
||||
const el = makeElement({ tag: 'textarea', attributes: new Map([['aria-modal', 'true']]) });
|
||||
const signals = noKeyboardTrap.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- isLayoutFile tightened matching ---
|
||||
describe('bypassBlocks layout matching', () => {
|
||||
it('does not match icon files with Layout in name', () => {
|
||||
const el = makeElement({ tag: 'div' });
|
||||
const signals = bypassBlocks.check([el], 'src/icons/IconLayout.svg');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not match LayoutIcon.png', () => {
|
||||
const el = makeElement({ tag: 'div' });
|
||||
const signals = bypassBlocks.check([el], 'src/assets/LayoutIcon.png');
|
||||
expect(signals).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still matches layout.tsx', () => {
|
||||
const el = makeElement({ tag: 'div' });
|
||||
const signals = bypassBlocks.check([el], 'src/app/layout.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('still matches RootLayout.tsx', () => {
|
||||
const el = makeElement({ tag: 'div' });
|
||||
const signals = bypassBlocks.check([el], 'src/app/RootLayout.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// --- runA11yRules integration ---
|
||||
describe('runA11yRules', () => {
|
||||
it('exports all 12 rules', () => {
|
||||
expect(RULES).toHaveLength(12);
|
||||
});
|
||||
|
||||
it('aggregates signals from multiple rules', () => {
|
||||
const img = makeElement({ tag: 'img' });
|
||||
const input = makeElement({ tag: 'input' });
|
||||
const signals = runA11yRules([img, input], 'test.tsx');
|
||||
const names = signals.map((s) => s.name);
|
||||
expect(names).toContain('imgAlt');
|
||||
expect(names).toContain('inputLabel');
|
||||
});
|
||||
|
||||
it('passes source to rules that need it', () => {
|
||||
const el = makeElement({ tag: 'div' });
|
||||
const source = `export const metadata = { title: 'Test' };\nexport default function Page() {}`;
|
||||
const signals = runA11yRules([el], 'src/app/page.tsx', source);
|
||||
const pageTitledSignal = signals.find(s => s.name === 'pageTitled');
|
||||
expect(pageTitledSignal).toBeDefined();
|
||||
expect(pageTitledSignal!.status).toBe('pass');
|
||||
});
|
||||
});
|
||||
|
||||
// --- resolved element status downgrade ---
|
||||
describe('resolved elements produce needs-review instead of violation', () => {
|
||||
it('native <img> without alt (not resolved) → violation', () => {
|
||||
const el = makeElement({ tag: 'img' });
|
||||
const signals = imgAlt.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('violation');
|
||||
});
|
||||
|
||||
it('resolved <img> without alt → needs-review', () => {
|
||||
const el = makeElement({ tag: 'img', resolved: true });
|
||||
const signals = imgAlt.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('needs-review');
|
||||
});
|
||||
|
||||
it('native <button> without label (not resolved) → violation', () => {
|
||||
const el = makeElement({ tag: 'button', hasChildren: false });
|
||||
const signals = iconButtonLabel.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('violation');
|
||||
});
|
||||
|
||||
it('resolved <button> without label → needs-review', () => {
|
||||
const el = makeElement({ tag: 'button', hasChildren: false, resolved: true });
|
||||
const signals = iconButtonLabel.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('needs-review');
|
||||
});
|
||||
|
||||
it('native div with onClick+tabIndex (not resolved) → violation in keyboard', () => {
|
||||
const el = makeElement({ attributes: new Map([['onClick', true as unknown as string], ['tabIndex', '0']]) });
|
||||
const signals = keyboard.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('violation');
|
||||
});
|
||||
|
||||
it('native div with onClick but no tabIndex (not resolved) → needs-review in keyboard', () => {
|
||||
const el = makeElement({ attributes: new Map([['onClick', true as unknown as string]]) });
|
||||
const signals = keyboard.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('needs-review');
|
||||
});
|
||||
|
||||
it('resolved div with onClick → needs-review in keyboard', () => {
|
||||
const el = makeElement({ attributes: new Map([['onClick', true as unknown as string]]), resolved: true });
|
||||
const signals = keyboard.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('needs-review');
|
||||
});
|
||||
|
||||
it('native interactive div with tabIndex but no role (not resolved) → violation in nameRoleValue', () => {
|
||||
const el = makeElement({ attributes: new Map([['onClick', true as unknown as string], ['tabIndex', '0']]) });
|
||||
const signals = nameRoleValue.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('violation');
|
||||
});
|
||||
|
||||
it('native interactive div without tabIndex or role (not resolved) → needs-review in nameRoleValue', () => {
|
||||
const el = makeElement({ attributes: new Map([['onClick', true as unknown as string]]) });
|
||||
const signals = nameRoleValue.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('needs-review');
|
||||
});
|
||||
|
||||
it('resolved interactive div without role → needs-review in nameRoleValue', () => {
|
||||
const el = makeElement({ attributes: new Map([['onClick', true as unknown as string]]), resolved: true });
|
||||
const signals = nameRoleValue.check([el], 'test.tsx');
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].status).toBe('needs-review');
|
||||
});
|
||||
});
|
||||
314
gitnexus/test/unit/component-resolver.test.ts
Normal file
314
gitnexus/test/unit/component-resolver.test.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import type { ExtractedJSXElement } from '../../src/core/ingestion/a11y-rules/types.js';
|
||||
import {
|
||||
buildComponentMap,
|
||||
resolveComponent,
|
||||
resolveComponentsInFile,
|
||||
isKnownAccessibleComponent,
|
||||
KNOWN_ACCESSIBLE_COMPONENTS,
|
||||
} from '../../src/core/ingestion/a11y-rules/component-resolver.js';
|
||||
import { inputLabel } from '../../src/core/ingestion/a11y-rules/perceivable/info-relationships.js';
|
||||
|
||||
function makeElement(overrides: Partial<ExtractedJSXElement>): ExtractedJSXElement {
|
||||
return {
|
||||
tag: 'div',
|
||||
filePath: 'test.tsx',
|
||||
lineNumber: 1,
|
||||
attributes: new Map(),
|
||||
hasChildren: false,
|
||||
enclosingFunction: 'TestComponent',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// --- buildComponentMap ---
|
||||
describe('buildComponentMap', () => {
|
||||
it('groups elements by enclosingFunction', () => {
|
||||
const elements = [
|
||||
makeElement({ tag: 'input', enclosingFunction: 'FormField' }),
|
||||
makeElement({ tag: 'label', enclosingFunction: 'FormField' }),
|
||||
makeElement({ tag: 'button', enclosingFunction: 'SubmitButton' }),
|
||||
];
|
||||
const map = buildComponentMap(elements);
|
||||
expect(map.get('FormField')).toHaveLength(2);
|
||||
expect(map.get('SubmitButton')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips elements with <module> enclosingFunction', () => {
|
||||
const elements = [
|
||||
makeElement({ tag: 'div', enclosingFunction: '<module>' }),
|
||||
];
|
||||
const map = buildComponentMap(elements);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('skips elements with <anonymous> enclosingFunction', () => {
|
||||
const elements = [
|
||||
makeElement({ tag: 'div', enclosingFunction: '<anonymous>' }),
|
||||
];
|
||||
const map = buildComponentMap(elements);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('skips elements with empty enclosingFunction', () => {
|
||||
const elements = [
|
||||
makeElement({ tag: 'div', enclosingFunction: '' }),
|
||||
];
|
||||
const map = buildComponentMap(elements);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- resolveComponent ---
|
||||
describe('resolveComponent', () => {
|
||||
it('follows one level of component resolution', () => {
|
||||
const elements = [
|
||||
makeElement({ tag: 'input', enclosingFunction: 'FormField' }),
|
||||
makeElement({ tag: 'label', enclosingFunction: 'FormField' }),
|
||||
];
|
||||
const map = buildComponentMap(elements);
|
||||
const resolved = resolveComponent('FormField', map);
|
||||
expect(resolved).toHaveLength(2);
|
||||
expect(resolved.map(r => r.tag)).toEqual(['input', 'label']);
|
||||
});
|
||||
|
||||
it('follows two levels of component resolution', () => {
|
||||
const elements = [
|
||||
makeElement({ tag: 'input', enclosingFunction: 'InnerInput' }),
|
||||
makeElement({ tag: 'InnerInput', enclosingFunction: 'FormField' }),
|
||||
];
|
||||
const map = buildComponentMap(elements);
|
||||
const resolved = resolveComponent('FormField', map);
|
||||
expect(resolved).toHaveLength(1);
|
||||
expect(resolved[0].tag).toBe('input');
|
||||
});
|
||||
|
||||
it('handles cycles via visited set', () => {
|
||||
const elements = [
|
||||
makeElement({ tag: 'ComponentB', enclosingFunction: 'ComponentA' }),
|
||||
makeElement({ tag: 'ComponentA', enclosingFunction: 'ComponentB' }),
|
||||
];
|
||||
const map = buildComponentMap(elements);
|
||||
const resolved = resolveComponent('ComponentA', map);
|
||||
// Should not infinite loop; ComponentA -> ComponentB -> ComponentA (visited, stop)
|
||||
expect(resolved).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns empty array for unknown component', () => {
|
||||
const map = new Map<string, ExtractedJSXElement[]>();
|
||||
const resolved = resolveComponent('Unknown', map);
|
||||
expect(resolved).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('respects maxDepth', () => {
|
||||
const elements = [
|
||||
makeElement({ tag: 'Level2', enclosingFunction: 'Level1' }),
|
||||
makeElement({ tag: 'Level3', enclosingFunction: 'Level2' }),
|
||||
makeElement({ tag: 'button', enclosingFunction: 'Level3' }),
|
||||
];
|
||||
const map = buildComponentMap(elements);
|
||||
// maxDepth=2 means: Level1 -> Level2 -> Level3 (depth exhausted, can't go into Level3)
|
||||
const resolved = resolveComponent('Level1', map, 2);
|
||||
expect(resolved).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- resolveComponentsInFile ---
|
||||
describe('resolveComponentsInFile', () => {
|
||||
it('adds resolved native elements alongside custom component usage', () => {
|
||||
const fileElements = [
|
||||
makeElement({ tag: 'FormField', filePath: 'page.tsx', lineNumber: 10, enclosingFunction: 'Page' }),
|
||||
];
|
||||
const allElements = [
|
||||
...fileElements,
|
||||
makeElement({ tag: 'input', enclosingFunction: 'FormField', filePath: 'form-field.tsx', lineNumber: 5 }),
|
||||
makeElement({ tag: 'label', enclosingFunction: 'FormField', filePath: 'form-field.tsx', lineNumber: 3 }),
|
||||
];
|
||||
const map = buildComponentMap(allElements);
|
||||
const resolved = resolveComponentsInFile(fileElements, map);
|
||||
|
||||
// Original FormField + 2 resolved native elements
|
||||
expect(resolved).toHaveLength(3);
|
||||
expect(resolved[0].tag).toBe('FormField');
|
||||
expect(resolved[1].tag).toBe('input');
|
||||
expect(resolved[1].filePath).toBe('page.tsx'); // attributed to usage site
|
||||
expect(resolved[1].lineNumber).toBe(10); // attributed to usage site
|
||||
expect(resolved[1].parentTag).toBe('FormField');
|
||||
expect(resolved[1].resolved).toBe(true); // marked as resolved
|
||||
expect(resolved[2].tag).toBe('label');
|
||||
expect(resolved[2].filePath).toBe('page.tsx');
|
||||
expect(resolved[2].resolved).toBe(true); // marked as resolved
|
||||
});
|
||||
|
||||
it('keeps native elements unchanged', () => {
|
||||
const fileElements = [
|
||||
makeElement({ tag: 'div', filePath: 'page.tsx' }),
|
||||
];
|
||||
const map = new Map<string, ExtractedJSXElement[]>();
|
||||
const resolved = resolveComponentsInFile(fileElements, map);
|
||||
expect(resolved).toHaveLength(1);
|
||||
expect(resolved[0].tag).toBe('div');
|
||||
});
|
||||
|
||||
it('merges usage-site props (id, aria-label) onto resolved native element', () => {
|
||||
const fileElements = [
|
||||
makeElement({
|
||||
tag: 'Input',
|
||||
filePath: 'page.tsx',
|
||||
lineNumber: 10,
|
||||
enclosingFunction: 'Page',
|
||||
attributes: new Map([['id', 'email'], ['aria-label', 'Email']]),
|
||||
}),
|
||||
];
|
||||
const allElements = [
|
||||
...fileElements,
|
||||
makeElement({
|
||||
tag: 'input',
|
||||
enclosingFunction: 'Input',
|
||||
filePath: 'input.tsx',
|
||||
lineNumber: 5,
|
||||
attributes: new Map([['type', 'text']]),
|
||||
}),
|
||||
];
|
||||
const map = buildComponentMap(allElements);
|
||||
const resolved = resolveComponentsInFile(fileElements, map);
|
||||
|
||||
// Original Input + resolved input
|
||||
expect(resolved).toHaveLength(2);
|
||||
const resolvedInput = resolved[1];
|
||||
expect(resolvedInput.tag).toBe('input');
|
||||
// Usage-site props should be merged
|
||||
expect(resolvedInput.attributes.get('id')).toBe('email');
|
||||
expect(resolvedInput.attributes.get('aria-label')).toBe('Email');
|
||||
// Internal props should be preserved
|
||||
expect(resolvedInput.attributes.get('type')).toBe('text');
|
||||
});
|
||||
|
||||
it('does not merge className or children props to resolved element', () => {
|
||||
const fileElements = [
|
||||
makeElement({
|
||||
tag: 'Input',
|
||||
filePath: 'page.tsx',
|
||||
lineNumber: 10,
|
||||
enclosingFunction: 'Page',
|
||||
attributes: new Map([['className', 'w-full'], ['children', 'text'], ['id', 'name']]),
|
||||
}),
|
||||
];
|
||||
const allElements = [
|
||||
...fileElements,
|
||||
makeElement({
|
||||
tag: 'input',
|
||||
enclosingFunction: 'Input',
|
||||
filePath: 'input.tsx',
|
||||
lineNumber: 5,
|
||||
attributes: new Map(),
|
||||
}),
|
||||
];
|
||||
const map = buildComponentMap(allElements);
|
||||
const resolved = resolveComponentsInFile(fileElements, map);
|
||||
const resolvedInput = resolved[1];
|
||||
// className and children should NOT be merged
|
||||
expect(resolvedInput.attributes.has('className')).toBe(false);
|
||||
expect(resolvedInput.attributes.has('children')).toBe(false);
|
||||
// id should be merged
|
||||
expect(resolvedInput.attributes.get('id')).toBe('name');
|
||||
});
|
||||
|
||||
it('merges classNames arrays from both component internals and usage site', () => {
|
||||
const fileElements = [
|
||||
makeElement({
|
||||
tag: 'Input',
|
||||
filePath: 'page.tsx',
|
||||
lineNumber: 10,
|
||||
enclosingFunction: 'Page',
|
||||
classNames: ['w-full'],
|
||||
}),
|
||||
];
|
||||
const allElements = [
|
||||
...fileElements,
|
||||
makeElement({
|
||||
tag: 'input',
|
||||
enclosingFunction: 'Input',
|
||||
filePath: 'input.tsx',
|
||||
lineNumber: 5,
|
||||
classNames: ['border', 'rounded'],
|
||||
}),
|
||||
];
|
||||
const map = buildComponentMap(allElements);
|
||||
const resolved = resolveComponentsInFile(fileElements, map);
|
||||
const resolvedInput = resolved[1];
|
||||
expect(resolvedInput.classNames).toEqual(['border', 'rounded', 'w-full']);
|
||||
});
|
||||
|
||||
it('resolved Input with id + Label with htmlFor passes inputLabel rule', () => {
|
||||
// Integration-style test: component resolution + inputLabel rule
|
||||
const fileElements = [
|
||||
makeElement({
|
||||
tag: 'Input',
|
||||
filePath: 'page.tsx',
|
||||
lineNumber: 10,
|
||||
enclosingFunction: 'Page',
|
||||
attributes: new Map([['id', 'email']]),
|
||||
}),
|
||||
];
|
||||
const allElements = [
|
||||
...fileElements,
|
||||
makeElement({
|
||||
tag: 'input',
|
||||
enclosingFunction: 'Input',
|
||||
filePath: 'input.tsx',
|
||||
lineNumber: 5,
|
||||
attributes: new Map(),
|
||||
}),
|
||||
];
|
||||
const map = buildComponentMap(allElements);
|
||||
const resolved = resolveComponentsInFile(fileElements, map);
|
||||
// Add a label that matches the id
|
||||
const label = makeElement({ tag: 'label', attributes: new Map([['htmlFor', 'email']]) });
|
||||
const allResolved = [label, ...resolved];
|
||||
const signals = inputLabel.check(allResolved, 'page.tsx');
|
||||
// The resolved input should pass because label htmlFor matches its id
|
||||
const inputSignals = signals.filter(s => s.element === 'input');
|
||||
expect(inputSignals).toHaveLength(1);
|
||||
expect(inputSignals[0].status).toBe('pass');
|
||||
});
|
||||
});
|
||||
|
||||
// --- isKnownAccessibleComponent ---
|
||||
describe('isKnownAccessibleComponent', () => {
|
||||
it('matches exact Radix component names', () => {
|
||||
expect(isKnownAccessibleComponent('Dialog')).toBe(true);
|
||||
expect(isKnownAccessibleComponent('DialogContent')).toBe(true);
|
||||
expect(isKnownAccessibleComponent('Select')).toBe(true);
|
||||
expect(isKnownAccessibleComponent('Checkbox')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches shadcn/ui component names', () => {
|
||||
expect(isKnownAccessibleComponent('Button')).toBe(true);
|
||||
expect(isKnownAccessibleComponent('Input')).toBe(true);
|
||||
expect(isKnownAccessibleComponent('FormField')).toBe(true);
|
||||
expect(isKnownAccessibleComponent('Label')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches dotted names like Dialog.Content', () => {
|
||||
expect(isKnownAccessibleComponent('Dialog.Content')).toBe(true);
|
||||
expect(isKnownAccessibleComponent('Select.Trigger')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches Radix Primitive component names', () => {
|
||||
expect(isKnownAccessibleComponent('AlertDialogPrimitive')).toBe(true);
|
||||
expect(isKnownAccessibleComponent('ToastPrimitive')).toBe(true);
|
||||
expect(isKnownAccessibleComponent('ToastViewport')).toBe(true);
|
||||
expect(isKnownAccessibleComponent('Toast')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unknown components', () => {
|
||||
expect(isKnownAccessibleComponent('MyCustomWidget')).toBe(false);
|
||||
expect(isKnownAccessibleComponent('UnknownComponent')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects native elements', () => {
|
||||
expect(isKnownAccessibleComponent('div')).toBe(false);
|
||||
expect(isKnownAccessibleComponent('button')).toBe(false);
|
||||
});
|
||||
});
|
||||
229
gitnexus/test/unit/jsx-extractor.test.ts
Normal file
229
gitnexus/test/unit/jsx-extractor.test.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { extractJSXElements } from '../../src/core/ingestion/a11y-rules/jsx-extractor.js';
|
||||
|
||||
describe('extractJSXElements', () => {
|
||||
const FILE = 'components/Test.tsx';
|
||||
|
||||
it('extracts img self-closing with alt attribute', () => {
|
||||
const source = `
|
||||
const Hero = () => (
|
||||
<img src="/hero.png" alt="Hero banner" />
|
||||
);`;
|
||||
const elements = extractJSXElements(source, FILE);
|
||||
const img = elements.find((e) => e.tag === 'img');
|
||||
expect(img).toBeDefined();
|
||||
expect(img!.tag).toBe('img');
|
||||
expect(img!.attributes.get('alt')).toBe('Hero banner');
|
||||
expect(img!.attributes.get('src')).toBe('/hero.png');
|
||||
expect(img!.filePath).toBe(FILE);
|
||||
expect(img!.lineNumber).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('extracts button with onClick and children text', () => {
|
||||
const source = `
|
||||
function SubmitBtn() {
|
||||
return <button onClick={handleSubmit} type="submit">Submit</button>;
|
||||
}`;
|
||||
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 = () => <div className="card flex p-4">Content</div>;`;
|
||||
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 = () => <nav aria-label="Main navigation">Links</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 = () => (
|
||||
<div>
|
||||
<h1>Title</h1>
|
||||
<img src="/logo.png" alt="Logo" />
|
||||
<button>Click</button>
|
||||
</div>
|
||||
);`;
|
||||
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 = () => <div>Hello</div>;`;
|
||||
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 <img alt="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 <span>text</span>;
|
||||
};`;
|
||||
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 = () => <input disabled required type="text" />;`;
|
||||
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 = () => <br />;`;
|
||||
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 = `<div\n className="fixed"\n onClick={() => 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 = () => (
|
||||
<input
|
||||
type="email"
|
||||
aria-label="Email address"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
);`;
|
||||
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 = () => (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="menuitem"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
Menu
|
||||
</button>
|
||||
);`;
|
||||
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 = `<div
|
||||
onClick={() => value > 0 ? "yes" : "no"}
|
||||
className="test"
|
||||
>
|
||||
Content
|
||||
</div>`;
|
||||
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 = `<label
|
||||
htmlFor="username"
|
||||
className="block text-sm"
|
||||
>
|
||||
Username
|
||||
</label>`;
|
||||
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 = `<CustomComponent
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="dialog-title"
|
||||
onClose={handleClose}
|
||||
/>`;
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue