@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,207 @@
1
+ /**
2
+ * Generic stack detection for any customer repo — never hardcoded to one
3
+ * design system. Three independent signals, each boundary-parsed:
4
+ *
5
+ * - framework + router : `package.json` deps + `app/` vs `pages/` on disk
6
+ * - designSystem : the dominant component-source MODULE across all
7
+ * resolved wrappers (registry + traced), so the team's
8
+ * actual UI library wins regardless of which one it is
9
+ * - language : tsconfig presence
10
+ *
11
+ * The design-system signal reuses `resolveComponents` (the same map the scanner
12
+ * builds) so detection and scanning agree on what a "component import" is.
13
+ */
14
+
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
+ import type { Language, Router, Stack } from "./contract";
18
+ import { familyLabel, isFrameworkPrimitive, isOwnModule, packageNameOf } from "./module-scope";
19
+ import { resolveComponents } from "./resolve-components";
20
+ import { ownAliasMatcherFor } from "./tsconfig-aliases";
21
+
22
+ // Re-export the shared module-scoping rules so existing importers of
23
+ // `packageNameOf` from this module keep working; the rules now live in
24
+ // `module-scope.ts` so `suggest.ts` shares them without a circular import.
25
+ export { isFrameworkPrimitive, isOwnModule, packageNameOf } from "./module-scope";
26
+
27
+ /**
28
+ * Walk UP from `dir` (inclusive) to the nearest ancestor containing `marker`,
29
+ * stopping at the filesystem root. Returns that directory, or `null` when no
30
+ * ancestor has it. This is package-up semantics: a scan pointed at a nested
31
+ * `src/` still finds the app's `package.json` / `tsconfig.json` one or more
32
+ * levels above, so framework/language detection doesn't silently degrade to
33
+ * "unknown".
34
+ */
35
+ function findUp(dir: string, marker: string): string | null {
36
+ let cur = dir;
37
+ for (;;) {
38
+ if (existsSync(join(cur, marker))) return cur;
39
+ const parent = dirname(cur);
40
+ if (parent === cur) return null;
41
+ cur = parent;
42
+ }
43
+ }
44
+
45
+ /** A `package.json`'s merged dependency names — the only fields we read. */
46
+ interface PackageDeps {
47
+ readonly all: ReadonlyMap<string, string>;
48
+ }
49
+
50
+ /**
51
+ * Read and boundary-parse a `package.json` into its merged dep map. A missing
52
+ * file yields an empty map (not every repo has one at the scanned root); a
53
+ * present-but-malformed file also degrades to empty rather than throwing —
54
+ * stack detection is best-effort signal, not a hard gate.
55
+ */
56
+ function readPackageDeps(dir: string): PackageDeps {
57
+ const path = join(dir, "package.json");
58
+ if (!existsSync(path)) return { all: new Map() };
59
+ let raw: unknown;
60
+ try {
61
+ raw = JSON.parse(readFileSync(path, "utf8"));
62
+ } catch {
63
+ return { all: new Map() };
64
+ }
65
+ if (typeof raw !== "object" || raw === null) return { all: new Map() };
66
+ const merged = new Map<string, string>();
67
+ for (const key of ["dependencies", "devDependencies", "peerDependencies"] as const) {
68
+ const section = (raw as Record<string, unknown>)[key];
69
+ if (typeof section !== "object" || section === null) continue;
70
+ for (const [name, version] of Object.entries(section)) {
71
+ if (typeof version === "string") merged.set(name, version);
72
+ }
73
+ }
74
+ return { all: merged };
75
+ }
76
+
77
+ /**
78
+ * Framework detected from a repo's dependency NAMES, with a generic fallback.
79
+ * Exported (taking a name set, not the whole `PackageDeps`) so the ordering
80
+ * rules are unit-testable without a fixture on disk.
81
+ */
82
+ export function detectFrameworkFromDeps(names: ReadonlySet<string>): string {
83
+ // Order matters: meta-frameworks before the view library they build on.
84
+ if (names.has("next")) return "next";
85
+ if (names.has("@remix-run/react") || names.has("@remix-run/node")) return "remix";
86
+ // React Router v7 "framework mode" is the Remix successor — it dropped the
87
+ // `@remix-run/*` packages for `@react-router/*` + `react-router`, so a RRv7
88
+ // app (Documenso) would otherwise fall through to the bare `react` label. We
89
+ // gate on the framework-mode packages, NOT bare `react-router`: a plain SPA
90
+ // that uses react-router only for client routing is still just `react`.
91
+ if (names.has("@react-router/node") || names.has("@react-router/serve")) {
92
+ return "react-router";
93
+ }
94
+ if (names.has("astro")) return "astro";
95
+ if (names.has("gatsby")) return "gatsby";
96
+ if (names.has("@angular/core")) return "angular";
97
+ if (names.has("vue")) return "vue";
98
+ if (names.has("svelte")) return "svelte";
99
+ if (names.has("react")) return "react";
100
+ return "unknown";
101
+ }
102
+
103
+ /** Framework detected from deps, with a generic fallback. */
104
+ function detectFramework(deps: PackageDeps): string {
105
+ return detectFrameworkFromDeps(new Set(deps.all.keys()));
106
+ }
107
+
108
+ /**
109
+ * Next router from on-disk layout: `app/` (or `src/app/`) is the App Router,
110
+ * `pages/` (or `src/pages/`) is the Pages Router. Only meaningful for Next —
111
+ * `null` for every other framework. App wins if both exist (incremental
112
+ * migrations keep `pages/` around).
113
+ */
114
+ function detectRouter(dir: string, framework: string): Router {
115
+ if (framework !== "next") return null;
116
+ const hasApp = existsSync(join(dir, "app")) || existsSync(join(dir, "src", "app"));
117
+ if (hasApp) return "app";
118
+ const hasPages = existsSync(join(dir, "pages")) || existsSync(join(dir, "src", "pages"));
119
+ if (hasPages) return "pages";
120
+ return null;
121
+ }
122
+
123
+ /** Language from tsconfig presence at or above the scanned dir (package-up). */
124
+ function detectLanguage(dir: string): Language {
125
+ return findUp(dir, "tsconfig.json") !== null ? "ts" : "js";
126
+ }
127
+
128
+ /**
129
+ * The repo's design system: the published package that contributes the most
130
+ * components RESOLVING TO A KNOWN INTERACTIVE HOST (registry or traced). That
131
+ * is what a design system *is* for a11y purposes — it wraps form/interactive
132
+ * primitives. Ranking on resolved-host count (not raw usage) is what keeps an
133
+ * icon library from winning: icons are opaque (no host) and drop out, even
134
+ * when imported at far more call sites than the real UI library.
135
+ *
136
+ * Fallbacks, in order:
137
+ * 1. most resolved-host wrappers among external packages (the real signal)
138
+ * 2. if nothing resolves, most-used external package by raw usage (so a repo
139
+ * whose wrappers are all opaque still names its dominant library)
140
+ * 3. `"custom"` when no external package is used at all
141
+ *
142
+ * Ties break by package name for determinism. Own-code modules (relative
143
+ * imports + path aliases) and framework primitives (`next`, `react`, ...) never
144
+ * count — the design system is the UI-component package, not the platform.
145
+ *
146
+ * The winning package is then collapsed to its canonical FAMILY name for the
147
+ * human-facing label via {@link familyLabel}: a Radix app's dominant package is
148
+ * some per-component sub-package (`@radix-ui/react-checkbox`), but the reported
149
+ * design system is `Radix`. Single-package design systems (`bootstrap`) and
150
+ * workspace packages (`@acme/ui`) pass through unchanged. The collapse is the
151
+ * label only — it does not affect trusted-library resolution, which keys off the
152
+ * raw module specifier in `registry.ts`.
153
+ *
154
+ * `rootDir` is where the repo's tsconfig is found (find-up), so its
155
+ * `compilerOptions.paths` aliases that map into own source are excluded too. It
156
+ * defaults to the directory of the first scanned file — find-up climbs from
157
+ * there to the governing tsconfig.
158
+ */
159
+ export function detectDesignSystem(tsxFiles: readonly string[], rootDir?: string): string {
160
+ if (tsxFiles.length === 0) return "custom";
161
+ const from = rootDir ?? dirname(tsxFiles[0] ?? ".");
162
+ const ownAlias = ownAliasMatcherFor(from).isOwnAlias;
163
+ const { resolutions } = resolveComponents(tsxFiles);
164
+ const resolvedHost = new Map<string, number>();
165
+ const rawUsage = new Map<string, number>();
166
+ for (const r of resolutions) {
167
+ if (isOwnModule(r.module, ownAlias)) continue; // own component, not a library
168
+ const pkg = packageNameOf(r.module);
169
+ if (isFrameworkPrimitive(pkg)) continue; // platform primitive, not a design system
170
+ rawUsage.set(pkg, (rawUsage.get(pkg) ?? 0) + 1);
171
+ if (r.host !== null) resolvedHost.set(pkg, (resolvedHost.get(pkg) ?? 0) + 1);
172
+ }
173
+
174
+ const pickMax = (counts: ReadonlyMap<string, number>): string | null => {
175
+ let best: { pkg: string; n: number } | null = null;
176
+ for (const [pkg, n] of counts) {
177
+ if (best === null || n > best.n || (n === best.n && pkg < best.pkg)) {
178
+ best = { pkg, n };
179
+ }
180
+ }
181
+ return best === null ? null : best.pkg;
182
+ };
183
+
184
+ const winner = pickMax(resolvedHost) ?? pickMax(rawUsage);
185
+ // Collapse a known multi-package family (Radix, MUI, …) to its canonical name
186
+ // for the human-facing label; an unknown/single-package DS passes through.
187
+ return winner === null ? "custom" : familyLabel(winner);
188
+ }
189
+
190
+ /**
191
+ * Detect the full stack for a repo rooted at `dir`, given its scannable `.tsx`
192
+ * files (the same set the checker scans). Each field is independent best-effort
193
+ * signal; see the per-field helpers for what each reads.
194
+ */
195
+ export function detectStack(dir: string, tsxFiles: readonly string[]): Stack {
196
+ // Package-up: a scan pointed at a nested `src/` finds the app's package.json
197
+ // (and the on-disk app/pages layout) one or more levels above.
198
+ const pkgRoot = findUp(dir, "package.json") ?? dir;
199
+ const deps = readPackageDeps(pkgRoot);
200
+ const framework = detectFramework(deps);
201
+ return {
202
+ framework,
203
+ router: detectRouter(pkgRoot, framework),
204
+ designSystem: detectDesignSystem(tsxFiles, dir),
205
+ language: detectLanguage(dir),
206
+ };
207
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The thin CLI over {@link scopeChangedTsxFromEnv} that `entrypoint.sh` invokes,
3
+ * so the CI Action resolves its changed-file scope through the SAME module the
4
+ * engine imports — no second copy of the diff logic in shell. Reads the CI env
5
+ * (`CHANGED_FILES` / `BASE_SHA` / `HEAD_SHA` / `GITHUB_WORKSPACE`) and prints the
6
+ * resolved `.tsx` paths, one per line, to stdout.
7
+ */
8
+ import { scopeChangedTsxFromEnv } from "./diff-scope";
9
+
10
+ for (const file of scopeChangedTsxFromEnv()) {
11
+ process.stdout.write(`${file}\n`);
12
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Changed-file diff-scoping — the ONE shared module the engine and the CI Action
3
+ * both import, replacing the ad-hoc `git diff | grep` shell that used to live
4
+ * inline in `entrypoint.sh`. Contract-independent: it resolves WHICH `.tsx`
5
+ * files a run should scan; it knows nothing about findings.
6
+ *
7
+ * Resolution order mirrors the tracer-bullet shell, highest priority first:
8
+ * 1. an explicit `CHANGED_FILES` list (the caller already knows the diff),
9
+ * 2. a `BASE..HEAD` git diff in the workspace,
10
+ * 3. nothing — the caller falls back to a wholesale scan.
11
+ */
12
+ import { execFileSync } from "node:child_process";
13
+
14
+ export interface DiffScopeInput {
15
+ /** Explicit changed-file list (whitespace-split upstream), highest priority. */
16
+ readonly changedFiles?: readonly string[];
17
+ /** Base commit of the diff (git range lower bound). */
18
+ readonly baseSha?: string;
19
+ /** Head commit of the diff (git range upper bound). */
20
+ readonly headSha?: string;
21
+ /** Working tree the git diff runs against. */
22
+ readonly workspace: string;
23
+ }
24
+
25
+ const TSX = /\.tsx$/;
26
+
27
+ /** Split a raw whitespace-separated `CHANGED_FILES` value into a path list. */
28
+ export function parseChangedFiles(raw: string): string[] {
29
+ return raw.split(/\s+/).filter((p) => p !== "");
30
+ }
31
+
32
+ function isGitRepo(workspace: string): boolean {
33
+ try {
34
+ execFileSync("git", ["-C", workspace, "rev-parse", "--git-dir"], { stdio: "ignore" });
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ function gitDiffNames(workspace: string, baseSha: string, headSha: string): string[] {
42
+ try {
43
+ const out = execFileSync(
44
+ "git",
45
+ ["-C", workspace, "diff", "--name-only", `${baseSha}...${headSha}`],
46
+ { encoding: "utf8" },
47
+ );
48
+ return out.split("\n").filter((p) => p !== "");
49
+ } catch {
50
+ // Advisory gate: a git failure is an empty scope, never a throw.
51
+ return [];
52
+ }
53
+ }
54
+
55
+ /**
56
+ * The TRUE deletions in a diff — files whose content is GONE, never MOVED.
57
+ *
58
+ * `--diff-filter=D` selects deleted entries; `--find-renames` (`-M`) is pinned ON
59
+ * EXPLICITLY (never left to git's config/context-dependent default) so a MOVE
60
+ * classifies as `R` and its old path is EXCLUDED — a rename is not a deletion. This
61
+ * closes a false-resolve hole (ADR 0043 v2): a renamed file's old path must NOT be
62
+ * reported deleted, or a move out of scan-scope silently vanishes its finding.
63
+ * Paths are repo-root-relative, forward-slash (git's native output).
64
+ */
65
+ function gitDeletedNames(workspace: string, baseSha: string, headSha: string): string[] {
66
+ try {
67
+ const out = execFileSync(
68
+ "git",
69
+ ["-C", workspace, "diff", "--diff-filter=D", "--find-renames", "--name-only", `${baseSha}...${headSha}`],
70
+ { encoding: "utf8" },
71
+ );
72
+ return out.split("\n").filter((p) => p !== "");
73
+ } catch {
74
+ // Advisory gate: a git failure is an empty deletion set, never a throw.
75
+ return [];
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Resolve the source files DELETED in this run's diff (ADR 0043 v2 — the
81
+ * `deletedPaths` coverage half). A deleted-file's source ticket resolves because the
82
+ * code — and so the issue — is gone. Derived ONLY from a real `BASE...HEAD` git diff:
83
+ * an explicit `CHANGED_FILES` name list cannot classify deleted-vs-added, and a full
84
+ * scan (no base ref) has no deletion context, so both yield `[]` — never a fabricated
85
+ * deletion (the safe direction: under-report, never over-report).
86
+ */
87
+ export function deletedPaths(input: DiffScopeInput): string[] {
88
+ if (
89
+ input.baseSha !== undefined &&
90
+ input.baseSha !== "" &&
91
+ input.headSha !== undefined &&
92
+ input.headSha !== "" &&
93
+ isGitRepo(input.workspace)
94
+ ) {
95
+ return gitDeletedNames(input.workspace, input.baseSha, input.headSha);
96
+ }
97
+ return [];
98
+ }
99
+
100
+ /**
101
+ * Resolve the changed `.tsx` files for a run. Returns only `.tsx` paths (the
102
+ * engine's static React surface); an empty array means "no diff context —
103
+ * fall back to a wholesale scan", the same signal the shell encoded as an empty
104
+ * `FILES`.
105
+ */
106
+ export function scopeChangedTsx(input: DiffScopeInput): string[] {
107
+ const explicit = input.changedFiles;
108
+ if (explicit !== undefined && explicit.length > 0) {
109
+ return explicit.filter((p) => TSX.test(p));
110
+ }
111
+ if (
112
+ input.baseSha !== undefined &&
113
+ input.baseSha !== "" &&
114
+ input.headSha !== undefined &&
115
+ input.headSha !== "" &&
116
+ isGitRepo(input.workspace)
117
+ ) {
118
+ return gitDiffNames(input.workspace, input.baseSha, input.headSha).filter((p) => TSX.test(p));
119
+ }
120
+ return [];
121
+ }
122
+
123
+ /**
124
+ * Resolve the scope from the CI environment (the Action's contract): reads the
125
+ * same `CHANGED_FILES` / `BASE_SHA` / `HEAD_SHA` / `GITHUB_WORKSPACE` variables
126
+ * the shell used, so the Action delegates its file-resolution here verbatim.
127
+ */
128
+ export function scopeChangedTsxFromEnv(env: NodeJS.ProcessEnv = process.env): string[] {
129
+ const rawChanged = env.CHANGED_FILES;
130
+ return scopeChangedTsx({
131
+ changedFiles: rawChanged !== undefined && rawChanged !== "" ? parseChangedFiles(rawChanged) : undefined,
132
+ baseSha: env.BASE_SHA,
133
+ headSha: env.HEAD_SHA,
134
+ workspace: env.GITHUB_WORKSPACE ?? process.cwd(),
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Resolve the run's TRUE deletions from the CI env — the sibling of
140
+ * {@link scopeChangedTsxFromEnv} for the `deletedPaths` coverage half. Reads the same
141
+ * `BASE_SHA` / `HEAD_SHA` / `GITHUB_WORKSPACE` the diff-scope uses; with no base ref
142
+ * it yields `[]` (never a fabricated deletion).
143
+ */
144
+ export function deletedPathsFromEnv(env: NodeJS.ProcessEnv = process.env): string[] {
145
+ return deletedPaths({
146
+ baseSha: env.BASE_SHA,
147
+ headSha: env.HEAD_SHA,
148
+ workspace: env.GITHUB_WORKSPACE ?? process.cwd(),
149
+ });
150
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The wire projection: `localFinding -> contract`.
3
+ *
4
+ * The engine's canonical model is the source-anchored, corpus-enriched
5
+ * {@link EnrichedFinding} (carries `file`/`line`/`selector`/`ruleId`). It is the
6
+ * single internal source of truth and NEVER leaves the customer's machine.
7
+ *
8
+ * The imported `@binclusive/a11y-contract` `Finding` is the METADATA-ONLY WIRE
9
+ * DTO — the narrowing boundary projection this module owns. It is what the
10
+ * phone-home / ingestion path emits. The projection deliberately DROPS every
11
+ * source locator (`file`, `line`, `ruleId`, `helpUrl`) so no source can cross
12
+ * the wire: metadata-only by construction, on top of the contract's own
13
+ * `.strict()` allowlist. This is Parse-Don't-Validate at the emit boundary —
14
+ * {@link toFindingPayload} re-parses the assembled batch through the contract's
15
+ * own schema, so an engine that drifts from the shape fails loud here, not on
16
+ * the platform.
17
+ *
18
+ * This is NOT a competing schema. There is ONE model (the local finding) and
19
+ * ONE wire DTO (the contract). Local renderers (the PR-comment reviewer, the
20
+ * SARIF renderer) read the RICH local finding directly and never route through
21
+ * this narrowing — only the emit path does.
22
+ */
23
+ import {
24
+ Finding as ContractFindingSchema,
25
+ type Finding as ContractFinding,
26
+ type FindingPayload,
27
+ type Location as ContractLocation,
28
+ parseFindingPayload,
29
+ type Provenance as ContractProvenance,
30
+ } from "@binclusive/a11y-contract";
31
+ import type { FindingProvenance } from "./core";
32
+ import type { EnrichedFinding } from "./evidence";
33
+ import { type LocationOptions, resolveLocations } from "./source-identity";
34
+
35
+ /**
36
+ * Collapse the engine's 7-value {@link FindingProvenance} onto the contract's
37
+ * binary origin: the corpus-grounded agent layer is `agent`; every deterministic
38
+ * static/rendered pass (`jsx-a11y`/`enforce`/`axe`/`swiftui`/`liquid`/`unity`)
39
+ * is `deterministic`.
40
+ */
41
+ export function toContractProvenance(p: FindingProvenance): ContractProvenance {
42
+ return p === "corpus-agent" ? "agent" : "deterministic";
43
+ }
44
+
45
+ /**
46
+ * A selector locates a live rendered element. Empty or whitespace-only is NOT a
47
+ * selector — a source-static pass has no live DOM node, and `??` would let `""`
48
+ * through as if it were present. The single predicate both the SARIF
49
+ * logicalLocation and the contract `element` fallback read, so the two can never
50
+ * disagree on "present vs absent".
51
+ */
52
+ export function hasSelector(selector: string | undefined): selector is string {
53
+ return selector !== undefined && selector.trim() !== "";
54
+ }
55
+
56
+ /**
57
+ * Project one enriched local finding onto the contract DTO, given its already
58
+ * resolved wire {@link ContractLocation}. Every source locator (`file`, `line`,
59
+ * `ruleId`, raw line content) is dropped: the ONLY location that crosses is the
60
+ * pre-computed `location` — a page `url`, or a source `{ path, lineHash, index }`
61
+ * fingerprint carrying neither the line number nor the content (ADR 0042). `index`
62
+ * disambiguation is a batch property, so the location is resolved by the batch
63
+ * ({@link toFindingPayload}) and threaded in here, keeping this projection pure.
64
+ *
65
+ * `element` falls back to the rule id when there is no rendered DOM selector — a
66
+ * source-static pass has no live element, and the rule id is the honest non-source
67
+ * locator for the occurrence.
68
+ */
69
+ export function toContractFinding(
70
+ f: EnrichedFinding,
71
+ scope: string,
72
+ location: ContractLocation,
73
+ ): ContractFinding {
74
+ const base = {
75
+ location,
76
+ criterion: f.wcag[0] ?? "",
77
+ element: hasSelector(f.selector) ? f.selector : f.ruleId,
78
+ evidence: f.message,
79
+ scope,
80
+ } as const;
81
+
82
+ if (toContractProvenance(f.provenance) === "agent") {
83
+ return { provenance: "agent", ...base, rationale: f.message };
84
+ }
85
+ return { provenance: "deterministic", ...base };
86
+ }
87
+
88
+ /**
89
+ * The emit path: project a batch of local findings onto the wire payload and
90
+ * re-parse it through the contract's own schema. The parse is the boundary
91
+ * guarantee — a payload that drifts from the metadata-only shape throws here
92
+ * (`ZodError`) rather than reaching the platform.
93
+ *
94
+ * Locations are resolved for the WHOLE batch up front — a source finding's
95
+ * `index` disambiguates identical line-content within one file, so it can only be
96
+ * assigned with the batch in hand (ADR 0042). `options` injects the repo root
97
+ * (for the relative `path`) and the line-content source (default: read from disk).
98
+ */
99
+ export function toFindingPayload(
100
+ findings: readonly EnrichedFinding[],
101
+ scope: string,
102
+ options?: LocationOptions,
103
+ ): FindingPayload {
104
+ const locations = resolveLocations(findings, options);
105
+ return parseFindingPayload({
106
+ findings: findings.map((f) => toContractFinding(f, scope, mustLocate(locations, f))),
107
+ });
108
+ }
109
+
110
+ /** A finding is always in the resolved map ({@link resolveLocations} covers the batch). */
111
+ function mustLocate(locations: ReadonlyMap<EnrichedFinding, ContractLocation>, f: EnrichedFinding): ContractLocation {
112
+ const location = locations.get(f);
113
+ if (location === undefined) throw new Error("finding missing from resolved location map");
114
+ return location;
115
+ }
116
+
117
+ /** A lenient emit that projects each finding independently, keeping the valid ones. */
118
+ export interface LenientPayload {
119
+ readonly payload: FindingPayload;
120
+ /** How many findings failed the contract re-parse and were dropped. */
121
+ readonly dropped: number;
122
+ /**
123
+ * The enriched sources that SURVIVED projection, in `payload.findings` order (1:1).
124
+ * The metadata-only wire `Finding` carries no impact field, so a transport layer
125
+ * that must send the 4-level `impact` (Kontrol's `CiFindingInput` extra) reads it
126
+ * off the paired source here — no second projection, and no widening of the wire
127
+ * contract.
128
+ */
129
+ readonly sources: readonly EnrichedFinding[];
130
+ }
131
+
132
+ /**
133
+ * The NON-BLOCKING emit path — same projection as {@link toFindingPayload}, but a
134
+ * single finding that fails the contract re-parse is DROPPED, not thrown.
135
+ *
136
+ * Two emit disciplines, one projection: the deterministic engine emits with the
137
+ * strict {@link toFindingPayload} (its output is reproducible — drift is a bug that
138
+ * must fail loud). The AI lane emits with THIS — one malformed model output must
139
+ * never fail the whole run (the runner always exits 0). Both reuse
140
+ * {@link toContractFinding} + the contract's own `Finding` schema, so there is no
141
+ * second projection and no second wire schema.
142
+ */
143
+ export function toFindingPayloadLenient(
144
+ findings: readonly EnrichedFinding[],
145
+ scope: string,
146
+ options?: LocationOptions,
147
+ ): LenientPayload {
148
+ const locations = resolveLocations(findings, options);
149
+ const projected: ContractFinding[] = [];
150
+ const sources: EnrichedFinding[] = [];
151
+ let dropped = 0;
152
+ for (const f of findings) {
153
+ // Guard the WHOLE projection, not just the parse: a malformed finding can
154
+ // throw inside toContractFinding (a bad wcag/corpus shape) as easily as it
155
+ // can fail the schema. Either way the one finding is dropped, never the run.
156
+ const contract = tryProject(f, scope, locations.get(f));
157
+ if (contract === null) dropped += 1;
158
+ else {
159
+ // Push in lockstep so `sources[i]` is the source `payload.findings[i]` came
160
+ // from — the alignment a transport layer relies on to recover a source-only field.
161
+ projected.push(contract);
162
+ sources.push(f);
163
+ }
164
+ }
165
+ return { payload: parseFindingPayload({ findings: projected }), dropped, sources };
166
+ }
167
+
168
+ /** Project one finding, returning `null` if it throws, has no location, or fails the contract. */
169
+ function tryProject(
170
+ f: EnrichedFinding,
171
+ scope: string,
172
+ location: ContractLocation | undefined,
173
+ ): ContractFinding | null {
174
+ if (location === undefined) return null;
175
+ try {
176
+ const parsed = ContractFindingSchema.safeParse(toContractFinding(f, scope, location));
177
+ return parsed.success ? parsed.data : null;
178
+ } catch {
179
+ return null;
180
+ }
181
+ }