@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.
Files changed (80) hide show
  1. package/README.md +285 -0
  2. package/bin/a11y.mjs +36 -0
  3. package/bin/diff-scope.mjs +29 -0
  4. package/data/baseline-rules.json +892 -0
  5. package/package.json +68 -0
  6. package/src/agent-lane.ts +138 -0
  7. package/src/agents-block.ts +157 -0
  8. package/src/baseline/gen-baseline.ts +166 -0
  9. package/src/cli.ts +1026 -0
  10. package/src/collect-dom.ts +119 -0
  11. package/src/collect-liquid.ts +103 -0
  12. package/src/collect-swift.ts +227 -0
  13. package/src/collect-unity.ts +99 -0
  14. package/src/collect.ts +54 -0
  15. package/src/commands.ts +314 -0
  16. package/src/config-scan.ts +177 -0
  17. package/src/contract.ts +355 -0
  18. package/src/core.ts +546 -0
  19. package/src/detect-stack.ts +207 -0
  20. package/src/diff-scope-cli.ts +12 -0
  21. package/src/diff-scope.ts +150 -0
  22. package/src/emit-contract.ts +181 -0
  23. package/src/enforce.ts +1125 -0
  24. package/src/eslint-plugin-jsx-a11y.d.ts +11 -0
  25. package/src/evidence.ts +308 -0
  26. package/src/github-identity.ts +201 -0
  27. package/src/hook.ts +242 -0
  28. package/src/impact-gate.ts +107 -0
  29. package/src/imports-resolve.ts +248 -0
  30. package/src/index.ts +183 -0
  31. package/src/liquid-ast.ts +203 -0
  32. package/src/liquid-rules.ts +691 -0
  33. package/src/mcp.ts +363 -0
  34. package/src/module-scope.ts +137 -0
  35. package/src/phone-home.ts +470 -0
  36. package/src/pr-comment.ts +250 -0
  37. package/src/pr-summary-cli.ts +182 -0
  38. package/src/pr-summary.ts +291 -0
  39. package/src/registry.ts +605 -0
  40. package/src/reporter/contract.ts +154 -0
  41. package/src/reporter/finding.ts +87 -0
  42. package/src/reporter/github-adapter.ts +183 -0
  43. package/src/reporter/null-adapter.ts +51 -0
  44. package/src/reporter/registry.ts +22 -0
  45. package/src/reporter-cli.ts +48 -0
  46. package/src/resolve-components.ts +579 -0
  47. package/src/runner/budget.ts +90 -0
  48. package/src/runner/codegraph-lookup.test.ts +93 -0
  49. package/src/runner/codegraph-lookup.ts +197 -0
  50. package/src/runner/index.ts +86 -0
  51. package/src/runner/lookup.ts +69 -0
  52. package/src/runner/provider.ts +72 -0
  53. package/src/runner/providers/anthropic.ts +168 -0
  54. package/src/runner/providers/openai.ts +191 -0
  55. package/src/runner/reasoner.ts +73 -0
  56. package/src/runner/reasoning/index.ts +53 -0
  57. package/src/runner/reasoning/prompt.ts +200 -0
  58. package/src/runner/reasoning/react.ts +149 -0
  59. package/src/runner/reasoning/shopify.ts +214 -0
  60. package/src/runner/reasoning/skills-reasoner.ts +117 -0
  61. package/src/runner/reasoning/types.ts +99 -0
  62. package/src/runner/runner.ts +203 -0
  63. package/src/sarif.ts +148 -0
  64. package/src/source-identity.ts +0 -0
  65. package/src/source-trace.ts +814 -0
  66. package/src/suggest.ts +328 -0
  67. package/src/suppression-ranges.ts +354 -0
  68. package/src/suppressor-map.ts +189 -0
  69. package/src/suppressors.ts +284 -0
  70. package/src/tsconfig-aliases.ts +155 -0
  71. package/src/unity-ast.ts +331 -0
  72. package/src/unity-findings.ts +91 -0
  73. package/src/unity-guid-registry.ts +82 -0
  74. package/src/unity-label-resolve.ts +249 -0
  75. package/src/unity-rule-color-only.ts +127 -0
  76. package/src/unity-rule-missing-label.ts +156 -0
  77. package/src/unity-rules-baseline.ts +273 -0
  78. package/src/wcag-map.ts +35 -0
  79. package/src/wcag-tags.ts +35 -0
  80. 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
+ };