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