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
@@ -30,35 +30,47 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/scanner/scan/index.ts
31
31
  var scan_exports = {};
32
32
  __export(scan_exports, {
33
+ ACK_ALL: () => ACK_ALL,
34
+ ACK_SCOPES: () => ACK_SCOPES,
33
35
  CONFIG_FILENAME: () => CONFIG_FILENAME,
34
36
  ConfigError: () => ConfigError,
35
37
  DEFAULT_CONVENTIONS: () => DEFAULT_CONVENTIONS,
38
+ DIAGNOSTIC_CODES: () => DIAGNOSTIC_CODES,
36
39
  applyFixes: () => applyFixes,
37
40
  audit: () => audit,
41
+ capturedElsewhere: () => capturedElsewhere,
42
+ checkComponentCoverage: () => checkComponentCoverage,
38
43
  checkPageCoverage: () => checkPageCoverage,
39
44
  checkStateCoverage: () => checkStateCoverage,
40
45
  checkStateMatrix: () => checkStateMatrix,
41
46
  compileRoute: () => compileRoute,
42
- computeMissingKinds: () => computeMissingKinds,
43
- computePageBaseline: () => computePageBaseline,
47
+ computeMatrixBacklog: () => computeMatrixBacklog,
48
+ computePageBacklog: () => computePageBacklog,
49
+ coverageBaselinePath: () => coverageBaselinePath,
44
50
  detectRoutes: () => detectRoutes,
45
51
  discover: () => discover,
46
52
  emit: () => emit,
53
+ entityAcknowledge: () => entityAcknowledge,
47
54
  extract: () => extract,
48
55
  extractUidexExports: () => extractUidexExports,
56
+ findWorkspaceRoot: () => findWorkspaceRoot,
49
57
  globToRegExp: () => globToRegExp,
50
- loadPageBaseline: () => loadPageBaseline,
58
+ loadCoverageBaseline: () => loadCoverageBaseline,
51
59
  matchUrlToRoute: () => matchUrlToRoute,
52
- pageBaselinePath: () => pageBaselinePath,
60
+ mergeStates: () => mergeStates,
53
61
  parseConfig: () => parseConfig,
62
+ parseCoverageBaseline: () => parseCoverageBaseline,
54
63
  pathToId: () => pathToId,
55
64
  renameEntity: () => renameEntity,
56
65
  resolve: () => resolve2,
57
66
  resolveGitContext: () => resolveGitContext,
67
+ resolveWorkspace: () => resolveWorkspace,
68
+ routeAcknowledge: () => routeAcknowledge,
58
69
  runCli: () => run,
59
70
  runScan: () => runScan,
60
71
  scaffoldSpec: () => scaffoldSpec,
61
72
  scaffoldWidgetSpec: () => scaffoldWidgetSpec,
73
+ serializeCoverageBaseline: () => serializeCoverageBaseline,
62
74
  validateConfig: () => validateConfig,
63
75
  walk: () => walk,
64
76
  writeScanResult: () => writeScanResult
@@ -94,10 +106,12 @@ var ALLOWED_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
94
106
  "exclude",
95
107
  "output",
96
108
  "flows",
109
+ "resolve",
97
110
  "audit",
98
111
  "conventions",
99
112
  "statesManifest",
100
- "coverageBaseline"
113
+ "coverageBaseline",
114
+ "workspace"
101
115
  ]);
102
116
  var ALLOWED_SOURCE_KEYS = /* @__PURE__ */ new Set(["rootDir", "include", "exclude", "prefix"]);
103
117
  var ALLOWED_CONVENTIONS_KEYS = /* @__PURE__ */ new Set([
@@ -111,21 +125,74 @@ var ALLOWED_AUDIT_KEYS = /* @__PURE__ */ new Set([
111
125
  "scopeLeak",
112
126
  "coverage",
113
127
  "acceptance",
128
+ "surfaces",
129
+ "requireAcceptance",
114
130
  "states",
115
131
  "pageCoverage",
116
- "stateMatrix"
132
+ "stateMatrix",
133
+ "severity"
117
134
  ]);
135
+ var DIAGNOSTIC_CODES = [
136
+ "acceptance-ambiguous",
137
+ "acceptance-duplicate-id",
138
+ "acceptance-orphaned",
139
+ "acceptance-outdated",
140
+ "acceptance-uncovered",
141
+ "acknowledged-elsewhere",
142
+ "competing-uidex-export",
143
+ "component-state-uncaptured",
144
+ "core-kind",
145
+ "covers-missing-rev",
146
+ "duplicate-id",
147
+ "dynamic-attr",
148
+ "dynamic-flow-reference",
149
+ "feature-missing-acceptance",
150
+ "feature-surface-uncaptured",
151
+ "feature-surface-unreferenced",
152
+ "gen-missing",
153
+ "gen-stale",
154
+ "matrix-backlog",
155
+ "missing-element-annotation",
156
+ "missing-state-capture",
157
+ "orphan-state-capture",
158
+ "page-backlog",
159
+ "page-captured",
160
+ "page-no-capture",
161
+ "parse-error",
162
+ "placeholder-reason",
163
+ "prefer-well-known-file",
164
+ "rename",
165
+ "scope-leak",
166
+ "spread-attr",
167
+ "stale-acknowledgment",
168
+ "stale-page-baseline",
169
+ "uidex-export-ambiguous-kind",
170
+ "uidex-export-duplicate-field",
171
+ "uidex-export-duplicate-state",
172
+ "uidex-export-empty-name",
173
+ "uidex-export-invalid-field",
174
+ "uidex-export-invalid-literal",
175
+ "uidex-export-missing-kind",
176
+ "uidex-export-satisfies-mismatch",
177
+ "uidex-export-unknown-field",
178
+ "unknown-reference",
179
+ "widget-id-mismatch",
180
+ "widget-missing-acceptance",
181
+ "widget-missing-dom"
182
+ ];
183
+ var DIAGNOSTIC_CODE_SET = new Set(DIAGNOSTIC_CODES);
184
+ var SEVERITY_LEVELS = /* @__PURE__ */ new Set(["error", "warning", "info", "off"]);
118
185
  function fail(msg) {
119
186
  throw new ConfigError(`Invalid .uidex.json: ${msg}`);
120
187
  }
121
- function assertObject(value, path12) {
188
+ function assertObject(value, path14) {
122
189
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
123
- fail(`${path12} must be an object`);
190
+ fail(`${path14} must be an object`);
124
191
  }
125
192
  }
126
- function assertStringArray(value, path12) {
193
+ function assertStringArray(value, path14) {
127
194
  if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
128
- fail(`${path12} must be a string[]`);
195
+ fail(`${path14} must be a string[]`);
129
196
  }
130
197
  }
131
198
  function validateConfig(raw) {
@@ -179,11 +246,47 @@ function validateConfig(raw) {
179
246
  if (raw.coverageBaseline !== void 0 && typeof raw.coverageBaseline !== "string") {
180
247
  fail(`"coverageBaseline" must be a string`);
181
248
  }
249
+ if (raw.workspace !== void 0 && raw.workspace !== false && typeof raw.workspace !== "string") {
250
+ fail(`"workspace" must be a path string or false`);
251
+ }
252
+ if (raw.resolve !== void 0) {
253
+ assertObject(raw.resolve, "resolve");
254
+ for (const key of Object.keys(raw.resolve)) {
255
+ if (key !== "aliases") fail(`unknown key "${key}" in resolve`);
256
+ }
257
+ const aliases = raw.resolve.aliases;
258
+ if (aliases === void 0) {
259
+ fail(`resolve.aliases is required when "resolve" is present`);
260
+ }
261
+ assertObject(aliases, "resolve.aliases");
262
+ for (const [alias, target] of Object.entries(aliases)) {
263
+ if (alias.length === 0) fail(`resolve.aliases keys must be non-empty`);
264
+ if (typeof target !== "string") {
265
+ fail(`resolve.aliases["${alias}"] must be a string`);
266
+ }
267
+ }
268
+ }
182
269
  if (raw.audit !== void 0) {
183
270
  assertObject(raw.audit, "audit");
184
271
  for (const key of Object.keys(raw.audit)) {
185
272
  if (!ALLOWED_AUDIT_KEYS.has(key)) fail(`unknown key "${key}" in audit`);
186
273
  const v = raw.audit[key];
274
+ if (key === "severity") {
275
+ assertObject(v, "audit.severity");
276
+ for (const [code, level] of Object.entries(v)) {
277
+ if (!DIAGNOSTIC_CODE_SET.has(code)) {
278
+ fail(
279
+ `unknown diagnostic code "${code}" in audit.severity (a typo here would silently leave that gate at its default severity)`
280
+ );
281
+ }
282
+ if (typeof level !== "string" || !SEVERITY_LEVELS.has(level)) {
283
+ fail(
284
+ `audit.severity["${code}"] must be one of "error", "warning", "info", "off"`
285
+ );
286
+ }
287
+ }
288
+ continue;
289
+ }
187
290
  if (typeof v !== "boolean") fail(`audit.${key} must be a boolean`);
188
291
  }
189
292
  }
@@ -223,10 +326,12 @@ function validateConfig(raw) {
223
326
  exclude: raw.exclude,
224
327
  output: raw.output,
225
328
  flows: raw.flows,
329
+ resolve: raw.resolve,
226
330
  audit: raw.audit,
227
331
  conventions: raw.conventions,
228
332
  statesManifest: raw.statesManifest,
229
- coverageBaseline: raw.coverageBaseline
333
+ coverageBaseline: raw.coverageBaseline,
334
+ workspace: raw.workspace
230
335
  };
231
336
  }
232
337
  function parseConfig(json) {
@@ -276,9 +381,11 @@ function tryReadConfig(configPath) {
276
381
  function discover(options = {}) {
277
382
  const cwd = options.cwd ?? process.cwd();
278
383
  const maxDepth = options.maxDepth ?? MAX_DEPTH;
384
+ const all = options.all ?? false;
279
385
  const rootMatch = tryReadConfig(path.join(cwd, CONFIG_FILENAME));
280
- if (rootMatch) return [rootMatch];
386
+ if (rootMatch && !all) return [rootMatch];
281
387
  const results = [];
388
+ if (rootMatch) results.push(rootMatch);
282
389
  const queue = [[cwd, 0]];
283
390
  while (queue.length > 0) {
284
391
  const [dir, depth] = queue.shift();
@@ -382,6 +489,26 @@ function toPosix(p2) {
382
489
  function matchesAny(rel, patterns) {
383
490
  return patterns.some((g) => globToRegExp(g).test(rel));
384
491
  }
492
+ function createSourceMatcher(sources, options) {
493
+ const { cwd, globalExcludes = [], includeTests = false } = options;
494
+ const baseDefaults = includeTests ? BASE_EXCLUDES : DEFAULT_EXCLUDES;
495
+ const compiled = sources.map((source) => ({
496
+ absRoot: path2.resolve(cwd, source.rootDir),
497
+ includes: source.include?.length ? source.include : DEFAULT_INCLUDES,
498
+ excludes: [...baseDefaults, ...globalExcludes, ...source.exclude ?? []]
499
+ }));
500
+ return (absFile) => {
501
+ const abs = path2.resolve(absFile);
502
+ return compiled.some(({ absRoot, includes, excludes }) => {
503
+ const rel = toPosix(path2.relative(absRoot, abs));
504
+ if (rel.length === 0 || rel.startsWith("../") || path2.isAbsolute(rel)) {
505
+ return false;
506
+ }
507
+ if (matchesAny(rel, excludes)) return false;
508
+ return matchesAny(rel, includes);
509
+ });
510
+ };
511
+ }
385
512
  function walk(sources, options) {
386
513
  const { cwd, globalExcludes = [], includeTests = false } = options;
387
514
  const out2 = [];
@@ -715,6 +842,109 @@ function createRegistry() {
715
842
  };
716
843
  }
717
844
 
845
+ // src/scanner/scan/acknowledge.ts
846
+ var ACK_ALL = "*";
847
+ var ACK_SCOPES = [ACK_ALL, ...CORE_STATE_KINDS];
848
+ var COMPONENT_ACK_SCOPES = [ACK_ALL];
849
+ var AUTHORABLE_ACK_TYPES = [
850
+ "impossible",
851
+ "backlog"
852
+ ];
853
+ var CORE_SET = new Set(CORE_STATE_KINDS);
854
+ function isCoreKind(scope) {
855
+ return CORE_SET.has(scope);
856
+ }
857
+ function isPlaceholderReason(reason) {
858
+ return typeof reason === "string" && /^\s*TODO\b/i.test(reason);
859
+ }
860
+ function describeAck(a) {
861
+ if (a.type === "captured-elsewhere") {
862
+ return a.by ? `captured-elsewhere (${a.by})` : "captured-elsewhere";
863
+ }
864
+ return a.reason ? `${a.type} ("${a.reason}")` : a.type;
865
+ }
866
+ function originHint(a, page) {
867
+ if (a.origin === "baseline") {
868
+ return "Prune it with `uidex scan --update-baseline` so the backlog reflects reality.";
869
+ }
870
+ if (a.origin === "workspace") {
871
+ return "This is computed from the workspace; it resolves itself once the sibling capture changes.";
872
+ }
873
+ const where = page ? ` on page "${page}"` : "";
874
+ return `Remove the \`acknowledge.${a.scope}\` entry${where} \u2014 a capture contradicts it.`;
875
+ }
876
+ function toResolved(map, origin, into) {
877
+ for (const [scope, ack] of Object.entries(map ?? {})) {
878
+ into.set(scope, { ...ack, scope, origin });
879
+ }
880
+ }
881
+ function routeAcknowledge(registry, baseline, route, page) {
882
+ const out2 = /* @__PURE__ */ new Map();
883
+ toResolved(baseline?.acknowledge?.[route], "baseline", out2);
884
+ if (page) {
885
+ toResolved(registry.get("page", page)?.meta?.acknowledge, "source", out2);
886
+ }
887
+ return out2;
888
+ }
889
+ function entityAcknowledge(meta, capturedElsewhereBy) {
890
+ const source = meta?.acknowledge?.[ACK_ALL];
891
+ if (source) return { ...source, scope: ACK_ALL, origin: "source" };
892
+ if (capturedElsewhereBy) {
893
+ return {
894
+ type: "captured-elsewhere",
895
+ by: capturedElsewhereBy,
896
+ scope: ACK_ALL,
897
+ origin: "workspace"
898
+ };
899
+ }
900
+ return void 0;
901
+ }
902
+ function readAck(raw) {
903
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
904
+ const r = raw;
905
+ if (r.type !== "impossible" && r.type !== "backlog") return void 0;
906
+ const out2 = { type: r.type };
907
+ if (typeof r.reason === "string" && r.reason.length > 0) out2.reason = r.reason;
908
+ return out2;
909
+ }
910
+ function parseCoverageBaseline(raw) {
911
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
912
+ const r = raw;
913
+ const ackRaw = r.acknowledge;
914
+ if (!ackRaw || typeof ackRaw !== "object" || Array.isArray(ackRaw))
915
+ return null;
916
+ const acknowledge = {};
917
+ for (const [route, scopesRaw] of Object.entries(
918
+ ackRaw
919
+ )) {
920
+ if (!scopesRaw || typeof scopesRaw !== "object" || Array.isArray(scopesRaw))
921
+ continue;
922
+ const scopes = {};
923
+ for (const [scope, ackVal] of Object.entries(
924
+ scopesRaw
925
+ )) {
926
+ if (scope !== ACK_ALL && !isCoreKind(scope)) continue;
927
+ const ack = readAck(ackVal);
928
+ if (ack) scopes[scope] = ack;
929
+ }
930
+ if (Object.keys(scopes).length > 0) acknowledge[route] = scopes;
931
+ }
932
+ return { version: 2, acknowledge };
933
+ }
934
+ function serializeCoverageBaseline(baseline) {
935
+ const routes = Object.keys(baseline.acknowledge).sort();
936
+ const ordered = {};
937
+ for (const route of routes) {
938
+ const scopes = baseline.acknowledge[route];
939
+ const sorted = {};
940
+ for (const scope of ACK_SCOPES) {
941
+ if (scopes[scope]) sorted[scope] = scopes[scope];
942
+ }
943
+ ordered[route] = sorted;
944
+ }
945
+ return JSON.stringify({ version: 2, acknowledge: ordered }, null, 2) + "\n";
946
+ }
947
+
718
948
  // src/scanner/scan/extract-uidex-export.ts
719
949
  var KIND_DISCRIMINATORS = [
720
950
  "page",
@@ -733,33 +963,44 @@ var ALLOWED_FIELDS = {
733
963
  "widgets",
734
964
  "acceptance",
735
965
  "states",
736
- "capture",
737
- "waivers",
966
+ "acknowledge",
738
967
  "description"
739
968
  ]),
740
- // `capture` / `waivers` are route-scoped, and only a page maps to a route — so
741
- // they are page-only (the gates read them off the page). A feature/widget that
742
- // sets them gets an `uidex-export-unknown-field` error rather than a silent no-op.
969
+ // A page's `acknowledge` accepts both scope shapes: `"*"` (the whole route)
970
+ // and a core kind (one cell of its matrix). A feature/widget has no route, so
971
+ // the core-kind matrix is meaningless there and only `"*"` is accepted —
972
+ // giving a component a scoped opt-out instead of forcing you to delete its
973
+ // whole `states` block.
743
974
  feature: /* @__PURE__ */ new Set([
744
975
  "feature",
745
976
  "name",
746
977
  "features",
978
+ "surfaces",
747
979
  "acceptance",
748
980
  "states",
981
+ "acknowledge",
749
982
  "description"
750
983
  ]),
751
984
  primitive: /* @__PURE__ */ new Set(["primitive", "name", "description"]),
752
- widget: /* @__PURE__ */ new Set(["widget", "name", "acceptance", "states", "description"]),
985
+ widget: /* @__PURE__ */ new Set([
986
+ "widget",
987
+ "name",
988
+ "acceptance",
989
+ "states",
990
+ "acknowledge",
991
+ "description"
992
+ ]),
753
993
  region: /* @__PURE__ */ new Set(["region", "name", "description"]),
754
994
  flow: /* @__PURE__ */ new Set(["flow", "notFlow", "name", "description"])
755
995
  };
996
+ var ACK_FIELDS = /* @__PURE__ */ new Set(["type", "reason"]);
997
+ var ACCEPTANCE_FIELDS = /* @__PURE__ */ new Set(["id", "rev", "text"]);
756
998
  var STATE_FIELDS = /* @__PURE__ */ new Set([
757
999
  "name",
758
1000
  "kind",
759
1001
  "acceptance",
760
1002
  "description"
761
1003
  ]);
762
- var CORE_STATE_KINDS2 = new Set(CORE_STATE_KINDS);
763
1004
  var STATE_KINDS = /* @__PURE__ */ new Set([...CORE_STATE_KINDS, "variant"]);
764
1005
  var FALSEABLE = /* @__PURE__ */ new Set([
765
1006
  "page",
@@ -1003,7 +1244,8 @@ function objectLit(node, content, p2, pos, span) {
1003
1244
  key,
1004
1245
  value,
1005
1246
  keyPos,
1006
- span: removalSpan(content, prop.start, prop.end)
1247
+ span: removalSpan(content, prop.start, prop.end),
1248
+ propSpan: { start: prop.start, end: prop.end }
1007
1249
  });
1008
1250
  }
1009
1251
  return { kind: "object", entries, pos, span };
@@ -1124,7 +1366,8 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
1124
1366
  } else {
1125
1367
  id = readIdField(idValue, kind, idField);
1126
1368
  }
1127
- const acceptance = readStringArrayField(byKey, "acceptance")?.values;
1369
+ const criterionIds = /* @__PURE__ */ new Set();
1370
+ const acceptance = readAcceptanceField(byKey, criterionIds);
1128
1371
  const description = readStringField(byKey, "description");
1129
1372
  const name = readStringField(byKey, "name");
1130
1373
  if (name === "") {
@@ -1150,9 +1393,10 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
1150
1393
  }
1151
1394
  const featuresField = kind === "page" || kind === "feature" ? readStringArrayField(byKey, "features") : void 0;
1152
1395
  const widgetsField = kind === "page" ? readStringArrayField(byKey, "widgets") : void 0;
1153
- const states = kind === "page" || kind === "feature" || kind === "widget" ? readStatesField(byKey) : void 0;
1154
- const capture = kind === "page" ? readBooleanField(byKey, "capture") : void 0;
1155
- const waivers = kind === "page" ? readWaiversField(byKey) : void 0;
1396
+ const surfacesField = kind === "feature" ? readStringArrayField(byKey, "surfaces") : void 0;
1397
+ const states = kind === "page" || kind === "feature" || kind === "widget" ? readStatesField(byKey, criterionIds) : void 0;
1398
+ const stateBearing = kind === "page" || kind === "feature" || kind === "widget";
1399
+ const acknowledge = stateBearing ? readAcknowledgeField(byKey, kind === "page") : void 0;
1156
1400
  const notFlow = kind === "flow" && discriminator === "notFlow" ? true : void 0;
1157
1401
  const metadata = {
1158
1402
  source: "ts-export",
@@ -1175,16 +1419,26 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
1175
1419
  metadata.widgets = widgetsField.values;
1176
1420
  metadata.widgetSpans = widgetsField.spans;
1177
1421
  }
1422
+ if (surfacesField) {
1423
+ metadata.surfaces = surfacesField.values;
1424
+ metadata.surfaceSpans = surfacesField.spans;
1425
+ }
1178
1426
  if (states?.length) metadata.states = states;
1179
- if (capture !== void 0) metadata.capture = capture;
1180
- if (waivers && Object.keys(waivers).length > 0) metadata.waivers = waivers;
1427
+ if (acknowledge && Object.keys(acknowledge).length > 0) {
1428
+ metadata.acknowledge = acknowledge;
1429
+ }
1181
1430
  if (notFlow) metadata.notFlow = true;
1182
1431
  if (typeof id === "string" && idValue.kind === "string") {
1183
1432
  metadata.idSpan = idValue.span;
1184
1433
  }
1185
1434
  const fieldSpans = {};
1186
- for (const entry of value.entries) fieldSpans[entry.key] = entry.span;
1435
+ const fieldPropSpans = {};
1436
+ for (const entry of value.entries) {
1437
+ fieldSpans[entry.key] = entry.span;
1438
+ fieldPropSpans[entry.key] = entry.propSpan;
1439
+ }
1187
1440
  metadata.fieldSpans = fieldSpans;
1441
+ metadata.fieldPropSpans = fieldPropSpans;
1188
1442
  return metadata;
1189
1443
  }
1190
1444
  function readIdField(value, kind, fieldName) {
@@ -1214,47 +1468,185 @@ function readIdField(value, kind, fieldName) {
1214
1468
  value.pos
1215
1469
  );
1216
1470
  }
1217
- function readWaiversField(byKey) {
1218
- const entry = byKey.get("waivers");
1471
+ function readAcknowledgeField(byKey, isPage) {
1472
+ const entry = byKey.get("acknowledge");
1219
1473
  if (!entry) return void 0;
1220
1474
  if (entry.value.kind !== "object") {
1221
1475
  throw new ExtractError(
1222
1476
  "uidex-export-invalid-field",
1223
- "`waivers` must be an object mapping a core kind to a reason.",
1477
+ '`acknowledge` must be an object mapping a scope (`"*"` or a core kind) to `{ type, reason? }`.',
1224
1478
  entry.value.pos
1225
1479
  );
1226
1480
  }
1227
- const waivers = {};
1481
+ const out2 = {};
1228
1482
  for (const e of entry.value.entries) {
1229
- if (!CORE_STATE_KINDS2.has(e.key)) {
1483
+ const scopes = isPage ? ACK_SCOPES : COMPONENT_ACK_SCOPES;
1484
+ if (!scopes.includes(e.key)) {
1230
1485
  throw new ExtractError(
1231
1486
  "uidex-export-invalid-field",
1232
- `waiver key "${e.key}" is not a core kind; only ${[...CORE_STATE_KINDS2].join(", ")} participate in the matrix.`,
1487
+ `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)"}.`,
1233
1488
  e.keyPos
1234
1489
  );
1235
1490
  }
1236
- if (e.value.kind !== "string" || e.value.value.length === 0) {
1491
+ if (e.value.kind !== "object") {
1492
+ throw new ExtractError(
1493
+ "uidex-export-invalid-field",
1494
+ `acknowledge "${e.key}" must be an object \`{ type, reason? }\`.`,
1495
+ e.value.pos
1496
+ );
1497
+ }
1498
+ const fieldByKey = /* @__PURE__ */ new Map();
1499
+ for (const f of e.value.entries) {
1500
+ if (!ACK_FIELDS.has(f.key)) {
1501
+ throw new ExtractError(
1502
+ "uidex-export-invalid-field",
1503
+ `Unknown field "${f.key}" in an \`acknowledge\` entry. Allowed: ${[...ACK_FIELDS].sort().join(", ")}.`,
1504
+ f.value.pos
1505
+ );
1506
+ }
1507
+ fieldByKey.set(f.key, f);
1508
+ }
1509
+ const type = readStringField(fieldByKey, "type");
1510
+ if (type === void 0) {
1237
1511
  throw new ExtractError(
1238
1512
  "uidex-export-invalid-field",
1239
- `waiver "${e.key}" needs a non-empty reason (why the route cannot render this kind).`,
1513
+ `acknowledge "${e.key}" needs a \`type\`: ${AUTHORABLE_ACK_TYPES.map((t) => `"${t}"`).join(" or ")}.`,
1240
1514
  e.value.pos
1241
1515
  );
1242
1516
  }
1243
- waivers[e.key] = e.value.value;
1517
+ if (type === "captured-elsewhere") {
1518
+ throw new ExtractError(
1519
+ "uidex-export-invalid-field",
1520
+ '`type: "captured-elsewhere"` cannot be authored; the workspace computes it from the sibling app that captured the declaration.',
1521
+ fieldByKey.get("type").value.pos,
1522
+ "Remove the entry \u2014 if a sibling app really captures this, the workspace reports it as `acknowledged-elsewhere` on its own."
1523
+ );
1524
+ }
1525
+ if (type !== "impossible" && type !== "backlog") {
1526
+ throw new ExtractError(
1527
+ "uidex-export-invalid-field",
1528
+ `acknowledge \`type\` must be ${AUTHORABLE_ACK_TYPES.map((t) => `"${t}"`).join(" or ")} (got "${type}").`,
1529
+ fieldByKey.get("type").value.pos
1530
+ );
1531
+ }
1532
+ const reason = readStringField(fieldByKey, "reason");
1533
+ if (type === "impossible" && (reason === void 0 || reason.length === 0)) {
1534
+ throw new ExtractError(
1535
+ "uidex-export-invalid-field",
1536
+ `acknowledge "${e.key}" is \`type: "impossible"\` and needs a non-empty \`reason\` (why this can NEVER be rendered/captured).`,
1537
+ e.value.pos,
1538
+ 'If it merely has not been captured yet, that is `type: "backlog"` \u2014 which needs no reason.'
1539
+ );
1540
+ }
1541
+ out2[e.key] = reason ? { type, reason } : { type };
1244
1542
  }
1245
- return waivers;
1543
+ return out2;
1246
1544
  }
1247
- function readBooleanField(byKey, name) {
1248
- const entry = byKey.get(name);
1545
+ function acceptanceSlug(text) {
1546
+ return text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).slice(0, 8).join("-");
1547
+ }
1548
+ function readAcceptanceField(byKey, seen) {
1549
+ const entry = byKey.get("acceptance");
1249
1550
  if (!entry) return void 0;
1250
- if (entry.value.kind !== "boolean") {
1551
+ return readAcceptanceItems(entry, seen);
1552
+ }
1553
+ function readAcceptanceItems(entry, seen) {
1554
+ if (entry.value.kind !== "array") {
1251
1555
  throw new ExtractError(
1252
1556
  "uidex-export-invalid-field",
1253
- `\`${name}\` must be a boolean.`,
1557
+ "`acceptance` must be an array of strings or `{ id, rev?, text }` objects.",
1254
1558
  entry.value.pos
1255
1559
  );
1256
1560
  }
1257
- return entry.value.value;
1561
+ const criteria = [];
1562
+ for (const item of entry.value.items) {
1563
+ let criterion;
1564
+ if (item.kind === "string") {
1565
+ if (item.value.length === 0) {
1566
+ throw new ExtractError(
1567
+ "uidex-export-invalid-field",
1568
+ "An `acceptance` entry is an empty string.",
1569
+ item.pos
1570
+ );
1571
+ }
1572
+ const id = acceptanceSlug(item.value);
1573
+ if (!id) {
1574
+ throw new ExtractError(
1575
+ "uidex-export-invalid-field",
1576
+ `Acceptance text "${item.value}" contains no alphanumeric characters to derive an id from; use the object form with an explicit \`id\`.`,
1577
+ item.pos
1578
+ );
1579
+ }
1580
+ criterion = { id, rev: 1, text: item.value };
1581
+ } else if (item.kind === "object") {
1582
+ const fieldByKey = /* @__PURE__ */ new Map();
1583
+ for (const e of item.entries) {
1584
+ if (!ACCEPTANCE_FIELDS.has(e.key)) {
1585
+ throw new ExtractError(
1586
+ "uidex-export-invalid-field",
1587
+ `Unknown field "${e.key}" in an \`acceptance\` entry. Allowed: ${[
1588
+ ...ACCEPTANCE_FIELDS
1589
+ ].sort().join(", ")}.`,
1590
+ e.value.pos
1591
+ );
1592
+ }
1593
+ fieldByKey.set(e.key, e);
1594
+ }
1595
+ const id = readStringField(fieldByKey, "id");
1596
+ if (id === void 0 || id.length === 0) {
1597
+ throw new ExtractError(
1598
+ "uidex-export-invalid-field",
1599
+ "An object `acceptance` entry needs a non-empty string `id`.",
1600
+ item.pos
1601
+ );
1602
+ }
1603
+ if (id.includes("@")) {
1604
+ throw new ExtractError(
1605
+ "uidex-export-invalid-field",
1606
+ `acceptance id "${id}" must not contain "@" \u2014 it is reserved for the \`@<rev>\` pin in \`uidexCovers\` claims.`,
1607
+ item.pos
1608
+ );
1609
+ }
1610
+ const text = readStringField(fieldByKey, "text");
1611
+ if (text === void 0 || text.length === 0) {
1612
+ throw new ExtractError(
1613
+ "uidex-export-invalid-field",
1614
+ `acceptance "${id}" needs a non-empty string \`text\`.`,
1615
+ item.pos
1616
+ );
1617
+ }
1618
+ let rev = 1;
1619
+ const revEntry = fieldByKey.get("rev");
1620
+ if (revEntry) {
1621
+ if (revEntry.value.kind !== "number" || !Number.isInteger(revEntry.value.value) || revEntry.value.value < 1) {
1622
+ throw new ExtractError(
1623
+ "uidex-export-invalid-field",
1624
+ `acceptance "${id}" \`rev\` must be a positive integer.`,
1625
+ revEntry.value.pos
1626
+ );
1627
+ }
1628
+ rev = revEntry.value.value;
1629
+ }
1630
+ criterion = { id, rev, text };
1631
+ } else {
1632
+ throw new ExtractError(
1633
+ "uidex-export-invalid-field",
1634
+ "`acceptance` items must be strings or `{ id, rev?, text }` objects.",
1635
+ item.pos
1636
+ );
1637
+ }
1638
+ if (seen.has(criterion.id)) {
1639
+ throw new ExtractError(
1640
+ "acceptance-duplicate-id",
1641
+ `Duplicate acceptance criterion id "${criterion.id}"; each criterion id must be unique within the entity.`,
1642
+ item.pos,
1643
+ "Give the criterion an explicit distinct `id` (object form) \u2014 two string criteria can slug to the same id."
1644
+ );
1645
+ }
1646
+ seen.add(criterion.id);
1647
+ criteria.push(criterion);
1648
+ }
1649
+ return criteria;
1258
1650
  }
1259
1651
  function readStringField(byKey, name) {
1260
1652
  const entry = byKey.get(name);
@@ -1293,7 +1685,7 @@ function readStringArrayField(byKey, name) {
1293
1685
  }
1294
1686
  return { values, spans };
1295
1687
  }
1296
- function readStatesField(byKey) {
1688
+ function readStatesField(byKey, criterionIds) {
1297
1689
  const entry = byKey.get("states");
1298
1690
  if (!entry) return void 0;
1299
1691
  if (entry.value.kind !== "array") {
@@ -1343,8 +1735,11 @@ function readStatesField(byKey) {
1343
1735
  }
1344
1736
  state.kind = kind;
1345
1737
  }
1346
- const acceptance = readStringArrayField(fieldByKey, "acceptance")?.values;
1347
- if (acceptance) state.acceptance = acceptance;
1738
+ const accEntry = fieldByKey.get("acceptance");
1739
+ if (accEntry) {
1740
+ const acceptance = readAcceptanceItems(accEntry, criterionIds);
1741
+ if (acceptance.length > 0) state.acceptance = acceptance;
1742
+ }
1348
1743
  const description = readStringField(fieldByKey, "description");
1349
1744
  if (description) state.description = description;
1350
1745
  } else {
@@ -1400,20 +1795,21 @@ function isNode2(value) {
1400
1795
  }
1401
1796
 
1402
1797
  // src/scanner/scan/flow-facts.ts
1403
- function collectFlowFacts(parsed, content) {
1798
+ function collectFlowFacts(parsed, content, displayPath) {
1404
1799
  if (parsed.program === null || !content.includes("test.describe")) {
1405
- return [];
1800
+ return { facts: [], diagnostics: [] };
1406
1801
  }
1407
1802
  const facts = [];
1803
+ const diagnostics = [];
1408
1804
  walkAst(parsed.program, (node) => {
1409
1805
  if (node.type !== "CallExpression") return void 0;
1410
- const fact = readTaggedDescribe(node, parsed);
1806
+ const fact = readTaggedDescribe(node, parsed, diagnostics, displayPath);
1411
1807
  if (fact) facts.push(fact);
1412
1808
  return void 0;
1413
1809
  });
1414
- return facts;
1810
+ return { facts, diagnostics };
1415
1811
  }
1416
- function readTaggedDescribe(call, parsed) {
1812
+ function readTaggedDescribe(call, parsed, diagnostics, displayPath) {
1417
1813
  const callee = call.callee;
1418
1814
  if (!callee || callee.type !== "MemberExpression" || !isIdentifier(callee.object, "test") || !isIdentifier(callee.property, "describe")) {
1419
1815
  return null;
@@ -1424,13 +1820,83 @@ function readTaggedDescribe(call, parsed) {
1424
1820
  if (!hasFlowTag(args[1])) return null;
1425
1821
  const body = args[2];
1426
1822
  const { calls, dynamicCalls } = body ? collectUidexCalls(body, parsed) : { calls: [], dynamicCalls: [] };
1823
+ const covers = body ? collectCoversClaims(body, parsed, title, diagnostics, displayPath) : [];
1427
1824
  return {
1428
1825
  title,
1429
1826
  line: parsed.lineAt(call.start),
1430
1827
  calls,
1431
- ...dynamicCalls.length > 0 ? { dynamicCalls } : {}
1828
+ ...dynamicCalls.length > 0 ? { dynamicCalls } : {},
1829
+ ...covers.length > 0 ? { covers } : {}
1432
1830
  };
1433
1831
  }
1832
+ function collectCoversClaims(body, parsed, flowTitle, diagnostics, displayPath) {
1833
+ const covers = [];
1834
+ walkAst(body, (node) => {
1835
+ if (node.type !== "CallExpression") return void 0;
1836
+ if (!isIdentifier(node.callee, "uidexCovers")) return void 0;
1837
+ const line = parsed.lineAt(node.start);
1838
+ const args = node.arguments ?? [];
1839
+ const entity = stringLiteralValue(args[0]);
1840
+ if (entity === null) {
1841
+ diagnostics.push({
1842
+ code: "covers-missing-rev",
1843
+ severity: "error",
1844
+ message: `\`uidexCovers(\u2026)\` in flow "${flowTitle}" needs a static string entity id as its first argument.`,
1845
+ file: displayPath,
1846
+ line,
1847
+ hint: 'Write `uidexCovers("<entityId>", "<criterionId>@<rev>")` with string literals.'
1848
+ });
1849
+ return void 0;
1850
+ }
1851
+ if (args.length < 2) {
1852
+ diagnostics.push({
1853
+ code: "covers-missing-rev",
1854
+ severity: "error",
1855
+ message: `\`uidexCovers("${entity}")\` in flow "${flowTitle}" names no criterion; a claim covers nothing without at least one \`"<criterionId>@<rev>"\` argument.`,
1856
+ file: displayPath,
1857
+ line,
1858
+ hint: 'List the criteria this flow verifies: `uidexCovers("<entityId>", "<criterionId>@<rev>", ...)`.'
1859
+ });
1860
+ return void 0;
1861
+ }
1862
+ for (const arg of args.slice(1)) {
1863
+ const claimLine = parsed.lineAt(arg.start);
1864
+ const raw = stringLiteralValue(arg);
1865
+ const at = raw === null ? -1 : raw.lastIndexOf("@");
1866
+ if (raw === null || at <= 0) {
1867
+ diagnostics.push({
1868
+ code: "covers-missing-rev",
1869
+ severity: "error",
1870
+ message: `\`uidexCovers(\u2026)\` claim ${raw === null ? "" : `"${raw}" `}in flow "${flowTitle}" is missing its \`@<rev>\` pin.`,
1871
+ file: displayPath,
1872
+ line: claimLine,
1873
+ hint: 'Pin the criterion rev \u2014 `uidexCovers("<entityId>", "<criterionId>@<rev>")` \u2014 so a later rev bump surfaces this claim as outdated.'
1874
+ });
1875
+ continue;
1876
+ }
1877
+ const revStr = raw.slice(at + 1);
1878
+ if (!/^[1-9]\d*$/.test(revStr)) {
1879
+ diagnostics.push({
1880
+ code: "covers-missing-rev",
1881
+ severity: "error",
1882
+ message: `\`uidexCovers(\u2026)\` claim "${raw}" in flow "${flowTitle}" has an invalid rev "@${revStr}"; the rev must be a positive integer (no leading zeros).`,
1883
+ file: displayPath,
1884
+ line: claimLine,
1885
+ hint: 'Write the pin as `"<criterionId>@<rev>"` with a positive integer rev, e.g. `"gated@1"`.'
1886
+ });
1887
+ continue;
1888
+ }
1889
+ covers.push({
1890
+ entity,
1891
+ criterion: raw.slice(0, at),
1892
+ rev: Number(revStr),
1893
+ line: claimLine
1894
+ });
1895
+ }
1896
+ return void 0;
1897
+ });
1898
+ return covers;
1899
+ }
1434
1900
  function hasFlowTag(node) {
1435
1901
  if (!node || node.type !== "ObjectExpression") return false;
1436
1902
  for (const prop of node.properties ?? []) {
@@ -1822,11 +2288,13 @@ function extract(files) {
1822
2288
  const out2 = { file, annotations: [] };
1823
2289
  out2.annotations = extractOne(file, parsed, out2);
1824
2290
  if (exports2.length > 0) out2.metadata = exports2;
2291
+ const flowFacts = collectFlowFacts(parsed, file.content, file.displayPath);
2292
+ diagnostics.push(...flowFacts.diagnostics);
1825
2293
  if (diagnostics.length > 0) out2.diagnostics = diagnostics;
1826
- const flows = collectFlowFacts(parsed, file.content);
1827
- if (flows.length > 0) out2.flows = flows;
2294
+ if (flowFacts.facts.length > 0) out2.flows = flowFacts.facts;
1828
2295
  const imports = collectImportFacts(parsed);
1829
2296
  if (imports.length > 0) out2.imports = imports;
2297
+ if (containsJsx(parsed)) out2.hasJsx = true;
1830
2298
  return out2;
1831
2299
  });
1832
2300
  }
@@ -1851,6 +2319,32 @@ function extractOne(file, parsed, out2) {
1851
2319
  }
1852
2320
  return annotations;
1853
2321
  }
2322
+ function containsJsx(parsed) {
2323
+ if (parsed.program === null) return false;
2324
+ let found = false;
2325
+ walkAst(parsed.program, (node) => {
2326
+ if (node.type === "JSXElement" || node.type === "JSXFragment") {
2327
+ found = true;
2328
+ return false;
2329
+ }
2330
+ if (node.type === "CallExpression" && isCreateElementCallee(node.callee)) {
2331
+ found = true;
2332
+ return false;
2333
+ }
2334
+ return void 0;
2335
+ });
2336
+ return found;
2337
+ }
2338
+ function isCreateElementCallee(callee) {
2339
+ const node = callee;
2340
+ if (!node) return false;
2341
+ if (node.type === "Identifier") return String(node.name) === "createElement";
2342
+ if (node.type === "MemberExpression") {
2343
+ const prop = node.property;
2344
+ return prop?.type === "Identifier" && String(prop.name) === "createElement";
2345
+ }
2346
+ return false;
2347
+ }
1854
2348
  function collectImportFacts(parsed) {
1855
2349
  if (parsed.program === null) return [];
1856
2350
  const out2 = [];
@@ -1883,6 +2377,21 @@ function collectImportFacts(parsed) {
1883
2377
  names
1884
2378
  });
1885
2379
  }
2380
+ walkAst(parsed.program, (node) => {
2381
+ if (node.type !== "ImportExpression") return void 0;
2382
+ const source = node.source;
2383
+ if (!source || source.type !== "Literal") return void 0;
2384
+ if (typeof source.value !== "string") return void 0;
2385
+ out2.push({
2386
+ specifier: source.value,
2387
+ line: parsed.lineAt(node.start),
2388
+ span: { start: node.start, end: node.end },
2389
+ isTypeOnly: false,
2390
+ names: [],
2391
+ dynamic: true
2392
+ });
2393
+ return void 0;
2394
+ });
1886
2395
  return out2;
1887
2396
  }
1888
2397
 
@@ -2054,6 +2563,7 @@ function buildMetaFromExport(exp) {
2054
2563
  if (exp.acceptance?.length) meta.acceptance = exp.acceptance;
2055
2564
  if (exp.features?.length) meta.features = exp.features;
2056
2565
  if (exp.widgets?.length) meta.widgets = exp.widgets;
2566
+ if (exp.surfaces?.length) meta.surfaces = exp.surfaces;
2057
2567
  if (exp.states?.length) {
2058
2568
  meta.states = exp.states.map((s) => ({
2059
2569
  name: s.name,
@@ -2062,9 +2572,8 @@ function buildMetaFromExport(exp) {
2062
2572
  ...s.description ? { description: s.description } : {}
2063
2573
  }));
2064
2574
  }
2065
- if (exp.capture === false) meta.capture = false;
2066
- if (exp.waivers && Object.keys(exp.waivers).length > 0) {
2067
- meta.waivers = exp.waivers;
2575
+ if (exp.acknowledge && Object.keys(exp.acknowledge).length > 0) {
2576
+ meta.acknowledge = exp.acknowledge;
2068
2577
  }
2069
2578
  return Object.keys(meta).length > 0 ? meta : void 0;
2070
2579
  }
@@ -2409,6 +2918,45 @@ function resolve2(ctx) {
2409
2918
  };
2410
2919
  registry.add(element);
2411
2920
  }
2921
+ const ancestorsById = /* @__PURE__ */ new Map();
2922
+ const fileById = /* @__PURE__ */ new Map();
2923
+ for (const a of allAnnotations) {
2924
+ if (!fileById.has(a.id)) fileById.set(a.id, a.file);
2925
+ if (a.ancestors?.length) {
2926
+ const prev = ancestorsById.get(a.id) ?? [];
2927
+ ancestorsById.set(a.id, [...prev, ...a.ancestors.map((x) => x.id)]);
2928
+ }
2929
+ }
2930
+ const ownersByFile = /* @__PURE__ */ new Map();
2931
+ const addOwner = (file, id) => {
2932
+ ownersByFile.set(file, [...ownersByFile.get(file) ?? [], id]);
2933
+ };
2934
+ const featureDirs = [];
2935
+ for (const f of registry.list("feature")) {
2936
+ const loc = f.loc?.file;
2937
+ if (!loc) continue;
2938
+ const dir = loc.endsWith("/") ? loc.slice(0, -1) : path4.posix.dirname(loc);
2939
+ featureDirs.push({ dir, id: f.id });
2940
+ }
2941
+ for (const p2 of registry.list("page")) {
2942
+ if (p2.loc?.file) addOwner(p2.loc.file, p2.id);
2943
+ }
2944
+ const ownersFor = (file) => {
2945
+ const out2 = [...ownersByFile.get(file) ?? []];
2946
+ for (const { dir, id } of featureDirs) {
2947
+ if (file === dir || file.startsWith(`${dir}/`)) out2.push(id);
2948
+ }
2949
+ return out2;
2950
+ };
2951
+ const expandTouches = (ids) => {
2952
+ const out2 = new Set(ids);
2953
+ for (const id of ids) {
2954
+ for (const anc of ancestorsById.get(id) ?? []) out2.add(anc);
2955
+ const file = fileById.get(id);
2956
+ if (file) for (const owner of ownersFor(file)) out2.add(owner);
2957
+ }
2958
+ return [...out2];
2959
+ };
2412
2960
  if (ctx.flowFiles && conventions.flows) {
2413
2961
  for (const ff of ctx.flowFiles) {
2414
2962
  const notFlowExport = (ff.metadata ?? []).find(
@@ -2418,7 +2966,10 @@ function resolve2(ctx) {
2418
2966
  const flowExport = (ff.metadata ?? []).find(
2419
2967
  (m) => m.kind === "flow" && typeof m.id === "string"
2420
2968
  );
2421
- const derived = flowsFromFacts(ff);
2969
+ const derived = flowsFromFacts(ff).map((f) => ({
2970
+ ...f,
2971
+ touches: expandTouches(f.touches)
2972
+ }));
2422
2973
  if (flowExport && typeof flowExport.id === "string" && derived.length === 1) {
2423
2974
  const base = derived[0];
2424
2975
  const flow = {
@@ -2426,7 +2977,8 @@ function resolve2(ctx) {
2426
2977
  id: flowExport.id,
2427
2978
  loc: base.loc,
2428
2979
  touches: base.touches,
2429
- steps: base.steps
2980
+ steps: base.steps,
2981
+ ...base.covers ? { covers: base.covers } : {}
2430
2982
  };
2431
2983
  registry.add(flow);
2432
2984
  } else {
@@ -2474,20 +3026,95 @@ function computeScope(displayPath) {
2474
3026
  return null;
2475
3027
  }
2476
3028
  function flowsFromFacts(ff) {
2477
- return (ff.flows ?? []).map((fact) => ({
2478
- kind: "flow",
2479
- id: kebab(fact.title),
2480
- loc: { file: ff.file.displayPath, line: fact.line },
2481
- touches: dedupe(fact.calls.map((c) => c.id)),
2482
- steps: fact.calls.filter((c) => c.action).map((c) => ({ entityId: c.id, action: c.action }))
2483
- }));
3029
+ return (ff.flows ?? []).map((fact) => {
3030
+ const covers = dedupeBy(
3031
+ (fact.covers ?? []).map((c) => ({
3032
+ entity: c.entity,
3033
+ criterion: c.criterion,
3034
+ rev: c.rev
3035
+ })),
3036
+ (c) => `${c.entity} ${c.criterion} ${c.rev}`
3037
+ );
3038
+ return {
3039
+ kind: "flow",
3040
+ id: kebab(fact.title),
3041
+ loc: { file: ff.file.displayPath, line: fact.line },
3042
+ touches: dedupe(fact.calls.map((c) => c.id)),
3043
+ steps: fact.calls.filter((c) => c.action).map((c) => ({ entityId: c.id, action: c.action })),
3044
+ ...covers.length > 0 ? { covers } : {}
3045
+ };
3046
+ });
3047
+ }
3048
+ function dedupeBy(arr, key) {
3049
+ const seen = /* @__PURE__ */ new Set();
3050
+ const out2 = [];
3051
+ for (const item of arr) {
3052
+ const k = key(item);
3053
+ if (seen.has(k)) continue;
3054
+ seen.add(k);
3055
+ out2.push(item);
3056
+ }
3057
+ return out2;
2484
3058
  }
2485
3059
  function dedupe(arr) {
2486
3060
  return Array.from(new Set(arr));
2487
3061
  }
2488
3062
 
2489
3063
  // src/scanner/scan/audit.ts
2490
- var path5 = __toESM(require("path"), 1);
3064
+ var path7 = __toESM(require("path"), 1);
3065
+
3066
+ // src/scanner/scan/component-coverage.ts
3067
+ var COMPOSED_KINDS = ["widget", "feature"];
3068
+ function composedStateEntities(registry, page) {
3069
+ const meta = registry.get("page", page)?.meta;
3070
+ if (!meta) return [];
3071
+ const out2 = [];
3072
+ for (const kind of COMPOSED_KINDS) {
3073
+ const ids = (kind === "widget" ? meta.widgets : meta.features) ?? [];
3074
+ for (const id of ids) {
3075
+ const e = registry.get(kind, id);
3076
+ if (!e?.meta?.states?.length) continue;
3077
+ if (e.meta.acknowledge?.[ACK_ALL]) continue;
3078
+ out2.push({ kind, id });
3079
+ }
3080
+ }
3081
+ return out2;
3082
+ }
3083
+ function capturedStatesFor(captured, kind, id) {
3084
+ const out2 = /* @__PURE__ */ new Set();
3085
+ for (const c of captured) {
3086
+ if (c.entity !== id) continue;
3087
+ if (c.kind !== void 0 && c.kind !== kind && c.kind !== "page") continue;
3088
+ out2.add(c.state);
3089
+ }
3090
+ return out2;
3091
+ }
3092
+ function checkComponentCoverage(registry, manifest) {
3093
+ const routes = registry.list("route");
3094
+ if (routes.length === 0) return [];
3095
+ const captured = manifest.captured ?? [];
3096
+ const diagnostics = [];
3097
+ for (const route of routes) {
3098
+ if (registry.get("page", route.page)?.meta?.acknowledge?.[ACK_ALL]) continue;
3099
+ for (const { kind, id } of composedStateEntities(registry, route.page)) {
3100
+ const entity = registry.get(kind, id);
3101
+ const declared = entity.meta.states.map((s) => s.name);
3102
+ const shot = capturedStatesFor(captured, kind, id);
3103
+ const missing = declared.filter((s) => !shot.has(s));
3104
+ if (missing.length === 0) continue;
3105
+ diagnostics.push({
3106
+ code: "component-state-uncaptured",
3107
+ severity: "info",
3108
+ message: `route "${route.path}" composes ${kind} "${id}", whose state(s) [${missing.join(", ")}] no capture produced`,
3109
+ file: entity.loc?.file,
3110
+ line: entity.loc?.line,
3111
+ entity: { kind, id },
3112
+ 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}".`
3113
+ });
3114
+ }
3115
+ }
3116
+ return diagnostics;
3117
+ }
2491
3118
 
2492
3119
  // src/scanner/scan/page-coverage.ts
2493
3120
  function compileRoute(pattern) {
@@ -2510,11 +3137,11 @@ function compileRoute(pattern) {
2510
3137
  return { test: (url) => re.test(url), specificity };
2511
3138
  }
2512
3139
  function matchUrlToRoute(url, routePaths) {
2513
- const path12 = stripQuery(url);
3140
+ const path14 = stripQuery(url);
2514
3141
  let best = null;
2515
3142
  for (const rp of routePaths) {
2516
3143
  const { test, specificity } = compileRoute(rp);
2517
- if (!test(path12)) continue;
3144
+ if (!test(path14)) continue;
2518
3145
  if (!best || specificity > best.specificity)
2519
3146
  best = { path: rp, specificity };
2520
3147
  }
@@ -2545,29 +3172,49 @@ function checkPageCoverage(registry, manifest, baseline) {
2545
3172
  if (routes.length === 0) return [];
2546
3173
  const routePaths = routes.map((r) => r.path);
2547
3174
  const captured = manifest.captured ?? [];
2548
- const optedOut = /* @__PURE__ */ new Set();
2549
- for (const r of routes) {
2550
- const page = registry.get("page", r.page);
2551
- if (page?.meta?.capture === false) optedOut.add(r.path);
2552
- }
2553
3175
  const covered = /* @__PURE__ */ new Set();
2554
3176
  for (const c of captured) {
2555
3177
  const route = routeForCapture(c, routes, routePaths);
2556
3178
  if (route) covered.add(route);
2557
3179
  }
2558
- const baselineSet = new Set(baseline?.uncapturedPages ?? []);
2559
3180
  const diagnostics = [];
2560
3181
  for (const r of routes) {
2561
- if (covered.has(r.path) || optedOut.has(r.path)) continue;
3182
+ const ack = routeAcknowledge(registry, baseline, r.path, r.page).get(
3183
+ ACK_ALL
3184
+ );
2562
3185
  const loc = registry.get("page", r.page)?.loc;
2563
- if (baselineSet.has(r.path)) {
3186
+ if (covered.has(r.path)) {
3187
+ if (ack?.type === "impossible") {
3188
+ diagnostics.push({
3189
+ code: "stale-acknowledgment",
3190
+ severity: "warning",
3191
+ message: `page "${r.page}" acknowledges "*" as ${describeAck(ack)}, but route "${r.path}" is captured`,
3192
+ file: loc?.file,
3193
+ line: loc?.line,
3194
+ entity: { kind: "page", id: r.page },
3195
+ hint: originHint(ack, r.page)
3196
+ });
3197
+ } else if (ack?.type === "backlog") {
3198
+ diagnostics.push({
3199
+ code: "page-captured",
3200
+ severity: "warning",
3201
+ message: `route "${r.path}" is now captured but is still acknowledged as backlog`,
3202
+ file: loc?.file,
3203
+ line: loc?.line,
3204
+ hint: originHint(ack, r.page)
3205
+ });
3206
+ }
3207
+ continue;
3208
+ }
3209
+ if (ack?.type === "impossible") continue;
3210
+ if (ack?.type === "backlog") {
2564
3211
  diagnostics.push({
2565
3212
  code: "page-backlog",
2566
3213
  severity: "info",
2567
3214
  message: `route "${r.path}" has no visual-states capture (acknowledged backlog)`,
2568
3215
  file: loc?.file,
2569
3216
  line: loc?.line,
2570
- 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 }\`.`
3217
+ 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}".`
2571
3218
  });
2572
3219
  continue;
2573
3220
  }
@@ -2578,46 +3225,155 @@ function checkPageCoverage(registry, manifest, baseline) {
2578
3225
  file: loc?.file,
2579
3226
  line: loc?.line,
2580
3227
  entity: { kind: "route", id: r.path },
2581
- 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).`
3228
+ 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).`
3229
+ });
3230
+ }
3231
+ for (const b of Object.keys(baseline?.acknowledge ?? {})) {
3232
+ if (routePaths.includes(b)) continue;
3233
+ diagnostics.push({
3234
+ code: "stale-page-baseline",
3235
+ severity: "warning",
3236
+ message: `coverage baseline acknowledges "${b}", which is not a route in the registry`,
3237
+ hint: `Remove it from the baseline (\`uidex scan --update-baseline\`); the route no longer exists.`
2582
3238
  });
2583
3239
  }
2584
- for (const b of baselineSet) {
2585
- if (!routePaths.includes(b)) {
2586
- diagnostics.push({
2587
- code: "stale-page-baseline",
2588
- severity: "warning",
2589
- message: `coverage baseline lists "${b}", which is not a route in the registry`,
2590
- hint: `Remove it from the baseline (\`uidex scan --update-baseline\`); the route no longer exists.`
2591
- });
2592
- } else if (covered.has(b) || optedOut.has(b)) {
2593
- diagnostics.push({
2594
- code: "page-captured",
2595
- severity: "warning",
2596
- message: `route "${b}" is now captured but is still in the coverage baseline`,
2597
- hint: `Prune it with \`uidex scan --update-baseline\` so the backlog reflects reality.`
2598
- });
2599
- }
2600
- }
2601
3240
  return diagnostics;
2602
3241
  }
2603
- function computePageBaseline(registry, manifest) {
2604
- const uncaptured = [];
3242
+ function computePageBacklog(registry, manifest) {
3243
+ const out2 = {};
2605
3244
  for (const d of checkPageCoverage(registry, manifest, {
2606
- uncapturedPages: []
3245
+ version: 2,
3246
+ acknowledge: {}
2607
3247
  })) {
2608
- if (d.code === "page-no-capture" && d.entity) uncaptured.push(d.entity.id);
3248
+ if (d.code === "page-no-capture" && d.entity) {
3249
+ out2[d.entity.id] = { [ACK_ALL]: { type: "backlog" } };
3250
+ }
3251
+ }
3252
+ return out2;
3253
+ }
3254
+
3255
+ // src/scanner/scan/workspace.ts
3256
+ var fs3 = __toESM(require("fs"), 1);
3257
+ var path5 = __toESM(require("path"), 1);
3258
+ var WORKSPACE_MARKERS = [
3259
+ "pnpm-workspace.yaml",
3260
+ "pnpm-workspace.yml",
3261
+ "turbo.json",
3262
+ "lerna.json",
3263
+ "nx.json",
3264
+ ".git"
3265
+ ];
3266
+ var MAX_ASCENT = 8;
3267
+ var DEFAULT_STATES_MANIFEST = "uidex-states.json";
3268
+ function captureKey(kind, entity) {
3269
+ return `${kind ?? ""}|${entity}`;
3270
+ }
3271
+ function hasMarker(dir) {
3272
+ return WORKSPACE_MARKERS.some((m) => fs3.existsSync(path5.join(dir, m)));
3273
+ }
3274
+ function findWorkspaceRoot(configDir) {
3275
+ let dir = path5.resolve(configDir);
3276
+ for (let i = 0; i < MAX_ASCENT; i++) {
3277
+ const parent = path5.dirname(dir);
3278
+ if (parent === dir) return void 0;
3279
+ dir = parent;
3280
+ if (hasMarker(dir)) return dir;
3281
+ }
3282
+ return void 0;
3283
+ }
3284
+ function captureKeys(manifest) {
3285
+ const out2 = /* @__PURE__ */ new Set();
3286
+ for (const c of manifest?.captured ?? []) {
3287
+ out2.add(captureKey(c.kind, c.entity));
3288
+ }
3289
+ return out2;
3290
+ }
3291
+ function readManifest(configDir, config) {
3292
+ const rel = config.statesManifest ?? DEFAULT_STATES_MANIFEST;
3293
+ let raw;
3294
+ try {
3295
+ raw = fs3.readFileSync(path5.resolve(configDir, rel), "utf8");
3296
+ } catch {
3297
+ return void 0;
3298
+ }
3299
+ try {
3300
+ const parsed = JSON.parse(raw);
3301
+ if (!parsed || !Array.isArray(parsed.captured)) return void 0;
3302
+ const captured = [];
3303
+ for (const entry of parsed.captured) {
3304
+ if (!entry || typeof entry !== "object") continue;
3305
+ const c = entry;
3306
+ if (typeof c.entity !== "string" || typeof c.state !== "string") continue;
3307
+ const rec = { entity: c.entity, state: c.state };
3308
+ if (typeof c.kind === "string") rec.kind = c.kind;
3309
+ captured.push(rec);
3310
+ }
3311
+ return { captured };
3312
+ } catch {
3313
+ return void 0;
3314
+ }
3315
+ }
3316
+ function toApp(dc) {
3317
+ return {
3318
+ name: path5.basename(dc.configDir),
3319
+ configDir: dc.configDir,
3320
+ configPath: dc.configPath,
3321
+ registers: createSourceMatcher(dc.config.sources, {
3322
+ cwd: dc.configDir,
3323
+ globalExcludes: dc.config.exclude
3324
+ }),
3325
+ captured: captureKeys(readManifest(dc.configDir, dc.config))
3326
+ };
3327
+ }
3328
+ function resolveWorkspace(opts) {
3329
+ const { configDir, config } = opts;
3330
+ if (config.workspace === false) return void 0;
3331
+ const root = typeof config.workspace === "string" ? path5.resolve(configDir, config.workspace) : findWorkspaceRoot(configDir);
3332
+ if (!root) return void 0;
3333
+ const configs = opts.configs ?? discover({ cwd: root, all: true });
3334
+ const self = path5.resolve(configDir, CONFIG_FILENAME);
3335
+ const siblings = configs.filter((dc) => path5.resolve(dc.configPath) !== self).map(toApp);
3336
+ if (siblings.length === 0) return void 0;
3337
+ return { root, siblings };
3338
+ }
3339
+ function capturedElsewhere(workspace, absFile, kind, id) {
3340
+ if (!workspace || !absFile) return void 0;
3341
+ for (const app of workspace.siblings) {
3342
+ if (!app.registers(absFile)) continue;
3343
+ if (app.captured.has(captureKey(kind, id)) || app.captured.has(captureKey(void 0, id))) {
3344
+ return app.name;
3345
+ }
2609
3346
  }
2610
- return { uncapturedPages: uncaptured.sort() };
3347
+ return void 0;
2611
3348
  }
2612
3349
 
2613
3350
  // src/scanner/scan/state-coverage.ts
2614
3351
  var STATE_KINDS2 = ["page", "feature", "widget"];
2615
- function declaredStateEntities(registry) {
3352
+ function declaredStateEntities(registry, opts, ungated) {
2616
3353
  const out2 = [];
2617
3354
  for (const kind of STATE_KINDS2) {
2618
3355
  for (const e of registry.list(kind)) {
2619
3356
  const states = e.meta?.states;
2620
3357
  if (!states || states.length === 0) continue;
3358
+ const absFile = e.loc?.file ? opts.resolveSourcePath?.(e.loc.file) : void 0;
3359
+ const ack = entityAcknowledge(
3360
+ e.meta,
3361
+ capturedElsewhere(opts.workspace, absFile, kind, e.id)
3362
+ );
3363
+ if (ack) {
3364
+ if (ack.origin === "workspace") {
3365
+ ungated.push({
3366
+ code: "acknowledged-elsewhere",
3367
+ severity: "info",
3368
+ 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)})`,
3369
+ file: e.loc?.file,
3370
+ line: e.loc?.line,
3371
+ entity: { kind, id: e.id },
3372
+ 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.`
3373
+ });
3374
+ }
3375
+ continue;
3376
+ }
2621
3377
  out2.push({
2622
3378
  kind,
2623
3379
  id: e.id,
@@ -2628,18 +3384,31 @@ function declaredStateEntities(registry) {
2628
3384
  }
2629
3385
  return out2;
2630
3386
  }
2631
- function captureMatchesEntity(cap, ent) {
3387
+ function unreliablePageKinds(registry, captured) {
3388
+ const out2 = /* @__PURE__ */ new Set();
3389
+ for (const c of captured) {
3390
+ if (c.kind !== "page") continue;
3391
+ if (registry.get("page", c.entity)) continue;
3392
+ out2.add(c.entity);
3393
+ }
3394
+ return out2;
3395
+ }
3396
+ function captureMatchesEntity(cap, ent, unreliable = EMPTY) {
2632
3397
  if (cap.entity !== ent.id) return false;
2633
- return cap.kind === void 0 || cap.kind === ent.kind;
3398
+ if (cap.kind === void 0) return true;
3399
+ if (cap.kind === ent.kind) return true;
3400
+ return cap.kind === "page" && unreliable.has(cap.entity);
2634
3401
  }
2635
- function checkStateCoverage(registry, manifest) {
3402
+ var EMPTY = /* @__PURE__ */ new Set();
3403
+ function checkStateCoverage(registry, manifest, opts = {}) {
2636
3404
  const diagnostics = [];
2637
- const declared = declaredStateEntities(registry);
2638
3405
  const captured = manifest.captured ?? [];
3406
+ const unreliable = unreliablePageKinds(registry, captured);
3407
+ const declared = declaredStateEntities(registry, opts, diagnostics);
2639
3408
  for (const ent of declared) {
2640
3409
  for (const state of ent.states) {
2641
3410
  const hit = captured.some(
2642
- (c) => captureMatchesEntity(c, ent) && c.state === state
3411
+ (c) => captureMatchesEntity(c, ent, unreliable) && c.state === state
2643
3412
  );
2644
3413
  if (hit) continue;
2645
3414
  diagnostics.push({
@@ -2654,7 +3423,9 @@ function checkStateCoverage(registry, manifest) {
2654
3423
  }
2655
3424
  }
2656
3425
  for (const cap of captured) {
2657
- const candidates = declared.filter((ent2) => captureMatchesEntity(cap, ent2));
3426
+ const candidates = declared.filter(
3427
+ (ent2) => captureMatchesEntity(cap, ent2, unreliable)
3428
+ );
2658
3429
  if (candidates.length === 0) continue;
2659
3430
  const declaredSomewhere = candidates.some(
2660
3431
  (ent2) => ent2.states.includes(cap.state)
@@ -2698,36 +3469,35 @@ function coreKindsByRoute(registry, manifest) {
2698
3469
  }
2699
3470
  return byRoute;
2700
3471
  }
2701
- function pageWaivers(registry, routePage) {
2702
- return registry.get("page", routePage)?.meta?.waivers ?? {};
2703
- }
2704
- function isOptedOut(registry, page) {
2705
- return page ? registry.get("page", page)?.meta?.capture === false : false;
2706
- }
2707
3472
  function checkStateMatrix(registry, manifest, baseline) {
2708
3473
  const routes = registry.list("route");
2709
3474
  if (routes.length === 0) return [];
2710
3475
  const pageOf = new Map(routes.map((r) => [r.path, r.page]));
2711
3476
  const captured = coreKindsByRoute(registry, manifest);
2712
- const baselined = baseline?.missingKinds ?? {};
2713
3477
  const diagnostics = [];
2714
3478
  for (const [route, kinds] of captured) {
2715
3479
  const page = pageOf.get(route);
2716
- if (isOptedOut(registry, page)) continue;
2717
- const waivers = page ? pageWaivers(registry, page) : {};
3480
+ const ack = routeAcknowledge(registry, baseline, route, page);
2718
3481
  const loc = page ? registry.get("page", page)?.loc : void 0;
2719
- const baselinedKinds = new Set(baselined[route] ?? []);
3482
+ const all = ack.get(ACK_ALL);
3483
+ if (all) {
3484
+ if (all.type === "impossible") {
3485
+ diagnostics.push(staleDiagnostic(all, route, page, loc));
3486
+ }
3487
+ continue;
3488
+ }
2720
3489
  for (const kind of CORE_KINDS) {
2721
3490
  if (kinds.has(kind)) continue;
2722
- if (waivers[kind]) continue;
2723
- if (baselinedKinds.has(kind)) {
3491
+ const entry = ack.get(kind);
3492
+ if (entry?.type === "impossible") continue;
3493
+ if (entry?.type === "backlog") {
2724
3494
  diagnostics.push({
2725
3495
  code: "matrix-backlog",
2726
3496
  severity: "info",
2727
- message: `route "${route}" does not render core kind "${kind}" (acknowledged MISSING_KINDS backlog)`,
3497
+ message: `route "${route}" does not render core kind "${kind}" (acknowledged backlog)`,
2728
3498
  file: loc?.file,
2729
3499
  line: loc?.line,
2730
- hint: `Capture the "${kind}" state for "${route}", waive it (\`waivers: { ${kind}: "why it can't" }\` on the page), or leave it in the baseline.`
3500
+ 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" }\`.`
2731
3501
  });
2732
3502
  continue;
2733
3503
  }
@@ -2738,44 +3508,221 @@ function checkStateMatrix(registry, manifest, baseline) {
2738
3508
  file: loc?.file,
2739
3509
  line: loc?.line,
2740
3510
  entity: { kind: "route", id: route },
2741
- 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).`
3511
+ 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).`
2742
3512
  });
2743
3513
  }
3514
+ for (const [scope, entry] of ack) {
3515
+ if (scope === ACK_ALL || !kinds.has(scope)) continue;
3516
+ diagnostics.push(staleDiagnostic(entry, route, page, loc));
3517
+ }
2744
3518
  }
3519
+ return diagnostics;
3520
+ }
3521
+ function staleDiagnostic(entry, route, page, loc) {
3522
+ const what = entry.scope === ACK_ALL ? `route "${route}" is captured` : `route "${route}" does render core kind "${entry.scope}"`;
3523
+ return {
3524
+ code: "stale-acknowledgment",
3525
+ severity: "warning",
3526
+ message: `${page ? `page "${page}"` : `route "${route}"`} acknowledges "${entry.scope}" as ${describeAck(entry)}, but ${what}`,
3527
+ file: loc?.file,
3528
+ line: loc?.line,
3529
+ ...page ? { entity: { kind: "page", id: page } } : {},
3530
+ hint: originHint(entry, page)
3531
+ };
3532
+ }
3533
+ function computeMatrixBacklog(registry, manifest) {
3534
+ const routes = registry.list("route");
3535
+ const pageOf = new Map(routes.map((r) => [r.path, r.page]));
3536
+ const captured = coreKindsByRoute(registry, manifest);
3537
+ const out2 = {};
2745
3538
  for (const [route, kinds] of captured) {
2746
3539
  const page = pageOf.get(route);
2747
- if (!page || isOptedOut(registry, page)) continue;
2748
- const waivers = pageWaivers(registry, page);
2749
- const loc = registry.get("page", page)?.loc;
2750
- for (const kind of Object.keys(waivers)) {
2751
- if (kinds.has(kind)) {
3540
+ const ack = routeAcknowledge(registry, void 0, route, page);
3541
+ if (ack.has(ACK_ALL)) continue;
3542
+ const scopes = {};
3543
+ for (const kind of CORE_KINDS) {
3544
+ if (kinds.has(kind) || ack.has(kind)) continue;
3545
+ scopes[kind] = { type: "backlog" };
3546
+ }
3547
+ if (Object.keys(scopes).length > 0) out2[route] = scopes;
3548
+ }
3549
+ return out2;
3550
+ }
3551
+
3552
+ // src/scanner/scan/surface-check.ts
3553
+ var path6 = __toESM(require("path"), 1);
3554
+ var EXT_CANDIDATES = [
3555
+ "",
3556
+ ".tsx",
3557
+ ".ts",
3558
+ ".jsx",
3559
+ ".js",
3560
+ "/index.tsx",
3561
+ "/index.ts",
3562
+ "/index.jsx",
3563
+ "/index.js"
3564
+ ];
3565
+ function checkFeatureSurfaces(registry, extracted, config) {
3566
+ const declarations = [];
3567
+ for (const ef of extracted) {
3568
+ for (const m of ef.metadata ?? []) {
3569
+ if (m.kind !== "feature") continue;
3570
+ if (typeof m.id !== "string") continue;
3571
+ if (!m.surfaces || m.surfaces.length === 0) continue;
3572
+ if (m.acknowledge?.["*"]) continue;
3573
+ declarations.push({
3574
+ ef,
3575
+ id: m.id,
3576
+ surfaces: m.surfaces,
3577
+ surfaceSpans: m.surfaceSpans,
3578
+ line: m.loc.line ?? 1
3579
+ });
3580
+ }
3581
+ }
3582
+ if (declarations.length === 0) return [];
3583
+ const fileSet = /* @__PURE__ */ new Set();
3584
+ const jsxFiles = /* @__PURE__ */ new Set();
3585
+ for (const ef of extracted) {
3586
+ fileSet.add(ef.file.displayPath);
3587
+ if (ef.hasJsx) jsxFiles.add(ef.file.displayPath);
3588
+ }
3589
+ const aliases = Object.entries(config.resolve?.aliases ?? {}).sort(
3590
+ (a, b) => b[0].length - a[0].length
3591
+ );
3592
+ const resolveSpecifier = (importer, spec) => {
3593
+ const bases = [];
3594
+ if (spec.startsWith("./") || spec.startsWith("../")) {
3595
+ bases.push(path6.posix.join(path6.posix.dirname(importer), spec));
3596
+ } else {
3597
+ const alias = aliases.find(([prefix]) => spec.startsWith(prefix));
3598
+ if (alias) {
3599
+ bases.push(alias[1] + spec.slice(alias[0].length));
3600
+ } else if (aliases.length === 0) {
3601
+ if (spec.startsWith("@/") || spec.startsWith("~/")) {
3602
+ bases.push("src/" + spec.slice(2));
3603
+ }
3604
+ }
3605
+ bases.push(spec);
3606
+ }
3607
+ const hits = [];
3608
+ for (const base of bases) {
3609
+ const normalized = path6.posix.normalize(base);
3610
+ for (const ext of EXT_CANDIDATES) {
3611
+ const candidate = normalized + ext;
3612
+ if (fileSet.has(candidate)) hits.push(candidate);
3613
+ }
3614
+ }
3615
+ return hits;
3616
+ };
3617
+ const adjacency = /* @__PURE__ */ new Map();
3618
+ for (const ef of extracted) {
3619
+ const targets = [];
3620
+ for (const imp of ef.imports ?? []) {
3621
+ if (imp.isTypeOnly) continue;
3622
+ targets.push(...resolveSpecifier(ef.file.displayPath, imp.specifier));
3623
+ }
3624
+ if (targets.length > 0) adjacency.set(ef.file.displayPath, targets);
3625
+ }
3626
+ const inDir = (file, dir) => file === dir || file.startsWith(dir + "/");
3627
+ const reachesDir = (seeds, dir) => {
3628
+ const queue = [...seeds];
3629
+ const visited = new Set(queue);
3630
+ while (queue.length > 0) {
3631
+ const current = queue.pop();
3632
+ if (inDir(current, dir) && jsxFiles.has(current)) return true;
3633
+ for (const next of adjacency.get(current) ?? []) {
3634
+ if (visited.has(next)) continue;
3635
+ visited.add(next);
3636
+ queue.push(next);
3637
+ }
3638
+ }
3639
+ return false;
3640
+ };
3641
+ const featuresConvention = config.conventions?.features === void 0 ? DEFAULT_CONVENTIONS.features : config.conventions.features;
3642
+ const featureGlob = typeof featuresConvention === "string" ? featuresConvention : null;
3643
+ const diagnostics = [];
3644
+ for (const decl of declarations) {
3645
+ const declPath = decl.ef.file.displayPath;
3646
+ const featureDir = (featureGlob !== null ? extractFeatureDir(declPath, featureGlob) : null) ?? path6.posix.dirname(declPath);
3647
+ for (let i = 0; i < decl.surfaces.length; i++) {
3648
+ const pageId = decl.surfaces[i];
3649
+ const line = decl.surfaceSpans?.[i] ? lineOfOffset(decl.ef.file.content, decl.surfaceSpans[i].start) : decl.line;
3650
+ const page = registry.get("page", pageId) ?? registry.matchPattern("page", pageId);
3651
+ if (!page) {
2752
3652
  diagnostics.push({
2753
- code: "stale-waiver",
3653
+ code: "unknown-reference",
2754
3654
  severity: "warning",
2755
- message: `page "${page}" waives core kind "${kind}", but route "${route}" does render it`,
2756
- file: loc?.file,
2757
- line: loc?.line,
2758
- entity: { kind: "page", id: page },
2759
- hint: `Remove the "${kind}" waiver \u2014 the route renders that kind, so the waiver's reason is stale.`
3655
+ message: `Feature "${decl.id}" declares a surface on unknown page "${pageId}"`,
3656
+ file: decl.ef.file.displayPath,
3657
+ line,
3658
+ entity: { kind: "feature", id: decl.id },
3659
+ hint: `No page with id "${pageId}" exists in the registry; fix the reference or add the page.`
2760
3660
  });
3661
+ continue;
2761
3662
  }
3663
+ const pageFile = page.loc?.file;
3664
+ if (!pageFile) continue;
3665
+ const seeds = [pageFile];
3666
+ if (path6.posix.basename(pageFile) === WELL_KNOWN_FILES.page) {
3667
+ const dir = path6.posix.dirname(pageFile);
3668
+ for (const f of fileSet) {
3669
+ if (f !== pageFile && path6.posix.dirname(f) === dir) seeds.push(f);
3670
+ }
3671
+ }
3672
+ if (reachesDir(seeds, featureDir)) continue;
3673
+ diagnostics.push({
3674
+ code: "feature-surface-unreferenced",
3675
+ severity: "warning",
3676
+ message: `Feature "${decl.id}" declares page "${pageId}" as a surface, but nothing reachable from ${pageFile} references a component file in ${featureDir}/`,
3677
+ file: decl.ef.file.displayPath,
3678
+ line,
3679
+ entity: { kind: "feature", id: decl.id },
3680
+ 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\`.`
3681
+ });
2762
3682
  }
2763
3683
  }
2764
3684
  return diagnostics;
2765
3685
  }
2766
- function computeMissingKinds(registry, manifest) {
3686
+ function checkFeatureSurfaceCaptures(registry, manifest) {
2767
3687
  const routes = registry.list("route");
2768
- const pageOf = new Map(routes.map((r) => [r.path, r.page]));
2769
- const captured = coreKindsByRoute(registry, manifest);
2770
- const out2 = {};
2771
- for (const [route, kinds] of captured) {
2772
- const page = pageOf.get(route);
2773
- if (isOptedOut(registry, page)) continue;
2774
- const waivers = page ? pageWaivers(registry, page) : {};
2775
- const missing = CORE_KINDS.filter((k) => !kinds.has(k) && !waivers[k]);
2776
- if (missing.length > 0) out2[route] = missing;
3688
+ if (routes.length === 0) return [];
3689
+ const routePaths = routes.map((r) => r.path);
3690
+ const captured = manifest.captured ?? [];
3691
+ const diagnostics = [];
3692
+ for (const feature of registry.list("feature")) {
3693
+ const surfaces = feature.meta?.surfaces ?? [];
3694
+ if (surfaces.length === 0) continue;
3695
+ if (entityAcknowledge(feature.meta)) continue;
3696
+ const coveredRoutes = /* @__PURE__ */ new Set();
3697
+ for (const c of captured) {
3698
+ if (c.kind !== "feature" || c.entity !== feature.id) continue;
3699
+ const route = routeForCapture(c, routes, routePaths);
3700
+ if (route) coveredRoutes.add(route);
3701
+ }
3702
+ for (const pageId of surfaces) {
3703
+ const pageRoutes = routes.filter((r) => r.page === pageId);
3704
+ if (pageRoutes.length === 0) continue;
3705
+ if (pageRoutes.some((r) => coveredRoutes.has(r.path))) continue;
3706
+ const routeList = pageRoutes.map((r) => `"${r.path}"`).join(", ");
3707
+ diagnostics.push({
3708
+ code: "feature-surface-uncaptured",
3709
+ severity: "warning",
3710
+ 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})`}`,
3711
+ file: feature.loc?.file,
3712
+ line: feature.loc?.line,
3713
+ entity: { kind: "feature", id: feature.id },
3714
+ 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 }\`.`
3715
+ });
3716
+ }
2777
3717
  }
2778
- return out2;
3718
+ return diagnostics;
3719
+ }
3720
+ function lineOfOffset(content, offset) {
3721
+ let line = 1;
3722
+ for (let i = 0; i < offset && i < content.length; i++) {
3723
+ if (content[i] === "\n") line++;
3724
+ }
3725
+ return line;
2779
3726
  }
2780
3727
 
2781
3728
  // src/scanner/scan/audit.ts
@@ -2785,6 +3732,8 @@ function audit(opts) {
2785
3732
  const check = opts.check ?? false;
2786
3733
  const lint = opts.lint ?? false;
2787
3734
  const acceptanceEnabled = config.audit?.acceptance ?? true;
3735
+ const surfacesEnabled = config.audit?.surfaces ?? true;
3736
+ const requireAcceptanceEnabled = config.audit?.requireAcceptance ?? true;
2788
3737
  const scopeLeakEnabled = config.audit?.scopeLeak ?? true;
2789
3738
  const coverageEnabled = config.audit?.coverage ?? true;
2790
3739
  const statesEnabled = config.audit?.states ?? true;
@@ -2797,6 +3746,21 @@ function audit(opts) {
2797
3746
  for (const ef of opts.flowExtracted ?? []) {
2798
3747
  if (ef.diagnostics) diagnostics.push(...ef.diagnostics);
2799
3748
  }
3749
+ for (const ef of extracted) {
3750
+ for (const m of ef.metadata ?? []) {
3751
+ for (const [scope, ack] of Object.entries(m.acknowledge ?? {})) {
3752
+ if (!isPlaceholderReason(ack.reason)) continue;
3753
+ diagnostics.push({
3754
+ code: "placeholder-reason",
3755
+ severity: "warning",
3756
+ message: `acknowledge "${scope}" still has the migration's placeholder reason ("${ack.reason}")`,
3757
+ file: ef.file.displayPath,
3758
+ line: m.loc.line,
3759
+ 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).`
3760
+ });
3761
+ }
3762
+ }
3763
+ }
2800
3764
  if (check && opts.generated !== void 0) {
2801
3765
  const outRel = opts.outputPath ?? config.output;
2802
3766
  const fresh = normalizeForCheck(opts.generated);
@@ -2866,9 +3830,66 @@ function audit(opts) {
2866
3830
  }
2867
3831
  }
2868
3832
  if (lint && acceptanceEnabled) {
2869
- for (const kind of ["widget", "feature", "page"]) {
3833
+ const flowEntities = registry.list("flow");
3834
+ const acceptanceKinds = ["widget", "feature", "page"];
3835
+ const claims = /* @__PURE__ */ new Map();
3836
+ const addClaim = (kind, entityId, criterion, claim) => {
3837
+ const key = `${kind}:${entityId}`;
3838
+ let byCrit = claims.get(key);
3839
+ if (!byCrit) {
3840
+ byCrit = /* @__PURE__ */ new Map();
3841
+ claims.set(key, byCrit);
3842
+ }
3843
+ let list = byCrit.get(criterion);
3844
+ if (!list) {
3845
+ list = [];
3846
+ byCrit.set(criterion, list);
3847
+ }
3848
+ list.push(claim);
3849
+ };
3850
+ for (const flow of flowEntities) {
3851
+ for (const c of flow.covers ?? []) {
3852
+ const candidates = acceptanceKinds.flatMap((k) => {
3853
+ const e = registry.get(k, c.entity);
3854
+ return e ? [e] : [];
3855
+ });
3856
+ const matching = candidates.filter(
3857
+ (e) => entityCriteria(e).some((crit) => crit.id === c.criterion)
3858
+ );
3859
+ if (matching.length === 0) {
3860
+ diagnostics.push({
3861
+ code: "acceptance-orphaned",
3862
+ severity: "warning",
3863
+ 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`}`,
3864
+ file: flow.loc.file,
3865
+ line: flow.loc.line,
3866
+ entity: { kind: "flow", id: flow.id },
3867
+ hint: "Remove the stale uidexCovers claim, or fix the entity/criterion id it names."
3868
+ });
3869
+ continue;
3870
+ }
3871
+ if (matching.length > 1) {
3872
+ diagnostics.push({
3873
+ code: "acceptance-ambiguous",
3874
+ severity: "warning",
3875
+ 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`,
3876
+ file: flow.loc.file,
3877
+ line: flow.loc.line,
3878
+ entity: { kind: "flow", id: flow.id },
3879
+ 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."
3880
+ });
3881
+ }
3882
+ for (const target of matching) {
3883
+ addClaim(target.kind, target.id, c.criterion, {
3884
+ flowId: flow.id,
3885
+ rev: c.rev
3886
+ });
3887
+ }
3888
+ }
3889
+ }
3890
+ for (const kind of acceptanceKinds) {
2870
3891
  for (const e of registry.list(kind)) {
2871
- const criteria = e.meta?.acceptance ?? [];
3892
+ const criteria = entityCriteria(e);
2872
3893
  if (criteria.length === 0) {
2873
3894
  if (kind === "widget") {
2874
3895
  diagnostics.push({
@@ -2883,14 +3904,27 @@ function audit(opts) {
2883
3904
  }
2884
3905
  continue;
2885
3906
  }
2886
- const flowIds = e.meta?.flows ?? [];
2887
- if (flowIds.length === 0) {
2888
- 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)`;
2889
- for (const c of criteria) {
3907
+ const entityClaims = claims.get(`${kind}:${e.id}`);
3908
+ for (const c of criteria) {
3909
+ const critClaims = entityClaims?.get(c.id) ?? [];
3910
+ const stale = critClaims.filter((cl) => cl.rev !== c.rev);
3911
+ for (const cl of stale) {
3912
+ diagnostics.push({
3913
+ code: "acceptance-outdated",
3914
+ severity: "warning",
3915
+ message: `Flow "${cl.flowId}" covers ${kind} "${e.id}" criterion "${c.id}" at rev ${cl.rev}, but the criterion is now rev ${c.rev}`,
3916
+ file: e.loc?.file,
3917
+ line: e.loc?.line,
3918
+ entity: { kind, id: e.id },
3919
+ hint: `Re-verify the flow against the current criterion text, then update its claim to \`uidexCovers("${e.id}", "${c.id}@${c.rev}")\`.`
3920
+ });
3921
+ }
3922
+ if (critClaims.length === 0) {
3923
+ 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}")\``;
2890
3924
  diagnostics.push({
2891
3925
  code: "acceptance-uncovered",
2892
3926
  severity: "warning",
2893
- message: `${kind} "${e.id}" has acceptance criterion not covered by any flow: "${c}"`,
3927
+ message: `${kind} "${e.id}" has acceptance criterion "${c.id}" not covered by any flow: "${c.text}"`,
2894
3928
  file: e.loc?.file,
2895
3929
  line: e.loc?.line,
2896
3930
  entity: { kind, id: e.id },
@@ -2901,7 +3935,31 @@ function audit(opts) {
2901
3935
  }
2902
3936
  }
2903
3937
  }
3938
+ if (lint && surfacesEnabled) {
3939
+ diagnostics.push(...checkFeatureSurfaces(registry, extracted, config));
3940
+ }
3941
+ if (lint && surfacesEnabled && opts.capturedStates) {
3942
+ diagnostics.push(
3943
+ ...checkFeatureSurfaceCaptures(registry, opts.capturedStates)
3944
+ );
3945
+ }
3946
+ if (lint && requireAcceptanceEnabled) {
3947
+ for (const e of registry.list("feature")) {
3948
+ if (entityCriteria(e).length > 0) continue;
3949
+ diagnostics.push({
3950
+ code: "feature-missing-acceptance",
3951
+ severity: "warning",
3952
+ message: `Feature "${e.id}" declares no acceptance criteria`,
3953
+ file: e.loc?.file,
3954
+ line: e.loc?.line,
3955
+ entity: { kind: "feature", id: e.id },
3956
+ 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.`
3957
+ });
3958
+ }
3959
+ }
2904
3960
  if (lint) {
3961
+ const featuresConvention = config.conventions?.features === void 0 ? DEFAULT_CONVENTIONS.features : config.conventions.features;
3962
+ const featureGlob = typeof featuresConvention === "string" ? featuresConvention : null;
2905
3963
  const scannedPaths = new Set(files.map((f) => f.displayPath));
2906
3964
  for (const ef of extracted) {
2907
3965
  if (!ef.metadata) continue;
@@ -2910,10 +3968,24 @@ function audit(opts) {
2910
3968
  if (typeof m.id !== "string") continue;
2911
3969
  const filePath = ef.file.displayPath;
2912
3970
  const wellKnownName = WELL_KNOWN_FILES[m.kind];
2913
- if (path5.posix.basename(filePath) === wellKnownName) continue;
2914
- const dir = path5.posix.dirname(filePath);
3971
+ if (path7.posix.basename(filePath) === wellKnownName) continue;
3972
+ const featureDir = m.kind === "feature" && featureGlob !== null ? extractFeatureDir(filePath, featureGlob) : null;
3973
+ const dir = featureDir ?? path7.posix.dirname(filePath);
2915
3974
  const wellKnownPath = dir === "." ? wellKnownName : `${dir}/${wellKnownName}`;
2916
3975
  if (scannedPaths.has(wellKnownPath)) continue;
3976
+ const inConventionDir = featureDir !== null;
3977
+ if (inConventionDir) {
3978
+ diagnostics.push({
3979
+ code: "prefer-well-known-file",
3980
+ severity: "error",
3981
+ message: `Feature "${m.id}" metadata lives on ${filePath}; a feature in a convention-derived directory must declare its metadata in ${wellKnownPath}`,
3982
+ file: filePath,
3983
+ line: m.loc.line,
3984
+ entity: { kind: "feature", id: m.id },
3985
+ hint: `Move the \`export const uidex\` block to ${wellKnownPath} and remove it from ${filePath}.`
3986
+ });
3987
+ continue;
3988
+ }
2917
3989
  const kindLabel = m.kind === "page" ? "Page" : "Feature";
2918
3990
  diagnostics.push({
2919
3991
  code: "prefer-well-known-file",
@@ -2997,6 +4069,7 @@ function audit(opts) {
2997
4069
  const displayPath = ef.file.displayPath;
2998
4070
  for (const imp of ef.imports ?? []) {
2999
4071
  if (imp.isTypeOnly) continue;
4072
+ if (imp.dynamic) continue;
3000
4073
  const baseName2 = imp.specifier.split("/").pop() ?? "";
3001
4074
  const primitive = byName.get(
3002
4075
  baseName2.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
@@ -3114,7 +4187,7 @@ function audit(opts) {
3114
4187
  severity: "warning",
3115
4188
  message: `\`export const uidex\` in ${ef.file.displayPath} references unknown ${refKind} "${refId}"`,
3116
4189
  file: ef.file.displayPath,
3117
- line: spans?.[i] ? lineOfOffset(ef.file.content, spans[i].start) : m.loc.line,
4190
+ line: spans?.[i] ? lineOfOffset2(ef.file.content, spans[i].start) : m.loc.line,
3118
4191
  hint: `No ${refKind} with id "${refId}" exists in the registry; fix the reference or add the ${refKind}.`
3119
4192
  });
3120
4193
  }
@@ -3125,33 +4198,57 @@ function audit(opts) {
3125
4198
  }
3126
4199
  }
3127
4200
  if (lint && statesEnabled && opts.capturedStates) {
3128
- diagnostics.push(...checkStateCoverage(registry, opts.capturedStates));
4201
+ const sourceByDisplay = new Map(
4202
+ files.map((f) => [f.displayPath, f.sourcePath])
4203
+ );
4204
+ diagnostics.push(
4205
+ ...checkStateCoverage(registry, opts.capturedStates, {
4206
+ workspace: opts.workspace,
4207
+ resolveSourcePath: (displayPath) => sourceByDisplay.get(displayPath)
4208
+ })
4209
+ );
3129
4210
  }
3130
4211
  if (lint && pageCoverageEnabled && opts.capturedStates) {
3131
4212
  diagnostics.push(
3132
- ...checkPageCoverage(registry, opts.capturedStates, opts.pageBaseline)
4213
+ ...checkPageCoverage(registry, opts.capturedStates, opts.coverageBaseline)
3133
4214
  );
3134
4215
  }
4216
+ if (lint && statesEnabled && opts.capturedStates) {
4217
+ diagnostics.push(...checkComponentCoverage(registry, opts.capturedStates));
4218
+ }
3135
4219
  if (lint && stateMatrixEnabled && opts.capturedStates) {
3136
4220
  diagnostics.push(
3137
- ...checkStateMatrix(registry, opts.capturedStates, {
3138
- missingKinds: opts.pageBaseline?.missingKinds ?? {}
3139
- })
4221
+ ...checkStateMatrix(registry, opts.capturedStates, opts.coverageBaseline)
3140
4222
  );
3141
4223
  }
4224
+ const overrides = config.audit?.severity ?? {};
4225
+ const governed = diagnostics.flatMap((d) => {
4226
+ const level = overrides[d.code];
4227
+ if (level === void 0) return [d];
4228
+ if (level === "off") return [];
4229
+ return [{ ...d, severity: level }];
4230
+ });
3142
4231
  const summary = {
3143
- errors: diagnostics.filter((d) => d.severity === "error").length,
3144
- warnings: diagnostics.filter((d) => d.severity === "warning").length
4232
+ errors: governed.filter((d) => d.severity === "error").length,
4233
+ warnings: governed.filter((d) => d.severity === "warning").length
3145
4234
  };
3146
- return { diagnostics, summary };
4235
+ return { diagnostics: governed, summary };
3147
4236
  }
3148
- function lineOfOffset(content, offset) {
4237
+ function lineOfOffset2(content, offset) {
3149
4238
  let line = 1;
3150
4239
  for (let i = 0; i < offset && i < content.length; i++) {
3151
4240
  if (content[i] === "\n") line++;
3152
4241
  }
3153
4242
  return line;
3154
4243
  }
4244
+ function entityCriteria(e) {
4245
+ const meta = e.meta;
4246
+ if (!meta) return [];
4247
+ return [
4248
+ ...meta.acceptance ?? [],
4249
+ ...(meta.states ?? []).flatMap((s) => s.acceptance ?? [])
4250
+ ];
4251
+ }
3155
4252
  var TAG_FALLBACK_ID = {
3156
4253
  a: "link",
3157
4254
  button: "button",
@@ -3471,6 +4568,30 @@ ${body}
3471
4568
  `;
3472
4569
  }
3473
4570
  var OPAQUE_ID_UNIONS = /* @__PURE__ */ new Set(["ElementId"]);
4571
+ function stateEntities(list) {
4572
+ return list.map((e) => ({
4573
+ id: e.id,
4574
+ states: (e.meta?.states ?? []).map((s) => s.name)
4575
+ }));
4576
+ }
4577
+ function emitStateMap(groups) {
4578
+ const lines = ["export interface UidexStates {"];
4579
+ for (const g of groups) {
4580
+ const withStates = g.entities.filter((e) => e.states.length > 0);
4581
+ if (withStates.length === 0) {
4582
+ lines.push(` ${g.kind}: never`);
4583
+ continue;
4584
+ }
4585
+ lines.push(` ${g.kind}: {`);
4586
+ for (const e of [...withStates].sort((a, b) => a.id.localeCompare(b.id))) {
4587
+ const states = [...new Set(e.states)].sort().map((s) => JSON.stringify(s)).join(" | ");
4588
+ lines.push(` ${JSON.stringify(e.id)}: ${states}`);
4589
+ }
4590
+ lines.push(" }");
4591
+ }
4592
+ lines.push("}");
4593
+ return lines.join("\n") + "\n";
4594
+ }
3474
4595
  function emitId(name, ids) {
3475
4596
  if (OPAQUE_ID_UNIONS.has(name)) {
3476
4597
  return `export type ${name} = string & { readonly __uidex?: ${JSON.stringify(name)} }
@@ -3558,36 +4679,64 @@ function emit(opts) {
3558
4679
  );
3559
4680
  t.push(emitId("StateId", [...stateNames]));
3560
4681
  t.push("");
4682
+ t.push("// ---- declared state space (kind \u2192 entity \u2192 its own states) ----");
4683
+ t.push(
4684
+ emitStateMap([
4685
+ { kind: "page", entities: stateEntities(pages) },
4686
+ { kind: "feature", entities: stateEntities(features) },
4687
+ { kind: "widget", entities: stateEntities(widgets) }
4688
+ ])
4689
+ );
3561
4690
  t.push("// ---- authoring-surface shape types ----");
3562
4691
  t.push("export namespace Uidex {");
3563
4692
  t.push(
3564
4693
  ' export type CoreStateKind = "loading" | "empty" | "populated" | "error"'
3565
4694
  );
3566
4695
  t.push(' export type StateKind = CoreStateKind | "variant"');
4696
+ t.push(" export interface AcceptanceCriterion {");
4697
+ t.push(" id: string");
4698
+ t.push(" rev?: number");
4699
+ t.push(" text: string");
4700
+ t.push(" }");
4701
+ t.push(" export type Acceptance = readonly (string | AcceptanceCriterion)[]");
3567
4702
  t.push(" export interface State {");
3568
4703
  t.push(" name: string");
3569
4704
  t.push(" kind?: StateKind");
3570
- t.push(" acceptance?: readonly string[]");
4705
+ t.push(" acceptance?: Acceptance");
3571
4706
  t.push(" description?: string");
3572
4707
  t.push(" }");
3573
- t.push(" export type Waivers = Partial<Record<CoreStateKind, string>>");
4708
+ t.push(' export type AcknowledgeScope = CoreStateKind | "*"');
4709
+ t.push(" export interface Impossible {");
4710
+ t.push(' type: "impossible"');
4711
+ t.push(" reason: string");
4712
+ t.push(" }");
4713
+ t.push(" export interface Backlog {");
4714
+ t.push(' type: "backlog"');
4715
+ t.push(" reason?: string");
4716
+ t.push(" }");
4717
+ t.push(" export type Acknowledgment = Impossible | Backlog");
4718
+ t.push(
4719
+ " export type Acknowledge = Partial<Record<AcknowledgeScope, Acknowledgment>>"
4720
+ );
4721
+ t.push(' export type ComponentAcknowledge = { "*"?: Acknowledgment }');
3574
4722
  t.push(" export interface Page {");
3575
4723
  t.push(" page: PageId | false");
3576
4724
  t.push(" name?: string");
3577
4725
  t.push(" features?: readonly FeatureId[]");
3578
4726
  t.push(" widgets?: readonly WidgetId[]");
3579
- t.push(" acceptance?: readonly string[]");
4727
+ t.push(" acceptance?: Acceptance");
3580
4728
  t.push(" states?: readonly (string | State)[]");
3581
- t.push(" capture?: boolean");
3582
- t.push(" waivers?: Waivers");
4729
+ t.push(" acknowledge?: Acknowledge");
3583
4730
  t.push(" description?: string");
3584
4731
  t.push(" }");
3585
4732
  t.push(" export interface Feature {");
3586
4733
  t.push(" feature: FeatureId | false");
3587
4734
  t.push(" name?: string");
3588
4735
  t.push(" features?: readonly FeatureId[]");
3589
- t.push(" acceptance?: readonly string[]");
4736
+ t.push(" surfaces?: readonly PageId[]");
4737
+ t.push(" acceptance?: Acceptance");
3590
4738
  t.push(" states?: readonly (string | State)[]");
4739
+ t.push(" acknowledge?: ComponentAcknowledge");
3591
4740
  t.push(" description?: string");
3592
4741
  t.push(" }");
3593
4742
  t.push(" export interface Primitive {");
@@ -3598,8 +4747,9 @@ function emit(opts) {
3598
4747
  t.push(" export interface Widget {");
3599
4748
  t.push(" widget: WidgetId");
3600
4749
  t.push(" name?: string");
3601
- t.push(" acceptance?: readonly string[]");
4750
+ t.push(" acceptance?: Acceptance");
3602
4751
  t.push(" states?: readonly (string | State)[]");
4752
+ t.push(" acknowledge?: ComponentAcknowledge");
3603
4753
  t.push(" description?: string");
3604
4754
  t.push(" }");
3605
4755
  t.push(" export interface Region {");
@@ -3695,8 +4845,8 @@ function parseGitHubRef(ref) {
3695
4845
  }
3696
4846
 
3697
4847
  // src/scanner/scan/scaffold.ts
3698
- var fs3 = __toESM(require("fs"), 1);
3699
- var path6 = __toESM(require("path"), 1);
4848
+ var fs4 = __toESM(require("fs"), 1);
4849
+ var path8 = __toESM(require("path"), 1);
3700
4850
  function scaffoldWidgetSpec(opts) {
3701
4851
  return scaffoldSpec({
3702
4852
  registry: opts.registry,
@@ -3720,10 +4870,10 @@ function scaffoldSpec(opts) {
3720
4870
  if (!entity) {
3721
4871
  throw new Error(`${capitalize(kind)} "${id}" not found in registry`);
3722
4872
  }
3723
- const criteria = entity.meta?.acceptance ?? [];
4873
+ const criteria = entityCriteria(entity);
3724
4874
  const filename = kind === "widget" ? `widget-${id}.spec.ts` : `flow-${id}.spec.ts`;
3725
- const outputPath = path6.resolve(outDir, filename);
3726
- if (fs3.existsSync(outputPath) && !force) {
4875
+ const outputPath = path8.resolve(outDir, filename);
4876
+ if (fs4.existsSync(outputPath) && !force) {
3727
4877
  return {
3728
4878
  outputPath,
3729
4879
  written: false,
@@ -3731,9 +4881,9 @@ function scaffoldSpec(opts) {
3731
4881
  reason: `spec already exists at ${outputPath}; pass --force to overwrite`
3732
4882
  };
3733
4883
  }
3734
- const content = renderSpec({ id, criteria, fixtureImport });
3735
- fs3.mkdirSync(path6.dirname(outputPath), { recursive: true });
3736
- fs3.writeFileSync(outputPath, content, "utf8");
4884
+ const content = renderSpec({ id, entityId: id, criteria, fixtureImport });
4885
+ fs4.mkdirSync(path8.dirname(outputPath), { recursive: true });
4886
+ fs4.writeFileSync(outputPath, content, "utf8");
3737
4887
  return { outputPath, written: true, skipped: false };
3738
4888
  }
3739
4889
  function capitalize(s) {
@@ -3741,25 +4891,28 @@ function capitalize(s) {
3741
4891
  }
3742
4892
  function renderSpec(args) {
3743
4893
  const lines = [];
3744
- lines.push(
3745
- `import { test, expect } from ${JSON.stringify(args.fixtureImport)}`
3746
- );
4894
+ lines.push(`import { test } from ${JSON.stringify(args.fixtureImport)}`);
3747
4895
  lines.push("");
3748
4896
  lines.push(
3749
4897
  `test.describe(${JSON.stringify(args.id)}, { tag: "@uidex:flow" }, () => {`
3750
4898
  );
3751
- if (args.criteria.length === 0) {
3752
- lines.push(` test("TODO: add acceptance criteria", async () => {`);
3753
- lines.push(` // TODO`);
4899
+ const stub = (title, body) => {
4900
+ lines.push(` test.fixme(${JSON.stringify(title)}, async ({ uidex }) => {`);
4901
+ for (const l of body) lines.push(` ${l}`);
4902
+ lines.push(` void uidex`);
3754
4903
  lines.push(` })`);
4904
+ lines.push("");
4905
+ };
4906
+ if (args.criteria.length === 0) {
4907
+ stub("TODO: add acceptance criteria", [
4908
+ "// Declare `acceptance: [...]` on the entity, then re-run scaffold."
4909
+ ]);
3755
4910
  } else {
3756
4911
  for (const criterion of args.criteria) {
3757
- lines.push(` test(${JSON.stringify(criterion)}, async ({ uidex }) => {`);
3758
- lines.push(` // TODO: implement criterion`);
3759
- lines.push(` void uidex`);
3760
- lines.push(` expect(true).toBe(true)`);
3761
- lines.push(` })`);
3762
- lines.push("");
4912
+ stub(`${criterion.id}: ${criterion.text}`, [
4913
+ "// TODO: drive this criterion, then remove `.fixme` to arm the test",
4914
+ `// and claim it: uidexCovers(${JSON.stringify(args.entityId)}, ${JSON.stringify(`${criterion.id}@${criterion.rev}`)})`
4915
+ ]);
3763
4916
  }
3764
4917
  }
3765
4918
  lines.push("})");
@@ -3768,47 +4921,33 @@ function renderSpec(args) {
3768
4921
  }
3769
4922
 
3770
4923
  // src/scanner/scan/pipeline.ts
3771
- var fs4 = __toESM(require("fs"), 1);
3772
- var path7 = __toESM(require("path"), 1);
3773
- var DEFAULT_STATES_MANIFEST = "uidex-states.json";
4924
+ var fs5 = __toESM(require("fs"), 1);
4925
+ var path9 = __toESM(require("path"), 1);
4926
+ var DEFAULT_STATES_MANIFEST2 = "uidex-states.json";
3774
4927
  var DEFAULT_COVERAGE_BASELINE = "uidex-coverage-baseline.json";
3775
- function loadPageBaseline(configDir, config) {
4928
+ function loadCoverageBaseline(configDir, config) {
3776
4929
  const rel = config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
3777
- const abs = path7.resolve(configDir, rel);
4930
+ const abs = path9.resolve(configDir, rel);
3778
4931
  let raw;
3779
4932
  try {
3780
- raw = fs4.readFileSync(abs, "utf8");
4933
+ raw = fs5.readFileSync(abs, "utf8");
3781
4934
  } catch {
3782
4935
  return void 0;
3783
4936
  }
4937
+ let parsed;
3784
4938
  try {
3785
- const parsed = JSON.parse(raw);
3786
- if (!parsed || !Array.isArray(parsed.uncapturedPages)) return void 0;
3787
- const uncapturedPages = parsed.uncapturedPages.filter(
3788
- (p2) => typeof p2 === "string"
3789
- );
3790
- let missingKinds;
3791
- const mkRaw = parsed.missingKinds;
3792
- if (mkRaw && typeof mkRaw === "object" && !Array.isArray(mkRaw)) {
3793
- const mk = {};
3794
- for (const [route, kinds] of Object.entries(mkRaw)) {
3795
- if (Array.isArray(kinds)) {
3796
- mk[route] = kinds.filter((k) => typeof k === "string");
3797
- }
3798
- }
3799
- missingKinds = mk;
3800
- }
3801
- return { uncapturedPages, ...missingKinds ? { missingKinds } : {} };
4939
+ parsed = JSON.parse(raw);
3802
4940
  } catch {
3803
4941
  return void 0;
3804
4942
  }
4943
+ return parseCoverageBaseline(parsed) ?? void 0;
3805
4944
  }
3806
4945
  function loadCapturedStates(configDir, config) {
3807
- const rel = config.statesManifest ?? DEFAULT_STATES_MANIFEST;
3808
- const abs = path7.resolve(configDir, rel);
4946
+ const rel = config.statesManifest ?? DEFAULT_STATES_MANIFEST2;
4947
+ const abs = path9.resolve(configDir, rel);
3809
4948
  let raw;
3810
4949
  try {
3811
- raw = fs4.readFileSync(abs, "utf8");
4950
+ raw = fs5.readFileSync(abs, "utf8");
3812
4951
  } catch {
3813
4952
  return void 0;
3814
4953
  }
@@ -3827,7 +4966,7 @@ function loadCapturedStates(configDir, config) {
3827
4966
  if (typeof c.route === "string") rec.route = c.route;
3828
4967
  captured.push(rec);
3829
4968
  }
3830
- return { captured };
4969
+ return typeof parsed.generatedAt === "string" ? { captured, generatedAt: parsed.generatedAt } : { captured };
3831
4970
  } catch {
3832
4971
  return void 0;
3833
4972
  }
@@ -3863,11 +5002,11 @@ function runOne(dc, opts) {
3863
5002
  gitContext
3864
5003
  });
3865
5004
  const typesRel = config.output;
3866
- const typesPath = path7.resolve(configDir, typesRel);
5005
+ const typesPath = path9.resolve(configDir, typesRel);
3867
5006
  const dataRel = dataOutputPath(config.output);
3868
- const dataPath = path7.resolve(configDir, dataRel);
5007
+ const dataPath = path9.resolve(configDir, dataRel);
3869
5008
  const locsRel = locsOutputPath(config.output);
3870
- const locsPath = path7.resolve(configDir, locsRel);
5009
+ const locsPath = path9.resolve(configDir, locsRel);
3871
5010
  let typesOnDisk = null;
3872
5011
  let dataOnDisk = null;
3873
5012
  let locsOnDisk = null;
@@ -3880,7 +5019,8 @@ function runOne(dc, opts) {
3880
5019
  (ef) => (ef.diagnostics?.length ?? 0) > 0
3881
5020
  );
3882
5021
  const capturedStates = opts.capturedStates ?? loadCapturedStates(configDir, config);
3883
- const pageBaseline = opts.pageBaseline ?? loadPageBaseline(configDir, config);
5022
+ const coverageBaseline = opts.coverageBaseline ?? loadCoverageBaseline(configDir, config);
5023
+ const workspace = opts.workspace ?? resolveWorkspace({ configDir, config });
3884
5024
  let auditResult;
3885
5025
  if (opts.check || opts.lint || resolved.diagnostics.length > 0 || hasExtractDiagnostics) {
3886
5026
  auditResult = audit({
@@ -3905,7 +5045,8 @@ function runOne(dc, opts) {
3905
5045
  locsOnDisk,
3906
5046
  locsOutputPath: locsRel,
3907
5047
  capturedStates,
3908
- pageBaseline
5048
+ coverageBaseline,
5049
+ workspace
3909
5050
  });
3910
5051
  }
3911
5052
  return {
@@ -3926,15 +5067,15 @@ function runOne(dc, opts) {
3926
5067
  }
3927
5068
  function readOrNull(p2) {
3928
5069
  try {
3929
- return fs4.readFileSync(p2, "utf8");
5070
+ return fs5.readFileSync(p2, "utf8");
3930
5071
  } catch {
3931
5072
  return null;
3932
5073
  }
3933
5074
  }
3934
5075
  function writeArtifact(artifact) {
3935
5076
  if (readOrNull(artifact.outputPath) === artifact.generated) return false;
3936
- fs4.mkdirSync(path7.dirname(artifact.outputPath), { recursive: true });
3937
- fs4.writeFileSync(artifact.outputPath, artifact.generated, "utf8");
5077
+ fs5.mkdirSync(path9.dirname(artifact.outputPath), { recursive: true });
5078
+ fs5.writeFileSync(artifact.outputPath, artifact.generated, "utf8");
3938
5079
  return true;
3939
5080
  }
3940
5081
  function writeScanResult(result) {
@@ -3943,14 +5084,45 @@ function writeScanResult(result) {
3943
5084
  const wroteLocs = writeArtifact(result.locs);
3944
5085
  return wroteTypes || wroteData || wroteLocs;
3945
5086
  }
3946
- function pageBaselinePath(result) {
5087
+ function coverageBaselinePath(result) {
3947
5088
  const rel = result.config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
3948
- return path7.resolve(result.configDir, rel);
5089
+ return path9.resolve(result.configDir, rel);
5090
+ }
5091
+
5092
+ // src/scanner/scan/states-merge.ts
5093
+ function keyOf(c) {
5094
+ return `${c.kind ?? "page"} ${c.entity} ${c.state}`;
5095
+ }
5096
+ function mergeStates(committed, partial) {
5097
+ const byKey = /* @__PURE__ */ new Map();
5098
+ for (const c of committed.captured ?? []) byKey.set(keyOf(c), c);
5099
+ const updated = [];
5100
+ const added = [];
5101
+ for (const c of partial.captured ?? []) {
5102
+ const k = keyOf(c);
5103
+ (byKey.has(k) ? updated : added).push(k);
5104
+ byKey.set(k, c);
5105
+ }
5106
+ const captured = [...byKey.values()].sort(
5107
+ (a, b) => a.entity.localeCompare(b.entity) || a.state.localeCompare(b.state)
5108
+ );
5109
+ return {
5110
+ manifest: {
5111
+ ...committed,
5112
+ captured,
5113
+ // The merged file is only as fresh as its OLDEST evidence: rows carried
5114
+ // over from the committed manifest were not re-verified by this run, so
5115
+ // claiming the partial's newer stamp would defeat the staleness check.
5116
+ ...committed.generatedAt ? { generatedAt: committed.generatedAt } : {}
5117
+ },
5118
+ updated: updated.sort(),
5119
+ added: added.sort()
5120
+ };
3949
5121
  }
3950
5122
 
3951
5123
  // src/scanner/scan/fix.ts
3952
- var fs5 = __toESM(require("fs"), 1);
3953
- var path8 = __toESM(require("path"), 1);
5124
+ var fs6 = __toESM(require("fs"), 1);
5125
+ var path10 = __toESM(require("path"), 1);
3954
5126
  function applyFixes(diagnostics) {
3955
5127
  const entries = [];
3956
5128
  for (const d of diagnostics) {
@@ -3983,7 +5155,7 @@ function applyFixes(diagnostics) {
3983
5155
  for (const [filePath, edits] of editsByFile) {
3984
5156
  let content;
3985
5157
  try {
3986
- content = fs5.readFileSync(filePath, "utf8");
5158
+ content = fs6.readFileSync(filePath, "utf8");
3987
5159
  } catch {
3988
5160
  for (const e of edits) e.entry.skippedReason ??= "file is unreadable";
3989
5161
  continue;
@@ -4003,22 +5175,22 @@ function applyFixes(diagnostics) {
4003
5175
  const edit = kept[i];
4004
5176
  content = content.slice(0, edit.start) + edit.replacement + content.slice(edit.end);
4005
5177
  }
4006
- if (kept.length > 0) fs5.writeFileSync(filePath, content, "utf8");
5178
+ if (kept.length > 0) fs6.writeFileSync(filePath, content, "utf8");
4007
5179
  }
4008
5180
  for (const entry of entries) {
4009
5181
  if (entry.skippedReason) continue;
4010
5182
  for (const create of entry.createFiles) {
4011
- if (fs5.existsSync(create.path)) {
4012
- entry.skippedReason = `${path8.basename(create.path)} already exists`;
5183
+ if (fs6.existsSync(create.path)) {
5184
+ entry.skippedReason = `${path10.basename(create.path)} already exists`;
4013
5185
  continue;
4014
5186
  }
4015
- fs5.mkdirSync(path8.dirname(create.path), { recursive: true });
4016
- fs5.writeFileSync(create.path, create.content, "utf8");
5187
+ fs6.mkdirSync(path10.dirname(create.path), { recursive: true });
5188
+ fs6.writeFileSync(create.path, create.content, "utf8");
4017
5189
  }
4018
5190
  if (entry.skippedReason) continue;
4019
5191
  for (const del of entry.deleteFiles) {
4020
5192
  try {
4021
- fs5.unlinkSync(del);
5193
+ fs6.unlinkSync(del);
4022
5194
  } catch {
4023
5195
  }
4024
5196
  }
@@ -4196,29 +5368,29 @@ function renameEntity(opts) {
4196
5368
  }
4197
5369
 
4198
5370
  // src/scanner/scan/cli.ts
4199
- var fs8 = __toESM(require("fs"), 1);
4200
- var path11 = __toESM(require("path"), 1);
5371
+ var fs9 = __toESM(require("fs"), 1);
5372
+ var path13 = __toESM(require("path"), 1);
4201
5373
 
4202
5374
  // src/scanner/scan/ai/index.ts
4203
5375
  var p = __toESM(require("@clack/prompts"), 1);
4204
5376
 
4205
5377
  // src/scanner/scan/ai/providers/claude.ts
4206
- var fs7 = __toESM(require("fs"), 1);
4207
- var path10 = __toESM(require("path"), 1);
5378
+ var fs8 = __toESM(require("fs"), 1);
5379
+ var path12 = __toESM(require("path"), 1);
4208
5380
 
4209
5381
  // src/scanner/scan/ai/templates.ts
4210
- var fs6 = __toESM(require("fs"), 1);
4211
- var path9 = __toESM(require("path"), 1);
5382
+ var fs7 = __toESM(require("fs"), 1);
5383
+ var path11 = __toESM(require("path"), 1);
4212
5384
  function templatePath(rel) {
4213
5385
  const candidates = [
4214
- path9.resolve(__dirname, "../../templates", rel),
5386
+ path11.resolve(__dirname, "../../templates", rel),
4215
5387
  // dist/cli/cli.cjs → ../../templates
4216
- path9.resolve(__dirname, "../../../../templates", rel)
5388
+ path11.resolve(__dirname, "../../../../templates", rel)
4217
5389
  // src/scanner/scan/ai → ../../../../templates
4218
5390
  ];
4219
5391
  for (const c of candidates) {
4220
5392
  try {
4221
- fs6.accessSync(c, fs6.constants.R_OK);
5393
+ fs7.accessSync(c, fs7.constants.R_OK);
4222
5394
  return c;
4223
5395
  } catch {
4224
5396
  continue;
@@ -4230,7 +5402,7 @@ function templatePath(rel) {
4230
5402
  );
4231
5403
  }
4232
5404
  function readTemplate(rel) {
4233
- return fs6.readFileSync(templatePath(rel), "utf8");
5405
+ return fs7.readFileSync(templatePath(rel), "utf8");
4234
5406
  }
4235
5407
 
4236
5408
  // src/scanner/scan/ai/providers/claude.ts
@@ -4261,8 +5433,8 @@ var claudeProvider = {
4261
5433
  async install({ cwd, force }) {
4262
5434
  const changes = [];
4263
5435
  for (const file of SKILL_FILES) {
4264
- const dest = path10.join(cwd, file.dest);
4265
- const exists = fs7.existsSync(dest);
5436
+ const dest = path12.join(cwd, file.dest);
5437
+ const exists = fs8.existsSync(dest);
4266
5438
  if (exists && !force) {
4267
5439
  changes.push({
4268
5440
  path: file.dest,
@@ -4271,56 +5443,56 @@ var claudeProvider = {
4271
5443
  });
4272
5444
  continue;
4273
5445
  }
4274
- fs7.mkdirSync(path10.dirname(dest), { recursive: true });
4275
- fs7.writeFileSync(dest, readTemplate(file.template));
5446
+ fs8.mkdirSync(path12.dirname(dest), { recursive: true });
5447
+ fs8.writeFileSync(dest, readTemplate(file.template));
4276
5448
  changes.push({
4277
5449
  path: file.dest,
4278
5450
  action: exists ? "overwritten" : "created"
4279
5451
  });
4280
5452
  }
4281
5453
  for (const rel of LEGACY_FILES) {
4282
- const dest = path10.join(cwd, rel);
4283
- if (fs7.existsSync(dest)) {
4284
- fs7.unlinkSync(dest);
5454
+ const dest = path12.join(cwd, rel);
5455
+ if (fs8.existsSync(dest)) {
5456
+ fs8.unlinkSync(dest);
4285
5457
  changes.push({ path: rel, action: "removed" });
4286
5458
  }
4287
5459
  }
4288
- cleanupEmpty(path10.join(cwd, ".claude/commands/uidex"));
4289
- cleanupEmpty(path10.join(cwd, ".claude/commands"));
4290
- cleanupEmpty(path10.join(cwd, ".claude/rules"));
5460
+ cleanupEmpty(path12.join(cwd, ".claude/commands/uidex"));
5461
+ cleanupEmpty(path12.join(cwd, ".claude/commands"));
5462
+ cleanupEmpty(path12.join(cwd, ".claude/rules"));
4291
5463
  return { changes };
4292
5464
  },
4293
5465
  async uninstall({ cwd }) {
4294
5466
  const changes = [];
4295
5467
  for (const file of SKILL_FILES) {
4296
- const dest = path10.join(cwd, file.dest);
4297
- if (!fs7.existsSync(dest)) {
5468
+ const dest = path12.join(cwd, file.dest);
5469
+ if (!fs8.existsSync(dest)) {
4298
5470
  changes.push({ path: file.dest, action: "skipped", reason: "absent" });
4299
5471
  continue;
4300
5472
  }
4301
- fs7.unlinkSync(dest);
5473
+ fs8.unlinkSync(dest);
4302
5474
  changes.push({ path: file.dest, action: "removed" });
4303
5475
  }
4304
- cleanupEmpty(path10.join(cwd, ".claude/skills/uidex/references"));
4305
- cleanupEmpty(path10.join(cwd, ".claude/skills/uidex"));
4306
- cleanupEmpty(path10.join(cwd, ".claude/skills"));
5476
+ cleanupEmpty(path12.join(cwd, ".claude/skills/uidex/references"));
5477
+ cleanupEmpty(path12.join(cwd, ".claude/skills/uidex"));
5478
+ cleanupEmpty(path12.join(cwd, ".claude/skills"));
4307
5479
  for (const rel of LEGACY_FILES) {
4308
- const dest = path10.join(cwd, rel);
4309
- if (fs7.existsSync(dest)) {
4310
- fs7.unlinkSync(dest);
5480
+ const dest = path12.join(cwd, rel);
5481
+ if (fs8.existsSync(dest)) {
5482
+ fs8.unlinkSync(dest);
4311
5483
  changes.push({ path: rel, action: "removed" });
4312
5484
  }
4313
5485
  }
4314
- cleanupEmpty(path10.join(cwd, ".claude/commands/uidex"));
4315
- cleanupEmpty(path10.join(cwd, ".claude/commands"));
4316
- cleanupEmpty(path10.join(cwd, ".claude/rules"));
5486
+ cleanupEmpty(path12.join(cwd, ".claude/commands/uidex"));
5487
+ cleanupEmpty(path12.join(cwd, ".claude/commands"));
5488
+ cleanupEmpty(path12.join(cwd, ".claude/rules"));
4317
5489
  return { changes };
4318
5490
  }
4319
5491
  };
4320
5492
  function cleanupEmpty(dir) {
4321
5493
  try {
4322
- const entries = fs7.readdirSync(dir);
4323
- if (entries.length === 0) fs7.rmdirSync(dir);
5494
+ const entries = fs8.readdirSync(dir);
5495
+ if (entries.length === 0) fs8.rmdirSync(dir);
4324
5496
  } catch {
4325
5497
  }
4326
5498
  }
@@ -4489,6 +5661,8 @@ async function run(opts) {
4489
5661
  return runScaffold(cwd, positional.slice(1), flags, writer);
4490
5662
  case "rename":
4491
5663
  return runRename(cwd, positional.slice(1), flags, writer);
5664
+ case "states":
5665
+ return runStates(cwd, positional.slice(1), writer);
4492
5666
  case "ai": {
4493
5667
  const result = await runAiCommand({
4494
5668
  cwd,
@@ -4517,6 +5691,7 @@ function helpText2() {
4517
5691
  " scan [flags] Run the scanner pipeline",
4518
5692
  " scaffold <widget|page|feature> <id> Emit a Playwright spec from declared acceptance",
4519
5693
  " rename <element|widget|region> <old-id> <new-id> Rename an id everywhere (DOM attr, flows, exports)",
5694
+ " states merge [partial] Fold a filtered run's *.partial.json into the committed manifest",
4520
5695
  " ai <install|uninstall|providers> Manage AI assistant integrations",
4521
5696
  "",
4522
5697
  "Flags:",
@@ -4524,15 +5699,15 @@ function helpText2() {
4524
5699
  " --lint Run lint diagnostics (missing annotations, scope leak, duplicate ids, coverage)",
4525
5700
  " --audit Equivalent to --check --lint (read-only)",
4526
5701
  " --fix Apply machine-generated fixes (add data-uidex to unannotated interactive elements, drop empty names), then rescan and write",
4527
- " --update-baseline Regenerate the page-coverage baseline (uidex-coverage-baseline.json) from the current uncaptured routes",
5702
+ " --update-baseline Regenerate the coverage baseline (uidex-coverage-baseline.json) as `acknowledge` backlog entries",
4528
5703
  " --json Emit JSON diagnostics on stdout",
4529
5704
  " --force (scaffold) overwrite existing spec",
4530
5705
  ""
4531
5706
  ].join("\n");
4532
5707
  }
4533
5708
  function runInit(cwd, w) {
4534
- const configPath = path11.join(cwd, CONFIG_FILENAME);
4535
- if (fs8.existsSync(configPath)) {
5709
+ const configPath = path13.join(cwd, CONFIG_FILENAME);
5710
+ if (fs9.existsSync(configPath)) {
4536
5711
  w.err(`.uidex.json already exists at ${configPath}`);
4537
5712
  return w.result(1);
4538
5713
  }
@@ -4541,16 +5716,16 @@ function runInit(cwd, w) {
4541
5716
  sources: [{ rootDir: "src" }],
4542
5717
  output: "src/uidex.gen.ts"
4543
5718
  };
4544
- fs8.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
5719
+ fs9.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
4545
5720
  w.out(`Created ${configPath}`);
4546
- const gitignorePath = path11.join(cwd, ".gitignore");
5721
+ const gitignorePath = path13.join(cwd, ".gitignore");
4547
5722
  const entry = "*.gen.*";
4548
- if (fs8.existsSync(gitignorePath)) {
4549
- const existing = fs8.readFileSync(gitignorePath, "utf8");
5723
+ if (fs9.existsSync(gitignorePath)) {
5724
+ const existing = fs9.readFileSync(gitignorePath, "utf8");
4550
5725
  const hasEntry = existing.split("\n").some((line) => line.trim() === entry);
4551
5726
  if (!hasEntry) {
4552
5727
  const needsNewline = existing.length > 0 && !existing.endsWith("\n");
4553
- fs8.appendFileSync(
5728
+ fs9.appendFileSync(
4554
5729
  gitignorePath,
4555
5730
  `${needsNewline ? "\n" : ""}${entry}
4556
5731
  `,
@@ -4559,7 +5734,7 @@ function runInit(cwd, w) {
4559
5734
  w.out(`Appended ${entry} to ${gitignorePath}`);
4560
5735
  }
4561
5736
  } else {
4562
- fs8.writeFileSync(gitignorePath, `${entry}
5737
+ fs9.writeFileSync(gitignorePath, `${entry}
4563
5738
  `, "utf8");
4564
5739
  w.out(`Created ${gitignorePath} with ${entry}`);
4565
5740
  }
@@ -4598,20 +5773,25 @@ function runScanCommand(cwd, flags, w) {
4598
5773
  if (updateBaseline) {
4599
5774
  for (const r of results) {
4600
5775
  const manifest = r.capturedStates ?? { captured: [] };
4601
- const pageBaseline = computePageBaseline(r.registry, manifest);
4602
- const missingKinds = computeMissingKinds(r.registry, manifest);
4603
- const baseline = {
4604
- ...pageBaseline,
4605
- ...Object.keys(missingKinds).length > 0 ? { missingKinds } : {}
5776
+ const acknowledge = {};
5777
+ const merge = (from) => {
5778
+ for (const [route, scopes] of Object.entries(from)) {
5779
+ acknowledge[route] = { ...acknowledge[route], ...scopes };
5780
+ }
4606
5781
  };
4607
- const p2 = pageBaselinePath(r);
4608
- fs8.writeFileSync(p2, JSON.stringify(baseline, null, 2) + "\n");
4609
- const kindGaps = Object.values(missingKinds).reduce(
4610
- (n, ks) => n + ks.length,
5782
+ merge(computePageBacklog(r.registry, manifest));
5783
+ merge(computeMatrixBacklog(r.registry, manifest));
5784
+ const baseline = { version: 2, acknowledge };
5785
+ const p2 = coverageBaselinePath(r);
5786
+ fs9.writeFileSync(p2, serializeCoverageBaseline(baseline));
5787
+ const entries = Object.values(acknowledge);
5788
+ const pages = entries.filter((s) => s["*"]).length;
5789
+ const kindGaps = entries.reduce(
5790
+ (n, s) => n + Object.keys(s).filter((k) => k !== "*").length,
4611
5791
  0
4612
5792
  );
4613
5793
  w.out(
4614
- `Wrote ${p2} \u2014 ${baseline.uncapturedPages.length} uncaptured page(s), ${kindGaps} missing core-kind(s) baselined`
5794
+ `Wrote ${p2} \u2014 ${pages} uncaptured page(s), ${kindGaps} missing core-kind(s) acknowledged as backlog`
4615
5795
  );
4616
5796
  }
4617
5797
  return w.result(0);
@@ -4684,7 +5864,7 @@ function runScaffold(cwd, args, flags, w) {
4684
5864
  for (const r of results) {
4685
5865
  const entity = r.registry.get(scaffoldKind, id);
4686
5866
  if (!entity) continue;
4687
- const outDir = path11.resolve(r.configDir, "e2e");
5867
+ const outDir = path13.resolve(r.configDir, "e2e");
4688
5868
  const result = scaffoldSpec({
4689
5869
  registry: r.registry,
4690
5870
  kind: scaffoldKind,
@@ -4705,6 +5885,53 @@ function runScaffold(cwd, args, flags, w) {
4705
5885
  return w.result(1);
4706
5886
  }
4707
5887
  var RENAME_KINDS = /* @__PURE__ */ new Set(["element", "widget", "region"]);
5888
+ function runStates(cwd, args, w) {
5889
+ const [sub, explicitPartial] = args;
5890
+ if (sub !== "merge") {
5891
+ w.err("Usage: uidex states merge [partial-manifest]");
5892
+ return w.result(1);
5893
+ }
5894
+ const configs = discover({ cwd });
5895
+ if (configs.length === 0) {
5896
+ w.err(`No ${CONFIG_FILENAME} found under ${cwd}`);
5897
+ return w.result(1);
5898
+ }
5899
+ let merged = 0;
5900
+ for (const { configDir, config } of configs) {
5901
+ const rel = config.statesManifest ?? "uidex-states.json";
5902
+ const target = path13.resolve(configDir, rel);
5903
+ const partial = explicitPartial ? path13.resolve(cwd, explicitPartial) : target.replace(/\.json$/i, "") + ".partial.json";
5904
+ const committedRaw = readJson(target);
5905
+ const partialRaw = readJson(partial);
5906
+ if (!partialRaw) continue;
5907
+ if (!committedRaw) {
5908
+ w.err(`No committed manifest at ${rel}; nothing to merge into`);
5909
+ return w.result(1);
5910
+ }
5911
+ const result = mergeStates(committedRaw, partialRaw);
5912
+ fs9.writeFileSync(target, JSON.stringify(result.manifest, null, 2) + "\n");
5913
+ merged++;
5914
+ w.out(
5915
+ `Merged ${path13.basename(partial)} \u2192 ${rel}: ${result.added.length} added, ${result.updated.length} updated, ${result.manifest.captured.length} total`
5916
+ );
5917
+ for (const k of result.added) w.out(` + ${k}`);
5918
+ for (const k of result.updated) w.out(` ~ ${k}`);
5919
+ }
5920
+ if (merged === 0) {
5921
+ w.out("No partial manifest found; nothing to merge.");
5922
+ }
5923
+ return w.result(0);
5924
+ }
5925
+ function readJson(p2) {
5926
+ try {
5927
+ const parsed = JSON.parse(fs9.readFileSync(p2, "utf8"));
5928
+ if (!parsed || typeof parsed !== "object") return null;
5929
+ const m = parsed;
5930
+ return Array.isArray(m.captured) ? m : null;
5931
+ } catch {
5932
+ return null;
5933
+ }
5934
+ }
4708
5935
  function runRename(cwd, args, flags, w) {
4709
5936
  const [kind, oldId, newId] = args;
4710
5937
  if (!kind || !RENAME_KINDS.has(kind) || !oldId || !newId) {
@@ -4753,35 +5980,47 @@ function createWriter() {
4753
5980
  }
4754
5981
  // Annotate the CommonJS export names for ESM import in node:
4755
5982
  0 && (module.exports = {
5983
+ ACK_ALL,
5984
+ ACK_SCOPES,
4756
5985
  CONFIG_FILENAME,
4757
5986
  ConfigError,
4758
5987
  DEFAULT_CONVENTIONS,
5988
+ DIAGNOSTIC_CODES,
4759
5989
  applyFixes,
4760
5990
  audit,
5991
+ capturedElsewhere,
5992
+ checkComponentCoverage,
4761
5993
  checkPageCoverage,
4762
5994
  checkStateCoverage,
4763
5995
  checkStateMatrix,
4764
5996
  compileRoute,
4765
- computeMissingKinds,
4766
- computePageBaseline,
5997
+ computeMatrixBacklog,
5998
+ computePageBacklog,
5999
+ coverageBaselinePath,
4767
6000
  detectRoutes,
4768
6001
  discover,
4769
6002
  emit,
6003
+ entityAcknowledge,
4770
6004
  extract,
4771
6005
  extractUidexExports,
6006
+ findWorkspaceRoot,
4772
6007
  globToRegExp,
4773
- loadPageBaseline,
6008
+ loadCoverageBaseline,
4774
6009
  matchUrlToRoute,
4775
- pageBaselinePath,
6010
+ mergeStates,
4776
6011
  parseConfig,
6012
+ parseCoverageBaseline,
4777
6013
  pathToId,
4778
6014
  renameEntity,
4779
6015
  resolve,
4780
6016
  resolveGitContext,
6017
+ resolveWorkspace,
6018
+ routeAcknowledge,
4781
6019
  runCli,
4782
6020
  runScan,
4783
6021
  scaffoldSpec,
4784
6022
  scaffoldWidgetSpec,
6023
+ serializeCoverageBaseline,
4785
6024
  validateConfig,
4786
6025
  walk,
4787
6026
  writeScanResult