@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,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
|
+
}
|