uidex 0.6.0 → 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 +1510 -1244
  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 +82 -255
  11. package/dist/headless/index.cjs.map +1 -1
  12. package/dist/headless/index.d.cts +5 -11
  13. package/dist/headless/index.d.ts +5 -11
  14. package/dist/headless/index.js +82 -257
  15. package/dist/headless/index.js.map +1 -1
  16. package/dist/index.cjs +721 -1053
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +149 -160
  19. package/dist/index.d.ts +149 -160
  20. package/dist/index.js +741 -1068
  21. package/dist/index.js.map +1 -1
  22. package/dist/react/index.cjs +729 -1000
  23. package/dist/react/index.cjs.map +1 -1
  24. package/dist/react/index.d.cts +99 -86
  25. package/dist/react/index.d.ts +99 -86
  26. package/dist/react/index.js +745 -1015
  27. package/dist/react/index.js.map +1 -1
  28. package/dist/scan/index.cjs +1518 -1237
  29. package/dist/scan/index.cjs.map +1 -1
  30. package/dist/scan/index.d.cts +209 -12
  31. package/dist/scan/index.d.ts +209 -12
  32. package/dist/scan/index.js +1515 -1236
  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 = [
@@ -553,13 +671,14 @@ function createRegistry() {
553
671
  };
554
672
  const getPatternsForKind = (kind) => {
555
673
  const cached = patternCache.get(kind);
556
- if (cached !== void 0)
557
- return cached;
674
+ if (cached !== void 0) return cached;
558
675
  const patterns = [];
559
676
  for (const [key, entity] of store[kind]) {
560
- if (key.endsWith("*")) {
677
+ if (key.includes("*")) {
678
+ const segments = key.split("*");
561
679
  patterns.push({
562
- prefix: key.slice(0, -1),
680
+ segments,
681
+ staticLength: segments.reduce((n, s) => n + s.length, 0),
563
682
  entity
564
683
  });
565
684
  }
@@ -570,13 +689,25 @@ function createRegistry() {
570
689
  );
571
690
  return patterns;
572
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
+ };
573
704
  const matchPattern = (kind, id) => {
574
705
  assertEntityKind(kind);
575
706
  const patterns = getPatternsForKind(kind);
576
707
  if (patterns.length === 0) return void 0;
577
708
  let best;
578
709
  for (const entry of patterns) {
579
- if (id.startsWith(entry.prefix) && (best === void 0 || entry.prefix.length > best.prefix.length)) {
710
+ if (matchesSegments(entry.segments, id) && (best === void 0 || entry.staticLength > best.staticLength)) {
580
711
  best = entry;
581
712
  }
582
713
  }
@@ -646,7 +777,6 @@ function createRegistry() {
646
777
  }
647
778
 
648
779
  // src/scanner/scan/audit.ts
649
- var MARKER_FILENAMES = ["UIDEX_PAGE.md", "UIDEX_FEATURE.md"];
650
780
  function audit(opts) {
651
781
  const diagnostics = [];
652
782
  const { registry, extracted, files, config } = opts;
@@ -656,22 +786,15 @@ function audit(opts) {
656
786
  const scopeLeakEnabled = config.audit?.scopeLeak ?? true;
657
787
  const coverageEnabled = config.audit?.coverage ?? true;
658
788
  if (opts.resolveDiagnostics) diagnostics.push(...opts.resolveDiagnostics);
659
- if (check) {
660
- for (const f of files) {
661
- const base = f.displayPath.split("/").pop() ?? "";
662
- if (MARKER_FILENAMES.includes(base)) {
663
- diagnostics.push({
664
- code: "marker-md-ignored",
665
- severity: "warning",
666
- message: `Marker file "${base}" is ignored in v2; migrate to \`export const uidex\``,
667
- file: f.displayPath
668
- });
669
- }
670
- }
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);
671
794
  }
672
795
  if (check && opts.generated !== void 0) {
673
796
  const outRel = opts.outputPath ?? config.output;
674
- const fresh = normalizeLineEndings(opts.generated);
797
+ const fresh = normalizeForCheck(opts.generated);
675
798
  if (opts.existingOnDisk === null || opts.existingOnDisk === void 0) {
676
799
  diagnostics.push({
677
800
  code: "gen-missing",
@@ -681,7 +804,7 @@ function audit(opts) {
681
804
  hint: "Run `uidex scan` (without --check) to regenerate"
682
805
  });
683
806
  } else {
684
- const existing = normalizeLineEndings(opts.existingOnDisk);
807
+ const existing = normalizeForCheck(opts.existingOnDisk);
685
808
  if (existing !== fresh) {
686
809
  const changed = diffEntities(existing, opts.generated, registry);
687
810
  const summary2 = formatChangedSummary(changed);
@@ -695,22 +818,6 @@ function audit(opts) {
695
818
  }
696
819
  }
697
820
  }
698
- if (lint) {
699
- for (const ef of extracted) {
700
- for (const a of ef.annotations) {
701
- const migration = legacyJsdocMigration(a);
702
- if (!migration) continue;
703
- diagnostics.push({
704
- code: "legacy-jsdoc",
705
- severity: "warning",
706
- message: migration.message,
707
- file: a.file,
708
- line: a.line,
709
- hint: migration.hint
710
- });
711
- }
712
- }
713
- }
714
821
  if (lint && acceptanceEnabled) {
715
822
  for (const kind of ["widget", "feature", "page"]) {
716
823
  for (const e of registry.list(kind)) {
@@ -756,8 +863,8 @@ function audit(opts) {
756
863
  if (typeof m.id !== "string") continue;
757
864
  const filePath = ef.file.displayPath;
758
865
  const wellKnownName = WELL_KNOWN_FILES[m.kind];
759
- if (path4.posix.basename(filePath) === wellKnownName) continue;
760
- const dir = path4.posix.dirname(filePath);
866
+ if (path5.posix.basename(filePath) === wellKnownName) continue;
867
+ const dir = path5.posix.dirname(filePath);
761
868
  const wellKnownPath = dir === "." ? wellKnownName : `${dir}/${wellKnownName}`;
762
869
  if (scannedPaths.has(wellKnownPath)) continue;
763
870
  const kindLabel = m.kind === "page" ? "Page" : "Feature";
@@ -774,53 +881,55 @@ function audit(opts) {
774
881
  }
775
882
  }
776
883
  if (lint) {
777
- const dynamicAttrRe = /\bdata-uidex(?:-(region|widget|primitive))?\s*=\s*\{/g;
778
- const templateWithPrefixRe = /\bdata-uidex(?:-(region|widget|primitive))?\s*=\s*\{\s*`[^`$]+\$\{/g;
779
- for (const f of files) {
780
- const templatePrefixPositions = /* @__PURE__ */ new Set();
781
- templateWithPrefixRe.lastIndex = 0;
782
- let tm;
783
- while ((tm = templateWithPrefixRe.exec(f.content)) !== null) {
784
- templatePrefixPositions.add(tm.index);
785
- }
786
- let m;
787
- dynamicAttrRe.lastIndex = 0;
788
- while ((m = dynamicAttrRe.exec(f.content)) !== null) {
789
- if (templatePrefixPositions.has(m.index)) continue;
790
- const kind = m[1] ?? "element";
791
- let line = 1;
792
- for (let i = 0; i < m.index; i++) if (f.content[i] === "\n") line++;
793
- const attrName = m[1] ? `data-uidex-${m[1]}` : "data-uidex";
884
+ for (const ef of extracted) {
885
+ for (const fact of ef.dynamicAttrs ?? []) {
794
886
  diagnostics.push({
795
887
  code: "dynamic-attr",
796
888
  severity: "warning",
797
- message: `\`${attrName}={\u2026}\` uses a dynamic expression; the scanner cannot resolve the ${kind} id statically`,
798
- file: f.displayPath,
799
- line,
800
- 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)
801
893
  });
802
894
  }
803
895
  }
804
896
  }
805
897
  if (lint) {
806
- for (const f of files) {
807
- const tagRe = /<(button|a|input|select|textarea)(?=[\s/>])/g;
808
- let m;
809
- while ((m = tagRe.exec(f.content)) !== null) {
810
- const afterTag = m.index + m[0].length;
811
- const closeIdx = findJsxOpeningEnd(f.content, afterTag);
812
- if (closeIdx === -1) continue;
813
- const attrs = f.content.slice(afterTag, closeIdx);
814
- if (attrs.includes("data-uidex")) continue;
815
- let line = 1;
816
- for (let i = 0; i < m.index; i++) if (f.content[i] === "\n") line++;
817
- diagnostics.push({
818
- code: "missing-element-annotation",
819
- severity: "info",
820
- message: `Interactive <${m[1].toLowerCase()}> without data-uidex annotation`,
821
- file: f.displayPath,
822
- line
823
- });
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
+ }
824
933
  }
825
934
  }
826
935
  }
@@ -837,12 +946,11 @@ function audit(opts) {
837
946
  }
838
947
  }
839
948
  }
840
- for (const f of files) {
841
- const importRe = /import\s+(?:[^'"]+)\s+from\s+['"]([^'"]+)['"]/g;
842
- let m;
843
- while ((m = importRe.exec(f.content)) !== null) {
844
- const spec = m[1];
845
- 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() ?? "";
846
954
  const primitive = byName.get(
847
955
  baseName2.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
848
956
  );
@@ -850,25 +958,37 @@ function audit(opts) {
850
958
  const scope = primitive.scopes?.[0];
851
959
  if (!scope) continue;
852
960
  const [kind, id] = scope.split(":");
853
- const importerSegments = f.displayPath.split("/");
961
+ const importerSegments = displayPath.split("/");
854
962
  if (importerSegments.includes(id) && importerSegments.includes(kind + "s")) {
855
963
  continue;
856
964
  }
857
965
  if (kind === "feature" && importerSegments.includes(id)) continue;
858
- if (kind === "feature" && declaredFeatures.get(f.displayPath)?.has(id)) {
966
+ if (kind === "feature" && declaredFeatures.get(displayPath)?.has(id)) {
859
967
  continue;
860
968
  }
861
969
  diagnostics.push({
862
970
  code: "scope-leak",
863
971
  severity: "warning",
864
- message: `Primitive "${primitive.id}" is scoped to ${scope} but is imported from ${f.displayPath}`,
865
- file: f.displayPath
972
+ message: `Primitive "${primitive.id}" is scoped to ${scope} but is imported from ${displayPath}`,
973
+ file: displayPath,
974
+ line: imp.line
866
975
  });
867
976
  }
868
977
  }
869
978
  }
870
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
+ }
871
990
  for (const flow of registry.list("flow")) {
991
+ const callLines = factsByLoc.get(`${flow.loc.file}:${flow.loc.line}`);
872
992
  for (const touchedId of flow.touches) {
873
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);
874
994
  if (!found) {
@@ -877,54 +997,131 @@ function audit(opts) {
877
997
  severity: "warning",
878
998
  message: `Flow "${flow.id}" references unknown entity "${touchedId}"`,
879
999
  file: flow.loc.file,
880
- 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
881
1003
  });
882
1004
  }
883
1005
  }
884
1006
  }
885
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
+ }
886
1080
  const summary = {
887
1081
  errors: diagnostics.filter((d) => d.severity === "error").length,
888
1082
  warnings: diagnostics.filter((d) => d.severity === "warning").length
889
1083
  };
890
1084
  return { diagnostics, summary };
891
1085
  }
892
- function legacyJsdocMigration(a) {
893
- const quote = (s) => JSON.stringify(s);
894
- const arr = (xs) => xs && xs.length > 0 ? `[${xs.map(quote).join(", ")}]` : "";
895
- const entityHint = (kind) => {
896
- const uidexKind = kind.charAt(0).toUpperCase() + kind.slice(1);
897
- const parts = [`${kind}: ${quote(a.id)}`];
898
- if (a.acceptance?.length) parts.push(`acceptance: ${arr(a.acceptance)}`);
899
- return {
900
- message: `Legacy JSDoc tag \`@uidex ${kind} ${a.id}\` is no longer recognised; migrate to \`export const uidex\``,
901
- hint: `Replace with: export const uidex = { ${parts.join(", ")} } as const satisfies Uidex.${uidexKind}`
902
- };
903
- };
904
- switch (a.kind) {
905
- case "page-doc":
906
- return entityHint("page");
907
- case "feature-doc":
908
- return entityHint("feature");
909
- case "widget-doc":
910
- return entityHint("widget");
911
- case "not-flow":
912
- return {
913
- message: `Legacy JSDoc tag \`@uidex:not-flow\` is no longer recognised; migrate to \`export const uidex\``,
914
- hint: `Replace with: export const uidex = { notFlow: true } as const satisfies Uidex.NotFlow`
915
- };
916
- case "orphan-acceptance":
917
- return {
918
- message: `Legacy JSDoc tag \`@acceptance\` is no longer recognised; migrate to the \`acceptance\` field on \`export const uidex\``,
919
- hint: `Replace with: export const uidex = { /* kind */, acceptance: ${arr(a.acceptance)} } as const`
920
- };
921
- default:
922
- 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;
923
1114
  }
924
1115
  }
925
1116
  function normalizeLineEndings(s) {
926
1117
  return s.replace(/\r\n/g, "\n");
927
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
+ }
928
1125
  function formatChangedSummary(change) {
929
1126
  const parts = [];
930
1127
  const fmt = (kind, names) => {
@@ -1023,62 +1220,11 @@ function extractEntitiesArray(source) {
1023
1220
  }
1024
1221
  return null;
1025
1222
  }
1026
- function findJsxOpeningEnd(src, start) {
1027
- let i = start;
1028
- while (i < src.length) {
1029
- const ch = src[i];
1030
- if (ch === ">" || ch === "/" && src[i + 1] === ">") return i;
1031
- if (ch === '"' || ch === "'" || ch === "`") {
1032
- i = skipString(src, i);
1033
- } else if (ch === "{") {
1034
- i = skipBraces(src, i);
1035
- } else {
1036
- i++;
1037
- }
1038
- }
1039
- return -1;
1040
- }
1041
- function skipString(src, start) {
1042
- const quote = src[start];
1043
- let i = start + 1;
1044
- while (i < src.length) {
1045
- if (src[i] === "\\" && quote !== "`") {
1046
- i += 2;
1047
- continue;
1048
- }
1049
- if (quote === "`" && src[i] === "$" && src[i + 1] === "{") {
1050
- i = skipBraces(src, i + 1);
1051
- continue;
1052
- }
1053
- if (src[i] === quote) return i + 1;
1054
- i++;
1055
- }
1056
- return i;
1057
- }
1058
- function skipBraces(src, start) {
1059
- let depth = 1;
1060
- let i = start + 1;
1061
- while (i < src.length && depth > 0) {
1062
- const ch = src[i];
1063
- if (ch === "{") {
1064
- depth++;
1065
- i++;
1066
- } else if (ch === "}") {
1067
- depth--;
1068
- i++;
1069
- } else if (ch === '"' || ch === "'" || ch === "`") {
1070
- i = skipString(src, i);
1071
- } else {
1072
- i++;
1073
- }
1074
- }
1075
- return i;
1076
- }
1077
1223
  function dynamicAttrHint(kind) {
1078
1224
  if (kind === "region") {
1079
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`;
1080
1226
  }
1081
- 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)`;
1082
1228
  }
1083
1229
  function stableStringify(value) {
1084
1230
  return JSON.stringify(value, stableReplacer);
@@ -1111,9 +1257,7 @@ function replacerSorted(_key, value) {
1111
1257
  }
1112
1258
  return value;
1113
1259
  }
1114
- function emitIdUnion(name, ids, typeMode) {
1115
- if (typeMode === "loose") return `export type ${name} = string
1116
- `;
1260
+ function emitIdUnion(name, ids) {
1117
1261
  if (ids.length === 0) return `export type ${name} = never
1118
1262
  `;
1119
1263
  const sorted = [...ids].sort();
@@ -1123,12 +1267,7 @@ ${body}
1123
1267
  `;
1124
1268
  }
1125
1269
  function emit(opts) {
1126
- const {
1127
- registry,
1128
- gitContext,
1129
- uidexImport = "uidex",
1130
- typeMode = "strict"
1131
- } = opts;
1270
+ const { registry, gitContext, uidexImport = "uidex" } = opts;
1132
1271
  const routes = [...registry.list("route")].sort(
1133
1272
  (a, b) => a.path.localeCompare(b.path)
1134
1273
  );
@@ -1151,57 +1290,49 @@ function emit(opts) {
1151
1290
  lines.push(
1152
1291
  emitIdUnion(
1153
1292
  "PageId",
1154
- pages.map((e) => e.id),
1155
- typeMode
1293
+ pages.map((e) => e.id)
1156
1294
  )
1157
1295
  );
1158
1296
  lines.push(
1159
1297
  emitIdUnion(
1160
1298
  "FeatureId",
1161
- features.map((e) => e.id),
1162
- typeMode
1299
+ features.map((e) => e.id)
1163
1300
  )
1164
1301
  );
1165
1302
  lines.push(
1166
1303
  emitIdUnion(
1167
1304
  "WidgetId",
1168
- widgets.map((e) => e.id),
1169
- typeMode
1305
+ widgets.map((e) => e.id)
1170
1306
  )
1171
1307
  );
1172
1308
  lines.push(
1173
1309
  emitIdUnion(
1174
1310
  "RegionId",
1175
- regions.map((e) => e.id),
1176
- typeMode
1311
+ regions.map((e) => e.id)
1177
1312
  )
1178
1313
  );
1179
1314
  lines.push(
1180
1315
  emitIdUnion(
1181
1316
  "ElementId",
1182
- elements.map((e) => e.id),
1183
- typeMode
1317
+ elements.map((e) => e.id)
1184
1318
  )
1185
1319
  );
1186
1320
  lines.push(
1187
1321
  emitIdUnion(
1188
1322
  "PrimitiveId",
1189
- primitives.map((e) => e.id),
1190
- typeMode
1323
+ primitives.map((e) => e.id)
1191
1324
  )
1192
1325
  );
1193
1326
  lines.push(
1194
1327
  emitIdUnion(
1195
1328
  "FlowId",
1196
- flows.map((e) => e.id),
1197
- typeMode
1329
+ flows.map((e) => e.id)
1198
1330
  )
1199
1331
  );
1200
1332
  lines.push(
1201
1333
  emitIdUnion(
1202
1334
  "RouteId",
1203
- routes.map((e) => e.path),
1204
- typeMode
1335
+ routes.map((e) => e.path)
1205
1336
  )
1206
1337
  );
1207
1338
  lines.push("");
@@ -1280,6 +1411,90 @@ function emit(opts) {
1280
1411
  return lines.join("\n");
1281
1412
  }
1282
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
+
1283
1498
  // src/scanner/scan/extract-uidex-export.ts
1284
1499
  var KIND_DISCRIMINATORS = [
1285
1500
  "page",
@@ -1317,6 +1532,16 @@ var FALSEABLE = /* @__PURE__ */ new Set([
1317
1532
  "primitive",
1318
1533
  "region"
1319
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));
1320
1545
  var ExtractError = class extends Error {
1321
1546
  code;
1322
1547
  hint;
@@ -1328,649 +1553,285 @@ var ExtractError = class extends Error {
1328
1553
  this.hint = hint;
1329
1554
  }
1330
1555
  };
1331
- function extractUidexExports(file) {
1556
+ function extractUidexExports(file, parsed) {
1332
1557
  const exports2 = [];
1333
1558
  const diagnostics = [];
1334
1559
  const { content, displayPath } = file;
1335
- for (const header of findExportHeaders(content)) {
1336
- try {
1337
- const value = parseExpression(content, header.exprStart);
1338
- const metadata = buildMetadata(
1339
- value,
1340
- displayPath,
1341
- header.headerPos,
1342
- diagnostics
1343
- );
1344
- exports2.push(metadata);
1345
- } catch (e) {
1346
- if (e instanceof ExtractError) {
1347
- diagnostics.push({
1348
- code: e.code,
1349
- severity: "error",
1350
- message: e.message,
1351
- file: displayPath,
1352
- line: e.pos.line,
1353
- hint: e.hint
1354
- });
1355
- } else {
1356
- throw e;
1357
- }
1358
- }
1359
- }
1360
- return { exports: exports2, diagnostics };
1361
- }
1362
- var HEADER_RE = /(?:^|\n)[\t ]*export\s+const\s+uidex\b(?:\s*:\s*[^=\n]+?)?\s*=\s*/g;
1363
- function findExportHeaders(content) {
1364
- const out2 = [];
1365
- HEADER_RE.lastIndex = 0;
1366
- let m;
1367
- while ((m = HEADER_RE.exec(content)) !== null) {
1368
- const leadingNewline = m[0].startsWith("\n") ? 1 : 0;
1369
- const headerOffset = m.index + leadingNewline;
1370
- const exprStart = m.index + m[0].length;
1371
- if (isInsideCommentOrString(content, headerOffset)) continue;
1372
- out2.push({
1373
- headerPos: posAt(content, headerOffset),
1374
- exprStart
1375
- });
1376
- }
1377
- return out2;
1378
- }
1379
- function isInsideCommentOrString(content, target) {
1380
- let i = 0;
1381
- let inLineComment = false;
1382
- let inBlockComment = false;
1383
- let stringDelim = null;
1384
- let inTemplate = false;
1385
- let templateDepth = 0;
1386
- while (i < target) {
1387
- const c = content[i];
1388
- const n = content[i + 1];
1389
- if (inLineComment) {
1390
- if (c === "\n") inLineComment = false;
1391
- i++;
1392
- continue;
1393
- }
1394
- if (inBlockComment) {
1395
- if (c === "*" && n === "/") {
1396
- inBlockComment = false;
1397
- i += 2;
1398
- continue;
1399
- }
1400
- i++;
1401
- continue;
1402
- }
1403
- if (stringDelim !== null) {
1404
- if (c === "\\") {
1405
- i += 2;
1406
- continue;
1407
- }
1408
- if (c === stringDelim) stringDelim = null;
1409
- i++;
1410
- continue;
1411
- }
1412
- if (inTemplate) {
1413
- if (c === "\\") {
1414
- i += 2;
1415
- continue;
1416
- }
1417
- if (c === "$" && n === "{") {
1418
- templateDepth++;
1419
- 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") {
1420
1570
  continue;
1421
1571
  }
1422
- if (c === "`" && templateDepth === 0) {
1423
- inTemplate = false;
1424
- i++;
1425
- continue;
1426
- }
1427
- if (templateDepth > 0 && c === "}") {
1428
- templateDepth--;
1429
- i++;
1430
- continue;
1431
- }
1432
- i++;
1433
- continue;
1434
- }
1435
- if (c === "/" && n === "/") {
1436
- inLineComment = true;
1437
- i += 2;
1438
- continue;
1439
- }
1440
- if (c === "/" && n === "*") {
1441
- inBlockComment = true;
1442
- i += 2;
1443
- continue;
1444
- }
1445
- if (c === '"' || c === "'") {
1446
- stringDelim = c;
1447
- i++;
1448
- continue;
1449
- }
1450
- if (c === "`") {
1451
- inTemplate = true;
1452
- i++;
1453
- continue;
1454
- }
1455
- i++;
1456
- }
1457
- return inLineComment || inBlockComment || stringDelim !== null || inTemplate;
1458
- }
1459
- var Tokenizer = class {
1460
- constructor(src, start) {
1461
- this.src = src;
1462
- this.pos = start;
1463
- let line = 1;
1464
- let lineStart = 0;
1465
- for (let i = 0; i < start; i++) {
1466
- if (src[i] === "\n") {
1467
- line++;
1468
- lineStart = i + 1;
1469
- }
1470
- }
1471
- this.line = line;
1472
- this.lineStart = lineStart;
1473
- }
1474
- src;
1475
- pos;
1476
- line;
1477
- lineStart;
1478
- currentPos() {
1479
- return {
1480
- offset: this.pos,
1481
- line: this.line,
1482
- column: this.pos - this.lineStart + 1
1483
- };
1484
- }
1485
- advance(n = 1) {
1486
- for (let i = 0; i < n; i++) {
1487
- if (this.pos < this.src.length && this.src[this.pos] === "\n") {
1488
- this.line++;
1489
- this.lineStart = this.pos + 1;
1490
- }
1491
- this.pos++;
1492
- }
1493
- }
1494
- skipTrivia() {
1495
- while (this.pos < this.src.length) {
1496
- const c = this.src[this.pos];
1497
- const n = this.src[this.pos + 1];
1498
- if (c === " " || c === " " || c === "\r" || c === "\n") {
1499
- this.advance();
1500
- continue;
1501
- }
1502
- if (c === "/" && n === "/") {
1503
- while (this.pos < this.src.length && this.src[this.pos] !== "\n") {
1504
- 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
+ );
1505
1581
  }
1506
- continue;
1507
- }
1508
- if (c === "/" && n === "*") {
1509
- this.advance(2);
1510
- while (this.pos < this.src.length) {
1511
- if (this.src[this.pos] === "*" && this.src[this.pos + 1] === "/") {
1512
- this.advance(2);
1513
- break;
1514
- }
1515
- 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;
1516
1605
  }
1517
- continue;
1518
1606
  }
1519
- break;
1520
- }
1521
- }
1522
- next() {
1523
- this.skipTrivia();
1524
- if (this.pos >= this.src.length) {
1525
- return { kind: "eof", value: "", pos: this.currentPos(), end: this.pos };
1526
- }
1527
- const pos = this.currentPos();
1528
- const c = this.src[this.pos];
1529
- switch (c) {
1530
- case "{":
1531
- this.advance();
1532
- return { kind: "lbrace", value: c, pos, end: this.pos };
1533
- case "}":
1534
- this.advance();
1535
- return { kind: "rbrace", value: c, pos, end: this.pos };
1536
- case "[":
1537
- this.advance();
1538
- return { kind: "lbracket", value: c, pos, end: this.pos };
1539
- case "]":
1540
- this.advance();
1541
- return { kind: "rbracket", value: c, pos, end: this.pos };
1542
- case "(":
1543
- this.advance();
1544
- return { kind: "lparen", value: c, pos, end: this.pos };
1545
- case ")":
1546
- this.advance();
1547
- return { kind: "rparen", value: c, pos, end: this.pos };
1548
- case ",":
1549
- this.advance();
1550
- return { kind: "comma", value: c, pos, end: this.pos };
1551
- case ":":
1552
- this.advance();
1553
- return { kind: "colon", value: c, pos, end: this.pos };
1554
- }
1555
- if (c === "." && this.src[this.pos + 1] === "." && this.src[this.pos + 2] === ".") {
1556
- this.advance(3);
1557
- return { kind: "spread", value: "...", pos, end: this.pos };
1558
- }
1559
- if (c === '"' || c === "'") {
1560
- return this.readString(pos, c);
1561
- }
1562
- if (c === "`") {
1563
- return this.readTemplate(pos);
1564
- }
1565
- if (isDigit(c) || c === "-" && isDigit(this.src[this.pos + 1])) {
1566
- return this.readNumber(pos);
1567
1607
  }
1568
- if (isIdentStart(c)) {
1569
- return this.readIdent(pos);
1570
- }
1571
- this.advance();
1572
- return { kind: "punct", value: c, pos, end: this.pos };
1573
1608
  }
1574
- readString(pos, delim) {
1575
- this.advance();
1576
- let value = "";
1577
- while (this.pos < this.src.length) {
1578
- const c = this.src[this.pos];
1579
- if (c === "\\") {
1580
- const esc = this.src[this.pos + 1];
1581
- this.advance(2);
1582
- value += decodeEscape(esc);
1583
- continue;
1584
- }
1585
- if (c === delim) {
1586
- this.advance();
1587
- return { kind: "string", value, pos, end: this.pos };
1588
- }
1589
- if (c === "\n") {
1590
- return { kind: "punct", value: delim, pos, end: this.pos };
1591
- }
1592
- value += c;
1593
- this.advance();
1594
- }
1595
- return { kind: "punct", value: delim, pos, end: this.pos };
1596
- }
1597
- readTemplate(pos) {
1598
- this.advance();
1599
- let value = "";
1600
- let hasExpression = false;
1601
- while (this.pos < this.src.length) {
1602
- const c = this.src[this.pos];
1603
- const n = this.src[this.pos + 1];
1604
- if (c === "\\") {
1605
- const esc = this.src[this.pos + 1];
1606
- this.advance(2);
1607
- value += decodeEscape(esc);
1608
- continue;
1609
- }
1610
- if (c === "$" && n === "{") {
1611
- hasExpression = true;
1612
- this.advance(2);
1613
- let depth = 1;
1614
- while (this.pos < this.src.length && depth > 0) {
1615
- const ch = this.src[this.pos];
1616
- if (ch === "{") depth++;
1617
- else if (ch === "}") depth--;
1618
- 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
+ );
1619
1626
  }
1620
- continue;
1627
+ return { kind: "number", value: v, pos, span };
1621
1628
  }
1622
- if (c === "`") {
1623
- this.advance();
1624
- if (hasExpression) {
1625
- return { kind: "template", value, pos, end: this.pos };
1626
- }
1627
- return { kind: "string", value, pos, end: this.pos };
1629
+ if (typeof v === "boolean") {
1630
+ return { kind: "boolean", value: v, pos, span };
1628
1631
  }
1629
- value += c;
1630
- this.advance();
1631
- }
1632
- return { kind: "template", value, pos, end: this.pos };
1633
- }
1634
- readNumber(pos) {
1635
- const start = this.pos;
1636
- if (this.src[this.pos] === "-") this.advance();
1637
- while (this.pos < this.src.length && isDigit(this.src[this.pos])) {
1638
- this.advance();
1639
- }
1640
- if (this.src[this.pos] === ".") {
1641
- this.advance();
1642
- while (this.pos < this.src.length && isDigit(this.src[this.pos])) {
1643
- this.advance();
1632
+ if (v === null && unwrapped.raw === "null") {
1633
+ return { kind: "null", pos, span };
1644
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
+ );
1645
1640
  }
1646
- if (this.src[this.pos] === "e" || this.src[this.pos] === "E") {
1647
- this.advance();
1648
- if (this.src[this.pos] === "+" || this.src[this.pos] === "-") {
1649
- this.advance();
1650
- }
1651
- while (this.pos < this.src.length && isDigit(this.src[this.pos])) {
1652
- 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 };
1653
1645
  }
1646
+ throw new ExtractError(
1647
+ "uidex-export-invalid-literal",
1648
+ "Unary expressions are not allowed in `export const uidex`.",
1649
+ pos
1650
+ );
1654
1651
  }
1655
- const value = this.src.slice(start, this.pos);
1656
- return { kind: "number", value, pos, end: this.pos };
1657
- }
1658
- readIdent(pos) {
1659
- const start = this.pos;
1660
- while (this.pos < this.src.length && isIdentPart(this.src[this.pos])) {
1661
- this.advance();
1662
- }
1663
- const value = this.src.slice(start, this.pos);
1664
- return { kind: "ident", value, pos, end: this.pos };
1665
- }
1666
- };
1667
- function isDigit(c) {
1668
- return c !== void 0 && c >= "0" && c <= "9";
1669
- }
1670
- function isIdentStart(c) {
1671
- if (c === void 0) return false;
1672
- return c >= "a" && c <= "z" || c >= "A" && c <= "Z" || c === "_" || c === "$";
1673
- }
1674
- function isIdentPart(c) {
1675
- return isIdentStart(c) || isDigit(c);
1676
- }
1677
- function decodeEscape(esc) {
1678
- switch (esc) {
1679
- case "n":
1680
- return "\n";
1681
- case "t":
1682
- return " ";
1683
- case "r":
1684
- return "\r";
1685
- case "\\":
1686
- return "\\";
1687
- case "'":
1688
- return "'";
1689
- case '"':
1690
- return '"';
1691
- case "`":
1692
- return "`";
1693
- case "0":
1694
- return "\0";
1695
- case "b":
1696
- return "\b";
1697
- case "f":
1698
- return "\f";
1699
- case "v":
1700
- return "\v";
1701
- default:
1702
- return esc ?? "";
1703
- }
1704
- }
1705
- function parseExpression(content, start) {
1706
- const tokenizer = new Tokenizer(content, start);
1707
- const parser = new Parser(tokenizer);
1708
- const value = parser.parseValue();
1709
- parser.consumeTrailingAssertions();
1710
- return value;
1711
- }
1712
- var Parser = class {
1713
- constructor(tok) {
1714
- this.tok = tok;
1715
- }
1716
- tok;
1717
- lookahead = null;
1718
- peek() {
1719
- if (this.lookahead === null) this.lookahead = this.tok.next();
1720
- return this.lookahead;
1721
- }
1722
- consume() {
1723
- const t = this.peek();
1724
- this.lookahead = null;
1725
- return t;
1726
- }
1727
- parseValue() {
1728
- const t = this.peek();
1729
- switch (t.kind) {
1730
- case "lbrace":
1731
- return this.parseObject();
1732
- case "lbracket":
1733
- return this.parseArray();
1734
- case "string":
1735
- this.consume();
1736
- return { kind: "string", value: t.value, pos: t.pos };
1737
- case "template":
1652
+ case "TemplateLiteral": {
1653
+ const expressions = unwrapped.expressions ?? [];
1654
+ if (expressions.length > 0) {
1738
1655
  throw new ExtractError(
1739
1656
  "uidex-export-invalid-literal",
1740
1657
  "Template literal with expression parts is not allowed in `export const uidex`; use a plain string literal.",
1741
- t.pos
1658
+ pos
1742
1659
  );
1743
- case "number": {
1744
- this.consume();
1745
- const n = Number(t.value);
1746
- if (!Number.isFinite(n)) {
1747
- throw new ExtractError(
1748
- "uidex-export-invalid-literal",
1749
- `Invalid numeric literal "${t.value}" in \`export const uidex\`.`,
1750
- t.pos
1751
- );
1752
- }
1753
- return { kind: "number", value: n, pos: t.pos };
1754
1660
  }
1755
- case "ident":
1756
- if (t.value === "true" || t.value === "false") {
1757
- this.consume();
1758
- return {
1759
- kind: "boolean",
1760
- value: t.value === "true",
1761
- pos: t.pos
1762
- };
1763
- }
1764
- if (t.value === "null") {
1765
- this.consume();
1766
- return { kind: "null", pos: t.pos };
1767
- }
1768
- if (t.value === "undefined") {
1769
- throw new ExtractError(
1770
- "uidex-export-invalid-literal",
1771
- "`undefined` is not allowed as a value in `export const uidex`; omit the field instead.",
1772
- t.pos
1773
- );
1774
- }
1775
- throw new ExtractError(
1776
- "uidex-export-invalid-literal",
1777
- `Identifier reference "${t.value}" is not allowed in \`export const uidex\`; the right-hand side must be a plain literal.`,
1778
- t.pos
1779
- );
1780
- case "spread":
1781
- throw new ExtractError(
1782
- "uidex-export-invalid-literal",
1783
- "Spread (`...`) is not allowed in `export const uidex`; the right-hand side must be a plain literal.",
1784
- t.pos
1785
- );
1786
- case "lparen":
1787
- throw new ExtractError(
1788
- "uidex-export-invalid-literal",
1789
- "Parenthesised or grouped expressions are not allowed in `export const uidex`.",
1790
- t.pos
1791
- );
1792
- case "punct":
1793
- throw new ExtractError(
1794
- "uidex-export-invalid-literal",
1795
- `Unexpected token "${t.value}" in \`export const uidex\`.`,
1796
- t.pos
1797
- );
1798
- case "eof":
1799
- throw new ExtractError(
1800
- "uidex-export-invalid-literal",
1801
- "Expected a value for `export const uidex` but reached end of file.",
1802
- t.pos
1803
- );
1804
- default:
1805
- throw new ExtractError(
1806
- "uidex-export-invalid-literal",
1807
- `Unexpected token in \`export const uidex\`.`,
1808
- t.pos
1809
- );
1661
+ const quasis = unwrapped.quasis ?? [];
1662
+ const cooked = quasis[0]?.value?.cooked ?? "";
1663
+ return { kind: "string", value: cooked, pos, span };
1810
1664
  }
1811
- }
1812
- parseObject() {
1813
- const open = this.consume();
1814
- const entries = [];
1815
- const seen = /* @__PURE__ */ new Set();
1816
- while (true) {
1817
- const t = this.peek();
1818
- if (t.kind === "rbrace") {
1819
- this.consume();
1820
- break;
1821
- }
1822
- if (t.kind === "spread") {
1665
+ case "Identifier": {
1666
+ const name = String(unwrapped.name);
1667
+ if (name === "undefined") {
1823
1668
  throw new ExtractError(
1824
1669
  "uidex-export-invalid-literal",
1825
- "Spread (`...`) is not allowed inside `export const uidex`.",
1826
- t.pos
1670
+ "`undefined` is not allowed as a value in `export const uidex`; omit the field instead.",
1671
+ pos
1827
1672
  );
1828
1673
  }
1829
- if (t.kind === "lbracket") {
1830
- this.consume();
1831
- const keyTok = this.peek();
1832
- if (keyTok.kind !== "string") {
1833
- throw new ExtractError(
1834
- "uidex-export-invalid-literal",
1835
- "Computed property keys must be string literals in `export const uidex`.",
1836
- keyTok.pos
1837
- );
1838
- }
1839
- this.consume();
1840
- const close = this.peek();
1841
- if (close.kind !== "rbracket") {
1842
- throw new ExtractError(
1843
- "uidex-export-invalid-literal",
1844
- "Expected `]` after computed property key.",
1845
- close.pos
1846
- );
1847
- }
1848
- this.consume();
1849
- const colon = this.peek();
1850
- if (colon.kind !== "colon") {
1851
- throw new ExtractError(
1852
- "uidex-export-invalid-literal",
1853
- "Expected `:` after computed property key.",
1854
- colon.pos
1855
- );
1856
- }
1857
- this.consume();
1858
- const value = this.parseValue();
1859
- this.recordEntry(entries, seen, keyTok.value, value, keyTok.pos);
1860
- } else if (t.kind === "ident" || t.kind === "string") {
1861
- const keyTok = this.consume();
1862
- const next = this.peek();
1863
- if (next.kind === "colon") {
1864
- this.consume();
1865
- const value = this.parseValue();
1866
- this.recordEntry(entries, seen, keyTok.value, value, keyTok.pos);
1867
- } else {
1868
- throw new ExtractError(
1869
- "uidex-export-invalid-literal",
1870
- keyTok.kind === "ident" ? `Shorthand property "${keyTok.value}" is not allowed; write "${keyTok.value}: ..." with a literal value.` : "Expected `:` after property key.",
1871
- keyTok.pos
1872
- );
1873
- }
1874
- } else if (t.kind === "number") {
1875
- throw new ExtractError(
1876
- "uidex-export-invalid-literal",
1877
- "Numeric property keys are not allowed in `export const uidex`.",
1878
- t.pos
1879
- );
1880
- } 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") {
1881
1740
  throw new ExtractError(
1882
1741
  "uidex-export-invalid-literal",
1883
- `Unexpected token "${t.value}" inside object.`,
1884
- t.pos
1742
+ "Computed property keys must be string literals in `export const uidex`.",
1743
+ keyPos
1885
1744
  );
1886
1745
  }
1887
- const after = this.peek();
1888
- if (after.kind === "comma") {
1889
- this.consume();
1890
- continue;
1891
- }
1892
- if (after.kind === "rbrace") {
1893
- this.consume();
1894
- break;
1895
- }
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 {
1896
1752
  throw new ExtractError(
1897
1753
  "uidex-export-invalid-literal",
1898
- `Expected \`,\` or \`}\`, got "${after.value}".`,
1899
- after.pos
1754
+ "Numeric property keys are not allowed in `export const uidex`.",
1755
+ keyPos
1900
1756
  );
1901
1757
  }
1902
- return { kind: "object", entries, pos: open.pos };
1903
- }
1904
- recordEntry(entries, seen, key, value, pos) {
1905
1758
  if (seen.has(key)) {
1906
1759
  throw new ExtractError(
1907
1760
  "uidex-export-duplicate-field",
1908
1761
  `Duplicate field "${key}" in \`export const uidex\`.`,
1909
- pos
1762
+ keyPos
1910
1763
  );
1911
1764
  }
1912
1765
  seen.add(key);
1913
- entries.push([key, value]);
1914
- }
1915
- parseArray() {
1916
- const open = this.consume();
1917
- const items = [];
1918
- while (true) {
1919
- const t = this.peek();
1920
- if (t.kind === "rbracket") {
1921
- this.consume();
1922
- break;
1923
- }
1924
- if (t.kind === "spread") {
1925
- throw new ExtractError(
1926
- "uidex-export-invalid-literal",
1927
- "Spread (`...`) is not allowed inside `export const uidex`.",
1928
- t.pos
1929
- );
1930
- }
1931
- const value = this.parseValue();
1932
- if (value.kind === "object") {
1933
- }
1934
- items.push(value);
1935
- const after = this.peek();
1936
- if (after.kind === "comma") {
1937
- this.consume();
1938
- continue;
1939
- }
1940
- if (after.kind === "rbracket") {
1941
- this.consume();
1942
- break;
1943
- }
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) {
1944
1780
  throw new ExtractError(
1945
1781
  "uidex-export-invalid-literal",
1946
- `Expected \`,\` or \`]\`, got "${after.value}".`,
1947
- after.pos
1782
+ "Array holes are not allowed in `export const uidex`.",
1783
+ pos
1948
1784
  );
1949
1785
  }
1950
- return { kind: "array", items, pos: open.pos };
1786
+ if (el.type === "SpreadElement") {
1787
+ throw new ExtractError(
1788
+ "uidex-export-invalid-literal",
1789
+ "Spread (`...`) is not allowed inside `export const uidex`.",
1790
+ posAt(content, el.start, p2)
1791
+ );
1792
+ }
1793
+ items.push(toLitValue(el, content, p2));
1951
1794
  }
1952
- consumeTrailingAssertions() {
1953
- const first = this.peek();
1954
- if (first.kind === "ident" && first.value === "as") {
1955
- this.consume();
1956
- const next = this.peek();
1957
- if (next.kind === "ident" && next.value === "const") {
1958
- this.consume();
1959
- } else {
1960
- throw new ExtractError(
1961
- "uidex-export-invalid-literal",
1962
- "Only `as const` is allowed after the `export const uidex` value.",
1963
- next.pos
1964
- );
1965
- }
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;
1966
1804
  }
1967
- const maybeSatisfies = this.peek();
1968
- if (maybeSatisfies.kind === "ident" && maybeSatisfies.value === "satisfies") {
1969
- return;
1805
+ if (node.type === "TSAsExpression" || node.type === "TSNonNullExpression" || node.type === "TSTypeAssertion" || node.type === "ParenthesizedExpression") {
1806
+ node = node.expression;
1807
+ continue;
1970
1808
  }
1809
+ break;
1971
1810
  }
1972
- };
1973
- 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) {
1974
1835
  if (value.kind !== "object") {
1975
1836
  throw new ExtractError(
1976
1837
  "uidex-export-invalid-literal",
@@ -1979,7 +1840,7 @@ function buildMetadata(value, file, headerPos, diagnostics) {
1979
1840
  );
1980
1841
  }
1981
1842
  const byKey = /* @__PURE__ */ new Map();
1982
- for (const [k, v] of value.entries) byKey.set(k, v);
1843
+ for (const entry of value.entries) byKey.set(entry.key, entry);
1983
1844
  const presentKinds = KIND_DISCRIMINATORS.filter(
1984
1845
  (k) => byKey.has(k)
1985
1846
  );
@@ -2002,49 +1863,58 @@ function buildMetadata(value, file, headerPos, diagnostics) {
2002
1863
  const discriminator = presentKinds[0];
2003
1864
  const kind = discriminator === "notFlow" ? "flow" : discriminator;
2004
1865
  const allowed = ALLOWED_FIELDS[kind];
2005
- for (const [k] of value.entries) {
2006
- if (!allowed.has(k)) {
2007
- const fieldVal = byKey.get(k);
1866
+ for (const entry of value.entries) {
1867
+ if (!allowed.has(entry.key)) {
2008
1868
  throw new ExtractError(
2009
1869
  "uidex-export-unknown-field",
2010
- `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(
2011
1871
  allowed
2012
1872
  ).sort().join(", ")}.`,
2013
- fieldVal.pos
1873
+ entry.value.pos
2014
1874
  );
2015
1875
  }
2016
1876
  }
2017
1877
  const idField = discriminator === "notFlow" ? "flow" : discriminator;
2018
- const idValue = byKey.get(discriminator);
1878
+ const idValue = byKey.get(discriminator).value;
2019
1879
  let id;
2020
1880
  if (discriminator === "notFlow") {
2021
- const v = idValue;
2022
- if (v.kind !== "boolean" || v.value !== true) {
1881
+ if (idValue.kind !== "boolean" || idValue.value !== true) {
2023
1882
  throw new ExtractError(
2024
1883
  "uidex-export-invalid-field",
2025
1884
  "`notFlow` must be `true`.",
2026
- v.pos
1885
+ idValue.pos
2027
1886
  );
2028
1887
  }
2029
1888
  id = false;
2030
1889
  } else {
2031
1890
  id = readIdField(idValue, kind, idField);
2032
1891
  }
2033
- const acceptance = readStringArrayField(byKey, "acceptance");
1892
+ const acceptance = readStringArrayField(byKey, "acceptance")?.values;
2034
1893
  const description = readStringField(byKey, "description");
2035
1894
  const name = readStringField(byKey, "name");
2036
1895
  if (name === "") {
2037
- const pos = byKey.get("name").pos;
1896
+ const entry = byKey.get("name");
2038
1897
  diagnostics.push({
2039
1898
  code: "uidex-export-empty-name",
2040
1899
  severity: "info",
2041
1900
  message: "`name` is an empty string; treating as unset.",
2042
1901
  file,
2043
- 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
+ }
2044
1914
  });
2045
1915
  }
2046
- const features = kind === "page" || kind === "feature" ? readStringArrayField(byKey, "features") : void 0;
2047
- 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;
2048
1918
  const notFlow = kind === "flow" && discriminator === "notFlow" ? true : void 0;
2049
1919
  const metadata = {
2050
1920
  source: "ts-export",
@@ -2059,9 +1929,21 @@ function buildMetadata(value, file, headerPos, diagnostics) {
2059
1929
  if (name) metadata.name = name;
2060
1930
  if (acceptance) metadata.acceptance = acceptance;
2061
1931
  if (description) metadata.description = description;
2062
- if (features) metadata.features = features;
2063
- 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
+ }
2064
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;
2065
1947
  return metadata;
2066
1948
  }
2067
1949
  function readIdField(value, kind, fieldName) {
@@ -2092,29 +1974,30 @@ function readIdField(value, kind, fieldName) {
2092
1974
  );
2093
1975
  }
2094
1976
  function readStringField(byKey, name) {
2095
- const v = byKey.get(name);
2096
- if (!v) return void 0;
2097
- if (v.kind !== "string") {
1977
+ const entry = byKey.get(name);
1978
+ if (!entry) return void 0;
1979
+ if (entry.value.kind !== "string") {
2098
1980
  throw new ExtractError(
2099
1981
  "uidex-export-invalid-field",
2100
1982
  `\`${name}\` must be a string.`,
2101
- v.pos
1983
+ entry.value.pos
2102
1984
  );
2103
1985
  }
2104
- return v.value;
1986
+ return entry.value.value;
2105
1987
  }
2106
1988
  function readStringArrayField(byKey, name) {
2107
- const v = byKey.get(name);
2108
- if (!v) return void 0;
2109
- if (v.kind !== "array") {
1989
+ const entry = byKey.get(name);
1990
+ if (!entry) return void 0;
1991
+ if (entry.value.kind !== "array") {
2110
1992
  throw new ExtractError(
2111
1993
  "uidex-export-invalid-field",
2112
1994
  `\`${name}\` must be an array of strings.`,
2113
- v.pos
1995
+ entry.value.pos
2114
1996
  );
2115
1997
  }
2116
- const out2 = [];
2117
- for (const item of v.items) {
1998
+ const values = [];
1999
+ const spans = [];
2000
+ for (const item of entry.value.items) {
2118
2001
  if (item.kind !== "string") {
2119
2002
  throw new ExtractError(
2120
2003
  "uidex-export-invalid-field",
@@ -2122,318 +2005,522 @@ function readStringArrayField(byKey, name) {
2122
2005
  item.pos
2123
2006
  );
2124
2007
  }
2125
- 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;
2126
2087
  }
2127
- return out2;
2088
+ return false;
2128
2089
  }
2129
- function posAt(content, offset) {
2130
- let line = 1;
2131
- let lineStart = 0;
2132
- for (let i = 0; i < offset && i < content.length; i++) {
2133
- if (content[i] === "\n") {
2134
- line++;
2135
- 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;
2136
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;
2137
2149
  }
2138
- 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;
2139
2154
  }
2140
2155
 
2141
2156
  // src/scanner/scan/jsx-ancestry.ts
2142
- var DATA_ATTR_RE = /\bdata-uidex(?:-(region|widget|primitive))?\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
2143
- var PATTERN_ATTR_RE = /\bdata-uidex(?:-(region|widget|primitive))?\s*=\s*\{\s*`([^`$]+)\$\{/g;
2144
- function parseDataAttrs(tagSource) {
2145
- if (!tagSource.includes("data-uidex")) return [];
2146
- const out2 = [];
2147
- for (const m of tagSource.matchAll(DATA_ATTR_RE)) {
2148
- const kind = m[1] ?? "element";
2149
- const id = m[2] ?? m[3];
2150
- if (id) out2.push({ kind, id });
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";
2166
+ }
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);
2180
+ continue;
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);
2187
+ }
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;
2151
2195
  }
2152
- for (const m of tagSource.matchAll(PATTERN_ATTR_RE)) {
2153
- const kind = m[1] ?? "element";
2154
- const prefix = m[2];
2155
- if (prefix) out2.push({ kind, id: `${prefix}*` });
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 ?? "";
2156
2201
  }
2157
- return out2;
2202
+ return null;
2158
2203
  }
2159
- function collectJSXAncestry(content) {
2160
- if (!content.includes("data-uidex")) return [];
2161
- const out2 = [];
2162
- const ancestors = [];
2163
- const stack = [];
2164
- const N = content.length;
2165
- let i = 0;
2166
- let line = 1;
2167
- const advanceLines = (from, to) => {
2168
- for (let k = from; k < to; k++) {
2169
- if (content.charCodeAt(k) === 10) line++;
2170
- }
2171
- };
2172
- while (i < N) {
2173
- const c = content[i];
2174
- if (c === "\n") {
2175
- line++;
2176
- i++;
2177
- continue;
2178
- }
2179
- if (c === "/" && content[i + 1] === "/") {
2180
- while (i < N && content[i] !== "\n") i++;
2181
- continue;
2182
- }
2183
- if (c === "/" && content[i + 1] === "*") {
2184
- const end = content.indexOf("*/", i + 2);
2185
- const next = end === -1 ? N : end + 2;
2186
- advanceLines(i, next);
2187
- i = next;
2188
- continue;
2189
- }
2190
- if (c === '"' || c === "'") {
2191
- const next = skipString2(content, i, c);
2192
- advanceLines(i, next);
2193
- i = next;
2194
- 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
+ }
2195
2221
  }
2196
- if (c === "`") {
2197
- const next = skipTemplate(content, i);
2198
- advanceLines(i, next);
2199
- i = next;
2200
- continue;
2222
+ out2 = out2.replace(/\*{2,}/g, "*");
2223
+ if (!out2.includes("*")) {
2224
+ return out2.length > 0 ? { resolved: true, ids: [out2] } : UNRESOLVED;
2201
2225
  }
2202
- if (c === "<") {
2203
- const nextCh = content[i + 1];
2204
- if (nextCh === "/") {
2205
- const end = content.indexOf(">", i);
2206
- if (end === -1) break;
2207
- const tagName = content.slice(i + 2, end).match(/^\s*([\w.-]*)/)?.[1] ?? "";
2208
- if (tagName) {
2209
- for (let k = stack.length - 1; k >= 0; k--) {
2210
- if (stack[k].tagName === tagName) {
2211
- for (let j = stack.length - 1; j >= k; j--) {
2212
- ancestors.length -= stack[j].pushed;
2213
- }
2214
- stack.length = k;
2215
- break;
2216
- }
2217
- }
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;
2231
+ }
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;
2239
+ }
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 };
2218
2263
  }
2219
- advanceLines(i, end + 1);
2220
- i = end + 1;
2221
- continue;
2222
2264
  }
2223
- if (nextCh && /[A-Za-z_]/.test(nextCh)) {
2224
- const end = findTagEnd(content, i + 1);
2225
- if (end === -1) break;
2226
- const tagSource = content.slice(i, end + 1);
2227
- const tagName = tagSource.match(/^<\s*([\w.-]*)/)?.[1] ?? "";
2228
- const isSelf = content[end - 1] === "/";
2229
- if (tagName) {
2230
- const attrs = parseDataAttrs(tagSource);
2231
- if (attrs.length > 0) {
2232
- const snapshot = ancestors.slice();
2233
- for (const a of attrs) {
2234
- out2.push({ kind: a.kind, id: a.id, line, ancestors: snapshot });
2235
- }
2236
- }
2237
- if (!isSelf) {
2238
- for (const a of attrs) ancestors.push(a);
2239
- stack.push({ tagName, pushed: attrs.length });
2240
- }
2241
- }
2242
- advanceLines(i, end + 1);
2243
- i = end + 1;
2265
+ if (!result2.resolved) {
2266
+ dynamicAttrs.push({
2267
+ kind,
2268
+ attrName: kind === "element" ? "data-uidex" : `data-uidex-${kind}`,
2269
+ line: lineAt(attr.start)
2270
+ });
2244
2271
  continue;
2245
2272
  }
2246
2273
  }
2247
- i++;
2248
- }
2249
- return out2;
2250
- }
2251
- function skipString2(content, start, quote) {
2252
- const N = content.length;
2253
- let i = start + 1;
2254
- while (i < N) {
2255
- const c = content[i];
2256
- if (c === "\\") {
2257
- i += 2;
2258
- continue;
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);
2259
2286
  }
2260
- if (c === quote) return i + 1;
2261
- i++;
2262
2287
  }
2263
- return N;
2288
+ return [...statics, ...patterns];
2264
2289
  }
2265
- function skipTemplate(content, start) {
2266
- const N = content.length;
2267
- let i = start + 1;
2268
- while (i < N) {
2269
- const c = content[i];
2270
- if (c === "\\") {
2271
- i += 2;
2272
- continue;
2273
- }
2274
- if (c === "`") return i + 1;
2275
- if (c === "$" && content[i + 1] === "{") {
2276
- i += 2;
2277
- let depth = 1;
2278
- while (i < N && depth > 0) {
2279
- const cj = content[i];
2280
- if (cj === '"' || cj === "'") {
2281
- i = skipString2(content, i, cj);
2282
- 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
+ });
2283
2322
  }
2284
- if (cj === "`") {
2285
- i = skipTemplate(content, i);
2286
- 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++;
2287
2332
  }
2288
- if (cj === "{") depth++;
2289
- else if (cj === "}") depth--;
2290
- i++;
2291
2333
  }
2292
- continue;
2293
- }
2294
- i++;
2295
- }
2296
- return N;
2297
- }
2298
- function findTagEnd(content, start) {
2299
- const N = content.length;
2300
- let i = start;
2301
- while (i < N) {
2302
- const c = content[i];
2303
- if (c === '"' || c === "'") {
2304
- i = skipString2(content, i, c);
2305
- continue;
2306
- }
2307
- if (c === "`") {
2308
- i = skipTemplate(content, i);
2309
- 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;
2310
2342
  }
2311
- if (c === "{") {
2312
- let depth = 1;
2313
- i++;
2314
- while (i < N && depth > 0) {
2315
- const cj = content[i];
2316
- if (cj === '"' || cj === "'") {
2317
- i = skipString2(content, i, cj);
2318
- continue;
2319
- }
2320
- if (cj === "`") {
2321
- i = skipTemplate(content, i);
2322
- continue;
2323
- }
2324
- if (cj === "{") depth++;
2325
- else if (cj === "}") depth--;
2326
- i++;
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);
2327
2353
  }
2328
- continue;
2329
2354
  }
2330
- if (c === ">") return i;
2331
- i++;
2332
- }
2333
- return -1;
2355
+ };
2356
+ visit(parsed.program);
2357
+ return { occurrences, dynamicAttrs, unannotatedInteractive, landmarks };
2334
2358
  }
2335
-
2336
- // src/scanner/scan/extract.ts
2337
- var JSDOC_BLOCK = /\/\*\*([\s\S]*?)\*\//g;
2338
- function lineAt(content, index) {
2339
- let line = 1;
2340
- for (let i = 0; i < index && i < content.length; i++) {
2341
- 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
+ }
2342
2374
  }
2343
- return line;
2375
+ return null;
2344
2376
  }
2345
- function parseJSDoc(block) {
2346
- const lines = block.split("\n").map((l) => l.replace(/^\s*\*\s?/, "").replace(/^\s*\/?\*+/, ""));
2347
- let kind = null;
2348
- let id = null;
2349
- const acceptance = [];
2350
- const desc = [];
2351
- let notFlow = false;
2352
- for (const raw of lines) {
2353
- const line = raw.trim();
2354
- if (!line) continue;
2355
- const uidex = line.match(
2356
- /^@uidex\s+(page|feature|widget)\s+(\S+)(?:\s+-\s+(.+))?/
2357
- );
2358
- if (uidex) {
2359
- kind = uidex[1];
2360
- id = uidex[2];
2361
- 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;
2362
2387
  continue;
2363
2388
  }
2364
- if (/^@uidex:not-flow\b/.test(line)) {
2365
- notFlow = true;
2366
- 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;
2367
2411
  }
2368
- const accept = line.match(/^@acceptance\s+(.+)$/);
2369
- if (accept) {
2370
- acceptance.push(accept[1].trim());
2371
- 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 ?? ""));
2372
2421
  }
2373
- if (line.startsWith("@")) continue;
2374
- desc.push(line);
2375
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;
2376
2444
  return {
2377
- kind,
2378
- id,
2379
- description: desc.join(" ").trim(),
2380
- acceptance,
2381
- 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"
2382
2451
  };
2383
2452
  }
2384
2453
  function extract(files) {
2385
2454
  return files.map((file) => {
2386
- const { exports: exports2, diagnostics } = extractUidexExports(file);
2387
- const out2 = {
2388
- file,
2389
- annotations: extractOne(file)
2390
- };
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);
2391
2461
  if (exports2.length > 0) out2.metadata = exports2;
2392
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;
2393
2467
  return out2;
2394
2468
  });
2395
2469
  }
2396
- function extractOne(file) {
2470
+ function extractOne(file, parsed, out2) {
2397
2471
  const annotations = [];
2398
- const { content, displayPath } = file;
2399
- 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) {
2400
2480
  annotations.push({
2401
2481
  kind: occ.kind,
2402
2482
  id: occ.id,
2403
2483
  file: displayPath,
2404
2484
  line: occ.line,
2405
- ...occ.ancestors.length > 0 ? { ancestors: occ.ancestors } : {}
2485
+ ...occ.ancestors.length > 0 ? { ancestors: occ.ancestors } : {},
2486
+ ...occ.span ? { span: occ.span } : {}
2406
2487
  });
2407
2488
  }
2408
- JSDOC_BLOCK.lastIndex = 0;
2409
- let jm;
2410
- while ((jm = JSDOC_BLOCK.exec(content)) !== null) {
2411
- const parsed = parseJSDoc(jm[1]);
2412
- const line = lineAt(content, jm.index);
2413
- if (parsed.notFlow) {
2414
- annotations.push({ kind: "not-flow", id: "", file: displayPath, line });
2415
- }
2416
- if (parsed.kind && parsed.id) {
2417
- const kind = parsed.kind === "page" ? "page-doc" : parsed.kind === "feature" ? "feature-doc" : "widget-doc";
2418
- annotations.push({
2419
- kind,
2420
- id: parsed.id,
2421
- file: displayPath,
2422
- line,
2423
- description: parsed.description || void 0,
2424
- acceptance: parsed.acceptance.length ? parsed.acceptance : void 0
2425
- });
2426
- } else if (parsed.acceptance.length > 0) {
2427
- annotations.push({
2428
- kind: "orphan-acceptance",
2429
- id: "",
2430
- file: displayPath,
2431
- line,
2432
- acceptance: parsed.acceptance
2433
- });
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;
2434
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
+ });
2435
2522
  }
2436
- return annotations;
2523
+ return out2;
2437
2524
  }
2438
2525
 
2439
2526
  // src/scanner/scan/git.ts
@@ -2469,7 +2556,7 @@ function parseGitHubRef(ref) {
2469
2556
  }
2470
2557
 
2471
2558
  // src/scanner/scan/resolve.ts
2472
- var path6 = __toESM(require("path"), 1);
2559
+ var path8 = __toESM(require("path"), 1);
2473
2560
 
2474
2561
  // src/scanner/scan/routes.ts
2475
2562
  var PAGE_BASENAME = /^page\.(tsx|ts|jsx|js|mjs|cjs)$/;
@@ -2538,8 +2625,8 @@ function pathToId(routePath) {
2538
2625
  }
2539
2626
 
2540
2627
  // src/scanner/scan/walk.ts
2541
- var fs4 = __toESM(require("fs"), 1);
2542
- var path5 = __toESM(require("path"), 1);
2628
+ var fs5 = __toESM(require("fs"), 1);
2629
+ var path7 = __toESM(require("path"), 1);
2543
2630
  var DEFAULT_INCLUDES = ["**/*.{ts,tsx,js,jsx,mjs,cjs}"];
2544
2631
  var BASE_EXCLUDES = [
2545
2632
  "**/node_modules/**",
@@ -2603,7 +2690,7 @@ function globToRegExp(glob) {
2603
2690
  return new RegExp(`^${out2}$`);
2604
2691
  }
2605
2692
  function toPosix(p2) {
2606
- return p2.split(path5.sep).join("/");
2693
+ return p2.split(path7.sep).join("/");
2607
2694
  }
2608
2695
  function matchesAny(rel, patterns) {
2609
2696
  return patterns.some((g) => globToRegExp(g).test(rel));
@@ -2619,18 +2706,18 @@ function walk(sources, options) {
2619
2706
  ...globalExcludes,
2620
2707
  ...source.exclude ?? []
2621
2708
  ];
2622
- const absRoot = path5.resolve(cwd, source.rootDir);
2709
+ const absRoot = path7.resolve(cwd, source.rootDir);
2623
2710
  for (const filePath of walkDir(absRoot, absRoot)) {
2624
- const rel = toPosix(path5.relative(absRoot, filePath));
2711
+ const rel = toPosix(path7.relative(absRoot, filePath));
2625
2712
  if (matchesAny(rel, excludes)) continue;
2626
2713
  if (!matchesAny(rel, includes)) continue;
2627
2714
  let content;
2628
2715
  try {
2629
- content = fs4.readFileSync(filePath, "utf8");
2716
+ content = fs5.readFileSync(filePath, "utf8");
2630
2717
  } catch {
2631
2718
  continue;
2632
2719
  }
2633
- const relFromCwd = toPosix(path5.relative(cwd, filePath));
2720
+ const relFromCwd = toPosix(path7.relative(cwd, filePath));
2634
2721
  const displayPath = source.prefix ? `${source.prefix.replace(/\/$/, "")}/${rel}` : relFromCwd;
2635
2722
  out2.push({
2636
2723
  sourcePath: filePath,
@@ -2645,12 +2732,12 @@ function walk(sources, options) {
2645
2732
  function* walkDir(root, dir) {
2646
2733
  let entries;
2647
2734
  try {
2648
- entries = fs4.readdirSync(dir, { withFileTypes: true });
2735
+ entries = fs5.readdirSync(dir, { withFileTypes: true });
2649
2736
  } catch {
2650
2737
  return;
2651
2738
  }
2652
2739
  for (const entry of entries) {
2653
- const full = path5.join(dir, entry.name);
2740
+ const full = path7.join(dir, entry.name);
2654
2741
  if (entry.isDirectory()) {
2655
2742
  if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git" || entry.name === "build" || entry.name === ".next") {
2656
2743
  continue;
@@ -2682,21 +2769,9 @@ function kebab(str) {
2682
2769
  return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").replace(/[^a-zA-Z0-9-]/g, "").toLowerCase();
2683
2770
  }
2684
2771
  function baseName(file) {
2685
- const b = path6.posix.basename(file);
2772
+ const b = path8.posix.basename(file);
2686
2773
  return b.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "");
2687
2774
  }
2688
- var LANDMARK_RE = /<(header|nav|main|aside|footer)(\s[^>]*)?>|role=["']region["']/gi;
2689
- function extractLandmarks(file) {
2690
- const out2 = [];
2691
- LANDMARK_RE.lastIndex = 0;
2692
- let m;
2693
- while ((m = LANDMARK_RE.exec(file.content)) !== null) {
2694
- const tag = m[1] ?? "region";
2695
- const line = 1 + file.content.slice(0, m.index).split("\n").length - 1;
2696
- out2.push({ tag, line });
2697
- }
2698
- return out2;
2699
- }
2700
2775
  function fileMatchesAny(displayPath, patterns) {
2701
2776
  return patterns.some((g) => globToRegExp(g).test(displayPath));
2702
2777
  }
@@ -2757,7 +2832,7 @@ function resolve3(ctx) {
2757
2832
  const routes = conventions.pages === "auto" ? detectRoutes(ctx.extracted.map((e) => e.file)) : [];
2758
2833
  const handledPageFiles = /* @__PURE__ */ new Set();
2759
2834
  for (const route of routes) {
2760
- const routeDir = path6.posix.dirname(route.file);
2835
+ const routeDir = path8.posix.dirname(route.file);
2761
2836
  const wellKnownPath = `${routeDir}/${WELL_KNOWN_FILES.page}`;
2762
2837
  const wellKnownExp = exportFor(wellKnownPath, "page");
2763
2838
  const routeExp = exportFor(route.file, "page");
@@ -2811,7 +2886,7 @@ function resolve3(ctx) {
2811
2886
  const dir = extractFeatureDir(ef.file.displayPath, featureGlob);
2812
2887
  if (!dir) continue;
2813
2888
  conventionalFeatureDirs.add(dir);
2814
- 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;
2815
2890
  if (isWellKnown) wellKnownFeatureFileByDir.set(dir, ef.file.displayPath);
2816
2891
  const exp = exportFor(ef.file.displayPath, "feature");
2817
2892
  if (exp) {
@@ -2848,7 +2923,7 @@ function resolve3(ctx) {
2848
2923
  } else if (allExports.length > 0) {
2849
2924
  exp = allExports[0].exp;
2850
2925
  }
2851
- 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);
2852
2927
  const meta = exp ? buildMetaFromExport(exp) : void 0;
2853
2928
  const feature = {
2854
2929
  kind: "feature",
@@ -2944,8 +3019,8 @@ function resolve3(ctx) {
2944
3019
  }
2945
3020
  if (conventions.regions === "landmarks") {
2946
3021
  for (const ef of ctx.extracted) {
2947
- for (const lm of extractLandmarks(ef.file)) {
2948
- const id = kebab(`${lm.tag}`);
3022
+ for (const lm of ef.landmarks ?? []) {
3023
+ const id = lm.tag;
2949
3024
  if (!registry.get("region", id)) {
2950
3025
  const meta = metaWithComposes("region", id);
2951
3026
  const region = {
@@ -3056,7 +3131,7 @@ function resolve3(ctx) {
3056
3131
  const flowExport = (ff.metadata ?? []).find(
3057
3132
  (m) => m.kind === "flow" && typeof m.id === "string"
3058
3133
  );
3059
- const derived = extractFlowsFromSource(ff.file);
3134
+ const derived = flowsFromFacts(ff);
3060
3135
  if (flowExport && typeof flowExport.id === "string" && derived.length === 1) {
3061
3136
  const base = derived[0];
3062
3137
  const flow = {
@@ -3111,52 +3186,14 @@ function computeScope(displayPath) {
3111
3186
  }
3112
3187
  return null;
3113
3188
  }
3114
- function extractFlowsFromSource(file) {
3115
- const flows = [];
3116
- const source = file.content;
3117
- const describeRe = /test\.describe\(\s*(?:'([^']*)'|"([^"]*)")\s*,\s*\{[^}]*tag:\s*(?:'@uidex:flow'|"@uidex:flow"|\[[^\]]*@uidex:flow[^\]]*\])[^}]*\}/g;
3118
- let m;
3119
- while ((m = describeRe.exec(source)) !== null) {
3120
- const title = m[1] ?? m[2];
3121
- const id = kebab(title);
3122
- const line = 1 + source.slice(0, m.index).split("\n").length - 1;
3123
- const after = source.slice(m.index + m[0].length);
3124
- const arrow = after.match(/=>\s*\{/);
3125
- if (!arrow || arrow.index === void 0) continue;
3126
- const bodyStart = m.index + m[0].length + arrow.index + arrow[0].length;
3127
- let depth = 1;
3128
- let bodyEnd = -1;
3129
- for (let i = bodyStart; i < source.length; i++) {
3130
- if (source[i] === "{") depth++;
3131
- else if (source[i] === "}") {
3132
- depth--;
3133
- if (depth === 0) {
3134
- bodyEnd = i;
3135
- break;
3136
- }
3137
- }
3138
- }
3139
- if (bodyEnd === -1) continue;
3140
- const body = source.slice(bodyStart, bodyEnd);
3141
- const touches = captureUidexIds(body);
3142
- flows.push({
3143
- kind: "flow",
3144
- id,
3145
- loc: { file: file.displayPath, line },
3146
- touches: dedupe(touches.map((t) => t.id)),
3147
- steps: touches.filter((t) => t.action).map((t) => ({ entityId: t.id, action: t.action }))
3148
- });
3149
- }
3150
- return flows;
3151
- }
3152
- function captureUidexIds(body) {
3153
- const out2 = [];
3154
- const re = /uidex\(\s*(?:'([^']+)'|"([^"]+)"|`([^`$]+)`)\s*\)(?:\.(\w+)\s*\()?/g;
3155
- let m;
3156
- while ((m = re.exec(body)) !== null) {
3157
- out2.push({ id: m[1] || m[2] || m[3], action: m[4] });
3158
- }
3159
- 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
+ }));
3160
3197
  }
3161
3198
  function dedupe(arr) {
3162
3199
  return Array.from(new Set(arr));
@@ -3191,25 +3228,28 @@ function runOne(dc, opts) {
3191
3228
  const gitContext = resolveGitContext({ cwd: configDir });
3192
3229
  const generated = emit({
3193
3230
  registry: resolved.registry,
3194
- gitContext,
3195
- typeMode: config.typeMode
3231
+ gitContext
3196
3232
  });
3197
- const outputPath = path7.resolve(configDir, config.output);
3233
+ const outputPath = path9.resolve(configDir, config.output);
3198
3234
  const outputRel = config.output;
3199
3235
  let existingOnDisk = null;
3200
3236
  if (opts.check) {
3201
3237
  try {
3202
- existingOnDisk = fs5.readFileSync(outputPath, "utf8");
3238
+ existingOnDisk = fs6.readFileSync(outputPath, "utf8");
3203
3239
  } catch {
3204
3240
  existingOnDisk = null;
3205
3241
  }
3206
3242
  }
3243
+ const hasExtractDiagnostics = [...extracted, ...extractedFlows].some(
3244
+ (ef) => (ef.diagnostics?.length ?? 0) > 0
3245
+ );
3207
3246
  let auditResult;
3208
- if (opts.check || opts.lint || resolved.diagnostics.length > 0) {
3247
+ if (opts.check || opts.lint || resolved.diagnostics.length > 0 || hasExtractDiagnostics) {
3209
3248
  auditResult = audit({
3210
3249
  registry: resolved.registry,
3211
3250
  extracted,
3212
3251
  files: sourceFiles,
3252
+ flowExtracted: extractedFlows,
3213
3253
  config,
3214
3254
  check: opts.check,
3215
3255
  lint: opts.lint,
@@ -3230,29 +3270,185 @@ function runOne(dc, opts) {
3230
3270
  };
3231
3271
  }
3232
3272
  function writeScanResult(result2) {
3233
- fs5.mkdirSync(path7.dirname(result2.outputPath), { recursive: true });
3234
- 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 };
3235
3430
  }
3236
3431
 
3237
3432
  // src/scanner/scan/scaffold.ts
3238
- var fs6 = __toESM(require("fs"), 1);
3239
- var path8 = __toESM(require("path"), 1);
3240
- function scaffoldWidgetSpec(opts) {
3433
+ var fs7 = __toESM(require("fs"), 1);
3434
+ var path10 = __toESM(require("path"), 1);
3435
+ function scaffoldSpec(opts) {
3241
3436
  const {
3242
3437
  registry,
3243
- widgetId,
3438
+ kind,
3439
+ id,
3244
3440
  outDir,
3245
3441
  force = false,
3246
3442
  fixtureImport = "./fixtures"
3247
3443
  } = opts;
3248
- const widget = registry.get("widget", widgetId);
3249
- if (!widget) {
3250
- throw new Error(`Widget "${widgetId}" not found in registry`);
3251
- }
3252
- const criteria = widget.meta?.acceptance ?? [];
3253
- const filename = `widget-${widgetId}.spec.ts`;
3254
- const outputPath = path8.resolve(outDir, filename);
3255
- 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) {
3256
3452
  return {
3257
3453
  outputPath,
3258
3454
  written: false,
@@ -3260,15 +3456,14 @@ function scaffoldWidgetSpec(opts) {
3260
3456
  reason: `spec already exists at ${outputPath}; pass --force to overwrite`
3261
3457
  };
3262
3458
  }
3263
- const content = renderSpec({
3264
- widgetId,
3265
- criteria,
3266
- fixtureImport
3267
- });
3268
- fs6.mkdirSync(path8.dirname(outputPath), { recursive: true });
3269
- 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");
3270
3462
  return { outputPath, written: true, skipped: false };
3271
3463
  }
3464
+ function capitalize(s) {
3465
+ return s.charAt(0).toUpperCase() + s.slice(1);
3466
+ }
3272
3467
  function renderSpec(args) {
3273
3468
  const lines = [];
3274
3469
  lines.push(
@@ -3276,7 +3471,7 @@ function renderSpec(args) {
3276
3471
  );
3277
3472
  lines.push("");
3278
3473
  lines.push(
3279
- `test.describe(${JSON.stringify(args.widgetId)}, { tag: "@uidex:flow" }, () => {`
3474
+ `test.describe(${JSON.stringify(args.id)}, { tag: "@uidex:flow" }, () => {`
3280
3475
  );
3281
3476
  if (args.criteria.length === 0) {
3282
3477
  lines.push(` test("TODO: add acceptance criteria", async () => {`);
@@ -3342,6 +3537,8 @@ async function run(opts) {
3342
3537
  return runScanCommand(cwd, flags, writer);
3343
3538
  case "scaffold":
3344
3539
  return runScaffold(cwd, positional.slice(1), flags, writer);
3540
+ case "rename":
3541
+ return runRename(cwd, positional.slice(1), flags, writer);
3345
3542
  case "ai": {
3346
3543
  const result2 = await runAiCommand({
3347
3544
  cwd,
@@ -3368,7 +3565,8 @@ function helpText2() {
3368
3565
  "Commands:",
3369
3566
  " init Create a .uidex.json",
3370
3567
  " scan [flags] Run the scanner pipeline",
3371
- " 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)",
3372
3570
  " ai <install|uninstall|providers> Manage AI assistant integrations",
3373
3571
  " api <METHOD> <PATH> Call the uidex API",
3374
3572
  " api --list Show available API routes",
@@ -3377,16 +3575,17 @@ function helpText2() {
3377
3575
  "",
3378
3576
  "Flags:",
3379
3577
  " --check Verify the on-disk gen file matches a fresh scan; exit non-zero on drift (read-only)",
3380
- " --lint Run lint diagnostics (missing annotations, scope leak, legacy JSDoc)",
3578
+ " --lint Run lint diagnostics (missing annotations, scope leak, duplicate ids, coverage)",
3381
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",
3382
3581
  " --json Emit JSON diagnostics on stdout",
3383
3582
  " --force (scaffold) overwrite existing spec",
3384
3583
  ""
3385
3584
  ].join("\n");
3386
3585
  }
3387
3586
  function runInit(cwd, w) {
3388
- const configPath = path9.join(cwd, CONFIG_FILENAME);
3389
- if (fs7.existsSync(configPath)) {
3587
+ const configPath = path11.join(cwd, CONFIG_FILENAME);
3588
+ if (fs8.existsSync(configPath)) {
3390
3589
  w.err(`.uidex.json already exists at ${configPath}`);
3391
3590
  return w.result(1);
3392
3591
  }
@@ -3395,16 +3594,16 @@ function runInit(cwd, w) {
3395
3594
  sources: [{ rootDir: "src" }],
3396
3595
  output: "src/uidex.gen.ts"
3397
3596
  };
3398
- fs7.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
3597
+ fs8.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
3399
3598
  w.out(`Created ${configPath}`);
3400
- const gitignorePath = path9.join(cwd, ".gitignore");
3599
+ const gitignorePath = path11.join(cwd, ".gitignore");
3401
3600
  const entry = "*.gen.ts";
3402
- if (fs7.existsSync(gitignorePath)) {
3403
- const existing = fs7.readFileSync(gitignorePath, "utf8");
3601
+ if (fs8.existsSync(gitignorePath)) {
3602
+ const existing = fs8.readFileSync(gitignorePath, "utf8");
3404
3603
  const hasEntry = existing.split("\n").some((line) => line.trim() === entry);
3405
3604
  if (!hasEntry) {
3406
3605
  const needsNewline = existing.length > 0 && !existing.endsWith("\n");
3407
- fs7.appendFileSync(
3606
+ fs8.appendFileSync(
3408
3607
  gitignorePath,
3409
3608
  `${needsNewline ? "\n" : ""}${entry}
3410
3609
  `,
@@ -3413,21 +3612,33 @@ function runInit(cwd, w) {
3413
3612
  w.out(`Appended ${entry} to ${gitignorePath}`);
3414
3613
  }
3415
3614
  } else {
3416
- fs7.writeFileSync(gitignorePath, `${entry}
3615
+ fs8.writeFileSync(gitignorePath, `${entry}
3417
3616
  `, "utf8");
3418
3617
  w.out(`Created ${gitignorePath} with ${entry}`);
3419
3618
  }
3420
3619
  return w.result(0);
3421
3620
  }
3422
3621
  function runScanCommand(cwd, flags, w) {
3423
- const check = Boolean(flags.check || flags.audit);
3424
- 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);
3425
3625
  const asJson = Boolean(flags.json);
3426
- const configs = discover({ cwd });
3626
+ let configs = discover({ cwd });
3427
3627
  if (configs.length === 0) {
3428
3628
  w.err(`No ${CONFIG_FILENAME} found under ${cwd}`);
3429
3629
  return w.result(1);
3430
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
+ }
3431
3642
  const results = runScan({ cwd, check, lint, configs });
3432
3643
  if (!check) {
3433
3644
  for (const r of results) writeScanResult(r);
@@ -3442,9 +3653,21 @@ function runScanCommand(cwd, flags, w) {
3442
3653
  { errors: 0, warnings: 0 }
3443
3654
  );
3444
3655
  if (asJson) {
3445
- const out2 = { diagnostics: allDiagnostics, summary };
3656
+ const out2 = {
3657
+ diagnostics: allDiagnostics.map(jsonDiagnostic),
3658
+ summary,
3659
+ ...fix ? { fixed, fixSkipped } : {}
3660
+ };
3446
3661
  w.out(JSON.stringify(out2, null, 2));
3447
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
+ }
3448
3671
  for (const r of results) {
3449
3672
  if (check) {
3450
3673
  w.out(`Checked ${r.outputPath}`);
@@ -3454,7 +3677,10 @@ function runScanCommand(cwd, flags, w) {
3454
3677
  for (const d of r.audit?.diagnostics ?? []) {
3455
3678
  const loc = d.file ? `${d.file}${d.line ? `:${d.line}` : ""}` : "";
3456
3679
  const stream = d.severity === "error" ? w.err : w.out;
3457
- 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
+ );
3458
3684
  if (d.hint) stream(` hint: ${d.hint}`);
3459
3685
  }
3460
3686
  }
@@ -3465,20 +3691,27 @@ function runScanCommand(cwd, flags, w) {
3465
3691
  const exit = summary.errors > 0 ? 1 : 0;
3466
3692
  return w.result(exit);
3467
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"]);
3468
3699
  function runScaffold(cwd, args, flags, w) {
3469
3700
  const [kind, id] = args;
3470
- if (kind !== "widget" || !id) {
3471
- 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]");
3472
3703
  return w.result(1);
3473
3704
  }
3705
+ const scaffoldKind = kind;
3474
3706
  const results = runScan({ cwd });
3475
3707
  for (const r of results) {
3476
- const widget = r.registry.get("widget", id);
3477
- if (!widget) continue;
3478
- const outDir = path9.resolve(r.configDir, "e2e");
3479
- 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({
3480
3712
  registry: r.registry,
3481
- widgetId: id,
3713
+ kind: scaffoldKind,
3714
+ id,
3482
3715
  outDir,
3483
3716
  force: Boolean(flags.force)
3484
3717
  });
@@ -3489,9 +3722,43 @@ function runScaffold(cwd, args, flags, w) {
3489
3722
  w.out(`Wrote ${result2.outputPath}`);
3490
3723
  return w.result(0);
3491
3724
  }
3492
- 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
+ );
3493
3728
  return w.result(1);
3494
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
+ }
3495
3762
  function createWriter() {
3496
3763
  let stdout = "";
3497
3764
  let stderr = "";
@@ -3551,15 +3818,15 @@ function createFileTokenStorage(options) {
3551
3818
  }
3552
3819
  function defaultTokenPath() {
3553
3820
  const os = require("os");
3554
- const path10 = require("path");
3555
- return path10.join(os.homedir(), ".uidex", "cloud-token.json");
3821
+ const path12 = require("path");
3822
+ return path12.join(os.homedir(), ".uidex", "cloud-token.json");
3556
3823
  }
3557
3824
 
3558
3825
  // src/scanner/cli/http.ts
3559
3826
  function createHttpClient(options) {
3560
3827
  const baseUrl = options.baseUrl.replace(/\/$/, "");
3561
- async function request(path10, opts = {}) {
3562
- let url = `${baseUrl}${path10.startsWith("/") ? path10 : `/${path10}`}`;
3828
+ async function request(path12, opts = {}) {
3829
+ let url = `${baseUrl}${path12.startsWith("/") ? path12 : `/${path12}`}`;
3563
3830
  if (opts.query) {
3564
3831
  url += (url.includes("?") ? "&" : "?") + opts.query;
3565
3832
  }
@@ -3947,9 +4214,9 @@ var API_ROUTES = [
3947
4214
  },
3948
4215
  {
3949
4216
  "method": "POST",
3950
- "path": "/api/organizations/{orgId}/projects/{projectId}/reports/archive",
3951
- "operationId": "bulkArchiveReport",
3952
- "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.",
3953
4220
  "tag": "Reports",
3954
4221
  "params": [
3955
4222
  "orgId",
@@ -4171,17 +4438,17 @@ async function runApiCommand(opts) {
4171
4438
  if (sub === "login") return runLogin(ctx);
4172
4439
  if (sub === "status") return runStatus(ctx);
4173
4440
  const method = sub?.toUpperCase();
4174
- const path10 = positional[1];
4441
+ const path12 = positional[1];
4175
4442
  if (!method || !METHODS.has(method)) {
4176
4443
  stderr.push(`Unknown command or method: ${sub}`);
4177
4444
  stderr.push(HELP);
4178
4445
  return result(1, stdout, stderr);
4179
4446
  }
4180
- if (!path10) {
4447
+ if (!path12) {
4181
4448
  stderr.push("Missing path. Usage: uidex api GET /api/organizations");
4182
4449
  return result(1, stdout, stderr);
4183
4450
  }
4184
- return runRequest(ctx, method, path10);
4451
+ return runRequest(ctx, method, path12);
4185
4452
  }
4186
4453
  function listRoutes(ctx) {
4187
4454
  const { flags, color, stdout, stderr } = ctx;
@@ -4220,7 +4487,7 @@ function listRoutes(ctx) {
4220
4487
  return result(0, stdout, stderr);
4221
4488
  }
4222
4489
  async function runLogin(ctx) {
4223
- const { flags, opts, color, stdout, stderr } = ctx;
4490
+ const { flags, opts, stdout, stderr } = ctx;
4224
4491
  const token = flags.token;
4225
4492
  if (typeof token === "string" && token.length > 0) {
4226
4493
  const storage = resolveTokenStorage(opts);
@@ -4300,7 +4567,7 @@ async function runBrowserLogin(ctx) {
4300
4567
  stdout.push("");
4301
4568
  process.stdout.write(result(0, stdout, stderr).stdout);
4302
4569
  stdout.length = 0;
4303
- openBrowser(authUrl);
4570
+ openBrowser(authUrl, exec);
4304
4571
  });
4305
4572
  server.on("error", (err2) => {
4306
4573
  stderr.push(`Failed to start local server: ${err2.message}`);
@@ -4308,8 +4575,7 @@ async function runBrowserLogin(ctx) {
4308
4575
  });
4309
4576
  });
4310
4577
  }
4311
- function openBrowser(url) {
4312
- const { exec } = require("child_process");
4578
+ function openBrowser(url, exec) {
4313
4579
  const platform = process.platform;
4314
4580
  const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
4315
4581
  exec(`${cmd} ${JSON.stringify(url)}`);
@@ -4323,7 +4589,7 @@ function runStatus(ctx) {
4323
4589
  stdout.push(`auth: ${token ? "authenticated" : "not authenticated"}`);
4324
4590
  return result(token ? 0 : 1, stdout, stderr);
4325
4591
  }
4326
- async function runRequest(ctx, method, path10) {
4592
+ async function runRequest(ctx, method, path12) {
4327
4593
  const { flags, opts, color, stdout, stderr } = ctx;
4328
4594
  const tokenFromFlag = flags.token;
4329
4595
  const token = typeof tokenFromFlag === "string" ? tokenFromFlag : resolveTokenStorage(opts).get();
@@ -4342,7 +4608,7 @@ async function runRequest(ctx, method, path10) {
4342
4608
  return result(1, stdout, stderr);
4343
4609
  }
4344
4610
  }
4345
- const res = await http.request(path10, {
4611
+ const res = await http.request(path12, {
4346
4612
  method,
4347
4613
  body: bodyStr,
4348
4614
  query: typeof flags.query === "string" ? flags.query : void 0