uidex 0.7.0 → 0.8.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 (51) hide show
  1. package/dist/cli/cli.cjs +1024 -1041
  2. package/dist/cli/cli.cjs.map +1 -1
  3. package/dist/headless/index.cjs +4 -448
  4. package/dist/headless/index.cjs.map +1 -1
  5. package/dist/headless/index.d.cts +41 -11
  6. package/dist/headless/index.d.ts +41 -11
  7. package/dist/headless/index.js +4 -450
  8. package/dist/headless/index.js.map +1 -1
  9. package/dist/index.cjs +147 -3252
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +43 -316
  12. package/dist/index.d.ts +43 -316
  13. package/dist/index.js +133 -3254
  14. package/dist/index.js.map +1 -1
  15. package/dist/playwright/index.cjs +175 -0
  16. package/dist/playwright/index.cjs.map +1 -1
  17. package/dist/playwright/index.d.cts +2 -0
  18. package/dist/playwright/index.d.ts +2 -0
  19. package/dist/playwright/index.js +167 -0
  20. package/dist/playwright/index.js.map +1 -1
  21. package/dist/playwright/states-reporter.cjs +123 -0
  22. package/dist/playwright/states-reporter.cjs.map +1 -0
  23. package/dist/playwright/states-reporter.d.cts +46 -0
  24. package/dist/playwright/states-reporter.d.ts +46 -0
  25. package/dist/playwright/states-reporter.js +88 -0
  26. package/dist/playwright/states-reporter.js.map +1 -0
  27. package/dist/playwright/states.cjs +118 -0
  28. package/dist/playwright/states.cjs.map +1 -0
  29. package/dist/playwright/states.d.cts +120 -0
  30. package/dist/playwright/states.d.ts +120 -0
  31. package/dist/playwright/states.js +88 -0
  32. package/dist/playwright/states.js.map +1 -0
  33. package/dist/react/index.cjs +163 -3255
  34. package/dist/react/index.cjs.map +1 -1
  35. package/dist/react/index.d.cts +62 -275
  36. package/dist/react/index.d.ts +62 -275
  37. package/dist/react/index.js +151 -3268
  38. package/dist/react/index.js.map +1 -1
  39. package/dist/scan/index.cjs +1194 -322
  40. package/dist/scan/index.cjs.map +1 -1
  41. package/dist/scan/index.d.cts +274 -8
  42. package/dist/scan/index.d.ts +274 -8
  43. package/dist/scan/index.js +1185 -322
  44. package/dist/scan/index.js.map +1 -1
  45. package/package.json +27 -31
  46. package/dist/cloud/index.cjs +0 -682
  47. package/dist/cloud/index.cjs.map +0 -1
  48. package/dist/cloud/index.d.cts +0 -270
  49. package/dist/cloud/index.d.ts +0 -270
  50. package/dist/cloud/index.js +0 -645
  51. package/dist/cloud/index.js.map +0 -1
@@ -35,12 +35,21 @@ __export(scan_exports, {
35
35
  DEFAULT_CONVENTIONS: () => DEFAULT_CONVENTIONS,
36
36
  applyFixes: () => applyFixes,
37
37
  audit: () => audit,
38
+ checkPageCoverage: () => checkPageCoverage,
39
+ checkStateCoverage: () => checkStateCoverage,
40
+ checkStateMatrix: () => checkStateMatrix,
41
+ compileRoute: () => compileRoute,
42
+ computeMissingKinds: () => computeMissingKinds,
43
+ computePageBaseline: () => computePageBaseline,
38
44
  detectRoutes: () => detectRoutes,
39
45
  discover: () => discover,
40
46
  emit: () => emit,
41
47
  extract: () => extract,
42
48
  extractUidexExports: () => extractUidexExports,
43
49
  globToRegExp: () => globToRegExp,
50
+ loadPageBaseline: () => loadPageBaseline,
51
+ matchUrlToRoute: () => matchUrlToRoute,
52
+ pageBaselinePath: () => pageBaselinePath,
44
53
  parseConfig: () => parseConfig,
45
54
  pathToId: () => pathToId,
46
55
  renameEntity: () => renameEntity,
@@ -86,7 +95,9 @@ var ALLOWED_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
86
95
  "output",
87
96
  "flows",
88
97
  "audit",
89
- "conventions"
98
+ "conventions",
99
+ "statesManifest",
100
+ "coverageBaseline"
90
101
  ]);
91
102
  var ALLOWED_SOURCE_KEYS = /* @__PURE__ */ new Set(["rootDir", "include", "exclude", "prefix"]);
92
103
  var ALLOWED_CONVENTIONS_KEYS = /* @__PURE__ */ new Set([
@@ -96,7 +107,14 @@ var ALLOWED_CONVENTIONS_KEYS = /* @__PURE__ */ new Set([
96
107
  "flows",
97
108
  "regions"
98
109
  ]);
99
- var ALLOWED_AUDIT_KEYS = /* @__PURE__ */ new Set(["scopeLeak", "coverage", "acceptance"]);
110
+ var ALLOWED_AUDIT_KEYS = /* @__PURE__ */ new Set([
111
+ "scopeLeak",
112
+ "coverage",
113
+ "acceptance",
114
+ "states",
115
+ "pageCoverage",
116
+ "stateMatrix"
117
+ ]);
100
118
  function fail(msg) {
101
119
  throw new ConfigError(`Invalid .uidex.json: ${msg}`);
102
120
  }
@@ -155,6 +173,12 @@ function validateConfig(raw) {
155
173
  }
156
174
  if (raw.exclude !== void 0) assertStringArray(raw.exclude, `exclude`);
157
175
  if (raw.flows !== void 0) assertStringArray(raw.flows, `flows`);
176
+ if (raw.statesManifest !== void 0 && typeof raw.statesManifest !== "string") {
177
+ fail(`"statesManifest" must be a string`);
178
+ }
179
+ if (raw.coverageBaseline !== void 0 && typeof raw.coverageBaseline !== "string") {
180
+ fail(`"coverageBaseline" must be a string`);
181
+ }
158
182
  if (raw.audit !== void 0) {
159
183
  assertObject(raw.audit, "audit");
160
184
  for (const key of Object.keys(raw.audit)) {
@@ -194,7 +218,9 @@ function validateConfig(raw) {
194
218
  output: raw.output,
195
219
  flows: raw.flows,
196
220
  audit: raw.audit,
197
- conventions: raw.conventions
221
+ conventions: raw.conventions,
222
+ statesManifest: raw.statesManifest,
223
+ coverageBaseline: raw.coverageBaseline
198
224
  };
199
225
  }
200
226
  function parseConfig(json) {
@@ -283,7 +309,12 @@ var BASE_EXCLUDES = [
283
309
  "**/dist/**",
284
310
  "**/build/**",
285
311
  "**/.next/**",
286
- "**/*.gen.ts"
312
+ // Exclude the scanner's own generated siblings so a scan never re-ingests its
313
+ // output. Enumerated (not `*.gen.*`) so a hand-written file that merely contains
314
+ // a `.gen.` segment, e.g. `foo.gen.helpers.ts`, is still scanned.
315
+ "**/*.gen.ts",
316
+ "**/*.gen.data.ts",
317
+ "**/*.gen.locs.ts"
287
318
  ];
288
319
  var TEST_EXCLUDES = [
289
320
  "**/*.test.ts",
@@ -483,6 +514,201 @@ function unwrapTsExpression(expr) {
483
514
  }
484
515
  }
485
516
 
517
+ // src/shared/entities/types.ts
518
+ var ENTITY_KINDS = [
519
+ "route",
520
+ "page",
521
+ "feature",
522
+ "widget",
523
+ "region",
524
+ "element",
525
+ "primitive",
526
+ "flow"
527
+ ];
528
+ var CORE_STATE_KINDS = [
529
+ "loading",
530
+ "empty",
531
+ "populated",
532
+ "error"
533
+ ];
534
+ function isMetaKind(kind) {
535
+ return kind !== "route" && kind !== "flow";
536
+ }
537
+ function isMetaEntity(entity) {
538
+ return isMetaKind(entity.kind);
539
+ }
540
+ function entityKey(entity) {
541
+ return entity.kind === "route" ? entity.path : entity.id;
542
+ }
543
+ var UnknownEntityKindError = class extends Error {
544
+ kind;
545
+ constructor(kind) {
546
+ super(`Unknown entity kind: ${kind}`);
547
+ this.name = "UnknownEntityKindError";
548
+ this.kind = kind;
549
+ }
550
+ };
551
+ var KIND_SET = new Set(ENTITY_KINDS);
552
+ function assertEntityKind(kind) {
553
+ if (!KIND_SET.has(kind)) throw new UnknownEntityKindError(kind);
554
+ }
555
+
556
+ // src/shared/entities/registry.ts
557
+ function emptyStore() {
558
+ return {
559
+ route: /* @__PURE__ */ new Map(),
560
+ page: /* @__PURE__ */ new Map(),
561
+ feature: /* @__PURE__ */ new Map(),
562
+ widget: /* @__PURE__ */ new Map(),
563
+ region: /* @__PURE__ */ new Map(),
564
+ element: /* @__PURE__ */ new Map(),
565
+ primitive: /* @__PURE__ */ new Map(),
566
+ flow: /* @__PURE__ */ new Map()
567
+ };
568
+ }
569
+ function computeFlowIds(flows, targetId) {
570
+ const ids = [];
571
+ for (const flow of flows) {
572
+ if (flow.touches.includes(targetId)) ids.push(flow.id);
573
+ }
574
+ return ids;
575
+ }
576
+ function freezeEntity(entity, flows) {
577
+ if (!isMetaKind(entity.kind)) return entity;
578
+ const withMeta = entity;
579
+ if (withMeta.meta === void 0) return entity;
580
+ const computedFlows = Object.freeze(computeFlowIds(flows, withMeta.id));
581
+ const mergedMeta = { ...withMeta.meta, flows: computedFlows };
582
+ return { ...entity, meta: Object.freeze(mergedMeta) };
583
+ }
584
+ function createRegistry() {
585
+ const store = emptyStore();
586
+ let flowsCache = null;
587
+ const patternCache = /* @__PURE__ */ new Map();
588
+ const getFlows = () => {
589
+ if (flowsCache === null) flowsCache = Array.from(store.flow.values());
590
+ return flowsCache;
591
+ };
592
+ const add = (entity) => {
593
+ assertEntityKind(entity.kind);
594
+ const key = entityKey(entity);
595
+ store[entity.kind].set(key, entity);
596
+ flowsCache = null;
597
+ patternCache.delete(entity.kind);
598
+ };
599
+ const get = (kind, id) => {
600
+ assertEntityKind(kind);
601
+ const raw = store[kind].get(id);
602
+ if (raw === void 0) return void 0;
603
+ return freezeEntity(raw, getFlows());
604
+ };
605
+ const getPatternsForKind = (kind) => {
606
+ const cached = patternCache.get(kind);
607
+ if (cached !== void 0) return cached;
608
+ const patterns = [];
609
+ for (const [key, entity] of store[kind]) {
610
+ if (key.includes("*")) {
611
+ const segments = key.split("*");
612
+ patterns.push({
613
+ segments,
614
+ staticLength: segments.reduce((n, s) => n + s.length, 0),
615
+ entity
616
+ });
617
+ }
618
+ }
619
+ patternCache.set(
620
+ kind,
621
+ patterns
622
+ );
623
+ return patterns;
624
+ };
625
+ const matchesSegments = (segments, id) => {
626
+ const first = segments[0];
627
+ const last = segments[segments.length - 1];
628
+ if (!id.startsWith(first)) return false;
629
+ let pos = first.length;
630
+ for (let i = 1; i < segments.length - 1; i++) {
631
+ const idx = id.indexOf(segments[i], pos);
632
+ if (idx === -1) return false;
633
+ pos = idx + segments[i].length;
634
+ }
635
+ return id.endsWith(last) && id.length - last.length >= pos;
636
+ };
637
+ const matchPattern = (kind, id) => {
638
+ assertEntityKind(kind);
639
+ const patterns = getPatternsForKind(kind);
640
+ if (patterns.length === 0) return void 0;
641
+ let best;
642
+ for (const entry of patterns) {
643
+ if (matchesSegments(entry.segments, id) && (best === void 0 || entry.staticLength > best.staticLength)) {
644
+ best = entry;
645
+ }
646
+ }
647
+ if (best === void 0) return void 0;
648
+ return freezeEntity(best.entity, getFlows());
649
+ };
650
+ const list = (kind) => {
651
+ assertEntityKind(kind);
652
+ const flows = getFlows();
653
+ return Array.from(
654
+ store[kind].values(),
655
+ (e) => freezeEntity(e, flows)
656
+ );
657
+ };
658
+ const allEntities = function* () {
659
+ for (const kind of Object.keys(store)) {
660
+ for (const entity of store[kind].values()) {
661
+ yield entity;
662
+ }
663
+ }
664
+ };
665
+ const query = (predicate) => {
666
+ const flows = getFlows();
667
+ const result = [];
668
+ for (const entity of allEntities()) {
669
+ if (predicate(entity)) result.push(freezeEntity(entity, flows));
670
+ }
671
+ return result;
672
+ };
673
+ const byScope = (scope) => query(
674
+ (entity) => "scopes" in entity && Array.isArray(entity.scopes) && entity.scopes.includes(scope)
675
+ );
676
+ const touchedBy = (flowId) => {
677
+ const flow = store.flow.get(flowId);
678
+ if (flow === void 0) return [];
679
+ const ids = new Set(flow.touches);
680
+ return query((entity) => {
681
+ if (!isMetaEntity(entity)) return false;
682
+ return ids.has(entity.id);
683
+ });
684
+ };
685
+ const reports = /* @__PURE__ */ new Map();
686
+ const reportsCbs = /* @__PURE__ */ new Set();
687
+ const setReports = (kind, id, records) => {
688
+ reports.set(`${kind}:${id}`, records);
689
+ for (const cb of reportsCbs) cb();
690
+ };
691
+ const getReports = (kind, id) => reports.get(`${kind}:${id}`) ?? [];
692
+ const listReportKeys = () => Array.from(reports.keys());
693
+ const onReportsChange = (cb) => {
694
+ reportsCbs.add(cb);
695
+ return () => reportsCbs.delete(cb);
696
+ };
697
+ return {
698
+ add,
699
+ get,
700
+ matchPattern,
701
+ list,
702
+ query,
703
+ byScope,
704
+ touchedBy,
705
+ setReports,
706
+ getReports,
707
+ listReportKeys,
708
+ onReportsChange
709
+ };
710
+ }
711
+
486
712
  // src/scanner/scan/extract-uidex-export.ts
487
713
  var KIND_DISCRIMINATORS = [
488
714
  "page",
@@ -500,20 +726,35 @@ var ALLOWED_FIELDS = {
500
726
  "features",
501
727
  "widgets",
502
728
  "acceptance",
729
+ "states",
730
+ "capture",
731
+ "waivers",
503
732
  "description"
504
733
  ]),
734
+ // `capture` / `waivers` are route-scoped, and only a page maps to a route — so
735
+ // they are page-only (the gates read them off the page). A feature/widget that
736
+ // sets them gets an `uidex-export-unknown-field` error rather than a silent no-op.
505
737
  feature: /* @__PURE__ */ new Set([
506
738
  "feature",
507
739
  "name",
508
740
  "features",
509
741
  "acceptance",
742
+ "states",
510
743
  "description"
511
744
  ]),
512
745
  primitive: /* @__PURE__ */ new Set(["primitive", "name", "description"]),
513
- widget: /* @__PURE__ */ new Set(["widget", "name", "acceptance", "description"]),
746
+ widget: /* @__PURE__ */ new Set(["widget", "name", "acceptance", "states", "description"]),
514
747
  region: /* @__PURE__ */ new Set(["region", "name", "description"]),
515
748
  flow: /* @__PURE__ */ new Set(["flow", "notFlow", "name", "description"])
516
749
  };
750
+ var STATE_FIELDS = /* @__PURE__ */ new Set([
751
+ "name",
752
+ "kind",
753
+ "acceptance",
754
+ "description"
755
+ ]);
756
+ var CORE_STATE_KINDS2 = new Set(CORE_STATE_KINDS);
757
+ var STATE_KINDS = /* @__PURE__ */ new Set([...CORE_STATE_KINDS, "variant"]);
517
758
  var FALSEABLE = /* @__PURE__ */ new Set([
518
759
  "page",
519
760
  "feature",
@@ -903,6 +1144,9 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
903
1144
  }
904
1145
  const featuresField = kind === "page" || kind === "feature" ? readStringArrayField(byKey, "features") : void 0;
905
1146
  const widgetsField = kind === "page" ? readStringArrayField(byKey, "widgets") : void 0;
1147
+ const states = kind === "page" || kind === "feature" || kind === "widget" ? readStatesField(byKey) : void 0;
1148
+ const capture = kind === "page" ? readBooleanField(byKey, "capture") : void 0;
1149
+ const waivers = kind === "page" ? readWaiversField(byKey) : void 0;
906
1150
  const notFlow = kind === "flow" && discriminator === "notFlow" ? true : void 0;
907
1151
  const metadata = {
908
1152
  source: "ts-export",
@@ -925,6 +1169,9 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
925
1169
  metadata.widgets = widgetsField.values;
926
1170
  metadata.widgetSpans = widgetsField.spans;
927
1171
  }
1172
+ if (states?.length) metadata.states = states;
1173
+ if (capture !== void 0) metadata.capture = capture;
1174
+ if (waivers && Object.keys(waivers).length > 0) metadata.waivers = waivers;
928
1175
  if (notFlow) metadata.notFlow = true;
929
1176
  if (typeof id === "string" && idValue.kind === "string") {
930
1177
  metadata.idSpan = idValue.span;
@@ -961,6 +1208,48 @@ function readIdField(value, kind, fieldName) {
961
1208
  value.pos
962
1209
  );
963
1210
  }
1211
+ function readWaiversField(byKey) {
1212
+ const entry = byKey.get("waivers");
1213
+ if (!entry) return void 0;
1214
+ if (entry.value.kind !== "object") {
1215
+ throw new ExtractError(
1216
+ "uidex-export-invalid-field",
1217
+ "`waivers` must be an object mapping a core kind to a reason.",
1218
+ entry.value.pos
1219
+ );
1220
+ }
1221
+ const waivers = {};
1222
+ for (const e of entry.value.entries) {
1223
+ if (!CORE_STATE_KINDS2.has(e.key)) {
1224
+ throw new ExtractError(
1225
+ "uidex-export-invalid-field",
1226
+ `waiver key "${e.key}" is not a core kind; only ${[...CORE_STATE_KINDS2].join(", ")} participate in the matrix.`,
1227
+ e.keyPos
1228
+ );
1229
+ }
1230
+ if (e.value.kind !== "string" || e.value.value.length === 0) {
1231
+ throw new ExtractError(
1232
+ "uidex-export-invalid-field",
1233
+ `waiver "${e.key}" needs a non-empty reason (why the route cannot render this kind).`,
1234
+ e.value.pos
1235
+ );
1236
+ }
1237
+ waivers[e.key] = e.value.value;
1238
+ }
1239
+ return waivers;
1240
+ }
1241
+ function readBooleanField(byKey, name) {
1242
+ const entry = byKey.get(name);
1243
+ if (!entry) return void 0;
1244
+ if (entry.value.kind !== "boolean") {
1245
+ throw new ExtractError(
1246
+ "uidex-export-invalid-field",
1247
+ `\`${name}\` must be a boolean.`,
1248
+ entry.value.pos
1249
+ );
1250
+ }
1251
+ return entry.value.value;
1252
+ }
964
1253
  function readStringField(byKey, name) {
965
1254
  const entry = byKey.get(name);
966
1255
  if (!entry) return void 0;
@@ -998,6 +1287,86 @@ function readStringArrayField(byKey, name) {
998
1287
  }
999
1288
  return { values, spans };
1000
1289
  }
1290
+ function readStatesField(byKey) {
1291
+ const entry = byKey.get("states");
1292
+ if (!entry) return void 0;
1293
+ if (entry.value.kind !== "array") {
1294
+ throw new ExtractError(
1295
+ "uidex-export-invalid-field",
1296
+ "`states` must be an array of state names or `{ name, acceptance?, description? }` objects.",
1297
+ entry.value.pos
1298
+ );
1299
+ }
1300
+ const states = [];
1301
+ const seen = /* @__PURE__ */ new Set();
1302
+ for (const item of entry.value.items) {
1303
+ let state;
1304
+ if (item.kind === "string") {
1305
+ state = { name: item.value, nameSpan: item.span };
1306
+ } else if (item.kind === "object") {
1307
+ const fieldByKey = /* @__PURE__ */ new Map();
1308
+ for (const e of item.entries) fieldByKey.set(e.key, e);
1309
+ for (const e of item.entries) {
1310
+ if (!STATE_FIELDS.has(e.key)) {
1311
+ throw new ExtractError(
1312
+ "uidex-export-invalid-field",
1313
+ `Unknown field "${e.key}" in a \`states\` entry. Allowed: ${Array.from(
1314
+ STATE_FIELDS
1315
+ ).sort().join(", ")}.`,
1316
+ e.value.pos
1317
+ );
1318
+ }
1319
+ }
1320
+ const nameEntry = fieldByKey.get("name");
1321
+ if (!nameEntry || nameEntry.value.kind !== "string") {
1322
+ throw new ExtractError(
1323
+ "uidex-export-invalid-field",
1324
+ "Each object `states` entry must have a string `name`.",
1325
+ item.pos
1326
+ );
1327
+ }
1328
+ state = { name: nameEntry.value.value, nameSpan: nameEntry.value.span };
1329
+ const kind = readStringField(fieldByKey, "kind");
1330
+ if (kind !== void 0) {
1331
+ if (!STATE_KINDS.has(kind)) {
1332
+ throw new ExtractError(
1333
+ "uidex-export-invalid-field",
1334
+ `state \`kind\` must be one of ${[...STATE_KINDS].join(", ")} (got "${kind}").`,
1335
+ fieldByKey.get("kind").value.pos
1336
+ );
1337
+ }
1338
+ state.kind = kind;
1339
+ }
1340
+ const acceptance = readStringArrayField(fieldByKey, "acceptance")?.values;
1341
+ if (acceptance) state.acceptance = acceptance;
1342
+ const description = readStringField(fieldByKey, "description");
1343
+ if (description) state.description = description;
1344
+ } else {
1345
+ throw new ExtractError(
1346
+ "uidex-export-invalid-field",
1347
+ "`states` items must be strings or `{ name, ... }` objects.",
1348
+ item.pos
1349
+ );
1350
+ }
1351
+ if (state.name.length === 0) {
1352
+ throw new ExtractError(
1353
+ "uidex-export-invalid-field",
1354
+ "A `states` entry has an empty `name`.",
1355
+ item.pos
1356
+ );
1357
+ }
1358
+ if (seen.has(state.name)) {
1359
+ throw new ExtractError(
1360
+ "uidex-export-duplicate-state",
1361
+ `Duplicate state "${state.name}" in \`states\`; each state name must be unique within the entity.`,
1362
+ item.pos
1363
+ );
1364
+ }
1365
+ seen.add(state.name);
1366
+ states.push(state);
1367
+ }
1368
+ return states;
1369
+ }
1001
1370
  function posAt(content, offset, p2) {
1002
1371
  const lineStart = content.lastIndexOf("\n", offset - 1) + 1;
1003
1372
  return { offset, line: p2.lineAt(offset), column: offset - lineStart + 1 };
@@ -1498,211 +1867,22 @@ function collectImportFacts(parsed) {
1498
1867
  } else {
1499
1868
  continue;
1500
1869
  }
1501
- if (!source || source.type !== "Literal") continue;
1502
- if (typeof source.value !== "string") continue;
1503
- out2.push({
1504
- specifier: source.value,
1505
- line: parsed.lineAt(stmt.start),
1506
- span: { start: stmt.start, end: stmt.end },
1507
- isTypeOnly,
1508
- names
1509
- });
1510
- }
1511
- return out2;
1512
- }
1513
-
1514
- // src/scanner/scan/resolve.ts
1515
- var path4 = __toESM(require("path"), 1);
1516
-
1517
- // src/shared/entities/types.ts
1518
- var ENTITY_KINDS = [
1519
- "route",
1520
- "page",
1521
- "feature",
1522
- "widget",
1523
- "region",
1524
- "element",
1525
- "primitive",
1526
- "flow"
1527
- ];
1528
- function isMetaKind(kind) {
1529
- return kind !== "route" && kind !== "flow";
1530
- }
1531
- function isMetaEntity(entity) {
1532
- return isMetaKind(entity.kind);
1533
- }
1534
- function entityKey(entity) {
1535
- return entity.kind === "route" ? entity.path : entity.id;
1536
- }
1537
- var UnknownEntityKindError = class extends Error {
1538
- kind;
1539
- constructor(kind) {
1540
- super(`Unknown entity kind: ${kind}`);
1541
- this.name = "UnknownEntityKindError";
1542
- this.kind = kind;
1543
- }
1544
- };
1545
- var KIND_SET = new Set(ENTITY_KINDS);
1546
- function assertEntityKind(kind) {
1547
- if (!KIND_SET.has(kind)) throw new UnknownEntityKindError(kind);
1548
- }
1549
-
1550
- // src/shared/entities/registry.ts
1551
- function emptyStore() {
1552
- return {
1553
- route: /* @__PURE__ */ new Map(),
1554
- page: /* @__PURE__ */ new Map(),
1555
- feature: /* @__PURE__ */ new Map(),
1556
- widget: /* @__PURE__ */ new Map(),
1557
- region: /* @__PURE__ */ new Map(),
1558
- element: /* @__PURE__ */ new Map(),
1559
- primitive: /* @__PURE__ */ new Map(),
1560
- flow: /* @__PURE__ */ new Map()
1561
- };
1562
- }
1563
- function computeFlowIds(flows, targetId) {
1564
- const ids = [];
1565
- for (const flow of flows) {
1566
- if (flow.touches.includes(targetId)) ids.push(flow.id);
1567
- }
1568
- return ids;
1569
- }
1570
- function freezeEntity(entity, flows) {
1571
- if (!isMetaKind(entity.kind)) return entity;
1572
- const withMeta = entity;
1573
- if (withMeta.meta === void 0) return entity;
1574
- const computedFlows = Object.freeze(computeFlowIds(flows, withMeta.id));
1575
- const mergedMeta = { ...withMeta.meta, flows: computedFlows };
1576
- return { ...entity, meta: Object.freeze(mergedMeta) };
1577
- }
1578
- function createRegistry() {
1579
- const store = emptyStore();
1580
- let flowsCache = null;
1581
- const patternCache = /* @__PURE__ */ new Map();
1582
- const getFlows = () => {
1583
- if (flowsCache === null) flowsCache = Array.from(store.flow.values());
1584
- return flowsCache;
1585
- };
1586
- const add = (entity) => {
1587
- assertEntityKind(entity.kind);
1588
- const key = entityKey(entity);
1589
- store[entity.kind].set(key, entity);
1590
- flowsCache = null;
1591
- patternCache.delete(entity.kind);
1592
- };
1593
- const get = (kind, id) => {
1594
- assertEntityKind(kind);
1595
- const raw = store[kind].get(id);
1596
- if (raw === void 0) return void 0;
1597
- return freezeEntity(raw, getFlows());
1598
- };
1599
- const getPatternsForKind = (kind) => {
1600
- const cached = patternCache.get(kind);
1601
- if (cached !== void 0) return cached;
1602
- const patterns = [];
1603
- for (const [key, entity] of store[kind]) {
1604
- if (key.includes("*")) {
1605
- const segments = key.split("*");
1606
- patterns.push({
1607
- segments,
1608
- staticLength: segments.reduce((n, s) => n + s.length, 0),
1609
- entity
1610
- });
1611
- }
1612
- }
1613
- patternCache.set(
1614
- kind,
1615
- patterns
1616
- );
1617
- return patterns;
1618
- };
1619
- const matchesSegments = (segments, id) => {
1620
- const first = segments[0];
1621
- const last = segments[segments.length - 1];
1622
- if (!id.startsWith(first)) return false;
1623
- let pos = first.length;
1624
- for (let i = 1; i < segments.length - 1; i++) {
1625
- const idx = id.indexOf(segments[i], pos);
1626
- if (idx === -1) return false;
1627
- pos = idx + segments[i].length;
1628
- }
1629
- return id.endsWith(last) && id.length - last.length >= pos;
1630
- };
1631
- const matchPattern = (kind, id) => {
1632
- assertEntityKind(kind);
1633
- const patterns = getPatternsForKind(kind);
1634
- if (patterns.length === 0) return void 0;
1635
- let best;
1636
- for (const entry of patterns) {
1637
- if (matchesSegments(entry.segments, id) && (best === void 0 || entry.staticLength > best.staticLength)) {
1638
- best = entry;
1639
- }
1640
- }
1641
- if (best === void 0) return void 0;
1642
- return freezeEntity(best.entity, getFlows());
1643
- };
1644
- const list = (kind) => {
1645
- assertEntityKind(kind);
1646
- const flows = getFlows();
1647
- return Array.from(
1648
- store[kind].values(),
1649
- (e) => freezeEntity(e, flows)
1650
- );
1651
- };
1652
- const allEntities = function* () {
1653
- for (const kind of Object.keys(store)) {
1654
- for (const entity of store[kind].values()) {
1655
- yield entity;
1656
- }
1657
- }
1658
- };
1659
- const query = (predicate) => {
1660
- const flows = getFlows();
1661
- const result = [];
1662
- for (const entity of allEntities()) {
1663
- if (predicate(entity)) result.push(freezeEntity(entity, flows));
1664
- }
1665
- return result;
1666
- };
1667
- const byScope = (scope) => query(
1668
- (entity) => "scopes" in entity && Array.isArray(entity.scopes) && entity.scopes.includes(scope)
1669
- );
1670
- const touchedBy = (flowId) => {
1671
- const flow = store.flow.get(flowId);
1672
- if (flow === void 0) return [];
1673
- const ids = new Set(flow.touches);
1674
- return query((entity) => {
1675
- if (!isMetaEntity(entity)) return false;
1676
- return ids.has(entity.id);
1870
+ if (!source || source.type !== "Literal") continue;
1871
+ if (typeof source.value !== "string") continue;
1872
+ out2.push({
1873
+ specifier: source.value,
1874
+ line: parsed.lineAt(stmt.start),
1875
+ span: { start: stmt.start, end: stmt.end },
1876
+ isTypeOnly,
1877
+ names
1677
1878
  });
1678
- };
1679
- const reports = /* @__PURE__ */ new Map();
1680
- const reportsCbs = /* @__PURE__ */ new Set();
1681
- const setReports = (kind, id, records) => {
1682
- reports.set(`${kind}:${id}`, records);
1683
- for (const cb of reportsCbs) cb();
1684
- };
1685
- const getReports = (kind, id) => reports.get(`${kind}:${id}`) ?? [];
1686
- const listReportKeys = () => Array.from(reports.keys());
1687
- const onReportsChange = (cb) => {
1688
- reportsCbs.add(cb);
1689
- return () => reportsCbs.delete(cb);
1690
- };
1691
- return {
1692
- add,
1693
- get,
1694
- matchPattern,
1695
- list,
1696
- query,
1697
- byScope,
1698
- touchedBy,
1699
- setReports,
1700
- getReports,
1701
- listReportKeys,
1702
- onReportsChange
1703
- };
1879
+ }
1880
+ return out2;
1704
1881
  }
1705
1882
 
1883
+ // src/scanner/scan/resolve.ts
1884
+ var path4 = __toESM(require("path"), 1);
1885
+
1706
1886
  // src/scanner/scan/routes.ts
1707
1887
  var PAGE_BASENAME = /^page\.(tsx|ts|jsx|js|mjs|cjs)$/;
1708
1888
  var PAGES_ROUTER_BASENAME = /\.(tsx|ts|jsx|js|mjs|cjs)$/;
@@ -1802,6 +1982,18 @@ function buildMetaFromExport(exp) {
1802
1982
  if (exp.acceptance?.length) meta.acceptance = exp.acceptance;
1803
1983
  if (exp.features?.length) meta.features = exp.features;
1804
1984
  if (exp.widgets?.length) meta.widgets = exp.widgets;
1985
+ if (exp.states?.length) {
1986
+ meta.states = exp.states.map((s) => ({
1987
+ name: s.name,
1988
+ ...s.kind ? { kind: s.kind } : {},
1989
+ ...s.acceptance?.length ? { acceptance: s.acceptance } : {},
1990
+ ...s.description ? { description: s.description } : {}
1991
+ }));
1992
+ }
1993
+ if (exp.capture === false) meta.capture = false;
1994
+ if (exp.waivers && Object.keys(exp.waivers).length > 0) {
1995
+ meta.waivers = exp.waivers;
1996
+ }
1805
1997
  return Object.keys(meta).length > 0 ? meta : void 0;
1806
1998
  }
1807
1999
  function resolve2(ctx) {
@@ -2221,6 +2413,297 @@ function dedupe(arr) {
2221
2413
 
2222
2414
  // src/scanner/scan/audit.ts
2223
2415
  var path5 = __toESM(require("path"), 1);
2416
+
2417
+ // src/scanner/scan/page-coverage.ts
2418
+ function compileRoute(pattern) {
2419
+ const segs = pattern.split("/").filter(Boolean);
2420
+ let specificity = 0;
2421
+ const optionalTail = segs.length > 0 && /^\[\[\.{3}.+\]\]$/.test(segs[segs.length - 1]);
2422
+ const effective = optionalTail ? segs.slice(0, -1) : segs;
2423
+ const parts = effective.map((seg) => {
2424
+ const catchAll = /^\[\.{3}.+\]$/.test(seg);
2425
+ const dynamic = /^\[.+\]$/.test(seg);
2426
+ if (catchAll) return "(?:[^/]+)(?:/[^/]+)*";
2427
+ if (dynamic) return "[^/]+";
2428
+ specificity++;
2429
+ return seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2430
+ });
2431
+ let body = parts.length === 0 ? "" : "/" + parts.join("/");
2432
+ if (optionalTail) body += "(?:/[^/]+)*";
2433
+ if (body === "") body = "/";
2434
+ const re = new RegExp(`^${body}/?$`);
2435
+ return { test: (url) => re.test(url), specificity };
2436
+ }
2437
+ function matchUrlToRoute(url, routePaths) {
2438
+ const path12 = stripQuery(url);
2439
+ let best = null;
2440
+ for (const rp of routePaths) {
2441
+ const { test, specificity } = compileRoute(rp);
2442
+ if (!test(path12)) continue;
2443
+ if (!best || specificity > best.specificity)
2444
+ best = { path: rp, specificity };
2445
+ }
2446
+ return best?.path ?? null;
2447
+ }
2448
+ function stripQuery(url) {
2449
+ const q = url.indexOf("?");
2450
+ const h = url.indexOf("#");
2451
+ let end = url.length;
2452
+ if (q !== -1) end = Math.min(end, q);
2453
+ if (h !== -1) end = Math.min(end, h);
2454
+ return url.slice(0, end);
2455
+ }
2456
+ function routeForCapture(c, routes, routePaths) {
2457
+ if (c.route && routePaths.includes(c.route)) return c.route;
2458
+ if (c.url) {
2459
+ const m = matchUrlToRoute(c.url, routePaths);
2460
+ if (m) return m;
2461
+ }
2462
+ if (c.kind === "page") {
2463
+ const r = routes.find((r2) => r2.page === c.entity);
2464
+ if (r) return r.path;
2465
+ }
2466
+ return null;
2467
+ }
2468
+ function checkPageCoverage(registry, manifest, baseline) {
2469
+ const routes = registry.list("route");
2470
+ if (routes.length === 0) return [];
2471
+ const routePaths = routes.map((r) => r.path);
2472
+ const captured = manifest.captured ?? [];
2473
+ const optedOut = /* @__PURE__ */ new Set();
2474
+ for (const r of routes) {
2475
+ const page = registry.get("page", r.page);
2476
+ if (page?.meta?.capture === false) optedOut.add(r.path);
2477
+ }
2478
+ const covered = /* @__PURE__ */ new Set();
2479
+ for (const c of captured) {
2480
+ const route = routeForCapture(c, routes, routePaths);
2481
+ if (route) covered.add(route);
2482
+ }
2483
+ const baselineSet = new Set(baseline?.uncapturedPages ?? []);
2484
+ const diagnostics = [];
2485
+ for (const r of routes) {
2486
+ if (covered.has(r.path) || optedOut.has(r.path)) continue;
2487
+ const loc = registry.get("page", r.page)?.loc;
2488
+ if (baselineSet.has(r.path)) {
2489
+ diagnostics.push({
2490
+ code: "page-backlog",
2491
+ severity: "info",
2492
+ message: `route "${r.path}" has no visual-states capture (acknowledged backlog)`,
2493
+ file: loc?.file,
2494
+ line: loc?.line,
2495
+ 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 }\`.`
2496
+ });
2497
+ continue;
2498
+ }
2499
+ diagnostics.push({
2500
+ code: "page-no-capture",
2501
+ severity: "error",
2502
+ message: `route "${r.path}" has no visual-states capture and is not accounted for`,
2503
+ file: loc?.file,
2504
+ line: loc?.line,
2505
+ entity: { kind: "route", id: r.path },
2506
+ 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).`
2507
+ });
2508
+ }
2509
+ for (const b of baselineSet) {
2510
+ if (!routePaths.includes(b)) {
2511
+ diagnostics.push({
2512
+ code: "stale-page-baseline",
2513
+ severity: "warning",
2514
+ message: `coverage baseline lists "${b}", which is not a route in the registry`,
2515
+ hint: `Remove it from the baseline (\`uidex scan --update-baseline\`); the route no longer exists.`
2516
+ });
2517
+ } else if (covered.has(b) || optedOut.has(b)) {
2518
+ diagnostics.push({
2519
+ code: "page-captured",
2520
+ severity: "warning",
2521
+ message: `route "${b}" is now captured but is still in the coverage baseline`,
2522
+ hint: `Prune it with \`uidex scan --update-baseline\` so the backlog reflects reality.`
2523
+ });
2524
+ }
2525
+ }
2526
+ return diagnostics;
2527
+ }
2528
+ function computePageBaseline(registry, manifest) {
2529
+ const uncaptured = [];
2530
+ for (const d of checkPageCoverage(registry, manifest, {
2531
+ uncapturedPages: []
2532
+ })) {
2533
+ if (d.code === "page-no-capture" && d.entity) uncaptured.push(d.entity.id);
2534
+ }
2535
+ return { uncapturedPages: uncaptured.sort() };
2536
+ }
2537
+
2538
+ // src/scanner/scan/state-coverage.ts
2539
+ var STATE_KINDS2 = ["page", "feature", "widget"];
2540
+ function declaredStateEntities(registry) {
2541
+ const out2 = [];
2542
+ for (const kind of STATE_KINDS2) {
2543
+ for (const e of registry.list(kind)) {
2544
+ const states = e.meta?.states;
2545
+ if (!states || states.length === 0) continue;
2546
+ out2.push({
2547
+ kind,
2548
+ id: e.id,
2549
+ states: states.map((s) => s.name),
2550
+ loc: e.loc
2551
+ });
2552
+ }
2553
+ }
2554
+ return out2;
2555
+ }
2556
+ function captureMatchesEntity(cap, ent) {
2557
+ if (cap.entity !== ent.id) return false;
2558
+ return cap.kind === void 0 || cap.kind === ent.kind;
2559
+ }
2560
+ function checkStateCoverage(registry, manifest) {
2561
+ const diagnostics = [];
2562
+ const declared = declaredStateEntities(registry);
2563
+ const captured = manifest.captured ?? [];
2564
+ for (const ent of declared) {
2565
+ for (const state of ent.states) {
2566
+ const hit = captured.some(
2567
+ (c) => captureMatchesEntity(c, ent) && c.state === state
2568
+ );
2569
+ if (hit) continue;
2570
+ diagnostics.push({
2571
+ code: "missing-state-capture",
2572
+ severity: "warning",
2573
+ message: `${ent.kind} "${ent.id}" declares state "${state}" but no capture produced it`,
2574
+ file: ent.loc?.file,
2575
+ line: ent.loc?.line,
2576
+ entity: { kind: ent.kind, id: ent.id },
2577
+ hint: `Add a capture for "${ent.id}/${state}" to the states matrix, or remove the state from \`export const uidex = { states: [...] }\` if it no longer exists.`
2578
+ });
2579
+ }
2580
+ }
2581
+ for (const cap of captured) {
2582
+ const candidates = declared.filter((ent2) => captureMatchesEntity(cap, ent2));
2583
+ if (candidates.length === 0) continue;
2584
+ const declaredSomewhere = candidates.some(
2585
+ (ent2) => ent2.states.includes(cap.state)
2586
+ );
2587
+ if (declaredSomewhere) continue;
2588
+ const ent = candidates[0];
2589
+ diagnostics.push({
2590
+ code: "orphan-state-capture",
2591
+ severity: "warning",
2592
+ message: `Capture produced state "${cap.state}" for ${ent.kind} "${ent.id}", which is not in its declared states [${ent.states.join(", ")}]`,
2593
+ file: ent.loc?.file,
2594
+ line: ent.loc?.line,
2595
+ entity: { kind: ent.kind, id: ent.id },
2596
+ hint: `Declare "${cap.state}" in \`export const uidex = { states: [...] }\` for "${ent.id}", or rename the capture to a declared state.`
2597
+ });
2598
+ }
2599
+ return diagnostics;
2600
+ }
2601
+
2602
+ // src/scanner/scan/state-matrix.ts
2603
+ var CORE_KINDS = CORE_STATE_KINDS;
2604
+ var STATE_ENTITY_KINDS = ["page", "feature", "widget"];
2605
+ function resolveMatrixKind(registry, c) {
2606
+ if (c.stateKind) return c.stateKind;
2607
+ for (const kind of STATE_ENTITY_KINDS) {
2608
+ const declared = registry.get(kind, c.entity)?.meta?.states?.find((s) => s.name === c.state)?.kind;
2609
+ if (declared) return declared;
2610
+ }
2611
+ return CORE_KINDS.includes(c.state) ? c.state : "variant";
2612
+ }
2613
+ function coreKindsByRoute(registry, manifest) {
2614
+ const routes = registry.list("route");
2615
+ const routePaths = routes.map((r) => r.path);
2616
+ const byRoute = /* @__PURE__ */ new Map();
2617
+ for (const c of manifest.captured ?? []) {
2618
+ const route = routeForCapture(c, routes, routePaths);
2619
+ if (!route) continue;
2620
+ if (!byRoute.has(route)) byRoute.set(route, /* @__PURE__ */ new Set());
2621
+ const kind = resolveMatrixKind(registry, c);
2622
+ if (CORE_KINDS.includes(kind)) byRoute.get(route).add(kind);
2623
+ }
2624
+ return byRoute;
2625
+ }
2626
+ function pageWaivers(registry, routePage) {
2627
+ return registry.get("page", routePage)?.meta?.waivers ?? {};
2628
+ }
2629
+ function isOptedOut(registry, page) {
2630
+ return page ? registry.get("page", page)?.meta?.capture === false : false;
2631
+ }
2632
+ function checkStateMatrix(registry, manifest, baseline) {
2633
+ const routes = registry.list("route");
2634
+ if (routes.length === 0) return [];
2635
+ const pageOf = new Map(routes.map((r) => [r.path, r.page]));
2636
+ const captured = coreKindsByRoute(registry, manifest);
2637
+ const baselined = baseline?.missingKinds ?? {};
2638
+ const diagnostics = [];
2639
+ for (const [route, kinds] of captured) {
2640
+ const page = pageOf.get(route);
2641
+ if (isOptedOut(registry, page)) continue;
2642
+ const waivers = page ? pageWaivers(registry, page) : {};
2643
+ const loc = page ? registry.get("page", page)?.loc : void 0;
2644
+ const baselinedKinds = new Set(baselined[route] ?? []);
2645
+ for (const kind of CORE_KINDS) {
2646
+ if (kinds.has(kind)) continue;
2647
+ if (waivers[kind]) continue;
2648
+ if (baselinedKinds.has(kind)) {
2649
+ diagnostics.push({
2650
+ code: "matrix-backlog",
2651
+ severity: "info",
2652
+ message: `route "${route}" does not render core kind "${kind}" (acknowledged MISSING_KINDS backlog)`,
2653
+ file: loc?.file,
2654
+ line: loc?.line,
2655
+ hint: `Capture the "${kind}" state for "${route}", waive it (\`waivers: { ${kind}: "why it can't" }\` on the page), or leave it in the baseline.`
2656
+ });
2657
+ continue;
2658
+ }
2659
+ diagnostics.push({
2660
+ code: "core-kind",
2661
+ severity: "error",
2662
+ message: `route "${route}" is captured but never renders core kind "${kind}"`,
2663
+ file: loc?.file,
2664
+ line: loc?.line,
2665
+ entity: { kind: "route", id: route },
2666
+ 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).`
2667
+ });
2668
+ }
2669
+ }
2670
+ for (const [route, kinds] of captured) {
2671
+ const page = pageOf.get(route);
2672
+ if (!page || isOptedOut(registry, page)) continue;
2673
+ const waivers = pageWaivers(registry, page);
2674
+ const loc = registry.get("page", page)?.loc;
2675
+ for (const kind of Object.keys(waivers)) {
2676
+ if (kinds.has(kind)) {
2677
+ diagnostics.push({
2678
+ code: "stale-waiver",
2679
+ severity: "warning",
2680
+ message: `page "${page}" waives core kind "${kind}", but route "${route}" does render it`,
2681
+ file: loc?.file,
2682
+ line: loc?.line,
2683
+ entity: { kind: "page", id: page },
2684
+ hint: `Remove the "${kind}" waiver \u2014 the route renders that kind, so the waiver's reason is stale.`
2685
+ });
2686
+ }
2687
+ }
2688
+ }
2689
+ return diagnostics;
2690
+ }
2691
+ function computeMissingKinds(registry, manifest) {
2692
+ const routes = registry.list("route");
2693
+ const pageOf = new Map(routes.map((r) => [r.path, r.page]));
2694
+ const captured = coreKindsByRoute(registry, manifest);
2695
+ const out2 = {};
2696
+ for (const [route, kinds] of captured) {
2697
+ const page = pageOf.get(route);
2698
+ if (isOptedOut(registry, page)) continue;
2699
+ const waivers = page ? pageWaivers(registry, page) : {};
2700
+ const missing = CORE_KINDS.filter((k) => !kinds.has(k) && !waivers[k]);
2701
+ if (missing.length > 0) out2[route] = missing;
2702
+ }
2703
+ return out2;
2704
+ }
2705
+
2706
+ // src/scanner/scan/audit.ts
2224
2707
  function audit(opts) {
2225
2708
  const diagnostics = [];
2226
2709
  const { registry, extracted, files, config } = opts;
@@ -2229,6 +2712,9 @@ function audit(opts) {
2229
2712
  const acceptanceEnabled = config.audit?.acceptance ?? true;
2230
2713
  const scopeLeakEnabled = config.audit?.scopeLeak ?? true;
2231
2714
  const coverageEnabled = config.audit?.coverage ?? true;
2715
+ const statesEnabled = config.audit?.states ?? true;
2716
+ const pageCoverageEnabled = config.audit?.pageCoverage ?? true;
2717
+ const stateMatrixEnabled = config.audit?.stateMatrix ?? true;
2232
2718
  if (opts.resolveDiagnostics) diagnostics.push(...opts.resolveDiagnostics);
2233
2719
  for (const ef of extracted) {
2234
2720
  if (ef.diagnostics) diagnostics.push(...ef.diagnostics);
@@ -2262,6 +2748,48 @@ function audit(opts) {
2262
2748
  }
2263
2749
  }
2264
2750
  }
2751
+ if (check && opts.typesGenerated !== void 0) {
2752
+ const outRel = opts.typesOutputPath ?? config.output;
2753
+ const fresh = normalizeForCheck(opts.typesGenerated);
2754
+ if (opts.typesOnDisk === null || opts.typesOnDisk === void 0) {
2755
+ diagnostics.push({
2756
+ code: "gen-missing",
2757
+ severity: "error",
2758
+ message: `Generated file "${outRel}" does not exist on disk; run \`uidex scan\` and commit the result`,
2759
+ file: outRel,
2760
+ hint: "Run `uidex scan` (without --check) to regenerate"
2761
+ });
2762
+ } else if (normalizeForCheck(opts.typesOnDisk) !== fresh) {
2763
+ diagnostics.push({
2764
+ code: "gen-stale",
2765
+ severity: "error",
2766
+ message: `Generated file "${outRel}" is stale`,
2767
+ file: outRel,
2768
+ hint: "Run `uidex scan` (without --check) to regenerate and commit the result"
2769
+ });
2770
+ }
2771
+ }
2772
+ if (check && opts.locsGenerated !== void 0) {
2773
+ const outRel = opts.locsOutputPath ?? config.output;
2774
+ const fresh = normalizeForCheck(opts.locsGenerated);
2775
+ if (opts.locsOnDisk === null || opts.locsOnDisk === void 0) {
2776
+ diagnostics.push({
2777
+ code: "gen-missing",
2778
+ severity: "error",
2779
+ message: `Generated file "${outRel}" does not exist on disk; run \`uidex scan\` and commit the result`,
2780
+ file: outRel,
2781
+ hint: "Run `uidex scan` (without --check) to regenerate"
2782
+ });
2783
+ } else if (normalizeForCheck(opts.locsOnDisk) !== fresh) {
2784
+ diagnostics.push({
2785
+ code: "gen-stale",
2786
+ severity: "error",
2787
+ message: `Generated file "${outRel}" is stale`,
2788
+ file: outRel,
2789
+ hint: "Run `uidex scan` (without --check) to regenerate and commit the result"
2790
+ });
2791
+ }
2792
+ }
2265
2793
  if (lint && acceptanceEnabled) {
2266
2794
  for (const kind of ["widget", "feature", "page"]) {
2267
2795
  for (const e of registry.list(kind)) {
@@ -2521,6 +3049,21 @@ function audit(opts) {
2521
3049
  }
2522
3050
  }
2523
3051
  }
3052
+ if (lint && statesEnabled && opts.capturedStates) {
3053
+ diagnostics.push(...checkStateCoverage(registry, opts.capturedStates));
3054
+ }
3055
+ if (lint && pageCoverageEnabled && opts.capturedStates) {
3056
+ diagnostics.push(
3057
+ ...checkPageCoverage(registry, opts.capturedStates, opts.pageBaseline)
3058
+ );
3059
+ }
3060
+ if (lint && stateMatrixEnabled && opts.capturedStates) {
3061
+ diagnostics.push(
3062
+ ...checkStateMatrix(registry, opts.capturedStates, {
3063
+ missingKinds: opts.pageBaseline?.missingKinds ?? {}
3064
+ })
3065
+ );
3066
+ }
2524
3067
  const summary = {
2525
3068
  errors: diagnostics.filter((d) => d.severity === "error").length,
2526
3069
  warnings: diagnostics.filter((d) => d.severity === "warning").length
@@ -2685,6 +3228,148 @@ function stableReplacer(_key, value) {
2685
3228
  }
2686
3229
 
2687
3230
  // src/scanner/scan/emit.ts
3231
+ var AUTHORED_KINDS = /* @__PURE__ */ new Set([
3232
+ "route",
3233
+ "page",
3234
+ "feature",
3235
+ "widget",
3236
+ "primitive",
3237
+ "flow"
3238
+ ]);
3239
+ function internedPayload(entities) {
3240
+ const fileIndex = /* @__PURE__ */ new Map();
3241
+ const files = [];
3242
+ const compact = entities.map((e) => {
3243
+ const loc = e.loc;
3244
+ if (!loc || typeof loc.file !== "string") return e;
3245
+ let idx = fileIndex.get(loc.file);
3246
+ if (idx === void 0) {
3247
+ idx = files.length;
3248
+ files.push(loc.file);
3249
+ fileIndex.set(loc.file, idx);
3250
+ }
3251
+ const rest = { ...loc };
3252
+ delete rest.file;
3253
+ return { ...e, loc: { f: idx, ...rest } };
3254
+ });
3255
+ return {
3256
+ filesDecl: `const files: readonly string[] = ${jsonStable(files)}`,
3257
+ parseExpr: `(JSON.parse(${JSON.stringify(
3258
+ JSON.stringify(compact, replacerSorted)
3259
+ )}) as any[]).map(hydrate)`
3260
+ };
3261
+ }
3262
+ var HYDRATE_FN = [
3263
+ "function hydrate(e: any): any {",
3264
+ ' if (e.loc && typeof e.loc.f === "number") {',
3265
+ " const { f, ...rest } = e.loc",
3266
+ " e.loc = { file: files[f], ...rest }",
3267
+ " }",
3268
+ " return e",
3269
+ "}"
3270
+ ].join("\n");
3271
+ function compactMeta(meta) {
3272
+ if (!meta || typeof meta !== "object") return meta;
3273
+ const composes = meta.composes;
3274
+ if (!Array.isArray(composes)) return meta;
3275
+ return {
3276
+ ...meta,
3277
+ composes: composes.map(
3278
+ (c) => c && typeof c === "object" && c.kind === "element" && Object.keys(c).length === 2 && typeof c.id === "string" ? c.id : c
3279
+ )
3280
+ };
3281
+ }
3282
+ function emitLocsModule(thin, uidexImport) {
3283
+ const kinds = [...new Set(thin.map((e) => e.kind))].sort();
3284
+ const kindIdx = new Map(kinds.map((k, i) => [k, i]));
3285
+ const fileIndex = /* @__PURE__ */ new Map();
3286
+ const files = [];
3287
+ const fi = (f) => {
3288
+ let i = fileIndex.get(f);
3289
+ if (i === void 0) {
3290
+ i = files.length;
3291
+ files.push(f);
3292
+ fileIndex.set(f, i);
3293
+ }
3294
+ return i;
3295
+ };
3296
+ const bare = [];
3297
+ const rich = [];
3298
+ for (const e of thin) {
3299
+ const loc = e.loc;
3300
+ const topOnlyKindIdLoc = Object.keys(e).every(
3301
+ (k) => k === "kind" || k === "id" || k === "loc"
3302
+ );
3303
+ const locOnlyKnown = !!loc && typeof loc.file === "string" && Object.keys(loc).every(
3304
+ (k) => k === "file" || k === "line" || k === "column"
3305
+ );
3306
+ if (topOnlyKindIdLoc && locOnlyKnown) {
3307
+ const row = [
3308
+ kindIdx.get(e.kind),
3309
+ e.id,
3310
+ fi(loc.file)
3311
+ ];
3312
+ if (loc.line != null || loc.column != null)
3313
+ row.push(Number(loc.line ?? 0));
3314
+ if (loc.column != null) row.push(Number(loc.column));
3315
+ bare.push(row);
3316
+ } else {
3317
+ const richEntity = { ...e };
3318
+ if (loc && typeof loc.file === "string") {
3319
+ const { file, ...rest } = loc;
3320
+ richEntity.loc = { f: fi(file), ...rest };
3321
+ }
3322
+ if (richEntity.meta) richEntity.meta = compactMeta(richEntity.meta);
3323
+ rich.push(richEntity);
3324
+ }
3325
+ }
3326
+ const str = (v) => JSON.stringify(JSON.stringify(v, replacerSorted));
3327
+ const l = [];
3328
+ l.push("// THIS FILE IS AUTO-GENERATED BY `uidex scan`. DO NOT EDIT.");
3329
+ l.push(
3330
+ "// Deferred element/region source locations for the inspector's copy-path."
3331
+ );
3332
+ l.push("// @ts-nocheck");
3333
+ l.push("/* eslint-disable */");
3334
+ l.push(`import type { Entity, Registry } from ${JSON.stringify(uidexImport)}`);
3335
+ l.push("");
3336
+ l.push(`const files: readonly string[] = ${jsonStable(files)}`);
3337
+ l.push(`const kinds: readonly string[] = ${jsonStable(kinds)}`);
3338
+ l.push("function loc(fi: number, line?: number, col?: number) {");
3339
+ l.push(" const o: any = { file: files[fi] }");
3340
+ l.push(" if (line) o.line = line");
3341
+ l.push(" if (col) o.column = col");
3342
+ l.push(" return o");
3343
+ l.push("}");
3344
+ l.push(`const bare: any[] = JSON.parse(${str(bare)})`);
3345
+ l.push(`const rich: any[] = JSON.parse(${str(rich)})`);
3346
+ l.push("");
3347
+ l.push(
3348
+ "/** Add the thin element/region entities (source locations) to a registry. */"
3349
+ );
3350
+ l.push("export function loadLocs(target: Registry): Registry {");
3351
+ l.push(" for (const r of bare)");
3352
+ l.push(
3353
+ " target.add({ kind: kinds[r[0]], id: r[1], loc: loc(r[2], r[3], r[4]) } as Entity)"
3354
+ );
3355
+ l.push(" for (const e of rich) {");
3356
+ l.push(" const out: any = { ...e }");
3357
+ l.push(' if (e.loc && typeof e.loc.f === "number") {');
3358
+ l.push(" const { f, ...rest } = e.loc");
3359
+ l.push(" out.loc = { file: files[f], ...rest }");
3360
+ l.push(" }");
3361
+ l.push(" if (e.meta && Array.isArray(e.meta.composes)) {");
3362
+ l.push(
3363
+ ' out.meta = { ...e.meta, composes: e.meta.composes.map((c: any) => typeof c === "string" ? { id: c, kind: "element" } : c) }'
3364
+ );
3365
+ l.push(" }");
3366
+ l.push(" target.add(out)");
3367
+ l.push(" }");
3368
+ l.push(" return target");
3369
+ l.push("}");
3370
+ l.push("");
3371
+ return l.join("\n");
3372
+ }
2688
3373
  function sortById(arr) {
2689
3374
  return [...arr].sort((a, b) => a.id.localeCompare(b.id));
2690
3375
  }
@@ -2710,6 +3395,20 @@ function emitIdUnion(name, ids) {
2710
3395
  ${body}
2711
3396
  `;
2712
3397
  }
3398
+ var OPAQUE_ID_UNIONS = /* @__PURE__ */ new Set(["ElementId"]);
3399
+ function emitId(name, ids) {
3400
+ if (OPAQUE_ID_UNIONS.has(name)) {
3401
+ return `export type ${name} = string & { readonly __uidex?: ${JSON.stringify(name)} }
3402
+ `;
3403
+ }
3404
+ return emitIdUnion(name, ids);
3405
+ }
3406
+ function dataOutputPath(output) {
3407
+ return output.replace(/\.(d\.ts|ts|tsx|mts|cts|js|mjs|cjs)$/, ".data.$1");
3408
+ }
3409
+ function locsOutputPath(output) {
3410
+ return output.replace(/\.(d\.ts|ts|tsx|mts|cts|js|mjs|cjs)$/, ".locs.$1");
3411
+ }
2713
3412
  function emit(opts) {
2714
3413
  const { registry, gitContext, uidexImport = "uidex" } = opts;
2715
3414
  const routes = [...registry.list("route")].sort(
@@ -2718,112 +3417,131 @@ function emit(opts) {
2718
3417
  const pages = sortById(registry.list("page"));
2719
3418
  const features = sortById(registry.list("feature"));
2720
3419
  const widgets = sortById(registry.list("widget"));
3420
+ const stateNames = /* @__PURE__ */ new Set();
3421
+ for (const e of [...pages, ...features, ...widgets]) {
3422
+ for (const s of e.meta?.states ?? []) stateNames.add(s.name);
3423
+ }
2721
3424
  const regions = sortById(registry.list("region"));
2722
3425
  const elements = sortById(registry.list("element"));
2723
3426
  const primitives = sortById(registry.list("primitive"));
2724
3427
  const flows = sortById(registry.list("flow"));
2725
- const lines = [];
2726
- lines.push("// THIS FILE IS AUTO-GENERATED BY `uidex scan`. DO NOT EDIT.");
2727
- lines.push("/* eslint-disable */");
2728
- lines.push(`import { createUidex } from ${JSON.stringify(uidexImport)}`);
2729
- lines.push(
2730
- `import type { Entity, Registry } from ${JSON.stringify(uidexImport)}`
3428
+ const t = [];
3429
+ t.push("// THIS FILE IS AUTO-GENERATED BY `uidex scan`. DO NOT EDIT.");
3430
+ t.push(
3431
+ "// Types only \u2014 the runtime registry lives in the sibling `.data` module."
2731
3432
  );
2732
- lines.push("");
2733
- lines.push("// ---- id unions ----");
2734
- lines.push(
2735
- emitIdUnion(
3433
+ t.push("/* eslint-disable */");
3434
+ t.push("");
3435
+ t.push("// ---- id unions ----");
3436
+ t.push(
3437
+ emitId(
2736
3438
  "PageId",
2737
3439
  pages.map((e) => e.id)
2738
3440
  )
2739
3441
  );
2740
- lines.push(
2741
- emitIdUnion(
3442
+ t.push(
3443
+ emitId(
2742
3444
  "FeatureId",
2743
3445
  features.map((e) => e.id)
2744
3446
  )
2745
3447
  );
2746
- lines.push(
2747
- emitIdUnion(
3448
+ t.push(
3449
+ emitId(
2748
3450
  "WidgetId",
2749
3451
  widgets.map((e) => e.id)
2750
3452
  )
2751
3453
  );
2752
- lines.push(
2753
- emitIdUnion(
3454
+ t.push(
3455
+ emitId(
2754
3456
  "RegionId",
2755
3457
  regions.map((e) => e.id)
2756
3458
  )
2757
3459
  );
2758
- lines.push(
2759
- emitIdUnion(
3460
+ t.push(
3461
+ emitId(
2760
3462
  "ElementId",
2761
3463
  elements.map((e) => e.id)
2762
3464
  )
2763
3465
  );
2764
- lines.push(
2765
- emitIdUnion(
3466
+ t.push(
3467
+ emitId(
2766
3468
  "PrimitiveId",
2767
3469
  primitives.map((e) => e.id)
2768
3470
  )
2769
3471
  );
2770
- lines.push(
2771
- emitIdUnion(
3472
+ t.push(
3473
+ emitId(
2772
3474
  "FlowId",
2773
3475
  flows.map((e) => e.id)
2774
3476
  )
2775
3477
  );
2776
- lines.push(
2777
- emitIdUnion(
3478
+ t.push(
3479
+ emitId(
2778
3480
  "RouteId",
2779
3481
  routes.map((e) => e.path)
2780
3482
  )
2781
3483
  );
2782
- lines.push("");
2783
- lines.push("// ---- authoring-surface shape types ----");
2784
- lines.push("export namespace Uidex {");
2785
- lines.push(" export interface Page {");
2786
- lines.push(" page: PageId | false");
2787
- lines.push(" name?: string");
2788
- lines.push(" features?: readonly FeatureId[]");
2789
- lines.push(" widgets?: readonly WidgetId[]");
2790
- lines.push(" acceptance?: readonly string[]");
2791
- lines.push(" description?: string");
2792
- lines.push(" }");
2793
- lines.push(" export interface Feature {");
2794
- lines.push(" feature: FeatureId | false");
2795
- lines.push(" name?: string");
2796
- lines.push(" features?: readonly FeatureId[]");
2797
- lines.push(" acceptance?: readonly string[]");
2798
- lines.push(" description?: string");
2799
- lines.push(" }");
2800
- lines.push(" export interface Primitive {");
2801
- lines.push(" primitive: PrimitiveId");
2802
- lines.push(" name?: string");
2803
- lines.push(" description?: string");
2804
- lines.push(" }");
2805
- lines.push(" export interface Widget {");
2806
- lines.push(" widget: WidgetId");
2807
- lines.push(" name?: string");
2808
- lines.push(" acceptance?: readonly string[]");
2809
- lines.push(" description?: string");
2810
- lines.push(" }");
2811
- lines.push(" export interface Region {");
2812
- lines.push(" region: RegionId | false");
2813
- lines.push(" name?: string");
2814
- lines.push(" description?: string");
2815
- lines.push(" }");
2816
- lines.push(" export interface Flow {");
2817
- lines.push(" flow: FlowId");
2818
- lines.push(" name?: string");
2819
- lines.push(" description?: string");
2820
- lines.push(" }");
2821
- lines.push(" export interface NotFlow {");
2822
- lines.push(" notFlow: true");
2823
- lines.push(" }");
2824
- lines.push("}");
2825
- lines.push("");
2826
- lines.push("// ---- entities ----");
3484
+ t.push(emitId("StateId", [...stateNames]));
3485
+ t.push("");
3486
+ t.push("// ---- authoring-surface shape types ----");
3487
+ t.push("export namespace Uidex {");
3488
+ t.push(
3489
+ ' export type CoreStateKind = "loading" | "empty" | "populated" | "error"'
3490
+ );
3491
+ t.push(' export type StateKind = CoreStateKind | "variant"');
3492
+ t.push(" export interface State {");
3493
+ t.push(" name: string");
3494
+ t.push(" kind?: StateKind");
3495
+ t.push(" acceptance?: readonly string[]");
3496
+ t.push(" description?: string");
3497
+ t.push(" }");
3498
+ t.push(" export type Waivers = Partial<Record<CoreStateKind, string>>");
3499
+ t.push(" export interface Page {");
3500
+ t.push(" page: PageId | false");
3501
+ t.push(" name?: string");
3502
+ t.push(" features?: readonly FeatureId[]");
3503
+ t.push(" widgets?: readonly WidgetId[]");
3504
+ t.push(" acceptance?: readonly string[]");
3505
+ t.push(" states?: readonly (string | State)[]");
3506
+ t.push(" capture?: boolean");
3507
+ t.push(" waivers?: Waivers");
3508
+ t.push(" description?: string");
3509
+ t.push(" }");
3510
+ t.push(" export interface Feature {");
3511
+ t.push(" feature: FeatureId | false");
3512
+ t.push(" name?: string");
3513
+ t.push(" features?: readonly FeatureId[]");
3514
+ t.push(" acceptance?: readonly string[]");
3515
+ t.push(" states?: readonly (string | State)[]");
3516
+ t.push(" description?: string");
3517
+ t.push(" }");
3518
+ t.push(" export interface Primitive {");
3519
+ t.push(" primitive: PrimitiveId");
3520
+ t.push(" name?: string");
3521
+ t.push(" description?: string");
3522
+ t.push(" }");
3523
+ t.push(" export interface Widget {");
3524
+ t.push(" widget: WidgetId");
3525
+ t.push(" name?: string");
3526
+ t.push(" acceptance?: readonly string[]");
3527
+ t.push(" states?: readonly (string | State)[]");
3528
+ t.push(" description?: string");
3529
+ t.push(" }");
3530
+ t.push(" export interface Region {");
3531
+ t.push(" region: RegionId | false");
3532
+ t.push(" name?: string");
3533
+ t.push(" description?: string");
3534
+ t.push(" }");
3535
+ t.push(" export interface Flow {");
3536
+ t.push(" flow: FlowId");
3537
+ t.push(" name?: string");
3538
+ t.push(" description?: string");
3539
+ t.push(" }");
3540
+ t.push(" export interface NotFlow {");
3541
+ t.push(" notFlow: true");
3542
+ t.push(" }");
3543
+ t.push("}");
3544
+ t.push("");
2827
3545
  const allEntities = [
2828
3546
  ...routes,
2829
3547
  ...pages,
@@ -2834,25 +3552,39 @@ function emit(opts) {
2834
3552
  ...primitives,
2835
3553
  ...flows
2836
3554
  ];
2837
- lines.push(
2838
- `export const entities: ReadonlyArray<Entity> = ${jsonStable(allEntities)}`
2839
- );
2840
- lines.push("");
2841
- lines.push("// ---- git context ----");
3555
+ const authored = allEntities.filter((e) => AUTHORED_KINDS.has(e.kind));
3556
+ const thin = allEntities.filter((e) => !AUTHORED_KINDS.has(e.kind));
2842
3557
  const gc = gitContext ?? { branch: null, commit: null, pr: null };
2843
- lines.push(`export const gitContext = ${jsonStable(gc)} as const`);
2844
- lines.push("");
2845
- lines.push("// ---- registry factory ----");
2846
- lines.push("export function loadRegistry(target: Registry): Registry {");
2847
- lines.push(" for (const entity of entities) target.add(entity)");
2848
- lines.push(" return target");
2849
- lines.push("}");
2850
- lines.push("");
2851
- lines.push("// ---- preconfigured uidex instance ----");
2852
- lines.push("export const uidex = createUidex()");
2853
- lines.push("for (const entity of entities) uidex.registry.add(entity)");
2854
- lines.push("");
2855
- return lines.join("\n");
3558
+ const authoredPayload = internedPayload(authored);
3559
+ const d = [];
3560
+ d.push("// THIS FILE IS AUTO-GENERATED BY `uidex scan`. DO NOT EDIT.");
3561
+ d.push(
3562
+ "// Authored-metadata registry. The thin element/region entities (source"
3563
+ );
3564
+ d.push(
3565
+ "// locations only) live in the sibling `.locs` module, loaded lazily."
3566
+ );
3567
+ d.push("// @ts-nocheck");
3568
+ d.push("/* eslint-disable */");
3569
+ d.push(`import { createUidex } from ${JSON.stringify(uidexImport)}`);
3570
+ d.push(`import type { Registry } from ${JSON.stringify(uidexImport)}`);
3571
+ d.push("");
3572
+ d.push(authoredPayload.filesDecl);
3573
+ d.push(HYDRATE_FN);
3574
+ d.push(`export const entities = ${authoredPayload.parseExpr}`);
3575
+ d.push("");
3576
+ d.push(`export const gitContext = ${jsonStable(gc)} as const`);
3577
+ d.push("");
3578
+ d.push("export function loadRegistry(target: Registry): Registry {");
3579
+ d.push(" for (const entity of entities) target.add(entity)");
3580
+ d.push(" return target");
3581
+ d.push("}");
3582
+ d.push("");
3583
+ d.push("export const uidex = createUidex()");
3584
+ d.push("for (const entity of entities) uidex.registry.add(entity)");
3585
+ d.push("");
3586
+ const locs = emitLocsModule(thin, uidexImport);
3587
+ return { types: t.join("\n"), data: d.join("\n"), locs };
2856
3588
  }
2857
3589
 
2858
3590
  // src/scanner/scan/git.ts
@@ -2963,6 +3695,68 @@ function renderSpec(args) {
2963
3695
  // src/scanner/scan/pipeline.ts
2964
3696
  var fs4 = __toESM(require("fs"), 1);
2965
3697
  var path7 = __toESM(require("path"), 1);
3698
+ var DEFAULT_STATES_MANIFEST = "uidex-states.json";
3699
+ var DEFAULT_COVERAGE_BASELINE = "uidex-coverage-baseline.json";
3700
+ function loadPageBaseline(configDir, config) {
3701
+ const rel = config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
3702
+ const abs = path7.resolve(configDir, rel);
3703
+ let raw;
3704
+ try {
3705
+ raw = fs4.readFileSync(abs, "utf8");
3706
+ } catch {
3707
+ return void 0;
3708
+ }
3709
+ try {
3710
+ const parsed = JSON.parse(raw);
3711
+ if (!parsed || !Array.isArray(parsed.uncapturedPages)) return void 0;
3712
+ const uncapturedPages = parsed.uncapturedPages.filter(
3713
+ (p2) => typeof p2 === "string"
3714
+ );
3715
+ let missingKinds;
3716
+ const mkRaw = parsed.missingKinds;
3717
+ if (mkRaw && typeof mkRaw === "object" && !Array.isArray(mkRaw)) {
3718
+ const mk = {};
3719
+ for (const [route, kinds] of Object.entries(mkRaw)) {
3720
+ if (Array.isArray(kinds)) {
3721
+ mk[route] = kinds.filter((k) => typeof k === "string");
3722
+ }
3723
+ }
3724
+ missingKinds = mk;
3725
+ }
3726
+ return { uncapturedPages, ...missingKinds ? { missingKinds } : {} };
3727
+ } catch {
3728
+ return void 0;
3729
+ }
3730
+ }
3731
+ function loadCapturedStates(configDir, config) {
3732
+ const rel = config.statesManifest ?? DEFAULT_STATES_MANIFEST;
3733
+ const abs = path7.resolve(configDir, rel);
3734
+ let raw;
3735
+ try {
3736
+ raw = fs4.readFileSync(abs, "utf8");
3737
+ } catch {
3738
+ return void 0;
3739
+ }
3740
+ try {
3741
+ const parsed = JSON.parse(raw);
3742
+ if (!parsed || !Array.isArray(parsed.captured)) return void 0;
3743
+ const captured = [];
3744
+ for (const entry of parsed.captured) {
3745
+ if (!entry || typeof entry !== "object") continue;
3746
+ const c = entry;
3747
+ if (typeof c.entity !== "string" || typeof c.state !== "string") continue;
3748
+ const rec = { entity: c.entity, state: c.state };
3749
+ if (typeof c.kind === "string") rec.kind = c.kind;
3750
+ if (typeof c.stateKind === "string") rec.stateKind = c.stateKind;
3751
+ if (typeof c.url === "string") rec.url = c.url;
3752
+ if (typeof c.route === "string") rec.route = c.route;
3753
+ captured.push(rec);
3754
+ }
3755
+ return { captured };
3756
+ } catch {
3757
+ return void 0;
3758
+ }
3759
+ }
2966
3760
  function runScan(opts = {}) {
2967
3761
  const cwd = opts.cwd ?? process.cwd();
2968
3762
  const configs = opts.configs ?? discover({ cwd });
@@ -2989,23 +3783,29 @@ function runOne(dc, opts) {
2989
3783
  flowFiles: extractedFlows
2990
3784
  });
2991
3785
  const gitContext = resolveGitContext({ cwd: configDir });
2992
- const generated = emit({
3786
+ const emitted = emit({
2993
3787
  registry: resolved.registry,
2994
3788
  gitContext
2995
3789
  });
2996
- const outputPath = path7.resolve(configDir, config.output);
2997
- const outputRel = config.output;
2998
- let existingOnDisk = null;
3790
+ const typesRel = config.output;
3791
+ const typesPath = path7.resolve(configDir, typesRel);
3792
+ const dataRel = dataOutputPath(config.output);
3793
+ const dataPath = path7.resolve(configDir, dataRel);
3794
+ const locsRel = locsOutputPath(config.output);
3795
+ const locsPath = path7.resolve(configDir, locsRel);
3796
+ let typesOnDisk = null;
3797
+ let dataOnDisk = null;
3798
+ let locsOnDisk = null;
2999
3799
  if (opts.check) {
3000
- try {
3001
- existingOnDisk = fs4.readFileSync(outputPath, "utf8");
3002
- } catch {
3003
- existingOnDisk = null;
3004
- }
3800
+ typesOnDisk = readOrNull(typesPath);
3801
+ dataOnDisk = readOrNull(dataPath);
3802
+ locsOnDisk = readOrNull(locsPath);
3005
3803
  }
3006
3804
  const hasExtractDiagnostics = [...extracted, ...extractedFlows].some(
3007
3805
  (ef) => (ef.diagnostics?.length ?? 0) > 0
3008
3806
  );
3807
+ const capturedStates = opts.capturedStates ?? loadCapturedStates(configDir, config);
3808
+ const pageBaseline = opts.pageBaseline ?? loadPageBaseline(configDir, config);
3009
3809
  let auditResult;
3010
3810
  if (opts.check || opts.lint || resolved.diagnostics.length > 0 || hasExtractDiagnostics) {
3011
3811
  auditResult = audit({
@@ -3017,9 +3817,20 @@ function runOne(dc, opts) {
3017
3817
  check: opts.check,
3018
3818
  lint: opts.lint,
3019
3819
  resolveDiagnostics: resolved.diagnostics,
3020
- generated,
3021
- existingOnDisk,
3022
- outputPath: outputRel
3820
+ // The entity array (and thus the meaningful drift) lives in the data file,
3821
+ // so the entity-aware gen-stale diff runs against it; the types file gets a
3822
+ // plain content compare (see audit()).
3823
+ generated: emitted.data,
3824
+ existingOnDisk: dataOnDisk,
3825
+ outputPath: dataRel,
3826
+ typesGenerated: emitted.types,
3827
+ typesOnDisk,
3828
+ typesOutputPath: typesRel,
3829
+ locsGenerated: emitted.locs,
3830
+ locsOnDisk,
3831
+ locsOutputPath: locsRel,
3832
+ capturedStates,
3833
+ pageBaseline
3023
3834
  });
3024
3835
  }
3025
3836
  return {
@@ -3028,13 +3839,38 @@ function runOne(dc, opts) {
3028
3839
  registry: resolved.registry,
3029
3840
  gitContext,
3030
3841
  audit: auditResult,
3031
- generated,
3032
- outputPath
3842
+ types: {
3843
+ generated: emitted.types,
3844
+ outputPath: typesPath,
3845
+ outputRel: typesRel
3846
+ },
3847
+ data: { generated: emitted.data, outputPath: dataPath, outputRel: dataRel },
3848
+ locs: { generated: emitted.locs, outputPath: locsPath, outputRel: locsRel },
3849
+ capturedStates
3033
3850
  };
3034
3851
  }
3852
+ function readOrNull(p2) {
3853
+ try {
3854
+ return fs4.readFileSync(p2, "utf8");
3855
+ } catch {
3856
+ return null;
3857
+ }
3858
+ }
3859
+ function writeArtifact(artifact) {
3860
+ if (readOrNull(artifact.outputPath) === artifact.generated) return false;
3861
+ fs4.mkdirSync(path7.dirname(artifact.outputPath), { recursive: true });
3862
+ fs4.writeFileSync(artifact.outputPath, artifact.generated, "utf8");
3863
+ return true;
3864
+ }
3035
3865
  function writeScanResult(result) {
3036
- fs4.mkdirSync(path7.dirname(result.outputPath), { recursive: true });
3037
- fs4.writeFileSync(result.outputPath, result.generated, "utf8");
3866
+ const wroteTypes = writeArtifact(result.types);
3867
+ const wroteData = writeArtifact(result.data);
3868
+ const wroteLocs = writeArtifact(result.locs);
3869
+ return wroteTypes || wroteData || wroteLocs;
3870
+ }
3871
+ function pageBaselinePath(result) {
3872
+ const rel = result.config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
3873
+ return path7.resolve(result.configDir, rel);
3038
3874
  }
3039
3875
 
3040
3876
  // src/scanner/scan/fix.ts
@@ -3607,16 +4443,13 @@ function helpText2() {
3607
4443
  " scaffold <widget|page|feature> <id> Emit a Playwright spec from declared acceptance",
3608
4444
  " rename <element|widget|region> <old-id> <new-id> Rename an id everywhere (DOM attr, flows, exports)",
3609
4445
  " ai <install|uninstall|providers> Manage AI assistant integrations",
3610
- " api <METHOD> <PATH> Call the uidex API",
3611
- " api --list Show available API routes",
3612
- " api login Authenticate via browser",
3613
- " api login --token <tok> Store an auth token directly",
3614
4446
  "",
3615
4447
  "Flags:",
3616
4448
  " --check Verify the on-disk gen file matches a fresh scan; exit non-zero on drift (read-only)",
3617
4449
  " --lint Run lint diagnostics (missing annotations, scope leak, duplicate ids, coverage)",
3618
4450
  " --audit Equivalent to --check --lint (read-only)",
3619
4451
  " --fix Apply machine-generated fixes (add data-uidex to unannotated interactive elements, drop empty names), then rescan and write",
4452
+ " --update-baseline Regenerate the page-coverage baseline (uidex-coverage-baseline.json) from the current uncaptured routes",
3620
4453
  " --json Emit JSON diagnostics on stdout",
3621
4454
  " --force (scaffold) overwrite existing spec",
3622
4455
  ""
@@ -3636,7 +4469,7 @@ function runInit(cwd, w) {
3636
4469
  fs8.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
3637
4470
  w.out(`Created ${configPath}`);
3638
4471
  const gitignorePath = path11.join(cwd, ".gitignore");
3639
- const entry = "*.gen.ts";
4472
+ const entry = "*.gen.*";
3640
4473
  if (fs8.existsSync(gitignorePath)) {
3641
4474
  const existing = fs8.readFileSync(gitignorePath, "utf8");
3642
4475
  const hasEntry = existing.split("\n").some((line) => line.trim() === entry);
@@ -3659,8 +4492,9 @@ function runInit(cwd, w) {
3659
4492
  }
3660
4493
  function runScanCommand(cwd, flags, w) {
3661
4494
  const fix = Boolean(flags.fix);
3662
- const check = !fix && Boolean(flags.check || flags.audit);
3663
- const lint = Boolean(flags.lint || flags.audit || fix);
4495
+ const updateBaseline = Boolean(flags["update-baseline"]);
4496
+ const check = !fix && !updateBaseline && Boolean(flags.check || flags.audit);
4497
+ const lint = Boolean(flags.lint || flags.audit || fix || updateBaseline);
3664
4498
  const asJson = Boolean(flags.json);
3665
4499
  let configs = discover({ cwd });
3666
4500
  if (configs.length === 0) {
@@ -3679,8 +4513,33 @@ function runScanCommand(cwd, flags, w) {
3679
4513
  configs = discover({ cwd });
3680
4514
  }
3681
4515
  const results = runScan({ cwd, check, lint, configs });
4516
+ const artifacts = results.flatMap((r) => [r.types, r.data, r.locs]);
4517
+ const wrote = /* @__PURE__ */ new Set();
3682
4518
  if (!check) {
3683
- for (const r of results) writeScanResult(r);
4519
+ for (const a of artifacts) {
4520
+ if (writeArtifact(a)) wrote.add(a);
4521
+ }
4522
+ }
4523
+ if (updateBaseline) {
4524
+ for (const r of results) {
4525
+ const manifest = r.capturedStates ?? { captured: [] };
4526
+ const pageBaseline = computePageBaseline(r.registry, manifest);
4527
+ const missingKinds = computeMissingKinds(r.registry, manifest);
4528
+ const baseline = {
4529
+ ...pageBaseline,
4530
+ ...Object.keys(missingKinds).length > 0 ? { missingKinds } : {}
4531
+ };
4532
+ const p2 = pageBaselinePath(r);
4533
+ fs8.writeFileSync(p2, JSON.stringify(baseline, null, 2) + "\n");
4534
+ const kindGaps = Object.values(missingKinds).reduce(
4535
+ (n, ks) => n + ks.length,
4536
+ 0
4537
+ );
4538
+ w.out(
4539
+ `Wrote ${p2} \u2014 ${baseline.uncapturedPages.length} uncaptured page(s), ${kindGaps} missing core-kind(s) baselined`
4540
+ );
4541
+ }
4542
+ return w.result(0);
3684
4543
  }
3685
4544
  const allDiagnostics = results.flatMap((r) => r.audit?.diagnostics ?? []);
3686
4545
  const summary = results.reduce(
@@ -3708,10 +4567,14 @@ function runScanCommand(cwd, flags, w) {
3708
4567
  );
3709
4568
  }
3710
4569
  for (const r of results) {
3711
- if (check) {
3712
- w.out(`Checked ${r.outputPath}`);
3713
- } else {
3714
- w.out(`Wrote ${r.outputPath}`);
4570
+ for (const art of [r.types, r.data, r.locs]) {
4571
+ if (check) {
4572
+ w.out(`Checked ${art.outputPath}`);
4573
+ } else if (wrote.has(art)) {
4574
+ w.out(`Wrote ${art.outputPath}`);
4575
+ } else {
4576
+ w.out(`Unchanged ${art.outputPath}`);
4577
+ }
3715
4578
  }
3716
4579
  for (const d of r.audit?.diagnostics ?? []) {
3717
4580
  const loc = d.file ? `${d.file}${d.line ? `:${d.line}` : ""}` : "";
@@ -3820,12 +4683,21 @@ function createWriter() {
3820
4683
  DEFAULT_CONVENTIONS,
3821
4684
  applyFixes,
3822
4685
  audit,
4686
+ checkPageCoverage,
4687
+ checkStateCoverage,
4688
+ checkStateMatrix,
4689
+ compileRoute,
4690
+ computeMissingKinds,
4691
+ computePageBaseline,
3823
4692
  detectRoutes,
3824
4693
  discover,
3825
4694
  emit,
3826
4695
  extract,
3827
4696
  extractUidexExports,
3828
4697
  globToRegExp,
4698
+ loadPageBaseline,
4699
+ matchUrlToRoute,
4700
+ pageBaselinePath,
3829
4701
  parseConfig,
3830
4702
  pathToId,
3831
4703
  renameEntity,