@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,273 @@
1
+ /**
2
+ * The Unity PROJECT-LEVEL structural-absence rule set (#72, child of #66) — the
3
+ * cheapest, highest-frequency negative findings for a Unity game, the analog of the
4
+ * Liquid structural-absence rules (`liquid-rules.ts`, #47/L2) one altitude up.
5
+ *
6
+ * Where the Liquid/uGUI rules are PER-WIDGET (one finding per offending node), these
7
+ * are WHOLE-PROJECT checks: each scans the entire pinned checkout once and emits ONE
8
+ * project-scoped finding, anchored on the project root, not a per-file flood. Two
9
+ * rules ship here:
10
+ *
11
+ * 1. `unity/no-screen-reader-support` — zero references to the hand-authored
12
+ * `UnityEngine.Accessibility` surface (`AccessibilityHierarchy` /
13
+ * `AccessibilityNode` / `AssistiveSupport`) anywhere in the project's `.cs`
14
+ * sources. Unity auto-bridges nothing into an accessibility tree for either uGUI
15
+ * or UI Toolkit, so absence = "the game is unusable with a screen reader." This
16
+ * is grounded: `UnityTechnologies/open-project-1` @ 608eac9 has zero such
17
+ * references across the whole repo (#66 ground truth).
18
+ * 2. `unity/no-input-rebinding` — no `.inputactions` asset anywhere AND no
19
+ * `PerformInteractiveRebinding` call in `.cs`, i.e. no path for a player to
20
+ * remap controls (a WCAG-relevant motor-accessibility gap).
21
+ *
22
+ * Each rule maps to a WCAG SC via the bridge below (the analog of
23
+ * `liquid-rules.ts`'s `RULE_WCAG` and `wcag-tags.ts`), and emits a
24
+ * {@link UnityProjectFinding} that mirrors the canonical `Finding` shape (file, line,
25
+ * ruleId, message, wcag, enforcement, provenance) so the future corpus harness (#69)
26
+ * and report can treat it uniformly. This module is intentionally SELF-CONTAINED and
27
+ * is not yet wired into any shared CLI/dispatch — that wiring is a separate child.
28
+ *
29
+ * Forgiving by construction (mirrors `scanUnity`): a missing project dir is an empty
30
+ * scan (no findings, never a throw), and one unreadable file never aborts the walk.
31
+ */
32
+
33
+ import { readdir, readFile, stat } from "node:fs/promises";
34
+ import { join, resolve } from "node:path";
35
+ import type { EnforcementLevel } from "./config-scan";
36
+
37
+ /**
38
+ * Build/library dirs that are not project source — skipped on the walk, mirroring
39
+ * `collect-unity.ts`'s `SKIP_DIRS`. `Library` and `Temp` are Unity's generated
40
+ * caches; an Accessibility reference that lives only in a generated cache is not the
41
+ * project authoring screen-reader support, so excluding them keeps the rule honest.
42
+ */
43
+ const SKIP_DIRS: ReadonlySet<string> = new Set([
44
+ "node_modules",
45
+ ".git",
46
+ "dist",
47
+ "build",
48
+ ".cache",
49
+ "Library",
50
+ "Temp",
51
+ "obj",
52
+ ]);
53
+
54
+ /**
55
+ * A project-scoped accessibility finding. Mirrors the canonical `Finding` shape
56
+ * (`core.ts`) field-for-field on the surface the report and enforcement gate read —
57
+ * but is self-contained to this module (no import of `Finding`) so its file surface
58
+ * stays disjoint from the sibling per-widget Unity rules until a later child wires a
59
+ * shared dispatch. `provenance` is the fixed literal `"unity"`, the producer tag the
60
+ * Unity static pass carries; `line` is `0` because a whole-project finding has no
61
+ * single source line (it is anchored on the project root in `file`).
62
+ */
63
+ export interface UnityProjectFinding {
64
+ /** The project root directory the finding is scoped to (absolute, resolved). */
65
+ readonly file: string;
66
+ /** Always `0` — a project-level finding has no single source line. */
67
+ readonly line: 0;
68
+ /** This rule's stable id (e.g. `unity/no-screen-reader-support`). */
69
+ readonly ruleId: string;
70
+ /** Human-readable description of the absence and its accessibility impact. */
71
+ readonly message: string;
72
+ /** The WCAG success criteria this rule maps to (via {@link wcagForUnityRule}). */
73
+ readonly wcag: readonly string[];
74
+ /** The enforcement level for this finding (defaults to `block`). */
75
+ readonly enforcement: EnforcementLevel;
76
+ /** The producer tag — always `"unity"` for the Unity static pass. */
77
+ readonly provenance: "unity";
78
+ }
79
+
80
+ /**
81
+ * The WCAG SC bridge: each Unity project-rule id → its success criteria. The analog
82
+ * of `liquid-rules.ts`'s `RULE_WCAG` (our rule ids are our own, so the mapping is
83
+ * direct). A no-screen-reader-support gap breaks Name/Role/Value (4.1.2) — controls
84
+ * expose no name/role to assistive tech — and Info-and-Relationships (1.3.1); a
85
+ * no-rebinding gap is the motor-accessibility floor, Keyboard (2.1.1) and Pointer
86
+ * Gestures / single-pointer remappability (2.5.1).
87
+ */
88
+ const RULE_WCAG: Readonly<Record<string, readonly string[]>> = {
89
+ "unity/no-screen-reader-support": ["1.3.1", "4.1.2"],
90
+ "unity/no-input-rebinding": ["2.1.1", "2.5.1"],
91
+ };
92
+
93
+ /** WCAG SCs for a Unity project-rule id (empty if unknown — never throws). */
94
+ export function wcagForUnityRule(ruleId: string): readonly string[] {
95
+ return RULE_WCAG[ruleId] ?? [];
96
+ }
97
+
98
+ /** Build a project-scoped finding with its rule's WCAG mapping pre-attached. */
99
+ function makeFinding(
100
+ root: string,
101
+ ruleId: string,
102
+ message: string,
103
+ enforcement: EnforcementLevel,
104
+ ): UnityProjectFinding {
105
+ return {
106
+ file: root,
107
+ line: 0,
108
+ ruleId,
109
+ message,
110
+ wcag: RULE_WCAG[ruleId] ?? [],
111
+ enforcement,
112
+ provenance: "unity",
113
+ };
114
+ }
115
+
116
+ /** File extensions the project-level scan inspects, by concern. */
117
+ const CS_EXTENSION = ".cs";
118
+ const INPUTACTIONS_EXTENSION = ".inputactions";
119
+
120
+ /**
121
+ * Any reference to the hand-authored `UnityEngine.Accessibility` module surface.
122
+ * Word-boundary anchored so an unrelated identifier that merely contains the substring
123
+ * is not a false match. These three types are the load-bearing surface a project must
124
+ * touch to register a screen-reader accessibility tree.
125
+ *
126
+ * Deliberately conservative: a match anywhere in `.cs` source — including a comment —
127
+ * is treated as a (present) reference, so the absence rule UNDER-reports rather than
128
+ * risk a false positive. A false "no screen-reader support" on a project that does
129
+ * author it is the failure mode that gets the tool uninstalled (the precision
130
+ * invariant the Liquid rules share); under-reporting on a project that only *mentions*
131
+ * the API in a comment is the safe direction.
132
+ */
133
+ const ACCESSIBILITY_API_RE = /\b(AccessibilityHierarchy|AccessibilityNode|AssistiveSupport)\b/;
134
+
135
+ /** The Input System call that drives interactive control rebinding. */
136
+ const REBINDING_API_RE = /\bPerformInteractiveRebinding\b/;
137
+
138
+ /** What the one-pass project walk gathers — the evidence both rules reason over. */
139
+ interface ProjectEvidence {
140
+ /** True if any non-skipped `.cs` references the `UnityEngine.Accessibility` surface. */
141
+ hasAccessibilityRef: boolean;
142
+ /** True if any `.inputactions` asset exists outside the skipped dirs. */
143
+ hasInputActionsAsset: boolean;
144
+ /** True if any non-skipped `.cs` calls `PerformInteractiveRebinding`. */
145
+ hasRebindingCall: boolean;
146
+ }
147
+
148
+ /**
149
+ * One forgiving recursive walk gathering the evidence both rules need. Skips the
150
+ * generated-cache dirs (`SKIP_DIRS`), reads each `.cs` once for both string probes,
151
+ * and notes the presence of any `.inputactions` asset. A missing/unreadable directory
152
+ * yields the empty evidence rather than throwing; one unreadable file is skipped, not
153
+ * fatal — the same forgiving contract `scanUnity` gives the CLI.
154
+ *
155
+ * Short-circuits each probe once satisfied: once all three signals are positive the
156
+ * walk can stop reading file contents (it still need not, but avoids needless I/O).
157
+ */
158
+ async function gatherEvidence(dir: string, evidence: ProjectEvidence): Promise<void> {
159
+ let entries: Awaited<ReturnType<typeof readdir>>;
160
+ try {
161
+ entries = await readdir(dir, { withFileTypes: true });
162
+ } catch {
163
+ return;
164
+ }
165
+ for (const entry of entries) {
166
+ const full = join(dir, entry.name);
167
+ if (entry.isDirectory()) {
168
+ if (SKIP_DIRS.has(entry.name)) continue;
169
+ await gatherEvidence(full, evidence);
170
+ continue;
171
+ }
172
+ if (!entry.isFile()) continue;
173
+
174
+ if (entry.name.endsWith(INPUTACTIONS_EXTENSION)) {
175
+ evidence.hasInputActionsAsset = true;
176
+ continue;
177
+ }
178
+ if (entry.name.endsWith(CS_EXTENSION)) {
179
+ // Only read if a probe still needs an answer — once both `.cs` signals are
180
+ // positive, file contents can't change the verdict.
181
+ if (evidence.hasAccessibilityRef && evidence.hasRebindingCall) continue;
182
+ let source: string;
183
+ try {
184
+ source = await readFile(full, "utf8");
185
+ } catch {
186
+ continue; // one unreadable file never aborts the scan
187
+ }
188
+ if (!evidence.hasAccessibilityRef && ACCESSIBILITY_API_RE.test(source)) {
189
+ evidence.hasAccessibilityRef = true;
190
+ }
191
+ if (!evidence.hasRebindingCall && REBINDING_API_RE.test(source)) {
192
+ evidence.hasRebindingCall = true;
193
+ }
194
+ }
195
+ }
196
+ }
197
+
198
+ /** Options for the project-level scan; `enforcement` defaults to `block`. */
199
+ export interface UnityBaselineOptions {
200
+ readonly enforcement?: EnforcementLevel;
201
+ }
202
+
203
+ /**
204
+ * Run the Unity project-level structural-absence rules over a whole project directory,
205
+ * returning the project-scoped findings (zero, one, or two). The single entry point
206
+ * the future corpus harness (#69) calls per project.
207
+ *
208
+ * - `unity/no-screen-reader-support` fires iff NO `.cs` references the
209
+ * `UnityEngine.Accessibility` surface anywhere in the project.
210
+ * - `unity/no-input-rebinding` fires iff the project has NEITHER a `.inputactions`
211
+ * asset NOR a `PerformInteractiveRebinding` call (either present → silent).
212
+ *
213
+ * A missing directory yields `[]` (an empty scan), never a throw.
214
+ */
215
+ export async function runUnityBaselineRules(
216
+ dir: string,
217
+ options: UnityBaselineOptions = {},
218
+ ): Promise<UnityProjectFinding[]> {
219
+ const root = resolve(dir);
220
+ const enforcement: EnforcementLevel = options.enforcement ?? "block";
221
+
222
+ // A non-existent project root is "no project to evaluate", not "a project with
223
+ // everything absent" — return an empty scan rather than firing every absence rule
224
+ // on nothing. (An EXISTING but empty/Accessibility-free project still fires, which
225
+ // is the #66 ground-truth case.)
226
+ try {
227
+ const info = await stat(root);
228
+ if (!info.isDirectory()) return [];
229
+ } catch {
230
+ return [];
231
+ }
232
+
233
+ const evidence: ProjectEvidence = {
234
+ hasAccessibilityRef: false,
235
+ hasInputActionsAsset: false,
236
+ hasRebindingCall: false,
237
+ };
238
+ await gatherEvidence(root, evidence);
239
+
240
+ const findings: UnityProjectFinding[] = [];
241
+
242
+ if (!evidence.hasAccessibilityRef) {
243
+ findings.push(
244
+ makeFinding(
245
+ root,
246
+ "unity/no-screen-reader-support",
247
+ "No screen-reader support present — the project references none of " +
248
+ "`AccessibilityHierarchy`, `AccessibilityNode`, or `AssistiveSupport` " +
249
+ "(the `UnityEngine.Accessibility` API). Unity bridges nothing into an " +
250
+ "accessibility tree automatically, so the game is unusable with a screen " +
251
+ "reader. Build an `AccessibilityHierarchy` of `AccessibilityNode`s and " +
252
+ "register it via `AssistiveSupport`.",
253
+ enforcement,
254
+ ),
255
+ );
256
+ }
257
+
258
+ if (!evidence.hasInputActionsAsset && !evidence.hasRebindingCall) {
259
+ findings.push(
260
+ makeFinding(
261
+ root,
262
+ "unity/no-input-rebinding",
263
+ "No input remapping present — the project ships no `.inputactions` asset and " +
264
+ "makes no `PerformInteractiveRebinding` call, so a player cannot remap " +
265
+ "controls. Fixed controls are a motor-accessibility barrier; expose a " +
266
+ "rebinding path via the Input System.",
267
+ enforcement,
268
+ ),
269
+ );
270
+ }
271
+
272
+ return findings;
273
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * jsx-a11y rule id -> WCAG Success Criteria.
3
+ *
4
+ * The map is the bridge between the lint engine's vocabulary (rule ids) and
5
+ * Binclusive's audit vocabulary (WCAG SC numbers). The corpus cross-ref
6
+ * (see `corpus.ts`) keys off the SC, so this map is what lets a source-level
7
+ * lint hit inherit real-world frequency from the dynamic-audit corpus.
8
+ *
9
+ * Rule ids are unprefixed here; the engine emits them prefixed as
10
+ * `jsx-a11y/<id>`. `wcagForRuleId` strips the prefix before lookup.
11
+ */
12
+ export const RULE_ID_TO_WCAG: Readonly<Record<string, readonly string[]>> = {
13
+ "label-has-associated-control": ["1.3.1", "4.1.2"],
14
+ "alt-text": ["1.1.1"],
15
+ "anchor-has-content": ["2.4.4"],
16
+ "anchor-is-valid": ["2.4.4"],
17
+ "aria-props": ["4.1.2"],
18
+ "role-has-required-aria-props": ["4.1.2"],
19
+ "role-supports-aria-props": ["4.1.2"],
20
+ "interactive-supports-focus": ["2.1.1"],
21
+ "click-events-have-key-events": ["2.1.1"],
22
+ "no-static-element-interactions": ["2.1.1"],
23
+ "heading-has-content": ["1.3.1"],
24
+ };
25
+
26
+ /**
27
+ * Look up the WCAG SC for a (possibly `jsx-a11y/`-prefixed) rule id.
28
+ * Returns `[]` for rules we have not mapped — the finding still reports,
29
+ * it just carries no SC and therefore no corpus enrichment.
30
+ */
31
+ export function wcagForRuleId(ruleId: string | null): readonly string[] {
32
+ if (ruleId === null) return [];
33
+ const bare = ruleId.startsWith("jsx-a11y/") ? ruleId.slice("jsx-a11y/".length) : ruleId;
34
+ return RULE_ID_TO_WCAG[bare] ?? [];
35
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The one bridge between axe-core's tag vocabulary and the corpus' WCAG SC keys.
3
+ *
4
+ * Pure — no browser, no playwright, no axe runtime. Lives apart from
5
+ * `collect-dom.ts` so BOTH the live-DOM collector and the offline
6
+ * baseline-catalog generator (`src/baseline/gen-baseline.ts`) read SCs off axe
7
+ * tags through the EXACT same function. `collect-dom.ts` re-exports `scFromTags`
8
+ * for back-compat, so existing imports keep working.
9
+ */
10
+
11
+ /**
12
+ * Read WCAG success criteria off axe-core's `tags`. axe tags an SC as
13
+ * `wcag<principle><guideline><criterion>` with the dots removed — `wcag111` is
14
+ * 1.1.1, `wcag244` is 2.4.4, `wcag1411` is 1.4.11. The principle and guideline
15
+ * are always one digit; the criterion is the remainder (so 2-or-more-digit
16
+ * criteria like `.11` round-trip). Conformance-level tags (`wcag2a`, `wcag21aa`)
17
+ * and non-WCAG tags (`best-practice`, `cat.color`, `ACT`) carry letters and are
18
+ * skipped. Deduped, original order preserved.
19
+ *
20
+ * This is the whole bridge between axe's vocabulary and the corpus: every
21
+ * `enrichAll` lookup — and the baseline catalog — keys off these SC strings.
22
+ */
23
+ export function scFromTags(tags: readonly string[]): string[] {
24
+ const out: string[] = [];
25
+ const seen = new Set<string>();
26
+ for (const tag of tags) {
27
+ const m = /^wcag(\d)(\d)(\d+)$/.exec(tag);
28
+ if (m === null) continue;
29
+ const sc = `${m[1]}.${m[2]}.${m[3]}`;
30
+ if (seen.has(sc)) continue;
31
+ seen.add(sc);
32
+ out.push(sc);
33
+ }
34
+ return out;
35
+ }