@binclusive/a11y 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +285 -0
  2. package/bin/a11y.mjs +36 -0
  3. package/bin/diff-scope.mjs +29 -0
  4. package/data/baseline-rules.json +892 -0
  5. package/package.json +68 -0
  6. package/src/agent-lane.ts +138 -0
  7. package/src/agents-block.ts +157 -0
  8. package/src/baseline/gen-baseline.ts +166 -0
  9. package/src/cli.ts +1026 -0
  10. package/src/collect-dom.ts +119 -0
  11. package/src/collect-liquid.ts +103 -0
  12. package/src/collect-swift.ts +227 -0
  13. package/src/collect-unity.ts +99 -0
  14. package/src/collect.ts +54 -0
  15. package/src/commands.ts +314 -0
  16. package/src/config-scan.ts +177 -0
  17. package/src/contract.ts +355 -0
  18. package/src/core.ts +546 -0
  19. package/src/detect-stack.ts +207 -0
  20. package/src/diff-scope-cli.ts +12 -0
  21. package/src/diff-scope.ts +150 -0
  22. package/src/emit-contract.ts +181 -0
  23. package/src/enforce.ts +1125 -0
  24. package/src/eslint-plugin-jsx-a11y.d.ts +11 -0
  25. package/src/evidence.ts +308 -0
  26. package/src/github-identity.ts +201 -0
  27. package/src/hook.ts +242 -0
  28. package/src/impact-gate.ts +107 -0
  29. package/src/imports-resolve.ts +248 -0
  30. package/src/index.ts +183 -0
  31. package/src/liquid-ast.ts +203 -0
  32. package/src/liquid-rules.ts +691 -0
  33. package/src/mcp.ts +363 -0
  34. package/src/module-scope.ts +137 -0
  35. package/src/phone-home.ts +470 -0
  36. package/src/pr-comment.ts +250 -0
  37. package/src/pr-summary-cli.ts +182 -0
  38. package/src/pr-summary.ts +291 -0
  39. package/src/registry.ts +605 -0
  40. package/src/reporter/contract.ts +154 -0
  41. package/src/reporter/finding.ts +87 -0
  42. package/src/reporter/github-adapter.ts +183 -0
  43. package/src/reporter/null-adapter.ts +51 -0
  44. package/src/reporter/registry.ts +22 -0
  45. package/src/reporter-cli.ts +48 -0
  46. package/src/resolve-components.ts +579 -0
  47. package/src/runner/budget.ts +90 -0
  48. package/src/runner/codegraph-lookup.test.ts +93 -0
  49. package/src/runner/codegraph-lookup.ts +197 -0
  50. package/src/runner/index.ts +86 -0
  51. package/src/runner/lookup.ts +69 -0
  52. package/src/runner/provider.ts +72 -0
  53. package/src/runner/providers/anthropic.ts +168 -0
  54. package/src/runner/providers/openai.ts +191 -0
  55. package/src/runner/reasoner.ts +73 -0
  56. package/src/runner/reasoning/index.ts +53 -0
  57. package/src/runner/reasoning/prompt.ts +200 -0
  58. package/src/runner/reasoning/react.ts +149 -0
  59. package/src/runner/reasoning/shopify.ts +214 -0
  60. package/src/runner/reasoning/skills-reasoner.ts +117 -0
  61. package/src/runner/reasoning/types.ts +99 -0
  62. package/src/runner/runner.ts +203 -0
  63. package/src/sarif.ts +148 -0
  64. package/src/source-identity.ts +0 -0
  65. package/src/source-trace.ts +814 -0
  66. package/src/suggest.ts +328 -0
  67. package/src/suppression-ranges.ts +354 -0
  68. package/src/suppressor-map.ts +189 -0
  69. package/src/suppressors.ts +284 -0
  70. package/src/tsconfig-aliases.ts +155 -0
  71. package/src/unity-ast.ts +331 -0
  72. package/src/unity-findings.ts +91 -0
  73. package/src/unity-guid-registry.ts +82 -0
  74. package/src/unity-label-resolve.ts +249 -0
  75. package/src/unity-rule-color-only.ts +127 -0
  76. package/src/unity-rule-missing-label.ts +156 -0
  77. package/src/unity-rules-baseline.ts +273 -0
  78. package/src/wcag-map.ts +35 -0
  79. package/src/wcag-tags.ts +35 -0
  80. package/src/workspace-resolve.ts +405 -0
@@ -0,0 +1,579 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import ts from "typescript";
4
+ import {
5
+ isIconLibrary,
6
+ isStructural,
7
+ isToggleRole,
8
+ lookupGuaranteed,
9
+ lookupRegistry,
10
+ } from "./registry";
11
+ import {
12
+ collectLocalImports,
13
+ resolveRoute,
14
+ traceComponent,
15
+ traceWrapperOrigin,
16
+ } from "./source-trace";
17
+
18
+ /**
19
+ * Resolve, for a set of scanned files, every wrapper component used in JSX to
20
+ * the host primitive it renders — so jsx-a11y's rules (which only see literal
21
+ * element names) fire on wrapped components too.
22
+ *
23
+ * Pipeline per used component:
24
+ * 0. declared — customer's `binclusive.json` `components` map wins outright
25
+ * 1. registry — known design-system mapping, no source needed
26
+ * 2. trace — resolve import, read source, infer single host + forwarding
27
+ * 3. opaque — neither resolved; counted in the coverage report, NOT hidden
28
+ *
29
+ * Step 0 is the escape hatch: a customer DECLARES the host for a wrapper the
30
+ * checker can't reach (host hidden behind library indirection, or no source on
31
+ * disk). It overrides registry/trace because the customer's word beats inference.
32
+ *
33
+ * The point of step 3 is honesty: a silent skip looks like a clean codebase.
34
+ * Surfacing the opaque set tells the user where the checker is blind. But
35
+ * "opaque" is not one thing — it sub-classifies into four honest buckets so the
36
+ * report doesn't paint a design-system app as 94% blind (see {@link OpaqueKind}):
37
+ *
38
+ * - trusted — imported from a known-accessible design system (Radix, MUI, …).
39
+ * The library guarantees the structure; opaque-but-fine.
40
+ * - icons — an icon library (lucide, heroicons, …). No interactive host
41
+ * exists to check; nothing actionable.
42
+ * - structural — plumbing with no interactive host: `Fragment`, providers,
43
+ * router layout (`Outlet`/`Route`), charts, email components.
44
+ * Like `icons`, non-actionable — NOT a gap, no declare hint.
45
+ * - declare — none of the above: a genuine unknown. THIS is the real gap,
46
+ * and the only bucket that carries the "declare it" hint.
47
+ *
48
+ * The sub-classification is REPORTING ONLY — none of the four enters the
49
+ * jsx-a11y map, exactly as a flat opaque didn't. Checking behavior is unchanged.
50
+ */
51
+
52
+ const CAP_NAME = /^[A-Z]/; // capitalized JSX name = component (vs intrinsic host)
53
+
54
+ /**
55
+ * How a component's host was determined, for the coverage report. `declared` is
56
+ * the customer's `binclusive.json` override — counted as covered, same as the
57
+ * auto-resolved provenances. `opaque` means no host could be determined; an
58
+ * opaque component additionally carries an {@link OpaqueKind} sub-classification.
59
+ */
60
+ export type Provenance = "declared" | "registry" | "trace" | "opaque";
61
+
62
+ /** A provenance under which a host WAS determined (so the component is CHECKED). */
63
+ export type ResolvedProvenance = Exclude<Provenance, "opaque">;
64
+
65
+ /**
66
+ * The honest sub-bucket of an OPAQUE component — why it has no host, and whether
67
+ * that's a real gap. See {@link resolveComponents}'s step 3.
68
+ *
69
+ * - `trusted` — from a known-accessible design system. Opaque-but-fine: the
70
+ * library handles its internal a11y when used correctly.
71
+ * - `icons` — from an icon library. No interactive host to check; not a gap.
72
+ * - `structural` — non-rendering / non-interactive plumbing (`Fragment`,
73
+ * providers, router layout, charts, email). Like `icons`: no
74
+ * host exists to check, so it is NOT a gap and carries no hint.
75
+ * - `declare` — none of the above: neither resolvable nor recognized. The
76
+ * genuine unknown — the only bucket that gets the declare hint.
77
+ */
78
+ export type OpaqueKind = "trusted" | "icons" | "structural" | "declare";
79
+
80
+ /**
81
+ * Per-component resolution outcome, a discriminated union on `provenance`:
82
+ *
83
+ * - resolved (`declared`/`registry`/`trace`) → a concrete `host`, fed to
84
+ * jsx-a11y. No `opaqueKind` (it would be meaningless — the host is known).
85
+ * - opaque → `host: null` plus an `opaqueKind` bucket for the report. The
86
+ * `library` is the guaranteeing design system for `trusted`, else `null`.
87
+ *
88
+ * Modeling it as a union makes the impossible state (an `opaqueKind` on a
89
+ * resolved component, or a `host` on an opaque one) unrepresentable.
90
+ */
91
+ export type ComponentResolution =
92
+ | {
93
+ readonly name: string;
94
+ readonly module: string;
95
+ /**
96
+ * The ORIGINAL imported/exported name (`used.imported`), NOT the local
97
+ * JSX alias in `name`. Router-link detection must match on this: a repo
98
+ * may `import { Link as RouterLink }` and map `RouterLink` -> `a`, so the
99
+ * local `name` is `RouterLink` but the export name (`Link`) is what the
100
+ * registry recognizes (see `isRouterLinkControl` in core.ts).
101
+ */
102
+ readonly imported: string;
103
+ readonly host: string;
104
+ readonly provenance: ResolvedProvenance;
105
+ /**
106
+ * The explicit ARIA `role` the resolved host carries, when one is known
107
+ * (a Radix/antd toggle primitive, or a static `role="…"` literal captured
108
+ * in the trace); `null` otherwise. A TOGGLE role ({@link isToggleRole})
109
+ * means the host is a checkbox/switch/radio, NOT a bare button/input — so
110
+ * such a host is kept OUT of the jsx-a11y map and skipped by enforce.
111
+ */
112
+ readonly role: string | null;
113
+ /**
114
+ * Whether the wrapper renders its host an internal STATIC accessible name
115
+ * (an `sr-only`/visually-hidden span, a literal `aria-label`, or static
116
+ * text children) — captured by the trace. When true the control is named
117
+ * even though the self-closing call site looks empty, so enforce skips its
118
+ * no-name check, exactly as a toggle role is skipped. `false` for declared
119
+ * and registry resolutions (no source body to scan).
120
+ */
121
+ readonly rendersOwnName: boolean;
122
+ }
123
+ | {
124
+ readonly name: string;
125
+ readonly module: string;
126
+ readonly host: null;
127
+ readonly provenance: "opaque";
128
+ readonly opaqueKind: OpaqueKind;
129
+ /** Guaranteeing library name for `trusted`; `null` for `icons`/`declare`. */
130
+ readonly library: string | null;
131
+ };
132
+
133
+ /**
134
+ * Coverage tally across all resolved components. `opaque` is the total of the
135
+ * four opaque sub-buckets (`trusted + icons + structural + declare`), kept so
136
+ * existing callers that only care "how many had no host" stay correct; the
137
+ * sub-bucket counts are the reframed, honest split.
138
+ */
139
+ export interface Coverage {
140
+ readonly total: number;
141
+ readonly declared: number;
142
+ readonly registry: number;
143
+ readonly traced: number;
144
+ readonly opaque: number;
145
+ /** OPAQUE from a known-accessible design system — the library handles a11y. */
146
+ readonly trusted: number;
147
+ /** OPAQUE icon-library components — no interactive host to check. */
148
+ readonly icons: number;
149
+ /** OPAQUE plumbing (Fragment/provider/router/chart/email) — no host; not a gap. */
150
+ readonly structural: number;
151
+ /** OPAQUE genuine unknowns — the real gap; carries the declare hint. */
152
+ readonly declare: number;
153
+ }
154
+
155
+ /** The resolved jsx-a11y component map plus its coverage report. */
156
+ export interface ResolvedComponents {
157
+ /** `settings.components` value: wrapper name -> host primitive. */
158
+ readonly map: Readonly<Record<string, string>>;
159
+ readonly coverage: Coverage;
160
+ /** Full per-component detail, incl. opaque ones, for reporting. */
161
+ readonly resolutions: readonly ComponentResolution[];
162
+ /**
163
+ * Distinct bare-package specifiers (e.g. `@base-ui/react`) for components
164
+ * that landed in the `declare` bucket AND cannot be resolved to any file on
165
+ * disk. Sorted. An empty array means all declare-bucket components came from
166
+ * resolvable sources (composite wrappers, etc.) — no package install note
167
+ * needed. Non-empty signals the cold-scan blind spot: "install deps to trace".
168
+ */
169
+ readonly unresolvedPackages: readonly string[];
170
+ /**
171
+ * The parsed `ts.SourceFile` for each SCANNED file (keyed by the path passed
172
+ * in), built by the walk this resolver already does. Surfaced so a downstream
173
+ * consumer (the edit-time hook) can reuse the parse instead of re-reading +
174
+ * re-parsing the file — the no-second-parse guarantee the hot hook path needs.
175
+ * A file that couldn't be read is absent (the resolver `continue`s on a read
176
+ * failure).
177
+ */
178
+ readonly sourceFiles: ReadonlyMap<string, ts.SourceFile>;
179
+ }
180
+
181
+ /** A capitalized JSX name used in a file, with its local-import context. */
182
+ export interface UsedComponent {
183
+ readonly local: string;
184
+ readonly module: string;
185
+ readonly imported: string;
186
+ readonly isNamespace: boolean;
187
+ }
188
+
189
+ /**
190
+ * Find every capitalized JSX element used in a file that maps to an import,
191
+ * deduped by local name. Locally-defined components (not imported) are skipped
192
+ * here — they are resolved separately because their "import" is the file itself.
193
+ *
194
+ * Exported so a per-file consumer (the recall gate's per-file slice scoping) can
195
+ * learn which resolutions a single file actually USES — the globally-deduped
196
+ * {@link ComponentResolution} array carries no file home of its own.
197
+ */
198
+ export function collectUsedComponents(sf: ts.SourceFile): UsedComponent[] {
199
+ const imports = collectLocalImports(sf);
200
+ const seen = new Set<string>();
201
+ const out: UsedComponent[] = [];
202
+
203
+ const consider = (rawName: string): void => {
204
+ // `NS.Member` -> resolve via the namespace local name.
205
+ const dot = rawName.indexOf(".");
206
+ const local = dot === -1 ? rawName : rawName.slice(0, dot);
207
+ const member = dot === -1 ? null : rawName.slice(dot + 1);
208
+ if (!CAP_NAME.test(local)) return;
209
+ if (seen.has(rawName)) return;
210
+ const binding = imports.get(local);
211
+ if (binding === undefined) return;
212
+ seen.add(rawName);
213
+ out.push({
214
+ local: rawName,
215
+ module: binding.module,
216
+ imported: binding.isNamespace ? (member ?? binding.imported) : binding.imported,
217
+ isNamespace: binding.isNamespace,
218
+ });
219
+ };
220
+
221
+ const visit = (node: ts.Node): void => {
222
+ if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
223
+ const tagName = node.tagName;
224
+ if (ts.isIdentifier(tagName)) {
225
+ consider(tagName.text);
226
+ } else if (ts.isPropertyAccessExpression(tagName) && ts.isIdentifier(tagName.expression)) {
227
+ consider(`${tagName.expression.text}.${tagName.name.text}`);
228
+ }
229
+ }
230
+ ts.forEachChild(node, visit);
231
+ };
232
+ visit(sf);
233
+ return out;
234
+ }
235
+
236
+ function readSourceFile(filePath: string): ts.SourceFile | null {
237
+ const text = ts.sys.readFile(filePath);
238
+ if (text === undefined) return null;
239
+ return ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
240
+ }
241
+
242
+ /**
243
+ * The key jsx-a11y matches a JSX element on. For a namespace render
244
+ * (`NS.Member`) that's the trailing member name; for a plain wrapper it's the
245
+ * name itself. Split-derived so it never needs a non-null assertion.
246
+ */
247
+ export function jsxKeyFor(localName: string): string {
248
+ const parts = localName.split(".");
249
+ return parts[parts.length - 1] ?? localName;
250
+ }
251
+
252
+ /**
253
+ * Host primitives whose ONLY scored jsx-a11y rule cannot be evaluated through a
254
+ * wrapper, so a wrapper resolving to one must NOT be put in the component map.
255
+ *
256
+ * `label` is the case: the only `label`-targeting rule is
257
+ * `label-has-associated-control`, which passes when the label carries `htmlFor`
258
+ * or nests its control. A library label COMPONENT (`FormLabel`, `FieldLabel`,
259
+ * MUI `InputLabel`, ...) establishes that association INTERNALLY — via `htmlFor`
260
+ * it injects, React context, or composition — none of which is visible at the
261
+ * `<FormLabel>Name</FormLabel>` call site. Mapping the wrapper to `label` makes
262
+ * jsx-a11y treat the call site as a bare `<label>` and demand a call-site
263
+ * association that isn't there, firing on EVERY usage: a guaranteed false
264
+ * positive.
265
+ *
266
+ * Excluding these from the map (while still counting them as resolved coverage)
267
+ * is false-negative-safe: a genuinely unassociated label written as a literal
268
+ * lowercase `<label>` is an intrinsic element jsx-a11y sees directly — it is
269
+ * unaffected by the component map and still flags correctly.
270
+ */
271
+ const UNMAPPABLE_HOSTS: ReadonlySet<string> = new Set(["label"]);
272
+
273
+ /**
274
+ * Resolve all wrapper components across the given scanned files into a
275
+ * jsx-a11y component map plus a coverage report. A component resolved in one
276
+ * file is reused everywhere (the map key is the JSX name jsx-a11y matches on).
277
+ *
278
+ * `declared` is the customer's `binclusive.json` `components` map (JSX name ->
279
+ * host). It is the escape hatch: a declared entry resolves a wrapper the checker
280
+ * can't reach and OVERRIDES registry/trace inference. Declared entries that no
281
+ * scanned file actually USES are silently ignored — coverage reflects the code,
282
+ * not the config — so a stale declaration never inflates the tally.
283
+ */
284
+ /**
285
+ * The npm package NAME of a bare specifier, or `null` if the specifier is not a
286
+ * syntactically valid bare package import.
287
+ *
288
+ * Rejects (returns null) — these are NOT packages, so they can never be a
289
+ * "missing dependency":
290
+ * - relative (`.`/`..`), absolute (`/`), and subpath-imports (`#internal`)
291
+ * - path aliases that LOOK bare but aren't valid package names: a leading `~`
292
+ * (`~/components/card`), and an empty-scope `@/...` (an `@` not followed by a
293
+ * scope segment, the Next/Vite default alias). These are the exact shapes
294
+ * that leaked into the unresolved-package note before the deps cross-check.
295
+ *
296
+ * For a real bare import it returns the package id: `pkg` or `@scope/pkg`,
297
+ * stripping any `/subpath` (`@base-ui/react/Dialog` → `@base-ui/react`).
298
+ */
299
+ function packageNameOf(specifier: string): string | null {
300
+ if (
301
+ specifier === "" ||
302
+ specifier.startsWith(".") ||
303
+ specifier.startsWith("/") ||
304
+ specifier.startsWith("#") ||
305
+ specifier.startsWith("~")
306
+ ) {
307
+ return null;
308
+ }
309
+ const parts = specifier.split("/");
310
+ if (specifier.startsWith("@")) {
311
+ // Scoped: must be `@scope/name` — reject empty-scope (`@/...`) and a bare
312
+ // `@scope` with no name segment.
313
+ const scope = parts[0];
314
+ const name = parts[1];
315
+ if (scope === undefined || scope === "@" || name === undefined || name === "") return null;
316
+ return `${scope}/${name}`;
317
+ }
318
+ const name = parts[0];
319
+ return name === undefined || name === "" ? null : name;
320
+ }
321
+
322
+ /** Per-directory cache of the nearest package.json's declared-dependency set. */
323
+ const declaredDepsCache = new Map<string, ReadonlySet<string>>();
324
+
325
+ /** The dependency buckets whose union counts as a "declared dependency". */
326
+ const DEP_FIELDS = [
327
+ "dependencies",
328
+ "devDependencies",
329
+ "peerDependencies",
330
+ "optionalDependencies",
331
+ ] as const;
332
+
333
+ /**
334
+ * The union of every declared dependency in the nearest `package.json` at or
335
+ * above `fromFile`. This is the real gate for the unresolved-package note: a
336
+ * specifier is only reported as an uninstalled package when it IS a declared
337
+ * dependency (so "install dependencies" is always correct advice) — path aliases
338
+ * and ad-hoc bare strings that no manifest declares are excluded. Walks up to the
339
+ * filesystem root; a malformed or missing manifest yields an empty set. Cached
340
+ * per starting directory.
341
+ */
342
+ function declaredDependencies(fromFile: string): ReadonlySet<string> {
343
+ let dir = dirname(fromFile);
344
+ for (;;) {
345
+ const cached = declaredDepsCache.get(dir);
346
+ if (cached !== undefined) return cached;
347
+
348
+ let text: string | null = null;
349
+ try {
350
+ text = readFileSync(join(dir, "package.json"), "utf8");
351
+ } catch {
352
+ text = null;
353
+ }
354
+ if (text !== null) {
355
+ const deps = new Set<string>();
356
+ try {
357
+ const pkg: unknown = JSON.parse(text);
358
+ if (typeof pkg === "object" && pkg !== null) {
359
+ for (const field of DEP_FIELDS) {
360
+ const bucket = (pkg as Record<string, unknown>)[field];
361
+ if (typeof bucket === "object" && bucket !== null && !Array.isArray(bucket)) {
362
+ for (const name of Object.keys(bucket)) deps.add(name);
363
+ }
364
+ }
365
+ }
366
+ } catch {
367
+ // Malformed manifest → empty set (boundary-parsed, never throws).
368
+ }
369
+ declaredDepsCache.set(dir, deps);
370
+ return deps;
371
+ }
372
+
373
+ const parent = dirname(dir);
374
+ if (parent === dir) break;
375
+ dir = parent;
376
+ }
377
+ const empty = new Set<string>();
378
+ declaredDepsCache.set(dirname(fromFile), empty);
379
+ return empty;
380
+ }
381
+
382
+ export function resolveComponents(
383
+ filePaths: readonly string[],
384
+ declared: Readonly<Record<string, string>> = {},
385
+ ): ResolvedComponents {
386
+ const map: Record<string, string> = {};
387
+ const resolutions: ComponentResolution[] = [];
388
+ const seen = new Set<string>();
389
+ // Collect bare package specifiers that are unresolvable on disk AND produced a
390
+ // declare-bucket opaque. Deduplicated; built into `unresolvedPackages` at the end.
391
+ const unresolvedPkgSet = new Set<string>();
392
+ // Keep each scanned file's parse so a downstream consumer (R4) can reuse it
393
+ // rather than re-read+re-parse — this is the parse the resolver already does.
394
+ const sourceFiles = new Map<string, ts.SourceFile>();
395
+
396
+ for (const filePath of filePaths) {
397
+ const sf = readSourceFile(filePath);
398
+ if (sf === null) continue;
399
+ sourceFiles.set(filePath, sf);
400
+
401
+ for (const used of collectUsedComponents(sf)) {
402
+ // Dedupe across files by (name@module) so the report counts each wrapper
403
+ // once, not once per usage site.
404
+ const key = `${used.local}@${used.module}`;
405
+ if (seen.has(key)) continue;
406
+ seen.add(key);
407
+
408
+ // Add a resolved wrapper to the jsx-a11y map UNLESS its host can't be
409
+ // evaluated through a wrapper. Two exclusions, both still counted as
410
+ // resolved coverage:
411
+ // - UNMAPPABLE_HOSTS — the host's sole rule needs call-site context the
412
+ // wrapper hides (`label`).
413
+ // - a TOGGLE role — a `button`/`input` host that actually renders
414
+ // `role="checkbox|switch|radio"` (Radix Checkbox/Switch, antd Switch,
415
+ // a homegrown `<button role="switch">`). Mapping it to the bare host
416
+ // makes jsx-a11y read `<Checkbox aria-invalid>` as a bare `<button>`
417
+ // and fire `role-supports-aria-props` — a false positive, since
418
+ // `aria-invalid` IS valid on `role="checkbox"`. Skipping the map (like
419
+ // a label wrapper) is FN-safe: a literal broken toggle is still seen.
420
+ const recordResolved = (
421
+ host: string,
422
+ provenance: ResolvedProvenance,
423
+ role: string | null,
424
+ rendersOwnName: boolean,
425
+ ): void => {
426
+ if (!UNMAPPABLE_HOSTS.has(host) && !isToggleRole(role)) {
427
+ map[jsxKeyFor(used.local)] = host;
428
+ }
429
+ resolutions.push({
430
+ name: used.local,
431
+ module: used.module,
432
+ imported: used.imported,
433
+ host,
434
+ provenance,
435
+ role,
436
+ rendersOwnName,
437
+ });
438
+ };
439
+
440
+ // 0. Customer declaration wins outright — the escape hatch for hosts the
441
+ // checker can't infer. A declared host carries no role and no internal
442
+ // name (the customer declares only the host).
443
+ //
444
+ // Lookup tries the FULL dotted local name first (`Dialog.Close`), then
445
+ // falls back to the leaf (`Close`). A compound member rendered as
446
+ // `<Dialog.Close>` is what the customer SEES in JSX, so declaring
447
+ // `"Dialog.Close": "button"` is the natural, precise way to map exactly
448
+ // that member — without bleeding across every unrelated `*.Close`. The
449
+ // leaf fallback keeps the older bare-leaf declaration working and is the
450
+ // only key shape that exists for a plain (non-member) wrapper.
451
+ //
452
+ // The jsx-a11y map write (above in `recordResolved`, via
453
+ // `map[jsxKeyFor(used.local)] = host`) stays LEAF-keyed regardless:
454
+ // jsx-a11y's `components` setting matches a `NS.Member` tag on its
455
+ // trailing identifier, so the host must land under the leaf for the
456
+ // structural pass to fire. The dotted-then-leaf DECLARATION lookup
457
+ // below feeds that same write through `recordResolved`. Declaration
458
+ // shape and map shape are deliberately independent here.
459
+ const declaredHost = declared[used.local] ?? declared[jsxKeyFor(used.local)];
460
+ if (declaredHost !== undefined) {
461
+ recordResolved(declaredHost, "declared", null, false);
462
+ continue;
463
+ }
464
+
465
+ // Registry is checked inside traceComponent, but we want the provenance
466
+ // distinction (registry vs trace) for the report, so probe it directly. A
467
+ // registry leaf primitive renders no internal name of its own.
468
+ const reg = lookupRegistry(used.module, used.imported);
469
+ if (reg !== null) {
470
+ recordResolved(reg.host, "registry", reg.role ?? null, false);
471
+ continue;
472
+ }
473
+
474
+ const traced = traceComponent(used.module, used.imported, filePath);
475
+ if (traced !== null) {
476
+ recordResolved(traced.host, traced.via, traced.role ?? null, traced.rendersOwnName);
477
+ continue;
478
+ }
479
+
480
+ // OPAQUE: no host. Sub-classify HONESTLY by where the import comes from.
481
+ // Order is by certainty of "no interactive host":
482
+ // icons — an SVG pack (`@radix-ui/react-icons`): no host, period.
483
+ // Checked FIRST so the `@radix-ui` guarantee prefix can't
484
+ // claim it as `trusted`.
485
+ // structural — plumbing (Fragment / *Provider / router layout / chart /
486
+ // email): also no host, also non-actionable. Checked BEFORE
487
+ // `trusted` because a provider from a guaranteed library
488
+ // (`<Tooltip.Provider>` from `@radix-ui`) is still plumbing,
489
+ // not a primitive to vouch for — structural is the truer
490
+ // bucket. A genuine control from a guaranteed lib never
491
+ // matches the structural rules, so it still reads `trusted`.
492
+ // trusted — from a known-accessible design system.
493
+ // declare — the genuine unknown; the only actionable gap.
494
+ const isIcons = isIconLibrary(used.module);
495
+ const isPlumbing = !isIcons && isStructural(used.local, used.module);
496
+ const guaranteedLib = isIcons || isPlumbing ? null : lookupGuaranteed(used.module);
497
+ let opaqueKind: OpaqueKind = isIcons
498
+ ? "icons"
499
+ : isPlumbing
500
+ ? "structural"
501
+ : guaranteedLib !== null
502
+ ? "trusted"
503
+ : "declare";
504
+ let opaqueLibrary = guaranteedLib;
505
+
506
+ // Own-code barrel re-export reclassification. The buckets above key on the
507
+ // literal import specifier, so a local `@/components/ui/dialog` wrapper that
508
+ // is just a thin alias of a guaranteed primitive (`const Dialog =
509
+ // DialogPrimitive.Root`) wears its OWN `@/…` specifier and reads `declare` —
510
+ // even though it is Radix underneath. Follow the wrapper to its ORIGIN
511
+ // module and re-bucket THERE: the honest classification is where the
512
+ // primitive really lives. Thin-only (see `traceWrapperOrigin`) — a genuine
513
+ // app composite resolves to no single origin and stays `declare`, never
514
+ // promoted. Same icons→structural→trusted precedence as above, run on the
515
+ // origin module; if the origin is itself unknown, the `declare` stands.
516
+ if (opaqueKind === "declare") {
517
+ const origin = traceWrapperOrigin(used.module, used.imported, filePath);
518
+ if (origin !== null) {
519
+ const originIcons = isIconLibrary(origin);
520
+ const originPlumbing = !originIcons && isStructural(used.local, origin);
521
+ const originGuaranteed =
522
+ originIcons || originPlumbing ? null : lookupGuaranteed(origin);
523
+ if (originIcons) {
524
+ opaqueKind = "icons";
525
+ opaqueLibrary = null;
526
+ } else if (originPlumbing) {
527
+ opaqueKind = "structural";
528
+ opaqueLibrary = null;
529
+ } else if (originGuaranteed !== null) {
530
+ opaqueKind = "trusted";
531
+ opaqueLibrary = originGuaranteed;
532
+ }
533
+ }
534
+ }
535
+ // When a component lands in `declare` because its specifier is a DECLARED
536
+ // dependency that resolveRoute can't reach on disk (uninstalled — shallow
537
+ // clone / fresh checkout), record the package so the CLI can surface an
538
+ // actionable "install deps" note instead of silent blindness.
539
+ //
540
+ // The declared-dependency cross-check is the real gate: it excludes path
541
+ // aliases (`~/…`, `@/…`, tsconfig `@app/…`) — which are NOT deps, so the
542
+ // "install dependencies" advice would be false for them — while keeping the
543
+ // genuine uninstalled packages (each IS in package.json, just not on disk).
544
+ // `packageNameOf` is belt-and-suspenders: it rejects alias-shaped strings
545
+ // syntactically before we even consult the manifest.
546
+ if (opaqueKind === "declare" && resolveRoute(used.module, filePath) === null) {
547
+ const pkgName = packageNameOf(used.module);
548
+ if (pkgName !== null && declaredDependencies(filePath).has(pkgName)) {
549
+ unresolvedPkgSet.add(pkgName);
550
+ }
551
+ }
552
+ resolutions.push({
553
+ name: used.local,
554
+ module: used.module,
555
+ host: null,
556
+ provenance: "opaque",
557
+ opaqueKind,
558
+ library: opaqueLibrary,
559
+ });
560
+ }
561
+ }
562
+
563
+ const opaque = resolutions.filter((r) => r.provenance === "opaque");
564
+ const opaqueOf = (kind: OpaqueKind): number => opaque.filter((r) => r.opaqueKind === kind).length;
565
+ const coverage: Coverage = {
566
+ total: resolutions.length,
567
+ declared: resolutions.filter((r) => r.provenance === "declared").length,
568
+ registry: resolutions.filter((r) => r.provenance === "registry").length,
569
+ traced: resolutions.filter((r) => r.provenance === "trace").length,
570
+ opaque: opaque.length,
571
+ trusted: opaqueOf("trusted"),
572
+ icons: opaqueOf("icons"),
573
+ structural: opaqueOf("structural"),
574
+ declare: opaqueOf("declare"),
575
+ };
576
+
577
+ const unresolvedPackages = [...unresolvedPkgSet].sort();
578
+ return { map, coverage, resolutions, unresolvedPackages, sourceFiles };
579
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The HARD per-PR token ceiling — the load-bearing cap of the low-cap loop.
3
+ *
4
+ * The ceiling is enforced in two layers that converge on the same terminal state
5
+ * ("capped"), never on a failure:
6
+ * 1. At each PASS boundary the runner asks {@link TokenLedger.exhausted}; once
7
+ * true, every remaining deterministic finding is skipped.
8
+ * 2. Mid-pass, {@link meterProvider} REFUSES a model call the moment the wallet
9
+ * is empty, throwing {@link TokenCeilingExceeded}. This is the runaway guard:
10
+ * a reasoner that loops model calls inside one pass cannot outrun the ceiling.
11
+ *
12
+ * The single in-flight call may overshoot the ceiling: you cannot know a model
13
+ * call's true cost before making it, so the meter admits a call while ANY budget
14
+ * remains and accounts for it after. Overshoot is bounded by one call — an honest
15
+ * "hard ceiling", not a fantasy of pre-metered spend.
16
+ *
17
+ * The throw is a bulkhead, not general control flow: it is caught in exactly one
18
+ * place (the runner's per-pass guard) and converted immediately into the `capped`
19
+ * arm of `RunOutcome`. The runner's public surface never throws.
20
+ */
21
+ import type { Provider, TokenUsage } from "./provider";
22
+ import { isMeterableUsage, usageTotal } from "./provider";
23
+
24
+ /** A read-only view of the ledger, safe to hand out in a `RunOutcome`. */
25
+ export interface BudgetSnapshot {
26
+ readonly ceiling: number;
27
+ readonly used: number;
28
+ readonly remaining: number;
29
+ }
30
+
31
+ /** Thrown by {@link meterProvider} when a model call is attempted with no budget left. */
32
+ export class TokenCeilingExceeded extends Error {
33
+ constructor(
34
+ readonly ceiling: number,
35
+ readonly used: number,
36
+ ) {
37
+ super(`per-PR token ceiling reached: used ${used} of ${ceiling}`);
38
+ this.name = "TokenCeilingExceeded";
39
+ }
40
+ }
41
+
42
+ /** Accumulates model-token spend against a fixed ceiling. */
43
+ export class TokenLedger {
44
+ #used = 0;
45
+
46
+ constructor(readonly ceiling: number) {}
47
+
48
+ get used(): number {
49
+ return this.#used;
50
+ }
51
+
52
+ get remaining(): number {
53
+ return Math.max(0, this.ceiling - this.#used);
54
+ }
55
+
56
+ /** No budget remains — the next pass must be skipped. */
57
+ exhausted(): boolean {
58
+ return this.#used >= this.ceiling;
59
+ }
60
+
61
+ record(usage: TokenUsage): void {
62
+ // Fail closed on an unmeterable usage report (NaN / ±Infinity / negative): the
63
+ // ceiling check `used >= ceiling` silently passes on a NaN, so a malformed
64
+ // provider response would run past the cap. Treat it as fully draining the
65
+ // wallet — unknown spend is AT-OR-OVER the ceiling, so the next call is refused.
66
+ this.#used = isMeterableUsage(usage)
67
+ ? this.#used + usageTotal(usage)
68
+ : Number.POSITIVE_INFINITY;
69
+ }
70
+
71
+ snapshot(): BudgetSnapshot {
72
+ return { ceiling: this.ceiling, used: this.#used, remaining: this.remaining };
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Wrap a provider so every completion is charged to the ledger. A call attempted
78
+ * with an already-empty wallet throws {@link TokenCeilingExceeded} instead of
79
+ * spending — this is the hard stop that makes the ceiling a ceiling.
80
+ */
81
+ export function meterProvider(provider: Provider, ledger: TokenLedger): Provider {
82
+ return {
83
+ async complete(request) {
84
+ if (ledger.exhausted()) throw new TokenCeilingExceeded(ledger.ceiling, ledger.used);
85
+ const response = await provider.complete(request);
86
+ ledger.record(response.usage);
87
+ return response;
88
+ },
89
+ };
90
+ }