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
@@ -27,10 +27,12 @@ var ALLOWED_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
27
27
  "exclude",
28
28
  "output",
29
29
  "flows",
30
+ "resolve",
30
31
  "audit",
31
32
  "conventions",
32
33
  "statesManifest",
33
- "coverageBaseline"
34
+ "coverageBaseline",
35
+ "workspace"
34
36
  ]);
35
37
  var ALLOWED_SOURCE_KEYS = /* @__PURE__ */ new Set(["rootDir", "include", "exclude", "prefix"]);
36
38
  var ALLOWED_CONVENTIONS_KEYS = /* @__PURE__ */ new Set([
@@ -44,21 +46,74 @@ var ALLOWED_AUDIT_KEYS = /* @__PURE__ */ new Set([
44
46
  "scopeLeak",
45
47
  "coverage",
46
48
  "acceptance",
49
+ "surfaces",
50
+ "requireAcceptance",
47
51
  "states",
48
52
  "pageCoverage",
49
- "stateMatrix"
53
+ "stateMatrix",
54
+ "severity"
50
55
  ]);
56
+ var DIAGNOSTIC_CODES = [
57
+ "acceptance-ambiguous",
58
+ "acceptance-duplicate-id",
59
+ "acceptance-orphaned",
60
+ "acceptance-outdated",
61
+ "acceptance-uncovered",
62
+ "acknowledged-elsewhere",
63
+ "competing-uidex-export",
64
+ "component-state-uncaptured",
65
+ "core-kind",
66
+ "covers-missing-rev",
67
+ "duplicate-id",
68
+ "dynamic-attr",
69
+ "dynamic-flow-reference",
70
+ "feature-missing-acceptance",
71
+ "feature-surface-uncaptured",
72
+ "feature-surface-unreferenced",
73
+ "gen-missing",
74
+ "gen-stale",
75
+ "matrix-backlog",
76
+ "missing-element-annotation",
77
+ "missing-state-capture",
78
+ "orphan-state-capture",
79
+ "page-backlog",
80
+ "page-captured",
81
+ "page-no-capture",
82
+ "parse-error",
83
+ "placeholder-reason",
84
+ "prefer-well-known-file",
85
+ "rename",
86
+ "scope-leak",
87
+ "spread-attr",
88
+ "stale-acknowledgment",
89
+ "stale-page-baseline",
90
+ "uidex-export-ambiguous-kind",
91
+ "uidex-export-duplicate-field",
92
+ "uidex-export-duplicate-state",
93
+ "uidex-export-empty-name",
94
+ "uidex-export-invalid-field",
95
+ "uidex-export-invalid-literal",
96
+ "uidex-export-missing-kind",
97
+ "uidex-export-satisfies-mismatch",
98
+ "uidex-export-unknown-field",
99
+ "unknown-reference",
100
+ "widget-id-mismatch",
101
+ "widget-missing-acceptance",
102
+ "widget-missing-dom"
103
+ ];
104
+ var DIAGNOSTIC_CODE_SET = new Set(DIAGNOSTIC_CODES);
105
+ var SEVERITY_LEVELS = /* @__PURE__ */ new Set(["error", "warning", "info", "off"]);
51
106
  function fail(msg) {
52
107
  throw new ConfigError(`Invalid .uidex.json: ${msg}`);
53
108
  }
54
- function assertObject(value, path12) {
109
+ function assertObject(value, path14) {
55
110
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
56
- fail(`${path12} must be an object`);
111
+ fail(`${path14} must be an object`);
57
112
  }
58
113
  }
59
- function assertStringArray(value, path12) {
114
+ function assertStringArray(value, path14) {
60
115
  if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
61
- fail(`${path12} must be a string[]`);
116
+ fail(`${path14} must be a string[]`);
62
117
  }
63
118
  }
64
119
  function validateConfig(raw) {
@@ -112,11 +167,47 @@ function validateConfig(raw) {
112
167
  if (raw.coverageBaseline !== void 0 && typeof raw.coverageBaseline !== "string") {
113
168
  fail(`"coverageBaseline" must be a string`);
114
169
  }
170
+ if (raw.workspace !== void 0 && raw.workspace !== false && typeof raw.workspace !== "string") {
171
+ fail(`"workspace" must be a path string or false`);
172
+ }
173
+ if (raw.resolve !== void 0) {
174
+ assertObject(raw.resolve, "resolve");
175
+ for (const key of Object.keys(raw.resolve)) {
176
+ if (key !== "aliases") fail(`unknown key "${key}" in resolve`);
177
+ }
178
+ const aliases = raw.resolve.aliases;
179
+ if (aliases === void 0) {
180
+ fail(`resolve.aliases is required when "resolve" is present`);
181
+ }
182
+ assertObject(aliases, "resolve.aliases");
183
+ for (const [alias, target] of Object.entries(aliases)) {
184
+ if (alias.length === 0) fail(`resolve.aliases keys must be non-empty`);
185
+ if (typeof target !== "string") {
186
+ fail(`resolve.aliases["${alias}"] must be a string`);
187
+ }
188
+ }
189
+ }
115
190
  if (raw.audit !== void 0) {
116
191
  assertObject(raw.audit, "audit");
117
192
  for (const key of Object.keys(raw.audit)) {
118
193
  if (!ALLOWED_AUDIT_KEYS.has(key)) fail(`unknown key "${key}" in audit`);
119
194
  const v = raw.audit[key];
195
+ if (key === "severity") {
196
+ assertObject(v, "audit.severity");
197
+ for (const [code, level] of Object.entries(v)) {
198
+ if (!DIAGNOSTIC_CODE_SET.has(code)) {
199
+ fail(
200
+ `unknown diagnostic code "${code}" in audit.severity (a typo here would silently leave that gate at its default severity)`
201
+ );
202
+ }
203
+ if (typeof level !== "string" || !SEVERITY_LEVELS.has(level)) {
204
+ fail(
205
+ `audit.severity["${code}"] must be one of "error", "warning", "info", "off"`
206
+ );
207
+ }
208
+ }
209
+ continue;
210
+ }
120
211
  if (typeof v !== "boolean") fail(`audit.${key} must be a boolean`);
121
212
  }
122
213
  }
@@ -156,10 +247,12 @@ function validateConfig(raw) {
156
247
  exclude: raw.exclude,
157
248
  output: raw.output,
158
249
  flows: raw.flows,
250
+ resolve: raw.resolve,
159
251
  audit: raw.audit,
160
252
  conventions: raw.conventions,
161
253
  statesManifest: raw.statesManifest,
162
- coverageBaseline: raw.coverageBaseline
254
+ coverageBaseline: raw.coverageBaseline,
255
+ workspace: raw.workspace
163
256
  };
164
257
  }
165
258
  function parseConfig(json) {
@@ -209,9 +302,11 @@ function tryReadConfig(configPath) {
209
302
  function discover(options = {}) {
210
303
  const cwd = options.cwd ?? process.cwd();
211
304
  const maxDepth = options.maxDepth ?? MAX_DEPTH;
305
+ const all = options.all ?? false;
212
306
  const rootMatch = tryReadConfig(path.join(cwd, CONFIG_FILENAME));
213
- if (rootMatch) return [rootMatch];
307
+ if (rootMatch && !all) return [rootMatch];
214
308
  const results = [];
309
+ if (rootMatch) results.push(rootMatch);
215
310
  const queue = [[cwd, 0]];
216
311
  while (queue.length > 0) {
217
312
  const [dir, depth] = queue.shift();
@@ -315,6 +410,26 @@ function toPosix(p2) {
315
410
  function matchesAny(rel, patterns) {
316
411
  return patterns.some((g) => globToRegExp(g).test(rel));
317
412
  }
413
+ function createSourceMatcher(sources, options) {
414
+ const { cwd, globalExcludes = [], includeTests = false } = options;
415
+ const baseDefaults = includeTests ? BASE_EXCLUDES : DEFAULT_EXCLUDES;
416
+ const compiled = sources.map((source) => ({
417
+ absRoot: path2.resolve(cwd, source.rootDir),
418
+ includes: source.include?.length ? source.include : DEFAULT_INCLUDES,
419
+ excludes: [...baseDefaults, ...globalExcludes, ...source.exclude ?? []]
420
+ }));
421
+ return (absFile) => {
422
+ const abs = path2.resolve(absFile);
423
+ return compiled.some(({ absRoot, includes, excludes }) => {
424
+ const rel = toPosix(path2.relative(absRoot, abs));
425
+ if (rel.length === 0 || rel.startsWith("../") || path2.isAbsolute(rel)) {
426
+ return false;
427
+ }
428
+ if (matchesAny(rel, excludes)) return false;
429
+ return matchesAny(rel, includes);
430
+ });
431
+ };
432
+ }
318
433
  function walk(sources, options) {
319
434
  const { cwd, globalExcludes = [], includeTests = false } = options;
320
435
  const out2 = [];
@@ -648,6 +763,109 @@ function createRegistry() {
648
763
  };
649
764
  }
650
765
 
766
+ // src/scanner/scan/acknowledge.ts
767
+ var ACK_ALL = "*";
768
+ var ACK_SCOPES = [ACK_ALL, ...CORE_STATE_KINDS];
769
+ var COMPONENT_ACK_SCOPES = [ACK_ALL];
770
+ var AUTHORABLE_ACK_TYPES = [
771
+ "impossible",
772
+ "backlog"
773
+ ];
774
+ var CORE_SET = new Set(CORE_STATE_KINDS);
775
+ function isCoreKind(scope) {
776
+ return CORE_SET.has(scope);
777
+ }
778
+ function isPlaceholderReason(reason) {
779
+ return typeof reason === "string" && /^\s*TODO\b/i.test(reason);
780
+ }
781
+ function describeAck(a) {
782
+ if (a.type === "captured-elsewhere") {
783
+ return a.by ? `captured-elsewhere (${a.by})` : "captured-elsewhere";
784
+ }
785
+ return a.reason ? `${a.type} ("${a.reason}")` : a.type;
786
+ }
787
+ function originHint(a, page) {
788
+ if (a.origin === "baseline") {
789
+ return "Prune it with `uidex scan --update-baseline` so the backlog reflects reality.";
790
+ }
791
+ if (a.origin === "workspace") {
792
+ return "This is computed from the workspace; it resolves itself once the sibling capture changes.";
793
+ }
794
+ const where = page ? ` on page "${page}"` : "";
795
+ return `Remove the \`acknowledge.${a.scope}\` entry${where} \u2014 a capture contradicts it.`;
796
+ }
797
+ function toResolved(map, origin, into) {
798
+ for (const [scope, ack] of Object.entries(map ?? {})) {
799
+ into.set(scope, { ...ack, scope, origin });
800
+ }
801
+ }
802
+ function routeAcknowledge(registry, baseline, route, page) {
803
+ const out2 = /* @__PURE__ */ new Map();
804
+ toResolved(baseline?.acknowledge?.[route], "baseline", out2);
805
+ if (page) {
806
+ toResolved(registry.get("page", page)?.meta?.acknowledge, "source", out2);
807
+ }
808
+ return out2;
809
+ }
810
+ function entityAcknowledge(meta, capturedElsewhereBy) {
811
+ const source = meta?.acknowledge?.[ACK_ALL];
812
+ if (source) return { ...source, scope: ACK_ALL, origin: "source" };
813
+ if (capturedElsewhereBy) {
814
+ return {
815
+ type: "captured-elsewhere",
816
+ by: capturedElsewhereBy,
817
+ scope: ACK_ALL,
818
+ origin: "workspace"
819
+ };
820
+ }
821
+ return void 0;
822
+ }
823
+ function readAck(raw) {
824
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
825
+ const r = raw;
826
+ if (r.type !== "impossible" && r.type !== "backlog") return void 0;
827
+ const out2 = { type: r.type };
828
+ if (typeof r.reason === "string" && r.reason.length > 0) out2.reason = r.reason;
829
+ return out2;
830
+ }
831
+ function parseCoverageBaseline(raw) {
832
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
833
+ const r = raw;
834
+ const ackRaw = r.acknowledge;
835
+ if (!ackRaw || typeof ackRaw !== "object" || Array.isArray(ackRaw))
836
+ return null;
837
+ const acknowledge = {};
838
+ for (const [route, scopesRaw] of Object.entries(
839
+ ackRaw
840
+ )) {
841
+ if (!scopesRaw || typeof scopesRaw !== "object" || Array.isArray(scopesRaw))
842
+ continue;
843
+ const scopes = {};
844
+ for (const [scope, ackVal] of Object.entries(
845
+ scopesRaw
846
+ )) {
847
+ if (scope !== ACK_ALL && !isCoreKind(scope)) continue;
848
+ const ack = readAck(ackVal);
849
+ if (ack) scopes[scope] = ack;
850
+ }
851
+ if (Object.keys(scopes).length > 0) acknowledge[route] = scopes;
852
+ }
853
+ return { version: 2, acknowledge };
854
+ }
855
+ function serializeCoverageBaseline(baseline) {
856
+ const routes = Object.keys(baseline.acknowledge).sort();
857
+ const ordered = {};
858
+ for (const route of routes) {
859
+ const scopes = baseline.acknowledge[route];
860
+ const sorted = {};
861
+ for (const scope of ACK_SCOPES) {
862
+ if (scopes[scope]) sorted[scope] = scopes[scope];
863
+ }
864
+ ordered[route] = sorted;
865
+ }
866
+ return JSON.stringify({ version: 2, acknowledge: ordered }, null, 2) + "\n";
867
+ }
868
+
651
869
  // src/scanner/scan/extract-uidex-export.ts
652
870
  var KIND_DISCRIMINATORS = [
653
871
  "page",
@@ -666,33 +884,44 @@ var ALLOWED_FIELDS = {
666
884
  "widgets",
667
885
  "acceptance",
668
886
  "states",
669
- "capture",
670
- "waivers",
887
+ "acknowledge",
671
888
  "description"
672
889
  ]),
673
- // `capture` / `waivers` are route-scoped, and only a page maps to a route — so
674
- // they are page-only (the gates read them off the page). A feature/widget that
675
- // sets them gets an `uidex-export-unknown-field` error rather than a silent no-op.
890
+ // A page's `acknowledge` accepts both scope shapes: `"*"` (the whole route)
891
+ // and a core kind (one cell of its matrix). A feature/widget has no route, so
892
+ // the core-kind matrix is meaningless there and only `"*"` is accepted —
893
+ // giving a component a scoped opt-out instead of forcing you to delete its
894
+ // whole `states` block.
676
895
  feature: /* @__PURE__ */ new Set([
677
896
  "feature",
678
897
  "name",
679
898
  "features",
899
+ "surfaces",
680
900
  "acceptance",
681
901
  "states",
902
+ "acknowledge",
682
903
  "description"
683
904
  ]),
684
905
  primitive: /* @__PURE__ */ new Set(["primitive", "name", "description"]),
685
- widget: /* @__PURE__ */ new Set(["widget", "name", "acceptance", "states", "description"]),
906
+ widget: /* @__PURE__ */ new Set([
907
+ "widget",
908
+ "name",
909
+ "acceptance",
910
+ "states",
911
+ "acknowledge",
912
+ "description"
913
+ ]),
686
914
  region: /* @__PURE__ */ new Set(["region", "name", "description"]),
687
915
  flow: /* @__PURE__ */ new Set(["flow", "notFlow", "name", "description"])
688
916
  };
917
+ var ACK_FIELDS = /* @__PURE__ */ new Set(["type", "reason"]);
918
+ var ACCEPTANCE_FIELDS = /* @__PURE__ */ new Set(["id", "rev", "text"]);
689
919
  var STATE_FIELDS = /* @__PURE__ */ new Set([
690
920
  "name",
691
921
  "kind",
692
922
  "acceptance",
693
923
  "description"
694
924
  ]);
695
- var CORE_STATE_KINDS2 = new Set(CORE_STATE_KINDS);
696
925
  var STATE_KINDS = /* @__PURE__ */ new Set([...CORE_STATE_KINDS, "variant"]);
697
926
  var FALSEABLE = /* @__PURE__ */ new Set([
698
927
  "page",
@@ -936,7 +1165,8 @@ function objectLit(node, content, p2, pos, span) {
936
1165
  key,
937
1166
  value,
938
1167
  keyPos,
939
- span: removalSpan(content, prop.start, prop.end)
1168
+ span: removalSpan(content, prop.start, prop.end),
1169
+ propSpan: { start: prop.start, end: prop.end }
940
1170
  });
941
1171
  }
942
1172
  return { kind: "object", entries, pos, span };
@@ -1057,7 +1287,8 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
1057
1287
  } else {
1058
1288
  id = readIdField(idValue, kind, idField);
1059
1289
  }
1060
- const acceptance = readStringArrayField(byKey, "acceptance")?.values;
1290
+ const criterionIds = /* @__PURE__ */ new Set();
1291
+ const acceptance = readAcceptanceField(byKey, criterionIds);
1061
1292
  const description = readStringField(byKey, "description");
1062
1293
  const name = readStringField(byKey, "name");
1063
1294
  if (name === "") {
@@ -1083,9 +1314,10 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
1083
1314
  }
1084
1315
  const featuresField = kind === "page" || kind === "feature" ? readStringArrayField(byKey, "features") : void 0;
1085
1316
  const widgetsField = kind === "page" ? readStringArrayField(byKey, "widgets") : void 0;
1086
- const states = kind === "page" || kind === "feature" || kind === "widget" ? readStatesField(byKey) : void 0;
1087
- const capture = kind === "page" ? readBooleanField(byKey, "capture") : void 0;
1088
- const waivers = kind === "page" ? readWaiversField(byKey) : void 0;
1317
+ const surfacesField = kind === "feature" ? readStringArrayField(byKey, "surfaces") : void 0;
1318
+ const states = kind === "page" || kind === "feature" || kind === "widget" ? readStatesField(byKey, criterionIds) : void 0;
1319
+ const stateBearing = kind === "page" || kind === "feature" || kind === "widget";
1320
+ const acknowledge = stateBearing ? readAcknowledgeField(byKey, kind === "page") : void 0;
1089
1321
  const notFlow = kind === "flow" && discriminator === "notFlow" ? true : void 0;
1090
1322
  const metadata = {
1091
1323
  source: "ts-export",
@@ -1108,16 +1340,26 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
1108
1340
  metadata.widgets = widgetsField.values;
1109
1341
  metadata.widgetSpans = widgetsField.spans;
1110
1342
  }
1343
+ if (surfacesField) {
1344
+ metadata.surfaces = surfacesField.values;
1345
+ metadata.surfaceSpans = surfacesField.spans;
1346
+ }
1111
1347
  if (states?.length) metadata.states = states;
1112
- if (capture !== void 0) metadata.capture = capture;
1113
- if (waivers && Object.keys(waivers).length > 0) metadata.waivers = waivers;
1348
+ if (acknowledge && Object.keys(acknowledge).length > 0) {
1349
+ metadata.acknowledge = acknowledge;
1350
+ }
1114
1351
  if (notFlow) metadata.notFlow = true;
1115
1352
  if (typeof id === "string" && idValue.kind === "string") {
1116
1353
  metadata.idSpan = idValue.span;
1117
1354
  }
1118
1355
  const fieldSpans = {};
1119
- 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
+ }
1120
1361
  metadata.fieldSpans = fieldSpans;
1362
+ metadata.fieldPropSpans = fieldPropSpans;
1121
1363
  return metadata;
1122
1364
  }
1123
1365
  function readIdField(value, kind, fieldName) {
@@ -1147,47 +1389,185 @@ function readIdField(value, kind, fieldName) {
1147
1389
  value.pos
1148
1390
  );
1149
1391
  }
1150
- function readWaiversField(byKey) {
1151
- const entry = byKey.get("waivers");
1392
+ function readAcknowledgeField(byKey, isPage) {
1393
+ const entry = byKey.get("acknowledge");
1152
1394
  if (!entry) return void 0;
1153
1395
  if (entry.value.kind !== "object") {
1154
1396
  throw new ExtractError(
1155
1397
  "uidex-export-invalid-field",
1156
- "`waivers` must be an object mapping a core kind to a reason.",
1398
+ '`acknowledge` must be an object mapping a scope (`"*"` or a core kind) to `{ type, reason? }`.',
1157
1399
  entry.value.pos
1158
1400
  );
1159
1401
  }
1160
- const waivers = {};
1402
+ const out2 = {};
1161
1403
  for (const e of entry.value.entries) {
1162
- if (!CORE_STATE_KINDS2.has(e.key)) {
1404
+ const scopes = isPage ? ACK_SCOPES : COMPONENT_ACK_SCOPES;
1405
+ if (!scopes.includes(e.key)) {
1163
1406
  throw new ExtractError(
1164
1407
  "uidex-export-invalid-field",
1165
- `waiver key "${e.key}" is not a core kind; only ${[...CORE_STATE_KINDS2].join(", ")} participate in the matrix.`,
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)"}.`,
1166
1409
  e.keyPos
1167
1410
  );
1168
1411
  }
1169
- if (e.value.kind !== "string" || e.value.value.length === 0) {
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) {
1170
1432
  throw new ExtractError(
1171
1433
  "uidex-export-invalid-field",
1172
- `waiver "${e.key}" needs a non-empty reason (why the route cannot render this kind).`,
1434
+ `acknowledge "${e.key}" needs a \`type\`: ${AUTHORABLE_ACK_TYPES.map((t) => `"${t}"`).join(" or ")}.`,
1173
1435
  e.value.pos
1174
1436
  );
1175
1437
  }
1176
- waivers[e.key] = e.value.value;
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 };
1177
1463
  }
1178
- return waivers;
1464
+ return out2;
1179
1465
  }
1180
- function readBooleanField(byKey, name) {
1181
- const entry = byKey.get(name);
1466
+ function acceptanceSlug(text) {
1467
+ return text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).slice(0, 8).join("-");
1468
+ }
1469
+ function readAcceptanceField(byKey, seen) {
1470
+ const entry = byKey.get("acceptance");
1182
1471
  if (!entry) return void 0;
1183
- if (entry.value.kind !== "boolean") {
1472
+ return readAcceptanceItems(entry, seen);
1473
+ }
1474
+ function readAcceptanceItems(entry, seen) {
1475
+ if (entry.value.kind !== "array") {
1184
1476
  throw new ExtractError(
1185
1477
  "uidex-export-invalid-field",
1186
- `\`${name}\` must be a boolean.`,
1478
+ "`acceptance` must be an array of strings or `{ id, rev?, text }` objects.",
1187
1479
  entry.value.pos
1188
1480
  );
1189
1481
  }
1190
- return entry.value.value;
1482
+ const criteria = [];
1483
+ for (const item of entry.value.items) {
1484
+ let criterion;
1485
+ if (item.kind === "string") {
1486
+ if (item.value.length === 0) {
1487
+ throw new ExtractError(
1488
+ "uidex-export-invalid-field",
1489
+ "An `acceptance` entry is an empty string.",
1490
+ item.pos
1491
+ );
1492
+ }
1493
+ const id = acceptanceSlug(item.value);
1494
+ if (!id) {
1495
+ throw new ExtractError(
1496
+ "uidex-export-invalid-field",
1497
+ `Acceptance text "${item.value}" contains no alphanumeric characters to derive an id from; use the object form with an explicit \`id\`.`,
1498
+ item.pos
1499
+ );
1500
+ }
1501
+ criterion = { id, rev: 1, text: item.value };
1502
+ } else if (item.kind === "object") {
1503
+ const fieldByKey = /* @__PURE__ */ new Map();
1504
+ for (const e of item.entries) {
1505
+ if (!ACCEPTANCE_FIELDS.has(e.key)) {
1506
+ throw new ExtractError(
1507
+ "uidex-export-invalid-field",
1508
+ `Unknown field "${e.key}" in an \`acceptance\` entry. Allowed: ${[
1509
+ ...ACCEPTANCE_FIELDS
1510
+ ].sort().join(", ")}.`,
1511
+ e.value.pos
1512
+ );
1513
+ }
1514
+ fieldByKey.set(e.key, e);
1515
+ }
1516
+ const id = readStringField(fieldByKey, "id");
1517
+ if (id === void 0 || id.length === 0) {
1518
+ throw new ExtractError(
1519
+ "uidex-export-invalid-field",
1520
+ "An object `acceptance` entry needs a non-empty string `id`.",
1521
+ item.pos
1522
+ );
1523
+ }
1524
+ if (id.includes("@")) {
1525
+ throw new ExtractError(
1526
+ "uidex-export-invalid-field",
1527
+ `acceptance id "${id}" must not contain "@" \u2014 it is reserved for the \`@<rev>\` pin in \`uidexCovers\` claims.`,
1528
+ item.pos
1529
+ );
1530
+ }
1531
+ const text = readStringField(fieldByKey, "text");
1532
+ if (text === void 0 || text.length === 0) {
1533
+ throw new ExtractError(
1534
+ "uidex-export-invalid-field",
1535
+ `acceptance "${id}" needs a non-empty string \`text\`.`,
1536
+ item.pos
1537
+ );
1538
+ }
1539
+ let rev = 1;
1540
+ const revEntry = fieldByKey.get("rev");
1541
+ if (revEntry) {
1542
+ if (revEntry.value.kind !== "number" || !Number.isInteger(revEntry.value.value) || revEntry.value.value < 1) {
1543
+ throw new ExtractError(
1544
+ "uidex-export-invalid-field",
1545
+ `acceptance "${id}" \`rev\` must be a positive integer.`,
1546
+ revEntry.value.pos
1547
+ );
1548
+ }
1549
+ rev = revEntry.value.value;
1550
+ }
1551
+ criterion = { id, rev, text };
1552
+ } else {
1553
+ throw new ExtractError(
1554
+ "uidex-export-invalid-field",
1555
+ "`acceptance` items must be strings or `{ id, rev?, text }` objects.",
1556
+ item.pos
1557
+ );
1558
+ }
1559
+ if (seen.has(criterion.id)) {
1560
+ throw new ExtractError(
1561
+ "acceptance-duplicate-id",
1562
+ `Duplicate acceptance criterion id "${criterion.id}"; each criterion id must be unique within the entity.`,
1563
+ item.pos,
1564
+ "Give the criterion an explicit distinct `id` (object form) \u2014 two string criteria can slug to the same id."
1565
+ );
1566
+ }
1567
+ seen.add(criterion.id);
1568
+ criteria.push(criterion);
1569
+ }
1570
+ return criteria;
1191
1571
  }
1192
1572
  function readStringField(byKey, name) {
1193
1573
  const entry = byKey.get(name);
@@ -1226,7 +1606,7 @@ function readStringArrayField(byKey, name) {
1226
1606
  }
1227
1607
  return { values, spans };
1228
1608
  }
1229
- function readStatesField(byKey) {
1609
+ function readStatesField(byKey, criterionIds) {
1230
1610
  const entry = byKey.get("states");
1231
1611
  if (!entry) return void 0;
1232
1612
  if (entry.value.kind !== "array") {
@@ -1276,8 +1656,11 @@ function readStatesField(byKey) {
1276
1656
  }
1277
1657
  state.kind = kind;
1278
1658
  }
1279
- const acceptance = readStringArrayField(fieldByKey, "acceptance")?.values;
1280
- if (acceptance) state.acceptance = acceptance;
1659
+ const accEntry = fieldByKey.get("acceptance");
1660
+ if (accEntry) {
1661
+ const acceptance = readAcceptanceItems(accEntry, criterionIds);
1662
+ if (acceptance.length > 0) state.acceptance = acceptance;
1663
+ }
1281
1664
  const description = readStringField(fieldByKey, "description");
1282
1665
  if (description) state.description = description;
1283
1666
  } else {
@@ -1333,20 +1716,21 @@ function isNode2(value) {
1333
1716
  }
1334
1717
 
1335
1718
  // src/scanner/scan/flow-facts.ts
1336
- function collectFlowFacts(parsed, content) {
1719
+ function collectFlowFacts(parsed, content, displayPath) {
1337
1720
  if (parsed.program === null || !content.includes("test.describe")) {
1338
- return [];
1721
+ return { facts: [], diagnostics: [] };
1339
1722
  }
1340
1723
  const facts = [];
1724
+ const diagnostics = [];
1341
1725
  walkAst(parsed.program, (node) => {
1342
1726
  if (node.type !== "CallExpression") return void 0;
1343
- const fact = readTaggedDescribe(node, parsed);
1727
+ const fact = readTaggedDescribe(node, parsed, diagnostics, displayPath);
1344
1728
  if (fact) facts.push(fact);
1345
1729
  return void 0;
1346
1730
  });
1347
- return facts;
1731
+ return { facts, diagnostics };
1348
1732
  }
1349
- function readTaggedDescribe(call, parsed) {
1733
+ function readTaggedDescribe(call, parsed, diagnostics, displayPath) {
1350
1734
  const callee = call.callee;
1351
1735
  if (!callee || callee.type !== "MemberExpression" || !isIdentifier(callee.object, "test") || !isIdentifier(callee.property, "describe")) {
1352
1736
  return null;
@@ -1357,13 +1741,83 @@ function readTaggedDescribe(call, parsed) {
1357
1741
  if (!hasFlowTag(args[1])) return null;
1358
1742
  const body = args[2];
1359
1743
  const { calls, dynamicCalls } = body ? collectUidexCalls(body, parsed) : { calls: [], dynamicCalls: [] };
1744
+ const covers = body ? collectCoversClaims(body, parsed, title, diagnostics, displayPath) : [];
1360
1745
  return {
1361
1746
  title,
1362
1747
  line: parsed.lineAt(call.start),
1363
1748
  calls,
1364
- ...dynamicCalls.length > 0 ? { dynamicCalls } : {}
1749
+ ...dynamicCalls.length > 0 ? { dynamicCalls } : {},
1750
+ ...covers.length > 0 ? { covers } : {}
1365
1751
  };
1366
1752
  }
1753
+ function collectCoversClaims(body, parsed, flowTitle, diagnostics, displayPath) {
1754
+ const covers = [];
1755
+ walkAst(body, (node) => {
1756
+ if (node.type !== "CallExpression") return void 0;
1757
+ if (!isIdentifier(node.callee, "uidexCovers")) return void 0;
1758
+ const line = parsed.lineAt(node.start);
1759
+ const args = node.arguments ?? [];
1760
+ const entity = stringLiteralValue(args[0]);
1761
+ if (entity === null) {
1762
+ diagnostics.push({
1763
+ code: "covers-missing-rev",
1764
+ severity: "error",
1765
+ message: `\`uidexCovers(\u2026)\` in flow "${flowTitle}" needs a static string entity id as its first argument.`,
1766
+ file: displayPath,
1767
+ line,
1768
+ hint: 'Write `uidexCovers("<entityId>", "<criterionId>@<rev>")` with string literals.'
1769
+ });
1770
+ return void 0;
1771
+ }
1772
+ if (args.length < 2) {
1773
+ diagnostics.push({
1774
+ code: "covers-missing-rev",
1775
+ severity: "error",
1776
+ message: `\`uidexCovers("${entity}")\` in flow "${flowTitle}" names no criterion; a claim covers nothing without at least one \`"<criterionId>@<rev>"\` argument.`,
1777
+ file: displayPath,
1778
+ line,
1779
+ hint: 'List the criteria this flow verifies: `uidexCovers("<entityId>", "<criterionId>@<rev>", ...)`.'
1780
+ });
1781
+ return void 0;
1782
+ }
1783
+ for (const arg of args.slice(1)) {
1784
+ const claimLine = parsed.lineAt(arg.start);
1785
+ const raw = stringLiteralValue(arg);
1786
+ const at = raw === null ? -1 : raw.lastIndexOf("@");
1787
+ if (raw === null || at <= 0) {
1788
+ diagnostics.push({
1789
+ code: "covers-missing-rev",
1790
+ severity: "error",
1791
+ message: `\`uidexCovers(\u2026)\` claim ${raw === null ? "" : `"${raw}" `}in flow "${flowTitle}" is missing its \`@<rev>\` pin.`,
1792
+ file: displayPath,
1793
+ line: claimLine,
1794
+ hint: 'Pin the criterion rev \u2014 `uidexCovers("<entityId>", "<criterionId>@<rev>")` \u2014 so a later rev bump surfaces this claim as outdated.'
1795
+ });
1796
+ continue;
1797
+ }
1798
+ const revStr = raw.slice(at + 1);
1799
+ if (!/^[1-9]\d*$/.test(revStr)) {
1800
+ diagnostics.push({
1801
+ code: "covers-missing-rev",
1802
+ severity: "error",
1803
+ message: `\`uidexCovers(\u2026)\` claim "${raw}" in flow "${flowTitle}" has an invalid rev "@${revStr}"; the rev must be a positive integer (no leading zeros).`,
1804
+ file: displayPath,
1805
+ line: claimLine,
1806
+ hint: 'Write the pin as `"<criterionId>@<rev>"` with a positive integer rev, e.g. `"gated@1"`.'
1807
+ });
1808
+ continue;
1809
+ }
1810
+ covers.push({
1811
+ entity,
1812
+ criterion: raw.slice(0, at),
1813
+ rev: Number(revStr),
1814
+ line: claimLine
1815
+ });
1816
+ }
1817
+ return void 0;
1818
+ });
1819
+ return covers;
1820
+ }
1367
1821
  function hasFlowTag(node) {
1368
1822
  if (!node || node.type !== "ObjectExpression") return false;
1369
1823
  for (const prop of node.properties ?? []) {
@@ -1755,11 +2209,13 @@ function extract(files) {
1755
2209
  const out2 = { file, annotations: [] };
1756
2210
  out2.annotations = extractOne(file, parsed, out2);
1757
2211
  if (exports.length > 0) out2.metadata = exports;
2212
+ const flowFacts = collectFlowFacts(parsed, file.content, file.displayPath);
2213
+ diagnostics.push(...flowFacts.diagnostics);
1758
2214
  if (diagnostics.length > 0) out2.diagnostics = diagnostics;
1759
- const flows = collectFlowFacts(parsed, file.content);
1760
- if (flows.length > 0) out2.flows = flows;
2215
+ if (flowFacts.facts.length > 0) out2.flows = flowFacts.facts;
1761
2216
  const imports = collectImportFacts(parsed);
1762
2217
  if (imports.length > 0) out2.imports = imports;
2218
+ if (containsJsx(parsed)) out2.hasJsx = true;
1763
2219
  return out2;
1764
2220
  });
1765
2221
  }
@@ -1784,6 +2240,32 @@ function extractOne(file, parsed, out2) {
1784
2240
  }
1785
2241
  return annotations;
1786
2242
  }
2243
+ function containsJsx(parsed) {
2244
+ if (parsed.program === null) return false;
2245
+ let found = false;
2246
+ walkAst(parsed.program, (node) => {
2247
+ if (node.type === "JSXElement" || node.type === "JSXFragment") {
2248
+ found = true;
2249
+ return false;
2250
+ }
2251
+ if (node.type === "CallExpression" && isCreateElementCallee(node.callee)) {
2252
+ found = true;
2253
+ return false;
2254
+ }
2255
+ return void 0;
2256
+ });
2257
+ return found;
2258
+ }
2259
+ function isCreateElementCallee(callee) {
2260
+ const node = callee;
2261
+ if (!node) return false;
2262
+ if (node.type === "Identifier") return String(node.name) === "createElement";
2263
+ if (node.type === "MemberExpression") {
2264
+ const prop = node.property;
2265
+ return prop?.type === "Identifier" && String(prop.name) === "createElement";
2266
+ }
2267
+ return false;
2268
+ }
1787
2269
  function collectImportFacts(parsed) {
1788
2270
  if (parsed.program === null) return [];
1789
2271
  const out2 = [];
@@ -1816,6 +2298,21 @@ function collectImportFacts(parsed) {
1816
2298
  names
1817
2299
  });
1818
2300
  }
2301
+ walkAst(parsed.program, (node) => {
2302
+ if (node.type !== "ImportExpression") return void 0;
2303
+ const source = node.source;
2304
+ if (!source || source.type !== "Literal") return void 0;
2305
+ if (typeof source.value !== "string") return void 0;
2306
+ out2.push({
2307
+ specifier: source.value,
2308
+ line: parsed.lineAt(node.start),
2309
+ span: { start: node.start, end: node.end },
2310
+ isTypeOnly: false,
2311
+ names: [],
2312
+ dynamic: true
2313
+ });
2314
+ return void 0;
2315
+ });
1819
2316
  return out2;
1820
2317
  }
1821
2318
 
@@ -1987,6 +2484,7 @@ function buildMetaFromExport(exp) {
1987
2484
  if (exp.acceptance?.length) meta.acceptance = exp.acceptance;
1988
2485
  if (exp.features?.length) meta.features = exp.features;
1989
2486
  if (exp.widgets?.length) meta.widgets = exp.widgets;
2487
+ if (exp.surfaces?.length) meta.surfaces = exp.surfaces;
1990
2488
  if (exp.states?.length) {
1991
2489
  meta.states = exp.states.map((s) => ({
1992
2490
  name: s.name,
@@ -1995,9 +2493,8 @@ function buildMetaFromExport(exp) {
1995
2493
  ...s.description ? { description: s.description } : {}
1996
2494
  }));
1997
2495
  }
1998
- if (exp.capture === false) meta.capture = false;
1999
- if (exp.waivers && Object.keys(exp.waivers).length > 0) {
2000
- meta.waivers = exp.waivers;
2496
+ if (exp.acknowledge && Object.keys(exp.acknowledge).length > 0) {
2497
+ meta.acknowledge = exp.acknowledge;
2001
2498
  }
2002
2499
  return Object.keys(meta).length > 0 ? meta : void 0;
2003
2500
  }
@@ -2342,6 +2839,45 @@ function resolve2(ctx) {
2342
2839
  };
2343
2840
  registry.add(element);
2344
2841
  }
2842
+ const ancestorsById = /* @__PURE__ */ new Map();
2843
+ const fileById = /* @__PURE__ */ new Map();
2844
+ for (const a of allAnnotations) {
2845
+ if (!fileById.has(a.id)) fileById.set(a.id, a.file);
2846
+ if (a.ancestors?.length) {
2847
+ const prev = ancestorsById.get(a.id) ?? [];
2848
+ ancestorsById.set(a.id, [...prev, ...a.ancestors.map((x) => x.id)]);
2849
+ }
2850
+ }
2851
+ const ownersByFile = /* @__PURE__ */ new Map();
2852
+ const addOwner = (file, id) => {
2853
+ ownersByFile.set(file, [...ownersByFile.get(file) ?? [], id]);
2854
+ };
2855
+ const featureDirs = [];
2856
+ for (const f of registry.list("feature")) {
2857
+ const loc = f.loc?.file;
2858
+ if (!loc) continue;
2859
+ const dir = loc.endsWith("/") ? loc.slice(0, -1) : path4.posix.dirname(loc);
2860
+ featureDirs.push({ dir, id: f.id });
2861
+ }
2862
+ for (const p2 of registry.list("page")) {
2863
+ if (p2.loc?.file) addOwner(p2.loc.file, p2.id);
2864
+ }
2865
+ const ownersFor = (file) => {
2866
+ const out2 = [...ownersByFile.get(file) ?? []];
2867
+ for (const { dir, id } of featureDirs) {
2868
+ if (file === dir || file.startsWith(`${dir}/`)) out2.push(id);
2869
+ }
2870
+ return out2;
2871
+ };
2872
+ const expandTouches = (ids) => {
2873
+ const out2 = new Set(ids);
2874
+ for (const id of ids) {
2875
+ for (const anc of ancestorsById.get(id) ?? []) out2.add(anc);
2876
+ const file = fileById.get(id);
2877
+ if (file) for (const owner of ownersFor(file)) out2.add(owner);
2878
+ }
2879
+ return [...out2];
2880
+ };
2345
2881
  if (ctx.flowFiles && conventions.flows) {
2346
2882
  for (const ff of ctx.flowFiles) {
2347
2883
  const notFlowExport = (ff.metadata ?? []).find(
@@ -2351,7 +2887,10 @@ function resolve2(ctx) {
2351
2887
  const flowExport = (ff.metadata ?? []).find(
2352
2888
  (m) => m.kind === "flow" && typeof m.id === "string"
2353
2889
  );
2354
- const derived = flowsFromFacts(ff);
2890
+ const derived = flowsFromFacts(ff).map((f) => ({
2891
+ ...f,
2892
+ touches: expandTouches(f.touches)
2893
+ }));
2355
2894
  if (flowExport && typeof flowExport.id === "string" && derived.length === 1) {
2356
2895
  const base = derived[0];
2357
2896
  const flow = {
@@ -2359,7 +2898,8 @@ function resolve2(ctx) {
2359
2898
  id: flowExport.id,
2360
2899
  loc: base.loc,
2361
2900
  touches: base.touches,
2362
- steps: base.steps
2901
+ steps: base.steps,
2902
+ ...base.covers ? { covers: base.covers } : {}
2363
2903
  };
2364
2904
  registry.add(flow);
2365
2905
  } else {
@@ -2407,20 +2947,95 @@ function computeScope(displayPath) {
2407
2947
  return null;
2408
2948
  }
2409
2949
  function flowsFromFacts(ff) {
2410
- return (ff.flows ?? []).map((fact) => ({
2411
- kind: "flow",
2412
- id: kebab(fact.title),
2413
- loc: { file: ff.file.displayPath, line: fact.line },
2414
- touches: dedupe(fact.calls.map((c) => c.id)),
2415
- steps: fact.calls.filter((c) => c.action).map((c) => ({ entityId: c.id, action: c.action }))
2416
- }));
2950
+ return (ff.flows ?? []).map((fact) => {
2951
+ const covers = dedupeBy(
2952
+ (fact.covers ?? []).map((c) => ({
2953
+ entity: c.entity,
2954
+ criterion: c.criterion,
2955
+ rev: c.rev
2956
+ })),
2957
+ (c) => `${c.entity} ${c.criterion} ${c.rev}`
2958
+ );
2959
+ return {
2960
+ kind: "flow",
2961
+ id: kebab(fact.title),
2962
+ loc: { file: ff.file.displayPath, line: fact.line },
2963
+ touches: dedupe(fact.calls.map((c) => c.id)),
2964
+ steps: fact.calls.filter((c) => c.action).map((c) => ({ entityId: c.id, action: c.action })),
2965
+ ...covers.length > 0 ? { covers } : {}
2966
+ };
2967
+ });
2968
+ }
2969
+ function dedupeBy(arr, key) {
2970
+ const seen = /* @__PURE__ */ new Set();
2971
+ const out2 = [];
2972
+ for (const item of arr) {
2973
+ const k = key(item);
2974
+ if (seen.has(k)) continue;
2975
+ seen.add(k);
2976
+ out2.push(item);
2977
+ }
2978
+ return out2;
2417
2979
  }
2418
2980
  function dedupe(arr) {
2419
2981
  return Array.from(new Set(arr));
2420
2982
  }
2421
2983
 
2422
2984
  // src/scanner/scan/audit.ts
2423
- import * as path5 from "path";
2985
+ import * as path7 from "path";
2986
+
2987
+ // src/scanner/scan/component-coverage.ts
2988
+ var COMPOSED_KINDS = ["widget", "feature"];
2989
+ function composedStateEntities(registry, page) {
2990
+ const meta = registry.get("page", page)?.meta;
2991
+ if (!meta) return [];
2992
+ const out2 = [];
2993
+ for (const kind of COMPOSED_KINDS) {
2994
+ const ids = (kind === "widget" ? meta.widgets : meta.features) ?? [];
2995
+ for (const id of ids) {
2996
+ const e = registry.get(kind, id);
2997
+ if (!e?.meta?.states?.length) continue;
2998
+ if (e.meta.acknowledge?.[ACK_ALL]) continue;
2999
+ out2.push({ kind, id });
3000
+ }
3001
+ }
3002
+ return out2;
3003
+ }
3004
+ function capturedStatesFor(captured, kind, id) {
3005
+ const out2 = /* @__PURE__ */ new Set();
3006
+ for (const c of captured) {
3007
+ if (c.entity !== id) continue;
3008
+ if (c.kind !== void 0 && c.kind !== kind && c.kind !== "page") continue;
3009
+ out2.add(c.state);
3010
+ }
3011
+ return out2;
3012
+ }
3013
+ function checkComponentCoverage(registry, manifest) {
3014
+ const routes = registry.list("route");
3015
+ if (routes.length === 0) return [];
3016
+ const captured = manifest.captured ?? [];
3017
+ const diagnostics = [];
3018
+ for (const route of routes) {
3019
+ if (registry.get("page", route.page)?.meta?.acknowledge?.[ACK_ALL]) continue;
3020
+ for (const { kind, id } of composedStateEntities(registry, route.page)) {
3021
+ const entity = registry.get(kind, id);
3022
+ const declared = entity.meta.states.map((s) => s.name);
3023
+ const shot = capturedStatesFor(captured, kind, id);
3024
+ const missing = declared.filter((s) => !shot.has(s));
3025
+ if (missing.length === 0) continue;
3026
+ diagnostics.push({
3027
+ code: "component-state-uncaptured",
3028
+ severity: "info",
3029
+ message: `route "${route.path}" composes ${kind} "${id}", whose state(s) [${missing.join(", ")}] no capture produced`,
3030
+ file: entity.loc?.file,
3031
+ line: entity.loc?.line,
3032
+ entity: { kind, id },
3033
+ 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}".`
3034
+ });
3035
+ }
3036
+ }
3037
+ return diagnostics;
3038
+ }
2424
3039
 
2425
3040
  // src/scanner/scan/page-coverage.ts
2426
3041
  function compileRoute(pattern) {
@@ -2443,11 +3058,11 @@ function compileRoute(pattern) {
2443
3058
  return { test: (url) => re.test(url), specificity };
2444
3059
  }
2445
3060
  function matchUrlToRoute(url, routePaths) {
2446
- const path12 = stripQuery(url);
3061
+ const path14 = stripQuery(url);
2447
3062
  let best = null;
2448
3063
  for (const rp of routePaths) {
2449
3064
  const { test, specificity } = compileRoute(rp);
2450
- if (!test(path12)) continue;
3065
+ if (!test(path14)) continue;
2451
3066
  if (!best || specificity > best.specificity)
2452
3067
  best = { path: rp, specificity };
2453
3068
  }
@@ -2478,29 +3093,49 @@ function checkPageCoverage(registry, manifest, baseline) {
2478
3093
  if (routes.length === 0) return [];
2479
3094
  const routePaths = routes.map((r) => r.path);
2480
3095
  const captured = manifest.captured ?? [];
2481
- const optedOut = /* @__PURE__ */ new Set();
2482
- for (const r of routes) {
2483
- const page = registry.get("page", r.page);
2484
- if (page?.meta?.capture === false) optedOut.add(r.path);
2485
- }
2486
3096
  const covered = /* @__PURE__ */ new Set();
2487
3097
  for (const c of captured) {
2488
3098
  const route = routeForCapture(c, routes, routePaths);
2489
3099
  if (route) covered.add(route);
2490
3100
  }
2491
- const baselineSet = new Set(baseline?.uncapturedPages ?? []);
2492
3101
  const diagnostics = [];
2493
3102
  for (const r of routes) {
2494
- if (covered.has(r.path) || optedOut.has(r.path)) continue;
3103
+ const ack = routeAcknowledge(registry, baseline, r.path, r.page).get(
3104
+ ACK_ALL
3105
+ );
2495
3106
  const loc = registry.get("page", r.page)?.loc;
2496
- if (baselineSet.has(r.path)) {
3107
+ if (covered.has(r.path)) {
3108
+ if (ack?.type === "impossible") {
3109
+ diagnostics.push({
3110
+ code: "stale-acknowledgment",
3111
+ severity: "warning",
3112
+ message: `page "${r.page}" acknowledges "*" as ${describeAck(ack)}, but route "${r.path}" is captured`,
3113
+ file: loc?.file,
3114
+ line: loc?.line,
3115
+ entity: { kind: "page", id: r.page },
3116
+ hint: originHint(ack, r.page)
3117
+ });
3118
+ } else if (ack?.type === "backlog") {
3119
+ diagnostics.push({
3120
+ code: "page-captured",
3121
+ severity: "warning",
3122
+ message: `route "${r.path}" is now captured but is still acknowledged as backlog`,
3123
+ file: loc?.file,
3124
+ line: loc?.line,
3125
+ hint: originHint(ack, r.page)
3126
+ });
3127
+ }
3128
+ continue;
3129
+ }
3130
+ if (ack?.type === "impossible") continue;
3131
+ if (ack?.type === "backlog") {
2497
3132
  diagnostics.push({
2498
3133
  code: "page-backlog",
2499
3134
  severity: "info",
2500
3135
  message: `route "${r.path}" has no visual-states capture (acknowledged backlog)`,
2501
3136
  file: loc?.file,
2502
3137
  line: loc?.line,
2503
- 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 }\`.`
3138
+ 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}".`
2504
3139
  });
2505
3140
  continue;
2506
3141
  }
@@ -2511,46 +3146,155 @@ function checkPageCoverage(registry, manifest, baseline) {
2511
3146
  file: loc?.file,
2512
3147
  line: loc?.line,
2513
3148
  entity: { kind: "route", id: r.path },
2514
- 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).`
3149
+ 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).`
3150
+ });
3151
+ }
3152
+ for (const b of Object.keys(baseline?.acknowledge ?? {})) {
3153
+ if (routePaths.includes(b)) continue;
3154
+ diagnostics.push({
3155
+ code: "stale-page-baseline",
3156
+ severity: "warning",
3157
+ message: `coverage baseline acknowledges "${b}", which is not a route in the registry`,
3158
+ hint: `Remove it from the baseline (\`uidex scan --update-baseline\`); the route no longer exists.`
2515
3159
  });
2516
3160
  }
2517
- for (const b of baselineSet) {
2518
- if (!routePaths.includes(b)) {
2519
- diagnostics.push({
2520
- code: "stale-page-baseline",
2521
- severity: "warning",
2522
- message: `coverage baseline lists "${b}", which is not a route in the registry`,
2523
- hint: `Remove it from the baseline (\`uidex scan --update-baseline\`); the route no longer exists.`
2524
- });
2525
- } else if (covered.has(b) || optedOut.has(b)) {
2526
- diagnostics.push({
2527
- code: "page-captured",
2528
- severity: "warning",
2529
- message: `route "${b}" is now captured but is still in the coverage baseline`,
2530
- hint: `Prune it with \`uidex scan --update-baseline\` so the backlog reflects reality.`
2531
- });
2532
- }
2533
- }
2534
3161
  return diagnostics;
2535
3162
  }
2536
- function computePageBaseline(registry, manifest) {
2537
- const uncaptured = [];
3163
+ function computePageBacklog(registry, manifest) {
3164
+ const out2 = {};
2538
3165
  for (const d of checkPageCoverage(registry, manifest, {
2539
- uncapturedPages: []
3166
+ version: 2,
3167
+ acknowledge: {}
2540
3168
  })) {
2541
- if (d.code === "page-no-capture" && d.entity) uncaptured.push(d.entity.id);
3169
+ if (d.code === "page-no-capture" && d.entity) {
3170
+ out2[d.entity.id] = { [ACK_ALL]: { type: "backlog" } };
3171
+ }
3172
+ }
3173
+ return out2;
3174
+ }
3175
+
3176
+ // src/scanner/scan/workspace.ts
3177
+ import * as fs3 from "fs";
3178
+ import * as path5 from "path";
3179
+ var WORKSPACE_MARKERS = [
3180
+ "pnpm-workspace.yaml",
3181
+ "pnpm-workspace.yml",
3182
+ "turbo.json",
3183
+ "lerna.json",
3184
+ "nx.json",
3185
+ ".git"
3186
+ ];
3187
+ var MAX_ASCENT = 8;
3188
+ var DEFAULT_STATES_MANIFEST = "uidex-states.json";
3189
+ function captureKey(kind, entity) {
3190
+ return `${kind ?? ""}|${entity}`;
3191
+ }
3192
+ function hasMarker(dir) {
3193
+ return WORKSPACE_MARKERS.some((m) => fs3.existsSync(path5.join(dir, m)));
3194
+ }
3195
+ function findWorkspaceRoot(configDir) {
3196
+ let dir = path5.resolve(configDir);
3197
+ for (let i = 0; i < MAX_ASCENT; i++) {
3198
+ const parent = path5.dirname(dir);
3199
+ if (parent === dir) return void 0;
3200
+ dir = parent;
3201
+ if (hasMarker(dir)) return dir;
3202
+ }
3203
+ return void 0;
3204
+ }
3205
+ function captureKeys(manifest) {
3206
+ const out2 = /* @__PURE__ */ new Set();
3207
+ for (const c of manifest?.captured ?? []) {
3208
+ out2.add(captureKey(c.kind, c.entity));
3209
+ }
3210
+ return out2;
3211
+ }
3212
+ function readManifest(configDir, config) {
3213
+ const rel = config.statesManifest ?? DEFAULT_STATES_MANIFEST;
3214
+ let raw;
3215
+ try {
3216
+ raw = fs3.readFileSync(path5.resolve(configDir, rel), "utf8");
3217
+ } catch {
3218
+ return void 0;
3219
+ }
3220
+ try {
3221
+ const parsed = JSON.parse(raw);
3222
+ if (!parsed || !Array.isArray(parsed.captured)) return void 0;
3223
+ const captured = [];
3224
+ for (const entry of parsed.captured) {
3225
+ if (!entry || typeof entry !== "object") continue;
3226
+ const c = entry;
3227
+ if (typeof c.entity !== "string" || typeof c.state !== "string") continue;
3228
+ const rec = { entity: c.entity, state: c.state };
3229
+ if (typeof c.kind === "string") rec.kind = c.kind;
3230
+ captured.push(rec);
3231
+ }
3232
+ return { captured };
3233
+ } catch {
3234
+ return void 0;
3235
+ }
3236
+ }
3237
+ function toApp(dc) {
3238
+ return {
3239
+ name: path5.basename(dc.configDir),
3240
+ configDir: dc.configDir,
3241
+ configPath: dc.configPath,
3242
+ registers: createSourceMatcher(dc.config.sources, {
3243
+ cwd: dc.configDir,
3244
+ globalExcludes: dc.config.exclude
3245
+ }),
3246
+ captured: captureKeys(readManifest(dc.configDir, dc.config))
3247
+ };
3248
+ }
3249
+ function resolveWorkspace(opts) {
3250
+ const { configDir, config } = opts;
3251
+ if (config.workspace === false) return void 0;
3252
+ const root = typeof config.workspace === "string" ? path5.resolve(configDir, config.workspace) : findWorkspaceRoot(configDir);
3253
+ if (!root) return void 0;
3254
+ const configs = opts.configs ?? discover({ cwd: root, all: true });
3255
+ const self = path5.resolve(configDir, CONFIG_FILENAME);
3256
+ const siblings = configs.filter((dc) => path5.resolve(dc.configPath) !== self).map(toApp);
3257
+ if (siblings.length === 0) return void 0;
3258
+ return { root, siblings };
3259
+ }
3260
+ function capturedElsewhere(workspace, absFile, kind, id) {
3261
+ if (!workspace || !absFile) return void 0;
3262
+ for (const app of workspace.siblings) {
3263
+ if (!app.registers(absFile)) continue;
3264
+ if (app.captured.has(captureKey(kind, id)) || app.captured.has(captureKey(void 0, id))) {
3265
+ return app.name;
3266
+ }
2542
3267
  }
2543
- return { uncapturedPages: uncaptured.sort() };
3268
+ return void 0;
2544
3269
  }
2545
3270
 
2546
3271
  // src/scanner/scan/state-coverage.ts
2547
3272
  var STATE_KINDS2 = ["page", "feature", "widget"];
2548
- function declaredStateEntities(registry) {
3273
+ function declaredStateEntities(registry, opts, ungated) {
2549
3274
  const out2 = [];
2550
3275
  for (const kind of STATE_KINDS2) {
2551
3276
  for (const e of registry.list(kind)) {
2552
3277
  const states = e.meta?.states;
2553
3278
  if (!states || states.length === 0) continue;
3279
+ const absFile = e.loc?.file ? opts.resolveSourcePath?.(e.loc.file) : void 0;
3280
+ const ack = entityAcknowledge(
3281
+ e.meta,
3282
+ capturedElsewhere(opts.workspace, absFile, kind, e.id)
3283
+ );
3284
+ if (ack) {
3285
+ if (ack.origin === "workspace") {
3286
+ ungated.push({
3287
+ code: "acknowledged-elsewhere",
3288
+ severity: "info",
3289
+ 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)})`,
3290
+ file: e.loc?.file,
3291
+ line: e.loc?.line,
3292
+ entity: { kind, id: e.id },
3293
+ 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.`
3294
+ });
3295
+ }
3296
+ continue;
3297
+ }
2554
3298
  out2.push({
2555
3299
  kind,
2556
3300
  id: e.id,
@@ -2561,18 +3305,31 @@ function declaredStateEntities(registry) {
2561
3305
  }
2562
3306
  return out2;
2563
3307
  }
2564
- function captureMatchesEntity(cap, ent) {
3308
+ function unreliablePageKinds(registry, captured) {
3309
+ const out2 = /* @__PURE__ */ new Set();
3310
+ for (const c of captured) {
3311
+ if (c.kind !== "page") continue;
3312
+ if (registry.get("page", c.entity)) continue;
3313
+ out2.add(c.entity);
3314
+ }
3315
+ return out2;
3316
+ }
3317
+ function captureMatchesEntity(cap, ent, unreliable = EMPTY) {
2565
3318
  if (cap.entity !== ent.id) return false;
2566
- return cap.kind === void 0 || cap.kind === ent.kind;
3319
+ if (cap.kind === void 0) return true;
3320
+ if (cap.kind === ent.kind) return true;
3321
+ return cap.kind === "page" && unreliable.has(cap.entity);
2567
3322
  }
2568
- function checkStateCoverage(registry, manifest) {
3323
+ var EMPTY = /* @__PURE__ */ new Set();
3324
+ function checkStateCoverage(registry, manifest, opts = {}) {
2569
3325
  const diagnostics = [];
2570
- const declared = declaredStateEntities(registry);
2571
3326
  const captured = manifest.captured ?? [];
3327
+ const unreliable = unreliablePageKinds(registry, captured);
3328
+ const declared = declaredStateEntities(registry, opts, diagnostics);
2572
3329
  for (const ent of declared) {
2573
3330
  for (const state of ent.states) {
2574
3331
  const hit = captured.some(
2575
- (c) => captureMatchesEntity(c, ent) && c.state === state
3332
+ (c) => captureMatchesEntity(c, ent, unreliable) && c.state === state
2576
3333
  );
2577
3334
  if (hit) continue;
2578
3335
  diagnostics.push({
@@ -2587,7 +3344,9 @@ function checkStateCoverage(registry, manifest) {
2587
3344
  }
2588
3345
  }
2589
3346
  for (const cap of captured) {
2590
- const candidates = declared.filter((ent2) => captureMatchesEntity(cap, ent2));
3347
+ const candidates = declared.filter(
3348
+ (ent2) => captureMatchesEntity(cap, ent2, unreliable)
3349
+ );
2591
3350
  if (candidates.length === 0) continue;
2592
3351
  const declaredSomewhere = candidates.some(
2593
3352
  (ent2) => ent2.states.includes(cap.state)
@@ -2631,36 +3390,35 @@ function coreKindsByRoute(registry, manifest) {
2631
3390
  }
2632
3391
  return byRoute;
2633
3392
  }
2634
- function pageWaivers(registry, routePage) {
2635
- return registry.get("page", routePage)?.meta?.waivers ?? {};
2636
- }
2637
- function isOptedOut(registry, page) {
2638
- return page ? registry.get("page", page)?.meta?.capture === false : false;
2639
- }
2640
3393
  function checkStateMatrix(registry, manifest, baseline) {
2641
3394
  const routes = registry.list("route");
2642
3395
  if (routes.length === 0) return [];
2643
3396
  const pageOf = new Map(routes.map((r) => [r.path, r.page]));
2644
3397
  const captured = coreKindsByRoute(registry, manifest);
2645
- const baselined = baseline?.missingKinds ?? {};
2646
3398
  const diagnostics = [];
2647
3399
  for (const [route, kinds] of captured) {
2648
3400
  const page = pageOf.get(route);
2649
- if (isOptedOut(registry, page)) continue;
2650
- const waivers = page ? pageWaivers(registry, page) : {};
3401
+ const ack = routeAcknowledge(registry, baseline, route, page);
2651
3402
  const loc = page ? registry.get("page", page)?.loc : void 0;
2652
- const baselinedKinds = new Set(baselined[route] ?? []);
3403
+ const all = ack.get(ACK_ALL);
3404
+ if (all) {
3405
+ if (all.type === "impossible") {
3406
+ diagnostics.push(staleDiagnostic(all, route, page, loc));
3407
+ }
3408
+ continue;
3409
+ }
2653
3410
  for (const kind of CORE_KINDS) {
2654
3411
  if (kinds.has(kind)) continue;
2655
- if (waivers[kind]) continue;
2656
- if (baselinedKinds.has(kind)) {
3412
+ const entry = ack.get(kind);
3413
+ if (entry?.type === "impossible") continue;
3414
+ if (entry?.type === "backlog") {
2657
3415
  diagnostics.push({
2658
3416
  code: "matrix-backlog",
2659
3417
  severity: "info",
2660
- message: `route "${route}" does not render core kind "${kind}" (acknowledged MISSING_KINDS backlog)`,
3418
+ message: `route "${route}" does not render core kind "${kind}" (acknowledged backlog)`,
2661
3419
  file: loc?.file,
2662
3420
  line: loc?.line,
2663
- hint: `Capture the "${kind}" state for "${route}", waive it (\`waivers: { ${kind}: "why it can't" }\` on the page), or leave it in the baseline.`
3421
+ 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" }\`.`
2664
3422
  });
2665
3423
  continue;
2666
3424
  }
@@ -2671,44 +3429,221 @@ function checkStateMatrix(registry, manifest, baseline) {
2671
3429
  file: loc?.file,
2672
3430
  line: loc?.line,
2673
3431
  entity: { kind: "route", id: route },
2674
- 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).`
3432
+ 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).`
2675
3433
  });
2676
3434
  }
3435
+ for (const [scope, entry] of ack) {
3436
+ if (scope === ACK_ALL || !kinds.has(scope)) continue;
3437
+ diagnostics.push(staleDiagnostic(entry, route, page, loc));
3438
+ }
2677
3439
  }
3440
+ return diagnostics;
3441
+ }
3442
+ function staleDiagnostic(entry, route, page, loc) {
3443
+ const what = entry.scope === ACK_ALL ? `route "${route}" is captured` : `route "${route}" does render core kind "${entry.scope}"`;
3444
+ return {
3445
+ code: "stale-acknowledgment",
3446
+ severity: "warning",
3447
+ message: `${page ? `page "${page}"` : `route "${route}"`} acknowledges "${entry.scope}" as ${describeAck(entry)}, but ${what}`,
3448
+ file: loc?.file,
3449
+ line: loc?.line,
3450
+ ...page ? { entity: { kind: "page", id: page } } : {},
3451
+ hint: originHint(entry, page)
3452
+ };
3453
+ }
3454
+ function computeMatrixBacklog(registry, manifest) {
3455
+ const routes = registry.list("route");
3456
+ const pageOf = new Map(routes.map((r) => [r.path, r.page]));
3457
+ const captured = coreKindsByRoute(registry, manifest);
3458
+ const out2 = {};
2678
3459
  for (const [route, kinds] of captured) {
2679
3460
  const page = pageOf.get(route);
2680
- if (!page || isOptedOut(registry, page)) continue;
2681
- const waivers = pageWaivers(registry, page);
2682
- const loc = registry.get("page", page)?.loc;
2683
- for (const kind of Object.keys(waivers)) {
2684
- if (kinds.has(kind)) {
3461
+ const ack = routeAcknowledge(registry, void 0, route, page);
3462
+ if (ack.has(ACK_ALL)) continue;
3463
+ const scopes = {};
3464
+ for (const kind of CORE_KINDS) {
3465
+ if (kinds.has(kind) || ack.has(kind)) continue;
3466
+ scopes[kind] = { type: "backlog" };
3467
+ }
3468
+ if (Object.keys(scopes).length > 0) out2[route] = scopes;
3469
+ }
3470
+ return out2;
3471
+ }
3472
+
3473
+ // src/scanner/scan/surface-check.ts
3474
+ import * as path6 from "path";
3475
+ var EXT_CANDIDATES = [
3476
+ "",
3477
+ ".tsx",
3478
+ ".ts",
3479
+ ".jsx",
3480
+ ".js",
3481
+ "/index.tsx",
3482
+ "/index.ts",
3483
+ "/index.jsx",
3484
+ "/index.js"
3485
+ ];
3486
+ function checkFeatureSurfaces(registry, extracted, config) {
3487
+ const declarations = [];
3488
+ for (const ef of extracted) {
3489
+ for (const m of ef.metadata ?? []) {
3490
+ if (m.kind !== "feature") continue;
3491
+ if (typeof m.id !== "string") continue;
3492
+ if (!m.surfaces || m.surfaces.length === 0) continue;
3493
+ if (m.acknowledge?.["*"]) continue;
3494
+ declarations.push({
3495
+ ef,
3496
+ id: m.id,
3497
+ surfaces: m.surfaces,
3498
+ surfaceSpans: m.surfaceSpans,
3499
+ line: m.loc.line ?? 1
3500
+ });
3501
+ }
3502
+ }
3503
+ if (declarations.length === 0) return [];
3504
+ const fileSet = /* @__PURE__ */ new Set();
3505
+ const jsxFiles = /* @__PURE__ */ new Set();
3506
+ for (const ef of extracted) {
3507
+ fileSet.add(ef.file.displayPath);
3508
+ if (ef.hasJsx) jsxFiles.add(ef.file.displayPath);
3509
+ }
3510
+ const aliases = Object.entries(config.resolve?.aliases ?? {}).sort(
3511
+ (a, b) => b[0].length - a[0].length
3512
+ );
3513
+ const resolveSpecifier = (importer, spec) => {
3514
+ const bases = [];
3515
+ if (spec.startsWith("./") || spec.startsWith("../")) {
3516
+ bases.push(path6.posix.join(path6.posix.dirname(importer), spec));
3517
+ } else {
3518
+ const alias = aliases.find(([prefix]) => spec.startsWith(prefix));
3519
+ if (alias) {
3520
+ bases.push(alias[1] + spec.slice(alias[0].length));
3521
+ } else if (aliases.length === 0) {
3522
+ if (spec.startsWith("@/") || spec.startsWith("~/")) {
3523
+ bases.push("src/" + spec.slice(2));
3524
+ }
3525
+ }
3526
+ bases.push(spec);
3527
+ }
3528
+ const hits = [];
3529
+ for (const base of bases) {
3530
+ const normalized = path6.posix.normalize(base);
3531
+ for (const ext of EXT_CANDIDATES) {
3532
+ const candidate = normalized + ext;
3533
+ if (fileSet.has(candidate)) hits.push(candidate);
3534
+ }
3535
+ }
3536
+ return hits;
3537
+ };
3538
+ const adjacency = /* @__PURE__ */ new Map();
3539
+ for (const ef of extracted) {
3540
+ const targets = [];
3541
+ for (const imp of ef.imports ?? []) {
3542
+ if (imp.isTypeOnly) continue;
3543
+ targets.push(...resolveSpecifier(ef.file.displayPath, imp.specifier));
3544
+ }
3545
+ if (targets.length > 0) adjacency.set(ef.file.displayPath, targets);
3546
+ }
3547
+ const inDir = (file, dir) => file === dir || file.startsWith(dir + "/");
3548
+ const reachesDir = (seeds, dir) => {
3549
+ const queue = [...seeds];
3550
+ const visited = new Set(queue);
3551
+ while (queue.length > 0) {
3552
+ const current = queue.pop();
3553
+ if (inDir(current, dir) && jsxFiles.has(current)) return true;
3554
+ for (const next of adjacency.get(current) ?? []) {
3555
+ if (visited.has(next)) continue;
3556
+ visited.add(next);
3557
+ queue.push(next);
3558
+ }
3559
+ }
3560
+ return false;
3561
+ };
3562
+ const featuresConvention = config.conventions?.features === void 0 ? DEFAULT_CONVENTIONS.features : config.conventions.features;
3563
+ const featureGlob = typeof featuresConvention === "string" ? featuresConvention : null;
3564
+ const diagnostics = [];
3565
+ for (const decl of declarations) {
3566
+ const declPath = decl.ef.file.displayPath;
3567
+ const featureDir = (featureGlob !== null ? extractFeatureDir(declPath, featureGlob) : null) ?? path6.posix.dirname(declPath);
3568
+ for (let i = 0; i < decl.surfaces.length; i++) {
3569
+ const pageId = decl.surfaces[i];
3570
+ const line = decl.surfaceSpans?.[i] ? lineOfOffset(decl.ef.file.content, decl.surfaceSpans[i].start) : decl.line;
3571
+ const page = registry.get("page", pageId) ?? registry.matchPattern("page", pageId);
3572
+ if (!page) {
2685
3573
  diagnostics.push({
2686
- code: "stale-waiver",
3574
+ code: "unknown-reference",
2687
3575
  severity: "warning",
2688
- message: `page "${page}" waives core kind "${kind}", but route "${route}" does render it`,
2689
- file: loc?.file,
2690
- line: loc?.line,
2691
- entity: { kind: "page", id: page },
2692
- hint: `Remove the "${kind}" waiver \u2014 the route renders that kind, so the waiver's reason is stale.`
3576
+ message: `Feature "${decl.id}" declares a surface on unknown page "${pageId}"`,
3577
+ file: decl.ef.file.displayPath,
3578
+ line,
3579
+ entity: { kind: "feature", id: decl.id },
3580
+ hint: `No page with id "${pageId}" exists in the registry; fix the reference or add the page.`
2693
3581
  });
3582
+ continue;
2694
3583
  }
3584
+ const pageFile = page.loc?.file;
3585
+ if (!pageFile) continue;
3586
+ const seeds = [pageFile];
3587
+ if (path6.posix.basename(pageFile) === WELL_KNOWN_FILES.page) {
3588
+ const dir = path6.posix.dirname(pageFile);
3589
+ for (const f of fileSet) {
3590
+ if (f !== pageFile && path6.posix.dirname(f) === dir) seeds.push(f);
3591
+ }
3592
+ }
3593
+ if (reachesDir(seeds, featureDir)) continue;
3594
+ diagnostics.push({
3595
+ code: "feature-surface-unreferenced",
3596
+ severity: "warning",
3597
+ message: `Feature "${decl.id}" declares page "${pageId}" as a surface, but nothing reachable from ${pageFile} references a component file in ${featureDir}/`,
3598
+ file: decl.ef.file.displayPath,
3599
+ line,
3600
+ entity: { kind: "feature", id: decl.id },
3601
+ hint: `This is a static tripwire, not proof either way: verify page "${pageId}" really mounts a component from ${featureDir}/ (and that the import resolves within the scanned sources), or remove "${pageId}" from the feature's \`surfaces\`.`
3602
+ });
2695
3603
  }
2696
3604
  }
2697
3605
  return diagnostics;
2698
3606
  }
2699
- function computeMissingKinds(registry, manifest) {
3607
+ function checkFeatureSurfaceCaptures(registry, manifest) {
2700
3608
  const routes = registry.list("route");
2701
- const pageOf = new Map(routes.map((r) => [r.path, r.page]));
2702
- const captured = coreKindsByRoute(registry, manifest);
2703
- const out2 = {};
2704
- for (const [route, kinds] of captured) {
2705
- const page = pageOf.get(route);
2706
- if (isOptedOut(registry, page)) continue;
2707
- const waivers = page ? pageWaivers(registry, page) : {};
2708
- const missing = CORE_KINDS.filter((k) => !kinds.has(k) && !waivers[k]);
2709
- if (missing.length > 0) out2[route] = missing;
3609
+ if (routes.length === 0) return [];
3610
+ const routePaths = routes.map((r) => r.path);
3611
+ const captured = manifest.captured ?? [];
3612
+ const diagnostics = [];
3613
+ for (const feature of registry.list("feature")) {
3614
+ const surfaces = feature.meta?.surfaces ?? [];
3615
+ if (surfaces.length === 0) continue;
3616
+ if (entityAcknowledge(feature.meta)) continue;
3617
+ const coveredRoutes = /* @__PURE__ */ new Set();
3618
+ for (const c of captured) {
3619
+ if (c.kind !== "feature" || c.entity !== feature.id) continue;
3620
+ const route = routeForCapture(c, routes, routePaths);
3621
+ if (route) coveredRoutes.add(route);
3622
+ }
3623
+ for (const pageId of surfaces) {
3624
+ const pageRoutes = routes.filter((r) => r.page === pageId);
3625
+ if (pageRoutes.length === 0) continue;
3626
+ if (pageRoutes.some((r) => coveredRoutes.has(r.path))) continue;
3627
+ const routeList = pageRoutes.map((r) => `"${r.path}"`).join(", ");
3628
+ diagnostics.push({
3629
+ code: "feature-surface-uncaptured",
3630
+ severity: "warning",
3631
+ message: `Feature "${feature.id}" declares page "${pageId}" as a surface, but no capture rendered the feature on ${pageRoutes.length === 1 ? `route ${routeList}` : `any of its routes (${routeList})`}`,
3632
+ file: feature.loc?.file,
3633
+ line: feature.loc?.line,
3634
+ entity: { kind: "feature", id: feature.id },
3635
+ hint: `Author a states capture spec that renders feature "${feature.id}" on page "${pageId}" (a capture with kind "feature" driving ${routeList}), or acknowledge the feature with \`acknowledge: { "*": \u2026 }\`.`
3636
+ });
3637
+ }
2710
3638
  }
2711
- return out2;
3639
+ return diagnostics;
3640
+ }
3641
+ function lineOfOffset(content, offset) {
3642
+ let line = 1;
3643
+ for (let i = 0; i < offset && i < content.length; i++) {
3644
+ if (content[i] === "\n") line++;
3645
+ }
3646
+ return line;
2712
3647
  }
2713
3648
 
2714
3649
  // src/scanner/scan/audit.ts
@@ -2718,6 +3653,8 @@ function audit(opts) {
2718
3653
  const check = opts.check ?? false;
2719
3654
  const lint = opts.lint ?? false;
2720
3655
  const acceptanceEnabled = config.audit?.acceptance ?? true;
3656
+ const surfacesEnabled = config.audit?.surfaces ?? true;
3657
+ const requireAcceptanceEnabled = config.audit?.requireAcceptance ?? true;
2721
3658
  const scopeLeakEnabled = config.audit?.scopeLeak ?? true;
2722
3659
  const coverageEnabled = config.audit?.coverage ?? true;
2723
3660
  const statesEnabled = config.audit?.states ?? true;
@@ -2730,6 +3667,21 @@ function audit(opts) {
2730
3667
  for (const ef of opts.flowExtracted ?? []) {
2731
3668
  if (ef.diagnostics) diagnostics.push(...ef.diagnostics);
2732
3669
  }
3670
+ for (const ef of extracted) {
3671
+ for (const m of ef.metadata ?? []) {
3672
+ for (const [scope, ack] of Object.entries(m.acknowledge ?? {})) {
3673
+ if (!isPlaceholderReason(ack.reason)) continue;
3674
+ diagnostics.push({
3675
+ code: "placeholder-reason",
3676
+ severity: "warning",
3677
+ message: `acknowledge "${scope}" still has the migration's placeholder reason ("${ack.reason}")`,
3678
+ file: ef.file.displayPath,
3679
+ line: m.loc.line,
3680
+ 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).`
3681
+ });
3682
+ }
3683
+ }
3684
+ }
2733
3685
  if (check && opts.generated !== void 0) {
2734
3686
  const outRel = opts.outputPath ?? config.output;
2735
3687
  const fresh = normalizeForCheck(opts.generated);
@@ -2799,9 +3751,66 @@ function audit(opts) {
2799
3751
  }
2800
3752
  }
2801
3753
  if (lint && acceptanceEnabled) {
2802
- for (const kind of ["widget", "feature", "page"]) {
3754
+ const flowEntities = registry.list("flow");
3755
+ const acceptanceKinds = ["widget", "feature", "page"];
3756
+ const claims = /* @__PURE__ */ new Map();
3757
+ const addClaim = (kind, entityId, criterion, claim) => {
3758
+ const key = `${kind}:${entityId}`;
3759
+ let byCrit = claims.get(key);
3760
+ if (!byCrit) {
3761
+ byCrit = /* @__PURE__ */ new Map();
3762
+ claims.set(key, byCrit);
3763
+ }
3764
+ let list = byCrit.get(criterion);
3765
+ if (!list) {
3766
+ list = [];
3767
+ byCrit.set(criterion, list);
3768
+ }
3769
+ list.push(claim);
3770
+ };
3771
+ for (const flow of flowEntities) {
3772
+ for (const c of flow.covers ?? []) {
3773
+ const candidates = acceptanceKinds.flatMap((k) => {
3774
+ const e = registry.get(k, c.entity);
3775
+ return e ? [e] : [];
3776
+ });
3777
+ const matching = candidates.filter(
3778
+ (e) => entityCriteria(e).some((crit) => crit.id === c.criterion)
3779
+ );
3780
+ if (matching.length === 0) {
3781
+ diagnostics.push({
3782
+ code: "acceptance-orphaned",
3783
+ severity: "warning",
3784
+ message: `Flow "${flow.id}" covers criterion "${c.criterion}" on "${c.entity}", but ${candidates.length > 0 ? `no criterion with that id exists on ${candidates.map((e) => `${e.kind} "${c.entity}"`).join(" or ")}` : `no page/feature/widget "${c.entity}" exists`}`,
3785
+ file: flow.loc.file,
3786
+ line: flow.loc.line,
3787
+ entity: { kind: "flow", id: flow.id },
3788
+ hint: "Remove the stale uidexCovers claim, or fix the entity/criterion id it names."
3789
+ });
3790
+ continue;
3791
+ }
3792
+ if (matching.length > 1) {
3793
+ diagnostics.push({
3794
+ code: "acceptance-ambiguous",
3795
+ severity: "warning",
3796
+ message: `Flow "${flow.id}" covers criterion "${c.criterion}" on "${c.entity}", which matches ${matching.map((e) => `${e.kind} "${c.entity}"`).join(" and ")}; the claim credits all of them`,
3797
+ file: flow.loc.file,
3798
+ line: flow.loc.line,
3799
+ entity: { kind: "flow", id: flow.id },
3800
+ hint: "The claim syntax carries no kind, so a criterion id shared by same-id entities cannot be told apart \u2014 rename one entity or criterion id to disambiguate."
3801
+ });
3802
+ }
3803
+ for (const target of matching) {
3804
+ addClaim(target.kind, target.id, c.criterion, {
3805
+ flowId: flow.id,
3806
+ rev: c.rev
3807
+ });
3808
+ }
3809
+ }
3810
+ }
3811
+ for (const kind of acceptanceKinds) {
2803
3812
  for (const e of registry.list(kind)) {
2804
- const criteria = e.meta?.acceptance ?? [];
3813
+ const criteria = entityCriteria(e);
2805
3814
  if (criteria.length === 0) {
2806
3815
  if (kind === "widget") {
2807
3816
  diagnostics.push({
@@ -2816,14 +3825,27 @@ function audit(opts) {
2816
3825
  }
2817
3826
  continue;
2818
3827
  }
2819
- const flowIds = e.meta?.flows ?? [];
2820
- if (flowIds.length === 0) {
2821
- const hint = kind === "widget" ? `uidex scaffold widget ${e.id}` : `Add a Playwright flow under e2e/** that touches ${kind} "${e.id}" (tag the describe with @uidex:flow)`;
2822
- for (const c of criteria) {
3828
+ const entityClaims = claims.get(`${kind}:${e.id}`);
3829
+ for (const c of criteria) {
3830
+ const critClaims = entityClaims?.get(c.id) ?? [];
3831
+ const stale = critClaims.filter((cl) => cl.rev !== c.rev);
3832
+ for (const cl of stale) {
3833
+ diagnostics.push({
3834
+ code: "acceptance-outdated",
3835
+ severity: "warning",
3836
+ message: `Flow "${cl.flowId}" covers ${kind} "${e.id}" criterion "${c.id}" at rev ${cl.rev}, but the criterion is now rev ${c.rev}`,
3837
+ file: e.loc?.file,
3838
+ line: e.loc?.line,
3839
+ entity: { kind, id: e.id },
3840
+ hint: `Re-verify the flow against the current criterion text, then update its claim to \`uidexCovers("${e.id}", "${c.id}@${c.rev}")\`.`
3841
+ });
3842
+ }
3843
+ if (critClaims.length === 0) {
3844
+ const hint = kind === "widget" ? `uidex scaffold widget ${e.id}, then claim it with \`uidexCovers("${e.id}", "${c.id}@${c.rev}")\`` : `Add a Playwright flow under e2e/** that touches ${kind} "${e.id}" (tag the describe with @uidex:flow) and claim the criterion with \`uidexCovers("${e.id}", "${c.id}@${c.rev}")\``;
2823
3845
  diagnostics.push({
2824
3846
  code: "acceptance-uncovered",
2825
3847
  severity: "warning",
2826
- message: `${kind} "${e.id}" has acceptance criterion not covered by any flow: "${c}"`,
3848
+ message: `${kind} "${e.id}" has acceptance criterion "${c.id}" not covered by any flow: "${c.text}"`,
2827
3849
  file: e.loc?.file,
2828
3850
  line: e.loc?.line,
2829
3851
  entity: { kind, id: e.id },
@@ -2834,7 +3856,31 @@ function audit(opts) {
2834
3856
  }
2835
3857
  }
2836
3858
  }
3859
+ if (lint && surfacesEnabled) {
3860
+ diagnostics.push(...checkFeatureSurfaces(registry, extracted, config));
3861
+ }
3862
+ if (lint && surfacesEnabled && opts.capturedStates) {
3863
+ diagnostics.push(
3864
+ ...checkFeatureSurfaceCaptures(registry, opts.capturedStates)
3865
+ );
3866
+ }
3867
+ if (lint && requireAcceptanceEnabled) {
3868
+ for (const e of registry.list("feature")) {
3869
+ if (entityCriteria(e).length > 0) continue;
3870
+ diagnostics.push({
3871
+ code: "feature-missing-acceptance",
3872
+ severity: "warning",
3873
+ message: `Feature "${e.id}" declares no acceptance criteria`,
3874
+ file: e.loc?.file,
3875
+ line: e.loc?.line,
3876
+ entity: { kind: "feature", id: e.id },
3877
+ hint: `Add \`acceptance: [...]\` to the feature's \`export const uidex\` \u2014 observable outcomes, not implementation. They feed \`uidex scaffold feature ${e.id}\` and the acceptance-uncovered gate.`
3878
+ });
3879
+ }
3880
+ }
2837
3881
  if (lint) {
3882
+ const featuresConvention = config.conventions?.features === void 0 ? DEFAULT_CONVENTIONS.features : config.conventions.features;
3883
+ const featureGlob = typeof featuresConvention === "string" ? featuresConvention : null;
2838
3884
  const scannedPaths = new Set(files.map((f) => f.displayPath));
2839
3885
  for (const ef of extracted) {
2840
3886
  if (!ef.metadata) continue;
@@ -2843,10 +3889,24 @@ function audit(opts) {
2843
3889
  if (typeof m.id !== "string") continue;
2844
3890
  const filePath = ef.file.displayPath;
2845
3891
  const wellKnownName = WELL_KNOWN_FILES[m.kind];
2846
- if (path5.posix.basename(filePath) === wellKnownName) continue;
2847
- const dir = path5.posix.dirname(filePath);
3892
+ if (path7.posix.basename(filePath) === wellKnownName) continue;
3893
+ const featureDir = m.kind === "feature" && featureGlob !== null ? extractFeatureDir(filePath, featureGlob) : null;
3894
+ const dir = featureDir ?? path7.posix.dirname(filePath);
2848
3895
  const wellKnownPath = dir === "." ? wellKnownName : `${dir}/${wellKnownName}`;
2849
3896
  if (scannedPaths.has(wellKnownPath)) continue;
3897
+ const inConventionDir = featureDir !== null;
3898
+ if (inConventionDir) {
3899
+ diagnostics.push({
3900
+ code: "prefer-well-known-file",
3901
+ severity: "error",
3902
+ message: `Feature "${m.id}" metadata lives on ${filePath}; a feature in a convention-derived directory must declare its metadata in ${wellKnownPath}`,
3903
+ file: filePath,
3904
+ line: m.loc.line,
3905
+ entity: { kind: "feature", id: m.id },
3906
+ hint: `Move the \`export const uidex\` block to ${wellKnownPath} and remove it from ${filePath}.`
3907
+ });
3908
+ continue;
3909
+ }
2850
3910
  const kindLabel = m.kind === "page" ? "Page" : "Feature";
2851
3911
  diagnostics.push({
2852
3912
  code: "prefer-well-known-file",
@@ -2930,6 +3990,7 @@ function audit(opts) {
2930
3990
  const displayPath = ef.file.displayPath;
2931
3991
  for (const imp of ef.imports ?? []) {
2932
3992
  if (imp.isTypeOnly) continue;
3993
+ if (imp.dynamic) continue;
2933
3994
  const baseName2 = imp.specifier.split("/").pop() ?? "";
2934
3995
  const primitive = byName.get(
2935
3996
  baseName2.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
@@ -3047,7 +4108,7 @@ function audit(opts) {
3047
4108
  severity: "warning",
3048
4109
  message: `\`export const uidex\` in ${ef.file.displayPath} references unknown ${refKind} "${refId}"`,
3049
4110
  file: ef.file.displayPath,
3050
- line: spans?.[i] ? lineOfOffset(ef.file.content, spans[i].start) : m.loc.line,
4111
+ line: spans?.[i] ? lineOfOffset2(ef.file.content, spans[i].start) : m.loc.line,
3051
4112
  hint: `No ${refKind} with id "${refId}" exists in the registry; fix the reference or add the ${refKind}.`
3052
4113
  });
3053
4114
  }
@@ -3058,33 +4119,57 @@ function audit(opts) {
3058
4119
  }
3059
4120
  }
3060
4121
  if (lint && statesEnabled && opts.capturedStates) {
3061
- diagnostics.push(...checkStateCoverage(registry, opts.capturedStates));
4122
+ const sourceByDisplay = new Map(
4123
+ files.map((f) => [f.displayPath, f.sourcePath])
4124
+ );
4125
+ diagnostics.push(
4126
+ ...checkStateCoverage(registry, opts.capturedStates, {
4127
+ workspace: opts.workspace,
4128
+ resolveSourcePath: (displayPath) => sourceByDisplay.get(displayPath)
4129
+ })
4130
+ );
3062
4131
  }
3063
4132
  if (lint && pageCoverageEnabled && opts.capturedStates) {
3064
4133
  diagnostics.push(
3065
- ...checkPageCoverage(registry, opts.capturedStates, opts.pageBaseline)
4134
+ ...checkPageCoverage(registry, opts.capturedStates, opts.coverageBaseline)
3066
4135
  );
3067
4136
  }
4137
+ if (lint && statesEnabled && opts.capturedStates) {
4138
+ diagnostics.push(...checkComponentCoverage(registry, opts.capturedStates));
4139
+ }
3068
4140
  if (lint && stateMatrixEnabled && opts.capturedStates) {
3069
4141
  diagnostics.push(
3070
- ...checkStateMatrix(registry, opts.capturedStates, {
3071
- missingKinds: opts.pageBaseline?.missingKinds ?? {}
3072
- })
4142
+ ...checkStateMatrix(registry, opts.capturedStates, opts.coverageBaseline)
3073
4143
  );
3074
4144
  }
4145
+ const overrides = config.audit?.severity ?? {};
4146
+ const governed = diagnostics.flatMap((d) => {
4147
+ const level = overrides[d.code];
4148
+ if (level === void 0) return [d];
4149
+ if (level === "off") return [];
4150
+ return [{ ...d, severity: level }];
4151
+ });
3075
4152
  const summary = {
3076
- errors: diagnostics.filter((d) => d.severity === "error").length,
3077
- warnings: diagnostics.filter((d) => d.severity === "warning").length
4153
+ errors: governed.filter((d) => d.severity === "error").length,
4154
+ warnings: governed.filter((d) => d.severity === "warning").length
3078
4155
  };
3079
- return { diagnostics, summary };
4156
+ return { diagnostics: governed, summary };
3080
4157
  }
3081
- function lineOfOffset(content, offset) {
4158
+ function lineOfOffset2(content, offset) {
3082
4159
  let line = 1;
3083
4160
  for (let i = 0; i < offset && i < content.length; i++) {
3084
4161
  if (content[i] === "\n") line++;
3085
4162
  }
3086
4163
  return line;
3087
4164
  }
4165
+ function entityCriteria(e) {
4166
+ const meta = e.meta;
4167
+ if (!meta) return [];
4168
+ return [
4169
+ ...meta.acceptance ?? [],
4170
+ ...(meta.states ?? []).flatMap((s) => s.acceptance ?? [])
4171
+ ];
4172
+ }
3088
4173
  var TAG_FALLBACK_ID = {
3089
4174
  a: "link",
3090
4175
  button: "button",
@@ -3404,6 +4489,30 @@ ${body}
3404
4489
  `;
3405
4490
  }
3406
4491
  var OPAQUE_ID_UNIONS = /* @__PURE__ */ new Set(["ElementId"]);
4492
+ function stateEntities(list) {
4493
+ return list.map((e) => ({
4494
+ id: e.id,
4495
+ states: (e.meta?.states ?? []).map((s) => s.name)
4496
+ }));
4497
+ }
4498
+ function emitStateMap(groups) {
4499
+ const lines = ["export interface UidexStates {"];
4500
+ for (const g of groups) {
4501
+ const withStates = g.entities.filter((e) => e.states.length > 0);
4502
+ if (withStates.length === 0) {
4503
+ lines.push(` ${g.kind}: never`);
4504
+ continue;
4505
+ }
4506
+ lines.push(` ${g.kind}: {`);
4507
+ for (const e of [...withStates].sort((a, b) => a.id.localeCompare(b.id))) {
4508
+ const states = [...new Set(e.states)].sort().map((s) => JSON.stringify(s)).join(" | ");
4509
+ lines.push(` ${JSON.stringify(e.id)}: ${states}`);
4510
+ }
4511
+ lines.push(" }");
4512
+ }
4513
+ lines.push("}");
4514
+ return lines.join("\n") + "\n";
4515
+ }
3407
4516
  function emitId(name, ids) {
3408
4517
  if (OPAQUE_ID_UNIONS.has(name)) {
3409
4518
  return `export type ${name} = string & { readonly __uidex?: ${JSON.stringify(name)} }
@@ -3491,36 +4600,64 @@ function emit(opts) {
3491
4600
  );
3492
4601
  t.push(emitId("StateId", [...stateNames]));
3493
4602
  t.push("");
4603
+ t.push("// ---- declared state space (kind \u2192 entity \u2192 its own states) ----");
4604
+ t.push(
4605
+ emitStateMap([
4606
+ { kind: "page", entities: stateEntities(pages) },
4607
+ { kind: "feature", entities: stateEntities(features) },
4608
+ { kind: "widget", entities: stateEntities(widgets) }
4609
+ ])
4610
+ );
3494
4611
  t.push("// ---- authoring-surface shape types ----");
3495
4612
  t.push("export namespace Uidex {");
3496
4613
  t.push(
3497
4614
  ' export type CoreStateKind = "loading" | "empty" | "populated" | "error"'
3498
4615
  );
3499
4616
  t.push(' export type StateKind = CoreStateKind | "variant"');
4617
+ t.push(" export interface AcceptanceCriterion {");
4618
+ t.push(" id: string");
4619
+ t.push(" rev?: number");
4620
+ t.push(" text: string");
4621
+ t.push(" }");
4622
+ t.push(" export type Acceptance = readonly (string | AcceptanceCriterion)[]");
3500
4623
  t.push(" export interface State {");
3501
4624
  t.push(" name: string");
3502
4625
  t.push(" kind?: StateKind");
3503
- t.push(" acceptance?: readonly string[]");
4626
+ t.push(" acceptance?: Acceptance");
3504
4627
  t.push(" description?: string");
3505
4628
  t.push(" }");
3506
- t.push(" export type Waivers = Partial<Record<CoreStateKind, string>>");
4629
+ t.push(' export type AcknowledgeScope = CoreStateKind | "*"');
4630
+ t.push(" export interface Impossible {");
4631
+ t.push(' type: "impossible"');
4632
+ t.push(" reason: string");
4633
+ t.push(" }");
4634
+ t.push(" export interface Backlog {");
4635
+ t.push(' type: "backlog"');
4636
+ t.push(" reason?: string");
4637
+ t.push(" }");
4638
+ t.push(" export type Acknowledgment = Impossible | Backlog");
4639
+ t.push(
4640
+ " export type Acknowledge = Partial<Record<AcknowledgeScope, Acknowledgment>>"
4641
+ );
4642
+ t.push(' export type ComponentAcknowledge = { "*"?: Acknowledgment }');
3507
4643
  t.push(" export interface Page {");
3508
4644
  t.push(" page: PageId | false");
3509
4645
  t.push(" name?: string");
3510
4646
  t.push(" features?: readonly FeatureId[]");
3511
4647
  t.push(" widgets?: readonly WidgetId[]");
3512
- t.push(" acceptance?: readonly string[]");
4648
+ t.push(" acceptance?: Acceptance");
3513
4649
  t.push(" states?: readonly (string | State)[]");
3514
- t.push(" capture?: boolean");
3515
- t.push(" waivers?: Waivers");
4650
+ t.push(" acknowledge?: Acknowledge");
3516
4651
  t.push(" description?: string");
3517
4652
  t.push(" }");
3518
4653
  t.push(" export interface Feature {");
3519
4654
  t.push(" feature: FeatureId | false");
3520
4655
  t.push(" name?: string");
3521
4656
  t.push(" features?: readonly FeatureId[]");
3522
- t.push(" acceptance?: readonly string[]");
4657
+ t.push(" surfaces?: readonly PageId[]");
4658
+ t.push(" acceptance?: Acceptance");
3523
4659
  t.push(" states?: readonly (string | State)[]");
4660
+ t.push(" acknowledge?: ComponentAcknowledge");
3524
4661
  t.push(" description?: string");
3525
4662
  t.push(" }");
3526
4663
  t.push(" export interface Primitive {");
@@ -3531,8 +4668,9 @@ function emit(opts) {
3531
4668
  t.push(" export interface Widget {");
3532
4669
  t.push(" widget: WidgetId");
3533
4670
  t.push(" name?: string");
3534
- t.push(" acceptance?: readonly string[]");
4671
+ t.push(" acceptance?: Acceptance");
3535
4672
  t.push(" states?: readonly (string | State)[]");
4673
+ t.push(" acknowledge?: ComponentAcknowledge");
3536
4674
  t.push(" description?: string");
3537
4675
  t.push(" }");
3538
4676
  t.push(" export interface Region {");
@@ -3628,8 +4766,8 @@ function parseGitHubRef(ref) {
3628
4766
  }
3629
4767
 
3630
4768
  // src/scanner/scan/scaffold.ts
3631
- import * as fs3 from "fs";
3632
- import * as path6 from "path";
4769
+ import * as fs4 from "fs";
4770
+ import * as path8 from "path";
3633
4771
  function scaffoldWidgetSpec(opts) {
3634
4772
  return scaffoldSpec({
3635
4773
  registry: opts.registry,
@@ -3653,10 +4791,10 @@ function scaffoldSpec(opts) {
3653
4791
  if (!entity) {
3654
4792
  throw new Error(`${capitalize(kind)} "${id}" not found in registry`);
3655
4793
  }
3656
- const criteria = entity.meta?.acceptance ?? [];
4794
+ const criteria = entityCriteria(entity);
3657
4795
  const filename = kind === "widget" ? `widget-${id}.spec.ts` : `flow-${id}.spec.ts`;
3658
- const outputPath = path6.resolve(outDir, filename);
3659
- if (fs3.existsSync(outputPath) && !force) {
4796
+ const outputPath = path8.resolve(outDir, filename);
4797
+ if (fs4.existsSync(outputPath) && !force) {
3660
4798
  return {
3661
4799
  outputPath,
3662
4800
  written: false,
@@ -3664,9 +4802,9 @@ function scaffoldSpec(opts) {
3664
4802
  reason: `spec already exists at ${outputPath}; pass --force to overwrite`
3665
4803
  };
3666
4804
  }
3667
- const content = renderSpec({ id, criteria, fixtureImport });
3668
- fs3.mkdirSync(path6.dirname(outputPath), { recursive: true });
3669
- fs3.writeFileSync(outputPath, content, "utf8");
4805
+ const content = renderSpec({ id, entityId: id, criteria, fixtureImport });
4806
+ fs4.mkdirSync(path8.dirname(outputPath), { recursive: true });
4807
+ fs4.writeFileSync(outputPath, content, "utf8");
3670
4808
  return { outputPath, written: true, skipped: false };
3671
4809
  }
3672
4810
  function capitalize(s) {
@@ -3674,25 +4812,28 @@ function capitalize(s) {
3674
4812
  }
3675
4813
  function renderSpec(args) {
3676
4814
  const lines = [];
3677
- lines.push(
3678
- `import { test, expect } from ${JSON.stringify(args.fixtureImport)}`
3679
- );
4815
+ lines.push(`import { test } from ${JSON.stringify(args.fixtureImport)}`);
3680
4816
  lines.push("");
3681
4817
  lines.push(
3682
4818
  `test.describe(${JSON.stringify(args.id)}, { tag: "@uidex:flow" }, () => {`
3683
4819
  );
3684
- if (args.criteria.length === 0) {
3685
- lines.push(` test("TODO: add acceptance criteria", async () => {`);
3686
- lines.push(` // TODO`);
4820
+ const stub = (title, body) => {
4821
+ lines.push(` test.fixme(${JSON.stringify(title)}, async ({ uidex }) => {`);
4822
+ for (const l of body) lines.push(` ${l}`);
4823
+ lines.push(` void uidex`);
3687
4824
  lines.push(` })`);
4825
+ lines.push("");
4826
+ };
4827
+ if (args.criteria.length === 0) {
4828
+ stub("TODO: add acceptance criteria", [
4829
+ "// Declare `acceptance: [...]` on the entity, then re-run scaffold."
4830
+ ]);
3688
4831
  } else {
3689
4832
  for (const criterion of args.criteria) {
3690
- lines.push(` test(${JSON.stringify(criterion)}, async ({ uidex }) => {`);
3691
- lines.push(` // TODO: implement criterion`);
3692
- lines.push(` void uidex`);
3693
- lines.push(` expect(true).toBe(true)`);
3694
- lines.push(` })`);
3695
- lines.push("");
4833
+ stub(`${criterion.id}: ${criterion.text}`, [
4834
+ "// TODO: drive this criterion, then remove `.fixme` to arm the test",
4835
+ `// and claim it: uidexCovers(${JSON.stringify(args.entityId)}, ${JSON.stringify(`${criterion.id}@${criterion.rev}`)})`
4836
+ ]);
3696
4837
  }
3697
4838
  }
3698
4839
  lines.push("})");
@@ -3701,47 +4842,33 @@ function renderSpec(args) {
3701
4842
  }
3702
4843
 
3703
4844
  // src/scanner/scan/pipeline.ts
3704
- import * as fs4 from "fs";
3705
- import * as path7 from "path";
3706
- var DEFAULT_STATES_MANIFEST = "uidex-states.json";
4845
+ import * as fs5 from "fs";
4846
+ import * as path9 from "path";
4847
+ var DEFAULT_STATES_MANIFEST2 = "uidex-states.json";
3707
4848
  var DEFAULT_COVERAGE_BASELINE = "uidex-coverage-baseline.json";
3708
- function loadPageBaseline(configDir, config) {
4849
+ function loadCoverageBaseline(configDir, config) {
3709
4850
  const rel = config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
3710
- const abs = path7.resolve(configDir, rel);
4851
+ const abs = path9.resolve(configDir, rel);
3711
4852
  let raw;
3712
4853
  try {
3713
- raw = fs4.readFileSync(abs, "utf8");
4854
+ raw = fs5.readFileSync(abs, "utf8");
3714
4855
  } catch {
3715
4856
  return void 0;
3716
4857
  }
4858
+ let parsed;
3717
4859
  try {
3718
- const parsed = JSON.parse(raw);
3719
- if (!parsed || !Array.isArray(parsed.uncapturedPages)) return void 0;
3720
- const uncapturedPages = parsed.uncapturedPages.filter(
3721
- (p2) => typeof p2 === "string"
3722
- );
3723
- let missingKinds;
3724
- const mkRaw = parsed.missingKinds;
3725
- if (mkRaw && typeof mkRaw === "object" && !Array.isArray(mkRaw)) {
3726
- const mk = {};
3727
- for (const [route, kinds] of Object.entries(mkRaw)) {
3728
- if (Array.isArray(kinds)) {
3729
- mk[route] = kinds.filter((k) => typeof k === "string");
3730
- }
3731
- }
3732
- missingKinds = mk;
3733
- }
3734
- return { uncapturedPages, ...missingKinds ? { missingKinds } : {} };
4860
+ parsed = JSON.parse(raw);
3735
4861
  } catch {
3736
4862
  return void 0;
3737
4863
  }
4864
+ return parseCoverageBaseline(parsed) ?? void 0;
3738
4865
  }
3739
4866
  function loadCapturedStates(configDir, config) {
3740
- const rel = config.statesManifest ?? DEFAULT_STATES_MANIFEST;
3741
- const abs = path7.resolve(configDir, rel);
4867
+ const rel = config.statesManifest ?? DEFAULT_STATES_MANIFEST2;
4868
+ const abs = path9.resolve(configDir, rel);
3742
4869
  let raw;
3743
4870
  try {
3744
- raw = fs4.readFileSync(abs, "utf8");
4871
+ raw = fs5.readFileSync(abs, "utf8");
3745
4872
  } catch {
3746
4873
  return void 0;
3747
4874
  }
@@ -3760,7 +4887,7 @@ function loadCapturedStates(configDir, config) {
3760
4887
  if (typeof c.route === "string") rec.route = c.route;
3761
4888
  captured.push(rec);
3762
4889
  }
3763
- return { captured };
4890
+ return typeof parsed.generatedAt === "string" ? { captured, generatedAt: parsed.generatedAt } : { captured };
3764
4891
  } catch {
3765
4892
  return void 0;
3766
4893
  }
@@ -3796,11 +4923,11 @@ function runOne(dc, opts) {
3796
4923
  gitContext
3797
4924
  });
3798
4925
  const typesRel = config.output;
3799
- const typesPath = path7.resolve(configDir, typesRel);
4926
+ const typesPath = path9.resolve(configDir, typesRel);
3800
4927
  const dataRel = dataOutputPath(config.output);
3801
- const dataPath = path7.resolve(configDir, dataRel);
4928
+ const dataPath = path9.resolve(configDir, dataRel);
3802
4929
  const locsRel = locsOutputPath(config.output);
3803
- const locsPath = path7.resolve(configDir, locsRel);
4930
+ const locsPath = path9.resolve(configDir, locsRel);
3804
4931
  let typesOnDisk = null;
3805
4932
  let dataOnDisk = null;
3806
4933
  let locsOnDisk = null;
@@ -3813,7 +4940,8 @@ function runOne(dc, opts) {
3813
4940
  (ef) => (ef.diagnostics?.length ?? 0) > 0
3814
4941
  );
3815
4942
  const capturedStates = opts.capturedStates ?? loadCapturedStates(configDir, config);
3816
- const pageBaseline = opts.pageBaseline ?? loadPageBaseline(configDir, config);
4943
+ const coverageBaseline = opts.coverageBaseline ?? loadCoverageBaseline(configDir, config);
4944
+ const workspace = opts.workspace ?? resolveWorkspace({ configDir, config });
3817
4945
  let auditResult;
3818
4946
  if (opts.check || opts.lint || resolved.diagnostics.length > 0 || hasExtractDiagnostics) {
3819
4947
  auditResult = audit({
@@ -3838,7 +4966,8 @@ function runOne(dc, opts) {
3838
4966
  locsOnDisk,
3839
4967
  locsOutputPath: locsRel,
3840
4968
  capturedStates,
3841
- pageBaseline
4969
+ coverageBaseline,
4970
+ workspace
3842
4971
  });
3843
4972
  }
3844
4973
  return {
@@ -3859,15 +4988,15 @@ function runOne(dc, opts) {
3859
4988
  }
3860
4989
  function readOrNull(p2) {
3861
4990
  try {
3862
- return fs4.readFileSync(p2, "utf8");
4991
+ return fs5.readFileSync(p2, "utf8");
3863
4992
  } catch {
3864
4993
  return null;
3865
4994
  }
3866
4995
  }
3867
4996
  function writeArtifact(artifact) {
3868
4997
  if (readOrNull(artifact.outputPath) === artifact.generated) return false;
3869
- fs4.mkdirSync(path7.dirname(artifact.outputPath), { recursive: true });
3870
- fs4.writeFileSync(artifact.outputPath, artifact.generated, "utf8");
4998
+ fs5.mkdirSync(path9.dirname(artifact.outputPath), { recursive: true });
4999
+ fs5.writeFileSync(artifact.outputPath, artifact.generated, "utf8");
3871
5000
  return true;
3872
5001
  }
3873
5002
  function writeScanResult(result) {
@@ -3876,14 +5005,45 @@ function writeScanResult(result) {
3876
5005
  const wroteLocs = writeArtifact(result.locs);
3877
5006
  return wroteTypes || wroteData || wroteLocs;
3878
5007
  }
3879
- function pageBaselinePath(result) {
5008
+ function coverageBaselinePath(result) {
3880
5009
  const rel = result.config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
3881
- return path7.resolve(result.configDir, rel);
5010
+ return path9.resolve(result.configDir, rel);
5011
+ }
5012
+
5013
+ // src/scanner/scan/states-merge.ts
5014
+ function keyOf(c) {
5015
+ return `${c.kind ?? "page"} ${c.entity} ${c.state}`;
5016
+ }
5017
+ function mergeStates(committed, partial) {
5018
+ const byKey = /* @__PURE__ */ new Map();
5019
+ for (const c of committed.captured ?? []) byKey.set(keyOf(c), c);
5020
+ const updated = [];
5021
+ const added = [];
5022
+ for (const c of partial.captured ?? []) {
5023
+ const k = keyOf(c);
5024
+ (byKey.has(k) ? updated : added).push(k);
5025
+ byKey.set(k, c);
5026
+ }
5027
+ const captured = [...byKey.values()].sort(
5028
+ (a, b) => a.entity.localeCompare(b.entity) || a.state.localeCompare(b.state)
5029
+ );
5030
+ return {
5031
+ manifest: {
5032
+ ...committed,
5033
+ captured,
5034
+ // The merged file is only as fresh as its OLDEST evidence: rows carried
5035
+ // over from the committed manifest were not re-verified by this run, so
5036
+ // claiming the partial's newer stamp would defeat the staleness check.
5037
+ ...committed.generatedAt ? { generatedAt: committed.generatedAt } : {}
5038
+ },
5039
+ updated: updated.sort(),
5040
+ added: added.sort()
5041
+ };
3882
5042
  }
3883
5043
 
3884
5044
  // src/scanner/scan/fix.ts
3885
- import * as fs5 from "fs";
3886
- import * as path8 from "path";
5045
+ import * as fs6 from "fs";
5046
+ import * as path10 from "path";
3887
5047
  function applyFixes(diagnostics) {
3888
5048
  const entries = [];
3889
5049
  for (const d of diagnostics) {
@@ -3916,7 +5076,7 @@ function applyFixes(diagnostics) {
3916
5076
  for (const [filePath, edits] of editsByFile) {
3917
5077
  let content;
3918
5078
  try {
3919
- content = fs5.readFileSync(filePath, "utf8");
5079
+ content = fs6.readFileSync(filePath, "utf8");
3920
5080
  } catch {
3921
5081
  for (const e of edits) e.entry.skippedReason ??= "file is unreadable";
3922
5082
  continue;
@@ -3936,22 +5096,22 @@ function applyFixes(diagnostics) {
3936
5096
  const edit = kept[i];
3937
5097
  content = content.slice(0, edit.start) + edit.replacement + content.slice(edit.end);
3938
5098
  }
3939
- if (kept.length > 0) fs5.writeFileSync(filePath, content, "utf8");
5099
+ if (kept.length > 0) fs6.writeFileSync(filePath, content, "utf8");
3940
5100
  }
3941
5101
  for (const entry of entries) {
3942
5102
  if (entry.skippedReason) continue;
3943
5103
  for (const create of entry.createFiles) {
3944
- if (fs5.existsSync(create.path)) {
3945
- entry.skippedReason = `${path8.basename(create.path)} already exists`;
5104
+ if (fs6.existsSync(create.path)) {
5105
+ entry.skippedReason = `${path10.basename(create.path)} already exists`;
3946
5106
  continue;
3947
5107
  }
3948
- fs5.mkdirSync(path8.dirname(create.path), { recursive: true });
3949
- fs5.writeFileSync(create.path, create.content, "utf8");
5108
+ fs6.mkdirSync(path10.dirname(create.path), { recursive: true });
5109
+ fs6.writeFileSync(create.path, create.content, "utf8");
3950
5110
  }
3951
5111
  if (entry.skippedReason) continue;
3952
5112
  for (const del of entry.deleteFiles) {
3953
5113
  try {
3954
- fs5.unlinkSync(del);
5114
+ fs6.unlinkSync(del);
3955
5115
  } catch {
3956
5116
  }
3957
5117
  }
@@ -4129,29 +5289,29 @@ function renameEntity(opts) {
4129
5289
  }
4130
5290
 
4131
5291
  // src/scanner/scan/cli.ts
4132
- import * as fs8 from "fs";
4133
- import * as path11 from "path";
5292
+ import * as fs9 from "fs";
5293
+ import * as path13 from "path";
4134
5294
 
4135
5295
  // src/scanner/scan/ai/index.ts
4136
5296
  import * as p from "@clack/prompts";
4137
5297
 
4138
5298
  // src/scanner/scan/ai/providers/claude.ts
4139
- import * as fs7 from "fs";
4140
- import * as path10 from "path";
5299
+ import * as fs8 from "fs";
5300
+ import * as path12 from "path";
4141
5301
 
4142
5302
  // src/scanner/scan/ai/templates.ts
4143
- import * as fs6 from "fs";
4144
- import * as path9 from "path";
5303
+ import * as fs7 from "fs";
5304
+ import * as path11 from "path";
4145
5305
  function templatePath(rel) {
4146
5306
  const candidates = [
4147
- path9.resolve(__dirname, "../../templates", rel),
5307
+ path11.resolve(__dirname, "../../templates", rel),
4148
5308
  // dist/cli/cli.cjs → ../../templates
4149
- path9.resolve(__dirname, "../../../../templates", rel)
5309
+ path11.resolve(__dirname, "../../../../templates", rel)
4150
5310
  // src/scanner/scan/ai → ../../../../templates
4151
5311
  ];
4152
5312
  for (const c of candidates) {
4153
5313
  try {
4154
- fs6.accessSync(c, fs6.constants.R_OK);
5314
+ fs7.accessSync(c, fs7.constants.R_OK);
4155
5315
  return c;
4156
5316
  } catch {
4157
5317
  continue;
@@ -4163,7 +5323,7 @@ function templatePath(rel) {
4163
5323
  );
4164
5324
  }
4165
5325
  function readTemplate(rel) {
4166
- return fs6.readFileSync(templatePath(rel), "utf8");
5326
+ return fs7.readFileSync(templatePath(rel), "utf8");
4167
5327
  }
4168
5328
 
4169
5329
  // src/scanner/scan/ai/providers/claude.ts
@@ -4194,8 +5354,8 @@ var claudeProvider = {
4194
5354
  async install({ cwd, force }) {
4195
5355
  const changes = [];
4196
5356
  for (const file of SKILL_FILES) {
4197
- const dest = path10.join(cwd, file.dest);
4198
- const exists = fs7.existsSync(dest);
5357
+ const dest = path12.join(cwd, file.dest);
5358
+ const exists = fs8.existsSync(dest);
4199
5359
  if (exists && !force) {
4200
5360
  changes.push({
4201
5361
  path: file.dest,
@@ -4204,56 +5364,56 @@ var claudeProvider = {
4204
5364
  });
4205
5365
  continue;
4206
5366
  }
4207
- fs7.mkdirSync(path10.dirname(dest), { recursive: true });
4208
- fs7.writeFileSync(dest, readTemplate(file.template));
5367
+ fs8.mkdirSync(path12.dirname(dest), { recursive: true });
5368
+ fs8.writeFileSync(dest, readTemplate(file.template));
4209
5369
  changes.push({
4210
5370
  path: file.dest,
4211
5371
  action: exists ? "overwritten" : "created"
4212
5372
  });
4213
5373
  }
4214
5374
  for (const rel of LEGACY_FILES) {
4215
- const dest = path10.join(cwd, rel);
4216
- if (fs7.existsSync(dest)) {
4217
- fs7.unlinkSync(dest);
5375
+ const dest = path12.join(cwd, rel);
5376
+ if (fs8.existsSync(dest)) {
5377
+ fs8.unlinkSync(dest);
4218
5378
  changes.push({ path: rel, action: "removed" });
4219
5379
  }
4220
5380
  }
4221
- cleanupEmpty(path10.join(cwd, ".claude/commands/uidex"));
4222
- cleanupEmpty(path10.join(cwd, ".claude/commands"));
4223
- cleanupEmpty(path10.join(cwd, ".claude/rules"));
5381
+ cleanupEmpty(path12.join(cwd, ".claude/commands/uidex"));
5382
+ cleanupEmpty(path12.join(cwd, ".claude/commands"));
5383
+ cleanupEmpty(path12.join(cwd, ".claude/rules"));
4224
5384
  return { changes };
4225
5385
  },
4226
5386
  async uninstall({ cwd }) {
4227
5387
  const changes = [];
4228
5388
  for (const file of SKILL_FILES) {
4229
- const dest = path10.join(cwd, file.dest);
4230
- if (!fs7.existsSync(dest)) {
5389
+ const dest = path12.join(cwd, file.dest);
5390
+ if (!fs8.existsSync(dest)) {
4231
5391
  changes.push({ path: file.dest, action: "skipped", reason: "absent" });
4232
5392
  continue;
4233
5393
  }
4234
- fs7.unlinkSync(dest);
5394
+ fs8.unlinkSync(dest);
4235
5395
  changes.push({ path: file.dest, action: "removed" });
4236
5396
  }
4237
- cleanupEmpty(path10.join(cwd, ".claude/skills/uidex/references"));
4238
- cleanupEmpty(path10.join(cwd, ".claude/skills/uidex"));
4239
- cleanupEmpty(path10.join(cwd, ".claude/skills"));
5397
+ cleanupEmpty(path12.join(cwd, ".claude/skills/uidex/references"));
5398
+ cleanupEmpty(path12.join(cwd, ".claude/skills/uidex"));
5399
+ cleanupEmpty(path12.join(cwd, ".claude/skills"));
4240
5400
  for (const rel of LEGACY_FILES) {
4241
- const dest = path10.join(cwd, rel);
4242
- if (fs7.existsSync(dest)) {
4243
- fs7.unlinkSync(dest);
5401
+ const dest = path12.join(cwd, rel);
5402
+ if (fs8.existsSync(dest)) {
5403
+ fs8.unlinkSync(dest);
4244
5404
  changes.push({ path: rel, action: "removed" });
4245
5405
  }
4246
5406
  }
4247
- cleanupEmpty(path10.join(cwd, ".claude/commands/uidex"));
4248
- cleanupEmpty(path10.join(cwd, ".claude/commands"));
4249
- cleanupEmpty(path10.join(cwd, ".claude/rules"));
5407
+ cleanupEmpty(path12.join(cwd, ".claude/commands/uidex"));
5408
+ cleanupEmpty(path12.join(cwd, ".claude/commands"));
5409
+ cleanupEmpty(path12.join(cwd, ".claude/rules"));
4250
5410
  return { changes };
4251
5411
  }
4252
5412
  };
4253
5413
  function cleanupEmpty(dir) {
4254
5414
  try {
4255
- const entries = fs7.readdirSync(dir);
4256
- if (entries.length === 0) fs7.rmdirSync(dir);
5415
+ const entries = fs8.readdirSync(dir);
5416
+ if (entries.length === 0) fs8.rmdirSync(dir);
4257
5417
  } catch {
4258
5418
  }
4259
5419
  }
@@ -4422,6 +5582,8 @@ async function run(opts) {
4422
5582
  return runScaffold(cwd, positional.slice(1), flags, writer);
4423
5583
  case "rename":
4424
5584
  return runRename(cwd, positional.slice(1), flags, writer);
5585
+ case "states":
5586
+ return runStates(cwd, positional.slice(1), writer);
4425
5587
  case "ai": {
4426
5588
  const result = await runAiCommand({
4427
5589
  cwd,
@@ -4450,6 +5612,7 @@ function helpText2() {
4450
5612
  " scan [flags] Run the scanner pipeline",
4451
5613
  " scaffold <widget|page|feature> <id> Emit a Playwright spec from declared acceptance",
4452
5614
  " rename <element|widget|region> <old-id> <new-id> Rename an id everywhere (DOM attr, flows, exports)",
5615
+ " states merge [partial] Fold a filtered run's *.partial.json into the committed manifest",
4453
5616
  " ai <install|uninstall|providers> Manage AI assistant integrations",
4454
5617
  "",
4455
5618
  "Flags:",
@@ -4457,15 +5620,15 @@ function helpText2() {
4457
5620
  " --lint Run lint diagnostics (missing annotations, scope leak, duplicate ids, coverage)",
4458
5621
  " --audit Equivalent to --check --lint (read-only)",
4459
5622
  " --fix Apply machine-generated fixes (add data-uidex to unannotated interactive elements, drop empty names), then rescan and write",
4460
- " --update-baseline Regenerate the page-coverage baseline (uidex-coverage-baseline.json) from the current uncaptured routes",
5623
+ " --update-baseline Regenerate the coverage baseline (uidex-coverage-baseline.json) as `acknowledge` backlog entries",
4461
5624
  " --json Emit JSON diagnostics on stdout",
4462
5625
  " --force (scaffold) overwrite existing spec",
4463
5626
  ""
4464
5627
  ].join("\n");
4465
5628
  }
4466
5629
  function runInit(cwd, w) {
4467
- const configPath = path11.join(cwd, CONFIG_FILENAME);
4468
- if (fs8.existsSync(configPath)) {
5630
+ const configPath = path13.join(cwd, CONFIG_FILENAME);
5631
+ if (fs9.existsSync(configPath)) {
4469
5632
  w.err(`.uidex.json already exists at ${configPath}`);
4470
5633
  return w.result(1);
4471
5634
  }
@@ -4474,16 +5637,16 @@ function runInit(cwd, w) {
4474
5637
  sources: [{ rootDir: "src" }],
4475
5638
  output: "src/uidex.gen.ts"
4476
5639
  };
4477
- fs8.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
5640
+ fs9.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
4478
5641
  w.out(`Created ${configPath}`);
4479
- const gitignorePath = path11.join(cwd, ".gitignore");
5642
+ const gitignorePath = path13.join(cwd, ".gitignore");
4480
5643
  const entry = "*.gen.*";
4481
- if (fs8.existsSync(gitignorePath)) {
4482
- const existing = fs8.readFileSync(gitignorePath, "utf8");
5644
+ if (fs9.existsSync(gitignorePath)) {
5645
+ const existing = fs9.readFileSync(gitignorePath, "utf8");
4483
5646
  const hasEntry = existing.split("\n").some((line) => line.trim() === entry);
4484
5647
  if (!hasEntry) {
4485
5648
  const needsNewline = existing.length > 0 && !existing.endsWith("\n");
4486
- fs8.appendFileSync(
5649
+ fs9.appendFileSync(
4487
5650
  gitignorePath,
4488
5651
  `${needsNewline ? "\n" : ""}${entry}
4489
5652
  `,
@@ -4492,7 +5655,7 @@ function runInit(cwd, w) {
4492
5655
  w.out(`Appended ${entry} to ${gitignorePath}`);
4493
5656
  }
4494
5657
  } else {
4495
- fs8.writeFileSync(gitignorePath, `${entry}
5658
+ fs9.writeFileSync(gitignorePath, `${entry}
4496
5659
  `, "utf8");
4497
5660
  w.out(`Created ${gitignorePath} with ${entry}`);
4498
5661
  }
@@ -4531,20 +5694,25 @@ function runScanCommand(cwd, flags, w) {
4531
5694
  if (updateBaseline) {
4532
5695
  for (const r of results) {
4533
5696
  const manifest = r.capturedStates ?? { captured: [] };
4534
- const pageBaseline = computePageBaseline(r.registry, manifest);
4535
- const missingKinds = computeMissingKinds(r.registry, manifest);
4536
- const baseline = {
4537
- ...pageBaseline,
4538
- ...Object.keys(missingKinds).length > 0 ? { missingKinds } : {}
5697
+ const acknowledge = {};
5698
+ const merge = (from) => {
5699
+ for (const [route, scopes] of Object.entries(from)) {
5700
+ acknowledge[route] = { ...acknowledge[route], ...scopes };
5701
+ }
4539
5702
  };
4540
- const p2 = pageBaselinePath(r);
4541
- fs8.writeFileSync(p2, JSON.stringify(baseline, null, 2) + "\n");
4542
- const kindGaps = Object.values(missingKinds).reduce(
4543
- (n, ks) => n + ks.length,
5703
+ merge(computePageBacklog(r.registry, manifest));
5704
+ merge(computeMatrixBacklog(r.registry, manifest));
5705
+ const baseline = { version: 2, acknowledge };
5706
+ const p2 = coverageBaselinePath(r);
5707
+ fs9.writeFileSync(p2, serializeCoverageBaseline(baseline));
5708
+ const entries = Object.values(acknowledge);
5709
+ const pages = entries.filter((s) => s["*"]).length;
5710
+ const kindGaps = entries.reduce(
5711
+ (n, s) => n + Object.keys(s).filter((k) => k !== "*").length,
4544
5712
  0
4545
5713
  );
4546
5714
  w.out(
4547
- `Wrote ${p2} \u2014 ${baseline.uncapturedPages.length} uncaptured page(s), ${kindGaps} missing core-kind(s) baselined`
5715
+ `Wrote ${p2} \u2014 ${pages} uncaptured page(s), ${kindGaps} missing core-kind(s) acknowledged as backlog`
4548
5716
  );
4549
5717
  }
4550
5718
  return w.result(0);
@@ -4617,7 +5785,7 @@ function runScaffold(cwd, args, flags, w) {
4617
5785
  for (const r of results) {
4618
5786
  const entity = r.registry.get(scaffoldKind, id);
4619
5787
  if (!entity) continue;
4620
- const outDir = path11.resolve(r.configDir, "e2e");
5788
+ const outDir = path13.resolve(r.configDir, "e2e");
4621
5789
  const result = scaffoldSpec({
4622
5790
  registry: r.registry,
4623
5791
  kind: scaffoldKind,
@@ -4638,6 +5806,53 @@ function runScaffold(cwd, args, flags, w) {
4638
5806
  return w.result(1);
4639
5807
  }
4640
5808
  var RENAME_KINDS = /* @__PURE__ */ new Set(["element", "widget", "region"]);
5809
+ function runStates(cwd, args, w) {
5810
+ const [sub, explicitPartial] = args;
5811
+ if (sub !== "merge") {
5812
+ w.err("Usage: uidex states merge [partial-manifest]");
5813
+ return w.result(1);
5814
+ }
5815
+ const configs = discover({ cwd });
5816
+ if (configs.length === 0) {
5817
+ w.err(`No ${CONFIG_FILENAME} found under ${cwd}`);
5818
+ return w.result(1);
5819
+ }
5820
+ let merged = 0;
5821
+ for (const { configDir, config } of configs) {
5822
+ const rel = config.statesManifest ?? "uidex-states.json";
5823
+ const target = path13.resolve(configDir, rel);
5824
+ const partial = explicitPartial ? path13.resolve(cwd, explicitPartial) : target.replace(/\.json$/i, "") + ".partial.json";
5825
+ const committedRaw = readJson(target);
5826
+ const partialRaw = readJson(partial);
5827
+ if (!partialRaw) continue;
5828
+ if (!committedRaw) {
5829
+ w.err(`No committed manifest at ${rel}; nothing to merge into`);
5830
+ return w.result(1);
5831
+ }
5832
+ const result = mergeStates(committedRaw, partialRaw);
5833
+ fs9.writeFileSync(target, JSON.stringify(result.manifest, null, 2) + "\n");
5834
+ merged++;
5835
+ w.out(
5836
+ `Merged ${path13.basename(partial)} \u2192 ${rel}: ${result.added.length} added, ${result.updated.length} updated, ${result.manifest.captured.length} total`
5837
+ );
5838
+ for (const k of result.added) w.out(` + ${k}`);
5839
+ for (const k of result.updated) w.out(` ~ ${k}`);
5840
+ }
5841
+ if (merged === 0) {
5842
+ w.out("No partial manifest found; nothing to merge.");
5843
+ }
5844
+ return w.result(0);
5845
+ }
5846
+ function readJson(p2) {
5847
+ try {
5848
+ const parsed = JSON.parse(fs9.readFileSync(p2, "utf8"));
5849
+ if (!parsed || typeof parsed !== "object") return null;
5850
+ const m = parsed;
5851
+ return Array.isArray(m.captured) ? m : null;
5852
+ } catch {
5853
+ return null;
5854
+ }
5855
+ }
4641
5856
  function runRename(cwd, args, flags, w) {
4642
5857
  const [kind, oldId, newId] = args;
4643
5858
  if (!kind || !RENAME_KINDS.has(kind) || !oldId || !newId) {
@@ -4685,35 +5900,47 @@ function createWriter() {
4685
5900
  };
4686
5901
  }
4687
5902
  export {
5903
+ ACK_ALL,
5904
+ ACK_SCOPES,
4688
5905
  CONFIG_FILENAME,
4689
5906
  ConfigError,
4690
5907
  DEFAULT_CONVENTIONS,
5908
+ DIAGNOSTIC_CODES,
4691
5909
  applyFixes,
4692
5910
  audit,
5911
+ capturedElsewhere,
5912
+ checkComponentCoverage,
4693
5913
  checkPageCoverage,
4694
5914
  checkStateCoverage,
4695
5915
  checkStateMatrix,
4696
5916
  compileRoute,
4697
- computeMissingKinds,
4698
- computePageBaseline,
5917
+ computeMatrixBacklog,
5918
+ computePageBacklog,
5919
+ coverageBaselinePath,
4699
5920
  detectRoutes,
4700
5921
  discover,
4701
5922
  emit,
5923
+ entityAcknowledge,
4702
5924
  extract,
4703
5925
  extractUidexExports,
5926
+ findWorkspaceRoot,
4704
5927
  globToRegExp,
4705
- loadPageBaseline,
5928
+ loadCoverageBaseline,
4706
5929
  matchUrlToRoute,
4707
- pageBaselinePath,
5930
+ mergeStates,
4708
5931
  parseConfig,
5932
+ parseCoverageBaseline,
4709
5933
  pathToId,
4710
5934
  renameEntity,
4711
5935
  resolve2 as resolve,
4712
5936
  resolveGitContext,
5937
+ resolveWorkspace,
5938
+ routeAcknowledge,
4713
5939
  run as runCli,
4714
5940
  runScan,
4715
5941
  scaffoldSpec,
4716
5942
  scaffoldWidgetSpec,
5943
+ serializeCoverageBaseline,
4717
5944
  validateConfig,
4718
5945
  walk,
4719
5946
  writeScanResult