@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,405 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, parse, resolve } from "node:path";
3
+
4
+ /**
5
+ * Workspace-package resolver: follow a `@scope/pkg[/subpath]` import to its
6
+ * REAL source file by reading the monorepo's workspace config, not by trusting
7
+ * an installed `node_modules` tree.
8
+ *
9
+ * Why this exists. A customer's app imports its design system as a published-
10
+ * looking specifier (`@rallly/ui/button`), but in a monorepo that package lives
11
+ * as source under `packages/ui` and is wired up by `pnpm-workspace.yaml` /
12
+ * `package.json#workspaces`. TypeScript's own resolver only reaches it through
13
+ * the symlink farm pnpm/yarn create under `node_modules` — which a freshly-
14
+ * cloned, un-installed repo does not have. So the wrapper is reported OPAQUE
15
+ * even though its source is sitting right there in the repo.
16
+ *
17
+ * This module resolves it the way the workspace itself defines it:
18
+ * 1. Walk UP from the importing file to the workspace root (the dir holding
19
+ * `pnpm-workspace.yaml` or a `package.json` with a `workspaces` field).
20
+ * 2. Expand the workspace package globs (`packages/*`, `apps/*`, ...) to the
21
+ * concrete package directories on disk.
22
+ * 3. Match the import's package name against each candidate's
23
+ * `package.json#name`.
24
+ * 4. Resolve the import's subpath against that package's `exports` map (incl.
25
+ * `"./*"` wildcards), falling back to `main` / `module` / `types` / an
26
+ * `index.{ts,tsx,js,jsx}` for a bare package import.
27
+ *
28
+ * Everything is best-effort and boundary-parsed: a malformed manifest, an
29
+ * unmatched name, or a subpath the `exports` map doesn't cover all yield
30
+ * `null` — the caller then treats the wrapper as opaque rather than guessing.
31
+ */
32
+
33
+ /** File extensions we treat as resolvable source, in resolution-preference order. */
34
+ const SOURCE_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"] as const;
35
+
36
+ /** Conditional-export keys we read, in preference order (source over built). */
37
+ const EXPORT_CONDITIONS = ["source", "types", "import", "module", "default", "require"] as const;
38
+
39
+ /** A discovered workspace: its root dir and the package globs it declares. */
40
+ interface Workspace {
41
+ readonly root: string;
42
+ readonly globs: readonly string[];
43
+ }
44
+
45
+ const workspaceCache = new Map<string, Workspace | null>();
46
+
47
+ /**
48
+ * Find the nearest workspace root at or above `fromDir`, with its package
49
+ * globs. A `pnpm-workspace.yaml` wins; otherwise a `package.json` with a
50
+ * `workspaces` array (npm/yarn/bun). Returns `null` when no workspace contains
51
+ * the file. Cached per starting directory.
52
+ */
53
+ function findWorkspace(fromDir: string): Workspace | null {
54
+ const cached = workspaceCache.get(fromDir);
55
+ if (cached !== undefined) return cached;
56
+
57
+ let dir = fromDir;
58
+ // Walk up to the filesystem root.
59
+ for (;;) {
60
+ const pnpm = join(dir, "pnpm-workspace.yaml");
61
+ if (existsSync(pnpm)) {
62
+ const globs = parsePnpmWorkspaceGlobs(readFileSafe(pnpm));
63
+ const ws: Workspace = { root: dir, globs };
64
+ workspaceCache.set(fromDir, ws);
65
+ return ws;
66
+ }
67
+ const pkgPath = join(dir, "package.json");
68
+ if (existsSync(pkgPath)) {
69
+ const globs = parsePackageJsonWorkspaceGlobs(readFileSafe(pkgPath));
70
+ if (globs.length > 0) {
71
+ const ws: Workspace = { root: dir, globs };
72
+ workspaceCache.set(fromDir, ws);
73
+ return ws;
74
+ }
75
+ }
76
+ const parent = dirname(dir);
77
+ if (parent === dir) break;
78
+ dir = parent;
79
+ }
80
+ workspaceCache.set(fromDir, null);
81
+ return null;
82
+ }
83
+
84
+ function readFileSafe(path: string): string {
85
+ try {
86
+ return readFileSync(path, "utf8");
87
+ } catch {
88
+ return "";
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Extract the package globs from a `pnpm-workspace.yaml`. The `packages:` key
94
+ * holds a YAML list of glob strings; we read just that list with a small
95
+ * line scanner (no YAML dependency). Negated globs (`!...`) and the quote
96
+ * styles pnpm permits are handled. Anything we can't parse yields no globs.
97
+ */
98
+ function parsePnpmWorkspaceGlobs(yaml: string): string[] {
99
+ const lines = yaml.split(/\r?\n/);
100
+ const out: string[] = [];
101
+ let inPackages = false;
102
+ for (const raw of lines) {
103
+ const line = raw.replace(/#.*$/, "").trimEnd();
104
+ if (/^packages\s*:/.test(line)) {
105
+ inPackages = true;
106
+ continue;
107
+ }
108
+ if (inPackages) {
109
+ const item = /^\s*-\s*(.+)$/.exec(line);
110
+ if (item?.[1] !== undefined) {
111
+ out.push(stripYamlScalar(item[1]));
112
+ continue;
113
+ }
114
+ // A non-list, non-blank, top-level key ends the packages block.
115
+ if (line.trim() !== "" && !/^\s/.test(line)) inPackages = false;
116
+ }
117
+ }
118
+ return out.filter((g) => g !== "" && !g.startsWith("!"));
119
+ }
120
+
121
+ /** Strip surrounding quotes from a YAML scalar value. */
122
+ function stripYamlScalar(s: string): string {
123
+ const t = s.trim();
124
+ if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
125
+ return t.slice(1, -1);
126
+ }
127
+ return t;
128
+ }
129
+
130
+ /** Read `workspaces` from a `package.json` (array form, or `{ packages: [] }`). */
131
+ function parsePackageJsonWorkspaceGlobs(text: string): string[] {
132
+ const pkg = parseJson(text);
133
+ if (pkg === null) return [];
134
+ const ws = pkg.workspaces;
135
+ if (Array.isArray(ws)) return ws.filter((g): g is string => typeof g === "string");
136
+ if (typeof ws === "object" && ws !== null && !Array.isArray(ws)) {
137
+ const packages = (ws as Record<string, unknown>).packages;
138
+ if (Array.isArray(packages)) return packages.filter((g): g is string => typeof g === "string");
139
+ }
140
+ return [];
141
+ }
142
+
143
+ function parseJson(text: string): Record<string, unknown> | null {
144
+ if (text === "") return null;
145
+ try {
146
+ const v: unknown = JSON.parse(text);
147
+ return typeof v === "object" && v !== null && !Array.isArray(v)
148
+ ? (v as Record<string, unknown>)
149
+ : null;
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Expand a workspace glob (`packages/*`, `apps/*`, or a literal `packages/ui`)
157
+ * to the concrete package directories on disk. Only the single trailing `/*`
158
+ * wildcard pnpm/yarn workspaces use in practice is supported; deeper globbing
159
+ * is out of scope (and would over-reach). Each candidate must contain a
160
+ * `package.json` to count.
161
+ */
162
+ function expandGlob(root: string, glob: string): string[] {
163
+ const normalized = glob.replace(/\\/g, "/").replace(/\/$/, "");
164
+ if (normalized.endsWith("/*")) {
165
+ const base = join(root, normalized.slice(0, -2));
166
+ if (!isDir(base)) return [];
167
+ const out: string[] = [];
168
+ for (const name of readdirSafe(base)) {
169
+ const dir = join(base, name);
170
+ if (isDir(dir) && existsSync(join(dir, "package.json"))) out.push(dir);
171
+ }
172
+ return out;
173
+ }
174
+ // Literal package path.
175
+ const dir = join(root, normalized);
176
+ return isDir(dir) && existsSync(join(dir, "package.json")) ? [dir] : [];
177
+ }
178
+
179
+ function isDir(path: string): boolean {
180
+ try {
181
+ return statSync(path).isDirectory();
182
+ } catch {
183
+ return false;
184
+ }
185
+ }
186
+
187
+ function readdirSafe(path: string): string[] {
188
+ try {
189
+ return readdirSync(path);
190
+ } catch {
191
+ return [];
192
+ }
193
+ }
194
+
195
+ /** Per-workspace-root index of package-name -> package-dir, built once. */
196
+ const packageDirCache = new Map<string, ReadonlyMap<string, string>>();
197
+
198
+ function packageDirsFor(ws: Workspace): ReadonlyMap<string, string> {
199
+ const cached = packageDirCache.get(ws.root);
200
+ if (cached !== undefined) return cached;
201
+ const byName = new Map<string, string>();
202
+ for (const glob of ws.globs) {
203
+ for (const dir of expandGlob(ws.root, glob)) {
204
+ const pkg = parseJson(readFileSafe(join(dir, "package.json")));
205
+ const name = pkg?.name;
206
+ if (typeof name === "string" && !byName.has(name)) byName.set(name, dir);
207
+ }
208
+ }
209
+ packageDirCache.set(ws.root, byName);
210
+ return byName;
211
+ }
212
+
213
+ /**
214
+ * Split a bare import specifier into its package name and the subpath after it.
215
+ * `@rallly/ui/button` -> { pkg: "@rallly/ui", sub: "./button" };
216
+ * `@rallly/ui` -> { pkg: "@rallly/ui", sub: "." };
217
+ * `lodash/fp` -> { pkg: "lodash", sub: "./fp" }.
218
+ * Relative/aliased specifiers are not packages -> `null`.
219
+ */
220
+ function splitSpecifier(specifier: string): { pkg: string; sub: string } | null {
221
+ if (specifier === "" || specifier.startsWith(".") || isAbsolute(specifier)) return null;
222
+ const parts = specifier.split("/");
223
+ if (specifier.startsWith("@")) {
224
+ if (parts.length < 2) return null;
225
+ const pkg = `${parts[0]}/${parts[1]}`;
226
+ const rest = parts.slice(2).join("/");
227
+ return { pkg, sub: rest === "" ? "." : `./${rest}` };
228
+ }
229
+ const pkg = parts[0] ?? specifier;
230
+ const rest = parts.slice(1).join("/");
231
+ return { pkg, sub: rest === "" ? "." : `./${rest}` };
232
+ }
233
+
234
+ /**
235
+ * Resolve an import specifier to a workspace package's real source file, or
236
+ * `null` when it is not a workspace package (or its subpath can't be mapped).
237
+ *
238
+ * @param specifier the bare module specifier as written (`@rallly/ui/button`)
239
+ * @param fromFile the importing file, used to locate the workspace root
240
+ */
241
+ export function resolveWorkspaceImport(specifier: string, fromFile: string): string | null {
242
+ const split = splitSpecifier(specifier);
243
+ if (split === null) return null;
244
+
245
+ const ws = findWorkspace(dirname(fromFile));
246
+ if (ws === null) return null;
247
+
248
+ const pkgDir = packageDirsFor(ws).get(split.pkg);
249
+ if (pkgDir === undefined) return null;
250
+
251
+ const pkg = parseJson(readFileSafe(join(pkgDir, "package.json")));
252
+ if (pkg === null) return null;
253
+
254
+ return resolveSubpathInPackage(pkgDir, pkg, split.sub);
255
+ }
256
+
257
+ /**
258
+ * Resolve a subpath (`.` or `./button`) against a package directory, honoring
259
+ * its `exports` map first (including a `"./*"` wildcard), then the legacy
260
+ * `main`/`module`/`types` fields for the package root, then an `index.*` file.
261
+ */
262
+ function resolveSubpathInPackage(
263
+ pkgDir: string,
264
+ pkg: Record<string, unknown>,
265
+ sub: string,
266
+ ): string | null {
267
+ const fromExports = resolveViaExports(pkgDir, pkg.exports, sub);
268
+ if (fromExports !== null) return fromExports;
269
+
270
+ // No `exports` (or it didn't cover this subpath). Legacy fields only define
271
+ // the package root; a sub-path import without an `exports` map resolves as a
272
+ // file/dir relative to the package directory.
273
+ if (sub === ".") {
274
+ for (const field of ["source", "module", "main", "types"] as const) {
275
+ const target = pkg[field];
276
+ if (typeof target === "string") {
277
+ const hit = resolveFileTarget(join(pkgDir, target));
278
+ if (hit !== null) return hit;
279
+ }
280
+ }
281
+ return resolveFileTarget(join(pkgDir, "index"));
282
+ }
283
+ return resolveFileTarget(join(pkgDir, sub.slice(2)));
284
+ }
285
+
286
+ /**
287
+ * Resolve a subpath against a package's `exports` field. Handles:
288
+ * - string exports: `{ ".": "./src/index.ts" }`
289
+ * - conditional exports: `{ ".": { "import": "./...", "types": "./..." } }`
290
+ * - subpath wildcards: `{ "./*": "./src/*.tsx" }` — the matched segment is
291
+ * substituted into the target's `*`.
292
+ * Returns `null` when the subpath has no matching export entry.
293
+ */
294
+ function resolveViaExports(pkgDir: string, exportsField: unknown, sub: string): string | null {
295
+ if (typeof exportsField !== "object" || exportsField === null || Array.isArray(exportsField)) {
296
+ // A string `exports` shorthand only defines the package root.
297
+ if (typeof exportsField === "string" && sub === ".") {
298
+ return resolveFileTarget(join(pkgDir, exportsField));
299
+ }
300
+ return null;
301
+ }
302
+ const map = exportsField as Record<string, unknown>;
303
+
304
+ // Exact key first (`.`, `./button`).
305
+ if (sub in map) {
306
+ const target = pickConditionTarget(map[sub]);
307
+ if (target !== null) return resolveFileTarget(join(pkgDir, target));
308
+ }
309
+
310
+ // Wildcard keys (`./*`, `./hooks/*`). Longest static prefix wins so a more
311
+ // specific pattern (`./hooks/*`) beats the catch-all (`./*`).
312
+ const wildcards = Object.keys(map)
313
+ .filter((k) => k.includes("*"))
314
+ .sort((a, b) => b.length - a.length);
315
+ for (const key of wildcards) {
316
+ const matched = matchWildcard(key, sub);
317
+ if (matched === null) continue;
318
+ const target = pickConditionTarget(map[key]);
319
+ if (target === null) continue;
320
+ const resolvedTarget = target.replace("*", matched);
321
+ const hit = resolveFileTarget(join(pkgDir, resolvedTarget));
322
+ if (hit !== null) return hit;
323
+ }
324
+ return null;
325
+ }
326
+
327
+ /**
328
+ * Match `sub` against a `"./prefix/*suffix"` export key, returning the text the
329
+ * `*` captured, or `null` when it doesn't match. Standard Node `exports`
330
+ * semantics: exactly one `*` in the key.
331
+ */
332
+ function matchWildcard(key: string, sub: string): string | null {
333
+ const star = key.indexOf("*");
334
+ if (star === -1) return null;
335
+ const prefix = key.slice(0, star);
336
+ const suffix = key.slice(star + 1);
337
+ if (!sub.startsWith(prefix) || !sub.endsWith(suffix)) return null;
338
+ if (sub.length < prefix.length + suffix.length) return null;
339
+ return sub.slice(prefix.length, sub.length - suffix.length);
340
+ }
341
+
342
+ /**
343
+ * Reduce an `exports` entry to a single target string. A plain string is the
344
+ * target; a conditions object is probed in our preference order (source before
345
+ * built artifacts). Nested condition objects recurse. Returns `null` when no
346
+ * usable string target is found.
347
+ */
348
+ function pickConditionTarget(entry: unknown): string | null {
349
+ if (typeof entry === "string") return entry;
350
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return null;
351
+ const obj = entry as Record<string, unknown>;
352
+ for (const cond of EXPORT_CONDITIONS) {
353
+ if (cond in obj) {
354
+ const nested = pickConditionTarget(obj[cond]);
355
+ if (nested !== null) return nested;
356
+ }
357
+ }
358
+ // Fall back to the first usable target in declaration order.
359
+ for (const value of Object.values(obj)) {
360
+ const nested = pickConditionTarget(value);
361
+ if (nested !== null) return nested;
362
+ }
363
+ return null;
364
+ }
365
+
366
+ /**
367
+ * Turn a target path into an existing source file: try it verbatim, then with
368
+ * each known source extension, then as a directory `index.*`. This is what
369
+ * lets a `main: "src/index.ts"` (verbatim) and a wildcard target like
370
+ * `"./src/button"` (extension-appended) both land on a real file. Returns the
371
+ * absolute path of the first hit, or `null`.
372
+ */
373
+ function resolveFileTarget(target: string): string | null {
374
+ const abs = resolve(target);
375
+ if (isFile(abs)) return abs;
376
+ for (const ext of SOURCE_EXTS) {
377
+ if (isFile(abs + ext)) return abs + ext;
378
+ }
379
+ // `.tsx` for a wildcard like `./src/*.tsx` is handled verbatim above; here we
380
+ // also cover a directory target resolving to its index.
381
+ if (isDir(abs)) {
382
+ for (const ext of SOURCE_EXTS) {
383
+ const idx = join(abs, `index${ext}`);
384
+ if (isFile(idx)) return idx;
385
+ }
386
+ }
387
+ // A target that already carries an extension we didn't list but exists.
388
+ const { ext } = parse(abs);
389
+ if (ext !== "" && isFile(abs)) return abs;
390
+ return null;
391
+ }
392
+
393
+ function isFile(path: string): boolean {
394
+ try {
395
+ return statSync(path).isFile();
396
+ } catch {
397
+ return false;
398
+ }
399
+ }
400
+
401
+ /** Reset all caches — test-only, so fixtures don't bleed across cases. */
402
+ export function __resetWorkspaceCachesForTest(): void {
403
+ workspaceCache.clear();
404
+ packageDirCache.clear();
405
+ }