uidex 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/cli/cli.cjs +1273 -343
  2. package/dist/cli/cli.cjs.map +1 -1
  3. package/dist/headless/index.cjs +3 -0
  4. package/dist/headless/index.cjs.map +1 -1
  5. package/dist/headless/index.d.cts +19 -13
  6. package/dist/headless/index.d.ts +19 -13
  7. package/dist/headless/index.js +3 -0
  8. package/dist/headless/index.js.map +1 -1
  9. package/dist/index.cjs +3 -0
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +19 -13
  12. package/dist/index.d.ts +19 -13
  13. package/dist/index.js +3 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/playwright/index.cjs +36 -7
  16. package/dist/playwright/index.cjs.map +1 -1
  17. package/dist/playwright/index.js +36 -7
  18. package/dist/playwright/index.js.map +1 -1
  19. package/dist/playwright/states-reporter.cjs +36 -7
  20. package/dist/playwright/states-reporter.cjs.map +1 -1
  21. package/dist/playwright/states-reporter.d.cts +15 -0
  22. package/dist/playwright/states-reporter.d.ts +15 -0
  23. package/dist/playwright/states-reporter.js +36 -7
  24. package/dist/playwright/states-reporter.js.map +1 -1
  25. package/dist/playwright/states.cjs +8 -0
  26. package/dist/playwright/states.cjs.map +1 -1
  27. package/dist/playwright/states.d.cts +44 -1
  28. package/dist/playwright/states.d.ts +44 -1
  29. package/dist/playwright/states.js +7 -0
  30. package/dist/playwright/states.js.map +1 -1
  31. package/dist/react/index.cjs +3 -0
  32. package/dist/react/index.cjs.map +1 -1
  33. package/dist/react/index.d.cts +19 -13
  34. package/dist/react/index.d.ts +19 -13
  35. package/dist/react/index.js +3 -0
  36. package/dist/react/index.js.map +1 -1
  37. package/dist/scan/index.cjs +1217 -257
  38. package/dist/scan/index.cjs.map +1 -1
  39. package/dist/scan/index.d.cts +410 -104
  40. package/dist/scan/index.d.ts +410 -104
  41. package/dist/scan/index.js +1197 -253
  42. package/dist/scan/index.js.map +1 -1
  43. package/package.json +17 -17
package/dist/cli/cli.cjs CHANGED
@@ -24,8 +24,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
 
26
26
  // src/scanner/scan/cli.ts
27
- var fs8 = __toESM(require("fs"), 1);
28
- var path11 = __toESM(require("path"), 1);
27
+ var fs9 = __toESM(require("fs"), 1);
28
+ var path12 = __toESM(require("path"), 1);
29
29
 
30
30
  // src/scanner/scan/ai/index.ts
31
31
  var p = __toESM(require("@clack/prompts"), 1);
@@ -302,7 +302,8 @@ var ALLOWED_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
302
302
  "audit",
303
303
  "conventions",
304
304
  "statesManifest",
305
- "coverageBaseline"
305
+ "coverageBaseline",
306
+ "workspace"
306
307
  ]);
307
308
  var ALLOWED_SOURCE_KEYS = /* @__PURE__ */ new Set(["rootDir", "include", "exclude", "prefix"]);
308
309
  var ALLOWED_CONVENTIONS_KEYS = /* @__PURE__ */ new Set([
@@ -318,19 +319,64 @@ var ALLOWED_AUDIT_KEYS = /* @__PURE__ */ new Set([
318
319
  "acceptance",
319
320
  "states",
320
321
  "pageCoverage",
321
- "stateMatrix"
322
+ "stateMatrix",
323
+ "severity"
322
324
  ]);
325
+ var DIAGNOSTIC_CODES = [
326
+ "acceptance-uncovered",
327
+ "acknowledged-elsewhere",
328
+ "competing-uidex-export",
329
+ "component-state-uncaptured",
330
+ "core-kind",
331
+ "duplicate-id",
332
+ "dynamic-attr",
333
+ "dynamic-flow-reference",
334
+ "gen-missing",
335
+ "gen-stale",
336
+ "legacy-acknowledge-field",
337
+ "legacy-coverage-baseline",
338
+ "matrix-backlog",
339
+ "missing-element-annotation",
340
+ "missing-state-capture",
341
+ "orphan-state-capture",
342
+ "page-backlog",
343
+ "page-captured",
344
+ "page-no-capture",
345
+ "parse-error",
346
+ "placeholder-reason",
347
+ "prefer-well-known-file",
348
+ "rename",
349
+ "scope-leak",
350
+ "spread-attr",
351
+ "stale-acknowledgment",
352
+ "stale-page-baseline",
353
+ "uidex-export-ambiguous-kind",
354
+ "uidex-export-duplicate-field",
355
+ "uidex-export-duplicate-state",
356
+ "uidex-export-empty-name",
357
+ "uidex-export-invalid-field",
358
+ "uidex-export-invalid-literal",
359
+ "uidex-export-missing-kind",
360
+ "uidex-export-satisfies-mismatch",
361
+ "uidex-export-unknown-field",
362
+ "unknown-reference",
363
+ "widget-id-mismatch",
364
+ "widget-missing-acceptance",
365
+ "widget-missing-dom"
366
+ ];
367
+ var DIAGNOSTIC_CODE_SET = new Set(DIAGNOSTIC_CODES);
368
+ var SEVERITY_LEVELS = /* @__PURE__ */ new Set(["error", "warning", "info", "off"]);
323
369
  function fail(msg) {
324
370
  throw new ConfigError(`Invalid .uidex.json: ${msg}`);
325
371
  }
326
- function assertObject(value, path12) {
372
+ function assertObject(value, path13) {
327
373
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
328
- fail(`${path12} must be an object`);
374
+ fail(`${path13} must be an object`);
329
375
  }
330
376
  }
331
- function assertStringArray(value, path12) {
377
+ function assertStringArray(value, path13) {
332
378
  if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
333
- fail(`${path12} must be a string[]`);
379
+ fail(`${path13} must be a string[]`);
334
380
  }
335
381
  }
336
382
  function validateConfig(raw) {
@@ -384,11 +430,30 @@ function validateConfig(raw) {
384
430
  if (raw.coverageBaseline !== void 0 && typeof raw.coverageBaseline !== "string") {
385
431
  fail(`"coverageBaseline" must be a string`);
386
432
  }
433
+ if (raw.workspace !== void 0 && raw.workspace !== false && typeof raw.workspace !== "string") {
434
+ fail(`"workspace" must be a path string or false`);
435
+ }
387
436
  if (raw.audit !== void 0) {
388
437
  assertObject(raw.audit, "audit");
389
438
  for (const key of Object.keys(raw.audit)) {
390
439
  if (!ALLOWED_AUDIT_KEYS.has(key)) fail(`unknown key "${key}" in audit`);
391
440
  const v = raw.audit[key];
441
+ if (key === "severity") {
442
+ assertObject(v, "audit.severity");
443
+ for (const [code, level] of Object.entries(v)) {
444
+ if (!DIAGNOSTIC_CODE_SET.has(code)) {
445
+ fail(
446
+ `unknown diagnostic code "${code}" in audit.severity (a typo here would silently leave that gate at its default severity)`
447
+ );
448
+ }
449
+ if (typeof level !== "string" || !SEVERITY_LEVELS.has(level)) {
450
+ fail(
451
+ `audit.severity["${code}"] must be one of "error", "warning", "info", "off"`
452
+ );
453
+ }
454
+ }
455
+ continue;
456
+ }
392
457
  if (typeof v !== "boolean") fail(`audit.${key} must be a boolean`);
393
458
  }
394
459
  }
@@ -407,7 +472,13 @@ function validateConfig(raw) {
407
472
  fail(`conventions.features must be a string or false`);
408
473
  }
409
474
  if (c.pages !== void 0 && c.pages !== false && c.pages !== "auto") {
410
- fail(`conventions.pages must be "auto" or false`);
475
+ const p2 = c.pages;
476
+ const routesDir = typeof p2 === "object" && p2 !== null && !Array.isArray(p2) ? p2.routesDir : void 0;
477
+ if (typeof p2 !== "object" || p2 === null || Array.isArray(p2) || Object.keys(p2).some((k) => k !== "routesDir") || typeof routesDir !== "string" || routesDir.length === 0) {
478
+ fail(
479
+ `conventions.pages must be "auto", false, or { routesDir: string }`
480
+ );
481
+ }
411
482
  }
412
483
  if (c.flows !== void 0 && c.flows !== false) {
413
484
  assertStringArray(c.flows, "conventions.flows");
@@ -425,7 +496,8 @@ function validateConfig(raw) {
425
496
  audit: raw.audit,
426
497
  conventions: raw.conventions,
427
498
  statesManifest: raw.statesManifest,
428
- coverageBaseline: raw.coverageBaseline
499
+ coverageBaseline: raw.coverageBaseline,
500
+ workspace: raw.workspace
429
501
  };
430
502
  }
431
503
  function parseConfig(json) {
@@ -475,9 +547,11 @@ function tryReadConfig(configPath) {
475
547
  function discover(options = {}) {
476
548
  const cwd = options.cwd ?? process.cwd();
477
549
  const maxDepth = options.maxDepth ?? MAX_DEPTH;
550
+ const all = options.all ?? false;
478
551
  const rootMatch = tryReadConfig(path3.join(cwd, CONFIG_FILENAME));
479
- if (rootMatch) return [rootMatch];
552
+ if (rootMatch && !all) return [rootMatch];
480
553
  const results = [];
554
+ if (rootMatch) results.push(rootMatch);
481
555
  const queue = [[cwd, 0]];
482
556
  while (queue.length > 0) {
483
557
  const [dir, depth] = queue.shift();
@@ -598,11 +672,11 @@ function applyFixes(diagnostics) {
598
672
  }
599
673
 
600
674
  // src/scanner/scan/pipeline.ts
601
- var fs6 = __toESM(require("fs"), 1);
602
- var path9 = __toESM(require("path"), 1);
675
+ var fs7 = __toESM(require("fs"), 1);
676
+ var path10 = __toESM(require("path"), 1);
603
677
 
604
678
  // src/scanner/scan/audit.ts
605
- var path5 = __toESM(require("path"), 1);
679
+ var path7 = __toESM(require("path"), 1);
606
680
 
607
681
  // src/shared/entities/types.ts
608
682
  var ENTITY_KINDS = [
@@ -799,6 +873,320 @@ function createRegistry() {
799
873
  };
800
874
  }
801
875
 
876
+ // src/scanner/scan/acknowledge.ts
877
+ var ACK_ALL = "*";
878
+ var ACK_SCOPES = [ACK_ALL, ...CORE_STATE_KINDS];
879
+ var COMPONENT_ACK_SCOPES = [ACK_ALL];
880
+ var AUTHORABLE_ACK_TYPES = [
881
+ "impossible",
882
+ "backlog"
883
+ ];
884
+ var CORE_SET = new Set(CORE_STATE_KINDS);
885
+ function isCoreKind(scope) {
886
+ return CORE_SET.has(scope);
887
+ }
888
+ var PLACEHOLDER_REASON = "TODO: why can this never be captured?";
889
+ function isPlaceholderReason(reason) {
890
+ return typeof reason === "string" && /^\s*TODO\b/i.test(reason);
891
+ }
892
+ function describeAck(a) {
893
+ if (a.type === "captured-elsewhere") {
894
+ return a.by ? `captured-elsewhere (${a.by})` : "captured-elsewhere";
895
+ }
896
+ return a.reason ? `${a.type} ("${a.reason}")` : a.type;
897
+ }
898
+ function originHint(a, page) {
899
+ if (a.origin === "baseline") {
900
+ return "Prune it with `uidex scan --update-baseline` so the backlog reflects reality.";
901
+ }
902
+ if (a.origin === "workspace") {
903
+ return "This is computed from the workspace; it resolves itself once the sibling capture changes.";
904
+ }
905
+ const where = page ? ` on page "${page}"` : "";
906
+ return `Remove the \`acknowledge.${a.scope}\` entry${where} \u2014 a capture contradicts it.`;
907
+ }
908
+ function toResolved(map, origin, into) {
909
+ for (const [scope, ack] of Object.entries(map ?? {})) {
910
+ into.set(scope, { ...ack, scope, origin });
911
+ }
912
+ }
913
+ function routeAcknowledge(registry, baseline, route, page) {
914
+ const out2 = /* @__PURE__ */ new Map();
915
+ toResolved(baseline?.acknowledge?.[route], "baseline", out2);
916
+ if (page) {
917
+ toResolved(registry.get("page", page)?.meta?.acknowledge, "source", out2);
918
+ }
919
+ return out2;
920
+ }
921
+ function entityAcknowledge(meta, capturedElsewhereBy) {
922
+ const source = meta?.acknowledge?.[ACK_ALL];
923
+ if (source) return { ...source, scope: ACK_ALL, origin: "source" };
924
+ if (capturedElsewhereBy) {
925
+ return {
926
+ type: "captured-elsewhere",
927
+ by: capturedElsewhereBy,
928
+ scope: ACK_ALL,
929
+ origin: "workspace"
930
+ };
931
+ }
932
+ return void 0;
933
+ }
934
+ function readAck(raw) {
935
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
936
+ const r = raw;
937
+ if (r.type !== "impossible" && r.type !== "backlog") return void 0;
938
+ const out2 = { type: r.type };
939
+ if (typeof r.reason === "string" && r.reason.length > 0) out2.reason = r.reason;
940
+ return out2;
941
+ }
942
+ function parseCoverageBaseline(raw) {
943
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
944
+ const r = raw;
945
+ const ackRaw = r.acknowledge;
946
+ if (!ackRaw || typeof ackRaw !== "object" || Array.isArray(ackRaw))
947
+ return null;
948
+ const acknowledge = {};
949
+ for (const [route, scopesRaw] of Object.entries(
950
+ ackRaw
951
+ )) {
952
+ if (!scopesRaw || typeof scopesRaw !== "object" || Array.isArray(scopesRaw))
953
+ continue;
954
+ const scopes = {};
955
+ for (const [scope, ackVal] of Object.entries(
956
+ scopesRaw
957
+ )) {
958
+ if (scope !== ACK_ALL && !isCoreKind(scope)) continue;
959
+ const ack = readAck(ackVal);
960
+ if (ack) scopes[scope] = ack;
961
+ }
962
+ if (Object.keys(scopes).length > 0) acknowledge[route] = scopes;
963
+ }
964
+ return { version: 2, acknowledge };
965
+ }
966
+ function isLegacyBaseline(raw) {
967
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
968
+ const r = raw;
969
+ if ("acknowledge" in r) return false;
970
+ return "uncapturedPages" in r || "missingKinds" in r;
971
+ }
972
+ function migrateBaseline(legacy) {
973
+ const acknowledge = {};
974
+ const at = (route) => acknowledge[route] ??= {};
975
+ for (const route of legacy.uncapturedPages ?? []) {
976
+ if (typeof route === "string") at(route)[ACK_ALL] = { type: "backlog" };
977
+ }
978
+ for (const [route, kinds] of Object.entries(legacy.missingKinds ?? {})) {
979
+ if (!Array.isArray(kinds)) continue;
980
+ for (const kind of kinds) {
981
+ if (typeof kind === "string" && isCoreKind(kind)) {
982
+ at(route)[kind] = { type: "backlog" };
983
+ }
984
+ }
985
+ }
986
+ return { version: 2, acknowledge };
987
+ }
988
+ function serializeCoverageBaseline(baseline) {
989
+ const routes = Object.keys(baseline.acknowledge).sort();
990
+ const ordered = {};
991
+ for (const route of routes) {
992
+ const scopes = baseline.acknowledge[route];
993
+ const sorted = {};
994
+ for (const scope of ACK_SCOPES) {
995
+ if (scopes[scope]) sorted[scope] = scopes[scope];
996
+ }
997
+ ordered[route] = sorted;
998
+ }
999
+ return JSON.stringify({ version: 2, acknowledge: ordered }, null, 2) + "\n";
1000
+ }
1001
+
1002
+ // src/scanner/scan/component-coverage.ts
1003
+ var COMPOSED_KINDS = ["widget", "feature"];
1004
+ function composedStateEntities(registry, page) {
1005
+ const meta = registry.get("page", page)?.meta;
1006
+ if (!meta) return [];
1007
+ const out2 = [];
1008
+ for (const kind of COMPOSED_KINDS) {
1009
+ const ids = (kind === "widget" ? meta.widgets : meta.features) ?? [];
1010
+ for (const id of ids) {
1011
+ const e = registry.get(kind, id);
1012
+ if (!e?.meta?.states?.length) continue;
1013
+ if (e.meta.acknowledge?.[ACK_ALL]) continue;
1014
+ out2.push({ kind, id });
1015
+ }
1016
+ }
1017
+ return out2;
1018
+ }
1019
+ function capturedStatesFor(captured, kind, id) {
1020
+ const out2 = /* @__PURE__ */ new Set();
1021
+ for (const c of captured) {
1022
+ if (c.entity !== id) continue;
1023
+ if (c.kind !== void 0 && c.kind !== kind && c.kind !== "page") continue;
1024
+ out2.add(c.state);
1025
+ }
1026
+ return out2;
1027
+ }
1028
+ function checkComponentCoverage(registry, manifest) {
1029
+ const routes = registry.list("route");
1030
+ if (routes.length === 0) return [];
1031
+ const captured = manifest.captured ?? [];
1032
+ const diagnostics = [];
1033
+ for (const route of routes) {
1034
+ if (registry.get("page", route.page)?.meta?.acknowledge?.[ACK_ALL]) continue;
1035
+ for (const { kind, id } of composedStateEntities(registry, route.page)) {
1036
+ const entity = registry.get(kind, id);
1037
+ const declared = entity.meta.states.map((s) => s.name);
1038
+ const shot = capturedStatesFor(captured, kind, id);
1039
+ const missing = declared.filter((s) => !shot.has(s));
1040
+ if (missing.length === 0) continue;
1041
+ diagnostics.push({
1042
+ code: "component-state-uncaptured",
1043
+ severity: "info",
1044
+ message: `route "${route.path}" composes ${kind} "${id}", whose state(s) [${missing.join(", ")}] no capture produced`,
1045
+ file: entity.loc?.file,
1046
+ line: entity.loc?.line,
1047
+ entity: { kind, id },
1048
+ 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}".`
1049
+ });
1050
+ }
1051
+ }
1052
+ return diagnostics;
1053
+ }
1054
+
1055
+ // src/scanner/scan/migrate-acknowledge.ts
1056
+ var CORE = CORE_STATE_KINDS;
1057
+ function orderScopes(scopes) {
1058
+ const rank = (s) => s === ACK_ALL ? -1 : CORE.indexOf(s);
1059
+ return [...scopes].sort((a, b) => rank(a) - rank(b));
1060
+ }
1061
+ function renderKey(scope) {
1062
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(scope) ? scope : JSON.stringify(scope);
1063
+ }
1064
+ function renderAck(a) {
1065
+ const parts = [`type: ${JSON.stringify(a.type)}`];
1066
+ if (a.reason) parts.push(`reason: ${JSON.stringify(a.reason)}`);
1067
+ return `{ ${parts.join(", ")} }`;
1068
+ }
1069
+ function renderAcknowledge(entries, indent) {
1070
+ const inner = indent + " ";
1071
+ const lines = orderScopes(Object.keys(entries)).map(
1072
+ (scope) => `${inner}${renderKey(scope)}: ${renderAck(entries[scope])},`
1073
+ );
1074
+ return ["acknowledge: {", ...lines, `${indent}}`].join("\n");
1075
+ }
1076
+ function indentAt(content, offset) {
1077
+ const lineStart = content.lastIndexOf("\n", offset - 1) + 1;
1078
+ const m = /^[ \t]*/.exec(content.slice(lineStart, offset));
1079
+ return m ? m[0] : "";
1080
+ }
1081
+ function plannedEntries(m) {
1082
+ const entries = {};
1083
+ if (m.legacyCapture === false) {
1084
+ entries[ACK_ALL] = {
1085
+ type: "impossible",
1086
+ reason: m.description || PLACEHOLDER_REASON
1087
+ };
1088
+ }
1089
+ for (const kind of CORE) {
1090
+ const reason = m.legacyWaivers?.[kind];
1091
+ if (reason) entries[kind] = { type: "impossible", reason };
1092
+ }
1093
+ return entries;
1094
+ }
1095
+ function migrateLegacyFields(ef) {
1096
+ const out2 = [];
1097
+ const { content, sourcePath, displayPath } = ef.file;
1098
+ for (const m of ef.metadata ?? []) {
1099
+ const legacy = [];
1100
+ if (m.legacyWaivers) legacy.push("waivers");
1101
+ if (m.legacyCapture !== void 0) legacy.push("capture");
1102
+ if (legacy.length === 0) continue;
1103
+ const fields = legacy.join(" and ");
1104
+ const base = {
1105
+ severity: "error",
1106
+ code: "legacy-acknowledge-field",
1107
+ file: displayPath,
1108
+ line: m.loc.line
1109
+ };
1110
+ if (m.acknowledge) {
1111
+ out2.push({
1112
+ ...base,
1113
+ message: `\`${fields}\` was replaced by \`acknowledge\`, and this declaration already has an \`acknowledge\` field`,
1114
+ hint: `Merge the remaining \`${fields}\` entries into \`acknowledge\` by hand and delete them; --fix will not merge two maps for you.`
1115
+ });
1116
+ continue;
1117
+ }
1118
+ const entries = plannedEntries(m);
1119
+ const edits = [];
1120
+ const present = legacy.map((f) => ({ f, prop: m.fieldPropSpans?.[f], del: m.fieldSpans?.[f] })).filter((x) => x.prop && x.del).sort((a, b) => a.prop.start - b.prop.start);
1121
+ if (present.length !== legacy.length) {
1122
+ out2.push({
1123
+ ...base,
1124
+ message: `\`${fields}\` was replaced by \`acknowledge\`, but the field spans needed to rewrite it are unavailable`,
1125
+ hint: "Rewrite the declaration by hand: `acknowledge: { <scope>: { type, reason? } }`."
1126
+ });
1127
+ continue;
1128
+ }
1129
+ const [anchor, ...rest] = present;
1130
+ if (Object.keys(entries).length === 0) {
1131
+ edits.push({
1132
+ path: sourcePath,
1133
+ start: anchor.del.start,
1134
+ end: anchor.del.end,
1135
+ replacement: ""
1136
+ });
1137
+ } else {
1138
+ edits.push({
1139
+ path: sourcePath,
1140
+ start: anchor.prop.start,
1141
+ end: anchor.prop.end,
1142
+ replacement: renderAcknowledge(
1143
+ entries,
1144
+ indentAt(content, anchor.prop.start)
1145
+ )
1146
+ });
1147
+ }
1148
+ for (const r of rest) {
1149
+ edits.push({
1150
+ path: sourcePath,
1151
+ start: r.del.start,
1152
+ end: r.del.end,
1153
+ replacement: ""
1154
+ });
1155
+ }
1156
+ out2.push({
1157
+ ...base,
1158
+ message: `\`${fields}\` was replaced by the unified \`acknowledge\` field`,
1159
+ hint: `Run \`uidex scan --fix\` to rewrite it${m.legacyCapture === false && !m.description ? ", then replace the TODO reason it plants \u2014 `capture: false` carried no explanation and `impossible` requires one" : ""}.`,
1160
+ fix: {
1161
+ description: `Rewrite \`${fields}\` as \`acknowledge\``,
1162
+ edits
1163
+ }
1164
+ });
1165
+ }
1166
+ return out2;
1167
+ }
1168
+ function migrateLegacyBaseline(opts) {
1169
+ if (!isLegacyBaseline(opts.parsed)) return void 0;
1170
+ return {
1171
+ code: "legacy-coverage-baseline",
1172
+ severity: "error",
1173
+ message: `coverage baseline "${opts.displayPath}" uses the removed \`uncapturedPages\` / \`missingKinds\` shape`,
1174
+ file: opts.displayPath,
1175
+ hint: "Run `uidex scan --fix` to rewrite it as `acknowledge` entries of type `backlog` (identical content, new shape \u2014 no capture run needed).",
1176
+ fix: {
1177
+ description: `Rewrite ${opts.displayPath} as an \`acknowledge\` baseline`,
1178
+ edits: [
1179
+ {
1180
+ path: opts.path,
1181
+ start: 0,
1182
+ end: opts.raw.length,
1183
+ replacement: serializeCoverageBaseline(migrateBaseline(opts.parsed))
1184
+ }
1185
+ ]
1186
+ }
1187
+ };
1188
+ }
1189
+
802
1190
  // src/scanner/scan/page-coverage.ts
803
1191
  function compileRoute(pattern) {
804
1192
  const segs = pattern.split("/").filter(Boolean);
@@ -820,11 +1208,11 @@ function compileRoute(pattern) {
820
1208
  return { test: (url) => re.test(url), specificity };
821
1209
  }
822
1210
  function matchUrlToRoute(url, routePaths) {
823
- const path12 = stripQuery(url);
1211
+ const path13 = stripQuery(url);
824
1212
  let best = null;
825
1213
  for (const rp of routePaths) {
826
1214
  const { test, specificity } = compileRoute(rp);
827
- if (!test(path12)) continue;
1215
+ if (!test(path13)) continue;
828
1216
  if (!best || specificity > best.specificity)
829
1217
  best = { path: rp, specificity };
830
1218
  }
@@ -855,29 +1243,49 @@ function checkPageCoverage(registry, manifest, baseline) {
855
1243
  if (routes.length === 0) return [];
856
1244
  const routePaths = routes.map((r) => r.path);
857
1245
  const captured = manifest.captured ?? [];
858
- const optedOut = /* @__PURE__ */ new Set();
859
- for (const r of routes) {
860
- const page = registry.get("page", r.page);
861
- if (page?.meta?.capture === false) optedOut.add(r.path);
862
- }
863
1246
  const covered = /* @__PURE__ */ new Set();
864
1247
  for (const c of captured) {
865
1248
  const route = routeForCapture(c, routes, routePaths);
866
1249
  if (route) covered.add(route);
867
1250
  }
868
- const baselineSet = new Set(baseline?.uncapturedPages ?? []);
869
1251
  const diagnostics = [];
870
1252
  for (const r of routes) {
871
- if (covered.has(r.path) || optedOut.has(r.path)) continue;
1253
+ const ack = routeAcknowledge(registry, baseline, r.path, r.page).get(
1254
+ ACK_ALL
1255
+ );
872
1256
  const loc = registry.get("page", r.page)?.loc;
873
- if (baselineSet.has(r.path)) {
1257
+ if (covered.has(r.path)) {
1258
+ if (ack?.type === "impossible") {
1259
+ diagnostics.push({
1260
+ code: "stale-acknowledgment",
1261
+ severity: "warning",
1262
+ message: `page "${r.page}" acknowledges "*" as ${describeAck(ack)}, but route "${r.path}" is captured`,
1263
+ file: loc?.file,
1264
+ line: loc?.line,
1265
+ entity: { kind: "page", id: r.page },
1266
+ hint: originHint(ack, r.page)
1267
+ });
1268
+ } else if (ack?.type === "backlog") {
1269
+ diagnostics.push({
1270
+ code: "page-captured",
1271
+ severity: "warning",
1272
+ message: `route "${r.path}" is now captured but is still acknowledged as backlog`,
1273
+ file: loc?.file,
1274
+ line: loc?.line,
1275
+ hint: originHint(ack, r.page)
1276
+ });
1277
+ }
1278
+ continue;
1279
+ }
1280
+ if (ack?.type === "impossible") continue;
1281
+ if (ack?.type === "backlog") {
874
1282
  diagnostics.push({
875
1283
  code: "page-backlog",
876
1284
  severity: "info",
877
1285
  message: `route "${r.path}" has no visual-states capture (acknowledged backlog)`,
878
1286
  file: loc?.file,
879
1287
  line: loc?.line,
880
- 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 }\`.`
1288
+ 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}".`
881
1289
  });
882
1290
  continue;
883
1291
  }
@@ -888,46 +1296,307 @@ function checkPageCoverage(registry, manifest, baseline) {
888
1296
  file: loc?.file,
889
1297
  line: loc?.line,
890
1298
  entity: { kind: "route", id: r.path },
891
- 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).`
1299
+ 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).`
892
1300
  });
893
1301
  }
894
- for (const b of baselineSet) {
895
- if (!routePaths.includes(b)) {
896
- diagnostics.push({
897
- code: "stale-page-baseline",
898
- severity: "warning",
899
- message: `coverage baseline lists "${b}", which is not a route in the registry`,
900
- hint: `Remove it from the baseline (\`uidex scan --update-baseline\`); the route no longer exists.`
901
- });
902
- } else if (covered.has(b) || optedOut.has(b)) {
903
- diagnostics.push({
904
- code: "page-captured",
905
- severity: "warning",
906
- message: `route "${b}" is now captured but is still in the coverage baseline`,
907
- hint: `Prune it with \`uidex scan --update-baseline\` so the backlog reflects reality.`
908
- });
909
- }
1302
+ for (const b of Object.keys(baseline?.acknowledge ?? {})) {
1303
+ if (routePaths.includes(b)) continue;
1304
+ diagnostics.push({
1305
+ code: "stale-page-baseline",
1306
+ severity: "warning",
1307
+ message: `coverage baseline acknowledges "${b}", which is not a route in the registry`,
1308
+ hint: `Remove it from the baseline (\`uidex scan --update-baseline\`); the route no longer exists.`
1309
+ });
910
1310
  }
911
1311
  return diagnostics;
912
1312
  }
913
- function computePageBaseline(registry, manifest) {
914
- const uncaptured = [];
1313
+ function computePageBacklog(registry, manifest) {
1314
+ const out2 = {};
915
1315
  for (const d of checkPageCoverage(registry, manifest, {
916
- uncapturedPages: []
1316
+ version: 2,
1317
+ acknowledge: {}
917
1318
  })) {
918
- if (d.code === "page-no-capture" && d.entity) uncaptured.push(d.entity.id);
1319
+ if (d.code === "page-no-capture" && d.entity) {
1320
+ out2[d.entity.id] = { [ACK_ALL]: { type: "backlog" } };
1321
+ }
1322
+ }
1323
+ return out2;
1324
+ }
1325
+
1326
+ // src/scanner/scan/workspace.ts
1327
+ var fs6 = __toESM(require("fs"), 1);
1328
+ var path6 = __toESM(require("path"), 1);
1329
+
1330
+ // src/scanner/scan/walk.ts
1331
+ var fs5 = __toESM(require("fs"), 1);
1332
+ var path5 = __toESM(require("path"), 1);
1333
+ var DEFAULT_INCLUDES = ["**/*.{ts,tsx,js,jsx,mjs,cjs}"];
1334
+ var BASE_EXCLUDES = [
1335
+ "**/node_modules/**",
1336
+ "**/dist/**",
1337
+ "**/build/**",
1338
+ "**/.next/**",
1339
+ // Exclude the scanner's own generated siblings so a scan never re-ingests its
1340
+ // output. Enumerated (not `*.gen.*`) so a hand-written file that merely contains
1341
+ // a `.gen.` segment, e.g. `foo.gen.helpers.ts`, is still scanned.
1342
+ "**/*.gen.ts",
1343
+ "**/*.gen.data.ts",
1344
+ "**/*.gen.locs.ts"
1345
+ ];
1346
+ var TEST_EXCLUDES = [
1347
+ "**/*.test.ts",
1348
+ "**/*.test.tsx",
1349
+ "**/*.spec.ts",
1350
+ "**/*.spec.tsx"
1351
+ ];
1352
+ var DEFAULT_EXCLUDES = [...BASE_EXCLUDES, ...TEST_EXCLUDES];
1353
+ function globToRegExp(glob) {
1354
+ let i = 0;
1355
+ let out2 = "";
1356
+ while (i < glob.length) {
1357
+ const c = glob[i];
1358
+ if (c === "*" && glob[i + 1] === "*") {
1359
+ if (glob[i + 2] === "/") {
1360
+ out2 += "(?:.*/)?";
1361
+ i += 3;
1362
+ continue;
1363
+ }
1364
+ out2 += ".*";
1365
+ i += 2;
1366
+ continue;
1367
+ }
1368
+ if (c === "*") {
1369
+ out2 += "[^/]*";
1370
+ i++;
1371
+ continue;
1372
+ }
1373
+ if (c === "?") {
1374
+ out2 += "[^/]";
1375
+ i++;
1376
+ continue;
1377
+ }
1378
+ if (c === "{") {
1379
+ const end = glob.indexOf("}", i);
1380
+ if (end === -1) {
1381
+ out2 += "\\{";
1382
+ i++;
1383
+ continue;
1384
+ }
1385
+ const parts = glob.slice(i + 1, end).split(",").map((p2) => p2.replace(/[.+^$()|\\]/g, "\\$&"));
1386
+ out2 += `(?:${parts.join("|")})`;
1387
+ i = end + 1;
1388
+ continue;
1389
+ }
1390
+ if (/[.+^$()|\\]/.test(c)) {
1391
+ out2 += "\\" + c;
1392
+ i++;
1393
+ continue;
1394
+ }
1395
+ out2 += c;
1396
+ i++;
1397
+ }
1398
+ return new RegExp(`^${out2}$`);
1399
+ }
1400
+ function toPosix(p2) {
1401
+ return p2.split(path5.sep).join("/");
1402
+ }
1403
+ function matchesAny(rel, patterns) {
1404
+ return patterns.some((g) => globToRegExp(g).test(rel));
1405
+ }
1406
+ function createSourceMatcher(sources, options) {
1407
+ const { cwd, globalExcludes = [], includeTests = false } = options;
1408
+ const baseDefaults = includeTests ? BASE_EXCLUDES : DEFAULT_EXCLUDES;
1409
+ const compiled = sources.map((source) => ({
1410
+ absRoot: path5.resolve(cwd, source.rootDir),
1411
+ includes: source.include?.length ? source.include : DEFAULT_INCLUDES,
1412
+ excludes: [...baseDefaults, ...globalExcludes, ...source.exclude ?? []]
1413
+ }));
1414
+ return (absFile) => {
1415
+ const abs = path5.resolve(absFile);
1416
+ return compiled.some(({ absRoot, includes, excludes }) => {
1417
+ const rel = toPosix(path5.relative(absRoot, abs));
1418
+ if (rel.length === 0 || rel.startsWith("../") || path5.isAbsolute(rel)) {
1419
+ return false;
1420
+ }
1421
+ if (matchesAny(rel, excludes)) return false;
1422
+ return matchesAny(rel, includes);
1423
+ });
1424
+ };
1425
+ }
1426
+ function walk(sources, options) {
1427
+ const { cwd, globalExcludes = [], includeTests = false } = options;
1428
+ const out2 = [];
1429
+ const baseDefaults = includeTests ? BASE_EXCLUDES : DEFAULT_EXCLUDES;
1430
+ for (const source of sources) {
1431
+ const includes = source.include?.length ? source.include : DEFAULT_INCLUDES;
1432
+ const excludes = [
1433
+ ...baseDefaults,
1434
+ ...globalExcludes,
1435
+ ...source.exclude ?? []
1436
+ ];
1437
+ const absRoot = path5.resolve(cwd, source.rootDir);
1438
+ for (const filePath of walkDir(absRoot, absRoot)) {
1439
+ const rel = toPosix(path5.relative(absRoot, filePath));
1440
+ if (matchesAny(rel, excludes)) continue;
1441
+ if (!matchesAny(rel, includes)) continue;
1442
+ let content;
1443
+ try {
1444
+ content = fs5.readFileSync(filePath, "utf8");
1445
+ } catch {
1446
+ continue;
1447
+ }
1448
+ const relFromCwd = toPosix(path5.relative(cwd, filePath));
1449
+ const displayPath = source.prefix ? `${source.prefix.replace(/\/$/, "")}/${rel}` : relFromCwd;
1450
+ out2.push({
1451
+ sourcePath: filePath,
1452
+ relativePath: rel,
1453
+ displayPath,
1454
+ content
1455
+ });
1456
+ }
1457
+ }
1458
+ return out2.sort((a, b) => a.displayPath.localeCompare(b.displayPath));
1459
+ }
1460
+ function* walkDir(root, dir) {
1461
+ let entries;
1462
+ try {
1463
+ entries = fs5.readdirSync(dir, { withFileTypes: true });
1464
+ } catch {
1465
+ return;
1466
+ }
1467
+ for (const entry of entries) {
1468
+ const full = path5.join(dir, entry.name);
1469
+ if (entry.isDirectory()) {
1470
+ if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git" || entry.name === "build" || entry.name === ".next") {
1471
+ continue;
1472
+ }
1473
+ yield* walkDir(root, full);
1474
+ } else if (entry.isFile()) {
1475
+ yield full;
1476
+ }
1477
+ }
1478
+ }
1479
+
1480
+ // src/scanner/scan/workspace.ts
1481
+ var WORKSPACE_MARKERS = [
1482
+ "pnpm-workspace.yaml",
1483
+ "pnpm-workspace.yml",
1484
+ "turbo.json",
1485
+ "lerna.json",
1486
+ "nx.json",
1487
+ ".git"
1488
+ ];
1489
+ var MAX_ASCENT = 8;
1490
+ var DEFAULT_STATES_MANIFEST = "uidex-states.json";
1491
+ function captureKey(kind, entity) {
1492
+ return `${kind ?? ""}|${entity}`;
1493
+ }
1494
+ function hasMarker(dir) {
1495
+ return WORKSPACE_MARKERS.some((m) => fs6.existsSync(path6.join(dir, m)));
1496
+ }
1497
+ function findWorkspaceRoot(configDir) {
1498
+ let dir = path6.resolve(configDir);
1499
+ for (let i = 0; i < MAX_ASCENT; i++) {
1500
+ const parent = path6.dirname(dir);
1501
+ if (parent === dir) return void 0;
1502
+ dir = parent;
1503
+ if (hasMarker(dir)) return dir;
1504
+ }
1505
+ return void 0;
1506
+ }
1507
+ function captureKeys(manifest) {
1508
+ const out2 = /* @__PURE__ */ new Set();
1509
+ for (const c of manifest?.captured ?? []) {
1510
+ out2.add(captureKey(c.kind, c.entity));
1511
+ }
1512
+ return out2;
1513
+ }
1514
+ function readManifest(configDir, config) {
1515
+ const rel = config.statesManifest ?? DEFAULT_STATES_MANIFEST;
1516
+ let raw;
1517
+ try {
1518
+ raw = fs6.readFileSync(path6.resolve(configDir, rel), "utf8");
1519
+ } catch {
1520
+ return void 0;
1521
+ }
1522
+ try {
1523
+ const parsed = JSON.parse(raw);
1524
+ if (!parsed || !Array.isArray(parsed.captured)) return void 0;
1525
+ const captured = [];
1526
+ for (const entry of parsed.captured) {
1527
+ if (!entry || typeof entry !== "object") continue;
1528
+ const c = entry;
1529
+ if (typeof c.entity !== "string" || typeof c.state !== "string") continue;
1530
+ const rec = { entity: c.entity, state: c.state };
1531
+ if (typeof c.kind === "string") rec.kind = c.kind;
1532
+ captured.push(rec);
1533
+ }
1534
+ return { captured };
1535
+ } catch {
1536
+ return void 0;
919
1537
  }
920
- return { uncapturedPages: uncaptured.sort() };
1538
+ }
1539
+ function toApp(dc) {
1540
+ return {
1541
+ name: path6.basename(dc.configDir),
1542
+ configDir: dc.configDir,
1543
+ configPath: dc.configPath,
1544
+ registers: createSourceMatcher(dc.config.sources, {
1545
+ cwd: dc.configDir,
1546
+ globalExcludes: dc.config.exclude
1547
+ }),
1548
+ captured: captureKeys(readManifest(dc.configDir, dc.config))
1549
+ };
1550
+ }
1551
+ function resolveWorkspace(opts) {
1552
+ const { configDir, config } = opts;
1553
+ if (config.workspace === false) return void 0;
1554
+ const root = typeof config.workspace === "string" ? path6.resolve(configDir, config.workspace) : findWorkspaceRoot(configDir);
1555
+ if (!root) return void 0;
1556
+ const configs = opts.configs ?? discover({ cwd: root, all: true });
1557
+ const self = path6.resolve(configDir, CONFIG_FILENAME);
1558
+ const siblings = configs.filter((dc) => path6.resolve(dc.configPath) !== self).map(toApp);
1559
+ if (siblings.length === 0) return void 0;
1560
+ return { root, siblings };
1561
+ }
1562
+ function capturedElsewhere(workspace, absFile, kind, id) {
1563
+ if (!workspace || !absFile) return void 0;
1564
+ for (const app of workspace.siblings) {
1565
+ if (!app.registers(absFile)) continue;
1566
+ if (app.captured.has(captureKey(kind, id)) || app.captured.has(captureKey(void 0, id))) {
1567
+ return app.name;
1568
+ }
1569
+ }
1570
+ return void 0;
921
1571
  }
922
1572
 
923
1573
  // src/scanner/scan/state-coverage.ts
924
1574
  var STATE_KINDS = ["page", "feature", "widget"];
925
- function declaredStateEntities(registry) {
1575
+ function declaredStateEntities(registry, opts, ungated) {
926
1576
  const out2 = [];
927
1577
  for (const kind of STATE_KINDS) {
928
1578
  for (const e of registry.list(kind)) {
929
1579
  const states = e.meta?.states;
930
1580
  if (!states || states.length === 0) continue;
1581
+ const absFile = e.loc?.file ? opts.resolveSourcePath?.(e.loc.file) : void 0;
1582
+ const ack = entityAcknowledge(
1583
+ e.meta,
1584
+ capturedElsewhere(opts.workspace, absFile, kind, e.id)
1585
+ );
1586
+ if (ack) {
1587
+ if (ack.origin === "workspace") {
1588
+ ungated.push({
1589
+ code: "acknowledged-elsewhere",
1590
+ severity: "info",
1591
+ 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)})`,
1592
+ file: e.loc?.file,
1593
+ line: e.loc?.line,
1594
+ entity: { kind, id: e.id },
1595
+ 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.`
1596
+ });
1597
+ }
1598
+ continue;
1599
+ }
931
1600
  out2.push({
932
1601
  kind,
933
1602
  id: e.id,
@@ -938,18 +1607,31 @@ function declaredStateEntities(registry) {
938
1607
  }
939
1608
  return out2;
940
1609
  }
941
- function captureMatchesEntity(cap, ent) {
1610
+ function unreliablePageKinds(registry, captured) {
1611
+ const out2 = /* @__PURE__ */ new Set();
1612
+ for (const c of captured) {
1613
+ if (c.kind !== "page") continue;
1614
+ if (registry.get("page", c.entity)) continue;
1615
+ out2.add(c.entity);
1616
+ }
1617
+ return out2;
1618
+ }
1619
+ function captureMatchesEntity(cap, ent, unreliable = EMPTY) {
942
1620
  if (cap.entity !== ent.id) return false;
943
- return cap.kind === void 0 || cap.kind === ent.kind;
1621
+ if (cap.kind === void 0) return true;
1622
+ if (cap.kind === ent.kind) return true;
1623
+ return cap.kind === "page" && unreliable.has(cap.entity);
944
1624
  }
945
- function checkStateCoverage(registry, manifest) {
1625
+ var EMPTY = /* @__PURE__ */ new Set();
1626
+ function checkStateCoverage(registry, manifest, opts = {}) {
946
1627
  const diagnostics = [];
947
- const declared = declaredStateEntities(registry);
948
1628
  const captured = manifest.captured ?? [];
1629
+ const unreliable = unreliablePageKinds(registry, captured);
1630
+ const declared = declaredStateEntities(registry, opts, diagnostics);
949
1631
  for (const ent of declared) {
950
1632
  for (const state of ent.states) {
951
1633
  const hit = captured.some(
952
- (c) => captureMatchesEntity(c, ent) && c.state === state
1634
+ (c) => captureMatchesEntity(c, ent, unreliable) && c.state === state
953
1635
  );
954
1636
  if (hit) continue;
955
1637
  diagnostics.push({
@@ -964,7 +1646,9 @@ function checkStateCoverage(registry, manifest) {
964
1646
  }
965
1647
  }
966
1648
  for (const cap of captured) {
967
- const candidates = declared.filter((ent2) => captureMatchesEntity(cap, ent2));
1649
+ const candidates = declared.filter(
1650
+ (ent2) => captureMatchesEntity(cap, ent2, unreliable)
1651
+ );
968
1652
  if (candidates.length === 0) continue;
969
1653
  const declaredSomewhere = candidates.some(
970
1654
  (ent2) => ent2.states.includes(cap.state)
@@ -1008,36 +1692,35 @@ function coreKindsByRoute(registry, manifest) {
1008
1692
  }
1009
1693
  return byRoute;
1010
1694
  }
1011
- function pageWaivers(registry, routePage) {
1012
- return registry.get("page", routePage)?.meta?.waivers ?? {};
1013
- }
1014
- function isOptedOut(registry, page) {
1015
- return page ? registry.get("page", page)?.meta?.capture === false : false;
1016
- }
1017
1695
  function checkStateMatrix(registry, manifest, baseline) {
1018
1696
  const routes = registry.list("route");
1019
1697
  if (routes.length === 0) return [];
1020
1698
  const pageOf = new Map(routes.map((r) => [r.path, r.page]));
1021
1699
  const captured = coreKindsByRoute(registry, manifest);
1022
- const baselined = baseline?.missingKinds ?? {};
1023
1700
  const diagnostics = [];
1024
1701
  for (const [route, kinds] of captured) {
1025
1702
  const page = pageOf.get(route);
1026
- if (isOptedOut(registry, page)) continue;
1027
- const waivers = page ? pageWaivers(registry, page) : {};
1703
+ const ack = routeAcknowledge(registry, baseline, route, page);
1028
1704
  const loc = page ? registry.get("page", page)?.loc : void 0;
1029
- const baselinedKinds = new Set(baselined[route] ?? []);
1705
+ const all = ack.get(ACK_ALL);
1706
+ if (all) {
1707
+ if (all.type === "impossible") {
1708
+ diagnostics.push(staleDiagnostic(all, route, page, loc));
1709
+ }
1710
+ continue;
1711
+ }
1030
1712
  for (const kind of CORE_KINDS) {
1031
1713
  if (kinds.has(kind)) continue;
1032
- if (waivers[kind]) continue;
1033
- if (baselinedKinds.has(kind)) {
1714
+ const entry = ack.get(kind);
1715
+ if (entry?.type === "impossible") continue;
1716
+ if (entry?.type === "backlog") {
1034
1717
  diagnostics.push({
1035
1718
  code: "matrix-backlog",
1036
1719
  severity: "info",
1037
- message: `route "${route}" does not render core kind "${kind}" (acknowledged MISSING_KINDS backlog)`,
1720
+ message: `route "${route}" does not render core kind "${kind}" (acknowledged backlog)`,
1038
1721
  file: loc?.file,
1039
1722
  line: loc?.line,
1040
- hint: `Capture the "${kind}" state for "${route}", waive it (\`waivers: { ${kind}: "why it can't" }\` on the page), or leave it in the baseline.`
1723
+ 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" }\`.`
1041
1724
  });
1042
1725
  continue;
1043
1726
  }
@@ -1048,42 +1731,43 @@ function checkStateMatrix(registry, manifest, baseline) {
1048
1731
  file: loc?.file,
1049
1732
  line: loc?.line,
1050
1733
  entity: { kind: "route", id: route },
1051
- 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).`
1734
+ 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).`
1052
1735
  });
1053
1736
  }
1054
- }
1055
- for (const [route, kinds] of captured) {
1056
- const page = pageOf.get(route);
1057
- if (!page || isOptedOut(registry, page)) continue;
1058
- const waivers = pageWaivers(registry, page);
1059
- const loc = registry.get("page", page)?.loc;
1060
- for (const kind of Object.keys(waivers)) {
1061
- if (kinds.has(kind)) {
1062
- diagnostics.push({
1063
- code: "stale-waiver",
1064
- severity: "warning",
1065
- message: `page "${page}" waives core kind "${kind}", but route "${route}" does render it`,
1066
- file: loc?.file,
1067
- line: loc?.line,
1068
- entity: { kind: "page", id: page },
1069
- hint: `Remove the "${kind}" waiver \u2014 the route renders that kind, so the waiver's reason is stale.`
1070
- });
1071
- }
1737
+ for (const [scope, entry] of ack) {
1738
+ if (scope === ACK_ALL || !kinds.has(scope)) continue;
1739
+ diagnostics.push(staleDiagnostic(entry, route, page, loc));
1072
1740
  }
1073
1741
  }
1074
1742
  return diagnostics;
1075
1743
  }
1076
- function computeMissingKinds(registry, manifest) {
1744
+ function staleDiagnostic(entry, route, page, loc) {
1745
+ const what = entry.scope === ACK_ALL ? `route "${route}" is captured` : `route "${route}" does render core kind "${entry.scope}"`;
1746
+ return {
1747
+ code: "stale-acknowledgment",
1748
+ severity: "warning",
1749
+ message: `${page ? `page "${page}"` : `route "${route}"`} acknowledges "${entry.scope}" as ${describeAck(entry)}, but ${what}`,
1750
+ file: loc?.file,
1751
+ line: loc?.line,
1752
+ ...page ? { entity: { kind: "page", id: page } } : {},
1753
+ hint: originHint(entry, page)
1754
+ };
1755
+ }
1756
+ function computeMatrixBacklog(registry, manifest) {
1077
1757
  const routes = registry.list("route");
1078
1758
  const pageOf = new Map(routes.map((r) => [r.path, r.page]));
1079
1759
  const captured = coreKindsByRoute(registry, manifest);
1080
1760
  const out2 = {};
1081
1761
  for (const [route, kinds] of captured) {
1082
1762
  const page = pageOf.get(route);
1083
- if (isOptedOut(registry, page)) continue;
1084
- const waivers = page ? pageWaivers(registry, page) : {};
1085
- const missing = CORE_KINDS.filter((k) => !kinds.has(k) && !waivers[k]);
1086
- if (missing.length > 0) out2[route] = missing;
1763
+ const ack = routeAcknowledge(registry, void 0, route, page);
1764
+ if (ack.has(ACK_ALL)) continue;
1765
+ const scopes = {};
1766
+ for (const kind of CORE_KINDS) {
1767
+ if (kinds.has(kind) || ack.has(kind)) continue;
1768
+ scopes[kind] = { type: "backlog" };
1769
+ }
1770
+ if (Object.keys(scopes).length > 0) out2[route] = scopes;
1087
1771
  }
1088
1772
  return out2;
1089
1773
  }
@@ -1107,6 +1791,23 @@ function audit(opts) {
1107
1791
  for (const ef of opts.flowExtracted ?? []) {
1108
1792
  if (ef.diagnostics) diagnostics.push(...ef.diagnostics);
1109
1793
  }
1794
+ for (const ef of extracted) diagnostics.push(...migrateLegacyFields(ef));
1795
+ if (opts.baselineDiagnostic) diagnostics.push(opts.baselineDiagnostic);
1796
+ for (const ef of extracted) {
1797
+ for (const m of ef.metadata ?? []) {
1798
+ for (const [scope, ack] of Object.entries(m.acknowledge ?? {})) {
1799
+ if (!isPlaceholderReason(ack.reason)) continue;
1800
+ diagnostics.push({
1801
+ code: "placeholder-reason",
1802
+ severity: "warning",
1803
+ message: `acknowledge "${scope}" still has the migration's placeholder reason ("${ack.reason}")`,
1804
+ file: ef.file.displayPath,
1805
+ line: m.loc.line,
1806
+ 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).`
1807
+ });
1808
+ }
1809
+ }
1810
+ }
1110
1811
  if (check && opts.generated !== void 0) {
1111
1812
  const outRel = opts.outputPath ?? config.output;
1112
1813
  const fresh = normalizeForCheck(opts.generated);
@@ -1220,8 +1921,8 @@ function audit(opts) {
1220
1921
  if (typeof m.id !== "string") continue;
1221
1922
  const filePath = ef.file.displayPath;
1222
1923
  const wellKnownName = WELL_KNOWN_FILES[m.kind];
1223
- if (path5.posix.basename(filePath) === wellKnownName) continue;
1224
- const dir = path5.posix.dirname(filePath);
1924
+ if (path7.posix.basename(filePath) === wellKnownName) continue;
1925
+ const dir = path7.posix.dirname(filePath);
1225
1926
  const wellKnownPath = dir === "." ? wellKnownName : `${dir}/${wellKnownName}`;
1226
1927
  if (scannedPaths.has(wellKnownPath)) continue;
1227
1928
  const kindLabel = m.kind === "page" ? "Page" : "Feature";
@@ -1435,25 +2136,41 @@ function audit(opts) {
1435
2136
  }
1436
2137
  }
1437
2138
  if (lint && statesEnabled && opts.capturedStates) {
1438
- diagnostics.push(...checkStateCoverage(registry, opts.capturedStates));
2139
+ const sourceByDisplay = new Map(
2140
+ files.map((f) => [f.displayPath, f.sourcePath])
2141
+ );
2142
+ diagnostics.push(
2143
+ ...checkStateCoverage(registry, opts.capturedStates, {
2144
+ workspace: opts.workspace,
2145
+ resolveSourcePath: (displayPath) => sourceByDisplay.get(displayPath)
2146
+ })
2147
+ );
1439
2148
  }
1440
2149
  if (lint && pageCoverageEnabled && opts.capturedStates) {
1441
2150
  diagnostics.push(
1442
- ...checkPageCoverage(registry, opts.capturedStates, opts.pageBaseline)
2151
+ ...checkPageCoverage(registry, opts.capturedStates, opts.coverageBaseline)
1443
2152
  );
1444
2153
  }
2154
+ if (lint && statesEnabled && opts.capturedStates) {
2155
+ diagnostics.push(...checkComponentCoverage(registry, opts.capturedStates));
2156
+ }
1445
2157
  if (lint && stateMatrixEnabled && opts.capturedStates) {
1446
2158
  diagnostics.push(
1447
- ...checkStateMatrix(registry, opts.capturedStates, {
1448
- missingKinds: opts.pageBaseline?.missingKinds ?? {}
1449
- })
2159
+ ...checkStateMatrix(registry, opts.capturedStates, opts.coverageBaseline)
1450
2160
  );
1451
2161
  }
2162
+ const overrides = config.audit?.severity ?? {};
2163
+ const governed = diagnostics.flatMap((d) => {
2164
+ const level = overrides[d.code];
2165
+ if (level === void 0) return [d];
2166
+ if (level === "off") return [];
2167
+ return [{ ...d, severity: level }];
2168
+ });
1452
2169
  const summary = {
1453
- errors: diagnostics.filter((d) => d.severity === "error").length,
1454
- warnings: diagnostics.filter((d) => d.severity === "warning").length
2170
+ errors: governed.filter((d) => d.severity === "error").length,
2171
+ warnings: governed.filter((d) => d.severity === "warning").length
1455
2172
  };
1456
- return { diagnostics, summary };
2173
+ return { diagnostics: governed, summary };
1457
2174
  }
1458
2175
  function lineOfOffset(content, offset) {
1459
2176
  let line = 1;
@@ -1781,6 +2498,30 @@ ${body}
1781
2498
  `;
1782
2499
  }
1783
2500
  var OPAQUE_ID_UNIONS = /* @__PURE__ */ new Set(["ElementId"]);
2501
+ function stateEntities(list) {
2502
+ return list.map((e) => ({
2503
+ id: e.id,
2504
+ states: (e.meta?.states ?? []).map((s) => s.name)
2505
+ }));
2506
+ }
2507
+ function emitStateMap(groups) {
2508
+ const lines = ["export interface UidexStates {"];
2509
+ for (const g of groups) {
2510
+ const withStates = g.entities.filter((e) => e.states.length > 0);
2511
+ if (withStates.length === 0) {
2512
+ lines.push(` ${g.kind}: never`);
2513
+ continue;
2514
+ }
2515
+ lines.push(` ${g.kind}: {`);
2516
+ for (const e of [...withStates].sort((a, b) => a.id.localeCompare(b.id))) {
2517
+ const states = [...new Set(e.states)].sort().map((s) => JSON.stringify(s)).join(" | ");
2518
+ lines.push(` ${JSON.stringify(e.id)}: ${states}`);
2519
+ }
2520
+ lines.push(" }");
2521
+ }
2522
+ lines.push("}");
2523
+ return lines.join("\n") + "\n";
2524
+ }
1784
2525
  function emitId(name, ids) {
1785
2526
  if (OPAQUE_ID_UNIONS.has(name)) {
1786
2527
  return `export type ${name} = string & { readonly __uidex?: ${JSON.stringify(name)} }
@@ -1868,6 +2609,14 @@ function emit(opts) {
1868
2609
  );
1869
2610
  t.push(emitId("StateId", [...stateNames]));
1870
2611
  t.push("");
2612
+ t.push("// ---- declared state space (kind \u2192 entity \u2192 its own states) ----");
2613
+ t.push(
2614
+ emitStateMap([
2615
+ { kind: "page", entities: stateEntities(pages) },
2616
+ { kind: "feature", entities: stateEntities(features) },
2617
+ { kind: "widget", entities: stateEntities(widgets) }
2618
+ ])
2619
+ );
1871
2620
  t.push("// ---- authoring-surface shape types ----");
1872
2621
  t.push("export namespace Uidex {");
1873
2622
  t.push(
@@ -1880,7 +2629,20 @@ function emit(opts) {
1880
2629
  t.push(" acceptance?: readonly string[]");
1881
2630
  t.push(" description?: string");
1882
2631
  t.push(" }");
1883
- t.push(" export type Waivers = Partial<Record<CoreStateKind, string>>");
2632
+ t.push(' export type AcknowledgeScope = CoreStateKind | "*"');
2633
+ t.push(" export interface Impossible {");
2634
+ t.push(' type: "impossible"');
2635
+ t.push(" reason: string");
2636
+ t.push(" }");
2637
+ t.push(" export interface Backlog {");
2638
+ t.push(' type: "backlog"');
2639
+ t.push(" reason?: string");
2640
+ t.push(" }");
2641
+ t.push(" export type Acknowledgment = Impossible | Backlog");
2642
+ t.push(
2643
+ " export type Acknowledge = Partial<Record<AcknowledgeScope, Acknowledgment>>"
2644
+ );
2645
+ t.push(' export type ComponentAcknowledge = { "*"?: Acknowledgment }');
1884
2646
  t.push(" export interface Page {");
1885
2647
  t.push(" page: PageId | false");
1886
2648
  t.push(" name?: string");
@@ -1888,8 +2650,7 @@ function emit(opts) {
1888
2650
  t.push(" widgets?: readonly WidgetId[]");
1889
2651
  t.push(" acceptance?: readonly string[]");
1890
2652
  t.push(" states?: readonly (string | State)[]");
1891
- t.push(" capture?: boolean");
1892
- t.push(" waivers?: Waivers");
2653
+ t.push(" acknowledge?: Acknowledge");
1893
2654
  t.push(" description?: string");
1894
2655
  t.push(" }");
1895
2656
  t.push(" export interface Feature {");
@@ -1898,6 +2659,7 @@ function emit(opts) {
1898
2659
  t.push(" features?: readonly FeatureId[]");
1899
2660
  t.push(" acceptance?: readonly string[]");
1900
2661
  t.push(" states?: readonly (string | State)[]");
2662
+ t.push(" acknowledge?: ComponentAcknowledge");
1901
2663
  t.push(" description?: string");
1902
2664
  t.push(" }");
1903
2665
  t.push(" export interface Primitive {");
@@ -1910,6 +2672,7 @@ function emit(opts) {
1910
2672
  t.push(" name?: string");
1911
2673
  t.push(" acceptance?: readonly string[]");
1912
2674
  t.push(" states?: readonly (string | State)[]");
2675
+ t.push(" acknowledge?: ComponentAcknowledge");
1913
2676
  t.push(" description?: string");
1914
2677
  t.push(" }");
1915
2678
  t.push(" export interface Region {");
@@ -1973,10 +2736,10 @@ function emit(opts) {
1973
2736
  }
1974
2737
 
1975
2738
  // src/scanner/scan/ast.ts
1976
- var path6 = __toESM(require("path"), 1);
2739
+ var path8 = __toESM(require("path"), 1);
1977
2740
  var import_oxc_parser = require("oxc-parser");
1978
2741
  function langFor(sourcePath) {
1979
- switch (path6.extname(sourcePath)) {
2742
+ switch (path8.extname(sourcePath)) {
1980
2743
  case ".tsx":
1981
2744
  return "tsx";
1982
2745
  case ".ts":
@@ -2074,26 +2837,41 @@ var ALLOWED_FIELDS = {
2074
2837
  "widgets",
2075
2838
  "acceptance",
2076
2839
  "states",
2840
+ "acknowledge",
2077
2841
  "capture",
2078
2842
  "waivers",
2079
2843
  "description"
2080
2844
  ]),
2081
- // `capture` / `waivers` are route-scoped, and only a page maps to a route — so
2082
- // they are page-only (the gates read them off the page). A feature/widget that
2083
- // sets them gets an `uidex-export-unknown-field` error rather than a silent no-op.
2845
+ // A page's `acknowledge` accepts both scope shapes: `"*"` (the whole route)
2846
+ // and a core kind (one cell of its matrix). A feature/widget has no route, so
2847
+ // the core-kind matrix is meaningless there and only `"*"` is accepted —
2848
+ // giving a component a scoped opt-out instead of forcing you to delete its
2849
+ // whole `states` block. `capture` / `waivers` are REMOVED; they stay parseable
2850
+ // only so `--fix` can rewrite them into `acknowledge` (see MIGRATION below).
2084
2851
  feature: /* @__PURE__ */ new Set([
2085
2852
  "feature",
2086
2853
  "name",
2087
- "features",
2854
+ "features",
2855
+ "acceptance",
2856
+ "states",
2857
+ "acknowledge",
2858
+ "capture",
2859
+ "description"
2860
+ ]),
2861
+ primitive: /* @__PURE__ */ new Set(["primitive", "name", "description"]),
2862
+ widget: /* @__PURE__ */ new Set([
2863
+ "widget",
2864
+ "name",
2088
2865
  "acceptance",
2089
2866
  "states",
2867
+ "acknowledge",
2868
+ "capture",
2090
2869
  "description"
2091
2870
  ]),
2092
- primitive: /* @__PURE__ */ new Set(["primitive", "name", "description"]),
2093
- widget: /* @__PURE__ */ new Set(["widget", "name", "acceptance", "states", "description"]),
2094
2871
  region: /* @__PURE__ */ new Set(["region", "name", "description"]),
2095
2872
  flow: /* @__PURE__ */ new Set(["flow", "notFlow", "name", "description"])
2096
2873
  };
2874
+ var ACK_FIELDS = /* @__PURE__ */ new Set(["type", "reason"]);
2097
2875
  var STATE_FIELDS = /* @__PURE__ */ new Set([
2098
2876
  "name",
2099
2877
  "kind",
@@ -2344,7 +3122,8 @@ function objectLit(node, content, p2, pos, span) {
2344
3122
  key,
2345
3123
  value,
2346
3124
  keyPos,
2347
- span: removalSpan(content, prop.start, prop.end)
3125
+ span: removalSpan(content, prop.start, prop.end),
3126
+ propSpan: { start: prop.start, end: prop.end }
2348
3127
  });
2349
3128
  }
2350
3129
  return { kind: "object", entries, pos, span };
@@ -2492,8 +3271,10 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
2492
3271
  const featuresField = kind === "page" || kind === "feature" ? readStringArrayField(byKey, "features") : void 0;
2493
3272
  const widgetsField = kind === "page" ? readStringArrayField(byKey, "widgets") : void 0;
2494
3273
  const states = kind === "page" || kind === "feature" || kind === "widget" ? readStatesField(byKey) : void 0;
2495
- const capture = kind === "page" ? readBooleanField(byKey, "capture") : void 0;
2496
- const waivers = kind === "page" ? readWaiversField(byKey) : void 0;
3274
+ const stateBearing = kind === "page" || kind === "feature" || kind === "widget";
3275
+ const acknowledge = stateBearing ? readAcknowledgeField(byKey, kind === "page") : void 0;
3276
+ const legacyCapture = stateBearing ? readBooleanField(byKey, "capture") : void 0;
3277
+ const legacyWaivers = kind === "page" ? readWaiversField(byKey) : void 0;
2497
3278
  const notFlow = kind === "flow" && discriminator === "notFlow" ? true : void 0;
2498
3279
  const metadata = {
2499
3280
  source: "ts-export",
@@ -2517,15 +3298,25 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
2517
3298
  metadata.widgetSpans = widgetsField.spans;
2518
3299
  }
2519
3300
  if (states?.length) metadata.states = states;
2520
- if (capture !== void 0) metadata.capture = capture;
2521
- if (waivers && Object.keys(waivers).length > 0) metadata.waivers = waivers;
3301
+ if (acknowledge && Object.keys(acknowledge).length > 0) {
3302
+ metadata.acknowledge = acknowledge;
3303
+ }
3304
+ if (legacyCapture !== void 0) metadata.legacyCapture = legacyCapture;
3305
+ if (legacyWaivers && Object.keys(legacyWaivers).length > 0) {
3306
+ metadata.legacyWaivers = legacyWaivers;
3307
+ }
2522
3308
  if (notFlow) metadata.notFlow = true;
2523
3309
  if (typeof id === "string" && idValue.kind === "string") {
2524
3310
  metadata.idSpan = idValue.span;
2525
3311
  }
2526
3312
  const fieldSpans = {};
2527
- for (const entry of value.entries) fieldSpans[entry.key] = entry.span;
3313
+ const fieldPropSpans = {};
3314
+ for (const entry of value.entries) {
3315
+ fieldSpans[entry.key] = entry.span;
3316
+ fieldPropSpans[entry.key] = entry.propSpan;
3317
+ }
2528
3318
  metadata.fieldSpans = fieldSpans;
3319
+ metadata.fieldPropSpans = fieldPropSpans;
2529
3320
  return metadata;
2530
3321
  }
2531
3322
  function readIdField(value, kind, fieldName) {
@@ -2555,6 +3346,80 @@ function readIdField(value, kind, fieldName) {
2555
3346
  value.pos
2556
3347
  );
2557
3348
  }
3349
+ function readAcknowledgeField(byKey, isPage) {
3350
+ const entry = byKey.get("acknowledge");
3351
+ if (!entry) return void 0;
3352
+ if (entry.value.kind !== "object") {
3353
+ throw new ExtractError(
3354
+ "uidex-export-invalid-field",
3355
+ '`acknowledge` must be an object mapping a scope (`"*"` or a core kind) to `{ type, reason? }`.',
3356
+ entry.value.pos
3357
+ );
3358
+ }
3359
+ const out2 = {};
3360
+ for (const e of entry.value.entries) {
3361
+ const scopes = isPage ? ACK_SCOPES : COMPONENT_ACK_SCOPES;
3362
+ if (!scopes.includes(e.key)) {
3363
+ throw new ExtractError(
3364
+ "uidex-export-invalid-field",
3365
+ `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)"}.`,
3366
+ e.keyPos
3367
+ );
3368
+ }
3369
+ if (e.value.kind !== "object") {
3370
+ throw new ExtractError(
3371
+ "uidex-export-invalid-field",
3372
+ `acknowledge "${e.key}" must be an object \`{ type, reason? }\`.`,
3373
+ e.value.pos
3374
+ );
3375
+ }
3376
+ const fieldByKey = /* @__PURE__ */ new Map();
3377
+ for (const f of e.value.entries) {
3378
+ if (!ACK_FIELDS.has(f.key)) {
3379
+ throw new ExtractError(
3380
+ "uidex-export-invalid-field",
3381
+ `Unknown field "${f.key}" in an \`acknowledge\` entry. Allowed: ${[...ACK_FIELDS].sort().join(", ")}.`,
3382
+ f.value.pos
3383
+ );
3384
+ }
3385
+ fieldByKey.set(f.key, f);
3386
+ }
3387
+ const type = readStringField(fieldByKey, "type");
3388
+ if (type === void 0) {
3389
+ throw new ExtractError(
3390
+ "uidex-export-invalid-field",
3391
+ `acknowledge "${e.key}" needs a \`type\`: ${AUTHORABLE_ACK_TYPES.map((t) => `"${t}"`).join(" or ")}.`,
3392
+ e.value.pos
3393
+ );
3394
+ }
3395
+ if (type === "captured-elsewhere") {
3396
+ throw new ExtractError(
3397
+ "uidex-export-invalid-field",
3398
+ '`type: "captured-elsewhere"` cannot be authored; the workspace computes it from the sibling app that captured the declaration.',
3399
+ fieldByKey.get("type").value.pos,
3400
+ "Remove the entry \u2014 if a sibling app really captures this, the workspace reports it as `acknowledged-elsewhere` on its own."
3401
+ );
3402
+ }
3403
+ if (type !== "impossible" && type !== "backlog") {
3404
+ throw new ExtractError(
3405
+ "uidex-export-invalid-field",
3406
+ `acknowledge \`type\` must be ${AUTHORABLE_ACK_TYPES.map((t) => `"${t}"`).join(" or ")} (got "${type}").`,
3407
+ fieldByKey.get("type").value.pos
3408
+ );
3409
+ }
3410
+ const reason = readStringField(fieldByKey, "reason");
3411
+ if (type === "impossible" && (reason === void 0 || reason.length === 0)) {
3412
+ throw new ExtractError(
3413
+ "uidex-export-invalid-field",
3414
+ `acknowledge "${e.key}" is \`type: "impossible"\` and needs a non-empty \`reason\` (why this can NEVER be rendered/captured).`,
3415
+ e.value.pos,
3416
+ 'If it merely has not been captured yet, that is `type: "backlog"` \u2014 which needs no reason.'
3417
+ );
3418
+ }
3419
+ out2[e.key] = reason ? { type, reason } : { type };
3420
+ }
3421
+ return out2;
3422
+ }
2558
3423
  function readWaiversField(byKey) {
2559
3424
  const entry = byKey.get("waivers");
2560
3425
  if (!entry) return void 0;
@@ -3260,19 +4125,32 @@ function parseGitHubRef(ref) {
3260
4125
  }
3261
4126
 
3262
4127
  // src/scanner/scan/resolve.ts
3263
- var path8 = __toESM(require("path"), 1);
4128
+ var path9 = __toESM(require("path"), 1);
3264
4129
 
3265
4130
  // src/scanner/scan/routes.ts
3266
4131
  var PAGE_BASENAME = /^page\.(tsx|ts|jsx|js|mjs|cjs)$/;
3267
4132
  var PAGES_ROUTER_BASENAME = /\.(tsx|ts|jsx|js|mjs|cjs)$/;
3268
4133
  var ROUTE_BASENAME = /^route\.(tsx|ts|jsx|js|mjs|cjs)$/;
3269
- function detectRoutes(files) {
4134
+ function detectRoutes(files, options = {}) {
3270
4135
  const out2 = [];
3271
4136
  const seen = /* @__PURE__ */ new Set();
4137
+ const routesDir = options.routesDir ? options.routesDir.replace(/^\.?\/+/, "").replace(/\/+$/, "") : null;
4138
+ const routesDirParts = routesDir ? routesDir.split("/") : null;
3272
4139
  for (const f of files) {
3273
4140
  const rel = f.displayPath;
3274
4141
  const parts = rel.split("/");
3275
4142
  const base = parts[parts.length - 1];
4143
+ if (routesDirParts) {
4144
+ const end = dirEndIndex(parts, routesDirParts);
4145
+ if (end === -1 || end > parts.length - 1) continue;
4146
+ if (!PAGES_ROUTER_BASENAME.test(base)) continue;
4147
+ const routePath = routesStylePath(
4148
+ parts.slice(end, parts.length - 1),
4149
+ base
4150
+ );
4151
+ if (routePath !== null) push(out2, seen, routePath, f.displayPath);
4152
+ continue;
4153
+ }
3276
4154
  const appIdx = parts.indexOf("app");
3277
4155
  if (appIdx !== -1 && PAGE_BASENAME.test(base)) {
3278
4156
  const routeSegments = parts.slice(appIdx + 1, parts.length - 1);
@@ -3299,15 +4177,11 @@ function detectRoutes(files) {
3299
4177
  }
3300
4178
  const routesIdx = parts.indexOf("routes");
3301
4179
  if (routesIdx !== -1 && PAGES_ROUTER_BASENAME.test(base)) {
3302
- const segs = parts.slice(routesIdx + 1);
3303
- const last = segs[segs.length - 1];
3304
- if (last.startsWith("_")) continue;
3305
- const normalized = [
3306
- ...segs.slice(0, -1),
3307
- base.replace(/\.[^.]+$/, "")
3308
- ].filter((s) => s !== "index" && s !== "__root");
3309
- const routePath = formatNextAppPath(normalized);
3310
- push(out2, seen, routePath, f.displayPath);
4180
+ const routePath = routesStylePath(
4181
+ parts.slice(routesIdx + 1, parts.length - 1),
4182
+ base
4183
+ );
4184
+ if (routePath !== null) push(out2, seen, routePath, f.displayPath);
3311
4185
  continue;
3312
4186
  }
3313
4187
  }
@@ -3323,139 +4197,66 @@ function formatNextAppPath(segments) {
3323
4197
  if (kept.length === 0) return "/";
3324
4198
  return "/" + kept.join("/");
3325
4199
  }
3326
- function pathToId(routePath) {
3327
- if (routePath === "/") return "root";
3328
- return routePath.replace(/^\/+/, "").replace(/\[\.{3}([^\]]+)\]/g, "$1").replace(/\[([^\]]+)\]/g, "$1").replace(/\//g, "-").replace(/[^a-zA-Z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
3329
- }
3330
-
3331
- // src/scanner/scan/walk.ts
3332
- var fs5 = __toESM(require("fs"), 1);
3333
- var path7 = __toESM(require("path"), 1);
3334
- var DEFAULT_INCLUDES = ["**/*.{ts,tsx,js,jsx,mjs,cjs}"];
3335
- var BASE_EXCLUDES = [
3336
- "**/node_modules/**",
3337
- "**/dist/**",
3338
- "**/build/**",
3339
- "**/.next/**",
3340
- // Exclude the scanner's own generated siblings so a scan never re-ingests its
3341
- // output. Enumerated (not `*.gen.*`) so a hand-written file that merely contains
3342
- // a `.gen.` segment, e.g. `foo.gen.helpers.ts`, is still scanned.
3343
- "**/*.gen.ts",
3344
- "**/*.gen.data.ts",
3345
- "**/*.gen.locs.ts"
3346
- ];
3347
- var TEST_EXCLUDES = [
3348
- "**/*.test.ts",
3349
- "**/*.test.tsx",
3350
- "**/*.spec.ts",
3351
- "**/*.spec.tsx"
3352
- ];
3353
- var DEFAULT_EXCLUDES = [...BASE_EXCLUDES, ...TEST_EXCLUDES];
3354
- function globToRegExp(glob) {
3355
- let i = 0;
3356
- let out2 = "";
3357
- while (i < glob.length) {
3358
- const c = glob[i];
3359
- if (c === "*" && glob[i + 1] === "*") {
3360
- if (glob[i + 2] === "/") {
3361
- out2 += "(?:.*/)?";
3362
- i += 3;
3363
- continue;
3364
- }
3365
- out2 += ".*";
3366
- i += 2;
3367
- continue;
3368
- }
3369
- if (c === "*") {
3370
- out2 += "[^/]*";
3371
- i++;
3372
- continue;
3373
- }
3374
- if (c === "?") {
3375
- out2 += "[^/]";
3376
- i++;
3377
- continue;
3378
- }
3379
- if (c === "{") {
3380
- const end = glob.indexOf("}", i);
3381
- if (end === -1) {
3382
- out2 += "\\{";
3383
- i++;
3384
- continue;
4200
+ function dirEndIndex(parts, dirParts) {
4201
+ if (dirParts.length === 0) return 0;
4202
+ for (let i = 0; i + dirParts.length <= parts.length; i++) {
4203
+ let match = true;
4204
+ for (let j = 0; j < dirParts.length; j++) {
4205
+ if (parts[i + j] !== dirParts[j]) {
4206
+ match = false;
4207
+ break;
3385
4208
  }
3386
- const parts = glob.slice(i + 1, end).split(",").map((p2) => p2.replace(/[.+^$()|\\]/g, "\\$&"));
3387
- out2 += `(?:${parts.join("|")})`;
3388
- i = end + 1;
3389
- continue;
3390
- }
3391
- if (/[.+^$()|\\]/.test(c)) {
3392
- out2 += "\\" + c;
3393
- i++;
3394
- continue;
3395
4209
  }
3396
- out2 += c;
3397
- i++;
4210
+ if (match) return i + dirParts.length;
3398
4211
  }
3399
- return new RegExp(`^${out2}$`);
3400
- }
3401
- function toPosix(p2) {
3402
- return p2.split(path7.sep).join("/");
4212
+ return -1;
3403
4213
  }
3404
- function matchesAny(rel, patterns) {
3405
- return patterns.some((g) => globToRegExp(g).test(rel));
4214
+ function routesStylePath(dirSegs, base) {
4215
+ const fileSegs = splitFlatSegments(base.replace(/\.[^.]+$/, ""));
4216
+ const leaf = fileSegs[fileSegs.length - 1];
4217
+ if (leaf.startsWith("_")) return null;
4218
+ const rawSegs = [...dirSegs, ...fileSegs];
4219
+ if (rawSegs.some((s) => s.startsWith("-"))) return null;
4220
+ if (rawSegs[0] === "api") return null;
4221
+ return formatTanStackPath(rawSegs);
3406
4222
  }
3407
- function walk(sources, options) {
3408
- const { cwd, globalExcludes = [], includeTests = false } = options;
3409
- const out2 = [];
3410
- const baseDefaults = includeTests ? BASE_EXCLUDES : DEFAULT_EXCLUDES;
3411
- for (const source of sources) {
3412
- const includes = source.include?.length ? source.include : DEFAULT_INCLUDES;
3413
- const excludes = [
3414
- ...baseDefaults,
3415
- ...globalExcludes,
3416
- ...source.exclude ?? []
3417
- ];
3418
- const absRoot = path7.resolve(cwd, source.rootDir);
3419
- for (const filePath of walkDir(absRoot, absRoot)) {
3420
- const rel = toPosix(path7.relative(absRoot, filePath));
3421
- if (matchesAny(rel, excludes)) continue;
3422
- if (!matchesAny(rel, includes)) continue;
3423
- let content;
3424
- try {
3425
- content = fs5.readFileSync(filePath, "utf8");
3426
- } catch {
3427
- continue;
3428
- }
3429
- const relFromCwd = toPosix(path7.relative(cwd, filePath));
3430
- const displayPath = source.prefix ? `${source.prefix.replace(/\/$/, "")}/${rel}` : relFromCwd;
3431
- out2.push({
3432
- sourcePath: filePath,
3433
- relativePath: rel,
3434
- displayPath,
3435
- content
3436
- });
4223
+ function splitFlatSegments(name) {
4224
+ const segs = [];
4225
+ let cur = "";
4226
+ let depth = 0;
4227
+ for (const ch of name) {
4228
+ if (ch === "[") depth++;
4229
+ else if (ch === "]") depth = Math.max(0, depth - 1);
4230
+ if (ch === "." && depth === 0) {
4231
+ segs.push(cur);
4232
+ cur = "";
4233
+ continue;
3437
4234
  }
4235
+ cur += ch;
3438
4236
  }
3439
- return out2.sort((a, b) => a.displayPath.localeCompare(b.displayPath));
4237
+ segs.push(cur);
4238
+ return segs;
3440
4239
  }
3441
- function* walkDir(root, dir) {
3442
- let entries;
3443
- try {
3444
- entries = fs5.readdirSync(dir, { withFileTypes: true });
3445
- } catch {
3446
- return;
3447
- }
3448
- for (const entry of entries) {
3449
- const full = path7.join(dir, entry.name);
3450
- if (entry.isDirectory()) {
3451
- if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git" || entry.name === "build" || entry.name === ".next") {
3452
- continue;
3453
- }
3454
- yield* walkDir(root, full);
3455
- } else if (entry.isFile()) {
3456
- yield full;
3457
- }
4240
+ function formatTanStackPath(segments) {
4241
+ const kept = [];
4242
+ for (const seg of segments) {
4243
+ if (!seg) continue;
4244
+ if (seg === "index" || seg === "route" || seg === "__root") continue;
4245
+ if (seg.startsWith("_")) continue;
4246
+ if (seg.startsWith("(") && seg.endsWith(")")) continue;
4247
+ kept.push(normalizeTanStackSegment(seg.replace(/_$/, "")));
3458
4248
  }
4249
+ if (kept.length === 0) return "/";
4250
+ return "/" + kept.join("/");
4251
+ }
4252
+ function normalizeTanStackSegment(seg) {
4253
+ if (seg === "$") return "[...splat]";
4254
+ if (seg.startsWith("$")) return `[${seg.slice(1)}]`;
4255
+ return seg.replace(/\[([^\]]*)\]/g, "$1");
4256
+ }
4257
+ function pathToId(routePath) {
4258
+ if (routePath === "/") return "root";
4259
+ return routePath.replace(/^\/+/, "").replace(/\[\.{3}([^\]]+)\]/g, "$1").replace(/\[([^\]]+)\]/g, "$1").replace(/\//g, "-").replace(/[^a-zA-Z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
3459
4260
  }
3460
4261
 
3461
4262
  // src/scanner/scan/resolve.ts
@@ -3478,7 +4279,7 @@ function kebab(str) {
3478
4279
  return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").replace(/[^a-zA-Z0-9-]/g, "").toLowerCase();
3479
4280
  }
3480
4281
  function baseName(file) {
3481
- const b = path8.posix.basename(file);
4282
+ const b = path9.posix.basename(file);
3482
4283
  return b.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "");
3483
4284
  }
3484
4285
  function fileMatchesAny(displayPath, patterns) {
@@ -3499,13 +4300,12 @@ function buildMetaFromExport(exp) {
3499
4300
  ...s.description ? { description: s.description } : {}
3500
4301
  }));
3501
4302
  }
3502
- if (exp.capture === false) meta.capture = false;
3503
- if (exp.waivers && Object.keys(exp.waivers).length > 0) {
3504
- meta.waivers = exp.waivers;
4303
+ if (exp.acknowledge && Object.keys(exp.acknowledge).length > 0) {
4304
+ meta.acknowledge = exp.acknowledge;
3505
4305
  }
3506
4306
  return Object.keys(meta).length > 0 ? meta : void 0;
3507
4307
  }
3508
- function resolve3(ctx) {
4308
+ function resolve4(ctx) {
3509
4309
  const conventions = resolveConventions(ctx.config.conventions);
3510
4310
  const registry = createRegistry();
3511
4311
  const diagnostics = [];
@@ -3548,12 +4348,15 @@ function resolve3(ctx) {
3548
4348
  function metaWithComposes(kind, id, base) {
3549
4349
  const composes = directChildren.get(`${kind}:${id}`);
3550
4350
  if (!composes || composes.length === 0) return base;
3551
- return { ...base ?? {}, composes };
4351
+ return { ...base, composes };
3552
4352
  }
3553
- const routes = conventions.pages === "auto" ? detectRoutes(ctx.extracted.map((e) => e.file)) : [];
4353
+ const routes = conventions.pages === false ? [] : detectRoutes(
4354
+ ctx.extracted.map((e) => e.file),
4355
+ conventions.pages === "auto" ? {} : { routesDir: conventions.pages.routesDir }
4356
+ );
3554
4357
  const handledPageFiles = /* @__PURE__ */ new Set();
3555
4358
  for (const route of routes) {
3556
- const routeDir = path8.posix.dirname(route.file);
4359
+ const routeDir = path9.posix.dirname(route.file);
3557
4360
  const wellKnownPath = `${routeDir}/${WELL_KNOWN_FILES.page}`;
3558
4361
  const wellKnownExp = exportFor(wellKnownPath, "page");
3559
4362
  const routeExp = exportFor(route.file, "page");
@@ -3607,7 +4410,7 @@ function resolve3(ctx) {
3607
4410
  const dir = extractFeatureDir(ef.file.displayPath, featureGlob);
3608
4411
  if (!dir) continue;
3609
4412
  conventionalFeatureDirs.add(dir);
3610
- const isWellKnown = path8.posix.basename(ef.file.displayPath) === WELL_KNOWN_FILES.feature;
4413
+ const isWellKnown = path9.posix.basename(ef.file.displayPath) === WELL_KNOWN_FILES.feature;
3611
4414
  if (isWellKnown) wellKnownFeatureFileByDir.set(dir, ef.file.displayPath);
3612
4415
  const exp = exportFor(ef.file.displayPath, "feature");
3613
4416
  if (exp) {
@@ -3644,7 +4447,7 @@ function resolve3(ctx) {
3644
4447
  } else if (allExports.length > 0) {
3645
4448
  exp = allExports[0].exp;
3646
4449
  }
3647
- const id = exp && typeof exp.id === "string" ? exp.id : path8.posix.basename(dir);
4450
+ const id = exp && typeof exp.id === "string" ? exp.id : path9.posix.basename(dir);
3648
4451
  const meta = exp ? buildMetaFromExport(exp) : void 0;
3649
4452
  const feature = {
3650
4453
  kind: "feature",
@@ -3843,6 +4646,45 @@ function resolve3(ctx) {
3843
4646
  };
3844
4647
  registry.add(element);
3845
4648
  }
4649
+ const ancestorsById = /* @__PURE__ */ new Map();
4650
+ const fileById = /* @__PURE__ */ new Map();
4651
+ for (const a of allAnnotations) {
4652
+ if (!fileById.has(a.id)) fileById.set(a.id, a.file);
4653
+ if (a.ancestors?.length) {
4654
+ const prev = ancestorsById.get(a.id) ?? [];
4655
+ ancestorsById.set(a.id, [...prev, ...a.ancestors.map((x) => x.id)]);
4656
+ }
4657
+ }
4658
+ const ownersByFile = /* @__PURE__ */ new Map();
4659
+ const addOwner = (file, id) => {
4660
+ ownersByFile.set(file, [...ownersByFile.get(file) ?? [], id]);
4661
+ };
4662
+ const featureDirs = [];
4663
+ for (const f of registry.list("feature")) {
4664
+ const loc = f.loc?.file;
4665
+ if (!loc) continue;
4666
+ const dir = loc.endsWith("/") ? loc.slice(0, -1) : path9.posix.dirname(loc);
4667
+ featureDirs.push({ dir, id: f.id });
4668
+ }
4669
+ for (const p2 of registry.list("page")) {
4670
+ if (p2.loc?.file) addOwner(p2.loc.file, p2.id);
4671
+ }
4672
+ const ownersFor = (file) => {
4673
+ const out2 = [...ownersByFile.get(file) ?? []];
4674
+ for (const { dir, id } of featureDirs) {
4675
+ if (file === dir || file.startsWith(`${dir}/`)) out2.push(id);
4676
+ }
4677
+ return out2;
4678
+ };
4679
+ const expandTouches = (ids) => {
4680
+ const out2 = new Set(ids);
4681
+ for (const id of ids) {
4682
+ for (const anc of ancestorsById.get(id) ?? []) out2.add(anc);
4683
+ const file = fileById.get(id);
4684
+ if (file) for (const owner of ownersFor(file)) out2.add(owner);
4685
+ }
4686
+ return [...out2];
4687
+ };
3846
4688
  if (ctx.flowFiles && conventions.flows) {
3847
4689
  for (const ff of ctx.flowFiles) {
3848
4690
  const notFlowExport = (ff.metadata ?? []).find(
@@ -3852,7 +4694,10 @@ function resolve3(ctx) {
3852
4694
  const flowExport = (ff.metadata ?? []).find(
3853
4695
  (m) => m.kind === "flow" && typeof m.id === "string"
3854
4696
  );
3855
- const derived = flowsFromFacts(ff);
4697
+ const derived = flowsFromFacts(ff).map((f) => ({
4698
+ ...f,
4699
+ touches: expandTouches(f.touches)
4700
+ }));
3856
4701
  if (flowExport && typeof flowExport.id === "string" && derived.length === 1) {
3857
4702
  const base = derived[0];
3858
4703
  const flow = {
@@ -3921,45 +4766,38 @@ function dedupe(arr) {
3921
4766
  }
3922
4767
 
3923
4768
  // src/scanner/scan/pipeline.ts
3924
- var DEFAULT_STATES_MANIFEST = "uidex-states.json";
4769
+ var DEFAULT_STATES_MANIFEST2 = "uidex-states.json";
3925
4770
  var DEFAULT_COVERAGE_BASELINE = "uidex-coverage-baseline.json";
3926
- function loadPageBaseline(configDir, config) {
4771
+ function loadCoverageBaseline(configDir, config) {
3927
4772
  const rel = config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
3928
- const abs = path9.resolve(configDir, rel);
4773
+ const abs = path10.resolve(configDir, rel);
3929
4774
  let raw;
3930
4775
  try {
3931
- raw = fs6.readFileSync(abs, "utf8");
4776
+ raw = fs7.readFileSync(abs, "utf8");
3932
4777
  } catch {
3933
- return void 0;
4778
+ return {};
3934
4779
  }
4780
+ let parsed;
3935
4781
  try {
3936
- const parsed = JSON.parse(raw);
3937
- if (!parsed || !Array.isArray(parsed.uncapturedPages)) return void 0;
3938
- const uncapturedPages = parsed.uncapturedPages.filter(
3939
- (p2) => typeof p2 === "string"
3940
- );
3941
- let missingKinds;
3942
- const mkRaw = parsed.missingKinds;
3943
- if (mkRaw && typeof mkRaw === "object" && !Array.isArray(mkRaw)) {
3944
- const mk = {};
3945
- for (const [route, kinds] of Object.entries(mkRaw)) {
3946
- if (Array.isArray(kinds)) {
3947
- mk[route] = kinds.filter((k) => typeof k === "string");
3948
- }
3949
- }
3950
- missingKinds = mk;
3951
- }
3952
- return { uncapturedPages, ...missingKinds ? { missingKinds } : {} };
4782
+ parsed = JSON.parse(raw);
3953
4783
  } catch {
3954
- return void 0;
4784
+ return {};
3955
4785
  }
4786
+ const legacy = migrateLegacyBaseline({
4787
+ path: abs,
4788
+ raw,
4789
+ parsed,
4790
+ displayPath: rel
4791
+ });
4792
+ if (legacy) return { legacy };
4793
+ return { baseline: parseCoverageBaseline(parsed) ?? void 0 };
3956
4794
  }
3957
4795
  function loadCapturedStates(configDir, config) {
3958
- const rel = config.statesManifest ?? DEFAULT_STATES_MANIFEST;
3959
- const abs = path9.resolve(configDir, rel);
4796
+ const rel = config.statesManifest ?? DEFAULT_STATES_MANIFEST2;
4797
+ const abs = path10.resolve(configDir, rel);
3960
4798
  let raw;
3961
4799
  try {
3962
- raw = fs6.readFileSync(abs, "utf8");
4800
+ raw = fs7.readFileSync(abs, "utf8");
3963
4801
  } catch {
3964
4802
  return void 0;
3965
4803
  }
@@ -3978,7 +4816,7 @@ function loadCapturedStates(configDir, config) {
3978
4816
  if (typeof c.route === "string") rec.route = c.route;
3979
4817
  captured.push(rec);
3980
4818
  }
3981
- return { captured };
4819
+ return typeof parsed.generatedAt === "string" ? { captured, generatedAt: parsed.generatedAt } : { captured };
3982
4820
  } catch {
3983
4821
  return void 0;
3984
4822
  }
@@ -4003,7 +4841,7 @@ function runOne(dc, opts) {
4003
4841
  { cwd: configDir, includeTests: true }
4004
4842
  ) : [];
4005
4843
  const extractedFlows = extract(flowFiles);
4006
- const resolved = resolve3({
4844
+ const resolved = resolve4({
4007
4845
  config,
4008
4846
  extracted,
4009
4847
  flowFiles: extractedFlows
@@ -4014,11 +4852,11 @@ function runOne(dc, opts) {
4014
4852
  gitContext
4015
4853
  });
4016
4854
  const typesRel = config.output;
4017
- const typesPath = path9.resolve(configDir, typesRel);
4855
+ const typesPath = path10.resolve(configDir, typesRel);
4018
4856
  const dataRel = dataOutputPath(config.output);
4019
- const dataPath = path9.resolve(configDir, dataRel);
4857
+ const dataPath = path10.resolve(configDir, dataRel);
4020
4858
  const locsRel = locsOutputPath(config.output);
4021
- const locsPath = path9.resolve(configDir, locsRel);
4859
+ const locsPath = path10.resolve(configDir, locsRel);
4022
4860
  let typesOnDisk = null;
4023
4861
  let dataOnDisk = null;
4024
4862
  let locsOnDisk = null;
@@ -4031,9 +4869,11 @@ function runOne(dc, opts) {
4031
4869
  (ef) => (ef.diagnostics?.length ?? 0) > 0
4032
4870
  );
4033
4871
  const capturedStates = opts.capturedStates ?? loadCapturedStates(configDir, config);
4034
- const pageBaseline = opts.pageBaseline ?? loadPageBaseline(configDir, config);
4872
+ const loadedBaseline = opts.coverageBaseline ? { baseline: opts.coverageBaseline } : loadCoverageBaseline(configDir, config);
4873
+ const coverageBaseline = loadedBaseline.baseline;
4874
+ const workspace = opts.workspace ?? resolveWorkspace({ configDir, config });
4035
4875
  let auditResult;
4036
- if (opts.check || opts.lint || resolved.diagnostics.length > 0 || hasExtractDiagnostics) {
4876
+ if (opts.check || opts.lint || resolved.diagnostics.length > 0 || hasExtractDiagnostics || loadedBaseline.legacy) {
4037
4877
  auditResult = audit({
4038
4878
  registry: resolved.registry,
4039
4879
  extracted,
@@ -4056,7 +4896,9 @@ function runOne(dc, opts) {
4056
4896
  locsOnDisk,
4057
4897
  locsOutputPath: locsRel,
4058
4898
  capturedStates,
4059
- pageBaseline
4899
+ coverageBaseline,
4900
+ baselineDiagnostic: loadedBaseline.legacy,
4901
+ workspace
4060
4902
  });
4061
4903
  }
4062
4904
  return {
@@ -4077,15 +4919,15 @@ function runOne(dc, opts) {
4077
4919
  }
4078
4920
  function readOrNull(p2) {
4079
4921
  try {
4080
- return fs6.readFileSync(p2, "utf8");
4922
+ return fs7.readFileSync(p2, "utf8");
4081
4923
  } catch {
4082
4924
  return null;
4083
4925
  }
4084
4926
  }
4085
4927
  function writeArtifact(artifact) {
4086
4928
  if (readOrNull(artifact.outputPath) === artifact.generated) return false;
4087
- fs6.mkdirSync(path9.dirname(artifact.outputPath), { recursive: true });
4088
- fs6.writeFileSync(artifact.outputPath, artifact.generated, "utf8");
4929
+ fs7.mkdirSync(path10.dirname(artifact.outputPath), { recursive: true });
4930
+ fs7.writeFileSync(artifact.outputPath, artifact.generated, "utf8");
4089
4931
  return true;
4090
4932
  }
4091
4933
  function writeScanResult(result) {
@@ -4094,9 +4936,9 @@ function writeScanResult(result) {
4094
4936
  const wroteLocs = writeArtifact(result.locs);
4095
4937
  return wroteTypes || wroteData || wroteLocs;
4096
4938
  }
4097
- function pageBaselinePath(result) {
4939
+ function coverageBaselinePath(result) {
4098
4940
  const rel = result.config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
4099
- return path9.resolve(result.configDir, rel);
4941
+ return path10.resolve(result.configDir, rel);
4100
4942
  }
4101
4943
 
4102
4944
  // src/scanner/scan/rename.ts
@@ -4255,8 +5097,8 @@ function renameEntity(opts) {
4255
5097
  }
4256
5098
 
4257
5099
  // src/scanner/scan/scaffold.ts
4258
- var fs7 = __toESM(require("fs"), 1);
4259
- var path10 = __toESM(require("path"), 1);
5100
+ var fs8 = __toESM(require("fs"), 1);
5101
+ var path11 = __toESM(require("path"), 1);
4260
5102
  function scaffoldSpec(opts) {
4261
5103
  const {
4262
5104
  registry,
@@ -4272,8 +5114,8 @@ function scaffoldSpec(opts) {
4272
5114
  }
4273
5115
  const criteria = entity.meta?.acceptance ?? [];
4274
5116
  const filename = kind === "widget" ? `widget-${id}.spec.ts` : `flow-${id}.spec.ts`;
4275
- const outputPath = path10.resolve(outDir, filename);
4276
- if (fs7.existsSync(outputPath) && !force) {
5117
+ const outputPath = path11.resolve(outDir, filename);
5118
+ if (fs8.existsSync(outputPath) && !force) {
4277
5119
  return {
4278
5120
  outputPath,
4279
5121
  written: false,
@@ -4282,8 +5124,8 @@ function scaffoldSpec(opts) {
4282
5124
  };
4283
5125
  }
4284
5126
  const content = renderSpec({ id, criteria, fixtureImport });
4285
- fs7.mkdirSync(path10.dirname(outputPath), { recursive: true });
4286
- fs7.writeFileSync(outputPath, content, "utf8");
5127
+ fs8.mkdirSync(path11.dirname(outputPath), { recursive: true });
5128
+ fs8.writeFileSync(outputPath, content, "utf8");
4287
5129
  return { outputPath, written: true, skipped: false };
4288
5130
  }
4289
5131
  function capitalize(s) {
@@ -4291,25 +5133,27 @@ function capitalize(s) {
4291
5133
  }
4292
5134
  function renderSpec(args) {
4293
5135
  const lines = [];
4294
- lines.push(
4295
- `import { test, expect } from ${JSON.stringify(args.fixtureImport)}`
4296
- );
5136
+ lines.push(`import { test } from ${JSON.stringify(args.fixtureImport)}`);
4297
5137
  lines.push("");
4298
5138
  lines.push(
4299
5139
  `test.describe(${JSON.stringify(args.id)}, { tag: "@uidex:flow" }, () => {`
4300
5140
  );
4301
- if (args.criteria.length === 0) {
4302
- lines.push(` test("TODO: add acceptance criteria", async () => {`);
4303
- lines.push(` // TODO`);
5141
+ const stub = (title, body) => {
5142
+ lines.push(` test.fixme(${JSON.stringify(title)}, async ({ uidex }) => {`);
5143
+ for (const l of body) lines.push(` ${l}`);
5144
+ lines.push(` void uidex`);
4304
5145
  lines.push(` })`);
5146
+ lines.push("");
5147
+ };
5148
+ if (args.criteria.length === 0) {
5149
+ stub("TODO: add acceptance criteria", [
5150
+ "// Declare `acceptance: [...]` on the entity, then re-run scaffold."
5151
+ ]);
4305
5152
  } else {
4306
5153
  for (const criterion of args.criteria) {
4307
- lines.push(` test(${JSON.stringify(criterion)}, async ({ uidex }) => {`);
4308
- lines.push(` // TODO: implement criterion`);
4309
- lines.push(` void uidex`);
4310
- lines.push(` expect(true).toBe(true)`);
4311
- lines.push(` })`);
4312
- lines.push("");
5154
+ stub(criterion, [
5155
+ "// TODO: drive this criterion, then remove `.fixme` to arm the test."
5156
+ ]);
4313
5157
  }
4314
5158
  }
4315
5159
  lines.push("})");
@@ -4317,6 +5161,37 @@ function renderSpec(args) {
4317
5161
  return lines.join("\n");
4318
5162
  }
4319
5163
 
5164
+ // src/scanner/scan/states-merge.ts
5165
+ function keyOf(c) {
5166
+ return `${c.kind ?? "page"} ${c.entity} ${c.state}`;
5167
+ }
5168
+ function mergeStates(committed, partial) {
5169
+ const byKey = /* @__PURE__ */ new Map();
5170
+ for (const c of committed.captured ?? []) byKey.set(keyOf(c), c);
5171
+ const updated = [];
5172
+ const added = [];
5173
+ for (const c of partial.captured ?? []) {
5174
+ const k = keyOf(c);
5175
+ (byKey.has(k) ? updated : added).push(k);
5176
+ byKey.set(k, c);
5177
+ }
5178
+ const captured = [...byKey.values()].sort(
5179
+ (a, b) => a.entity.localeCompare(b.entity) || a.state.localeCompare(b.state)
5180
+ );
5181
+ return {
5182
+ manifest: {
5183
+ ...committed,
5184
+ captured,
5185
+ // The merged file is only as fresh as its OLDEST evidence: rows carried
5186
+ // over from the committed manifest were not re-verified by this run, so
5187
+ // claiming the partial's newer stamp would defeat the staleness check.
5188
+ ...committed.generatedAt ? { generatedAt: committed.generatedAt } : {}
5189
+ },
5190
+ updated: updated.sort(),
5191
+ added: added.sort()
5192
+ };
5193
+ }
5194
+
4320
5195
  // src/scanner/cli/parse-args.ts
4321
5196
  function parseArgs(args) {
4322
5197
  const positional = [];
@@ -4364,6 +5239,8 @@ async function run(opts) {
4364
5239
  return runScaffold(cwd, positional.slice(1), flags, writer);
4365
5240
  case "rename":
4366
5241
  return runRename(cwd, positional.slice(1), flags, writer);
5242
+ case "states":
5243
+ return runStates(cwd, positional.slice(1), writer);
4367
5244
  case "ai": {
4368
5245
  const result = await runAiCommand({
4369
5246
  cwd,
@@ -4392,22 +5269,23 @@ function helpText2() {
4392
5269
  " scan [flags] Run the scanner pipeline",
4393
5270
  " scaffold <widget|page|feature> <id> Emit a Playwright spec from declared acceptance",
4394
5271
  " rename <element|widget|region> <old-id> <new-id> Rename an id everywhere (DOM attr, flows, exports)",
5272
+ " states merge [partial] Fold a filtered run's *.partial.json into the committed manifest",
4395
5273
  " ai <install|uninstall|providers> Manage AI assistant integrations",
4396
5274
  "",
4397
5275
  "Flags:",
4398
5276
  " --check Verify the on-disk gen file matches a fresh scan; exit non-zero on drift (read-only)",
4399
5277
  " --lint Run lint diagnostics (missing annotations, scope leak, duplicate ids, coverage)",
4400
5278
  " --audit Equivalent to --check --lint (read-only)",
4401
- " --fix Apply machine-generated fixes (add data-uidex to unannotated interactive elements, drop empty names), then rescan and write",
4402
- " --update-baseline Regenerate the page-coverage baseline (uidex-coverage-baseline.json) from the current uncaptured routes",
5279
+ " --fix Apply machine-generated fixes (add data-uidex to unannotated interactive elements, drop empty names, migrate waivers/capture to acknowledge), then rescan and write",
5280
+ " --update-baseline Regenerate the coverage baseline (uidex-coverage-baseline.json) as `acknowledge` backlog entries",
4403
5281
  " --json Emit JSON diagnostics on stdout",
4404
5282
  " --force (scaffold) overwrite existing spec",
4405
5283
  ""
4406
5284
  ].join("\n");
4407
5285
  }
4408
5286
  function runInit(cwd, w) {
4409
- const configPath = path11.join(cwd, CONFIG_FILENAME);
4410
- if (fs8.existsSync(configPath)) {
5287
+ const configPath = path12.join(cwd, CONFIG_FILENAME);
5288
+ if (fs9.existsSync(configPath)) {
4411
5289
  w.err(`.uidex.json already exists at ${configPath}`);
4412
5290
  return w.result(1);
4413
5291
  }
@@ -4416,16 +5294,16 @@ function runInit(cwd, w) {
4416
5294
  sources: [{ rootDir: "src" }],
4417
5295
  output: "src/uidex.gen.ts"
4418
5296
  };
4419
- fs8.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
5297
+ fs9.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
4420
5298
  w.out(`Created ${configPath}`);
4421
- const gitignorePath = path11.join(cwd, ".gitignore");
5299
+ const gitignorePath = path12.join(cwd, ".gitignore");
4422
5300
  const entry = "*.gen.*";
4423
- if (fs8.existsSync(gitignorePath)) {
4424
- const existing = fs8.readFileSync(gitignorePath, "utf8");
5301
+ if (fs9.existsSync(gitignorePath)) {
5302
+ const existing = fs9.readFileSync(gitignorePath, "utf8");
4425
5303
  const hasEntry = existing.split("\n").some((line) => line.trim() === entry);
4426
5304
  if (!hasEntry) {
4427
5305
  const needsNewline = existing.length > 0 && !existing.endsWith("\n");
4428
- fs8.appendFileSync(
5306
+ fs9.appendFileSync(
4429
5307
  gitignorePath,
4430
5308
  `${needsNewline ? "\n" : ""}${entry}
4431
5309
  `,
@@ -4434,7 +5312,7 @@ function runInit(cwd, w) {
4434
5312
  w.out(`Appended ${entry} to ${gitignorePath}`);
4435
5313
  }
4436
5314
  } else {
4437
- fs8.writeFileSync(gitignorePath, `${entry}
5315
+ fs9.writeFileSync(gitignorePath, `${entry}
4438
5316
  `, "utf8");
4439
5317
  w.out(`Created ${gitignorePath} with ${entry}`);
4440
5318
  }
@@ -4473,20 +5351,25 @@ function runScanCommand(cwd, flags, w) {
4473
5351
  if (updateBaseline) {
4474
5352
  for (const r of results) {
4475
5353
  const manifest = r.capturedStates ?? { captured: [] };
4476
- const pageBaseline = computePageBaseline(r.registry, manifest);
4477
- const missingKinds = computeMissingKinds(r.registry, manifest);
4478
- const baseline = {
4479
- ...pageBaseline,
4480
- ...Object.keys(missingKinds).length > 0 ? { missingKinds } : {}
5354
+ const acknowledge = {};
5355
+ const merge = (from) => {
5356
+ for (const [route, scopes] of Object.entries(from)) {
5357
+ acknowledge[route] = { ...acknowledge[route] ?? {}, ...scopes };
5358
+ }
4481
5359
  };
4482
- const p2 = pageBaselinePath(r);
4483
- fs8.writeFileSync(p2, JSON.stringify(baseline, null, 2) + "\n");
4484
- const kindGaps = Object.values(missingKinds).reduce(
4485
- (n, ks) => n + ks.length,
5360
+ merge(computePageBacklog(r.registry, manifest));
5361
+ merge(computeMatrixBacklog(r.registry, manifest));
5362
+ const baseline = { version: 2, acknowledge };
5363
+ const p2 = coverageBaselinePath(r);
5364
+ fs9.writeFileSync(p2, serializeCoverageBaseline(baseline));
5365
+ const entries = Object.values(acknowledge);
5366
+ const pages = entries.filter((s) => s["*"]).length;
5367
+ const kindGaps = entries.reduce(
5368
+ (n, s) => n + Object.keys(s).filter((k) => k !== "*").length,
4486
5369
  0
4487
5370
  );
4488
5371
  w.out(
4489
- `Wrote ${p2} \u2014 ${baseline.uncapturedPages.length} uncaptured page(s), ${kindGaps} missing core-kind(s) baselined`
5372
+ `Wrote ${p2} \u2014 ${pages} uncaptured page(s), ${kindGaps} missing core-kind(s) acknowledged as backlog`
4490
5373
  );
4491
5374
  }
4492
5375
  return w.result(0);
@@ -4559,7 +5442,7 @@ function runScaffold(cwd, args, flags, w) {
4559
5442
  for (const r of results) {
4560
5443
  const entity = r.registry.get(scaffoldKind, id);
4561
5444
  if (!entity) continue;
4562
- const outDir = path11.resolve(r.configDir, "e2e");
5445
+ const outDir = path12.resolve(r.configDir, "e2e");
4563
5446
  const result = scaffoldSpec({
4564
5447
  registry: r.registry,
4565
5448
  kind: scaffoldKind,
@@ -4580,6 +5463,53 @@ function runScaffold(cwd, args, flags, w) {
4580
5463
  return w.result(1);
4581
5464
  }
4582
5465
  var RENAME_KINDS = /* @__PURE__ */ new Set(["element", "widget", "region"]);
5466
+ function runStates(cwd, args, w) {
5467
+ const [sub, explicitPartial] = args;
5468
+ if (sub !== "merge") {
5469
+ w.err("Usage: uidex states merge [partial-manifest]");
5470
+ return w.result(1);
5471
+ }
5472
+ const configs = discover({ cwd });
5473
+ if (configs.length === 0) {
5474
+ w.err(`No ${CONFIG_FILENAME} found under ${cwd}`);
5475
+ return w.result(1);
5476
+ }
5477
+ let merged = 0;
5478
+ for (const { configDir, config } of configs) {
5479
+ const rel = config.statesManifest ?? "uidex-states.json";
5480
+ const target = path12.resolve(configDir, rel);
5481
+ const partial = explicitPartial ? path12.resolve(cwd, explicitPartial) : target.replace(/\.json$/i, "") + ".partial.json";
5482
+ const committedRaw = readJson(target);
5483
+ const partialRaw = readJson(partial);
5484
+ if (!partialRaw) continue;
5485
+ if (!committedRaw) {
5486
+ w.err(`No committed manifest at ${rel}; nothing to merge into`);
5487
+ return w.result(1);
5488
+ }
5489
+ const result = mergeStates(committedRaw, partialRaw);
5490
+ fs9.writeFileSync(target, JSON.stringify(result.manifest, null, 2) + "\n");
5491
+ merged++;
5492
+ w.out(
5493
+ `Merged ${path12.basename(partial)} \u2192 ${rel}: ${result.added.length} added, ${result.updated.length} updated, ${result.manifest.captured.length} total`
5494
+ );
5495
+ for (const k of result.added) w.out(` + ${k}`);
5496
+ for (const k of result.updated) w.out(` ~ ${k}`);
5497
+ }
5498
+ if (merged === 0) {
5499
+ w.out("No partial manifest found; nothing to merge.");
5500
+ }
5501
+ return w.result(0);
5502
+ }
5503
+ function readJson(p2) {
5504
+ try {
5505
+ const parsed = JSON.parse(fs9.readFileSync(p2, "utf8"));
5506
+ if (!parsed || typeof parsed !== "object") return null;
5507
+ const m = parsed;
5508
+ return Array.isArray(m.captured) ? m : null;
5509
+ } catch {
5510
+ return null;
5511
+ }
5512
+ }
4583
5513
  function runRename(cwd, args, flags, w) {
4584
5514
  const [kind, oldId, newId] = args;
4585
5515
  if (!kind || !RENAME_KINDS.has(kind) || !oldId || !newId) {