@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
package/src/hook.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Claude Code "auto-whisper" hook: a `PostToolUse` handler that runs the
|
|
3
|
+
* a11y-checker on JUST the file the AI just edited and feeds any findings back
|
|
4
|
+
* as `additionalContext` so the model fixes them in the SAME turn.
|
|
5
|
+
*
|
|
6
|
+
* It is the loop-closer for slice 2. Slice 1 (`mcp.ts`) lets an agent ASK the
|
|
7
|
+
* checker; this hook makes the checker speak up UNASKED the instant a `.tsx`
|
|
8
|
+
* edit lands. Same wrapping discipline as `mcp.ts`: every handler is a THIN
|
|
9
|
+
* wrapper over `scan` + `enrichAll` — no new a11y logic lives here.
|
|
10
|
+
*
|
|
11
|
+
* It handles two ecosystems by file extension: a `.tsx` edit runs the React scan
|
|
12
|
+
* (floor only), and a `.prefab`/`.unity` edit runs the Unity finding-emission
|
|
13
|
+
* aggregator (`collectUnityFindings`) over the enclosing project, scoped to the
|
|
14
|
+
* edited asset — Unity reaches the editor surface at parity with React (#92).
|
|
15
|
+
*
|
|
16
|
+
* Contract (verified against https://docs.claude.com/en/docs/claude-code/hooks):
|
|
17
|
+
* stdin — the PostToolUse event JSON. Relevant fields:
|
|
18
|
+
* { tool_name, tool_input: { file_path, ... }, cwd, ... }
|
|
19
|
+
* Edit / Write / MultiEdit all carry the edited path at
|
|
20
|
+
* `tool_input.file_path`.
|
|
21
|
+
* stdout — to inject context the model reads alongside the tool result:
|
|
22
|
+
* { "hookSpecificOutput":
|
|
23
|
+
* { "hookEventName": "PostToolUse", "additionalContext": "…" } }
|
|
24
|
+
*
|
|
25
|
+
* Two hard rules, both because this runs in the developer's edit path:
|
|
26
|
+
* - FAST: scan ONE file, never a directory walk.
|
|
27
|
+
* - FAIL-SAFE: never throw, never block. PostToolUse cannot block the edit
|
|
28
|
+
* (it already ran), and a crash here would spam stderr to the model every
|
|
29
|
+
* edit. Any problem → emit nothing, exit 0. The edit is advisory-only.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { existsSync } from "node:fs";
|
|
33
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
34
|
+
import { z } from "zod";
|
|
35
|
+
import { scan, type ScanResult } from "./core";
|
|
36
|
+
import { evidenceFix, type EnrichedFinding, enrichAll } from "./evidence";
|
|
37
|
+
import { collectUnityFindings } from "./unity-findings";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The slice of the PostToolUse payload we use. The full event carries more
|
|
41
|
+
* (session_id, transcript_path, permission_mode, tool_response, …) but we only
|
|
42
|
+
* need the edited path and the cwd to resolve it. `tool_input.file_path` is
|
|
43
|
+
* how Edit / Write / MultiEdit each name the file they touched; `.passthrough()`
|
|
44
|
+
* keeps the parse from rejecting the fields we don't model.
|
|
45
|
+
*
|
|
46
|
+
* `cwd` is optional: a relative `file_path` is resolved against it, but an
|
|
47
|
+
* absolute path (the common case) needs neither, so a payload without `cwd`
|
|
48
|
+
* still works rather than failing the parse.
|
|
49
|
+
*/
|
|
50
|
+
const PostToolUseInput = z
|
|
51
|
+
.object({
|
|
52
|
+
tool_name: z.string().optional(),
|
|
53
|
+
cwd: z.string().optional(),
|
|
54
|
+
tool_input: z.object({ file_path: z.string() }).passthrough().optional(),
|
|
55
|
+
})
|
|
56
|
+
.passthrough();
|
|
57
|
+
|
|
58
|
+
/** The stdout envelope that injects context back into the model. */
|
|
59
|
+
export interface HookOutput {
|
|
60
|
+
readonly hookSpecificOutput: {
|
|
61
|
+
readonly hookEventName: "PostToolUse";
|
|
62
|
+
readonly additionalContext: string;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Cap the whisper: a few lines max, top findings only. It runs every edit. */
|
|
67
|
+
const MAX_FINDINGS = 5;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One finding → one terse line: `file:line · rule · WCAG SC · fix`.
|
|
71
|
+
* The rule id is stripped of its `jsx-a11y/` prefix; the fix falls back to the
|
|
72
|
+
* eslint message when the baseline catalog has no representative fix for the SC.
|
|
73
|
+
*/
|
|
74
|
+
function formatLine(f: EnrichedFinding, root: string): string {
|
|
75
|
+
const where = `${relative(root, f.file)}:${f.line}`;
|
|
76
|
+
const rule = f.ruleId.replace(/^jsx-a11y\//, "");
|
|
77
|
+
const sc = f.wcag.length > 0 ? `WCAG ${f.wcag.join(", ")}` : "no WCAG mapping";
|
|
78
|
+
const fix = evidenceFix(f.corpus) ?? f.message;
|
|
79
|
+
return ` ${where} · ${rule} · ${sc} · ${fix}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build the `additionalContext` string for a file's findings, framed
|
|
84
|
+
* imperatively so the model corrects the edit it just made. Returns null when
|
|
85
|
+
* there is nothing to say (no findings) — the caller then no-ops.
|
|
86
|
+
*/
|
|
87
|
+
export function formatWhisper(
|
|
88
|
+
filePath: string,
|
|
89
|
+
findings: readonly EnrichedFinding[],
|
|
90
|
+
root: string,
|
|
91
|
+
): string | null {
|
|
92
|
+
if (findings.length === 0) return null;
|
|
93
|
+
const shown = findings.slice(0, MAX_FINDINGS);
|
|
94
|
+
const header = `You just edited ${relative(root, filePath)}. It has these accessibility issues — fix them now:`;
|
|
95
|
+
const lines = shown.map((f) => formatLine(f, root));
|
|
96
|
+
const more =
|
|
97
|
+
findings.length > shown.length
|
|
98
|
+
? [` …and ${findings.length - shown.length} more (run \`a11y-checker check\` for all).`]
|
|
99
|
+
: [];
|
|
100
|
+
return [header, ...lines, ...more].join("\n");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
/** Markers of a Unity project root, checked walking up from an edited asset.
|
|
105
|
+
* `Assets` + `ProjectSettings` are the two canonical top-level dirs Unity creates;
|
|
106
|
+
* either one present is enough to call a directory the project root. */
|
|
107
|
+
const UNITY_ROOT_MARKERS = ["Assets", "ProjectSettings"] as const;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Locate the enclosing Unity project for an edited `.prefab`/`.unity` asset.
|
|
111
|
+
* Walks up from the asset's directory looking for a Unity root marker
|
|
112
|
+
* (`Assets`/`ProjectSettings`); returns the first directory that has one. Falls
|
|
113
|
+
* back to the asset's own directory when no marker is found (a loose fixture or a
|
|
114
|
+
* detached asset) so the aggregator still scans something rather than no-op'ing —
|
|
115
|
+
* `collectUnityFindings` is forgiving, so a directory with no further assets just
|
|
116
|
+
* yields the findings for this one file's siblings.
|
|
117
|
+
*/
|
|
118
|
+
function findUnityProjectRoot(filePath: string): string {
|
|
119
|
+
let dir = dirname(filePath);
|
|
120
|
+
for (;;) {
|
|
121
|
+
if (UNITY_ROOT_MARKERS.some((m) => existsSync(join(dir, m)))) return dir;
|
|
122
|
+
const parent = dirname(dir);
|
|
123
|
+
if (parent === dir) break; // reached the filesystem root
|
|
124
|
+
dir = parent;
|
|
125
|
+
}
|
|
126
|
+
return dirname(filePath);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The Unity branch of {@link runHook}: an edited `.prefab`/`.unity` asset. Locate
|
|
131
|
+
* the enclosing project, run the Unity finding-emission aggregator over it
|
|
132
|
+
* (`collectUnityFindings` — the SAME spine the CLI/MCP use), enrich the findings,
|
|
133
|
+
* and emit the SAME floor whisper as the `.tsx` path, scoped to the edited asset so
|
|
134
|
+
* the model fixes the file it just touched (per-file parity with React). No
|
|
135
|
+
* Unity-specific report path — it reuses `enrichAll` + `formatWhisper` verbatim.
|
|
136
|
+
*
|
|
137
|
+
* Fail-safe like the `.tsx` branch: any throw → null (the edit is advisory-only).
|
|
138
|
+
*/
|
|
139
|
+
async function runUnityHook(filePath: string, base: string): Promise<HookOutput | null> {
|
|
140
|
+
try {
|
|
141
|
+
const projectRoot = findUnityProjectRoot(filePath);
|
|
142
|
+
const raw = await collectUnityFindings(projectRoot);
|
|
143
|
+
// Scope to the asset just edited (the aggregator scans the whole project) so the
|
|
144
|
+
// whisper speaks only to the file the model touched — per-file parity with .tsx.
|
|
145
|
+
const forFile = raw.filter((f) => resolve(f.file) === resolve(filePath));
|
|
146
|
+
const findings = enrichAll(forFile);
|
|
147
|
+
|
|
148
|
+
const floor = formatWhisper(filePath, findings, base);
|
|
149
|
+
if (floor === null) return null;
|
|
150
|
+
return { hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: floor } };
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Run the checker on ONE edited file and return the `additionalContext`
|
|
158
|
+
* envelope, or null to no-op. Pulls `file_path` from `tool_input`, resolves it
|
|
159
|
+
* against `cwd` if relative, and dispatches by extension: `.tsx` runs the React
|
|
160
|
+
* scan (floor only), `.prefab`/`.unity` runs the Unity aggregator; anything
|
|
161
|
+
* else no-ops.
|
|
162
|
+
*
|
|
163
|
+
* Reused by both the CLI entry and the tests — the tests call it directly with
|
|
164
|
+
* a sample payload, the CLI feeds it parsed stdin.
|
|
165
|
+
*/
|
|
166
|
+
export async function runHook(raw: unknown): Promise<HookOutput | null> {
|
|
167
|
+
const parsed = PostToolUseInput.safeParse(raw);
|
|
168
|
+
if (!parsed.success) return null;
|
|
169
|
+
|
|
170
|
+
const filePathRaw = parsed.data.tool_input?.file_path;
|
|
171
|
+
if (filePathRaw === undefined) return null;
|
|
172
|
+
|
|
173
|
+
// Resolve relative paths against the event's cwd; absolute paths pass through.
|
|
174
|
+
const base = parsed.data.cwd ?? process.cwd();
|
|
175
|
+
const filePath = isAbsolute(filePathRaw) ? filePathRaw : resolve(base, filePathRaw);
|
|
176
|
+
|
|
177
|
+
// Unity assets take the aggregator path; .tsx takes the React scan below.
|
|
178
|
+
if (filePath.endsWith(".prefab") || filePath.endsWith(".unity")) {
|
|
179
|
+
return runUnityHook(filePath, base);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Only .tsx files are scannable on the React path — silently no-op on the rest.
|
|
183
|
+
if (!filePath.endsWith(".tsx")) return null;
|
|
184
|
+
|
|
185
|
+
// FAIL-SAFE (module contract): from the scan onward, any throw → null. This
|
|
186
|
+
// wraps the direct (test/non-CLI) callers too, not just `runHookCli`.
|
|
187
|
+
try {
|
|
188
|
+
const result = await scan([filePath]);
|
|
189
|
+
const findings = enrichAll(result.findings);
|
|
190
|
+
|
|
191
|
+
// Relativize against the file's directory so the whisper shows a short path,
|
|
192
|
+
// not the absolute one ESLint reports.
|
|
193
|
+
const root = base;
|
|
194
|
+
// The PRECISE static floor ("fix these"). Emit nothing when it is empty.
|
|
195
|
+
const floor = formatWhisper(filePath, findings, root);
|
|
196
|
+
if (floor === null) return null;
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: floor },
|
|
200
|
+
};
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Read all of stdin to a string. Returns "" if stdin is empty/closed. */
|
|
207
|
+
async function readStdin(): Promise<string> {
|
|
208
|
+
const chunks: Buffer[] = [];
|
|
209
|
+
for await (const chunk of process.stdin) {
|
|
210
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
211
|
+
}
|
|
212
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* CLI entry for `a11y-checker hook`: read the PostToolUse JSON from stdin, run
|
|
217
|
+
* the checker on the edited file, and print the `additionalContext` envelope.
|
|
218
|
+
*
|
|
219
|
+
* Wrapped end-to-end so NOTHING escapes as a non-zero exit or an unhandled
|
|
220
|
+
* throw — a hook that crashes would heckle the model on every edit. On any
|
|
221
|
+
* problem (bad JSON, scan failure, non-tsx) it prints nothing and exits 0.
|
|
222
|
+
*/
|
|
223
|
+
export async function runHookCli(): Promise<void> {
|
|
224
|
+
try {
|
|
225
|
+
const stdin = await readStdin();
|
|
226
|
+
if (stdin.trim() === "") return;
|
|
227
|
+
|
|
228
|
+
let raw: unknown;
|
|
229
|
+
try {
|
|
230
|
+
raw = JSON.parse(stdin);
|
|
231
|
+
} catch {
|
|
232
|
+
return; // malformed input → no-op
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const output = await runHook(raw);
|
|
236
|
+
if (output !== null) {
|
|
237
|
+
process.stdout.write(JSON.stringify(output));
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
// Fail-safe: any error → silent no-op, exit 0. Never block the edit.
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `check` command's OPT-IN blocking gate (issue #2134).
|
|
3
|
+
*
|
|
4
|
+
* By default `check` exits non-zero only when a contract-BLOCKING finding fired
|
|
5
|
+
* (`enforcement === "block"`); a scan that surfaces only warn-level findings is a
|
|
6
|
+
* clean build. This module adds a strictly opt-in overlay: `--fail-on <impact>`
|
|
7
|
+
* fails the check when any finding is at or above an impact threshold, and
|
|
8
|
+
* `--max-violations <n>` fails it when the finding count exceeds `n`.
|
|
9
|
+
*
|
|
10
|
+
* DEFAULT-SAFE BY CONSTRUCTION: with an unset gate the exit code is EXACTLY
|
|
11
|
+
* today's behavior — findings never fail the check on impact or volume alone.
|
|
12
|
+
* Both knobs are opt-in; there is no impact default.
|
|
13
|
+
*/
|
|
14
|
+
import { Impact } from "@binclusive/a11y-contract";
|
|
15
|
+
import { evidenceImpact, type EnrichedFinding } from "./evidence";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The canonical impact ordering — most-severe first (`critical` < `serious` <
|
|
19
|
+
* `moderate` < `minor` < `unknown`) — read straight from the contract's own
|
|
20
|
+
* `Impact` enum. We source the order from the enum's `.options` rather than
|
|
21
|
+
* hand-rolling a second rank map, so the gate can never disagree with the
|
|
22
|
+
* contract's impact vocabulary.
|
|
23
|
+
*/
|
|
24
|
+
export const IMPACT_ORDER: readonly Impact[] = Impact.options;
|
|
25
|
+
|
|
26
|
+
/** Rank of an impact in {@link IMPACT_ORDER} — lower index = more severe. */
|
|
27
|
+
function impactRank(s: Impact): number {
|
|
28
|
+
return IMPACT_ORDER.indexOf(s);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The exit-policy config. Two independent overlays on the exit code:
|
|
33
|
+
*
|
|
34
|
+
* - `failOn` / `maxViolations` — the OPT-IN blocking gate (#2134). Both default
|
|
35
|
+
* to `null` (unset), the default-safe state in which the gate never fails the
|
|
36
|
+
* check on findings alone.
|
|
37
|
+
* - `advisory` — the generic CI runner mode (#2236). When `true`, the run's
|
|
38
|
+
* BASELINE exit is 0 even when contract-blocking findings fired: the engine
|
|
39
|
+
* reports every finding but never fails the check by itself. This is the
|
|
40
|
+
* first-class non-blocking default a CI runner selects (via `check --ci`),
|
|
41
|
+
* replacing the old shell `|| true` swallow with an exit policy the engine
|
|
42
|
+
* owns. The opt-in gate still overrides in advisory mode — set `failOn` /
|
|
43
|
+
* `maxViolations` and the run fails when the threshold trips — so blocking
|
|
44
|
+
* stays available but is strictly opt-in.
|
|
45
|
+
*/
|
|
46
|
+
export interface GateConfig {
|
|
47
|
+
readonly failOn: Impact | null;
|
|
48
|
+
readonly maxViolations: number | null;
|
|
49
|
+
readonly advisory: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The default exit policy: blocking-on-contract-block, gate off. This is the
|
|
54
|
+
* plain-CLI default — a local `check` still exits non-zero on a blocking finding.
|
|
55
|
+
*/
|
|
56
|
+
export const GATE_OFF: GateConfig = { failOn: null, maxViolations: null, advisory: false };
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The generic CI runner default (#2236): non-blocking baseline (exit 0 on any
|
|
60
|
+
* findings) with no opt-in gate. `check --ci` selects this so any CI can consume
|
|
61
|
+
* the SARIF/JSON artifact without the check failing; layering `failOn` /
|
|
62
|
+
* `maxViolations` on top re-enables a failing exit.
|
|
63
|
+
*/
|
|
64
|
+
export const GATE_ADVISORY: GateConfig = { failOn: null, maxViolations: null, advisory: true };
|
|
65
|
+
|
|
66
|
+
/** The minimal per-finding projection the gate reasons over. */
|
|
67
|
+
export interface GateFinding {
|
|
68
|
+
readonly impact: Impact;
|
|
69
|
+
/** Whether the contract BLOCKS this finding (`enforcement === "block"`). */
|
|
70
|
+
readonly blocking: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Project an enriched finding onto the gate's minimal shape. */
|
|
74
|
+
export function toGateFinding(f: EnrichedFinding): GateFinding {
|
|
75
|
+
// Absent impact ⇒ the contract's `unknown` (least severe): the gate reads the
|
|
76
|
+
// finding's own resolved impact, never a re-derived band.
|
|
77
|
+
return { impact: evidenceImpact(f) ?? "unknown", blocking: f.enforcement === "block" };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The `check` command's exit code, honoring the opt-in gate.
|
|
82
|
+
*
|
|
83
|
+
* With an unset gate (`failOn` and `maxViolations` both `null`) the result is
|
|
84
|
+
* today's behavior EXACTLY: non-zero iff a contract-blocking finding fired —
|
|
85
|
+
* UNLESS `advisory` is set, in which case the baseline is 0 (the generic CI
|
|
86
|
+
* runner mode, #2236: findings are reported but never fail the check on their
|
|
87
|
+
* own).
|
|
88
|
+
*
|
|
89
|
+
* When the gate IS set, the exit reflects the gate: non-zero when any finding is
|
|
90
|
+
* at or above `failOn` (impact rank ≤ threshold rank), or when the finding
|
|
91
|
+
* count exceeds `maxViolations`; zero otherwise. The opt-in gate applies in
|
|
92
|
+
* advisory mode too, so a CI runner can still opt into a failing exit.
|
|
93
|
+
*/
|
|
94
|
+
export function gateExitCode(findings: readonly GateFinding[], gate: GateConfig): number {
|
|
95
|
+
const gated = gate.failOn !== null || gate.maxViolations !== null;
|
|
96
|
+
if (!gated) {
|
|
97
|
+
// Advisory baseline (#2236): non-blocking even with contract-blocking findings.
|
|
98
|
+
if (gate.advisory) return 0;
|
|
99
|
+
return findings.some((f) => f.blocking) ? 1 : 0;
|
|
100
|
+
}
|
|
101
|
+
if (gate.failOn !== null) {
|
|
102
|
+
const threshold = impactRank(gate.failOn);
|
|
103
|
+
if (findings.some((f) => impactRank(f.impact) <= threshold)) return 1;
|
|
104
|
+
}
|
|
105
|
+
if (gate.maxViolations !== null && findings.length > gate.maxViolations) return 1;
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join, parse, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* package.json `imports`-subpath resolver: follow a `#`-prefixed internal import
|
|
6
|
+
* (`#app/components/ui/button`) to its REAL source file by reading the nearest
|
|
7
|
+
* package.json's `imports` map, the same way Node + bundlers do.
|
|
8
|
+
*
|
|
9
|
+
* Why this exists. The checker already resolves tsconfig `paths` aliases and
|
|
10
|
+
* workspace packages to own-code so it can TRACE them. Node's `imports` field is
|
|
11
|
+
* the third own-code alias source — the `#`-prefixed internal subpath imports
|
|
12
|
+
* Epic Stack and modern Remix-style repos use (`"#app/*": "./app/*"`). A bare
|
|
13
|
+
* `import { Button } from "#app/components/ui/button"` (no extension) is invisible
|
|
14
|
+
* to TypeScript's module resolution, so the own-code component it points at falls
|
|
15
|
+
* into the opaque/declare set and hollows the `checked` count. (TS DOES follow a
|
|
16
|
+
* `#`-import that carries an explicit extension — `#app/.../icon.tsx` — but not
|
|
17
|
+
* the extensionless form, which is the common one.) This module fills that gap.
|
|
18
|
+
*
|
|
19
|
+
* It is ONE MORE ALIAS SOURCE, not a new subsystem — it mirrors
|
|
20
|
+
* `resolveWorkspaceImport`'s shape (find root → match pattern → resolve target to
|
|
21
|
+
* a file on disk) so a `#`-resolved target feeds the SAME trace/own-code
|
|
22
|
+
* machinery as a tsconfig-`paths` target.
|
|
23
|
+
*
|
|
24
|
+
* Everything is best-effort + boundary-parsed: a missing/malformed package.json,
|
|
25
|
+
* an `imports` value in an unrecognized shape, or a subpath no pattern covers all
|
|
26
|
+
* yield `null` — the caller then keeps the wrapper opaque rather than guessing.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** File extensions we treat as resolvable source, in resolution-preference order. */
|
|
30
|
+
const SOURCE_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"] as const;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Conditional-`imports` keys we read to pick the RUNTIME target, in preference
|
|
34
|
+
* order. `default` first (the unconditional fallback Node always honors), then
|
|
35
|
+
* the runtime conditions; `types` is deliberately absent — a `.d.ts` is not the
|
|
36
|
+
* source we trace. Probed in this order so the actual code path wins.
|
|
37
|
+
*/
|
|
38
|
+
const IMPORT_CONDITIONS = ["default", "import", "require", "node", "browser"] as const;
|
|
39
|
+
|
|
40
|
+
/** A compiled `imports` entry: a `#`-prefixed subpath pattern and its target. */
|
|
41
|
+
interface ImportPattern {
|
|
42
|
+
/** The key as written (`"#app/*"`, `"#config"`). */
|
|
43
|
+
readonly key: string;
|
|
44
|
+
/** The resolved runtime target string (`"./app/*"`), already condition-picked. */
|
|
45
|
+
readonly target: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A discovered `imports` map: the dir its targets resolve against + the patterns. */
|
|
49
|
+
interface ImportsMap {
|
|
50
|
+
readonly baseDir: string;
|
|
51
|
+
readonly patterns: readonly ImportPattern[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const importsCache = new Map<string, ImportsMap | null>();
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Find the nearest package.json at or above `fromDir` that declares an `imports`
|
|
58
|
+
* field, returning its compiled patterns + base dir. Walks UP to the filesystem
|
|
59
|
+
* root (the same root-detection the checker uses for stack/tsconfig), so a scan
|
|
60
|
+
* pointed at a nested route file still finds the app's package.json. A
|
|
61
|
+
* package.json WITHOUT `imports` does not stop the walk — a parent may carry it.
|
|
62
|
+
* Cached per starting directory. Returns `null` when none is found.
|
|
63
|
+
*/
|
|
64
|
+
function findImportsMap(fromDir: string): ImportsMap | null {
|
|
65
|
+
const cached = importsCache.get(fromDir);
|
|
66
|
+
if (cached !== undefined) return cached;
|
|
67
|
+
|
|
68
|
+
let dir = fromDir;
|
|
69
|
+
for (;;) {
|
|
70
|
+
const pkgPath = join(dir, "package.json");
|
|
71
|
+
if (existsSync(pkgPath)) {
|
|
72
|
+
const patterns = parseImportsField(readFileSafe(pkgPath));
|
|
73
|
+
if (patterns.length > 0) {
|
|
74
|
+
const map: ImportsMap = { baseDir: dir, patterns };
|
|
75
|
+
importsCache.set(fromDir, map);
|
|
76
|
+
return map;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const parent = dirname(dir);
|
|
80
|
+
if (parent === dir) break;
|
|
81
|
+
dir = parent;
|
|
82
|
+
}
|
|
83
|
+
importsCache.set(fromDir, null);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readFileSafe(path: string): string {
|
|
88
|
+
try {
|
|
89
|
+
return readFileSync(path, "utf8");
|
|
90
|
+
} catch {
|
|
91
|
+
return "";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseJson(text: string): Record<string, unknown> | null {
|
|
96
|
+
if (text === "") return null;
|
|
97
|
+
try {
|
|
98
|
+
const v: unknown = JSON.parse(text);
|
|
99
|
+
return typeof v === "object" && v !== null && !Array.isArray(v)
|
|
100
|
+
? (v as Record<string, unknown>)
|
|
101
|
+
: null;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Compile the `imports` field of a package.json into its `#`-prefixed patterns.
|
|
109
|
+
* Each value is reduced to a single runtime target string: a plain string is the
|
|
110
|
+
* target verbatim; a conditional object (`{ types, import, default }`) is probed
|
|
111
|
+
* in {@link IMPORT_CONDITIONS} order (runtime target, never `types`). A value in
|
|
112
|
+
* an unrecognized shape (array, no usable condition) is skipped — it never
|
|
113
|
+
* crashes the parse. Only `#`-prefixed keys are kept (the `imports` spec requires
|
|
114
|
+
* the `#` prefix; anything else is malformed and ignored).
|
|
115
|
+
*/
|
|
116
|
+
function parseImportsField(text: string): ImportPattern[] {
|
|
117
|
+
const pkg = parseJson(text);
|
|
118
|
+
if (pkg === null) return [];
|
|
119
|
+
const imports = pkg.imports;
|
|
120
|
+
if (typeof imports !== "object" || imports === null || Array.isArray(imports)) return [];
|
|
121
|
+
const out: ImportPattern[] = [];
|
|
122
|
+
for (const [key, value] of Object.entries(imports)) {
|
|
123
|
+
if (!key.startsWith("#")) continue;
|
|
124
|
+
const target = pickConditionTarget(value);
|
|
125
|
+
if (target !== null) out.push({ key, target });
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Reduce an `imports` entry to a single RUNTIME target string. A plain string is
|
|
132
|
+
* the target; a conditions object is probed in our preference order (`default`
|
|
133
|
+
* first, then runtime conditions; `types` ignored). Nested condition objects
|
|
134
|
+
* recurse. Returns `null` when no usable string target is found — the caller
|
|
135
|
+
* skips that pattern gracefully.
|
|
136
|
+
*/
|
|
137
|
+
function pickConditionTarget(entry: unknown): string | null {
|
|
138
|
+
if (typeof entry === "string") return entry;
|
|
139
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return null;
|
|
140
|
+
const obj = entry as Record<string, unknown>;
|
|
141
|
+
for (const cond of IMPORT_CONDITIONS) {
|
|
142
|
+
if (cond in obj) {
|
|
143
|
+
const nested = pickConditionTarget(obj[cond]);
|
|
144
|
+
if (nested !== null) return nested;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Fall back to the first usable target in declaration order, but never a
|
|
148
|
+
// `types`-only entry (a `.d.ts` is not the source we want to trace).
|
|
149
|
+
for (const [cond, value] of Object.entries(obj)) {
|
|
150
|
+
if (cond === "types") continue;
|
|
151
|
+
const nested = pickConditionTarget(value);
|
|
152
|
+
if (nested !== null) return nested;
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Match a `#`-prefixed specifier against a pattern and substitute the `*`. Two
|
|
159
|
+
* shapes, same as Node + tsconfig path globs:
|
|
160
|
+
* - wildcard `"#app/*"` -> `"./app/*"`: capture the text after the prefix and
|
|
161
|
+
* substitute it into the target's `*`.
|
|
162
|
+
* - exact `"#config"` -> `"./config.ts"`: the specifier must equal the key.
|
|
163
|
+
* Returns the target with the wildcard filled, or `null` when no match.
|
|
164
|
+
*/
|
|
165
|
+
function applyPattern(pattern: ImportPattern, specifier: string): string | null {
|
|
166
|
+
const star = pattern.key.indexOf("*");
|
|
167
|
+
if (star === -1) {
|
|
168
|
+
return specifier === pattern.key ? pattern.target : null;
|
|
169
|
+
}
|
|
170
|
+
const prefix = pattern.key.slice(0, star);
|
|
171
|
+
const suffix = pattern.key.slice(star + 1);
|
|
172
|
+
if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) return null;
|
|
173
|
+
if (specifier.length < prefix.length + suffix.length) return null;
|
|
174
|
+
const matched = specifier.slice(prefix.length, specifier.length - suffix.length);
|
|
175
|
+
return pattern.target.replace("*", matched);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Resolve a `#`-prefixed import specifier to its own-code source file via the
|
|
180
|
+
* nearest package.json `imports` map, or `null` when it is not an `imports`
|
|
181
|
+
* subpath (or its target can't be mapped to a file). Longest-key-first so a more
|
|
182
|
+
* specific pattern wins over a catch-all.
|
|
183
|
+
*
|
|
184
|
+
* @param specifier the bare module specifier as written (`#app/components/button`)
|
|
185
|
+
* @param fromFile the importing file, used to locate the package.json
|
|
186
|
+
*/
|
|
187
|
+
export function resolveImportsSubpath(specifier: string, fromFile: string): string | null {
|
|
188
|
+
if (!specifier.startsWith("#")) return null;
|
|
189
|
+
|
|
190
|
+
const map = findImportsMap(dirname(fromFile));
|
|
191
|
+
if (map === null) return null;
|
|
192
|
+
|
|
193
|
+
// Longest static prefix first: `#app/ui/*` beats `#app/*`.
|
|
194
|
+
const ordered = [...map.patterns].sort((a, b) => b.key.length - a.key.length);
|
|
195
|
+
for (const pattern of ordered) {
|
|
196
|
+
const target = applyPattern(pattern, specifier);
|
|
197
|
+
if (target === null) continue;
|
|
198
|
+
const hit = resolveFileTarget(join(map.baseDir, target));
|
|
199
|
+
if (hit !== null) return hit;
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Turn a target path into an existing source file: try it verbatim, then with
|
|
206
|
+
* each known source extension, then as a directory `index.*`. This is what lets
|
|
207
|
+
* an extensionless `#app/components/button` (extension-appended) and a target
|
|
208
|
+
* that already carries an extension both land on a real file. Returns the
|
|
209
|
+
* absolute path of the first hit, or `null`.
|
|
210
|
+
*/
|
|
211
|
+
function resolveFileTarget(target: string): string | null {
|
|
212
|
+
const abs = resolve(target);
|
|
213
|
+
if (isFile(abs)) return abs;
|
|
214
|
+
for (const ext of SOURCE_EXTS) {
|
|
215
|
+
if (isFile(abs + ext)) return abs + ext;
|
|
216
|
+
}
|
|
217
|
+
if (isDir(abs)) {
|
|
218
|
+
for (const ext of SOURCE_EXTS) {
|
|
219
|
+
const idx = join(abs, `index${ext}`);
|
|
220
|
+
if (isFile(idx)) return idx;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// A target that already carries an extension we didn't list but exists.
|
|
224
|
+
const { ext } = parse(abs);
|
|
225
|
+
if (ext !== "" && isFile(abs)) return abs;
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function isFile(path: string): boolean {
|
|
230
|
+
try {
|
|
231
|
+
return statSync(path).isFile();
|
|
232
|
+
} catch {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function isDir(path: string): boolean {
|
|
238
|
+
try {
|
|
239
|
+
return statSync(path).isDirectory();
|
|
240
|
+
} catch {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Reset the cache — test-only, so fixtures don't bleed across cases. */
|
|
246
|
+
export function __resetImportsCacheForTest(): void {
|
|
247
|
+
importsCache.clear();
|
|
248
|
+
}
|