uidex 0.7.0 → 0.9.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 +1112 -1054
  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 +1292 -345
  40. package/dist/scan/index.cjs.map +1 -1
  41. package/dist/scan/index.d.cts +305 -12
  42. package/dist/scan/index.d.ts +305 -12
  43. package/dist/scan/index.js +1283 -345
  44. package/dist/scan/index.js.map +1 -1
  45. package/package.json +12 -16
  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)) {
@@ -178,7 +202,13 @@ function validateConfig(raw) {
178
202
  fail(`conventions.features must be a string or false`);
179
203
  }
180
204
  if (c.pages !== void 0 && c.pages !== false && c.pages !== "auto") {
181
- fail(`conventions.pages must be "auto" or false`);
205
+ const p2 = c.pages;
206
+ const routesDir = typeof p2 === "object" && p2 !== null && !Array.isArray(p2) ? p2.routesDir : void 0;
207
+ if (typeof p2 !== "object" || p2 === null || Array.isArray(p2) || Object.keys(p2).some((k) => k !== "routesDir") || typeof routesDir !== "string" || routesDir.length === 0) {
208
+ fail(
209
+ `conventions.pages must be "auto", false, or { routesDir: string }`
210
+ );
211
+ }
182
212
  }
183
213
  if (c.flows !== void 0 && c.flows !== false) {
184
214
  assertStringArray(c.flows, "conventions.flows");
@@ -194,7 +224,9 @@ function validateConfig(raw) {
194
224
  output: raw.output,
195
225
  flows: raw.flows,
196
226
  audit: raw.audit,
197
- conventions: raw.conventions
227
+ conventions: raw.conventions,
228
+ statesManifest: raw.statesManifest,
229
+ coverageBaseline: raw.coverageBaseline
198
230
  };
199
231
  }
200
232
  function parseConfig(json) {
@@ -283,7 +315,12 @@ var BASE_EXCLUDES = [
283
315
  "**/dist/**",
284
316
  "**/build/**",
285
317
  "**/.next/**",
286
- "**/*.gen.ts"
318
+ // Exclude the scanner's own generated siblings so a scan never re-ingests its
319
+ // output. Enumerated (not `*.gen.*`) so a hand-written file that merely contains
320
+ // a `.gen.` segment, e.g. `foo.gen.helpers.ts`, is still scanned.
321
+ "**/*.gen.ts",
322
+ "**/*.gen.data.ts",
323
+ "**/*.gen.locs.ts"
287
324
  ];
288
325
  var TEST_EXCLUDES = [
289
326
  "**/*.test.ts",
@@ -483,6 +520,201 @@ function unwrapTsExpression(expr) {
483
520
  }
484
521
  }
485
522
 
523
+ // src/shared/entities/types.ts
524
+ var ENTITY_KINDS = [
525
+ "route",
526
+ "page",
527
+ "feature",
528
+ "widget",
529
+ "region",
530
+ "element",
531
+ "primitive",
532
+ "flow"
533
+ ];
534
+ var CORE_STATE_KINDS = [
535
+ "loading",
536
+ "empty",
537
+ "populated",
538
+ "error"
539
+ ];
540
+ function isMetaKind(kind) {
541
+ return kind !== "route" && kind !== "flow";
542
+ }
543
+ function isMetaEntity(entity) {
544
+ return isMetaKind(entity.kind);
545
+ }
546
+ function entityKey(entity) {
547
+ return entity.kind === "route" ? entity.path : entity.id;
548
+ }
549
+ var UnknownEntityKindError = class extends Error {
550
+ kind;
551
+ constructor(kind) {
552
+ super(`Unknown entity kind: ${kind}`);
553
+ this.name = "UnknownEntityKindError";
554
+ this.kind = kind;
555
+ }
556
+ };
557
+ var KIND_SET = new Set(ENTITY_KINDS);
558
+ function assertEntityKind(kind) {
559
+ if (!KIND_SET.has(kind)) throw new UnknownEntityKindError(kind);
560
+ }
561
+
562
+ // src/shared/entities/registry.ts
563
+ function emptyStore() {
564
+ return {
565
+ route: /* @__PURE__ */ new Map(),
566
+ page: /* @__PURE__ */ new Map(),
567
+ feature: /* @__PURE__ */ new Map(),
568
+ widget: /* @__PURE__ */ new Map(),
569
+ region: /* @__PURE__ */ new Map(),
570
+ element: /* @__PURE__ */ new Map(),
571
+ primitive: /* @__PURE__ */ new Map(),
572
+ flow: /* @__PURE__ */ new Map()
573
+ };
574
+ }
575
+ function computeFlowIds(flows, targetId) {
576
+ const ids = [];
577
+ for (const flow of flows) {
578
+ if (flow.touches.includes(targetId)) ids.push(flow.id);
579
+ }
580
+ return ids;
581
+ }
582
+ function freezeEntity(entity, flows) {
583
+ if (!isMetaKind(entity.kind)) return entity;
584
+ const withMeta = entity;
585
+ if (withMeta.meta === void 0) return entity;
586
+ const computedFlows = Object.freeze(computeFlowIds(flows, withMeta.id));
587
+ const mergedMeta = { ...withMeta.meta, flows: computedFlows };
588
+ return { ...entity, meta: Object.freeze(mergedMeta) };
589
+ }
590
+ function createRegistry() {
591
+ const store = emptyStore();
592
+ let flowsCache = null;
593
+ const patternCache = /* @__PURE__ */ new Map();
594
+ const getFlows = () => {
595
+ if (flowsCache === null) flowsCache = Array.from(store.flow.values());
596
+ return flowsCache;
597
+ };
598
+ const add = (entity) => {
599
+ assertEntityKind(entity.kind);
600
+ const key = entityKey(entity);
601
+ store[entity.kind].set(key, entity);
602
+ flowsCache = null;
603
+ patternCache.delete(entity.kind);
604
+ };
605
+ const get = (kind, id) => {
606
+ assertEntityKind(kind);
607
+ const raw = store[kind].get(id);
608
+ if (raw === void 0) return void 0;
609
+ return freezeEntity(raw, getFlows());
610
+ };
611
+ const getPatternsForKind = (kind) => {
612
+ const cached = patternCache.get(kind);
613
+ if (cached !== void 0) return cached;
614
+ const patterns = [];
615
+ for (const [key, entity] of store[kind]) {
616
+ if (key.includes("*")) {
617
+ const segments = key.split("*");
618
+ patterns.push({
619
+ segments,
620
+ staticLength: segments.reduce((n, s) => n + s.length, 0),
621
+ entity
622
+ });
623
+ }
624
+ }
625
+ patternCache.set(
626
+ kind,
627
+ patterns
628
+ );
629
+ return patterns;
630
+ };
631
+ const matchesSegments = (segments, id) => {
632
+ const first = segments[0];
633
+ const last = segments[segments.length - 1];
634
+ if (!id.startsWith(first)) return false;
635
+ let pos = first.length;
636
+ for (let i = 1; i < segments.length - 1; i++) {
637
+ const idx = id.indexOf(segments[i], pos);
638
+ if (idx === -1) return false;
639
+ pos = idx + segments[i].length;
640
+ }
641
+ return id.endsWith(last) && id.length - last.length >= pos;
642
+ };
643
+ const matchPattern = (kind, id) => {
644
+ assertEntityKind(kind);
645
+ const patterns = getPatternsForKind(kind);
646
+ if (patterns.length === 0) return void 0;
647
+ let best;
648
+ for (const entry of patterns) {
649
+ if (matchesSegments(entry.segments, id) && (best === void 0 || entry.staticLength > best.staticLength)) {
650
+ best = entry;
651
+ }
652
+ }
653
+ if (best === void 0) return void 0;
654
+ return freezeEntity(best.entity, getFlows());
655
+ };
656
+ const list = (kind) => {
657
+ assertEntityKind(kind);
658
+ const flows = getFlows();
659
+ return Array.from(
660
+ store[kind].values(),
661
+ (e) => freezeEntity(e, flows)
662
+ );
663
+ };
664
+ const allEntities = function* () {
665
+ for (const kind of Object.keys(store)) {
666
+ for (const entity of store[kind].values()) {
667
+ yield entity;
668
+ }
669
+ }
670
+ };
671
+ const query = (predicate) => {
672
+ const flows = getFlows();
673
+ const result = [];
674
+ for (const entity of allEntities()) {
675
+ if (predicate(entity)) result.push(freezeEntity(entity, flows));
676
+ }
677
+ return result;
678
+ };
679
+ const byScope = (scope) => query(
680
+ (entity) => "scopes" in entity && Array.isArray(entity.scopes) && entity.scopes.includes(scope)
681
+ );
682
+ const touchedBy = (flowId) => {
683
+ const flow = store.flow.get(flowId);
684
+ if (flow === void 0) return [];
685
+ const ids = new Set(flow.touches);
686
+ return query((entity) => {
687
+ if (!isMetaEntity(entity)) return false;
688
+ return ids.has(entity.id);
689
+ });
690
+ };
691
+ const reports = /* @__PURE__ */ new Map();
692
+ const reportsCbs = /* @__PURE__ */ new Set();
693
+ const setReports = (kind, id, records) => {
694
+ reports.set(`${kind}:${id}`, records);
695
+ for (const cb of reportsCbs) cb();
696
+ };
697
+ const getReports = (kind, id) => reports.get(`${kind}:${id}`) ?? [];
698
+ const listReportKeys = () => Array.from(reports.keys());
699
+ const onReportsChange = (cb) => {
700
+ reportsCbs.add(cb);
701
+ return () => reportsCbs.delete(cb);
702
+ };
703
+ return {
704
+ add,
705
+ get,
706
+ matchPattern,
707
+ list,
708
+ query,
709
+ byScope,
710
+ touchedBy,
711
+ setReports,
712
+ getReports,
713
+ listReportKeys,
714
+ onReportsChange
715
+ };
716
+ }
717
+
486
718
  // src/scanner/scan/extract-uidex-export.ts
487
719
  var KIND_DISCRIMINATORS = [
488
720
  "page",
@@ -500,20 +732,35 @@ var ALLOWED_FIELDS = {
500
732
  "features",
501
733
  "widgets",
502
734
  "acceptance",
735
+ "states",
736
+ "capture",
737
+ "waivers",
503
738
  "description"
504
739
  ]),
740
+ // `capture` / `waivers` are route-scoped, and only a page maps to a route — so
741
+ // they are page-only (the gates read them off the page). A feature/widget that
742
+ // sets them gets an `uidex-export-unknown-field` error rather than a silent no-op.
505
743
  feature: /* @__PURE__ */ new Set([
506
744
  "feature",
507
745
  "name",
508
746
  "features",
509
747
  "acceptance",
748
+ "states",
510
749
  "description"
511
750
  ]),
512
751
  primitive: /* @__PURE__ */ new Set(["primitive", "name", "description"]),
513
- widget: /* @__PURE__ */ new Set(["widget", "name", "acceptance", "description"]),
752
+ widget: /* @__PURE__ */ new Set(["widget", "name", "acceptance", "states", "description"]),
514
753
  region: /* @__PURE__ */ new Set(["region", "name", "description"]),
515
754
  flow: /* @__PURE__ */ new Set(["flow", "notFlow", "name", "description"])
516
755
  };
756
+ var STATE_FIELDS = /* @__PURE__ */ new Set([
757
+ "name",
758
+ "kind",
759
+ "acceptance",
760
+ "description"
761
+ ]);
762
+ var CORE_STATE_KINDS2 = new Set(CORE_STATE_KINDS);
763
+ var STATE_KINDS = /* @__PURE__ */ new Set([...CORE_STATE_KINDS, "variant"]);
517
764
  var FALSEABLE = /* @__PURE__ */ new Set([
518
765
  "page",
519
766
  "feature",
@@ -903,6 +1150,9 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
903
1150
  }
904
1151
  const featuresField = kind === "page" || kind === "feature" ? readStringArrayField(byKey, "features") : void 0;
905
1152
  const widgetsField = kind === "page" ? readStringArrayField(byKey, "widgets") : void 0;
1153
+ const states = kind === "page" || kind === "feature" || kind === "widget" ? readStatesField(byKey) : void 0;
1154
+ const capture = kind === "page" ? readBooleanField(byKey, "capture") : void 0;
1155
+ const waivers = kind === "page" ? readWaiversField(byKey) : void 0;
906
1156
  const notFlow = kind === "flow" && discriminator === "notFlow" ? true : void 0;
907
1157
  const metadata = {
908
1158
  source: "ts-export",
@@ -925,6 +1175,9 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
925
1175
  metadata.widgets = widgetsField.values;
926
1176
  metadata.widgetSpans = widgetsField.spans;
927
1177
  }
1178
+ if (states?.length) metadata.states = states;
1179
+ if (capture !== void 0) metadata.capture = capture;
1180
+ if (waivers && Object.keys(waivers).length > 0) metadata.waivers = waivers;
928
1181
  if (notFlow) metadata.notFlow = true;
929
1182
  if (typeof id === "string" && idValue.kind === "string") {
930
1183
  metadata.idSpan = idValue.span;
@@ -961,6 +1214,48 @@ function readIdField(value, kind, fieldName) {
961
1214
  value.pos
962
1215
  );
963
1216
  }
1217
+ function readWaiversField(byKey) {
1218
+ const entry = byKey.get("waivers");
1219
+ if (!entry) return void 0;
1220
+ if (entry.value.kind !== "object") {
1221
+ throw new ExtractError(
1222
+ "uidex-export-invalid-field",
1223
+ "`waivers` must be an object mapping a core kind to a reason.",
1224
+ entry.value.pos
1225
+ );
1226
+ }
1227
+ const waivers = {};
1228
+ for (const e of entry.value.entries) {
1229
+ if (!CORE_STATE_KINDS2.has(e.key)) {
1230
+ throw new ExtractError(
1231
+ "uidex-export-invalid-field",
1232
+ `waiver key "${e.key}" is not a core kind; only ${[...CORE_STATE_KINDS2].join(", ")} participate in the matrix.`,
1233
+ e.keyPos
1234
+ );
1235
+ }
1236
+ if (e.value.kind !== "string" || e.value.value.length === 0) {
1237
+ throw new ExtractError(
1238
+ "uidex-export-invalid-field",
1239
+ `waiver "${e.key}" needs a non-empty reason (why the route cannot render this kind).`,
1240
+ e.value.pos
1241
+ );
1242
+ }
1243
+ waivers[e.key] = e.value.value;
1244
+ }
1245
+ return waivers;
1246
+ }
1247
+ function readBooleanField(byKey, name) {
1248
+ const entry = byKey.get(name);
1249
+ if (!entry) return void 0;
1250
+ if (entry.value.kind !== "boolean") {
1251
+ throw new ExtractError(
1252
+ "uidex-export-invalid-field",
1253
+ `\`${name}\` must be a boolean.`,
1254
+ entry.value.pos
1255
+ );
1256
+ }
1257
+ return entry.value.value;
1258
+ }
964
1259
  function readStringField(byKey, name) {
965
1260
  const entry = byKey.get(name);
966
1261
  if (!entry) return void 0;
@@ -998,6 +1293,86 @@ function readStringArrayField(byKey, name) {
998
1293
  }
999
1294
  return { values, spans };
1000
1295
  }
1296
+ function readStatesField(byKey) {
1297
+ const entry = byKey.get("states");
1298
+ if (!entry) return void 0;
1299
+ if (entry.value.kind !== "array") {
1300
+ throw new ExtractError(
1301
+ "uidex-export-invalid-field",
1302
+ "`states` must be an array of state names or `{ name, acceptance?, description? }` objects.",
1303
+ entry.value.pos
1304
+ );
1305
+ }
1306
+ const states = [];
1307
+ const seen = /* @__PURE__ */ new Set();
1308
+ for (const item of entry.value.items) {
1309
+ let state;
1310
+ if (item.kind === "string") {
1311
+ state = { name: item.value, nameSpan: item.span };
1312
+ } else if (item.kind === "object") {
1313
+ const fieldByKey = /* @__PURE__ */ new Map();
1314
+ for (const e of item.entries) fieldByKey.set(e.key, e);
1315
+ for (const e of item.entries) {
1316
+ if (!STATE_FIELDS.has(e.key)) {
1317
+ throw new ExtractError(
1318
+ "uidex-export-invalid-field",
1319
+ `Unknown field "${e.key}" in a \`states\` entry. Allowed: ${Array.from(
1320
+ STATE_FIELDS
1321
+ ).sort().join(", ")}.`,
1322
+ e.value.pos
1323
+ );
1324
+ }
1325
+ }
1326
+ const nameEntry = fieldByKey.get("name");
1327
+ if (!nameEntry || nameEntry.value.kind !== "string") {
1328
+ throw new ExtractError(
1329
+ "uidex-export-invalid-field",
1330
+ "Each object `states` entry must have a string `name`.",
1331
+ item.pos
1332
+ );
1333
+ }
1334
+ state = { name: nameEntry.value.value, nameSpan: nameEntry.value.span };
1335
+ const kind = readStringField(fieldByKey, "kind");
1336
+ if (kind !== void 0) {
1337
+ if (!STATE_KINDS.has(kind)) {
1338
+ throw new ExtractError(
1339
+ "uidex-export-invalid-field",
1340
+ `state \`kind\` must be one of ${[...STATE_KINDS].join(", ")} (got "${kind}").`,
1341
+ fieldByKey.get("kind").value.pos
1342
+ );
1343
+ }
1344
+ state.kind = kind;
1345
+ }
1346
+ const acceptance = readStringArrayField(fieldByKey, "acceptance")?.values;
1347
+ if (acceptance) state.acceptance = acceptance;
1348
+ const description = readStringField(fieldByKey, "description");
1349
+ if (description) state.description = description;
1350
+ } else {
1351
+ throw new ExtractError(
1352
+ "uidex-export-invalid-field",
1353
+ "`states` items must be strings or `{ name, ... }` objects.",
1354
+ item.pos
1355
+ );
1356
+ }
1357
+ if (state.name.length === 0) {
1358
+ throw new ExtractError(
1359
+ "uidex-export-invalid-field",
1360
+ "A `states` entry has an empty `name`.",
1361
+ item.pos
1362
+ );
1363
+ }
1364
+ if (seen.has(state.name)) {
1365
+ throw new ExtractError(
1366
+ "uidex-export-duplicate-state",
1367
+ `Duplicate state "${state.name}" in \`states\`; each state name must be unique within the entity.`,
1368
+ item.pos
1369
+ );
1370
+ }
1371
+ seen.add(state.name);
1372
+ states.push(state);
1373
+ }
1374
+ return states;
1375
+ }
1001
1376
  function posAt(content, offset, p2) {
1002
1377
  const lineStart = content.lastIndexOf("\n", offset - 1) + 1;
1003
1378
  return { offset, line: p2.lineAt(offset), column: offset - lineStart + 1 };
@@ -1489,231 +1864,55 @@ function collectImportFacts(parsed) {
1489
1864
  for (const spec of stmt.specifiers ?? []) {
1490
1865
  const local = spec.local;
1491
1866
  if (local && local.type === "Identifier") {
1492
- names.push(String(local.name));
1493
- }
1494
- }
1495
- } else if ((stmt.type === "ExportNamedDeclaration" || stmt.type === "ExportAllDeclaration") && stmt.source) {
1496
- source = stmt.source;
1497
- isTypeOnly = stmt.exportKind === "type";
1498
- } else {
1499
- continue;
1500
- }
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);
1677
- });
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
- };
1867
+ names.push(String(local.name));
1868
+ }
1869
+ }
1870
+ } else if ((stmt.type === "ExportNamedDeclaration" || stmt.type === "ExportAllDeclaration") && stmt.source) {
1871
+ source = stmt.source;
1872
+ isTypeOnly = stmt.exportKind === "type";
1873
+ } else {
1874
+ continue;
1875
+ }
1876
+ if (!source || source.type !== "Literal") continue;
1877
+ if (typeof source.value !== "string") continue;
1878
+ out2.push({
1879
+ specifier: source.value,
1880
+ line: parsed.lineAt(stmt.start),
1881
+ span: { start: stmt.start, end: stmt.end },
1882
+ isTypeOnly,
1883
+ names
1884
+ });
1885
+ }
1886
+ return out2;
1704
1887
  }
1705
1888
 
1889
+ // src/scanner/scan/resolve.ts
1890
+ var path4 = __toESM(require("path"), 1);
1891
+
1706
1892
  // src/scanner/scan/routes.ts
1707
1893
  var PAGE_BASENAME = /^page\.(tsx|ts|jsx|js|mjs|cjs)$/;
1708
1894
  var PAGES_ROUTER_BASENAME = /\.(tsx|ts|jsx|js|mjs|cjs)$/;
1709
1895
  var ROUTE_BASENAME = /^route\.(tsx|ts|jsx|js|mjs|cjs)$/;
1710
- function detectRoutes(files) {
1896
+ function detectRoutes(files, options = {}) {
1711
1897
  const out2 = [];
1712
1898
  const seen = /* @__PURE__ */ new Set();
1899
+ const routesDir = options.routesDir ? options.routesDir.replace(/^\.?\/+/, "").replace(/\/+$/, "") : null;
1900
+ const routesDirParts = routesDir ? routesDir.split("/") : null;
1713
1901
  for (const f of files) {
1714
1902
  const rel = f.displayPath;
1715
1903
  const parts = rel.split("/");
1716
1904
  const base = parts[parts.length - 1];
1905
+ if (routesDirParts) {
1906
+ const end = dirEndIndex(parts, routesDirParts);
1907
+ if (end === -1 || end > parts.length - 1) continue;
1908
+ if (!PAGES_ROUTER_BASENAME.test(base)) continue;
1909
+ const routePath = routesStylePath(
1910
+ parts.slice(end, parts.length - 1),
1911
+ base
1912
+ );
1913
+ if (routePath !== null) push(out2, seen, routePath, f.displayPath);
1914
+ continue;
1915
+ }
1717
1916
  const appIdx = parts.indexOf("app");
1718
1917
  if (appIdx !== -1 && PAGE_BASENAME.test(base)) {
1719
1918
  const routeSegments = parts.slice(appIdx + 1, parts.length - 1);
@@ -1740,15 +1939,11 @@ function detectRoutes(files) {
1740
1939
  }
1741
1940
  const routesIdx = parts.indexOf("routes");
1742
1941
  if (routesIdx !== -1 && PAGES_ROUTER_BASENAME.test(base)) {
1743
- const segs = parts.slice(routesIdx + 1);
1744
- const last = segs[segs.length - 1];
1745
- if (last.startsWith("_")) continue;
1746
- const normalized = [
1747
- ...segs.slice(0, -1),
1748
- base.replace(/\.[^.]+$/, "")
1749
- ].filter((s) => s !== "index" && s !== "__root");
1750
- const routePath = formatNextAppPath(normalized);
1751
- push(out2, seen, routePath, f.displayPath);
1942
+ const routePath = routesStylePath(
1943
+ parts.slice(routesIdx + 1, parts.length - 1),
1944
+ base
1945
+ );
1946
+ if (routePath !== null) push(out2, seen, routePath, f.displayPath);
1752
1947
  continue;
1753
1948
  }
1754
1949
  }
@@ -1764,6 +1959,63 @@ function formatNextAppPath(segments) {
1764
1959
  if (kept.length === 0) return "/";
1765
1960
  return "/" + kept.join("/");
1766
1961
  }
1962
+ function dirEndIndex(parts, dirParts) {
1963
+ if (dirParts.length === 0) return 0;
1964
+ for (let i = 0; i + dirParts.length <= parts.length; i++) {
1965
+ let match = true;
1966
+ for (let j = 0; j < dirParts.length; j++) {
1967
+ if (parts[i + j] !== dirParts[j]) {
1968
+ match = false;
1969
+ break;
1970
+ }
1971
+ }
1972
+ if (match) return i + dirParts.length;
1973
+ }
1974
+ return -1;
1975
+ }
1976
+ function routesStylePath(dirSegs, base) {
1977
+ const fileSegs = splitFlatSegments(base.replace(/\.[^.]+$/, ""));
1978
+ const leaf = fileSegs[fileSegs.length - 1];
1979
+ if (leaf.startsWith("_")) return null;
1980
+ const rawSegs = [...dirSegs, ...fileSegs];
1981
+ if (rawSegs.some((s) => s.startsWith("-"))) return null;
1982
+ if (rawSegs[0] === "api") return null;
1983
+ return formatTanStackPath(rawSegs);
1984
+ }
1985
+ function splitFlatSegments(name) {
1986
+ const segs = [];
1987
+ let cur = "";
1988
+ let depth = 0;
1989
+ for (const ch of name) {
1990
+ if (ch === "[") depth++;
1991
+ else if (ch === "]") depth = Math.max(0, depth - 1);
1992
+ if (ch === "." && depth === 0) {
1993
+ segs.push(cur);
1994
+ cur = "";
1995
+ continue;
1996
+ }
1997
+ cur += ch;
1998
+ }
1999
+ segs.push(cur);
2000
+ return segs;
2001
+ }
2002
+ function formatTanStackPath(segments) {
2003
+ const kept = [];
2004
+ for (const seg of segments) {
2005
+ if (!seg) continue;
2006
+ if (seg === "index" || seg === "route" || seg === "__root") continue;
2007
+ if (seg.startsWith("_")) continue;
2008
+ if (seg.startsWith("(") && seg.endsWith(")")) continue;
2009
+ kept.push(normalizeTanStackSegment(seg.replace(/_$/, "")));
2010
+ }
2011
+ if (kept.length === 0) return "/";
2012
+ return "/" + kept.join("/");
2013
+ }
2014
+ function normalizeTanStackSegment(seg) {
2015
+ if (seg === "$") return "[...splat]";
2016
+ if (seg.startsWith("$")) return `[${seg.slice(1)}]`;
2017
+ return seg.replace(/\[([^\]]*)\]/g, "$1");
2018
+ }
1767
2019
  function pathToId(routePath) {
1768
2020
  if (routePath === "/") return "root";
1769
2021
  return routePath.replace(/^\/+/, "").replace(/\[\.{3}([^\]]+)\]/g, "$1").replace(/\[([^\]]+)\]/g, "$1").replace(/\//g, "-").replace(/[^a-zA-Z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
@@ -1802,6 +2054,18 @@ function buildMetaFromExport(exp) {
1802
2054
  if (exp.acceptance?.length) meta.acceptance = exp.acceptance;
1803
2055
  if (exp.features?.length) meta.features = exp.features;
1804
2056
  if (exp.widgets?.length) meta.widgets = exp.widgets;
2057
+ if (exp.states?.length) {
2058
+ meta.states = exp.states.map((s) => ({
2059
+ name: s.name,
2060
+ ...s.kind ? { kind: s.kind } : {},
2061
+ ...s.acceptance?.length ? { acceptance: s.acceptance } : {},
2062
+ ...s.description ? { description: s.description } : {}
2063
+ }));
2064
+ }
2065
+ if (exp.capture === false) meta.capture = false;
2066
+ if (exp.waivers && Object.keys(exp.waivers).length > 0) {
2067
+ meta.waivers = exp.waivers;
2068
+ }
1805
2069
  return Object.keys(meta).length > 0 ? meta : void 0;
1806
2070
  }
1807
2071
  function resolve2(ctx) {
@@ -1847,9 +2111,12 @@ function resolve2(ctx) {
1847
2111
  function metaWithComposes(kind, id, base) {
1848
2112
  const composes = directChildren.get(`${kind}:${id}`);
1849
2113
  if (!composes || composes.length === 0) return base;
1850
- return { ...base ?? {}, composes };
2114
+ return { ...base, composes };
1851
2115
  }
1852
- const routes = conventions.pages === "auto" ? detectRoutes(ctx.extracted.map((e) => e.file)) : [];
2116
+ const routes = conventions.pages === false ? [] : detectRoutes(
2117
+ ctx.extracted.map((e) => e.file),
2118
+ conventions.pages === "auto" ? {} : { routesDir: conventions.pages.routesDir }
2119
+ );
1853
2120
  const handledPageFiles = /* @__PURE__ */ new Set();
1854
2121
  for (const route of routes) {
1855
2122
  const routeDir = path4.posix.dirname(route.file);
@@ -2221,6 +2488,297 @@ function dedupe(arr) {
2221
2488
 
2222
2489
  // src/scanner/scan/audit.ts
2223
2490
  var path5 = __toESM(require("path"), 1);
2491
+
2492
+ // src/scanner/scan/page-coverage.ts
2493
+ function compileRoute(pattern) {
2494
+ const segs = pattern.split("/").filter(Boolean);
2495
+ let specificity = 0;
2496
+ const optionalTail = segs.length > 0 && /^\[\[\.{3}.+\]\]$/.test(segs[segs.length - 1]);
2497
+ const effective = optionalTail ? segs.slice(0, -1) : segs;
2498
+ const parts = effective.map((seg) => {
2499
+ const catchAll = /^\[\.{3}.+\]$/.test(seg);
2500
+ const dynamic = /^\[.+\]$/.test(seg);
2501
+ if (catchAll) return "(?:[^/]+)(?:/[^/]+)*";
2502
+ if (dynamic) return "[^/]+";
2503
+ specificity++;
2504
+ return seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2505
+ });
2506
+ let body = parts.length === 0 ? "" : "/" + parts.join("/");
2507
+ if (optionalTail) body += "(?:/[^/]+)*";
2508
+ if (body === "") body = "/";
2509
+ const re = new RegExp(`^${body}/?$`);
2510
+ return { test: (url) => re.test(url), specificity };
2511
+ }
2512
+ function matchUrlToRoute(url, routePaths) {
2513
+ const path12 = stripQuery(url);
2514
+ let best = null;
2515
+ for (const rp of routePaths) {
2516
+ const { test, specificity } = compileRoute(rp);
2517
+ if (!test(path12)) continue;
2518
+ if (!best || specificity > best.specificity)
2519
+ best = { path: rp, specificity };
2520
+ }
2521
+ return best?.path ?? null;
2522
+ }
2523
+ function stripQuery(url) {
2524
+ const q = url.indexOf("?");
2525
+ const h = url.indexOf("#");
2526
+ let end = url.length;
2527
+ if (q !== -1) end = Math.min(end, q);
2528
+ if (h !== -1) end = Math.min(end, h);
2529
+ return url.slice(0, end);
2530
+ }
2531
+ function routeForCapture(c, routes, routePaths) {
2532
+ if (c.route && routePaths.includes(c.route)) return c.route;
2533
+ if (c.url) {
2534
+ const m = matchUrlToRoute(c.url, routePaths);
2535
+ if (m) return m;
2536
+ }
2537
+ if (c.kind === "page") {
2538
+ const r = routes.find((r2) => r2.page === c.entity);
2539
+ if (r) return r.path;
2540
+ }
2541
+ return null;
2542
+ }
2543
+ function checkPageCoverage(registry, manifest, baseline) {
2544
+ const routes = registry.list("route");
2545
+ if (routes.length === 0) return [];
2546
+ const routePaths = routes.map((r) => r.path);
2547
+ const captured = manifest.captured ?? [];
2548
+ const optedOut = /* @__PURE__ */ new Set();
2549
+ for (const r of routes) {
2550
+ const page = registry.get("page", r.page);
2551
+ if (page?.meta?.capture === false) optedOut.add(r.path);
2552
+ }
2553
+ const covered = /* @__PURE__ */ new Set();
2554
+ for (const c of captured) {
2555
+ const route = routeForCapture(c, routes, routePaths);
2556
+ if (route) covered.add(route);
2557
+ }
2558
+ const baselineSet = new Set(baseline?.uncapturedPages ?? []);
2559
+ const diagnostics = [];
2560
+ for (const r of routes) {
2561
+ if (covered.has(r.path) || optedOut.has(r.path)) continue;
2562
+ const loc = registry.get("page", r.page)?.loc;
2563
+ if (baselineSet.has(r.path)) {
2564
+ diagnostics.push({
2565
+ code: "page-backlog",
2566
+ severity: "info",
2567
+ message: `route "${r.path}" has no visual-states capture (acknowledged backlog)`,
2568
+ file: loc?.file,
2569
+ line: loc?.line,
2570
+ hint: `Capture it (drive the route in a states spec) and it drops off the baseline, or opt it out with \`export const uidex = { page: "${r.page}", capture: false }\`.`
2571
+ });
2572
+ continue;
2573
+ }
2574
+ diagnostics.push({
2575
+ code: "page-no-capture",
2576
+ severity: "error",
2577
+ message: `route "${r.path}" has no visual-states capture and is not accounted for`,
2578
+ file: loc?.file,
2579
+ line: loc?.line,
2580
+ entity: { kind: "route", id: r.path },
2581
+ hint: `Capture the route in a states spec, opt it out with \`export const uidex = { page: "${r.page}", capture: false }\`, or grandfather it as acknowledged backlog with \`uidex scan --update-baseline\` (the baseline change lands in the diff for review).`
2582
+ });
2583
+ }
2584
+ for (const b of baselineSet) {
2585
+ if (!routePaths.includes(b)) {
2586
+ diagnostics.push({
2587
+ code: "stale-page-baseline",
2588
+ severity: "warning",
2589
+ message: `coverage baseline lists "${b}", which is not a route in the registry`,
2590
+ hint: `Remove it from the baseline (\`uidex scan --update-baseline\`); the route no longer exists.`
2591
+ });
2592
+ } else if (covered.has(b) || optedOut.has(b)) {
2593
+ diagnostics.push({
2594
+ code: "page-captured",
2595
+ severity: "warning",
2596
+ message: `route "${b}" is now captured but is still in the coverage baseline`,
2597
+ hint: `Prune it with \`uidex scan --update-baseline\` so the backlog reflects reality.`
2598
+ });
2599
+ }
2600
+ }
2601
+ return diagnostics;
2602
+ }
2603
+ function computePageBaseline(registry, manifest) {
2604
+ const uncaptured = [];
2605
+ for (const d of checkPageCoverage(registry, manifest, {
2606
+ uncapturedPages: []
2607
+ })) {
2608
+ if (d.code === "page-no-capture" && d.entity) uncaptured.push(d.entity.id);
2609
+ }
2610
+ return { uncapturedPages: uncaptured.sort() };
2611
+ }
2612
+
2613
+ // src/scanner/scan/state-coverage.ts
2614
+ var STATE_KINDS2 = ["page", "feature", "widget"];
2615
+ function declaredStateEntities(registry) {
2616
+ const out2 = [];
2617
+ for (const kind of STATE_KINDS2) {
2618
+ for (const e of registry.list(kind)) {
2619
+ const states = e.meta?.states;
2620
+ if (!states || states.length === 0) continue;
2621
+ out2.push({
2622
+ kind,
2623
+ id: e.id,
2624
+ states: states.map((s) => s.name),
2625
+ loc: e.loc
2626
+ });
2627
+ }
2628
+ }
2629
+ return out2;
2630
+ }
2631
+ function captureMatchesEntity(cap, ent) {
2632
+ if (cap.entity !== ent.id) return false;
2633
+ return cap.kind === void 0 || cap.kind === ent.kind;
2634
+ }
2635
+ function checkStateCoverage(registry, manifest) {
2636
+ const diagnostics = [];
2637
+ const declared = declaredStateEntities(registry);
2638
+ const captured = manifest.captured ?? [];
2639
+ for (const ent of declared) {
2640
+ for (const state of ent.states) {
2641
+ const hit = captured.some(
2642
+ (c) => captureMatchesEntity(c, ent) && c.state === state
2643
+ );
2644
+ if (hit) continue;
2645
+ diagnostics.push({
2646
+ code: "missing-state-capture",
2647
+ severity: "warning",
2648
+ message: `${ent.kind} "${ent.id}" declares state "${state}" but no capture produced it`,
2649
+ file: ent.loc?.file,
2650
+ line: ent.loc?.line,
2651
+ entity: { kind: ent.kind, id: ent.id },
2652
+ 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.`
2653
+ });
2654
+ }
2655
+ }
2656
+ for (const cap of captured) {
2657
+ const candidates = declared.filter((ent2) => captureMatchesEntity(cap, ent2));
2658
+ if (candidates.length === 0) continue;
2659
+ const declaredSomewhere = candidates.some(
2660
+ (ent2) => ent2.states.includes(cap.state)
2661
+ );
2662
+ if (declaredSomewhere) continue;
2663
+ const ent = candidates[0];
2664
+ diagnostics.push({
2665
+ code: "orphan-state-capture",
2666
+ severity: "warning",
2667
+ message: `Capture produced state "${cap.state}" for ${ent.kind} "${ent.id}", which is not in its declared states [${ent.states.join(", ")}]`,
2668
+ file: ent.loc?.file,
2669
+ line: ent.loc?.line,
2670
+ entity: { kind: ent.kind, id: ent.id },
2671
+ hint: `Declare "${cap.state}" in \`export const uidex = { states: [...] }\` for "${ent.id}", or rename the capture to a declared state.`
2672
+ });
2673
+ }
2674
+ return diagnostics;
2675
+ }
2676
+
2677
+ // src/scanner/scan/state-matrix.ts
2678
+ var CORE_KINDS = CORE_STATE_KINDS;
2679
+ var STATE_ENTITY_KINDS = ["page", "feature", "widget"];
2680
+ function resolveMatrixKind(registry, c) {
2681
+ if (c.stateKind) return c.stateKind;
2682
+ for (const kind of STATE_ENTITY_KINDS) {
2683
+ const declared = registry.get(kind, c.entity)?.meta?.states?.find((s) => s.name === c.state)?.kind;
2684
+ if (declared) return declared;
2685
+ }
2686
+ return CORE_KINDS.includes(c.state) ? c.state : "variant";
2687
+ }
2688
+ function coreKindsByRoute(registry, manifest) {
2689
+ const routes = registry.list("route");
2690
+ const routePaths = routes.map((r) => r.path);
2691
+ const byRoute = /* @__PURE__ */ new Map();
2692
+ for (const c of manifest.captured ?? []) {
2693
+ const route = routeForCapture(c, routes, routePaths);
2694
+ if (!route) continue;
2695
+ if (!byRoute.has(route)) byRoute.set(route, /* @__PURE__ */ new Set());
2696
+ const kind = resolveMatrixKind(registry, c);
2697
+ if (CORE_KINDS.includes(kind)) byRoute.get(route).add(kind);
2698
+ }
2699
+ return byRoute;
2700
+ }
2701
+ function pageWaivers(registry, routePage) {
2702
+ return registry.get("page", routePage)?.meta?.waivers ?? {};
2703
+ }
2704
+ function isOptedOut(registry, page) {
2705
+ return page ? registry.get("page", page)?.meta?.capture === false : false;
2706
+ }
2707
+ function checkStateMatrix(registry, manifest, baseline) {
2708
+ const routes = registry.list("route");
2709
+ if (routes.length === 0) return [];
2710
+ const pageOf = new Map(routes.map((r) => [r.path, r.page]));
2711
+ const captured = coreKindsByRoute(registry, manifest);
2712
+ const baselined = baseline?.missingKinds ?? {};
2713
+ const diagnostics = [];
2714
+ for (const [route, kinds] of captured) {
2715
+ const page = pageOf.get(route);
2716
+ if (isOptedOut(registry, page)) continue;
2717
+ const waivers = page ? pageWaivers(registry, page) : {};
2718
+ const loc = page ? registry.get("page", page)?.loc : void 0;
2719
+ const baselinedKinds = new Set(baselined[route] ?? []);
2720
+ for (const kind of CORE_KINDS) {
2721
+ if (kinds.has(kind)) continue;
2722
+ if (waivers[kind]) continue;
2723
+ if (baselinedKinds.has(kind)) {
2724
+ diagnostics.push({
2725
+ code: "matrix-backlog",
2726
+ severity: "info",
2727
+ message: `route "${route}" does not render core kind "${kind}" (acknowledged MISSING_KINDS backlog)`,
2728
+ file: loc?.file,
2729
+ line: loc?.line,
2730
+ hint: `Capture the "${kind}" state for "${route}", waive it (\`waivers: { ${kind}: "why it can't" }\` on the page), or leave it in the baseline.`
2731
+ });
2732
+ continue;
2733
+ }
2734
+ diagnostics.push({
2735
+ code: "core-kind",
2736
+ severity: "error",
2737
+ message: `route "${route}" is captured but never renders core kind "${kind}"`,
2738
+ file: loc?.file,
2739
+ line: loc?.line,
2740
+ entity: { kind: "route", id: route },
2741
+ hint: `Capture the "${kind}" state, or if the route cannot render it add \`export const uidex = { page: "${page}", waivers: { ${kind}: "why it can't" } }\`, or grandfather it via \`uidex scan --update-baseline\` (the baseline change lands in the diff for review).`
2742
+ });
2743
+ }
2744
+ }
2745
+ for (const [route, kinds] of captured) {
2746
+ const page = pageOf.get(route);
2747
+ if (!page || isOptedOut(registry, page)) continue;
2748
+ const waivers = pageWaivers(registry, page);
2749
+ const loc = registry.get("page", page)?.loc;
2750
+ for (const kind of Object.keys(waivers)) {
2751
+ if (kinds.has(kind)) {
2752
+ diagnostics.push({
2753
+ code: "stale-waiver",
2754
+ severity: "warning",
2755
+ message: `page "${page}" waives core kind "${kind}", but route "${route}" does render it`,
2756
+ file: loc?.file,
2757
+ line: loc?.line,
2758
+ entity: { kind: "page", id: page },
2759
+ hint: `Remove the "${kind}" waiver \u2014 the route renders that kind, so the waiver's reason is stale.`
2760
+ });
2761
+ }
2762
+ }
2763
+ }
2764
+ return diagnostics;
2765
+ }
2766
+ function computeMissingKinds(registry, manifest) {
2767
+ const routes = registry.list("route");
2768
+ const pageOf = new Map(routes.map((r) => [r.path, r.page]));
2769
+ const captured = coreKindsByRoute(registry, manifest);
2770
+ const out2 = {};
2771
+ for (const [route, kinds] of captured) {
2772
+ const page = pageOf.get(route);
2773
+ if (isOptedOut(registry, page)) continue;
2774
+ const waivers = page ? pageWaivers(registry, page) : {};
2775
+ const missing = CORE_KINDS.filter((k) => !kinds.has(k) && !waivers[k]);
2776
+ if (missing.length > 0) out2[route] = missing;
2777
+ }
2778
+ return out2;
2779
+ }
2780
+
2781
+ // src/scanner/scan/audit.ts
2224
2782
  function audit(opts) {
2225
2783
  const diagnostics = [];
2226
2784
  const { registry, extracted, files, config } = opts;
@@ -2229,6 +2787,9 @@ function audit(opts) {
2229
2787
  const acceptanceEnabled = config.audit?.acceptance ?? true;
2230
2788
  const scopeLeakEnabled = config.audit?.scopeLeak ?? true;
2231
2789
  const coverageEnabled = config.audit?.coverage ?? true;
2790
+ const statesEnabled = config.audit?.states ?? true;
2791
+ const pageCoverageEnabled = config.audit?.pageCoverage ?? true;
2792
+ const stateMatrixEnabled = config.audit?.stateMatrix ?? true;
2232
2793
  if (opts.resolveDiagnostics) diagnostics.push(...opts.resolveDiagnostics);
2233
2794
  for (const ef of extracted) {
2234
2795
  if (ef.diagnostics) diagnostics.push(...ef.diagnostics);
@@ -2262,6 +2823,48 @@ function audit(opts) {
2262
2823
  }
2263
2824
  }
2264
2825
  }
2826
+ if (check && opts.typesGenerated !== void 0) {
2827
+ const outRel = opts.typesOutputPath ?? config.output;
2828
+ const fresh = normalizeForCheck(opts.typesGenerated);
2829
+ if (opts.typesOnDisk === null || opts.typesOnDisk === void 0) {
2830
+ diagnostics.push({
2831
+ code: "gen-missing",
2832
+ severity: "error",
2833
+ message: `Generated file "${outRel}" does not exist on disk; run \`uidex scan\` and commit the result`,
2834
+ file: outRel,
2835
+ hint: "Run `uidex scan` (without --check) to regenerate"
2836
+ });
2837
+ } else if (normalizeForCheck(opts.typesOnDisk) !== fresh) {
2838
+ diagnostics.push({
2839
+ code: "gen-stale",
2840
+ severity: "error",
2841
+ message: `Generated file "${outRel}" is stale`,
2842
+ file: outRel,
2843
+ hint: "Run `uidex scan` (without --check) to regenerate and commit the result"
2844
+ });
2845
+ }
2846
+ }
2847
+ if (check && opts.locsGenerated !== void 0) {
2848
+ const outRel = opts.locsOutputPath ?? config.output;
2849
+ const fresh = normalizeForCheck(opts.locsGenerated);
2850
+ if (opts.locsOnDisk === null || opts.locsOnDisk === void 0) {
2851
+ diagnostics.push({
2852
+ code: "gen-missing",
2853
+ severity: "error",
2854
+ message: `Generated file "${outRel}" does not exist on disk; run \`uidex scan\` and commit the result`,
2855
+ file: outRel,
2856
+ hint: "Run `uidex scan` (without --check) to regenerate"
2857
+ });
2858
+ } else if (normalizeForCheck(opts.locsOnDisk) !== fresh) {
2859
+ diagnostics.push({
2860
+ code: "gen-stale",
2861
+ severity: "error",
2862
+ message: `Generated file "${outRel}" is stale`,
2863
+ file: outRel,
2864
+ hint: "Run `uidex scan` (without --check) to regenerate and commit the result"
2865
+ });
2866
+ }
2867
+ }
2265
2868
  if (lint && acceptanceEnabled) {
2266
2869
  for (const kind of ["widget", "feature", "page"]) {
2267
2870
  for (const e of registry.list(kind)) {
@@ -2521,6 +3124,21 @@ function audit(opts) {
2521
3124
  }
2522
3125
  }
2523
3126
  }
3127
+ if (lint && statesEnabled && opts.capturedStates) {
3128
+ diagnostics.push(...checkStateCoverage(registry, opts.capturedStates));
3129
+ }
3130
+ if (lint && pageCoverageEnabled && opts.capturedStates) {
3131
+ diagnostics.push(
3132
+ ...checkPageCoverage(registry, opts.capturedStates, opts.pageBaseline)
3133
+ );
3134
+ }
3135
+ if (lint && stateMatrixEnabled && opts.capturedStates) {
3136
+ diagnostics.push(
3137
+ ...checkStateMatrix(registry, opts.capturedStates, {
3138
+ missingKinds: opts.pageBaseline?.missingKinds ?? {}
3139
+ })
3140
+ );
3141
+ }
2524
3142
  const summary = {
2525
3143
  errors: diagnostics.filter((d) => d.severity === "error").length,
2526
3144
  warnings: diagnostics.filter((d) => d.severity === "warning").length
@@ -2685,6 +3303,148 @@ function stableReplacer(_key, value) {
2685
3303
  }
2686
3304
 
2687
3305
  // src/scanner/scan/emit.ts
3306
+ var AUTHORED_KINDS = /* @__PURE__ */ new Set([
3307
+ "route",
3308
+ "page",
3309
+ "feature",
3310
+ "widget",
3311
+ "primitive",
3312
+ "flow"
3313
+ ]);
3314
+ function internedPayload(entities) {
3315
+ const fileIndex = /* @__PURE__ */ new Map();
3316
+ const files = [];
3317
+ const compact = entities.map((e) => {
3318
+ const loc = e.loc;
3319
+ if (!loc || typeof loc.file !== "string") return e;
3320
+ let idx = fileIndex.get(loc.file);
3321
+ if (idx === void 0) {
3322
+ idx = files.length;
3323
+ files.push(loc.file);
3324
+ fileIndex.set(loc.file, idx);
3325
+ }
3326
+ const rest = { ...loc };
3327
+ delete rest.file;
3328
+ return { ...e, loc: { f: idx, ...rest } };
3329
+ });
3330
+ return {
3331
+ filesDecl: `const files: readonly string[] = ${jsonStable(files)}`,
3332
+ parseExpr: `(JSON.parse(${JSON.stringify(
3333
+ JSON.stringify(compact, replacerSorted)
3334
+ )}) as any[]).map(hydrate)`
3335
+ };
3336
+ }
3337
+ var HYDRATE_FN = [
3338
+ "function hydrate(e: any): any {",
3339
+ ' if (e.loc && typeof e.loc.f === "number") {',
3340
+ " const { f, ...rest } = e.loc",
3341
+ " e.loc = { file: files[f], ...rest }",
3342
+ " }",
3343
+ " return e",
3344
+ "}"
3345
+ ].join("\n");
3346
+ function compactMeta(meta) {
3347
+ if (!meta || typeof meta !== "object") return meta;
3348
+ const composes = meta.composes;
3349
+ if (!Array.isArray(composes)) return meta;
3350
+ return {
3351
+ ...meta,
3352
+ composes: composes.map(
3353
+ (c) => c && typeof c === "object" && c.kind === "element" && Object.keys(c).length === 2 && typeof c.id === "string" ? c.id : c
3354
+ )
3355
+ };
3356
+ }
3357
+ function emitLocsModule(thin, uidexImport) {
3358
+ const kinds = [...new Set(thin.map((e) => e.kind))].sort();
3359
+ const kindIdx = new Map(kinds.map((k, i) => [k, i]));
3360
+ const fileIndex = /* @__PURE__ */ new Map();
3361
+ const files = [];
3362
+ const fi = (f) => {
3363
+ let i = fileIndex.get(f);
3364
+ if (i === void 0) {
3365
+ i = files.length;
3366
+ files.push(f);
3367
+ fileIndex.set(f, i);
3368
+ }
3369
+ return i;
3370
+ };
3371
+ const bare = [];
3372
+ const rich = [];
3373
+ for (const e of thin) {
3374
+ const loc = e.loc;
3375
+ const topOnlyKindIdLoc = Object.keys(e).every(
3376
+ (k) => k === "kind" || k === "id" || k === "loc"
3377
+ );
3378
+ const locOnlyKnown = !!loc && typeof loc.file === "string" && Object.keys(loc).every(
3379
+ (k) => k === "file" || k === "line" || k === "column"
3380
+ );
3381
+ if (topOnlyKindIdLoc && locOnlyKnown) {
3382
+ const row = [
3383
+ kindIdx.get(e.kind),
3384
+ e.id,
3385
+ fi(loc.file)
3386
+ ];
3387
+ if (loc.line != null || loc.column != null)
3388
+ row.push(Number(loc.line ?? 0));
3389
+ if (loc.column != null) row.push(Number(loc.column));
3390
+ bare.push(row);
3391
+ } else {
3392
+ const richEntity = { ...e };
3393
+ if (loc && typeof loc.file === "string") {
3394
+ const { file, ...rest } = loc;
3395
+ richEntity.loc = { f: fi(file), ...rest };
3396
+ }
3397
+ if (richEntity.meta) richEntity.meta = compactMeta(richEntity.meta);
3398
+ rich.push(richEntity);
3399
+ }
3400
+ }
3401
+ const str = (v) => JSON.stringify(JSON.stringify(v, replacerSorted));
3402
+ const l = [];
3403
+ l.push("// THIS FILE IS AUTO-GENERATED BY `uidex scan`. DO NOT EDIT.");
3404
+ l.push(
3405
+ "// Deferred element/region source locations for the inspector's copy-path."
3406
+ );
3407
+ l.push("// @ts-nocheck");
3408
+ l.push("/* eslint-disable */");
3409
+ l.push(`import type { Entity, Registry } from ${JSON.stringify(uidexImport)}`);
3410
+ l.push("");
3411
+ l.push(`const files: readonly string[] = ${jsonStable(files)}`);
3412
+ l.push(`const kinds: readonly string[] = ${jsonStable(kinds)}`);
3413
+ l.push("function loc(fi: number, line?: number, col?: number) {");
3414
+ l.push(" const o: any = { file: files[fi] }");
3415
+ l.push(" if (line) o.line = line");
3416
+ l.push(" if (col) o.column = col");
3417
+ l.push(" return o");
3418
+ l.push("}");
3419
+ l.push(`const bare: any[] = JSON.parse(${str(bare)})`);
3420
+ l.push(`const rich: any[] = JSON.parse(${str(rich)})`);
3421
+ l.push("");
3422
+ l.push(
3423
+ "/** Add the thin element/region entities (source locations) to a registry. */"
3424
+ );
3425
+ l.push("export function loadLocs(target: Registry): Registry {");
3426
+ l.push(" for (const r of bare)");
3427
+ l.push(
3428
+ " target.add({ kind: kinds[r[0]], id: r[1], loc: loc(r[2], r[3], r[4]) } as Entity)"
3429
+ );
3430
+ l.push(" for (const e of rich) {");
3431
+ l.push(" const out: any = { ...e }");
3432
+ l.push(' if (e.loc && typeof e.loc.f === "number") {');
3433
+ l.push(" const { f, ...rest } = e.loc");
3434
+ l.push(" out.loc = { file: files[f], ...rest }");
3435
+ l.push(" }");
3436
+ l.push(" if (e.meta && Array.isArray(e.meta.composes)) {");
3437
+ l.push(
3438
+ ' out.meta = { ...e.meta, composes: e.meta.composes.map((c: any) => typeof c === "string" ? { id: c, kind: "element" } : c) }'
3439
+ );
3440
+ l.push(" }");
3441
+ l.push(" target.add(out)");
3442
+ l.push(" }");
3443
+ l.push(" return target");
3444
+ l.push("}");
3445
+ l.push("");
3446
+ return l.join("\n");
3447
+ }
2688
3448
  function sortById(arr) {
2689
3449
  return [...arr].sort((a, b) => a.id.localeCompare(b.id));
2690
3450
  }
@@ -2710,6 +3470,20 @@ function emitIdUnion(name, ids) {
2710
3470
  ${body}
2711
3471
  `;
2712
3472
  }
3473
+ var OPAQUE_ID_UNIONS = /* @__PURE__ */ new Set(["ElementId"]);
3474
+ function emitId(name, ids) {
3475
+ if (OPAQUE_ID_UNIONS.has(name)) {
3476
+ return `export type ${name} = string & { readonly __uidex?: ${JSON.stringify(name)} }
3477
+ `;
3478
+ }
3479
+ return emitIdUnion(name, ids);
3480
+ }
3481
+ function dataOutputPath(output) {
3482
+ return output.replace(/\.(d\.ts|ts|tsx|mts|cts|js|mjs|cjs)$/, ".data.$1");
3483
+ }
3484
+ function locsOutputPath(output) {
3485
+ return output.replace(/\.(d\.ts|ts|tsx|mts|cts|js|mjs|cjs)$/, ".locs.$1");
3486
+ }
2713
3487
  function emit(opts) {
2714
3488
  const { registry, gitContext, uidexImport = "uidex" } = opts;
2715
3489
  const routes = [...registry.list("route")].sort(
@@ -2718,112 +3492,131 @@ function emit(opts) {
2718
3492
  const pages = sortById(registry.list("page"));
2719
3493
  const features = sortById(registry.list("feature"));
2720
3494
  const widgets = sortById(registry.list("widget"));
3495
+ const stateNames = /* @__PURE__ */ new Set();
3496
+ for (const e of [...pages, ...features, ...widgets]) {
3497
+ for (const s of e.meta?.states ?? []) stateNames.add(s.name);
3498
+ }
2721
3499
  const regions = sortById(registry.list("region"));
2722
3500
  const elements = sortById(registry.list("element"));
2723
3501
  const primitives = sortById(registry.list("primitive"));
2724
3502
  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)}`
3503
+ const t = [];
3504
+ t.push("// THIS FILE IS AUTO-GENERATED BY `uidex scan`. DO NOT EDIT.");
3505
+ t.push(
3506
+ "// Types only \u2014 the runtime registry lives in the sibling `.data` module."
2731
3507
  );
2732
- lines.push("");
2733
- lines.push("// ---- id unions ----");
2734
- lines.push(
2735
- emitIdUnion(
3508
+ t.push("/* eslint-disable */");
3509
+ t.push("");
3510
+ t.push("// ---- id unions ----");
3511
+ t.push(
3512
+ emitId(
2736
3513
  "PageId",
2737
3514
  pages.map((e) => e.id)
2738
3515
  )
2739
3516
  );
2740
- lines.push(
2741
- emitIdUnion(
3517
+ t.push(
3518
+ emitId(
2742
3519
  "FeatureId",
2743
3520
  features.map((e) => e.id)
2744
3521
  )
2745
3522
  );
2746
- lines.push(
2747
- emitIdUnion(
3523
+ t.push(
3524
+ emitId(
2748
3525
  "WidgetId",
2749
3526
  widgets.map((e) => e.id)
2750
3527
  )
2751
3528
  );
2752
- lines.push(
2753
- emitIdUnion(
3529
+ t.push(
3530
+ emitId(
2754
3531
  "RegionId",
2755
3532
  regions.map((e) => e.id)
2756
3533
  )
2757
3534
  );
2758
- lines.push(
2759
- emitIdUnion(
3535
+ t.push(
3536
+ emitId(
2760
3537
  "ElementId",
2761
3538
  elements.map((e) => e.id)
2762
3539
  )
2763
3540
  );
2764
- lines.push(
2765
- emitIdUnion(
3541
+ t.push(
3542
+ emitId(
2766
3543
  "PrimitiveId",
2767
3544
  primitives.map((e) => e.id)
2768
3545
  )
2769
3546
  );
2770
- lines.push(
2771
- emitIdUnion(
3547
+ t.push(
3548
+ emitId(
2772
3549
  "FlowId",
2773
3550
  flows.map((e) => e.id)
2774
3551
  )
2775
3552
  );
2776
- lines.push(
2777
- emitIdUnion(
3553
+ t.push(
3554
+ emitId(
2778
3555
  "RouteId",
2779
3556
  routes.map((e) => e.path)
2780
3557
  )
2781
3558
  );
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 ----");
3559
+ t.push(emitId("StateId", [...stateNames]));
3560
+ t.push("");
3561
+ t.push("// ---- authoring-surface shape types ----");
3562
+ t.push("export namespace Uidex {");
3563
+ t.push(
3564
+ ' export type CoreStateKind = "loading" | "empty" | "populated" | "error"'
3565
+ );
3566
+ t.push(' export type StateKind = CoreStateKind | "variant"');
3567
+ t.push(" export interface State {");
3568
+ t.push(" name: string");
3569
+ t.push(" kind?: StateKind");
3570
+ t.push(" acceptance?: readonly string[]");
3571
+ t.push(" description?: string");
3572
+ t.push(" }");
3573
+ t.push(" export type Waivers = Partial<Record<CoreStateKind, string>>");
3574
+ t.push(" export interface Page {");
3575
+ t.push(" page: PageId | false");
3576
+ t.push(" name?: string");
3577
+ t.push(" features?: readonly FeatureId[]");
3578
+ t.push(" widgets?: readonly WidgetId[]");
3579
+ t.push(" acceptance?: readonly string[]");
3580
+ t.push(" states?: readonly (string | State)[]");
3581
+ t.push(" capture?: boolean");
3582
+ t.push(" waivers?: Waivers");
3583
+ t.push(" description?: string");
3584
+ t.push(" }");
3585
+ t.push(" export interface Feature {");
3586
+ t.push(" feature: FeatureId | false");
3587
+ t.push(" name?: string");
3588
+ t.push(" features?: readonly FeatureId[]");
3589
+ t.push(" acceptance?: readonly string[]");
3590
+ t.push(" states?: readonly (string | State)[]");
3591
+ t.push(" description?: string");
3592
+ t.push(" }");
3593
+ t.push(" export interface Primitive {");
3594
+ t.push(" primitive: PrimitiveId");
3595
+ t.push(" name?: string");
3596
+ t.push(" description?: string");
3597
+ t.push(" }");
3598
+ t.push(" export interface Widget {");
3599
+ t.push(" widget: WidgetId");
3600
+ t.push(" name?: string");
3601
+ t.push(" acceptance?: readonly string[]");
3602
+ t.push(" states?: readonly (string | State)[]");
3603
+ t.push(" description?: string");
3604
+ t.push(" }");
3605
+ t.push(" export interface Region {");
3606
+ t.push(" region: RegionId | false");
3607
+ t.push(" name?: string");
3608
+ t.push(" description?: string");
3609
+ t.push(" }");
3610
+ t.push(" export interface Flow {");
3611
+ t.push(" flow: FlowId");
3612
+ t.push(" name?: string");
3613
+ t.push(" description?: string");
3614
+ t.push(" }");
3615
+ t.push(" export interface NotFlow {");
3616
+ t.push(" notFlow: true");
3617
+ t.push(" }");
3618
+ t.push("}");
3619
+ t.push("");
2827
3620
  const allEntities = [
2828
3621
  ...routes,
2829
3622
  ...pages,
@@ -2834,25 +3627,39 @@ function emit(opts) {
2834
3627
  ...primitives,
2835
3628
  ...flows
2836
3629
  ];
2837
- lines.push(
2838
- `export const entities: ReadonlyArray<Entity> = ${jsonStable(allEntities)}`
2839
- );
2840
- lines.push("");
2841
- lines.push("// ---- git context ----");
3630
+ const authored = allEntities.filter((e) => AUTHORED_KINDS.has(e.kind));
3631
+ const thin = allEntities.filter((e) => !AUTHORED_KINDS.has(e.kind));
2842
3632
  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");
3633
+ const authoredPayload = internedPayload(authored);
3634
+ const d = [];
3635
+ d.push("// THIS FILE IS AUTO-GENERATED BY `uidex scan`. DO NOT EDIT.");
3636
+ d.push(
3637
+ "// Authored-metadata registry. The thin element/region entities (source"
3638
+ );
3639
+ d.push(
3640
+ "// locations only) live in the sibling `.locs` module, loaded lazily."
3641
+ );
3642
+ d.push("// @ts-nocheck");
3643
+ d.push("/* eslint-disable */");
3644
+ d.push(`import { createUidex } from ${JSON.stringify(uidexImport)}`);
3645
+ d.push(`import type { Registry } from ${JSON.stringify(uidexImport)}`);
3646
+ d.push("");
3647
+ d.push(authoredPayload.filesDecl);
3648
+ d.push(HYDRATE_FN);
3649
+ d.push(`export const entities = ${authoredPayload.parseExpr}`);
3650
+ d.push("");
3651
+ d.push(`export const gitContext = ${jsonStable(gc)} as const`);
3652
+ d.push("");
3653
+ d.push("export function loadRegistry(target: Registry): Registry {");
3654
+ d.push(" for (const entity of entities) target.add(entity)");
3655
+ d.push(" return target");
3656
+ d.push("}");
3657
+ d.push("");
3658
+ d.push("export const uidex = createUidex()");
3659
+ d.push("for (const entity of entities) uidex.registry.add(entity)");
3660
+ d.push("");
3661
+ const locs = emitLocsModule(thin, uidexImport);
3662
+ return { types: t.join("\n"), data: d.join("\n"), locs };
2856
3663
  }
2857
3664
 
2858
3665
  // src/scanner/scan/git.ts
@@ -2963,6 +3770,68 @@ function renderSpec(args) {
2963
3770
  // src/scanner/scan/pipeline.ts
2964
3771
  var fs4 = __toESM(require("fs"), 1);
2965
3772
  var path7 = __toESM(require("path"), 1);
3773
+ var DEFAULT_STATES_MANIFEST = "uidex-states.json";
3774
+ var DEFAULT_COVERAGE_BASELINE = "uidex-coverage-baseline.json";
3775
+ function loadPageBaseline(configDir, config) {
3776
+ const rel = config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
3777
+ const abs = path7.resolve(configDir, rel);
3778
+ let raw;
3779
+ try {
3780
+ raw = fs4.readFileSync(abs, "utf8");
3781
+ } catch {
3782
+ return void 0;
3783
+ }
3784
+ try {
3785
+ const parsed = JSON.parse(raw);
3786
+ if (!parsed || !Array.isArray(parsed.uncapturedPages)) return void 0;
3787
+ const uncapturedPages = parsed.uncapturedPages.filter(
3788
+ (p2) => typeof p2 === "string"
3789
+ );
3790
+ let missingKinds;
3791
+ const mkRaw = parsed.missingKinds;
3792
+ if (mkRaw && typeof mkRaw === "object" && !Array.isArray(mkRaw)) {
3793
+ const mk = {};
3794
+ for (const [route, kinds] of Object.entries(mkRaw)) {
3795
+ if (Array.isArray(kinds)) {
3796
+ mk[route] = kinds.filter((k) => typeof k === "string");
3797
+ }
3798
+ }
3799
+ missingKinds = mk;
3800
+ }
3801
+ return { uncapturedPages, ...missingKinds ? { missingKinds } : {} };
3802
+ } catch {
3803
+ return void 0;
3804
+ }
3805
+ }
3806
+ function loadCapturedStates(configDir, config) {
3807
+ const rel = config.statesManifest ?? DEFAULT_STATES_MANIFEST;
3808
+ const abs = path7.resolve(configDir, rel);
3809
+ let raw;
3810
+ try {
3811
+ raw = fs4.readFileSync(abs, "utf8");
3812
+ } catch {
3813
+ return void 0;
3814
+ }
3815
+ try {
3816
+ const parsed = JSON.parse(raw);
3817
+ if (!parsed || !Array.isArray(parsed.captured)) return void 0;
3818
+ const captured = [];
3819
+ for (const entry of parsed.captured) {
3820
+ if (!entry || typeof entry !== "object") continue;
3821
+ const c = entry;
3822
+ if (typeof c.entity !== "string" || typeof c.state !== "string") continue;
3823
+ const rec = { entity: c.entity, state: c.state };
3824
+ if (typeof c.kind === "string") rec.kind = c.kind;
3825
+ if (typeof c.stateKind === "string") rec.stateKind = c.stateKind;
3826
+ if (typeof c.url === "string") rec.url = c.url;
3827
+ if (typeof c.route === "string") rec.route = c.route;
3828
+ captured.push(rec);
3829
+ }
3830
+ return { captured };
3831
+ } catch {
3832
+ return void 0;
3833
+ }
3834
+ }
2966
3835
  function runScan(opts = {}) {
2967
3836
  const cwd = opts.cwd ?? process.cwd();
2968
3837
  const configs = opts.configs ?? discover({ cwd });
@@ -2989,23 +3858,29 @@ function runOne(dc, opts) {
2989
3858
  flowFiles: extractedFlows
2990
3859
  });
2991
3860
  const gitContext = resolveGitContext({ cwd: configDir });
2992
- const generated = emit({
3861
+ const emitted = emit({
2993
3862
  registry: resolved.registry,
2994
3863
  gitContext
2995
3864
  });
2996
- const outputPath = path7.resolve(configDir, config.output);
2997
- const outputRel = config.output;
2998
- let existingOnDisk = null;
3865
+ const typesRel = config.output;
3866
+ const typesPath = path7.resolve(configDir, typesRel);
3867
+ const dataRel = dataOutputPath(config.output);
3868
+ const dataPath = path7.resolve(configDir, dataRel);
3869
+ const locsRel = locsOutputPath(config.output);
3870
+ const locsPath = path7.resolve(configDir, locsRel);
3871
+ let typesOnDisk = null;
3872
+ let dataOnDisk = null;
3873
+ let locsOnDisk = null;
2999
3874
  if (opts.check) {
3000
- try {
3001
- existingOnDisk = fs4.readFileSync(outputPath, "utf8");
3002
- } catch {
3003
- existingOnDisk = null;
3004
- }
3875
+ typesOnDisk = readOrNull(typesPath);
3876
+ dataOnDisk = readOrNull(dataPath);
3877
+ locsOnDisk = readOrNull(locsPath);
3005
3878
  }
3006
3879
  const hasExtractDiagnostics = [...extracted, ...extractedFlows].some(
3007
3880
  (ef) => (ef.diagnostics?.length ?? 0) > 0
3008
3881
  );
3882
+ const capturedStates = opts.capturedStates ?? loadCapturedStates(configDir, config);
3883
+ const pageBaseline = opts.pageBaseline ?? loadPageBaseline(configDir, config);
3009
3884
  let auditResult;
3010
3885
  if (opts.check || opts.lint || resolved.diagnostics.length > 0 || hasExtractDiagnostics) {
3011
3886
  auditResult = audit({
@@ -3017,9 +3892,20 @@ function runOne(dc, opts) {
3017
3892
  check: opts.check,
3018
3893
  lint: opts.lint,
3019
3894
  resolveDiagnostics: resolved.diagnostics,
3020
- generated,
3021
- existingOnDisk,
3022
- outputPath: outputRel
3895
+ // The entity array (and thus the meaningful drift) lives in the data file,
3896
+ // so the entity-aware gen-stale diff runs against it; the types file gets a
3897
+ // plain content compare (see audit()).
3898
+ generated: emitted.data,
3899
+ existingOnDisk: dataOnDisk,
3900
+ outputPath: dataRel,
3901
+ typesGenerated: emitted.types,
3902
+ typesOnDisk,
3903
+ typesOutputPath: typesRel,
3904
+ locsGenerated: emitted.locs,
3905
+ locsOnDisk,
3906
+ locsOutputPath: locsRel,
3907
+ capturedStates,
3908
+ pageBaseline
3023
3909
  });
3024
3910
  }
3025
3911
  return {
@@ -3028,13 +3914,38 @@ function runOne(dc, opts) {
3028
3914
  registry: resolved.registry,
3029
3915
  gitContext,
3030
3916
  audit: auditResult,
3031
- generated,
3032
- outputPath
3917
+ types: {
3918
+ generated: emitted.types,
3919
+ outputPath: typesPath,
3920
+ outputRel: typesRel
3921
+ },
3922
+ data: { generated: emitted.data, outputPath: dataPath, outputRel: dataRel },
3923
+ locs: { generated: emitted.locs, outputPath: locsPath, outputRel: locsRel },
3924
+ capturedStates
3033
3925
  };
3034
3926
  }
3927
+ function readOrNull(p2) {
3928
+ try {
3929
+ return fs4.readFileSync(p2, "utf8");
3930
+ } catch {
3931
+ return null;
3932
+ }
3933
+ }
3934
+ function writeArtifact(artifact) {
3935
+ if (readOrNull(artifact.outputPath) === artifact.generated) return false;
3936
+ fs4.mkdirSync(path7.dirname(artifact.outputPath), { recursive: true });
3937
+ fs4.writeFileSync(artifact.outputPath, artifact.generated, "utf8");
3938
+ return true;
3939
+ }
3035
3940
  function writeScanResult(result) {
3036
- fs4.mkdirSync(path7.dirname(result.outputPath), { recursive: true });
3037
- fs4.writeFileSync(result.outputPath, result.generated, "utf8");
3941
+ const wroteTypes = writeArtifact(result.types);
3942
+ const wroteData = writeArtifact(result.data);
3943
+ const wroteLocs = writeArtifact(result.locs);
3944
+ return wroteTypes || wroteData || wroteLocs;
3945
+ }
3946
+ function pageBaselinePath(result) {
3947
+ const rel = result.config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
3948
+ return path7.resolve(result.configDir, rel);
3038
3949
  }
3039
3950
 
3040
3951
  // src/scanner/scan/fix.ts
@@ -3607,16 +4518,13 @@ function helpText2() {
3607
4518
  " scaffold <widget|page|feature> <id> Emit a Playwright spec from declared acceptance",
3608
4519
  " rename <element|widget|region> <old-id> <new-id> Rename an id everywhere (DOM attr, flows, exports)",
3609
4520
  " 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
4521
  "",
3615
4522
  "Flags:",
3616
4523
  " --check Verify the on-disk gen file matches a fresh scan; exit non-zero on drift (read-only)",
3617
4524
  " --lint Run lint diagnostics (missing annotations, scope leak, duplicate ids, coverage)",
3618
4525
  " --audit Equivalent to --check --lint (read-only)",
3619
4526
  " --fix Apply machine-generated fixes (add data-uidex to unannotated interactive elements, drop empty names), then rescan and write",
4527
+ " --update-baseline Regenerate the page-coverage baseline (uidex-coverage-baseline.json) from the current uncaptured routes",
3620
4528
  " --json Emit JSON diagnostics on stdout",
3621
4529
  " --force (scaffold) overwrite existing spec",
3622
4530
  ""
@@ -3636,7 +4544,7 @@ function runInit(cwd, w) {
3636
4544
  fs8.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
3637
4545
  w.out(`Created ${configPath}`);
3638
4546
  const gitignorePath = path11.join(cwd, ".gitignore");
3639
- const entry = "*.gen.ts";
4547
+ const entry = "*.gen.*";
3640
4548
  if (fs8.existsSync(gitignorePath)) {
3641
4549
  const existing = fs8.readFileSync(gitignorePath, "utf8");
3642
4550
  const hasEntry = existing.split("\n").some((line) => line.trim() === entry);
@@ -3659,8 +4567,9 @@ function runInit(cwd, w) {
3659
4567
  }
3660
4568
  function runScanCommand(cwd, flags, w) {
3661
4569
  const fix = Boolean(flags.fix);
3662
- const check = !fix && Boolean(flags.check || flags.audit);
3663
- const lint = Boolean(flags.lint || flags.audit || fix);
4570
+ const updateBaseline = Boolean(flags["update-baseline"]);
4571
+ const check = !fix && !updateBaseline && Boolean(flags.check || flags.audit);
4572
+ const lint = Boolean(flags.lint || flags.audit || fix || updateBaseline);
3664
4573
  const asJson = Boolean(flags.json);
3665
4574
  let configs = discover({ cwd });
3666
4575
  if (configs.length === 0) {
@@ -3679,8 +4588,33 @@ function runScanCommand(cwd, flags, w) {
3679
4588
  configs = discover({ cwd });
3680
4589
  }
3681
4590
  const results = runScan({ cwd, check, lint, configs });
4591
+ const artifacts = results.flatMap((r) => [r.types, r.data, r.locs]);
4592
+ const wrote = /* @__PURE__ */ new Set();
3682
4593
  if (!check) {
3683
- for (const r of results) writeScanResult(r);
4594
+ for (const a of artifacts) {
4595
+ if (writeArtifact(a)) wrote.add(a);
4596
+ }
4597
+ }
4598
+ if (updateBaseline) {
4599
+ for (const r of results) {
4600
+ const manifest = r.capturedStates ?? { captured: [] };
4601
+ const pageBaseline = computePageBaseline(r.registry, manifest);
4602
+ const missingKinds = computeMissingKinds(r.registry, manifest);
4603
+ const baseline = {
4604
+ ...pageBaseline,
4605
+ ...Object.keys(missingKinds).length > 0 ? { missingKinds } : {}
4606
+ };
4607
+ const p2 = pageBaselinePath(r);
4608
+ fs8.writeFileSync(p2, JSON.stringify(baseline, null, 2) + "\n");
4609
+ const kindGaps = Object.values(missingKinds).reduce(
4610
+ (n, ks) => n + ks.length,
4611
+ 0
4612
+ );
4613
+ w.out(
4614
+ `Wrote ${p2} \u2014 ${baseline.uncapturedPages.length} uncaptured page(s), ${kindGaps} missing core-kind(s) baselined`
4615
+ );
4616
+ }
4617
+ return w.result(0);
3684
4618
  }
3685
4619
  const allDiagnostics = results.flatMap((r) => r.audit?.diagnostics ?? []);
3686
4620
  const summary = results.reduce(
@@ -3708,10 +4642,14 @@ function runScanCommand(cwd, flags, w) {
3708
4642
  );
3709
4643
  }
3710
4644
  for (const r of results) {
3711
- if (check) {
3712
- w.out(`Checked ${r.outputPath}`);
3713
- } else {
3714
- w.out(`Wrote ${r.outputPath}`);
4645
+ for (const art of [r.types, r.data, r.locs]) {
4646
+ if (check) {
4647
+ w.out(`Checked ${art.outputPath}`);
4648
+ } else if (wrote.has(art)) {
4649
+ w.out(`Wrote ${art.outputPath}`);
4650
+ } else {
4651
+ w.out(`Unchanged ${art.outputPath}`);
4652
+ }
3715
4653
  }
3716
4654
  for (const d of r.audit?.diagnostics ?? []) {
3717
4655
  const loc = d.file ? `${d.file}${d.line ? `:${d.line}` : ""}` : "";
@@ -3820,12 +4758,21 @@ function createWriter() {
3820
4758
  DEFAULT_CONVENTIONS,
3821
4759
  applyFixes,
3822
4760
  audit,
4761
+ checkPageCoverage,
4762
+ checkStateCoverage,
4763
+ checkStateMatrix,
4764
+ compileRoute,
4765
+ computeMissingKinds,
4766
+ computePageBaseline,
3823
4767
  detectRoutes,
3824
4768
  discover,
3825
4769
  emit,
3826
4770
  extract,
3827
4771
  extractUidexExports,
3828
4772
  globToRegExp,
4773
+ loadPageBaseline,
4774
+ matchUrlToRoute,
4775
+ pageBaselinePath,
3829
4776
  parseConfig,
3830
4777
  pathToId,
3831
4778
  renameEntity,