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
@@ -33,7 +33,7 @@ __export(scan_exports, {
33
33
  CONFIG_FILENAME: () => CONFIG_FILENAME,
34
34
  ConfigError: () => ConfigError,
35
35
  DEFAULT_CONVENTIONS: () => DEFAULT_CONVENTIONS,
36
- DEFAULT_TYPE_MODE: () => DEFAULT_TYPE_MODE,
36
+ applyFixes: () => applyFixes,
37
37
  audit: () => audit,
38
38
  detectRoutes: () => detectRoutes,
39
39
  discover: () => discover,
@@ -43,10 +43,12 @@ __export(scan_exports, {
43
43
  globToRegExp: () => globToRegExp,
44
44
  parseConfig: () => parseConfig,
45
45
  pathToId: () => pathToId,
46
+ renameEntity: () => renameEntity,
46
47
  resolve: () => resolve2,
47
48
  resolveGitContext: () => resolveGitContext,
48
49
  runCli: () => run,
49
50
  runScan: () => runScan,
51
+ scaffoldSpec: () => scaffoldSpec,
50
52
  scaffoldWidgetSpec: () => scaffoldWidgetSpec,
51
53
  validateConfig: () => validateConfig,
52
54
  walk: () => walk,
@@ -59,7 +61,6 @@ var fs = __toESM(require("fs"), 1);
59
61
  var path = __toESM(require("path"), 1);
60
62
 
61
63
  // src/scanner/scan/config.ts
62
- var DEFAULT_TYPE_MODE = "strict";
63
64
  var WELL_KNOWN_FILES = {
64
65
  page: "uidex.page.ts",
65
66
  feature: "uidex.feature.ts"
@@ -84,11 +85,9 @@ var ALLOWED_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
84
85
  "exclude",
85
86
  "output",
86
87
  "flows",
87
- "typeMode",
88
88
  "audit",
89
89
  "conventions"
90
90
  ]);
91
- var ALLOWED_TYPE_MODES = /* @__PURE__ */ new Set(["strict", "loose"]);
92
91
  var ALLOWED_SOURCE_KEYS = /* @__PURE__ */ new Set(["rootDir", "include", "exclude", "prefix"]);
93
92
  var ALLOWED_CONVENTIONS_KEYS = /* @__PURE__ */ new Set([
94
93
  "primitives",
@@ -101,14 +100,14 @@ var ALLOWED_AUDIT_KEYS = /* @__PURE__ */ new Set(["scopeLeak", "coverage", "acce
101
100
  function fail(msg) {
102
101
  throw new ConfigError(`Invalid .uidex.json: ${msg}`);
103
102
  }
104
- function assertObject(value, path10) {
103
+ function assertObject(value, path12) {
105
104
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
106
- fail(`${path10} must be an object`);
105
+ fail(`${path12} must be an object`);
107
106
  }
108
107
  }
109
- function assertStringArray(value, path10) {
108
+ function assertStringArray(value, path12) {
110
109
  if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
111
- fail(`${path10} must be a string[]`);
110
+ fail(`${path12} must be a string[]`);
112
111
  }
113
112
  }
114
113
  function validateConfig(raw) {
@@ -156,11 +155,6 @@ function validateConfig(raw) {
156
155
  }
157
156
  if (raw.exclude !== void 0) assertStringArray(raw.exclude, `exclude`);
158
157
  if (raw.flows !== void 0) assertStringArray(raw.flows, `flows`);
159
- if (raw.typeMode !== void 0) {
160
- if (typeof raw.typeMode !== "string" || !ALLOWED_TYPE_MODES.has(raw.typeMode)) {
161
- fail(`"typeMode" must be "strict" or "loose"`);
162
- }
163
- }
164
158
  if (raw.audit !== void 0) {
165
159
  assertObject(raw.audit, "audit");
166
160
  for (const key of Object.keys(raw.audit)) {
@@ -199,7 +193,6 @@ function validateConfig(raw) {
199
193
  exclude: raw.exclude,
200
194
  output: raw.output,
201
195
  flows: raw.flows,
202
- typeMode: raw.typeMode ?? DEFAULT_TYPE_MODE,
203
196
  audit: raw.audit,
204
197
  conventions: raw.conventions
205
198
  };
@@ -406,6 +399,90 @@ function* walkDir(root, dir) {
406
399
  }
407
400
  }
408
401
 
402
+ // src/scanner/scan/ast.ts
403
+ var path3 = __toESM(require("path"), 1);
404
+ var import_oxc_parser = require("oxc-parser");
405
+ function langFor(sourcePath) {
406
+ switch (path3.extname(sourcePath)) {
407
+ case ".tsx":
408
+ return "tsx";
409
+ case ".ts":
410
+ case ".mts":
411
+ case ".cts":
412
+ return "ts";
413
+ default:
414
+ return "jsx";
415
+ }
416
+ }
417
+ function makeLineAt(content) {
418
+ const starts = [0];
419
+ for (let i = 0; i < content.length; i++) {
420
+ if (content[i] === "\n") starts.push(i + 1);
421
+ }
422
+ return (offset) => {
423
+ let lo = 0;
424
+ let hi = starts.length - 1;
425
+ while (lo < hi) {
426
+ const mid = lo + hi + 1 >> 1;
427
+ if (starts[mid] <= offset) lo = mid;
428
+ else hi = mid - 1;
429
+ }
430
+ return lo + 1;
431
+ };
432
+ }
433
+ function parseSource(file) {
434
+ const lineAt = makeLineAt(file.content);
435
+ try {
436
+ const result = (0, import_oxc_parser.parseSync)(file.sourcePath, file.content, {
437
+ lang: langFor(file.sourcePath),
438
+ sourceType: "module"
439
+ });
440
+ return {
441
+ program: result.program,
442
+ hasErrors: result.errors.length > 0,
443
+ comments: result.comments ?? [],
444
+ lineAt
445
+ };
446
+ } catch {
447
+ return { program: null, hasErrors: true, comments: [], lineAt };
448
+ }
449
+ }
450
+ function isNode(value) {
451
+ return typeof value === "object" && value !== null && typeof value.type === "string";
452
+ }
453
+ function walkAst(root, visit) {
454
+ if (!isNode(root)) return;
455
+ if (visit(root) === false) return;
456
+ for (const key of Object.keys(root)) {
457
+ if (key === "type" || key === "start" || key === "end") continue;
458
+ const value = root[key];
459
+ if (Array.isArray(value)) {
460
+ for (const item of value) walkAst(item, visit);
461
+ } else if (isNode(value)) {
462
+ walkAst(value, visit);
463
+ }
464
+ }
465
+ }
466
+ function unwrapTsExpression(expr) {
467
+ let node = expr;
468
+ for (; ; ) {
469
+ switch (node.type) {
470
+ case "TSAsExpression":
471
+ case "TSSatisfiesExpression":
472
+ case "TSNonNullExpression":
473
+ case "TSTypeAssertion":
474
+ case "ParenthesizedExpression": {
475
+ const inner = node.expression;
476
+ if (!isNode(inner)) return node;
477
+ node = inner;
478
+ break;
479
+ }
480
+ default:
481
+ return node;
482
+ }
483
+ }
484
+ }
485
+
409
486
  // src/scanner/scan/extract-uidex-export.ts
410
487
  var KIND_DISCRIMINATORS = [
411
488
  "page",
@@ -443,6 +520,16 @@ var FALSEABLE = /* @__PURE__ */ new Set([
443
520
  "primitive",
444
521
  "region"
445
522
  ]);
523
+ var SATISFIES_NAMES = {
524
+ page: "Page",
525
+ feature: "Feature",
526
+ primitive: "Primitive",
527
+ widget: "Widget",
528
+ region: "Region",
529
+ flow: "Flow",
530
+ notFlow: "NotFlow"
531
+ };
532
+ var KNOWN_SATISFIES = new Set(Object.values(SATISFIES_NAMES));
446
533
  var ExtractError = class extends Error {
447
534
  code;
448
535
  hint;
@@ -454,649 +541,285 @@ var ExtractError = class extends Error {
454
541
  this.hint = hint;
455
542
  }
456
543
  };
457
- function extractUidexExports(file) {
544
+ function extractUidexExports(file, parsed) {
458
545
  const exports2 = [];
459
546
  const diagnostics = [];
460
547
  const { content, displayPath } = file;
461
- for (const header of findExportHeaders(content)) {
462
- try {
463
- const value = parseExpression(content, header.exprStart);
464
- const metadata = buildMetadata(
465
- value,
466
- displayPath,
467
- header.headerPos,
468
- diagnostics
469
- );
470
- exports2.push(metadata);
471
- } catch (e) {
472
- if (e instanceof ExtractError) {
473
- diagnostics.push({
474
- code: e.code,
475
- severity: "error",
476
- message: e.message,
477
- file: displayPath,
478
- line: e.pos.line,
479
- hint: e.hint
480
- });
481
- } else {
482
- throw e;
483
- }
484
- }
485
- }
486
- return { exports: exports2, diagnostics };
487
- }
488
- var HEADER_RE = /(?:^|\n)[\t ]*export\s+const\s+uidex\b(?:\s*:\s*[^=\n]+?)?\s*=\s*/g;
489
- function findExportHeaders(content) {
490
- const out2 = [];
491
- HEADER_RE.lastIndex = 0;
492
- let m;
493
- while ((m = HEADER_RE.exec(content)) !== null) {
494
- const leadingNewline = m[0].startsWith("\n") ? 1 : 0;
495
- const headerOffset = m.index + leadingNewline;
496
- const exprStart = m.index + m[0].length;
497
- if (isInsideCommentOrString(content, headerOffset)) continue;
498
- out2.push({
499
- headerPos: posAt(content, headerOffset),
500
- exprStart
501
- });
502
- }
503
- return out2;
504
- }
505
- function isInsideCommentOrString(content, target) {
506
- let i = 0;
507
- let inLineComment = false;
508
- let inBlockComment = false;
509
- let stringDelim = null;
510
- let inTemplate = false;
511
- let templateDepth = 0;
512
- while (i < target) {
513
- const c = content[i];
514
- const n = content[i + 1];
515
- if (inLineComment) {
516
- if (c === "\n") inLineComment = false;
517
- i++;
518
- continue;
519
- }
520
- if (inBlockComment) {
521
- if (c === "*" && n === "/") {
522
- inBlockComment = false;
523
- i += 2;
524
- continue;
525
- }
526
- i++;
527
- continue;
528
- }
529
- if (stringDelim !== null) {
530
- if (c === "\\") {
531
- i += 2;
532
- continue;
533
- }
534
- if (c === stringDelim) stringDelim = null;
535
- i++;
536
- continue;
537
- }
538
- if (inTemplate) {
539
- if (c === "\\") {
540
- i += 2;
548
+ const p2 = parsed ?? parseSource(file);
549
+ if (p2.program === null) return { exports: exports2, diagnostics };
550
+ for (const stmt of p2.program.body) {
551
+ if (stmt.type !== "ExportNamedDeclaration") continue;
552
+ const decl = stmt.declaration;
553
+ if (!isNode2(decl) || decl.type !== "VariableDeclaration") continue;
554
+ if (decl.kind !== "const") continue;
555
+ for (const declarator of decl.declarations ?? []) {
556
+ const id = declarator.id;
557
+ if (!id || id.type !== "Identifier" || String(id.name) !== "uidex") {
541
558
  continue;
542
559
  }
543
- if (c === "$" && n === "{") {
544
- templateDepth++;
545
- i += 2;
546
- continue;
547
- }
548
- if (c === "`" && templateDepth === 0) {
549
- inTemplate = false;
550
- i++;
551
- continue;
552
- }
553
- if (templateDepth > 0 && c === "}") {
554
- templateDepth--;
555
- i++;
556
- continue;
557
- }
558
- i++;
559
- continue;
560
- }
561
- if (c === "/" && n === "/") {
562
- inLineComment = true;
563
- i += 2;
564
- continue;
565
- }
566
- if (c === "/" && n === "*") {
567
- inBlockComment = true;
568
- i += 2;
569
- continue;
570
- }
571
- if (c === '"' || c === "'") {
572
- stringDelim = c;
573
- i++;
574
- continue;
575
- }
576
- if (c === "`") {
577
- inTemplate = true;
578
- i++;
579
- continue;
580
- }
581
- i++;
582
- }
583
- return inLineComment || inBlockComment || stringDelim !== null || inTemplate;
584
- }
585
- var Tokenizer = class {
586
- constructor(src, start) {
587
- this.src = src;
588
- this.pos = start;
589
- let line = 1;
590
- let lineStart = 0;
591
- for (let i = 0; i < start; i++) {
592
- if (src[i] === "\n") {
593
- line++;
594
- lineStart = i + 1;
595
- }
596
- }
597
- this.line = line;
598
- this.lineStart = lineStart;
599
- }
600
- src;
601
- pos;
602
- line;
603
- lineStart;
604
- currentPos() {
605
- return {
606
- offset: this.pos,
607
- line: this.line,
608
- column: this.pos - this.lineStart + 1
609
- };
610
- }
611
- advance(n = 1) {
612
- for (let i = 0; i < n; i++) {
613
- if (this.pos < this.src.length && this.src[this.pos] === "\n") {
614
- this.line++;
615
- this.lineStart = this.pos + 1;
616
- }
617
- this.pos++;
618
- }
619
- }
620
- skipTrivia() {
621
- while (this.pos < this.src.length) {
622
- const c = this.src[this.pos];
623
- const n = this.src[this.pos + 1];
624
- if (c === " " || c === " " || c === "\r" || c === "\n") {
625
- this.advance();
626
- continue;
627
- }
628
- if (c === "/" && n === "/") {
629
- while (this.pos < this.src.length && this.src[this.pos] !== "\n") {
630
- this.advance();
560
+ const headerPos = posAt(content, stmt.start, p2);
561
+ try {
562
+ const init = declarator.init;
563
+ if (!isNode2(init)) {
564
+ throw new ExtractError(
565
+ "uidex-export-invalid-literal",
566
+ "`export const uidex` must be assigned an object literal.",
567
+ headerPos
568
+ );
631
569
  }
632
- continue;
633
- }
634
- if (c === "/" && n === "*") {
635
- this.advance(2);
636
- while (this.pos < this.src.length) {
637
- if (this.src[this.pos] === "*" && this.src[this.pos + 1] === "/") {
638
- this.advance(2);
639
- break;
640
- }
641
- this.advance();
570
+ const value = toLitValue(unwrapTsExpression(init), content, p2);
571
+ const metadata = buildMetadata(
572
+ value,
573
+ displayPath,
574
+ file.sourcePath,
575
+ headerPos,
576
+ diagnostics
577
+ );
578
+ metadata.span = statementSpan(stmt, content);
579
+ checkSatisfies(init, metadata, displayPath, p2, diagnostics);
580
+ exports2.push(metadata);
581
+ } catch (e) {
582
+ if (e instanceof ExtractError) {
583
+ diagnostics.push({
584
+ code: e.code,
585
+ severity: "error",
586
+ message: e.message,
587
+ file: displayPath,
588
+ line: e.pos.line,
589
+ hint: e.hint
590
+ });
591
+ } else {
592
+ throw e;
642
593
  }
643
- continue;
644
594
  }
645
- break;
646
595
  }
647
596
  }
648
- next() {
649
- this.skipTrivia();
650
- if (this.pos >= this.src.length) {
651
- return { kind: "eof", value: "", pos: this.currentPos(), end: this.pos };
652
- }
653
- const pos = this.currentPos();
654
- const c = this.src[this.pos];
655
- switch (c) {
656
- case "{":
657
- this.advance();
658
- return { kind: "lbrace", value: c, pos, end: this.pos };
659
- case "}":
660
- this.advance();
661
- return { kind: "rbrace", value: c, pos, end: this.pos };
662
- case "[":
663
- this.advance();
664
- return { kind: "lbracket", value: c, pos, end: this.pos };
665
- case "]":
666
- this.advance();
667
- return { kind: "rbracket", value: c, pos, end: this.pos };
668
- case "(":
669
- this.advance();
670
- return { kind: "lparen", value: c, pos, end: this.pos };
671
- case ")":
672
- this.advance();
673
- return { kind: "rparen", value: c, pos, end: this.pos };
674
- case ",":
675
- this.advance();
676
- return { kind: "comma", value: c, pos, end: this.pos };
677
- case ":":
678
- this.advance();
679
- return { kind: "colon", value: c, pos, end: this.pos };
680
- }
681
- if (c === "." && this.src[this.pos + 1] === "." && this.src[this.pos + 2] === ".") {
682
- this.advance(3);
683
- return { kind: "spread", value: "...", pos, end: this.pos };
684
- }
685
- if (c === '"' || c === "'") {
686
- return this.readString(pos, c);
687
- }
688
- if (c === "`") {
689
- return this.readTemplate(pos);
690
- }
691
- if (isDigit(c) || c === "-" && isDigit(this.src[this.pos + 1])) {
692
- return this.readNumber(pos);
693
- }
694
- if (isIdentStart(c)) {
695
- return this.readIdent(pos);
696
- }
697
- this.advance();
698
- return { kind: "punct", value: c, pos, end: this.pos };
699
- }
700
- readString(pos, delim) {
701
- this.advance();
702
- let value = "";
703
- while (this.pos < this.src.length) {
704
- const c = this.src[this.pos];
705
- if (c === "\\") {
706
- const esc = this.src[this.pos + 1];
707
- this.advance(2);
708
- value += decodeEscape(esc);
709
- continue;
710
- }
711
- if (c === delim) {
712
- this.advance();
713
- return { kind: "string", value, pos, end: this.pos };
714
- }
715
- if (c === "\n") {
716
- return { kind: "punct", value: delim, pos, end: this.pos };
717
- }
718
- value += c;
719
- this.advance();
720
- }
721
- return { kind: "punct", value: delim, pos, end: this.pos };
722
- }
723
- readTemplate(pos) {
724
- this.advance();
725
- let value = "";
726
- let hasExpression = false;
727
- while (this.pos < this.src.length) {
728
- const c = this.src[this.pos];
729
- const n = this.src[this.pos + 1];
730
- if (c === "\\") {
731
- const esc = this.src[this.pos + 1];
732
- this.advance(2);
733
- value += decodeEscape(esc);
734
- continue;
735
- }
736
- if (c === "$" && n === "{") {
737
- hasExpression = true;
738
- this.advance(2);
739
- let depth = 1;
740
- while (this.pos < this.src.length && depth > 0) {
741
- const ch = this.src[this.pos];
742
- if (ch === "{") depth++;
743
- else if (ch === "}") depth--;
744
- this.advance();
597
+ return { exports: exports2, diagnostics };
598
+ }
599
+ function toLitValue(node, content, p2) {
600
+ const unwrapped = unwrapTsExpression(node);
601
+ const pos = posAt(content, unwrapped.start, p2);
602
+ const span = { start: unwrapped.start, end: unwrapped.end };
603
+ switch (unwrapped.type) {
604
+ case "Literal": {
605
+ const v = unwrapped.value;
606
+ if (typeof v === "string") return { kind: "string", value: v, pos, span };
607
+ if (typeof v === "number") {
608
+ if (!Number.isFinite(v)) {
609
+ throw new ExtractError(
610
+ "uidex-export-invalid-literal",
611
+ `Invalid numeric literal in \`export const uidex\`.`,
612
+ pos
613
+ );
745
614
  }
746
- continue;
615
+ return { kind: "number", value: v, pos, span };
747
616
  }
748
- if (c === "`") {
749
- this.advance();
750
- if (hasExpression) {
751
- return { kind: "template", value, pos, end: this.pos };
752
- }
753
- return { kind: "string", value, pos, end: this.pos };
617
+ if (typeof v === "boolean") {
618
+ return { kind: "boolean", value: v, pos, span };
754
619
  }
755
- value += c;
756
- this.advance();
757
- }
758
- return { kind: "template", value, pos, end: this.pos };
759
- }
760
- readNumber(pos) {
761
- const start = this.pos;
762
- if (this.src[this.pos] === "-") this.advance();
763
- while (this.pos < this.src.length && isDigit(this.src[this.pos])) {
764
- this.advance();
765
- }
766
- if (this.src[this.pos] === ".") {
767
- this.advance();
768
- while (this.pos < this.src.length && isDigit(this.src[this.pos])) {
769
- this.advance();
620
+ if (v === null && unwrapped.raw === "null") {
621
+ return { kind: "null", pos, span };
770
622
  }
623
+ throw new ExtractError(
624
+ "uidex-export-invalid-literal",
625
+ `Unsupported literal in \`export const uidex\`; only strings, numbers, booleans, and null are allowed.`,
626
+ pos
627
+ );
771
628
  }
772
- if (this.src[this.pos] === "e" || this.src[this.pos] === "E") {
773
- this.advance();
774
- if (this.src[this.pos] === "+" || this.src[this.pos] === "-") {
775
- this.advance();
629
+ case "UnaryExpression": {
630
+ const arg = unwrapped.argument;
631
+ if (unwrapped.operator === "-" && isNode2(arg) && arg.type === "Literal" && typeof arg.value === "number") {
632
+ return { kind: "number", value: -arg.value, pos, span };
776
633
  }
777
- while (this.pos < this.src.length && isDigit(this.src[this.pos])) {
778
- this.advance();
779
- }
780
- }
781
- const value = this.src.slice(start, this.pos);
782
- return { kind: "number", value, pos, end: this.pos };
783
- }
784
- readIdent(pos) {
785
- const start = this.pos;
786
- while (this.pos < this.src.length && isIdentPart(this.src[this.pos])) {
787
- this.advance();
634
+ throw new ExtractError(
635
+ "uidex-export-invalid-literal",
636
+ "Unary expressions are not allowed in `export const uidex`.",
637
+ pos
638
+ );
788
639
  }
789
- const value = this.src.slice(start, this.pos);
790
- return { kind: "ident", value, pos, end: this.pos };
791
- }
792
- };
793
- function isDigit(c) {
794
- return c !== void 0 && c >= "0" && c <= "9";
795
- }
796
- function isIdentStart(c) {
797
- if (c === void 0) return false;
798
- return c >= "a" && c <= "z" || c >= "A" && c <= "Z" || c === "_" || c === "$";
799
- }
800
- function isIdentPart(c) {
801
- return isIdentStart(c) || isDigit(c);
802
- }
803
- function decodeEscape(esc) {
804
- switch (esc) {
805
- case "n":
806
- return "\n";
807
- case "t":
808
- return " ";
809
- case "r":
810
- return "\r";
811
- case "\\":
812
- return "\\";
813
- case "'":
814
- return "'";
815
- case '"':
816
- return '"';
817
- case "`":
818
- return "`";
819
- case "0":
820
- return "\0";
821
- case "b":
822
- return "\b";
823
- case "f":
824
- return "\f";
825
- case "v":
826
- return "\v";
827
- default:
828
- return esc ?? "";
829
- }
830
- }
831
- function parseExpression(content, start) {
832
- const tokenizer = new Tokenizer(content, start);
833
- const parser = new Parser(tokenizer);
834
- const value = parser.parseValue();
835
- parser.consumeTrailingAssertions();
836
- return value;
837
- }
838
- var Parser = class {
839
- constructor(tok) {
840
- this.tok = tok;
841
- }
842
- tok;
843
- lookahead = null;
844
- peek() {
845
- if (this.lookahead === null) this.lookahead = this.tok.next();
846
- return this.lookahead;
847
- }
848
- consume() {
849
- const t = this.peek();
850
- this.lookahead = null;
851
- return t;
852
- }
853
- parseValue() {
854
- const t = this.peek();
855
- switch (t.kind) {
856
- case "lbrace":
857
- return this.parseObject();
858
- case "lbracket":
859
- return this.parseArray();
860
- case "string":
861
- this.consume();
862
- return { kind: "string", value: t.value, pos: t.pos };
863
- case "template":
640
+ case "TemplateLiteral": {
641
+ const expressions = unwrapped.expressions ?? [];
642
+ if (expressions.length > 0) {
864
643
  throw new ExtractError(
865
644
  "uidex-export-invalid-literal",
866
645
  "Template literal with expression parts is not allowed in `export const uidex`; use a plain string literal.",
867
- t.pos
868
- );
869
- case "number": {
870
- this.consume();
871
- const n = Number(t.value);
872
- if (!Number.isFinite(n)) {
873
- throw new ExtractError(
874
- "uidex-export-invalid-literal",
875
- `Invalid numeric literal "${t.value}" in \`export const uidex\`.`,
876
- t.pos
877
- );
878
- }
879
- return { kind: "number", value: n, pos: t.pos };
880
- }
881
- case "ident":
882
- if (t.value === "true" || t.value === "false") {
883
- this.consume();
884
- return {
885
- kind: "boolean",
886
- value: t.value === "true",
887
- pos: t.pos
888
- };
889
- }
890
- if (t.value === "null") {
891
- this.consume();
892
- return { kind: "null", pos: t.pos };
893
- }
894
- if (t.value === "undefined") {
895
- throw new ExtractError(
896
- "uidex-export-invalid-literal",
897
- "`undefined` is not allowed as a value in `export const uidex`; omit the field instead.",
898
- t.pos
899
- );
900
- }
901
- throw new ExtractError(
902
- "uidex-export-invalid-literal",
903
- `Identifier reference "${t.value}" is not allowed in \`export const uidex\`; the right-hand side must be a plain literal.`,
904
- t.pos
646
+ pos
905
647
  );
906
- case "spread":
907
- throw new ExtractError(
908
- "uidex-export-invalid-literal",
909
- "Spread (`...`) is not allowed in `export const uidex`; the right-hand side must be a plain literal.",
910
- t.pos
911
- );
912
- case "lparen":
913
- throw new ExtractError(
914
- "uidex-export-invalid-literal",
915
- "Parenthesised or grouped expressions are not allowed in `export const uidex`.",
916
- t.pos
917
- );
918
- case "punct":
919
- throw new ExtractError(
920
- "uidex-export-invalid-literal",
921
- `Unexpected token "${t.value}" in \`export const uidex\`.`,
922
- t.pos
923
- );
924
- case "eof":
925
- throw new ExtractError(
926
- "uidex-export-invalid-literal",
927
- "Expected a value for `export const uidex` but reached end of file.",
928
- t.pos
929
- );
930
- default:
931
- throw new ExtractError(
932
- "uidex-export-invalid-literal",
933
- `Unexpected token in \`export const uidex\`.`,
934
- t.pos
935
- );
936
- }
937
- }
938
- parseObject() {
939
- const open = this.consume();
940
- const entries = [];
941
- const seen = /* @__PURE__ */ new Set();
942
- while (true) {
943
- const t = this.peek();
944
- if (t.kind === "rbrace") {
945
- this.consume();
946
- break;
947
648
  }
948
- if (t.kind === "spread") {
649
+ const quasis = unwrapped.quasis ?? [];
650
+ const cooked = quasis[0]?.value?.cooked ?? "";
651
+ return { kind: "string", value: cooked, pos, span };
652
+ }
653
+ case "Identifier": {
654
+ const name = String(unwrapped.name);
655
+ if (name === "undefined") {
949
656
  throw new ExtractError(
950
657
  "uidex-export-invalid-literal",
951
- "Spread (`...`) is not allowed inside `export const uidex`.",
952
- t.pos
658
+ "`undefined` is not allowed as a value in `export const uidex`; omit the field instead.",
659
+ pos
953
660
  );
954
661
  }
955
- if (t.kind === "lbracket") {
956
- this.consume();
957
- const keyTok = this.peek();
958
- if (keyTok.kind !== "string") {
959
- throw new ExtractError(
960
- "uidex-export-invalid-literal",
961
- "Computed property keys must be string literals in `export const uidex`.",
962
- keyTok.pos
963
- );
964
- }
965
- this.consume();
966
- const close = this.peek();
967
- if (close.kind !== "rbracket") {
968
- throw new ExtractError(
969
- "uidex-export-invalid-literal",
970
- "Expected `]` after computed property key.",
971
- close.pos
972
- );
973
- }
974
- this.consume();
975
- const colon = this.peek();
976
- if (colon.kind !== "colon") {
977
- throw new ExtractError(
978
- "uidex-export-invalid-literal",
979
- "Expected `:` after computed property key.",
980
- colon.pos
981
- );
982
- }
983
- this.consume();
984
- const value = this.parseValue();
985
- this.recordEntry(entries, seen, keyTok.value, value, keyTok.pos);
986
- } else if (t.kind === "ident" || t.kind === "string") {
987
- const keyTok = this.consume();
988
- const next = this.peek();
989
- if (next.kind === "colon") {
990
- this.consume();
991
- const value = this.parseValue();
992
- this.recordEntry(entries, seen, keyTok.value, value, keyTok.pos);
993
- } else {
994
- throw new ExtractError(
995
- "uidex-export-invalid-literal",
996
- keyTok.kind === "ident" ? `Shorthand property "${keyTok.value}" is not allowed; write "${keyTok.value}: ..." with a literal value.` : "Expected `:` after property key.",
997
- keyTok.pos
998
- );
999
- }
1000
- } else if (t.kind === "number") {
1001
- throw new ExtractError(
1002
- "uidex-export-invalid-literal",
1003
- "Numeric property keys are not allowed in `export const uidex`.",
1004
- t.pos
1005
- );
1006
- } else {
662
+ throw new ExtractError(
663
+ "uidex-export-invalid-literal",
664
+ `Identifier reference "${name}" is not allowed in \`export const uidex\`; the right-hand side must be a plain literal.`,
665
+ pos
666
+ );
667
+ }
668
+ case "ObjectExpression":
669
+ return objectLit(unwrapped, content, p2, pos, span);
670
+ case "ArrayExpression":
671
+ return arrayLit(unwrapped, content, p2, pos, span);
672
+ case "SpreadElement":
673
+ throw new ExtractError(
674
+ "uidex-export-invalid-literal",
675
+ "Spread (`...`) is not allowed in `export const uidex`; the right-hand side must be a plain literal.",
676
+ pos
677
+ );
678
+ case "CallExpression":
679
+ throw new ExtractError(
680
+ "uidex-export-invalid-literal",
681
+ "Function calls are not allowed in `export const uidex`; the right-hand side must be a plain literal.",
682
+ pos
683
+ );
684
+ case "ConditionalExpression":
685
+ throw new ExtractError(
686
+ "uidex-export-invalid-literal",
687
+ "Conditional expressions are not allowed in `export const uidex`; the right-hand side must be a plain literal.",
688
+ pos
689
+ );
690
+ default:
691
+ throw new ExtractError(
692
+ "uidex-export-invalid-literal",
693
+ `Computed expressions are not allowed in \`export const uidex\`; the right-hand side must be a plain literal.`,
694
+ pos
695
+ );
696
+ }
697
+ }
698
+ function objectLit(node, content, p2, pos, span) {
699
+ const entries = [];
700
+ const seen = /* @__PURE__ */ new Set();
701
+ for (const prop of node.properties ?? []) {
702
+ if (prop.type === "SpreadElement") {
703
+ throw new ExtractError(
704
+ "uidex-export-invalid-literal",
705
+ "Spread (`...`) is not allowed inside `export const uidex`.",
706
+ posAt(content, prop.start, p2)
707
+ );
708
+ }
709
+ if (prop.type !== "Property") {
710
+ throw new ExtractError(
711
+ "uidex-export-invalid-literal",
712
+ "Unexpected member inside `export const uidex` object.",
713
+ posAt(content, prop.start, p2)
714
+ );
715
+ }
716
+ const keyNode = prop.key;
717
+ const keyPos = posAt(content, keyNode.start, p2);
718
+ if (prop.shorthand) {
719
+ throw new ExtractError(
720
+ "uidex-export-invalid-literal",
721
+ `Shorthand property "${String(keyNode.name)}" is not allowed; write "${String(keyNode.name)}: ..." with a literal value.`,
722
+ keyPos
723
+ );
724
+ }
725
+ let key;
726
+ if (prop.computed) {
727
+ if (keyNode.type !== "Literal" || typeof keyNode.value !== "string") {
1007
728
  throw new ExtractError(
1008
729
  "uidex-export-invalid-literal",
1009
- `Unexpected token "${t.value}" inside object.`,
1010
- t.pos
730
+ "Computed property keys must be string literals in `export const uidex`.",
731
+ keyPos
1011
732
  );
1012
733
  }
1013
- const after = this.peek();
1014
- if (after.kind === "comma") {
1015
- this.consume();
1016
- continue;
1017
- }
1018
- if (after.kind === "rbrace") {
1019
- this.consume();
1020
- break;
1021
- }
734
+ key = keyNode.value;
735
+ } else if (keyNode.type === "Identifier") {
736
+ key = String(keyNode.name);
737
+ } else if (keyNode.type === "Literal" && typeof keyNode.value === "string") {
738
+ key = keyNode.value;
739
+ } else {
1022
740
  throw new ExtractError(
1023
741
  "uidex-export-invalid-literal",
1024
- `Expected \`,\` or \`}\`, got "${after.value}".`,
1025
- after.pos
742
+ "Numeric property keys are not allowed in `export const uidex`.",
743
+ keyPos
1026
744
  );
1027
745
  }
1028
- return { kind: "object", entries, pos: open.pos };
1029
- }
1030
- recordEntry(entries, seen, key, value, pos) {
1031
746
  if (seen.has(key)) {
1032
747
  throw new ExtractError(
1033
748
  "uidex-export-duplicate-field",
1034
749
  `Duplicate field "${key}" in \`export const uidex\`.`,
1035
- pos
750
+ keyPos
1036
751
  );
1037
752
  }
1038
753
  seen.add(key);
1039
- entries.push([key, value]);
1040
- }
1041
- parseArray() {
1042
- const open = this.consume();
1043
- const items = [];
1044
- while (true) {
1045
- const t = this.peek();
1046
- if (t.kind === "rbracket") {
1047
- this.consume();
1048
- break;
1049
- }
1050
- if (t.kind === "spread") {
1051
- throw new ExtractError(
1052
- "uidex-export-invalid-literal",
1053
- "Spread (`...`) is not allowed inside `export const uidex`.",
1054
- t.pos
1055
- );
1056
- }
1057
- const value = this.parseValue();
1058
- if (value.kind === "object") {
1059
- }
1060
- items.push(value);
1061
- const after = this.peek();
1062
- if (after.kind === "comma") {
1063
- this.consume();
1064
- continue;
1065
- }
1066
- if (after.kind === "rbracket") {
1067
- this.consume();
1068
- break;
1069
- }
754
+ const value = toLitValue(prop.value, content, p2);
755
+ entries.push({
756
+ key,
757
+ value,
758
+ keyPos,
759
+ span: removalSpan(content, prop.start, prop.end)
760
+ });
761
+ }
762
+ return { kind: "object", entries, pos, span };
763
+ }
764
+ function arrayLit(node, content, p2, pos, span) {
765
+ const items = [];
766
+ for (const el of node.elements ?? []) {
767
+ if (el === null) {
768
+ throw new ExtractError(
769
+ "uidex-export-invalid-literal",
770
+ "Array holes are not allowed in `export const uidex`.",
771
+ pos
772
+ );
773
+ }
774
+ if (el.type === "SpreadElement") {
1070
775
  throw new ExtractError(
1071
776
  "uidex-export-invalid-literal",
1072
- `Expected \`,\` or \`]\`, got "${after.value}".`,
1073
- after.pos
777
+ "Spread (`...`) is not allowed inside `export const uidex`.",
778
+ posAt(content, el.start, p2)
1074
779
  );
1075
780
  }
1076
- return { kind: "array", items, pos: open.pos };
781
+ items.push(toLitValue(el, content, p2));
1077
782
  }
1078
- consumeTrailingAssertions() {
1079
- const first = this.peek();
1080
- if (first.kind === "ident" && first.value === "as") {
1081
- this.consume();
1082
- const next = this.peek();
1083
- if (next.kind === "ident" && next.value === "const") {
1084
- this.consume();
1085
- } else {
1086
- throw new ExtractError(
1087
- "uidex-export-invalid-literal",
1088
- "Only `as const` is allowed after the `export const uidex` value.",
1089
- next.pos
1090
- );
1091
- }
783
+ return { kind: "array", items, pos, span };
784
+ }
785
+ function checkSatisfies(init, metadata, file, p2, diagnostics) {
786
+ let node = init;
787
+ let satisfiesType;
788
+ while (isNode2(node)) {
789
+ if (node.type === "TSSatisfiesExpression") {
790
+ satisfiesType = node.typeAnnotation;
791
+ break;
1092
792
  }
1093
- const maybeSatisfies = this.peek();
1094
- if (maybeSatisfies.kind === "ident" && maybeSatisfies.value === "satisfies") {
1095
- return;
793
+ if (node.type === "TSAsExpression" || node.type === "TSNonNullExpression" || node.type === "TSTypeAssertion" || node.type === "ParenthesizedExpression") {
794
+ node = node.expression;
795
+ continue;
1096
796
  }
797
+ break;
1097
798
  }
1098
- };
1099
- function buildMetadata(value, file, headerPos, diagnostics) {
799
+ if (!isNode2(satisfiesType) || satisfiesType.type !== "TSTypeReference") return;
800
+ const typeName = satisfiesType.typeName;
801
+ if (!isNode2(typeName) || typeName.type !== "TSQualifiedName") return;
802
+ const left = typeName.left;
803
+ const right = typeName.right;
804
+ if (!isNode2(left) || left.type !== "Identifier" || left.name !== "Uidex") {
805
+ return;
806
+ }
807
+ if (!isNode2(right) || right.type !== "Identifier") return;
808
+ const actual = String(right.name);
809
+ if (!KNOWN_SATISFIES.has(actual)) return;
810
+ const discriminator = metadata.notFlow ? "notFlow" : metadata.kind;
811
+ const expected = SATISFIES_NAMES[discriminator];
812
+ if (actual === expected) return;
813
+ diagnostics.push({
814
+ code: "uidex-export-satisfies-mismatch",
815
+ severity: "warning",
816
+ message: `\`export const uidex\` declares kind "${discriminator}" but is annotated \`satisfies Uidex.${actual}\`; expected \`Uidex.${expected}\`.`,
817
+ file,
818
+ line: p2.lineAt(satisfiesType.start),
819
+ hint: `Change the annotation to \`satisfies Uidex.${expected}\` or fix the kind discriminator.`
820
+ });
821
+ }
822
+ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
1100
823
  if (value.kind !== "object") {
1101
824
  throw new ExtractError(
1102
825
  "uidex-export-invalid-literal",
@@ -1105,7 +828,7 @@ function buildMetadata(value, file, headerPos, diagnostics) {
1105
828
  );
1106
829
  }
1107
830
  const byKey = /* @__PURE__ */ new Map();
1108
- for (const [k, v] of value.entries) byKey.set(k, v);
831
+ for (const entry of value.entries) byKey.set(entry.key, entry);
1109
832
  const presentKinds = KIND_DISCRIMINATORS.filter(
1110
833
  (k) => byKey.has(k)
1111
834
  );
@@ -1128,49 +851,58 @@ function buildMetadata(value, file, headerPos, diagnostics) {
1128
851
  const discriminator = presentKinds[0];
1129
852
  const kind = discriminator === "notFlow" ? "flow" : discriminator;
1130
853
  const allowed = ALLOWED_FIELDS[kind];
1131
- for (const [k] of value.entries) {
1132
- if (!allowed.has(k)) {
1133
- const fieldVal = byKey.get(k);
854
+ for (const entry of value.entries) {
855
+ if (!allowed.has(entry.key)) {
1134
856
  throw new ExtractError(
1135
857
  "uidex-export-unknown-field",
1136
- `Unknown field "${k}" in \`export const uidex\` for kind "${kind}". Allowed: ${Array.from(
858
+ `Unknown field "${entry.key}" in \`export const uidex\` for kind "${kind}". Allowed: ${Array.from(
1137
859
  allowed
1138
860
  ).sort().join(", ")}.`,
1139
- fieldVal.pos
861
+ entry.value.pos
1140
862
  );
1141
863
  }
1142
864
  }
1143
865
  const idField = discriminator === "notFlow" ? "flow" : discriminator;
1144
- const idValue = byKey.get(discriminator);
866
+ const idValue = byKey.get(discriminator).value;
1145
867
  let id;
1146
868
  if (discriminator === "notFlow") {
1147
- const v = idValue;
1148
- if (v.kind !== "boolean" || v.value !== true) {
869
+ if (idValue.kind !== "boolean" || idValue.value !== true) {
1149
870
  throw new ExtractError(
1150
871
  "uidex-export-invalid-field",
1151
872
  "`notFlow` must be `true`.",
1152
- v.pos
873
+ idValue.pos
1153
874
  );
1154
875
  }
1155
876
  id = false;
1156
877
  } else {
1157
878
  id = readIdField(idValue, kind, idField);
1158
879
  }
1159
- const acceptance = readStringArrayField(byKey, "acceptance");
880
+ const acceptance = readStringArrayField(byKey, "acceptance")?.values;
1160
881
  const description = readStringField(byKey, "description");
1161
882
  const name = readStringField(byKey, "name");
1162
883
  if (name === "") {
1163
- const pos = byKey.get("name").pos;
884
+ const entry = byKey.get("name");
1164
885
  diagnostics.push({
1165
886
  code: "uidex-export-empty-name",
1166
887
  severity: "info",
1167
888
  message: "`name` is an empty string; treating as unset.",
1168
889
  file,
1169
- line: pos.line
890
+ line: entry.value.pos.line,
891
+ fix: {
892
+ description: "Remove the empty `name` field",
893
+ edits: [
894
+ {
895
+ path: sourcePath,
896
+ start: entry.span.start,
897
+ end: entry.span.end,
898
+ replacement: ""
899
+ }
900
+ ]
901
+ }
1170
902
  });
1171
903
  }
1172
- const features = kind === "page" || kind === "feature" ? readStringArrayField(byKey, "features") : void 0;
1173
- const widgets = kind === "page" ? readStringArrayField(byKey, "widgets") : void 0;
904
+ const featuresField = kind === "page" || kind === "feature" ? readStringArrayField(byKey, "features") : void 0;
905
+ const widgetsField = kind === "page" ? readStringArrayField(byKey, "widgets") : void 0;
1174
906
  const notFlow = kind === "flow" && discriminator === "notFlow" ? true : void 0;
1175
907
  const metadata = {
1176
908
  source: "ts-export",
@@ -1185,9 +917,21 @@ function buildMetadata(value, file, headerPos, diagnostics) {
1185
917
  if (name) metadata.name = name;
1186
918
  if (acceptance) metadata.acceptance = acceptance;
1187
919
  if (description) metadata.description = description;
1188
- if (features) metadata.features = features;
1189
- if (widgets) metadata.widgets = widgets;
920
+ if (featuresField) {
921
+ metadata.features = featuresField.values;
922
+ metadata.featureSpans = featuresField.spans;
923
+ }
924
+ if (widgetsField) {
925
+ metadata.widgets = widgetsField.values;
926
+ metadata.widgetSpans = widgetsField.spans;
927
+ }
1190
928
  if (notFlow) metadata.notFlow = true;
929
+ if (typeof id === "string" && idValue.kind === "string") {
930
+ metadata.idSpan = idValue.span;
931
+ }
932
+ const fieldSpans = {};
933
+ for (const entry of value.entries) fieldSpans[entry.key] = entry.span;
934
+ metadata.fieldSpans = fieldSpans;
1191
935
  return metadata;
1192
936
  }
1193
937
  function readIdField(value, kind, fieldName) {
@@ -1218,29 +962,30 @@ function readIdField(value, kind, fieldName) {
1218
962
  );
1219
963
  }
1220
964
  function readStringField(byKey, name) {
1221
- const v = byKey.get(name);
1222
- if (!v) return void 0;
1223
- if (v.kind !== "string") {
965
+ const entry = byKey.get(name);
966
+ if (!entry) return void 0;
967
+ if (entry.value.kind !== "string") {
1224
968
  throw new ExtractError(
1225
969
  "uidex-export-invalid-field",
1226
970
  `\`${name}\` must be a string.`,
1227
- v.pos
971
+ entry.value.pos
1228
972
  );
1229
973
  }
1230
- return v.value;
974
+ return entry.value.value;
1231
975
  }
1232
976
  function readStringArrayField(byKey, name) {
1233
- const v = byKey.get(name);
1234
- if (!v) return void 0;
1235
- if (v.kind !== "array") {
977
+ const entry = byKey.get(name);
978
+ if (!entry) return void 0;
979
+ if (entry.value.kind !== "array") {
1236
980
  throw new ExtractError(
1237
981
  "uidex-export-invalid-field",
1238
982
  `\`${name}\` must be an array of strings.`,
1239
- v.pos
983
+ entry.value.pos
1240
984
  );
1241
985
  }
1242
- const out2 = [];
1243
- for (const item of v.items) {
986
+ const values = [];
987
+ const spans = [];
988
+ for (const item of entry.value.items) {
1244
989
  if (item.kind !== "string") {
1245
990
  throw new ExtractError(
1246
991
  "uidex-export-invalid-field",
@@ -1248,322 +993,526 @@ function readStringArrayField(byKey, name) {
1248
993
  item.pos
1249
994
  );
1250
995
  }
1251
- out2.push(item.value);
996
+ values.push(item.value);
997
+ spans.push(item.span);
1252
998
  }
1253
- return out2;
999
+ return { values, spans };
1254
1000
  }
1255
- function posAt(content, offset) {
1256
- let line = 1;
1257
- let lineStart = 0;
1258
- for (let i = 0; i < offset && i < content.length; i++) {
1259
- if (content[i] === "\n") {
1260
- line++;
1261
- lineStart = i + 1;
1001
+ function posAt(content, offset, p2) {
1002
+ const lineStart = content.lastIndexOf("\n", offset - 1) + 1;
1003
+ return { offset, line: p2.lineAt(offset), column: offset - lineStart + 1 };
1004
+ }
1005
+ function removalSpan(content, start, end) {
1006
+ let e = end;
1007
+ while (e < content.length && /[ \t]/.test(content[e])) e++;
1008
+ if (content[e] === ",") return { start, end: e + 1 };
1009
+ let s = start;
1010
+ while (s > 0 && /[\s]/.test(content[s - 1])) s--;
1011
+ if (content[s - 1] === ",") return { start: s - 1, end };
1012
+ return { start, end };
1013
+ }
1014
+ function statementSpan(stmt, content) {
1015
+ let end = stmt.end;
1016
+ while (end < content.length && /[ \t]/.test(content[end])) end++;
1017
+ if (content[end] === ";") end++;
1018
+ while (end < content.length && /[ \t]/.test(content[end])) end++;
1019
+ if (content[end] === "\r") end++;
1020
+ if (content[end] === "\n") end++;
1021
+ return { start: stmt.start, end };
1022
+ }
1023
+ function isNode2(value) {
1024
+ return typeof value === "object" && value !== null && typeof value.type === "string";
1025
+ }
1026
+
1027
+ // src/scanner/scan/flow-facts.ts
1028
+ function collectFlowFacts(parsed, content) {
1029
+ if (parsed.program === null || !content.includes("test.describe")) {
1030
+ return [];
1031
+ }
1032
+ const facts = [];
1033
+ walkAst(parsed.program, (node) => {
1034
+ if (node.type !== "CallExpression") return void 0;
1035
+ const fact = readTaggedDescribe(node, parsed);
1036
+ if (fact) facts.push(fact);
1037
+ return void 0;
1038
+ });
1039
+ return facts;
1040
+ }
1041
+ function readTaggedDescribe(call, parsed) {
1042
+ const callee = call.callee;
1043
+ if (!callee || callee.type !== "MemberExpression" || !isIdentifier(callee.object, "test") || !isIdentifier(callee.property, "describe")) {
1044
+ return null;
1045
+ }
1046
+ const args = call.arguments ?? [];
1047
+ const title = stringLiteralValue(args[0]);
1048
+ if (title === null) return null;
1049
+ if (!hasFlowTag(args[1])) return null;
1050
+ const body = args[2];
1051
+ const { calls, dynamicCalls } = body ? collectUidexCalls(body, parsed) : { calls: [], dynamicCalls: [] };
1052
+ return {
1053
+ title,
1054
+ line: parsed.lineAt(call.start),
1055
+ calls,
1056
+ ...dynamicCalls.length > 0 ? { dynamicCalls } : {}
1057
+ };
1058
+ }
1059
+ function hasFlowTag(node) {
1060
+ if (!node || node.type !== "ObjectExpression") return false;
1061
+ for (const prop of node.properties ?? []) {
1062
+ if (prop.type !== "Property") continue;
1063
+ const key = prop.key;
1064
+ const keyName = key?.type === "Identifier" ? String(key.name) : key?.type === "Literal" ? String(key.value) : null;
1065
+ if (keyName !== "tag") continue;
1066
+ const value = prop.value;
1067
+ if (!value) return false;
1068
+ if (stringLiteralValue(value) === "@uidex:flow") return true;
1069
+ if (value.type === "ArrayExpression") {
1070
+ for (const el of value.elements ?? []) {
1071
+ if (el && stringLiteralValue(el) === "@uidex:flow") return true;
1072
+ }
1262
1073
  }
1074
+ return false;
1075
+ }
1076
+ return false;
1077
+ }
1078
+ function collectUidexCalls(body, parsed) {
1079
+ const calls = [];
1080
+ const dynamicCalls = [];
1081
+ const claimed = /* @__PURE__ */ new Set();
1082
+ const record = (node, action) => {
1083
+ if (claimed.has(node)) return;
1084
+ claimed.add(node);
1085
+ const resolved = uidexCallId(node);
1086
+ if (resolved === null) {
1087
+ dynamicCalls.push({ line: parsed.lineAt(node.start) });
1088
+ return;
1089
+ }
1090
+ calls.push({
1091
+ id: resolved.id,
1092
+ ...action ? { action } : {},
1093
+ line: parsed.lineAt(node.start),
1094
+ span: resolved.span
1095
+ });
1096
+ };
1097
+ walkAst(body, (node) => {
1098
+ if (node.type !== "CallExpression") return void 0;
1099
+ const callee = node.callee;
1100
+ if (callee?.type === "MemberExpression") {
1101
+ const inner = callee.object;
1102
+ if (inner && isUidexCall(inner)) {
1103
+ const property = callee.property;
1104
+ const action = property?.type === "Identifier" ? String(property.name) : void 0;
1105
+ record(inner, action);
1106
+ }
1107
+ return void 0;
1108
+ }
1109
+ if (isUidexCall(node)) record(node);
1110
+ return void 0;
1111
+ });
1112
+ return { calls, dynamicCalls };
1113
+ }
1114
+ function isUidexCall(node) {
1115
+ if (node.type !== "CallExpression") return false;
1116
+ if (!isIdentifier(node.callee, "uidex")) return false;
1117
+ return (node.arguments ?? []).length >= 1;
1118
+ }
1119
+ function uidexCallId(node) {
1120
+ const args = node.arguments ?? [];
1121
+ const arg = args[0];
1122
+ const id = stringLiteralValue(arg);
1123
+ if (id === null) return null;
1124
+ return { id, span: { start: arg.start, end: arg.end } };
1125
+ }
1126
+ function stringLiteralValue(node) {
1127
+ if (!node) return null;
1128
+ if (node.type === "Literal" && typeof node.value === "string") {
1129
+ return node.value.length > 0 ? node.value : null;
1130
+ }
1131
+ if (node.type === "TemplateLiteral") {
1132
+ const expressions = node.expressions ?? [];
1133
+ if (expressions.length > 0) return null;
1134
+ const quasis = node.quasis ?? [];
1135
+ const cooked = quasis[0]?.value?.cooked;
1136
+ return cooked && cooked.length > 0 ? cooked : null;
1263
1137
  }
1264
- return { offset, line, column: offset - lineStart + 1 };
1138
+ return null;
1139
+ }
1140
+ function isIdentifier(node, name) {
1141
+ return typeof node === "object" && node !== null && node.type === "Identifier" && String(node.name) === name;
1265
1142
  }
1266
1143
 
1267
1144
  // src/scanner/scan/jsx-ancestry.ts
1268
- var DATA_ATTR_RE = /\bdata-uidex(?:-(region|widget|primitive))?\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
1269
- var PATTERN_ATTR_RE = /\bdata-uidex(?:-(region|widget|primitive))?\s*=\s*\{\s*`([^`$]+)\$\{/g;
1270
- function parseDataAttrs(tagSource) {
1271
- if (!tagSource.includes("data-uidex")) return [];
1272
- const out2 = [];
1273
- for (const m of tagSource.matchAll(DATA_ATTR_RE)) {
1274
- const kind = m[1] ?? "element";
1275
- const id = m[2] ?? m[3];
1276
- if (id) out2.push({ kind, id });
1145
+ var ATTR_NAME_RE = /^data-uidex(?:-(region|widget|primitive))?$/;
1146
+ var INTERACTIVE_TAGS = /* @__PURE__ */ new Set(["button", "a", "input", "select", "textarea"]);
1147
+ var LANDMARK_TAGS = /* @__PURE__ */ new Set(["header", "nav", "main", "aside", "footer"]);
1148
+ function attrKind(node) {
1149
+ const name = node.name;
1150
+ if (!name || name.type !== "JSXIdentifier") return null;
1151
+ const m = ATTR_NAME_RE.exec(String(name.name));
1152
+ if (!m) return null;
1153
+ return m[1] ?? "element";
1154
+ }
1155
+ function collectConstStrings(program) {
1156
+ const consts = /* @__PURE__ */ new Map();
1157
+ const seen = /* @__PURE__ */ new Set();
1158
+ walkAst(program, (node) => {
1159
+ if (node.type !== "VariableDeclaration" || node.kind !== "const") {
1160
+ return void 0;
1161
+ }
1162
+ for (const decl of node.declarations ?? []) {
1163
+ const id = decl.id;
1164
+ if (!id || id.type !== "Identifier") continue;
1165
+ const name = String(id.name);
1166
+ if (seen.has(name)) {
1167
+ consts.delete(name);
1168
+ continue;
1169
+ }
1170
+ seen.add(name);
1171
+ const init = decl.init;
1172
+ if (!init) continue;
1173
+ const value = staticString(unwrapTsExpression(init));
1174
+ if (value !== null) consts.set(name, value);
1175
+ }
1176
+ return void 0;
1177
+ });
1178
+ return consts;
1179
+ }
1180
+ function staticString(node) {
1181
+ if (node.type === "Literal" && typeof node.value === "string") {
1182
+ return node.value;
1277
1183
  }
1278
- for (const m of tagSource.matchAll(PATTERN_ATTR_RE)) {
1279
- const kind = m[1] ?? "element";
1280
- const prefix = m[2];
1281
- if (prefix) out2.push({ kind, id: `${prefix}*` });
1184
+ if (node.type === "TemplateLiteral") {
1185
+ const expressions = node.expressions ?? [];
1186
+ if (expressions.length > 0) return null;
1187
+ const quasis = node.quasis ?? [];
1188
+ return quasis[0]?.value?.cooked ?? "";
1282
1189
  }
1283
- return out2;
1190
+ return null;
1284
1191
  }
1285
- function collectJSXAncestry(content) {
1286
- if (!content.includes("data-uidex")) return [];
1287
- const out2 = [];
1288
- const ancestors = [];
1289
- const stack = [];
1290
- const N = content.length;
1291
- let i = 0;
1292
- let line = 1;
1293
- const advanceLines = (from, to) => {
1294
- for (let k = from; k < to; k++) {
1295
- if (content.charCodeAt(k) === 10) line++;
1296
- }
1297
- };
1298
- while (i < N) {
1299
- const c = content[i];
1300
- if (c === "\n") {
1301
- line++;
1302
- i++;
1303
- continue;
1304
- }
1305
- if (c === "/" && content[i + 1] === "/") {
1306
- while (i < N && content[i] !== "\n") i++;
1307
- continue;
1308
- }
1309
- if (c === "/" && content[i + 1] === "*") {
1310
- const end = content.indexOf("*/", i + 2);
1311
- const next = end === -1 ? N : end + 2;
1312
- advanceLines(i, next);
1313
- i = next;
1314
- continue;
1315
- }
1316
- if (c === '"' || c === "'") {
1317
- const next = skipString(content, i, c);
1318
- advanceLines(i, next);
1319
- i = next;
1320
- continue;
1321
- }
1322
- if (c === "`") {
1323
- const next = skipTemplate(content, i);
1324
- advanceLines(i, next);
1325
- i = next;
1326
- continue;
1327
- }
1328
- if (c === "<") {
1329
- const nextCh = content[i + 1];
1330
- if (nextCh === "/") {
1331
- const end = content.indexOf(">", i);
1332
- if (end === -1) break;
1333
- const tagName = content.slice(i + 2, end).match(/^\s*([\w.-]*)/)?.[1] ?? "";
1334
- if (tagName) {
1335
- for (let k = stack.length - 1; k >= 0; k--) {
1336
- if (stack[k].tagName === tagName) {
1337
- for (let j = stack.length - 1; j >= k; j--) {
1338
- ancestors.length -= stack[j].pushed;
1339
- }
1340
- stack.length = k;
1341
- break;
1342
- }
1343
- }
1192
+ var UNRESOLVED = { resolved: false };
1193
+ function evalIdExpression(expr, consts) {
1194
+ const node = unwrapTsExpression(expr);
1195
+ const literal = staticString(node);
1196
+ if (literal !== null) {
1197
+ return literal.length > 0 ? { resolved: true, ids: [literal] } : UNRESOLVED;
1198
+ }
1199
+ if (node.type === "TemplateLiteral") {
1200
+ const quasis = node.quasis ?? [];
1201
+ const expressions = node.expressions ?? [];
1202
+ let out2 = "";
1203
+ for (let i = 0; i < quasis.length; i++) {
1204
+ out2 += quasis[i].value?.cooked ?? "";
1205
+ if (i < expressions.length) {
1206
+ const part = evalIdExpression(expressions[i], consts);
1207
+ out2 += part.resolved && part.ids.length === 1 ? part.ids[0] : "*";
1208
+ }
1209
+ }
1210
+ out2 = out2.replace(/\*{2,}/g, "*");
1211
+ if (!out2.includes("*")) {
1212
+ return out2.length > 0 ? { resolved: true, ids: [out2] } : UNRESOLVED;
1213
+ }
1214
+ return out2.replace(/\*/g, "").length > 0 ? { resolved: true, ids: [out2] } : UNRESOLVED;
1215
+ }
1216
+ if (node.type === "Identifier") {
1217
+ const value = consts.get(String(node.name));
1218
+ return value !== void 0 && value.length > 0 ? { resolved: true, ids: [value] } : UNRESOLVED;
1219
+ }
1220
+ if (node.type === "ConditionalExpression") {
1221
+ const left = evalIdExpression(node.consequent, consts);
1222
+ const right = evalIdExpression(node.alternate, consts);
1223
+ if (!left.resolved || !right.resolved) return UNRESOLVED;
1224
+ return { resolved: true, ids: [.../* @__PURE__ */ new Set([...left.ids, ...right.ids])] };
1225
+ }
1226
+ return UNRESOLVED;
1227
+ }
1228
+ function collectElementAttrs(opening, consts, dynamicAttrs, lineAt) {
1229
+ const statics = [];
1230
+ const patterns = [];
1231
+ const attributes = opening.attributes ?? [];
1232
+ for (const attr of attributes) {
1233
+ if (attr.type !== "JSXAttribute") continue;
1234
+ const kind = attrKind(attr);
1235
+ if (!kind) continue;
1236
+ const value = attr.value;
1237
+ if (!value) continue;
1238
+ let result = UNRESOLVED;
1239
+ let valueSpan;
1240
+ if (value.type === "Literal") {
1241
+ const v = staticString(value);
1242
+ result = v !== null && v.length > 0 ? { resolved: true, ids: [v] } : UNRESOLVED;
1243
+ if (result.resolved) valueSpan = { start: value.start, end: value.end };
1244
+ } else if (value.type === "JSXExpressionContainer") {
1245
+ const expr = value.expression;
1246
+ if (expr && expr.type !== "JSXEmptyExpression") {
1247
+ result = evalIdExpression(expr, consts);
1248
+ const inner = unwrapTsExpression(expr);
1249
+ if (result.resolved && staticString(inner) !== null) {
1250
+ valueSpan = { start: inner.start, end: inner.end };
1344
1251
  }
1345
- advanceLines(i, end + 1);
1346
- i = end + 1;
1347
- continue;
1348
1252
  }
1349
- if (nextCh && /[A-Za-z_]/.test(nextCh)) {
1350
- const end = findTagEnd(content, i + 1);
1351
- if (end === -1) break;
1352
- const tagSource = content.slice(i, end + 1);
1353
- const tagName = tagSource.match(/^<\s*([\w.-]*)/)?.[1] ?? "";
1354
- const isSelf = content[end - 1] === "/";
1355
- if (tagName) {
1356
- const attrs = parseDataAttrs(tagSource);
1357
- if (attrs.length > 0) {
1358
- const snapshot = ancestors.slice();
1359
- for (const a of attrs) {
1360
- out2.push({ kind: a.kind, id: a.id, line, ancestors: snapshot });
1361
- }
1362
- }
1363
- if (!isSelf) {
1364
- for (const a of attrs) ancestors.push(a);
1365
- stack.push({ tagName, pushed: attrs.length });
1366
- }
1367
- }
1368
- advanceLines(i, end + 1);
1369
- i = end + 1;
1253
+ if (!result.resolved) {
1254
+ dynamicAttrs.push({
1255
+ kind,
1256
+ attrName: kind === "element" ? "data-uidex" : `data-uidex-${kind}`,
1257
+ line: lineAt(attr.start)
1258
+ });
1370
1259
  continue;
1371
1260
  }
1372
1261
  }
1373
- i++;
1374
- }
1375
- return out2;
1376
- }
1377
- function skipString(content, start, quote) {
1378
- const N = content.length;
1379
- let i = start + 1;
1380
- while (i < N) {
1381
- const c = content[i];
1382
- if (c === "\\") {
1383
- i += 2;
1384
- continue;
1262
+ if (!result.resolved) continue;
1263
+ for (const id of result.ids) {
1264
+ const resolved = {
1265
+ kind,
1266
+ id,
1267
+ start: attr.start,
1268
+ isPattern: id.includes("*"),
1269
+ // Only a single plain string literal is renameable in place.
1270
+ ...result.ids.length === 1 && valueSpan ? { span: valueSpan } : {}
1271
+ };
1272
+ if (resolved.isPattern) patterns.push(resolved);
1273
+ else statics.push(resolved);
1385
1274
  }
1386
- if (c === quote) return i + 1;
1387
- i++;
1388
1275
  }
1389
- return N;
1276
+ return [...statics, ...patterns];
1390
1277
  }
1391
- function skipTemplate(content, start) {
1392
- const N = content.length;
1393
- let i = start + 1;
1394
- while (i < N) {
1395
- const c = content[i];
1396
- if (c === "\\") {
1397
- i += 2;
1398
- continue;
1399
- }
1400
- if (c === "`") return i + 1;
1401
- if (c === "$" && content[i + 1] === "{") {
1402
- i += 2;
1403
- let depth = 1;
1404
- while (i < N && depth > 0) {
1405
- const cj = content[i];
1406
- if (cj === '"' || cj === "'") {
1407
- i = skipString(content, i, cj);
1408
- continue;
1278
+ function collectJSXFacts(parsed) {
1279
+ const occurrences = [];
1280
+ const dynamicAttrs = [];
1281
+ const unannotatedInteractive = [];
1282
+ const landmarks = [];
1283
+ if (parsed.program === null) {
1284
+ return { occurrences, dynamicAttrs, unannotatedInteractive, landmarks };
1285
+ }
1286
+ const consts = collectConstStrings(parsed.program);
1287
+ const ancestors = [];
1288
+ const visit = (node) => {
1289
+ if (!isNode3(node)) return;
1290
+ if (node.type === "JSXElement") {
1291
+ const opening = node.openingElement;
1292
+ const attrs = collectElementAttrs(
1293
+ opening,
1294
+ consts,
1295
+ dynamicAttrs,
1296
+ parsed.lineAt
1297
+ );
1298
+ const interactive = readInteractive(node, parsed.lineAt);
1299
+ if (interactive) unannotatedInteractive.push(interactive);
1300
+ if (attrs.length > 0) {
1301
+ const snapshot = ancestors.slice();
1302
+ for (const a of attrs) {
1303
+ occurrences.push({
1304
+ kind: a.kind,
1305
+ id: a.id,
1306
+ line: parsed.lineAt(a.start),
1307
+ ancestors: snapshot,
1308
+ ...a.span ? { span: a.span } : {}
1309
+ });
1409
1310
  }
1410
- if (cj === "`") {
1411
- i = skipTemplate(content, i);
1412
- continue;
1311
+ }
1312
+ let pushed = attrs.length;
1313
+ for (const a of attrs) ancestors.push({ kind: a.kind, id: a.id });
1314
+ const landmark = readLandmark(opening, parsed.lineAt);
1315
+ if (landmark) {
1316
+ landmarks.push(landmark);
1317
+ if (!attrs.some((a) => a.kind === "region")) {
1318
+ ancestors.push({ kind: "region", id: landmark.tag });
1319
+ pushed++;
1413
1320
  }
1414
- if (cj === "{") depth++;
1415
- else if (cj === "}") depth--;
1416
- i++;
1417
1321
  }
1418
- continue;
1419
- }
1420
- i++;
1421
- }
1422
- return N;
1423
- }
1424
- function findTagEnd(content, start) {
1425
- const N = content.length;
1426
- let i = start;
1427
- while (i < N) {
1428
- const c = content[i];
1429
- if (c === '"' || c === "'") {
1430
- i = skipString(content, i, c);
1431
- continue;
1432
- }
1433
- if (c === "`") {
1434
- i = skipTemplate(content, i);
1435
- continue;
1322
+ visitChildren(opening);
1323
+ for (const child of node.children ?? []) {
1324
+ visit(child);
1325
+ }
1326
+ const closing = node.closingElement;
1327
+ if (isNode3(closing)) visitChildren(closing);
1328
+ ancestors.length -= pushed;
1329
+ return;
1436
1330
  }
1437
- if (c === "{") {
1438
- let depth = 1;
1439
- i++;
1440
- while (i < N && depth > 0) {
1441
- const cj = content[i];
1442
- if (cj === '"' || cj === "'") {
1443
- i = skipString(content, i, cj);
1444
- continue;
1445
- }
1446
- if (cj === "`") {
1447
- i = skipTemplate(content, i);
1448
- continue;
1449
- }
1450
- if (cj === "{") depth++;
1451
- else if (cj === "}") depth--;
1452
- i++;
1331
+ visitChildren(node);
1332
+ };
1333
+ const visitChildren = (node) => {
1334
+ for (const key of Object.keys(node)) {
1335
+ if (key === "type" || key === "start" || key === "end") continue;
1336
+ const value = node[key];
1337
+ if (Array.isArray(value)) {
1338
+ for (const item of value) visit(item);
1339
+ } else {
1340
+ visit(value);
1453
1341
  }
1454
- continue;
1455
1342
  }
1456
- if (c === ">") return i;
1457
- i++;
1458
- }
1459
- return -1;
1343
+ };
1344
+ visit(parsed.program);
1345
+ return { occurrences, dynamicAttrs, unannotatedInteractive, landmarks };
1460
1346
  }
1461
-
1462
- // src/scanner/scan/extract.ts
1463
- var JSDOC_BLOCK = /\/\*\*([\s\S]*?)\*\//g;
1464
- function lineAt(content, index) {
1465
- let line = 1;
1466
- for (let i = 0; i < index && i < content.length; i++) {
1467
- if (content[i] === "\n") line++;
1347
+ function readLandmark(opening, lineAt) {
1348
+ const name = opening.name;
1349
+ if (!name || name.type !== "JSXIdentifier") return null;
1350
+ const tag = String(name.name);
1351
+ if (LANDMARK_TAGS.has(tag)) {
1352
+ return { tag, line: lineAt(opening.start) };
1468
1353
  }
1469
- return line;
1354
+ for (const attr of opening.attributes ?? []) {
1355
+ if (attr.type !== "JSXAttribute") continue;
1356
+ const attrName = attr.name;
1357
+ if (!attrName || String(attrName.name) !== "role") continue;
1358
+ const value = attr.value;
1359
+ if (value && value.type === "Literal" && value.value === "region") {
1360
+ return { tag: "region", line: lineAt(opening.start) };
1361
+ }
1362
+ }
1363
+ return null;
1470
1364
  }
1471
- function parseJSDoc(block) {
1472
- const lines = block.split("\n").map((l) => l.replace(/^\s*\*\s?/, "").replace(/^\s*\/?\*+/, ""));
1473
- let kind = null;
1474
- let id = null;
1475
- const acceptance = [];
1476
- const desc = [];
1477
- let notFlow = false;
1478
- for (const raw of lines) {
1479
- const line = raw.trim();
1480
- if (!line) continue;
1481
- const uidex = line.match(
1482
- /^@uidex\s+(page|feature|widget)\s+(\S+)(?:\s+-\s+(.+))?/
1483
- );
1484
- if (uidex) {
1485
- kind = uidex[1];
1486
- id = uidex[2];
1487
- if (uidex[3]) desc.push(uidex[3].trim());
1365
+ function readInteractive(element, lineAt) {
1366
+ const opening = element.openingElement;
1367
+ const name = opening.name;
1368
+ if (!name || name.type !== "JSXIdentifier") return null;
1369
+ const tag = String(name.name);
1370
+ if (!INTERACTIVE_TAGS.has(tag)) return null;
1371
+ let hasSpread = false;
1372
+ for (const attr of opening.attributes ?? []) {
1373
+ if (attr.type === "JSXSpreadAttribute") {
1374
+ hasSpread = true;
1488
1375
  continue;
1489
1376
  }
1490
- if (/^@uidex:not-flow\b/.test(line)) {
1491
- notFlow = true;
1492
- continue;
1377
+ if (attr.type === "JSXAttribute" && attrKind(attr) !== null) return null;
1378
+ }
1379
+ const nameHint = interactiveNameHint(element, opening);
1380
+ return {
1381
+ tag,
1382
+ line: lineAt(opening.start),
1383
+ hasSpread,
1384
+ nameEnd: name.end,
1385
+ ...nameHint ? { nameHint } : {}
1386
+ };
1387
+ }
1388
+ function staticAttrValue(opening, attrName) {
1389
+ for (const attr of opening.attributes ?? []) {
1390
+ if (attr.type !== "JSXAttribute") continue;
1391
+ const n = attr.name;
1392
+ if (!n || String(n.name) !== attrName) continue;
1393
+ const value = attr.value;
1394
+ if (!value) return null;
1395
+ if (value.type === "Literal") return staticString(value);
1396
+ if (value.type === "JSXExpressionContainer") {
1397
+ const expr = value.expression;
1398
+ return expr ? staticString(unwrapTsExpression(expr)) : null;
1493
1399
  }
1494
- const accept = line.match(/^@acceptance\s+(.+)$/);
1495
- if (accept) {
1496
- acceptance.push(accept[1].trim());
1497
- continue;
1400
+ return null;
1401
+ }
1402
+ return null;
1403
+ }
1404
+ function staticChildText(element) {
1405
+ const parts = [];
1406
+ for (const child of element.children ?? []) {
1407
+ if (child.type === "JSXText") {
1408
+ parts.push(String(child.value ?? ""));
1498
1409
  }
1499
- if (line.startsWith("@")) continue;
1500
- desc.push(line);
1501
1410
  }
1411
+ return parts.join(" ").replace(/\s+/g, " ").trim();
1412
+ }
1413
+ function interactiveNameHint(element, opening) {
1414
+ const ariaLabel = staticAttrValue(opening, "aria-label");
1415
+ if (ariaLabel) return ariaLabel;
1416
+ const text = staticChildText(element);
1417
+ if (text) return text;
1418
+ for (const attr of ["title", "name", "placeholder"]) {
1419
+ const v = staticAttrValue(opening, attr);
1420
+ if (v) return v;
1421
+ }
1422
+ return void 0;
1423
+ }
1424
+ function isNode3(value) {
1425
+ return typeof value === "object" && value !== null && typeof value.type === "string";
1426
+ }
1427
+
1428
+ // src/scanner/scan/extract.ts
1429
+ function parseFailureDiagnostic(file, parsed) {
1430
+ const fatal = parsed.program === null || parsed.hasErrors && parsed.program.body.length === 0;
1431
+ if (!fatal) return null;
1502
1432
  return {
1503
- kind,
1504
- id,
1505
- description: desc.join(" ").trim(),
1506
- acceptance,
1507
- notFlow
1433
+ code: "parse-error",
1434
+ severity: "warning",
1435
+ 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",
1436
+ file: file.displayPath,
1437
+ line: 1,
1438
+ hint: "Fix the file's syntax (or exclude it from .uidex.json sources) so the scanner can read its annotations"
1508
1439
  };
1509
1440
  }
1510
1441
  function extract(files) {
1511
1442
  return files.map((file) => {
1512
- const { exports: exports2, diagnostics } = extractUidexExports(file);
1513
- const out2 = {
1514
- file,
1515
- annotations: extractOne(file)
1516
- };
1443
+ const parsed = parseSource(file);
1444
+ const { exports: exports2, diagnostics } = extractUidexExports(file, parsed);
1445
+ const parseFailure = parseFailureDiagnostic(file, parsed);
1446
+ if (parseFailure) diagnostics.push(parseFailure);
1447
+ const out2 = { file, annotations: [] };
1448
+ out2.annotations = extractOne(file, parsed, out2);
1517
1449
  if (exports2.length > 0) out2.metadata = exports2;
1518
1450
  if (diagnostics.length > 0) out2.diagnostics = diagnostics;
1451
+ const flows = collectFlowFacts(parsed, file.content);
1452
+ if (flows.length > 0) out2.flows = flows;
1453
+ const imports = collectImportFacts(parsed);
1454
+ if (imports.length > 0) out2.imports = imports;
1519
1455
  return out2;
1520
1456
  });
1521
1457
  }
1522
- function extractOne(file) {
1458
+ function extractOne(file, parsed, out2) {
1523
1459
  const annotations = [];
1524
- const { content, displayPath } = file;
1525
- for (const occ of collectJSXAncestry(content)) {
1460
+ const { displayPath } = file;
1461
+ const jsx = collectJSXFacts(parsed);
1462
+ if (jsx.dynamicAttrs.length > 0) out2.dynamicAttrs = jsx.dynamicAttrs;
1463
+ if (jsx.unannotatedInteractive.length > 0) {
1464
+ out2.unannotatedInteractive = jsx.unannotatedInteractive;
1465
+ }
1466
+ if (jsx.landmarks.length > 0) out2.landmarks = jsx.landmarks;
1467
+ for (const occ of jsx.occurrences) {
1526
1468
  annotations.push({
1527
1469
  kind: occ.kind,
1528
1470
  id: occ.id,
1529
1471
  file: displayPath,
1530
1472
  line: occ.line,
1531
- ...occ.ancestors.length > 0 ? { ancestors: occ.ancestors } : {}
1473
+ ...occ.ancestors.length > 0 ? { ancestors: occ.ancestors } : {},
1474
+ ...occ.span ? { span: occ.span } : {}
1532
1475
  });
1533
1476
  }
1534
- JSDOC_BLOCK.lastIndex = 0;
1535
- let jm;
1536
- while ((jm = JSDOC_BLOCK.exec(content)) !== null) {
1537
- const parsed = parseJSDoc(jm[1]);
1538
- const line = lineAt(content, jm.index);
1539
- if (parsed.notFlow) {
1540
- annotations.push({ kind: "not-flow", id: "", file: displayPath, line });
1541
- }
1542
- if (parsed.kind && parsed.id) {
1543
- const kind = parsed.kind === "page" ? "page-doc" : parsed.kind === "feature" ? "feature-doc" : "widget-doc";
1544
- annotations.push({
1545
- kind,
1546
- id: parsed.id,
1547
- file: displayPath,
1548
- line,
1549
- description: parsed.description || void 0,
1550
- acceptance: parsed.acceptance.length ? parsed.acceptance : void 0
1551
- });
1552
- } else if (parsed.acceptance.length > 0) {
1553
- annotations.push({
1554
- kind: "orphan-acceptance",
1555
- id: "",
1556
- file: displayPath,
1557
- line,
1558
- acceptance: parsed.acceptance
1559
- });
1477
+ return annotations;
1478
+ }
1479
+ function collectImportFacts(parsed) {
1480
+ if (parsed.program === null) return [];
1481
+ const out2 = [];
1482
+ for (const stmt of parsed.program.body) {
1483
+ let source;
1484
+ let isTypeOnly = false;
1485
+ const names = [];
1486
+ if (stmt.type === "ImportDeclaration") {
1487
+ source = stmt.source;
1488
+ isTypeOnly = stmt.importKind === "type";
1489
+ for (const spec of stmt.specifiers ?? []) {
1490
+ const local = spec.local;
1491
+ if (local && local.type === "Identifier") {
1492
+ names.push(String(local.name));
1493
+ }
1494
+ }
1495
+ } else if ((stmt.type === "ExportNamedDeclaration" || stmt.type === "ExportAllDeclaration") && stmt.source) {
1496
+ source = stmt.source;
1497
+ isTypeOnly = stmt.exportKind === "type";
1498
+ } else {
1499
+ continue;
1560
1500
  }
1501
+ if (!source || source.type !== "Literal") continue;
1502
+ if (typeof source.value !== "string") continue;
1503
+ out2.push({
1504
+ specifier: source.value,
1505
+ line: parsed.lineAt(stmt.start),
1506
+ span: { start: stmt.start, end: stmt.end },
1507
+ isTypeOnly,
1508
+ names
1509
+ });
1561
1510
  }
1562
- return annotations;
1511
+ return out2;
1563
1512
  }
1564
1513
 
1565
1514
  // src/scanner/scan/resolve.ts
1566
- var path3 = __toESM(require("path"), 1);
1515
+ var path4 = __toESM(require("path"), 1);
1567
1516
 
1568
1517
  // src/shared/entities/types.ts
1569
1518
  var ENTITY_KINDS = [
@@ -1649,13 +1598,14 @@ function createRegistry() {
1649
1598
  };
1650
1599
  const getPatternsForKind = (kind) => {
1651
1600
  const cached = patternCache.get(kind);
1652
- if (cached !== void 0)
1653
- return cached;
1601
+ if (cached !== void 0) return cached;
1654
1602
  const patterns = [];
1655
1603
  for (const [key, entity] of store[kind]) {
1656
- if (key.endsWith("*")) {
1604
+ if (key.includes("*")) {
1605
+ const segments = key.split("*");
1657
1606
  patterns.push({
1658
- prefix: key.slice(0, -1),
1607
+ segments,
1608
+ staticLength: segments.reduce((n, s) => n + s.length, 0),
1659
1609
  entity
1660
1610
  });
1661
1611
  }
@@ -1666,13 +1616,25 @@ function createRegistry() {
1666
1616
  );
1667
1617
  return patterns;
1668
1618
  };
1619
+ const matchesSegments = (segments, id) => {
1620
+ const first = segments[0];
1621
+ const last = segments[segments.length - 1];
1622
+ if (!id.startsWith(first)) return false;
1623
+ let pos = first.length;
1624
+ for (let i = 1; i < segments.length - 1; i++) {
1625
+ const idx = id.indexOf(segments[i], pos);
1626
+ if (idx === -1) return false;
1627
+ pos = idx + segments[i].length;
1628
+ }
1629
+ return id.endsWith(last) && id.length - last.length >= pos;
1630
+ };
1669
1631
  const matchPattern = (kind, id) => {
1670
1632
  assertEntityKind(kind);
1671
1633
  const patterns = getPatternsForKind(kind);
1672
1634
  if (patterns.length === 0) return void 0;
1673
1635
  let best;
1674
1636
  for (const entry of patterns) {
1675
- if (id.startsWith(entry.prefix) && (best === void 0 || entry.prefix.length > best.prefix.length)) {
1637
+ if (matchesSegments(entry.segments, id) && (best === void 0 || entry.staticLength > best.staticLength)) {
1676
1638
  best = entry;
1677
1639
  }
1678
1640
  }
@@ -1826,21 +1788,9 @@ function resolveConventions(c) {
1826
1788
  function kebab(str) {
1827
1789
  return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").replace(/[^a-zA-Z0-9-]/g, "").toLowerCase();
1828
1790
  }
1829
- function baseName(file) {
1830
- const b = path3.posix.basename(file);
1831
- return b.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "");
1832
- }
1833
- var LANDMARK_RE = /<(header|nav|main|aside|footer)(\s[^>]*)?>|role=["']region["']/gi;
1834
- function extractLandmarks(file) {
1835
- const out2 = [];
1836
- LANDMARK_RE.lastIndex = 0;
1837
- let m;
1838
- while ((m = LANDMARK_RE.exec(file.content)) !== null) {
1839
- const tag = m[1] ?? "region";
1840
- const line = 1 + file.content.slice(0, m.index).split("\n").length - 1;
1841
- out2.push({ tag, line });
1842
- }
1843
- return out2;
1791
+ function baseName(file) {
1792
+ const b = path4.posix.basename(file);
1793
+ return b.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "");
1844
1794
  }
1845
1795
  function fileMatchesAny(displayPath, patterns) {
1846
1796
  return patterns.some((g) => globToRegExp(g).test(displayPath));
@@ -1902,7 +1852,7 @@ function resolve2(ctx) {
1902
1852
  const routes = conventions.pages === "auto" ? detectRoutes(ctx.extracted.map((e) => e.file)) : [];
1903
1853
  const handledPageFiles = /* @__PURE__ */ new Set();
1904
1854
  for (const route of routes) {
1905
- const routeDir = path3.posix.dirname(route.file);
1855
+ const routeDir = path4.posix.dirname(route.file);
1906
1856
  const wellKnownPath = `${routeDir}/${WELL_KNOWN_FILES.page}`;
1907
1857
  const wellKnownExp = exportFor(wellKnownPath, "page");
1908
1858
  const routeExp = exportFor(route.file, "page");
@@ -1956,7 +1906,7 @@ function resolve2(ctx) {
1956
1906
  const dir = extractFeatureDir(ef.file.displayPath, featureGlob);
1957
1907
  if (!dir) continue;
1958
1908
  conventionalFeatureDirs.add(dir);
1959
- const isWellKnown = path3.posix.basename(ef.file.displayPath) === WELL_KNOWN_FILES.feature;
1909
+ const isWellKnown = path4.posix.basename(ef.file.displayPath) === WELL_KNOWN_FILES.feature;
1960
1910
  if (isWellKnown) wellKnownFeatureFileByDir.set(dir, ef.file.displayPath);
1961
1911
  const exp = exportFor(ef.file.displayPath, "feature");
1962
1912
  if (exp) {
@@ -1993,7 +1943,7 @@ function resolve2(ctx) {
1993
1943
  } else if (allExports.length > 0) {
1994
1944
  exp = allExports[0].exp;
1995
1945
  }
1996
- const id = exp && typeof exp.id === "string" ? exp.id : path3.posix.basename(dir);
1946
+ const id = exp && typeof exp.id === "string" ? exp.id : path4.posix.basename(dir);
1997
1947
  const meta = exp ? buildMetaFromExport(exp) : void 0;
1998
1948
  const feature = {
1999
1949
  kind: "feature",
@@ -2089,8 +2039,8 @@ function resolve2(ctx) {
2089
2039
  }
2090
2040
  if (conventions.regions === "landmarks") {
2091
2041
  for (const ef of ctx.extracted) {
2092
- for (const lm of extractLandmarks(ef.file)) {
2093
- const id = kebab(`${lm.tag}`);
2042
+ for (const lm of ef.landmarks ?? []) {
2043
+ const id = lm.tag;
2094
2044
  if (!registry.get("region", id)) {
2095
2045
  const meta = metaWithComposes("region", id);
2096
2046
  const region = {
@@ -2201,7 +2151,7 @@ function resolve2(ctx) {
2201
2151
  const flowExport = (ff.metadata ?? []).find(
2202
2152
  (m) => m.kind === "flow" && typeof m.id === "string"
2203
2153
  );
2204
- const derived = extractFlowsFromSource(ff.file);
2154
+ const derived = flowsFromFacts(ff);
2205
2155
  if (flowExport && typeof flowExport.id === "string" && derived.length === 1) {
2206
2156
  const base = derived[0];
2207
2157
  const flow = {
@@ -2256,60 +2206,21 @@ function computeScope(displayPath) {
2256
2206
  }
2257
2207
  return null;
2258
2208
  }
2259
- function extractFlowsFromSource(file) {
2260
- const flows = [];
2261
- const source = file.content;
2262
- const describeRe = /test\.describe\(\s*(?:'([^']*)'|"([^"]*)")\s*,\s*\{[^}]*tag:\s*(?:'@uidex:flow'|"@uidex:flow"|\[[^\]]*@uidex:flow[^\]]*\])[^}]*\}/g;
2263
- let m;
2264
- while ((m = describeRe.exec(source)) !== null) {
2265
- const title = m[1] ?? m[2];
2266
- const id = kebab(title);
2267
- const line = 1 + source.slice(0, m.index).split("\n").length - 1;
2268
- const after = source.slice(m.index + m[0].length);
2269
- const arrow = after.match(/=>\s*\{/);
2270
- if (!arrow || arrow.index === void 0) continue;
2271
- const bodyStart = m.index + m[0].length + arrow.index + arrow[0].length;
2272
- let depth = 1;
2273
- let bodyEnd = -1;
2274
- for (let i = bodyStart; i < source.length; i++) {
2275
- if (source[i] === "{") depth++;
2276
- else if (source[i] === "}") {
2277
- depth--;
2278
- if (depth === 0) {
2279
- bodyEnd = i;
2280
- break;
2281
- }
2282
- }
2283
- }
2284
- if (bodyEnd === -1) continue;
2285
- const body = source.slice(bodyStart, bodyEnd);
2286
- const touches = captureUidexIds(body);
2287
- flows.push({
2288
- kind: "flow",
2289
- id,
2290
- loc: { file: file.displayPath, line },
2291
- touches: dedupe(touches.map((t) => t.id)),
2292
- steps: touches.filter((t) => t.action).map((t) => ({ entityId: t.id, action: t.action }))
2293
- });
2294
- }
2295
- return flows;
2296
- }
2297
- function captureUidexIds(body) {
2298
- const out2 = [];
2299
- const re = /uidex\(\s*(?:'([^']+)'|"([^"]+)"|`([^`$]+)`)\s*\)(?:\.(\w+)\s*\()?/g;
2300
- let m;
2301
- while ((m = re.exec(body)) !== null) {
2302
- out2.push({ id: m[1] || m[2] || m[3], action: m[4] });
2303
- }
2304
- return out2;
2209
+ function flowsFromFacts(ff) {
2210
+ return (ff.flows ?? []).map((fact) => ({
2211
+ kind: "flow",
2212
+ id: kebab(fact.title),
2213
+ loc: { file: ff.file.displayPath, line: fact.line },
2214
+ touches: dedupe(fact.calls.map((c) => c.id)),
2215
+ steps: fact.calls.filter((c) => c.action).map((c) => ({ entityId: c.id, action: c.action }))
2216
+ }));
2305
2217
  }
2306
2218
  function dedupe(arr) {
2307
2219
  return Array.from(new Set(arr));
2308
2220
  }
2309
2221
 
2310
2222
  // src/scanner/scan/audit.ts
2311
- var path4 = __toESM(require("path"), 1);
2312
- var MARKER_FILENAMES = ["UIDEX_PAGE.md", "UIDEX_FEATURE.md"];
2223
+ var path5 = __toESM(require("path"), 1);
2313
2224
  function audit(opts) {
2314
2225
  const diagnostics = [];
2315
2226
  const { registry, extracted, files, config } = opts;
@@ -2319,22 +2230,15 @@ function audit(opts) {
2319
2230
  const scopeLeakEnabled = config.audit?.scopeLeak ?? true;
2320
2231
  const coverageEnabled = config.audit?.coverage ?? true;
2321
2232
  if (opts.resolveDiagnostics) diagnostics.push(...opts.resolveDiagnostics);
2322
- if (check) {
2323
- for (const f of files) {
2324
- const base = f.displayPath.split("/").pop() ?? "";
2325
- if (MARKER_FILENAMES.includes(base)) {
2326
- diagnostics.push({
2327
- code: "marker-md-ignored",
2328
- severity: "warning",
2329
- message: `Marker file "${base}" is ignored in v2; migrate to \`export const uidex\``,
2330
- file: f.displayPath
2331
- });
2332
- }
2333
- }
2233
+ for (const ef of extracted) {
2234
+ if (ef.diagnostics) diagnostics.push(...ef.diagnostics);
2235
+ }
2236
+ for (const ef of opts.flowExtracted ?? []) {
2237
+ if (ef.diagnostics) diagnostics.push(...ef.diagnostics);
2334
2238
  }
2335
2239
  if (check && opts.generated !== void 0) {
2336
2240
  const outRel = opts.outputPath ?? config.output;
2337
- const fresh = normalizeLineEndings(opts.generated);
2241
+ const fresh = normalizeForCheck(opts.generated);
2338
2242
  if (opts.existingOnDisk === null || opts.existingOnDisk === void 0) {
2339
2243
  diagnostics.push({
2340
2244
  code: "gen-missing",
@@ -2344,7 +2248,7 @@ function audit(opts) {
2344
2248
  hint: "Run `uidex scan` (without --check) to regenerate"
2345
2249
  });
2346
2250
  } else {
2347
- const existing = normalizeLineEndings(opts.existingOnDisk);
2251
+ const existing = normalizeForCheck(opts.existingOnDisk);
2348
2252
  if (existing !== fresh) {
2349
2253
  const changed = diffEntities(existing, opts.generated, registry);
2350
2254
  const summary2 = formatChangedSummary(changed);
@@ -2358,22 +2262,6 @@ function audit(opts) {
2358
2262
  }
2359
2263
  }
2360
2264
  }
2361
- if (lint) {
2362
- for (const ef of extracted) {
2363
- for (const a of ef.annotations) {
2364
- const migration = legacyJsdocMigration(a);
2365
- if (!migration) continue;
2366
- diagnostics.push({
2367
- code: "legacy-jsdoc",
2368
- severity: "warning",
2369
- message: migration.message,
2370
- file: a.file,
2371
- line: a.line,
2372
- hint: migration.hint
2373
- });
2374
- }
2375
- }
2376
- }
2377
2265
  if (lint && acceptanceEnabled) {
2378
2266
  for (const kind of ["widget", "feature", "page"]) {
2379
2267
  for (const e of registry.list(kind)) {
@@ -2419,8 +2307,8 @@ function audit(opts) {
2419
2307
  if (typeof m.id !== "string") continue;
2420
2308
  const filePath = ef.file.displayPath;
2421
2309
  const wellKnownName = WELL_KNOWN_FILES[m.kind];
2422
- if (path4.posix.basename(filePath) === wellKnownName) continue;
2423
- const dir = path4.posix.dirname(filePath);
2310
+ if (path5.posix.basename(filePath) === wellKnownName) continue;
2311
+ const dir = path5.posix.dirname(filePath);
2424
2312
  const wellKnownPath = dir === "." ? wellKnownName : `${dir}/${wellKnownName}`;
2425
2313
  if (scannedPaths.has(wellKnownPath)) continue;
2426
2314
  const kindLabel = m.kind === "page" ? "Page" : "Feature";
@@ -2437,53 +2325,55 @@ function audit(opts) {
2437
2325
  }
2438
2326
  }
2439
2327
  if (lint) {
2440
- const dynamicAttrRe = /\bdata-uidex(?:-(region|widget|primitive))?\s*=\s*\{/g;
2441
- const templateWithPrefixRe = /\bdata-uidex(?:-(region|widget|primitive))?\s*=\s*\{\s*`[^`$]+\$\{/g;
2442
- for (const f of files) {
2443
- const templatePrefixPositions = /* @__PURE__ */ new Set();
2444
- templateWithPrefixRe.lastIndex = 0;
2445
- let tm;
2446
- while ((tm = templateWithPrefixRe.exec(f.content)) !== null) {
2447
- templatePrefixPositions.add(tm.index);
2448
- }
2449
- let m;
2450
- dynamicAttrRe.lastIndex = 0;
2451
- while ((m = dynamicAttrRe.exec(f.content)) !== null) {
2452
- if (templatePrefixPositions.has(m.index)) continue;
2453
- const kind = m[1] ?? "element";
2454
- let line = 1;
2455
- for (let i = 0; i < m.index; i++) if (f.content[i] === "\n") line++;
2456
- const attrName = m[1] ? `data-uidex-${m[1]}` : "data-uidex";
2328
+ for (const ef of extracted) {
2329
+ for (const fact of ef.dynamicAttrs ?? []) {
2457
2330
  diagnostics.push({
2458
2331
  code: "dynamic-attr",
2459
2332
  severity: "warning",
2460
- message: `\`${attrName}={\u2026}\` uses a dynamic expression; the scanner cannot resolve the ${kind} id statically`,
2461
- file: f.displayPath,
2462
- line,
2463
- hint: dynamicAttrHint(kind)
2333
+ message: `\`${fact.attrName}={\u2026}\` uses a dynamic expression; the scanner cannot resolve the ${fact.kind} id statically`,
2334
+ file: ef.file.displayPath,
2335
+ line: fact.line,
2336
+ hint: dynamicAttrHint(fact.kind)
2464
2337
  });
2465
2338
  }
2466
2339
  }
2467
2340
  }
2468
2341
  if (lint) {
2469
- for (const f of files) {
2470
- const tagRe = /<(button|a|input|select|textarea)(?=[\s/>])/g;
2471
- let m;
2472
- while ((m = tagRe.exec(f.content)) !== null) {
2473
- const afterTag = m.index + m[0].length;
2474
- const closeIdx = findJsxOpeningEnd(f.content, afterTag);
2475
- if (closeIdx === -1) continue;
2476
- const attrs = f.content.slice(afterTag, closeIdx);
2477
- if (attrs.includes("data-uidex")) continue;
2478
- let line = 1;
2479
- for (let i = 0; i < m.index; i++) if (f.content[i] === "\n") line++;
2480
- diagnostics.push({
2481
- code: "missing-element-annotation",
2482
- severity: "info",
2483
- message: `Interactive <${m[1].toLowerCase()}> without data-uidex annotation`,
2484
- file: f.displayPath,
2485
- line
2486
- });
2342
+ const usedElementIds = new Set(registry.list("element").map((e) => e.id));
2343
+ for (const ef of extracted) {
2344
+ for (const fact of ef.unannotatedInteractive ?? []) {
2345
+ if (fact.hasSpread) {
2346
+ diagnostics.push({
2347
+ code: "spread-attr",
2348
+ severity: "info",
2349
+ 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`,
2350
+ file: ef.file.displayPath,
2351
+ line: fact.line,
2352
+ hint: "Prefer a string-literal data-uidex on the element itself, or annotate at the call site."
2353
+ });
2354
+ } else {
2355
+ const id = uniqueElementId(fact, usedElementIds);
2356
+ usedElementIds.add(id);
2357
+ diagnostics.push({
2358
+ code: "missing-element-annotation",
2359
+ severity: "info",
2360
+ message: `Interactive <${fact.tag}> without data-uidex annotation`,
2361
+ file: ef.file.displayPath,
2362
+ line: fact.line,
2363
+ hint: `Add \`data-uidex="${id}"\` (or run \`uidex scan --fix\`).`,
2364
+ fix: {
2365
+ description: `Add data-uidex="${id}" to <${fact.tag}>`,
2366
+ edits: [
2367
+ {
2368
+ path: ef.file.sourcePath,
2369
+ start: fact.nameEnd,
2370
+ end: fact.nameEnd,
2371
+ replacement: ` data-uidex="${id}"`
2372
+ }
2373
+ ]
2374
+ }
2375
+ });
2376
+ }
2487
2377
  }
2488
2378
  }
2489
2379
  }
@@ -2500,12 +2390,11 @@ function audit(opts) {
2500
2390
  }
2501
2391
  }
2502
2392
  }
2503
- for (const f of files) {
2504
- const importRe = /import\s+(?:[^'"]+)\s+from\s+['"]([^'"]+)['"]/g;
2505
- let m;
2506
- while ((m = importRe.exec(f.content)) !== null) {
2507
- const spec = m[1];
2508
- const baseName2 = spec.split("/").pop() ?? "";
2393
+ for (const ef of extracted) {
2394
+ const displayPath = ef.file.displayPath;
2395
+ for (const imp of ef.imports ?? []) {
2396
+ if (imp.isTypeOnly) continue;
2397
+ const baseName2 = imp.specifier.split("/").pop() ?? "";
2509
2398
  const primitive = byName.get(
2510
2399
  baseName2.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
2511
2400
  );
@@ -2513,25 +2402,37 @@ function audit(opts) {
2513
2402
  const scope = primitive.scopes?.[0];
2514
2403
  if (!scope) continue;
2515
2404
  const [kind, id] = scope.split(":");
2516
- const importerSegments = f.displayPath.split("/");
2405
+ const importerSegments = displayPath.split("/");
2517
2406
  if (importerSegments.includes(id) && importerSegments.includes(kind + "s")) {
2518
2407
  continue;
2519
2408
  }
2520
2409
  if (kind === "feature" && importerSegments.includes(id)) continue;
2521
- if (kind === "feature" && declaredFeatures.get(f.displayPath)?.has(id)) {
2410
+ if (kind === "feature" && declaredFeatures.get(displayPath)?.has(id)) {
2522
2411
  continue;
2523
2412
  }
2524
2413
  diagnostics.push({
2525
2414
  code: "scope-leak",
2526
2415
  severity: "warning",
2527
- message: `Primitive "${primitive.id}" is scoped to ${scope} but is imported from ${f.displayPath}`,
2528
- file: f.displayPath
2416
+ message: `Primitive "${primitive.id}" is scoped to ${scope} but is imported from ${displayPath}`,
2417
+ file: displayPath,
2418
+ line: imp.line
2529
2419
  });
2530
2420
  }
2531
2421
  }
2532
2422
  }
2533
2423
  if (lint && coverageEnabled) {
2424
+ const factsByLoc = /* @__PURE__ */ new Map();
2425
+ for (const ef of opts.flowExtracted ?? []) {
2426
+ for (const fact of ef.flows ?? []) {
2427
+ const lines = /* @__PURE__ */ new Map();
2428
+ for (const call of fact.calls) {
2429
+ if (!lines.has(call.id)) lines.set(call.id, call.line);
2430
+ }
2431
+ factsByLoc.set(`${ef.file.displayPath}:${fact.line}`, lines);
2432
+ }
2433
+ }
2534
2434
  for (const flow of registry.list("flow")) {
2435
+ const callLines = factsByLoc.get(`${flow.loc.file}:${flow.loc.line}`);
2535
2436
  for (const touchedId of flow.touches) {
2536
2437
  const found = registry.get("element", touchedId) ?? registry.get("widget", touchedId) ?? registry.get("region", touchedId) ?? registry.matchPattern("element", touchedId) ?? registry.matchPattern("widget", touchedId) ?? registry.matchPattern("region", touchedId);
2537
2438
  if (!found) {
@@ -2540,54 +2441,131 @@ function audit(opts) {
2540
2441
  severity: "warning",
2541
2442
  message: `Flow "${flow.id}" references unknown entity "${touchedId}"`,
2542
2443
  file: flow.loc.file,
2543
- line: flow.loc.line
2444
+ // Point at the uidex() call itself when the spec facts are
2445
+ // available; the describe line is the fallback.
2446
+ line: callLines?.get(touchedId) ?? flow.loc.line
2447
+ });
2448
+ }
2449
+ }
2450
+ }
2451
+ }
2452
+ if (lint) {
2453
+ const occurrences = /* @__PURE__ */ new Map();
2454
+ for (const ef of extracted) {
2455
+ for (const a of ef.annotations) {
2456
+ if (a.kind !== "element" && a.kind !== "region" && a.kind !== "widget" && a.kind !== "primitive") {
2457
+ continue;
2458
+ }
2459
+ const key = `${a.kind}:${a.id}`;
2460
+ let list = occurrences.get(key);
2461
+ if (!list) {
2462
+ list = [];
2463
+ occurrences.set(key, list);
2464
+ }
2465
+ list.push({ file: a.file, line: a.line });
2466
+ }
2467
+ }
2468
+ for (const [key, list] of occurrences) {
2469
+ const filesSeen = new Set(list.map((o) => o.file));
2470
+ if (filesSeen.size < 2) continue;
2471
+ const [kind, id] = key.split(/:(.*)/s);
2472
+ const others = list.slice(1).map((o) => `${o.file}:${o.line}`).join(", ");
2473
+ diagnostics.push({
2474
+ code: "duplicate-id",
2475
+ severity: kind === "widget" || kind === "primitive" ? "warning" : "info",
2476
+ message: `${kind} id "${id}" is declared in ${filesSeen.size} files (also at ${others}); the registry keeps only one entry`,
2477
+ file: list[0].file,
2478
+ line: list[0].line,
2479
+ entity: { kind, id },
2480
+ 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."
2481
+ });
2482
+ }
2483
+ }
2484
+ if (lint && coverageEnabled) {
2485
+ for (const ef of opts.flowExtracted ?? []) {
2486
+ for (const fact of ef.flows ?? []) {
2487
+ for (const dyn of fact.dynamicCalls ?? []) {
2488
+ diagnostics.push({
2489
+ code: "dynamic-flow-reference",
2490
+ severity: "warning",
2491
+ message: `\`uidex(\u2026)\` call in flow "${fact.title}" uses a dynamic expression; the id is invisible to coverage and registry validation`,
2492
+ file: ef.file.displayPath,
2493
+ line: dyn.line,
2494
+ hint: "Use a string-literal id (component ids inside uidex() must be statically analysable)."
2544
2495
  });
2545
2496
  }
2546
2497
  }
2547
2498
  }
2548
2499
  }
2500
+ if (lint && coverageEnabled) {
2501
+ for (const ef of extracted) {
2502
+ if (!ef.metadata) continue;
2503
+ for (const m of ef.metadata) {
2504
+ const check2 = (refKind, ids, spans) => {
2505
+ for (let i = 0; i < (ids?.length ?? 0); i++) {
2506
+ const refId = ids[i];
2507
+ const found = registry.get(refKind, refId) ?? registry.matchPattern(refKind, refId);
2508
+ if (found) continue;
2509
+ diagnostics.push({
2510
+ code: "unknown-reference",
2511
+ severity: "warning",
2512
+ message: `\`export const uidex\` in ${ef.file.displayPath} references unknown ${refKind} "${refId}"`,
2513
+ file: ef.file.displayPath,
2514
+ line: spans?.[i] ? lineOfOffset(ef.file.content, spans[i].start) : m.loc.line,
2515
+ hint: `No ${refKind} with id "${refId}" exists in the registry; fix the reference or add the ${refKind}.`
2516
+ });
2517
+ }
2518
+ };
2519
+ check2("feature", m.features, m.featureSpans);
2520
+ check2("widget", m.widgets, m.widgetSpans);
2521
+ }
2522
+ }
2523
+ }
2549
2524
  const summary = {
2550
2525
  errors: diagnostics.filter((d) => d.severity === "error").length,
2551
2526
  warnings: diagnostics.filter((d) => d.severity === "warning").length
2552
2527
  };
2553
2528
  return { diagnostics, summary };
2554
2529
  }
2555
- function legacyJsdocMigration(a) {
2556
- const quote = (s) => JSON.stringify(s);
2557
- const arr = (xs) => xs && xs.length > 0 ? `[${xs.map(quote).join(", ")}]` : "";
2558
- const entityHint = (kind) => {
2559
- const uidexKind = kind.charAt(0).toUpperCase() + kind.slice(1);
2560
- const parts = [`${kind}: ${quote(a.id)}`];
2561
- if (a.acceptance?.length) parts.push(`acceptance: ${arr(a.acceptance)}`);
2562
- return {
2563
- message: `Legacy JSDoc tag \`@uidex ${kind} ${a.id}\` is no longer recognised; migrate to \`export const uidex\``,
2564
- hint: `Replace with: export const uidex = { ${parts.join(", ")} } as const satisfies Uidex.${uidexKind}`
2565
- };
2566
- };
2567
- switch (a.kind) {
2568
- case "page-doc":
2569
- return entityHint("page");
2570
- case "feature-doc":
2571
- return entityHint("feature");
2572
- case "widget-doc":
2573
- return entityHint("widget");
2574
- case "not-flow":
2575
- return {
2576
- message: `Legacy JSDoc tag \`@uidex:not-flow\` is no longer recognised; migrate to \`export const uidex\``,
2577
- hint: `Replace with: export const uidex = { notFlow: true } as const satisfies Uidex.NotFlow`
2578
- };
2579
- case "orphan-acceptance":
2580
- return {
2581
- message: `Legacy JSDoc tag \`@acceptance\` is no longer recognised; migrate to the \`acceptance\` field on \`export const uidex\``,
2582
- hint: `Replace with: export const uidex = { /* kind */, acceptance: ${arr(a.acceptance)} } as const`
2583
- };
2584
- default:
2585
- return null;
2530
+ function lineOfOffset(content, offset) {
2531
+ let line = 1;
2532
+ for (let i = 0; i < offset && i < content.length; i++) {
2533
+ if (content[i] === "\n") line++;
2534
+ }
2535
+ return line;
2536
+ }
2537
+ var TAG_FALLBACK_ID = {
2538
+ a: "link",
2539
+ button: "button",
2540
+ input: "input",
2541
+ select: "select",
2542
+ textarea: "textarea"
2543
+ };
2544
+ function kebabId(str) {
2545
+ return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
2546
+ }
2547
+ function deriveElementId(fact) {
2548
+ const fromHint = fact.nameHint ? kebabId(fact.nameHint) : "";
2549
+ const capped = fromHint.split("-").filter(Boolean).slice(0, 5).join("-");
2550
+ return capped || TAG_FALLBACK_ID[fact.tag] || fact.tag;
2551
+ }
2552
+ function uniqueElementId(fact, used) {
2553
+ const base = deriveElementId(fact);
2554
+ if (!used.has(base)) return base;
2555
+ for (let n = 2; ; n++) {
2556
+ const candidate = `${base}-${n}`;
2557
+ if (!used.has(candidate)) return candidate;
2586
2558
  }
2587
2559
  }
2588
2560
  function normalizeLineEndings(s) {
2589
2561
  return s.replace(/\r\n/g, "\n");
2590
2562
  }
2563
+ function normalizeForCheck(s) {
2564
+ return normalizeLineEndings(s).replace(
2565
+ /export const gitContext = \{[\s\S]*?\} as const/,
2566
+ "export const gitContext = {} as const"
2567
+ );
2568
+ }
2591
2569
  function formatChangedSummary(change) {
2592
2570
  const parts = [];
2593
2571
  const fmt = (kind, names) => {
@@ -2686,62 +2664,11 @@ function extractEntitiesArray(source) {
2686
2664
  }
2687
2665
  return null;
2688
2666
  }
2689
- function findJsxOpeningEnd(src, start) {
2690
- let i = start;
2691
- while (i < src.length) {
2692
- const ch = src[i];
2693
- if (ch === ">" || ch === "/" && src[i + 1] === ">") return i;
2694
- if (ch === '"' || ch === "'" || ch === "`") {
2695
- i = skipString2(src, i);
2696
- } else if (ch === "{") {
2697
- i = skipBraces(src, i);
2698
- } else {
2699
- i++;
2700
- }
2701
- }
2702
- return -1;
2703
- }
2704
- function skipString2(src, start) {
2705
- const quote = src[start];
2706
- let i = start + 1;
2707
- while (i < src.length) {
2708
- if (src[i] === "\\" && quote !== "`") {
2709
- i += 2;
2710
- continue;
2711
- }
2712
- if (quote === "`" && src[i] === "$" && src[i + 1] === "{") {
2713
- i = skipBraces(src, i + 1);
2714
- continue;
2715
- }
2716
- if (src[i] === quote) return i + 1;
2717
- i++;
2718
- }
2719
- return i;
2720
- }
2721
- function skipBraces(src, start) {
2722
- let depth = 1;
2723
- let i = start + 1;
2724
- while (i < src.length && depth > 0) {
2725
- const ch = src[i];
2726
- if (ch === "{") {
2727
- depth++;
2728
- i++;
2729
- } else if (ch === "}") {
2730
- depth--;
2731
- i++;
2732
- } else if (ch === '"' || ch === "'" || ch === "`") {
2733
- i = skipString2(src, i);
2734
- } else {
2735
- i++;
2736
- }
2737
- }
2738
- return i;
2739
- }
2740
2667
  function dynamicAttrHint(kind) {
2741
2668
  if (kind === "region") {
2742
2669
  return `Use a string literal: \`data-uidex-region="id"\`, or declare the region via \`export const uidex = { region: "id" } as const satisfies Uidex.Region\` on the file that passes the region value`;
2743
2670
  }
2744
- return `The scanner requires string-literal attribute values. If this component forwards the annotation via a prop, restructure so the caller provides the annotated element directly (e.g. via a slot or render prop) with a string-literal \`data-uidex\` attribute`;
2671
+ return `The scanner resolves string literals, same-file const references, ternaries with literal branches, and template literals with static text (dynamic parts become \`*\` patterns). If this component forwards the annotation via a prop, restructure so the caller provides the annotated element directly (e.g. via a slot or render prop)`;
2745
2672
  }
2746
2673
  function stableStringify(value) {
2747
2674
  return JSON.stringify(value, stableReplacer);
@@ -2774,9 +2701,7 @@ function replacerSorted(_key, value) {
2774
2701
  }
2775
2702
  return value;
2776
2703
  }
2777
- function emitIdUnion(name, ids, typeMode) {
2778
- if (typeMode === "loose") return `export type ${name} = string
2779
- `;
2704
+ function emitIdUnion(name, ids) {
2780
2705
  if (ids.length === 0) return `export type ${name} = never
2781
2706
  `;
2782
2707
  const sorted = [...ids].sort();
@@ -2786,12 +2711,7 @@ ${body}
2786
2711
  `;
2787
2712
  }
2788
2713
  function emit(opts) {
2789
- const {
2790
- registry,
2791
- gitContext,
2792
- uidexImport = "uidex",
2793
- typeMode = "strict"
2794
- } = opts;
2714
+ const { registry, gitContext, uidexImport = "uidex" } = opts;
2795
2715
  const routes = [...registry.list("route")].sort(
2796
2716
  (a, b) => a.path.localeCompare(b.path)
2797
2717
  );
@@ -2814,57 +2734,49 @@ function emit(opts) {
2814
2734
  lines.push(
2815
2735
  emitIdUnion(
2816
2736
  "PageId",
2817
- pages.map((e) => e.id),
2818
- typeMode
2737
+ pages.map((e) => e.id)
2819
2738
  )
2820
2739
  );
2821
2740
  lines.push(
2822
2741
  emitIdUnion(
2823
2742
  "FeatureId",
2824
- features.map((e) => e.id),
2825
- typeMode
2743
+ features.map((e) => e.id)
2826
2744
  )
2827
2745
  );
2828
2746
  lines.push(
2829
2747
  emitIdUnion(
2830
2748
  "WidgetId",
2831
- widgets.map((e) => e.id),
2832
- typeMode
2749
+ widgets.map((e) => e.id)
2833
2750
  )
2834
2751
  );
2835
2752
  lines.push(
2836
2753
  emitIdUnion(
2837
2754
  "RegionId",
2838
- regions.map((e) => e.id),
2839
- typeMode
2755
+ regions.map((e) => e.id)
2840
2756
  )
2841
2757
  );
2842
2758
  lines.push(
2843
2759
  emitIdUnion(
2844
2760
  "ElementId",
2845
- elements.map((e) => e.id),
2846
- typeMode
2761
+ elements.map((e) => e.id)
2847
2762
  )
2848
2763
  );
2849
2764
  lines.push(
2850
2765
  emitIdUnion(
2851
2766
  "PrimitiveId",
2852
- primitives.map((e) => e.id),
2853
- typeMode
2767
+ primitives.map((e) => e.id)
2854
2768
  )
2855
2769
  );
2856
2770
  lines.push(
2857
2771
  emitIdUnion(
2858
2772
  "FlowId",
2859
- flows.map((e) => e.id),
2860
- typeMode
2773
+ flows.map((e) => e.id)
2861
2774
  )
2862
2775
  );
2863
2776
  lines.push(
2864
2777
  emitIdUnion(
2865
2778
  "RouteId",
2866
- routes.map((e) => e.path),
2867
- typeMode
2779
+ routes.map((e) => e.path)
2868
2780
  )
2869
2781
  );
2870
2782
  lines.push("");
@@ -2977,22 +2889,33 @@ function parseGitHubRef(ref) {
2977
2889
 
2978
2890
  // src/scanner/scan/scaffold.ts
2979
2891
  var fs3 = __toESM(require("fs"), 1);
2980
- var path5 = __toESM(require("path"), 1);
2892
+ var path6 = __toESM(require("path"), 1);
2981
2893
  function scaffoldWidgetSpec(opts) {
2894
+ return scaffoldSpec({
2895
+ registry: opts.registry,
2896
+ kind: "widget",
2897
+ id: opts.widgetId,
2898
+ outDir: opts.outDir,
2899
+ force: opts.force,
2900
+ fixtureImport: opts.fixtureImport
2901
+ });
2902
+ }
2903
+ function scaffoldSpec(opts) {
2982
2904
  const {
2983
2905
  registry,
2984
- widgetId,
2906
+ kind,
2907
+ id,
2985
2908
  outDir,
2986
2909
  force = false,
2987
2910
  fixtureImport = "./fixtures"
2988
2911
  } = opts;
2989
- const widget = registry.get("widget", widgetId);
2990
- if (!widget) {
2991
- throw new Error(`Widget "${widgetId}" not found in registry`);
2912
+ const entity = registry.get(kind, id);
2913
+ if (!entity) {
2914
+ throw new Error(`${capitalize(kind)} "${id}" not found in registry`);
2992
2915
  }
2993
- const criteria = widget.meta?.acceptance ?? [];
2994
- const filename = `widget-${widgetId}.spec.ts`;
2995
- const outputPath = path5.resolve(outDir, filename);
2916
+ const criteria = entity.meta?.acceptance ?? [];
2917
+ const filename = kind === "widget" ? `widget-${id}.spec.ts` : `flow-${id}.spec.ts`;
2918
+ const outputPath = path6.resolve(outDir, filename);
2996
2919
  if (fs3.existsSync(outputPath) && !force) {
2997
2920
  return {
2998
2921
  outputPath,
@@ -3001,15 +2924,14 @@ function scaffoldWidgetSpec(opts) {
3001
2924
  reason: `spec already exists at ${outputPath}; pass --force to overwrite`
3002
2925
  };
3003
2926
  }
3004
- const content = renderSpec({
3005
- widgetId,
3006
- criteria,
3007
- fixtureImport
3008
- });
3009
- fs3.mkdirSync(path5.dirname(outputPath), { recursive: true });
2927
+ const content = renderSpec({ id, criteria, fixtureImport });
2928
+ fs3.mkdirSync(path6.dirname(outputPath), { recursive: true });
3010
2929
  fs3.writeFileSync(outputPath, content, "utf8");
3011
2930
  return { outputPath, written: true, skipped: false };
3012
2931
  }
2932
+ function capitalize(s) {
2933
+ return s.charAt(0).toUpperCase() + s.slice(1);
2934
+ }
3013
2935
  function renderSpec(args) {
3014
2936
  const lines = [];
3015
2937
  lines.push(
@@ -3017,7 +2939,7 @@ function renderSpec(args) {
3017
2939
  );
3018
2940
  lines.push("");
3019
2941
  lines.push(
3020
- `test.describe(${JSON.stringify(args.widgetId)}, { tag: "@uidex:flow" }, () => {`
2942
+ `test.describe(${JSON.stringify(args.id)}, { tag: "@uidex:flow" }, () => {`
3021
2943
  );
3022
2944
  if (args.criteria.length === 0) {
3023
2945
  lines.push(` test("TODO: add acceptance criteria", async () => {`);
@@ -3040,7 +2962,7 @@ function renderSpec(args) {
3040
2962
 
3041
2963
  // src/scanner/scan/pipeline.ts
3042
2964
  var fs4 = __toESM(require("fs"), 1);
3043
- var path6 = __toESM(require("path"), 1);
2965
+ var path7 = __toESM(require("path"), 1);
3044
2966
  function runScan(opts = {}) {
3045
2967
  const cwd = opts.cwd ?? process.cwd();
3046
2968
  const configs = opts.configs ?? discover({ cwd });
@@ -3069,10 +2991,9 @@ function runOne(dc, opts) {
3069
2991
  const gitContext = resolveGitContext({ cwd: configDir });
3070
2992
  const generated = emit({
3071
2993
  registry: resolved.registry,
3072
- gitContext,
3073
- typeMode: config.typeMode
2994
+ gitContext
3074
2995
  });
3075
- const outputPath = path6.resolve(configDir, config.output);
2996
+ const outputPath = path7.resolve(configDir, config.output);
3076
2997
  const outputRel = config.output;
3077
2998
  let existingOnDisk = null;
3078
2999
  if (opts.check) {
@@ -3082,12 +3003,16 @@ function runOne(dc, opts) {
3082
3003
  existingOnDisk = null;
3083
3004
  }
3084
3005
  }
3006
+ const hasExtractDiagnostics = [...extracted, ...extractedFlows].some(
3007
+ (ef) => (ef.diagnostics?.length ?? 0) > 0
3008
+ );
3085
3009
  let auditResult;
3086
- if (opts.check || opts.lint || resolved.diagnostics.length > 0) {
3010
+ if (opts.check || opts.lint || resolved.diagnostics.length > 0 || hasExtractDiagnostics) {
3087
3011
  auditResult = audit({
3088
3012
  registry: resolved.registry,
3089
3013
  extracted,
3090
3014
  files: sourceFiles,
3015
+ flowExtracted: extractedFlows,
3091
3016
  config,
3092
3017
  check: opts.check,
3093
3018
  lint: opts.lint,
@@ -3108,34 +3033,281 @@ function runOne(dc, opts) {
3108
3033
  };
3109
3034
  }
3110
3035
  function writeScanResult(result) {
3111
- fs4.mkdirSync(path6.dirname(result.outputPath), { recursive: true });
3036
+ fs4.mkdirSync(path7.dirname(result.outputPath), { recursive: true });
3112
3037
  fs4.writeFileSync(result.outputPath, result.generated, "utf8");
3113
3038
  }
3114
3039
 
3040
+ // src/scanner/scan/fix.ts
3041
+ var fs5 = __toESM(require("fs"), 1);
3042
+ var path8 = __toESM(require("path"), 1);
3043
+ function applyFixes(diagnostics) {
3044
+ const entries = [];
3045
+ for (const d of diagnostics) {
3046
+ if (!d.fix) continue;
3047
+ entries.push({
3048
+ code: d.code,
3049
+ description: d.fix.description,
3050
+ file: d.file,
3051
+ edits: d.fix.edits ?? [],
3052
+ createFiles: d.fix.createFiles ?? [],
3053
+ deleteFiles: d.fix.deleteFiles ?? []
3054
+ });
3055
+ }
3056
+ if (entries.length === 0) return { applied: [], skipped: [] };
3057
+ const seenEdits = /* @__PURE__ */ new Set();
3058
+ const editsByFile = /* @__PURE__ */ new Map();
3059
+ for (const entry of entries) {
3060
+ for (const edit of entry.edits) {
3061
+ const key = `${edit.path}:${edit.start}:${edit.end}:${edit.replacement}`;
3062
+ if (seenEdits.has(key)) continue;
3063
+ seenEdits.add(key);
3064
+ let list = editsByFile.get(edit.path);
3065
+ if (!list) {
3066
+ list = [];
3067
+ editsByFile.set(edit.path, list);
3068
+ }
3069
+ list.push({ ...edit, entry });
3070
+ }
3071
+ }
3072
+ for (const [filePath, edits] of editsByFile) {
3073
+ let content;
3074
+ try {
3075
+ content = fs5.readFileSync(filePath, "utf8");
3076
+ } catch {
3077
+ for (const e of edits) e.entry.skippedReason ??= "file is unreadable";
3078
+ continue;
3079
+ }
3080
+ edits.sort((a, b) => a.start - b.start || a.end - b.end);
3081
+ const kept = [];
3082
+ let prevEnd = -1;
3083
+ for (const edit of edits) {
3084
+ if (edit.start < prevEnd) {
3085
+ edit.entry.skippedReason ??= "overlapping edit";
3086
+ continue;
3087
+ }
3088
+ kept.push(edit);
3089
+ prevEnd = edit.end;
3090
+ }
3091
+ for (let i = kept.length - 1; i >= 0; i--) {
3092
+ const edit = kept[i];
3093
+ content = content.slice(0, edit.start) + edit.replacement + content.slice(edit.end);
3094
+ }
3095
+ if (kept.length > 0) fs5.writeFileSync(filePath, content, "utf8");
3096
+ }
3097
+ for (const entry of entries) {
3098
+ if (entry.skippedReason) continue;
3099
+ for (const create of entry.createFiles) {
3100
+ if (fs5.existsSync(create.path)) {
3101
+ entry.skippedReason = `${path8.basename(create.path)} already exists`;
3102
+ continue;
3103
+ }
3104
+ fs5.mkdirSync(path8.dirname(create.path), { recursive: true });
3105
+ fs5.writeFileSync(create.path, create.content, "utf8");
3106
+ }
3107
+ if (entry.skippedReason) continue;
3108
+ for (const del of entry.deleteFiles) {
3109
+ try {
3110
+ fs5.unlinkSync(del);
3111
+ } catch {
3112
+ }
3113
+ }
3114
+ }
3115
+ const applied = [];
3116
+ const skipped = [];
3117
+ for (const entry of entries) {
3118
+ const summary = {
3119
+ code: entry.code,
3120
+ description: entry.description,
3121
+ file: entry.file
3122
+ };
3123
+ if (entry.skippedReason) {
3124
+ skipped.push({ ...summary, reason: entry.skippedReason });
3125
+ } else {
3126
+ applied.push(summary);
3127
+ }
3128
+ }
3129
+ return { applied, skipped };
3130
+ }
3131
+
3132
+ // src/scanner/scan/rename.ts
3133
+ var ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
3134
+ function renameEntity(opts) {
3135
+ const { cwd, kind, oldId, newId, force = false } = opts;
3136
+ const manual = [];
3137
+ const errors = [];
3138
+ if (!ID_RE.test(newId)) {
3139
+ return {
3140
+ edits: 0,
3141
+ manual,
3142
+ errors: [`New id "${newId}" is not kebab-case`]
3143
+ };
3144
+ }
3145
+ const configs = discover({ cwd });
3146
+ if (configs.length === 0) {
3147
+ return { edits: 0, manual, errors: [`No .uidex.json found under ${cwd}`] };
3148
+ }
3149
+ const edits = [];
3150
+ for (const dc of configs) {
3151
+ const { config, configDir } = dc;
3152
+ const sourceFiles = walk(config.sources, {
3153
+ cwd: configDir,
3154
+ globalExcludes: config.exclude
3155
+ });
3156
+ const extracted = extract(sourceFiles);
3157
+ const flowFiles = config.flows ? walk(
3158
+ config.flows.map((glob) => ({ rootDir: ".", include: [glob] })),
3159
+ { cwd: configDir, includeTests: true }
3160
+ ) : [];
3161
+ const extractedFlows = extract(flowFiles);
3162
+ const scan = runScan({ cwd: configDir, configs: [dc] })[0];
3163
+ const registry = scan.registry;
3164
+ if (!registry.get(kind, oldId)) {
3165
+ if (registry.matchPattern(kind, oldId)) {
3166
+ errors.push(
3167
+ `${kind} "${oldId}" only matches via a pattern id; pattern-backed ids cannot be renamed mechanically`
3168
+ );
3169
+ } else {
3170
+ errors.push(`${kind} "${oldId}" not found in registry`);
3171
+ }
3172
+ continue;
3173
+ }
3174
+ if (registry.get(kind, newId) && !force) {
3175
+ errors.push(
3176
+ `${kind} "${newId}" already exists; pass --force to merge the ids`
3177
+ );
3178
+ continue;
3179
+ }
3180
+ const quoteAs = (content, start) => {
3181
+ const q = content[start];
3182
+ return q === '"' || q === "'" || q === "`" ? `${q}${newId}${q}` : `"${newId}"`;
3183
+ };
3184
+ for (const ef of extracted) {
3185
+ for (const a of ef.annotations) {
3186
+ if (a.kind !== kind || a.id !== oldId) continue;
3187
+ if (a.span) {
3188
+ edits.push({
3189
+ path: ef.file.sourcePath,
3190
+ start: a.span.start,
3191
+ end: a.span.end,
3192
+ replacement: quoteAs(ef.file.content, a.span.start)
3193
+ });
3194
+ } else {
3195
+ manual.push({
3196
+ file: a.file,
3197
+ line: a.line,
3198
+ reason: "attribute value is not a plain string literal (const reference, ternary, or template)"
3199
+ });
3200
+ }
3201
+ }
3202
+ for (const m of ef.metadata ?? []) {
3203
+ if (kind === "widget" && m.kind === "widget" && m.id === oldId) {
3204
+ if (m.idSpan) {
3205
+ edits.push({
3206
+ path: ef.file.sourcePath,
3207
+ start: m.idSpan.start,
3208
+ end: m.idSpan.end,
3209
+ replacement: quoteAs(ef.file.content, m.idSpan.start)
3210
+ });
3211
+ } else {
3212
+ manual.push({
3213
+ file: ef.file.displayPath,
3214
+ line: m.loc.line ?? 1,
3215
+ reason: "widget export id is not a plain string literal"
3216
+ });
3217
+ }
3218
+ }
3219
+ if (kind === "widget" && m.widgets) {
3220
+ for (let i = 0; i < m.widgets.length; i++) {
3221
+ if (m.widgets[i] !== oldId) continue;
3222
+ const span = m.widgetSpans?.[i];
3223
+ if (span) {
3224
+ edits.push({
3225
+ path: ef.file.sourcePath,
3226
+ start: span.start,
3227
+ end: span.end,
3228
+ replacement: quoteAs(ef.file.content, span.start)
3229
+ });
3230
+ }
3231
+ }
3232
+ }
3233
+ }
3234
+ }
3235
+ for (const ef of extractedFlows) {
3236
+ for (const fact of ef.flows ?? []) {
3237
+ for (const call of fact.calls) {
3238
+ if (call.id !== oldId) continue;
3239
+ if (call.span) {
3240
+ edits.push({
3241
+ path: ef.file.sourcePath,
3242
+ start: call.span.start,
3243
+ end: call.span.end,
3244
+ replacement: quoteAs(ef.file.content, call.span.start)
3245
+ });
3246
+ } else {
3247
+ manual.push({
3248
+ file: ef.file.displayPath,
3249
+ line: call.line,
3250
+ reason: "uidex() argument is not a plain string literal"
3251
+ });
3252
+ }
3253
+ }
3254
+ }
3255
+ }
3256
+ }
3257
+ if (errors.length > 0) {
3258
+ return { edits: 0, manual, errors };
3259
+ }
3260
+ if (edits.length === 0 && manual.length === 0) {
3261
+ return {
3262
+ edits: 0,
3263
+ manual,
3264
+ errors: [
3265
+ `${kind} "${oldId}" has no editable occurrences (convention-derived ids like landmarks cannot be renamed)`
3266
+ ]
3267
+ };
3268
+ }
3269
+ const result = applyFixes([
3270
+ {
3271
+ code: "rename",
3272
+ severity: "info",
3273
+ message: "",
3274
+ fix: {
3275
+ description: `Rename ${kind} "${oldId}" to "${newId}"`,
3276
+ edits
3277
+ }
3278
+ }
3279
+ ]);
3280
+ if (result.skipped.length > 0) {
3281
+ errors.push(`Some edits were skipped: ${result.skipped[0].reason}`);
3282
+ }
3283
+ for (const r of runScan({ cwd })) writeScanResult(r);
3284
+ return { edits: edits.length, manual, errors };
3285
+ }
3286
+
3115
3287
  // src/scanner/scan/cli.ts
3116
- var fs7 = __toESM(require("fs"), 1);
3117
- var path9 = __toESM(require("path"), 1);
3288
+ var fs8 = __toESM(require("fs"), 1);
3289
+ var path11 = __toESM(require("path"), 1);
3118
3290
 
3119
3291
  // src/scanner/scan/ai/index.ts
3120
3292
  var p = __toESM(require("@clack/prompts"), 1);
3121
3293
 
3122
3294
  // src/scanner/scan/ai/providers/claude.ts
3123
- var fs6 = __toESM(require("fs"), 1);
3124
- var path8 = __toESM(require("path"), 1);
3295
+ var fs7 = __toESM(require("fs"), 1);
3296
+ var path10 = __toESM(require("path"), 1);
3125
3297
 
3126
3298
  // src/scanner/scan/ai/templates.ts
3127
- var fs5 = __toESM(require("fs"), 1);
3128
- var path7 = __toESM(require("path"), 1);
3299
+ var fs6 = __toESM(require("fs"), 1);
3300
+ var path9 = __toESM(require("path"), 1);
3129
3301
  function templatePath(rel) {
3130
3302
  const candidates = [
3131
- path7.resolve(__dirname, "../../templates", rel),
3132
- // dist/cli/cli.cjs → ../../templates
3133
- path7.resolve(__dirname, "../../../templates", rel)
3134
- // src/scan/ai/foo.ts../../../templates
3303
+ path9.resolve(__dirname, "../../templates", rel),
3304
+ // dist/cli/cli.cjs → ../../templates
3305
+ path9.resolve(__dirname, "../../../../templates", rel)
3306
+ // src/scanner/scan/ai → ../../../../templates
3135
3307
  ];
3136
3308
  for (const c of candidates) {
3137
3309
  try {
3138
- fs5.accessSync(c, fs5.constants.R_OK);
3310
+ fs6.accessSync(c, fs6.constants.R_OK);
3139
3311
  return c;
3140
3312
  } catch {
3141
3313
  continue;
@@ -3147,24 +3319,39 @@ function templatePath(rel) {
3147
3319
  );
3148
3320
  }
3149
3321
  function readTemplate(rel) {
3150
- return fs5.readFileSync(templatePath(rel), "utf8");
3322
+ return fs6.readFileSync(templatePath(rel), "utf8");
3151
3323
  }
3152
3324
 
3153
3325
  // src/scanner/scan/ai/providers/claude.ts
3154
- var CLAUDE_FILES = [
3155
- { dest: ".claude/rules/uidex.md", template: "claude/rules.md" },
3156
- { dest: ".claude/commands/uidex/audit.md", template: "claude/audit.md" },
3157
- { dest: ".claude/commands/uidex/api.md", template: "claude/api.md" }
3326
+ var SKILL_FILES = [
3327
+ { dest: ".claude/skills/uidex/SKILL.md", template: "claude/SKILL.md" },
3328
+ {
3329
+ dest: ".claude/skills/uidex/references/conventions.md",
3330
+ template: "claude/references/conventions.md"
3331
+ },
3332
+ {
3333
+ dest: ".claude/skills/uidex/references/audit.md",
3334
+ template: "claude/references/audit.md"
3335
+ },
3336
+ {
3337
+ dest: ".claude/skills/uidex/references/api.md",
3338
+ template: "claude/references/api.md"
3339
+ }
3340
+ ];
3341
+ var LEGACY_FILES = [
3342
+ ".claude/rules/uidex.md",
3343
+ ".claude/commands/uidex/audit.md",
3344
+ ".claude/commands/uidex/api.md"
3158
3345
  ];
3159
3346
  var claudeProvider = {
3160
3347
  id: "claude",
3161
3348
  label: "Claude Code",
3162
- description: "Adds .claude/rules/uidex.md, /uidex:audit, and /uidex:api slash commands.",
3349
+ description: "Adds .claude/skills/uidex/ skill with conventions, audit, and API references.",
3163
3350
  async install({ cwd, force }) {
3164
3351
  const changes = [];
3165
- for (const file of CLAUDE_FILES) {
3166
- const dest = path8.join(cwd, file.dest);
3167
- const exists = fs6.existsSync(dest);
3352
+ for (const file of SKILL_FILES) {
3353
+ const dest = path10.join(cwd, file.dest);
3354
+ const exists = fs7.existsSync(dest);
3168
3355
  if (exists && !force) {
3169
3356
  changes.push({
3170
3357
  path: file.dest,
@@ -3173,36 +3360,56 @@ var claudeProvider = {
3173
3360
  });
3174
3361
  continue;
3175
3362
  }
3176
- fs6.mkdirSync(path8.dirname(dest), { recursive: true });
3177
- fs6.writeFileSync(dest, readTemplate(file.template));
3363
+ fs7.mkdirSync(path10.dirname(dest), { recursive: true });
3364
+ fs7.writeFileSync(dest, readTemplate(file.template));
3178
3365
  changes.push({
3179
3366
  path: file.dest,
3180
3367
  action: exists ? "overwritten" : "created"
3181
3368
  });
3182
3369
  }
3370
+ for (const rel of LEGACY_FILES) {
3371
+ const dest = path10.join(cwd, rel);
3372
+ if (fs7.existsSync(dest)) {
3373
+ fs7.unlinkSync(dest);
3374
+ changes.push({ path: rel, action: "removed" });
3375
+ }
3376
+ }
3377
+ cleanupEmpty(path10.join(cwd, ".claude/commands/uidex"));
3378
+ cleanupEmpty(path10.join(cwd, ".claude/commands"));
3379
+ cleanupEmpty(path10.join(cwd, ".claude/rules"));
3183
3380
  return { changes };
3184
3381
  },
3185
3382
  async uninstall({ cwd }) {
3186
3383
  const changes = [];
3187
- for (const file of CLAUDE_FILES) {
3188
- const dest = path8.join(cwd, file.dest);
3189
- if (!fs6.existsSync(dest)) {
3384
+ for (const file of SKILL_FILES) {
3385
+ const dest = path10.join(cwd, file.dest);
3386
+ if (!fs7.existsSync(dest)) {
3190
3387
  changes.push({ path: file.dest, action: "skipped", reason: "absent" });
3191
3388
  continue;
3192
3389
  }
3193
- fs6.unlinkSync(dest);
3390
+ fs7.unlinkSync(dest);
3194
3391
  changes.push({ path: file.dest, action: "removed" });
3195
3392
  }
3196
- cleanupEmpty(path8.join(cwd, ".claude/commands/uidex"));
3197
- cleanupEmpty(path8.join(cwd, ".claude/commands"));
3198
- cleanupEmpty(path8.join(cwd, ".claude/rules"));
3393
+ cleanupEmpty(path10.join(cwd, ".claude/skills/uidex/references"));
3394
+ cleanupEmpty(path10.join(cwd, ".claude/skills/uidex"));
3395
+ cleanupEmpty(path10.join(cwd, ".claude/skills"));
3396
+ for (const rel of LEGACY_FILES) {
3397
+ const dest = path10.join(cwd, rel);
3398
+ if (fs7.existsSync(dest)) {
3399
+ fs7.unlinkSync(dest);
3400
+ changes.push({ path: rel, action: "removed" });
3401
+ }
3402
+ }
3403
+ cleanupEmpty(path10.join(cwd, ".claude/commands/uidex"));
3404
+ cleanupEmpty(path10.join(cwd, ".claude/commands"));
3405
+ cleanupEmpty(path10.join(cwd, ".claude/rules"));
3199
3406
  return { changes };
3200
3407
  }
3201
3408
  };
3202
3409
  function cleanupEmpty(dir) {
3203
3410
  try {
3204
- const entries = fs6.readdirSync(dir);
3205
- if (entries.length === 0) fs6.rmdirSync(dir);
3411
+ const entries = fs7.readdirSync(dir);
3412
+ if (entries.length === 0) fs7.rmdirSync(dir);
3206
3413
  } catch {
3207
3414
  }
3208
3415
  }
@@ -3369,6 +3576,8 @@ async function run(opts) {
3369
3576
  return runScanCommand(cwd, flags, writer);
3370
3577
  case "scaffold":
3371
3578
  return runScaffold(cwd, positional.slice(1), flags, writer);
3579
+ case "rename":
3580
+ return runRename(cwd, positional.slice(1), flags, writer);
3372
3581
  case "ai": {
3373
3582
  const result = await runAiCommand({
3374
3583
  cwd,
@@ -3395,7 +3604,8 @@ function helpText2() {
3395
3604
  "Commands:",
3396
3605
  " init Create a .uidex.json",
3397
3606
  " scan [flags] Run the scanner pipeline",
3398
- " scaffold widget <id> Emit a Playwright spec from a widget's acceptance",
3607
+ " scaffold <widget|page|feature> <id> Emit a Playwright spec from declared acceptance",
3608
+ " rename <element|widget|region> <old-id> <new-id> Rename an id everywhere (DOM attr, flows, exports)",
3399
3609
  " ai <install|uninstall|providers> Manage AI assistant integrations",
3400
3610
  " api <METHOD> <PATH> Call the uidex API",
3401
3611
  " api --list Show available API routes",
@@ -3404,16 +3614,17 @@ function helpText2() {
3404
3614
  "",
3405
3615
  "Flags:",
3406
3616
  " --check Verify the on-disk gen file matches a fresh scan; exit non-zero on drift (read-only)",
3407
- " --lint Run lint diagnostics (missing annotations, scope leak, legacy JSDoc)",
3617
+ " --lint Run lint diagnostics (missing annotations, scope leak, duplicate ids, coverage)",
3408
3618
  " --audit Equivalent to --check --lint (read-only)",
3619
+ " --fix Apply machine-generated fixes (add data-uidex to unannotated interactive elements, drop empty names), then rescan and write",
3409
3620
  " --json Emit JSON diagnostics on stdout",
3410
3621
  " --force (scaffold) overwrite existing spec",
3411
3622
  ""
3412
3623
  ].join("\n");
3413
3624
  }
3414
3625
  function runInit(cwd, w) {
3415
- const configPath = path9.join(cwd, CONFIG_FILENAME);
3416
- if (fs7.existsSync(configPath)) {
3626
+ const configPath = path11.join(cwd, CONFIG_FILENAME);
3627
+ if (fs8.existsSync(configPath)) {
3417
3628
  w.err(`.uidex.json already exists at ${configPath}`);
3418
3629
  return w.result(1);
3419
3630
  }
@@ -3422,16 +3633,16 @@ function runInit(cwd, w) {
3422
3633
  sources: [{ rootDir: "src" }],
3423
3634
  output: "src/uidex.gen.ts"
3424
3635
  };
3425
- fs7.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
3636
+ fs8.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
3426
3637
  w.out(`Created ${configPath}`);
3427
- const gitignorePath = path9.join(cwd, ".gitignore");
3638
+ const gitignorePath = path11.join(cwd, ".gitignore");
3428
3639
  const entry = "*.gen.ts";
3429
- if (fs7.existsSync(gitignorePath)) {
3430
- const existing = fs7.readFileSync(gitignorePath, "utf8");
3640
+ if (fs8.existsSync(gitignorePath)) {
3641
+ const existing = fs8.readFileSync(gitignorePath, "utf8");
3431
3642
  const hasEntry = existing.split("\n").some((line) => line.trim() === entry);
3432
3643
  if (!hasEntry) {
3433
3644
  const needsNewline = existing.length > 0 && !existing.endsWith("\n");
3434
- fs7.appendFileSync(
3645
+ fs8.appendFileSync(
3435
3646
  gitignorePath,
3436
3647
  `${needsNewline ? "\n" : ""}${entry}
3437
3648
  `,
@@ -3440,21 +3651,33 @@ function runInit(cwd, w) {
3440
3651
  w.out(`Appended ${entry} to ${gitignorePath}`);
3441
3652
  }
3442
3653
  } else {
3443
- fs7.writeFileSync(gitignorePath, `${entry}
3654
+ fs8.writeFileSync(gitignorePath, `${entry}
3444
3655
  `, "utf8");
3445
3656
  w.out(`Created ${gitignorePath} with ${entry}`);
3446
3657
  }
3447
3658
  return w.result(0);
3448
3659
  }
3449
3660
  function runScanCommand(cwd, flags, w) {
3450
- const check = Boolean(flags.check || flags.audit);
3451
- const lint = Boolean(flags.lint || flags.audit);
3661
+ const fix = Boolean(flags.fix);
3662
+ const check = !fix && Boolean(flags.check || flags.audit);
3663
+ const lint = Boolean(flags.lint || flags.audit || fix);
3452
3664
  const asJson = Boolean(flags.json);
3453
- const configs = discover({ cwd });
3665
+ let configs = discover({ cwd });
3454
3666
  if (configs.length === 0) {
3455
3667
  w.err(`No ${CONFIG_FILENAME} found under ${cwd}`);
3456
3668
  return w.result(1);
3457
3669
  }
3670
+ let fixed = [];
3671
+ let fixSkipped = [];
3672
+ if (fix) {
3673
+ const discovery = runScan({ cwd, check: true, lint: true, configs });
3674
+ const result = applyFixes(
3675
+ discovery.flatMap((r) => r.audit?.diagnostics ?? [])
3676
+ );
3677
+ fixed = result.applied;
3678
+ fixSkipped = result.skipped;
3679
+ configs = discover({ cwd });
3680
+ }
3458
3681
  const results = runScan({ cwd, check, lint, configs });
3459
3682
  if (!check) {
3460
3683
  for (const r of results) writeScanResult(r);
@@ -3469,9 +3692,21 @@ function runScanCommand(cwd, flags, w) {
3469
3692
  { errors: 0, warnings: 0 }
3470
3693
  );
3471
3694
  if (asJson) {
3472
- const out2 = { diagnostics: allDiagnostics, summary };
3695
+ const out2 = {
3696
+ diagnostics: allDiagnostics.map(jsonDiagnostic),
3697
+ summary,
3698
+ ...fix ? { fixed, fixSkipped } : {}
3699
+ };
3473
3700
  w.out(JSON.stringify(out2, null, 2));
3474
3701
  } else {
3702
+ for (const f of fixed) {
3703
+ w.out(`FIXED [${f.code}] ${f.file ?? ""} ${f.description}`);
3704
+ }
3705
+ for (const s of fixSkipped) {
3706
+ w.out(
3707
+ `SKIPPED [${s.code}] ${s.file ?? ""} ${s.description} (${s.reason})`
3708
+ );
3709
+ }
3475
3710
  for (const r of results) {
3476
3711
  if (check) {
3477
3712
  w.out(`Checked ${r.outputPath}`);
@@ -3481,7 +3716,10 @@ function runScanCommand(cwd, flags, w) {
3481
3716
  for (const d of r.audit?.diagnostics ?? []) {
3482
3717
  const loc = d.file ? `${d.file}${d.line ? `:${d.line}` : ""}` : "";
3483
3718
  const stream = d.severity === "error" ? w.err : w.out;
3484
- stream(`${d.severity.toUpperCase()} [${d.code}] ${loc} ${d.message}`);
3719
+ const fixable = d.fix && !fix ? " [fixable: run with --fix]" : "";
3720
+ stream(
3721
+ `${d.severity.toUpperCase()} [${d.code}] ${loc} ${d.message}${fixable}`
3722
+ );
3485
3723
  if (d.hint) stream(` hint: ${d.hint}`);
3486
3724
  }
3487
3725
  }
@@ -3492,20 +3730,27 @@ function runScanCommand(cwd, flags, w) {
3492
3730
  const exit = summary.errors > 0 ? 1 : 0;
3493
3731
  return w.result(exit);
3494
3732
  }
3733
+ function jsonDiagnostic(d) {
3734
+ const { fix, ...rest } = d;
3735
+ return fix ? { ...rest, fixable: true } : rest;
3736
+ }
3737
+ var SCAFFOLD_KINDS = /* @__PURE__ */ new Set(["widget", "page", "feature"]);
3495
3738
  function runScaffold(cwd, args, flags, w) {
3496
3739
  const [kind, id] = args;
3497
- if (kind !== "widget" || !id) {
3498
- w.err("Usage: uidex scaffold widget <id> [--force]");
3740
+ if (!kind || !SCAFFOLD_KINDS.has(kind) || !id) {
3741
+ w.err("Usage: uidex scaffold <widget|page|feature> <id> [--force]");
3499
3742
  return w.result(1);
3500
3743
  }
3744
+ const scaffoldKind = kind;
3501
3745
  const results = runScan({ cwd });
3502
3746
  for (const r of results) {
3503
- const widget = r.registry.get("widget", id);
3504
- if (!widget) continue;
3505
- const outDir = path9.resolve(r.configDir, "e2e");
3506
- const result = scaffoldWidgetSpec({
3747
+ const entity = r.registry.get(scaffoldKind, id);
3748
+ if (!entity) continue;
3749
+ const outDir = path11.resolve(r.configDir, "e2e");
3750
+ const result = scaffoldSpec({
3507
3751
  registry: r.registry,
3508
- widgetId: id,
3752
+ kind: scaffoldKind,
3753
+ id,
3509
3754
  outDir,
3510
3755
  force: Boolean(flags.force)
3511
3756
  });
@@ -3516,9 +3761,43 @@ function runScaffold(cwd, args, flags, w) {
3516
3761
  w.out(`Wrote ${result.outputPath}`);
3517
3762
  return w.result(0);
3518
3763
  }
3519
- w.err(`Widget "${id}" not found in registry`);
3764
+ w.err(
3765
+ `${scaffoldKind.charAt(0).toUpperCase() + scaffoldKind.slice(1)} "${id}" not found in registry`
3766
+ );
3520
3767
  return w.result(1);
3521
3768
  }
3769
+ var RENAME_KINDS = /* @__PURE__ */ new Set(["element", "widget", "region"]);
3770
+ function runRename(cwd, args, flags, w) {
3771
+ const [kind, oldId, newId] = args;
3772
+ if (!kind || !RENAME_KINDS.has(kind) || !oldId || !newId) {
3773
+ w.err(
3774
+ "Usage: uidex rename <element|widget|region> <old-id> <new-id> [--force]"
3775
+ );
3776
+ return w.result(1);
3777
+ }
3778
+ const result = renameEntity({
3779
+ cwd,
3780
+ kind,
3781
+ oldId,
3782
+ newId,
3783
+ force: Boolean(flags.force)
3784
+ });
3785
+ for (const e of result.errors) w.err(e);
3786
+ for (const m of result.manual) {
3787
+ w.err(`MANUAL ${m.file}:${m.line} \u2014 ${m.reason}`);
3788
+ }
3789
+ if (result.errors.length > 0) return w.result(1);
3790
+ w.out(
3791
+ `Renamed ${kind} "${oldId}" \u2192 "${newId}" (${result.edits} edit(s)); gen file regenerated`
3792
+ );
3793
+ if (result.manual.length > 0) {
3794
+ w.err(
3795
+ `${result.manual.length} occurrence(s) need manual follow-up (listed above)`
3796
+ );
3797
+ return w.result(1);
3798
+ }
3799
+ return w.result(0);
3800
+ }
3522
3801
  function createWriter() {
3523
3802
  let stdout = "";
3524
3803
  let stderr = "";
@@ -3539,7 +3818,7 @@ function createWriter() {
3539
3818
  CONFIG_FILENAME,
3540
3819
  ConfigError,
3541
3820
  DEFAULT_CONVENTIONS,
3542
- DEFAULT_TYPE_MODE,
3821
+ applyFixes,
3543
3822
  audit,
3544
3823
  detectRoutes,
3545
3824
  discover,
@@ -3549,10 +3828,12 @@ function createWriter() {
3549
3828
  globToRegExp,
3550
3829
  parseConfig,
3551
3830
  pathToId,
3831
+ renameEntity,
3552
3832
  resolve,
3553
3833
  resolveGitContext,
3554
3834
  runCli,
3555
3835
  runScan,
3836
+ scaffoldSpec,
3556
3837
  scaffoldWidgetSpec,
3557
3838
  validateConfig,
3558
3839
  walk,