@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
package/src/core.ts
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
import type { Impact } from "@binclusive/a11y-contract";
|
|
2
|
+
import tsParser from "@typescript-eslint/parser";
|
|
3
|
+
import { ESLint, type Linter } from "eslint";
|
|
4
|
+
import jsxA11y from "eslint-plugin-jsx-a11y";
|
|
5
|
+
import ts from "typescript";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The 4-level axe runtime impact a finding can carry — the contract's canonical
|
|
9
|
+
* {@link Impact} vocabulary minus `unknown` (a finding either observed a concrete
|
|
10
|
+
* axe impact or carries none; "not judged" is modeled by ABSENCE, so the wire
|
|
11
|
+
* maps an absent impact to the contract's `unknown` at the boundary). Derived
|
|
12
|
+
* from the contract enum so the runtime scale can never drift from it.
|
|
13
|
+
*/
|
|
14
|
+
export type AxeImpact = Exclude<Impact, "unknown">;
|
|
15
|
+
import {
|
|
16
|
+
commonBaseDir,
|
|
17
|
+
contractForFiles,
|
|
18
|
+
type EnforcementLevel,
|
|
19
|
+
enforcementFor,
|
|
20
|
+
fileIgnoreMatcher,
|
|
21
|
+
ignoredRuleIds,
|
|
22
|
+
} from "./config-scan";
|
|
23
|
+
import type { Contract } from "./contract";
|
|
24
|
+
import { enforceContent } from "./enforce";
|
|
25
|
+
import { isRouterLinkControl } from "./registry";
|
|
26
|
+
import {
|
|
27
|
+
type ComponentResolution,
|
|
28
|
+
type Coverage,
|
|
29
|
+
type ResolvedComponents,
|
|
30
|
+
resolveComponents,
|
|
31
|
+
} from "./resolve-components";
|
|
32
|
+
import {
|
|
33
|
+
ariaHiddenLineRanges,
|
|
34
|
+
isContentSuppressed,
|
|
35
|
+
spreadChildrenLineRanges,
|
|
36
|
+
transInjectedLineRanges,
|
|
37
|
+
} from "./suppression-ranges";
|
|
38
|
+
import { wcagForRuleId } from "./wcag-map";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Where a finding came from. Two producers, deliberately distinct so the report
|
|
42
|
+
* (and dedupe) can tell them apart:
|
|
43
|
+
*
|
|
44
|
+
* - `jsx-a11y` — the structural lint pass. Fires only on elements it can see
|
|
45
|
+
* as a host (intrinsic tags + wrappers resolved to a host via the component
|
|
46
|
+
* map). Misses opaque/trusted design-system components entirely.
|
|
47
|
+
* - `enforce` — the corpus-driven call-site content check. Recognizes the
|
|
48
|
+
* control TYPE at the call site (resolved host / registry / name heuristic)
|
|
49
|
+
* and checks the app-owned content (name/alt/label/link-text) — so it fires
|
|
50
|
+
* on opaque/trusted components the structural pass can't reach. This is the
|
|
51
|
+
* recall win: "trusted" stops being false reassurance.
|
|
52
|
+
* - `axe` — the rendered-DOM collector (see `collect-dom.ts`): a live URL
|
|
53
|
+
* is rendered in a real browser and axe-core runs against the resulting DOM.
|
|
54
|
+
* Source-blind by design — it covers non-React sites and live pages we have
|
|
55
|
+
* no `.tsx` for, and it sees what static analysis can't (color-contrast,
|
|
56
|
+
* computed roles, real rendered text). Anchored by `selector`, not a line.
|
|
57
|
+
* - `swiftui` — the SwiftUI static collector (see `collect-swift.ts`): an
|
|
58
|
+
* out-of-process SwiftSyntax engine parses `.swift` source and applies the
|
|
59
|
+
* SwiftUI accessibility rules (missing image label, unlabeled control) with
|
|
60
|
+
* the ancestor-climb heuristic. Anchored by `file:line` like the source
|
|
61
|
+
* passes; the native counterpart of the jsx-a11y structural pass.
|
|
62
|
+
* - `corpus-agent` — the corpus-grounded RECALL layer (RFC Phase 1): an agent
|
|
63
|
+
* matches the scanned code against the distilled corpus slice and the
|
|
64
|
+
* server-side gate stack disposes. **Never produced by `scan()`** — only by
|
|
65
|
+
* the future `review_a11y` MCP tool, and always quarantined into a SEPARATE
|
|
66
|
+
* `recall` field (see {@link ScanResult}). Advisory only: `enforcement` is
|
|
67
|
+
* always `"warn"`, `layer` is always `"recall"`, and it can never reach the
|
|
68
|
+
* CLI exit code. This keeps `scan()`'s output byte-identical and the floor's
|
|
69
|
+
* precision intact.
|
|
70
|
+
*/
|
|
71
|
+
export type FindingProvenance =
|
|
72
|
+
| "jsx-a11y"
|
|
73
|
+
| "enforce"
|
|
74
|
+
| "axe"
|
|
75
|
+
| "swiftui"
|
|
76
|
+
| "liquid"
|
|
77
|
+
| "unity"
|
|
78
|
+
| "corpus-agent";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Which layer a finding belongs to. `floor` is the deterministic static floor
|
|
82
|
+
* (jsx-a11y / enforce / axe / swiftui) — it gates the CLI exit code. `recall` is
|
|
83
|
+
* the corpus-agent layer — advisory, quarantined, never exit-code-affecting.
|
|
84
|
+
* Floor findings carry no `layer` (it defaults to `floor`); only the recall
|
|
85
|
+
* layer tags it explicitly.
|
|
86
|
+
*/
|
|
87
|
+
export type FindingLayer = "floor" | "recall";
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* How sure the agent lane is about a DISCOVERED finding — the recall layer's
|
|
91
|
+
* self-reported judgement strength. Set only on `corpus-agent` discoveries (never
|
|
92
|
+
* on a deterministic floor finding, which is reproducible, not a judgement). It is
|
|
93
|
+
* advisory metadata for local rendering; it never reaches the CLI exit code and,
|
|
94
|
+
* like every agent field, has no home on the metadata-only wire contract.
|
|
95
|
+
*/
|
|
96
|
+
export type AgentConfidence = "low" | "medium" | "high";
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A single accessibility finding. A jsx-a11y finding is normalized off an
|
|
100
|
+
* ESLint message; an enforce finding is produced by the call-site content check
|
|
101
|
+
* (see `enforce.ts`). Both carry the same shape so the report and enforcement
|
|
102
|
+
* gate treat them uniformly.
|
|
103
|
+
*
|
|
104
|
+
* `wcag` is derived from `ruleId` via {@link wcagForRuleId} for jsx-a11y, or set
|
|
105
|
+
* directly by the enforce rule that fired; an empty array means we recognized
|
|
106
|
+
* the rule fired but have no WCAG mapping for it yet (the finding still surfaces).
|
|
107
|
+
*
|
|
108
|
+
* `enforcement` is the contract's policy for this finding's SC (`block` gates
|
|
109
|
+
* the CLI exit code, `warn` only surfaces). With no `binclusive.json` every
|
|
110
|
+
* finding is `block` — the historical behavior.
|
|
111
|
+
*
|
|
112
|
+
* `provenance` tags which pass produced it (see {@link FindingProvenance}).
|
|
113
|
+
*/
|
|
114
|
+
export interface Finding {
|
|
115
|
+
readonly file: string;
|
|
116
|
+
readonly line: number;
|
|
117
|
+
readonly ruleId: string;
|
|
118
|
+
readonly message: string;
|
|
119
|
+
readonly wcag: readonly string[];
|
|
120
|
+
readonly enforcement: EnforcementLevel;
|
|
121
|
+
readonly provenance: FindingProvenance;
|
|
122
|
+
/**
|
|
123
|
+
* Which layer produced this finding (see {@link FindingLayer}). Absent on the
|
|
124
|
+
* static floor passes (treated as `floor`); set to `recall` only on
|
|
125
|
+
* `corpus-agent` findings, the quarantine signal the dedup + result shape rely
|
|
126
|
+
* on.
|
|
127
|
+
*/
|
|
128
|
+
readonly layer?: FindingLayer;
|
|
129
|
+
/**
|
|
130
|
+
* The distilled corpus pattern id this finding matched. Set only on
|
|
131
|
+
* `corpus-agent` (recall) findings — it is the key for self-dedup
|
|
132
|
+
* (`file:line:patternId`) and the provenance back to the corpus slice that
|
|
133
|
+
* grounded it. Absent on every static-floor pass.
|
|
134
|
+
*/
|
|
135
|
+
readonly patternId?: string;
|
|
136
|
+
/**
|
|
137
|
+
* Where the finding lives in a rendered DOM: the CSS selector axe-core
|
|
138
|
+
* reports for the offending node. Set only on `axe` findings (`file` holds the
|
|
139
|
+
* page URL and `line` is 0 — a live DOM has no source line). Absent on the
|
|
140
|
+
* source-level passes, which anchor with `file:line`.
|
|
141
|
+
*/
|
|
142
|
+
readonly selector?: string;
|
|
143
|
+
/**
|
|
144
|
+
* axe-core's per-node runtime IMPACT for this finding — the single most
|
|
145
|
+
* accurate impact, computed by axe against the actual rendered node. Set
|
|
146
|
+
* only on `axe` findings (the source passes have no axe runtime). The corpus
|
|
147
|
+
* enrich step prefers this over the static baseline impact when present.
|
|
148
|
+
* Absent ⇒ not judged (the wire maps that to the contract's `unknown`).
|
|
149
|
+
*/
|
|
150
|
+
readonly impact?: AxeImpact;
|
|
151
|
+
/**
|
|
152
|
+
* axe-core's Deque-University help URL for the rule that fired. Set only on
|
|
153
|
+
* `axe` findings; the baseline catalog supplies the same URL for source-pass
|
|
154
|
+
* findings via the SC lookup.
|
|
155
|
+
*/
|
|
156
|
+
readonly helpUrl?: string;
|
|
157
|
+
/**
|
|
158
|
+
* The agent lane's IN-PLACE enrichment of a DETERMINISTIC finding — a prose
|
|
159
|
+
* note / fix suggestion the agent attached without changing what the finding
|
|
160
|
+
* IS. The finding stays `provenance: deterministic` (an automated rule still
|
|
161
|
+
* surfaced it); this only adds AI judgement on top. Prose, never a patch —
|
|
162
|
+
* suggestions-not-patches is structural (this is a string, not an edit). Local
|
|
163
|
+
* only: the wire contract's deterministic arm has no field for it, so an
|
|
164
|
+
* enrichment never crosses the metadata-only boundary.
|
|
165
|
+
*/
|
|
166
|
+
readonly agentNote?: string;
|
|
167
|
+
/**
|
|
168
|
+
* The agent lane's confidence in a DISCOVERED finding (see {@link AgentConfidence}).
|
|
169
|
+
* Set only on `corpus-agent` findings the agent surfaced that no deterministic
|
|
170
|
+
* pass caught — never on a floor finding.
|
|
171
|
+
*/
|
|
172
|
+
readonly confidence?: AgentConfidence;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The jsx-a11y rules we score, pinned to "error" so every one surfaces
|
|
177
|
+
* regardless of the plugin's recommended on/off defaults. Each id here has a
|
|
178
|
+
* WCAG mapping in `wcag-map.ts`; keeping the two lists aligned is what makes
|
|
179
|
+
* a finding corpus-enrichable.
|
|
180
|
+
*/
|
|
181
|
+
const SCORED_RULES: readonly string[] = [
|
|
182
|
+
"label-has-associated-control",
|
|
183
|
+
"alt-text",
|
|
184
|
+
"anchor-has-content",
|
|
185
|
+
"anchor-is-valid",
|
|
186
|
+
"aria-props",
|
|
187
|
+
"role-has-required-aria-props",
|
|
188
|
+
"role-supports-aria-props",
|
|
189
|
+
"interactive-supports-focus",
|
|
190
|
+
"click-events-have-key-events",
|
|
191
|
+
"no-static-element-interactions",
|
|
192
|
+
"heading-has-content",
|
|
193
|
+
];
|
|
194
|
+
// NOTE: jsx-a11y's `prefer-tag-over-role` is deliberately NOT enabled here. Run
|
|
195
|
+
// wholesale it is ~90% noise — it fires on `<svg role="img" aria-label>` (the
|
|
196
|
+
// correct accessible-SVG pattern), `role="status"`, custom `role="combobox"`
|
|
197
|
+
// widgets, etc. We ship a SCOPED version instead (`enforce/prefer-tag-over-role`
|
|
198
|
+
// in enforce.ts) limited to landmark/structural roles with one clean native tag.
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The router-Link destination prop. react-router / Remix `Link` / `NavLink`
|
|
202
|
+
* render `<a>` but carry the navigation target on `to`, NOT `href` — so once
|
|
203
|
+
* such a wrapper is mapped to host `a` (a `binclusive.json` `components`
|
|
204
|
+
* declaration), `anchor-is-valid` reads the literal missing `href` and false-
|
|
205
|
+
* positives on every valid `<Link to="/route">`. The fix is jsx-a11y's own
|
|
206
|
+
* `specialLink` lever: it ALIASES `to` onto the rule's href check, so a valid
|
|
207
|
+
* `to` satisfies it. It narrows the rule, it does NOT disable it — an empty
|
|
208
|
+
* `to=""` or `to="#"` still lands in the rule's invalid-href branch and flags.
|
|
209
|
+
*/
|
|
210
|
+
const ROUTER_LINK_HREF_PROP = "to";
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Whether any resolved wrapper that landed in the jsx-a11y component map is a
|
|
214
|
+
* react-router / Remix link control mapped to host `a` — i.e. the user mapped
|
|
215
|
+
* `Link`/`NavLink` → `a` in `binclusive.json`. ONLY then do we alias `to` onto
|
|
216
|
+
* `anchor-is-valid` (see {@link ROUTER_LINK_HREF_PROP}). When no router Link is
|
|
217
|
+
* mapped, the rule config is byte-identical to before — zero-config and
|
|
218
|
+
* non-router scans (and the matrix baseline) are untouched.
|
|
219
|
+
*
|
|
220
|
+
* Matches on `r.imported` (the original export name), NOT `r.name` (the local
|
|
221
|
+
* JSX alias): a repo may `import { Link as RouterLink }` and map `RouterLink` ->
|
|
222
|
+
* `a`, so the alias is `RouterLink` while the export the registry recognizes is
|
|
223
|
+
* `Link`. Keying off the alias would silently disarm the fix for aliased imports.
|
|
224
|
+
*/
|
|
225
|
+
function mapsRouterLinkToAnchor(resolutions: readonly ComponentResolution[]): boolean {
|
|
226
|
+
return resolutions.some(
|
|
227
|
+
(r) => r.host === "a" && isRouterLinkControl(r.module, r.imported),
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function buildRuleConfig(aliasRouterLinkHref: boolean): Linter.RulesRecord {
|
|
232
|
+
const rules: Linter.RulesRecord = {};
|
|
233
|
+
for (const id of SCORED_RULES) {
|
|
234
|
+
rules[`jsx-a11y/${id}`] =
|
|
235
|
+
id === "anchor-is-valid" && aliasRouterLinkHref
|
|
236
|
+
? // `specialLink: ['to']` adds `to` to the props that satisfy the href
|
|
237
|
+
// requirement, so a router `<Link to="/route">` is valid — while an
|
|
238
|
+
// empty `to=""`/`to="#"` still flags via the invalid-href aspect.
|
|
239
|
+
["error", { specialLink: [ROUTER_LINK_HREF_PROP] }]
|
|
240
|
+
: "error";
|
|
241
|
+
}
|
|
242
|
+
return rules;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function buildESLint(
|
|
246
|
+
cwd: string,
|
|
247
|
+
components: Readonly<Record<string, string>>,
|
|
248
|
+
aliasRouterLinkHref: boolean,
|
|
249
|
+
): ESLint {
|
|
250
|
+
return new ESLint({
|
|
251
|
+
cwd,
|
|
252
|
+
// Self-contained: ignore any eslintrc / flat config found on disk so the
|
|
253
|
+
// checker behaves identically wherever it runs.
|
|
254
|
+
overrideConfigFile: true,
|
|
255
|
+
errorOnUnmatchedPattern: false,
|
|
256
|
+
overrideConfig: [
|
|
257
|
+
{
|
|
258
|
+
files: ["**/*.tsx"],
|
|
259
|
+
plugins: { "jsx-a11y": jsxA11y },
|
|
260
|
+
languageOptions: {
|
|
261
|
+
parser: tsParser,
|
|
262
|
+
parserOptions: {
|
|
263
|
+
ecmaFeatures: { jsx: true },
|
|
264
|
+
sourceType: "module",
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
settings: {
|
|
268
|
+
"jsx-a11y": {
|
|
269
|
+
polymorphicPropName: "as",
|
|
270
|
+
// Derived per-scan from the registry + source-tracer, never a
|
|
271
|
+
// hardcoded design-system list.
|
|
272
|
+
components,
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
rules: buildRuleConfig(aliasRouterLinkHref),
|
|
276
|
+
},
|
|
277
|
+
],
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The full output of a scan: findings plus the component-map coverage report. */
|
|
282
|
+
export interface ScanResult {
|
|
283
|
+
readonly findings: readonly Finding[];
|
|
284
|
+
readonly coverage: Coverage;
|
|
285
|
+
readonly resolved: ResolvedComponents;
|
|
286
|
+
/**
|
|
287
|
+
* The `binclusive.json` that governed this scan (found at or above the files),
|
|
288
|
+
* or `null` when the scan ran zero-config. Surfaced so the CLI can report what
|
|
289
|
+
* policy was applied without re-loading the file.
|
|
290
|
+
*/
|
|
291
|
+
readonly contract: Contract | null;
|
|
292
|
+
/**
|
|
293
|
+
* The source files this run SUCCESSFULLY ANALYZED — ABSOLUTE paths, one per
|
|
294
|
+
* `.tsx` file ESLint parsed without a fatal error (`fatalErrorCount === 0`). This
|
|
295
|
+
* is the source-scan-scope coverage set 4b's reconcile keys on (ADR 0043): a source
|
|
296
|
+
* ticket resolves iff its file is in this set AND its fingerprint wasn't re-emitted.
|
|
297
|
+
*
|
|
298
|
+
* TWO halves, both load-bearing (the whole no-false-resolve guarantee):
|
|
299
|
+
* - INCLUDES zero-finding files — a clean analyzed file MUST be present, else its
|
|
300
|
+
* already-open ticket can never resolve.
|
|
301
|
+
* - EXCLUDES attempted-but-failed files (`fatalErrorCount > 0` — parse error /
|
|
302
|
+
* fatal) — a file we could not analyze must NOT be here, or its ticket
|
|
303
|
+
* false-resolves (a compliance lie: "fixed" when we simply never looked).
|
|
304
|
+
*
|
|
305
|
+
* Paths are ABSOLUTE here (the local-finding convention — a `Finding.file` is
|
|
306
|
+
* likewise absolute); they are narrowed to repo-relative at the emit boundary via
|
|
307
|
+
* the SAME `repoRelativePath(file, root)` that mints a source finding's wire
|
|
308
|
+
* `path`, so `scannedPaths` membership matches `mf.path` by construction.
|
|
309
|
+
*/
|
|
310
|
+
readonly analyzedFiles: readonly string[];
|
|
311
|
+
/**
|
|
312
|
+
* QUARANTINE (RFC Phase 1d). The corpus-agent RECALL findings ride here, in a
|
|
313
|
+
* field SEPARATE from `findings` — never mixed in, never gating the CLI exit
|
|
314
|
+
* code, never `enforcement:"block"`. `scan()` itself ALWAYS leaves this empty
|
|
315
|
+
* (`[]`): only the future `review_a11y` recall layer populates it, after the
|
|
316
|
+
* agent returns and the server-side gate stack disposes. Because the recall
|
|
317
|
+
* findings live outside `findings`, `matrix:check` (which snapshots `findings`)
|
|
318
|
+
* is structurally unable to see them — a stochastic count can never flip the
|
|
319
|
+
* regression gate.
|
|
320
|
+
*/
|
|
321
|
+
readonly recall: readonly Finding[];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const EMPTY_COVERAGE: Coverage = {
|
|
325
|
+
total: 0,
|
|
326
|
+
declared: 0,
|
|
327
|
+
registry: 0,
|
|
328
|
+
traced: 0,
|
|
329
|
+
opaque: 0,
|
|
330
|
+
trusted: 0,
|
|
331
|
+
icons: 0,
|
|
332
|
+
structural: 0,
|
|
333
|
+
declare: 0,
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Scan `.tsx` files: derive the wrapper->host component map (declared +
|
|
338
|
+
* registry + source-trace), run eslint-plugin-jsx-a11y with that map, and return
|
|
339
|
+
* the normalized findings plus the map's coverage report. Non-`.tsx` paths are
|
|
340
|
+
* ignored. Parser errors are skipped (not a11y findings).
|
|
341
|
+
*
|
|
342
|
+
* Config-OPTIONAL. If a `binclusive.json` exists at or above the scanned files
|
|
343
|
+
* its escape-hatch declarations + enforcement policy are applied:
|
|
344
|
+
*
|
|
345
|
+
* - `components` merge into the wrapper map (provenance `declared`, override)
|
|
346
|
+
* - `injectsChildren` extend the runtime child-injection suppression
|
|
347
|
+
* - `ignore` globs drop matching files before they are linted
|
|
348
|
+
* - `ignore` rule ids drop findings for that rule ("off")
|
|
349
|
+
* - `enforcement` tags each finding `block` vs `warn`
|
|
350
|
+
*
|
|
351
|
+
* With NO contract the behavior is exactly the historical one: every wrapper
|
|
352
|
+
* auto-resolved, no extra suppression, no files skipped, every finding `block`.
|
|
353
|
+
*/
|
|
354
|
+
export async function scan(filePaths: readonly string[]): Promise<ScanResult> {
|
|
355
|
+
const allTsx = filePaths.filter((p) => p.endsWith(".tsx"));
|
|
356
|
+
const contract = contractForFiles(allTsx);
|
|
357
|
+
const declarations = contract?.declarations ?? null;
|
|
358
|
+
|
|
359
|
+
// Drop ignored files BEFORE linting — they never enter the scan, so they
|
|
360
|
+
// contribute neither findings nor coverage noise.
|
|
361
|
+
const isIgnoredFile =
|
|
362
|
+
declarations === null ? () => false : fileIgnoreMatcher(declarations.ignore);
|
|
363
|
+
const tsxPaths = allTsx.filter((p) => !isIgnoredFile(p));
|
|
364
|
+
|
|
365
|
+
if (tsxPaths.length === 0) {
|
|
366
|
+
const empty: ResolvedComponents = { map: {}, coverage: EMPTY_COVERAGE, resolutions: [], unresolvedPackages: [], sourceFiles: new Map() };
|
|
367
|
+
return { findings: [], coverage: empty.coverage, resolved: empty, contract, analyzedFiles: [], recall: [] };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const resolved = resolveComponents(tsxPaths, declarations?.components ?? {});
|
|
371
|
+
const eslint = buildESLint(
|
|
372
|
+
commonBaseDir(tsxPaths),
|
|
373
|
+
resolved.map,
|
|
374
|
+
mapsRouterLinkToAnchor(resolved.resolutions),
|
|
375
|
+
);
|
|
376
|
+
const results = await eslint.lintFiles([...tsxPaths]);
|
|
377
|
+
|
|
378
|
+
const injectsChildren = declarations?.injectsChildren ?? [];
|
|
379
|
+
const dropRules = declarations === null ? new Set<string>() : ignoredRuleIds(declarations.ignore);
|
|
380
|
+
|
|
381
|
+
// Per-file line ranges of elements where a content-family finding is a false
|
|
382
|
+
// positive. Two sources, merged:
|
|
383
|
+
// - runtime child injection (`<Trans>`/`render=` + the customer's own
|
|
384
|
+
// `injectsChildren` helpers) — the element is contentful at render time.
|
|
385
|
+
// - `aria-hidden` — the element is out of the a11y tree, so "empty
|
|
386
|
+
// link/heading" doesn't apply.
|
|
387
|
+
// Computed once per file and reused, since a file can carry many findings.
|
|
388
|
+
const suppressRangesCache = new Map<string, ReturnType<typeof transInjectedLineRanges>>();
|
|
389
|
+
const suppressRangesFor = (filePath: string): ReturnType<typeof transInjectedLineRanges> => {
|
|
390
|
+
const cached = suppressRangesCache.get(filePath);
|
|
391
|
+
if (cached !== undefined) return cached;
|
|
392
|
+
const text = ts.sys.readFile(filePath);
|
|
393
|
+
const ranges =
|
|
394
|
+
text === undefined
|
|
395
|
+
? []
|
|
396
|
+
: (() => {
|
|
397
|
+
const file = ts.createSourceFile(
|
|
398
|
+
filePath,
|
|
399
|
+
text,
|
|
400
|
+
ts.ScriptTarget.Latest,
|
|
401
|
+
true,
|
|
402
|
+
ts.ScriptKind.TSX,
|
|
403
|
+
);
|
|
404
|
+
return [
|
|
405
|
+
...transInjectedLineRanges(file, injectsChildren),
|
|
406
|
+
...ariaHiddenLineRanges(file),
|
|
407
|
+
...spreadChildrenLineRanges(file),
|
|
408
|
+
];
|
|
409
|
+
})();
|
|
410
|
+
suppressRangesCache.set(filePath, ranges);
|
|
411
|
+
return ranges;
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
// The source-scan-scope coverage set (ADR 0043): the files ESLint SUCCESSFULLY
|
|
415
|
+
// analyzed. A fatal parse error (`fatalErrorCount > 0`) means the file was
|
|
416
|
+
// attempted but NOT analyzed — excluded, so its open ticket cannot false-resolve.
|
|
417
|
+
// Zero-finding files have `fatalErrorCount === 0` and are kept, so a fixed file
|
|
418
|
+
// is present and its ticket can resolve. This mirrors the Liquid pass's
|
|
419
|
+
// parse-error partition (`collect-liquid.ts`).
|
|
420
|
+
const analyzedFiles = results.filter((r) => r.fatalErrorCount === 0).map((r) => r.filePath);
|
|
421
|
+
|
|
422
|
+
const findings: Finding[] = [];
|
|
423
|
+
for (const result of results) {
|
|
424
|
+
for (const msg of result.messages) {
|
|
425
|
+
// Skip fatal parse errors — not a11y findings.
|
|
426
|
+
if (msg.fatal === true || msg.ruleId === null) continue;
|
|
427
|
+
// Source files carry inline `eslint-disable` directives for rules we
|
|
428
|
+
// don't load (e.g. @next/next/*, react-hooks/*). ESLint surfaces those
|
|
429
|
+
// as "Definition for rule ... was not found" problems. They are not
|
|
430
|
+
// accessibility findings — keep only jsx-a11y hits.
|
|
431
|
+
if (!msg.ruleId.startsWith("jsx-a11y/")) continue;
|
|
432
|
+
// `ignore` rule ids turn a rule OFF — drop every finding for it.
|
|
433
|
+
if (dropRules.has(msg.ruleId)) continue;
|
|
434
|
+
// Drop content-family findings on elements that are contentful at runtime
|
|
435
|
+
// (`<Trans>`, `render=`, declared helper) OR removed from the a11y tree
|
|
436
|
+
// (`aria-hidden`) — the "empty element" premise doesn't hold for either.
|
|
437
|
+
if (isContentSuppressed(msg.ruleId, msg.line, suppressRangesFor(result.filePath))) continue;
|
|
438
|
+
const wcag = wcagForRuleId(msg.ruleId);
|
|
439
|
+
findings.push({
|
|
440
|
+
file: result.filePath,
|
|
441
|
+
line: msg.line,
|
|
442
|
+
ruleId: msg.ruleId,
|
|
443
|
+
message: msg.message,
|
|
444
|
+
wcag,
|
|
445
|
+
enforcement: enforcementFor(wcag, contract),
|
|
446
|
+
provenance: "jsx-a11y",
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Corpus-driven call-site content check. Recognizes the control TYPE at the
|
|
452
|
+
// call site (resolved host / registry / name heuristic) and flags app-owned
|
|
453
|
+
// content that is clearly missing — INCLUDING on opaque/trusted components the
|
|
454
|
+
// structural pass above can never reach. Conservative: dynamic/spread/computed
|
|
455
|
+
// content is "incomplete", not a violation, and is never flagged.
|
|
456
|
+
const enforceFindings = enforceContent(tsxPaths, {
|
|
457
|
+
resolutions: resolved.resolutions,
|
|
458
|
+
declarations,
|
|
459
|
+
contract,
|
|
460
|
+
});
|
|
461
|
+
// Dedupe: an element the structural pass already flagged (resolved-to-host)
|
|
462
|
+
// must not be double-reported by the enforce pass.
|
|
463
|
+
const merged = [...findings, ...dedupeEnforce(enforceFindings, findings)];
|
|
464
|
+
// `scan()` produces NO corpus-agent findings — the recall layer is the only
|
|
465
|
+
// producer, and it rides the quarantined `recall` field, never `findings`.
|
|
466
|
+
// Keeping it empty here is what makes `scan()` output byte-identical.
|
|
467
|
+
return { findings: merged, coverage: resolved.coverage, resolved, contract, analyzedFiles, recall: [] };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Drop every enforce finding that the structural jsx-a11y pass already reported
|
|
472
|
+
* for the SAME element, so a wrapper resolved to a host (and flagged there)
|
|
473
|
+
* isn't double-counted by the call-site check.
|
|
474
|
+
*
|
|
475
|
+
* The match key is (file, line, shared WCAG SC): both passes anchor a finding
|
|
476
|
+
* to the element's opening-tag line and tag it with the same SC family
|
|
477
|
+
* (button-no-name → 4.1.2, img-no-alt → 1.1.1, link-no-name → 2.4.4, ...). When
|
|
478
|
+
* jsx-a11y has already fired on that line for an overlapping SC, the enforce
|
|
479
|
+
* finding is redundant. When it hasn't — the opaque/trusted case the structural
|
|
480
|
+
* pass can't see — the enforce finding is the NEW recall the check exists for.
|
|
481
|
+
*/
|
|
482
|
+
function dedupeEnforce(
|
|
483
|
+
enforce: readonly Finding[],
|
|
484
|
+
jsxA11y: readonly Finding[],
|
|
485
|
+
): readonly Finding[] {
|
|
486
|
+
const covered = new Set<string>();
|
|
487
|
+
for (const f of jsxA11y) {
|
|
488
|
+
for (const sc of f.wcag) covered.add(`${f.file}:${f.line}:${sc}`);
|
|
489
|
+
}
|
|
490
|
+
return enforce.filter((f) => !f.wcag.some((sc) => covered.has(`${f.file}:${f.line}:${sc}`)));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Dedup the corpus-agent RECALL candidates against the static floor and against
|
|
495
|
+
* each other (RFC Phase 1d). Two mechanical passes, reusing `dedupeEnforce`'s
|
|
496
|
+
* key discipline (`file:line:sc`):
|
|
497
|
+
*
|
|
498
|
+
* 1. CROSS-dedup — drop any candidate that shares `file:line` AND any WCAG SC
|
|
499
|
+
* with a STATIC finding. The floor already caught it; the recall layer
|
|
500
|
+
* exists for what the floor MISSES, so a co-located same-SC hit is
|
|
501
|
+
* redundant. (A missing floor finding is NOT permission to flag — that is
|
|
502
|
+
* G4's abstention veto, not this dedup; this only removes "floor already
|
|
503
|
+
* caught it.")
|
|
504
|
+
* 2. SELF-dedup — collapse PATTERNED candidates by `(file, line, patternId)`,
|
|
505
|
+
* keeping the first (the same pattern matched twice on one element is one
|
|
506
|
+
* finding). A patternless candidate (no `patternId`) is EXEMPT: two distinct
|
|
507
|
+
* patternless discoveries at one `file:line` are genuinely different findings,
|
|
508
|
+
* so each is kept rather than collapsed onto the shared `file:line:` key (#2180).
|
|
509
|
+
*
|
|
510
|
+
* Pure and model-free: a deterministic filter the recall layer applies AFTER the
|
|
511
|
+
* agent returns and BEFORE quarantining the survivors into `recall`. Order is
|
|
512
|
+
* stable — survivors keep their input order.
|
|
513
|
+
*/
|
|
514
|
+
export function dedupeRecall(
|
|
515
|
+
candidates: readonly Finding[],
|
|
516
|
+
staticFindings: readonly Finding[],
|
|
517
|
+
): readonly Finding[] {
|
|
518
|
+
// 1 — CROSS-dedup against the static floor: identical `file:line:sc` covered-set
|
|
519
|
+
// discipline as the enforce pass, so reuse it verbatim rather than re-derive it.
|
|
520
|
+
const crossDeduped = dedupeEnforce(candidates, staticFindings);
|
|
521
|
+
// 2 — SELF-dedup by `(file, line, patternId)`, keeping the first survivor. Only
|
|
522
|
+
// PATTERNED candidates dedup: a patternless discovery has no identity to key on,
|
|
523
|
+
// so two distinct ones at one `file:line` must not collapse onto `file:line:` and
|
|
524
|
+
// silently drop a real finding (#2180) — each patternless candidate is kept.
|
|
525
|
+
const seen = new Set<string>();
|
|
526
|
+
const out: Finding[] = [];
|
|
527
|
+
for (const f of crossDeduped) {
|
|
528
|
+
const patternId = f.patternId ?? "";
|
|
529
|
+
if (patternId !== "") {
|
|
530
|
+
const key = `${f.file}:${f.line}:${patternId}`;
|
|
531
|
+
if (seen.has(key)) continue;
|
|
532
|
+
seen.add(key);
|
|
533
|
+
}
|
|
534
|
+
out.push(f);
|
|
535
|
+
}
|
|
536
|
+
return out;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Convenience wrapper returning just the findings. Retained for callers that
|
|
541
|
+
* don't need the coverage report.
|
|
542
|
+
*/
|
|
543
|
+
export async function checkFiles(filePaths: readonly string[]): Promise<Finding[]> {
|
|
544
|
+
const { findings } = await scan(filePaths);
|
|
545
|
+
return [...findings];
|
|
546
|
+
}
|