@binclusive/a11y 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +285 -0
- package/bin/a11y.mjs +36 -0
- package/bin/diff-scope.mjs +29 -0
- package/data/baseline-rules.json +892 -0
- package/package.json +68 -0
- package/src/agent-lane.ts +138 -0
- package/src/agents-block.ts +157 -0
- package/src/baseline/gen-baseline.ts +166 -0
- package/src/cli.ts +1026 -0
- package/src/collect-dom.ts +119 -0
- package/src/collect-liquid.ts +103 -0
- package/src/collect-swift.ts +227 -0
- package/src/collect-unity.ts +99 -0
- package/src/collect.ts +54 -0
- package/src/commands.ts +314 -0
- package/src/config-scan.ts +177 -0
- package/src/contract.ts +355 -0
- package/src/core.ts +546 -0
- package/src/detect-stack.ts +207 -0
- package/src/diff-scope-cli.ts +12 -0
- package/src/diff-scope.ts +150 -0
- package/src/emit-contract.ts +181 -0
- package/src/enforce.ts +1125 -0
- package/src/eslint-plugin-jsx-a11y.d.ts +11 -0
- package/src/evidence.ts +308 -0
- package/src/github-identity.ts +201 -0
- package/src/hook.ts +242 -0
- package/src/impact-gate.ts +107 -0
- package/src/imports-resolve.ts +248 -0
- package/src/index.ts +183 -0
- package/src/liquid-ast.ts +203 -0
- package/src/liquid-rules.ts +691 -0
- package/src/mcp.ts +363 -0
- package/src/module-scope.ts +137 -0
- package/src/phone-home.ts +470 -0
- package/src/pr-comment.ts +250 -0
- package/src/pr-summary-cli.ts +182 -0
- package/src/pr-summary.ts +291 -0
- package/src/registry.ts +605 -0
- package/src/reporter/contract.ts +154 -0
- package/src/reporter/finding.ts +87 -0
- package/src/reporter/github-adapter.ts +183 -0
- package/src/reporter/null-adapter.ts +51 -0
- package/src/reporter/registry.ts +22 -0
- package/src/reporter-cli.ts +48 -0
- package/src/resolve-components.ts +579 -0
- package/src/runner/budget.ts +90 -0
- package/src/runner/codegraph-lookup.test.ts +93 -0
- package/src/runner/codegraph-lookup.ts +197 -0
- package/src/runner/index.ts +86 -0
- package/src/runner/lookup.ts +69 -0
- package/src/runner/provider.ts +72 -0
- package/src/runner/providers/anthropic.ts +168 -0
- package/src/runner/providers/openai.ts +191 -0
- package/src/runner/reasoner.ts +73 -0
- package/src/runner/reasoning/index.ts +53 -0
- package/src/runner/reasoning/prompt.ts +200 -0
- package/src/runner/reasoning/react.ts +149 -0
- package/src/runner/reasoning/shopify.ts +214 -0
- package/src/runner/reasoning/skills-reasoner.ts +117 -0
- package/src/runner/reasoning/types.ts +99 -0
- package/src/runner/runner.ts +203 -0
- package/src/sarif.ts +148 -0
- package/src/source-identity.ts +0 -0
- package/src/source-trace.ts +814 -0
- package/src/suggest.ts +328 -0
- package/src/suppression-ranges.ts +354 -0
- package/src/suppressor-map.ts +189 -0
- package/src/suppressors.ts +284 -0
- package/src/tsconfig-aliases.ts +155 -0
- package/src/unity-ast.ts +331 -0
- package/src/unity-findings.ts +91 -0
- package/src/unity-guid-registry.ts +82 -0
- package/src/unity-label-resolve.ts +249 -0
- package/src/unity-rule-color-only.ts +127 -0
- package/src/unity-rule-missing-label.ts +156 -0
- package/src/unity-rules-baseline.ts +273 -0
- package/src/wcag-map.ts +35 -0
- package/src/wcag-tags.ts +35 -0
- package/src/workspace-resolve.ts +405 -0
|
@@ -0,0 +1,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
|
+
}
|