@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,119 @@
1
+ /**
2
+ * The rendered-DOM collector: the live-URL counterpart to `collect.ts` + the
3
+ * `scan` in `core.ts`. Where that path needs `.tsx` on disk and reads a JSX AST,
4
+ * this one renders a URL in a real browser and runs axe-core against the
5
+ * resulting DOM — so it covers the ~half of real audited sites that are NOT
6
+ * React (ASP.NET/Razor, server-rendered, jQuery) AND the React ones we only
7
+ * have a live URL for, never the source.
8
+ *
9
+ * It is deliberately a SECOND PRODUCER into the same `Finding` shape, not a
10
+ * second product: the findings it emits flow through the exact same SC-keyed
11
+ * `enrichAll` corpus cross-ref and enforcement gate as the source passes. The
12
+ * only bridge needed is reading the WCAG SC off axe's `tags` (`wcag111` →
13
+ * `1.1.1`) — no rule-id crosswalk, because the corpus enriches by SC.
14
+ *
15
+ * Render engine is Playwright (real Chromium) by decision 0001: a real layout
16
+ * is what lets axe run color-contrast / target-size / reflow and resolve
17
+ * computed ARIA roles — exactly the categories a headless DOM (jsdom) is blind
18
+ * to, and a huge share of real WCAG failures.
19
+ */
20
+
21
+ import { AxeBuilder } from "@axe-core/playwright";
22
+ import { chromium } from "playwright";
23
+ import { enforcementFor } from "./config-scan";
24
+ import type { Finding } from "./core";
25
+ import { scFromTags } from "./wcag-tags";
26
+
27
+ // Re-exported so the historical `import { scFromTags } from "./collect-dom"`
28
+ // keeps working; the implementation now lives in the browser-free `wcag-tags.ts`
29
+ // so the offline baseline generator can share it without pulling in playwright.
30
+ export { scFromTags };
31
+
32
+ /** The result of rendering one URL and running axe over it. */
33
+ export interface DomScanResult {
34
+ readonly url: string;
35
+ readonly findings: readonly Finding[];
36
+ }
37
+
38
+ /** Options for {@link scanUrl}. */
39
+ export interface DomScanOptions {
40
+ /** Max ms to wait for navigation + the `load` event. Default 30_000. */
41
+ readonly timeoutMs?: number;
42
+ }
43
+
44
+ const DEFAULT_TIMEOUT_MS = 30_000;
45
+
46
+ /**
47
+ * Flatten an axe node target into a single CSS selector string. axe reports
48
+ * `target` as an array — one entry per frame for cross-frame nodes — so we join
49
+ * with the iframe-descent ` >>> ` marker axe itself uses. Nested arrays (shadow
50
+ * DOM) are flattened the same way.
51
+ */
52
+ function selectorOf(target: readonly unknown[]): string {
53
+ return target.flat(Infinity).map(String).join(" >>> ");
54
+ }
55
+
56
+ /**
57
+ * Render `url` in a real browser and return its accessibility findings as
58
+ * `Finding[]` — the same shape the source passes emit, so callers run the
59
+ * identical `enrichAll` + report path. Each axe violation expands to one finding
60
+ * per offending node (so the report points at the actual elements). WCAG SC is
61
+ * read from the violation's tags; enforcement is the zero-config default
62
+ * (`block`) since a live URL has no `binclusive.json`.
63
+ *
64
+ * The browser is always closed, even on error. The caller owns the URL's
65
+ * trustworthiness — this navigates to and executes whatever the page serves.
66
+ */
67
+ export async function scanUrl(url: string, opts: DomScanOptions = {}): Promise<DomScanResult> {
68
+ const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
69
+ const browser = await chromium.launch();
70
+ try {
71
+ // @axe-core/playwright 4.11.x rejects pages from the implicit default
72
+ // context — it requires an explicit browser.newContext().
73
+ const context = await browser.newContext();
74
+ const page = await context.newPage();
75
+ // A typo'd or unreachable URL (or a nav timeout) rejects raw inside goto;
76
+ // re-throw it as an actionable Error that names the URL, the cause, and the
77
+ // most common real-world fix (pointing at a running server, not a raw
78
+ // template file). The caller prints just this message — no stack.
79
+ try {
80
+ await page.goto(url, { waitUntil: "load", timeout });
81
+ } catch (cause) {
82
+ // Playwright's message embeds a multi-line "Call log:" block; keep only the
83
+ // first line so the actionable Error reads as one clean line, not a dump.
84
+ const raw = cause instanceof Error ? cause.message : String(cause);
85
+ const reason = raw.split("\n")[0];
86
+ throw new Error(
87
+ `Failed to load ${url}: ${reason}. Check the URL is reachable; server-side templates (.cshtml etc.) only render via a running server — point check-url at localhost.`,
88
+ );
89
+ }
90
+ const results = await new AxeBuilder({ page }).analyze();
91
+
92
+ const findings: Finding[] = [];
93
+ for (const v of results.violations) {
94
+ const wcag = scFromTags(v.tags);
95
+ const enforcement = enforcementFor(wcag, null);
96
+ for (const node of v.nodes) {
97
+ // axe's runtime IMPACT is the most accurate signal: it is computed
98
+ // against the actual rendered node. Prefer the per-node impact; fall
99
+ // back to the violation-level impact when axe leaves the node's null.
100
+ const impact = node.impact ?? v.impact ?? undefined;
101
+ findings.push({
102
+ file: url,
103
+ line: 0,
104
+ selector: selectorOf(node.target),
105
+ ruleId: v.id,
106
+ message: v.help,
107
+ wcag,
108
+ enforcement,
109
+ provenance: "axe",
110
+ ...(impact != null ? { impact } : {}),
111
+ helpUrl: v.helpUrl,
112
+ });
113
+ }
114
+ }
115
+ return { url, findings };
116
+ } finally {
117
+ await browser.close();
118
+ }
119
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The Liquid STATIC collector — L3 of the Shopify/Liquid producer (issue #50),
3
+ * the 5th producer of {@link Finding}s, parallel to the jsx-a11y structural pass
4
+ * (`core.ts`), the corpus enforce pass (`enforce.ts`), the rendered-DOM/axe pass
5
+ * (`collect-dom.ts`), and the SwiftUI static pass (`collect-swift.ts`).
6
+ *
7
+ * Unlike the SwiftUI collector, the analysis is IN-process: L1 (`liquid-ast.ts`)
8
+ * parses each `.liquid` file with `@shopify/liquid-html-parser` and L2
9
+ * (`liquid-rules.ts`) applies the structural-absence rules. This module is the
10
+ * THIN file walk + boundary: collect the `.liquid` files under a dir, parse each
11
+ * (skipping — never crashing on — a malformed file), run the rules, and stamp the
12
+ * contract-derived enforcement level onto every finding. The result mirrors
13
+ * `scanSwift`'s shape so the `check-shopify` CLI runner is a sibling of
14
+ * `runCheckSwift`.
15
+ */
16
+
17
+ import { readdir, readFile } from "node:fs/promises";
18
+ import { join, resolve } from "node:path";
19
+ import { contractForFiles, enforcementFor } from "./config-scan";
20
+ import type { Finding } from "./core";
21
+ import { parseLiquid } from "./liquid-ast";
22
+ import { runLiquidRules } from "./liquid-rules";
23
+
24
+ /** Build/vendor dirs that are not theme source — skipped on the walk, same set
25
+ * the `.tsx` walk (`collect.ts`) and the SwiftUI walk skip. */
26
+ const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".cache"]);
27
+
28
+ /**
29
+ * Recursively collect `.liquid` files under `dir`, skipping build/vendor dirs.
30
+ * A missing or unreadable directory yields `[]` rather than throwing — a
31
+ * non-existent scan target is an empty scan, the same forgiving contract the
32
+ * other collectors give the CLI.
33
+ */
34
+ export async function collectLiquidFiles(dir: string): Promise<string[]> {
35
+ const out: string[] = [];
36
+ let entries: Awaited<ReturnType<typeof readdir>>;
37
+ try {
38
+ entries = await readdir(dir, { withFileTypes: true });
39
+ } catch {
40
+ return out;
41
+ }
42
+ for (const entry of entries) {
43
+ const full = join(dir, entry.name);
44
+ if (entry.isDirectory()) {
45
+ if (SKIP_DIRS.has(entry.name)) continue;
46
+ out.push(...(await collectLiquidFiles(full)));
47
+ } else if (entry.isFile() && entry.name.endsWith(".liquid")) {
48
+ out.push(full);
49
+ }
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /** One file the parser could not handle — surfaced as a skipped-count line in the
55
+ * report, never as a crash (the precision invariant: one bad file must not take
56
+ * down a whole-theme scan). */
57
+ export interface LiquidParseError {
58
+ readonly file: string;
59
+ readonly message: string;
60
+ }
61
+
62
+ /** The result of a `.liquid` theme scan — mirrors `SwiftScanResult` plus the
63
+ * parse-error list the in-process parser can produce. */
64
+ export interface LiquidScanResult {
65
+ readonly root: string;
66
+ readonly files: readonly string[];
67
+ readonly findings: readonly Finding[];
68
+ readonly parseErrors: readonly LiquidParseError[];
69
+ }
70
+
71
+ /**
72
+ * Scan `.liquid` theme source under `dir` for static structural a11y findings.
73
+ *
74
+ * Collects the files, finds the governing `binclusive.json` (package-up, the same
75
+ * rule the jsx-a11y and SwiftUI scans use — with no contract every finding is
76
+ * `block`), then for each file parses + runs the L2 rules and stamps the
77
+ * contract-derived enforcement onto each finding (per-finding, exactly like
78
+ * `scanSwift`). A file the parser rejects is recorded in `parseErrors` and
79
+ * skipped — it never aborts the scan.
80
+ */
81
+ export async function scanLiquid(dir: string): Promise<LiquidScanResult> {
82
+ const root = resolve(dir);
83
+ const files = await collectLiquidFiles(root);
84
+ const contract = contractForFiles(files);
85
+
86
+ const findings: Finding[] = [];
87
+ const parseErrors: LiquidParseError[] = [];
88
+
89
+ for (const file of files) {
90
+ const source = await readFile(file, "utf8");
91
+ const parsed = parseLiquid(source);
92
+ if (!parsed.ok) {
93
+ parseErrors.push({ file, message: parsed.error.message });
94
+ continue;
95
+ }
96
+ const raw = runLiquidRules(parsed.ast, { file, source, enforcement: "block" });
97
+ for (const finding of raw) {
98
+ findings.push({ ...finding, enforcement: enforcementFor(finding.wcag, contract) });
99
+ }
100
+ }
101
+
102
+ return { root, files, findings, parseErrors };
103
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The SwiftUI STATIC collector — the 4th producer of {@link Finding}s, parallel
3
+ * to the jsx-a11y structural pass (`core.ts`) and the corpus enforce pass
4
+ * (`enforce.ts`).
5
+ *
6
+ * The accessibility analysis itself lives OUT of process, in a SwiftPM binary
7
+ * (`swift/A11ySwiftScan`) that parses `.swift` source with SwiftSyntax and applies
8
+ * the static rules from `plugin/skills/swiftui-a11y/SKILL.md` — crucially with the
9
+ * ancestor-climb heuristic that stops a label on the enclosing
10
+ * Button/NavigationLink/toolbar item from reading as a false positive. We can't run
11
+ * SwiftSyntax from Node, so this module is the THIN boundary: spawn the engine, read
12
+ * its JSON array from stdout, and map each raw record into a full `Finding` carrying
13
+ * `provenance: "swiftui"` and the contract-derived enforcement level — exactly the
14
+ * external-engine→Finding shape an out-of-process DOM/axe collector would use.
15
+ *
16
+ * Two invocation strategies, tried in order:
17
+ * 1. the prebuilt release binary (`swift build -c release` was run once) — fast,
18
+ * no compile on every scan;
19
+ * 2. `swift run --package-path …` — a fallback that compiles on first use, so the
20
+ * collector still works on a machine where the release build hasn't been made.
21
+ */
22
+
23
+ import { spawn } from "node:child_process";
24
+ import { existsSync, realpathSync } from "node:fs";
25
+ import { dirname, join, resolve } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import { contractForFiles, enforcementFor } from "./config-scan";
28
+ import type { Finding } from "./core";
29
+
30
+ /** The two SwiftUI static rule ids the engine emits — the contract with it. */
31
+ type SwiftRuleId = "swiftui/image-no-label" | "swiftui/control-no-name";
32
+
33
+ /**
34
+ * One raw finding as the Swift engine prints it. Mirrors `Finding.swift` exactly:
35
+ * `{ file, line, ruleId, message, wcag: ["1.1.1"], severity: "serious"|"critical" }`.
36
+ * Validated structurally at the process boundary before it becomes a `Finding`.
37
+ */
38
+ interface SwiftFinding {
39
+ readonly file: string;
40
+ readonly line: number;
41
+ readonly ruleId: SwiftRuleId;
42
+ readonly message: string;
43
+ readonly wcag: readonly string[];
44
+ readonly severity: "serious" | "critical";
45
+ }
46
+
47
+ /**
48
+ * Resolve `dir` to a canonical, symlink-free absolute path — the same namespace
49
+ * the Swift engine emits its `file` paths in (it walks via macOS `FileManager`,
50
+ * which resolves symlinks). Both the engine input and the `root` returned in
51
+ * {@link SwiftScanResult} derive from this, so the CLI's `relative(root, …)`
52
+ * always agrees with the engine's emitted paths. Falls back to a plain `resolve`
53
+ * when the path doesn't exist yet (the engine then reports the empty scan).
54
+ *
55
+ * Module-private: the canonical root is an INTERNAL namespace decision the
56
+ * collector owns. It is not exported — the CLI receives the root it must render
57
+ * against on {@link SwiftScanResult.root}, so no path helper crosses the seam.
58
+ */
59
+ function canonicalRoot(dir: string): string {
60
+ const abs = resolve(dir);
61
+ try {
62
+ return realpathSync(abs);
63
+ } catch {
64
+ return abs;
65
+ }
66
+ }
67
+
68
+ /** The in-repo location of the Swift package, resolved relative to this file. */
69
+ function packageRoot(): string {
70
+ const here = dirname(fileURLToPath(import.meta.url));
71
+ // src/collect-swift.ts -> <repo>/swift/A11ySwiftScan
72
+ return resolve(here, "..", "swift", "A11ySwiftScan");
73
+ }
74
+
75
+ /** The prebuilt release binary path, or `null` when it hasn't been built yet. */
76
+ function releaseBinary(): string | null {
77
+ const bin = join(packageRoot(), ".build", "release", "A11ySwiftScan");
78
+ return existsSync(bin) ? bin : null;
79
+ }
80
+
81
+ /**
82
+ * Build the command+args to run the engine against `dir`. Prefers the prebuilt
83
+ * release binary; falls back to `swift run --package-path` so a fresh checkout
84
+ * works without a manual build step (it just compiles on first use).
85
+ */
86
+ function engineInvocation(dir: string): { command: string; args: string[] } {
87
+ const bin = releaseBinary();
88
+ if (bin !== null) {
89
+ return { command: bin, args: [dir] };
90
+ }
91
+ return {
92
+ command: "swift",
93
+ // `--` ends `swift run`'s own option parsing so a `dir` that starts with `-`
94
+ // is passed through to the engine as a positional, never read as a flag.
95
+ args: ["run", "-c", "release", "--package-path", packageRoot(), "A11ySwiftScan", "--", dir],
96
+ };
97
+ }
98
+
99
+ /** Spawn the engine and resolve with its raw stdout (the JSON array text). */
100
+ function runEngine(dir: string): Promise<string> {
101
+ const { command, args } = engineInvocation(dir);
102
+ return new Promise<string>((resolvePromise, reject) => {
103
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
104
+ let stdout = "";
105
+ let stderr = "";
106
+ child.stdout.on("data", (chunk: Buffer) => {
107
+ stdout += chunk.toString();
108
+ });
109
+ child.stderr.on("data", (chunk: Buffer) => {
110
+ stderr += chunk.toString();
111
+ });
112
+ child.on("error", reject);
113
+ child.on("close", (code) => {
114
+ if (code === 0) {
115
+ resolvePromise(stdout);
116
+ } else {
117
+ reject(
118
+ new Error(
119
+ `A11ySwiftScan exited with code ${code ?? "null"}` +
120
+ (stderr.trim() !== "" ? `:\n${stderr.trim()}` : ""),
121
+ ),
122
+ );
123
+ }
124
+ });
125
+ });
126
+ }
127
+
128
+ /**
129
+ * Boundary-parse the engine's stdout into validated {@link SwiftFinding}s. A
130
+ * malformed record is dropped rather than smuggling untyped data inward — the
131
+ * engine is trusted, but this is the one place its output crosses into TS, so we
132
+ * narrow it explicitly (the same discipline `corpus.ts` uses at the JSON
133
+ * boundary).
134
+ */
135
+ export function parseSwiftFindings(raw: string): SwiftFinding[] {
136
+ const text = raw.trim();
137
+ if (text === "") return [];
138
+ let data: unknown;
139
+ try {
140
+ data = JSON.parse(text);
141
+ } catch (err) {
142
+ // The engine contract is a JSON array on stdout. Non-JSON here means the
143
+ // engine misbehaved (a stray log line, a partial write, a crash banner) —
144
+ // fail loud with a one-line, actionable error instead of letting a raw
145
+ // SyntaxError bubble untyped across the boundary.
146
+ const detail = err instanceof Error ? err.message : String(err);
147
+ throw new Error(`A11ySwiftScan produced non-JSON output (${detail})`);
148
+ }
149
+ if (!Array.isArray(data)) return [];
150
+ const out: SwiftFinding[] = [];
151
+ for (const item of data) {
152
+ if (typeof item !== "object" || item === null) continue;
153
+ const r = item as Record<string, unknown>;
154
+ if (
155
+ typeof r.file === "string" &&
156
+ typeof r.line === "number" &&
157
+ (r.ruleId === "swiftui/image-no-label" || r.ruleId === "swiftui/control-no-name") &&
158
+ typeof r.message === "string" &&
159
+ Array.isArray(r.wcag) &&
160
+ r.wcag.every((w) => typeof w === "string") &&
161
+ (r.severity === "serious" || r.severity === "critical")
162
+ ) {
163
+ out.push({
164
+ file: r.file,
165
+ line: r.line,
166
+ ruleId: r.ruleId,
167
+ message: r.message,
168
+ wcag: r.wcag as readonly string[],
169
+ severity: r.severity,
170
+ });
171
+ }
172
+ }
173
+ return out;
174
+ }
175
+
176
+ /**
177
+ * The full output of a SwiftUI scan, parallel to `scan`: the findings plus the
178
+ * canonical, symlink-free `root` the collector actually scanned in. The CLI
179
+ * renders `relative(root, …)` against THIS — so the collector owns its path
180
+ * namespace end-to-end and the CLI imports no path helper from here.
181
+ */
182
+ export interface SwiftScanResult {
183
+ readonly root: string;
184
+ readonly findings: readonly Finding[];
185
+ }
186
+
187
+ /**
188
+ * Scan `.swift` source under `dir` for static SwiftUI accessibility findings.
189
+ *
190
+ * Shells to the Swift engine, parses its JSON, and maps each raw record into a
191
+ * full {@link Finding} — `provenance: "swiftui"`, the engine's `file`/`line`/
192
+ * `ruleId`/`message`/`wcag`, and the enforcement level the governing
193
+ * `binclusive.json` assigns to that finding's WCAG SC (or `block` with no
194
+ * contract, the historical default). The engine's own `severity` is folded into
195
+ * the message so it survives into the report without widening the `Finding`
196
+ * shape — the TS side stays a pure mirror of the existing collectors.
197
+ */
198
+ export async function scanSwift(dir: string): Promise<SwiftScanResult> {
199
+ // Canonicalize: the engine walks the tree via macOS `FileManager`, which
200
+ // resolves symlinks (e.g. `/tmp` → `/private/tmp`), so every emitted `file`
201
+ // is a real path. Feeding it the real root keeps the engine's output and the
202
+ // report's `relative(root, …)` base in the same namespace — otherwise a
203
+ // symlinked root renders broken `../../private/…` paths. We return this `root`
204
+ // so the CLI renders against the exact namespace the engine emitted in.
205
+ const root = canonicalRoot(dir);
206
+ const raw = await runEngine(root);
207
+ const swiftFindings = parseSwiftFindings(raw);
208
+
209
+ // The contract that governs these files, found by walking up from them — same
210
+ // package-up rule the jsx-a11y scan uses (`contractForFiles`). With no
211
+ // `binclusive.json` every finding is `block`.
212
+ const contract = contractForFiles(swiftFindings.map((f) => f.file));
213
+
214
+ const findings: Finding[] = swiftFindings.map((f) => ({
215
+ file: f.file,
216
+ line: f.line,
217
+ ruleId: f.ruleId,
218
+ // Carry the engine's severity in the message so the existing report path
219
+ // (which has no `severity` field) still surfaces it.
220
+ message: `[${f.severity}] ${f.message}`,
221
+ wcag: f.wcag,
222
+ enforcement: enforcementFor(f.wcag, contract),
223
+ provenance: "swiftui",
224
+ }));
225
+
226
+ return { root, findings };
227
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The Unity STATIC collector — L3 of the Unity producer (issue #71, ADR 0004), the
3
+ * analog of `collect-liquid.ts` for the Unity ecosystem.
4
+ *
5
+ * This is the THIN file walk + boundary: collect the `.prefab` / `.unity` assets
6
+ * under a project dir, parse each into a node graph (or an OPAQUE state) via L1
7
+ * (`unity-ast.ts`), and hand back a per-asset result the structural-absence rules
8
+ * (later children #70/#72/#73) will walk. Identity resolution rides on the built-in
9
+ * widget GUID registry (`unity-guid-registry.ts`).
10
+ *
11
+ * Mirrors `scanLiquid`'s shape: a forgiving walk (a missing dir is an empty scan),
12
+ * and one bad/binary asset is recorded as opaque, never a crash — opaque is reported,
13
+ * not silently skipped (ADR 0004). This slice emits no `Finding`s yet (the rules are
14
+ * separate children); it stands up the producer + graph the rules attach to.
15
+ */
16
+
17
+ import { readdir, readFile } from "node:fs/promises";
18
+ import { join, resolve } from "node:path";
19
+ import { parseUnityDocument, type UnityParseResult } from "./unity-ast";
20
+
21
+ /** Build/library dirs that are not project source — skipped on the walk. `Library`
22
+ * and `Temp` are Unity's generated caches; the rest match the other collectors. */
23
+ const SKIP_DIRS = new Set([
24
+ "node_modules",
25
+ ".git",
26
+ "dist",
27
+ "build",
28
+ ".cache",
29
+ "Library",
30
+ "Temp",
31
+ "obj",
32
+ ]);
33
+
34
+ /** The Unity serialized-asset extensions this slice reads: uGUI prefab YAML and
35
+ * scene YAML. (`.uxml`/`.inputactions` are out of scope — ADR 0004 / #71.) */
36
+ const UNITY_EXTENSIONS = [".prefab", ".unity"] as const;
37
+
38
+ const isUnityAsset = (name: string): boolean =>
39
+ UNITY_EXTENSIONS.some((ext) => name.endsWith(ext));
40
+
41
+ /**
42
+ * Recursively collect `.prefab` / `.unity` files under `dir`, skipping build/library
43
+ * dirs. A missing or unreadable directory yields `[]` rather than throwing — a
44
+ * non-existent scan target is an empty scan, the forgiving contract the other
45
+ * collectors give the CLI.
46
+ */
47
+ export async function collectUnityFiles(dir: string): Promise<string[]> {
48
+ const out: string[] = [];
49
+ let entries: Awaited<ReturnType<typeof readdir>>;
50
+ try {
51
+ entries = await readdir(dir, { withFileTypes: true });
52
+ } catch {
53
+ return out;
54
+ }
55
+ for (const entry of entries) {
56
+ const full = join(dir, entry.name);
57
+ if (entry.isDirectory()) {
58
+ if (SKIP_DIRS.has(entry.name)) continue;
59
+ out.push(...(await collectUnityFiles(full)));
60
+ } else if (entry.isFile() && isUnityAsset(entry.name)) {
61
+ out.push(full);
62
+ }
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /** One scanned asset and its parse outcome — a walkable graph, or an OPAQUE state
68
+ * (binary / unparseable) surfaced rather than skipped. */
69
+ export interface UnityAsset {
70
+ readonly file: string;
71
+ readonly parse: UnityParseResult;
72
+ }
73
+
74
+ /** The result of a Unity project scan — mirrors `LiquidScanResult`'s shape with
75
+ * per-asset parse outcomes in place of findings (rules are later children). */
76
+ export interface UnityScanResult {
77
+ readonly root: string;
78
+ readonly files: readonly string[];
79
+ readonly assets: readonly UnityAsset[];
80
+ }
81
+
82
+ /**
83
+ * Scan Unity serialized source under `dir`. Collects the assets, then parses each into
84
+ * a graph or an opaque state. A binary (non-Force-Text) or unparseable asset is
85
+ * recorded as `{ kind: "opaque", … }` on its `UnityAsset` and the scan continues — one
86
+ * bad asset never aborts the scan, and the opaque state is reported, not hidden.
87
+ */
88
+ export async function scanUnity(dir: string): Promise<UnityScanResult> {
89
+ const root = resolve(dir);
90
+ const files = await collectUnityFiles(root);
91
+
92
+ const assets: UnityAsset[] = [];
93
+ for (const file of files) {
94
+ const source = await readFile(file, "utf8");
95
+ assets.push({ file, parse: parseUnityDocument(source) });
96
+ }
97
+
98
+ return { root, files, assets };
99
+ }
package/src/collect.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Recursively collect the `.tsx` source files under a directory — the scan
3
+ * target set shared by the `check` command and stack detection. Build and
4
+ * generated dirs are skipped: generated Relay artifacts (`__generated__`) are
5
+ * not source and would only add noise.
6
+ */
7
+
8
+ import { readdir } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+
11
+ const SKIP_DIRS = new Set([
12
+ "node_modules",
13
+ ".next",
14
+ ".turbo",
15
+ "__generated__",
16
+ ".git",
17
+ "dist",
18
+ // Test scaffolding is not shipped UI — its `.tsx` files are fixtures/specs,
19
+ // not pages a visitor ever loads. Skipping the dir keeps both the scan and
20
+ // stack detection focused on production code (Saleor's findings were ~20%
21
+ // test scaffolding before this).
22
+ "__tests__",
23
+ "__mocks__",
24
+ ]);
25
+
26
+ /**
27
+ * A test/spec file by name: `Foo.test.tsx`, `Foo.spec.tsx`, and the `stories`
28
+ * variants. These ship in the repo but never render to a real visitor, so an
29
+ * a11y finding in one is noise, not a defect. Matched on the filename so it
30
+ * holds regardless of directory layout.
31
+ */
32
+ function isTestFile(name: string): boolean {
33
+ return /\.(test|spec|stories)\.tsx$/.test(name);
34
+ }
35
+
36
+ /**
37
+ * Recursively collect `.tsx` files under `dir`, skipping build/generated dirs
38
+ * and test scaffolding (`*.test.tsx` / `*.spec.tsx`, `__tests__/`). Shipped UI
39
+ * only — the unit under accessibility test.
40
+ */
41
+ export async function collectTsx(dir: string): Promise<string[]> {
42
+ const out: string[] = [];
43
+ const entries = await readdir(dir, { withFileTypes: true });
44
+ for (const entry of entries) {
45
+ const full = join(dir, entry.name);
46
+ if (entry.isDirectory()) {
47
+ if (SKIP_DIRS.has(entry.name)) continue;
48
+ out.push(...(await collectTsx(full)));
49
+ } else if (entry.isFile() && entry.name.endsWith(".tsx") && !isTestFile(entry.name)) {
50
+ out.push(full);
51
+ }
52
+ }
53
+ return out;
54
+ }