uidex 0.8.0 → 0.10.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 (43) hide show
  1. package/dist/cli/cli.cjs +1273 -343
  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 +19 -13
  6. package/dist/headless/index.d.ts +19 -13
  7. package/dist/headless/index.js +3 -0
  8. package/dist/headless/index.js.map +1 -1
  9. package/dist/index.cjs +3 -0
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +19 -13
  12. package/dist/index.d.ts +19 -13
  13. package/dist/index.js +3 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/playwright/index.cjs +36 -7
  16. package/dist/playwright/index.cjs.map +1 -1
  17. package/dist/playwright/index.js +36 -7
  18. package/dist/playwright/index.js.map +1 -1
  19. package/dist/playwright/states-reporter.cjs +36 -7
  20. package/dist/playwright/states-reporter.cjs.map +1 -1
  21. package/dist/playwright/states-reporter.d.cts +15 -0
  22. package/dist/playwright/states-reporter.d.ts +15 -0
  23. package/dist/playwright/states-reporter.js +36 -7
  24. package/dist/playwright/states-reporter.js.map +1 -1
  25. package/dist/playwright/states.cjs +8 -0
  26. package/dist/playwright/states.cjs.map +1 -1
  27. package/dist/playwright/states.d.cts +44 -1
  28. package/dist/playwright/states.d.ts +44 -1
  29. package/dist/playwright/states.js +7 -0
  30. package/dist/playwright/states.js.map +1 -1
  31. package/dist/react/index.cjs +3 -0
  32. package/dist/react/index.cjs.map +1 -1
  33. package/dist/react/index.d.cts +19 -13
  34. package/dist/react/index.d.ts +19 -13
  35. package/dist/react/index.js +3 -0
  36. package/dist/react/index.js.map +1 -1
  37. package/dist/scan/index.cjs +1217 -257
  38. package/dist/scan/index.cjs.map +1 -1
  39. package/dist/scan/index.d.cts +410 -104
  40. package/dist/scan/index.d.ts +410 -104
  41. package/dist/scan/index.js +1197 -253
  42. package/dist/scan/index.js.map +1 -1
  43. package/package.json +17 -17
@@ -30,7 +30,8 @@ var ALLOWED_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
30
30
  "audit",
31
31
  "conventions",
32
32
  "statesManifest",
33
- "coverageBaseline"
33
+ "coverageBaseline",
34
+ "workspace"
34
35
  ]);
35
36
  var ALLOWED_SOURCE_KEYS = /* @__PURE__ */ new Set(["rootDir", "include", "exclude", "prefix"]);
36
37
  var ALLOWED_CONVENTIONS_KEYS = /* @__PURE__ */ new Set([
@@ -46,19 +47,64 @@ var ALLOWED_AUDIT_KEYS = /* @__PURE__ */ new Set([
46
47
  "acceptance",
47
48
  "states",
48
49
  "pageCoverage",
49
- "stateMatrix"
50
+ "stateMatrix",
51
+ "severity"
50
52
  ]);
53
+ var DIAGNOSTIC_CODES = [
54
+ "acceptance-uncovered",
55
+ "acknowledged-elsewhere",
56
+ "competing-uidex-export",
57
+ "component-state-uncaptured",
58
+ "core-kind",
59
+ "duplicate-id",
60
+ "dynamic-attr",
61
+ "dynamic-flow-reference",
62
+ "gen-missing",
63
+ "gen-stale",
64
+ "legacy-acknowledge-field",
65
+ "legacy-coverage-baseline",
66
+ "matrix-backlog",
67
+ "missing-element-annotation",
68
+ "missing-state-capture",
69
+ "orphan-state-capture",
70
+ "page-backlog",
71
+ "page-captured",
72
+ "page-no-capture",
73
+ "parse-error",
74
+ "placeholder-reason",
75
+ "prefer-well-known-file",
76
+ "rename",
77
+ "scope-leak",
78
+ "spread-attr",
79
+ "stale-acknowledgment",
80
+ "stale-page-baseline",
81
+ "uidex-export-ambiguous-kind",
82
+ "uidex-export-duplicate-field",
83
+ "uidex-export-duplicate-state",
84
+ "uidex-export-empty-name",
85
+ "uidex-export-invalid-field",
86
+ "uidex-export-invalid-literal",
87
+ "uidex-export-missing-kind",
88
+ "uidex-export-satisfies-mismatch",
89
+ "uidex-export-unknown-field",
90
+ "unknown-reference",
91
+ "widget-id-mismatch",
92
+ "widget-missing-acceptance",
93
+ "widget-missing-dom"
94
+ ];
95
+ var DIAGNOSTIC_CODE_SET = new Set(DIAGNOSTIC_CODES);
96
+ var SEVERITY_LEVELS = /* @__PURE__ */ new Set(["error", "warning", "info", "off"]);
51
97
  function fail(msg) {
52
98
  throw new ConfigError(`Invalid .uidex.json: ${msg}`);
53
99
  }
54
- function assertObject(value, path12) {
100
+ function assertObject(value, path13) {
55
101
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
56
- fail(`${path12} must be an object`);
102
+ fail(`${path13} must be an object`);
57
103
  }
58
104
  }
59
- function assertStringArray(value, path12) {
105
+ function assertStringArray(value, path13) {
60
106
  if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
61
- fail(`${path12} must be a string[]`);
107
+ fail(`${path13} must be a string[]`);
62
108
  }
63
109
  }
64
110
  function validateConfig(raw) {
@@ -112,11 +158,30 @@ function validateConfig(raw) {
112
158
  if (raw.coverageBaseline !== void 0 && typeof raw.coverageBaseline !== "string") {
113
159
  fail(`"coverageBaseline" must be a string`);
114
160
  }
161
+ if (raw.workspace !== void 0 && raw.workspace !== false && typeof raw.workspace !== "string") {
162
+ fail(`"workspace" must be a path string or false`);
163
+ }
115
164
  if (raw.audit !== void 0) {
116
165
  assertObject(raw.audit, "audit");
117
166
  for (const key of Object.keys(raw.audit)) {
118
167
  if (!ALLOWED_AUDIT_KEYS.has(key)) fail(`unknown key "${key}" in audit`);
119
168
  const v = raw.audit[key];
169
+ if (key === "severity") {
170
+ assertObject(v, "audit.severity");
171
+ for (const [code, level] of Object.entries(v)) {
172
+ if (!DIAGNOSTIC_CODE_SET.has(code)) {
173
+ fail(
174
+ `unknown diagnostic code "${code}" in audit.severity (a typo here would silently leave that gate at its default severity)`
175
+ );
176
+ }
177
+ if (typeof level !== "string" || !SEVERITY_LEVELS.has(level)) {
178
+ fail(
179
+ `audit.severity["${code}"] must be one of "error", "warning", "info", "off"`
180
+ );
181
+ }
182
+ }
183
+ continue;
184
+ }
120
185
  if (typeof v !== "boolean") fail(`audit.${key} must be a boolean`);
121
186
  }
122
187
  }
@@ -135,7 +200,13 @@ function validateConfig(raw) {
135
200
  fail(`conventions.features must be a string or false`);
136
201
  }
137
202
  if (c.pages !== void 0 && c.pages !== false && c.pages !== "auto") {
138
- fail(`conventions.pages must be "auto" or false`);
203
+ const p2 = c.pages;
204
+ const routesDir = typeof p2 === "object" && p2 !== null && !Array.isArray(p2) ? p2.routesDir : void 0;
205
+ if (typeof p2 !== "object" || p2 === null || Array.isArray(p2) || Object.keys(p2).some((k) => k !== "routesDir") || typeof routesDir !== "string" || routesDir.length === 0) {
206
+ fail(
207
+ `conventions.pages must be "auto", false, or { routesDir: string }`
208
+ );
209
+ }
139
210
  }
140
211
  if (c.flows !== void 0 && c.flows !== false) {
141
212
  assertStringArray(c.flows, "conventions.flows");
@@ -153,7 +224,8 @@ function validateConfig(raw) {
153
224
  audit: raw.audit,
154
225
  conventions: raw.conventions,
155
226
  statesManifest: raw.statesManifest,
156
- coverageBaseline: raw.coverageBaseline
227
+ coverageBaseline: raw.coverageBaseline,
228
+ workspace: raw.workspace
157
229
  };
158
230
  }
159
231
  function parseConfig(json) {
@@ -203,9 +275,11 @@ function tryReadConfig(configPath) {
203
275
  function discover(options = {}) {
204
276
  const cwd = options.cwd ?? process.cwd();
205
277
  const maxDepth = options.maxDepth ?? MAX_DEPTH;
278
+ const all = options.all ?? false;
206
279
  const rootMatch = tryReadConfig(path.join(cwd, CONFIG_FILENAME));
207
- if (rootMatch) return [rootMatch];
280
+ if (rootMatch && !all) return [rootMatch];
208
281
  const results = [];
282
+ if (rootMatch) results.push(rootMatch);
209
283
  const queue = [[cwd, 0]];
210
284
  while (queue.length > 0) {
211
285
  const [dir, depth] = queue.shift();
@@ -309,6 +383,26 @@ function toPosix(p2) {
309
383
  function matchesAny(rel, patterns) {
310
384
  return patterns.some((g) => globToRegExp(g).test(rel));
311
385
  }
386
+ function createSourceMatcher(sources, options) {
387
+ const { cwd, globalExcludes = [], includeTests = false } = options;
388
+ const baseDefaults = includeTests ? BASE_EXCLUDES : DEFAULT_EXCLUDES;
389
+ const compiled = sources.map((source) => ({
390
+ absRoot: path2.resolve(cwd, source.rootDir),
391
+ includes: source.include?.length ? source.include : DEFAULT_INCLUDES,
392
+ excludes: [...baseDefaults, ...globalExcludes, ...source.exclude ?? []]
393
+ }));
394
+ return (absFile) => {
395
+ const abs = path2.resolve(absFile);
396
+ return compiled.some(({ absRoot, includes, excludes }) => {
397
+ const rel = toPosix(path2.relative(absRoot, abs));
398
+ if (rel.length === 0 || rel.startsWith("../") || path2.isAbsolute(rel)) {
399
+ return false;
400
+ }
401
+ if (matchesAny(rel, excludes)) return false;
402
+ return matchesAny(rel, includes);
403
+ });
404
+ };
405
+ }
312
406
  function walk(sources, options) {
313
407
  const { cwd, globalExcludes = [], includeTests = false } = options;
314
408
  const out2 = [];
@@ -642,6 +736,132 @@ function createRegistry() {
642
736
  };
643
737
  }
644
738
 
739
+ // src/scanner/scan/acknowledge.ts
740
+ var ACK_ALL = "*";
741
+ var ACK_SCOPES = [ACK_ALL, ...CORE_STATE_KINDS];
742
+ var COMPONENT_ACK_SCOPES = [ACK_ALL];
743
+ var AUTHORABLE_ACK_TYPES = [
744
+ "impossible",
745
+ "backlog"
746
+ ];
747
+ var CORE_SET = new Set(CORE_STATE_KINDS);
748
+ function isCoreKind(scope) {
749
+ return CORE_SET.has(scope);
750
+ }
751
+ var PLACEHOLDER_REASON = "TODO: why can this never be captured?";
752
+ function isPlaceholderReason(reason) {
753
+ return typeof reason === "string" && /^\s*TODO\b/i.test(reason);
754
+ }
755
+ function describeAck(a) {
756
+ if (a.type === "captured-elsewhere") {
757
+ return a.by ? `captured-elsewhere (${a.by})` : "captured-elsewhere";
758
+ }
759
+ return a.reason ? `${a.type} ("${a.reason}")` : a.type;
760
+ }
761
+ function originHint(a, page) {
762
+ if (a.origin === "baseline") {
763
+ return "Prune it with `uidex scan --update-baseline` so the backlog reflects reality.";
764
+ }
765
+ if (a.origin === "workspace") {
766
+ return "This is computed from the workspace; it resolves itself once the sibling capture changes.";
767
+ }
768
+ const where = page ? ` on page "${page}"` : "";
769
+ return `Remove the \`acknowledge.${a.scope}\` entry${where} \u2014 a capture contradicts it.`;
770
+ }
771
+ function toResolved(map, origin, into) {
772
+ for (const [scope, ack] of Object.entries(map ?? {})) {
773
+ into.set(scope, { ...ack, scope, origin });
774
+ }
775
+ }
776
+ function routeAcknowledge(registry, baseline, route, page) {
777
+ const out2 = /* @__PURE__ */ new Map();
778
+ toResolved(baseline?.acknowledge?.[route], "baseline", out2);
779
+ if (page) {
780
+ toResolved(registry.get("page", page)?.meta?.acknowledge, "source", out2);
781
+ }
782
+ return out2;
783
+ }
784
+ function entityAcknowledge(meta, capturedElsewhereBy) {
785
+ const source = meta?.acknowledge?.[ACK_ALL];
786
+ if (source) return { ...source, scope: ACK_ALL, origin: "source" };
787
+ if (capturedElsewhereBy) {
788
+ return {
789
+ type: "captured-elsewhere",
790
+ by: capturedElsewhereBy,
791
+ scope: ACK_ALL,
792
+ origin: "workspace"
793
+ };
794
+ }
795
+ return void 0;
796
+ }
797
+ function readAck(raw) {
798
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
799
+ const r = raw;
800
+ if (r.type !== "impossible" && r.type !== "backlog") return void 0;
801
+ const out2 = { type: r.type };
802
+ if (typeof r.reason === "string" && r.reason.length > 0) out2.reason = r.reason;
803
+ return out2;
804
+ }
805
+ function parseCoverageBaseline(raw) {
806
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
807
+ const r = raw;
808
+ const ackRaw = r.acknowledge;
809
+ if (!ackRaw || typeof ackRaw !== "object" || Array.isArray(ackRaw))
810
+ return null;
811
+ const acknowledge = {};
812
+ for (const [route, scopesRaw] of Object.entries(
813
+ ackRaw
814
+ )) {
815
+ if (!scopesRaw || typeof scopesRaw !== "object" || Array.isArray(scopesRaw))
816
+ continue;
817
+ const scopes = {};
818
+ for (const [scope, ackVal] of Object.entries(
819
+ scopesRaw
820
+ )) {
821
+ if (scope !== ACK_ALL && !isCoreKind(scope)) continue;
822
+ const ack = readAck(ackVal);
823
+ if (ack) scopes[scope] = ack;
824
+ }
825
+ if (Object.keys(scopes).length > 0) acknowledge[route] = scopes;
826
+ }
827
+ return { version: 2, acknowledge };
828
+ }
829
+ function isLegacyBaseline(raw) {
830
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
831
+ const r = raw;
832
+ if ("acknowledge" in r) return false;
833
+ return "uncapturedPages" in r || "missingKinds" in r;
834
+ }
835
+ function migrateBaseline(legacy) {
836
+ const acknowledge = {};
837
+ const at = (route) => acknowledge[route] ??= {};
838
+ for (const route of legacy.uncapturedPages ?? []) {
839
+ if (typeof route === "string") at(route)[ACK_ALL] = { type: "backlog" };
840
+ }
841
+ for (const [route, kinds] of Object.entries(legacy.missingKinds ?? {})) {
842
+ if (!Array.isArray(kinds)) continue;
843
+ for (const kind of kinds) {
844
+ if (typeof kind === "string" && isCoreKind(kind)) {
845
+ at(route)[kind] = { type: "backlog" };
846
+ }
847
+ }
848
+ }
849
+ return { version: 2, acknowledge };
850
+ }
851
+ function serializeCoverageBaseline(baseline) {
852
+ const routes = Object.keys(baseline.acknowledge).sort();
853
+ const ordered = {};
854
+ for (const route of routes) {
855
+ const scopes = baseline.acknowledge[route];
856
+ const sorted = {};
857
+ for (const scope of ACK_SCOPES) {
858
+ if (scopes[scope]) sorted[scope] = scopes[scope];
859
+ }
860
+ ordered[route] = sorted;
861
+ }
862
+ return JSON.stringify({ version: 2, acknowledge: ordered }, null, 2) + "\n";
863
+ }
864
+
645
865
  // src/scanner/scan/extract-uidex-export.ts
646
866
  var KIND_DISCRIMINATORS = [
647
867
  "page",
@@ -660,26 +880,41 @@ var ALLOWED_FIELDS = {
660
880
  "widgets",
661
881
  "acceptance",
662
882
  "states",
883
+ "acknowledge",
663
884
  "capture",
664
885
  "waivers",
665
886
  "description"
666
887
  ]),
667
- // `capture` / `waivers` are route-scoped, and only a page maps to a route — so
668
- // they are page-only (the gates read them off the page). A feature/widget that
669
- // sets them gets an `uidex-export-unknown-field` error rather than a silent no-op.
888
+ // A page's `acknowledge` accepts both scope shapes: `"*"` (the whole route)
889
+ // and a core kind (one cell of its matrix). A feature/widget has no route, so
890
+ // the core-kind matrix is meaningless there and only `"*"` is accepted —
891
+ // giving a component a scoped opt-out instead of forcing you to delete its
892
+ // whole `states` block. `capture` / `waivers` are REMOVED; they stay parseable
893
+ // only so `--fix` can rewrite them into `acknowledge` (see MIGRATION below).
670
894
  feature: /* @__PURE__ */ new Set([
671
895
  "feature",
672
896
  "name",
673
897
  "features",
674
898
  "acceptance",
675
899
  "states",
900
+ "acknowledge",
901
+ "capture",
676
902
  "description"
677
903
  ]),
678
904
  primitive: /* @__PURE__ */ new Set(["primitive", "name", "description"]),
679
- widget: /* @__PURE__ */ new Set(["widget", "name", "acceptance", "states", "description"]),
905
+ widget: /* @__PURE__ */ new Set([
906
+ "widget",
907
+ "name",
908
+ "acceptance",
909
+ "states",
910
+ "acknowledge",
911
+ "capture",
912
+ "description"
913
+ ]),
680
914
  region: /* @__PURE__ */ new Set(["region", "name", "description"]),
681
915
  flow: /* @__PURE__ */ new Set(["flow", "notFlow", "name", "description"])
682
916
  };
917
+ var ACK_FIELDS = /* @__PURE__ */ new Set(["type", "reason"]);
683
918
  var STATE_FIELDS = /* @__PURE__ */ new Set([
684
919
  "name",
685
920
  "kind",
@@ -930,7 +1165,8 @@ function objectLit(node, content, p2, pos, span) {
930
1165
  key,
931
1166
  value,
932
1167
  keyPos,
933
- span: removalSpan(content, prop.start, prop.end)
1168
+ span: removalSpan(content, prop.start, prop.end),
1169
+ propSpan: { start: prop.start, end: prop.end }
934
1170
  });
935
1171
  }
936
1172
  return { kind: "object", entries, pos, span };
@@ -1078,8 +1314,10 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
1078
1314
  const featuresField = kind === "page" || kind === "feature" ? readStringArrayField(byKey, "features") : void 0;
1079
1315
  const widgetsField = kind === "page" ? readStringArrayField(byKey, "widgets") : void 0;
1080
1316
  const states = kind === "page" || kind === "feature" || kind === "widget" ? readStatesField(byKey) : void 0;
1081
- const capture = kind === "page" ? readBooleanField(byKey, "capture") : void 0;
1082
- const waivers = kind === "page" ? readWaiversField(byKey) : void 0;
1317
+ const stateBearing = kind === "page" || kind === "feature" || kind === "widget";
1318
+ const acknowledge = stateBearing ? readAcknowledgeField(byKey, kind === "page") : void 0;
1319
+ const legacyCapture = stateBearing ? readBooleanField(byKey, "capture") : void 0;
1320
+ const legacyWaivers = kind === "page" ? readWaiversField(byKey) : void 0;
1083
1321
  const notFlow = kind === "flow" && discriminator === "notFlow" ? true : void 0;
1084
1322
  const metadata = {
1085
1323
  source: "ts-export",
@@ -1103,15 +1341,25 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
1103
1341
  metadata.widgetSpans = widgetsField.spans;
1104
1342
  }
1105
1343
  if (states?.length) metadata.states = states;
1106
- if (capture !== void 0) metadata.capture = capture;
1107
- if (waivers && Object.keys(waivers).length > 0) metadata.waivers = waivers;
1344
+ if (acknowledge && Object.keys(acknowledge).length > 0) {
1345
+ metadata.acknowledge = acknowledge;
1346
+ }
1347
+ if (legacyCapture !== void 0) metadata.legacyCapture = legacyCapture;
1348
+ if (legacyWaivers && Object.keys(legacyWaivers).length > 0) {
1349
+ metadata.legacyWaivers = legacyWaivers;
1350
+ }
1108
1351
  if (notFlow) metadata.notFlow = true;
1109
1352
  if (typeof id === "string" && idValue.kind === "string") {
1110
1353
  metadata.idSpan = idValue.span;
1111
1354
  }
1112
1355
  const fieldSpans = {};
1113
- for (const entry of value.entries) fieldSpans[entry.key] = entry.span;
1356
+ const fieldPropSpans = {};
1357
+ for (const entry of value.entries) {
1358
+ fieldSpans[entry.key] = entry.span;
1359
+ fieldPropSpans[entry.key] = entry.propSpan;
1360
+ }
1114
1361
  metadata.fieldSpans = fieldSpans;
1362
+ metadata.fieldPropSpans = fieldPropSpans;
1115
1363
  return metadata;
1116
1364
  }
1117
1365
  function readIdField(value, kind, fieldName) {
@@ -1141,6 +1389,80 @@ function readIdField(value, kind, fieldName) {
1141
1389
  value.pos
1142
1390
  );
1143
1391
  }
1392
+ function readAcknowledgeField(byKey, isPage) {
1393
+ const entry = byKey.get("acknowledge");
1394
+ if (!entry) return void 0;
1395
+ if (entry.value.kind !== "object") {
1396
+ throw new ExtractError(
1397
+ "uidex-export-invalid-field",
1398
+ '`acknowledge` must be an object mapping a scope (`"*"` or a core kind) to `{ type, reason? }`.',
1399
+ entry.value.pos
1400
+ );
1401
+ }
1402
+ const out2 = {};
1403
+ for (const e of entry.value.entries) {
1404
+ const scopes = isPage ? ACK_SCOPES : COMPONENT_ACK_SCOPES;
1405
+ if (!scopes.includes(e.key)) {
1406
+ throw new ExtractError(
1407
+ "uidex-export-invalid-field",
1408
+ `acknowledge scope "${e.key}" is not valid here; expected ${scopes.map((s) => `"${s}"`).join(", ")}${isPage ? "" : " (core-kind scopes are page-only \u2014 the state matrix is route-scoped)"}.`,
1409
+ e.keyPos
1410
+ );
1411
+ }
1412
+ if (e.value.kind !== "object") {
1413
+ throw new ExtractError(
1414
+ "uidex-export-invalid-field",
1415
+ `acknowledge "${e.key}" must be an object \`{ type, reason? }\`.`,
1416
+ e.value.pos
1417
+ );
1418
+ }
1419
+ const fieldByKey = /* @__PURE__ */ new Map();
1420
+ for (const f of e.value.entries) {
1421
+ if (!ACK_FIELDS.has(f.key)) {
1422
+ throw new ExtractError(
1423
+ "uidex-export-invalid-field",
1424
+ `Unknown field "${f.key}" in an \`acknowledge\` entry. Allowed: ${[...ACK_FIELDS].sort().join(", ")}.`,
1425
+ f.value.pos
1426
+ );
1427
+ }
1428
+ fieldByKey.set(f.key, f);
1429
+ }
1430
+ const type = readStringField(fieldByKey, "type");
1431
+ if (type === void 0) {
1432
+ throw new ExtractError(
1433
+ "uidex-export-invalid-field",
1434
+ `acknowledge "${e.key}" needs a \`type\`: ${AUTHORABLE_ACK_TYPES.map((t) => `"${t}"`).join(" or ")}.`,
1435
+ e.value.pos
1436
+ );
1437
+ }
1438
+ if (type === "captured-elsewhere") {
1439
+ throw new ExtractError(
1440
+ "uidex-export-invalid-field",
1441
+ '`type: "captured-elsewhere"` cannot be authored; the workspace computes it from the sibling app that captured the declaration.',
1442
+ fieldByKey.get("type").value.pos,
1443
+ "Remove the entry \u2014 if a sibling app really captures this, the workspace reports it as `acknowledged-elsewhere` on its own."
1444
+ );
1445
+ }
1446
+ if (type !== "impossible" && type !== "backlog") {
1447
+ throw new ExtractError(
1448
+ "uidex-export-invalid-field",
1449
+ `acknowledge \`type\` must be ${AUTHORABLE_ACK_TYPES.map((t) => `"${t}"`).join(" or ")} (got "${type}").`,
1450
+ fieldByKey.get("type").value.pos
1451
+ );
1452
+ }
1453
+ const reason = readStringField(fieldByKey, "reason");
1454
+ if (type === "impossible" && (reason === void 0 || reason.length === 0)) {
1455
+ throw new ExtractError(
1456
+ "uidex-export-invalid-field",
1457
+ `acknowledge "${e.key}" is \`type: "impossible"\` and needs a non-empty \`reason\` (why this can NEVER be rendered/captured).`,
1458
+ e.value.pos,
1459
+ 'If it merely has not been captured yet, that is `type: "backlog"` \u2014 which needs no reason.'
1460
+ );
1461
+ }
1462
+ out2[e.key] = reason ? { type, reason } : { type };
1463
+ }
1464
+ return out2;
1465
+ }
1144
1466
  function readWaiversField(byKey) {
1145
1467
  const entry = byKey.get("waivers");
1146
1468
  if (!entry) return void 0;
@@ -1820,13 +2142,26 @@ import * as path4 from "path";
1820
2142
  var PAGE_BASENAME = /^page\.(tsx|ts|jsx|js|mjs|cjs)$/;
1821
2143
  var PAGES_ROUTER_BASENAME = /\.(tsx|ts|jsx|js|mjs|cjs)$/;
1822
2144
  var ROUTE_BASENAME = /^route\.(tsx|ts|jsx|js|mjs|cjs)$/;
1823
- function detectRoutes(files) {
2145
+ function detectRoutes(files, options = {}) {
1824
2146
  const out2 = [];
1825
2147
  const seen = /* @__PURE__ */ new Set();
2148
+ const routesDir = options.routesDir ? options.routesDir.replace(/^\.?\/+/, "").replace(/\/+$/, "") : null;
2149
+ const routesDirParts = routesDir ? routesDir.split("/") : null;
1826
2150
  for (const f of files) {
1827
2151
  const rel = f.displayPath;
1828
2152
  const parts = rel.split("/");
1829
2153
  const base = parts[parts.length - 1];
2154
+ if (routesDirParts) {
2155
+ const end = dirEndIndex(parts, routesDirParts);
2156
+ if (end === -1 || end > parts.length - 1) continue;
2157
+ if (!PAGES_ROUTER_BASENAME.test(base)) continue;
2158
+ const routePath = routesStylePath(
2159
+ parts.slice(end, parts.length - 1),
2160
+ base
2161
+ );
2162
+ if (routePath !== null) push(out2, seen, routePath, f.displayPath);
2163
+ continue;
2164
+ }
1830
2165
  const appIdx = parts.indexOf("app");
1831
2166
  if (appIdx !== -1 && PAGE_BASENAME.test(base)) {
1832
2167
  const routeSegments = parts.slice(appIdx + 1, parts.length - 1);
@@ -1853,15 +2188,11 @@ function detectRoutes(files) {
1853
2188
  }
1854
2189
  const routesIdx = parts.indexOf("routes");
1855
2190
  if (routesIdx !== -1 && PAGES_ROUTER_BASENAME.test(base)) {
1856
- const segs = parts.slice(routesIdx + 1);
1857
- const last = segs[segs.length - 1];
1858
- if (last.startsWith("_")) continue;
1859
- const normalized = [
1860
- ...segs.slice(0, -1),
1861
- base.replace(/\.[^.]+$/, "")
1862
- ].filter((s) => s !== "index" && s !== "__root");
1863
- const routePath = formatNextAppPath(normalized);
1864
- push(out2, seen, routePath, f.displayPath);
2191
+ const routePath = routesStylePath(
2192
+ parts.slice(routesIdx + 1, parts.length - 1),
2193
+ base
2194
+ );
2195
+ if (routePath !== null) push(out2, seen, routePath, f.displayPath);
1865
2196
  continue;
1866
2197
  }
1867
2198
  }
@@ -1877,6 +2208,63 @@ function formatNextAppPath(segments) {
1877
2208
  if (kept.length === 0) return "/";
1878
2209
  return "/" + kept.join("/");
1879
2210
  }
2211
+ function dirEndIndex(parts, dirParts) {
2212
+ if (dirParts.length === 0) return 0;
2213
+ for (let i = 0; i + dirParts.length <= parts.length; i++) {
2214
+ let match = true;
2215
+ for (let j = 0; j < dirParts.length; j++) {
2216
+ if (parts[i + j] !== dirParts[j]) {
2217
+ match = false;
2218
+ break;
2219
+ }
2220
+ }
2221
+ if (match) return i + dirParts.length;
2222
+ }
2223
+ return -1;
2224
+ }
2225
+ function routesStylePath(dirSegs, base) {
2226
+ const fileSegs = splitFlatSegments(base.replace(/\.[^.]+$/, ""));
2227
+ const leaf = fileSegs[fileSegs.length - 1];
2228
+ if (leaf.startsWith("_")) return null;
2229
+ const rawSegs = [...dirSegs, ...fileSegs];
2230
+ if (rawSegs.some((s) => s.startsWith("-"))) return null;
2231
+ if (rawSegs[0] === "api") return null;
2232
+ return formatTanStackPath(rawSegs);
2233
+ }
2234
+ function splitFlatSegments(name) {
2235
+ const segs = [];
2236
+ let cur = "";
2237
+ let depth = 0;
2238
+ for (const ch of name) {
2239
+ if (ch === "[") depth++;
2240
+ else if (ch === "]") depth = Math.max(0, depth - 1);
2241
+ if (ch === "." && depth === 0) {
2242
+ segs.push(cur);
2243
+ cur = "";
2244
+ continue;
2245
+ }
2246
+ cur += ch;
2247
+ }
2248
+ segs.push(cur);
2249
+ return segs;
2250
+ }
2251
+ function formatTanStackPath(segments) {
2252
+ const kept = [];
2253
+ for (const seg of segments) {
2254
+ if (!seg) continue;
2255
+ if (seg === "index" || seg === "route" || seg === "__root") continue;
2256
+ if (seg.startsWith("_")) continue;
2257
+ if (seg.startsWith("(") && seg.endsWith(")")) continue;
2258
+ kept.push(normalizeTanStackSegment(seg.replace(/_$/, "")));
2259
+ }
2260
+ if (kept.length === 0) return "/";
2261
+ return "/" + kept.join("/");
2262
+ }
2263
+ function normalizeTanStackSegment(seg) {
2264
+ if (seg === "$") return "[...splat]";
2265
+ if (seg.startsWith("$")) return `[${seg.slice(1)}]`;
2266
+ return seg.replace(/\[([^\]]*)\]/g, "$1");
2267
+ }
1880
2268
  function pathToId(routePath) {
1881
2269
  if (routePath === "/") return "root";
1882
2270
  return routePath.replace(/^\/+/, "").replace(/\[\.{3}([^\]]+)\]/g, "$1").replace(/\[([^\]]+)\]/g, "$1").replace(/\//g, "-").replace(/[^a-zA-Z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
@@ -1923,9 +2311,8 @@ function buildMetaFromExport(exp) {
1923
2311
  ...s.description ? { description: s.description } : {}
1924
2312
  }));
1925
2313
  }
1926
- if (exp.capture === false) meta.capture = false;
1927
- if (exp.waivers && Object.keys(exp.waivers).length > 0) {
1928
- meta.waivers = exp.waivers;
2314
+ if (exp.acknowledge && Object.keys(exp.acknowledge).length > 0) {
2315
+ meta.acknowledge = exp.acknowledge;
1929
2316
  }
1930
2317
  return Object.keys(meta).length > 0 ? meta : void 0;
1931
2318
  }
@@ -1972,9 +2359,12 @@ function resolve2(ctx) {
1972
2359
  function metaWithComposes(kind, id, base) {
1973
2360
  const composes = directChildren.get(`${kind}:${id}`);
1974
2361
  if (!composes || composes.length === 0) return base;
1975
- return { ...base ?? {}, composes };
2362
+ return { ...base, composes };
1976
2363
  }
1977
- const routes = conventions.pages === "auto" ? detectRoutes(ctx.extracted.map((e) => e.file)) : [];
2364
+ const routes = conventions.pages === false ? [] : detectRoutes(
2365
+ ctx.extracted.map((e) => e.file),
2366
+ conventions.pages === "auto" ? {} : { routesDir: conventions.pages.routesDir }
2367
+ );
1978
2368
  const handledPageFiles = /* @__PURE__ */ new Set();
1979
2369
  for (const route of routes) {
1980
2370
  const routeDir = path4.posix.dirname(route.file);
@@ -2267,6 +2657,45 @@ function resolve2(ctx) {
2267
2657
  };
2268
2658
  registry.add(element);
2269
2659
  }
2660
+ const ancestorsById = /* @__PURE__ */ new Map();
2661
+ const fileById = /* @__PURE__ */ new Map();
2662
+ for (const a of allAnnotations) {
2663
+ if (!fileById.has(a.id)) fileById.set(a.id, a.file);
2664
+ if (a.ancestors?.length) {
2665
+ const prev = ancestorsById.get(a.id) ?? [];
2666
+ ancestorsById.set(a.id, [...prev, ...a.ancestors.map((x) => x.id)]);
2667
+ }
2668
+ }
2669
+ const ownersByFile = /* @__PURE__ */ new Map();
2670
+ const addOwner = (file, id) => {
2671
+ ownersByFile.set(file, [...ownersByFile.get(file) ?? [], id]);
2672
+ };
2673
+ const featureDirs = [];
2674
+ for (const f of registry.list("feature")) {
2675
+ const loc = f.loc?.file;
2676
+ if (!loc) continue;
2677
+ const dir = loc.endsWith("/") ? loc.slice(0, -1) : path4.posix.dirname(loc);
2678
+ featureDirs.push({ dir, id: f.id });
2679
+ }
2680
+ for (const p2 of registry.list("page")) {
2681
+ if (p2.loc?.file) addOwner(p2.loc.file, p2.id);
2682
+ }
2683
+ const ownersFor = (file) => {
2684
+ const out2 = [...ownersByFile.get(file) ?? []];
2685
+ for (const { dir, id } of featureDirs) {
2686
+ if (file === dir || file.startsWith(`${dir}/`)) out2.push(id);
2687
+ }
2688
+ return out2;
2689
+ };
2690
+ const expandTouches = (ids) => {
2691
+ const out2 = new Set(ids);
2692
+ for (const id of ids) {
2693
+ for (const anc of ancestorsById.get(id) ?? []) out2.add(anc);
2694
+ const file = fileById.get(id);
2695
+ if (file) for (const owner of ownersFor(file)) out2.add(owner);
2696
+ }
2697
+ return [...out2];
2698
+ };
2270
2699
  if (ctx.flowFiles && conventions.flows) {
2271
2700
  for (const ff of ctx.flowFiles) {
2272
2701
  const notFlowExport = (ff.metadata ?? []).find(
@@ -2276,7 +2705,10 @@ function resolve2(ctx) {
2276
2705
  const flowExport = (ff.metadata ?? []).find(
2277
2706
  (m) => m.kind === "flow" && typeof m.id === "string"
2278
2707
  );
2279
- const derived = flowsFromFacts(ff);
2708
+ const derived = flowsFromFacts(ff).map((f) => ({
2709
+ ...f,
2710
+ touches: expandTouches(f.touches)
2711
+ }));
2280
2712
  if (flowExport && typeof flowExport.id === "string" && derived.length === 1) {
2281
2713
  const base = derived[0];
2282
2714
  const flow = {
@@ -2345,7 +2777,195 @@ function dedupe(arr) {
2345
2777
  }
2346
2778
 
2347
2779
  // src/scanner/scan/audit.ts
2348
- import * as path5 from "path";
2780
+ import * as path6 from "path";
2781
+
2782
+ // src/scanner/scan/component-coverage.ts
2783
+ var COMPOSED_KINDS = ["widget", "feature"];
2784
+ function composedStateEntities(registry, page) {
2785
+ const meta = registry.get("page", page)?.meta;
2786
+ if (!meta) return [];
2787
+ const out2 = [];
2788
+ for (const kind of COMPOSED_KINDS) {
2789
+ const ids = (kind === "widget" ? meta.widgets : meta.features) ?? [];
2790
+ for (const id of ids) {
2791
+ const e = registry.get(kind, id);
2792
+ if (!e?.meta?.states?.length) continue;
2793
+ if (e.meta.acknowledge?.[ACK_ALL]) continue;
2794
+ out2.push({ kind, id });
2795
+ }
2796
+ }
2797
+ return out2;
2798
+ }
2799
+ function capturedStatesFor(captured, kind, id) {
2800
+ const out2 = /* @__PURE__ */ new Set();
2801
+ for (const c of captured) {
2802
+ if (c.entity !== id) continue;
2803
+ if (c.kind !== void 0 && c.kind !== kind && c.kind !== "page") continue;
2804
+ out2.add(c.state);
2805
+ }
2806
+ return out2;
2807
+ }
2808
+ function checkComponentCoverage(registry, manifest) {
2809
+ const routes = registry.list("route");
2810
+ if (routes.length === 0) return [];
2811
+ const captured = manifest.captured ?? [];
2812
+ const diagnostics = [];
2813
+ for (const route of routes) {
2814
+ if (registry.get("page", route.page)?.meta?.acknowledge?.[ACK_ALL]) continue;
2815
+ for (const { kind, id } of composedStateEntities(registry, route.page)) {
2816
+ const entity = registry.get(kind, id);
2817
+ const declared = entity.meta.states.map((s) => s.name);
2818
+ const shot = capturedStatesFor(captured, kind, id);
2819
+ const missing = declared.filter((s) => !shot.has(s));
2820
+ if (missing.length === 0) continue;
2821
+ diagnostics.push({
2822
+ code: "component-state-uncaptured",
2823
+ severity: "info",
2824
+ message: `route "${route.path}" composes ${kind} "${id}", whose state(s) [${missing.join(", ")}] no capture produced`,
2825
+ file: entity.loc?.file,
2826
+ line: entity.loc?.line,
2827
+ entity: { kind, id },
2828
+ hint: `Capture the missing state(s) for "${id}", or drop them from its \`states\` if they no longer exist. This is the integration view: an isolated component capture never proves the state renders correctly INSIDE "${route.path}".`
2829
+ });
2830
+ }
2831
+ }
2832
+ return diagnostics;
2833
+ }
2834
+
2835
+ // src/scanner/scan/migrate-acknowledge.ts
2836
+ var CORE = CORE_STATE_KINDS;
2837
+ function orderScopes(scopes) {
2838
+ const rank = (s) => s === ACK_ALL ? -1 : CORE.indexOf(s);
2839
+ return [...scopes].sort((a, b) => rank(a) - rank(b));
2840
+ }
2841
+ function renderKey(scope) {
2842
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(scope) ? scope : JSON.stringify(scope);
2843
+ }
2844
+ function renderAck(a) {
2845
+ const parts = [`type: ${JSON.stringify(a.type)}`];
2846
+ if (a.reason) parts.push(`reason: ${JSON.stringify(a.reason)}`);
2847
+ return `{ ${parts.join(", ")} }`;
2848
+ }
2849
+ function renderAcknowledge(entries, indent) {
2850
+ const inner = indent + " ";
2851
+ const lines = orderScopes(Object.keys(entries)).map(
2852
+ (scope) => `${inner}${renderKey(scope)}: ${renderAck(entries[scope])},`
2853
+ );
2854
+ return ["acknowledge: {", ...lines, `${indent}}`].join("\n");
2855
+ }
2856
+ function indentAt(content, offset) {
2857
+ const lineStart = content.lastIndexOf("\n", offset - 1) + 1;
2858
+ const m = /^[ \t]*/.exec(content.slice(lineStart, offset));
2859
+ return m ? m[0] : "";
2860
+ }
2861
+ function plannedEntries(m) {
2862
+ const entries = {};
2863
+ if (m.legacyCapture === false) {
2864
+ entries[ACK_ALL] = {
2865
+ type: "impossible",
2866
+ reason: m.description || PLACEHOLDER_REASON
2867
+ };
2868
+ }
2869
+ for (const kind of CORE) {
2870
+ const reason = m.legacyWaivers?.[kind];
2871
+ if (reason) entries[kind] = { type: "impossible", reason };
2872
+ }
2873
+ return entries;
2874
+ }
2875
+ function migrateLegacyFields(ef) {
2876
+ const out2 = [];
2877
+ const { content, sourcePath, displayPath } = ef.file;
2878
+ for (const m of ef.metadata ?? []) {
2879
+ const legacy = [];
2880
+ if (m.legacyWaivers) legacy.push("waivers");
2881
+ if (m.legacyCapture !== void 0) legacy.push("capture");
2882
+ if (legacy.length === 0) continue;
2883
+ const fields = legacy.join(" and ");
2884
+ const base = {
2885
+ severity: "error",
2886
+ code: "legacy-acknowledge-field",
2887
+ file: displayPath,
2888
+ line: m.loc.line
2889
+ };
2890
+ if (m.acknowledge) {
2891
+ out2.push({
2892
+ ...base,
2893
+ message: `\`${fields}\` was replaced by \`acknowledge\`, and this declaration already has an \`acknowledge\` field`,
2894
+ hint: `Merge the remaining \`${fields}\` entries into \`acknowledge\` by hand and delete them; --fix will not merge two maps for you.`
2895
+ });
2896
+ continue;
2897
+ }
2898
+ const entries = plannedEntries(m);
2899
+ const edits = [];
2900
+ const present = legacy.map((f) => ({ f, prop: m.fieldPropSpans?.[f], del: m.fieldSpans?.[f] })).filter((x) => x.prop && x.del).sort((a, b) => a.prop.start - b.prop.start);
2901
+ if (present.length !== legacy.length) {
2902
+ out2.push({
2903
+ ...base,
2904
+ message: `\`${fields}\` was replaced by \`acknowledge\`, but the field spans needed to rewrite it are unavailable`,
2905
+ hint: "Rewrite the declaration by hand: `acknowledge: { <scope>: { type, reason? } }`."
2906
+ });
2907
+ continue;
2908
+ }
2909
+ const [anchor, ...rest] = present;
2910
+ if (Object.keys(entries).length === 0) {
2911
+ edits.push({
2912
+ path: sourcePath,
2913
+ start: anchor.del.start,
2914
+ end: anchor.del.end,
2915
+ replacement: ""
2916
+ });
2917
+ } else {
2918
+ edits.push({
2919
+ path: sourcePath,
2920
+ start: anchor.prop.start,
2921
+ end: anchor.prop.end,
2922
+ replacement: renderAcknowledge(
2923
+ entries,
2924
+ indentAt(content, anchor.prop.start)
2925
+ )
2926
+ });
2927
+ }
2928
+ for (const r of rest) {
2929
+ edits.push({
2930
+ path: sourcePath,
2931
+ start: r.del.start,
2932
+ end: r.del.end,
2933
+ replacement: ""
2934
+ });
2935
+ }
2936
+ out2.push({
2937
+ ...base,
2938
+ message: `\`${fields}\` was replaced by the unified \`acknowledge\` field`,
2939
+ hint: `Run \`uidex scan --fix\` to rewrite it${m.legacyCapture === false && !m.description ? ", then replace the TODO reason it plants \u2014 `capture: false` carried no explanation and `impossible` requires one" : ""}.`,
2940
+ fix: {
2941
+ description: `Rewrite \`${fields}\` as \`acknowledge\``,
2942
+ edits
2943
+ }
2944
+ });
2945
+ }
2946
+ return out2;
2947
+ }
2948
+ function migrateLegacyBaseline(opts) {
2949
+ if (!isLegacyBaseline(opts.parsed)) return void 0;
2950
+ return {
2951
+ code: "legacy-coverage-baseline",
2952
+ severity: "error",
2953
+ message: `coverage baseline "${opts.displayPath}" uses the removed \`uncapturedPages\` / \`missingKinds\` shape`,
2954
+ file: opts.displayPath,
2955
+ hint: "Run `uidex scan --fix` to rewrite it as `acknowledge` entries of type `backlog` (identical content, new shape \u2014 no capture run needed).",
2956
+ fix: {
2957
+ description: `Rewrite ${opts.displayPath} as an \`acknowledge\` baseline`,
2958
+ edits: [
2959
+ {
2960
+ path: opts.path,
2961
+ start: 0,
2962
+ end: opts.raw.length,
2963
+ replacement: serializeCoverageBaseline(migrateBaseline(opts.parsed))
2964
+ }
2965
+ ]
2966
+ }
2967
+ };
2968
+ }
2349
2969
 
2350
2970
  // src/scanner/scan/page-coverage.ts
2351
2971
  function compileRoute(pattern) {
@@ -2368,11 +2988,11 @@ function compileRoute(pattern) {
2368
2988
  return { test: (url) => re.test(url), specificity };
2369
2989
  }
2370
2990
  function matchUrlToRoute(url, routePaths) {
2371
- const path12 = stripQuery(url);
2991
+ const path13 = stripQuery(url);
2372
2992
  let best = null;
2373
2993
  for (const rp of routePaths) {
2374
2994
  const { test, specificity } = compileRoute(rp);
2375
- if (!test(path12)) continue;
2995
+ if (!test(path13)) continue;
2376
2996
  if (!best || specificity > best.specificity)
2377
2997
  best = { path: rp, specificity };
2378
2998
  }
@@ -2403,29 +3023,49 @@ function checkPageCoverage(registry, manifest, baseline) {
2403
3023
  if (routes.length === 0) return [];
2404
3024
  const routePaths = routes.map((r) => r.path);
2405
3025
  const captured = manifest.captured ?? [];
2406
- const optedOut = /* @__PURE__ */ new Set();
2407
- for (const r of routes) {
2408
- const page = registry.get("page", r.page);
2409
- if (page?.meta?.capture === false) optedOut.add(r.path);
2410
- }
2411
3026
  const covered = /* @__PURE__ */ new Set();
2412
3027
  for (const c of captured) {
2413
3028
  const route = routeForCapture(c, routes, routePaths);
2414
3029
  if (route) covered.add(route);
2415
3030
  }
2416
- const baselineSet = new Set(baseline?.uncapturedPages ?? []);
2417
3031
  const diagnostics = [];
2418
3032
  for (const r of routes) {
2419
- if (covered.has(r.path) || optedOut.has(r.path)) continue;
3033
+ const ack = routeAcknowledge(registry, baseline, r.path, r.page).get(
3034
+ ACK_ALL
3035
+ );
2420
3036
  const loc = registry.get("page", r.page)?.loc;
2421
- if (baselineSet.has(r.path)) {
3037
+ if (covered.has(r.path)) {
3038
+ if (ack?.type === "impossible") {
3039
+ diagnostics.push({
3040
+ code: "stale-acknowledgment",
3041
+ severity: "warning",
3042
+ message: `page "${r.page}" acknowledges "*" as ${describeAck(ack)}, but route "${r.path}" is captured`,
3043
+ file: loc?.file,
3044
+ line: loc?.line,
3045
+ entity: { kind: "page", id: r.page },
3046
+ hint: originHint(ack, r.page)
3047
+ });
3048
+ } else if (ack?.type === "backlog") {
3049
+ diagnostics.push({
3050
+ code: "page-captured",
3051
+ severity: "warning",
3052
+ message: `route "${r.path}" is now captured but is still acknowledged as backlog`,
3053
+ file: loc?.file,
3054
+ line: loc?.line,
3055
+ hint: originHint(ack, r.page)
3056
+ });
3057
+ }
3058
+ continue;
3059
+ }
3060
+ if (ack?.type === "impossible") continue;
3061
+ if (ack?.type === "backlog") {
2422
3062
  diagnostics.push({
2423
3063
  code: "page-backlog",
2424
3064
  severity: "info",
2425
3065
  message: `route "${r.path}" has no visual-states capture (acknowledged backlog)`,
2426
3066
  file: loc?.file,
2427
3067
  line: loc?.line,
2428
- hint: `Capture it (drive the route in a states spec) and it drops off the baseline, or opt it out with \`export const uidex = { page: "${r.page}", capture: false }\`.`
3068
+ hint: `Capture it (drive the route in a states spec) and it drops off the baseline, or \u2014 if it can NEVER be captured \u2014 say so with \`acknowledge: { "*": { type: "impossible", reason: "\u2026" } }\` on page "${r.page}".`
2429
3069
  });
2430
3070
  continue;
2431
3071
  }
@@ -2436,46 +3076,155 @@ function checkPageCoverage(registry, manifest, baseline) {
2436
3076
  file: loc?.file,
2437
3077
  line: loc?.line,
2438
3078
  entity: { kind: "route", id: r.path },
2439
- hint: `Capture the route in a states spec, opt it out with \`export const uidex = { page: "${r.page}", capture: false }\`, or grandfather it as acknowledged backlog with \`uidex scan --update-baseline\` (the baseline change lands in the diff for review).`
3079
+ hint: `Capture the route in a states spec; or if it can NEVER be captured add \`acknowledge: { "*": { type: "impossible", reason: "why" } }\` to page "${r.page}"; or record it as debt with \`uidex scan --update-baseline\` (which writes \`{ type: "backlog" }\`, visible in the diff for review).`
2440
3080
  });
2441
3081
  }
2442
- for (const b of baselineSet) {
2443
- if (!routePaths.includes(b)) {
2444
- diagnostics.push({
2445
- code: "stale-page-baseline",
2446
- severity: "warning",
2447
- message: `coverage baseline lists "${b}", which is not a route in the registry`,
2448
- hint: `Remove it from the baseline (\`uidex scan --update-baseline\`); the route no longer exists.`
2449
- });
2450
- } else if (covered.has(b) || optedOut.has(b)) {
2451
- diagnostics.push({
2452
- code: "page-captured",
2453
- severity: "warning",
2454
- message: `route "${b}" is now captured but is still in the coverage baseline`,
2455
- hint: `Prune it with \`uidex scan --update-baseline\` so the backlog reflects reality.`
2456
- });
2457
- }
3082
+ for (const b of Object.keys(baseline?.acknowledge ?? {})) {
3083
+ if (routePaths.includes(b)) continue;
3084
+ diagnostics.push({
3085
+ code: "stale-page-baseline",
3086
+ severity: "warning",
3087
+ message: `coverage baseline acknowledges "${b}", which is not a route in the registry`,
3088
+ hint: `Remove it from the baseline (\`uidex scan --update-baseline\`); the route no longer exists.`
3089
+ });
2458
3090
  }
2459
3091
  return diagnostics;
2460
3092
  }
2461
- function computePageBaseline(registry, manifest) {
2462
- const uncaptured = [];
3093
+ function computePageBacklog(registry, manifest) {
3094
+ const out2 = {};
2463
3095
  for (const d of checkPageCoverage(registry, manifest, {
2464
- uncapturedPages: []
3096
+ version: 2,
3097
+ acknowledge: {}
2465
3098
  })) {
2466
- if (d.code === "page-no-capture" && d.entity) uncaptured.push(d.entity.id);
3099
+ if (d.code === "page-no-capture" && d.entity) {
3100
+ out2[d.entity.id] = { [ACK_ALL]: { type: "backlog" } };
3101
+ }
3102
+ }
3103
+ return out2;
3104
+ }
3105
+
3106
+ // src/scanner/scan/workspace.ts
3107
+ import * as fs3 from "fs";
3108
+ import * as path5 from "path";
3109
+ var WORKSPACE_MARKERS = [
3110
+ "pnpm-workspace.yaml",
3111
+ "pnpm-workspace.yml",
3112
+ "turbo.json",
3113
+ "lerna.json",
3114
+ "nx.json",
3115
+ ".git"
3116
+ ];
3117
+ var MAX_ASCENT = 8;
3118
+ var DEFAULT_STATES_MANIFEST = "uidex-states.json";
3119
+ function captureKey(kind, entity) {
3120
+ return `${kind ?? ""}|${entity}`;
3121
+ }
3122
+ function hasMarker(dir) {
3123
+ return WORKSPACE_MARKERS.some((m) => fs3.existsSync(path5.join(dir, m)));
3124
+ }
3125
+ function findWorkspaceRoot(configDir) {
3126
+ let dir = path5.resolve(configDir);
3127
+ for (let i = 0; i < MAX_ASCENT; i++) {
3128
+ const parent = path5.dirname(dir);
3129
+ if (parent === dir) return void 0;
3130
+ dir = parent;
3131
+ if (hasMarker(dir)) return dir;
3132
+ }
3133
+ return void 0;
3134
+ }
3135
+ function captureKeys(manifest) {
3136
+ const out2 = /* @__PURE__ */ new Set();
3137
+ for (const c of manifest?.captured ?? []) {
3138
+ out2.add(captureKey(c.kind, c.entity));
3139
+ }
3140
+ return out2;
3141
+ }
3142
+ function readManifest(configDir, config) {
3143
+ const rel = config.statesManifest ?? DEFAULT_STATES_MANIFEST;
3144
+ let raw;
3145
+ try {
3146
+ raw = fs3.readFileSync(path5.resolve(configDir, rel), "utf8");
3147
+ } catch {
3148
+ return void 0;
3149
+ }
3150
+ try {
3151
+ const parsed = JSON.parse(raw);
3152
+ if (!parsed || !Array.isArray(parsed.captured)) return void 0;
3153
+ const captured = [];
3154
+ for (const entry of parsed.captured) {
3155
+ if (!entry || typeof entry !== "object") continue;
3156
+ const c = entry;
3157
+ if (typeof c.entity !== "string" || typeof c.state !== "string") continue;
3158
+ const rec = { entity: c.entity, state: c.state };
3159
+ if (typeof c.kind === "string") rec.kind = c.kind;
3160
+ captured.push(rec);
3161
+ }
3162
+ return { captured };
3163
+ } catch {
3164
+ return void 0;
2467
3165
  }
2468
- return { uncapturedPages: uncaptured.sort() };
3166
+ }
3167
+ function toApp(dc) {
3168
+ return {
3169
+ name: path5.basename(dc.configDir),
3170
+ configDir: dc.configDir,
3171
+ configPath: dc.configPath,
3172
+ registers: createSourceMatcher(dc.config.sources, {
3173
+ cwd: dc.configDir,
3174
+ globalExcludes: dc.config.exclude
3175
+ }),
3176
+ captured: captureKeys(readManifest(dc.configDir, dc.config))
3177
+ };
3178
+ }
3179
+ function resolveWorkspace(opts) {
3180
+ const { configDir, config } = opts;
3181
+ if (config.workspace === false) return void 0;
3182
+ const root = typeof config.workspace === "string" ? path5.resolve(configDir, config.workspace) : findWorkspaceRoot(configDir);
3183
+ if (!root) return void 0;
3184
+ const configs = opts.configs ?? discover({ cwd: root, all: true });
3185
+ const self = path5.resolve(configDir, CONFIG_FILENAME);
3186
+ const siblings = configs.filter((dc) => path5.resolve(dc.configPath) !== self).map(toApp);
3187
+ if (siblings.length === 0) return void 0;
3188
+ return { root, siblings };
3189
+ }
3190
+ function capturedElsewhere(workspace, absFile, kind, id) {
3191
+ if (!workspace || !absFile) return void 0;
3192
+ for (const app of workspace.siblings) {
3193
+ if (!app.registers(absFile)) continue;
3194
+ if (app.captured.has(captureKey(kind, id)) || app.captured.has(captureKey(void 0, id))) {
3195
+ return app.name;
3196
+ }
3197
+ }
3198
+ return void 0;
2469
3199
  }
2470
3200
 
2471
3201
  // src/scanner/scan/state-coverage.ts
2472
3202
  var STATE_KINDS2 = ["page", "feature", "widget"];
2473
- function declaredStateEntities(registry) {
3203
+ function declaredStateEntities(registry, opts, ungated) {
2474
3204
  const out2 = [];
2475
3205
  for (const kind of STATE_KINDS2) {
2476
3206
  for (const e of registry.list(kind)) {
2477
3207
  const states = e.meta?.states;
2478
3208
  if (!states || states.length === 0) continue;
3209
+ const absFile = e.loc?.file ? opts.resolveSourcePath?.(e.loc.file) : void 0;
3210
+ const ack = entityAcknowledge(
3211
+ e.meta,
3212
+ capturedElsewhere(opts.workspace, absFile, kind, e.id)
3213
+ );
3214
+ if (ack) {
3215
+ if (ack.origin === "workspace") {
3216
+ ungated.push({
3217
+ code: "acknowledged-elsewhere",
3218
+ severity: "info",
3219
+ message: `${kind} "${e.id}" declares states that no capture here produced, but sibling app "${ack.by}" registers the same declaration and captured it (${describeAck(ack)})`,
3220
+ file: e.loc?.file,
3221
+ line: e.loc?.line,
3222
+ entity: { kind, id: e.id },
3223
+ hint: `This is the workspace answering, not a guess: "${ack.by}" sources the declaring file and its manifest has a capture for "${e.id}". Set \`workspace: false\` in .uidex.json to gate it here anyway.`
3224
+ });
3225
+ }
3226
+ continue;
3227
+ }
2479
3228
  out2.push({
2480
3229
  kind,
2481
3230
  id: e.id,
@@ -2486,18 +3235,31 @@ function declaredStateEntities(registry) {
2486
3235
  }
2487
3236
  return out2;
2488
3237
  }
2489
- function captureMatchesEntity(cap, ent) {
3238
+ function unreliablePageKinds(registry, captured) {
3239
+ const out2 = /* @__PURE__ */ new Set();
3240
+ for (const c of captured) {
3241
+ if (c.kind !== "page") continue;
3242
+ if (registry.get("page", c.entity)) continue;
3243
+ out2.add(c.entity);
3244
+ }
3245
+ return out2;
3246
+ }
3247
+ function captureMatchesEntity(cap, ent, unreliable = EMPTY) {
2490
3248
  if (cap.entity !== ent.id) return false;
2491
- return cap.kind === void 0 || cap.kind === ent.kind;
3249
+ if (cap.kind === void 0) return true;
3250
+ if (cap.kind === ent.kind) return true;
3251
+ return cap.kind === "page" && unreliable.has(cap.entity);
2492
3252
  }
2493
- function checkStateCoverage(registry, manifest) {
3253
+ var EMPTY = /* @__PURE__ */ new Set();
3254
+ function checkStateCoverage(registry, manifest, opts = {}) {
2494
3255
  const diagnostics = [];
2495
- const declared = declaredStateEntities(registry);
2496
3256
  const captured = manifest.captured ?? [];
3257
+ const unreliable = unreliablePageKinds(registry, captured);
3258
+ const declared = declaredStateEntities(registry, opts, diagnostics);
2497
3259
  for (const ent of declared) {
2498
3260
  for (const state of ent.states) {
2499
3261
  const hit = captured.some(
2500
- (c) => captureMatchesEntity(c, ent) && c.state === state
3262
+ (c) => captureMatchesEntity(c, ent, unreliable) && c.state === state
2501
3263
  );
2502
3264
  if (hit) continue;
2503
3265
  diagnostics.push({
@@ -2512,7 +3274,9 @@ function checkStateCoverage(registry, manifest) {
2512
3274
  }
2513
3275
  }
2514
3276
  for (const cap of captured) {
2515
- const candidates = declared.filter((ent2) => captureMatchesEntity(cap, ent2));
3277
+ const candidates = declared.filter(
3278
+ (ent2) => captureMatchesEntity(cap, ent2, unreliable)
3279
+ );
2516
3280
  if (candidates.length === 0) continue;
2517
3281
  const declaredSomewhere = candidates.some(
2518
3282
  (ent2) => ent2.states.includes(cap.state)
@@ -2556,36 +3320,35 @@ function coreKindsByRoute(registry, manifest) {
2556
3320
  }
2557
3321
  return byRoute;
2558
3322
  }
2559
- function pageWaivers(registry, routePage) {
2560
- return registry.get("page", routePage)?.meta?.waivers ?? {};
2561
- }
2562
- function isOptedOut(registry, page) {
2563
- return page ? registry.get("page", page)?.meta?.capture === false : false;
2564
- }
2565
3323
  function checkStateMatrix(registry, manifest, baseline) {
2566
3324
  const routes = registry.list("route");
2567
3325
  if (routes.length === 0) return [];
2568
3326
  const pageOf = new Map(routes.map((r) => [r.path, r.page]));
2569
3327
  const captured = coreKindsByRoute(registry, manifest);
2570
- const baselined = baseline?.missingKinds ?? {};
2571
3328
  const diagnostics = [];
2572
3329
  for (const [route, kinds] of captured) {
2573
3330
  const page = pageOf.get(route);
2574
- if (isOptedOut(registry, page)) continue;
2575
- const waivers = page ? pageWaivers(registry, page) : {};
3331
+ const ack = routeAcknowledge(registry, baseline, route, page);
2576
3332
  const loc = page ? registry.get("page", page)?.loc : void 0;
2577
- const baselinedKinds = new Set(baselined[route] ?? []);
3333
+ const all = ack.get(ACK_ALL);
3334
+ if (all) {
3335
+ if (all.type === "impossible") {
3336
+ diagnostics.push(staleDiagnostic(all, route, page, loc));
3337
+ }
3338
+ continue;
3339
+ }
2578
3340
  for (const kind of CORE_KINDS) {
2579
3341
  if (kinds.has(kind)) continue;
2580
- if (waivers[kind]) continue;
2581
- if (baselinedKinds.has(kind)) {
3342
+ const entry = ack.get(kind);
3343
+ if (entry?.type === "impossible") continue;
3344
+ if (entry?.type === "backlog") {
2582
3345
  diagnostics.push({
2583
3346
  code: "matrix-backlog",
2584
3347
  severity: "info",
2585
- message: `route "${route}" does not render core kind "${kind}" (acknowledged MISSING_KINDS backlog)`,
3348
+ message: `route "${route}" does not render core kind "${kind}" (acknowledged backlog)`,
2586
3349
  file: loc?.file,
2587
3350
  line: loc?.line,
2588
- hint: `Capture the "${kind}" state for "${route}", waive it (\`waivers: { ${kind}: "why it can't" }\` on the page), or leave it in the baseline.`
3351
+ hint: `Capture the "${kind}" state for "${route}", or if the route CANNOT render it change the acknowledgment to \`{ type: "impossible", reason: "why it can't" }\`.`
2589
3352
  });
2590
3353
  continue;
2591
3354
  }
@@ -2596,42 +3359,43 @@ function checkStateMatrix(registry, manifest, baseline) {
2596
3359
  file: loc?.file,
2597
3360
  line: loc?.line,
2598
3361
  entity: { kind: "route", id: route },
2599
- hint: `Capture the "${kind}" state, or if the route cannot render it add \`export const uidex = { page: "${page}", waivers: { ${kind}: "why it can't" } }\`, or grandfather it via \`uidex scan --update-baseline\` (the baseline change lands in the diff for review).`
3362
+ hint: `Capture the "${kind}" state; or if the route CANNOT render it, add \`acknowledge: { ${kind}: { type: "impossible", reason: "why it can't" } }\` to page "${page}"; or record it as debt with \`uidex scan --update-baseline\` (which writes \`{ type: "backlog" }\`, visible in the diff for review).`
2600
3363
  });
2601
3364
  }
2602
- }
2603
- for (const [route, kinds] of captured) {
2604
- const page = pageOf.get(route);
2605
- if (!page || isOptedOut(registry, page)) continue;
2606
- const waivers = pageWaivers(registry, page);
2607
- const loc = registry.get("page", page)?.loc;
2608
- for (const kind of Object.keys(waivers)) {
2609
- if (kinds.has(kind)) {
2610
- diagnostics.push({
2611
- code: "stale-waiver",
2612
- severity: "warning",
2613
- message: `page "${page}" waives core kind "${kind}", but route "${route}" does render it`,
2614
- file: loc?.file,
2615
- line: loc?.line,
2616
- entity: { kind: "page", id: page },
2617
- hint: `Remove the "${kind}" waiver \u2014 the route renders that kind, so the waiver's reason is stale.`
2618
- });
2619
- }
3365
+ for (const [scope, entry] of ack) {
3366
+ if (scope === ACK_ALL || !kinds.has(scope)) continue;
3367
+ diagnostics.push(staleDiagnostic(entry, route, page, loc));
2620
3368
  }
2621
3369
  }
2622
3370
  return diagnostics;
2623
3371
  }
2624
- function computeMissingKinds(registry, manifest) {
3372
+ function staleDiagnostic(entry, route, page, loc) {
3373
+ const what = entry.scope === ACK_ALL ? `route "${route}" is captured` : `route "${route}" does render core kind "${entry.scope}"`;
3374
+ return {
3375
+ code: "stale-acknowledgment",
3376
+ severity: "warning",
3377
+ message: `${page ? `page "${page}"` : `route "${route}"`} acknowledges "${entry.scope}" as ${describeAck(entry)}, but ${what}`,
3378
+ file: loc?.file,
3379
+ line: loc?.line,
3380
+ ...page ? { entity: { kind: "page", id: page } } : {},
3381
+ hint: originHint(entry, page)
3382
+ };
3383
+ }
3384
+ function computeMatrixBacklog(registry, manifest) {
2625
3385
  const routes = registry.list("route");
2626
3386
  const pageOf = new Map(routes.map((r) => [r.path, r.page]));
2627
3387
  const captured = coreKindsByRoute(registry, manifest);
2628
3388
  const out2 = {};
2629
3389
  for (const [route, kinds] of captured) {
2630
3390
  const page = pageOf.get(route);
2631
- if (isOptedOut(registry, page)) continue;
2632
- const waivers = page ? pageWaivers(registry, page) : {};
2633
- const missing = CORE_KINDS.filter((k) => !kinds.has(k) && !waivers[k]);
2634
- if (missing.length > 0) out2[route] = missing;
3391
+ const ack = routeAcknowledge(registry, void 0, route, page);
3392
+ if (ack.has(ACK_ALL)) continue;
3393
+ const scopes = {};
3394
+ for (const kind of CORE_KINDS) {
3395
+ if (kinds.has(kind) || ack.has(kind)) continue;
3396
+ scopes[kind] = { type: "backlog" };
3397
+ }
3398
+ if (Object.keys(scopes).length > 0) out2[route] = scopes;
2635
3399
  }
2636
3400
  return out2;
2637
3401
  }
@@ -2655,6 +3419,23 @@ function audit(opts) {
2655
3419
  for (const ef of opts.flowExtracted ?? []) {
2656
3420
  if (ef.diagnostics) diagnostics.push(...ef.diagnostics);
2657
3421
  }
3422
+ for (const ef of extracted) diagnostics.push(...migrateLegacyFields(ef));
3423
+ if (opts.baselineDiagnostic) diagnostics.push(opts.baselineDiagnostic);
3424
+ for (const ef of extracted) {
3425
+ for (const m of ef.metadata ?? []) {
3426
+ for (const [scope, ack] of Object.entries(m.acknowledge ?? {})) {
3427
+ if (!isPlaceholderReason(ack.reason)) continue;
3428
+ diagnostics.push({
3429
+ code: "placeholder-reason",
3430
+ severity: "warning",
3431
+ message: `acknowledge "${scope}" still has the migration's placeholder reason ("${ack.reason}")`,
3432
+ file: ef.file.displayPath,
3433
+ line: m.loc.line,
3434
+ hint: `Replace it with why this can NEVER be captured \u2014 or, if it merely has not been captured yet, change the type to "backlog" (which needs no reason).`
3435
+ });
3436
+ }
3437
+ }
3438
+ }
2658
3439
  if (check && opts.generated !== void 0) {
2659
3440
  const outRel = opts.outputPath ?? config.output;
2660
3441
  const fresh = normalizeForCheck(opts.generated);
@@ -2768,8 +3549,8 @@ function audit(opts) {
2768
3549
  if (typeof m.id !== "string") continue;
2769
3550
  const filePath = ef.file.displayPath;
2770
3551
  const wellKnownName = WELL_KNOWN_FILES[m.kind];
2771
- if (path5.posix.basename(filePath) === wellKnownName) continue;
2772
- const dir = path5.posix.dirname(filePath);
3552
+ if (path6.posix.basename(filePath) === wellKnownName) continue;
3553
+ const dir = path6.posix.dirname(filePath);
2773
3554
  const wellKnownPath = dir === "." ? wellKnownName : `${dir}/${wellKnownName}`;
2774
3555
  if (scannedPaths.has(wellKnownPath)) continue;
2775
3556
  const kindLabel = m.kind === "page" ? "Page" : "Feature";
@@ -2983,25 +3764,41 @@ function audit(opts) {
2983
3764
  }
2984
3765
  }
2985
3766
  if (lint && statesEnabled && opts.capturedStates) {
2986
- diagnostics.push(...checkStateCoverage(registry, opts.capturedStates));
3767
+ const sourceByDisplay = new Map(
3768
+ files.map((f) => [f.displayPath, f.sourcePath])
3769
+ );
3770
+ diagnostics.push(
3771
+ ...checkStateCoverage(registry, opts.capturedStates, {
3772
+ workspace: opts.workspace,
3773
+ resolveSourcePath: (displayPath) => sourceByDisplay.get(displayPath)
3774
+ })
3775
+ );
2987
3776
  }
2988
3777
  if (lint && pageCoverageEnabled && opts.capturedStates) {
2989
3778
  diagnostics.push(
2990
- ...checkPageCoverage(registry, opts.capturedStates, opts.pageBaseline)
3779
+ ...checkPageCoverage(registry, opts.capturedStates, opts.coverageBaseline)
2991
3780
  );
2992
3781
  }
3782
+ if (lint && statesEnabled && opts.capturedStates) {
3783
+ diagnostics.push(...checkComponentCoverage(registry, opts.capturedStates));
3784
+ }
2993
3785
  if (lint && stateMatrixEnabled && opts.capturedStates) {
2994
3786
  diagnostics.push(
2995
- ...checkStateMatrix(registry, opts.capturedStates, {
2996
- missingKinds: opts.pageBaseline?.missingKinds ?? {}
2997
- })
3787
+ ...checkStateMatrix(registry, opts.capturedStates, opts.coverageBaseline)
2998
3788
  );
2999
3789
  }
3790
+ const overrides = config.audit?.severity ?? {};
3791
+ const governed = diagnostics.flatMap((d) => {
3792
+ const level = overrides[d.code];
3793
+ if (level === void 0) return [d];
3794
+ if (level === "off") return [];
3795
+ return [{ ...d, severity: level }];
3796
+ });
3000
3797
  const summary = {
3001
- errors: diagnostics.filter((d) => d.severity === "error").length,
3002
- warnings: diagnostics.filter((d) => d.severity === "warning").length
3798
+ errors: governed.filter((d) => d.severity === "error").length,
3799
+ warnings: governed.filter((d) => d.severity === "warning").length
3003
3800
  };
3004
- return { diagnostics, summary };
3801
+ return { diagnostics: governed, summary };
3005
3802
  }
3006
3803
  function lineOfOffset(content, offset) {
3007
3804
  let line = 1;
@@ -3329,6 +4126,30 @@ ${body}
3329
4126
  `;
3330
4127
  }
3331
4128
  var OPAQUE_ID_UNIONS = /* @__PURE__ */ new Set(["ElementId"]);
4129
+ function stateEntities(list) {
4130
+ return list.map((e) => ({
4131
+ id: e.id,
4132
+ states: (e.meta?.states ?? []).map((s) => s.name)
4133
+ }));
4134
+ }
4135
+ function emitStateMap(groups) {
4136
+ const lines = ["export interface UidexStates {"];
4137
+ for (const g of groups) {
4138
+ const withStates = g.entities.filter((e) => e.states.length > 0);
4139
+ if (withStates.length === 0) {
4140
+ lines.push(` ${g.kind}: never`);
4141
+ continue;
4142
+ }
4143
+ lines.push(` ${g.kind}: {`);
4144
+ for (const e of [...withStates].sort((a, b) => a.id.localeCompare(b.id))) {
4145
+ const states = [...new Set(e.states)].sort().map((s) => JSON.stringify(s)).join(" | ");
4146
+ lines.push(` ${JSON.stringify(e.id)}: ${states}`);
4147
+ }
4148
+ lines.push(" }");
4149
+ }
4150
+ lines.push("}");
4151
+ return lines.join("\n") + "\n";
4152
+ }
3332
4153
  function emitId(name, ids) {
3333
4154
  if (OPAQUE_ID_UNIONS.has(name)) {
3334
4155
  return `export type ${name} = string & { readonly __uidex?: ${JSON.stringify(name)} }
@@ -3416,6 +4237,14 @@ function emit(opts) {
3416
4237
  );
3417
4238
  t.push(emitId("StateId", [...stateNames]));
3418
4239
  t.push("");
4240
+ t.push("// ---- declared state space (kind \u2192 entity \u2192 its own states) ----");
4241
+ t.push(
4242
+ emitStateMap([
4243
+ { kind: "page", entities: stateEntities(pages) },
4244
+ { kind: "feature", entities: stateEntities(features) },
4245
+ { kind: "widget", entities: stateEntities(widgets) }
4246
+ ])
4247
+ );
3419
4248
  t.push("// ---- authoring-surface shape types ----");
3420
4249
  t.push("export namespace Uidex {");
3421
4250
  t.push(
@@ -3428,7 +4257,20 @@ function emit(opts) {
3428
4257
  t.push(" acceptance?: readonly string[]");
3429
4258
  t.push(" description?: string");
3430
4259
  t.push(" }");
3431
- t.push(" export type Waivers = Partial<Record<CoreStateKind, string>>");
4260
+ t.push(' export type AcknowledgeScope = CoreStateKind | "*"');
4261
+ t.push(" export interface Impossible {");
4262
+ t.push(' type: "impossible"');
4263
+ t.push(" reason: string");
4264
+ t.push(" }");
4265
+ t.push(" export interface Backlog {");
4266
+ t.push(' type: "backlog"');
4267
+ t.push(" reason?: string");
4268
+ t.push(" }");
4269
+ t.push(" export type Acknowledgment = Impossible | Backlog");
4270
+ t.push(
4271
+ " export type Acknowledge = Partial<Record<AcknowledgeScope, Acknowledgment>>"
4272
+ );
4273
+ t.push(' export type ComponentAcknowledge = { "*"?: Acknowledgment }');
3432
4274
  t.push(" export interface Page {");
3433
4275
  t.push(" page: PageId | false");
3434
4276
  t.push(" name?: string");
@@ -3436,8 +4278,7 @@ function emit(opts) {
3436
4278
  t.push(" widgets?: readonly WidgetId[]");
3437
4279
  t.push(" acceptance?: readonly string[]");
3438
4280
  t.push(" states?: readonly (string | State)[]");
3439
- t.push(" capture?: boolean");
3440
- t.push(" waivers?: Waivers");
4281
+ t.push(" acknowledge?: Acknowledge");
3441
4282
  t.push(" description?: string");
3442
4283
  t.push(" }");
3443
4284
  t.push(" export interface Feature {");
@@ -3446,6 +4287,7 @@ function emit(opts) {
3446
4287
  t.push(" features?: readonly FeatureId[]");
3447
4288
  t.push(" acceptance?: readonly string[]");
3448
4289
  t.push(" states?: readonly (string | State)[]");
4290
+ t.push(" acknowledge?: ComponentAcknowledge");
3449
4291
  t.push(" description?: string");
3450
4292
  t.push(" }");
3451
4293
  t.push(" export interface Primitive {");
@@ -3458,6 +4300,7 @@ function emit(opts) {
3458
4300
  t.push(" name?: string");
3459
4301
  t.push(" acceptance?: readonly string[]");
3460
4302
  t.push(" states?: readonly (string | State)[]");
4303
+ t.push(" acknowledge?: ComponentAcknowledge");
3461
4304
  t.push(" description?: string");
3462
4305
  t.push(" }");
3463
4306
  t.push(" export interface Region {");
@@ -3553,8 +4396,8 @@ function parseGitHubRef(ref) {
3553
4396
  }
3554
4397
 
3555
4398
  // src/scanner/scan/scaffold.ts
3556
- import * as fs3 from "fs";
3557
- import * as path6 from "path";
4399
+ import * as fs4 from "fs";
4400
+ import * as path7 from "path";
3558
4401
  function scaffoldWidgetSpec(opts) {
3559
4402
  return scaffoldSpec({
3560
4403
  registry: opts.registry,
@@ -3580,8 +4423,8 @@ function scaffoldSpec(opts) {
3580
4423
  }
3581
4424
  const criteria = entity.meta?.acceptance ?? [];
3582
4425
  const filename = kind === "widget" ? `widget-${id}.spec.ts` : `flow-${id}.spec.ts`;
3583
- const outputPath = path6.resolve(outDir, filename);
3584
- if (fs3.existsSync(outputPath) && !force) {
4426
+ const outputPath = path7.resolve(outDir, filename);
4427
+ if (fs4.existsSync(outputPath) && !force) {
3585
4428
  return {
3586
4429
  outputPath,
3587
4430
  written: false,
@@ -3590,8 +4433,8 @@ function scaffoldSpec(opts) {
3590
4433
  };
3591
4434
  }
3592
4435
  const content = renderSpec({ id, criteria, fixtureImport });
3593
- fs3.mkdirSync(path6.dirname(outputPath), { recursive: true });
3594
- fs3.writeFileSync(outputPath, content, "utf8");
4436
+ fs4.mkdirSync(path7.dirname(outputPath), { recursive: true });
4437
+ fs4.writeFileSync(outputPath, content, "utf8");
3595
4438
  return { outputPath, written: true, skipped: false };
3596
4439
  }
3597
4440
  function capitalize(s) {
@@ -3599,25 +4442,27 @@ function capitalize(s) {
3599
4442
  }
3600
4443
  function renderSpec(args) {
3601
4444
  const lines = [];
3602
- lines.push(
3603
- `import { test, expect } from ${JSON.stringify(args.fixtureImport)}`
3604
- );
4445
+ lines.push(`import { test } from ${JSON.stringify(args.fixtureImport)}`);
3605
4446
  lines.push("");
3606
4447
  lines.push(
3607
4448
  `test.describe(${JSON.stringify(args.id)}, { tag: "@uidex:flow" }, () => {`
3608
4449
  );
3609
- if (args.criteria.length === 0) {
3610
- lines.push(` test("TODO: add acceptance criteria", async () => {`);
3611
- lines.push(` // TODO`);
4450
+ const stub = (title, body) => {
4451
+ lines.push(` test.fixme(${JSON.stringify(title)}, async ({ uidex }) => {`);
4452
+ for (const l of body) lines.push(` ${l}`);
4453
+ lines.push(` void uidex`);
3612
4454
  lines.push(` })`);
4455
+ lines.push("");
4456
+ };
4457
+ if (args.criteria.length === 0) {
4458
+ stub("TODO: add acceptance criteria", [
4459
+ "// Declare `acceptance: [...]` on the entity, then re-run scaffold."
4460
+ ]);
3613
4461
  } else {
3614
4462
  for (const criterion of args.criteria) {
3615
- lines.push(` test(${JSON.stringify(criterion)}, async ({ uidex }) => {`);
3616
- lines.push(` // TODO: implement criterion`);
3617
- lines.push(` void uidex`);
3618
- lines.push(` expect(true).toBe(true)`);
3619
- lines.push(` })`);
3620
- lines.push("");
4463
+ stub(criterion, [
4464
+ "// TODO: drive this criterion, then remove `.fixme` to arm the test."
4465
+ ]);
3621
4466
  }
3622
4467
  }
3623
4468
  lines.push("})");
@@ -3626,47 +4471,40 @@ function renderSpec(args) {
3626
4471
  }
3627
4472
 
3628
4473
  // src/scanner/scan/pipeline.ts
3629
- import * as fs4 from "fs";
3630
- import * as path7 from "path";
3631
- var DEFAULT_STATES_MANIFEST = "uidex-states.json";
4474
+ import * as fs5 from "fs";
4475
+ import * as path8 from "path";
4476
+ var DEFAULT_STATES_MANIFEST2 = "uidex-states.json";
3632
4477
  var DEFAULT_COVERAGE_BASELINE = "uidex-coverage-baseline.json";
3633
- function loadPageBaseline(configDir, config) {
4478
+ function loadCoverageBaseline(configDir, config) {
3634
4479
  const rel = config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
3635
- const abs = path7.resolve(configDir, rel);
4480
+ const abs = path8.resolve(configDir, rel);
3636
4481
  let raw;
3637
4482
  try {
3638
- raw = fs4.readFileSync(abs, "utf8");
4483
+ raw = fs5.readFileSync(abs, "utf8");
3639
4484
  } catch {
3640
- return void 0;
4485
+ return {};
3641
4486
  }
4487
+ let parsed;
3642
4488
  try {
3643
- const parsed = JSON.parse(raw);
3644
- if (!parsed || !Array.isArray(parsed.uncapturedPages)) return void 0;
3645
- const uncapturedPages = parsed.uncapturedPages.filter(
3646
- (p2) => typeof p2 === "string"
3647
- );
3648
- let missingKinds;
3649
- const mkRaw = parsed.missingKinds;
3650
- if (mkRaw && typeof mkRaw === "object" && !Array.isArray(mkRaw)) {
3651
- const mk = {};
3652
- for (const [route, kinds] of Object.entries(mkRaw)) {
3653
- if (Array.isArray(kinds)) {
3654
- mk[route] = kinds.filter((k) => typeof k === "string");
3655
- }
3656
- }
3657
- missingKinds = mk;
3658
- }
3659
- return { uncapturedPages, ...missingKinds ? { missingKinds } : {} };
4489
+ parsed = JSON.parse(raw);
3660
4490
  } catch {
3661
- return void 0;
4491
+ return {};
3662
4492
  }
4493
+ const legacy = migrateLegacyBaseline({
4494
+ path: abs,
4495
+ raw,
4496
+ parsed,
4497
+ displayPath: rel
4498
+ });
4499
+ if (legacy) return { legacy };
4500
+ return { baseline: parseCoverageBaseline(parsed) ?? void 0 };
3663
4501
  }
3664
4502
  function loadCapturedStates(configDir, config) {
3665
- const rel = config.statesManifest ?? DEFAULT_STATES_MANIFEST;
3666
- const abs = path7.resolve(configDir, rel);
4503
+ const rel = config.statesManifest ?? DEFAULT_STATES_MANIFEST2;
4504
+ const abs = path8.resolve(configDir, rel);
3667
4505
  let raw;
3668
4506
  try {
3669
- raw = fs4.readFileSync(abs, "utf8");
4507
+ raw = fs5.readFileSync(abs, "utf8");
3670
4508
  } catch {
3671
4509
  return void 0;
3672
4510
  }
@@ -3685,7 +4523,7 @@ function loadCapturedStates(configDir, config) {
3685
4523
  if (typeof c.route === "string") rec.route = c.route;
3686
4524
  captured.push(rec);
3687
4525
  }
3688
- return { captured };
4526
+ return typeof parsed.generatedAt === "string" ? { captured, generatedAt: parsed.generatedAt } : { captured };
3689
4527
  } catch {
3690
4528
  return void 0;
3691
4529
  }
@@ -3721,11 +4559,11 @@ function runOne(dc, opts) {
3721
4559
  gitContext
3722
4560
  });
3723
4561
  const typesRel = config.output;
3724
- const typesPath = path7.resolve(configDir, typesRel);
4562
+ const typesPath = path8.resolve(configDir, typesRel);
3725
4563
  const dataRel = dataOutputPath(config.output);
3726
- const dataPath = path7.resolve(configDir, dataRel);
4564
+ const dataPath = path8.resolve(configDir, dataRel);
3727
4565
  const locsRel = locsOutputPath(config.output);
3728
- const locsPath = path7.resolve(configDir, locsRel);
4566
+ const locsPath = path8.resolve(configDir, locsRel);
3729
4567
  let typesOnDisk = null;
3730
4568
  let dataOnDisk = null;
3731
4569
  let locsOnDisk = null;
@@ -3738,9 +4576,11 @@ function runOne(dc, opts) {
3738
4576
  (ef) => (ef.diagnostics?.length ?? 0) > 0
3739
4577
  );
3740
4578
  const capturedStates = opts.capturedStates ?? loadCapturedStates(configDir, config);
3741
- const pageBaseline = opts.pageBaseline ?? loadPageBaseline(configDir, config);
4579
+ const loadedBaseline = opts.coverageBaseline ? { baseline: opts.coverageBaseline } : loadCoverageBaseline(configDir, config);
4580
+ const coverageBaseline = loadedBaseline.baseline;
4581
+ const workspace = opts.workspace ?? resolveWorkspace({ configDir, config });
3742
4582
  let auditResult;
3743
- if (opts.check || opts.lint || resolved.diagnostics.length > 0 || hasExtractDiagnostics) {
4583
+ if (opts.check || opts.lint || resolved.diagnostics.length > 0 || hasExtractDiagnostics || loadedBaseline.legacy) {
3744
4584
  auditResult = audit({
3745
4585
  registry: resolved.registry,
3746
4586
  extracted,
@@ -3763,7 +4603,9 @@ function runOne(dc, opts) {
3763
4603
  locsOnDisk,
3764
4604
  locsOutputPath: locsRel,
3765
4605
  capturedStates,
3766
- pageBaseline
4606
+ coverageBaseline,
4607
+ baselineDiagnostic: loadedBaseline.legacy,
4608
+ workspace
3767
4609
  });
3768
4610
  }
3769
4611
  return {
@@ -3784,15 +4626,15 @@ function runOne(dc, opts) {
3784
4626
  }
3785
4627
  function readOrNull(p2) {
3786
4628
  try {
3787
- return fs4.readFileSync(p2, "utf8");
4629
+ return fs5.readFileSync(p2, "utf8");
3788
4630
  } catch {
3789
4631
  return null;
3790
4632
  }
3791
4633
  }
3792
4634
  function writeArtifact(artifact) {
3793
4635
  if (readOrNull(artifact.outputPath) === artifact.generated) return false;
3794
- fs4.mkdirSync(path7.dirname(artifact.outputPath), { recursive: true });
3795
- fs4.writeFileSync(artifact.outputPath, artifact.generated, "utf8");
4636
+ fs5.mkdirSync(path8.dirname(artifact.outputPath), { recursive: true });
4637
+ fs5.writeFileSync(artifact.outputPath, artifact.generated, "utf8");
3796
4638
  return true;
3797
4639
  }
3798
4640
  function writeScanResult(result) {
@@ -3801,14 +4643,45 @@ function writeScanResult(result) {
3801
4643
  const wroteLocs = writeArtifact(result.locs);
3802
4644
  return wroteTypes || wroteData || wroteLocs;
3803
4645
  }
3804
- function pageBaselinePath(result) {
4646
+ function coverageBaselinePath(result) {
3805
4647
  const rel = result.config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
3806
- return path7.resolve(result.configDir, rel);
4648
+ return path8.resolve(result.configDir, rel);
4649
+ }
4650
+
4651
+ // src/scanner/scan/states-merge.ts
4652
+ function keyOf(c) {
4653
+ return `${c.kind ?? "page"} ${c.entity} ${c.state}`;
4654
+ }
4655
+ function mergeStates(committed, partial) {
4656
+ const byKey = /* @__PURE__ */ new Map();
4657
+ for (const c of committed.captured ?? []) byKey.set(keyOf(c), c);
4658
+ const updated = [];
4659
+ const added = [];
4660
+ for (const c of partial.captured ?? []) {
4661
+ const k = keyOf(c);
4662
+ (byKey.has(k) ? updated : added).push(k);
4663
+ byKey.set(k, c);
4664
+ }
4665
+ const captured = [...byKey.values()].sort(
4666
+ (a, b) => a.entity.localeCompare(b.entity) || a.state.localeCompare(b.state)
4667
+ );
4668
+ return {
4669
+ manifest: {
4670
+ ...committed,
4671
+ captured,
4672
+ // The merged file is only as fresh as its OLDEST evidence: rows carried
4673
+ // over from the committed manifest were not re-verified by this run, so
4674
+ // claiming the partial's newer stamp would defeat the staleness check.
4675
+ ...committed.generatedAt ? { generatedAt: committed.generatedAt } : {}
4676
+ },
4677
+ updated: updated.sort(),
4678
+ added: added.sort()
4679
+ };
3807
4680
  }
3808
4681
 
3809
4682
  // src/scanner/scan/fix.ts
3810
- import * as fs5 from "fs";
3811
- import * as path8 from "path";
4683
+ import * as fs6 from "fs";
4684
+ import * as path9 from "path";
3812
4685
  function applyFixes(diagnostics) {
3813
4686
  const entries = [];
3814
4687
  for (const d of diagnostics) {
@@ -3841,7 +4714,7 @@ function applyFixes(diagnostics) {
3841
4714
  for (const [filePath, edits] of editsByFile) {
3842
4715
  let content;
3843
4716
  try {
3844
- content = fs5.readFileSync(filePath, "utf8");
4717
+ content = fs6.readFileSync(filePath, "utf8");
3845
4718
  } catch {
3846
4719
  for (const e of edits) e.entry.skippedReason ??= "file is unreadable";
3847
4720
  continue;
@@ -3861,22 +4734,22 @@ function applyFixes(diagnostics) {
3861
4734
  const edit = kept[i];
3862
4735
  content = content.slice(0, edit.start) + edit.replacement + content.slice(edit.end);
3863
4736
  }
3864
- if (kept.length > 0) fs5.writeFileSync(filePath, content, "utf8");
4737
+ if (kept.length > 0) fs6.writeFileSync(filePath, content, "utf8");
3865
4738
  }
3866
4739
  for (const entry of entries) {
3867
4740
  if (entry.skippedReason) continue;
3868
4741
  for (const create of entry.createFiles) {
3869
- if (fs5.existsSync(create.path)) {
3870
- entry.skippedReason = `${path8.basename(create.path)} already exists`;
4742
+ if (fs6.existsSync(create.path)) {
4743
+ entry.skippedReason = `${path9.basename(create.path)} already exists`;
3871
4744
  continue;
3872
4745
  }
3873
- fs5.mkdirSync(path8.dirname(create.path), { recursive: true });
3874
- fs5.writeFileSync(create.path, create.content, "utf8");
4746
+ fs6.mkdirSync(path9.dirname(create.path), { recursive: true });
4747
+ fs6.writeFileSync(create.path, create.content, "utf8");
3875
4748
  }
3876
4749
  if (entry.skippedReason) continue;
3877
4750
  for (const del of entry.deleteFiles) {
3878
4751
  try {
3879
- fs5.unlinkSync(del);
4752
+ fs6.unlinkSync(del);
3880
4753
  } catch {
3881
4754
  }
3882
4755
  }
@@ -4054,29 +4927,29 @@ function renameEntity(opts) {
4054
4927
  }
4055
4928
 
4056
4929
  // src/scanner/scan/cli.ts
4057
- import * as fs8 from "fs";
4058
- import * as path11 from "path";
4930
+ import * as fs9 from "fs";
4931
+ import * as path12 from "path";
4059
4932
 
4060
4933
  // src/scanner/scan/ai/index.ts
4061
4934
  import * as p from "@clack/prompts";
4062
4935
 
4063
4936
  // src/scanner/scan/ai/providers/claude.ts
4064
- import * as fs7 from "fs";
4065
- import * as path10 from "path";
4937
+ import * as fs8 from "fs";
4938
+ import * as path11 from "path";
4066
4939
 
4067
4940
  // src/scanner/scan/ai/templates.ts
4068
- import * as fs6 from "fs";
4069
- import * as path9 from "path";
4941
+ import * as fs7 from "fs";
4942
+ import * as path10 from "path";
4070
4943
  function templatePath(rel) {
4071
4944
  const candidates = [
4072
- path9.resolve(__dirname, "../../templates", rel),
4945
+ path10.resolve(__dirname, "../../templates", rel),
4073
4946
  // dist/cli/cli.cjs → ../../templates
4074
- path9.resolve(__dirname, "../../../../templates", rel)
4947
+ path10.resolve(__dirname, "../../../../templates", rel)
4075
4948
  // src/scanner/scan/ai → ../../../../templates
4076
4949
  ];
4077
4950
  for (const c of candidates) {
4078
4951
  try {
4079
- fs6.accessSync(c, fs6.constants.R_OK);
4952
+ fs7.accessSync(c, fs7.constants.R_OK);
4080
4953
  return c;
4081
4954
  } catch {
4082
4955
  continue;
@@ -4088,7 +4961,7 @@ function templatePath(rel) {
4088
4961
  );
4089
4962
  }
4090
4963
  function readTemplate(rel) {
4091
- return fs6.readFileSync(templatePath(rel), "utf8");
4964
+ return fs7.readFileSync(templatePath(rel), "utf8");
4092
4965
  }
4093
4966
 
4094
4967
  // src/scanner/scan/ai/providers/claude.ts
@@ -4119,8 +4992,8 @@ var claudeProvider = {
4119
4992
  async install({ cwd, force }) {
4120
4993
  const changes = [];
4121
4994
  for (const file of SKILL_FILES) {
4122
- const dest = path10.join(cwd, file.dest);
4123
- const exists = fs7.existsSync(dest);
4995
+ const dest = path11.join(cwd, file.dest);
4996
+ const exists = fs8.existsSync(dest);
4124
4997
  if (exists && !force) {
4125
4998
  changes.push({
4126
4999
  path: file.dest,
@@ -4129,56 +5002,56 @@ var claudeProvider = {
4129
5002
  });
4130
5003
  continue;
4131
5004
  }
4132
- fs7.mkdirSync(path10.dirname(dest), { recursive: true });
4133
- fs7.writeFileSync(dest, readTemplate(file.template));
5005
+ fs8.mkdirSync(path11.dirname(dest), { recursive: true });
5006
+ fs8.writeFileSync(dest, readTemplate(file.template));
4134
5007
  changes.push({
4135
5008
  path: file.dest,
4136
5009
  action: exists ? "overwritten" : "created"
4137
5010
  });
4138
5011
  }
4139
5012
  for (const rel of LEGACY_FILES) {
4140
- const dest = path10.join(cwd, rel);
4141
- if (fs7.existsSync(dest)) {
4142
- fs7.unlinkSync(dest);
5013
+ const dest = path11.join(cwd, rel);
5014
+ if (fs8.existsSync(dest)) {
5015
+ fs8.unlinkSync(dest);
4143
5016
  changes.push({ path: rel, action: "removed" });
4144
5017
  }
4145
5018
  }
4146
- cleanupEmpty(path10.join(cwd, ".claude/commands/uidex"));
4147
- cleanupEmpty(path10.join(cwd, ".claude/commands"));
4148
- cleanupEmpty(path10.join(cwd, ".claude/rules"));
5019
+ cleanupEmpty(path11.join(cwd, ".claude/commands/uidex"));
5020
+ cleanupEmpty(path11.join(cwd, ".claude/commands"));
5021
+ cleanupEmpty(path11.join(cwd, ".claude/rules"));
4149
5022
  return { changes };
4150
5023
  },
4151
5024
  async uninstall({ cwd }) {
4152
5025
  const changes = [];
4153
5026
  for (const file of SKILL_FILES) {
4154
- const dest = path10.join(cwd, file.dest);
4155
- if (!fs7.existsSync(dest)) {
5027
+ const dest = path11.join(cwd, file.dest);
5028
+ if (!fs8.existsSync(dest)) {
4156
5029
  changes.push({ path: file.dest, action: "skipped", reason: "absent" });
4157
5030
  continue;
4158
5031
  }
4159
- fs7.unlinkSync(dest);
5032
+ fs8.unlinkSync(dest);
4160
5033
  changes.push({ path: file.dest, action: "removed" });
4161
5034
  }
4162
- cleanupEmpty(path10.join(cwd, ".claude/skills/uidex/references"));
4163
- cleanupEmpty(path10.join(cwd, ".claude/skills/uidex"));
4164
- cleanupEmpty(path10.join(cwd, ".claude/skills"));
5035
+ cleanupEmpty(path11.join(cwd, ".claude/skills/uidex/references"));
5036
+ cleanupEmpty(path11.join(cwd, ".claude/skills/uidex"));
5037
+ cleanupEmpty(path11.join(cwd, ".claude/skills"));
4165
5038
  for (const rel of LEGACY_FILES) {
4166
- const dest = path10.join(cwd, rel);
4167
- if (fs7.existsSync(dest)) {
4168
- fs7.unlinkSync(dest);
5039
+ const dest = path11.join(cwd, rel);
5040
+ if (fs8.existsSync(dest)) {
5041
+ fs8.unlinkSync(dest);
4169
5042
  changes.push({ path: rel, action: "removed" });
4170
5043
  }
4171
5044
  }
4172
- cleanupEmpty(path10.join(cwd, ".claude/commands/uidex"));
4173
- cleanupEmpty(path10.join(cwd, ".claude/commands"));
4174
- cleanupEmpty(path10.join(cwd, ".claude/rules"));
5045
+ cleanupEmpty(path11.join(cwd, ".claude/commands/uidex"));
5046
+ cleanupEmpty(path11.join(cwd, ".claude/commands"));
5047
+ cleanupEmpty(path11.join(cwd, ".claude/rules"));
4175
5048
  return { changes };
4176
5049
  }
4177
5050
  };
4178
5051
  function cleanupEmpty(dir) {
4179
5052
  try {
4180
- const entries = fs7.readdirSync(dir);
4181
- if (entries.length === 0) fs7.rmdirSync(dir);
5053
+ const entries = fs8.readdirSync(dir);
5054
+ if (entries.length === 0) fs8.rmdirSync(dir);
4182
5055
  } catch {
4183
5056
  }
4184
5057
  }
@@ -4347,6 +5220,8 @@ async function run(opts) {
4347
5220
  return runScaffold(cwd, positional.slice(1), flags, writer);
4348
5221
  case "rename":
4349
5222
  return runRename(cwd, positional.slice(1), flags, writer);
5223
+ case "states":
5224
+ return runStates(cwd, positional.slice(1), writer);
4350
5225
  case "ai": {
4351
5226
  const result = await runAiCommand({
4352
5227
  cwd,
@@ -4375,22 +5250,23 @@ function helpText2() {
4375
5250
  " scan [flags] Run the scanner pipeline",
4376
5251
  " scaffold <widget|page|feature> <id> Emit a Playwright spec from declared acceptance",
4377
5252
  " rename <element|widget|region> <old-id> <new-id> Rename an id everywhere (DOM attr, flows, exports)",
5253
+ " states merge [partial] Fold a filtered run's *.partial.json into the committed manifest",
4378
5254
  " ai <install|uninstall|providers> Manage AI assistant integrations",
4379
5255
  "",
4380
5256
  "Flags:",
4381
5257
  " --check Verify the on-disk gen file matches a fresh scan; exit non-zero on drift (read-only)",
4382
5258
  " --lint Run lint diagnostics (missing annotations, scope leak, duplicate ids, coverage)",
4383
5259
  " --audit Equivalent to --check --lint (read-only)",
4384
- " --fix Apply machine-generated fixes (add data-uidex to unannotated interactive elements, drop empty names), then rescan and write",
4385
- " --update-baseline Regenerate the page-coverage baseline (uidex-coverage-baseline.json) from the current uncaptured routes",
5260
+ " --fix Apply machine-generated fixes (add data-uidex to unannotated interactive elements, drop empty names, migrate waivers/capture to acknowledge), then rescan and write",
5261
+ " --update-baseline Regenerate the coverage baseline (uidex-coverage-baseline.json) as `acknowledge` backlog entries",
4386
5262
  " --json Emit JSON diagnostics on stdout",
4387
5263
  " --force (scaffold) overwrite existing spec",
4388
5264
  ""
4389
5265
  ].join("\n");
4390
5266
  }
4391
5267
  function runInit(cwd, w) {
4392
- const configPath = path11.join(cwd, CONFIG_FILENAME);
4393
- if (fs8.existsSync(configPath)) {
5268
+ const configPath = path12.join(cwd, CONFIG_FILENAME);
5269
+ if (fs9.existsSync(configPath)) {
4394
5270
  w.err(`.uidex.json already exists at ${configPath}`);
4395
5271
  return w.result(1);
4396
5272
  }
@@ -4399,16 +5275,16 @@ function runInit(cwd, w) {
4399
5275
  sources: [{ rootDir: "src" }],
4400
5276
  output: "src/uidex.gen.ts"
4401
5277
  };
4402
- fs8.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
5278
+ fs9.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
4403
5279
  w.out(`Created ${configPath}`);
4404
- const gitignorePath = path11.join(cwd, ".gitignore");
5280
+ const gitignorePath = path12.join(cwd, ".gitignore");
4405
5281
  const entry = "*.gen.*";
4406
- if (fs8.existsSync(gitignorePath)) {
4407
- const existing = fs8.readFileSync(gitignorePath, "utf8");
5282
+ if (fs9.existsSync(gitignorePath)) {
5283
+ const existing = fs9.readFileSync(gitignorePath, "utf8");
4408
5284
  const hasEntry = existing.split("\n").some((line) => line.trim() === entry);
4409
5285
  if (!hasEntry) {
4410
5286
  const needsNewline = existing.length > 0 && !existing.endsWith("\n");
4411
- fs8.appendFileSync(
5287
+ fs9.appendFileSync(
4412
5288
  gitignorePath,
4413
5289
  `${needsNewline ? "\n" : ""}${entry}
4414
5290
  `,
@@ -4417,7 +5293,7 @@ function runInit(cwd, w) {
4417
5293
  w.out(`Appended ${entry} to ${gitignorePath}`);
4418
5294
  }
4419
5295
  } else {
4420
- fs8.writeFileSync(gitignorePath, `${entry}
5296
+ fs9.writeFileSync(gitignorePath, `${entry}
4421
5297
  `, "utf8");
4422
5298
  w.out(`Created ${gitignorePath} with ${entry}`);
4423
5299
  }
@@ -4456,20 +5332,25 @@ function runScanCommand(cwd, flags, w) {
4456
5332
  if (updateBaseline) {
4457
5333
  for (const r of results) {
4458
5334
  const manifest = r.capturedStates ?? { captured: [] };
4459
- const pageBaseline = computePageBaseline(r.registry, manifest);
4460
- const missingKinds = computeMissingKinds(r.registry, manifest);
4461
- const baseline = {
4462
- ...pageBaseline,
4463
- ...Object.keys(missingKinds).length > 0 ? { missingKinds } : {}
5335
+ const acknowledge = {};
5336
+ const merge = (from) => {
5337
+ for (const [route, scopes] of Object.entries(from)) {
5338
+ acknowledge[route] = { ...acknowledge[route] ?? {}, ...scopes };
5339
+ }
4464
5340
  };
4465
- const p2 = pageBaselinePath(r);
4466
- fs8.writeFileSync(p2, JSON.stringify(baseline, null, 2) + "\n");
4467
- const kindGaps = Object.values(missingKinds).reduce(
4468
- (n, ks) => n + ks.length,
5341
+ merge(computePageBacklog(r.registry, manifest));
5342
+ merge(computeMatrixBacklog(r.registry, manifest));
5343
+ const baseline = { version: 2, acknowledge };
5344
+ const p2 = coverageBaselinePath(r);
5345
+ fs9.writeFileSync(p2, serializeCoverageBaseline(baseline));
5346
+ const entries = Object.values(acknowledge);
5347
+ const pages = entries.filter((s) => s["*"]).length;
5348
+ const kindGaps = entries.reduce(
5349
+ (n, s) => n + Object.keys(s).filter((k) => k !== "*").length,
4469
5350
  0
4470
5351
  );
4471
5352
  w.out(
4472
- `Wrote ${p2} \u2014 ${baseline.uncapturedPages.length} uncaptured page(s), ${kindGaps} missing core-kind(s) baselined`
5353
+ `Wrote ${p2} \u2014 ${pages} uncaptured page(s), ${kindGaps} missing core-kind(s) acknowledged as backlog`
4473
5354
  );
4474
5355
  }
4475
5356
  return w.result(0);
@@ -4542,7 +5423,7 @@ function runScaffold(cwd, args, flags, w) {
4542
5423
  for (const r of results) {
4543
5424
  const entity = r.registry.get(scaffoldKind, id);
4544
5425
  if (!entity) continue;
4545
- const outDir = path11.resolve(r.configDir, "e2e");
5426
+ const outDir = path12.resolve(r.configDir, "e2e");
4546
5427
  const result = scaffoldSpec({
4547
5428
  registry: r.registry,
4548
5429
  kind: scaffoldKind,
@@ -4563,6 +5444,53 @@ function runScaffold(cwd, args, flags, w) {
4563
5444
  return w.result(1);
4564
5445
  }
4565
5446
  var RENAME_KINDS = /* @__PURE__ */ new Set(["element", "widget", "region"]);
5447
+ function runStates(cwd, args, w) {
5448
+ const [sub, explicitPartial] = args;
5449
+ if (sub !== "merge") {
5450
+ w.err("Usage: uidex states merge [partial-manifest]");
5451
+ return w.result(1);
5452
+ }
5453
+ const configs = discover({ cwd });
5454
+ if (configs.length === 0) {
5455
+ w.err(`No ${CONFIG_FILENAME} found under ${cwd}`);
5456
+ return w.result(1);
5457
+ }
5458
+ let merged = 0;
5459
+ for (const { configDir, config } of configs) {
5460
+ const rel = config.statesManifest ?? "uidex-states.json";
5461
+ const target = path12.resolve(configDir, rel);
5462
+ const partial = explicitPartial ? path12.resolve(cwd, explicitPartial) : target.replace(/\.json$/i, "") + ".partial.json";
5463
+ const committedRaw = readJson(target);
5464
+ const partialRaw = readJson(partial);
5465
+ if (!partialRaw) continue;
5466
+ if (!committedRaw) {
5467
+ w.err(`No committed manifest at ${rel}; nothing to merge into`);
5468
+ return w.result(1);
5469
+ }
5470
+ const result = mergeStates(committedRaw, partialRaw);
5471
+ fs9.writeFileSync(target, JSON.stringify(result.manifest, null, 2) + "\n");
5472
+ merged++;
5473
+ w.out(
5474
+ `Merged ${path12.basename(partial)} \u2192 ${rel}: ${result.added.length} added, ${result.updated.length} updated, ${result.manifest.captured.length} total`
5475
+ );
5476
+ for (const k of result.added) w.out(` + ${k}`);
5477
+ for (const k of result.updated) w.out(` ~ ${k}`);
5478
+ }
5479
+ if (merged === 0) {
5480
+ w.out("No partial manifest found; nothing to merge.");
5481
+ }
5482
+ return w.result(0);
5483
+ }
5484
+ function readJson(p2) {
5485
+ try {
5486
+ const parsed = JSON.parse(fs9.readFileSync(p2, "utf8"));
5487
+ if (!parsed || typeof parsed !== "object") return null;
5488
+ const m = parsed;
5489
+ return Array.isArray(m.captured) ? m : null;
5490
+ } catch {
5491
+ return null;
5492
+ }
5493
+ }
4566
5494
  function runRename(cwd, args, flags, w) {
4567
5495
  const [kind, oldId, newId] = args;
4568
5496
  if (!kind || !RENAME_KINDS.has(kind) || !oldId || !newId) {
@@ -4610,35 +5538,51 @@ function createWriter() {
4610
5538
  };
4611
5539
  }
4612
5540
  export {
5541
+ ACK_ALL,
5542
+ ACK_SCOPES,
4613
5543
  CONFIG_FILENAME,
4614
5544
  ConfigError,
4615
5545
  DEFAULT_CONVENTIONS,
5546
+ DIAGNOSTIC_CODES,
4616
5547
  applyFixes,
4617
5548
  audit,
5549
+ capturedElsewhere,
5550
+ checkComponentCoverage,
4618
5551
  checkPageCoverage,
4619
5552
  checkStateCoverage,
4620
5553
  checkStateMatrix,
4621
5554
  compileRoute,
4622
- computeMissingKinds,
4623
- computePageBaseline,
5555
+ computeMatrixBacklog,
5556
+ computePageBacklog,
5557
+ coverageBaselinePath,
4624
5558
  detectRoutes,
4625
5559
  discover,
4626
5560
  emit,
5561
+ entityAcknowledge,
4627
5562
  extract,
4628
5563
  extractUidexExports,
5564
+ findWorkspaceRoot,
4629
5565
  globToRegExp,
4630
- loadPageBaseline,
5566
+ isLegacyBaseline,
5567
+ loadCoverageBaseline,
4631
5568
  matchUrlToRoute,
4632
- pageBaselinePath,
5569
+ mergeStates,
5570
+ migrateBaseline,
5571
+ migrateLegacyBaseline,
5572
+ migrateLegacyFields,
4633
5573
  parseConfig,
5574
+ parseCoverageBaseline,
4634
5575
  pathToId,
4635
5576
  renameEntity,
4636
5577
  resolve2 as resolve,
4637
5578
  resolveGitContext,
5579
+ resolveWorkspace,
5580
+ routeAcknowledge,
4638
5581
  run as runCli,
4639
5582
  runScan,
4640
5583
  scaffoldSpec,
4641
5584
  scaffoldWidgetSpec,
5585
+ serializeCoverageBaseline,
4642
5586
  validateConfig,
4643
5587
  walk,
4644
5588
  writeScanResult