uidex 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +3 -3
  2. package/dist/cli/cli.cjs +1542 -1227
  3. package/dist/cli/cli.cjs.map +1 -1
  4. package/dist/cloud/index.cjs +385 -175
  5. package/dist/cloud/index.cjs.map +1 -1
  6. package/dist/cloud/index.d.cts +192 -4
  7. package/dist/cloud/index.d.ts +192 -4
  8. package/dist/cloud/index.js +377 -177
  9. package/dist/cloud/index.js.map +1 -1
  10. package/dist/headless/index.cjs +116 -251
  11. package/dist/headless/index.cjs.map +1 -1
  12. package/dist/headless/index.d.cts +6 -11
  13. package/dist/headless/index.d.ts +6 -11
  14. package/dist/headless/index.js +116 -253
  15. package/dist/headless/index.js.map +1 -1
  16. package/dist/index.cjs +776 -1055
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +152 -160
  19. package/dist/index.d.ts +152 -160
  20. package/dist/index.js +792 -1066
  21. package/dist/index.js.map +1 -1
  22. package/dist/react/index.cjs +801 -1019
  23. package/dist/react/index.cjs.map +1 -1
  24. package/dist/react/index.d.cts +102 -86
  25. package/dist/react/index.d.ts +102 -86
  26. package/dist/react/index.js +821 -1038
  27. package/dist/react/index.js.map +1 -1
  28. package/dist/scan/index.cjs +1550 -1220
  29. package/dist/scan/index.cjs.map +1 -1
  30. package/dist/scan/index.d.cts +210 -12
  31. package/dist/scan/index.d.ts +210 -12
  32. package/dist/scan/index.js +1547 -1219
  33. package/dist/scan/index.js.map +1 -1
  34. package/package.json +22 -21
  35. package/templates/claude/SKILL.md +71 -0
  36. package/templates/claude/references/audit.md +43 -0
  37. package/templates/claude/{rules.md → references/conventions.md} +25 -28
  38. package/templates/claude/audit.md +0 -43
  39. /package/templates/claude/{api.md → references/api.md} +0 -0
@@ -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;
541
- continue;
542
- }
543
- if (c === "$" && n === "{") {
544
- templateDepth++;
545
- i += 2;
546
- continue;
547
- }
548
- if (c === "`" && templateDepth === 0) {
549
- inTemplate = false;
550
- i++;
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") {
551
558
  continue;
552
559
  }
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
- }
647
- }
648
- next() {
649
- this.skipTrivia();
650
- if (this.pos >= this.src.length) {
651
- return { kind: "eof", value: "", pos: this.currentPos(), end: this.pos };
652
- }
653
- const pos = this.currentPos();
654
- const c = this.src[this.pos];
655
- switch (c) {
656
- case "{":
657
- this.advance();
658
- return { kind: "lbrace", value: c, pos, end: this.pos };
659
- case "}":
660
- this.advance();
661
- return { kind: "rbrace", value: c, pos, end: this.pos };
662
- case "[":
663
- this.advance();
664
- return { kind: "lbracket", value: c, pos, end: this.pos };
665
- case "]":
666
- this.advance();
667
- return { kind: "rbracket", value: c, pos, end: this.pos };
668
- case "(":
669
- this.advance();
670
- return { kind: "lparen", value: c, pos, end: this.pos };
671
- case ")":
672
- this.advance();
673
- return { kind: "rparen", value: c, pos, end: this.pos };
674
- case ",":
675
- this.advance();
676
- return { kind: "comma", value: c, pos, end: this.pos };
677
- case ":":
678
- this.advance();
679
- return { kind: "colon", value: c, pos, end: this.pos };
680
- }
681
- if (c === "." && this.src[this.pos + 1] === "." && this.src[this.pos + 2] === ".") {
682
- this.advance(3);
683
- return { kind: "spread", value: "...", pos, end: this.pos };
684
- }
685
- if (c === '"' || c === "'") {
686
- return this.readString(pos, c);
687
- }
688
- if (c === "`") {
689
- return this.readTemplate(pos);
690
- }
691
- if (isDigit(c) || c === "-" && isDigit(this.src[this.pos + 1])) {
692
- return this.readNumber(pos);
693
595
  }
694
- if (isIdentStart(c)) {
695
- return this.readIdent(pos);
696
- }
697
- this.advance();
698
- return { kind: "punct", value: c, pos, end: this.pos };
699
596
  }
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
905
- );
906
- case "spread":
907
- throw new ExtractError(
908
- "uidex-export-invalid-literal",
909
- "Spread (`...`) is not allowed in `export const uidex`; the right-hand side must be a plain literal.",
910
- t.pos
911
- );
912
- case "lparen":
913
- throw new ExtractError(
914
- "uidex-export-invalid-literal",
915
- "Parenthesised or grouped expressions are not allowed in `export const uidex`.",
916
- t.pos
917
- );
918
- case "punct":
919
- throw new ExtractError(
920
- "uidex-export-invalid-literal",
921
- `Unexpected token "${t.value}" in \`export const uidex\`.`,
922
- t.pos
923
- );
924
- case "eof":
925
- throw new ExtractError(
926
- "uidex-export-invalid-literal",
927
- "Expected a value for `export const uidex` but reached end of file.",
928
- t.pos
929
- );
930
- default:
931
- throw new ExtractError(
932
- "uidex-export-invalid-literal",
933
- `Unexpected token in \`export const uidex\`.`,
934
- t.pos
646
+ pos
935
647
  );
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,316 +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;
1262
- }
1263
- }
1264
- return { offset, line, column: offset - lineStart + 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";
1265
1025
  }
1266
1026
 
1267
- // src/scanner/scan/jsx-ancestry.ts
1268
- var DATA_ATTR_RE = /\bdata-uidex(?:-(region|widget|primitive))?\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
1269
- function parseDataAttrs(tagSource) {
1270
- if (!tagSource.includes("data-uidex")) return [];
1271
- const out2 = [];
1272
- for (const m of tagSource.matchAll(DATA_ATTR_RE)) {
1273
- const kind = m[1] ?? "element";
1274
- const id = m[2] ?? m[3];
1275
- if (id) out2.push({ kind, id });
1276
- }
1277
- return out2;
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;
1278
1040
  }
1279
- function collectJSXAncestry(content) {
1280
- if (!content.includes("data-uidex")) return [];
1281
- const out2 = [];
1282
- const ancestors = [];
1283
- const stack = [];
1284
- const N = content.length;
1285
- let i = 0;
1286
- let line = 1;
1287
- const advanceLines = (from, to) => {
1288
- for (let k = from; k < to; k++) {
1289
- if (content.charCodeAt(k) === 10) line++;
1290
- }
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 } : {}
1291
1057
  };
1292
- while (i < N) {
1293
- const c = content[i];
1294
- if (c === "\n") {
1295
- line++;
1296
- i++;
1297
- continue;
1298
- }
1299
- if (c === "/" && content[i + 1] === "/") {
1300
- while (i < N && content[i] !== "\n") i++;
1301
- continue;
1302
- }
1303
- if (c === "/" && content[i + 1] === "*") {
1304
- const end = content.indexOf("*/", i + 2);
1305
- const next = end === -1 ? N : end + 2;
1306
- advanceLines(i, next);
1307
- i = next;
1308
- continue;
1309
- }
1310
- if (c === '"' || c === "'") {
1311
- const next = skipString(content, i, c);
1312
- advanceLines(i, next);
1313
- i = next;
1314
- continue;
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
+ }
1315
1073
  }
1316
- if (c === "`") {
1317
- const next = skipTemplate(content, i);
1318
- advanceLines(i, next);
1319
- i = next;
1320
- continue;
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;
1321
1089
  }
1322
- if (c === "<") {
1323
- const nextCh = content[i + 1];
1324
- if (nextCh === "/") {
1325
- const end = content.indexOf(">", i);
1326
- if (end === -1) break;
1327
- const tagName = content.slice(i + 2, end).match(/^\s*([\w.-]*)/)?.[1] ?? "";
1328
- if (tagName) {
1329
- for (let k = stack.length - 1; k >= 0; k--) {
1330
- if (stack[k].tagName === tagName) {
1331
- for (let j = stack.length - 1; j >= k; j--) {
1332
- ancestors.length -= stack[j].pushed;
1333
- }
1334
- stack.length = k;
1335
- break;
1336
- }
1337
- }
1338
- }
1339
- advanceLines(i, end + 1);
1340
- i = end + 1;
1341
- continue;
1342
- }
1343
- if (nextCh && /[A-Za-z_]/.test(nextCh)) {
1344
- const end = findTagEnd(content, i + 1);
1345
- if (end === -1) break;
1346
- const tagSource = content.slice(i, end + 1);
1347
- const tagName = tagSource.match(/^<\s*([\w.-]*)/)?.[1] ?? "";
1348
- const isSelf = content[end - 1] === "/";
1349
- if (tagName) {
1350
- const attrs = parseDataAttrs(tagSource);
1351
- if (attrs.length > 0) {
1352
- const snapshot = ancestors.slice();
1353
- for (const a of attrs) {
1354
- out2.push({ kind: a.kind, id: a.id, line, ancestors: snapshot });
1355
- }
1356
- }
1357
- if (!isSelf) {
1358
- for (const a of attrs) ancestors.push(a);
1359
- stack.push({ tagName, pushed: attrs.length });
1360
- }
1361
- }
1362
- advanceLines(i, end + 1);
1363
- i = end + 1;
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;
1137
+ }
1138
+ return null;
1139
+ }
1140
+ function isIdentifier(node, name) {
1141
+ return typeof node === "object" && node !== null && node.type === "Identifier" && String(node.name) === name;
1142
+ }
1143
+
1144
+ // src/scanner/scan/jsx-ancestry.ts
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);
1364
1168
  continue;
1365
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);
1366
1175
  }
1367
- i++;
1368
- }
1369
- return out2;
1176
+ return void 0;
1177
+ });
1178
+ return consts;
1370
1179
  }
1371
- function skipString(content, start, quote) {
1372
- const N = content.length;
1373
- let i = start + 1;
1374
- while (i < N) {
1375
- const c = content[i];
1376
- if (c === "\\") {
1377
- i += 2;
1378
- continue;
1379
- }
1380
- if (c === quote) return i + 1;
1381
- i++;
1180
+ function staticString(node) {
1181
+ if (node.type === "Literal" && typeof node.value === "string") {
1182
+ return node.value;
1382
1183
  }
1383
- return N;
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 ?? "";
1189
+ }
1190
+ return null;
1384
1191
  }
1385
- function skipTemplate(content, start) {
1386
- const N = content.length;
1387
- let i = start + 1;
1388
- while (i < N) {
1389
- const c = content[i];
1390
- if (c === "\\") {
1391
- i += 2;
1392
- continue;
1393
- }
1394
- if (c === "`") return i + 1;
1395
- if (c === "$" && content[i + 1] === "{") {
1396
- i += 2;
1397
- let depth = 1;
1398
- while (i < N && depth > 0) {
1399
- const cj = content[i];
1400
- if (cj === '"' || cj === "'") {
1401
- i = skipString(content, i, cj);
1402
- continue;
1403
- }
1404
- if (cj === "`") {
1405
- i = skipTemplate(content, i);
1406
- continue;
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 };
1407
1251
  }
1408
- if (cj === "{") depth++;
1409
- else if (cj === "}") depth--;
1410
- i++;
1411
1252
  }
1412
- continue;
1253
+ if (!result.resolved) {
1254
+ dynamicAttrs.push({
1255
+ kind,
1256
+ attrName: kind === "element" ? "data-uidex" : `data-uidex-${kind}`,
1257
+ line: lineAt(attr.start)
1258
+ });
1259
+ continue;
1260
+ }
1261
+ }
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);
1413
1274
  }
1414
- i++;
1415
1275
  }
1416
- return N;
1276
+ return [...statics, ...patterns];
1417
1277
  }
1418
- function findTagEnd(content, start) {
1419
- const N = content.length;
1420
- let i = start;
1421
- while (i < N) {
1422
- const c = content[i];
1423
- if (c === '"' || c === "'") {
1424
- i = skipString(content, i, c);
1425
- continue;
1426
- }
1427
- if (c === "`") {
1428
- i = skipTemplate(content, i);
1429
- continue;
1430
- }
1431
- if (c === "{") {
1432
- let depth = 1;
1433
- i++;
1434
- while (i < N && depth > 0) {
1435
- const cj = content[i];
1436
- if (cj === '"' || cj === "'") {
1437
- i = skipString(content, i, cj);
1438
- 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
+ });
1439
1310
  }
1440
- if (cj === "`") {
1441
- i = skipTemplate(content, i);
1442
- 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++;
1443
1320
  }
1444
- if (cj === "{") depth++;
1445
- else if (cj === "}") depth--;
1446
- i++;
1447
1321
  }
1448
- 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;
1449
1330
  }
1450
- if (c === ">") return i;
1451
- i++;
1452
- }
1453
- return -1;
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);
1341
+ }
1342
+ }
1343
+ };
1344
+ visit(parsed.program);
1345
+ return { occurrences, dynamicAttrs, unannotatedInteractive, landmarks };
1454
1346
  }
1455
-
1456
- // src/scanner/scan/extract.ts
1457
- var JSDOC_BLOCK = /\/\*\*([\s\S]*?)\*\//g;
1458
- function lineAt(content, index) {
1459
- let line = 1;
1460
- for (let i = 0; i < index && i < content.length; i++) {
1461
- 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) };
1462
1353
  }
1463
- 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;
1464
1364
  }
1465
- function parseJSDoc(block) {
1466
- const lines = block.split("\n").map((l) => l.replace(/^\s*\*\s?/, "").replace(/^\s*\/?\*+/, ""));
1467
- let kind = null;
1468
- let id = null;
1469
- const acceptance = [];
1470
- const desc = [];
1471
- let notFlow = false;
1472
- for (const raw of lines) {
1473
- const line = raw.trim();
1474
- if (!line) continue;
1475
- const uidex = line.match(
1476
- /^@uidex\s+(page|feature|widget)\s+(\S+)(?:\s+-\s+(.+))?/
1477
- );
1478
- if (uidex) {
1479
- kind = uidex[1];
1480
- id = uidex[2];
1481
- 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;
1482
1375
  continue;
1483
1376
  }
1484
- if (/^@uidex:not-flow\b/.test(line)) {
1485
- notFlow = true;
1486
- 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;
1487
1399
  }
1488
- const accept = line.match(/^@acceptance\s+(.+)$/);
1489
- if (accept) {
1490
- acceptance.push(accept[1].trim());
1491
- 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 ?? ""));
1492
1409
  }
1493
- if (line.startsWith("@")) continue;
1494
- desc.push(line);
1495
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;
1496
1432
  return {
1497
- kind,
1498
- id,
1499
- description: desc.join(" ").trim(),
1500
- acceptance,
1501
- 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"
1502
1439
  };
1503
1440
  }
1504
1441
  function extract(files) {
1505
1442
  return files.map((file) => {
1506
- const { exports: exports2, diagnostics } = extractUidexExports(file);
1507
- const out2 = {
1508
- file,
1509
- annotations: extractOne(file)
1510
- };
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);
1511
1449
  if (exports2.length > 0) out2.metadata = exports2;
1512
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;
1513
1455
  return out2;
1514
1456
  });
1515
1457
  }
1516
- function extractOne(file) {
1458
+ function extractOne(file, parsed, out2) {
1517
1459
  const annotations = [];
1518
- const { content, displayPath } = file;
1519
- 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) {
1520
1468
  annotations.push({
1521
1469
  kind: occ.kind,
1522
1470
  id: occ.id,
1523
1471
  file: displayPath,
1524
1472
  line: occ.line,
1525
- ...occ.ancestors.length > 0 ? { ancestors: occ.ancestors } : {}
1473
+ ...occ.ancestors.length > 0 ? { ancestors: occ.ancestors } : {},
1474
+ ...occ.span ? { span: occ.span } : {}
1526
1475
  });
1527
1476
  }
1528
- JSDOC_BLOCK.lastIndex = 0;
1529
- let jm;
1530
- while ((jm = JSDOC_BLOCK.exec(content)) !== null) {
1531
- const parsed = parseJSDoc(jm[1]);
1532
- const line = lineAt(content, jm.index);
1533
- if (parsed.notFlow) {
1534
- annotations.push({ kind: "not-flow", id: "", file: displayPath, line });
1535
- }
1536
- if (parsed.kind && parsed.id) {
1537
- const kind = parsed.kind === "page" ? "page-doc" : parsed.kind === "feature" ? "feature-doc" : "widget-doc";
1538
- annotations.push({
1539
- kind,
1540
- id: parsed.id,
1541
- file: displayPath,
1542
- line,
1543
- description: parsed.description || void 0,
1544
- acceptance: parsed.acceptance.length ? parsed.acceptance : void 0
1545
- });
1546
- } else if (parsed.acceptance.length > 0) {
1547
- annotations.push({
1548
- kind: "orphan-acceptance",
1549
- id: "",
1550
- file: displayPath,
1551
- line,
1552
- acceptance: parsed.acceptance
1553
- });
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;
1554
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
+ });
1555
1510
  }
1556
- return annotations;
1511
+ return out2;
1557
1512
  }
1558
1513
 
1559
1514
  // src/scanner/scan/resolve.ts
1560
- var path3 = __toESM(require("path"), 1);
1515
+ var path4 = __toESM(require("path"), 1);
1561
1516
 
1562
1517
  // src/shared/entities/types.ts
1563
1518
  var ENTITY_KINDS = [
@@ -1623,6 +1578,7 @@ function freezeEntity(entity, flows) {
1623
1578
  function createRegistry() {
1624
1579
  const store = emptyStore();
1625
1580
  let flowsCache = null;
1581
+ const patternCache = /* @__PURE__ */ new Map();
1626
1582
  const getFlows = () => {
1627
1583
  if (flowsCache === null) flowsCache = Array.from(store.flow.values());
1628
1584
  return flowsCache;
@@ -1632,6 +1588,7 @@ function createRegistry() {
1632
1588
  const key = entityKey(entity);
1633
1589
  store[entity.kind].set(key, entity);
1634
1590
  flowsCache = null;
1591
+ patternCache.delete(entity.kind);
1635
1592
  };
1636
1593
  const get = (kind, id) => {
1637
1594
  assertEntityKind(kind);
@@ -1639,6 +1596,51 @@ function createRegistry() {
1639
1596
  if (raw === void 0) return void 0;
1640
1597
  return freezeEntity(raw, getFlows());
1641
1598
  };
1599
+ const getPatternsForKind = (kind) => {
1600
+ const cached = patternCache.get(kind);
1601
+ if (cached !== void 0) return cached;
1602
+ const patterns = [];
1603
+ for (const [key, entity] of store[kind]) {
1604
+ if (key.includes("*")) {
1605
+ const segments = key.split("*");
1606
+ patterns.push({
1607
+ segments,
1608
+ staticLength: segments.reduce((n, s) => n + s.length, 0),
1609
+ entity
1610
+ });
1611
+ }
1612
+ }
1613
+ patternCache.set(
1614
+ kind,
1615
+ patterns
1616
+ );
1617
+ return patterns;
1618
+ };
1619
+ const matchesSegments = (segments, id) => {
1620
+ const first = segments[0];
1621
+ const last = segments[segments.length - 1];
1622
+ if (!id.startsWith(first)) return false;
1623
+ let pos = first.length;
1624
+ for (let i = 1; i < segments.length - 1; i++) {
1625
+ const idx = id.indexOf(segments[i], pos);
1626
+ if (idx === -1) return false;
1627
+ pos = idx + segments[i].length;
1628
+ }
1629
+ return id.endsWith(last) && id.length - last.length >= pos;
1630
+ };
1631
+ const matchPattern = (kind, id) => {
1632
+ assertEntityKind(kind);
1633
+ const patterns = getPatternsForKind(kind);
1634
+ if (patterns.length === 0) return void 0;
1635
+ let best;
1636
+ for (const entry of patterns) {
1637
+ if (matchesSegments(entry.segments, id) && (best === void 0 || entry.staticLength > best.staticLength)) {
1638
+ best = entry;
1639
+ }
1640
+ }
1641
+ if (best === void 0) return void 0;
1642
+ return freezeEntity(best.entity, getFlows());
1643
+ };
1642
1644
  const list = (kind) => {
1643
1645
  assertEntityKind(kind);
1644
1646
  const flows = getFlows();
@@ -1689,6 +1691,7 @@ function createRegistry() {
1689
1691
  return {
1690
1692
  add,
1691
1693
  get,
1694
+ matchPattern,
1692
1695
  list,
1693
1696
  query,
1694
1697
  byScope,
@@ -1785,21 +1788,9 @@ function resolveConventions(c) {
1785
1788
  function kebab(str) {
1786
1789
  return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").replace(/[^a-zA-Z0-9-]/g, "").toLowerCase();
1787
1790
  }
1788
- function baseName(file) {
1789
- const b = path3.posix.basename(file);
1790
- return b.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "");
1791
- }
1792
- var LANDMARK_RE = /<(header|nav|main|aside|footer)(\s[^>]*)?>|role=["']region["']/gi;
1793
- function extractLandmarks(file) {
1794
- const out2 = [];
1795
- LANDMARK_RE.lastIndex = 0;
1796
- let m;
1797
- while ((m = LANDMARK_RE.exec(file.content)) !== null) {
1798
- const tag = m[1] ?? "region";
1799
- const line = 1 + file.content.slice(0, m.index).split("\n").length - 1;
1800
- out2.push({ tag, line });
1801
- }
1802
- return out2;
1791
+ function baseName(file) {
1792
+ const b = path4.posix.basename(file);
1793
+ return b.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "");
1803
1794
  }
1804
1795
  function fileMatchesAny(displayPath, patterns) {
1805
1796
  return patterns.some((g) => globToRegExp(g).test(displayPath));
@@ -1861,7 +1852,7 @@ function resolve2(ctx) {
1861
1852
  const routes = conventions.pages === "auto" ? detectRoutes(ctx.extracted.map((e) => e.file)) : [];
1862
1853
  const handledPageFiles = /* @__PURE__ */ new Set();
1863
1854
  for (const route of routes) {
1864
- const routeDir = path3.posix.dirname(route.file);
1855
+ const routeDir = path4.posix.dirname(route.file);
1865
1856
  const wellKnownPath = `${routeDir}/${WELL_KNOWN_FILES.page}`;
1866
1857
  const wellKnownExp = exportFor(wellKnownPath, "page");
1867
1858
  const routeExp = exportFor(route.file, "page");
@@ -1915,7 +1906,7 @@ function resolve2(ctx) {
1915
1906
  const dir = extractFeatureDir(ef.file.displayPath, featureGlob);
1916
1907
  if (!dir) continue;
1917
1908
  conventionalFeatureDirs.add(dir);
1918
- 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;
1919
1910
  if (isWellKnown) wellKnownFeatureFileByDir.set(dir, ef.file.displayPath);
1920
1911
  const exp = exportFor(ef.file.displayPath, "feature");
1921
1912
  if (exp) {
@@ -1952,7 +1943,7 @@ function resolve2(ctx) {
1952
1943
  } else if (allExports.length > 0) {
1953
1944
  exp = allExports[0].exp;
1954
1945
  }
1955
- 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);
1956
1947
  const meta = exp ? buildMetaFromExport(exp) : void 0;
1957
1948
  const feature = {
1958
1949
  kind: "feature",
@@ -2048,8 +2039,8 @@ function resolve2(ctx) {
2048
2039
  }
2049
2040
  if (conventions.regions === "landmarks") {
2050
2041
  for (const ef of ctx.extracted) {
2051
- for (const lm of extractLandmarks(ef.file)) {
2052
- const id = kebab(`${lm.tag}`);
2042
+ for (const lm of ef.landmarks ?? []) {
2043
+ const id = lm.tag;
2053
2044
  if (!registry.get("region", id)) {
2054
2045
  const meta = metaWithComposes("region", id);
2055
2046
  const region = {
@@ -2160,7 +2151,7 @@ function resolve2(ctx) {
2160
2151
  const flowExport = (ff.metadata ?? []).find(
2161
2152
  (m) => m.kind === "flow" && typeof m.id === "string"
2162
2153
  );
2163
- const derived = extractFlowsFromSource(ff.file);
2154
+ const derived = flowsFromFacts(ff);
2164
2155
  if (flowExport && typeof flowExport.id === "string" && derived.length === 1) {
2165
2156
  const base = derived[0];
2166
2157
  const flow = {
@@ -2215,60 +2206,21 @@ function computeScope(displayPath) {
2215
2206
  }
2216
2207
  return null;
2217
2208
  }
2218
- function extractFlowsFromSource(file) {
2219
- const flows = [];
2220
- const source = file.content;
2221
- const describeRe = /test\.describe\(\s*(?:'([^']*)'|"([^"]*)")\s*,\s*\{[^}]*tag:\s*(?:'@uidex:flow'|"@uidex:flow"|\[[^\]]*@uidex:flow[^\]]*\])[^}]*\}/g;
2222
- let m;
2223
- while ((m = describeRe.exec(source)) !== null) {
2224
- const title = m[1] ?? m[2];
2225
- const id = kebab(title);
2226
- const line = 1 + source.slice(0, m.index).split("\n").length - 1;
2227
- const after = source.slice(m.index + m[0].length);
2228
- const arrow = after.match(/=>\s*\{/);
2229
- if (!arrow || arrow.index === void 0) continue;
2230
- const bodyStart = m.index + m[0].length + arrow.index + arrow[0].length;
2231
- let depth = 1;
2232
- let bodyEnd = -1;
2233
- for (let i = bodyStart; i < source.length; i++) {
2234
- if (source[i] === "{") depth++;
2235
- else if (source[i] === "}") {
2236
- depth--;
2237
- if (depth === 0) {
2238
- bodyEnd = i;
2239
- break;
2240
- }
2241
- }
2242
- }
2243
- if (bodyEnd === -1) continue;
2244
- const body = source.slice(bodyStart, bodyEnd);
2245
- const touches = captureUidexIds(body);
2246
- flows.push({
2247
- kind: "flow",
2248
- id,
2249
- loc: { file: file.displayPath, line },
2250
- touches: dedupe(touches.map((t) => t.id)),
2251
- steps: touches.filter((t) => t.action).map((t) => ({ entityId: t.id, action: t.action }))
2252
- });
2253
- }
2254
- return flows;
2255
- }
2256
- function captureUidexIds(body) {
2257
- const out2 = [];
2258
- const re = /uidex\(\s*(?:'([^']+)'|"([^"]+)"|`([^`$]+)`)\s*\)(?:\.(\w+)\s*\()?/g;
2259
- let m;
2260
- while ((m = re.exec(body)) !== null) {
2261
- out2.push({ id: m[1] || m[2] || m[3], action: m[4] });
2262
- }
2263
- 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
+ }));
2264
2217
  }
2265
2218
  function dedupe(arr) {
2266
2219
  return Array.from(new Set(arr));
2267
2220
  }
2268
2221
 
2269
2222
  // src/scanner/scan/audit.ts
2270
- var path4 = __toESM(require("path"), 1);
2271
- var MARKER_FILENAMES = ["UIDEX_PAGE.md", "UIDEX_FEATURE.md"];
2223
+ var path5 = __toESM(require("path"), 1);
2272
2224
  function audit(opts) {
2273
2225
  const diagnostics = [];
2274
2226
  const { registry, extracted, files, config } = opts;
@@ -2278,22 +2230,15 @@ function audit(opts) {
2278
2230
  const scopeLeakEnabled = config.audit?.scopeLeak ?? true;
2279
2231
  const coverageEnabled = config.audit?.coverage ?? true;
2280
2232
  if (opts.resolveDiagnostics) diagnostics.push(...opts.resolveDiagnostics);
2281
- if (check) {
2282
- for (const f of files) {
2283
- const base = f.displayPath.split("/").pop() ?? "";
2284
- if (MARKER_FILENAMES.includes(base)) {
2285
- diagnostics.push({
2286
- code: "marker-md-ignored",
2287
- severity: "warning",
2288
- message: `Marker file "${base}" is ignored in v2; migrate to \`export const uidex\``,
2289
- file: f.displayPath
2290
- });
2291
- }
2292
- }
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);
2293
2238
  }
2294
2239
  if (check && opts.generated !== void 0) {
2295
2240
  const outRel = opts.outputPath ?? config.output;
2296
- const fresh = normalizeLineEndings(opts.generated);
2241
+ const fresh = normalizeForCheck(opts.generated);
2297
2242
  if (opts.existingOnDisk === null || opts.existingOnDisk === void 0) {
2298
2243
  diagnostics.push({
2299
2244
  code: "gen-missing",
@@ -2303,7 +2248,7 @@ function audit(opts) {
2303
2248
  hint: "Run `uidex scan` (without --check) to regenerate"
2304
2249
  });
2305
2250
  } else {
2306
- const existing = normalizeLineEndings(opts.existingOnDisk);
2251
+ const existing = normalizeForCheck(opts.existingOnDisk);
2307
2252
  if (existing !== fresh) {
2308
2253
  const changed = diffEntities(existing, opts.generated, registry);
2309
2254
  const summary2 = formatChangedSummary(changed);
@@ -2317,22 +2262,6 @@ function audit(opts) {
2317
2262
  }
2318
2263
  }
2319
2264
  }
2320
- if (lint) {
2321
- for (const ef of extracted) {
2322
- for (const a of ef.annotations) {
2323
- const migration = legacyJsdocMigration(a);
2324
- if (!migration) continue;
2325
- diagnostics.push({
2326
- code: "legacy-jsdoc",
2327
- severity: "warning",
2328
- message: migration.message,
2329
- file: a.file,
2330
- line: a.line,
2331
- hint: migration.hint
2332
- });
2333
- }
2334
- }
2335
- }
2336
2265
  if (lint && acceptanceEnabled) {
2337
2266
  for (const kind of ["widget", "feature", "page"]) {
2338
2267
  for (const e of registry.list(kind)) {
@@ -2378,8 +2307,8 @@ function audit(opts) {
2378
2307
  if (typeof m.id !== "string") continue;
2379
2308
  const filePath = ef.file.displayPath;
2380
2309
  const wellKnownName = WELL_KNOWN_FILES[m.kind];
2381
- if (path4.posix.basename(filePath) === wellKnownName) continue;
2382
- const dir = path4.posix.dirname(filePath);
2310
+ if (path5.posix.basename(filePath) === wellKnownName) continue;
2311
+ const dir = path5.posix.dirname(filePath);
2383
2312
  const wellKnownPath = dir === "." ? wellKnownName : `${dir}/${wellKnownName}`;
2384
2313
  if (scannedPaths.has(wellKnownPath)) continue;
2385
2314
  const kindLabel = m.kind === "page" ? "Page" : "Feature";
@@ -2396,45 +2325,55 @@ function audit(opts) {
2396
2325
  }
2397
2326
  }
2398
2327
  if (lint) {
2399
- const dynamicAttrRe = /\bdata-uidex(?:-(region|widget|primitive))?\s*=\s*\{/g;
2400
- for (const f of files) {
2401
- let m;
2402
- dynamicAttrRe.lastIndex = 0;
2403
- while ((m = dynamicAttrRe.exec(f.content)) !== null) {
2404
- const kind = m[1] ?? "element";
2405
- let line = 1;
2406
- for (let i = 0; i < m.index; i++) if (f.content[i] === "\n") line++;
2407
- const attrName = m[1] ? `data-uidex-${m[1]}` : "data-uidex";
2328
+ for (const ef of extracted) {
2329
+ for (const fact of ef.dynamicAttrs ?? []) {
2408
2330
  diagnostics.push({
2409
2331
  code: "dynamic-attr",
2410
2332
  severity: "warning",
2411
- message: `\`${attrName}={\u2026}\` uses a dynamic expression; the scanner cannot resolve the ${kind} id statically`,
2412
- file: f.displayPath,
2413
- line,
2414
- 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)
2415
2337
  });
2416
2338
  }
2417
2339
  }
2418
2340
  }
2419
2341
  if (lint) {
2420
- for (const f of files) {
2421
- const tagRe = /<(button|a|input|select|textarea)(?=[\s/>])/g;
2422
- let m;
2423
- while ((m = tagRe.exec(f.content)) !== null) {
2424
- const afterTag = m.index + m[0].length;
2425
- const closeIdx = findJsxOpeningEnd(f.content, afterTag);
2426
- if (closeIdx === -1) continue;
2427
- const attrs = f.content.slice(afterTag, closeIdx);
2428
- if (attrs.includes("data-uidex")) continue;
2429
- let line = 1;
2430
- for (let i = 0; i < m.index; i++) if (f.content[i] === "\n") line++;
2431
- diagnostics.push({
2432
- code: "missing-element-annotation",
2433
- severity: "info",
2434
- message: `Interactive <${m[1].toLowerCase()}> without data-uidex annotation`,
2435
- file: f.displayPath,
2436
- line
2437
- });
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
+ }
2438
2377
  }
2439
2378
  }
2440
2379
  }
@@ -2451,12 +2390,11 @@ function audit(opts) {
2451
2390
  }
2452
2391
  }
2453
2392
  }
2454
- for (const f of files) {
2455
- const importRe = /import\s+(?:[^'"]+)\s+from\s+['"]([^'"]+)['"]/g;
2456
- let m;
2457
- while ((m = importRe.exec(f.content)) !== null) {
2458
- const spec = m[1];
2459
- 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() ?? "";
2460
2398
  const primitive = byName.get(
2461
2399
  baseName2.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
2462
2400
  );
@@ -2464,81 +2402,170 @@ function audit(opts) {
2464
2402
  const scope = primitive.scopes?.[0];
2465
2403
  if (!scope) continue;
2466
2404
  const [kind, id] = scope.split(":");
2467
- const importerSegments = f.displayPath.split("/");
2405
+ const importerSegments = displayPath.split("/");
2468
2406
  if (importerSegments.includes(id) && importerSegments.includes(kind + "s")) {
2469
2407
  continue;
2470
2408
  }
2471
2409
  if (kind === "feature" && importerSegments.includes(id)) continue;
2472
- if (kind === "feature" && declaredFeatures.get(f.displayPath)?.has(id)) {
2410
+ if (kind === "feature" && declaredFeatures.get(displayPath)?.has(id)) {
2473
2411
  continue;
2474
2412
  }
2475
2413
  diagnostics.push({
2476
2414
  code: "scope-leak",
2477
2415
  severity: "warning",
2478
- message: `Primitive "${primitive.id}" is scoped to ${scope} but is imported from ${f.displayPath}`,
2479
- file: f.displayPath
2416
+ message: `Primitive "${primitive.id}" is scoped to ${scope} but is imported from ${displayPath}`,
2417
+ file: displayPath,
2418
+ line: imp.line
2480
2419
  });
2481
2420
  }
2482
2421
  }
2483
2422
  }
2484
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
+ }
2485
2434
  for (const flow of registry.list("flow")) {
2435
+ const callLines = factsByLoc.get(`${flow.loc.file}:${flow.loc.line}`);
2486
2436
  for (const touchedId of flow.touches) {
2487
- const found = registry.get("element", touchedId) ?? registry.get("widget", touchedId) ?? registry.get("region", touchedId);
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);
2488
2438
  if (!found) {
2489
2439
  diagnostics.push({
2490
2440
  code: "unknown-reference",
2491
2441
  severity: "warning",
2492
2442
  message: `Flow "${flow.id}" references unknown entity "${touchedId}"`,
2493
2443
  file: flow.loc.file,
2494
- 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)."
2495
2495
  });
2496
2496
  }
2497
2497
  }
2498
2498
  }
2499
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
+ }
2500
2524
  const summary = {
2501
2525
  errors: diagnostics.filter((d) => d.severity === "error").length,
2502
2526
  warnings: diagnostics.filter((d) => d.severity === "warning").length
2503
2527
  };
2504
2528
  return { diagnostics, summary };
2505
2529
  }
2506
- function legacyJsdocMigration(a) {
2507
- const quote = (s) => JSON.stringify(s);
2508
- const arr = (xs) => xs && xs.length > 0 ? `[${xs.map(quote).join(", ")}]` : "";
2509
- const entityHint = (kind) => {
2510
- const uidexKind = kind.charAt(0).toUpperCase() + kind.slice(1);
2511
- const parts = [`${kind}: ${quote(a.id)}`];
2512
- if (a.acceptance?.length) parts.push(`acceptance: ${arr(a.acceptance)}`);
2513
- return {
2514
- message: `Legacy JSDoc tag \`@uidex ${kind} ${a.id}\` is no longer recognised; migrate to \`export const uidex\``,
2515
- hint: `Replace with: export const uidex = { ${parts.join(", ")} } as const satisfies Uidex.${uidexKind}`
2516
- };
2517
- };
2518
- switch (a.kind) {
2519
- case "page-doc":
2520
- return entityHint("page");
2521
- case "feature-doc":
2522
- return entityHint("feature");
2523
- case "widget-doc":
2524
- return entityHint("widget");
2525
- case "not-flow":
2526
- return {
2527
- message: `Legacy JSDoc tag \`@uidex:not-flow\` is no longer recognised; migrate to \`export const uidex\``,
2528
- hint: `Replace with: export const uidex = { notFlow: true } as const satisfies Uidex.NotFlow`
2529
- };
2530
- case "orphan-acceptance":
2531
- return {
2532
- message: `Legacy JSDoc tag \`@acceptance\` is no longer recognised; migrate to the \`acceptance\` field on \`export const uidex\``,
2533
- hint: `Replace with: export const uidex = { /* kind */, acceptance: ${arr(a.acceptance)} } as const`
2534
- };
2535
- default:
2536
- 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;
2537
2558
  }
2538
2559
  }
2539
2560
  function normalizeLineEndings(s) {
2540
2561
  return s.replace(/\r\n/g, "\n");
2541
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
+ }
2542
2569
  function formatChangedSummary(change) {
2543
2570
  const parts = [];
2544
2571
  const fmt = (kind, names) => {
@@ -2637,62 +2664,11 @@ function extractEntitiesArray(source) {
2637
2664
  }
2638
2665
  return null;
2639
2666
  }
2640
- function findJsxOpeningEnd(src, start) {
2641
- let i = start;
2642
- while (i < src.length) {
2643
- const ch = src[i];
2644
- if (ch === ">" || ch === "/" && src[i + 1] === ">") return i;
2645
- if (ch === '"' || ch === "'" || ch === "`") {
2646
- i = skipString2(src, i);
2647
- } else if (ch === "{") {
2648
- i = skipBraces(src, i);
2649
- } else {
2650
- i++;
2651
- }
2652
- }
2653
- return -1;
2654
- }
2655
- function skipString2(src, start) {
2656
- const quote = src[start];
2657
- let i = start + 1;
2658
- while (i < src.length) {
2659
- if (src[i] === "\\" && quote !== "`") {
2660
- i += 2;
2661
- continue;
2662
- }
2663
- if (quote === "`" && src[i] === "$" && src[i + 1] === "{") {
2664
- i = skipBraces(src, i + 1);
2665
- continue;
2666
- }
2667
- if (src[i] === quote) return i + 1;
2668
- i++;
2669
- }
2670
- return i;
2671
- }
2672
- function skipBraces(src, start) {
2673
- let depth = 1;
2674
- let i = start + 1;
2675
- while (i < src.length && depth > 0) {
2676
- const ch = src[i];
2677
- if (ch === "{") {
2678
- depth++;
2679
- i++;
2680
- } else if (ch === "}") {
2681
- depth--;
2682
- i++;
2683
- } else if (ch === '"' || ch === "'" || ch === "`") {
2684
- i = skipString2(src, i);
2685
- } else {
2686
- i++;
2687
- }
2688
- }
2689
- return i;
2690
- }
2691
2667
  function dynamicAttrHint(kind) {
2692
2668
  if (kind === "region") {
2693
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`;
2694
2670
  }
2695
- 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)`;
2696
2672
  }
2697
2673
  function stableStringify(value) {
2698
2674
  return JSON.stringify(value, stableReplacer);
@@ -2725,9 +2701,7 @@ function replacerSorted(_key, value) {
2725
2701
  }
2726
2702
  return value;
2727
2703
  }
2728
- function emitIdUnion(name, ids, typeMode) {
2729
- if (typeMode === "loose") return `export type ${name} = string
2730
- `;
2704
+ function emitIdUnion(name, ids) {
2731
2705
  if (ids.length === 0) return `export type ${name} = never
2732
2706
  `;
2733
2707
  const sorted = [...ids].sort();
@@ -2737,12 +2711,7 @@ ${body}
2737
2711
  `;
2738
2712
  }
2739
2713
  function emit(opts) {
2740
- const {
2741
- registry,
2742
- gitContext,
2743
- uidexImport = "uidex",
2744
- typeMode = "strict"
2745
- } = opts;
2714
+ const { registry, gitContext, uidexImport = "uidex" } = opts;
2746
2715
  const routes = [...registry.list("route")].sort(
2747
2716
  (a, b) => a.path.localeCompare(b.path)
2748
2717
  );
@@ -2765,57 +2734,49 @@ function emit(opts) {
2765
2734
  lines.push(
2766
2735
  emitIdUnion(
2767
2736
  "PageId",
2768
- pages.map((e) => e.id),
2769
- typeMode
2737
+ pages.map((e) => e.id)
2770
2738
  )
2771
2739
  );
2772
2740
  lines.push(
2773
2741
  emitIdUnion(
2774
2742
  "FeatureId",
2775
- features.map((e) => e.id),
2776
- typeMode
2743
+ features.map((e) => e.id)
2777
2744
  )
2778
2745
  );
2779
2746
  lines.push(
2780
2747
  emitIdUnion(
2781
2748
  "WidgetId",
2782
- widgets.map((e) => e.id),
2783
- typeMode
2749
+ widgets.map((e) => e.id)
2784
2750
  )
2785
2751
  );
2786
2752
  lines.push(
2787
2753
  emitIdUnion(
2788
2754
  "RegionId",
2789
- regions.map((e) => e.id),
2790
- typeMode
2755
+ regions.map((e) => e.id)
2791
2756
  )
2792
2757
  );
2793
2758
  lines.push(
2794
2759
  emitIdUnion(
2795
2760
  "ElementId",
2796
- elements.map((e) => e.id),
2797
- typeMode
2761
+ elements.map((e) => e.id)
2798
2762
  )
2799
2763
  );
2800
2764
  lines.push(
2801
2765
  emitIdUnion(
2802
2766
  "PrimitiveId",
2803
- primitives.map((e) => e.id),
2804
- typeMode
2767
+ primitives.map((e) => e.id)
2805
2768
  )
2806
2769
  );
2807
2770
  lines.push(
2808
2771
  emitIdUnion(
2809
2772
  "FlowId",
2810
- flows.map((e) => e.id),
2811
- typeMode
2773
+ flows.map((e) => e.id)
2812
2774
  )
2813
2775
  );
2814
2776
  lines.push(
2815
2777
  emitIdUnion(
2816
2778
  "RouteId",
2817
- routes.map((e) => e.path),
2818
- typeMode
2779
+ routes.map((e) => e.path)
2819
2780
  )
2820
2781
  );
2821
2782
  lines.push("");
@@ -2928,22 +2889,33 @@ function parseGitHubRef(ref) {
2928
2889
 
2929
2890
  // src/scanner/scan/scaffold.ts
2930
2891
  var fs3 = __toESM(require("fs"), 1);
2931
- var path5 = __toESM(require("path"), 1);
2892
+ var path6 = __toESM(require("path"), 1);
2932
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) {
2933
2904
  const {
2934
2905
  registry,
2935
- widgetId,
2906
+ kind,
2907
+ id,
2936
2908
  outDir,
2937
2909
  force = false,
2938
2910
  fixtureImport = "./fixtures"
2939
2911
  } = opts;
2940
- const widget = registry.get("widget", widgetId);
2941
- if (!widget) {
2942
- 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`);
2943
2915
  }
2944
- const criteria = widget.meta?.acceptance ?? [];
2945
- const filename = `widget-${widgetId}.spec.ts`;
2946
- 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);
2947
2919
  if (fs3.existsSync(outputPath) && !force) {
2948
2920
  return {
2949
2921
  outputPath,
@@ -2952,15 +2924,14 @@ function scaffoldWidgetSpec(opts) {
2952
2924
  reason: `spec already exists at ${outputPath}; pass --force to overwrite`
2953
2925
  };
2954
2926
  }
2955
- const content = renderSpec({
2956
- widgetId,
2957
- criteria,
2958
- fixtureImport
2959
- });
2960
- fs3.mkdirSync(path5.dirname(outputPath), { recursive: true });
2927
+ const content = renderSpec({ id, criteria, fixtureImport });
2928
+ fs3.mkdirSync(path6.dirname(outputPath), { recursive: true });
2961
2929
  fs3.writeFileSync(outputPath, content, "utf8");
2962
2930
  return { outputPath, written: true, skipped: false };
2963
2931
  }
2932
+ function capitalize(s) {
2933
+ return s.charAt(0).toUpperCase() + s.slice(1);
2934
+ }
2964
2935
  function renderSpec(args) {
2965
2936
  const lines = [];
2966
2937
  lines.push(
@@ -2968,7 +2939,7 @@ function renderSpec(args) {
2968
2939
  );
2969
2940
  lines.push("");
2970
2941
  lines.push(
2971
- `test.describe(${JSON.stringify(args.widgetId)}, { tag: "@uidex:flow" }, () => {`
2942
+ `test.describe(${JSON.stringify(args.id)}, { tag: "@uidex:flow" }, () => {`
2972
2943
  );
2973
2944
  if (args.criteria.length === 0) {
2974
2945
  lines.push(` test("TODO: add acceptance criteria", async () => {`);
@@ -2991,7 +2962,7 @@ function renderSpec(args) {
2991
2962
 
2992
2963
  // src/scanner/scan/pipeline.ts
2993
2964
  var fs4 = __toESM(require("fs"), 1);
2994
- var path6 = __toESM(require("path"), 1);
2965
+ var path7 = __toESM(require("path"), 1);
2995
2966
  function runScan(opts = {}) {
2996
2967
  const cwd = opts.cwd ?? process.cwd();
2997
2968
  const configs = opts.configs ?? discover({ cwd });
@@ -3020,10 +2991,9 @@ function runOne(dc, opts) {
3020
2991
  const gitContext = resolveGitContext({ cwd: configDir });
3021
2992
  const generated = emit({
3022
2993
  registry: resolved.registry,
3023
- gitContext,
3024
- typeMode: config.typeMode
2994
+ gitContext
3025
2995
  });
3026
- const outputPath = path6.resolve(configDir, config.output);
2996
+ const outputPath = path7.resolve(configDir, config.output);
3027
2997
  const outputRel = config.output;
3028
2998
  let existingOnDisk = null;
3029
2999
  if (opts.check) {
@@ -3033,12 +3003,16 @@ function runOne(dc, opts) {
3033
3003
  existingOnDisk = null;
3034
3004
  }
3035
3005
  }
3006
+ const hasExtractDiagnostics = [...extracted, ...extractedFlows].some(
3007
+ (ef) => (ef.diagnostics?.length ?? 0) > 0
3008
+ );
3036
3009
  let auditResult;
3037
- if (opts.check || opts.lint || resolved.diagnostics.length > 0) {
3010
+ if (opts.check || opts.lint || resolved.diagnostics.length > 0 || hasExtractDiagnostics) {
3038
3011
  auditResult = audit({
3039
3012
  registry: resolved.registry,
3040
3013
  extracted,
3041
3014
  files: sourceFiles,
3015
+ flowExtracted: extractedFlows,
3042
3016
  config,
3043
3017
  check: opts.check,
3044
3018
  lint: opts.lint,
@@ -3059,34 +3033,281 @@ function runOne(dc, opts) {
3059
3033
  };
3060
3034
  }
3061
3035
  function writeScanResult(result) {
3062
- fs4.mkdirSync(path6.dirname(result.outputPath), { recursive: true });
3036
+ fs4.mkdirSync(path7.dirname(result.outputPath), { recursive: true });
3063
3037
  fs4.writeFileSync(result.outputPath, result.generated, "utf8");
3064
3038
  }
3065
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
+
3066
3287
  // src/scanner/scan/cli.ts
3067
- var fs7 = __toESM(require("fs"), 1);
3068
- var path9 = __toESM(require("path"), 1);
3288
+ var fs8 = __toESM(require("fs"), 1);
3289
+ var path11 = __toESM(require("path"), 1);
3069
3290
 
3070
3291
  // src/scanner/scan/ai/index.ts
3071
3292
  var p = __toESM(require("@clack/prompts"), 1);
3072
3293
 
3073
3294
  // src/scanner/scan/ai/providers/claude.ts
3074
- var fs6 = __toESM(require("fs"), 1);
3075
- var path8 = __toESM(require("path"), 1);
3295
+ var fs7 = __toESM(require("fs"), 1);
3296
+ var path10 = __toESM(require("path"), 1);
3076
3297
 
3077
3298
  // src/scanner/scan/ai/templates.ts
3078
- var fs5 = __toESM(require("fs"), 1);
3079
- var path7 = __toESM(require("path"), 1);
3299
+ var fs6 = __toESM(require("fs"), 1);
3300
+ var path9 = __toESM(require("path"), 1);
3080
3301
  function templatePath(rel) {
3081
3302
  const candidates = [
3082
- path7.resolve(__dirname, "../../templates", rel),
3083
- // dist/cli/cli.cjs → ../../templates
3084
- path7.resolve(__dirname, "../../../templates", rel)
3085
- // 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
3086
3307
  ];
3087
3308
  for (const c of candidates) {
3088
3309
  try {
3089
- fs5.accessSync(c, fs5.constants.R_OK);
3310
+ fs6.accessSync(c, fs6.constants.R_OK);
3090
3311
  return c;
3091
3312
  } catch {
3092
3313
  continue;
@@ -3098,24 +3319,39 @@ function templatePath(rel) {
3098
3319
  );
3099
3320
  }
3100
3321
  function readTemplate(rel) {
3101
- return fs5.readFileSync(templatePath(rel), "utf8");
3322
+ return fs6.readFileSync(templatePath(rel), "utf8");
3102
3323
  }
3103
3324
 
3104
3325
  // src/scanner/scan/ai/providers/claude.ts
3105
- var CLAUDE_FILES = [
3106
- { dest: ".claude/rules/uidex.md", template: "claude/rules.md" },
3107
- { dest: ".claude/commands/uidex/audit.md", template: "claude/audit.md" },
3108
- { 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"
3109
3345
  ];
3110
3346
  var claudeProvider = {
3111
3347
  id: "claude",
3112
3348
  label: "Claude Code",
3113
- 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.",
3114
3350
  async install({ cwd, force }) {
3115
3351
  const changes = [];
3116
- for (const file of CLAUDE_FILES) {
3117
- const dest = path8.join(cwd, file.dest);
3118
- 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);
3119
3355
  if (exists && !force) {
3120
3356
  changes.push({
3121
3357
  path: file.dest,
@@ -3124,36 +3360,56 @@ var claudeProvider = {
3124
3360
  });
3125
3361
  continue;
3126
3362
  }
3127
- fs6.mkdirSync(path8.dirname(dest), { recursive: true });
3128
- fs6.writeFileSync(dest, readTemplate(file.template));
3363
+ fs7.mkdirSync(path10.dirname(dest), { recursive: true });
3364
+ fs7.writeFileSync(dest, readTemplate(file.template));
3129
3365
  changes.push({
3130
3366
  path: file.dest,
3131
3367
  action: exists ? "overwritten" : "created"
3132
3368
  });
3133
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"));
3134
3380
  return { changes };
3135
3381
  },
3136
3382
  async uninstall({ cwd }) {
3137
3383
  const changes = [];
3138
- for (const file of CLAUDE_FILES) {
3139
- const dest = path8.join(cwd, file.dest);
3140
- if (!fs6.existsSync(dest)) {
3384
+ for (const file of SKILL_FILES) {
3385
+ const dest = path10.join(cwd, file.dest);
3386
+ if (!fs7.existsSync(dest)) {
3141
3387
  changes.push({ path: file.dest, action: "skipped", reason: "absent" });
3142
3388
  continue;
3143
3389
  }
3144
- fs6.unlinkSync(dest);
3390
+ fs7.unlinkSync(dest);
3145
3391
  changes.push({ path: file.dest, action: "removed" });
3146
3392
  }
3147
- cleanupEmpty(path8.join(cwd, ".claude/commands/uidex"));
3148
- cleanupEmpty(path8.join(cwd, ".claude/commands"));
3149
- 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"));
3150
3406
  return { changes };
3151
3407
  }
3152
3408
  };
3153
3409
  function cleanupEmpty(dir) {
3154
3410
  try {
3155
- const entries = fs6.readdirSync(dir);
3156
- if (entries.length === 0) fs6.rmdirSync(dir);
3411
+ const entries = fs7.readdirSync(dir);
3412
+ if (entries.length === 0) fs7.rmdirSync(dir);
3157
3413
  } catch {
3158
3414
  }
3159
3415
  }
@@ -3320,6 +3576,8 @@ async function run(opts) {
3320
3576
  return runScanCommand(cwd, flags, writer);
3321
3577
  case "scaffold":
3322
3578
  return runScaffold(cwd, positional.slice(1), flags, writer);
3579
+ case "rename":
3580
+ return runRename(cwd, positional.slice(1), flags, writer);
3323
3581
  case "ai": {
3324
3582
  const result = await runAiCommand({
3325
3583
  cwd,
@@ -3346,7 +3604,8 @@ function helpText2() {
3346
3604
  "Commands:",
3347
3605
  " init Create a .uidex.json",
3348
3606
  " scan [flags] Run the scanner pipeline",
3349
- " 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)",
3350
3609
  " ai <install|uninstall|providers> Manage AI assistant integrations",
3351
3610
  " api <METHOD> <PATH> Call the uidex API",
3352
3611
  " api --list Show available API routes",
@@ -3355,16 +3614,17 @@ function helpText2() {
3355
3614
  "",
3356
3615
  "Flags:",
3357
3616
  " --check Verify the on-disk gen file matches a fresh scan; exit non-zero on drift (read-only)",
3358
- " --lint Run lint diagnostics (missing annotations, scope leak, legacy JSDoc)",
3617
+ " --lint Run lint diagnostics (missing annotations, scope leak, duplicate ids, coverage)",
3359
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",
3360
3620
  " --json Emit JSON diagnostics on stdout",
3361
3621
  " --force (scaffold) overwrite existing spec",
3362
3622
  ""
3363
3623
  ].join("\n");
3364
3624
  }
3365
3625
  function runInit(cwd, w) {
3366
- const configPath = path9.join(cwd, CONFIG_FILENAME);
3367
- if (fs7.existsSync(configPath)) {
3626
+ const configPath = path11.join(cwd, CONFIG_FILENAME);
3627
+ if (fs8.existsSync(configPath)) {
3368
3628
  w.err(`.uidex.json already exists at ${configPath}`);
3369
3629
  return w.result(1);
3370
3630
  }
@@ -3373,16 +3633,16 @@ function runInit(cwd, w) {
3373
3633
  sources: [{ rootDir: "src" }],
3374
3634
  output: "src/uidex.gen.ts"
3375
3635
  };
3376
- fs7.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
3636
+ fs8.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
3377
3637
  w.out(`Created ${configPath}`);
3378
- const gitignorePath = path9.join(cwd, ".gitignore");
3638
+ const gitignorePath = path11.join(cwd, ".gitignore");
3379
3639
  const entry = "*.gen.ts";
3380
- if (fs7.existsSync(gitignorePath)) {
3381
- const existing = fs7.readFileSync(gitignorePath, "utf8");
3640
+ if (fs8.existsSync(gitignorePath)) {
3641
+ const existing = fs8.readFileSync(gitignorePath, "utf8");
3382
3642
  const hasEntry = existing.split("\n").some((line) => line.trim() === entry);
3383
3643
  if (!hasEntry) {
3384
3644
  const needsNewline = existing.length > 0 && !existing.endsWith("\n");
3385
- fs7.appendFileSync(
3645
+ fs8.appendFileSync(
3386
3646
  gitignorePath,
3387
3647
  `${needsNewline ? "\n" : ""}${entry}
3388
3648
  `,
@@ -3391,21 +3651,33 @@ function runInit(cwd, w) {
3391
3651
  w.out(`Appended ${entry} to ${gitignorePath}`);
3392
3652
  }
3393
3653
  } else {
3394
- fs7.writeFileSync(gitignorePath, `${entry}
3654
+ fs8.writeFileSync(gitignorePath, `${entry}
3395
3655
  `, "utf8");
3396
3656
  w.out(`Created ${gitignorePath} with ${entry}`);
3397
3657
  }
3398
3658
  return w.result(0);
3399
3659
  }
3400
3660
  function runScanCommand(cwd, flags, w) {
3401
- const check = Boolean(flags.check || flags.audit);
3402
- 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);
3403
3664
  const asJson = Boolean(flags.json);
3404
- const configs = discover({ cwd });
3665
+ let configs = discover({ cwd });
3405
3666
  if (configs.length === 0) {
3406
3667
  w.err(`No ${CONFIG_FILENAME} found under ${cwd}`);
3407
3668
  return w.result(1);
3408
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
+ }
3409
3681
  const results = runScan({ cwd, check, lint, configs });
3410
3682
  if (!check) {
3411
3683
  for (const r of results) writeScanResult(r);
@@ -3420,9 +3692,21 @@ function runScanCommand(cwd, flags, w) {
3420
3692
  { errors: 0, warnings: 0 }
3421
3693
  );
3422
3694
  if (asJson) {
3423
- const out2 = { diagnostics: allDiagnostics, summary };
3695
+ const out2 = {
3696
+ diagnostics: allDiagnostics.map(jsonDiagnostic),
3697
+ summary,
3698
+ ...fix ? { fixed, fixSkipped } : {}
3699
+ };
3424
3700
  w.out(JSON.stringify(out2, null, 2));
3425
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
+ }
3426
3710
  for (const r of results) {
3427
3711
  if (check) {
3428
3712
  w.out(`Checked ${r.outputPath}`);
@@ -3432,7 +3716,10 @@ function runScanCommand(cwd, flags, w) {
3432
3716
  for (const d of r.audit?.diagnostics ?? []) {
3433
3717
  const loc = d.file ? `${d.file}${d.line ? `:${d.line}` : ""}` : "";
3434
3718
  const stream = d.severity === "error" ? w.err : w.out;
3435
- 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
+ );
3436
3723
  if (d.hint) stream(` hint: ${d.hint}`);
3437
3724
  }
3438
3725
  }
@@ -3443,20 +3730,27 @@ function runScanCommand(cwd, flags, w) {
3443
3730
  const exit = summary.errors > 0 ? 1 : 0;
3444
3731
  return w.result(exit);
3445
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"]);
3446
3738
  function runScaffold(cwd, args, flags, w) {
3447
3739
  const [kind, id] = args;
3448
- if (kind !== "widget" || !id) {
3449
- 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]");
3450
3742
  return w.result(1);
3451
3743
  }
3744
+ const scaffoldKind = kind;
3452
3745
  const results = runScan({ cwd });
3453
3746
  for (const r of results) {
3454
- const widget = r.registry.get("widget", id);
3455
- if (!widget) continue;
3456
- const outDir = path9.resolve(r.configDir, "e2e");
3457
- 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({
3458
3751
  registry: r.registry,
3459
- widgetId: id,
3752
+ kind: scaffoldKind,
3753
+ id,
3460
3754
  outDir,
3461
3755
  force: Boolean(flags.force)
3462
3756
  });
@@ -3467,9 +3761,43 @@ function runScaffold(cwd, args, flags, w) {
3467
3761
  w.out(`Wrote ${result.outputPath}`);
3468
3762
  return w.result(0);
3469
3763
  }
3470
- 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
+ );
3471
3767
  return w.result(1);
3472
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
+ }
3473
3801
  function createWriter() {
3474
3802
  let stdout = "";
3475
3803
  let stderr = "";
@@ -3490,7 +3818,7 @@ function createWriter() {
3490
3818
  CONFIG_FILENAME,
3491
3819
  ConfigError,
3492
3820
  DEFAULT_CONVENTIONS,
3493
- DEFAULT_TYPE_MODE,
3821
+ applyFixes,
3494
3822
  audit,
3495
3823
  detectRoutes,
3496
3824
  discover,
@@ -3500,10 +3828,12 @@ function createWriter() {
3500
3828
  globToRegExp,
3501
3829
  parseConfig,
3502
3830
  pathToId,
3831
+ renameEntity,
3503
3832
  resolve,
3504
3833
  resolveGitContext,
3505
3834
  runCli,
3506
3835
  runScan,
3836
+ scaffoldSpec,
3507
3837
  scaffoldWidgetSpec,
3508
3838
  validateConfig,
3509
3839
  walk,