uidex 0.6.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 (57) hide show
  1. package/README.md +3 -3
  2. package/dist/cli/cli.cjs +2502 -2253
  3. package/dist/cli/cli.cjs.map +1 -1
  4. package/dist/headless/index.cjs +86 -703
  5. package/dist/headless/index.cjs.map +1 -1
  6. package/dist/headless/index.d.cts +46 -22
  7. package/dist/headless/index.d.ts +46 -22
  8. package/dist/headless/index.js +86 -707
  9. package/dist/headless/index.js.map +1 -1
  10. package/dist/index.cjs +712 -4149
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +82 -366
  13. package/dist/index.d.ts +82 -366
  14. package/dist/index.js +708 -4156
  15. package/dist/index.js.map +1 -1
  16. package/dist/playwright/index.cjs +175 -0
  17. package/dist/playwright/index.cjs.map +1 -1
  18. package/dist/playwright/index.d.cts +2 -0
  19. package/dist/playwright/index.d.ts +2 -0
  20. package/dist/playwright/index.js +167 -0
  21. package/dist/playwright/index.js.map +1 -1
  22. package/dist/playwright/states-reporter.cjs +123 -0
  23. package/dist/playwright/states-reporter.cjs.map +1 -0
  24. package/dist/playwright/states-reporter.d.cts +46 -0
  25. package/dist/playwright/states-reporter.d.ts +46 -0
  26. package/dist/playwright/states-reporter.js +88 -0
  27. package/dist/playwright/states-reporter.js.map +1 -0
  28. package/dist/playwright/states.cjs +118 -0
  29. package/dist/playwright/states.cjs.map +1 -0
  30. package/dist/playwright/states.d.cts +120 -0
  31. package/dist/playwright/states.d.ts +120 -0
  32. package/dist/playwright/states.js +88 -0
  33. package/dist/playwright/states.js.map +1 -0
  34. package/dist/react/index.cjs +750 -4113
  35. package/dist/react/index.cjs.map +1 -1
  36. package/dist/react/index.d.cts +78 -278
  37. package/dist/react/index.d.ts +78 -278
  38. package/dist/react/index.js +748 -4135
  39. package/dist/react/index.js.map +1 -1
  40. package/dist/scan/index.cjs +2694 -1541
  41. package/dist/scan/index.cjs.map +1 -1
  42. package/dist/scan/index.d.cts +482 -19
  43. package/dist/scan/index.d.ts +482 -19
  44. package/dist/scan/index.js +2682 -1540
  45. package/dist/scan/index.js.map +1 -1
  46. package/package.json +14 -17
  47. package/templates/claude/SKILL.md +71 -0
  48. package/templates/claude/references/audit.md +43 -0
  49. package/templates/claude/{rules.md → references/conventions.md} +25 -28
  50. package/dist/cloud/index.cjs +0 -472
  51. package/dist/cloud/index.cjs.map +0 -1
  52. package/dist/cloud/index.d.cts +0 -82
  53. package/dist/cloud/index.d.ts +0 -82
  54. package/dist/cloud/index.js +0 -445
  55. package/dist/cloud/index.js.map +0 -1
  56. package/templates/claude/audit.md +0 -43
  57. /package/templates/claude/{api.md → references/api.md} +0 -0
@@ -33,20 +33,31 @@ __export(scan_exports, {
33
33
  CONFIG_FILENAME: () => CONFIG_FILENAME,
34
34
  ConfigError: () => ConfigError,
35
35
  DEFAULT_CONVENTIONS: () => DEFAULT_CONVENTIONS,
36
- DEFAULT_TYPE_MODE: () => DEFAULT_TYPE_MODE,
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,
55
+ renameEntity: () => renameEntity,
46
56
  resolve: () => resolve2,
47
57
  resolveGitContext: () => resolveGitContext,
48
58
  runCli: () => run,
49
59
  runScan: () => runScan,
60
+ scaffoldSpec: () => scaffoldSpec,
50
61
  scaffoldWidgetSpec: () => scaffoldWidgetSpec,
51
62
  validateConfig: () => validateConfig,
52
63
  walk: () => walk,
@@ -59,7 +70,6 @@ var fs = __toESM(require("fs"), 1);
59
70
  var path = __toESM(require("path"), 1);
60
71
 
61
72
  // src/scanner/scan/config.ts
62
- var DEFAULT_TYPE_MODE = "strict";
63
73
  var WELL_KNOWN_FILES = {
64
74
  page: "uidex.page.ts",
65
75
  feature: "uidex.feature.ts"
@@ -84,11 +94,11 @@ var ALLOWED_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
84
94
  "exclude",
85
95
  "output",
86
96
  "flows",
87
- "typeMode",
88
97
  "audit",
89
- "conventions"
98
+ "conventions",
99
+ "statesManifest",
100
+ "coverageBaseline"
90
101
  ]);
91
- var ALLOWED_TYPE_MODES = /* @__PURE__ */ new Set(["strict", "loose"]);
92
102
  var ALLOWED_SOURCE_KEYS = /* @__PURE__ */ new Set(["rootDir", "include", "exclude", "prefix"]);
93
103
  var ALLOWED_CONVENTIONS_KEYS = /* @__PURE__ */ new Set([
94
104
  "primitives",
@@ -97,18 +107,25 @@ var ALLOWED_CONVENTIONS_KEYS = /* @__PURE__ */ new Set([
97
107
  "flows",
98
108
  "regions"
99
109
  ]);
100
- 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
+ ]);
101
118
  function fail(msg) {
102
119
  throw new ConfigError(`Invalid .uidex.json: ${msg}`);
103
120
  }
104
- function assertObject(value, path10) {
121
+ function assertObject(value, path12) {
105
122
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
106
- fail(`${path10} must be an object`);
123
+ fail(`${path12} must be an object`);
107
124
  }
108
125
  }
109
- function assertStringArray(value, path10) {
126
+ function assertStringArray(value, path12) {
110
127
  if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
111
- fail(`${path10} must be a string[]`);
128
+ fail(`${path12} must be a string[]`);
112
129
  }
113
130
  }
114
131
  function validateConfig(raw) {
@@ -156,10 +173,11 @@ function validateConfig(raw) {
156
173
  }
157
174
  if (raw.exclude !== void 0) assertStringArray(raw.exclude, `exclude`);
158
175
  if (raw.flows !== void 0) assertStringArray(raw.flows, `flows`);
159
- if (raw.typeMode !== void 0) {
160
- if (typeof raw.typeMode !== "string" || !ALLOWED_TYPE_MODES.has(raw.typeMode)) {
161
- fail(`"typeMode" must be "strict" or "loose"`);
162
- }
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`);
163
181
  }
164
182
  if (raw.audit !== void 0) {
165
183
  assertObject(raw.audit, "audit");
@@ -199,9 +217,10 @@ function validateConfig(raw) {
199
217
  exclude: raw.exclude,
200
218
  output: raw.output,
201
219
  flows: raw.flows,
202
- typeMode: raw.typeMode ?? DEFAULT_TYPE_MODE,
203
220
  audit: raw.audit,
204
- conventions: raw.conventions
221
+ conventions: raw.conventions,
222
+ statesManifest: raw.statesManifest,
223
+ coverageBaseline: raw.coverageBaseline
205
224
  };
206
225
  }
207
226
  function parseConfig(json) {
@@ -290,7 +309,12 @@ var BASE_EXCLUDES = [
290
309
  "**/dist/**",
291
310
  "**/build/**",
292
311
  "**/.next/**",
293
- "**/*.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"
294
318
  ];
295
319
  var TEST_EXCLUDES = [
296
320
  "**/*.test.ts",
@@ -406,6 +430,285 @@ function* walkDir(root, dir) {
406
430
  }
407
431
  }
408
432
 
433
+ // src/scanner/scan/ast.ts
434
+ var path3 = __toESM(require("path"), 1);
435
+ var import_oxc_parser = require("oxc-parser");
436
+ function langFor(sourcePath) {
437
+ switch (path3.extname(sourcePath)) {
438
+ case ".tsx":
439
+ return "tsx";
440
+ case ".ts":
441
+ case ".mts":
442
+ case ".cts":
443
+ return "ts";
444
+ default:
445
+ return "jsx";
446
+ }
447
+ }
448
+ function makeLineAt(content) {
449
+ const starts = [0];
450
+ for (let i = 0; i < content.length; i++) {
451
+ if (content[i] === "\n") starts.push(i + 1);
452
+ }
453
+ return (offset) => {
454
+ let lo = 0;
455
+ let hi = starts.length - 1;
456
+ while (lo < hi) {
457
+ const mid = lo + hi + 1 >> 1;
458
+ if (starts[mid] <= offset) lo = mid;
459
+ else hi = mid - 1;
460
+ }
461
+ return lo + 1;
462
+ };
463
+ }
464
+ function parseSource(file) {
465
+ const lineAt = makeLineAt(file.content);
466
+ try {
467
+ const result = (0, import_oxc_parser.parseSync)(file.sourcePath, file.content, {
468
+ lang: langFor(file.sourcePath),
469
+ sourceType: "module"
470
+ });
471
+ return {
472
+ program: result.program,
473
+ hasErrors: result.errors.length > 0,
474
+ comments: result.comments ?? [],
475
+ lineAt
476
+ };
477
+ } catch {
478
+ return { program: null, hasErrors: true, comments: [], lineAt };
479
+ }
480
+ }
481
+ function isNode(value) {
482
+ return typeof value === "object" && value !== null && typeof value.type === "string";
483
+ }
484
+ function walkAst(root, visit) {
485
+ if (!isNode(root)) return;
486
+ if (visit(root) === false) return;
487
+ for (const key of Object.keys(root)) {
488
+ if (key === "type" || key === "start" || key === "end") continue;
489
+ const value = root[key];
490
+ if (Array.isArray(value)) {
491
+ for (const item of value) walkAst(item, visit);
492
+ } else if (isNode(value)) {
493
+ walkAst(value, visit);
494
+ }
495
+ }
496
+ }
497
+ function unwrapTsExpression(expr) {
498
+ let node = expr;
499
+ for (; ; ) {
500
+ switch (node.type) {
501
+ case "TSAsExpression":
502
+ case "TSSatisfiesExpression":
503
+ case "TSNonNullExpression":
504
+ case "TSTypeAssertion":
505
+ case "ParenthesizedExpression": {
506
+ const inner = node.expression;
507
+ if (!isNode(inner)) return node;
508
+ node = inner;
509
+ break;
510
+ }
511
+ default:
512
+ return node;
513
+ }
514
+ }
515
+ }
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
+
409
712
  // src/scanner/scan/extract-uidex-export.ts
410
713
  var KIND_DISCRIMINATORS = [
411
714
  "page",
@@ -423,26 +726,51 @@ var ALLOWED_FIELDS = {
423
726
  "features",
424
727
  "widgets",
425
728
  "acceptance",
729
+ "states",
730
+ "capture",
731
+ "waivers",
426
732
  "description"
427
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.
428
737
  feature: /* @__PURE__ */ new Set([
429
738
  "feature",
430
739
  "name",
431
740
  "features",
432
741
  "acceptance",
742
+ "states",
433
743
  "description"
434
744
  ]),
435
745
  primitive: /* @__PURE__ */ new Set(["primitive", "name", "description"]),
436
- widget: /* @__PURE__ */ new Set(["widget", "name", "acceptance", "description"]),
746
+ widget: /* @__PURE__ */ new Set(["widget", "name", "acceptance", "states", "description"]),
437
747
  region: /* @__PURE__ */ new Set(["region", "name", "description"]),
438
748
  flow: /* @__PURE__ */ new Set(["flow", "notFlow", "name", "description"])
439
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"]);
440
758
  var FALSEABLE = /* @__PURE__ */ new Set([
441
759
  "page",
442
760
  "feature",
443
761
  "primitive",
444
762
  "region"
445
763
  ]);
764
+ var SATISFIES_NAMES = {
765
+ page: "Page",
766
+ feature: "Feature",
767
+ primitive: "Primitive",
768
+ widget: "Widget",
769
+ region: "Region",
770
+ flow: "Flow",
771
+ notFlow: "NotFlow"
772
+ };
773
+ var KNOWN_SATISFIES = new Set(Object.values(SATISFIES_NAMES));
446
774
  var ExtractError = class extends Error {
447
775
  code;
448
776
  hint;
@@ -454,649 +782,285 @@ var ExtractError = class extends Error {
454
782
  this.hint = hint;
455
783
  }
456
784
  };
457
- function extractUidexExports(file) {
785
+ function extractUidexExports(file, parsed) {
458
786
  const exports2 = [];
459
787
  const diagnostics = [];
460
788
  const { content, displayPath } = file;
461
- for (const header of findExportHeaders(content)) {
462
- try {
463
- const value = parseExpression(content, header.exprStart);
464
- const metadata = buildMetadata(
465
- value,
466
- displayPath,
467
- header.headerPos,
468
- diagnostics
469
- );
470
- exports2.push(metadata);
471
- } catch (e) {
472
- if (e instanceof ExtractError) {
473
- diagnostics.push({
474
- code: e.code,
475
- severity: "error",
476
- message: e.message,
477
- file: displayPath,
478
- line: e.pos.line,
479
- hint: e.hint
480
- });
481
- } else {
482
- throw e;
789
+ const p2 = parsed ?? parseSource(file);
790
+ if (p2.program === null) return { exports: exports2, diagnostics };
791
+ for (const stmt of p2.program.body) {
792
+ if (stmt.type !== "ExportNamedDeclaration") continue;
793
+ const decl = stmt.declaration;
794
+ if (!isNode2(decl) || decl.type !== "VariableDeclaration") continue;
795
+ if (decl.kind !== "const") continue;
796
+ for (const declarator of decl.declarations ?? []) {
797
+ const id = declarator.id;
798
+ if (!id || id.type !== "Identifier" || String(id.name) !== "uidex") {
799
+ continue;
800
+ }
801
+ const headerPos = posAt(content, stmt.start, p2);
802
+ try {
803
+ const init = declarator.init;
804
+ if (!isNode2(init)) {
805
+ throw new ExtractError(
806
+ "uidex-export-invalid-literal",
807
+ "`export const uidex` must be assigned an object literal.",
808
+ headerPos
809
+ );
810
+ }
811
+ const value = toLitValue(unwrapTsExpression(init), content, p2);
812
+ const metadata = buildMetadata(
813
+ value,
814
+ displayPath,
815
+ file.sourcePath,
816
+ headerPos,
817
+ diagnostics
818
+ );
819
+ metadata.span = statementSpan(stmt, content);
820
+ checkSatisfies(init, metadata, displayPath, p2, diagnostics);
821
+ exports2.push(metadata);
822
+ } catch (e) {
823
+ if (e instanceof ExtractError) {
824
+ diagnostics.push({
825
+ code: e.code,
826
+ severity: "error",
827
+ message: e.message,
828
+ file: displayPath,
829
+ line: e.pos.line,
830
+ hint: e.hint
831
+ });
832
+ } else {
833
+ throw e;
834
+ }
483
835
  }
484
836
  }
485
837
  }
486
838
  return { exports: exports2, diagnostics };
487
839
  }
488
- var HEADER_RE = /(?:^|\n)[\t ]*export\s+const\s+uidex\b(?:\s*:\s*[^=\n]+?)?\s*=\s*/g;
489
- function findExportHeaders(content) {
490
- const out2 = [];
491
- HEADER_RE.lastIndex = 0;
492
- let m;
493
- while ((m = HEADER_RE.exec(content)) !== null) {
494
- const leadingNewline = m[0].startsWith("\n") ? 1 : 0;
495
- const headerOffset = m.index + leadingNewline;
496
- const exprStart = m.index + m[0].length;
497
- if (isInsideCommentOrString(content, headerOffset)) continue;
498
- out2.push({
499
- headerPos: posAt(content, headerOffset),
500
- exprStart
501
- });
502
- }
503
- return out2;
504
- }
505
- function isInsideCommentOrString(content, target) {
506
- let i = 0;
507
- let inLineComment = false;
508
- let inBlockComment = false;
509
- let stringDelim = null;
510
- let inTemplate = false;
511
- let templateDepth = 0;
512
- while (i < target) {
513
- const c = content[i];
514
- const n = content[i + 1];
515
- if (inLineComment) {
516
- if (c === "\n") inLineComment = false;
517
- i++;
518
- continue;
840
+ function toLitValue(node, content, p2) {
841
+ const unwrapped = unwrapTsExpression(node);
842
+ const pos = posAt(content, unwrapped.start, p2);
843
+ const span = { start: unwrapped.start, end: unwrapped.end };
844
+ switch (unwrapped.type) {
845
+ case "Literal": {
846
+ const v = unwrapped.value;
847
+ if (typeof v === "string") return { kind: "string", value: v, pos, span };
848
+ if (typeof v === "number") {
849
+ if (!Number.isFinite(v)) {
850
+ throw new ExtractError(
851
+ "uidex-export-invalid-literal",
852
+ `Invalid numeric literal in \`export const uidex\`.`,
853
+ pos
854
+ );
855
+ }
856
+ return { kind: "number", value: v, pos, span };
857
+ }
858
+ if (typeof v === "boolean") {
859
+ return { kind: "boolean", value: v, pos, span };
860
+ }
861
+ if (v === null && unwrapped.raw === "null") {
862
+ return { kind: "null", pos, span };
863
+ }
864
+ throw new ExtractError(
865
+ "uidex-export-invalid-literal",
866
+ `Unsupported literal in \`export const uidex\`; only strings, numbers, booleans, and null are allowed.`,
867
+ pos
868
+ );
519
869
  }
520
- if (inBlockComment) {
521
- if (c === "*" && n === "/") {
522
- inBlockComment = false;
523
- i += 2;
524
- continue;
870
+ case "UnaryExpression": {
871
+ const arg = unwrapped.argument;
872
+ if (unwrapped.operator === "-" && isNode2(arg) && arg.type === "Literal" && typeof arg.value === "number") {
873
+ return { kind: "number", value: -arg.value, pos, span };
525
874
  }
526
- i++;
527
- continue;
875
+ throw new ExtractError(
876
+ "uidex-export-invalid-literal",
877
+ "Unary expressions are not allowed in `export const uidex`.",
878
+ pos
879
+ );
528
880
  }
529
- if (stringDelim !== null) {
530
- if (c === "\\") {
531
- i += 2;
532
- continue;
881
+ case "TemplateLiteral": {
882
+ const expressions = unwrapped.expressions ?? [];
883
+ if (expressions.length > 0) {
884
+ throw new ExtractError(
885
+ "uidex-export-invalid-literal",
886
+ "Template literal with expression parts is not allowed in `export const uidex`; use a plain string literal.",
887
+ pos
888
+ );
533
889
  }
534
- if (c === stringDelim) stringDelim = null;
535
- i++;
536
- continue;
890
+ const quasis = unwrapped.quasis ?? [];
891
+ const cooked = quasis[0]?.value?.cooked ?? "";
892
+ return { kind: "string", value: cooked, pos, span };
537
893
  }
538
- if (inTemplate) {
539
- if (c === "\\") {
540
- i += 2;
541
- continue;
542
- }
543
- if (c === "$" && n === "{") {
544
- templateDepth++;
545
- i += 2;
546
- continue;
547
- }
548
- if (c === "`" && templateDepth === 0) {
549
- inTemplate = false;
550
- i++;
551
- continue;
552
- }
553
- if (templateDepth > 0 && c === "}") {
554
- templateDepth--;
555
- i++;
556
- continue;
894
+ case "Identifier": {
895
+ const name = String(unwrapped.name);
896
+ if (name === "undefined") {
897
+ throw new ExtractError(
898
+ "uidex-export-invalid-literal",
899
+ "`undefined` is not allowed as a value in `export const uidex`; omit the field instead.",
900
+ pos
901
+ );
557
902
  }
558
- i++;
559
- continue;
560
- }
561
- if (c === "/" && n === "/") {
562
- inLineComment = true;
563
- i += 2;
564
- continue;
565
- }
566
- if (c === "/" && n === "*") {
567
- inBlockComment = true;
568
- i += 2;
569
- continue;
570
- }
571
- if (c === '"' || c === "'") {
572
- stringDelim = c;
573
- i++;
574
- continue;
575
- }
576
- if (c === "`") {
577
- inTemplate = true;
578
- i++;
579
- continue;
903
+ throw new ExtractError(
904
+ "uidex-export-invalid-literal",
905
+ `Identifier reference "${name}" is not allowed in \`export const uidex\`; the right-hand side must be a plain literal.`,
906
+ pos
907
+ );
580
908
  }
581
- i++;
909
+ case "ObjectExpression":
910
+ return objectLit(unwrapped, content, p2, pos, span);
911
+ case "ArrayExpression":
912
+ return arrayLit(unwrapped, content, p2, pos, span);
913
+ case "SpreadElement":
914
+ throw new ExtractError(
915
+ "uidex-export-invalid-literal",
916
+ "Spread (`...`) is not allowed in `export const uidex`; the right-hand side must be a plain literal.",
917
+ pos
918
+ );
919
+ case "CallExpression":
920
+ throw new ExtractError(
921
+ "uidex-export-invalid-literal",
922
+ "Function calls are not allowed in `export const uidex`; the right-hand side must be a plain literal.",
923
+ pos
924
+ );
925
+ case "ConditionalExpression":
926
+ throw new ExtractError(
927
+ "uidex-export-invalid-literal",
928
+ "Conditional expressions are not allowed in `export const uidex`; the right-hand side must be a plain literal.",
929
+ pos
930
+ );
931
+ default:
932
+ throw new ExtractError(
933
+ "uidex-export-invalid-literal",
934
+ `Computed expressions are not allowed in \`export const uidex\`; the right-hand side must be a plain literal.`,
935
+ pos
936
+ );
582
937
  }
583
- return inLineComment || inBlockComment || stringDelim !== null || inTemplate;
584
938
  }
585
- var Tokenizer = class {
586
- constructor(src, start) {
587
- this.src = src;
588
- this.pos = start;
589
- let line = 1;
590
- let lineStart = 0;
591
- for (let i = 0; i < start; i++) {
592
- if (src[i] === "\n") {
593
- line++;
594
- lineStart = i + 1;
595
- }
596
- }
597
- this.line = line;
598
- this.lineStart = lineStart;
599
- }
600
- src;
601
- pos;
602
- line;
603
- lineStart;
604
- currentPos() {
605
- return {
606
- offset: this.pos,
607
- line: this.line,
608
- column: this.pos - this.lineStart + 1
609
- };
610
- }
611
- advance(n = 1) {
612
- for (let i = 0; i < n; i++) {
613
- if (this.pos < this.src.length && this.src[this.pos] === "\n") {
614
- this.line++;
615
- this.lineStart = this.pos + 1;
616
- }
617
- this.pos++;
618
- }
619
- }
620
- skipTrivia() {
621
- while (this.pos < this.src.length) {
622
- const c = this.src[this.pos];
623
- const n = this.src[this.pos + 1];
624
- if (c === " " || c === " " || c === "\r" || c === "\n") {
625
- this.advance();
626
- continue;
627
- }
628
- if (c === "/" && n === "/") {
629
- while (this.pos < this.src.length && this.src[this.pos] !== "\n") {
630
- this.advance();
631
- }
632
- continue;
633
- }
634
- if (c === "/" && n === "*") {
635
- this.advance(2);
636
- while (this.pos < this.src.length) {
637
- if (this.src[this.pos] === "*" && this.src[this.pos + 1] === "/") {
638
- this.advance(2);
639
- break;
640
- }
641
- this.advance();
642
- }
643
- continue;
644
- }
645
- break;
646
- }
647
- }
648
- next() {
649
- this.skipTrivia();
650
- if (this.pos >= this.src.length) {
651
- return { kind: "eof", value: "", pos: this.currentPos(), end: this.pos };
652
- }
653
- const pos = this.currentPos();
654
- const c = this.src[this.pos];
655
- switch (c) {
656
- case "{":
657
- this.advance();
658
- return { kind: "lbrace", value: c, pos, end: this.pos };
659
- case "}":
660
- this.advance();
661
- return { kind: "rbrace", value: c, pos, end: this.pos };
662
- case "[":
663
- this.advance();
664
- return { kind: "lbracket", value: c, pos, end: this.pos };
665
- case "]":
666
- this.advance();
667
- return { kind: "rbracket", value: c, pos, end: this.pos };
668
- case "(":
669
- this.advance();
670
- return { kind: "lparen", value: c, pos, end: this.pos };
671
- case ")":
672
- this.advance();
673
- return { kind: "rparen", value: c, pos, end: this.pos };
674
- case ",":
675
- this.advance();
676
- return { kind: "comma", value: c, pos, end: this.pos };
677
- case ":":
678
- this.advance();
679
- return { kind: "colon", value: c, pos, end: this.pos };
680
- }
681
- if (c === "." && this.src[this.pos + 1] === "." && this.src[this.pos + 2] === ".") {
682
- this.advance(3);
683
- return { kind: "spread", value: "...", pos, end: this.pos };
684
- }
685
- if (c === '"' || c === "'") {
686
- return this.readString(pos, c);
687
- }
688
- if (c === "`") {
689
- return this.readTemplate(pos);
690
- }
691
- if (isDigit(c) || c === "-" && isDigit(this.src[this.pos + 1])) {
692
- return this.readNumber(pos);
693
- }
694
- if (isIdentStart(c)) {
695
- return this.readIdent(pos);
696
- }
697
- this.advance();
698
- return { kind: "punct", value: c, pos, end: this.pos };
699
- }
700
- readString(pos, delim) {
701
- this.advance();
702
- let value = "";
703
- while (this.pos < this.src.length) {
704
- const c = this.src[this.pos];
705
- if (c === "\\") {
706
- const esc = this.src[this.pos + 1];
707
- this.advance(2);
708
- value += decodeEscape(esc);
709
- continue;
710
- }
711
- if (c === delim) {
712
- this.advance();
713
- return { kind: "string", value, pos, end: this.pos };
714
- }
715
- if (c === "\n") {
716
- return { kind: "punct", value: delim, pos, end: this.pos };
717
- }
718
- value += c;
719
- this.advance();
720
- }
721
- return { kind: "punct", value: delim, pos, end: this.pos };
722
- }
723
- readTemplate(pos) {
724
- this.advance();
725
- let value = "";
726
- let hasExpression = false;
727
- while (this.pos < this.src.length) {
728
- const c = this.src[this.pos];
729
- const n = this.src[this.pos + 1];
730
- if (c === "\\") {
731
- const esc = this.src[this.pos + 1];
732
- this.advance(2);
733
- value += decodeEscape(esc);
734
- continue;
735
- }
736
- if (c === "$" && n === "{") {
737
- hasExpression = true;
738
- this.advance(2);
739
- let depth = 1;
740
- while (this.pos < this.src.length && depth > 0) {
741
- const ch = this.src[this.pos];
742
- if (ch === "{") depth++;
743
- else if (ch === "}") depth--;
744
- this.advance();
745
- }
746
- continue;
747
- }
748
- if (c === "`") {
749
- this.advance();
750
- if (hasExpression) {
751
- return { kind: "template", value, pos, end: this.pos };
752
- }
753
- return { kind: "string", value, pos, end: this.pos };
754
- }
755
- value += c;
756
- this.advance();
757
- }
758
- return { kind: "template", value, pos, end: this.pos };
759
- }
760
- readNumber(pos) {
761
- const start = this.pos;
762
- if (this.src[this.pos] === "-") this.advance();
763
- while (this.pos < this.src.length && isDigit(this.src[this.pos])) {
764
- this.advance();
765
- }
766
- if (this.src[this.pos] === ".") {
767
- this.advance();
768
- while (this.pos < this.src.length && isDigit(this.src[this.pos])) {
769
- this.advance();
770
- }
771
- }
772
- if (this.src[this.pos] === "e" || this.src[this.pos] === "E") {
773
- this.advance();
774
- if (this.src[this.pos] === "+" || this.src[this.pos] === "-") {
775
- this.advance();
776
- }
777
- while (this.pos < this.src.length && isDigit(this.src[this.pos])) {
778
- this.advance();
779
- }
939
+ function objectLit(node, content, p2, pos, span) {
940
+ const entries = [];
941
+ const seen = /* @__PURE__ */ new Set();
942
+ for (const prop of node.properties ?? []) {
943
+ if (prop.type === "SpreadElement") {
944
+ throw new ExtractError(
945
+ "uidex-export-invalid-literal",
946
+ "Spread (`...`) is not allowed inside `export const uidex`.",
947
+ posAt(content, prop.start, p2)
948
+ );
780
949
  }
781
- const value = this.src.slice(start, this.pos);
782
- return { kind: "number", value, pos, end: this.pos };
783
- }
784
- readIdent(pos) {
785
- const start = this.pos;
786
- while (this.pos < this.src.length && isIdentPart(this.src[this.pos])) {
787
- this.advance();
950
+ if (prop.type !== "Property") {
951
+ throw new ExtractError(
952
+ "uidex-export-invalid-literal",
953
+ "Unexpected member inside `export const uidex` object.",
954
+ posAt(content, prop.start, p2)
955
+ );
788
956
  }
789
- const value = this.src.slice(start, this.pos);
790
- return { kind: "ident", value, pos, end: this.pos };
791
- }
792
- };
793
- function isDigit(c) {
794
- return c !== void 0 && c >= "0" && c <= "9";
795
- }
796
- function isIdentStart(c) {
797
- if (c === void 0) return false;
798
- return c >= "a" && c <= "z" || c >= "A" && c <= "Z" || c === "_" || c === "$";
799
- }
800
- function isIdentPart(c) {
801
- return isIdentStart(c) || isDigit(c);
802
- }
803
- function decodeEscape(esc) {
804
- switch (esc) {
805
- case "n":
806
- return "\n";
807
- case "t":
808
- return " ";
809
- case "r":
810
- return "\r";
811
- case "\\":
812
- return "\\";
813
- case "'":
814
- return "'";
815
- case '"':
816
- return '"';
817
- case "`":
818
- return "`";
819
- case "0":
820
- return "\0";
821
- case "b":
822
- return "\b";
823
- case "f":
824
- return "\f";
825
- case "v":
826
- return "\v";
827
- default:
828
- return esc ?? "";
829
- }
830
- }
831
- function parseExpression(content, start) {
832
- const tokenizer = new Tokenizer(content, start);
833
- const parser = new Parser(tokenizer);
834
- const value = parser.parseValue();
835
- parser.consumeTrailingAssertions();
836
- return value;
837
- }
838
- var Parser = class {
839
- constructor(tok) {
840
- this.tok = tok;
841
- }
842
- tok;
843
- lookahead = null;
844
- peek() {
845
- if (this.lookahead === null) this.lookahead = this.tok.next();
846
- return this.lookahead;
847
- }
848
- consume() {
849
- const t = this.peek();
850
- this.lookahead = null;
851
- return t;
852
- }
853
- parseValue() {
854
- const t = this.peek();
855
- switch (t.kind) {
856
- case "lbrace":
857
- return this.parseObject();
858
- case "lbracket":
859
- return this.parseArray();
860
- case "string":
861
- this.consume();
862
- return { kind: "string", value: t.value, pos: t.pos };
863
- case "template":
864
- throw new ExtractError(
865
- "uidex-export-invalid-literal",
866
- "Template literal with expression parts is not allowed in `export const uidex`; use a plain string literal.",
867
- t.pos
868
- );
869
- case "number": {
870
- this.consume();
871
- const n = Number(t.value);
872
- if (!Number.isFinite(n)) {
873
- throw new ExtractError(
874
- "uidex-export-invalid-literal",
875
- `Invalid numeric literal "${t.value}" in \`export const uidex\`.`,
876
- t.pos
877
- );
878
- }
879
- return { kind: "number", value: n, pos: t.pos };
880
- }
881
- case "ident":
882
- if (t.value === "true" || t.value === "false") {
883
- this.consume();
884
- return {
885
- kind: "boolean",
886
- value: t.value === "true",
887
- pos: t.pos
888
- };
889
- }
890
- if (t.value === "null") {
891
- this.consume();
892
- return { kind: "null", pos: t.pos };
893
- }
894
- if (t.value === "undefined") {
895
- throw new ExtractError(
896
- "uidex-export-invalid-literal",
897
- "`undefined` is not allowed as a value in `export const uidex`; omit the field instead.",
898
- t.pos
899
- );
900
- }
901
- throw new ExtractError(
902
- "uidex-export-invalid-literal",
903
- `Identifier reference "${t.value}" is not allowed in \`export const uidex\`; the right-hand side must be a plain literal.`,
904
- t.pos
905
- );
906
- case "spread":
907
- throw new ExtractError(
908
- "uidex-export-invalid-literal",
909
- "Spread (`...`) is not allowed in `export const uidex`; the right-hand side must be a plain literal.",
910
- t.pos
911
- );
912
- case "lparen":
913
- throw new ExtractError(
914
- "uidex-export-invalid-literal",
915
- "Parenthesised or grouped expressions are not allowed in `export const uidex`.",
916
- t.pos
917
- );
918
- case "punct":
919
- throw new ExtractError(
920
- "uidex-export-invalid-literal",
921
- `Unexpected token "${t.value}" in \`export const uidex\`.`,
922
- t.pos
923
- );
924
- case "eof":
925
- throw new ExtractError(
926
- "uidex-export-invalid-literal",
927
- "Expected a value for `export const uidex` but reached end of file.",
928
- t.pos
929
- );
930
- default:
931
- throw new ExtractError(
932
- "uidex-export-invalid-literal",
933
- `Unexpected token in \`export const uidex\`.`,
934
- t.pos
935
- );
957
+ const keyNode = prop.key;
958
+ const keyPos = posAt(content, keyNode.start, p2);
959
+ if (prop.shorthand) {
960
+ throw new ExtractError(
961
+ "uidex-export-invalid-literal",
962
+ `Shorthand property "${String(keyNode.name)}" is not allowed; write "${String(keyNode.name)}: ..." with a literal value.`,
963
+ keyPos
964
+ );
936
965
  }
937
- }
938
- parseObject() {
939
- const open = this.consume();
940
- const entries = [];
941
- const seen = /* @__PURE__ */ new Set();
942
- while (true) {
943
- const t = this.peek();
944
- if (t.kind === "rbrace") {
945
- this.consume();
946
- break;
947
- }
948
- if (t.kind === "spread") {
949
- throw new ExtractError(
950
- "uidex-export-invalid-literal",
951
- "Spread (`...`) is not allowed inside `export const uidex`.",
952
- t.pos
953
- );
954
- }
955
- if (t.kind === "lbracket") {
956
- this.consume();
957
- const keyTok = this.peek();
958
- if (keyTok.kind !== "string") {
959
- throw new ExtractError(
960
- "uidex-export-invalid-literal",
961
- "Computed property keys must be string literals in `export const uidex`.",
962
- keyTok.pos
963
- );
964
- }
965
- this.consume();
966
- const close = this.peek();
967
- if (close.kind !== "rbracket") {
968
- throw new ExtractError(
969
- "uidex-export-invalid-literal",
970
- "Expected `]` after computed property key.",
971
- close.pos
972
- );
973
- }
974
- this.consume();
975
- const colon = this.peek();
976
- if (colon.kind !== "colon") {
977
- throw new ExtractError(
978
- "uidex-export-invalid-literal",
979
- "Expected `:` after computed property key.",
980
- colon.pos
981
- );
982
- }
983
- this.consume();
984
- const value = this.parseValue();
985
- this.recordEntry(entries, seen, keyTok.value, value, keyTok.pos);
986
- } else if (t.kind === "ident" || t.kind === "string") {
987
- const keyTok = this.consume();
988
- const next = this.peek();
989
- if (next.kind === "colon") {
990
- this.consume();
991
- const value = this.parseValue();
992
- this.recordEntry(entries, seen, keyTok.value, value, keyTok.pos);
993
- } else {
994
- throw new ExtractError(
995
- "uidex-export-invalid-literal",
996
- keyTok.kind === "ident" ? `Shorthand property "${keyTok.value}" is not allowed; write "${keyTok.value}: ..." with a literal value.` : "Expected `:` after property key.",
997
- keyTok.pos
998
- );
999
- }
1000
- } else if (t.kind === "number") {
1001
- throw new ExtractError(
1002
- "uidex-export-invalid-literal",
1003
- "Numeric property keys are not allowed in `export const uidex`.",
1004
- t.pos
1005
- );
1006
- } else {
966
+ let key;
967
+ if (prop.computed) {
968
+ if (keyNode.type !== "Literal" || typeof keyNode.value !== "string") {
1007
969
  throw new ExtractError(
1008
970
  "uidex-export-invalid-literal",
1009
- `Unexpected token "${t.value}" inside object.`,
1010
- t.pos
971
+ "Computed property keys must be string literals in `export const uidex`.",
972
+ keyPos
1011
973
  );
1012
974
  }
1013
- const after = this.peek();
1014
- if (after.kind === "comma") {
1015
- this.consume();
1016
- continue;
1017
- }
1018
- if (after.kind === "rbrace") {
1019
- this.consume();
1020
- break;
1021
- }
975
+ key = keyNode.value;
976
+ } else if (keyNode.type === "Identifier") {
977
+ key = String(keyNode.name);
978
+ } else if (keyNode.type === "Literal" && typeof keyNode.value === "string") {
979
+ key = keyNode.value;
980
+ } else {
1022
981
  throw new ExtractError(
1023
982
  "uidex-export-invalid-literal",
1024
- `Expected \`,\` or \`}\`, got "${after.value}".`,
1025
- after.pos
983
+ "Numeric property keys are not allowed in `export const uidex`.",
984
+ keyPos
1026
985
  );
1027
986
  }
1028
- return { kind: "object", entries, pos: open.pos };
1029
- }
1030
- recordEntry(entries, seen, key, value, pos) {
1031
987
  if (seen.has(key)) {
1032
988
  throw new ExtractError(
1033
989
  "uidex-export-duplicate-field",
1034
990
  `Duplicate field "${key}" in \`export const uidex\`.`,
1035
- pos
991
+ keyPos
1036
992
  );
1037
993
  }
1038
994
  seen.add(key);
1039
- entries.push([key, value]);
1040
- }
1041
- parseArray() {
1042
- const open = this.consume();
1043
- const items = [];
1044
- while (true) {
1045
- const t = this.peek();
1046
- if (t.kind === "rbracket") {
1047
- this.consume();
1048
- break;
1049
- }
1050
- if (t.kind === "spread") {
1051
- throw new ExtractError(
1052
- "uidex-export-invalid-literal",
1053
- "Spread (`...`) is not allowed inside `export const uidex`.",
1054
- t.pos
1055
- );
1056
- }
1057
- const value = this.parseValue();
1058
- if (value.kind === "object") {
1059
- }
1060
- items.push(value);
1061
- const after = this.peek();
1062
- if (after.kind === "comma") {
1063
- this.consume();
1064
- continue;
1065
- }
1066
- if (after.kind === "rbracket") {
1067
- this.consume();
1068
- break;
1069
- }
995
+ const value = toLitValue(prop.value, content, p2);
996
+ entries.push({
997
+ key,
998
+ value,
999
+ keyPos,
1000
+ span: removalSpan(content, prop.start, prop.end)
1001
+ });
1002
+ }
1003
+ return { kind: "object", entries, pos, span };
1004
+ }
1005
+ function arrayLit(node, content, p2, pos, span) {
1006
+ const items = [];
1007
+ for (const el of node.elements ?? []) {
1008
+ if (el === null) {
1009
+ throw new ExtractError(
1010
+ "uidex-export-invalid-literal",
1011
+ "Array holes are not allowed in `export const uidex`.",
1012
+ pos
1013
+ );
1014
+ }
1015
+ if (el.type === "SpreadElement") {
1070
1016
  throw new ExtractError(
1071
1017
  "uidex-export-invalid-literal",
1072
- `Expected \`,\` or \`]\`, got "${after.value}".`,
1073
- after.pos
1018
+ "Spread (`...`) is not allowed inside `export const uidex`.",
1019
+ posAt(content, el.start, p2)
1074
1020
  );
1075
1021
  }
1076
- return { kind: "array", items, pos: open.pos };
1022
+ items.push(toLitValue(el, content, p2));
1077
1023
  }
1078
- consumeTrailingAssertions() {
1079
- const first = this.peek();
1080
- if (first.kind === "ident" && first.value === "as") {
1081
- this.consume();
1082
- const next = this.peek();
1083
- if (next.kind === "ident" && next.value === "const") {
1084
- this.consume();
1085
- } else {
1086
- throw new ExtractError(
1087
- "uidex-export-invalid-literal",
1088
- "Only `as const` is allowed after the `export const uidex` value.",
1089
- next.pos
1090
- );
1091
- }
1024
+ return { kind: "array", items, pos, span };
1025
+ }
1026
+ function checkSatisfies(init, metadata, file, p2, diagnostics) {
1027
+ let node = init;
1028
+ let satisfiesType;
1029
+ while (isNode2(node)) {
1030
+ if (node.type === "TSSatisfiesExpression") {
1031
+ satisfiesType = node.typeAnnotation;
1032
+ break;
1092
1033
  }
1093
- const maybeSatisfies = this.peek();
1094
- if (maybeSatisfies.kind === "ident" && maybeSatisfies.value === "satisfies") {
1095
- return;
1034
+ if (node.type === "TSAsExpression" || node.type === "TSNonNullExpression" || node.type === "TSTypeAssertion" || node.type === "ParenthesizedExpression") {
1035
+ node = node.expression;
1036
+ continue;
1096
1037
  }
1038
+ break;
1097
1039
  }
1098
- };
1099
- function buildMetadata(value, file, headerPos, diagnostics) {
1040
+ if (!isNode2(satisfiesType) || satisfiesType.type !== "TSTypeReference") return;
1041
+ const typeName = satisfiesType.typeName;
1042
+ if (!isNode2(typeName) || typeName.type !== "TSQualifiedName") return;
1043
+ const left = typeName.left;
1044
+ const right = typeName.right;
1045
+ if (!isNode2(left) || left.type !== "Identifier" || left.name !== "Uidex") {
1046
+ return;
1047
+ }
1048
+ if (!isNode2(right) || right.type !== "Identifier") return;
1049
+ const actual = String(right.name);
1050
+ if (!KNOWN_SATISFIES.has(actual)) return;
1051
+ const discriminator = metadata.notFlow ? "notFlow" : metadata.kind;
1052
+ const expected = SATISFIES_NAMES[discriminator];
1053
+ if (actual === expected) return;
1054
+ diagnostics.push({
1055
+ code: "uidex-export-satisfies-mismatch",
1056
+ severity: "warning",
1057
+ message: `\`export const uidex\` declares kind "${discriminator}" but is annotated \`satisfies Uidex.${actual}\`; expected \`Uidex.${expected}\`.`,
1058
+ file,
1059
+ line: p2.lineAt(satisfiesType.start),
1060
+ hint: `Change the annotation to \`satisfies Uidex.${expected}\` or fix the kind discriminator.`
1061
+ });
1062
+ }
1063
+ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
1100
1064
  if (value.kind !== "object") {
1101
1065
  throw new ExtractError(
1102
1066
  "uidex-export-invalid-literal",
@@ -1105,7 +1069,7 @@ function buildMetadata(value, file, headerPos, diagnostics) {
1105
1069
  );
1106
1070
  }
1107
1071
  const byKey = /* @__PURE__ */ new Map();
1108
- for (const [k, v] of value.entries) byKey.set(k, v);
1072
+ for (const entry of value.entries) byKey.set(entry.key, entry);
1109
1073
  const presentKinds = KIND_DISCRIMINATORS.filter(
1110
1074
  (k) => byKey.has(k)
1111
1075
  );
@@ -1128,49 +1092,61 @@ function buildMetadata(value, file, headerPos, diagnostics) {
1128
1092
  const discriminator = presentKinds[0];
1129
1093
  const kind = discriminator === "notFlow" ? "flow" : discriminator;
1130
1094
  const allowed = ALLOWED_FIELDS[kind];
1131
- for (const [k] of value.entries) {
1132
- if (!allowed.has(k)) {
1133
- const fieldVal = byKey.get(k);
1095
+ for (const entry of value.entries) {
1096
+ if (!allowed.has(entry.key)) {
1134
1097
  throw new ExtractError(
1135
1098
  "uidex-export-unknown-field",
1136
- `Unknown field "${k}" in \`export const uidex\` for kind "${kind}". Allowed: ${Array.from(
1099
+ `Unknown field "${entry.key}" in \`export const uidex\` for kind "${kind}". Allowed: ${Array.from(
1137
1100
  allowed
1138
1101
  ).sort().join(", ")}.`,
1139
- fieldVal.pos
1102
+ entry.value.pos
1140
1103
  );
1141
1104
  }
1142
1105
  }
1143
1106
  const idField = discriminator === "notFlow" ? "flow" : discriminator;
1144
- const idValue = byKey.get(discriminator);
1107
+ const idValue = byKey.get(discriminator).value;
1145
1108
  let id;
1146
1109
  if (discriminator === "notFlow") {
1147
- const v = idValue;
1148
- if (v.kind !== "boolean" || v.value !== true) {
1110
+ if (idValue.kind !== "boolean" || idValue.value !== true) {
1149
1111
  throw new ExtractError(
1150
1112
  "uidex-export-invalid-field",
1151
1113
  "`notFlow` must be `true`.",
1152
- v.pos
1114
+ idValue.pos
1153
1115
  );
1154
1116
  }
1155
1117
  id = false;
1156
1118
  } else {
1157
1119
  id = readIdField(idValue, kind, idField);
1158
1120
  }
1159
- const acceptance = readStringArrayField(byKey, "acceptance");
1121
+ const acceptance = readStringArrayField(byKey, "acceptance")?.values;
1160
1122
  const description = readStringField(byKey, "description");
1161
1123
  const name = readStringField(byKey, "name");
1162
1124
  if (name === "") {
1163
- const pos = byKey.get("name").pos;
1125
+ const entry = byKey.get("name");
1164
1126
  diagnostics.push({
1165
1127
  code: "uidex-export-empty-name",
1166
1128
  severity: "info",
1167
1129
  message: "`name` is an empty string; treating as unset.",
1168
1130
  file,
1169
- line: pos.line
1131
+ line: entry.value.pos.line,
1132
+ fix: {
1133
+ description: "Remove the empty `name` field",
1134
+ edits: [
1135
+ {
1136
+ path: sourcePath,
1137
+ start: entry.span.start,
1138
+ end: entry.span.end,
1139
+ replacement: ""
1140
+ }
1141
+ ]
1142
+ }
1170
1143
  });
1171
1144
  }
1172
- const features = kind === "page" || kind === "feature" ? readStringArrayField(byKey, "features") : void 0;
1173
- const widgets = kind === "page" ? readStringArrayField(byKey, "widgets") : void 0;
1145
+ const featuresField = kind === "page" || kind === "feature" ? readStringArrayField(byKey, "features") : void 0;
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;
1174
1150
  const notFlow = kind === "flow" && discriminator === "notFlow" ? true : void 0;
1175
1151
  const metadata = {
1176
1152
  source: "ts-export",
@@ -1185,9 +1161,24 @@ function buildMetadata(value, file, headerPos, diagnostics) {
1185
1161
  if (name) metadata.name = name;
1186
1162
  if (acceptance) metadata.acceptance = acceptance;
1187
1163
  if (description) metadata.description = description;
1188
- if (features) metadata.features = features;
1189
- if (widgets) metadata.widgets = widgets;
1164
+ if (featuresField) {
1165
+ metadata.features = featuresField.values;
1166
+ metadata.featureSpans = featuresField.spans;
1167
+ }
1168
+ if (widgetsField) {
1169
+ metadata.widgets = widgetsField.values;
1170
+ metadata.widgetSpans = widgetsField.spans;
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;
1190
1175
  if (notFlow) metadata.notFlow = true;
1176
+ if (typeof id === "string" && idValue.kind === "string") {
1177
+ metadata.idSpan = idValue.span;
1178
+ }
1179
+ const fieldSpans = {};
1180
+ for (const entry of value.entries) fieldSpans[entry.key] = entry.span;
1181
+ metadata.fieldSpans = fieldSpans;
1191
1182
  return metadata;
1192
1183
  }
1193
1184
  function readIdField(value, kind, fieldName) {
@@ -1217,30 +1208,73 @@ function readIdField(value, kind, fieldName) {
1217
1208
  value.pos
1218
1209
  );
1219
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
+ }
1220
1253
  function readStringField(byKey, name) {
1221
- const v = byKey.get(name);
1222
- if (!v) return void 0;
1223
- if (v.kind !== "string") {
1254
+ const entry = byKey.get(name);
1255
+ if (!entry) return void 0;
1256
+ if (entry.value.kind !== "string") {
1224
1257
  throw new ExtractError(
1225
1258
  "uidex-export-invalid-field",
1226
1259
  `\`${name}\` must be a string.`,
1227
- v.pos
1260
+ entry.value.pos
1228
1261
  );
1229
1262
  }
1230
- return v.value;
1263
+ return entry.value.value;
1231
1264
  }
1232
1265
  function readStringArrayField(byKey, name) {
1233
- const v = byKey.get(name);
1234
- if (!v) return void 0;
1235
- if (v.kind !== "array") {
1266
+ const entry = byKey.get(name);
1267
+ if (!entry) return void 0;
1268
+ if (entry.value.kind !== "array") {
1236
1269
  throw new ExtractError(
1237
1270
  "uidex-export-invalid-field",
1238
1271
  `\`${name}\` must be an array of strings.`,
1239
- v.pos
1272
+ entry.value.pos
1240
1273
  );
1241
1274
  }
1242
- const out2 = [];
1243
- for (const item of v.items) {
1275
+ const values = [];
1276
+ const spans = [];
1277
+ for (const item of entry.value.items) {
1244
1278
  if (item.kind !== "string") {
1245
1279
  throw new ExtractError(
1246
1280
  "uidex-export-invalid-field",
@@ -1248,498 +1282,606 @@ function readStringArrayField(byKey, name) {
1248
1282
  item.pos
1249
1283
  );
1250
1284
  }
1251
- out2.push(item.value);
1252
- }
1253
- return out2;
1254
- }
1255
- function posAt(content, offset) {
1256
- let line = 1;
1257
- let lineStart = 0;
1258
- for (let i = 0; i < offset && i < content.length; i++) {
1259
- if (content[i] === "\n") {
1260
- line++;
1261
- lineStart = i + 1;
1262
- }
1285
+ values.push(item.value);
1286
+ spans.push(item.span);
1263
1287
  }
1264
- return { offset, line, column: offset - lineStart + 1 };
1288
+ return { values, spans };
1265
1289
  }
1266
-
1267
- // src/scanner/scan/jsx-ancestry.ts
1268
- var DATA_ATTR_RE = /\bdata-uidex(?:-(region|widget|primitive))?\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
1269
- var PATTERN_ATTR_RE = /\bdata-uidex(?:-(region|widget|primitive))?\s*=\s*\{\s*`([^`$]+)\$\{/g;
1270
- function parseDataAttrs(tagSource) {
1271
- if (!tagSource.includes("data-uidex")) return [];
1272
- const out2 = [];
1273
- for (const m of tagSource.matchAll(DATA_ATTR_RE)) {
1274
- const kind = m[1] ?? "element";
1275
- const id = m[2] ?? m[3];
1276
- if (id) out2.push({ kind, id });
1277
- }
1278
- for (const m of tagSource.matchAll(PATTERN_ATTR_RE)) {
1279
- const kind = m[1] ?? "element";
1280
- const prefix = m[2];
1281
- if (prefix) out2.push({ kind, id: `${prefix}*` });
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
+ );
1282
1299
  }
1283
- return out2;
1284
- }
1285
- function collectJSXAncestry(content) {
1286
- if (!content.includes("data-uidex")) return [];
1287
- const out2 = [];
1288
- const ancestors = [];
1289
- const stack = [];
1290
- const N = content.length;
1291
- let i = 0;
1292
- let line = 1;
1293
- const advanceLines = (from, to) => {
1294
- for (let k = from; k < to; k++) {
1295
- if (content.charCodeAt(k) === 10) line++;
1296
- }
1297
- };
1298
- while (i < N) {
1299
- const c = content[i];
1300
- if (c === "\n") {
1301
- line++;
1302
- i++;
1303
- continue;
1304
- }
1305
- if (c === "/" && content[i + 1] === "/") {
1306
- while (i < N && content[i] !== "\n") i++;
1307
- continue;
1308
- }
1309
- if (c === "/" && content[i + 1] === "*") {
1310
- const end = content.indexOf("*/", i + 2);
1311
- const next = end === -1 ? N : end + 2;
1312
- advanceLines(i, next);
1313
- i = next;
1314
- continue;
1315
- }
1316
- if (c === '"' || c === "'") {
1317
- const next = skipString(content, i, c);
1318
- advanceLines(i, next);
1319
- i = next;
1320
- continue;
1321
- }
1322
- if (c === "`") {
1323
- const next = skipTemplate(content, i);
1324
- advanceLines(i, next);
1325
- i = next;
1326
- continue;
1327
- }
1328
- if (c === "<") {
1329
- const nextCh = content[i + 1];
1330
- if (nextCh === "/") {
1331
- const end = content.indexOf(">", i);
1332
- if (end === -1) break;
1333
- const tagName = content.slice(i + 2, end).match(/^\s*([\w.-]*)/)?.[1] ?? "";
1334
- if (tagName) {
1335
- for (let k = stack.length - 1; k >= 0; k--) {
1336
- if (stack[k].tagName === tagName) {
1337
- for (let j = stack.length - 1; j >= k; j--) {
1338
- ancestors.length -= stack[j].pushed;
1339
- }
1340
- stack.length = k;
1341
- break;
1342
- }
1343
- }
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
+ );
1344
1318
  }
1345
- advanceLines(i, end + 1);
1346
- i = end + 1;
1347
- continue;
1348
1319
  }
1349
- if (nextCh && /[A-Za-z_]/.test(nextCh)) {
1350
- const end = findTagEnd(content, i + 1);
1351
- if (end === -1) break;
1352
- const tagSource = content.slice(i, end + 1);
1353
- const tagName = tagSource.match(/^<\s*([\w.-]*)/)?.[1] ?? "";
1354
- const isSelf = content[end - 1] === "/";
1355
- if (tagName) {
1356
- const attrs = parseDataAttrs(tagSource);
1357
- if (attrs.length > 0) {
1358
- const snapshot = ancestors.slice();
1359
- for (const a of attrs) {
1360
- out2.push({ kind: a.kind, id: a.id, line, ancestors: snapshot });
1361
- }
1362
- }
1363
- if (!isSelf) {
1364
- for (const a of attrs) ancestors.push(a);
1365
- stack.push({ tagName, pushed: attrs.length });
1366
- }
1367
- }
1368
- advanceLines(i, end + 1);
1369
- i = end + 1;
1370
- continue;
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
+ );
1371
1327
  }
1372
- }
1373
- i++;
1374
- }
1375
- return out2;
1376
- }
1377
- function skipString(content, start, quote) {
1378
- const N = content.length;
1379
- let i = start + 1;
1380
- while (i < N) {
1381
- const c = content[i];
1382
- if (c === "\\") {
1383
- i += 2;
1384
- continue;
1385
- }
1386
- if (c === quote) return i + 1;
1387
- i++;
1388
- }
1389
- return N;
1390
- }
1391
- function skipTemplate(content, start) {
1392
- const N = content.length;
1393
- let i = start + 1;
1394
- while (i < N) {
1395
- const c = content[i];
1396
- if (c === "\\") {
1397
- i += 2;
1398
- continue;
1399
- }
1400
- if (c === "`") return i + 1;
1401
- if (c === "$" && content[i + 1] === "{") {
1402
- i += 2;
1403
- let depth = 1;
1404
- while (i < N && depth > 0) {
1405
- const cj = content[i];
1406
- if (cj === '"' || cj === "'") {
1407
- i = skipString(content, i, cj);
1408
- continue;
1409
- }
1410
- if (cj === "`") {
1411
- i = skipTemplate(content, i);
1412
- continue;
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
+ );
1413
1337
  }
1414
- if (cj === "{") depth++;
1415
- else if (cj === "}") depth--;
1416
- i++;
1338
+ state.kind = kind;
1417
1339
  }
1418
- continue;
1419
- }
1420
- i++;
1421
- }
1422
- return N;
1423
- }
1424
- function findTagEnd(content, start) {
1425
- const N = content.length;
1426
- let i = start;
1427
- while (i < N) {
1428
- const c = content[i];
1429
- if (c === '"' || c === "'") {
1430
- i = skipString(content, i, c);
1431
- continue;
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
+ );
1432
1350
  }
1433
- if (c === "`") {
1434
- i = skipTemplate(content, i);
1435
- continue;
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
+ );
1436
1357
  }
1437
- if (c === "{") {
1438
- let depth = 1;
1439
- i++;
1440
- while (i < N && depth > 0) {
1441
- const cj = content[i];
1442
- if (cj === '"' || cj === "'") {
1443
- i = skipString(content, i, cj);
1444
- continue;
1445
- }
1446
- if (cj === "`") {
1447
- i = skipTemplate(content, i);
1448
- continue;
1449
- }
1450
- if (cj === "{") depth++;
1451
- else if (cj === "}") depth--;
1452
- i++;
1453
- }
1454
- continue;
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
+ );
1455
1364
  }
1456
- if (c === ">") return i;
1457
- i++;
1365
+ seen.add(state.name);
1366
+ states.push(state);
1458
1367
  }
1459
- return -1;
1368
+ return states;
1369
+ }
1370
+ function posAt(content, offset, p2) {
1371
+ const lineStart = content.lastIndexOf("\n", offset - 1) + 1;
1372
+ return { offset, line: p2.lineAt(offset), column: offset - lineStart + 1 };
1373
+ }
1374
+ function removalSpan(content, start, end) {
1375
+ let e = end;
1376
+ while (e < content.length && /[ \t]/.test(content[e])) e++;
1377
+ if (content[e] === ",") return { start, end: e + 1 };
1378
+ let s = start;
1379
+ while (s > 0 && /[\s]/.test(content[s - 1])) s--;
1380
+ if (content[s - 1] === ",") return { start: s - 1, end };
1381
+ return { start, end };
1382
+ }
1383
+ function statementSpan(stmt, content) {
1384
+ let end = stmt.end;
1385
+ while (end < content.length && /[ \t]/.test(content[end])) end++;
1386
+ if (content[end] === ";") end++;
1387
+ while (end < content.length && /[ \t]/.test(content[end])) end++;
1388
+ if (content[end] === "\r") end++;
1389
+ if (content[end] === "\n") end++;
1390
+ return { start: stmt.start, end };
1391
+ }
1392
+ function isNode2(value) {
1393
+ return typeof value === "object" && value !== null && typeof value.type === "string";
1460
1394
  }
1461
1395
 
1462
- // src/scanner/scan/extract.ts
1463
- var JSDOC_BLOCK = /\/\*\*([\s\S]*?)\*\//g;
1464
- function lineAt(content, index) {
1465
- let line = 1;
1466
- for (let i = 0; i < index && i < content.length; i++) {
1467
- if (content[i] === "\n") line++;
1468
- }
1469
- return line;
1396
+ // src/scanner/scan/flow-facts.ts
1397
+ function collectFlowFacts(parsed, content) {
1398
+ if (parsed.program === null || !content.includes("test.describe")) {
1399
+ return [];
1400
+ }
1401
+ const facts = [];
1402
+ walkAst(parsed.program, (node) => {
1403
+ if (node.type !== "CallExpression") return void 0;
1404
+ const fact = readTaggedDescribe(node, parsed);
1405
+ if (fact) facts.push(fact);
1406
+ return void 0;
1407
+ });
1408
+ return facts;
1470
1409
  }
1471
- function parseJSDoc(block) {
1472
- const lines = block.split("\n").map((l) => l.replace(/^\s*\*\s?/, "").replace(/^\s*\/?\*+/, ""));
1473
- let kind = null;
1474
- let id = null;
1475
- const acceptance = [];
1476
- const desc = [];
1477
- let notFlow = false;
1478
- for (const raw of lines) {
1479
- const line = raw.trim();
1480
- if (!line) continue;
1481
- const uidex = line.match(
1482
- /^@uidex\s+(page|feature|widget)\s+(\S+)(?:\s+-\s+(.+))?/
1483
- );
1484
- if (uidex) {
1485
- kind = uidex[1];
1486
- id = uidex[2];
1487
- if (uidex[3]) desc.push(uidex[3].trim());
1488
- continue;
1489
- }
1490
- if (/^@uidex:not-flow\b/.test(line)) {
1491
- notFlow = true;
1492
- continue;
1493
- }
1494
- const accept = line.match(/^@acceptance\s+(.+)$/);
1495
- if (accept) {
1496
- acceptance.push(accept[1].trim());
1497
- continue;
1498
- }
1499
- if (line.startsWith("@")) continue;
1500
- desc.push(line);
1410
+ function readTaggedDescribe(call, parsed) {
1411
+ const callee = call.callee;
1412
+ if (!callee || callee.type !== "MemberExpression" || !isIdentifier(callee.object, "test") || !isIdentifier(callee.property, "describe")) {
1413
+ return null;
1501
1414
  }
1415
+ const args = call.arguments ?? [];
1416
+ const title = stringLiteralValue(args[0]);
1417
+ if (title === null) return null;
1418
+ if (!hasFlowTag(args[1])) return null;
1419
+ const body = args[2];
1420
+ const { calls, dynamicCalls } = body ? collectUidexCalls(body, parsed) : { calls: [], dynamicCalls: [] };
1502
1421
  return {
1503
- kind,
1504
- id,
1505
- description: desc.join(" ").trim(),
1506
- acceptance,
1507
- notFlow
1422
+ title,
1423
+ line: parsed.lineAt(call.start),
1424
+ calls,
1425
+ ...dynamicCalls.length > 0 ? { dynamicCalls } : {}
1508
1426
  };
1509
1427
  }
1510
- function extract(files) {
1511
- return files.map((file) => {
1512
- const { exports: exports2, diagnostics } = extractUidexExports(file);
1513
- const out2 = {
1514
- file,
1515
- annotations: extractOne(file)
1516
- };
1517
- if (exports2.length > 0) out2.metadata = exports2;
1518
- if (diagnostics.length > 0) out2.diagnostics = diagnostics;
1519
- return out2;
1520
- });
1521
- }
1522
- function extractOne(file) {
1523
- const annotations = [];
1524
- const { content, displayPath } = file;
1525
- for (const occ of collectJSXAncestry(content)) {
1526
- annotations.push({
1527
- kind: occ.kind,
1528
- id: occ.id,
1529
- file: displayPath,
1530
- line: occ.line,
1531
- ...occ.ancestors.length > 0 ? { ancestors: occ.ancestors } : {}
1532
- });
1533
- }
1534
- JSDOC_BLOCK.lastIndex = 0;
1535
- let jm;
1536
- while ((jm = JSDOC_BLOCK.exec(content)) !== null) {
1537
- const parsed = parseJSDoc(jm[1]);
1538
- const line = lineAt(content, jm.index);
1539
- if (parsed.notFlow) {
1540
- annotations.push({ kind: "not-flow", id: "", file: displayPath, line });
1541
- }
1542
- if (parsed.kind && parsed.id) {
1543
- const kind = parsed.kind === "page" ? "page-doc" : parsed.kind === "feature" ? "feature-doc" : "widget-doc";
1544
- annotations.push({
1545
- kind,
1546
- id: parsed.id,
1547
- file: displayPath,
1548
- line,
1549
- description: parsed.description || void 0,
1550
- acceptance: parsed.acceptance.length ? parsed.acceptance : void 0
1551
- });
1552
- } else if (parsed.acceptance.length > 0) {
1553
- annotations.push({
1554
- kind: "orphan-acceptance",
1555
- id: "",
1556
- file: displayPath,
1557
- line,
1558
- acceptance: parsed.acceptance
1559
- });
1428
+ function hasFlowTag(node) {
1429
+ if (!node || node.type !== "ObjectExpression") return false;
1430
+ for (const prop of node.properties ?? []) {
1431
+ if (prop.type !== "Property") continue;
1432
+ const key = prop.key;
1433
+ const keyName = key?.type === "Identifier" ? String(key.name) : key?.type === "Literal" ? String(key.value) : null;
1434
+ if (keyName !== "tag") continue;
1435
+ const value = prop.value;
1436
+ if (!value) return false;
1437
+ if (stringLiteralValue(value) === "@uidex:flow") return true;
1438
+ if (value.type === "ArrayExpression") {
1439
+ for (const el of value.elements ?? []) {
1440
+ if (el && stringLiteralValue(el) === "@uidex:flow") return true;
1441
+ }
1560
1442
  }
1443
+ return false;
1561
1444
  }
1562
- return annotations;
1445
+ return false;
1563
1446
  }
1564
-
1565
- // src/scanner/scan/resolve.ts
1566
- var path3 = __toESM(require("path"), 1);
1567
-
1568
- // src/shared/entities/types.ts
1569
- var ENTITY_KINDS = [
1570
- "route",
1571
- "page",
1572
- "feature",
1573
- "widget",
1574
- "region",
1575
- "element",
1576
- "primitive",
1577
- "flow"
1578
- ];
1579
- function isMetaKind(kind) {
1580
- return kind !== "route" && kind !== "flow";
1447
+ function collectUidexCalls(body, parsed) {
1448
+ const calls = [];
1449
+ const dynamicCalls = [];
1450
+ const claimed = /* @__PURE__ */ new Set();
1451
+ const record = (node, action) => {
1452
+ if (claimed.has(node)) return;
1453
+ claimed.add(node);
1454
+ const resolved = uidexCallId(node);
1455
+ if (resolved === null) {
1456
+ dynamicCalls.push({ line: parsed.lineAt(node.start) });
1457
+ return;
1458
+ }
1459
+ calls.push({
1460
+ id: resolved.id,
1461
+ ...action ? { action } : {},
1462
+ line: parsed.lineAt(node.start),
1463
+ span: resolved.span
1464
+ });
1465
+ };
1466
+ walkAst(body, (node) => {
1467
+ if (node.type !== "CallExpression") return void 0;
1468
+ const callee = node.callee;
1469
+ if (callee?.type === "MemberExpression") {
1470
+ const inner = callee.object;
1471
+ if (inner && isUidexCall(inner)) {
1472
+ const property = callee.property;
1473
+ const action = property?.type === "Identifier" ? String(property.name) : void 0;
1474
+ record(inner, action);
1475
+ }
1476
+ return void 0;
1477
+ }
1478
+ if (isUidexCall(node)) record(node);
1479
+ return void 0;
1480
+ });
1481
+ return { calls, dynamicCalls };
1581
1482
  }
1582
- function isMetaEntity(entity) {
1583
- return isMetaKind(entity.kind);
1483
+ function isUidexCall(node) {
1484
+ if (node.type !== "CallExpression") return false;
1485
+ if (!isIdentifier(node.callee, "uidex")) return false;
1486
+ return (node.arguments ?? []).length >= 1;
1584
1487
  }
1585
- function entityKey(entity) {
1586
- return entity.kind === "route" ? entity.path : entity.id;
1488
+ function uidexCallId(node) {
1489
+ const args = node.arguments ?? [];
1490
+ const arg = args[0];
1491
+ const id = stringLiteralValue(arg);
1492
+ if (id === null) return null;
1493
+ return { id, span: { start: arg.start, end: arg.end } };
1587
1494
  }
1588
- var UnknownEntityKindError = class extends Error {
1589
- kind;
1590
- constructor(kind) {
1591
- super(`Unknown entity kind: ${kind}`);
1592
- this.name = "UnknownEntityKindError";
1593
- this.kind = kind;
1495
+ function stringLiteralValue(node) {
1496
+ if (!node) return null;
1497
+ if (node.type === "Literal" && typeof node.value === "string") {
1498
+ return node.value.length > 0 ? node.value : null;
1499
+ }
1500
+ if (node.type === "TemplateLiteral") {
1501
+ const expressions = node.expressions ?? [];
1502
+ if (expressions.length > 0) return null;
1503
+ const quasis = node.quasis ?? [];
1504
+ const cooked = quasis[0]?.value?.cooked;
1505
+ return cooked && cooked.length > 0 ? cooked : null;
1594
1506
  }
1595
- };
1596
- var KIND_SET = new Set(ENTITY_KINDS);
1597
- function assertEntityKind(kind) {
1598
- if (!KIND_SET.has(kind)) throw new UnknownEntityKindError(kind);
1507
+ return null;
1508
+ }
1509
+ function isIdentifier(node, name) {
1510
+ return typeof node === "object" && node !== null && node.type === "Identifier" && String(node.name) === name;
1599
1511
  }
1600
1512
 
1601
- // src/shared/entities/registry.ts
1602
- function emptyStore() {
1603
- return {
1604
- route: /* @__PURE__ */ new Map(),
1605
- page: /* @__PURE__ */ new Map(),
1606
- feature: /* @__PURE__ */ new Map(),
1607
- widget: /* @__PURE__ */ new Map(),
1608
- region: /* @__PURE__ */ new Map(),
1609
- element: /* @__PURE__ */ new Map(),
1610
- primitive: /* @__PURE__ */ new Map(),
1611
- flow: /* @__PURE__ */ new Map()
1612
- };
1513
+ // src/scanner/scan/jsx-ancestry.ts
1514
+ var ATTR_NAME_RE = /^data-uidex(?:-(region|widget|primitive))?$/;
1515
+ var INTERACTIVE_TAGS = /* @__PURE__ */ new Set(["button", "a", "input", "select", "textarea"]);
1516
+ var LANDMARK_TAGS = /* @__PURE__ */ new Set(["header", "nav", "main", "aside", "footer"]);
1517
+ function attrKind(node) {
1518
+ const name = node.name;
1519
+ if (!name || name.type !== "JSXIdentifier") return null;
1520
+ const m = ATTR_NAME_RE.exec(String(name.name));
1521
+ if (!m) return null;
1522
+ return m[1] ?? "element";
1613
1523
  }
1614
- function computeFlowIds(flows, targetId) {
1615
- const ids = [];
1616
- for (const flow of flows) {
1617
- if (flow.touches.includes(targetId)) ids.push(flow.id);
1524
+ function collectConstStrings(program) {
1525
+ const consts = /* @__PURE__ */ new Map();
1526
+ const seen = /* @__PURE__ */ new Set();
1527
+ walkAst(program, (node) => {
1528
+ if (node.type !== "VariableDeclaration" || node.kind !== "const") {
1529
+ return void 0;
1530
+ }
1531
+ for (const decl of node.declarations ?? []) {
1532
+ const id = decl.id;
1533
+ if (!id || id.type !== "Identifier") continue;
1534
+ const name = String(id.name);
1535
+ if (seen.has(name)) {
1536
+ consts.delete(name);
1537
+ continue;
1538
+ }
1539
+ seen.add(name);
1540
+ const init = decl.init;
1541
+ if (!init) continue;
1542
+ const value = staticString(unwrapTsExpression(init));
1543
+ if (value !== null) consts.set(name, value);
1544
+ }
1545
+ return void 0;
1546
+ });
1547
+ return consts;
1548
+ }
1549
+ function staticString(node) {
1550
+ if (node.type === "Literal" && typeof node.value === "string") {
1551
+ return node.value;
1618
1552
  }
1619
- return ids;
1553
+ if (node.type === "TemplateLiteral") {
1554
+ const expressions = node.expressions ?? [];
1555
+ if (expressions.length > 0) return null;
1556
+ const quasis = node.quasis ?? [];
1557
+ return quasis[0]?.value?.cooked ?? "";
1558
+ }
1559
+ return null;
1620
1560
  }
1621
- function freezeEntity(entity, flows) {
1622
- if (!isMetaKind(entity.kind)) return entity;
1623
- const withMeta = entity;
1624
- if (withMeta.meta === void 0) return entity;
1625
- const computedFlows = Object.freeze(computeFlowIds(flows, withMeta.id));
1626
- const mergedMeta = { ...withMeta.meta, flows: computedFlows };
1627
- return { ...entity, meta: Object.freeze(mergedMeta) };
1561
+ var UNRESOLVED = { resolved: false };
1562
+ function evalIdExpression(expr, consts) {
1563
+ const node = unwrapTsExpression(expr);
1564
+ const literal = staticString(node);
1565
+ if (literal !== null) {
1566
+ return literal.length > 0 ? { resolved: true, ids: [literal] } : UNRESOLVED;
1567
+ }
1568
+ if (node.type === "TemplateLiteral") {
1569
+ const quasis = node.quasis ?? [];
1570
+ const expressions = node.expressions ?? [];
1571
+ let out2 = "";
1572
+ for (let i = 0; i < quasis.length; i++) {
1573
+ out2 += quasis[i].value?.cooked ?? "";
1574
+ if (i < expressions.length) {
1575
+ const part = evalIdExpression(expressions[i], consts);
1576
+ out2 += part.resolved && part.ids.length === 1 ? part.ids[0] : "*";
1577
+ }
1578
+ }
1579
+ out2 = out2.replace(/\*{2,}/g, "*");
1580
+ if (!out2.includes("*")) {
1581
+ return out2.length > 0 ? { resolved: true, ids: [out2] } : UNRESOLVED;
1582
+ }
1583
+ return out2.replace(/\*/g, "").length > 0 ? { resolved: true, ids: [out2] } : UNRESOLVED;
1584
+ }
1585
+ if (node.type === "Identifier") {
1586
+ const value = consts.get(String(node.name));
1587
+ return value !== void 0 && value.length > 0 ? { resolved: true, ids: [value] } : UNRESOLVED;
1588
+ }
1589
+ if (node.type === "ConditionalExpression") {
1590
+ const left = evalIdExpression(node.consequent, consts);
1591
+ const right = evalIdExpression(node.alternate, consts);
1592
+ if (!left.resolved || !right.resolved) return UNRESOLVED;
1593
+ return { resolved: true, ids: [.../* @__PURE__ */ new Set([...left.ids, ...right.ids])] };
1594
+ }
1595
+ return UNRESOLVED;
1628
1596
  }
1629
- function createRegistry() {
1630
- const store = emptyStore();
1631
- let flowsCache = null;
1632
- const patternCache = /* @__PURE__ */ new Map();
1633
- const getFlows = () => {
1634
- if (flowsCache === null) flowsCache = Array.from(store.flow.values());
1635
- return flowsCache;
1636
- };
1637
- const add = (entity) => {
1638
- assertEntityKind(entity.kind);
1639
- const key = entityKey(entity);
1640
- store[entity.kind].set(key, entity);
1641
- flowsCache = null;
1642
- patternCache.delete(entity.kind);
1643
- };
1644
- const get = (kind, id) => {
1645
- assertEntityKind(kind);
1646
- const raw = store[kind].get(id);
1647
- if (raw === void 0) return void 0;
1648
- return freezeEntity(raw, getFlows());
1649
- };
1650
- const getPatternsForKind = (kind) => {
1651
- const cached = patternCache.get(kind);
1652
- if (cached !== void 0)
1653
- return cached;
1654
- const patterns = [];
1655
- for (const [key, entity] of store[kind]) {
1656
- if (key.endsWith("*")) {
1657
- patterns.push({
1658
- prefix: key.slice(0, -1),
1659
- entity
1597
+ function collectElementAttrs(opening, consts, dynamicAttrs, lineAt) {
1598
+ const statics = [];
1599
+ const patterns = [];
1600
+ const attributes = opening.attributes ?? [];
1601
+ for (const attr of attributes) {
1602
+ if (attr.type !== "JSXAttribute") continue;
1603
+ const kind = attrKind(attr);
1604
+ if (!kind) continue;
1605
+ const value = attr.value;
1606
+ if (!value) continue;
1607
+ let result = UNRESOLVED;
1608
+ let valueSpan;
1609
+ if (value.type === "Literal") {
1610
+ const v = staticString(value);
1611
+ result = v !== null && v.length > 0 ? { resolved: true, ids: [v] } : UNRESOLVED;
1612
+ if (result.resolved) valueSpan = { start: value.start, end: value.end };
1613
+ } else if (value.type === "JSXExpressionContainer") {
1614
+ const expr = value.expression;
1615
+ if (expr && expr.type !== "JSXEmptyExpression") {
1616
+ result = evalIdExpression(expr, consts);
1617
+ const inner = unwrapTsExpression(expr);
1618
+ if (result.resolved && staticString(inner) !== null) {
1619
+ valueSpan = { start: inner.start, end: inner.end };
1620
+ }
1621
+ }
1622
+ if (!result.resolved) {
1623
+ dynamicAttrs.push({
1624
+ kind,
1625
+ attrName: kind === "element" ? "data-uidex" : `data-uidex-${kind}`,
1626
+ line: lineAt(attr.start)
1660
1627
  });
1628
+ continue;
1661
1629
  }
1662
1630
  }
1663
- patternCache.set(
1664
- kind,
1665
- patterns
1666
- );
1667
- return patterns;
1668
- };
1669
- const matchPattern = (kind, id) => {
1670
- assertEntityKind(kind);
1671
- const patterns = getPatternsForKind(kind);
1672
- if (patterns.length === 0) return void 0;
1673
- let best;
1674
- for (const entry of patterns) {
1675
- if (id.startsWith(entry.prefix) && (best === void 0 || entry.prefix.length > best.prefix.length)) {
1676
- best = entry;
1631
+ if (!result.resolved) continue;
1632
+ for (const id of result.ids) {
1633
+ const resolved = {
1634
+ kind,
1635
+ id,
1636
+ start: attr.start,
1637
+ isPattern: id.includes("*"),
1638
+ // Only a single plain string literal is renameable in place.
1639
+ ...result.ids.length === 1 && valueSpan ? { span: valueSpan } : {}
1640
+ };
1641
+ if (resolved.isPattern) patterns.push(resolved);
1642
+ else statics.push(resolved);
1643
+ }
1644
+ }
1645
+ return [...statics, ...patterns];
1646
+ }
1647
+ function collectJSXFacts(parsed) {
1648
+ const occurrences = [];
1649
+ const dynamicAttrs = [];
1650
+ const unannotatedInteractive = [];
1651
+ const landmarks = [];
1652
+ if (parsed.program === null) {
1653
+ return { occurrences, dynamicAttrs, unannotatedInteractive, landmarks };
1654
+ }
1655
+ const consts = collectConstStrings(parsed.program);
1656
+ const ancestors = [];
1657
+ const visit = (node) => {
1658
+ if (!isNode3(node)) return;
1659
+ if (node.type === "JSXElement") {
1660
+ const opening = node.openingElement;
1661
+ const attrs = collectElementAttrs(
1662
+ opening,
1663
+ consts,
1664
+ dynamicAttrs,
1665
+ parsed.lineAt
1666
+ );
1667
+ const interactive = readInteractive(node, parsed.lineAt);
1668
+ if (interactive) unannotatedInteractive.push(interactive);
1669
+ if (attrs.length > 0) {
1670
+ const snapshot = ancestors.slice();
1671
+ for (const a of attrs) {
1672
+ occurrences.push({
1673
+ kind: a.kind,
1674
+ id: a.id,
1675
+ line: parsed.lineAt(a.start),
1676
+ ancestors: snapshot,
1677
+ ...a.span ? { span: a.span } : {}
1678
+ });
1679
+ }
1680
+ }
1681
+ let pushed = attrs.length;
1682
+ for (const a of attrs) ancestors.push({ kind: a.kind, id: a.id });
1683
+ const landmark = readLandmark(opening, parsed.lineAt);
1684
+ if (landmark) {
1685
+ landmarks.push(landmark);
1686
+ if (!attrs.some((a) => a.kind === "region")) {
1687
+ ancestors.push({ kind: "region", id: landmark.tag });
1688
+ pushed++;
1689
+ }
1677
1690
  }
1691
+ visitChildren(opening);
1692
+ for (const child of node.children ?? []) {
1693
+ visit(child);
1694
+ }
1695
+ const closing = node.closingElement;
1696
+ if (isNode3(closing)) visitChildren(closing);
1697
+ ancestors.length -= pushed;
1698
+ return;
1678
1699
  }
1679
- if (best === void 0) return void 0;
1680
- return freezeEntity(best.entity, getFlows());
1681
- };
1682
- const list = (kind) => {
1683
- assertEntityKind(kind);
1684
- const flows = getFlows();
1685
- return Array.from(
1686
- store[kind].values(),
1687
- (e) => freezeEntity(e, flows)
1688
- );
1700
+ visitChildren(node);
1689
1701
  };
1690
- const allEntities = function* () {
1691
- for (const kind of Object.keys(store)) {
1692
- for (const entity of store[kind].values()) {
1693
- yield entity;
1702
+ const visitChildren = (node) => {
1703
+ for (const key of Object.keys(node)) {
1704
+ if (key === "type" || key === "start" || key === "end") continue;
1705
+ const value = node[key];
1706
+ if (Array.isArray(value)) {
1707
+ for (const item of value) visit(item);
1708
+ } else {
1709
+ visit(value);
1694
1710
  }
1695
1711
  }
1696
1712
  };
1697
- const query = (predicate) => {
1698
- const flows = getFlows();
1699
- const result = [];
1700
- for (const entity of allEntities()) {
1701
- if (predicate(entity)) result.push(freezeEntity(entity, flows));
1713
+ visit(parsed.program);
1714
+ return { occurrences, dynamicAttrs, unannotatedInteractive, landmarks };
1715
+ }
1716
+ function readLandmark(opening, lineAt) {
1717
+ const name = opening.name;
1718
+ if (!name || name.type !== "JSXIdentifier") return null;
1719
+ const tag = String(name.name);
1720
+ if (LANDMARK_TAGS.has(tag)) {
1721
+ return { tag, line: lineAt(opening.start) };
1722
+ }
1723
+ for (const attr of opening.attributes ?? []) {
1724
+ if (attr.type !== "JSXAttribute") continue;
1725
+ const attrName = attr.name;
1726
+ if (!attrName || String(attrName.name) !== "role") continue;
1727
+ const value = attr.value;
1728
+ if (value && value.type === "Literal" && value.value === "region") {
1729
+ return { tag: "region", line: lineAt(opening.start) };
1702
1730
  }
1703
- return result;
1704
- };
1705
- const byScope = (scope) => query(
1706
- (entity) => "scopes" in entity && Array.isArray(entity.scopes) && entity.scopes.includes(scope)
1707
- );
1708
- const touchedBy = (flowId) => {
1709
- const flow = store.flow.get(flowId);
1710
- if (flow === void 0) return [];
1711
- const ids = new Set(flow.touches);
1712
- return query((entity) => {
1713
- if (!isMetaEntity(entity)) return false;
1714
- return ids.has(entity.id);
1715
- });
1716
- };
1717
- const reports = /* @__PURE__ */ new Map();
1718
- const reportsCbs = /* @__PURE__ */ new Set();
1719
- const setReports = (kind, id, records) => {
1720
- reports.set(`${kind}:${id}`, records);
1721
- for (const cb of reportsCbs) cb();
1722
- };
1723
- const getReports = (kind, id) => reports.get(`${kind}:${id}`) ?? [];
1724
- const listReportKeys = () => Array.from(reports.keys());
1725
- const onReportsChange = (cb) => {
1726
- reportsCbs.add(cb);
1727
- return () => reportsCbs.delete(cb);
1728
- };
1731
+ }
1732
+ return null;
1733
+ }
1734
+ function readInteractive(element, lineAt) {
1735
+ const opening = element.openingElement;
1736
+ const name = opening.name;
1737
+ if (!name || name.type !== "JSXIdentifier") return null;
1738
+ const tag = String(name.name);
1739
+ if (!INTERACTIVE_TAGS.has(tag)) return null;
1740
+ let hasSpread = false;
1741
+ for (const attr of opening.attributes ?? []) {
1742
+ if (attr.type === "JSXSpreadAttribute") {
1743
+ hasSpread = true;
1744
+ continue;
1745
+ }
1746
+ if (attr.type === "JSXAttribute" && attrKind(attr) !== null) return null;
1747
+ }
1748
+ const nameHint = interactiveNameHint(element, opening);
1729
1749
  return {
1730
- add,
1731
- get,
1732
- matchPattern,
1733
- list,
1734
- query,
1735
- byScope,
1736
- touchedBy,
1737
- setReports,
1738
- getReports,
1739
- listReportKeys,
1740
- onReportsChange
1750
+ tag,
1751
+ line: lineAt(opening.start),
1752
+ hasSpread,
1753
+ nameEnd: name.end,
1754
+ ...nameHint ? { nameHint } : {}
1755
+ };
1756
+ }
1757
+ function staticAttrValue(opening, attrName) {
1758
+ for (const attr of opening.attributes ?? []) {
1759
+ if (attr.type !== "JSXAttribute") continue;
1760
+ const n = attr.name;
1761
+ if (!n || String(n.name) !== attrName) continue;
1762
+ const value = attr.value;
1763
+ if (!value) return null;
1764
+ if (value.type === "Literal") return staticString(value);
1765
+ if (value.type === "JSXExpressionContainer") {
1766
+ const expr = value.expression;
1767
+ return expr ? staticString(unwrapTsExpression(expr)) : null;
1768
+ }
1769
+ return null;
1770
+ }
1771
+ return null;
1772
+ }
1773
+ function staticChildText(element) {
1774
+ const parts = [];
1775
+ for (const child of element.children ?? []) {
1776
+ if (child.type === "JSXText") {
1777
+ parts.push(String(child.value ?? ""));
1778
+ }
1779
+ }
1780
+ return parts.join(" ").replace(/\s+/g, " ").trim();
1781
+ }
1782
+ function interactiveNameHint(element, opening) {
1783
+ const ariaLabel = staticAttrValue(opening, "aria-label");
1784
+ if (ariaLabel) return ariaLabel;
1785
+ const text = staticChildText(element);
1786
+ if (text) return text;
1787
+ for (const attr of ["title", "name", "placeholder"]) {
1788
+ const v = staticAttrValue(opening, attr);
1789
+ if (v) return v;
1790
+ }
1791
+ return void 0;
1792
+ }
1793
+ function isNode3(value) {
1794
+ return typeof value === "object" && value !== null && typeof value.type === "string";
1795
+ }
1796
+
1797
+ // src/scanner/scan/extract.ts
1798
+ function parseFailureDiagnostic(file, parsed) {
1799
+ const fatal = parsed.program === null || parsed.hasErrors && parsed.program.body.length === 0;
1800
+ if (!fatal) return null;
1801
+ return {
1802
+ code: "parse-error",
1803
+ severity: "warning",
1804
+ message: "File could not be parsed \u2014 data-uidex attributes and flow facts in it were skipped, and their ids will drop out of the gen file",
1805
+ file: file.displayPath,
1806
+ line: 1,
1807
+ hint: "Fix the file's syntax (or exclude it from .uidex.json sources) so the scanner can read its annotations"
1741
1808
  };
1742
1809
  }
1810
+ function extract(files) {
1811
+ return files.map((file) => {
1812
+ const parsed = parseSource(file);
1813
+ const { exports: exports2, diagnostics } = extractUidexExports(file, parsed);
1814
+ const parseFailure = parseFailureDiagnostic(file, parsed);
1815
+ if (parseFailure) diagnostics.push(parseFailure);
1816
+ const out2 = { file, annotations: [] };
1817
+ out2.annotations = extractOne(file, parsed, out2);
1818
+ if (exports2.length > 0) out2.metadata = exports2;
1819
+ if (diagnostics.length > 0) out2.diagnostics = diagnostics;
1820
+ const flows = collectFlowFacts(parsed, file.content);
1821
+ if (flows.length > 0) out2.flows = flows;
1822
+ const imports = collectImportFacts(parsed);
1823
+ if (imports.length > 0) out2.imports = imports;
1824
+ return out2;
1825
+ });
1826
+ }
1827
+ function extractOne(file, parsed, out2) {
1828
+ const annotations = [];
1829
+ const { displayPath } = file;
1830
+ const jsx = collectJSXFacts(parsed);
1831
+ if (jsx.dynamicAttrs.length > 0) out2.dynamicAttrs = jsx.dynamicAttrs;
1832
+ if (jsx.unannotatedInteractive.length > 0) {
1833
+ out2.unannotatedInteractive = jsx.unannotatedInteractive;
1834
+ }
1835
+ if (jsx.landmarks.length > 0) out2.landmarks = jsx.landmarks;
1836
+ for (const occ of jsx.occurrences) {
1837
+ annotations.push({
1838
+ kind: occ.kind,
1839
+ id: occ.id,
1840
+ file: displayPath,
1841
+ line: occ.line,
1842
+ ...occ.ancestors.length > 0 ? { ancestors: occ.ancestors } : {},
1843
+ ...occ.span ? { span: occ.span } : {}
1844
+ });
1845
+ }
1846
+ return annotations;
1847
+ }
1848
+ function collectImportFacts(parsed) {
1849
+ if (parsed.program === null) return [];
1850
+ const out2 = [];
1851
+ for (const stmt of parsed.program.body) {
1852
+ let source;
1853
+ let isTypeOnly = false;
1854
+ const names = [];
1855
+ if (stmt.type === "ImportDeclaration") {
1856
+ source = stmt.source;
1857
+ isTypeOnly = stmt.importKind === "type";
1858
+ for (const spec of stmt.specifiers ?? []) {
1859
+ const local = spec.local;
1860
+ if (local && local.type === "Identifier") {
1861
+ names.push(String(local.name));
1862
+ }
1863
+ }
1864
+ } else if ((stmt.type === "ExportNamedDeclaration" || stmt.type === "ExportAllDeclaration") && stmt.source) {
1865
+ source = stmt.source;
1866
+ isTypeOnly = stmt.exportKind === "type";
1867
+ } else {
1868
+ continue;
1869
+ }
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
1878
+ });
1879
+ }
1880
+ return out2;
1881
+ }
1882
+
1883
+ // src/scanner/scan/resolve.ts
1884
+ var path4 = __toESM(require("path"), 1);
1743
1885
 
1744
1886
  // src/scanner/scan/routes.ts
1745
1887
  var PAGE_BASENAME = /^page\.(tsx|ts|jsx|js|mjs|cjs)$/;
@@ -1827,21 +1969,9 @@ function kebab(str) {
1827
1969
  return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").replace(/[^a-zA-Z0-9-]/g, "").toLowerCase();
1828
1970
  }
1829
1971
  function baseName(file) {
1830
- const b = path3.posix.basename(file);
1972
+ const b = path4.posix.basename(file);
1831
1973
  return b.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "");
1832
1974
  }
1833
- var LANDMARK_RE = /<(header|nav|main|aside|footer)(\s[^>]*)?>|role=["']region["']/gi;
1834
- function extractLandmarks(file) {
1835
- const out2 = [];
1836
- LANDMARK_RE.lastIndex = 0;
1837
- let m;
1838
- while ((m = LANDMARK_RE.exec(file.content)) !== null) {
1839
- const tag = m[1] ?? "region";
1840
- const line = 1 + file.content.slice(0, m.index).split("\n").length - 1;
1841
- out2.push({ tag, line });
1842
- }
1843
- return out2;
1844
- }
1845
1975
  function fileMatchesAny(displayPath, patterns) {
1846
1976
  return patterns.some((g) => globToRegExp(g).test(displayPath));
1847
1977
  }
@@ -1852,6 +1982,18 @@ function buildMetaFromExport(exp) {
1852
1982
  if (exp.acceptance?.length) meta.acceptance = exp.acceptance;
1853
1983
  if (exp.features?.length) meta.features = exp.features;
1854
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
+ }
1855
1997
  return Object.keys(meta).length > 0 ? meta : void 0;
1856
1998
  }
1857
1999
  function resolve2(ctx) {
@@ -1902,7 +2044,7 @@ function resolve2(ctx) {
1902
2044
  const routes = conventions.pages === "auto" ? detectRoutes(ctx.extracted.map((e) => e.file)) : [];
1903
2045
  const handledPageFiles = /* @__PURE__ */ new Set();
1904
2046
  for (const route of routes) {
1905
- const routeDir = path3.posix.dirname(route.file);
2047
+ const routeDir = path4.posix.dirname(route.file);
1906
2048
  const wellKnownPath = `${routeDir}/${WELL_KNOWN_FILES.page}`;
1907
2049
  const wellKnownExp = exportFor(wellKnownPath, "page");
1908
2050
  const routeExp = exportFor(route.file, "page");
@@ -1956,7 +2098,7 @@ function resolve2(ctx) {
1956
2098
  const dir = extractFeatureDir(ef.file.displayPath, featureGlob);
1957
2099
  if (!dir) continue;
1958
2100
  conventionalFeatureDirs.add(dir);
1959
- const isWellKnown = path3.posix.basename(ef.file.displayPath) === WELL_KNOWN_FILES.feature;
2101
+ const isWellKnown = path4.posix.basename(ef.file.displayPath) === WELL_KNOWN_FILES.feature;
1960
2102
  if (isWellKnown) wellKnownFeatureFileByDir.set(dir, ef.file.displayPath);
1961
2103
  const exp = exportFor(ef.file.displayPath, "feature");
1962
2104
  if (exp) {
@@ -1993,7 +2135,7 @@ function resolve2(ctx) {
1993
2135
  } else if (allExports.length > 0) {
1994
2136
  exp = allExports[0].exp;
1995
2137
  }
1996
- const id = exp && typeof exp.id === "string" ? exp.id : path3.posix.basename(dir);
2138
+ const id = exp && typeof exp.id === "string" ? exp.id : path4.posix.basename(dir);
1997
2139
  const meta = exp ? buildMetaFromExport(exp) : void 0;
1998
2140
  const feature = {
1999
2141
  kind: "feature",
@@ -2089,8 +2231,8 @@ function resolve2(ctx) {
2089
2231
  }
2090
2232
  if (conventions.regions === "landmarks") {
2091
2233
  for (const ef of ctx.extracted) {
2092
- for (const lm of extractLandmarks(ef.file)) {
2093
- const id = kebab(`${lm.tag}`);
2234
+ for (const lm of ef.landmarks ?? []) {
2235
+ const id = lm.tag;
2094
2236
  if (!registry.get("region", id)) {
2095
2237
  const meta = metaWithComposes("region", id);
2096
2238
  const region = {
@@ -2201,7 +2343,7 @@ function resolve2(ctx) {
2201
2343
  const flowExport = (ff.metadata ?? []).find(
2202
2344
  (m) => m.kind === "flow" && typeof m.id === "string"
2203
2345
  );
2204
- const derived = extractFlowsFromSource(ff.file);
2346
+ const derived = flowsFromFacts(ff);
2205
2347
  if (flowExport && typeof flowExport.id === "string" && derived.length === 1) {
2206
2348
  const base = derived[0];
2207
2349
  const flow = {
@@ -2256,60 +2398,312 @@ function computeScope(displayPath) {
2256
2398
  }
2257
2399
  return null;
2258
2400
  }
2259
- function extractFlowsFromSource(file) {
2260
- const flows = [];
2261
- const source = file.content;
2262
- const describeRe = /test\.describe\(\s*(?:'([^']*)'|"([^"]*)")\s*,\s*\{[^}]*tag:\s*(?:'@uidex:flow'|"@uidex:flow"|\[[^\]]*@uidex:flow[^\]]*\])[^}]*\}/g;
2263
- let m;
2264
- while ((m = describeRe.exec(source)) !== null) {
2265
- const title = m[1] ?? m[2];
2266
- const id = kebab(title);
2267
- const line = 1 + source.slice(0, m.index).split("\n").length - 1;
2268
- const after = source.slice(m.index + m[0].length);
2269
- const arrow = after.match(/=>\s*\{/);
2270
- if (!arrow || arrow.index === void 0) continue;
2271
- const bodyStart = m.index + m[0].length + arrow.index + arrow[0].length;
2272
- let depth = 1;
2273
- let bodyEnd = -1;
2274
- for (let i = bodyStart; i < source.length; i++) {
2275
- if (source[i] === "{") depth++;
2276
- else if (source[i] === "}") {
2277
- depth--;
2278
- if (depth === 0) {
2279
- bodyEnd = i;
2280
- break;
2281
- }
2282
- }
2401
+ function flowsFromFacts(ff) {
2402
+ return (ff.flows ?? []).map((fact) => ({
2403
+ kind: "flow",
2404
+ id: kebab(fact.title),
2405
+ loc: { file: ff.file.displayPath, line: fact.line },
2406
+ touches: dedupe(fact.calls.map((c) => c.id)),
2407
+ steps: fact.calls.filter((c) => c.action).map((c) => ({ entityId: c.id, action: c.action }))
2408
+ }));
2409
+ }
2410
+ function dedupe(arr) {
2411
+ return Array.from(new Set(arr));
2412
+ }
2413
+
2414
+ // src/scanner/scan/audit.ts
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;
2283
2498
  }
2284
- if (bodyEnd === -1) continue;
2285
- const body = source.slice(bodyStart, bodyEnd);
2286
- const touches = captureUidexIds(body);
2287
- flows.push({
2288
- kind: "flow",
2289
- id,
2290
- loc: { file: file.displayPath, line },
2291
- touches: dedupe(touches.map((t) => t.id)),
2292
- steps: touches.filter((t) => t.action).map((t) => ({ entityId: t.id, action: t.action }))
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).`
2293
2507
  });
2294
2508
  }
2295
- return flows;
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;
2296
2527
  }
2297
- function captureUidexIds(body) {
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) {
2298
2541
  const out2 = [];
2299
- const re = /uidex\(\s*(?:'([^']+)'|"([^"]+)"|`([^`$]+)`)\s*\)(?:\.(\w+)\s*\()?/g;
2300
- let m;
2301
- while ((m = re.exec(body)) !== null) {
2302
- out2.push({ id: m[1] || m[2] || m[3], action: m[4] });
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
+ }
2303
2553
  }
2304
2554
  return out2;
2305
2555
  }
2306
- function dedupe(arr) {
2307
- return Array.from(new Set(arr));
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;
2308
2704
  }
2309
2705
 
2310
2706
  // src/scanner/scan/audit.ts
2311
- var path4 = __toESM(require("path"), 1);
2312
- var MARKER_FILENAMES = ["UIDEX_PAGE.md", "UIDEX_FEATURE.md"];
2313
2707
  function audit(opts) {
2314
2708
  const diagnostics = [];
2315
2709
  const { registry, extracted, files, config } = opts;
@@ -2318,23 +2712,19 @@ function audit(opts) {
2318
2712
  const acceptanceEnabled = config.audit?.acceptance ?? true;
2319
2713
  const scopeLeakEnabled = config.audit?.scopeLeak ?? true;
2320
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;
2321
2718
  if (opts.resolveDiagnostics) diagnostics.push(...opts.resolveDiagnostics);
2322
- if (check) {
2323
- for (const f of files) {
2324
- const base = f.displayPath.split("/").pop() ?? "";
2325
- if (MARKER_FILENAMES.includes(base)) {
2326
- diagnostics.push({
2327
- code: "marker-md-ignored",
2328
- severity: "warning",
2329
- message: `Marker file "${base}" is ignored in v2; migrate to \`export const uidex\``,
2330
- file: f.displayPath
2331
- });
2332
- }
2333
- }
2719
+ for (const ef of extracted) {
2720
+ if (ef.diagnostics) diagnostics.push(...ef.diagnostics);
2721
+ }
2722
+ for (const ef of opts.flowExtracted ?? []) {
2723
+ if (ef.diagnostics) diagnostics.push(...ef.diagnostics);
2334
2724
  }
2335
2725
  if (check && opts.generated !== void 0) {
2336
2726
  const outRel = opts.outputPath ?? config.output;
2337
- const fresh = normalizeLineEndings(opts.generated);
2727
+ const fresh = normalizeForCheck(opts.generated);
2338
2728
  if (opts.existingOnDisk === null || opts.existingOnDisk === void 0) {
2339
2729
  diagnostics.push({
2340
2730
  code: "gen-missing",
@@ -2344,7 +2734,7 @@ function audit(opts) {
2344
2734
  hint: "Run `uidex scan` (without --check) to regenerate"
2345
2735
  });
2346
2736
  } else {
2347
- const existing = normalizeLineEndings(opts.existingOnDisk);
2737
+ const existing = normalizeForCheck(opts.existingOnDisk);
2348
2738
  if (existing !== fresh) {
2349
2739
  const changed = diffEntities(existing, opts.generated, registry);
2350
2740
  const summary2 = formatChangedSummary(changed);
@@ -2358,20 +2748,46 @@ function audit(opts) {
2358
2748
  }
2359
2749
  }
2360
2750
  }
2361
- if (lint) {
2362
- for (const ef of extracted) {
2363
- for (const a of ef.annotations) {
2364
- const migration = legacyJsdocMigration(a);
2365
- if (!migration) continue;
2366
- diagnostics.push({
2367
- code: "legacy-jsdoc",
2368
- severity: "warning",
2369
- message: migration.message,
2370
- file: a.file,
2371
- line: a.line,
2372
- hint: migration.hint
2373
- });
2374
- }
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
+ });
2375
2791
  }
2376
2792
  }
2377
2793
  if (lint && acceptanceEnabled) {
@@ -2419,8 +2835,8 @@ function audit(opts) {
2419
2835
  if (typeof m.id !== "string") continue;
2420
2836
  const filePath = ef.file.displayPath;
2421
2837
  const wellKnownName = WELL_KNOWN_FILES[m.kind];
2422
- if (path4.posix.basename(filePath) === wellKnownName) continue;
2423
- const dir = path4.posix.dirname(filePath);
2838
+ if (path5.posix.basename(filePath) === wellKnownName) continue;
2839
+ const dir = path5.posix.dirname(filePath);
2424
2840
  const wellKnownPath = dir === "." ? wellKnownName : `${dir}/${wellKnownName}`;
2425
2841
  if (scannedPaths.has(wellKnownPath)) continue;
2426
2842
  const kindLabel = m.kind === "page" ? "Page" : "Feature";
@@ -2437,53 +2853,55 @@ function audit(opts) {
2437
2853
  }
2438
2854
  }
2439
2855
  if (lint) {
2440
- const dynamicAttrRe = /\bdata-uidex(?:-(region|widget|primitive))?\s*=\s*\{/g;
2441
- const templateWithPrefixRe = /\bdata-uidex(?:-(region|widget|primitive))?\s*=\s*\{\s*`[^`$]+\$\{/g;
2442
- for (const f of files) {
2443
- const templatePrefixPositions = /* @__PURE__ */ new Set();
2444
- templateWithPrefixRe.lastIndex = 0;
2445
- let tm;
2446
- while ((tm = templateWithPrefixRe.exec(f.content)) !== null) {
2447
- templatePrefixPositions.add(tm.index);
2448
- }
2449
- let m;
2450
- dynamicAttrRe.lastIndex = 0;
2451
- while ((m = dynamicAttrRe.exec(f.content)) !== null) {
2452
- if (templatePrefixPositions.has(m.index)) continue;
2453
- const kind = m[1] ?? "element";
2454
- let line = 1;
2455
- for (let i = 0; i < m.index; i++) if (f.content[i] === "\n") line++;
2456
- const attrName = m[1] ? `data-uidex-${m[1]}` : "data-uidex";
2856
+ for (const ef of extracted) {
2857
+ for (const fact of ef.dynamicAttrs ?? []) {
2457
2858
  diagnostics.push({
2458
2859
  code: "dynamic-attr",
2459
2860
  severity: "warning",
2460
- message: `\`${attrName}={\u2026}\` uses a dynamic expression; the scanner cannot resolve the ${kind} id statically`,
2461
- file: f.displayPath,
2462
- line,
2463
- hint: dynamicAttrHint(kind)
2861
+ message: `\`${fact.attrName}={\u2026}\` uses a dynamic expression; the scanner cannot resolve the ${fact.kind} id statically`,
2862
+ file: ef.file.displayPath,
2863
+ line: fact.line,
2864
+ hint: dynamicAttrHint(fact.kind)
2464
2865
  });
2465
2866
  }
2466
2867
  }
2467
2868
  }
2468
2869
  if (lint) {
2469
- for (const f of files) {
2470
- const tagRe = /<(button|a|input|select|textarea)(?=[\s/>])/g;
2471
- let m;
2472
- while ((m = tagRe.exec(f.content)) !== null) {
2473
- const afterTag = m.index + m[0].length;
2474
- const closeIdx = findJsxOpeningEnd(f.content, afterTag);
2475
- if (closeIdx === -1) continue;
2476
- const attrs = f.content.slice(afterTag, closeIdx);
2477
- if (attrs.includes("data-uidex")) continue;
2478
- let line = 1;
2479
- for (let i = 0; i < m.index; i++) if (f.content[i] === "\n") line++;
2480
- diagnostics.push({
2481
- code: "missing-element-annotation",
2482
- severity: "info",
2483
- message: `Interactive <${m[1].toLowerCase()}> without data-uidex annotation`,
2484
- file: f.displayPath,
2485
- line
2486
- });
2870
+ const usedElementIds = new Set(registry.list("element").map((e) => e.id));
2871
+ for (const ef of extracted) {
2872
+ for (const fact of ef.unannotatedInteractive ?? []) {
2873
+ if (fact.hasSpread) {
2874
+ diagnostics.push({
2875
+ code: "spread-attr",
2876
+ severity: "info",
2877
+ message: `Interactive <${fact.tag}> spreads dynamic props and has no static data-uidex attribute; if the annotation is forwarded via props the scanner cannot register it`,
2878
+ file: ef.file.displayPath,
2879
+ line: fact.line,
2880
+ hint: "Prefer a string-literal data-uidex on the element itself, or annotate at the call site."
2881
+ });
2882
+ } else {
2883
+ const id = uniqueElementId(fact, usedElementIds);
2884
+ usedElementIds.add(id);
2885
+ diagnostics.push({
2886
+ code: "missing-element-annotation",
2887
+ severity: "info",
2888
+ message: `Interactive <${fact.tag}> without data-uidex annotation`,
2889
+ file: ef.file.displayPath,
2890
+ line: fact.line,
2891
+ hint: `Add \`data-uidex="${id}"\` (or run \`uidex scan --fix\`).`,
2892
+ fix: {
2893
+ description: `Add data-uidex="${id}" to <${fact.tag}>`,
2894
+ edits: [
2895
+ {
2896
+ path: ef.file.sourcePath,
2897
+ start: fact.nameEnd,
2898
+ end: fact.nameEnd,
2899
+ replacement: ` data-uidex="${id}"`
2900
+ }
2901
+ ]
2902
+ }
2903
+ });
2904
+ }
2487
2905
  }
2488
2906
  }
2489
2907
  }
@@ -2500,12 +2918,11 @@ function audit(opts) {
2500
2918
  }
2501
2919
  }
2502
2920
  }
2503
- for (const f of files) {
2504
- const importRe = /import\s+(?:[^'"]+)\s+from\s+['"]([^'"]+)['"]/g;
2505
- let m;
2506
- while ((m = importRe.exec(f.content)) !== null) {
2507
- const spec = m[1];
2508
- const baseName2 = spec.split("/").pop() ?? "";
2921
+ for (const ef of extracted) {
2922
+ const displayPath = ef.file.displayPath;
2923
+ for (const imp of ef.imports ?? []) {
2924
+ if (imp.isTypeOnly) continue;
2925
+ const baseName2 = imp.specifier.split("/").pop() ?? "";
2509
2926
  const primitive = byName.get(
2510
2927
  baseName2.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
2511
2928
  );
@@ -2513,25 +2930,37 @@ function audit(opts) {
2513
2930
  const scope = primitive.scopes?.[0];
2514
2931
  if (!scope) continue;
2515
2932
  const [kind, id] = scope.split(":");
2516
- const importerSegments = f.displayPath.split("/");
2933
+ const importerSegments = displayPath.split("/");
2517
2934
  if (importerSegments.includes(id) && importerSegments.includes(kind + "s")) {
2518
2935
  continue;
2519
2936
  }
2520
2937
  if (kind === "feature" && importerSegments.includes(id)) continue;
2521
- if (kind === "feature" && declaredFeatures.get(f.displayPath)?.has(id)) {
2938
+ if (kind === "feature" && declaredFeatures.get(displayPath)?.has(id)) {
2522
2939
  continue;
2523
2940
  }
2524
2941
  diagnostics.push({
2525
2942
  code: "scope-leak",
2526
2943
  severity: "warning",
2527
- message: `Primitive "${primitive.id}" is scoped to ${scope} but is imported from ${f.displayPath}`,
2528
- file: f.displayPath
2944
+ message: `Primitive "${primitive.id}" is scoped to ${scope} but is imported from ${displayPath}`,
2945
+ file: displayPath,
2946
+ line: imp.line
2529
2947
  });
2530
2948
  }
2531
2949
  }
2532
2950
  }
2533
2951
  if (lint && coverageEnabled) {
2952
+ const factsByLoc = /* @__PURE__ */ new Map();
2953
+ for (const ef of opts.flowExtracted ?? []) {
2954
+ for (const fact of ef.flows ?? []) {
2955
+ const lines = /* @__PURE__ */ new Map();
2956
+ for (const call of fact.calls) {
2957
+ if (!lines.has(call.id)) lines.set(call.id, call.line);
2958
+ }
2959
+ factsByLoc.set(`${ef.file.displayPath}:${fact.line}`, lines);
2960
+ }
2961
+ }
2534
2962
  for (const flow of registry.list("flow")) {
2963
+ const callLines = factsByLoc.get(`${flow.loc.file}:${flow.loc.line}`);
2535
2964
  for (const touchedId of flow.touches) {
2536
2965
  const found = registry.get("element", touchedId) ?? registry.get("widget", touchedId) ?? registry.get("region", touchedId) ?? registry.matchPattern("element", touchedId) ?? registry.matchPattern("widget", touchedId) ?? registry.matchPattern("region", touchedId);
2537
2966
  if (!found) {
@@ -2540,54 +2969,146 @@ function audit(opts) {
2540
2969
  severity: "warning",
2541
2970
  message: `Flow "${flow.id}" references unknown entity "${touchedId}"`,
2542
2971
  file: flow.loc.file,
2543
- line: flow.loc.line
2972
+ // Point at the uidex() call itself when the spec facts are
2973
+ // available; the describe line is the fallback.
2974
+ line: callLines?.get(touchedId) ?? flow.loc.line
2975
+ });
2976
+ }
2977
+ }
2978
+ }
2979
+ }
2980
+ if (lint) {
2981
+ const occurrences = /* @__PURE__ */ new Map();
2982
+ for (const ef of extracted) {
2983
+ for (const a of ef.annotations) {
2984
+ if (a.kind !== "element" && a.kind !== "region" && a.kind !== "widget" && a.kind !== "primitive") {
2985
+ continue;
2986
+ }
2987
+ const key = `${a.kind}:${a.id}`;
2988
+ let list = occurrences.get(key);
2989
+ if (!list) {
2990
+ list = [];
2991
+ occurrences.set(key, list);
2992
+ }
2993
+ list.push({ file: a.file, line: a.line });
2994
+ }
2995
+ }
2996
+ for (const [key, list] of occurrences) {
2997
+ const filesSeen = new Set(list.map((o) => o.file));
2998
+ if (filesSeen.size < 2) continue;
2999
+ const [kind, id] = key.split(/:(.*)/s);
3000
+ const others = list.slice(1).map((o) => `${o.file}:${o.line}`).join(", ");
3001
+ diagnostics.push({
3002
+ code: "duplicate-id",
3003
+ severity: kind === "widget" || kind === "primitive" ? "warning" : "info",
3004
+ message: `${kind} id "${id}" is declared in ${filesSeen.size} files (also at ${others}); the registry keeps only one entry`,
3005
+ file: list[0].file,
3006
+ line: list[0].line,
3007
+ entity: { kind, id },
3008
+ hint: kind === "element" || kind === "region" ? "If these are variants of the same logical element this is fine; otherwise rename one (`uidex rename` updates flow references too)." : "Rename one of the definitions; two definitions with the same id silently merge."
3009
+ });
3010
+ }
3011
+ }
3012
+ if (lint && coverageEnabled) {
3013
+ for (const ef of opts.flowExtracted ?? []) {
3014
+ for (const fact of ef.flows ?? []) {
3015
+ for (const dyn of fact.dynamicCalls ?? []) {
3016
+ diagnostics.push({
3017
+ code: "dynamic-flow-reference",
3018
+ severity: "warning",
3019
+ message: `\`uidex(\u2026)\` call in flow "${fact.title}" uses a dynamic expression; the id is invisible to coverage and registry validation`,
3020
+ file: ef.file.displayPath,
3021
+ line: dyn.line,
3022
+ hint: "Use a string-literal id (component ids inside uidex() must be statically analysable)."
2544
3023
  });
2545
3024
  }
2546
3025
  }
2547
3026
  }
2548
3027
  }
3028
+ if (lint && coverageEnabled) {
3029
+ for (const ef of extracted) {
3030
+ if (!ef.metadata) continue;
3031
+ for (const m of ef.metadata) {
3032
+ const check2 = (refKind, ids, spans) => {
3033
+ for (let i = 0; i < (ids?.length ?? 0); i++) {
3034
+ const refId = ids[i];
3035
+ const found = registry.get(refKind, refId) ?? registry.matchPattern(refKind, refId);
3036
+ if (found) continue;
3037
+ diagnostics.push({
3038
+ code: "unknown-reference",
3039
+ severity: "warning",
3040
+ message: `\`export const uidex\` in ${ef.file.displayPath} references unknown ${refKind} "${refId}"`,
3041
+ file: ef.file.displayPath,
3042
+ line: spans?.[i] ? lineOfOffset(ef.file.content, spans[i].start) : m.loc.line,
3043
+ hint: `No ${refKind} with id "${refId}" exists in the registry; fix the reference or add the ${refKind}.`
3044
+ });
3045
+ }
3046
+ };
3047
+ check2("feature", m.features, m.featureSpans);
3048
+ check2("widget", m.widgets, m.widgetSpans);
3049
+ }
3050
+ }
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
+ }
2549
3067
  const summary = {
2550
3068
  errors: diagnostics.filter((d) => d.severity === "error").length,
2551
3069
  warnings: diagnostics.filter((d) => d.severity === "warning").length
2552
3070
  };
2553
3071
  return { diagnostics, summary };
2554
3072
  }
2555
- function legacyJsdocMigration(a) {
2556
- const quote = (s) => JSON.stringify(s);
2557
- const arr = (xs) => xs && xs.length > 0 ? `[${xs.map(quote).join(", ")}]` : "";
2558
- const entityHint = (kind) => {
2559
- const uidexKind = kind.charAt(0).toUpperCase() + kind.slice(1);
2560
- const parts = [`${kind}: ${quote(a.id)}`];
2561
- if (a.acceptance?.length) parts.push(`acceptance: ${arr(a.acceptance)}`);
2562
- return {
2563
- message: `Legacy JSDoc tag \`@uidex ${kind} ${a.id}\` is no longer recognised; migrate to \`export const uidex\``,
2564
- hint: `Replace with: export const uidex = { ${parts.join(", ")} } as const satisfies Uidex.${uidexKind}`
2565
- };
2566
- };
2567
- switch (a.kind) {
2568
- case "page-doc":
2569
- return entityHint("page");
2570
- case "feature-doc":
2571
- return entityHint("feature");
2572
- case "widget-doc":
2573
- return entityHint("widget");
2574
- case "not-flow":
2575
- return {
2576
- message: `Legacy JSDoc tag \`@uidex:not-flow\` is no longer recognised; migrate to \`export const uidex\``,
2577
- hint: `Replace with: export const uidex = { notFlow: true } as const satisfies Uidex.NotFlow`
2578
- };
2579
- case "orphan-acceptance":
2580
- return {
2581
- message: `Legacy JSDoc tag \`@acceptance\` is no longer recognised; migrate to the \`acceptance\` field on \`export const uidex\``,
2582
- hint: `Replace with: export const uidex = { /* kind */, acceptance: ${arr(a.acceptance)} } as const`
2583
- };
2584
- default:
2585
- return null;
3073
+ function lineOfOffset(content, offset) {
3074
+ let line = 1;
3075
+ for (let i = 0; i < offset && i < content.length; i++) {
3076
+ if (content[i] === "\n") line++;
3077
+ }
3078
+ return line;
3079
+ }
3080
+ var TAG_FALLBACK_ID = {
3081
+ a: "link",
3082
+ button: "button",
3083
+ input: "input",
3084
+ select: "select",
3085
+ textarea: "textarea"
3086
+ };
3087
+ function kebabId(str) {
3088
+ return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
3089
+ }
3090
+ function deriveElementId(fact) {
3091
+ const fromHint = fact.nameHint ? kebabId(fact.nameHint) : "";
3092
+ const capped = fromHint.split("-").filter(Boolean).slice(0, 5).join("-");
3093
+ return capped || TAG_FALLBACK_ID[fact.tag] || fact.tag;
3094
+ }
3095
+ function uniqueElementId(fact, used) {
3096
+ const base = deriveElementId(fact);
3097
+ if (!used.has(base)) return base;
3098
+ for (let n = 2; ; n++) {
3099
+ const candidate = `${base}-${n}`;
3100
+ if (!used.has(candidate)) return candidate;
2586
3101
  }
2587
3102
  }
2588
3103
  function normalizeLineEndings(s) {
2589
3104
  return s.replace(/\r\n/g, "\n");
2590
3105
  }
3106
+ function normalizeForCheck(s) {
3107
+ return normalizeLineEndings(s).replace(
3108
+ /export const gitContext = \{[\s\S]*?\} as const/,
3109
+ "export const gitContext = {} as const"
3110
+ );
3111
+ }
2591
3112
  function formatChangedSummary(change) {
2592
3113
  const parts = [];
2593
3114
  const fmt = (kind, names) => {
@@ -2671,77 +3192,26 @@ function extractEntitiesArray(source) {
2671
3192
  inStr = c;
2672
3193
  continue;
2673
3194
  }
2674
- if (c === "[") depth++;
2675
- else if (c === "]") {
2676
- depth--;
2677
- if (depth === 0) {
2678
- const json = source.slice(start, i + 1);
2679
- try {
2680
- return JSON.parse(json);
2681
- } catch {
2682
- return null;
2683
- }
2684
- }
2685
- }
2686
- }
2687
- return null;
2688
- }
2689
- function findJsxOpeningEnd(src, start) {
2690
- let i = start;
2691
- while (i < src.length) {
2692
- const ch = src[i];
2693
- if (ch === ">" || ch === "/" && src[i + 1] === ">") return i;
2694
- if (ch === '"' || ch === "'" || ch === "`") {
2695
- i = skipString2(src, i);
2696
- } else if (ch === "{") {
2697
- i = skipBraces(src, i);
2698
- } else {
2699
- i++;
2700
- }
2701
- }
2702
- return -1;
2703
- }
2704
- function skipString2(src, start) {
2705
- const quote = src[start];
2706
- let i = start + 1;
2707
- while (i < src.length) {
2708
- if (src[i] === "\\" && quote !== "`") {
2709
- i += 2;
2710
- continue;
2711
- }
2712
- if (quote === "`" && src[i] === "$" && src[i + 1] === "{") {
2713
- i = skipBraces(src, i + 1);
2714
- continue;
2715
- }
2716
- if (src[i] === quote) return i + 1;
2717
- i++;
2718
- }
2719
- return i;
2720
- }
2721
- function skipBraces(src, start) {
2722
- let depth = 1;
2723
- let i = start + 1;
2724
- while (i < src.length && depth > 0) {
2725
- const ch = src[i];
2726
- if (ch === "{") {
2727
- depth++;
2728
- i++;
2729
- } else if (ch === "}") {
3195
+ if (c === "[") depth++;
3196
+ else if (c === "]") {
2730
3197
  depth--;
2731
- i++;
2732
- } else if (ch === '"' || ch === "'" || ch === "`") {
2733
- i = skipString2(src, i);
2734
- } else {
2735
- i++;
3198
+ if (depth === 0) {
3199
+ const json = source.slice(start, i + 1);
3200
+ try {
3201
+ return JSON.parse(json);
3202
+ } catch {
3203
+ return null;
3204
+ }
3205
+ }
2736
3206
  }
2737
3207
  }
2738
- return i;
3208
+ return null;
2739
3209
  }
2740
3210
  function dynamicAttrHint(kind) {
2741
3211
  if (kind === "region") {
2742
3212
  return `Use a string literal: \`data-uidex-region="id"\`, or declare the region via \`export const uidex = { region: "id" } as const satisfies Uidex.Region\` on the file that passes the region value`;
2743
3213
  }
2744
- return `The scanner requires string-literal attribute values. If this component forwards the annotation via a prop, restructure so the caller provides the annotated element directly (e.g. via a slot or render prop) with a string-literal \`data-uidex\` attribute`;
3214
+ return `The scanner resolves string literals, same-file const references, ternaries with literal branches, and template literals with static text (dynamic parts become \`*\` patterns). If this component forwards the annotation via a prop, restructure so the caller provides the annotated element directly (e.g. via a slot or render prop)`;
2745
3215
  }
2746
3216
  function stableStringify(value) {
2747
3217
  return JSON.stringify(value, stableReplacer);
@@ -2758,6 +3228,148 @@ function stableReplacer(_key, value) {
2758
3228
  }
2759
3229
 
2760
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
+ }
2761
3373
  function sortById(arr) {
2762
3374
  return [...arr].sort((a, b) => a.id.localeCompare(b.id));
2763
3375
  }
@@ -2774,9 +3386,7 @@ function replacerSorted(_key, value) {
2774
3386
  }
2775
3387
  return value;
2776
3388
  }
2777
- function emitIdUnion(name, ids, typeMode) {
2778
- if (typeMode === "loose") return `export type ${name} = string
2779
- `;
3389
+ function emitIdUnion(name, ids) {
2780
3390
  if (ids.length === 0) return `export type ${name} = never
2781
3391
  `;
2782
3392
  const sorted = [...ids].sort();
@@ -2785,133 +3395,153 @@ function emitIdUnion(name, ids, typeMode) {
2785
3395
  ${body}
2786
3396
  `;
2787
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
+ }
2788
3412
  function emit(opts) {
2789
- const {
2790
- registry,
2791
- gitContext,
2792
- uidexImport = "uidex",
2793
- typeMode = "strict"
2794
- } = opts;
3413
+ const { registry, gitContext, uidexImport = "uidex" } = opts;
2795
3414
  const routes = [...registry.list("route")].sort(
2796
3415
  (a, b) => a.path.localeCompare(b.path)
2797
3416
  );
2798
3417
  const pages = sortById(registry.list("page"));
2799
3418
  const features = sortById(registry.list("feature"));
2800
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
+ }
2801
3424
  const regions = sortById(registry.list("region"));
2802
3425
  const elements = sortById(registry.list("element"));
2803
3426
  const primitives = sortById(registry.list("primitive"));
2804
3427
  const flows = sortById(registry.list("flow"));
2805
- const lines = [];
2806
- lines.push("// THIS FILE IS AUTO-GENERATED BY `uidex scan`. DO NOT EDIT.");
2807
- lines.push("/* eslint-disable */");
2808
- lines.push(`import { createUidex } from ${JSON.stringify(uidexImport)}`);
2809
- lines.push(
2810
- `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."
2811
3432
  );
2812
- lines.push("");
2813
- lines.push("// ---- id unions ----");
2814
- lines.push(
2815
- emitIdUnion(
3433
+ t.push("/* eslint-disable */");
3434
+ t.push("");
3435
+ t.push("// ---- id unions ----");
3436
+ t.push(
3437
+ emitId(
2816
3438
  "PageId",
2817
- pages.map((e) => e.id),
2818
- typeMode
3439
+ pages.map((e) => e.id)
2819
3440
  )
2820
3441
  );
2821
- lines.push(
2822
- emitIdUnion(
3442
+ t.push(
3443
+ emitId(
2823
3444
  "FeatureId",
2824
- features.map((e) => e.id),
2825
- typeMode
3445
+ features.map((e) => e.id)
2826
3446
  )
2827
3447
  );
2828
- lines.push(
2829
- emitIdUnion(
3448
+ t.push(
3449
+ emitId(
2830
3450
  "WidgetId",
2831
- widgets.map((e) => e.id),
2832
- typeMode
3451
+ widgets.map((e) => e.id)
2833
3452
  )
2834
3453
  );
2835
- lines.push(
2836
- emitIdUnion(
3454
+ t.push(
3455
+ emitId(
2837
3456
  "RegionId",
2838
- regions.map((e) => e.id),
2839
- typeMode
3457
+ regions.map((e) => e.id)
2840
3458
  )
2841
3459
  );
2842
- lines.push(
2843
- emitIdUnion(
3460
+ t.push(
3461
+ emitId(
2844
3462
  "ElementId",
2845
- elements.map((e) => e.id),
2846
- typeMode
3463
+ elements.map((e) => e.id)
2847
3464
  )
2848
3465
  );
2849
- lines.push(
2850
- emitIdUnion(
3466
+ t.push(
3467
+ emitId(
2851
3468
  "PrimitiveId",
2852
- primitives.map((e) => e.id),
2853
- typeMode
3469
+ primitives.map((e) => e.id)
2854
3470
  )
2855
3471
  );
2856
- lines.push(
2857
- emitIdUnion(
3472
+ t.push(
3473
+ emitId(
2858
3474
  "FlowId",
2859
- flows.map((e) => e.id),
2860
- typeMode
3475
+ flows.map((e) => e.id)
2861
3476
  )
2862
3477
  );
2863
- lines.push(
2864
- emitIdUnion(
3478
+ t.push(
3479
+ emitId(
2865
3480
  "RouteId",
2866
- routes.map((e) => e.path),
2867
- typeMode
3481
+ routes.map((e) => e.path)
2868
3482
  )
2869
3483
  );
2870
- lines.push("");
2871
- lines.push("// ---- authoring-surface shape types ----");
2872
- lines.push("export namespace Uidex {");
2873
- lines.push(" export interface Page {");
2874
- lines.push(" page: PageId | false");
2875
- lines.push(" name?: string");
2876
- lines.push(" features?: readonly FeatureId[]");
2877
- lines.push(" widgets?: readonly WidgetId[]");
2878
- lines.push(" acceptance?: readonly string[]");
2879
- lines.push(" description?: string");
2880
- lines.push(" }");
2881
- lines.push(" export interface Feature {");
2882
- lines.push(" feature: FeatureId | false");
2883
- lines.push(" name?: string");
2884
- lines.push(" features?: readonly FeatureId[]");
2885
- lines.push(" acceptance?: readonly string[]");
2886
- lines.push(" description?: string");
2887
- lines.push(" }");
2888
- lines.push(" export interface Primitive {");
2889
- lines.push(" primitive: PrimitiveId");
2890
- lines.push(" name?: string");
2891
- lines.push(" description?: string");
2892
- lines.push(" }");
2893
- lines.push(" export interface Widget {");
2894
- lines.push(" widget: WidgetId");
2895
- lines.push(" name?: string");
2896
- lines.push(" acceptance?: readonly string[]");
2897
- lines.push(" description?: string");
2898
- lines.push(" }");
2899
- lines.push(" export interface Region {");
2900
- lines.push(" region: RegionId | false");
2901
- lines.push(" name?: string");
2902
- lines.push(" description?: string");
2903
- lines.push(" }");
2904
- lines.push(" export interface Flow {");
2905
- lines.push(" flow: FlowId");
2906
- lines.push(" name?: string");
2907
- lines.push(" description?: string");
2908
- lines.push(" }");
2909
- lines.push(" export interface NotFlow {");
2910
- lines.push(" notFlow: true");
2911
- lines.push(" }");
2912
- lines.push("}");
2913
- lines.push("");
2914
- 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("");
2915
3545
  const allEntities = [
2916
3546
  ...routes,
2917
3547
  ...pages,
@@ -2922,25 +3552,39 @@ function emit(opts) {
2922
3552
  ...primitives,
2923
3553
  ...flows
2924
3554
  ];
2925
- lines.push(
2926
- `export const entities: ReadonlyArray<Entity> = ${jsonStable(allEntities)}`
2927
- );
2928
- lines.push("");
2929
- 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));
2930
3557
  const gc = gitContext ?? { branch: null, commit: null, pr: null };
2931
- lines.push(`export const gitContext = ${jsonStable(gc)} as const`);
2932
- lines.push("");
2933
- lines.push("// ---- registry factory ----");
2934
- lines.push("export function loadRegistry(target: Registry): Registry {");
2935
- lines.push(" for (const entity of entities) target.add(entity)");
2936
- lines.push(" return target");
2937
- lines.push("}");
2938
- lines.push("");
2939
- lines.push("// ---- preconfigured uidex instance ----");
2940
- lines.push("export const uidex = createUidex()");
2941
- lines.push("for (const entity of entities) uidex.registry.add(entity)");
2942
- lines.push("");
2943
- 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 };
2944
3588
  }
2945
3589
 
2946
3590
  // src/scanner/scan/git.ts
@@ -2977,22 +3621,33 @@ function parseGitHubRef(ref) {
2977
3621
 
2978
3622
  // src/scanner/scan/scaffold.ts
2979
3623
  var fs3 = __toESM(require("fs"), 1);
2980
- var path5 = __toESM(require("path"), 1);
3624
+ var path6 = __toESM(require("path"), 1);
2981
3625
  function scaffoldWidgetSpec(opts) {
3626
+ return scaffoldSpec({
3627
+ registry: opts.registry,
3628
+ kind: "widget",
3629
+ id: opts.widgetId,
3630
+ outDir: opts.outDir,
3631
+ force: opts.force,
3632
+ fixtureImport: opts.fixtureImport
3633
+ });
3634
+ }
3635
+ function scaffoldSpec(opts) {
2982
3636
  const {
2983
3637
  registry,
2984
- widgetId,
3638
+ kind,
3639
+ id,
2985
3640
  outDir,
2986
3641
  force = false,
2987
3642
  fixtureImport = "./fixtures"
2988
3643
  } = opts;
2989
- const widget = registry.get("widget", widgetId);
2990
- if (!widget) {
2991
- throw new Error(`Widget "${widgetId}" not found in registry`);
3644
+ const entity = registry.get(kind, id);
3645
+ if (!entity) {
3646
+ throw new Error(`${capitalize(kind)} "${id}" not found in registry`);
2992
3647
  }
2993
- const criteria = widget.meta?.acceptance ?? [];
2994
- const filename = `widget-${widgetId}.spec.ts`;
2995
- const outputPath = path5.resolve(outDir, filename);
3648
+ const criteria = entity.meta?.acceptance ?? [];
3649
+ const filename = kind === "widget" ? `widget-${id}.spec.ts` : `flow-${id}.spec.ts`;
3650
+ const outputPath = path6.resolve(outDir, filename);
2996
3651
  if (fs3.existsSync(outputPath) && !force) {
2997
3652
  return {
2998
3653
  outputPath,
@@ -3001,15 +3656,14 @@ function scaffoldWidgetSpec(opts) {
3001
3656
  reason: `spec already exists at ${outputPath}; pass --force to overwrite`
3002
3657
  };
3003
3658
  }
3004
- const content = renderSpec({
3005
- widgetId,
3006
- criteria,
3007
- fixtureImport
3008
- });
3009
- fs3.mkdirSync(path5.dirname(outputPath), { recursive: true });
3659
+ const content = renderSpec({ id, criteria, fixtureImport });
3660
+ fs3.mkdirSync(path6.dirname(outputPath), { recursive: true });
3010
3661
  fs3.writeFileSync(outputPath, content, "utf8");
3011
3662
  return { outputPath, written: true, skipped: false };
3012
3663
  }
3664
+ function capitalize(s) {
3665
+ return s.charAt(0).toUpperCase() + s.slice(1);
3666
+ }
3013
3667
  function renderSpec(args) {
3014
3668
  const lines = [];
3015
3669
  lines.push(
@@ -3017,7 +3671,7 @@ function renderSpec(args) {
3017
3671
  );
3018
3672
  lines.push("");
3019
3673
  lines.push(
3020
- `test.describe(${JSON.stringify(args.widgetId)}, { tag: "@uidex:flow" }, () => {`
3674
+ `test.describe(${JSON.stringify(args.id)}, { tag: "@uidex:flow" }, () => {`
3021
3675
  );
3022
3676
  if (args.criteria.length === 0) {
3023
3677
  lines.push(` test("TODO: add acceptance criteria", async () => {`);
@@ -3040,7 +3694,69 @@ function renderSpec(args) {
3040
3694
 
3041
3695
  // src/scanner/scan/pipeline.ts
3042
3696
  var fs4 = __toESM(require("fs"), 1);
3043
- var path6 = __toESM(require("path"), 1);
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
+ }
3044
3760
  function runScan(opts = {}) {
3045
3761
  const cwd = opts.cwd ?? process.cwd();
3046
3762
  const configs = opts.configs ?? discover({ cwd });
@@ -3067,34 +3783,54 @@ function runOne(dc, opts) {
3067
3783
  flowFiles: extractedFlows
3068
3784
  });
3069
3785
  const gitContext = resolveGitContext({ cwd: configDir });
3070
- const generated = emit({
3786
+ const emitted = emit({
3071
3787
  registry: resolved.registry,
3072
- gitContext,
3073
- typeMode: config.typeMode
3788
+ gitContext
3074
3789
  });
3075
- const outputPath = path6.resolve(configDir, config.output);
3076
- const outputRel = config.output;
3077
- 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;
3078
3799
  if (opts.check) {
3079
- try {
3080
- existingOnDisk = fs4.readFileSync(outputPath, "utf8");
3081
- } catch {
3082
- existingOnDisk = null;
3083
- }
3800
+ typesOnDisk = readOrNull(typesPath);
3801
+ dataOnDisk = readOrNull(dataPath);
3802
+ locsOnDisk = readOrNull(locsPath);
3084
3803
  }
3804
+ const hasExtractDiagnostics = [...extracted, ...extractedFlows].some(
3805
+ (ef) => (ef.diagnostics?.length ?? 0) > 0
3806
+ );
3807
+ const capturedStates = opts.capturedStates ?? loadCapturedStates(configDir, config);
3808
+ const pageBaseline = opts.pageBaseline ?? loadPageBaseline(configDir, config);
3085
3809
  let auditResult;
3086
- if (opts.check || opts.lint || resolved.diagnostics.length > 0) {
3810
+ if (opts.check || opts.lint || resolved.diagnostics.length > 0 || hasExtractDiagnostics) {
3087
3811
  auditResult = audit({
3088
3812
  registry: resolved.registry,
3089
3813
  extracted,
3090
3814
  files: sourceFiles,
3815
+ flowExtracted: extractedFlows,
3091
3816
  config,
3092
3817
  check: opts.check,
3093
3818
  lint: opts.lint,
3094
3819
  resolveDiagnostics: resolved.diagnostics,
3095
- generated,
3096
- existingOnDisk,
3097
- 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
3098
3834
  });
3099
3835
  }
3100
3836
  return {
@@ -3103,39 +3839,311 @@ function runOne(dc, opts) {
3103
3839
  registry: resolved.registry,
3104
3840
  gitContext,
3105
3841
  audit: auditResult,
3106
- generated,
3107
- 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
3108
3850
  };
3109
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
+ }
3110
3865
  function writeScanResult(result) {
3111
- fs4.mkdirSync(path6.dirname(result.outputPath), { recursive: true });
3112
- 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);
3874
+ }
3875
+
3876
+ // src/scanner/scan/fix.ts
3877
+ var fs5 = __toESM(require("fs"), 1);
3878
+ var path8 = __toESM(require("path"), 1);
3879
+ function applyFixes(diagnostics) {
3880
+ const entries = [];
3881
+ for (const d of diagnostics) {
3882
+ if (!d.fix) continue;
3883
+ entries.push({
3884
+ code: d.code,
3885
+ description: d.fix.description,
3886
+ file: d.file,
3887
+ edits: d.fix.edits ?? [],
3888
+ createFiles: d.fix.createFiles ?? [],
3889
+ deleteFiles: d.fix.deleteFiles ?? []
3890
+ });
3891
+ }
3892
+ if (entries.length === 0) return { applied: [], skipped: [] };
3893
+ const seenEdits = /* @__PURE__ */ new Set();
3894
+ const editsByFile = /* @__PURE__ */ new Map();
3895
+ for (const entry of entries) {
3896
+ for (const edit of entry.edits) {
3897
+ const key = `${edit.path}:${edit.start}:${edit.end}:${edit.replacement}`;
3898
+ if (seenEdits.has(key)) continue;
3899
+ seenEdits.add(key);
3900
+ let list = editsByFile.get(edit.path);
3901
+ if (!list) {
3902
+ list = [];
3903
+ editsByFile.set(edit.path, list);
3904
+ }
3905
+ list.push({ ...edit, entry });
3906
+ }
3907
+ }
3908
+ for (const [filePath, edits] of editsByFile) {
3909
+ let content;
3910
+ try {
3911
+ content = fs5.readFileSync(filePath, "utf8");
3912
+ } catch {
3913
+ for (const e of edits) e.entry.skippedReason ??= "file is unreadable";
3914
+ continue;
3915
+ }
3916
+ edits.sort((a, b) => a.start - b.start || a.end - b.end);
3917
+ const kept = [];
3918
+ let prevEnd = -1;
3919
+ for (const edit of edits) {
3920
+ if (edit.start < prevEnd) {
3921
+ edit.entry.skippedReason ??= "overlapping edit";
3922
+ continue;
3923
+ }
3924
+ kept.push(edit);
3925
+ prevEnd = edit.end;
3926
+ }
3927
+ for (let i = kept.length - 1; i >= 0; i--) {
3928
+ const edit = kept[i];
3929
+ content = content.slice(0, edit.start) + edit.replacement + content.slice(edit.end);
3930
+ }
3931
+ if (kept.length > 0) fs5.writeFileSync(filePath, content, "utf8");
3932
+ }
3933
+ for (const entry of entries) {
3934
+ if (entry.skippedReason) continue;
3935
+ for (const create of entry.createFiles) {
3936
+ if (fs5.existsSync(create.path)) {
3937
+ entry.skippedReason = `${path8.basename(create.path)} already exists`;
3938
+ continue;
3939
+ }
3940
+ fs5.mkdirSync(path8.dirname(create.path), { recursive: true });
3941
+ fs5.writeFileSync(create.path, create.content, "utf8");
3942
+ }
3943
+ if (entry.skippedReason) continue;
3944
+ for (const del of entry.deleteFiles) {
3945
+ try {
3946
+ fs5.unlinkSync(del);
3947
+ } catch {
3948
+ }
3949
+ }
3950
+ }
3951
+ const applied = [];
3952
+ const skipped = [];
3953
+ for (const entry of entries) {
3954
+ const summary = {
3955
+ code: entry.code,
3956
+ description: entry.description,
3957
+ file: entry.file
3958
+ };
3959
+ if (entry.skippedReason) {
3960
+ skipped.push({ ...summary, reason: entry.skippedReason });
3961
+ } else {
3962
+ applied.push(summary);
3963
+ }
3964
+ }
3965
+ return { applied, skipped };
3966
+ }
3967
+
3968
+ // src/scanner/scan/rename.ts
3969
+ var ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
3970
+ function renameEntity(opts) {
3971
+ const { cwd, kind, oldId, newId, force = false } = opts;
3972
+ const manual = [];
3973
+ const errors = [];
3974
+ if (!ID_RE.test(newId)) {
3975
+ return {
3976
+ edits: 0,
3977
+ manual,
3978
+ errors: [`New id "${newId}" is not kebab-case`]
3979
+ };
3980
+ }
3981
+ const configs = discover({ cwd });
3982
+ if (configs.length === 0) {
3983
+ return { edits: 0, manual, errors: [`No .uidex.json found under ${cwd}`] };
3984
+ }
3985
+ const edits = [];
3986
+ for (const dc of configs) {
3987
+ const { config, configDir } = dc;
3988
+ const sourceFiles = walk(config.sources, {
3989
+ cwd: configDir,
3990
+ globalExcludes: config.exclude
3991
+ });
3992
+ const extracted = extract(sourceFiles);
3993
+ const flowFiles = config.flows ? walk(
3994
+ config.flows.map((glob) => ({ rootDir: ".", include: [glob] })),
3995
+ { cwd: configDir, includeTests: true }
3996
+ ) : [];
3997
+ const extractedFlows = extract(flowFiles);
3998
+ const scan = runScan({ cwd: configDir, configs: [dc] })[0];
3999
+ const registry = scan.registry;
4000
+ if (!registry.get(kind, oldId)) {
4001
+ if (registry.matchPattern(kind, oldId)) {
4002
+ errors.push(
4003
+ `${kind} "${oldId}" only matches via a pattern id; pattern-backed ids cannot be renamed mechanically`
4004
+ );
4005
+ } else {
4006
+ errors.push(`${kind} "${oldId}" not found in registry`);
4007
+ }
4008
+ continue;
4009
+ }
4010
+ if (registry.get(kind, newId) && !force) {
4011
+ errors.push(
4012
+ `${kind} "${newId}" already exists; pass --force to merge the ids`
4013
+ );
4014
+ continue;
4015
+ }
4016
+ const quoteAs = (content, start) => {
4017
+ const q = content[start];
4018
+ return q === '"' || q === "'" || q === "`" ? `${q}${newId}${q}` : `"${newId}"`;
4019
+ };
4020
+ for (const ef of extracted) {
4021
+ for (const a of ef.annotations) {
4022
+ if (a.kind !== kind || a.id !== oldId) continue;
4023
+ if (a.span) {
4024
+ edits.push({
4025
+ path: ef.file.sourcePath,
4026
+ start: a.span.start,
4027
+ end: a.span.end,
4028
+ replacement: quoteAs(ef.file.content, a.span.start)
4029
+ });
4030
+ } else {
4031
+ manual.push({
4032
+ file: a.file,
4033
+ line: a.line,
4034
+ reason: "attribute value is not a plain string literal (const reference, ternary, or template)"
4035
+ });
4036
+ }
4037
+ }
4038
+ for (const m of ef.metadata ?? []) {
4039
+ if (kind === "widget" && m.kind === "widget" && m.id === oldId) {
4040
+ if (m.idSpan) {
4041
+ edits.push({
4042
+ path: ef.file.sourcePath,
4043
+ start: m.idSpan.start,
4044
+ end: m.idSpan.end,
4045
+ replacement: quoteAs(ef.file.content, m.idSpan.start)
4046
+ });
4047
+ } else {
4048
+ manual.push({
4049
+ file: ef.file.displayPath,
4050
+ line: m.loc.line ?? 1,
4051
+ reason: "widget export id is not a plain string literal"
4052
+ });
4053
+ }
4054
+ }
4055
+ if (kind === "widget" && m.widgets) {
4056
+ for (let i = 0; i < m.widgets.length; i++) {
4057
+ if (m.widgets[i] !== oldId) continue;
4058
+ const span = m.widgetSpans?.[i];
4059
+ if (span) {
4060
+ edits.push({
4061
+ path: ef.file.sourcePath,
4062
+ start: span.start,
4063
+ end: span.end,
4064
+ replacement: quoteAs(ef.file.content, span.start)
4065
+ });
4066
+ }
4067
+ }
4068
+ }
4069
+ }
4070
+ }
4071
+ for (const ef of extractedFlows) {
4072
+ for (const fact of ef.flows ?? []) {
4073
+ for (const call of fact.calls) {
4074
+ if (call.id !== oldId) continue;
4075
+ if (call.span) {
4076
+ edits.push({
4077
+ path: ef.file.sourcePath,
4078
+ start: call.span.start,
4079
+ end: call.span.end,
4080
+ replacement: quoteAs(ef.file.content, call.span.start)
4081
+ });
4082
+ } else {
4083
+ manual.push({
4084
+ file: ef.file.displayPath,
4085
+ line: call.line,
4086
+ reason: "uidex() argument is not a plain string literal"
4087
+ });
4088
+ }
4089
+ }
4090
+ }
4091
+ }
4092
+ }
4093
+ if (errors.length > 0) {
4094
+ return { edits: 0, manual, errors };
4095
+ }
4096
+ if (edits.length === 0 && manual.length === 0) {
4097
+ return {
4098
+ edits: 0,
4099
+ manual,
4100
+ errors: [
4101
+ `${kind} "${oldId}" has no editable occurrences (convention-derived ids like landmarks cannot be renamed)`
4102
+ ]
4103
+ };
4104
+ }
4105
+ const result = applyFixes([
4106
+ {
4107
+ code: "rename",
4108
+ severity: "info",
4109
+ message: "",
4110
+ fix: {
4111
+ description: `Rename ${kind} "${oldId}" to "${newId}"`,
4112
+ edits
4113
+ }
4114
+ }
4115
+ ]);
4116
+ if (result.skipped.length > 0) {
4117
+ errors.push(`Some edits were skipped: ${result.skipped[0].reason}`);
4118
+ }
4119
+ for (const r of runScan({ cwd })) writeScanResult(r);
4120
+ return { edits: edits.length, manual, errors };
3113
4121
  }
3114
4122
 
3115
4123
  // src/scanner/scan/cli.ts
3116
- var fs7 = __toESM(require("fs"), 1);
3117
- var path9 = __toESM(require("path"), 1);
4124
+ var fs8 = __toESM(require("fs"), 1);
4125
+ var path11 = __toESM(require("path"), 1);
3118
4126
 
3119
4127
  // src/scanner/scan/ai/index.ts
3120
4128
  var p = __toESM(require("@clack/prompts"), 1);
3121
4129
 
3122
4130
  // src/scanner/scan/ai/providers/claude.ts
3123
- var fs6 = __toESM(require("fs"), 1);
3124
- var path8 = __toESM(require("path"), 1);
4131
+ var fs7 = __toESM(require("fs"), 1);
4132
+ var path10 = __toESM(require("path"), 1);
3125
4133
 
3126
4134
  // src/scanner/scan/ai/templates.ts
3127
- var fs5 = __toESM(require("fs"), 1);
3128
- var path7 = __toESM(require("path"), 1);
4135
+ var fs6 = __toESM(require("fs"), 1);
4136
+ var path9 = __toESM(require("path"), 1);
3129
4137
  function templatePath(rel) {
3130
4138
  const candidates = [
3131
- path7.resolve(__dirname, "../../templates", rel),
3132
- // dist/cli/cli.cjs → ../../templates
3133
- path7.resolve(__dirname, "../../../templates", rel)
3134
- // src/scan/ai/foo.ts../../../templates
4139
+ path9.resolve(__dirname, "../../templates", rel),
4140
+ // dist/cli/cli.cjs → ../../templates
4141
+ path9.resolve(__dirname, "../../../../templates", rel)
4142
+ // src/scanner/scan/ai → ../../../../templates
3135
4143
  ];
3136
4144
  for (const c of candidates) {
3137
4145
  try {
3138
- fs5.accessSync(c, fs5.constants.R_OK);
4146
+ fs6.accessSync(c, fs6.constants.R_OK);
3139
4147
  return c;
3140
4148
  } catch {
3141
4149
  continue;
@@ -3147,24 +4155,39 @@ function templatePath(rel) {
3147
4155
  );
3148
4156
  }
3149
4157
  function readTemplate(rel) {
3150
- return fs5.readFileSync(templatePath(rel), "utf8");
4158
+ return fs6.readFileSync(templatePath(rel), "utf8");
3151
4159
  }
3152
4160
 
3153
4161
  // src/scanner/scan/ai/providers/claude.ts
3154
- var CLAUDE_FILES = [
3155
- { dest: ".claude/rules/uidex.md", template: "claude/rules.md" },
3156
- { dest: ".claude/commands/uidex/audit.md", template: "claude/audit.md" },
3157
- { dest: ".claude/commands/uidex/api.md", template: "claude/api.md" }
4162
+ var SKILL_FILES = [
4163
+ { dest: ".claude/skills/uidex/SKILL.md", template: "claude/SKILL.md" },
4164
+ {
4165
+ dest: ".claude/skills/uidex/references/conventions.md",
4166
+ template: "claude/references/conventions.md"
4167
+ },
4168
+ {
4169
+ dest: ".claude/skills/uidex/references/audit.md",
4170
+ template: "claude/references/audit.md"
4171
+ },
4172
+ {
4173
+ dest: ".claude/skills/uidex/references/api.md",
4174
+ template: "claude/references/api.md"
4175
+ }
4176
+ ];
4177
+ var LEGACY_FILES = [
4178
+ ".claude/rules/uidex.md",
4179
+ ".claude/commands/uidex/audit.md",
4180
+ ".claude/commands/uidex/api.md"
3158
4181
  ];
3159
4182
  var claudeProvider = {
3160
4183
  id: "claude",
3161
4184
  label: "Claude Code",
3162
- description: "Adds .claude/rules/uidex.md, /uidex:audit, and /uidex:api slash commands.",
4185
+ description: "Adds .claude/skills/uidex/ skill with conventions, audit, and API references.",
3163
4186
  async install({ cwd, force }) {
3164
4187
  const changes = [];
3165
- for (const file of CLAUDE_FILES) {
3166
- const dest = path8.join(cwd, file.dest);
3167
- const exists = fs6.existsSync(dest);
4188
+ for (const file of SKILL_FILES) {
4189
+ const dest = path10.join(cwd, file.dest);
4190
+ const exists = fs7.existsSync(dest);
3168
4191
  if (exists && !force) {
3169
4192
  changes.push({
3170
4193
  path: file.dest,
@@ -3173,36 +4196,56 @@ var claudeProvider = {
3173
4196
  });
3174
4197
  continue;
3175
4198
  }
3176
- fs6.mkdirSync(path8.dirname(dest), { recursive: true });
3177
- fs6.writeFileSync(dest, readTemplate(file.template));
4199
+ fs7.mkdirSync(path10.dirname(dest), { recursive: true });
4200
+ fs7.writeFileSync(dest, readTemplate(file.template));
3178
4201
  changes.push({
3179
4202
  path: file.dest,
3180
4203
  action: exists ? "overwritten" : "created"
3181
4204
  });
3182
4205
  }
4206
+ for (const rel of LEGACY_FILES) {
4207
+ const dest = path10.join(cwd, rel);
4208
+ if (fs7.existsSync(dest)) {
4209
+ fs7.unlinkSync(dest);
4210
+ changes.push({ path: rel, action: "removed" });
4211
+ }
4212
+ }
4213
+ cleanupEmpty(path10.join(cwd, ".claude/commands/uidex"));
4214
+ cleanupEmpty(path10.join(cwd, ".claude/commands"));
4215
+ cleanupEmpty(path10.join(cwd, ".claude/rules"));
3183
4216
  return { changes };
3184
4217
  },
3185
4218
  async uninstall({ cwd }) {
3186
4219
  const changes = [];
3187
- for (const file of CLAUDE_FILES) {
3188
- const dest = path8.join(cwd, file.dest);
3189
- if (!fs6.existsSync(dest)) {
4220
+ for (const file of SKILL_FILES) {
4221
+ const dest = path10.join(cwd, file.dest);
4222
+ if (!fs7.existsSync(dest)) {
3190
4223
  changes.push({ path: file.dest, action: "skipped", reason: "absent" });
3191
4224
  continue;
3192
4225
  }
3193
- fs6.unlinkSync(dest);
4226
+ fs7.unlinkSync(dest);
3194
4227
  changes.push({ path: file.dest, action: "removed" });
3195
4228
  }
3196
- cleanupEmpty(path8.join(cwd, ".claude/commands/uidex"));
3197
- cleanupEmpty(path8.join(cwd, ".claude/commands"));
3198
- cleanupEmpty(path8.join(cwd, ".claude/rules"));
4229
+ cleanupEmpty(path10.join(cwd, ".claude/skills/uidex/references"));
4230
+ cleanupEmpty(path10.join(cwd, ".claude/skills/uidex"));
4231
+ cleanupEmpty(path10.join(cwd, ".claude/skills"));
4232
+ for (const rel of LEGACY_FILES) {
4233
+ const dest = path10.join(cwd, rel);
4234
+ if (fs7.existsSync(dest)) {
4235
+ fs7.unlinkSync(dest);
4236
+ changes.push({ path: rel, action: "removed" });
4237
+ }
4238
+ }
4239
+ cleanupEmpty(path10.join(cwd, ".claude/commands/uidex"));
4240
+ cleanupEmpty(path10.join(cwd, ".claude/commands"));
4241
+ cleanupEmpty(path10.join(cwd, ".claude/rules"));
3199
4242
  return { changes };
3200
4243
  }
3201
4244
  };
3202
4245
  function cleanupEmpty(dir) {
3203
4246
  try {
3204
- const entries = fs6.readdirSync(dir);
3205
- if (entries.length === 0) fs6.rmdirSync(dir);
4247
+ const entries = fs7.readdirSync(dir);
4248
+ if (entries.length === 0) fs7.rmdirSync(dir);
3206
4249
  } catch {
3207
4250
  }
3208
4251
  }
@@ -3369,6 +4412,8 @@ async function run(opts) {
3369
4412
  return runScanCommand(cwd, flags, writer);
3370
4413
  case "scaffold":
3371
4414
  return runScaffold(cwd, positional.slice(1), flags, writer);
4415
+ case "rename":
4416
+ return runRename(cwd, positional.slice(1), flags, writer);
3372
4417
  case "ai": {
3373
4418
  const result = await runAiCommand({
3374
4419
  cwd,
@@ -3395,25 +4440,24 @@ function helpText2() {
3395
4440
  "Commands:",
3396
4441
  " init Create a .uidex.json",
3397
4442
  " scan [flags] Run the scanner pipeline",
3398
- " scaffold widget <id> Emit a Playwright spec from a widget's acceptance",
4443
+ " scaffold <widget|page|feature> <id> Emit a Playwright spec from declared acceptance",
4444
+ " rename <element|widget|region> <old-id> <new-id> Rename an id everywhere (DOM attr, flows, exports)",
3399
4445
  " ai <install|uninstall|providers> Manage AI assistant integrations",
3400
- " api <METHOD> <PATH> Call the uidex API",
3401
- " api --list Show available API routes",
3402
- " api login Authenticate via browser",
3403
- " api login --token <tok> Store an auth token directly",
3404
4446
  "",
3405
4447
  "Flags:",
3406
4448
  " --check Verify the on-disk gen file matches a fresh scan; exit non-zero on drift (read-only)",
3407
- " --lint Run lint diagnostics (missing annotations, scope leak, legacy JSDoc)",
4449
+ " --lint Run lint diagnostics (missing annotations, scope leak, duplicate ids, coverage)",
3408
4450
  " --audit Equivalent to --check --lint (read-only)",
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",
3409
4453
  " --json Emit JSON diagnostics on stdout",
3410
4454
  " --force (scaffold) overwrite existing spec",
3411
4455
  ""
3412
4456
  ].join("\n");
3413
4457
  }
3414
4458
  function runInit(cwd, w) {
3415
- const configPath = path9.join(cwd, CONFIG_FILENAME);
3416
- if (fs7.existsSync(configPath)) {
4459
+ const configPath = path11.join(cwd, CONFIG_FILENAME);
4460
+ if (fs8.existsSync(configPath)) {
3417
4461
  w.err(`.uidex.json already exists at ${configPath}`);
3418
4462
  return w.result(1);
3419
4463
  }
@@ -3422,16 +4466,16 @@ function runInit(cwd, w) {
3422
4466
  sources: [{ rootDir: "src" }],
3423
4467
  output: "src/uidex.gen.ts"
3424
4468
  };
3425
- fs7.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
4469
+ fs8.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
3426
4470
  w.out(`Created ${configPath}`);
3427
- const gitignorePath = path9.join(cwd, ".gitignore");
3428
- const entry = "*.gen.ts";
3429
- if (fs7.existsSync(gitignorePath)) {
3430
- const existing = fs7.readFileSync(gitignorePath, "utf8");
4471
+ const gitignorePath = path11.join(cwd, ".gitignore");
4472
+ const entry = "*.gen.*";
4473
+ if (fs8.existsSync(gitignorePath)) {
4474
+ const existing = fs8.readFileSync(gitignorePath, "utf8");
3431
4475
  const hasEntry = existing.split("\n").some((line) => line.trim() === entry);
3432
4476
  if (!hasEntry) {
3433
4477
  const needsNewline = existing.length > 0 && !existing.endsWith("\n");
3434
- fs7.appendFileSync(
4478
+ fs8.appendFileSync(
3435
4479
  gitignorePath,
3436
4480
  `${needsNewline ? "\n" : ""}${entry}
3437
4481
  `,
@@ -3440,24 +4484,62 @@ function runInit(cwd, w) {
3440
4484
  w.out(`Appended ${entry} to ${gitignorePath}`);
3441
4485
  }
3442
4486
  } else {
3443
- fs7.writeFileSync(gitignorePath, `${entry}
4487
+ fs8.writeFileSync(gitignorePath, `${entry}
3444
4488
  `, "utf8");
3445
4489
  w.out(`Created ${gitignorePath} with ${entry}`);
3446
4490
  }
3447
4491
  return w.result(0);
3448
4492
  }
3449
4493
  function runScanCommand(cwd, flags, w) {
3450
- const check = Boolean(flags.check || flags.audit);
3451
- const lint = Boolean(flags.lint || flags.audit);
4494
+ const fix = Boolean(flags.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);
3452
4498
  const asJson = Boolean(flags.json);
3453
- const configs = discover({ cwd });
4499
+ let configs = discover({ cwd });
3454
4500
  if (configs.length === 0) {
3455
4501
  w.err(`No ${CONFIG_FILENAME} found under ${cwd}`);
3456
4502
  return w.result(1);
3457
4503
  }
4504
+ let fixed = [];
4505
+ let fixSkipped = [];
4506
+ if (fix) {
4507
+ const discovery = runScan({ cwd, check: true, lint: true, configs });
4508
+ const result = applyFixes(
4509
+ discovery.flatMap((r) => r.audit?.diagnostics ?? [])
4510
+ );
4511
+ fixed = result.applied;
4512
+ fixSkipped = result.skipped;
4513
+ configs = discover({ cwd });
4514
+ }
3458
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();
3459
4518
  if (!check) {
3460
- 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);
3461
4543
  }
3462
4544
  const allDiagnostics = results.flatMap((r) => r.audit?.diagnostics ?? []);
3463
4545
  const summary = results.reduce(
@@ -3469,19 +4551,38 @@ function runScanCommand(cwd, flags, w) {
3469
4551
  { errors: 0, warnings: 0 }
3470
4552
  );
3471
4553
  if (asJson) {
3472
- const out2 = { diagnostics: allDiagnostics, summary };
4554
+ const out2 = {
4555
+ diagnostics: allDiagnostics.map(jsonDiagnostic),
4556
+ summary,
4557
+ ...fix ? { fixed, fixSkipped } : {}
4558
+ };
3473
4559
  w.out(JSON.stringify(out2, null, 2));
3474
4560
  } else {
4561
+ for (const f of fixed) {
4562
+ w.out(`FIXED [${f.code}] ${f.file ?? ""} ${f.description}`);
4563
+ }
4564
+ for (const s of fixSkipped) {
4565
+ w.out(
4566
+ `SKIPPED [${s.code}] ${s.file ?? ""} ${s.description} (${s.reason})`
4567
+ );
4568
+ }
3475
4569
  for (const r of results) {
3476
- if (check) {
3477
- w.out(`Checked ${r.outputPath}`);
3478
- } else {
3479
- 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
+ }
3480
4578
  }
3481
4579
  for (const d of r.audit?.diagnostics ?? []) {
3482
4580
  const loc = d.file ? `${d.file}${d.line ? `:${d.line}` : ""}` : "";
3483
4581
  const stream = d.severity === "error" ? w.err : w.out;
3484
- stream(`${d.severity.toUpperCase()} [${d.code}] ${loc} ${d.message}`);
4582
+ const fixable = d.fix && !fix ? " [fixable: run with --fix]" : "";
4583
+ stream(
4584
+ `${d.severity.toUpperCase()} [${d.code}] ${loc} ${d.message}${fixable}`
4585
+ );
3485
4586
  if (d.hint) stream(` hint: ${d.hint}`);
3486
4587
  }
3487
4588
  }
@@ -3492,20 +4593,27 @@ function runScanCommand(cwd, flags, w) {
3492
4593
  const exit = summary.errors > 0 ? 1 : 0;
3493
4594
  return w.result(exit);
3494
4595
  }
4596
+ function jsonDiagnostic(d) {
4597
+ const { fix, ...rest } = d;
4598
+ return fix ? { ...rest, fixable: true } : rest;
4599
+ }
4600
+ var SCAFFOLD_KINDS = /* @__PURE__ */ new Set(["widget", "page", "feature"]);
3495
4601
  function runScaffold(cwd, args, flags, w) {
3496
4602
  const [kind, id] = args;
3497
- if (kind !== "widget" || !id) {
3498
- w.err("Usage: uidex scaffold widget <id> [--force]");
4603
+ if (!kind || !SCAFFOLD_KINDS.has(kind) || !id) {
4604
+ w.err("Usage: uidex scaffold <widget|page|feature> <id> [--force]");
3499
4605
  return w.result(1);
3500
4606
  }
4607
+ const scaffoldKind = kind;
3501
4608
  const results = runScan({ cwd });
3502
4609
  for (const r of results) {
3503
- const widget = r.registry.get("widget", id);
3504
- if (!widget) continue;
3505
- const outDir = path9.resolve(r.configDir, "e2e");
3506
- const result = scaffoldWidgetSpec({
4610
+ const entity = r.registry.get(scaffoldKind, id);
4611
+ if (!entity) continue;
4612
+ const outDir = path11.resolve(r.configDir, "e2e");
4613
+ const result = scaffoldSpec({
3507
4614
  registry: r.registry,
3508
- widgetId: id,
4615
+ kind: scaffoldKind,
4616
+ id,
3509
4617
  outDir,
3510
4618
  force: Boolean(flags.force)
3511
4619
  });
@@ -3516,9 +4624,43 @@ function runScaffold(cwd, args, flags, w) {
3516
4624
  w.out(`Wrote ${result.outputPath}`);
3517
4625
  return w.result(0);
3518
4626
  }
3519
- w.err(`Widget "${id}" not found in registry`);
4627
+ w.err(
4628
+ `${scaffoldKind.charAt(0).toUpperCase() + scaffoldKind.slice(1)} "${id}" not found in registry`
4629
+ );
3520
4630
  return w.result(1);
3521
4631
  }
4632
+ var RENAME_KINDS = /* @__PURE__ */ new Set(["element", "widget", "region"]);
4633
+ function runRename(cwd, args, flags, w) {
4634
+ const [kind, oldId, newId] = args;
4635
+ if (!kind || !RENAME_KINDS.has(kind) || !oldId || !newId) {
4636
+ w.err(
4637
+ "Usage: uidex rename <element|widget|region> <old-id> <new-id> [--force]"
4638
+ );
4639
+ return w.result(1);
4640
+ }
4641
+ const result = renameEntity({
4642
+ cwd,
4643
+ kind,
4644
+ oldId,
4645
+ newId,
4646
+ force: Boolean(flags.force)
4647
+ });
4648
+ for (const e of result.errors) w.err(e);
4649
+ for (const m of result.manual) {
4650
+ w.err(`MANUAL ${m.file}:${m.line} \u2014 ${m.reason}`);
4651
+ }
4652
+ if (result.errors.length > 0) return w.result(1);
4653
+ w.out(
4654
+ `Renamed ${kind} "${oldId}" \u2192 "${newId}" (${result.edits} edit(s)); gen file regenerated`
4655
+ );
4656
+ if (result.manual.length > 0) {
4657
+ w.err(
4658
+ `${result.manual.length} occurrence(s) need manual follow-up (listed above)`
4659
+ );
4660
+ return w.result(1);
4661
+ }
4662
+ return w.result(0);
4663
+ }
3522
4664
  function createWriter() {
3523
4665
  let stdout = "";
3524
4666
  let stderr = "";
@@ -3539,20 +4681,31 @@ function createWriter() {
3539
4681
  CONFIG_FILENAME,
3540
4682
  ConfigError,
3541
4683
  DEFAULT_CONVENTIONS,
3542
- DEFAULT_TYPE_MODE,
4684
+ applyFixes,
3543
4685
  audit,
4686
+ checkPageCoverage,
4687
+ checkStateCoverage,
4688
+ checkStateMatrix,
4689
+ compileRoute,
4690
+ computeMissingKinds,
4691
+ computePageBaseline,
3544
4692
  detectRoutes,
3545
4693
  discover,
3546
4694
  emit,
3547
4695
  extract,
3548
4696
  extractUidexExports,
3549
4697
  globToRegExp,
4698
+ loadPageBaseline,
4699
+ matchUrlToRoute,
4700
+ pageBaselinePath,
3550
4701
  parseConfig,
3551
4702
  pathToId,
4703
+ renameEntity,
3552
4704
  resolve,
3553
4705
  resolveGitContext,
3554
4706
  runCli,
3555
4707
  runScan,
4708
+ scaffoldSpec,
3556
4709
  scaffoldWidgetSpec,
3557
4710
  validateConfig,
3558
4711
  walk,