uidex 0.9.0 → 0.11.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 (45) hide show
  1. package/dist/cli/cli.cjs +4117 -2902
  2. package/dist/cli/cli.cjs.map +1 -1
  3. package/dist/headless/index.cjs +3 -0
  4. package/dist/headless/index.cjs.map +1 -1
  5. package/dist/headless/index.d.cts +55 -16
  6. package/dist/headless/index.d.ts +55 -16
  7. package/dist/headless/index.js +3 -0
  8. package/dist/headless/index.js.map +1 -1
  9. package/dist/index.cjs +7 -1
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +55 -16
  12. package/dist/index.d.ts +55 -16
  13. package/dist/index.js +7 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/playwright/index.cjs +40 -7
  16. package/dist/playwright/index.cjs.map +1 -1
  17. package/dist/playwright/index.d.cts +15 -1
  18. package/dist/playwright/index.d.ts +15 -1
  19. package/dist/playwright/index.js +39 -7
  20. package/dist/playwright/index.js.map +1 -1
  21. package/dist/playwright/states-reporter.cjs +36 -7
  22. package/dist/playwright/states-reporter.cjs.map +1 -1
  23. package/dist/playwright/states-reporter.d.cts +15 -0
  24. package/dist/playwright/states-reporter.d.ts +15 -0
  25. package/dist/playwright/states-reporter.js +36 -7
  26. package/dist/playwright/states-reporter.js.map +1 -1
  27. package/dist/playwright/states.cjs +8 -0
  28. package/dist/playwright/states.cjs.map +1 -1
  29. package/dist/playwright/states.d.cts +44 -1
  30. package/dist/playwright/states.d.ts +44 -1
  31. package/dist/playwright/states.js +7 -0
  32. package/dist/playwright/states.js.map +1 -1
  33. package/dist/react/index.cjs +7 -1
  34. package/dist/react/index.cjs.map +1 -1
  35. package/dist/react/index.d.cts +55 -16
  36. package/dist/react/index.d.ts +55 -16
  37. package/dist/react/index.js +7 -1
  38. package/dist/react/index.js.map +1 -1
  39. package/dist/scan/index.cjs +1533 -294
  40. package/dist/scan/index.cjs.map +1 -1
  41. package/dist/scan/index.d.cts +459 -112
  42. package/dist/scan/index.d.ts +459 -112
  43. package/dist/scan/index.js +1517 -290
  44. package/dist/scan/index.js.map +1 -1
  45. package/package.json +1 -1
@@ -15,8 +15,8 @@ interface EntityRef {
15
15
  /**
16
16
  * The four kinds every captured route is expected to render — the state-matrix
17
17
  * axis. A capture that only shoots the happy path leaves most of the value on
18
- * the table, so a missing core kind must be captured, waived (the page CANNOT
19
- * render it), or acknowledged in the MISSING_KINDS baseline.
18
+ * the table, so a missing core kind must be captured or ACKNOWLEDGED (see
19
+ * `Metadata.acknowledge`).
20
20
  */
21
21
  declare const CORE_STATE_KINDS: readonly ["loading", "empty", "populated", "error"];
22
22
  type CoreStateKind = (typeof CORE_STATE_KINDS)[number];
@@ -34,34 +34,60 @@ interface StateDecl {
34
34
  name: string;
35
35
  /** Canonical kind for the state-matrix axis (defaults to `variant`). */
36
36
  kind?: StateKind;
37
- /** Acceptance criteria that only this state can evidence. */
38
- acceptance?: string[];
37
+ /**
38
+ * Acceptance criteria that only this state can evidence. Normalized into the
39
+ * same `{ id, rev, text }` regime as entity-level `acceptance` (one
40
+ * criterion-id namespace per entity), so per-state criteria are claimable by
41
+ * `uidexCovers` and visible to every acceptance gate.
42
+ */
43
+ acceptance?: AcceptanceCriterion[];
39
44
  description?: string;
40
45
  }
46
+ /**
47
+ * A normalized acceptance criterion. Authored either as a bare string (sugar:
48
+ * `id` is a deterministic kebab slug of the text, `rev` is 1) or as an object
49
+ * `{ id, rev?, text }`; parse-time normalization means everything downstream —
50
+ * audit, scaffold, the devtools — always sees this object shape. `rev` is the
51
+ * pin flows cover against (`uidexCovers("entity", "id@rev")`): bumping it when
52
+ * the criterion's meaning changes is what makes outdated-coverage detectable.
53
+ */
54
+ interface AcceptanceCriterion {
55
+ id: string;
56
+ rev: number;
57
+ text: string;
58
+ }
41
59
  interface Metadata {
42
60
  name?: string;
43
61
  description?: string;
44
- acceptance?: string[];
62
+ acceptance?: AcceptanceCriterion[];
45
63
  notes?: string;
46
64
  composes?: EntityRef[];
47
65
  flows?: readonly string[];
48
66
  features?: string[];
49
67
  widgets?: string[];
68
+ /** Page ids a feature declares it must be mounted on (feature kind only). */
69
+ surfaces?: string[];
50
70
  /** Declared render states (see StateDecl); the capture-completeness gate. */
51
71
  states?: StateDecl[];
52
72
  /**
53
- * `false` opts this page/feature/widget out of visual-states capture forever
54
- * (a redirect, a static notice, an internal target). The page-coverage gate
55
- * treats an opted-out route as accounted-for. Reason belongs in `description`.
56
- */
57
- capture?: boolean;
58
- /**
59
- * Per-core-kind waivers: a core kind this page's route legitimately CANNOT
60
- * render (a create form has no `empty`), mapped to a substantive reason. The
61
- * state-matrix gate treats a waived kind as accounted-for. A waiver is a
62
- * permanent design statement unlike MISSING_KINDS backlog, which is debt.
73
+ * The ONE way to say "don't complain about this gap": scope typed reason.
74
+ *
75
+ * - scope `"*"` covers the whole entity (a redirect page that can never be
76
+ * captured, a component whose states are not gated here);
77
+ * - a core-kind scope covers that one cell of this page's route matrix.
78
+ *
79
+ * `type: "impossible"` is a permanent design statement and needs a `reason`;
80
+ * `type: "backlog"` is debt and does not. Keeping both as values of one field
81
+ * is what makes mislabeling debt as impossible a visible, reviewable edit.
82
+ * `captured-elsewhere` is computed by the workspace and never authored here.
63
83
  */
64
- waivers?: Partial<Record<CoreStateKind, string>>;
84
+ acknowledge?: Partial<Record<AcknowledgeScope, MetaAcknowledgment>>;
85
+ }
86
+ /** Scope of an acknowledgment: one core kind, or the whole entity. */
87
+ type AcknowledgeScope = CoreStateKind | "*";
88
+ interface MetaAcknowledgment {
89
+ type: "impossible" | "backlog";
90
+ reason?: string;
65
91
  }
66
92
  interface EntityWithMetaBase {
67
93
  id: string;
@@ -78,12 +104,25 @@ interface FlowStep {
78
104
  entityId: string;
79
105
  action?: string;
80
106
  }
107
+ /**
108
+ * A per-criterion coverage claim a flow makes via
109
+ * `uidexCovers("<entityId>", "<criterionId>@<rev>")`. The rev is REQUIRED at
110
+ * the claim site — a rev-less claim could never go stale, which would defeat
111
+ * the outdated-detection the pin exists for.
112
+ */
113
+ interface FlowCover {
114
+ entity: string;
115
+ criterion: string;
116
+ rev: number;
117
+ }
81
118
  interface Flow {
82
119
  kind: "flow";
83
120
  id: string;
84
121
  loc: Location;
85
122
  touches: string[];
86
123
  steps: FlowStep[];
124
+ /** Acceptance-criterion coverage claims made inside this flow's describe. */
125
+ covers?: FlowCover[];
87
126
  }
88
127
  interface Page extends EntityWithMetaBase {
89
128
  kind: "page";
@@ -172,12 +211,38 @@ interface AuditConfig {
172
211
  scopeLeak?: boolean;
173
212
  coverage?: boolean;
174
213
  acceptance?: boolean;
214
+ /** Feature `surfaces` mount verification (default on; only fires when a feature declares `surfaces`). */
215
+ surfaces?: boolean;
216
+ /**
217
+ * Require every feature to declare at least one acceptance criterion
218
+ * (default ON — a bare `uidex.feature.ts` is a warning, escalatable to
219
+ * error via `severity` or disabled with `requireAcceptance: false`).
220
+ */
221
+ requireAcceptance?: boolean;
175
222
  /** State-capture completeness gate (default on when a manifest is present). */
176
223
  states?: boolean;
177
224
  /** Page-capture coverage gate (default on when a manifest is present). */
178
225
  pageCoverage?: boolean;
179
226
  /** State-matrix (core-kind) gate (default on when a manifest is present). */
180
227
  stateMatrix?: boolean;
228
+ /**
229
+ * Per-diagnostic-code severity overrides, e.g.
230
+ * `{ "missing-state-capture": "error" }`. A code's built-in severity is a
231
+ * default, not a policy — this is how a repo escalates an advisory gate into
232
+ * a build-failing ratchet, or silences one it has consciously accepted.
233
+ * `"off"` drops the diagnostic entirely. Unknown codes are rejected by
234
+ * config validation, so a typo can't silently disable a gate.
235
+ */
236
+ severity?: Record<string, DiagnosticSeverity | "off">;
237
+ }
238
+ /** Declared module-resolution hints for the import-graph checks. */
239
+ interface ResolveConfig {
240
+ /**
241
+ * Specifier-prefix → config-dir-relative path-prefix aliases
242
+ * (e.g. `{ "@/": "src/" }`). Longest matching prefix wins. When declared,
243
+ * these replace the scanner's zero-config `@/` / `~/` → `src/` fallback.
244
+ */
245
+ aliases: Record<string, string>;
181
246
  }
182
247
  interface UidexConfig {
183
248
  $schema?: string;
@@ -185,6 +250,7 @@ interface UidexConfig {
185
250
  exclude?: string[];
186
251
  output: string;
187
252
  flows?: string[];
253
+ resolve?: ResolveConfig;
188
254
  audit?: AuditConfig;
189
255
  conventions?: ConventionsConfig;
190
256
  /**
@@ -194,11 +260,21 @@ interface UidexConfig {
194
260
  */
195
261
  statesManifest?: string;
196
262
  /**
197
- * Path (relative to the config dir) of the page-coverage baseline — the
198
- * tool-managed, shrink-only backlog written by `uidex scan --update-baseline`.
199
- * Defaults to `uidex-coverage-baseline.json`.
263
+ * Path (relative to the config dir) of the coverage baseline — the
264
+ * tool-managed, shrink-only `acknowledge` backlog written by
265
+ * `uidex scan --update-baseline`. Defaults to `uidex-coverage-baseline.json`.
200
266
  */
201
267
  coverageBaseline?: string;
268
+ /**
269
+ * The workspace this app belongs to — the level that answers "did a SIBLING
270
+ * app capture this shared declaration?" exactly, instead of guessing.
271
+ *
272
+ * - omitted (default): auto-detect by walking up for a workspace marker
273
+ * (`pnpm-workspace.yaml`, `turbo.json`, `.git`, …). Costs a few JSON reads.
274
+ * - a path (relative to the config dir): use exactly this root.
275
+ * - `false`: closed world — never look at siblings.
276
+ */
277
+ workspace?: string | false;
202
278
  }
203
279
  interface DiscoveredConfig {
204
280
  configPath: string;
@@ -238,7 +314,8 @@ interface MetadataExport {
238
314
  kind: MetadataExportKind;
239
315
  id: string | false;
240
316
  name?: string;
241
- acceptance?: string[];
317
+ /** Entity-level criteria, normalized at parse time (string sugar → object). */
318
+ acceptance?: AcceptanceCriterion[];
242
319
  description?: string;
243
320
  features?: string[];
244
321
  widgets?: string[];
@@ -253,16 +330,29 @@ interface MetadataExport {
253
330
  * one adjacent comma) keyed by field name. Used by `--fix` codemods.
254
331
  */
255
332
  fieldSpans?: Record<string, Span>;
333
+ /**
334
+ * Spans of top-level fields WITHOUT the comma extension (key start → value
335
+ * end), keyed by field name. `fieldSpans` deletes a property; this one
336
+ * replaces it in place, which is what the `acknowledge` codemod needs.
337
+ */
338
+ fieldPropSpans?: Record<string, Span>;
256
339
  /** Spans of the string-literal items in `features`, parallel to `features`. */
257
340
  featureSpans?: Span[];
258
341
  /** Spans of the string-literal items in `widgets`, parallel to `widgets`. */
259
342
  widgetSpans?: Span[];
343
+ /** Declared surface page ids (feature kind only). */
344
+ surfaces?: string[];
345
+ /** Spans of the string-literal items in `surfaces`, parallel to `surfaces`. */
346
+ surfaceSpans?: Span[];
260
347
  /** Declared render states (page/feature/widget); see ParsedState. */
261
348
  states?: ParsedState[];
262
- /** `capture: false` opts the entity out of the page-coverage gate. */
263
- capture?: boolean;
264
- /** Per-core-kind waivers: kind → reason the route cannot render it. */
265
- waivers?: Record<string, string>;
349
+ /** Scope (`"*"` | core kind) typed acknowledgment. See `acknowledge.ts`. */
350
+ acknowledge?: Record<string, ParsedAcknowledgment>;
351
+ }
352
+ /** A parsed `acknowledge` entry from `export const uidex`. */
353
+ interface ParsedAcknowledgment {
354
+ type: "impossible" | "backlog";
355
+ reason?: string;
266
356
  }
267
357
  /**
268
358
  * A parsed `states` entry from `export const uidex`. The authored form is a
@@ -273,7 +363,8 @@ interface ParsedState {
273
363
  name: string;
274
364
  /** Canonical kind: loading | empty | populated | error | variant. */
275
365
  kind?: string;
276
- acceptance?: string[];
366
+ /** Per-state criteria, normalized like entity-level `acceptance`. */
367
+ acceptance?: AcceptanceCriterion[];
277
368
  description?: string;
278
369
  /** Span of the state's name string literal. */
279
370
  nameSpan?: Span;
@@ -290,16 +381,23 @@ interface LandmarkFact {
290
381
  tag: string;
291
382
  line: number;
292
383
  }
293
- /** A static import binding (import / export-from), for the scope-leak check. */
384
+ /** An import binding (import / export-from / dynamic `import()`). */
294
385
  interface ImportFact {
295
386
  specifier: string;
296
387
  line: number;
297
- /** span of the whole import/export statement */
388
+ /** span of the whole import/export statement (or the import() expression) */
298
389
  span: Span;
299
390
  /** `import type` or an export-from with type-only kind */
300
391
  isTypeOnly: boolean;
301
392
  /** imported local binding names (default + named + namespace) */
302
393
  names: string[];
394
+ /**
395
+ * A dynamic `import("literal")` expression. Dynamic facts are real runtime
396
+ * edges (React.lazy is a mount edge) so the surfaces reach-check uses them,
397
+ * but the scope-leak check skips them — it is about static module
398
+ * dependency structure, which a dynamic import doesn't declare.
399
+ */
400
+ dynamic?: true;
303
401
  }
304
402
  /** An interactive host element (`button`, `a`, …) with no data-uidex* attribute. */
305
403
  interface InteractiveElementFact {
@@ -326,6 +424,18 @@ interface FlowCallFact {
326
424
  interface DynamicFlowCallFact {
327
425
  line: number;
328
426
  }
427
+ /**
428
+ * A parsed `uidexCovers("<entityId>", "<criterionId>@<rev>")` claim inside a
429
+ * tagged flow describe. The rev is REQUIRED at the claim site (parse
430
+ * diagnostic `covers-missing-rev` otherwise) — rev-pinning is what makes
431
+ * outdated-coverage detection possible.
432
+ */
433
+ interface FlowCoverFact {
434
+ entity: string;
435
+ criterion: string;
436
+ rev: number;
437
+ line: number;
438
+ }
329
439
  /** A tagged `test.describe(..., { tag: "@uidex:flow" }, ...)` occurrence. */
330
440
  interface FlowFact {
331
441
  title: string;
@@ -333,6 +443,8 @@ interface FlowFact {
333
443
  calls: FlowCallFact[];
334
444
  /** uidex() calls inside this describe whose ids could not be resolved */
335
445
  dynamicCalls?: DynamicFlowCallFact[];
446
+ /** uidexCovers() acceptance-coverage claims inside this describe */
447
+ covers?: FlowCoverFact[];
336
448
  }
337
449
  interface ExtractedFile {
338
450
  file: ScannedFile;
@@ -340,6 +452,8 @@ interface ExtractedFile {
340
452
  metadata?: MetadataExport[];
341
453
  diagnostics?: Diagnostic[];
342
454
  flows?: FlowFact[];
455
+ /** true when the file contains any JSX element or fragment */
456
+ hasJsx?: boolean;
343
457
  dynamicAttrs?: DynamicAttrFact[];
344
458
  unannotatedInteractive?: InteractiveElementFact[];
345
459
  landmarks?: LandmarkFact[];
@@ -405,6 +519,13 @@ declare const CONFIG_FILENAME = ".uidex.json";
405
519
  interface DiscoverOptions {
406
520
  cwd?: string;
407
521
  maxDepth?: number;
522
+ /**
523
+ * Keep descending past a config found at `cwd` itself, so the result is EVERY
524
+ * config in the tree rather than the innermost one. The workspace level needs
525
+ * the whole set; a plain `uidex scan` wants the nearest, which is why that
526
+ * stays the default.
527
+ */
528
+ all?: boolean;
408
529
  }
409
530
  declare function discover(options?: DiscoverOptions): DiscoveredConfig[];
410
531
 
@@ -474,6 +595,142 @@ interface ResolveOutput {
474
595
  */
475
596
  declare function resolve(ctx: ResolveContext): ResolveOutput;
476
597
 
598
+ /**
599
+ * ACKNOWLEDGMENT — the single primitive for "don't complain about this".
600
+ *
601
+ * There used to be four mechanisms plus an implicit fifth (`waivers`, the
602
+ * baseline's `missingKinds` / `uncapturedPages`, `capture: false`, and a
603
+ * borrowed-entity inference). They overlapped, lived in unrelated files with
604
+ * unrelated shapes, and the rule keeping them straight — "don't fold capturable
605
+ * debt into a waiver" — was enforced socially, in prose, because nothing
606
+ * structural stopped you reaching for the wrong one.
607
+ *
608
+ * They are now one map from a SCOPE to a typed REASON:
609
+ *
610
+ * acknowledge: {
611
+ * empty: { type: "impossible", reason: "a create form has no empty state" },
612
+ * "*": { type: "backlog" },
613
+ * }
614
+ *
615
+ * SCOPE is a core kind (that kind of this route) or `"*"` (the whole entity —
616
+ * every axis of it). TYPE is why:
617
+ *
618
+ * - `impossible` — human-authored. The surface CANNOT render this; a
619
+ * substantive `reason` is REQUIRED. Contradicted by an actual capture →
620
+ * `stale-acknowledgment`.
621
+ * - `backlog` — debt. It CAN render this and nobody has captured it yet.
622
+ * `reason` is optional. Tool-managed in the coverage baseline
623
+ * (`--update-baseline`, shrink-only), and authorable in source when you want
624
+ * the debt to sit next to the declaration.
625
+ * - `captured-elsewhere` — COMPUTED by the workspace level, never authored: a
626
+ * sibling app that registers this same declaration captured it. Replaces the
627
+ * borrowed-entity guess with an exact answer.
628
+ *
629
+ * The anti-laundering property is now structural rather than social:
630
+ * `impossible` and `backlog` are sibling values of ONE field, so choosing wrongly
631
+ * is a one-token, greppable, reviewable edit — not a choice between two
632
+ * unrelated files. And the reason rule points the lazy path at the honest
633
+ * answer: `impossible` costs you a sentence, `backlog` is free.
634
+ */
635
+ /** The scope key meaning "the whole entity", not one core kind. */
636
+ declare const ACK_ALL = "*";
637
+ /** Every valid scope key: the four core kinds plus `"*"`. */
638
+ declare const ACK_SCOPES: readonly string[];
639
+ type AcknowledgeType = "impossible" | "backlog" | "captured-elsewhere";
640
+ interface Acknowledgment {
641
+ type: AcknowledgeType;
642
+ /** Why. Required for `impossible`, optional for `backlog`. */
643
+ reason?: string;
644
+ /** `captured-elsewhere` only: the workspace app whose capture covers this. */
645
+ by?: string;
646
+ }
647
+ /** scope (`"*"` | core kind) → why it is acknowledged. */
648
+ type AcknowledgeMap = Record<string, Acknowledgment>;
649
+ /** Where an acknowledgment came from, so diagnostics can point at the right file. */
650
+ type AcknowledgeOrigin = "source" | "baseline" | "workspace";
651
+ interface ResolvedAcknowledgment extends Acknowledgment {
652
+ scope: string;
653
+ origin: AcknowledgeOrigin;
654
+ }
655
+ /**
656
+ * The committed, tool-managed coverage backlog. Same `Acknowledgment` shape as
657
+ * the source-authored field, keyed by route pattern — so the two really are the
658
+ * same primitive in two homes, not two lookalikes.
659
+ */
660
+ interface CoverageBaseline {
661
+ version: 2;
662
+ /** route pattern → scope → acknowledgment (always `type: "backlog"`). */
663
+ acknowledge: Record<string, AcknowledgeMap>;
664
+ }
665
+ /**
666
+ * The acknowledgments in force for one ROUTE: the baseline's entry for the route
667
+ * layered UNDER the page's own `acknowledge`. Source wins — a human statement
668
+ * outranks tool-recorded debt, and `--update-baseline` drops the shadowed entry
669
+ * on its next run.
670
+ */
671
+ declare function routeAcknowledge(registry: Registry, baseline: CoverageBaseline | undefined, route: string, page: string | undefined): Map<string, ResolvedAcknowledgment>;
672
+ /**
673
+ * The acknowledgments in force for one ENTITY (page/feature/widget). Only `"*"`
674
+ * is meaningful here — the core-kind axis is route-scoped. `capturedElsewhere`
675
+ * is the workspace's computed verdict; source still wins, so an explicit
676
+ * `impossible` is never silently reinterpreted.
677
+ */
678
+ declare function entityAcknowledge(meta: Metadata | undefined, capturedElsewhereBy?: string): ResolvedAcknowledgment | undefined;
679
+ /**
680
+ * Parse a v2 baseline. Malformed entries are dropped rather than thrown on: a
681
+ * hand-mangled baseline must not crash the scan, and every dropped entry simply
682
+ * re-appears as an unacknowledged gap the gate reports.
683
+ */
684
+ declare function parseCoverageBaseline(raw: unknown): CoverageBaseline | null;
685
+ /** Serialize a baseline with stable key order, so its diff is reviewable. */
686
+ declare function serializeCoverageBaseline(baseline: CoverageBaseline): string;
687
+
688
+ interface WorkspaceApp {
689
+ /** Display name — the config dir's basename (e.g. "platform-web"). */
690
+ name: string;
691
+ configDir: string;
692
+ configPath: string;
693
+ /** Whether this app's configured sources would register `absFile`. */
694
+ registers: (absFile: string) => boolean;
695
+ /** `captureKey` values this app's manifest captured. */
696
+ captured: ReadonlySet<string>;
697
+ }
698
+ interface Workspace {
699
+ root: string;
700
+ /** Every OTHER app in the workspace; the scanning app is excluded. */
701
+ siblings: WorkspaceApp[];
702
+ }
703
+ interface ResolveWorkspaceOptions {
704
+ configDir: string;
705
+ config: UidexConfig;
706
+ /** Overrides discovery; used by tests to avoid touching the real filesystem. */
707
+ configs?: DiscoveredConfig[];
708
+ }
709
+ /**
710
+ * Walk up from the app looking for a workspace marker. Returns `undefined` when
711
+ * there is none — a standalone app then keeps today's closed-world behavior
712
+ * rather than silently treating its parent directory as a workspace.
713
+ */
714
+ declare function findWorkspaceRoot(configDir: string): string | undefined;
715
+ /**
716
+ * Resolve the workspace an app participates in.
717
+ *
718
+ * `config.workspace` selects the root: a path (relative to the config dir),
719
+ * `false` for an explicit closed world, or omitted for auto-detect via
720
+ * `findWorkspaceRoot`. Returns `undefined` when there is no workspace or no
721
+ * sibling in it — callers then behave exactly as a single-app scan does.
722
+ */
723
+ declare function resolveWorkspace(opts: ResolveWorkspaceOptions): Workspace | undefined;
724
+ /**
725
+ * The sibling app that already captured this declaration, if any — the exact
726
+ * answer that replaces the borrowed-entity guess. `absFile` is the declaring
727
+ * file's real path (a display path cannot be resolved back across a `prefix`).
728
+ *
729
+ * A capture recording no `kind` matches on id alone, mirroring how the
730
+ * state-coverage gate treats an unlabeled capture.
731
+ */
732
+ declare function capturedElsewhere(workspace: Workspace | undefined, absFile: string | undefined, kind: string, id: string): string | undefined;
733
+
477
734
  /**
478
735
  * State-capture completeness — the deterministic replacement for a bespoke
479
736
  * `check-states.mjs`. Cross-checks the states DECLARED on registry entities
@@ -487,8 +744,17 @@ declare function resolve(ctx: ResolveContext): ResolveOutput;
487
744
  * - `orphan-state-capture` — a run captured a state the entity never declared,
488
745
  * so the contact sheet shows a state the registry doesn't know about.
489
746
  *
490
- * Pure: same (registry, manifest) same diagnostics. Only runs when a manifest
491
- * is present, so a plain `uidex scan` (no capture run) never fails on it.
747
+ * Two things take an entity out of the gate, and both are acknowledgments:
748
+ * a SOURCE `acknowledge: { "*": }`, or the WORKSPACE observing that a sibling
749
+ * app which registers this same declaration captured it (`captured-elsewhere`).
750
+ *
751
+ * The workspace answer replaces a heuristic this module used to carry: a
752
+ * "borrowed" entity (one declared under a shared source `prefix`) that this app
753
+ * captured nothing for was ASSUMED to be a sibling's responsibility. That guess
754
+ * could not tell a genuinely shared component from an id that merely collided
755
+ * with a sibling's, and it silenced real gaps whenever an app captured nothing.
756
+ * The workspace checks the sibling's actual source set and actual manifest, so
757
+ * the answer is exact and the guess is gone.
492
758
  */
493
759
  /** One `entity/state` a capture run recorded. */
494
760
  interface CapturedState {
@@ -507,49 +773,21 @@ interface CapturedState {
507
773
  }
508
774
  interface CapturedStatesManifest {
509
775
  captured: CapturedState[];
776
+ /** ISO timestamp the reporter stamped; drives the staleness check. */
777
+ generatedAt?: string;
510
778
  }
511
- declare function checkStateCoverage(registry: Registry, manifest: CapturedStatesManifest): Diagnostic[];
512
-
513
- /**
514
- * Page-capture coverage — the coarse gate above the per-state gate. Every
515
- * derived route must be accounted for: captured (a run drove it), opted out
516
- * (`export const uidex = { page, capture: false }`), or acknowledged backlog (a
517
- * tool-managed, shrink-only baseline). A route in none of those is a *new*
518
- * uncaptured page and fails hard — the ratchet that keeps debt from growing.
519
- *
520
- * uidex derives the route set (route groups stripped, dynamic segments kept), so
521
- * unlike a hand-rolled `src/app/**` walk there is no page list to enumerate or
522
- * keep from rotting: a route exists in the registry or it doesn't. And captures
523
- * observe the URL they ran against, so which route a capture covers is matched,
524
- * never hand-declared.
525
- */
526
- /** The committed, tool-managed backlog. Shrinks as pages/kinds get captured. */
527
- interface PageCoverageBaseline {
528
- /** Route patterns acknowledged as uncaptured (e.g. "/[scope]/collections"). */
529
- uncapturedPages: string[];
530
- /** route → core kinds acknowledged as not-yet-captured (the matrix backlog). */
531
- missingKinds?: Record<string, string[]>;
779
+ interface StateCoverageOptions {
780
+ /** The workspace, when one is resolved; enables `captured-elsewhere`. */
781
+ workspace?: Workspace;
782
+ /**
783
+ * Maps an entity's `loc.file` (a DISPLAY path, which a `prefix` makes
784
+ * unresolvable on its own) back to its real path on disk. Supplied by
785
+ * `audit()` from the scanned-file list, so the workspace can test the file
786
+ * against a sibling's source roots.
787
+ */
788
+ resolveSourcePath?: (displayPath: string) => string | undefined;
532
789
  }
533
- /**
534
- * Turn a Next-style route pattern into an anchored matcher.
535
- * `/[scope]/products/[productId]/edit` → matches `/acme/products/p1/edit`
536
- * `/[...slug]` (catch-all) → matches one or more segments
537
- * Returns the literal-segment count as a specificity score, so the most literal
538
- * matching pattern wins when several match (e.g. `/x/new` beats `/x/[id]`).
539
- */
540
- declare function compileRoute(pattern: string): {
541
- test: (url: string) => boolean;
542
- specificity: number;
543
- };
544
- /** The route pattern (from the given set) that most-specifically matches a URL. */
545
- declare function matchUrlToRoute(url: string, routePaths: string[]): string | null;
546
- declare function checkPageCoverage(registry: Registry, manifest: CapturedStatesManifest, baseline?: PageCoverageBaseline): Diagnostic[];
547
- /**
548
- * Compute the shrink-only baseline for `--update-baseline`: every route with no
549
- * capture and no opt-out. Seeds on first run and prunes on later runs; growth
550
- * only ever happens through this explicit command, visible in the file's diff.
551
- */
552
- declare function computePageBaseline(registry: Registry, manifest: CapturedStatesManifest): PageCoverageBaseline;
790
+ declare function checkStateCoverage(registry: Registry, manifest: CapturedStatesManifest, opts?: StateCoverageOptions): Diagnostic[];
553
791
 
554
792
  interface AuditOptions {
555
793
  registry: Registry;
@@ -581,8 +819,10 @@ interface AuditOptions {
581
819
  locsOutputPath?: string;
582
820
  /** Captured-states manifest for the completeness gate; absent → gate skipped. */
583
821
  capturedStates?: CapturedStatesManifest;
584
- /** Page-coverage baseline (the shrink-only backlog); absent → empty baseline. */
585
- pageBaseline?: PageCoverageBaseline;
822
+ /** Coverage baseline (the shrink-only `acknowledge` backlog). */
823
+ coverageBaseline?: CoverageBaseline;
824
+ /** The resolved workspace; enables `captured-elsewhere`. */
825
+ workspace?: Workspace;
586
826
  }
587
827
  declare function audit(opts: AuditOptions): AuditSummary;
588
828
 
@@ -688,11 +928,13 @@ interface RunScanOptions {
688
928
  configs?: DiscoveredConfig[];
689
929
  /** Captured-states manifest for the completeness gate; overrides on-disk load. */
690
930
  capturedStates?: CapturedStatesManifest;
691
- /** Page-coverage baseline; overrides on-disk load. */
692
- pageBaseline?: PageCoverageBaseline;
931
+ /** Coverage baseline (the shrink-only backlog); overrides on-disk load. */
932
+ coverageBaseline?: CoverageBaseline;
933
+ /** Workspace override; skips auto-detection (used by tests). */
934
+ workspace?: Workspace;
693
935
  }
694
- /** Load the page-coverage baseline from disk, if present + well-formed. */
695
- declare function loadPageBaseline(configDir: string, config: UidexConfig): PageCoverageBaseline | undefined;
936
+ /** Load the coverage baseline from disk, if present + well-formed. */
937
+ declare function loadCoverageBaseline(configDir: string, config: UidexConfig): CoverageBaseline | undefined;
696
938
  /** One generated artifact: its bytes plus where it lands (abs + repo-relative). */
697
939
  interface ScanArtifact {
698
940
  generated: string;
@@ -724,33 +966,97 @@ declare function runScan(opts?: RunScanOptions): ScanResult[];
724
966
  * actually moved. Returns `true` if any file was written.
725
967
  */
726
968
  declare function writeScanResult(result: ScanResult): boolean;
727
- /** Absolute path of the page-coverage baseline for a scan result. */
728
- declare function pageBaselinePath(result: ScanResult): string;
969
+ /** Absolute path of the coverage baseline for a scan result. */
970
+ declare function coverageBaselinePath(result: ScanResult): string;
971
+
972
+ /**
973
+ * Page-capture coverage — the coarse gate above the per-state gate. Every
974
+ * derived route must be accounted for: captured (a run drove it), or
975
+ * ACKNOWLEDGED at `"*"` scope — either `impossible` (a redirect, a static
976
+ * notice, an internal target — the old `capture: false`, which now has to say
977
+ * WHY) or `backlog` (tool-managed, shrink-only). A route in none of those is a
978
+ * *new* uncaptured page and fails hard — the ratchet that keeps debt growing.
979
+ *
980
+ * uidex derives the route set (route groups stripped, dynamic segments kept), so
981
+ * unlike a hand-rolled `src/app/**` walk there is no page list to enumerate or
982
+ * keep from rotting: a route exists in the registry or it doesn't. And captures
983
+ * observe the URL they ran against, so which route a capture covers is matched,
984
+ * never hand-declared.
985
+ */
986
+ /**
987
+ * Turn a Next-style route pattern into an anchored matcher.
988
+ * `/[scope]/products/[productId]/edit` → matches `/acme/products/p1/edit`
989
+ * `/[...slug]` (catch-all) → matches one or more segments
990
+ * Returns the literal-segment count as a specificity score, so the most literal
991
+ * matching pattern wins when several match (e.g. `/x/new` beats `/x/[id]`).
992
+ */
993
+ declare function compileRoute(pattern: string): {
994
+ test: (url: string) => boolean;
995
+ specificity: number;
996
+ };
997
+ /** The route pattern (from the given set) that most-specifically matches a URL. */
998
+ declare function matchUrlToRoute(url: string, routePaths: string[]): string | null;
999
+ declare function checkPageCoverage(registry: Registry, manifest: CapturedStatesManifest, baseline?: CoverageBaseline): Diagnostic[];
1000
+ /**
1001
+ * The page half of the shrink-only baseline for `--update-baseline`: every route
1002
+ * with no capture and no SOURCE acknowledgment. Seeds on first run and prunes on
1003
+ * later runs; growth only ever happens through this explicit command, visible in
1004
+ * the file's diff.
1005
+ */
1006
+ declare function computePageBacklog(registry: Registry, manifest: CapturedStatesManifest): Record<string, AcknowledgeMap>;
729
1007
 
1008
+ declare function checkStateMatrix(registry: Registry, manifest: CapturedStatesManifest, baseline?: CoverageBaseline): Diagnostic[];
730
1009
  /**
731
- * State-matrix coverage the second completeness axis. A capture that only ever
732
- * shoots the happy path leaves most of the harness's value on the table, so
733
- * every *captured* route must render each of the four core kinds
734
- * (loading / empty / populated / error), or account for the gap:
1010
+ * The matrix half of the shrink-only baseline for `--update-baseline`: every
1011
+ * (captured route, core kind) that is neither captured nor already acknowledged
1012
+ * in SOURCE. Source acknowledgments are excluded so the same gap is never
1013
+ * recorded twice, in two files, saying two things.
1014
+ */
1015
+ declare function computeMatrixBacklog(registry: Registry, manifest: CapturedStatesManifest): Record<string, AcknowledgeMap>;
1016
+
1017
+ declare function checkComponentCoverage(registry: Registry, manifest: CapturedStatesManifest): Diagnostic[];
1018
+
1019
+ /**
1020
+ * Fold a PARTIAL capture manifest into the committed one.
735
1021
  *
736
- * - a **waiver** (`export const uidex = { page, waivers: { empty: "…" } }`) —
737
- * the route CANNOT render that kind (a create form has no empty state);
738
- * - the **MISSING_KINDS baseline** the route CAN render it and nobody has
739
- * captured it yet (a per-(route, kind) shrink-only backlog).
1022
+ * A filtered run (`--grep`) legitimately captures a subset, so the reporter's
1023
+ * shrink guard diverts it to `<name>.partial.json` rather than overwriting a
1024
+ * fuller manifest. Without a merge path the only ways forward are re-running the
1025
+ * FULL suite or hand-editing JSON — both of which make scoped re-capture
1026
+ * expensive enough that people skip it and let the manifest drift.
740
1027
  *
741
- * Anything else is a `core-kind` error. Only *captured* routes are in scope: a
742
- * route with no capture at all is a page-coverage concern, not a matrix hole.
1028
+ * Merge is by `(kind, entity, state)`: rows in the partial REPLACE their
1029
+ * counterparts (that run is the newer evidence) and rows absent from it are
1030
+ * preserved (it never claimed to cover them). So merging is purely additive to
1031
+ * coverage — it can never drop a state, which is what makes it safe to run
1032
+ * without re-reading the whole manifest.
743
1033
  */
744
- /** route → core kinds acknowledged as not-yet-captured (the finer ratchet). */
745
- interface StateMatrixBaseline {
746
- missingKinds: Record<string, string[]>;
1034
+ interface MergeResult {
1035
+ manifest: CapturedStatesManifest;
1036
+ /** `(kind, entity, state)` keys the partial replaced. */
1037
+ updated: string[];
1038
+ /** Keys the partial contributed that the committed manifest lacked. */
1039
+ added: string[];
1040
+ }
1041
+ declare function mergeStates(committed: CapturedStatesManifest, partial: CapturedStatesManifest): MergeResult;
1042
+
1043
+ declare class ConfigError extends Error {
1044
+ constructor(message: string);
747
1045
  }
748
- declare function checkStateMatrix(registry: Registry, manifest: CapturedStatesManifest, baseline?: StateMatrixBaseline): Diagnostic[];
749
1046
  /**
750
- * Compute the shrink-only MISSING_KINDS baseline for `--update-baseline`: every
751
- * (captured route, core kind) that is neither captured nor waived.
1047
+ * Every diagnostic code the scanner can emit the allow-list `audit.severity`
1048
+ * validates against, so a typo'd key fails loudly instead of silently leaving
1049
+ * the gate it was meant to govern at its default severity.
1050
+ *
1051
+ * Keep in sync when adding a diagnostic; `tests/scan/severity-codes.test.ts`
1052
+ * asserts this set matches the codes actually emitted by the source — a code
1053
+ * missing here cannot be softened/disabled by any repo (validateConfig
1054
+ * rejects unknown `audit.severity` keys).
752
1055
  */
753
- declare function computeMissingKinds(registry: Registry, manifest: CapturedStatesManifest): Record<string, string[]>;
1056
+ declare const DIAGNOSTIC_CODES: readonly ["acceptance-ambiguous", "acceptance-duplicate-id", "acceptance-orphaned", "acceptance-outdated", "acceptance-uncovered", "acknowledged-elsewhere", "competing-uidex-export", "component-state-uncaptured", "core-kind", "covers-missing-rev", "duplicate-id", "dynamic-attr", "dynamic-flow-reference", "feature-missing-acceptance", "feature-surface-uncaptured", "feature-surface-unreferenced", "gen-missing", "gen-stale", "matrix-backlog", "missing-element-annotation", "missing-state-capture", "orphan-state-capture", "page-backlog", "page-captured", "page-no-capture", "parse-error", "placeholder-reason", "prefer-well-known-file", "rename", "scope-leak", "spread-attr", "stale-acknowledgment", "stale-page-baseline", "uidex-export-ambiguous-kind", "uidex-export-duplicate-field", "uidex-export-duplicate-state", "uidex-export-empty-name", "uidex-export-invalid-field", "uidex-export-invalid-literal", "uidex-export-missing-kind", "uidex-export-satisfies-mismatch", "uidex-export-unknown-field", "unknown-reference", "widget-id-mismatch", "widget-missing-acceptance", "widget-missing-dom"];
1057
+ declare function validateConfig(raw: unknown): UidexConfig;
1058
+ declare function parseConfig(json: string): UidexConfig;
1059
+ declare const DEFAULT_CONVENTIONS: Required<Pick<NonNullable<UidexConfig["conventions"]>, "primitives" | "features" | "pages" | "flows" | "regions">>;
754
1060
 
755
1061
  /**
756
1062
  * Applies the machine-generated fixes attached to diagnostics.
@@ -816,13 +1122,6 @@ interface CliOptions {
816
1122
  }
817
1123
  declare function run(opts: CliOptions): Promise<CliResult>;
818
1124
 
819
- declare class ConfigError extends Error {
820
- constructor(message: string);
821
- }
822
- declare function validateConfig(raw: unknown): UidexConfig;
823
- declare function parseConfig(json: string): UidexConfig;
824
- declare const DEFAULT_CONVENTIONS: Required<Pick<NonNullable<UidexConfig["conventions"]>, "primitives" | "features" | "pages" | "flows" | "regions">>;
825
-
826
1125
  /**
827
1126
  * Authoring-surface shape types for `export const uidex = { ... }` declarations.
828
1127
  *
@@ -842,32 +1141,74 @@ declare const DEFAULT_CONVENTIONS: Required<Pick<NonNullable<UidexConfig["conven
842
1141
  declare namespace Uidex {
843
1142
  type CoreStateKind = "loading" | "empty" | "populated" | "error";
844
1143
  type StateKind = CoreStateKind | "variant";
1144
+ /**
1145
+ * An acceptance criterion as authored. A bare string is sugar: its `id`
1146
+ * becomes a deterministic kebab slug of the text and `rev` defaults to 1.
1147
+ * The object form pins an explicit `id` (stable across copy edits) and an
1148
+ * optional `rev` (bump it when the criterion's meaning changes so flow
1149
+ * coverage pinned to the old rev surfaces as outdated).
1150
+ */
1151
+ interface AcceptanceCriterion {
1152
+ id: string;
1153
+ rev?: number;
1154
+ text: string;
1155
+ }
1156
+ type Acceptance = readonly (string | AcceptanceCriterion)[];
845
1157
  /** A declared render state, as authored in `states: [{ name, ... }]`. */
846
1158
  interface State {
847
1159
  name: string;
848
1160
  kind?: StateKind;
849
- acceptance?: readonly string[];
1161
+ /** Per-state criteria; same authoring sugar as entity-level `acceptance`. */
1162
+ acceptance?: Acceptance;
850
1163
  description?: string;
851
1164
  }
852
- /** Per-core-kind waivers: a kind this route cannot render → reason. */
853
- type Waivers = Partial<Record<CoreStateKind, string>>;
1165
+ /** Scope of an acknowledgment: one core kind, or the whole entity. */
1166
+ type AcknowledgeScope = CoreStateKind | "*";
1167
+ /** A permanent design statement — so it has to say why. */
1168
+ interface Impossible {
1169
+ type: "impossible";
1170
+ reason: string;
1171
+ }
1172
+ /** Debt: capturable, not captured yet. No reason required. */
1173
+ interface Backlog {
1174
+ type: "backlog";
1175
+ reason?: string;
1176
+ }
1177
+ type Acknowledgment = Impossible | Backlog;
1178
+ type Acknowledge = Partial<Record<AcknowledgeScope, Acknowledgment>>;
1179
+ /** A component has no route, so only the `"*"` scope applies to one. */
1180
+ type ComponentAcknowledge = {
1181
+ "*"?: Acknowledgment;
1182
+ };
854
1183
  interface Page<PageIds extends string = string, FeatureIds extends string = string, WidgetIds extends string = string> {
855
1184
  page: PageIds | false;
856
1185
  name?: string;
857
1186
  features?: readonly FeatureIds[];
858
1187
  widgets?: readonly WidgetIds[];
859
- acceptance?: readonly string[];
1188
+ acceptance?: Acceptance;
860
1189
  states?: readonly (string | State)[];
861
- capture?: boolean;
862
- waivers?: Waivers;
1190
+ acknowledge?: Acknowledge;
863
1191
  description?: string;
864
1192
  }
865
- interface Feature<FeatureIds extends string = string> {
1193
+ interface Feature<FeatureIds extends string = string, PageIds extends string = string> {
866
1194
  feature: FeatureIds | false;
867
1195
  name?: string;
868
1196
  features?: readonly FeatureIds[];
869
- acceptance?: readonly string[];
1197
+ /**
1198
+ * Pages this feature MUST be mounted on. Each id is verified by the audit
1199
+ * in two halves: the page must exist and its import closure must reference
1200
+ * a component file in the feature's directory (the static
1201
+ * `feature-surface-unreferenced` tripwire), and — when a captured-states
1202
+ * manifest is present — a capture must have rendered the feature on the
1203
+ * page's route (`feature-surface-uncaptured`, the runtime evidence). This
1204
+ * is how a cross-cutting feature (a gate editor, a share panel) states
1205
+ * "every one of these editors owes me a mount" and a forgotten mount fails
1206
+ * the scan instead of shipping.
1207
+ */
1208
+ surfaces?: readonly PageIds[];
1209
+ acceptance?: Acceptance;
870
1210
  states?: readonly (string | State)[];
1211
+ acknowledge?: ComponentAcknowledge;
871
1212
  description?: string;
872
1213
  }
873
1214
  interface Primitive<PrimitiveIds extends string = string> {
@@ -878,8 +1219,14 @@ declare namespace Uidex {
878
1219
  interface Widget<WidgetIds extends string = string> {
879
1220
  widget: WidgetIds;
880
1221
  name?: string;
881
- acceptance?: readonly string[];
1222
+ acceptance?: Acceptance;
882
1223
  states?: readonly (string | State)[];
1224
+ acknowledge?: ComponentAcknowledge;
1225
+ description?: string;
1226
+ }
1227
+ interface Region<RegionIds extends string = string> {
1228
+ region: RegionIds | false;
1229
+ name?: string;
883
1230
  description?: string;
884
1231
  }
885
1232
  interface Flow<FlowIds extends string = string> {
@@ -892,4 +1239,4 @@ declare namespace Uidex {
892
1239
  }
893
1240
  }
894
1241
 
895
- export { type Annotation, type AnnotationKind, type AppliedFix, type ApplyFixesResult, type AuditConfig, type AuditSummary, CONFIG_FILENAME, type CapturedState, type CapturedStatesManifest, type CliOptions, type CliResult, ConfigError, type ConventionsConfig, DEFAULT_CONVENTIONS, type DetectedRoute, type Diagnostic, type DiagnosticFix, type DiagnosticSeverity, type DiscoveredConfig, type ExtractedFile, type FixEdit, type GitContext, type ImportFact, type LandmarkFact, type MetadataExport, type MetadataExportKind, type PageCoverageBaseline, type ParsedState, type RenameKind, type RenameOptions, type RenameResult, type RunScanOptions, type ScaffoldKind, type ScaffoldOptions, type ScaffoldResult, type ScaffoldSpecOptions, type ScanResult, type ScannedFile, type SourceConfig, type Span, type StateMatrixBaseline, Uidex, type UidexConfig, applyFixes, audit, checkPageCoverage, checkStateCoverage, checkStateMatrix, compileRoute, computeMissingKinds, computePageBaseline, detectRoutes, discover, emit, extract, extractUidexExports, globToRegExp, loadPageBaseline, matchUrlToRoute, pageBaselinePath, parseConfig, pathToId, renameEntity, resolve, resolveGitContext, run as runCli, runScan, scaffoldSpec, scaffoldWidgetSpec, validateConfig, walk, writeScanResult };
1242
+ export { ACK_ALL, ACK_SCOPES, type AcknowledgeMap, type AcknowledgeOrigin, type AcknowledgeType, type Acknowledgment, type Annotation, type AnnotationKind, type AppliedFix, type ApplyFixesResult, type AuditConfig, type AuditSummary, CONFIG_FILENAME, type CapturedState, type CapturedStatesManifest, type CliOptions, type CliResult, ConfigError, type ConventionsConfig, type CoverageBaseline, DEFAULT_CONVENTIONS, DIAGNOSTIC_CODES, type DetectedRoute, type Diagnostic, type DiagnosticFix, type DiagnosticSeverity, type DiscoveredConfig, type ExtractedFile, type FixEdit, type GitContext, type ImportFact, type LandmarkFact, type MergeResult, type MetadataExport, type MetadataExportKind, type ParsedAcknowledgment, type ParsedState, type RenameKind, type RenameOptions, type RenameResult, type ResolvedAcknowledgment, type RunScanOptions, type ScaffoldKind, type ScaffoldOptions, type ScaffoldResult, type ScaffoldSpecOptions, type ScanResult, type ScannedFile, type SourceConfig, type Span, type StateCoverageOptions, Uidex, type UidexConfig, type Workspace, type WorkspaceApp, applyFixes, audit, capturedElsewhere, checkComponentCoverage, checkPageCoverage, checkStateCoverage, checkStateMatrix, compileRoute, computeMatrixBacklog, computePageBacklog, coverageBaselinePath, detectRoutes, discover, emit, entityAcknowledge, extract, extractUidexExports, findWorkspaceRoot, globToRegExp, loadCoverageBaseline, matchUrlToRoute, mergeStates, parseConfig, parseCoverageBaseline, pathToId, renameEntity, resolve, resolveGitContext, resolveWorkspace, routeAcknowledge, run as runCli, runScan, scaffoldSpec, scaffoldWidgetSpec, serializeCoverageBaseline, validateConfig, walk, writeScanResult };