@binclusive/a11y 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +285 -0
- package/bin/a11y.mjs +36 -0
- package/bin/diff-scope.mjs +29 -0
- package/data/baseline-rules.json +892 -0
- package/package.json +68 -0
- package/src/agent-lane.ts +138 -0
- package/src/agents-block.ts +157 -0
- package/src/baseline/gen-baseline.ts +166 -0
- package/src/cli.ts +1026 -0
- package/src/collect-dom.ts +119 -0
- package/src/collect-liquid.ts +103 -0
- package/src/collect-swift.ts +227 -0
- package/src/collect-unity.ts +99 -0
- package/src/collect.ts +54 -0
- package/src/commands.ts +314 -0
- package/src/config-scan.ts +177 -0
- package/src/contract.ts +355 -0
- package/src/core.ts +546 -0
- package/src/detect-stack.ts +207 -0
- package/src/diff-scope-cli.ts +12 -0
- package/src/diff-scope.ts +150 -0
- package/src/emit-contract.ts +181 -0
- package/src/enforce.ts +1125 -0
- package/src/eslint-plugin-jsx-a11y.d.ts +11 -0
- package/src/evidence.ts +308 -0
- package/src/github-identity.ts +201 -0
- package/src/hook.ts +242 -0
- package/src/impact-gate.ts +107 -0
- package/src/imports-resolve.ts +248 -0
- package/src/index.ts +183 -0
- package/src/liquid-ast.ts +203 -0
- package/src/liquid-rules.ts +691 -0
- package/src/mcp.ts +363 -0
- package/src/module-scope.ts +137 -0
- package/src/phone-home.ts +470 -0
- package/src/pr-comment.ts +250 -0
- package/src/pr-summary-cli.ts +182 -0
- package/src/pr-summary.ts +291 -0
- package/src/registry.ts +605 -0
- package/src/reporter/contract.ts +154 -0
- package/src/reporter/finding.ts +87 -0
- package/src/reporter/github-adapter.ts +183 -0
- package/src/reporter/null-adapter.ts +51 -0
- package/src/reporter/registry.ts +22 -0
- package/src/reporter-cli.ts +48 -0
- package/src/resolve-components.ts +579 -0
- package/src/runner/budget.ts +90 -0
- package/src/runner/codegraph-lookup.test.ts +93 -0
- package/src/runner/codegraph-lookup.ts +197 -0
- package/src/runner/index.ts +86 -0
- package/src/runner/lookup.ts +69 -0
- package/src/runner/provider.ts +72 -0
- package/src/runner/providers/anthropic.ts +168 -0
- package/src/runner/providers/openai.ts +191 -0
- package/src/runner/reasoner.ts +73 -0
- package/src/runner/reasoning/index.ts +53 -0
- package/src/runner/reasoning/prompt.ts +200 -0
- package/src/runner/reasoning/react.ts +149 -0
- package/src/runner/reasoning/shopify.ts +214 -0
- package/src/runner/reasoning/skills-reasoner.ts +117 -0
- package/src/runner/reasoning/types.ts +99 -0
- package/src/runner/runner.ts +203 -0
- package/src/sarif.ts +148 -0
- package/src/source-identity.ts +0 -0
- package/src/source-trace.ts +814 -0
- package/src/suggest.ts +328 -0
- package/src/suppression-ranges.ts +354 -0
- package/src/suppressor-map.ts +189 -0
- package/src/suppressors.ts +284 -0
- package/src/tsconfig-aliases.ts +155 -0
- package/src/unity-ast.ts +331 -0
- package/src/unity-findings.ts +91 -0
- package/src/unity-guid-registry.ts +82 -0
- package/src/unity-label-resolve.ts +249 -0
- package/src/unity-rule-color-only.ts +127 -0
- package/src/unity-rule-missing-label.ts +156 -0
- package/src/unity-rules-baseline.ts +273 -0
- package/src/wcag-map.ts +35 -0
- package/src/wcag-tags.ts +35 -0
- package/src/workspace-resolve.ts +405 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A second ship-in-the-box {@link Provider} — OpenAI's Chat Completions API over
|
|
3
|
+
* global `fetch` (epic #2083, issue #2318).
|
|
4
|
+
*
|
|
5
|
+
* The {@link Provider} interface (`../provider`) is the BYO seam: nothing in the
|
|
6
|
+
* runner names a vendor. This module is the OpenAI twin of `./anthropic.ts` —
|
|
7
|
+
* ONE more concrete default so a customer with `LLM_PROVIDER=openai` + a bare
|
|
8
|
+
* `LLM_API_KEY` drives the AI lane end to end, no vendor SDK, no extra install.
|
|
9
|
+
* It maps the vendor-neutral {@link ProviderRequest} onto the Chat Completions
|
|
10
|
+
* request body and the reply back onto {@link ProviderResponse} (crucially its
|
|
11
|
+
* {@link TokenUsage} — the runner's token ceiling is only enforceable because
|
|
12
|
+
* usage is reported).
|
|
13
|
+
*
|
|
14
|
+
* Two vendor-shape differences from Anthropic, and nothing else changes:
|
|
15
|
+
* - the system prompt is a leading `role: "system"` message, not a top-level
|
|
16
|
+
* field; and
|
|
17
|
+
* - usage is `prompt_tokens` / `completion_tokens`, the reply text is
|
|
18
|
+
* `choices[].message.content`.
|
|
19
|
+
*
|
|
20
|
+
* Per the {@link Provider} contract, this SURFACES transport failures by
|
|
21
|
+
* REJECTING: a non-2xx, a malformed body, or a network error throws. The runner
|
|
22
|
+
* catches a rejected pass and records it as a non-fatal error, so a provider
|
|
23
|
+
* that throws degrades the run to "no agent findings for that pass", never a
|
|
24
|
+
* crash — the AI lane stays non-blocking.
|
|
25
|
+
*
|
|
26
|
+
* A HANG is a failure a `throw` alone does not cover, so the `fetch` is bounded
|
|
27
|
+
* by an {@link AbortController} + timeout (mirroring `./anthropic.ts`). A stalled
|
|
28
|
+
* provider — network stall, provider outage, no response — is aborted at the
|
|
29
|
+
* bound and surfaced as a rejection like any other transport failure, so the
|
|
30
|
+
* runner records the pass as errored and keeps going. Without this, a single hung
|
|
31
|
+
* request would hang the whole CI job, turning the non-blocking soft-degrade into
|
|
32
|
+
* a hard block on the customer's PR pipeline (issue #2192).
|
|
33
|
+
*/
|
|
34
|
+
import type { Provider, ProviderRequest, ProviderResponse, TokenUsage } from "../provider";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The default model for the low-cap advisory CI lane: a fast, cost-appropriate
|
|
38
|
+
* model. Overridable via the provider config (wired to `LLM_MODEL`), so dialing
|
|
39
|
+
* up to a stronger model is one env var, not a code change.
|
|
40
|
+
*/
|
|
41
|
+
export const DEFAULT_OPENAI_MODEL = "gpt-4o-mini";
|
|
42
|
+
|
|
43
|
+
const DEFAULT_BASE_URL = "https://api.openai.com";
|
|
44
|
+
/** A pass is one short suggestion; cap output when the caller doesn't ask. */
|
|
45
|
+
const DEFAULT_MAX_OUTPUT_TOKENS = 1024;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The safe default wall-clock bound on one provider request (issue #2192). A
|
|
49
|
+
* low-cap advisory completion (≤ {@link DEFAULT_MAX_OUTPUT_TOKENS} output tokens
|
|
50
|
+
* on a fast model) returns in seconds; 60s leaves generous headroom for a slow
|
|
51
|
+
* healthy response while still bounding a genuine hang, so a stalled request
|
|
52
|
+
* aborts and degrades the AI lane softly instead of stalling CI. Override via
|
|
53
|
+
* {@link OpenAIProviderConfig.timeoutMs} (wired to `LLM_TIMEOUT_MS`).
|
|
54
|
+
*/
|
|
55
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
|
56
|
+
|
|
57
|
+
export interface OpenAIProviderConfig {
|
|
58
|
+
/** The customer's OpenAI key (from `LLM_API_KEY`). Never logged. */
|
|
59
|
+
readonly apiKey: string;
|
|
60
|
+
/** Model id. Defaults to {@link DEFAULT_OPENAI_MODEL}. */
|
|
61
|
+
readonly model?: string;
|
|
62
|
+
/** Injectable for tests / alternate gateways. Defaults to `globalThis.fetch`. */
|
|
63
|
+
readonly fetchImpl?: typeof fetch;
|
|
64
|
+
/** Override the API origin (Azure OpenAI / self-hosted gateway / proxy). Defaults to OpenAI. */
|
|
65
|
+
readonly baseUrl?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Wall-clock bound on one request before it aborts (from `LLM_TIMEOUT_MS`).
|
|
68
|
+
* Defaults to {@link DEFAULT_REQUEST_TIMEOUT_MS}. A non-finite or non-positive
|
|
69
|
+
* value falls back to the default — a bad knob can't disable the bound and
|
|
70
|
+
* re-open the hang hole (issue #2192).
|
|
71
|
+
*/
|
|
72
|
+
readonly timeoutMs?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** OpenAI's `chat/completions` response, narrowed at the boundary — never trusted raw. */
|
|
76
|
+
interface OpenAIResponseBody {
|
|
77
|
+
readonly choices?: ReadonlyArray<{ readonly message?: { readonly content?: string | null } }>;
|
|
78
|
+
readonly usage?: { readonly prompt_tokens?: number; readonly completion_tokens?: number };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The output-token request field for a given model id. OpenAI's reasoning-model
|
|
83
|
+
* families (`o1` / `o3` / `o4` … and `gpt-5`) REJECT the legacy `max_tokens`
|
|
84
|
+
* field with a 400 and require `max_completion_tokens`; the `gpt-4o` line and
|
|
85
|
+
* older still take `max_tokens`. Pick by id so a customer who points `LLM_MODEL`
|
|
86
|
+
* at a reasoning model gets a well-formed request instead of a soft-degrade on
|
|
87
|
+
* every pass. An unrecognized id falls through to `max_tokens` — the field the
|
|
88
|
+
* widest set of models (and the shipped default) accept, so an ambiguous id
|
|
89
|
+
* still runs (issue #2318).
|
|
90
|
+
*/
|
|
91
|
+
function outputTokenField(model: string): "max_tokens" | "max_completion_tokens" {
|
|
92
|
+
return /^(o[1-9]|gpt-5)/i.test(model) ? "max_completion_tokens" : "max_tokens";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Concatenate the message content of the returned choices into one string. */
|
|
96
|
+
function textOf(body: OpenAIResponseBody): string {
|
|
97
|
+
if (!Array.isArray(body.choices)) return "";
|
|
98
|
+
return body.choices
|
|
99
|
+
.map((choice) => choice.message?.content)
|
|
100
|
+
.filter((content): content is string => typeof content === "string")
|
|
101
|
+
.join("");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A single reported token count, or `+Infinity` when the body reports it absent,
|
|
106
|
+
* non-numeric, `NaN`, or negative. OpenAI always reports both counts; a body
|
|
107
|
+
* that doesn't is malformed, so we parse-don't-validate at the boundary and map
|
|
108
|
+
* the bad count to a fail-closed sentinel rather than silently charging 0. The
|
|
109
|
+
* ledger meters `+Infinity` as at-or-over the ceiling, stopping the lane instead
|
|
110
|
+
* of letting a malformed response run past the cap (issue #2169).
|
|
111
|
+
*/
|
|
112
|
+
function meterableCount(raw: number | undefined): number {
|
|
113
|
+
return typeof raw === "number" && Number.isFinite(raw) && raw >= 0 ? raw : Number.POSITIVE_INFINITY;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The token usage the runner meters against the per-PR ceiling — parsed from the
|
|
118
|
+
* untrusted body, never read raw. A missing or malformed count maps to `+Infinity`
|
|
119
|
+
* (see {@link meterableCount}) so the ceiling fails closed on a bad provider
|
|
120
|
+
* response rather than under-counting toward a silent overspend.
|
|
121
|
+
*/
|
|
122
|
+
function usageOf(body: OpenAIResponseBody): TokenUsage {
|
|
123
|
+
return {
|
|
124
|
+
inputTokens: meterableCount(body.usage?.prompt_tokens),
|
|
125
|
+
outputTokens: meterableCount(body.usage?.completion_tokens),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build the ship-in-the-box OpenAI {@link Provider}. Inject into the runner's
|
|
131
|
+
* `RunInput.provider`; the harness meters every `complete` call against the token
|
|
132
|
+
* ceiling. Rejects on any transport / shape failure — the runner treats that as a
|
|
133
|
+
* non-fatal, recorded pass error.
|
|
134
|
+
*/
|
|
135
|
+
export function createOpenAIProvider(config: OpenAIProviderConfig): Provider {
|
|
136
|
+
const model = config.model ?? DEFAULT_OPENAI_MODEL;
|
|
137
|
+
const fetchImpl = config.fetchImpl ?? globalThis.fetch;
|
|
138
|
+
const endpoint = `${config.baseUrl ?? DEFAULT_BASE_URL}/v1/chat/completions`;
|
|
139
|
+
const timeoutMs =
|
|
140
|
+
typeof config.timeoutMs === "number" && Number.isFinite(config.timeoutMs) && config.timeoutMs > 0
|
|
141
|
+
? config.timeoutMs
|
|
142
|
+
: DEFAULT_REQUEST_TIMEOUT_MS;
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
async complete(request: ProviderRequest): Promise<ProviderResponse> {
|
|
146
|
+
// Bound the whole exchange — connect AND body read: a hang at either stage
|
|
147
|
+
// aborts at the timeout and surfaces as a rejection, closing the
|
|
148
|
+
// non-blocking hole a bare `throw` can't (#2192). We own this controller,
|
|
149
|
+
// so any abort here IS our timeout. The signal cancels an in-flight body
|
|
150
|
+
// read too, so a stalled response can't slip past a resolved-headers fetch.
|
|
151
|
+
const controller = new AbortController();
|
|
152
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
153
|
+
try {
|
|
154
|
+
const response = await fetchImpl(endpoint, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: {
|
|
157
|
+
"content-type": "application/json",
|
|
158
|
+
authorization: `Bearer ${config.apiKey}`,
|
|
159
|
+
},
|
|
160
|
+
body: JSON.stringify({
|
|
161
|
+
model,
|
|
162
|
+
// Reasoning models require `max_completion_tokens`; the gpt-4o line
|
|
163
|
+
// and older take `max_tokens`. Pick the field by id (outputTokenField).
|
|
164
|
+
[outputTokenField(model)]: request.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS,
|
|
165
|
+
// OpenAI carries the system framing as a leading message, not a
|
|
166
|
+
// top-level field (the one shape divergence from Anthropic).
|
|
167
|
+
messages: [
|
|
168
|
+
...(request.system !== undefined ? [{ role: "system", content: request.system }] : []),
|
|
169
|
+
...request.messages.map((m) => ({ role: m.role, content: m.content })),
|
|
170
|
+
],
|
|
171
|
+
}),
|
|
172
|
+
signal: controller.signal,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
if (!response.ok) {
|
|
176
|
+
// Surface as a rejection — the runner records the pass as a non-fatal
|
|
177
|
+
// error and keeps going. The key never reaches the message.
|
|
178
|
+
throw new Error(`OpenAI API returned HTTP ${response.status}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const body = (await response.json()) as OpenAIResponseBody;
|
|
182
|
+
return { text: textOf(body), usage: usageOf(body) };
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (controller.signal.aborted) throw new Error(`OpenAI request timed out after ${timeoutMs}ms`);
|
|
185
|
+
throw error;
|
|
186
|
+
} finally {
|
|
187
|
+
clearTimeout(timer);
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reasoning seam — the AI-lane content the harness does NOT own.
|
|
3
|
+
*
|
|
4
|
+
* The harness drives ONE pass per deterministic finding and hands the reasoner a
|
|
5
|
+
* metered provider and a capped lookup tool. What the reasoner DOES with them —
|
|
6
|
+
* the prompt/skills (#2096), the code-graph queries (#2097), and the enrich +
|
|
7
|
+
* discover logic that turns a model response into agent findings (#2098) — is
|
|
8
|
+
* behind this interface. The harness stays content-free: it meters, loops, caps,
|
|
9
|
+
* and emits; the reasoner reasons.
|
|
10
|
+
*
|
|
11
|
+
* The reasoner owns NO budget logic. It calls `ctx.provider.complete` and
|
|
12
|
+
* `ctx.lookup.lookup` as if they were unbounded; the harness has already wrapped
|
|
13
|
+
* both so the token ceiling and the per-finding lookup cap are enforced
|
|
14
|
+
* underneath. This keeps #2096/#2098 free of cap plumbing.
|
|
15
|
+
*/
|
|
16
|
+
import type { EnrichedFinding } from "../evidence";
|
|
17
|
+
import type { LookupTool } from "./lookup";
|
|
18
|
+
import type { Provider } from "./provider";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* An agent finding is an {@link EnrichedFinding} on the `corpus-agent` arm — the
|
|
22
|
+
* recall lane the emit projection routes to the contract's `agent` provenance.
|
|
23
|
+
* Narrowing the provenance to the literal makes "an agent finding not tagged as an
|
|
24
|
+
* agent finding" unrepresentable: the type itself is the guarantee that whatever
|
|
25
|
+
* the reasoner returns will project to the contract's agent arm, never the
|
|
26
|
+
* deterministic one.
|
|
27
|
+
*/
|
|
28
|
+
export type AgentFinding = EnrichedFinding & { readonly provenance: "corpus-agent" };
|
|
29
|
+
|
|
30
|
+
/** Everything one pass gets: the finding to reason about, plus the metered tools. */
|
|
31
|
+
export interface ReasonContext {
|
|
32
|
+
/** The deterministic finding this pass enriches / discovers around. */
|
|
33
|
+
readonly finding: EnrichedFinding;
|
|
34
|
+
/** Metered against the per-PR token ceiling by the harness. */
|
|
35
|
+
readonly provider: Provider;
|
|
36
|
+
/** Capped at the per-finding lookup budget by the harness. */
|
|
37
|
+
readonly lookup: LookupTool;
|
|
38
|
+
/** The declared diff scope, carried onto every emitted finding. */
|
|
39
|
+
readonly scope: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What ONE pass produces — the two agent behaviors, kept structurally distinct so
|
|
44
|
+
* "enrich" and "discover" can never be confused (issue #2098):
|
|
45
|
+
*
|
|
46
|
+
* - {@link enrichment} ENRICHES the source deterministic finding IN PLACE: a
|
|
47
|
+
* prose note the harness folds onto that finding, which stays
|
|
48
|
+
* `provenance: deterministic`. `null` means "nothing to add to this one".
|
|
49
|
+
* - {@link discoveries} DISCOVERS new issues the deterministic engine missed —
|
|
50
|
+
* standalone `corpus-agent` findings. `[]` means "found nothing new".
|
|
51
|
+
*
|
|
52
|
+
* Both come out of the SAME single model call, so a pass still costs one provider
|
|
53
|
+
* turn (the low-cap "one pass/finding" discipline the harness meters).
|
|
54
|
+
*/
|
|
55
|
+
export interface ReasonResult {
|
|
56
|
+
/** An in-place note for the SOURCE deterministic finding, or `null`. Prose, never a patch. */
|
|
57
|
+
readonly enrichment: string | null;
|
|
58
|
+
/** New `corpus-agent` findings the deterministic pass missed. `[]` is a normal empty pass. */
|
|
59
|
+
readonly discoveries: readonly AgentFinding[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The empty pass — nothing to enrich, nothing discovered. */
|
|
63
|
+
export const EMPTY_RESULT: ReasonResult = { enrichment: null, discoveries: [] };
|
|
64
|
+
|
|
65
|
+
export interface AgentReasoner {
|
|
66
|
+
/**
|
|
67
|
+
* Reason about one deterministic finding, producing a {@link ReasonResult}.
|
|
68
|
+
* Returning {@link EMPTY_RESULT} is a normal "nothing to add" pass. Rejecting is
|
|
69
|
+
* allowed — the harness records the pass as a non-fatal error and continues; a
|
|
70
|
+
* reasoner failure never fails the run.
|
|
71
|
+
*/
|
|
72
|
+
readonly reason: (ctx: ReasonContext) => Promise<ReasonResult>;
|
|
73
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reasoning-core registry — how the AI lane selects which per-framework
|
|
3
|
+
* knowledge to consult for a given deterministic finding (issue #2096, #2319).
|
|
4
|
+
*
|
|
5
|
+
* React / TSX (epic #2083) and Shopify Liquid themes (#2319) are wired. Selection
|
|
6
|
+
* is intentionally narrow: a finding no wired framework claims gets NO guidance,
|
|
7
|
+
* and the reasoner treats "no guidance" as a normal "nothing to add" pass rather
|
|
8
|
+
* than reasoning blind. Adding a framework is one entry here plus one guidance
|
|
9
|
+
* module — the parked frameworks (React Native, iOS, Android, ASP.NET, Angular,
|
|
10
|
+
* Flutter) slot in the same way.
|
|
11
|
+
*
|
|
12
|
+
* Shopify is matched BEFORE React because the `liquid` provenance is the authority
|
|
13
|
+
* for a theme finding: a theme's `assets/*.js`/`*.css` file would otherwise be
|
|
14
|
+
* claimed by React's extension list. Provenance-first keeps the two seams disjoint.
|
|
15
|
+
*/
|
|
16
|
+
import type { Finding } from "../../core";
|
|
17
|
+
import { REACT_GUIDANCE } from "./react";
|
|
18
|
+
import { SHOPIFY_GUIDANCE } from "./shopify";
|
|
19
|
+
import type { FrameworkGuidance } from "./types";
|
|
20
|
+
|
|
21
|
+
export type { ChecklistArea, Discovery, FixSeverity, FixSuggestion, FixType, FrameworkGuidance, PatternCatalogEntry } from "./types";
|
|
22
|
+
export { FIX_TYPES } from "./types";
|
|
23
|
+
export { REACT_GUIDANCE } from "./react";
|
|
24
|
+
export { SHOPIFY_GUIDANCE } from "./shopify";
|
|
25
|
+
|
|
26
|
+
/** Source-static provenances that fire on React/web JSX. */
|
|
27
|
+
const WEB_PROVENANCES: ReadonlySet<Finding["provenance"]> = new Set(["jsx-a11y", "enforce", "axe"]);
|
|
28
|
+
|
|
29
|
+
/** File extensions React guidance applies to. */
|
|
30
|
+
const REACT_EXTENSIONS: readonly string[] = [".tsx", ".jsx", ".ts", ".js", ".mjs", ".cjs"];
|
|
31
|
+
|
|
32
|
+
function isReactFinding(finding: Finding): boolean {
|
|
33
|
+
if (WEB_PROVENANCES.has(finding.provenance)) return true;
|
|
34
|
+
const file = finding.file.toLowerCase();
|
|
35
|
+
return REACT_EXTENSIONS.some((ext) => file.endsWith(ext));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A Shopify theme finding — tagged `liquid`, or a `.liquid` template/section/snippet. */
|
|
39
|
+
function isShopifyFinding(finding: Finding): boolean {
|
|
40
|
+
if (finding.provenance === "liquid") return true;
|
|
41
|
+
return finding.file.toLowerCase().endsWith(".liquid");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The framework knowledge for one finding, or `null` when no wired framework
|
|
46
|
+
* claims it. `null` is the reasoner's cue to add nothing — the honest floor when
|
|
47
|
+
* the corpus has no reasoning to ground a suggestion in.
|
|
48
|
+
*/
|
|
49
|
+
export function frameworkGuidanceFor(finding: Finding): FrameworkGuidance | null {
|
|
50
|
+
if (isShopifyFinding(finding)) return SHOPIFY_GUIDANCE;
|
|
51
|
+
if (isReactFinding(finding)) return REACT_GUIDANCE;
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projecting the reasoning core into a model conversation and reading its answer
|
|
3
|
+
* back (issue #2096). Three pure pieces, no I/O:
|
|
4
|
+
*
|
|
5
|
+
* - {@link buildSystemPrompt} folds a {@link FrameworkGuidance} (checklist +
|
|
6
|
+
* pattern catalog) into the `system` framing — this is where the ported
|
|
7
|
+
* skills prose actually reaches the model.
|
|
8
|
+
* - {@link buildUserPrompt} states the one deterministic finding to reason about.
|
|
9
|
+
* - {@link parseReasonResponse} reads the model's reply into a typed, patch-free
|
|
10
|
+
* {@link ParsedReasonResponse} — an in-place {@link FixSuggestion} enrichment
|
|
11
|
+
* (or none) plus a {@link Discovery} array — over a tolerant zod boundary
|
|
12
|
+
* (issue #2098). Every layer degrades to the valid subset: an unparseable
|
|
13
|
+
* reply, a malformed envelope, a bad enrichment, or bad discovery entries each
|
|
14
|
+
* yield the empty/partial result, never a throw, so bad model output can't fail
|
|
15
|
+
* the non-blocking run.
|
|
16
|
+
*
|
|
17
|
+
* The response contract asked of the model is SUGGESTION-ONLY: it returns prose
|
|
18
|
+
* fixes, never edits. {@link parseReasonResponse} has no path to a patch — it only
|
|
19
|
+
* ever produces prose, so "fix = suggestions only" survives the parse boundary.
|
|
20
|
+
*/
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import type { Finding } from "../../core";
|
|
23
|
+
import {
|
|
24
|
+
type Discovery,
|
|
25
|
+
FIX_TYPES,
|
|
26
|
+
type FixSuggestion,
|
|
27
|
+
type FixType,
|
|
28
|
+
type FrameworkGuidance,
|
|
29
|
+
type PatternCatalogEntry,
|
|
30
|
+
} from "./types";
|
|
31
|
+
|
|
32
|
+
/** Render one pattern-catalog entry as the model sees it. */
|
|
33
|
+
function renderPattern(p: PatternCatalogEntry): string {
|
|
34
|
+
const lines = [
|
|
35
|
+
`### ${p.id}: ${p.title}`,
|
|
36
|
+
`- Component type: ${p.componentType}`,
|
|
37
|
+
`- WCAG: ${p.wcag.join(", ")}`,
|
|
38
|
+
`- Severity default: ${p.severityDefault}`,
|
|
39
|
+
`- Fix type default: ${p.fixTypeDefault}${p.fixTypeNote ? ` (${p.fixTypeNote})` : ""}`,
|
|
40
|
+
`- Bad shape: ${p.badShape}`,
|
|
41
|
+
`- Detection hints: ${p.detectionHints}`,
|
|
42
|
+
`- Correct fix: ${p.correctFix}`,
|
|
43
|
+
`- Verification: ${p.verification}`,
|
|
44
|
+
`- False positives / exceptions: ${p.exceptions}`,
|
|
45
|
+
];
|
|
46
|
+
return lines.join("\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The `system` framing: the framework's checklist + pattern catalog, plus the
|
|
51
|
+
* suggestion-only output contract. This IS the reshaped skill — the reasoning
|
|
52
|
+
* prose the runner's AI lane consults, carried in-engine.
|
|
53
|
+
*/
|
|
54
|
+
export function buildSystemPrompt(guidance: FrameworkGuidance): string {
|
|
55
|
+
const checklist = guidance.checklist
|
|
56
|
+
.map((area) => `## ${area.title}\n${area.items.map((i) => `- ${i}`).join("\n")}`)
|
|
57
|
+
.join("\n\n");
|
|
58
|
+
const patterns = guidance.patterns.map(renderPattern).join("\n\n");
|
|
59
|
+
|
|
60
|
+
return [
|
|
61
|
+
`You are an accessibility auditor reviewing ${guidance.framework} code.`,
|
|
62
|
+
`Applies to: ${guidance.appliesTo}`,
|
|
63
|
+
"",
|
|
64
|
+
"Use the checklist and pattern catalog below as your reasoning core. Ground every",
|
|
65
|
+
"suggestion in a specific checklist item or catalogued pattern.",
|
|
66
|
+
"",
|
|
67
|
+
`# ${guidance.framework} Audit Checklist`,
|
|
68
|
+
checklist,
|
|
69
|
+
"",
|
|
70
|
+
"# Pattern Catalog",
|
|
71
|
+
patterns,
|
|
72
|
+
"",
|
|
73
|
+
"# Output contract",
|
|
74
|
+
"You SUGGEST fixes; you never apply them. Do TWO things for the finding below and",
|
|
75
|
+
"return ONLY a JSON object (no prose outside it) with this exact shape:",
|
|
76
|
+
" {",
|
|
77
|
+
' "enrichment": { "observation": string, "suggestedFix": string, "wcag": string[],',
|
|
78
|
+
` "fixType": one of ${FIX_TYPES.map((t) => `"${t}"`).join(" | ")}, "patternId"?: string } | null,`,
|
|
79
|
+
' "discoveries": [ { "observation": string, "suggestedFix": string, "rationale": string,',
|
|
80
|
+
' "confidence": "low" | "medium" | "high", "wcag": string[],',
|
|
81
|
+
` "fixType": one of ${FIX_TYPES.map((t) => `"${t}"`).join(" | ")}, "element"?: string, "patternId"?: string } ]`,
|
|
82
|
+
" }",
|
|
83
|
+
"",
|
|
84
|
+
"ENRICHMENT enriches the GIVEN deterministic finding in place — a note/fix for",
|
|
85
|
+
"THAT exact issue. Use null when you have nothing to add to it.",
|
|
86
|
+
"DISCOVERIES are NEW accessibility problems the rule engine MISSED — compositional",
|
|
87
|
+
"or contextual issues (e.g. a broken focus order across components, a duplicated",
|
|
88
|
+
"landmark, a heading-level skip) that no single-element rule would catch. Use [] when",
|
|
89
|
+
"you find nothing new; do NOT restate the given finding as a discovery.",
|
|
90
|
+
"Never emit a diff, patch, or file edit — only described fixes. Set patternId to the",
|
|
91
|
+
"matching catalog id when one applies.",
|
|
92
|
+
].join("\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The user turn: the single deterministic finding this pass reasons about. */
|
|
96
|
+
export function buildUserPrompt(finding: Finding, evidenceFix: string | null, structural: string | null): string {
|
|
97
|
+
const lines = [
|
|
98
|
+
"Reason about this deterministic finding and suggest fixes:",
|
|
99
|
+
`- rule: ${finding.ruleId}`,
|
|
100
|
+
`- message: ${finding.message}`,
|
|
101
|
+
`- wcag: ${finding.wcag.length > 0 ? finding.wcag.join(", ") : "(none mapped)"}`,
|
|
102
|
+
];
|
|
103
|
+
if (evidenceFix !== null) lines.push(`- corpus fix hint: ${evidenceFix}`);
|
|
104
|
+
if (structural !== null) lines.push(`- structural context: ${structural}`);
|
|
105
|
+
return lines.join("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The prose an agent finding carries — the suggestion folded into one message. */
|
|
109
|
+
export function suggestionMessage(s: FixSuggestion): string {
|
|
110
|
+
return `${s.observation} Suggested fix (${s.fixType}): ${s.suggestedFix}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Coerce a model-supplied `fixType` to a known label. An unknown/absent value
|
|
115
|
+
* defaults to the most conservative label — a fix we can't classify is one the
|
|
116
|
+
* developer must verify, never a silent "SAFE". Reuses {@link FIX_TYPES} as the
|
|
117
|
+
* single source of valid labels (no duplicated literal list to drift).
|
|
118
|
+
*/
|
|
119
|
+
function coerceFixType(value: unknown): FixType {
|
|
120
|
+
for (const t of FIX_TYPES) if (t === value) return t;
|
|
121
|
+
return "RUNTIME-CHECK";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The zod boundary for one suggestion. `.min(1)` rejects empty prose; `fixType`
|
|
126
|
+
* is coerced not rejected (a bad label degrades to RUNTIME-CHECK); `wcag` that
|
|
127
|
+
* isn't a clean string array degrades to `[]`. This is the structured-output
|
|
128
|
+
* parse the AC demands — malformed entries are rejected at `safeParse`, never
|
|
129
|
+
* trusted.
|
|
130
|
+
*/
|
|
131
|
+
const fixSuggestionSchema = z.object({
|
|
132
|
+
observation: z.string().min(1),
|
|
133
|
+
suggestedFix: z.string().min(1),
|
|
134
|
+
wcag: z.array(z.string()).catch([]),
|
|
135
|
+
fixType: z.unknown().transform(coerceFixType),
|
|
136
|
+
patternId: z.string().optional(),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/** A discovery is a suggestion plus the standalone-judgement fields (rationale + confidence). */
|
|
140
|
+
const discoverySchema = fixSuggestionSchema.extend({
|
|
141
|
+
rationale: z.string().min(1),
|
|
142
|
+
confidence: z.enum(["low", "medium", "high"]),
|
|
143
|
+
element: z.string().optional(),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The top-level envelope. Deliberately PERMISSIVE — every field is `unknown` and
|
|
148
|
+
* the whole thing `.catch`es to empty — so a malformed enrichment can never sink
|
|
149
|
+
* an otherwise-good discoveries array (and vice versa). Each field is re-parsed
|
|
150
|
+
* INDEPENDENTLY below, so tolerance is per-entry, not all-or-nothing.
|
|
151
|
+
*/
|
|
152
|
+
const envelopeSchema = z
|
|
153
|
+
.object({ enrichment: z.unknown().optional(), discoveries: z.unknown().optional() })
|
|
154
|
+
.catch({ enrichment: undefined, discoveries: undefined });
|
|
155
|
+
|
|
156
|
+
/** The parsed two-behavior response — an in-place enrichment (or none) plus discoveries. */
|
|
157
|
+
export interface ParsedReasonResponse {
|
|
158
|
+
readonly enrichment: FixSuggestion | null;
|
|
159
|
+
readonly discoveries: readonly Discovery[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Pull the first JSON object out of a model reply (fenced ```json block or bare). */
|
|
163
|
+
function extractJsonObject(text: string): string | null {
|
|
164
|
+
const fenced = text.match(/```(?:json)?\s*(\{[\s\S]*\})\s*```/);
|
|
165
|
+
if (fenced) return fenced[1];
|
|
166
|
+
const start = text.indexOf("{");
|
|
167
|
+
const end = text.lastIndexOf("}");
|
|
168
|
+
return start !== -1 && end > start ? text.slice(start, end + 1) : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function parseOne<T>(schema: z.ZodType<T>, value: unknown): T | null {
|
|
172
|
+
const parsed = schema.safeParse(value);
|
|
173
|
+
return parsed.success ? parsed.data : null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Read a model reply into a typed {@link ParsedReasonResponse}. Tolerant on
|
|
178
|
+
* purpose and at every layer: an unparseable reply, a malformed envelope, a bad
|
|
179
|
+
* enrichment, or bad discovery entries all degrade to the valid subset — it never
|
|
180
|
+
* throws, because the AI lane must never fail the non-blocking run.
|
|
181
|
+
*/
|
|
182
|
+
export function parseReasonResponse(text: string): ParsedReasonResponse {
|
|
183
|
+
const json = extractJsonObject(text);
|
|
184
|
+
if (json === null) return { enrichment: null, discoveries: [] };
|
|
185
|
+
let root: unknown;
|
|
186
|
+
try {
|
|
187
|
+
root = JSON.parse(json);
|
|
188
|
+
} catch {
|
|
189
|
+
return { enrichment: null, discoveries: [] };
|
|
190
|
+
}
|
|
191
|
+
const envelope = envelopeSchema.parse(root);
|
|
192
|
+
const enrichment = parseOne(fixSuggestionSchema, envelope.enrichment);
|
|
193
|
+
const rawDiscoveries = Array.isArray(envelope.discoveries) ? envelope.discoveries : [];
|
|
194
|
+
const discoveries: Discovery[] = [];
|
|
195
|
+
for (const entry of rawDiscoveries) {
|
|
196
|
+
const discovery = parseOne(discoverySchema, entry);
|
|
197
|
+
if (discovery !== null) discoveries.push(discovery);
|
|
198
|
+
}
|
|
199
|
+
return { enrichment, discoveries };
|
|
200
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The React / Next.js reasoning core — ported from the
|
|
3
|
+
* `Binclusive-Accessibility-Skills` repo's `react-nextjs.md` audit checklist and
|
|
4
|
+
* `patterns/react-nextjs-patterns.md` catalog (issue #2096). The prose is carried
|
|
5
|
+
* over faithfully; only its FORMAT changed (loose markdown → typed data) so the
|
|
6
|
+
* runner's AI lane can consult it with no external agent harness.
|
|
7
|
+
*
|
|
8
|
+
* React / TSX is the first framework wired (epic #2083); the other frameworks the
|
|
9
|
+
* skills repo covers (React Native, iOS, Android, ASP.NET, Shopify) are parked.
|
|
10
|
+
*/
|
|
11
|
+
import type { FrameworkGuidance } from "./types";
|
|
12
|
+
|
|
13
|
+
export const REACT_GUIDANCE: FrameworkGuidance = {
|
|
14
|
+
framework: "React / Next.js",
|
|
15
|
+
appliesTo: "React or Next.js web projects (.tsx / .jsx source, jsx-a11y / enforce / axe findings).",
|
|
16
|
+
checklist: [
|
|
17
|
+
{
|
|
18
|
+
title: "Framework-Specific Areas",
|
|
19
|
+
items: [
|
|
20
|
+
"Next.js App Router: audit `app/**/page.*`, `layout.*`, `template.*`, route groups, dynamic segments, loading/error/not-found files, and `[locale]` segments.",
|
|
21
|
+
"Next.js Pages Router: audit `pages/**` except API routes; include `_app`, `_document`, and layout wrappers for lang, landmarks, providers, and page title behavior.",
|
|
22
|
+
"React Router/Vite: audit route config, `<Routes>`, lazy route boundaries, layout components, and SPA route focus management.",
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
title: "High-Risk React Patterns",
|
|
27
|
+
items: [
|
|
28
|
+
"non-semantic click targets: `div/span` with `onClick`",
|
|
29
|
+
"icon-only buttons without accessible names",
|
|
30
|
+
"custom controls without role/name/state and keyboard support",
|
|
31
|
+
"modals/drawers/popovers without focus management and accessible name",
|
|
32
|
+
"forms missing label/id pairing, error association, required/autocomplete signals",
|
|
33
|
+
"table components that render data with `<div>` grids, `<td>` headers, missing `<caption>`, missing `<th scope>`, or lost header relationships after responsive rendering",
|
|
34
|
+
"route changes without focus reset or announcement",
|
|
35
|
+
"hardcoded `aria-label`, `placeholder`, `title`, `alt`, and visible strings outside i18n",
|
|
36
|
+
"animation without reduced-motion handling",
|
|
37
|
+
"Suspense/loading/toast states without live region strategy",
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
title: "React / Next.js Table Checks",
|
|
42
|
+
items: [
|
|
43
|
+
"Audit reusable table/data-grid components and inline `<table>` markup for a programmatic table name: prefer a visible `<caption>` for data tables, or use `aria-labelledby`/`aria-label` when the design intentionally has no visible caption.",
|
|
44
|
+
"Verify header cells are rendered as `<th>`, not styled `<td>`, `<div>`, or text-only wrappers.",
|
|
45
|
+
"For simple tables, verify column headers use `scope=\"col\"` and row headers use `scope=\"row\"` when applicable.",
|
|
46
|
+
"For grouped or multi-level headers, verify stable `id`/`headers` relationships or mark as `RUNTIME-CHECK` if the rendered structure cannot be proven statically.",
|
|
47
|
+
"Check component APIs such as `columns`, `header`, `accessor`, `renderHeader`, and `cell` so icon-only or custom header renderers still expose meaningful header text.",
|
|
48
|
+
"For sortable headers, verify a native `<button>` or equivalent keyboard support is used and the active sort state is exposed with `aria-sort` on the relevant header.",
|
|
49
|
+
"Check responsive table/card transforms, virtualization, sticky headers, and horizontal scroll wrappers for lost header relationships, clipped focus, or runtime-only keyboard/reader behavior.",
|
|
50
|
+
"Do not flag tables used purely for layout if they are hidden from table semantics with an appropriate presentation strategy; do flag layout tables exposed as data tables.",
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
title: "Next.js Checks",
|
|
55
|
+
items: [
|
|
56
|
+
"`<html lang>` and locale-specific `dir` handling in root layout or document",
|
|
57
|
+
"page titles and metadata per route",
|
|
58
|
+
"skip-to-content link and stable `<main id=\"main-content\">`",
|
|
59
|
+
"server/client component boundaries that hide interactive behavior in client wrappers",
|
|
60
|
+
"image `alt` for `next/image`; decorative images use empty alt",
|
|
61
|
+
"Link usage: navigational links must have href and meaningful text",
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
title: "Runtime-Only Checks",
|
|
66
|
+
items: [
|
|
67
|
+
"Mark as `RUNTIME-CHECK` when not statically provable:",
|
|
68
|
+
"color contrast and dark mode contrast",
|
|
69
|
+
"focus trap/restore inside third-party dialogs",
|
|
70
|
+
"actual screen-reader announcement order",
|
|
71
|
+
"browser zoom/reflow at 200%/400%",
|
|
72
|
+
"touch target measurements",
|
|
73
|
+
"route transition focus behavior",
|
|
74
|
+
"carousel autoplay pause behavior",
|
|
75
|
+
"responsive or virtualized table header relationships",
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
patterns: [
|
|
80
|
+
{
|
|
81
|
+
id: "PATTERN-REACT-001",
|
|
82
|
+
title: "Non-semantic click target",
|
|
83
|
+
componentType: "Button-like custom control",
|
|
84
|
+
wcag: ["2.1.1", "4.1.2"],
|
|
85
|
+
severityDefault: "Critical",
|
|
86
|
+
fixTypeDefault: "FUNCTIONAL-RISK",
|
|
87
|
+
badShape: "A `div`, `span`, layout component, or icon wrapper has `onClick` but no native semantics.",
|
|
88
|
+
detectionHints:
|
|
89
|
+
"`onClick` on non-interactive JSX elements; missing `role`, `tabIndex`, Enter/Space handler, and accessible name.",
|
|
90
|
+
correctFix:
|
|
91
|
+
"Render a native `<button type=\"button\">` for actions or `<a href>` for navigation. Use ARIA only when native replacement is not feasible.",
|
|
92
|
+
verification: "Tab reaches it, Enter/Space activates it, and screen reader announces name, role, and state.",
|
|
93
|
+
exceptions:
|
|
94
|
+
"Do not flag non-interactive containers where the handler is only delegated and an inner native control handles activation.",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: "PATTERN-REACT-002",
|
|
98
|
+
title: "Icon-only control without accessible name",
|
|
99
|
+
componentType: "IconButton / close button / carousel arrow / menu trigger",
|
|
100
|
+
wcag: ["4.1.2"],
|
|
101
|
+
severityDefault: "Serious",
|
|
102
|
+
fixTypeDefault: "SAFE",
|
|
103
|
+
fixTypeNote: "SAFE when adding a real label; FUNCTIONAL-RISK when changing structure.",
|
|
104
|
+
badShape:
|
|
105
|
+
"A button or clickable icon contains only SVG/icon content and has no visible text, `aria-label`, or `aria-labelledby`.",
|
|
106
|
+
detectionHints: "icon children, empty text content, close/search/favorite/menu SVGs.",
|
|
107
|
+
correctFix:
|
|
108
|
+
"Provide a localized accessible name that describes the action, not the icon. Hide decorative SVGs from assistive tech.",
|
|
109
|
+
verification: "Screen reader announces the intended action plus role.",
|
|
110
|
+
exceptions:
|
|
111
|
+
"Do not add `aria-label` that conflicts with visible text; prefer visible text or `aria-labelledby` when available.",
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: "PATTERN-REACT-003",
|
|
115
|
+
title: "Form field label is visual only",
|
|
116
|
+
componentType: "Input / Textarea / Select",
|
|
117
|
+
wcag: ["1.3.1", "3.3.2"],
|
|
118
|
+
severityDefault: "Serious",
|
|
119
|
+
fixTypeDefault: "SAFE",
|
|
120
|
+
badShape:
|
|
121
|
+
"Visible label text is not programmatically associated with the form control, or placeholder is the only label.",
|
|
122
|
+
detectionHints: "`<label>` without `htmlFor`, input without `id`, custom label wrapper, placeholder-only fields.",
|
|
123
|
+
correctFix:
|
|
124
|
+
"Use a stable `id` plus `<label htmlFor>`, or `aria-labelledby`; associate help/error text with `aria-describedby`.",
|
|
125
|
+
verification: "Accessibility tree exposes the intended name and description.",
|
|
126
|
+
exceptions:
|
|
127
|
+
"A control can be validly named by `aria-label` or `aria-labelledby` when no visible label is appropriate.",
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
id: "PATTERN-REACT-004",
|
|
131
|
+
title: "Data table lacks semantic headers or caption",
|
|
132
|
+
componentType: "Table / Data grid",
|
|
133
|
+
wcag: ["1.3.1", "2.4.6"],
|
|
134
|
+
severityDefault: "Serious",
|
|
135
|
+
fixTypeDefault: "SAFE",
|
|
136
|
+
fixTypeNote: "SAFE when adding caption/header semantics; RUNTIME-CHECK for virtualized or third-party grids.",
|
|
137
|
+
badShape:
|
|
138
|
+
"A data table renders without `<caption>`, uses `<td>` for headers, omits `scope`/`headers`, or renders a visual table with `<div>` elements and no equivalent grid semantics.",
|
|
139
|
+
detectionHints:
|
|
140
|
+
"reusable `Table`, `DataTable`, `Grid`, `columns` configs, `renderHeader`, sortable headers, `<table>` without `<caption>`, `<thead>` containing `<td>`.",
|
|
141
|
+
correctFix:
|
|
142
|
+
"Use native table markup for tabular data; provide a table name, semantic `<th>` headers, `scope` for simple relationships, and `id`/`headers` for complex relationships.",
|
|
143
|
+
verification:
|
|
144
|
+
"Screen reader can identify the table name and announce the correct row/column headers for representative cells.",
|
|
145
|
+
exceptions:
|
|
146
|
+
"Do not require `<caption>` for layout tables that are correctly removed from table semantics; do not force native tables for interactive widgets that correctly implement the ARIA grid pattern.",
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
};
|