uidex 0.5.2 → 0.7.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 (39) hide show
  1. package/README.md +3 -3
  2. package/dist/cli/cli.cjs +1542 -1227
  3. package/dist/cli/cli.cjs.map +1 -1
  4. package/dist/cloud/index.cjs +385 -175
  5. package/dist/cloud/index.cjs.map +1 -1
  6. package/dist/cloud/index.d.cts +192 -4
  7. package/dist/cloud/index.d.ts +192 -4
  8. package/dist/cloud/index.js +377 -177
  9. package/dist/cloud/index.js.map +1 -1
  10. package/dist/headless/index.cjs +116 -251
  11. package/dist/headless/index.cjs.map +1 -1
  12. package/dist/headless/index.d.cts +6 -11
  13. package/dist/headless/index.d.ts +6 -11
  14. package/dist/headless/index.js +116 -253
  15. package/dist/headless/index.js.map +1 -1
  16. package/dist/index.cjs +776 -1055
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +152 -160
  19. package/dist/index.d.ts +152 -160
  20. package/dist/index.js +792 -1066
  21. package/dist/index.js.map +1 -1
  22. package/dist/react/index.cjs +801 -1019
  23. package/dist/react/index.cjs.map +1 -1
  24. package/dist/react/index.d.cts +102 -86
  25. package/dist/react/index.d.ts +102 -86
  26. package/dist/react/index.js +821 -1038
  27. package/dist/react/index.js.map +1 -1
  28. package/dist/scan/index.cjs +1550 -1220
  29. package/dist/scan/index.cjs.map +1 -1
  30. package/dist/scan/index.d.cts +210 -12
  31. package/dist/scan/index.d.ts +210 -12
  32. package/dist/scan/index.js +1547 -1219
  33. package/dist/scan/index.js.map +1 -1
  34. package/package.json +22 -21
  35. package/templates/claude/SKILL.md +71 -0
  36. package/templates/claude/references/audit.md +43 -0
  37. package/templates/claude/{rules.md → references/conventions.md} +25 -28
  38. package/templates/claude/audit.md +0 -43
  39. /package/templates/claude/{api.md → references/api.md} +0 -0
package/dist/cli/cli.cjs CHANGED
@@ -24,8 +24,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
 
26
26
  // src/scanner/scan/cli.ts
27
- var fs7 = __toESM(require("fs"), 1);
28
- var path9 = __toESM(require("path"), 1);
27
+ var fs8 = __toESM(require("fs"), 1);
28
+ var path11 = __toESM(require("path"), 1);
29
29
 
30
30
  // src/scanner/scan/ai/index.ts
31
31
  var p = __toESM(require("@clack/prompts"), 1);
@@ -40,9 +40,9 @@ var path = __toESM(require("path"), 1);
40
40
  function templatePath(rel) {
41
41
  const candidates = [
42
42
  path.resolve(__dirname, "../../templates", rel),
43
- // dist/cli/cli.cjs → ../../templates
44
- path.resolve(__dirname, "../../../templates", rel)
45
- // src/scan/ai/foo.ts../../../templates
43
+ // dist/cli/cli.cjs → ../../templates
44
+ path.resolve(__dirname, "../../../../templates", rel)
45
+ // src/scanner/scan/ai → ../../../../templates
46
46
  ];
47
47
  for (const c of candidates) {
48
48
  try {
@@ -62,18 +62,33 @@ function readTemplate(rel) {
62
62
  }
63
63
 
64
64
  // src/scanner/scan/ai/providers/claude.ts
65
- var CLAUDE_FILES = [
66
- { dest: ".claude/rules/uidex.md", template: "claude/rules.md" },
67
- { dest: ".claude/commands/uidex/audit.md", template: "claude/audit.md" },
68
- { dest: ".claude/commands/uidex/api.md", template: "claude/api.md" }
65
+ var SKILL_FILES = [
66
+ { dest: ".claude/skills/uidex/SKILL.md", template: "claude/SKILL.md" },
67
+ {
68
+ dest: ".claude/skills/uidex/references/conventions.md",
69
+ template: "claude/references/conventions.md"
70
+ },
71
+ {
72
+ dest: ".claude/skills/uidex/references/audit.md",
73
+ template: "claude/references/audit.md"
74
+ },
75
+ {
76
+ dest: ".claude/skills/uidex/references/api.md",
77
+ template: "claude/references/api.md"
78
+ }
79
+ ];
80
+ var LEGACY_FILES = [
81
+ ".claude/rules/uidex.md",
82
+ ".claude/commands/uidex/audit.md",
83
+ ".claude/commands/uidex/api.md"
69
84
  ];
70
85
  var claudeProvider = {
71
86
  id: "claude",
72
87
  label: "Claude Code",
73
- description: "Adds .claude/rules/uidex.md, /uidex:audit, and /uidex:api slash commands.",
88
+ description: "Adds .claude/skills/uidex/ skill with conventions, audit, and API references.",
74
89
  async install({ cwd, force }) {
75
90
  const changes = [];
76
- for (const file of CLAUDE_FILES) {
91
+ for (const file of SKILL_FILES) {
77
92
  const dest = path2.join(cwd, file.dest);
78
93
  const exists = fs2.existsSync(dest);
79
94
  if (exists && !force) {
@@ -91,11 +106,21 @@ var claudeProvider = {
91
106
  action: exists ? "overwritten" : "created"
92
107
  });
93
108
  }
109
+ for (const rel of LEGACY_FILES) {
110
+ const dest = path2.join(cwd, rel);
111
+ if (fs2.existsSync(dest)) {
112
+ fs2.unlinkSync(dest);
113
+ changes.push({ path: rel, action: "removed" });
114
+ }
115
+ }
116
+ cleanupEmpty(path2.join(cwd, ".claude/commands/uidex"));
117
+ cleanupEmpty(path2.join(cwd, ".claude/commands"));
118
+ cleanupEmpty(path2.join(cwd, ".claude/rules"));
94
119
  return { changes };
95
120
  },
96
121
  async uninstall({ cwd }) {
97
122
  const changes = [];
98
- for (const file of CLAUDE_FILES) {
123
+ for (const file of SKILL_FILES) {
99
124
  const dest = path2.join(cwd, file.dest);
100
125
  if (!fs2.existsSync(dest)) {
101
126
  changes.push({ path: file.dest, action: "skipped", reason: "absent" });
@@ -104,6 +129,16 @@ var claudeProvider = {
104
129
  fs2.unlinkSync(dest);
105
130
  changes.push({ path: file.dest, action: "removed" });
106
131
  }
132
+ cleanupEmpty(path2.join(cwd, ".claude/skills/uidex/references"));
133
+ cleanupEmpty(path2.join(cwd, ".claude/skills/uidex"));
134
+ cleanupEmpty(path2.join(cwd, ".claude/skills"));
135
+ for (const rel of LEGACY_FILES) {
136
+ const dest = path2.join(cwd, rel);
137
+ if (fs2.existsSync(dest)) {
138
+ fs2.unlinkSync(dest);
139
+ changes.push({ path: rel, action: "removed" });
140
+ }
141
+ }
107
142
  cleanupEmpty(path2.join(cwd, ".claude/commands/uidex"));
108
143
  cleanupEmpty(path2.join(cwd, ".claude/commands"));
109
144
  cleanupEmpty(path2.join(cwd, ".claude/rules"));
@@ -240,7 +275,6 @@ var fs3 = __toESM(require("fs"), 1);
240
275
  var path3 = __toESM(require("path"), 1);
241
276
 
242
277
  // src/scanner/scan/config.ts
243
- var DEFAULT_TYPE_MODE = "strict";
244
278
  var WELL_KNOWN_FILES = {
245
279
  page: "uidex.page.ts",
246
280
  feature: "uidex.feature.ts"
@@ -265,11 +299,9 @@ var ALLOWED_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
265
299
  "exclude",
266
300
  "output",
267
301
  "flows",
268
- "typeMode",
269
302
  "audit",
270
303
  "conventions"
271
304
  ]);
272
- var ALLOWED_TYPE_MODES = /* @__PURE__ */ new Set(["strict", "loose"]);
273
305
  var ALLOWED_SOURCE_KEYS = /* @__PURE__ */ new Set(["rootDir", "include", "exclude", "prefix"]);
274
306
  var ALLOWED_CONVENTIONS_KEYS = /* @__PURE__ */ new Set([
275
307
  "primitives",
@@ -282,14 +314,14 @@ var ALLOWED_AUDIT_KEYS = /* @__PURE__ */ new Set(["scopeLeak", "coverage", "acce
282
314
  function fail(msg) {
283
315
  throw new ConfigError(`Invalid .uidex.json: ${msg}`);
284
316
  }
285
- function assertObject(value, path10) {
317
+ function assertObject(value, path12) {
286
318
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
287
- fail(`${path10} must be an object`);
319
+ fail(`${path12} must be an object`);
288
320
  }
289
321
  }
290
- function assertStringArray(value, path10) {
322
+ function assertStringArray(value, path12) {
291
323
  if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
292
- fail(`${path10} must be a string[]`);
324
+ fail(`${path12} must be a string[]`);
293
325
  }
294
326
  }
295
327
  function validateConfig(raw) {
@@ -337,11 +369,6 @@ function validateConfig(raw) {
337
369
  }
338
370
  if (raw.exclude !== void 0) assertStringArray(raw.exclude, `exclude`);
339
371
  if (raw.flows !== void 0) assertStringArray(raw.flows, `flows`);
340
- if (raw.typeMode !== void 0) {
341
- if (typeof raw.typeMode !== "string" || !ALLOWED_TYPE_MODES.has(raw.typeMode)) {
342
- fail(`"typeMode" must be "strict" or "loose"`);
343
- }
344
- }
345
372
  if (raw.audit !== void 0) {
346
373
  assertObject(raw.audit, "audit");
347
374
  for (const key of Object.keys(raw.audit)) {
@@ -380,7 +407,6 @@ function validateConfig(raw) {
380
407
  exclude: raw.exclude,
381
408
  output: raw.output,
382
409
  flows: raw.flows,
383
- typeMode: raw.typeMode ?? DEFAULT_TYPE_MODE,
384
410
  audit: raw.audit,
385
411
  conventions: raw.conventions
386
412
  };
@@ -462,12 +488,104 @@ function discover(options = {}) {
462
488
  return results.sort((a, b) => a.configPath.localeCompare(b.configPath));
463
489
  }
464
490
 
491
+ // src/scanner/scan/fix.ts
492
+ var fs4 = __toESM(require("fs"), 1);
493
+ var path4 = __toESM(require("path"), 1);
494
+ function applyFixes(diagnostics) {
495
+ const entries = [];
496
+ for (const d of diagnostics) {
497
+ if (!d.fix) continue;
498
+ entries.push({
499
+ code: d.code,
500
+ description: d.fix.description,
501
+ file: d.file,
502
+ edits: d.fix.edits ?? [],
503
+ createFiles: d.fix.createFiles ?? [],
504
+ deleteFiles: d.fix.deleteFiles ?? []
505
+ });
506
+ }
507
+ if (entries.length === 0) return { applied: [], skipped: [] };
508
+ const seenEdits = /* @__PURE__ */ new Set();
509
+ const editsByFile = /* @__PURE__ */ new Map();
510
+ for (const entry of entries) {
511
+ for (const edit of entry.edits) {
512
+ const key = `${edit.path}:${edit.start}:${edit.end}:${edit.replacement}`;
513
+ if (seenEdits.has(key)) continue;
514
+ seenEdits.add(key);
515
+ let list = editsByFile.get(edit.path);
516
+ if (!list) {
517
+ list = [];
518
+ editsByFile.set(edit.path, list);
519
+ }
520
+ list.push({ ...edit, entry });
521
+ }
522
+ }
523
+ for (const [filePath, edits] of editsByFile) {
524
+ let content;
525
+ try {
526
+ content = fs4.readFileSync(filePath, "utf8");
527
+ } catch {
528
+ for (const e of edits) e.entry.skippedReason ??= "file is unreadable";
529
+ continue;
530
+ }
531
+ edits.sort((a, b) => a.start - b.start || a.end - b.end);
532
+ const kept = [];
533
+ let prevEnd = -1;
534
+ for (const edit of edits) {
535
+ if (edit.start < prevEnd) {
536
+ edit.entry.skippedReason ??= "overlapping edit";
537
+ continue;
538
+ }
539
+ kept.push(edit);
540
+ prevEnd = edit.end;
541
+ }
542
+ for (let i = kept.length - 1; i >= 0; i--) {
543
+ const edit = kept[i];
544
+ content = content.slice(0, edit.start) + edit.replacement + content.slice(edit.end);
545
+ }
546
+ if (kept.length > 0) fs4.writeFileSync(filePath, content, "utf8");
547
+ }
548
+ for (const entry of entries) {
549
+ if (entry.skippedReason) continue;
550
+ for (const create of entry.createFiles) {
551
+ if (fs4.existsSync(create.path)) {
552
+ entry.skippedReason = `${path4.basename(create.path)} already exists`;
553
+ continue;
554
+ }
555
+ fs4.mkdirSync(path4.dirname(create.path), { recursive: true });
556
+ fs4.writeFileSync(create.path, create.content, "utf8");
557
+ }
558
+ if (entry.skippedReason) continue;
559
+ for (const del of entry.deleteFiles) {
560
+ try {
561
+ fs4.unlinkSync(del);
562
+ } catch {
563
+ }
564
+ }
565
+ }
566
+ const applied = [];
567
+ const skipped = [];
568
+ for (const entry of entries) {
569
+ const summary = {
570
+ code: entry.code,
571
+ description: entry.description,
572
+ file: entry.file
573
+ };
574
+ if (entry.skippedReason) {
575
+ skipped.push({ ...summary, reason: entry.skippedReason });
576
+ } else {
577
+ applied.push(summary);
578
+ }
579
+ }
580
+ return { applied, skipped };
581
+ }
582
+
465
583
  // src/scanner/scan/pipeline.ts
466
- var fs5 = __toESM(require("fs"), 1);
467
- var path7 = __toESM(require("path"), 1);
584
+ var fs6 = __toESM(require("fs"), 1);
585
+ var path9 = __toESM(require("path"), 1);
468
586
 
469
587
  // src/scanner/scan/audit.ts
470
- var path4 = __toESM(require("path"), 1);
588
+ var path5 = __toESM(require("path"), 1);
471
589
 
472
590
  // src/shared/entities/types.ts
473
591
  var ENTITY_KINDS = [
@@ -533,6 +651,7 @@ function freezeEntity(entity, flows) {
533
651
  function createRegistry() {
534
652
  const store = emptyStore();
535
653
  let flowsCache = null;
654
+ const patternCache = /* @__PURE__ */ new Map();
536
655
  const getFlows = () => {
537
656
  if (flowsCache === null) flowsCache = Array.from(store.flow.values());
538
657
  return flowsCache;
@@ -542,6 +661,7 @@ function createRegistry() {
542
661
  const key = entityKey(entity);
543
662
  store[entity.kind].set(key, entity);
544
663
  flowsCache = null;
664
+ patternCache.delete(entity.kind);
545
665
  };
546
666
  const get = (kind, id) => {
547
667
  assertEntityKind(kind);
@@ -549,6 +669,51 @@ function createRegistry() {
549
669
  if (raw === void 0) return void 0;
550
670
  return freezeEntity(raw, getFlows());
551
671
  };
672
+ const getPatternsForKind = (kind) => {
673
+ const cached = patternCache.get(kind);
674
+ if (cached !== void 0) return cached;
675
+ const patterns = [];
676
+ for (const [key, entity] of store[kind]) {
677
+ if (key.includes("*")) {
678
+ const segments = key.split("*");
679
+ patterns.push({
680
+ segments,
681
+ staticLength: segments.reduce((n, s) => n + s.length, 0),
682
+ entity
683
+ });
684
+ }
685
+ }
686
+ patternCache.set(
687
+ kind,
688
+ patterns
689
+ );
690
+ return patterns;
691
+ };
692
+ const matchesSegments = (segments, id) => {
693
+ const first = segments[0];
694
+ const last = segments[segments.length - 1];
695
+ if (!id.startsWith(first)) return false;
696
+ let pos = first.length;
697
+ for (let i = 1; i < segments.length - 1; i++) {
698
+ const idx = id.indexOf(segments[i], pos);
699
+ if (idx === -1) return false;
700
+ pos = idx + segments[i].length;
701
+ }
702
+ return id.endsWith(last) && id.length - last.length >= pos;
703
+ };
704
+ const matchPattern = (kind, id) => {
705
+ assertEntityKind(kind);
706
+ const patterns = getPatternsForKind(kind);
707
+ if (patterns.length === 0) return void 0;
708
+ let best;
709
+ for (const entry of patterns) {
710
+ if (matchesSegments(entry.segments, id) && (best === void 0 || entry.staticLength > best.staticLength)) {
711
+ best = entry;
712
+ }
713
+ }
714
+ if (best === void 0) return void 0;
715
+ return freezeEntity(best.entity, getFlows());
716
+ };
552
717
  const list = (kind) => {
553
718
  assertEntityKind(kind);
554
719
  const flows = getFlows();
@@ -599,6 +764,7 @@ function createRegistry() {
599
764
  return {
600
765
  add,
601
766
  get,
767
+ matchPattern,
602
768
  list,
603
769
  query,
604
770
  byScope,
@@ -611,7 +777,6 @@ function createRegistry() {
611
777
  }
612
778
 
613
779
  // src/scanner/scan/audit.ts
614
- var MARKER_FILENAMES = ["UIDEX_PAGE.md", "UIDEX_FEATURE.md"];
615
780
  function audit(opts) {
616
781
  const diagnostics = [];
617
782
  const { registry, extracted, files, config } = opts;
@@ -621,22 +786,15 @@ function audit(opts) {
621
786
  const scopeLeakEnabled = config.audit?.scopeLeak ?? true;
622
787
  const coverageEnabled = config.audit?.coverage ?? true;
623
788
  if (opts.resolveDiagnostics) diagnostics.push(...opts.resolveDiagnostics);
624
- if (check) {
625
- for (const f of files) {
626
- const base = f.displayPath.split("/").pop() ?? "";
627
- if (MARKER_FILENAMES.includes(base)) {
628
- diagnostics.push({
629
- code: "marker-md-ignored",
630
- severity: "warning",
631
- message: `Marker file "${base}" is ignored in v2; migrate to \`export const uidex\``,
632
- file: f.displayPath
633
- });
634
- }
635
- }
789
+ for (const ef of extracted) {
790
+ if (ef.diagnostics) diagnostics.push(...ef.diagnostics);
791
+ }
792
+ for (const ef of opts.flowExtracted ?? []) {
793
+ if (ef.diagnostics) diagnostics.push(...ef.diagnostics);
636
794
  }
637
795
  if (check && opts.generated !== void 0) {
638
796
  const outRel = opts.outputPath ?? config.output;
639
- const fresh = normalizeLineEndings(opts.generated);
797
+ const fresh = normalizeForCheck(opts.generated);
640
798
  if (opts.existingOnDisk === null || opts.existingOnDisk === void 0) {
641
799
  diagnostics.push({
642
800
  code: "gen-missing",
@@ -646,7 +804,7 @@ function audit(opts) {
646
804
  hint: "Run `uidex scan` (without --check) to regenerate"
647
805
  });
648
806
  } else {
649
- const existing = normalizeLineEndings(opts.existingOnDisk);
807
+ const existing = normalizeForCheck(opts.existingOnDisk);
650
808
  if (existing !== fresh) {
651
809
  const changed = diffEntities(existing, opts.generated, registry);
652
810
  const summary2 = formatChangedSummary(changed);
@@ -660,22 +818,6 @@ function audit(opts) {
660
818
  }
661
819
  }
662
820
  }
663
- if (lint) {
664
- for (const ef of extracted) {
665
- for (const a of ef.annotations) {
666
- const migration = legacyJsdocMigration(a);
667
- if (!migration) continue;
668
- diagnostics.push({
669
- code: "legacy-jsdoc",
670
- severity: "warning",
671
- message: migration.message,
672
- file: a.file,
673
- line: a.line,
674
- hint: migration.hint
675
- });
676
- }
677
- }
678
- }
679
821
  if (lint && acceptanceEnabled) {
680
822
  for (const kind of ["widget", "feature", "page"]) {
681
823
  for (const e of registry.list(kind)) {
@@ -721,8 +863,8 @@ function audit(opts) {
721
863
  if (typeof m.id !== "string") continue;
722
864
  const filePath = ef.file.displayPath;
723
865
  const wellKnownName = WELL_KNOWN_FILES[m.kind];
724
- if (path4.posix.basename(filePath) === wellKnownName) continue;
725
- const dir = path4.posix.dirname(filePath);
866
+ if (path5.posix.basename(filePath) === wellKnownName) continue;
867
+ const dir = path5.posix.dirname(filePath);
726
868
  const wellKnownPath = dir === "." ? wellKnownName : `${dir}/${wellKnownName}`;
727
869
  if (scannedPaths.has(wellKnownPath)) continue;
728
870
  const kindLabel = m.kind === "page" ? "Page" : "Feature";
@@ -739,45 +881,55 @@ function audit(opts) {
739
881
  }
740
882
  }
741
883
  if (lint) {
742
- const dynamicAttrRe = /\bdata-uidex(?:-(region|widget|primitive))?\s*=\s*\{/g;
743
- for (const f of files) {
744
- let m;
745
- dynamicAttrRe.lastIndex = 0;
746
- while ((m = dynamicAttrRe.exec(f.content)) !== null) {
747
- const kind = m[1] ?? "element";
748
- let line = 1;
749
- for (let i = 0; i < m.index; i++) if (f.content[i] === "\n") line++;
750
- const attrName = m[1] ? `data-uidex-${m[1]}` : "data-uidex";
884
+ for (const ef of extracted) {
885
+ for (const fact of ef.dynamicAttrs ?? []) {
751
886
  diagnostics.push({
752
887
  code: "dynamic-attr",
753
888
  severity: "warning",
754
- message: `\`${attrName}={\u2026}\` uses a dynamic expression; the scanner cannot resolve the ${kind} id statically`,
755
- file: f.displayPath,
756
- line,
757
- hint: dynamicAttrHint(kind)
889
+ message: `\`${fact.attrName}={\u2026}\` uses a dynamic expression; the scanner cannot resolve the ${fact.kind} id statically`,
890
+ file: ef.file.displayPath,
891
+ line: fact.line,
892
+ hint: dynamicAttrHint(fact.kind)
758
893
  });
759
894
  }
760
895
  }
761
896
  }
762
897
  if (lint) {
763
- for (const f of files) {
764
- const tagRe = /<(button|a|input|select|textarea)(?=[\s/>])/g;
765
- let m;
766
- while ((m = tagRe.exec(f.content)) !== null) {
767
- const afterTag = m.index + m[0].length;
768
- const closeIdx = findJsxOpeningEnd(f.content, afterTag);
769
- if (closeIdx === -1) continue;
770
- const attrs = f.content.slice(afterTag, closeIdx);
771
- if (attrs.includes("data-uidex")) continue;
772
- let line = 1;
773
- for (let i = 0; i < m.index; i++) if (f.content[i] === "\n") line++;
774
- diagnostics.push({
775
- code: "missing-element-annotation",
776
- severity: "info",
777
- message: `Interactive <${m[1].toLowerCase()}> without data-uidex annotation`,
778
- file: f.displayPath,
779
- line
780
- });
898
+ const usedElementIds = new Set(registry.list("element").map((e) => e.id));
899
+ for (const ef of extracted) {
900
+ for (const fact of ef.unannotatedInteractive ?? []) {
901
+ if (fact.hasSpread) {
902
+ diagnostics.push({
903
+ code: "spread-attr",
904
+ severity: "info",
905
+ 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`,
906
+ file: ef.file.displayPath,
907
+ line: fact.line,
908
+ hint: "Prefer a string-literal data-uidex on the element itself, or annotate at the call site."
909
+ });
910
+ } else {
911
+ const id = uniqueElementId(fact, usedElementIds);
912
+ usedElementIds.add(id);
913
+ diagnostics.push({
914
+ code: "missing-element-annotation",
915
+ severity: "info",
916
+ message: `Interactive <${fact.tag}> without data-uidex annotation`,
917
+ file: ef.file.displayPath,
918
+ line: fact.line,
919
+ hint: `Add \`data-uidex="${id}"\` (or run \`uidex scan --fix\`).`,
920
+ fix: {
921
+ description: `Add data-uidex="${id}" to <${fact.tag}>`,
922
+ edits: [
923
+ {
924
+ path: ef.file.sourcePath,
925
+ start: fact.nameEnd,
926
+ end: fact.nameEnd,
927
+ replacement: ` data-uidex="${id}"`
928
+ }
929
+ ]
930
+ }
931
+ });
932
+ }
781
933
  }
782
934
  }
783
935
  }
@@ -794,12 +946,11 @@ function audit(opts) {
794
946
  }
795
947
  }
796
948
  }
797
- for (const f of files) {
798
- const importRe = /import\s+(?:[^'"]+)\s+from\s+['"]([^'"]+)['"]/g;
799
- let m;
800
- while ((m = importRe.exec(f.content)) !== null) {
801
- const spec = m[1];
802
- const baseName2 = spec.split("/").pop() ?? "";
949
+ for (const ef of extracted) {
950
+ const displayPath = ef.file.displayPath;
951
+ for (const imp of ef.imports ?? []) {
952
+ if (imp.isTypeOnly) continue;
953
+ const baseName2 = imp.specifier.split("/").pop() ?? "";
803
954
  const primitive = byName.get(
804
955
  baseName2.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
805
956
  );
@@ -807,81 +958,170 @@ function audit(opts) {
807
958
  const scope = primitive.scopes?.[0];
808
959
  if (!scope) continue;
809
960
  const [kind, id] = scope.split(":");
810
- const importerSegments = f.displayPath.split("/");
961
+ const importerSegments = displayPath.split("/");
811
962
  if (importerSegments.includes(id) && importerSegments.includes(kind + "s")) {
812
963
  continue;
813
964
  }
814
965
  if (kind === "feature" && importerSegments.includes(id)) continue;
815
- if (kind === "feature" && declaredFeatures.get(f.displayPath)?.has(id)) {
966
+ if (kind === "feature" && declaredFeatures.get(displayPath)?.has(id)) {
816
967
  continue;
817
968
  }
818
969
  diagnostics.push({
819
970
  code: "scope-leak",
820
971
  severity: "warning",
821
- message: `Primitive "${primitive.id}" is scoped to ${scope} but is imported from ${f.displayPath}`,
822
- file: f.displayPath
972
+ message: `Primitive "${primitive.id}" is scoped to ${scope} but is imported from ${displayPath}`,
973
+ file: displayPath,
974
+ line: imp.line
823
975
  });
824
976
  }
825
977
  }
826
978
  }
827
979
  if (lint && coverageEnabled) {
980
+ const factsByLoc = /* @__PURE__ */ new Map();
981
+ for (const ef of opts.flowExtracted ?? []) {
982
+ for (const fact of ef.flows ?? []) {
983
+ const lines = /* @__PURE__ */ new Map();
984
+ for (const call of fact.calls) {
985
+ if (!lines.has(call.id)) lines.set(call.id, call.line);
986
+ }
987
+ factsByLoc.set(`${ef.file.displayPath}:${fact.line}`, lines);
988
+ }
989
+ }
828
990
  for (const flow of registry.list("flow")) {
991
+ const callLines = factsByLoc.get(`${flow.loc.file}:${flow.loc.line}`);
829
992
  for (const touchedId of flow.touches) {
830
- const found = registry.get("element", touchedId) ?? registry.get("widget", touchedId) ?? registry.get("region", touchedId);
993
+ 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);
831
994
  if (!found) {
832
995
  diagnostics.push({
833
996
  code: "unknown-reference",
834
997
  severity: "warning",
835
998
  message: `Flow "${flow.id}" references unknown entity "${touchedId}"`,
836
999
  file: flow.loc.file,
837
- line: flow.loc.line
1000
+ // Point at the uidex() call itself when the spec facts are
1001
+ // available; the describe line is the fallback.
1002
+ line: callLines?.get(touchedId) ?? flow.loc.line
838
1003
  });
839
1004
  }
840
1005
  }
841
1006
  }
842
1007
  }
1008
+ if (lint) {
1009
+ const occurrences = /* @__PURE__ */ new Map();
1010
+ for (const ef of extracted) {
1011
+ for (const a of ef.annotations) {
1012
+ if (a.kind !== "element" && a.kind !== "region" && a.kind !== "widget" && a.kind !== "primitive") {
1013
+ continue;
1014
+ }
1015
+ const key = `${a.kind}:${a.id}`;
1016
+ let list = occurrences.get(key);
1017
+ if (!list) {
1018
+ list = [];
1019
+ occurrences.set(key, list);
1020
+ }
1021
+ list.push({ file: a.file, line: a.line });
1022
+ }
1023
+ }
1024
+ for (const [key, list] of occurrences) {
1025
+ const filesSeen = new Set(list.map((o) => o.file));
1026
+ if (filesSeen.size < 2) continue;
1027
+ const [kind, id] = key.split(/:(.*)/s);
1028
+ const others = list.slice(1).map((o) => `${o.file}:${o.line}`).join(", ");
1029
+ diagnostics.push({
1030
+ code: "duplicate-id",
1031
+ severity: kind === "widget" || kind === "primitive" ? "warning" : "info",
1032
+ message: `${kind} id "${id}" is declared in ${filesSeen.size} files (also at ${others}); the registry keeps only one entry`,
1033
+ file: list[0].file,
1034
+ line: list[0].line,
1035
+ entity: { kind, id },
1036
+ 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."
1037
+ });
1038
+ }
1039
+ }
1040
+ if (lint && coverageEnabled) {
1041
+ for (const ef of opts.flowExtracted ?? []) {
1042
+ for (const fact of ef.flows ?? []) {
1043
+ for (const dyn of fact.dynamicCalls ?? []) {
1044
+ diagnostics.push({
1045
+ code: "dynamic-flow-reference",
1046
+ severity: "warning",
1047
+ message: `\`uidex(\u2026)\` call in flow "${fact.title}" uses a dynamic expression; the id is invisible to coverage and registry validation`,
1048
+ file: ef.file.displayPath,
1049
+ line: dyn.line,
1050
+ hint: "Use a string-literal id (component ids inside uidex() must be statically analysable)."
1051
+ });
1052
+ }
1053
+ }
1054
+ }
1055
+ }
1056
+ if (lint && coverageEnabled) {
1057
+ for (const ef of extracted) {
1058
+ if (!ef.metadata) continue;
1059
+ for (const m of ef.metadata) {
1060
+ const check2 = (refKind, ids, spans) => {
1061
+ for (let i = 0; i < (ids?.length ?? 0); i++) {
1062
+ const refId = ids[i];
1063
+ const found = registry.get(refKind, refId) ?? registry.matchPattern(refKind, refId);
1064
+ if (found) continue;
1065
+ diagnostics.push({
1066
+ code: "unknown-reference",
1067
+ severity: "warning",
1068
+ message: `\`export const uidex\` in ${ef.file.displayPath} references unknown ${refKind} "${refId}"`,
1069
+ file: ef.file.displayPath,
1070
+ line: spans?.[i] ? lineOfOffset(ef.file.content, spans[i].start) : m.loc.line,
1071
+ hint: `No ${refKind} with id "${refId}" exists in the registry; fix the reference or add the ${refKind}.`
1072
+ });
1073
+ }
1074
+ };
1075
+ check2("feature", m.features, m.featureSpans);
1076
+ check2("widget", m.widgets, m.widgetSpans);
1077
+ }
1078
+ }
1079
+ }
843
1080
  const summary = {
844
1081
  errors: diagnostics.filter((d) => d.severity === "error").length,
845
1082
  warnings: diagnostics.filter((d) => d.severity === "warning").length
846
1083
  };
847
1084
  return { diagnostics, summary };
848
1085
  }
849
- function legacyJsdocMigration(a) {
850
- const quote = (s) => JSON.stringify(s);
851
- const arr = (xs) => xs && xs.length > 0 ? `[${xs.map(quote).join(", ")}]` : "";
852
- const entityHint = (kind) => {
853
- const uidexKind = kind.charAt(0).toUpperCase() + kind.slice(1);
854
- const parts = [`${kind}: ${quote(a.id)}`];
855
- if (a.acceptance?.length) parts.push(`acceptance: ${arr(a.acceptance)}`);
856
- return {
857
- message: `Legacy JSDoc tag \`@uidex ${kind} ${a.id}\` is no longer recognised; migrate to \`export const uidex\``,
858
- hint: `Replace with: export const uidex = { ${parts.join(", ")} } as const satisfies Uidex.${uidexKind}`
859
- };
860
- };
861
- switch (a.kind) {
862
- case "page-doc":
863
- return entityHint("page");
864
- case "feature-doc":
865
- return entityHint("feature");
866
- case "widget-doc":
867
- return entityHint("widget");
868
- case "not-flow":
869
- return {
870
- message: `Legacy JSDoc tag \`@uidex:not-flow\` is no longer recognised; migrate to \`export const uidex\``,
871
- hint: `Replace with: export const uidex = { notFlow: true } as const satisfies Uidex.NotFlow`
872
- };
873
- case "orphan-acceptance":
874
- return {
875
- message: `Legacy JSDoc tag \`@acceptance\` is no longer recognised; migrate to the \`acceptance\` field on \`export const uidex\``,
876
- hint: `Replace with: export const uidex = { /* kind */, acceptance: ${arr(a.acceptance)} } as const`
877
- };
878
- default:
879
- return null;
1086
+ function lineOfOffset(content, offset) {
1087
+ let line = 1;
1088
+ for (let i = 0; i < offset && i < content.length; i++) {
1089
+ if (content[i] === "\n") line++;
1090
+ }
1091
+ return line;
1092
+ }
1093
+ var TAG_FALLBACK_ID = {
1094
+ a: "link",
1095
+ button: "button",
1096
+ input: "input",
1097
+ select: "select",
1098
+ textarea: "textarea"
1099
+ };
1100
+ function kebabId(str) {
1101
+ return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
1102
+ }
1103
+ function deriveElementId(fact) {
1104
+ const fromHint = fact.nameHint ? kebabId(fact.nameHint) : "";
1105
+ const capped = fromHint.split("-").filter(Boolean).slice(0, 5).join("-");
1106
+ return capped || TAG_FALLBACK_ID[fact.tag] || fact.tag;
1107
+ }
1108
+ function uniqueElementId(fact, used) {
1109
+ const base = deriveElementId(fact);
1110
+ if (!used.has(base)) return base;
1111
+ for (let n = 2; ; n++) {
1112
+ const candidate = `${base}-${n}`;
1113
+ if (!used.has(candidate)) return candidate;
880
1114
  }
881
1115
  }
882
1116
  function normalizeLineEndings(s) {
883
1117
  return s.replace(/\r\n/g, "\n");
884
1118
  }
1119
+ function normalizeForCheck(s) {
1120
+ return normalizeLineEndings(s).replace(
1121
+ /export const gitContext = \{[\s\S]*?\} as const/,
1122
+ "export const gitContext = {} as const"
1123
+ );
1124
+ }
885
1125
  function formatChangedSummary(change) {
886
1126
  const parts = [];
887
1127
  const fmt = (kind, names) => {
@@ -980,62 +1220,11 @@ function extractEntitiesArray(source) {
980
1220
  }
981
1221
  return null;
982
1222
  }
983
- function findJsxOpeningEnd(src, start) {
984
- let i = start;
985
- while (i < src.length) {
986
- const ch = src[i];
987
- if (ch === ">" || ch === "/" && src[i + 1] === ">") return i;
988
- if (ch === '"' || ch === "'" || ch === "`") {
989
- i = skipString(src, i);
990
- } else if (ch === "{") {
991
- i = skipBraces(src, i);
992
- } else {
993
- i++;
994
- }
995
- }
996
- return -1;
997
- }
998
- function skipString(src, start) {
999
- const quote = src[start];
1000
- let i = start + 1;
1001
- while (i < src.length) {
1002
- if (src[i] === "\\" && quote !== "`") {
1003
- i += 2;
1004
- continue;
1005
- }
1006
- if (quote === "`" && src[i] === "$" && src[i + 1] === "{") {
1007
- i = skipBraces(src, i + 1);
1008
- continue;
1009
- }
1010
- if (src[i] === quote) return i + 1;
1011
- i++;
1012
- }
1013
- return i;
1014
- }
1015
- function skipBraces(src, start) {
1016
- let depth = 1;
1017
- let i = start + 1;
1018
- while (i < src.length && depth > 0) {
1019
- const ch = src[i];
1020
- if (ch === "{") {
1021
- depth++;
1022
- i++;
1023
- } else if (ch === "}") {
1024
- depth--;
1025
- i++;
1026
- } else if (ch === '"' || ch === "'" || ch === "`") {
1027
- i = skipString(src, i);
1028
- } else {
1029
- i++;
1030
- }
1031
- }
1032
- return i;
1033
- }
1034
1223
  function dynamicAttrHint(kind) {
1035
1224
  if (kind === "region") {
1036
1225
  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`;
1037
1226
  }
1038
- 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`;
1227
+ 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)`;
1039
1228
  }
1040
1229
  function stableStringify(value) {
1041
1230
  return JSON.stringify(value, stableReplacer);
@@ -1068,9 +1257,7 @@ function replacerSorted(_key, value) {
1068
1257
  }
1069
1258
  return value;
1070
1259
  }
1071
- function emitIdUnion(name, ids, typeMode) {
1072
- if (typeMode === "loose") return `export type ${name} = string
1073
- `;
1260
+ function emitIdUnion(name, ids) {
1074
1261
  if (ids.length === 0) return `export type ${name} = never
1075
1262
  `;
1076
1263
  const sorted = [...ids].sort();
@@ -1080,12 +1267,7 @@ ${body}
1080
1267
  `;
1081
1268
  }
1082
1269
  function emit(opts) {
1083
- const {
1084
- registry,
1085
- gitContext,
1086
- uidexImport = "uidex",
1087
- typeMode = "strict"
1088
- } = opts;
1270
+ const { registry, gitContext, uidexImport = "uidex" } = opts;
1089
1271
  const routes = [...registry.list("route")].sort(
1090
1272
  (a, b) => a.path.localeCompare(b.path)
1091
1273
  );
@@ -1108,57 +1290,49 @@ function emit(opts) {
1108
1290
  lines.push(
1109
1291
  emitIdUnion(
1110
1292
  "PageId",
1111
- pages.map((e) => e.id),
1112
- typeMode
1293
+ pages.map((e) => e.id)
1113
1294
  )
1114
1295
  );
1115
1296
  lines.push(
1116
1297
  emitIdUnion(
1117
1298
  "FeatureId",
1118
- features.map((e) => e.id),
1119
- typeMode
1299
+ features.map((e) => e.id)
1120
1300
  )
1121
1301
  );
1122
1302
  lines.push(
1123
1303
  emitIdUnion(
1124
1304
  "WidgetId",
1125
- widgets.map((e) => e.id),
1126
- typeMode
1305
+ widgets.map((e) => e.id)
1127
1306
  )
1128
1307
  );
1129
1308
  lines.push(
1130
1309
  emitIdUnion(
1131
1310
  "RegionId",
1132
- regions.map((e) => e.id),
1133
- typeMode
1311
+ regions.map((e) => e.id)
1134
1312
  )
1135
1313
  );
1136
1314
  lines.push(
1137
1315
  emitIdUnion(
1138
1316
  "ElementId",
1139
- elements.map((e) => e.id),
1140
- typeMode
1317
+ elements.map((e) => e.id)
1141
1318
  )
1142
1319
  );
1143
1320
  lines.push(
1144
1321
  emitIdUnion(
1145
1322
  "PrimitiveId",
1146
- primitives.map((e) => e.id),
1147
- typeMode
1323
+ primitives.map((e) => e.id)
1148
1324
  )
1149
1325
  );
1150
1326
  lines.push(
1151
1327
  emitIdUnion(
1152
1328
  "FlowId",
1153
- flows.map((e) => e.id),
1154
- typeMode
1329
+ flows.map((e) => e.id)
1155
1330
  )
1156
1331
  );
1157
1332
  lines.push(
1158
1333
  emitIdUnion(
1159
1334
  "RouteId",
1160
- routes.map((e) => e.path),
1161
- typeMode
1335
+ routes.map((e) => e.path)
1162
1336
  )
1163
1337
  );
1164
1338
  lines.push("");
@@ -1237,6 +1411,90 @@ function emit(opts) {
1237
1411
  return lines.join("\n");
1238
1412
  }
1239
1413
 
1414
+ // src/scanner/scan/ast.ts
1415
+ var path6 = __toESM(require("path"), 1);
1416
+ var import_oxc_parser = require("oxc-parser");
1417
+ function langFor(sourcePath) {
1418
+ switch (path6.extname(sourcePath)) {
1419
+ case ".tsx":
1420
+ return "tsx";
1421
+ case ".ts":
1422
+ case ".mts":
1423
+ case ".cts":
1424
+ return "ts";
1425
+ default:
1426
+ return "jsx";
1427
+ }
1428
+ }
1429
+ function makeLineAt(content) {
1430
+ const starts = [0];
1431
+ for (let i = 0; i < content.length; i++) {
1432
+ if (content[i] === "\n") starts.push(i + 1);
1433
+ }
1434
+ return (offset) => {
1435
+ let lo = 0;
1436
+ let hi = starts.length - 1;
1437
+ while (lo < hi) {
1438
+ const mid = lo + hi + 1 >> 1;
1439
+ if (starts[mid] <= offset) lo = mid;
1440
+ else hi = mid - 1;
1441
+ }
1442
+ return lo + 1;
1443
+ };
1444
+ }
1445
+ function parseSource(file) {
1446
+ const lineAt = makeLineAt(file.content);
1447
+ try {
1448
+ const result2 = (0, import_oxc_parser.parseSync)(file.sourcePath, file.content, {
1449
+ lang: langFor(file.sourcePath),
1450
+ sourceType: "module"
1451
+ });
1452
+ return {
1453
+ program: result2.program,
1454
+ hasErrors: result2.errors.length > 0,
1455
+ comments: result2.comments ?? [],
1456
+ lineAt
1457
+ };
1458
+ } catch {
1459
+ return { program: null, hasErrors: true, comments: [], lineAt };
1460
+ }
1461
+ }
1462
+ function isNode(value) {
1463
+ return typeof value === "object" && value !== null && typeof value.type === "string";
1464
+ }
1465
+ function walkAst(root, visit) {
1466
+ if (!isNode(root)) return;
1467
+ if (visit(root) === false) return;
1468
+ for (const key of Object.keys(root)) {
1469
+ if (key === "type" || key === "start" || key === "end") continue;
1470
+ const value = root[key];
1471
+ if (Array.isArray(value)) {
1472
+ for (const item of value) walkAst(item, visit);
1473
+ } else if (isNode(value)) {
1474
+ walkAst(value, visit);
1475
+ }
1476
+ }
1477
+ }
1478
+ function unwrapTsExpression(expr) {
1479
+ let node = expr;
1480
+ for (; ; ) {
1481
+ switch (node.type) {
1482
+ case "TSAsExpression":
1483
+ case "TSSatisfiesExpression":
1484
+ case "TSNonNullExpression":
1485
+ case "TSTypeAssertion":
1486
+ case "ParenthesizedExpression": {
1487
+ const inner = node.expression;
1488
+ if (!isNode(inner)) return node;
1489
+ node = inner;
1490
+ break;
1491
+ }
1492
+ default:
1493
+ return node;
1494
+ }
1495
+ }
1496
+ }
1497
+
1240
1498
  // src/scanner/scan/extract-uidex-export.ts
1241
1499
  var KIND_DISCRIMINATORS = [
1242
1500
  "page",
@@ -1274,6 +1532,16 @@ var FALSEABLE = /* @__PURE__ */ new Set([
1274
1532
  "primitive",
1275
1533
  "region"
1276
1534
  ]);
1535
+ var SATISFIES_NAMES = {
1536
+ page: "Page",
1537
+ feature: "Feature",
1538
+ primitive: "Primitive",
1539
+ widget: "Widget",
1540
+ region: "Region",
1541
+ flow: "Flow",
1542
+ notFlow: "NotFlow"
1543
+ };
1544
+ var KNOWN_SATISFIES = new Set(Object.values(SATISFIES_NAMES));
1277
1545
  var ExtractError = class extends Error {
1278
1546
  code;
1279
1547
  hint;
@@ -1285,649 +1553,285 @@ var ExtractError = class extends Error {
1285
1553
  this.hint = hint;
1286
1554
  }
1287
1555
  };
1288
- function extractUidexExports(file) {
1556
+ function extractUidexExports(file, parsed) {
1289
1557
  const exports2 = [];
1290
1558
  const diagnostics = [];
1291
1559
  const { content, displayPath } = file;
1292
- for (const header of findExportHeaders(content)) {
1293
- try {
1294
- const value = parseExpression(content, header.exprStart);
1295
- const metadata = buildMetadata(
1296
- value,
1297
- displayPath,
1298
- header.headerPos,
1299
- diagnostics
1300
- );
1301
- exports2.push(metadata);
1302
- } catch (e) {
1303
- if (e instanceof ExtractError) {
1304
- diagnostics.push({
1305
- code: e.code,
1306
- severity: "error",
1307
- message: e.message,
1308
- file: displayPath,
1309
- line: e.pos.line,
1310
- hint: e.hint
1311
- });
1312
- } else {
1313
- throw e;
1314
- }
1315
- }
1316
- }
1317
- return { exports: exports2, diagnostics };
1318
- }
1319
- var HEADER_RE = /(?:^|\n)[\t ]*export\s+const\s+uidex\b(?:\s*:\s*[^=\n]+?)?\s*=\s*/g;
1320
- function findExportHeaders(content) {
1321
- const out2 = [];
1322
- HEADER_RE.lastIndex = 0;
1323
- let m;
1324
- while ((m = HEADER_RE.exec(content)) !== null) {
1325
- const leadingNewline = m[0].startsWith("\n") ? 1 : 0;
1326
- const headerOffset = m.index + leadingNewline;
1327
- const exprStart = m.index + m[0].length;
1328
- if (isInsideCommentOrString(content, headerOffset)) continue;
1329
- out2.push({
1330
- headerPos: posAt(content, headerOffset),
1331
- exprStart
1332
- });
1333
- }
1334
- return out2;
1335
- }
1336
- function isInsideCommentOrString(content, target) {
1337
- let i = 0;
1338
- let inLineComment = false;
1339
- let inBlockComment = false;
1340
- let stringDelim = null;
1341
- let inTemplate = false;
1342
- let templateDepth = 0;
1343
- while (i < target) {
1344
- const c = content[i];
1345
- const n = content[i + 1];
1346
- if (inLineComment) {
1347
- if (c === "\n") inLineComment = false;
1348
- i++;
1349
- continue;
1350
- }
1351
- if (inBlockComment) {
1352
- if (c === "*" && n === "/") {
1353
- inBlockComment = false;
1354
- i += 2;
1355
- continue;
1356
- }
1357
- i++;
1358
- continue;
1359
- }
1360
- if (stringDelim !== null) {
1361
- if (c === "\\") {
1362
- i += 2;
1363
- continue;
1364
- }
1365
- if (c === stringDelim) stringDelim = null;
1366
- i++;
1367
- continue;
1368
- }
1369
- if (inTemplate) {
1370
- if (c === "\\") {
1371
- i += 2;
1372
- continue;
1373
- }
1374
- if (c === "$" && n === "{") {
1375
- templateDepth++;
1376
- i += 2;
1560
+ const p2 = parsed ?? parseSource(file);
1561
+ if (p2.program === null) return { exports: exports2, diagnostics };
1562
+ for (const stmt of p2.program.body) {
1563
+ if (stmt.type !== "ExportNamedDeclaration") continue;
1564
+ const decl = stmt.declaration;
1565
+ if (!isNode2(decl) || decl.type !== "VariableDeclaration") continue;
1566
+ if (decl.kind !== "const") continue;
1567
+ for (const declarator of decl.declarations ?? []) {
1568
+ const id = declarator.id;
1569
+ if (!id || id.type !== "Identifier" || String(id.name) !== "uidex") {
1377
1570
  continue;
1378
1571
  }
1379
- if (c === "`" && templateDepth === 0) {
1380
- inTemplate = false;
1381
- i++;
1382
- continue;
1383
- }
1384
- if (templateDepth > 0 && c === "}") {
1385
- templateDepth--;
1386
- i++;
1387
- continue;
1388
- }
1389
- i++;
1390
- continue;
1391
- }
1392
- if (c === "/" && n === "/") {
1393
- inLineComment = true;
1394
- i += 2;
1395
- continue;
1396
- }
1397
- if (c === "/" && n === "*") {
1398
- inBlockComment = true;
1399
- i += 2;
1400
- continue;
1401
- }
1402
- if (c === '"' || c === "'") {
1403
- stringDelim = c;
1404
- i++;
1405
- continue;
1406
- }
1407
- if (c === "`") {
1408
- inTemplate = true;
1409
- i++;
1410
- continue;
1411
- }
1412
- i++;
1413
- }
1414
- return inLineComment || inBlockComment || stringDelim !== null || inTemplate;
1415
- }
1416
- var Tokenizer = class {
1417
- constructor(src, start) {
1418
- this.src = src;
1419
- this.pos = start;
1420
- let line = 1;
1421
- let lineStart = 0;
1422
- for (let i = 0; i < start; i++) {
1423
- if (src[i] === "\n") {
1424
- line++;
1425
- lineStart = i + 1;
1426
- }
1427
- }
1428
- this.line = line;
1429
- this.lineStart = lineStart;
1430
- }
1431
- src;
1432
- pos;
1433
- line;
1434
- lineStart;
1435
- currentPos() {
1436
- return {
1437
- offset: this.pos,
1438
- line: this.line,
1439
- column: this.pos - this.lineStart + 1
1440
- };
1441
- }
1442
- advance(n = 1) {
1443
- for (let i = 0; i < n; i++) {
1444
- if (this.pos < this.src.length && this.src[this.pos] === "\n") {
1445
- this.line++;
1446
- this.lineStart = this.pos + 1;
1447
- }
1448
- this.pos++;
1449
- }
1450
- }
1451
- skipTrivia() {
1452
- while (this.pos < this.src.length) {
1453
- const c = this.src[this.pos];
1454
- const n = this.src[this.pos + 1];
1455
- if (c === " " || c === " " || c === "\r" || c === "\n") {
1456
- this.advance();
1457
- continue;
1458
- }
1459
- if (c === "/" && n === "/") {
1460
- while (this.pos < this.src.length && this.src[this.pos] !== "\n") {
1461
- this.advance();
1572
+ const headerPos = posAt(content, stmt.start, p2);
1573
+ try {
1574
+ const init = declarator.init;
1575
+ if (!isNode2(init)) {
1576
+ throw new ExtractError(
1577
+ "uidex-export-invalid-literal",
1578
+ "`export const uidex` must be assigned an object literal.",
1579
+ headerPos
1580
+ );
1462
1581
  }
1463
- continue;
1464
- }
1465
- if (c === "/" && n === "*") {
1466
- this.advance(2);
1467
- while (this.pos < this.src.length) {
1468
- if (this.src[this.pos] === "*" && this.src[this.pos + 1] === "/") {
1469
- this.advance(2);
1470
- break;
1471
- }
1472
- this.advance();
1582
+ const value = toLitValue(unwrapTsExpression(init), content, p2);
1583
+ const metadata = buildMetadata(
1584
+ value,
1585
+ displayPath,
1586
+ file.sourcePath,
1587
+ headerPos,
1588
+ diagnostics
1589
+ );
1590
+ metadata.span = statementSpan(stmt, content);
1591
+ checkSatisfies(init, metadata, displayPath, p2, diagnostics);
1592
+ exports2.push(metadata);
1593
+ } catch (e) {
1594
+ if (e instanceof ExtractError) {
1595
+ diagnostics.push({
1596
+ code: e.code,
1597
+ severity: "error",
1598
+ message: e.message,
1599
+ file: displayPath,
1600
+ line: e.pos.line,
1601
+ hint: e.hint
1602
+ });
1603
+ } else {
1604
+ throw e;
1473
1605
  }
1474
- continue;
1475
1606
  }
1476
- break;
1477
- }
1478
- }
1479
- next() {
1480
- this.skipTrivia();
1481
- if (this.pos >= this.src.length) {
1482
- return { kind: "eof", value: "", pos: this.currentPos(), end: this.pos };
1483
- }
1484
- const pos = this.currentPos();
1485
- const c = this.src[this.pos];
1486
- switch (c) {
1487
- case "{":
1488
- this.advance();
1489
- return { kind: "lbrace", value: c, pos, end: this.pos };
1490
- case "}":
1491
- this.advance();
1492
- return { kind: "rbrace", value: c, pos, end: this.pos };
1493
- case "[":
1494
- this.advance();
1495
- return { kind: "lbracket", value: c, pos, end: this.pos };
1496
- case "]":
1497
- this.advance();
1498
- return { kind: "rbracket", value: c, pos, end: this.pos };
1499
- case "(":
1500
- this.advance();
1501
- return { kind: "lparen", value: c, pos, end: this.pos };
1502
- case ")":
1503
- this.advance();
1504
- return { kind: "rparen", value: c, pos, end: this.pos };
1505
- case ",":
1506
- this.advance();
1507
- return { kind: "comma", value: c, pos, end: this.pos };
1508
- case ":":
1509
- this.advance();
1510
- return { kind: "colon", value: c, pos, end: this.pos };
1511
- }
1512
- if (c === "." && this.src[this.pos + 1] === "." && this.src[this.pos + 2] === ".") {
1513
- this.advance(3);
1514
- return { kind: "spread", value: "...", pos, end: this.pos };
1515
- }
1516
- if (c === '"' || c === "'") {
1517
- return this.readString(pos, c);
1518
- }
1519
- if (c === "`") {
1520
- return this.readTemplate(pos);
1521
- }
1522
- if (isDigit(c) || c === "-" && isDigit(this.src[this.pos + 1])) {
1523
- return this.readNumber(pos);
1524
- }
1525
- if (isIdentStart(c)) {
1526
- return this.readIdent(pos);
1527
1607
  }
1528
- this.advance();
1529
- return { kind: "punct", value: c, pos, end: this.pos };
1530
1608
  }
1531
- readString(pos, delim) {
1532
- this.advance();
1533
- let value = "";
1534
- while (this.pos < this.src.length) {
1535
- const c = this.src[this.pos];
1536
- if (c === "\\") {
1537
- const esc = this.src[this.pos + 1];
1538
- this.advance(2);
1539
- value += decodeEscape(esc);
1540
- continue;
1541
- }
1542
- if (c === delim) {
1543
- this.advance();
1544
- return { kind: "string", value, pos, end: this.pos };
1545
- }
1546
- if (c === "\n") {
1547
- return { kind: "punct", value: delim, pos, end: this.pos };
1548
- }
1549
- value += c;
1550
- this.advance();
1551
- }
1552
- return { kind: "punct", value: delim, pos, end: this.pos };
1553
- }
1554
- readTemplate(pos) {
1555
- this.advance();
1556
- let value = "";
1557
- let hasExpression = false;
1558
- while (this.pos < this.src.length) {
1559
- const c = this.src[this.pos];
1560
- const n = this.src[this.pos + 1];
1561
- if (c === "\\") {
1562
- const esc = this.src[this.pos + 1];
1563
- this.advance(2);
1564
- value += decodeEscape(esc);
1565
- continue;
1566
- }
1567
- if (c === "$" && n === "{") {
1568
- hasExpression = true;
1569
- this.advance(2);
1570
- let depth = 1;
1571
- while (this.pos < this.src.length && depth > 0) {
1572
- const ch = this.src[this.pos];
1573
- if (ch === "{") depth++;
1574
- else if (ch === "}") depth--;
1575
- this.advance();
1609
+ return { exports: exports2, diagnostics };
1610
+ }
1611
+ function toLitValue(node, content, p2) {
1612
+ const unwrapped = unwrapTsExpression(node);
1613
+ const pos = posAt(content, unwrapped.start, p2);
1614
+ const span = { start: unwrapped.start, end: unwrapped.end };
1615
+ switch (unwrapped.type) {
1616
+ case "Literal": {
1617
+ const v = unwrapped.value;
1618
+ if (typeof v === "string") return { kind: "string", value: v, pos, span };
1619
+ if (typeof v === "number") {
1620
+ if (!Number.isFinite(v)) {
1621
+ throw new ExtractError(
1622
+ "uidex-export-invalid-literal",
1623
+ `Invalid numeric literal in \`export const uidex\`.`,
1624
+ pos
1625
+ );
1576
1626
  }
1577
- continue;
1627
+ return { kind: "number", value: v, pos, span };
1578
1628
  }
1579
- if (c === "`") {
1580
- this.advance();
1581
- if (hasExpression) {
1582
- return { kind: "template", value, pos, end: this.pos };
1583
- }
1584
- return { kind: "string", value, pos, end: this.pos };
1629
+ if (typeof v === "boolean") {
1630
+ return { kind: "boolean", value: v, pos, span };
1585
1631
  }
1586
- value += c;
1587
- this.advance();
1588
- }
1589
- return { kind: "template", value, pos, end: this.pos };
1590
- }
1591
- readNumber(pos) {
1592
- const start = this.pos;
1593
- if (this.src[this.pos] === "-") this.advance();
1594
- while (this.pos < this.src.length && isDigit(this.src[this.pos])) {
1595
- this.advance();
1596
- }
1597
- if (this.src[this.pos] === ".") {
1598
- this.advance();
1599
- while (this.pos < this.src.length && isDigit(this.src[this.pos])) {
1600
- this.advance();
1632
+ if (v === null && unwrapped.raw === "null") {
1633
+ return { kind: "null", pos, span };
1601
1634
  }
1635
+ throw new ExtractError(
1636
+ "uidex-export-invalid-literal",
1637
+ `Unsupported literal in \`export const uidex\`; only strings, numbers, booleans, and null are allowed.`,
1638
+ pos
1639
+ );
1602
1640
  }
1603
- if (this.src[this.pos] === "e" || this.src[this.pos] === "E") {
1604
- this.advance();
1605
- if (this.src[this.pos] === "+" || this.src[this.pos] === "-") {
1606
- this.advance();
1641
+ case "UnaryExpression": {
1642
+ const arg = unwrapped.argument;
1643
+ if (unwrapped.operator === "-" && isNode2(arg) && arg.type === "Literal" && typeof arg.value === "number") {
1644
+ return { kind: "number", value: -arg.value, pos, span };
1607
1645
  }
1608
- while (this.pos < this.src.length && isDigit(this.src[this.pos])) {
1609
- this.advance();
1610
- }
1611
- }
1612
- const value = this.src.slice(start, this.pos);
1613
- return { kind: "number", value, pos, end: this.pos };
1614
- }
1615
- readIdent(pos) {
1616
- const start = this.pos;
1617
- while (this.pos < this.src.length && isIdentPart(this.src[this.pos])) {
1618
- this.advance();
1646
+ throw new ExtractError(
1647
+ "uidex-export-invalid-literal",
1648
+ "Unary expressions are not allowed in `export const uidex`.",
1649
+ pos
1650
+ );
1619
1651
  }
1620
- const value = this.src.slice(start, this.pos);
1621
- return { kind: "ident", value, pos, end: this.pos };
1622
- }
1623
- };
1624
- function isDigit(c) {
1625
- return c !== void 0 && c >= "0" && c <= "9";
1626
- }
1627
- function isIdentStart(c) {
1628
- if (c === void 0) return false;
1629
- return c >= "a" && c <= "z" || c >= "A" && c <= "Z" || c === "_" || c === "$";
1630
- }
1631
- function isIdentPart(c) {
1632
- return isIdentStart(c) || isDigit(c);
1633
- }
1634
- function decodeEscape(esc) {
1635
- switch (esc) {
1636
- case "n":
1637
- return "\n";
1638
- case "t":
1639
- return " ";
1640
- case "r":
1641
- return "\r";
1642
- case "\\":
1643
- return "\\";
1644
- case "'":
1645
- return "'";
1646
- case '"':
1647
- return '"';
1648
- case "`":
1649
- return "`";
1650
- case "0":
1651
- return "\0";
1652
- case "b":
1653
- return "\b";
1654
- case "f":
1655
- return "\f";
1656
- case "v":
1657
- return "\v";
1658
- default:
1659
- return esc ?? "";
1660
- }
1661
- }
1662
- function parseExpression(content, start) {
1663
- const tokenizer = new Tokenizer(content, start);
1664
- const parser = new Parser(tokenizer);
1665
- const value = parser.parseValue();
1666
- parser.consumeTrailingAssertions();
1667
- return value;
1668
- }
1669
- var Parser = class {
1670
- constructor(tok) {
1671
- this.tok = tok;
1672
- }
1673
- tok;
1674
- lookahead = null;
1675
- peek() {
1676
- if (this.lookahead === null) this.lookahead = this.tok.next();
1677
- return this.lookahead;
1678
- }
1679
- consume() {
1680
- const t = this.peek();
1681
- this.lookahead = null;
1682
- return t;
1683
- }
1684
- parseValue() {
1685
- const t = this.peek();
1686
- switch (t.kind) {
1687
- case "lbrace":
1688
- return this.parseObject();
1689
- case "lbracket":
1690
- return this.parseArray();
1691
- case "string":
1692
- this.consume();
1693
- return { kind: "string", value: t.value, pos: t.pos };
1694
- case "template":
1652
+ case "TemplateLiteral": {
1653
+ const expressions = unwrapped.expressions ?? [];
1654
+ if (expressions.length > 0) {
1695
1655
  throw new ExtractError(
1696
1656
  "uidex-export-invalid-literal",
1697
1657
  "Template literal with expression parts is not allowed in `export const uidex`; use a plain string literal.",
1698
- t.pos
1658
+ pos
1699
1659
  );
1700
- case "number": {
1701
- this.consume();
1702
- const n = Number(t.value);
1703
- if (!Number.isFinite(n)) {
1704
- throw new ExtractError(
1705
- "uidex-export-invalid-literal",
1706
- `Invalid numeric literal "${t.value}" in \`export const uidex\`.`,
1707
- t.pos
1708
- );
1709
- }
1710
- return { kind: "number", value: n, pos: t.pos };
1711
1660
  }
1712
- case "ident":
1713
- if (t.value === "true" || t.value === "false") {
1714
- this.consume();
1715
- return {
1716
- kind: "boolean",
1717
- value: t.value === "true",
1718
- pos: t.pos
1719
- };
1720
- }
1721
- if (t.value === "null") {
1722
- this.consume();
1723
- return { kind: "null", pos: t.pos };
1724
- }
1725
- if (t.value === "undefined") {
1726
- throw new ExtractError(
1727
- "uidex-export-invalid-literal",
1728
- "`undefined` is not allowed as a value in `export const uidex`; omit the field instead.",
1729
- t.pos
1730
- );
1731
- }
1732
- throw new ExtractError(
1733
- "uidex-export-invalid-literal",
1734
- `Identifier reference "${t.value}" is not allowed in \`export const uidex\`; the right-hand side must be a plain literal.`,
1735
- t.pos
1736
- );
1737
- case "spread":
1738
- throw new ExtractError(
1739
- "uidex-export-invalid-literal",
1740
- "Spread (`...`) is not allowed in `export const uidex`; the right-hand side must be a plain literal.",
1741
- t.pos
1742
- );
1743
- case "lparen":
1744
- throw new ExtractError(
1745
- "uidex-export-invalid-literal",
1746
- "Parenthesised or grouped expressions are not allowed in `export const uidex`.",
1747
- t.pos
1748
- );
1749
- case "punct":
1750
- throw new ExtractError(
1751
- "uidex-export-invalid-literal",
1752
- `Unexpected token "${t.value}" in \`export const uidex\`.`,
1753
- t.pos
1754
- );
1755
- case "eof":
1756
- throw new ExtractError(
1757
- "uidex-export-invalid-literal",
1758
- "Expected a value for `export const uidex` but reached end of file.",
1759
- t.pos
1760
- );
1761
- default:
1762
- throw new ExtractError(
1763
- "uidex-export-invalid-literal",
1764
- `Unexpected token in \`export const uidex\`.`,
1765
- t.pos
1766
- );
1661
+ const quasis = unwrapped.quasis ?? [];
1662
+ const cooked = quasis[0]?.value?.cooked ?? "";
1663
+ return { kind: "string", value: cooked, pos, span };
1767
1664
  }
1768
- }
1769
- parseObject() {
1770
- const open = this.consume();
1771
- const entries = [];
1772
- const seen = /* @__PURE__ */ new Set();
1773
- while (true) {
1774
- const t = this.peek();
1775
- if (t.kind === "rbrace") {
1776
- this.consume();
1777
- break;
1778
- }
1779
- if (t.kind === "spread") {
1665
+ case "Identifier": {
1666
+ const name = String(unwrapped.name);
1667
+ if (name === "undefined") {
1780
1668
  throw new ExtractError(
1781
1669
  "uidex-export-invalid-literal",
1782
- "Spread (`...`) is not allowed inside `export const uidex`.",
1783
- t.pos
1670
+ "`undefined` is not allowed as a value in `export const uidex`; omit the field instead.",
1671
+ pos
1784
1672
  );
1785
1673
  }
1786
- if (t.kind === "lbracket") {
1787
- this.consume();
1788
- const keyTok = this.peek();
1789
- if (keyTok.kind !== "string") {
1790
- throw new ExtractError(
1791
- "uidex-export-invalid-literal",
1792
- "Computed property keys must be string literals in `export const uidex`.",
1793
- keyTok.pos
1794
- );
1795
- }
1796
- this.consume();
1797
- const close = this.peek();
1798
- if (close.kind !== "rbracket") {
1799
- throw new ExtractError(
1800
- "uidex-export-invalid-literal",
1801
- "Expected `]` after computed property key.",
1802
- close.pos
1803
- );
1804
- }
1805
- this.consume();
1806
- const colon = this.peek();
1807
- if (colon.kind !== "colon") {
1808
- throw new ExtractError(
1809
- "uidex-export-invalid-literal",
1810
- "Expected `:` after computed property key.",
1811
- colon.pos
1812
- );
1813
- }
1814
- this.consume();
1815
- const value = this.parseValue();
1816
- this.recordEntry(entries, seen, keyTok.value, value, keyTok.pos);
1817
- } else if (t.kind === "ident" || t.kind === "string") {
1818
- const keyTok = this.consume();
1819
- const next = this.peek();
1820
- if (next.kind === "colon") {
1821
- this.consume();
1822
- const value = this.parseValue();
1823
- this.recordEntry(entries, seen, keyTok.value, value, keyTok.pos);
1824
- } else {
1825
- throw new ExtractError(
1826
- "uidex-export-invalid-literal",
1827
- keyTok.kind === "ident" ? `Shorthand property "${keyTok.value}" is not allowed; write "${keyTok.value}: ..." with a literal value.` : "Expected `:` after property key.",
1828
- keyTok.pos
1829
- );
1830
- }
1831
- } else if (t.kind === "number") {
1832
- throw new ExtractError(
1833
- "uidex-export-invalid-literal",
1834
- "Numeric property keys are not allowed in `export const uidex`.",
1835
- t.pos
1836
- );
1837
- } else {
1674
+ throw new ExtractError(
1675
+ "uidex-export-invalid-literal",
1676
+ `Identifier reference "${name}" is not allowed in \`export const uidex\`; the right-hand side must be a plain literal.`,
1677
+ pos
1678
+ );
1679
+ }
1680
+ case "ObjectExpression":
1681
+ return objectLit(unwrapped, content, p2, pos, span);
1682
+ case "ArrayExpression":
1683
+ return arrayLit(unwrapped, content, p2, pos, span);
1684
+ case "SpreadElement":
1685
+ throw new ExtractError(
1686
+ "uidex-export-invalid-literal",
1687
+ "Spread (`...`) is not allowed in `export const uidex`; the right-hand side must be a plain literal.",
1688
+ pos
1689
+ );
1690
+ case "CallExpression":
1691
+ throw new ExtractError(
1692
+ "uidex-export-invalid-literal",
1693
+ "Function calls are not allowed in `export const uidex`; the right-hand side must be a plain literal.",
1694
+ pos
1695
+ );
1696
+ case "ConditionalExpression":
1697
+ throw new ExtractError(
1698
+ "uidex-export-invalid-literal",
1699
+ "Conditional expressions are not allowed in `export const uidex`; the right-hand side must be a plain literal.",
1700
+ pos
1701
+ );
1702
+ default:
1703
+ throw new ExtractError(
1704
+ "uidex-export-invalid-literal",
1705
+ `Computed expressions are not allowed in \`export const uidex\`; the right-hand side must be a plain literal.`,
1706
+ pos
1707
+ );
1708
+ }
1709
+ }
1710
+ function objectLit(node, content, p2, pos, span) {
1711
+ const entries = [];
1712
+ const seen = /* @__PURE__ */ new Set();
1713
+ for (const prop of node.properties ?? []) {
1714
+ if (prop.type === "SpreadElement") {
1715
+ throw new ExtractError(
1716
+ "uidex-export-invalid-literal",
1717
+ "Spread (`...`) is not allowed inside `export const uidex`.",
1718
+ posAt(content, prop.start, p2)
1719
+ );
1720
+ }
1721
+ if (prop.type !== "Property") {
1722
+ throw new ExtractError(
1723
+ "uidex-export-invalid-literal",
1724
+ "Unexpected member inside `export const uidex` object.",
1725
+ posAt(content, prop.start, p2)
1726
+ );
1727
+ }
1728
+ const keyNode = prop.key;
1729
+ const keyPos = posAt(content, keyNode.start, p2);
1730
+ if (prop.shorthand) {
1731
+ throw new ExtractError(
1732
+ "uidex-export-invalid-literal",
1733
+ `Shorthand property "${String(keyNode.name)}" is not allowed; write "${String(keyNode.name)}: ..." with a literal value.`,
1734
+ keyPos
1735
+ );
1736
+ }
1737
+ let key;
1738
+ if (prop.computed) {
1739
+ if (keyNode.type !== "Literal" || typeof keyNode.value !== "string") {
1838
1740
  throw new ExtractError(
1839
1741
  "uidex-export-invalid-literal",
1840
- `Unexpected token "${t.value}" inside object.`,
1841
- t.pos
1742
+ "Computed property keys must be string literals in `export const uidex`.",
1743
+ keyPos
1842
1744
  );
1843
1745
  }
1844
- const after = this.peek();
1845
- if (after.kind === "comma") {
1846
- this.consume();
1847
- continue;
1848
- }
1849
- if (after.kind === "rbrace") {
1850
- this.consume();
1851
- break;
1852
- }
1746
+ key = keyNode.value;
1747
+ } else if (keyNode.type === "Identifier") {
1748
+ key = String(keyNode.name);
1749
+ } else if (keyNode.type === "Literal" && typeof keyNode.value === "string") {
1750
+ key = keyNode.value;
1751
+ } else {
1853
1752
  throw new ExtractError(
1854
1753
  "uidex-export-invalid-literal",
1855
- `Expected \`,\` or \`}\`, got "${after.value}".`,
1856
- after.pos
1754
+ "Numeric property keys are not allowed in `export const uidex`.",
1755
+ keyPos
1857
1756
  );
1858
1757
  }
1859
- return { kind: "object", entries, pos: open.pos };
1860
- }
1861
- recordEntry(entries, seen, key, value, pos) {
1862
1758
  if (seen.has(key)) {
1863
1759
  throw new ExtractError(
1864
1760
  "uidex-export-duplicate-field",
1865
1761
  `Duplicate field "${key}" in \`export const uidex\`.`,
1866
- pos
1762
+ keyPos
1867
1763
  );
1868
1764
  }
1869
1765
  seen.add(key);
1870
- entries.push([key, value]);
1871
- }
1872
- parseArray() {
1873
- const open = this.consume();
1874
- const items = [];
1875
- while (true) {
1876
- const t = this.peek();
1877
- if (t.kind === "rbracket") {
1878
- this.consume();
1879
- break;
1880
- }
1881
- if (t.kind === "spread") {
1882
- throw new ExtractError(
1883
- "uidex-export-invalid-literal",
1884
- "Spread (`...`) is not allowed inside `export const uidex`.",
1885
- t.pos
1886
- );
1887
- }
1888
- const value = this.parseValue();
1889
- if (value.kind === "object") {
1890
- }
1891
- items.push(value);
1892
- const after = this.peek();
1893
- if (after.kind === "comma") {
1894
- this.consume();
1895
- continue;
1896
- }
1897
- if (after.kind === "rbracket") {
1898
- this.consume();
1899
- break;
1900
- }
1766
+ const value = toLitValue(prop.value, content, p2);
1767
+ entries.push({
1768
+ key,
1769
+ value,
1770
+ keyPos,
1771
+ span: removalSpan(content, prop.start, prop.end)
1772
+ });
1773
+ }
1774
+ return { kind: "object", entries, pos, span };
1775
+ }
1776
+ function arrayLit(node, content, p2, pos, span) {
1777
+ const items = [];
1778
+ for (const el of node.elements ?? []) {
1779
+ if (el === null) {
1780
+ throw new ExtractError(
1781
+ "uidex-export-invalid-literal",
1782
+ "Array holes are not allowed in `export const uidex`.",
1783
+ pos
1784
+ );
1785
+ }
1786
+ if (el.type === "SpreadElement") {
1901
1787
  throw new ExtractError(
1902
1788
  "uidex-export-invalid-literal",
1903
- `Expected \`,\` or \`]\`, got "${after.value}".`,
1904
- after.pos
1789
+ "Spread (`...`) is not allowed inside `export const uidex`.",
1790
+ posAt(content, el.start, p2)
1905
1791
  );
1906
1792
  }
1907
- return { kind: "array", items, pos: open.pos };
1793
+ items.push(toLitValue(el, content, p2));
1908
1794
  }
1909
- consumeTrailingAssertions() {
1910
- const first = this.peek();
1911
- if (first.kind === "ident" && first.value === "as") {
1912
- this.consume();
1913
- const next = this.peek();
1914
- if (next.kind === "ident" && next.value === "const") {
1915
- this.consume();
1916
- } else {
1917
- throw new ExtractError(
1918
- "uidex-export-invalid-literal",
1919
- "Only `as const` is allowed after the `export const uidex` value.",
1920
- next.pos
1921
- );
1922
- }
1795
+ return { kind: "array", items, pos, span };
1796
+ }
1797
+ function checkSatisfies(init, metadata, file, p2, diagnostics) {
1798
+ let node = init;
1799
+ let satisfiesType;
1800
+ while (isNode2(node)) {
1801
+ if (node.type === "TSSatisfiesExpression") {
1802
+ satisfiesType = node.typeAnnotation;
1803
+ break;
1923
1804
  }
1924
- const maybeSatisfies = this.peek();
1925
- if (maybeSatisfies.kind === "ident" && maybeSatisfies.value === "satisfies") {
1926
- return;
1805
+ if (node.type === "TSAsExpression" || node.type === "TSNonNullExpression" || node.type === "TSTypeAssertion" || node.type === "ParenthesizedExpression") {
1806
+ node = node.expression;
1807
+ continue;
1927
1808
  }
1809
+ break;
1928
1810
  }
1929
- };
1930
- function buildMetadata(value, file, headerPos, diagnostics) {
1811
+ if (!isNode2(satisfiesType) || satisfiesType.type !== "TSTypeReference") return;
1812
+ const typeName = satisfiesType.typeName;
1813
+ if (!isNode2(typeName) || typeName.type !== "TSQualifiedName") return;
1814
+ const left = typeName.left;
1815
+ const right = typeName.right;
1816
+ if (!isNode2(left) || left.type !== "Identifier" || left.name !== "Uidex") {
1817
+ return;
1818
+ }
1819
+ if (!isNode2(right) || right.type !== "Identifier") return;
1820
+ const actual = String(right.name);
1821
+ if (!KNOWN_SATISFIES.has(actual)) return;
1822
+ const discriminator = metadata.notFlow ? "notFlow" : metadata.kind;
1823
+ const expected = SATISFIES_NAMES[discriminator];
1824
+ if (actual === expected) return;
1825
+ diagnostics.push({
1826
+ code: "uidex-export-satisfies-mismatch",
1827
+ severity: "warning",
1828
+ message: `\`export const uidex\` declares kind "${discriminator}" but is annotated \`satisfies Uidex.${actual}\`; expected \`Uidex.${expected}\`.`,
1829
+ file,
1830
+ line: p2.lineAt(satisfiesType.start),
1831
+ hint: `Change the annotation to \`satisfies Uidex.${expected}\` or fix the kind discriminator.`
1832
+ });
1833
+ }
1834
+ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
1931
1835
  if (value.kind !== "object") {
1932
1836
  throw new ExtractError(
1933
1837
  "uidex-export-invalid-literal",
@@ -1936,7 +1840,7 @@ function buildMetadata(value, file, headerPos, diagnostics) {
1936
1840
  );
1937
1841
  }
1938
1842
  const byKey = /* @__PURE__ */ new Map();
1939
- for (const [k, v] of value.entries) byKey.set(k, v);
1843
+ for (const entry of value.entries) byKey.set(entry.key, entry);
1940
1844
  const presentKinds = KIND_DISCRIMINATORS.filter(
1941
1845
  (k) => byKey.has(k)
1942
1846
  );
@@ -1959,49 +1863,58 @@ function buildMetadata(value, file, headerPos, diagnostics) {
1959
1863
  const discriminator = presentKinds[0];
1960
1864
  const kind = discriminator === "notFlow" ? "flow" : discriminator;
1961
1865
  const allowed = ALLOWED_FIELDS[kind];
1962
- for (const [k] of value.entries) {
1963
- if (!allowed.has(k)) {
1964
- const fieldVal = byKey.get(k);
1866
+ for (const entry of value.entries) {
1867
+ if (!allowed.has(entry.key)) {
1965
1868
  throw new ExtractError(
1966
1869
  "uidex-export-unknown-field",
1967
- `Unknown field "${k}" in \`export const uidex\` for kind "${kind}". Allowed: ${Array.from(
1870
+ `Unknown field "${entry.key}" in \`export const uidex\` for kind "${kind}". Allowed: ${Array.from(
1968
1871
  allowed
1969
1872
  ).sort().join(", ")}.`,
1970
- fieldVal.pos
1873
+ entry.value.pos
1971
1874
  );
1972
1875
  }
1973
1876
  }
1974
1877
  const idField = discriminator === "notFlow" ? "flow" : discriminator;
1975
- const idValue = byKey.get(discriminator);
1878
+ const idValue = byKey.get(discriminator).value;
1976
1879
  let id;
1977
1880
  if (discriminator === "notFlow") {
1978
- const v = idValue;
1979
- if (v.kind !== "boolean" || v.value !== true) {
1881
+ if (idValue.kind !== "boolean" || idValue.value !== true) {
1980
1882
  throw new ExtractError(
1981
1883
  "uidex-export-invalid-field",
1982
1884
  "`notFlow` must be `true`.",
1983
- v.pos
1885
+ idValue.pos
1984
1886
  );
1985
1887
  }
1986
1888
  id = false;
1987
1889
  } else {
1988
1890
  id = readIdField(idValue, kind, idField);
1989
1891
  }
1990
- const acceptance = readStringArrayField(byKey, "acceptance");
1892
+ const acceptance = readStringArrayField(byKey, "acceptance")?.values;
1991
1893
  const description = readStringField(byKey, "description");
1992
1894
  const name = readStringField(byKey, "name");
1993
1895
  if (name === "") {
1994
- const pos = byKey.get("name").pos;
1896
+ const entry = byKey.get("name");
1995
1897
  diagnostics.push({
1996
1898
  code: "uidex-export-empty-name",
1997
1899
  severity: "info",
1998
1900
  message: "`name` is an empty string; treating as unset.",
1999
1901
  file,
2000
- line: pos.line
1902
+ line: entry.value.pos.line,
1903
+ fix: {
1904
+ description: "Remove the empty `name` field",
1905
+ edits: [
1906
+ {
1907
+ path: sourcePath,
1908
+ start: entry.span.start,
1909
+ end: entry.span.end,
1910
+ replacement: ""
1911
+ }
1912
+ ]
1913
+ }
2001
1914
  });
2002
1915
  }
2003
- const features = kind === "page" || kind === "feature" ? readStringArrayField(byKey, "features") : void 0;
2004
- const widgets = kind === "page" ? readStringArrayField(byKey, "widgets") : void 0;
1916
+ const featuresField = kind === "page" || kind === "feature" ? readStringArrayField(byKey, "features") : void 0;
1917
+ const widgetsField = kind === "page" ? readStringArrayField(byKey, "widgets") : void 0;
2005
1918
  const notFlow = kind === "flow" && discriminator === "notFlow" ? true : void 0;
2006
1919
  const metadata = {
2007
1920
  source: "ts-export",
@@ -2016,9 +1929,21 @@ function buildMetadata(value, file, headerPos, diagnostics) {
2016
1929
  if (name) metadata.name = name;
2017
1930
  if (acceptance) metadata.acceptance = acceptance;
2018
1931
  if (description) metadata.description = description;
2019
- if (features) metadata.features = features;
2020
- if (widgets) metadata.widgets = widgets;
1932
+ if (featuresField) {
1933
+ metadata.features = featuresField.values;
1934
+ metadata.featureSpans = featuresField.spans;
1935
+ }
1936
+ if (widgetsField) {
1937
+ metadata.widgets = widgetsField.values;
1938
+ metadata.widgetSpans = widgetsField.spans;
1939
+ }
2021
1940
  if (notFlow) metadata.notFlow = true;
1941
+ if (typeof id === "string" && idValue.kind === "string") {
1942
+ metadata.idSpan = idValue.span;
1943
+ }
1944
+ const fieldSpans = {};
1945
+ for (const entry of value.entries) fieldSpans[entry.key] = entry.span;
1946
+ metadata.fieldSpans = fieldSpans;
2022
1947
  return metadata;
2023
1948
  }
2024
1949
  function readIdField(value, kind, fieldName) {
@@ -2049,29 +1974,30 @@ function readIdField(value, kind, fieldName) {
2049
1974
  );
2050
1975
  }
2051
1976
  function readStringField(byKey, name) {
2052
- const v = byKey.get(name);
2053
- if (!v) return void 0;
2054
- if (v.kind !== "string") {
1977
+ const entry = byKey.get(name);
1978
+ if (!entry) return void 0;
1979
+ if (entry.value.kind !== "string") {
2055
1980
  throw new ExtractError(
2056
1981
  "uidex-export-invalid-field",
2057
1982
  `\`${name}\` must be a string.`,
2058
- v.pos
1983
+ entry.value.pos
2059
1984
  );
2060
1985
  }
2061
- return v.value;
1986
+ return entry.value.value;
2062
1987
  }
2063
1988
  function readStringArrayField(byKey, name) {
2064
- const v = byKey.get(name);
2065
- if (!v) return void 0;
2066
- if (v.kind !== "array") {
1989
+ const entry = byKey.get(name);
1990
+ if (!entry) return void 0;
1991
+ if (entry.value.kind !== "array") {
2067
1992
  throw new ExtractError(
2068
1993
  "uidex-export-invalid-field",
2069
1994
  `\`${name}\` must be an array of strings.`,
2070
- v.pos
1995
+ entry.value.pos
2071
1996
  );
2072
1997
  }
2073
- const out2 = [];
2074
- for (const item of v.items) {
1998
+ const values = [];
1999
+ const spans = [];
2000
+ for (const item of entry.value.items) {
2075
2001
  if (item.kind !== "string") {
2076
2002
  throw new ExtractError(
2077
2003
  "uidex-export-invalid-field",
@@ -2079,312 +2005,522 @@ function readStringArrayField(byKey, name) {
2079
2005
  item.pos
2080
2006
  );
2081
2007
  }
2082
- out2.push(item.value);
2008
+ values.push(item.value);
2009
+ spans.push(item.span);
2010
+ }
2011
+ return { values, spans };
2012
+ }
2013
+ function posAt(content, offset, p2) {
2014
+ const lineStart = content.lastIndexOf("\n", offset - 1) + 1;
2015
+ return { offset, line: p2.lineAt(offset), column: offset - lineStart + 1 };
2016
+ }
2017
+ function removalSpan(content, start, end) {
2018
+ let e = end;
2019
+ while (e < content.length && /[ \t]/.test(content[e])) e++;
2020
+ if (content[e] === ",") return { start, end: e + 1 };
2021
+ let s = start;
2022
+ while (s > 0 && /[\s]/.test(content[s - 1])) s--;
2023
+ if (content[s - 1] === ",") return { start: s - 1, end };
2024
+ return { start, end };
2025
+ }
2026
+ function statementSpan(stmt, content) {
2027
+ let end = stmt.end;
2028
+ while (end < content.length && /[ \t]/.test(content[end])) end++;
2029
+ if (content[end] === ";") end++;
2030
+ while (end < content.length && /[ \t]/.test(content[end])) end++;
2031
+ if (content[end] === "\r") end++;
2032
+ if (content[end] === "\n") end++;
2033
+ return { start: stmt.start, end };
2034
+ }
2035
+ function isNode2(value) {
2036
+ return typeof value === "object" && value !== null && typeof value.type === "string";
2037
+ }
2038
+
2039
+ // src/scanner/scan/flow-facts.ts
2040
+ function collectFlowFacts(parsed, content) {
2041
+ if (parsed.program === null || !content.includes("test.describe")) {
2042
+ return [];
2043
+ }
2044
+ const facts = [];
2045
+ walkAst(parsed.program, (node) => {
2046
+ if (node.type !== "CallExpression") return void 0;
2047
+ const fact = readTaggedDescribe(node, parsed);
2048
+ if (fact) facts.push(fact);
2049
+ return void 0;
2050
+ });
2051
+ return facts;
2052
+ }
2053
+ function readTaggedDescribe(call, parsed) {
2054
+ const callee = call.callee;
2055
+ if (!callee || callee.type !== "MemberExpression" || !isIdentifier(callee.object, "test") || !isIdentifier(callee.property, "describe")) {
2056
+ return null;
2057
+ }
2058
+ const args = call.arguments ?? [];
2059
+ const title = stringLiteralValue(args[0]);
2060
+ if (title === null) return null;
2061
+ if (!hasFlowTag(args[1])) return null;
2062
+ const body = args[2];
2063
+ const { calls, dynamicCalls } = body ? collectUidexCalls(body, parsed) : { calls: [], dynamicCalls: [] };
2064
+ return {
2065
+ title,
2066
+ line: parsed.lineAt(call.start),
2067
+ calls,
2068
+ ...dynamicCalls.length > 0 ? { dynamicCalls } : {}
2069
+ };
2070
+ }
2071
+ function hasFlowTag(node) {
2072
+ if (!node || node.type !== "ObjectExpression") return false;
2073
+ for (const prop of node.properties ?? []) {
2074
+ if (prop.type !== "Property") continue;
2075
+ const key = prop.key;
2076
+ const keyName = key?.type === "Identifier" ? String(key.name) : key?.type === "Literal" ? String(key.value) : null;
2077
+ if (keyName !== "tag") continue;
2078
+ const value = prop.value;
2079
+ if (!value) return false;
2080
+ if (stringLiteralValue(value) === "@uidex:flow") return true;
2081
+ if (value.type === "ArrayExpression") {
2082
+ for (const el of value.elements ?? []) {
2083
+ if (el && stringLiteralValue(el) === "@uidex:flow") return true;
2084
+ }
2085
+ }
2086
+ return false;
2083
2087
  }
2084
- return out2;
2088
+ return false;
2085
2089
  }
2086
- function posAt(content, offset) {
2087
- let line = 1;
2088
- let lineStart = 0;
2089
- for (let i = 0; i < offset && i < content.length; i++) {
2090
- if (content[i] === "\n") {
2091
- line++;
2092
- lineStart = i + 1;
2090
+ function collectUidexCalls(body, parsed) {
2091
+ const calls = [];
2092
+ const dynamicCalls = [];
2093
+ const claimed = /* @__PURE__ */ new Set();
2094
+ const record = (node, action) => {
2095
+ if (claimed.has(node)) return;
2096
+ claimed.add(node);
2097
+ const resolved = uidexCallId(node);
2098
+ if (resolved === null) {
2099
+ dynamicCalls.push({ line: parsed.lineAt(node.start) });
2100
+ return;
2101
+ }
2102
+ calls.push({
2103
+ id: resolved.id,
2104
+ ...action ? { action } : {},
2105
+ line: parsed.lineAt(node.start),
2106
+ span: resolved.span
2107
+ });
2108
+ };
2109
+ walkAst(body, (node) => {
2110
+ if (node.type !== "CallExpression") return void 0;
2111
+ const callee = node.callee;
2112
+ if (callee?.type === "MemberExpression") {
2113
+ const inner = callee.object;
2114
+ if (inner && isUidexCall(inner)) {
2115
+ const property = callee.property;
2116
+ const action = property?.type === "Identifier" ? String(property.name) : void 0;
2117
+ record(inner, action);
2118
+ }
2119
+ return void 0;
2093
2120
  }
2121
+ if (isUidexCall(node)) record(node);
2122
+ return void 0;
2123
+ });
2124
+ return { calls, dynamicCalls };
2125
+ }
2126
+ function isUidexCall(node) {
2127
+ if (node.type !== "CallExpression") return false;
2128
+ if (!isIdentifier(node.callee, "uidex")) return false;
2129
+ return (node.arguments ?? []).length >= 1;
2130
+ }
2131
+ function uidexCallId(node) {
2132
+ const args = node.arguments ?? [];
2133
+ const arg = args[0];
2134
+ const id = stringLiteralValue(arg);
2135
+ if (id === null) return null;
2136
+ return { id, span: { start: arg.start, end: arg.end } };
2137
+ }
2138
+ function stringLiteralValue(node) {
2139
+ if (!node) return null;
2140
+ if (node.type === "Literal" && typeof node.value === "string") {
2141
+ return node.value.length > 0 ? node.value : null;
2142
+ }
2143
+ if (node.type === "TemplateLiteral") {
2144
+ const expressions = node.expressions ?? [];
2145
+ if (expressions.length > 0) return null;
2146
+ const quasis = node.quasis ?? [];
2147
+ const cooked = quasis[0]?.value?.cooked;
2148
+ return cooked && cooked.length > 0 ? cooked : null;
2094
2149
  }
2095
- return { offset, line, column: offset - lineStart + 1 };
2150
+ return null;
2151
+ }
2152
+ function isIdentifier(node, name) {
2153
+ return typeof node === "object" && node !== null && node.type === "Identifier" && String(node.name) === name;
2096
2154
  }
2097
2155
 
2098
2156
  // src/scanner/scan/jsx-ancestry.ts
2099
- var DATA_ATTR_RE = /\bdata-uidex(?:-(region|widget|primitive))?\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
2100
- function parseDataAttrs(tagSource) {
2101
- if (!tagSource.includes("data-uidex")) return [];
2102
- const out2 = [];
2103
- for (const m of tagSource.matchAll(DATA_ATTR_RE)) {
2104
- const kind = m[1] ?? "element";
2105
- const id = m[2] ?? m[3];
2106
- if (id) out2.push({ kind, id });
2107
- }
2108
- return out2;
2157
+ var ATTR_NAME_RE = /^data-uidex(?:-(region|widget|primitive))?$/;
2158
+ var INTERACTIVE_TAGS = /* @__PURE__ */ new Set(["button", "a", "input", "select", "textarea"]);
2159
+ var LANDMARK_TAGS = /* @__PURE__ */ new Set(["header", "nav", "main", "aside", "footer"]);
2160
+ function attrKind(node) {
2161
+ const name = node.name;
2162
+ if (!name || name.type !== "JSXIdentifier") return null;
2163
+ const m = ATTR_NAME_RE.exec(String(name.name));
2164
+ if (!m) return null;
2165
+ return m[1] ?? "element";
2109
2166
  }
2110
- function collectJSXAncestry(content) {
2111
- if (!content.includes("data-uidex")) return [];
2112
- const out2 = [];
2113
- const ancestors = [];
2114
- const stack = [];
2115
- const N = content.length;
2116
- let i = 0;
2117
- let line = 1;
2118
- const advanceLines = (from, to) => {
2119
- for (let k = from; k < to; k++) {
2120
- if (content.charCodeAt(k) === 10) line++;
2121
- }
2122
- };
2123
- while (i < N) {
2124
- const c = content[i];
2125
- if (c === "\n") {
2126
- line++;
2127
- i++;
2128
- continue;
2129
- }
2130
- if (c === "/" && content[i + 1] === "/") {
2131
- while (i < N && content[i] !== "\n") i++;
2132
- continue;
2133
- }
2134
- if (c === "/" && content[i + 1] === "*") {
2135
- const end = content.indexOf("*/", i + 2);
2136
- const next = end === -1 ? N : end + 2;
2137
- advanceLines(i, next);
2138
- i = next;
2139
- continue;
2140
- }
2141
- if (c === '"' || c === "'") {
2142
- const next = skipString2(content, i, c);
2143
- advanceLines(i, next);
2144
- i = next;
2145
- continue;
2146
- }
2147
- if (c === "`") {
2148
- const next = skipTemplate(content, i);
2149
- advanceLines(i, next);
2150
- i = next;
2151
- continue;
2152
- }
2153
- if (c === "<") {
2154
- const nextCh = content[i + 1];
2155
- if (nextCh === "/") {
2156
- const end = content.indexOf(">", i);
2157
- if (end === -1) break;
2158
- const tagName = content.slice(i + 2, end).match(/^\s*([\w.-]*)/)?.[1] ?? "";
2159
- if (tagName) {
2160
- for (let k = stack.length - 1; k >= 0; k--) {
2161
- if (stack[k].tagName === tagName) {
2162
- for (let j = stack.length - 1; j >= k; j--) {
2163
- ancestors.length -= stack[j].pushed;
2164
- }
2165
- stack.length = k;
2166
- break;
2167
- }
2168
- }
2169
- }
2170
- advanceLines(i, end + 1);
2171
- i = end + 1;
2172
- continue;
2173
- }
2174
- if (nextCh && /[A-Za-z_]/.test(nextCh)) {
2175
- const end = findTagEnd(content, i + 1);
2176
- if (end === -1) break;
2177
- const tagSource = content.slice(i, end + 1);
2178
- const tagName = tagSource.match(/^<\s*([\w.-]*)/)?.[1] ?? "";
2179
- const isSelf = content[end - 1] === "/";
2180
- if (tagName) {
2181
- const attrs = parseDataAttrs(tagSource);
2182
- if (attrs.length > 0) {
2183
- const snapshot = ancestors.slice();
2184
- for (const a of attrs) {
2185
- out2.push({ kind: a.kind, id: a.id, line, ancestors: snapshot });
2186
- }
2187
- }
2188
- if (!isSelf) {
2189
- for (const a of attrs) ancestors.push(a);
2190
- stack.push({ tagName, pushed: attrs.length });
2191
- }
2192
- }
2193
- advanceLines(i, end + 1);
2194
- i = end + 1;
2167
+ function collectConstStrings(program) {
2168
+ const consts = /* @__PURE__ */ new Map();
2169
+ const seen = /* @__PURE__ */ new Set();
2170
+ walkAst(program, (node) => {
2171
+ if (node.type !== "VariableDeclaration" || node.kind !== "const") {
2172
+ return void 0;
2173
+ }
2174
+ for (const decl of node.declarations ?? []) {
2175
+ const id = decl.id;
2176
+ if (!id || id.type !== "Identifier") continue;
2177
+ const name = String(id.name);
2178
+ if (seen.has(name)) {
2179
+ consts.delete(name);
2195
2180
  continue;
2196
2181
  }
2182
+ seen.add(name);
2183
+ const init = decl.init;
2184
+ if (!init) continue;
2185
+ const value = staticString(unwrapTsExpression(init));
2186
+ if (value !== null) consts.set(name, value);
2197
2187
  }
2198
- i++;
2188
+ return void 0;
2189
+ });
2190
+ return consts;
2191
+ }
2192
+ function staticString(node) {
2193
+ if (node.type === "Literal" && typeof node.value === "string") {
2194
+ return node.value;
2199
2195
  }
2200
- return out2;
2196
+ if (node.type === "TemplateLiteral") {
2197
+ const expressions = node.expressions ?? [];
2198
+ if (expressions.length > 0) return null;
2199
+ const quasis = node.quasis ?? [];
2200
+ return quasis[0]?.value?.cooked ?? "";
2201
+ }
2202
+ return null;
2201
2203
  }
2202
- function skipString2(content, start, quote) {
2203
- const N = content.length;
2204
- let i = start + 1;
2205
- while (i < N) {
2206
- const c = content[i];
2207
- if (c === "\\") {
2208
- i += 2;
2209
- continue;
2204
+ var UNRESOLVED = { resolved: false };
2205
+ function evalIdExpression(expr, consts) {
2206
+ const node = unwrapTsExpression(expr);
2207
+ const literal = staticString(node);
2208
+ if (literal !== null) {
2209
+ return literal.length > 0 ? { resolved: true, ids: [literal] } : UNRESOLVED;
2210
+ }
2211
+ if (node.type === "TemplateLiteral") {
2212
+ const quasis = node.quasis ?? [];
2213
+ const expressions = node.expressions ?? [];
2214
+ let out2 = "";
2215
+ for (let i = 0; i < quasis.length; i++) {
2216
+ out2 += quasis[i].value?.cooked ?? "";
2217
+ if (i < expressions.length) {
2218
+ const part = evalIdExpression(expressions[i], consts);
2219
+ out2 += part.resolved && part.ids.length === 1 ? part.ids[0] : "*";
2220
+ }
2210
2221
  }
2211
- if (c === quote) return i + 1;
2212
- i++;
2222
+ out2 = out2.replace(/\*{2,}/g, "*");
2223
+ if (!out2.includes("*")) {
2224
+ return out2.length > 0 ? { resolved: true, ids: [out2] } : UNRESOLVED;
2225
+ }
2226
+ return out2.replace(/\*/g, "").length > 0 ? { resolved: true, ids: [out2] } : UNRESOLVED;
2227
+ }
2228
+ if (node.type === "Identifier") {
2229
+ const value = consts.get(String(node.name));
2230
+ return value !== void 0 && value.length > 0 ? { resolved: true, ids: [value] } : UNRESOLVED;
2213
2231
  }
2214
- return N;
2232
+ if (node.type === "ConditionalExpression") {
2233
+ const left = evalIdExpression(node.consequent, consts);
2234
+ const right = evalIdExpression(node.alternate, consts);
2235
+ if (!left.resolved || !right.resolved) return UNRESOLVED;
2236
+ return { resolved: true, ids: [.../* @__PURE__ */ new Set([...left.ids, ...right.ids])] };
2237
+ }
2238
+ return UNRESOLVED;
2215
2239
  }
2216
- function skipTemplate(content, start) {
2217
- const N = content.length;
2218
- let i = start + 1;
2219
- while (i < N) {
2220
- const c = content[i];
2221
- if (c === "\\") {
2222
- i += 2;
2223
- continue;
2224
- }
2225
- if (c === "`") return i + 1;
2226
- if (c === "$" && content[i + 1] === "{") {
2227
- i += 2;
2228
- let depth = 1;
2229
- while (i < N && depth > 0) {
2230
- const cj = content[i];
2231
- if (cj === '"' || cj === "'") {
2232
- i = skipString2(content, i, cj);
2233
- continue;
2240
+ function collectElementAttrs(opening, consts, dynamicAttrs, lineAt) {
2241
+ const statics = [];
2242
+ const patterns = [];
2243
+ const attributes = opening.attributes ?? [];
2244
+ for (const attr of attributes) {
2245
+ if (attr.type !== "JSXAttribute") continue;
2246
+ const kind = attrKind(attr);
2247
+ if (!kind) continue;
2248
+ const value = attr.value;
2249
+ if (!value) continue;
2250
+ let result2 = UNRESOLVED;
2251
+ let valueSpan;
2252
+ if (value.type === "Literal") {
2253
+ const v = staticString(value);
2254
+ result2 = v !== null && v.length > 0 ? { resolved: true, ids: [v] } : UNRESOLVED;
2255
+ if (result2.resolved) valueSpan = { start: value.start, end: value.end };
2256
+ } else if (value.type === "JSXExpressionContainer") {
2257
+ const expr = value.expression;
2258
+ if (expr && expr.type !== "JSXEmptyExpression") {
2259
+ result2 = evalIdExpression(expr, consts);
2260
+ const inner = unwrapTsExpression(expr);
2261
+ if (result2.resolved && staticString(inner) !== null) {
2262
+ valueSpan = { start: inner.start, end: inner.end };
2234
2263
  }
2235
- if (cj === "`") {
2236
- i = skipTemplate(content, i);
2237
- continue;
2238
- }
2239
- if (cj === "{") depth++;
2240
- else if (cj === "}") depth--;
2241
- i++;
2242
2264
  }
2243
- continue;
2265
+ if (!result2.resolved) {
2266
+ dynamicAttrs.push({
2267
+ kind,
2268
+ attrName: kind === "element" ? "data-uidex" : `data-uidex-${kind}`,
2269
+ line: lineAt(attr.start)
2270
+ });
2271
+ continue;
2272
+ }
2273
+ }
2274
+ if (!result2.resolved) continue;
2275
+ for (const id of result2.ids) {
2276
+ const resolved = {
2277
+ kind,
2278
+ id,
2279
+ start: attr.start,
2280
+ isPattern: id.includes("*"),
2281
+ // Only a single plain string literal is renameable in place.
2282
+ ...result2.ids.length === 1 && valueSpan ? { span: valueSpan } : {}
2283
+ };
2284
+ if (resolved.isPattern) patterns.push(resolved);
2285
+ else statics.push(resolved);
2244
2286
  }
2245
- i++;
2246
2287
  }
2247
- return N;
2288
+ return [...statics, ...patterns];
2248
2289
  }
2249
- function findTagEnd(content, start) {
2250
- const N = content.length;
2251
- let i = start;
2252
- while (i < N) {
2253
- const c = content[i];
2254
- if (c === '"' || c === "'") {
2255
- i = skipString2(content, i, c);
2256
- continue;
2257
- }
2258
- if (c === "`") {
2259
- i = skipTemplate(content, i);
2260
- continue;
2261
- }
2262
- if (c === "{") {
2263
- let depth = 1;
2264
- i++;
2265
- while (i < N && depth > 0) {
2266
- const cj = content[i];
2267
- if (cj === '"' || cj === "'") {
2268
- i = skipString2(content, i, cj);
2269
- continue;
2290
+ function collectJSXFacts(parsed) {
2291
+ const occurrences = [];
2292
+ const dynamicAttrs = [];
2293
+ const unannotatedInteractive = [];
2294
+ const landmarks = [];
2295
+ if (parsed.program === null) {
2296
+ return { occurrences, dynamicAttrs, unannotatedInteractive, landmarks };
2297
+ }
2298
+ const consts = collectConstStrings(parsed.program);
2299
+ const ancestors = [];
2300
+ const visit = (node) => {
2301
+ if (!isNode3(node)) return;
2302
+ if (node.type === "JSXElement") {
2303
+ const opening = node.openingElement;
2304
+ const attrs = collectElementAttrs(
2305
+ opening,
2306
+ consts,
2307
+ dynamicAttrs,
2308
+ parsed.lineAt
2309
+ );
2310
+ const interactive = readInteractive(node, parsed.lineAt);
2311
+ if (interactive) unannotatedInteractive.push(interactive);
2312
+ if (attrs.length > 0) {
2313
+ const snapshot = ancestors.slice();
2314
+ for (const a of attrs) {
2315
+ occurrences.push({
2316
+ kind: a.kind,
2317
+ id: a.id,
2318
+ line: parsed.lineAt(a.start),
2319
+ ancestors: snapshot,
2320
+ ...a.span ? { span: a.span } : {}
2321
+ });
2270
2322
  }
2271
- if (cj === "`") {
2272
- i = skipTemplate(content, i);
2273
- continue;
2323
+ }
2324
+ let pushed = attrs.length;
2325
+ for (const a of attrs) ancestors.push({ kind: a.kind, id: a.id });
2326
+ const landmark = readLandmark(opening, parsed.lineAt);
2327
+ if (landmark) {
2328
+ landmarks.push(landmark);
2329
+ if (!attrs.some((a) => a.kind === "region")) {
2330
+ ancestors.push({ kind: "region", id: landmark.tag });
2331
+ pushed++;
2274
2332
  }
2275
- if (cj === "{") depth++;
2276
- else if (cj === "}") depth--;
2277
- i++;
2278
2333
  }
2279
- continue;
2334
+ visitChildren(opening);
2335
+ for (const child of node.children ?? []) {
2336
+ visit(child);
2337
+ }
2338
+ const closing = node.closingElement;
2339
+ if (isNode3(closing)) visitChildren(closing);
2340
+ ancestors.length -= pushed;
2341
+ return;
2280
2342
  }
2281
- if (c === ">") return i;
2282
- i++;
2283
- }
2284
- return -1;
2343
+ visitChildren(node);
2344
+ };
2345
+ const visitChildren = (node) => {
2346
+ for (const key of Object.keys(node)) {
2347
+ if (key === "type" || key === "start" || key === "end") continue;
2348
+ const value = node[key];
2349
+ if (Array.isArray(value)) {
2350
+ for (const item of value) visit(item);
2351
+ } else {
2352
+ visit(value);
2353
+ }
2354
+ }
2355
+ };
2356
+ visit(parsed.program);
2357
+ return { occurrences, dynamicAttrs, unannotatedInteractive, landmarks };
2285
2358
  }
2286
-
2287
- // src/scanner/scan/extract.ts
2288
- var JSDOC_BLOCK = /\/\*\*([\s\S]*?)\*\//g;
2289
- function lineAt(content, index) {
2290
- let line = 1;
2291
- for (let i = 0; i < index && i < content.length; i++) {
2292
- if (content[i] === "\n") line++;
2359
+ function readLandmark(opening, lineAt) {
2360
+ const name = opening.name;
2361
+ if (!name || name.type !== "JSXIdentifier") return null;
2362
+ const tag = String(name.name);
2363
+ if (LANDMARK_TAGS.has(tag)) {
2364
+ return { tag, line: lineAt(opening.start) };
2365
+ }
2366
+ for (const attr of opening.attributes ?? []) {
2367
+ if (attr.type !== "JSXAttribute") continue;
2368
+ const attrName = attr.name;
2369
+ if (!attrName || String(attrName.name) !== "role") continue;
2370
+ const value = attr.value;
2371
+ if (value && value.type === "Literal" && value.value === "region") {
2372
+ return { tag: "region", line: lineAt(opening.start) };
2373
+ }
2293
2374
  }
2294
- return line;
2375
+ return null;
2295
2376
  }
2296
- function parseJSDoc(block) {
2297
- const lines = block.split("\n").map((l) => l.replace(/^\s*\*\s?/, "").replace(/^\s*\/?\*+/, ""));
2298
- let kind = null;
2299
- let id = null;
2300
- const acceptance = [];
2301
- const desc = [];
2302
- let notFlow = false;
2303
- for (const raw of lines) {
2304
- const line = raw.trim();
2305
- if (!line) continue;
2306
- const uidex = line.match(
2307
- /^@uidex\s+(page|feature|widget)\s+(\S+)(?:\s+-\s+(.+))?/
2308
- );
2309
- if (uidex) {
2310
- kind = uidex[1];
2311
- id = uidex[2];
2312
- if (uidex[3]) desc.push(uidex[3].trim());
2377
+ function readInteractive(element, lineAt) {
2378
+ const opening = element.openingElement;
2379
+ const name = opening.name;
2380
+ if (!name || name.type !== "JSXIdentifier") return null;
2381
+ const tag = String(name.name);
2382
+ if (!INTERACTIVE_TAGS.has(tag)) return null;
2383
+ let hasSpread = false;
2384
+ for (const attr of opening.attributes ?? []) {
2385
+ if (attr.type === "JSXSpreadAttribute") {
2386
+ hasSpread = true;
2313
2387
  continue;
2314
2388
  }
2315
- if (/^@uidex:not-flow\b/.test(line)) {
2316
- notFlow = true;
2317
- continue;
2389
+ if (attr.type === "JSXAttribute" && attrKind(attr) !== null) return null;
2390
+ }
2391
+ const nameHint = interactiveNameHint(element, opening);
2392
+ return {
2393
+ tag,
2394
+ line: lineAt(opening.start),
2395
+ hasSpread,
2396
+ nameEnd: name.end,
2397
+ ...nameHint ? { nameHint } : {}
2398
+ };
2399
+ }
2400
+ function staticAttrValue(opening, attrName) {
2401
+ for (const attr of opening.attributes ?? []) {
2402
+ if (attr.type !== "JSXAttribute") continue;
2403
+ const n = attr.name;
2404
+ if (!n || String(n.name) !== attrName) continue;
2405
+ const value = attr.value;
2406
+ if (!value) return null;
2407
+ if (value.type === "Literal") return staticString(value);
2408
+ if (value.type === "JSXExpressionContainer") {
2409
+ const expr = value.expression;
2410
+ return expr ? staticString(unwrapTsExpression(expr)) : null;
2318
2411
  }
2319
- const accept = line.match(/^@acceptance\s+(.+)$/);
2320
- if (accept) {
2321
- acceptance.push(accept[1].trim());
2322
- continue;
2412
+ return null;
2413
+ }
2414
+ return null;
2415
+ }
2416
+ function staticChildText(element) {
2417
+ const parts = [];
2418
+ for (const child of element.children ?? []) {
2419
+ if (child.type === "JSXText") {
2420
+ parts.push(String(child.value ?? ""));
2323
2421
  }
2324
- if (line.startsWith("@")) continue;
2325
- desc.push(line);
2326
2422
  }
2423
+ return parts.join(" ").replace(/\s+/g, " ").trim();
2424
+ }
2425
+ function interactiveNameHint(element, opening) {
2426
+ const ariaLabel = staticAttrValue(opening, "aria-label");
2427
+ if (ariaLabel) return ariaLabel;
2428
+ const text = staticChildText(element);
2429
+ if (text) return text;
2430
+ for (const attr of ["title", "name", "placeholder"]) {
2431
+ const v = staticAttrValue(opening, attr);
2432
+ if (v) return v;
2433
+ }
2434
+ return void 0;
2435
+ }
2436
+ function isNode3(value) {
2437
+ return typeof value === "object" && value !== null && typeof value.type === "string";
2438
+ }
2439
+
2440
+ // src/scanner/scan/extract.ts
2441
+ function parseFailureDiagnostic(file, parsed) {
2442
+ const fatal = parsed.program === null || parsed.hasErrors && parsed.program.body.length === 0;
2443
+ if (!fatal) return null;
2327
2444
  return {
2328
- kind,
2329
- id,
2330
- description: desc.join(" ").trim(),
2331
- acceptance,
2332
- notFlow
2445
+ code: "parse-error",
2446
+ severity: "warning",
2447
+ 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",
2448
+ file: file.displayPath,
2449
+ line: 1,
2450
+ hint: "Fix the file's syntax (or exclude it from .uidex.json sources) so the scanner can read its annotations"
2333
2451
  };
2334
2452
  }
2335
2453
  function extract(files) {
2336
2454
  return files.map((file) => {
2337
- const { exports: exports2, diagnostics } = extractUidexExports(file);
2338
- const out2 = {
2339
- file,
2340
- annotations: extractOne(file)
2341
- };
2455
+ const parsed = parseSource(file);
2456
+ const { exports: exports2, diagnostics } = extractUidexExports(file, parsed);
2457
+ const parseFailure = parseFailureDiagnostic(file, parsed);
2458
+ if (parseFailure) diagnostics.push(parseFailure);
2459
+ const out2 = { file, annotations: [] };
2460
+ out2.annotations = extractOne(file, parsed, out2);
2342
2461
  if (exports2.length > 0) out2.metadata = exports2;
2343
2462
  if (diagnostics.length > 0) out2.diagnostics = diagnostics;
2463
+ const flows = collectFlowFacts(parsed, file.content);
2464
+ if (flows.length > 0) out2.flows = flows;
2465
+ const imports = collectImportFacts(parsed);
2466
+ if (imports.length > 0) out2.imports = imports;
2344
2467
  return out2;
2345
2468
  });
2346
2469
  }
2347
- function extractOne(file) {
2470
+ function extractOne(file, parsed, out2) {
2348
2471
  const annotations = [];
2349
- const { content, displayPath } = file;
2350
- for (const occ of collectJSXAncestry(content)) {
2472
+ const { displayPath } = file;
2473
+ const jsx = collectJSXFacts(parsed);
2474
+ if (jsx.dynamicAttrs.length > 0) out2.dynamicAttrs = jsx.dynamicAttrs;
2475
+ if (jsx.unannotatedInteractive.length > 0) {
2476
+ out2.unannotatedInteractive = jsx.unannotatedInteractive;
2477
+ }
2478
+ if (jsx.landmarks.length > 0) out2.landmarks = jsx.landmarks;
2479
+ for (const occ of jsx.occurrences) {
2351
2480
  annotations.push({
2352
2481
  kind: occ.kind,
2353
2482
  id: occ.id,
2354
2483
  file: displayPath,
2355
2484
  line: occ.line,
2356
- ...occ.ancestors.length > 0 ? { ancestors: occ.ancestors } : {}
2485
+ ...occ.ancestors.length > 0 ? { ancestors: occ.ancestors } : {},
2486
+ ...occ.span ? { span: occ.span } : {}
2357
2487
  });
2358
2488
  }
2359
- JSDOC_BLOCK.lastIndex = 0;
2360
- let jm;
2361
- while ((jm = JSDOC_BLOCK.exec(content)) !== null) {
2362
- const parsed = parseJSDoc(jm[1]);
2363
- const line = lineAt(content, jm.index);
2364
- if (parsed.notFlow) {
2365
- annotations.push({ kind: "not-flow", id: "", file: displayPath, line });
2366
- }
2367
- if (parsed.kind && parsed.id) {
2368
- const kind = parsed.kind === "page" ? "page-doc" : parsed.kind === "feature" ? "feature-doc" : "widget-doc";
2369
- annotations.push({
2370
- kind,
2371
- id: parsed.id,
2372
- file: displayPath,
2373
- line,
2374
- description: parsed.description || void 0,
2375
- acceptance: parsed.acceptance.length ? parsed.acceptance : void 0
2376
- });
2377
- } else if (parsed.acceptance.length > 0) {
2378
- annotations.push({
2379
- kind: "orphan-acceptance",
2380
- id: "",
2381
- file: displayPath,
2382
- line,
2383
- acceptance: parsed.acceptance
2384
- });
2489
+ return annotations;
2490
+ }
2491
+ function collectImportFacts(parsed) {
2492
+ if (parsed.program === null) return [];
2493
+ const out2 = [];
2494
+ for (const stmt of parsed.program.body) {
2495
+ let source;
2496
+ let isTypeOnly = false;
2497
+ const names = [];
2498
+ if (stmt.type === "ImportDeclaration") {
2499
+ source = stmt.source;
2500
+ isTypeOnly = stmt.importKind === "type";
2501
+ for (const spec of stmt.specifiers ?? []) {
2502
+ const local = spec.local;
2503
+ if (local && local.type === "Identifier") {
2504
+ names.push(String(local.name));
2505
+ }
2506
+ }
2507
+ } else if ((stmt.type === "ExportNamedDeclaration" || stmt.type === "ExportAllDeclaration") && stmt.source) {
2508
+ source = stmt.source;
2509
+ isTypeOnly = stmt.exportKind === "type";
2510
+ } else {
2511
+ continue;
2385
2512
  }
2513
+ if (!source || source.type !== "Literal") continue;
2514
+ if (typeof source.value !== "string") continue;
2515
+ out2.push({
2516
+ specifier: source.value,
2517
+ line: parsed.lineAt(stmt.start),
2518
+ span: { start: stmt.start, end: stmt.end },
2519
+ isTypeOnly,
2520
+ names
2521
+ });
2386
2522
  }
2387
- return annotations;
2523
+ return out2;
2388
2524
  }
2389
2525
 
2390
2526
  // src/scanner/scan/git.ts
@@ -2420,7 +2556,7 @@ function parseGitHubRef(ref) {
2420
2556
  }
2421
2557
 
2422
2558
  // src/scanner/scan/resolve.ts
2423
- var path6 = __toESM(require("path"), 1);
2559
+ var path8 = __toESM(require("path"), 1);
2424
2560
 
2425
2561
  // src/scanner/scan/routes.ts
2426
2562
  var PAGE_BASENAME = /^page\.(tsx|ts|jsx|js|mjs|cjs)$/;
@@ -2489,8 +2625,8 @@ function pathToId(routePath) {
2489
2625
  }
2490
2626
 
2491
2627
  // src/scanner/scan/walk.ts
2492
- var fs4 = __toESM(require("fs"), 1);
2493
- var path5 = __toESM(require("path"), 1);
2628
+ var fs5 = __toESM(require("fs"), 1);
2629
+ var path7 = __toESM(require("path"), 1);
2494
2630
  var DEFAULT_INCLUDES = ["**/*.{ts,tsx,js,jsx,mjs,cjs}"];
2495
2631
  var BASE_EXCLUDES = [
2496
2632
  "**/node_modules/**",
@@ -2554,7 +2690,7 @@ function globToRegExp(glob) {
2554
2690
  return new RegExp(`^${out2}$`);
2555
2691
  }
2556
2692
  function toPosix(p2) {
2557
- return p2.split(path5.sep).join("/");
2693
+ return p2.split(path7.sep).join("/");
2558
2694
  }
2559
2695
  function matchesAny(rel, patterns) {
2560
2696
  return patterns.some((g) => globToRegExp(g).test(rel));
@@ -2570,18 +2706,18 @@ function walk(sources, options) {
2570
2706
  ...globalExcludes,
2571
2707
  ...source.exclude ?? []
2572
2708
  ];
2573
- const absRoot = path5.resolve(cwd, source.rootDir);
2709
+ const absRoot = path7.resolve(cwd, source.rootDir);
2574
2710
  for (const filePath of walkDir(absRoot, absRoot)) {
2575
- const rel = toPosix(path5.relative(absRoot, filePath));
2711
+ const rel = toPosix(path7.relative(absRoot, filePath));
2576
2712
  if (matchesAny(rel, excludes)) continue;
2577
2713
  if (!matchesAny(rel, includes)) continue;
2578
2714
  let content;
2579
2715
  try {
2580
- content = fs4.readFileSync(filePath, "utf8");
2716
+ content = fs5.readFileSync(filePath, "utf8");
2581
2717
  } catch {
2582
2718
  continue;
2583
2719
  }
2584
- const relFromCwd = toPosix(path5.relative(cwd, filePath));
2720
+ const relFromCwd = toPosix(path7.relative(cwd, filePath));
2585
2721
  const displayPath = source.prefix ? `${source.prefix.replace(/\/$/, "")}/${rel}` : relFromCwd;
2586
2722
  out2.push({
2587
2723
  sourcePath: filePath,
@@ -2596,12 +2732,12 @@ function walk(sources, options) {
2596
2732
  function* walkDir(root, dir) {
2597
2733
  let entries;
2598
2734
  try {
2599
- entries = fs4.readdirSync(dir, { withFileTypes: true });
2735
+ entries = fs5.readdirSync(dir, { withFileTypes: true });
2600
2736
  } catch {
2601
2737
  return;
2602
2738
  }
2603
2739
  for (const entry of entries) {
2604
- const full = path5.join(dir, entry.name);
2740
+ const full = path7.join(dir, entry.name);
2605
2741
  if (entry.isDirectory()) {
2606
2742
  if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git" || entry.name === "build" || entry.name === ".next") {
2607
2743
  continue;
@@ -2633,21 +2769,9 @@ function kebab(str) {
2633
2769
  return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").replace(/[^a-zA-Z0-9-]/g, "").toLowerCase();
2634
2770
  }
2635
2771
  function baseName(file) {
2636
- const b = path6.posix.basename(file);
2772
+ const b = path8.posix.basename(file);
2637
2773
  return b.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "");
2638
2774
  }
2639
- var LANDMARK_RE = /<(header|nav|main|aside|footer)(\s[^>]*)?>|role=["']region["']/gi;
2640
- function extractLandmarks(file) {
2641
- const out2 = [];
2642
- LANDMARK_RE.lastIndex = 0;
2643
- let m;
2644
- while ((m = LANDMARK_RE.exec(file.content)) !== null) {
2645
- const tag = m[1] ?? "region";
2646
- const line = 1 + file.content.slice(0, m.index).split("\n").length - 1;
2647
- out2.push({ tag, line });
2648
- }
2649
- return out2;
2650
- }
2651
2775
  function fileMatchesAny(displayPath, patterns) {
2652
2776
  return patterns.some((g) => globToRegExp(g).test(displayPath));
2653
2777
  }
@@ -2708,7 +2832,7 @@ function resolve3(ctx) {
2708
2832
  const routes = conventions.pages === "auto" ? detectRoutes(ctx.extracted.map((e) => e.file)) : [];
2709
2833
  const handledPageFiles = /* @__PURE__ */ new Set();
2710
2834
  for (const route of routes) {
2711
- const routeDir = path6.posix.dirname(route.file);
2835
+ const routeDir = path8.posix.dirname(route.file);
2712
2836
  const wellKnownPath = `${routeDir}/${WELL_KNOWN_FILES.page}`;
2713
2837
  const wellKnownExp = exportFor(wellKnownPath, "page");
2714
2838
  const routeExp = exportFor(route.file, "page");
@@ -2762,7 +2886,7 @@ function resolve3(ctx) {
2762
2886
  const dir = extractFeatureDir(ef.file.displayPath, featureGlob);
2763
2887
  if (!dir) continue;
2764
2888
  conventionalFeatureDirs.add(dir);
2765
- const isWellKnown = path6.posix.basename(ef.file.displayPath) === WELL_KNOWN_FILES.feature;
2889
+ const isWellKnown = path8.posix.basename(ef.file.displayPath) === WELL_KNOWN_FILES.feature;
2766
2890
  if (isWellKnown) wellKnownFeatureFileByDir.set(dir, ef.file.displayPath);
2767
2891
  const exp = exportFor(ef.file.displayPath, "feature");
2768
2892
  if (exp) {
@@ -2799,7 +2923,7 @@ function resolve3(ctx) {
2799
2923
  } else if (allExports.length > 0) {
2800
2924
  exp = allExports[0].exp;
2801
2925
  }
2802
- const id = exp && typeof exp.id === "string" ? exp.id : path6.posix.basename(dir);
2926
+ const id = exp && typeof exp.id === "string" ? exp.id : path8.posix.basename(dir);
2803
2927
  const meta = exp ? buildMetaFromExport(exp) : void 0;
2804
2928
  const feature = {
2805
2929
  kind: "feature",
@@ -2895,8 +3019,8 @@ function resolve3(ctx) {
2895
3019
  }
2896
3020
  if (conventions.regions === "landmarks") {
2897
3021
  for (const ef of ctx.extracted) {
2898
- for (const lm of extractLandmarks(ef.file)) {
2899
- const id = kebab(`${lm.tag}`);
3022
+ for (const lm of ef.landmarks ?? []) {
3023
+ const id = lm.tag;
2900
3024
  if (!registry.get("region", id)) {
2901
3025
  const meta = metaWithComposes("region", id);
2902
3026
  const region = {
@@ -3007,7 +3131,7 @@ function resolve3(ctx) {
3007
3131
  const flowExport = (ff.metadata ?? []).find(
3008
3132
  (m) => m.kind === "flow" && typeof m.id === "string"
3009
3133
  );
3010
- const derived = extractFlowsFromSource(ff.file);
3134
+ const derived = flowsFromFacts(ff);
3011
3135
  if (flowExport && typeof flowExport.id === "string" && derived.length === 1) {
3012
3136
  const base = derived[0];
3013
3137
  const flow = {
@@ -3062,52 +3186,14 @@ function computeScope(displayPath) {
3062
3186
  }
3063
3187
  return null;
3064
3188
  }
3065
- function extractFlowsFromSource(file) {
3066
- const flows = [];
3067
- const source = file.content;
3068
- const describeRe = /test\.describe\(\s*(?:'([^']*)'|"([^"]*)")\s*,\s*\{[^}]*tag:\s*(?:'@uidex:flow'|"@uidex:flow"|\[[^\]]*@uidex:flow[^\]]*\])[^}]*\}/g;
3069
- let m;
3070
- while ((m = describeRe.exec(source)) !== null) {
3071
- const title = m[1] ?? m[2];
3072
- const id = kebab(title);
3073
- const line = 1 + source.slice(0, m.index).split("\n").length - 1;
3074
- const after = source.slice(m.index + m[0].length);
3075
- const arrow = after.match(/=>\s*\{/);
3076
- if (!arrow || arrow.index === void 0) continue;
3077
- const bodyStart = m.index + m[0].length + arrow.index + arrow[0].length;
3078
- let depth = 1;
3079
- let bodyEnd = -1;
3080
- for (let i = bodyStart; i < source.length; i++) {
3081
- if (source[i] === "{") depth++;
3082
- else if (source[i] === "}") {
3083
- depth--;
3084
- if (depth === 0) {
3085
- bodyEnd = i;
3086
- break;
3087
- }
3088
- }
3089
- }
3090
- if (bodyEnd === -1) continue;
3091
- const body = source.slice(bodyStart, bodyEnd);
3092
- const touches = captureUidexIds(body);
3093
- flows.push({
3094
- kind: "flow",
3095
- id,
3096
- loc: { file: file.displayPath, line },
3097
- touches: dedupe(touches.map((t) => t.id)),
3098
- steps: touches.filter((t) => t.action).map((t) => ({ entityId: t.id, action: t.action }))
3099
- });
3100
- }
3101
- return flows;
3102
- }
3103
- function captureUidexIds(body) {
3104
- const out2 = [];
3105
- const re = /uidex\(\s*(?:'([^']+)'|"([^"]+)"|`([^`$]+)`)\s*\)(?:\.(\w+)\s*\()?/g;
3106
- let m;
3107
- while ((m = re.exec(body)) !== null) {
3108
- out2.push({ id: m[1] || m[2] || m[3], action: m[4] });
3109
- }
3110
- return out2;
3189
+ function flowsFromFacts(ff) {
3190
+ return (ff.flows ?? []).map((fact) => ({
3191
+ kind: "flow",
3192
+ id: kebab(fact.title),
3193
+ loc: { file: ff.file.displayPath, line: fact.line },
3194
+ touches: dedupe(fact.calls.map((c) => c.id)),
3195
+ steps: fact.calls.filter((c) => c.action).map((c) => ({ entityId: c.id, action: c.action }))
3196
+ }));
3111
3197
  }
3112
3198
  function dedupe(arr) {
3113
3199
  return Array.from(new Set(arr));
@@ -3142,25 +3228,28 @@ function runOne(dc, opts) {
3142
3228
  const gitContext = resolveGitContext({ cwd: configDir });
3143
3229
  const generated = emit({
3144
3230
  registry: resolved.registry,
3145
- gitContext,
3146
- typeMode: config.typeMode
3231
+ gitContext
3147
3232
  });
3148
- const outputPath = path7.resolve(configDir, config.output);
3233
+ const outputPath = path9.resolve(configDir, config.output);
3149
3234
  const outputRel = config.output;
3150
3235
  let existingOnDisk = null;
3151
3236
  if (opts.check) {
3152
3237
  try {
3153
- existingOnDisk = fs5.readFileSync(outputPath, "utf8");
3238
+ existingOnDisk = fs6.readFileSync(outputPath, "utf8");
3154
3239
  } catch {
3155
3240
  existingOnDisk = null;
3156
3241
  }
3157
3242
  }
3243
+ const hasExtractDiagnostics = [...extracted, ...extractedFlows].some(
3244
+ (ef) => (ef.diagnostics?.length ?? 0) > 0
3245
+ );
3158
3246
  let auditResult;
3159
- if (opts.check || opts.lint || resolved.diagnostics.length > 0) {
3247
+ if (opts.check || opts.lint || resolved.diagnostics.length > 0 || hasExtractDiagnostics) {
3160
3248
  auditResult = audit({
3161
3249
  registry: resolved.registry,
3162
3250
  extracted,
3163
3251
  files: sourceFiles,
3252
+ flowExtracted: extractedFlows,
3164
3253
  config,
3165
3254
  check: opts.check,
3166
3255
  lint: opts.lint,
@@ -3181,29 +3270,185 @@ function runOne(dc, opts) {
3181
3270
  };
3182
3271
  }
3183
3272
  function writeScanResult(result2) {
3184
- fs5.mkdirSync(path7.dirname(result2.outputPath), { recursive: true });
3185
- fs5.writeFileSync(result2.outputPath, result2.generated, "utf8");
3273
+ fs6.mkdirSync(path9.dirname(result2.outputPath), { recursive: true });
3274
+ fs6.writeFileSync(result2.outputPath, result2.generated, "utf8");
3275
+ }
3276
+
3277
+ // src/scanner/scan/rename.ts
3278
+ var ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
3279
+ function renameEntity(opts) {
3280
+ const { cwd, kind, oldId, newId, force = false } = opts;
3281
+ const manual = [];
3282
+ const errors = [];
3283
+ if (!ID_RE.test(newId)) {
3284
+ return {
3285
+ edits: 0,
3286
+ manual,
3287
+ errors: [`New id "${newId}" is not kebab-case`]
3288
+ };
3289
+ }
3290
+ const configs = discover({ cwd });
3291
+ if (configs.length === 0) {
3292
+ return { edits: 0, manual, errors: [`No .uidex.json found under ${cwd}`] };
3293
+ }
3294
+ const edits = [];
3295
+ for (const dc of configs) {
3296
+ const { config, configDir } = dc;
3297
+ const sourceFiles = walk(config.sources, {
3298
+ cwd: configDir,
3299
+ globalExcludes: config.exclude
3300
+ });
3301
+ const extracted = extract(sourceFiles);
3302
+ const flowFiles = config.flows ? walk(
3303
+ config.flows.map((glob) => ({ rootDir: ".", include: [glob] })),
3304
+ { cwd: configDir, includeTests: true }
3305
+ ) : [];
3306
+ const extractedFlows = extract(flowFiles);
3307
+ const scan = runScan({ cwd: configDir, configs: [dc] })[0];
3308
+ const registry = scan.registry;
3309
+ if (!registry.get(kind, oldId)) {
3310
+ if (registry.matchPattern(kind, oldId)) {
3311
+ errors.push(
3312
+ `${kind} "${oldId}" only matches via a pattern id; pattern-backed ids cannot be renamed mechanically`
3313
+ );
3314
+ } else {
3315
+ errors.push(`${kind} "${oldId}" not found in registry`);
3316
+ }
3317
+ continue;
3318
+ }
3319
+ if (registry.get(kind, newId) && !force) {
3320
+ errors.push(
3321
+ `${kind} "${newId}" already exists; pass --force to merge the ids`
3322
+ );
3323
+ continue;
3324
+ }
3325
+ const quoteAs = (content, start) => {
3326
+ const q = content[start];
3327
+ return q === '"' || q === "'" || q === "`" ? `${q}${newId}${q}` : `"${newId}"`;
3328
+ };
3329
+ for (const ef of extracted) {
3330
+ for (const a of ef.annotations) {
3331
+ if (a.kind !== kind || a.id !== oldId) continue;
3332
+ if (a.span) {
3333
+ edits.push({
3334
+ path: ef.file.sourcePath,
3335
+ start: a.span.start,
3336
+ end: a.span.end,
3337
+ replacement: quoteAs(ef.file.content, a.span.start)
3338
+ });
3339
+ } else {
3340
+ manual.push({
3341
+ file: a.file,
3342
+ line: a.line,
3343
+ reason: "attribute value is not a plain string literal (const reference, ternary, or template)"
3344
+ });
3345
+ }
3346
+ }
3347
+ for (const m of ef.metadata ?? []) {
3348
+ if (kind === "widget" && m.kind === "widget" && m.id === oldId) {
3349
+ if (m.idSpan) {
3350
+ edits.push({
3351
+ path: ef.file.sourcePath,
3352
+ start: m.idSpan.start,
3353
+ end: m.idSpan.end,
3354
+ replacement: quoteAs(ef.file.content, m.idSpan.start)
3355
+ });
3356
+ } else {
3357
+ manual.push({
3358
+ file: ef.file.displayPath,
3359
+ line: m.loc.line ?? 1,
3360
+ reason: "widget export id is not a plain string literal"
3361
+ });
3362
+ }
3363
+ }
3364
+ if (kind === "widget" && m.widgets) {
3365
+ for (let i = 0; i < m.widgets.length; i++) {
3366
+ if (m.widgets[i] !== oldId) continue;
3367
+ const span = m.widgetSpans?.[i];
3368
+ if (span) {
3369
+ edits.push({
3370
+ path: ef.file.sourcePath,
3371
+ start: span.start,
3372
+ end: span.end,
3373
+ replacement: quoteAs(ef.file.content, span.start)
3374
+ });
3375
+ }
3376
+ }
3377
+ }
3378
+ }
3379
+ }
3380
+ for (const ef of extractedFlows) {
3381
+ for (const fact of ef.flows ?? []) {
3382
+ for (const call of fact.calls) {
3383
+ if (call.id !== oldId) continue;
3384
+ if (call.span) {
3385
+ edits.push({
3386
+ path: ef.file.sourcePath,
3387
+ start: call.span.start,
3388
+ end: call.span.end,
3389
+ replacement: quoteAs(ef.file.content, call.span.start)
3390
+ });
3391
+ } else {
3392
+ manual.push({
3393
+ file: ef.file.displayPath,
3394
+ line: call.line,
3395
+ reason: "uidex() argument is not a plain string literal"
3396
+ });
3397
+ }
3398
+ }
3399
+ }
3400
+ }
3401
+ }
3402
+ if (errors.length > 0) {
3403
+ return { edits: 0, manual, errors };
3404
+ }
3405
+ if (edits.length === 0 && manual.length === 0) {
3406
+ return {
3407
+ edits: 0,
3408
+ manual,
3409
+ errors: [
3410
+ `${kind} "${oldId}" has no editable occurrences (convention-derived ids like landmarks cannot be renamed)`
3411
+ ]
3412
+ };
3413
+ }
3414
+ const result2 = applyFixes([
3415
+ {
3416
+ code: "rename",
3417
+ severity: "info",
3418
+ message: "",
3419
+ fix: {
3420
+ description: `Rename ${kind} "${oldId}" to "${newId}"`,
3421
+ edits
3422
+ }
3423
+ }
3424
+ ]);
3425
+ if (result2.skipped.length > 0) {
3426
+ errors.push(`Some edits were skipped: ${result2.skipped[0].reason}`);
3427
+ }
3428
+ for (const r of runScan({ cwd })) writeScanResult(r);
3429
+ return { edits: edits.length, manual, errors };
3186
3430
  }
3187
3431
 
3188
3432
  // src/scanner/scan/scaffold.ts
3189
- var fs6 = __toESM(require("fs"), 1);
3190
- var path8 = __toESM(require("path"), 1);
3191
- function scaffoldWidgetSpec(opts) {
3433
+ var fs7 = __toESM(require("fs"), 1);
3434
+ var path10 = __toESM(require("path"), 1);
3435
+ function scaffoldSpec(opts) {
3192
3436
  const {
3193
3437
  registry,
3194
- widgetId,
3438
+ kind,
3439
+ id,
3195
3440
  outDir,
3196
3441
  force = false,
3197
3442
  fixtureImport = "./fixtures"
3198
3443
  } = opts;
3199
- const widget = registry.get("widget", widgetId);
3200
- if (!widget) {
3201
- throw new Error(`Widget "${widgetId}" not found in registry`);
3202
- }
3203
- const criteria = widget.meta?.acceptance ?? [];
3204
- const filename = `widget-${widgetId}.spec.ts`;
3205
- const outputPath = path8.resolve(outDir, filename);
3206
- if (fs6.existsSync(outputPath) && !force) {
3444
+ const entity = registry.get(kind, id);
3445
+ if (!entity) {
3446
+ throw new Error(`${capitalize(kind)} "${id}" not found in registry`);
3447
+ }
3448
+ const criteria = entity.meta?.acceptance ?? [];
3449
+ const filename = kind === "widget" ? `widget-${id}.spec.ts` : `flow-${id}.spec.ts`;
3450
+ const outputPath = path10.resolve(outDir, filename);
3451
+ if (fs7.existsSync(outputPath) && !force) {
3207
3452
  return {
3208
3453
  outputPath,
3209
3454
  written: false,
@@ -3211,15 +3456,14 @@ function scaffoldWidgetSpec(opts) {
3211
3456
  reason: `spec already exists at ${outputPath}; pass --force to overwrite`
3212
3457
  };
3213
3458
  }
3214
- const content = renderSpec({
3215
- widgetId,
3216
- criteria,
3217
- fixtureImport
3218
- });
3219
- fs6.mkdirSync(path8.dirname(outputPath), { recursive: true });
3220
- fs6.writeFileSync(outputPath, content, "utf8");
3459
+ const content = renderSpec({ id, criteria, fixtureImport });
3460
+ fs7.mkdirSync(path10.dirname(outputPath), { recursive: true });
3461
+ fs7.writeFileSync(outputPath, content, "utf8");
3221
3462
  return { outputPath, written: true, skipped: false };
3222
3463
  }
3464
+ function capitalize(s) {
3465
+ return s.charAt(0).toUpperCase() + s.slice(1);
3466
+ }
3223
3467
  function renderSpec(args) {
3224
3468
  const lines = [];
3225
3469
  lines.push(
@@ -3227,7 +3471,7 @@ function renderSpec(args) {
3227
3471
  );
3228
3472
  lines.push("");
3229
3473
  lines.push(
3230
- `test.describe(${JSON.stringify(args.widgetId)}, { tag: "@uidex:flow" }, () => {`
3474
+ `test.describe(${JSON.stringify(args.id)}, { tag: "@uidex:flow" }, () => {`
3231
3475
  );
3232
3476
  if (args.criteria.length === 0) {
3233
3477
  lines.push(` test("TODO: add acceptance criteria", async () => {`);
@@ -3293,6 +3537,8 @@ async function run(opts) {
3293
3537
  return runScanCommand(cwd, flags, writer);
3294
3538
  case "scaffold":
3295
3539
  return runScaffold(cwd, positional.slice(1), flags, writer);
3540
+ case "rename":
3541
+ return runRename(cwd, positional.slice(1), flags, writer);
3296
3542
  case "ai": {
3297
3543
  const result2 = await runAiCommand({
3298
3544
  cwd,
@@ -3319,7 +3565,8 @@ function helpText2() {
3319
3565
  "Commands:",
3320
3566
  " init Create a .uidex.json",
3321
3567
  " scan [flags] Run the scanner pipeline",
3322
- " scaffold widget <id> Emit a Playwright spec from a widget's acceptance",
3568
+ " scaffold <widget|page|feature> <id> Emit a Playwright spec from declared acceptance",
3569
+ " rename <element|widget|region> <old-id> <new-id> Rename an id everywhere (DOM attr, flows, exports)",
3323
3570
  " ai <install|uninstall|providers> Manage AI assistant integrations",
3324
3571
  " api <METHOD> <PATH> Call the uidex API",
3325
3572
  " api --list Show available API routes",
@@ -3328,16 +3575,17 @@ function helpText2() {
3328
3575
  "",
3329
3576
  "Flags:",
3330
3577
  " --check Verify the on-disk gen file matches a fresh scan; exit non-zero on drift (read-only)",
3331
- " --lint Run lint diagnostics (missing annotations, scope leak, legacy JSDoc)",
3578
+ " --lint Run lint diagnostics (missing annotations, scope leak, duplicate ids, coverage)",
3332
3579
  " --audit Equivalent to --check --lint (read-only)",
3580
+ " --fix Apply machine-generated fixes (add data-uidex to unannotated interactive elements, drop empty names), then rescan and write",
3333
3581
  " --json Emit JSON diagnostics on stdout",
3334
3582
  " --force (scaffold) overwrite existing spec",
3335
3583
  ""
3336
3584
  ].join("\n");
3337
3585
  }
3338
3586
  function runInit(cwd, w) {
3339
- const configPath = path9.join(cwd, CONFIG_FILENAME);
3340
- if (fs7.existsSync(configPath)) {
3587
+ const configPath = path11.join(cwd, CONFIG_FILENAME);
3588
+ if (fs8.existsSync(configPath)) {
3341
3589
  w.err(`.uidex.json already exists at ${configPath}`);
3342
3590
  return w.result(1);
3343
3591
  }
@@ -3346,16 +3594,16 @@ function runInit(cwd, w) {
3346
3594
  sources: [{ rootDir: "src" }],
3347
3595
  output: "src/uidex.gen.ts"
3348
3596
  };
3349
- fs7.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
3597
+ fs8.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
3350
3598
  w.out(`Created ${configPath}`);
3351
- const gitignorePath = path9.join(cwd, ".gitignore");
3599
+ const gitignorePath = path11.join(cwd, ".gitignore");
3352
3600
  const entry = "*.gen.ts";
3353
- if (fs7.existsSync(gitignorePath)) {
3354
- const existing = fs7.readFileSync(gitignorePath, "utf8");
3601
+ if (fs8.existsSync(gitignorePath)) {
3602
+ const existing = fs8.readFileSync(gitignorePath, "utf8");
3355
3603
  const hasEntry = existing.split("\n").some((line) => line.trim() === entry);
3356
3604
  if (!hasEntry) {
3357
3605
  const needsNewline = existing.length > 0 && !existing.endsWith("\n");
3358
- fs7.appendFileSync(
3606
+ fs8.appendFileSync(
3359
3607
  gitignorePath,
3360
3608
  `${needsNewline ? "\n" : ""}${entry}
3361
3609
  `,
@@ -3364,21 +3612,33 @@ function runInit(cwd, w) {
3364
3612
  w.out(`Appended ${entry} to ${gitignorePath}`);
3365
3613
  }
3366
3614
  } else {
3367
- fs7.writeFileSync(gitignorePath, `${entry}
3615
+ fs8.writeFileSync(gitignorePath, `${entry}
3368
3616
  `, "utf8");
3369
3617
  w.out(`Created ${gitignorePath} with ${entry}`);
3370
3618
  }
3371
3619
  return w.result(0);
3372
3620
  }
3373
3621
  function runScanCommand(cwd, flags, w) {
3374
- const check = Boolean(flags.check || flags.audit);
3375
- const lint = Boolean(flags.lint || flags.audit);
3622
+ const fix = Boolean(flags.fix);
3623
+ const check = !fix && Boolean(flags.check || flags.audit);
3624
+ const lint = Boolean(flags.lint || flags.audit || fix);
3376
3625
  const asJson = Boolean(flags.json);
3377
- const configs = discover({ cwd });
3626
+ let configs = discover({ cwd });
3378
3627
  if (configs.length === 0) {
3379
3628
  w.err(`No ${CONFIG_FILENAME} found under ${cwd}`);
3380
3629
  return w.result(1);
3381
3630
  }
3631
+ let fixed = [];
3632
+ let fixSkipped = [];
3633
+ if (fix) {
3634
+ const discovery = runScan({ cwd, check: true, lint: true, configs });
3635
+ const result2 = applyFixes(
3636
+ discovery.flatMap((r) => r.audit?.diagnostics ?? [])
3637
+ );
3638
+ fixed = result2.applied;
3639
+ fixSkipped = result2.skipped;
3640
+ configs = discover({ cwd });
3641
+ }
3382
3642
  const results = runScan({ cwd, check, lint, configs });
3383
3643
  if (!check) {
3384
3644
  for (const r of results) writeScanResult(r);
@@ -3393,9 +3653,21 @@ function runScanCommand(cwd, flags, w) {
3393
3653
  { errors: 0, warnings: 0 }
3394
3654
  );
3395
3655
  if (asJson) {
3396
- const out2 = { diagnostics: allDiagnostics, summary };
3656
+ const out2 = {
3657
+ diagnostics: allDiagnostics.map(jsonDiagnostic),
3658
+ summary,
3659
+ ...fix ? { fixed, fixSkipped } : {}
3660
+ };
3397
3661
  w.out(JSON.stringify(out2, null, 2));
3398
3662
  } else {
3663
+ for (const f of fixed) {
3664
+ w.out(`FIXED [${f.code}] ${f.file ?? ""} ${f.description}`);
3665
+ }
3666
+ for (const s of fixSkipped) {
3667
+ w.out(
3668
+ `SKIPPED [${s.code}] ${s.file ?? ""} ${s.description} (${s.reason})`
3669
+ );
3670
+ }
3399
3671
  for (const r of results) {
3400
3672
  if (check) {
3401
3673
  w.out(`Checked ${r.outputPath}`);
@@ -3405,7 +3677,10 @@ function runScanCommand(cwd, flags, w) {
3405
3677
  for (const d of r.audit?.diagnostics ?? []) {
3406
3678
  const loc = d.file ? `${d.file}${d.line ? `:${d.line}` : ""}` : "";
3407
3679
  const stream = d.severity === "error" ? w.err : w.out;
3408
- stream(`${d.severity.toUpperCase()} [${d.code}] ${loc} ${d.message}`);
3680
+ const fixable = d.fix && !fix ? " [fixable: run with --fix]" : "";
3681
+ stream(
3682
+ `${d.severity.toUpperCase()} [${d.code}] ${loc} ${d.message}${fixable}`
3683
+ );
3409
3684
  if (d.hint) stream(` hint: ${d.hint}`);
3410
3685
  }
3411
3686
  }
@@ -3416,20 +3691,27 @@ function runScanCommand(cwd, flags, w) {
3416
3691
  const exit = summary.errors > 0 ? 1 : 0;
3417
3692
  return w.result(exit);
3418
3693
  }
3694
+ function jsonDiagnostic(d) {
3695
+ const { fix, ...rest } = d;
3696
+ return fix ? { ...rest, fixable: true } : rest;
3697
+ }
3698
+ var SCAFFOLD_KINDS = /* @__PURE__ */ new Set(["widget", "page", "feature"]);
3419
3699
  function runScaffold(cwd, args, flags, w) {
3420
3700
  const [kind, id] = args;
3421
- if (kind !== "widget" || !id) {
3422
- w.err("Usage: uidex scaffold widget <id> [--force]");
3701
+ if (!kind || !SCAFFOLD_KINDS.has(kind) || !id) {
3702
+ w.err("Usage: uidex scaffold <widget|page|feature> <id> [--force]");
3423
3703
  return w.result(1);
3424
3704
  }
3705
+ const scaffoldKind = kind;
3425
3706
  const results = runScan({ cwd });
3426
3707
  for (const r of results) {
3427
- const widget = r.registry.get("widget", id);
3428
- if (!widget) continue;
3429
- const outDir = path9.resolve(r.configDir, "e2e");
3430
- const result2 = scaffoldWidgetSpec({
3708
+ const entity = r.registry.get(scaffoldKind, id);
3709
+ if (!entity) continue;
3710
+ const outDir = path11.resolve(r.configDir, "e2e");
3711
+ const result2 = scaffoldSpec({
3431
3712
  registry: r.registry,
3432
- widgetId: id,
3713
+ kind: scaffoldKind,
3714
+ id,
3433
3715
  outDir,
3434
3716
  force: Boolean(flags.force)
3435
3717
  });
@@ -3440,9 +3722,43 @@ function runScaffold(cwd, args, flags, w) {
3440
3722
  w.out(`Wrote ${result2.outputPath}`);
3441
3723
  return w.result(0);
3442
3724
  }
3443
- w.err(`Widget "${id}" not found in registry`);
3725
+ w.err(
3726
+ `${scaffoldKind.charAt(0).toUpperCase() + scaffoldKind.slice(1)} "${id}" not found in registry`
3727
+ );
3444
3728
  return w.result(1);
3445
3729
  }
3730
+ var RENAME_KINDS = /* @__PURE__ */ new Set(["element", "widget", "region"]);
3731
+ function runRename(cwd, args, flags, w) {
3732
+ const [kind, oldId, newId] = args;
3733
+ if (!kind || !RENAME_KINDS.has(kind) || !oldId || !newId) {
3734
+ w.err(
3735
+ "Usage: uidex rename <element|widget|region> <old-id> <new-id> [--force]"
3736
+ );
3737
+ return w.result(1);
3738
+ }
3739
+ const result2 = renameEntity({
3740
+ cwd,
3741
+ kind,
3742
+ oldId,
3743
+ newId,
3744
+ force: Boolean(flags.force)
3745
+ });
3746
+ for (const e of result2.errors) w.err(e);
3747
+ for (const m of result2.manual) {
3748
+ w.err(`MANUAL ${m.file}:${m.line} \u2014 ${m.reason}`);
3749
+ }
3750
+ if (result2.errors.length > 0) return w.result(1);
3751
+ w.out(
3752
+ `Renamed ${kind} "${oldId}" \u2192 "${newId}" (${result2.edits} edit(s)); gen file regenerated`
3753
+ );
3754
+ if (result2.manual.length > 0) {
3755
+ w.err(
3756
+ `${result2.manual.length} occurrence(s) need manual follow-up (listed above)`
3757
+ );
3758
+ return w.result(1);
3759
+ }
3760
+ return w.result(0);
3761
+ }
3446
3762
  function createWriter() {
3447
3763
  let stdout = "";
3448
3764
  let stderr = "";
@@ -3502,15 +3818,15 @@ function createFileTokenStorage(options) {
3502
3818
  }
3503
3819
  function defaultTokenPath() {
3504
3820
  const os = require("os");
3505
- const path10 = require("path");
3506
- return path10.join(os.homedir(), ".uidex", "cloud-token.json");
3821
+ const path12 = require("path");
3822
+ return path12.join(os.homedir(), ".uidex", "cloud-token.json");
3507
3823
  }
3508
3824
 
3509
3825
  // src/scanner/cli/http.ts
3510
3826
  function createHttpClient(options) {
3511
3827
  const baseUrl = options.baseUrl.replace(/\/$/, "");
3512
- async function request(path10, opts = {}) {
3513
- let url = `${baseUrl}${path10.startsWith("/") ? path10 : `/${path10}`}`;
3828
+ async function request(path12, opts = {}) {
3829
+ let url = `${baseUrl}${path12.startsWith("/") ? path12 : `/${path12}`}`;
3514
3830
  if (opts.query) {
3515
3831
  url += (url.includes("?") ? "&" : "?") + opts.query;
3516
3832
  }
@@ -3898,9 +4214,9 @@ var API_ROUTES = [
3898
4214
  },
3899
4215
  {
3900
4216
  "method": "POST",
3901
- "path": "/api/organizations/{orgId}/projects/{projectId}/reports/archive",
3902
- "operationId": "bulkArchiveReport",
3903
- "summary": "Archive multiple feedback records.",
4217
+ "path": "/api/organizations/{orgId}/projects/{projectId}/reports/bulk-status",
4218
+ "operationId": "bulkUpdateReportStatus",
4219
+ "summary": "Update status on multiple feedback records.",
3904
4220
  "tag": "Reports",
3905
4221
  "params": [
3906
4222
  "orgId",
@@ -4122,17 +4438,17 @@ async function runApiCommand(opts) {
4122
4438
  if (sub === "login") return runLogin(ctx);
4123
4439
  if (sub === "status") return runStatus(ctx);
4124
4440
  const method = sub?.toUpperCase();
4125
- const path10 = positional[1];
4441
+ const path12 = positional[1];
4126
4442
  if (!method || !METHODS.has(method)) {
4127
4443
  stderr.push(`Unknown command or method: ${sub}`);
4128
4444
  stderr.push(HELP);
4129
4445
  return result(1, stdout, stderr);
4130
4446
  }
4131
- if (!path10) {
4447
+ if (!path12) {
4132
4448
  stderr.push("Missing path. Usage: uidex api GET /api/organizations");
4133
4449
  return result(1, stdout, stderr);
4134
4450
  }
4135
- return runRequest(ctx, method, path10);
4451
+ return runRequest(ctx, method, path12);
4136
4452
  }
4137
4453
  function listRoutes(ctx) {
4138
4454
  const { flags, color, stdout, stderr } = ctx;
@@ -4171,7 +4487,7 @@ function listRoutes(ctx) {
4171
4487
  return result(0, stdout, stderr);
4172
4488
  }
4173
4489
  async function runLogin(ctx) {
4174
- const { flags, opts, color, stdout, stderr } = ctx;
4490
+ const { flags, opts, stdout, stderr } = ctx;
4175
4491
  const token = flags.token;
4176
4492
  if (typeof token === "string" && token.length > 0) {
4177
4493
  const storage = resolveTokenStorage(opts);
@@ -4251,7 +4567,7 @@ async function runBrowserLogin(ctx) {
4251
4567
  stdout.push("");
4252
4568
  process.stdout.write(result(0, stdout, stderr).stdout);
4253
4569
  stdout.length = 0;
4254
- openBrowser(authUrl);
4570
+ openBrowser(authUrl, exec);
4255
4571
  });
4256
4572
  server.on("error", (err2) => {
4257
4573
  stderr.push(`Failed to start local server: ${err2.message}`);
@@ -4259,8 +4575,7 @@ async function runBrowserLogin(ctx) {
4259
4575
  });
4260
4576
  });
4261
4577
  }
4262
- function openBrowser(url) {
4263
- const { exec } = require("child_process");
4578
+ function openBrowser(url, exec) {
4264
4579
  const platform = process.platform;
4265
4580
  const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
4266
4581
  exec(`${cmd} ${JSON.stringify(url)}`);
@@ -4274,7 +4589,7 @@ function runStatus(ctx) {
4274
4589
  stdout.push(`auth: ${token ? "authenticated" : "not authenticated"}`);
4275
4590
  return result(token ? 0 : 1, stdout, stderr);
4276
4591
  }
4277
- async function runRequest(ctx, method, path10) {
4592
+ async function runRequest(ctx, method, path12) {
4278
4593
  const { flags, opts, color, stdout, stderr } = ctx;
4279
4594
  const tokenFromFlag = flags.token;
4280
4595
  const token = typeof tokenFromFlag === "string" ? tokenFromFlag : resolveTokenStorage(opts).get();
@@ -4293,7 +4608,7 @@ async function runRequest(ctx, method, path10) {
4293
4608
  return result(1, stdout, stderr);
4294
4609
  }
4295
4610
  }
4296
- const res = await http.request(path10, {
4611
+ const res = await http.request(path12, {
4297
4612
  method,
4298
4613
  body: bodyStr,
4299
4614
  query: typeof flags.query === "string" ? flags.query : void 0