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
@@ -3,7 +3,6 @@ import * as fs from "fs";
3
3
  import * as path from "path";
4
4
 
5
5
  // src/scanner/scan/config.ts
6
- var DEFAULT_TYPE_MODE = "strict";
7
6
  var WELL_KNOWN_FILES = {
8
7
  page: "uidex.page.ts",
9
8
  feature: "uidex.feature.ts"
@@ -28,11 +27,9 @@ var ALLOWED_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
28
27
  "exclude",
29
28
  "output",
30
29
  "flows",
31
- "typeMode",
32
30
  "audit",
33
31
  "conventions"
34
32
  ]);
35
- var ALLOWED_TYPE_MODES = /* @__PURE__ */ new Set(["strict", "loose"]);
36
33
  var ALLOWED_SOURCE_KEYS = /* @__PURE__ */ new Set(["rootDir", "include", "exclude", "prefix"]);
37
34
  var ALLOWED_CONVENTIONS_KEYS = /* @__PURE__ */ new Set([
38
35
  "primitives",
@@ -45,14 +42,14 @@ var ALLOWED_AUDIT_KEYS = /* @__PURE__ */ new Set(["scopeLeak", "coverage", "acce
45
42
  function fail(msg) {
46
43
  throw new ConfigError(`Invalid .uidex.json: ${msg}`);
47
44
  }
48
- function assertObject(value, path10) {
45
+ function assertObject(value, path12) {
49
46
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
50
- fail(`${path10} must be an object`);
47
+ fail(`${path12} must be an object`);
51
48
  }
52
49
  }
53
- function assertStringArray(value, path10) {
50
+ function assertStringArray(value, path12) {
54
51
  if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
55
- fail(`${path10} must be a string[]`);
52
+ fail(`${path12} must be a string[]`);
56
53
  }
57
54
  }
58
55
  function validateConfig(raw) {
@@ -100,11 +97,6 @@ function validateConfig(raw) {
100
97
  }
101
98
  if (raw.exclude !== void 0) assertStringArray(raw.exclude, `exclude`);
102
99
  if (raw.flows !== void 0) assertStringArray(raw.flows, `flows`);
103
- if (raw.typeMode !== void 0) {
104
- if (typeof raw.typeMode !== "string" || !ALLOWED_TYPE_MODES.has(raw.typeMode)) {
105
- fail(`"typeMode" must be "strict" or "loose"`);
106
- }
107
- }
108
100
  if (raw.audit !== void 0) {
109
101
  assertObject(raw.audit, "audit");
110
102
  for (const key of Object.keys(raw.audit)) {
@@ -143,7 +135,6 @@ function validateConfig(raw) {
143
135
  exclude: raw.exclude,
144
136
  output: raw.output,
145
137
  flows: raw.flows,
146
- typeMode: raw.typeMode ?? DEFAULT_TYPE_MODE,
147
138
  audit: raw.audit,
148
139
  conventions: raw.conventions
149
140
  };
@@ -350,6 +341,90 @@ function* walkDir(root, dir) {
350
341
  }
351
342
  }
352
343
 
344
+ // src/scanner/scan/ast.ts
345
+ import * as path3 from "path";
346
+ import { parseSync } from "oxc-parser";
347
+ function langFor(sourcePath) {
348
+ switch (path3.extname(sourcePath)) {
349
+ case ".tsx":
350
+ return "tsx";
351
+ case ".ts":
352
+ case ".mts":
353
+ case ".cts":
354
+ return "ts";
355
+ default:
356
+ return "jsx";
357
+ }
358
+ }
359
+ function makeLineAt(content) {
360
+ const starts = [0];
361
+ for (let i = 0; i < content.length; i++) {
362
+ if (content[i] === "\n") starts.push(i + 1);
363
+ }
364
+ return (offset) => {
365
+ let lo = 0;
366
+ let hi = starts.length - 1;
367
+ while (lo < hi) {
368
+ const mid = lo + hi + 1 >> 1;
369
+ if (starts[mid] <= offset) lo = mid;
370
+ else hi = mid - 1;
371
+ }
372
+ return lo + 1;
373
+ };
374
+ }
375
+ function parseSource(file) {
376
+ const lineAt = makeLineAt(file.content);
377
+ try {
378
+ const result = parseSync(file.sourcePath, file.content, {
379
+ lang: langFor(file.sourcePath),
380
+ sourceType: "module"
381
+ });
382
+ return {
383
+ program: result.program,
384
+ hasErrors: result.errors.length > 0,
385
+ comments: result.comments ?? [],
386
+ lineAt
387
+ };
388
+ } catch {
389
+ return { program: null, hasErrors: true, comments: [], lineAt };
390
+ }
391
+ }
392
+ function isNode(value) {
393
+ return typeof value === "object" && value !== null && typeof value.type === "string";
394
+ }
395
+ function walkAst(root, visit) {
396
+ if (!isNode(root)) return;
397
+ if (visit(root) === false) return;
398
+ for (const key of Object.keys(root)) {
399
+ if (key === "type" || key === "start" || key === "end") continue;
400
+ const value = root[key];
401
+ if (Array.isArray(value)) {
402
+ for (const item of value) walkAst(item, visit);
403
+ } else if (isNode(value)) {
404
+ walkAst(value, visit);
405
+ }
406
+ }
407
+ }
408
+ function unwrapTsExpression(expr) {
409
+ let node = expr;
410
+ for (; ; ) {
411
+ switch (node.type) {
412
+ case "TSAsExpression":
413
+ case "TSSatisfiesExpression":
414
+ case "TSNonNullExpression":
415
+ case "TSTypeAssertion":
416
+ case "ParenthesizedExpression": {
417
+ const inner = node.expression;
418
+ if (!isNode(inner)) return node;
419
+ node = inner;
420
+ break;
421
+ }
422
+ default:
423
+ return node;
424
+ }
425
+ }
426
+ }
427
+
353
428
  // src/scanner/scan/extract-uidex-export.ts
354
429
  var KIND_DISCRIMINATORS = [
355
430
  "page",
@@ -387,6 +462,16 @@ var FALSEABLE = /* @__PURE__ */ new Set([
387
462
  "primitive",
388
463
  "region"
389
464
  ]);
465
+ var SATISFIES_NAMES = {
466
+ page: "Page",
467
+ feature: "Feature",
468
+ primitive: "Primitive",
469
+ widget: "Widget",
470
+ region: "Region",
471
+ flow: "Flow",
472
+ notFlow: "NotFlow"
473
+ };
474
+ var KNOWN_SATISFIES = new Set(Object.values(SATISFIES_NAMES));
390
475
  var ExtractError = class extends Error {
391
476
  code;
392
477
  hint;
@@ -398,649 +483,285 @@ var ExtractError = class extends Error {
398
483
  this.hint = hint;
399
484
  }
400
485
  };
401
- function extractUidexExports(file) {
486
+ function extractUidexExports(file, parsed) {
402
487
  const exports = [];
403
488
  const diagnostics = [];
404
489
  const { content, displayPath } = file;
405
- for (const header of findExportHeaders(content)) {
406
- try {
407
- const value = parseExpression(content, header.exprStart);
408
- const metadata = buildMetadata(
409
- value,
410
- displayPath,
411
- header.headerPos,
412
- diagnostics
413
- );
414
- exports.push(metadata);
415
- } catch (e) {
416
- if (e instanceof ExtractError) {
417
- diagnostics.push({
418
- code: e.code,
419
- severity: "error",
420
- message: e.message,
421
- file: displayPath,
422
- line: e.pos.line,
423
- hint: e.hint
424
- });
425
- } else {
426
- throw e;
427
- }
428
- }
429
- }
430
- return { exports, diagnostics };
431
- }
432
- var HEADER_RE = /(?:^|\n)[\t ]*export\s+const\s+uidex\b(?:\s*:\s*[^=\n]+?)?\s*=\s*/g;
433
- function findExportHeaders(content) {
434
- const out2 = [];
435
- HEADER_RE.lastIndex = 0;
436
- let m;
437
- while ((m = HEADER_RE.exec(content)) !== null) {
438
- const leadingNewline = m[0].startsWith("\n") ? 1 : 0;
439
- const headerOffset = m.index + leadingNewline;
440
- const exprStart = m.index + m[0].length;
441
- if (isInsideCommentOrString(content, headerOffset)) continue;
442
- out2.push({
443
- headerPos: posAt(content, headerOffset),
444
- exprStart
445
- });
446
- }
447
- return out2;
448
- }
449
- function isInsideCommentOrString(content, target) {
450
- let i = 0;
451
- let inLineComment = false;
452
- let inBlockComment = false;
453
- let stringDelim = null;
454
- let inTemplate = false;
455
- let templateDepth = 0;
456
- while (i < target) {
457
- const c = content[i];
458
- const n = content[i + 1];
459
- if (inLineComment) {
460
- if (c === "\n") inLineComment = false;
461
- i++;
462
- continue;
463
- }
464
- if (inBlockComment) {
465
- if (c === "*" && n === "/") {
466
- inBlockComment = false;
467
- i += 2;
468
- continue;
469
- }
470
- i++;
471
- continue;
472
- }
473
- if (stringDelim !== null) {
474
- if (c === "\\") {
475
- i += 2;
476
- continue;
477
- }
478
- if (c === stringDelim) stringDelim = null;
479
- i++;
480
- continue;
481
- }
482
- if (inTemplate) {
483
- if (c === "\\") {
484
- i += 2;
485
- continue;
486
- }
487
- if (c === "$" && n === "{") {
488
- templateDepth++;
489
- i += 2;
490
- continue;
491
- }
492
- if (c === "`" && templateDepth === 0) {
493
- inTemplate = false;
494
- i++;
490
+ const p2 = parsed ?? parseSource(file);
491
+ if (p2.program === null) return { exports, diagnostics };
492
+ for (const stmt of p2.program.body) {
493
+ if (stmt.type !== "ExportNamedDeclaration") continue;
494
+ const decl = stmt.declaration;
495
+ if (!isNode2(decl) || decl.type !== "VariableDeclaration") continue;
496
+ if (decl.kind !== "const") continue;
497
+ for (const declarator of decl.declarations ?? []) {
498
+ const id = declarator.id;
499
+ if (!id || id.type !== "Identifier" || String(id.name) !== "uidex") {
495
500
  continue;
496
501
  }
497
- if (templateDepth > 0 && c === "}") {
498
- templateDepth--;
499
- i++;
500
- continue;
501
- }
502
- i++;
503
- continue;
504
- }
505
- if (c === "/" && n === "/") {
506
- inLineComment = true;
507
- i += 2;
508
- continue;
509
- }
510
- if (c === "/" && n === "*") {
511
- inBlockComment = true;
512
- i += 2;
513
- continue;
514
- }
515
- if (c === '"' || c === "'") {
516
- stringDelim = c;
517
- i++;
518
- continue;
519
- }
520
- if (c === "`") {
521
- inTemplate = true;
522
- i++;
523
- continue;
524
- }
525
- i++;
526
- }
527
- return inLineComment || inBlockComment || stringDelim !== null || inTemplate;
528
- }
529
- var Tokenizer = class {
530
- constructor(src, start) {
531
- this.src = src;
532
- this.pos = start;
533
- let line = 1;
534
- let lineStart = 0;
535
- for (let i = 0; i < start; i++) {
536
- if (src[i] === "\n") {
537
- line++;
538
- lineStart = i + 1;
539
- }
540
- }
541
- this.line = line;
542
- this.lineStart = lineStart;
543
- }
544
- src;
545
- pos;
546
- line;
547
- lineStart;
548
- currentPos() {
549
- return {
550
- offset: this.pos,
551
- line: this.line,
552
- column: this.pos - this.lineStart + 1
553
- };
554
- }
555
- advance(n = 1) {
556
- for (let i = 0; i < n; i++) {
557
- if (this.pos < this.src.length && this.src[this.pos] === "\n") {
558
- this.line++;
559
- this.lineStart = this.pos + 1;
560
- }
561
- this.pos++;
562
- }
563
- }
564
- skipTrivia() {
565
- while (this.pos < this.src.length) {
566
- const c = this.src[this.pos];
567
- const n = this.src[this.pos + 1];
568
- if (c === " " || c === " " || c === "\r" || c === "\n") {
569
- this.advance();
570
- continue;
571
- }
572
- if (c === "/" && n === "/") {
573
- while (this.pos < this.src.length && this.src[this.pos] !== "\n") {
574
- this.advance();
502
+ const headerPos = posAt(content, stmt.start, p2);
503
+ try {
504
+ const init = declarator.init;
505
+ if (!isNode2(init)) {
506
+ throw new ExtractError(
507
+ "uidex-export-invalid-literal",
508
+ "`export const uidex` must be assigned an object literal.",
509
+ headerPos
510
+ );
575
511
  }
576
- continue;
577
- }
578
- if (c === "/" && n === "*") {
579
- this.advance(2);
580
- while (this.pos < this.src.length) {
581
- if (this.src[this.pos] === "*" && this.src[this.pos + 1] === "/") {
582
- this.advance(2);
583
- break;
584
- }
585
- this.advance();
512
+ const value = toLitValue(unwrapTsExpression(init), content, p2);
513
+ const metadata = buildMetadata(
514
+ value,
515
+ displayPath,
516
+ file.sourcePath,
517
+ headerPos,
518
+ diagnostics
519
+ );
520
+ metadata.span = statementSpan(stmt, content);
521
+ checkSatisfies(init, metadata, displayPath, p2, diagnostics);
522
+ exports.push(metadata);
523
+ } catch (e) {
524
+ if (e instanceof ExtractError) {
525
+ diagnostics.push({
526
+ code: e.code,
527
+ severity: "error",
528
+ message: e.message,
529
+ file: displayPath,
530
+ line: e.pos.line,
531
+ hint: e.hint
532
+ });
533
+ } else {
534
+ throw e;
586
535
  }
587
- continue;
588
536
  }
589
- break;
590
- }
591
- }
592
- next() {
593
- this.skipTrivia();
594
- if (this.pos >= this.src.length) {
595
- return { kind: "eof", value: "", pos: this.currentPos(), end: this.pos };
596
- }
597
- const pos = this.currentPos();
598
- const c = this.src[this.pos];
599
- switch (c) {
600
- case "{":
601
- this.advance();
602
- return { kind: "lbrace", value: c, pos, end: this.pos };
603
- case "}":
604
- this.advance();
605
- return { kind: "rbrace", value: c, pos, end: this.pos };
606
- case "[":
607
- this.advance();
608
- return { kind: "lbracket", value: c, pos, end: this.pos };
609
- case "]":
610
- this.advance();
611
- return { kind: "rbracket", value: c, pos, end: this.pos };
612
- case "(":
613
- this.advance();
614
- return { kind: "lparen", value: c, pos, end: this.pos };
615
- case ")":
616
- this.advance();
617
- return { kind: "rparen", value: c, pos, end: this.pos };
618
- case ",":
619
- this.advance();
620
- return { kind: "comma", value: c, pos, end: this.pos };
621
- case ":":
622
- this.advance();
623
- return { kind: "colon", value: c, pos, end: this.pos };
624
- }
625
- if (c === "." && this.src[this.pos + 1] === "." && this.src[this.pos + 2] === ".") {
626
- this.advance(3);
627
- return { kind: "spread", value: "...", pos, end: this.pos };
628
- }
629
- if (c === '"' || c === "'") {
630
- return this.readString(pos, c);
631
- }
632
- if (c === "`") {
633
- return this.readTemplate(pos);
634
- }
635
- if (isDigit(c) || c === "-" && isDigit(this.src[this.pos + 1])) {
636
- return this.readNumber(pos);
637
537
  }
638
- if (isIdentStart(c)) {
639
- return this.readIdent(pos);
640
- }
641
- this.advance();
642
- return { kind: "punct", value: c, pos, end: this.pos };
643
538
  }
644
- readString(pos, delim) {
645
- this.advance();
646
- let value = "";
647
- while (this.pos < this.src.length) {
648
- const c = this.src[this.pos];
649
- if (c === "\\") {
650
- const esc = this.src[this.pos + 1];
651
- this.advance(2);
652
- value += decodeEscape(esc);
653
- continue;
654
- }
655
- if (c === delim) {
656
- this.advance();
657
- return { kind: "string", value, pos, end: this.pos };
658
- }
659
- if (c === "\n") {
660
- return { kind: "punct", value: delim, pos, end: this.pos };
661
- }
662
- value += c;
663
- this.advance();
664
- }
665
- return { kind: "punct", value: delim, pos, end: this.pos };
666
- }
667
- readTemplate(pos) {
668
- this.advance();
669
- let value = "";
670
- let hasExpression = false;
671
- while (this.pos < this.src.length) {
672
- const c = this.src[this.pos];
673
- const n = this.src[this.pos + 1];
674
- if (c === "\\") {
675
- const esc = this.src[this.pos + 1];
676
- this.advance(2);
677
- value += decodeEscape(esc);
678
- continue;
679
- }
680
- if (c === "$" && n === "{") {
681
- hasExpression = true;
682
- this.advance(2);
683
- let depth = 1;
684
- while (this.pos < this.src.length && depth > 0) {
685
- const ch = this.src[this.pos];
686
- if (ch === "{") depth++;
687
- else if (ch === "}") depth--;
688
- this.advance();
539
+ return { exports, diagnostics };
540
+ }
541
+ function toLitValue(node, content, p2) {
542
+ const unwrapped = unwrapTsExpression(node);
543
+ const pos = posAt(content, unwrapped.start, p2);
544
+ const span = { start: unwrapped.start, end: unwrapped.end };
545
+ switch (unwrapped.type) {
546
+ case "Literal": {
547
+ const v = unwrapped.value;
548
+ if (typeof v === "string") return { kind: "string", value: v, pos, span };
549
+ if (typeof v === "number") {
550
+ if (!Number.isFinite(v)) {
551
+ throw new ExtractError(
552
+ "uidex-export-invalid-literal",
553
+ `Invalid numeric literal in \`export const uidex\`.`,
554
+ pos
555
+ );
689
556
  }
690
- continue;
557
+ return { kind: "number", value: v, pos, span };
691
558
  }
692
- if (c === "`") {
693
- this.advance();
694
- if (hasExpression) {
695
- return { kind: "template", value, pos, end: this.pos };
696
- }
697
- return { kind: "string", value, pos, end: this.pos };
559
+ if (typeof v === "boolean") {
560
+ return { kind: "boolean", value: v, pos, span };
698
561
  }
699
- value += c;
700
- this.advance();
701
- }
702
- return { kind: "template", value, pos, end: this.pos };
703
- }
704
- readNumber(pos) {
705
- const start = this.pos;
706
- if (this.src[this.pos] === "-") this.advance();
707
- while (this.pos < this.src.length && isDigit(this.src[this.pos])) {
708
- this.advance();
709
- }
710
- if (this.src[this.pos] === ".") {
711
- this.advance();
712
- while (this.pos < this.src.length && isDigit(this.src[this.pos])) {
713
- this.advance();
562
+ if (v === null && unwrapped.raw === "null") {
563
+ return { kind: "null", pos, span };
714
564
  }
565
+ throw new ExtractError(
566
+ "uidex-export-invalid-literal",
567
+ `Unsupported literal in \`export const uidex\`; only strings, numbers, booleans, and null are allowed.`,
568
+ pos
569
+ );
715
570
  }
716
- if (this.src[this.pos] === "e" || this.src[this.pos] === "E") {
717
- this.advance();
718
- if (this.src[this.pos] === "+" || this.src[this.pos] === "-") {
719
- this.advance();
571
+ case "UnaryExpression": {
572
+ const arg = unwrapped.argument;
573
+ if (unwrapped.operator === "-" && isNode2(arg) && arg.type === "Literal" && typeof arg.value === "number") {
574
+ return { kind: "number", value: -arg.value, pos, span };
720
575
  }
721
- while (this.pos < this.src.length && isDigit(this.src[this.pos])) {
722
- this.advance();
723
- }
724
- }
725
- const value = this.src.slice(start, this.pos);
726
- return { kind: "number", value, pos, end: this.pos };
727
- }
728
- readIdent(pos) {
729
- const start = this.pos;
730
- while (this.pos < this.src.length && isIdentPart(this.src[this.pos])) {
731
- this.advance();
576
+ throw new ExtractError(
577
+ "uidex-export-invalid-literal",
578
+ "Unary expressions are not allowed in `export const uidex`.",
579
+ pos
580
+ );
732
581
  }
733
- const value = this.src.slice(start, this.pos);
734
- return { kind: "ident", value, pos, end: this.pos };
735
- }
736
- };
737
- function isDigit(c) {
738
- return c !== void 0 && c >= "0" && c <= "9";
739
- }
740
- function isIdentStart(c) {
741
- if (c === void 0) return false;
742
- return c >= "a" && c <= "z" || c >= "A" && c <= "Z" || c === "_" || c === "$";
743
- }
744
- function isIdentPart(c) {
745
- return isIdentStart(c) || isDigit(c);
746
- }
747
- function decodeEscape(esc) {
748
- switch (esc) {
749
- case "n":
750
- return "\n";
751
- case "t":
752
- return " ";
753
- case "r":
754
- return "\r";
755
- case "\\":
756
- return "\\";
757
- case "'":
758
- return "'";
759
- case '"':
760
- return '"';
761
- case "`":
762
- return "`";
763
- case "0":
764
- return "\0";
765
- case "b":
766
- return "\b";
767
- case "f":
768
- return "\f";
769
- case "v":
770
- return "\v";
771
- default:
772
- return esc ?? "";
773
- }
774
- }
775
- function parseExpression(content, start) {
776
- const tokenizer = new Tokenizer(content, start);
777
- const parser = new Parser(tokenizer);
778
- const value = parser.parseValue();
779
- parser.consumeTrailingAssertions();
780
- return value;
781
- }
782
- var Parser = class {
783
- constructor(tok) {
784
- this.tok = tok;
785
- }
786
- tok;
787
- lookahead = null;
788
- peek() {
789
- if (this.lookahead === null) this.lookahead = this.tok.next();
790
- return this.lookahead;
791
- }
792
- consume() {
793
- const t = this.peek();
794
- this.lookahead = null;
795
- return t;
796
- }
797
- parseValue() {
798
- const t = this.peek();
799
- switch (t.kind) {
800
- case "lbrace":
801
- return this.parseObject();
802
- case "lbracket":
803
- return this.parseArray();
804
- case "string":
805
- this.consume();
806
- return { kind: "string", value: t.value, pos: t.pos };
807
- case "template":
582
+ case "TemplateLiteral": {
583
+ const expressions = unwrapped.expressions ?? [];
584
+ if (expressions.length > 0) {
808
585
  throw new ExtractError(
809
586
  "uidex-export-invalid-literal",
810
587
  "Template literal with expression parts is not allowed in `export const uidex`; use a plain string literal.",
811
- t.pos
812
- );
813
- case "number": {
814
- this.consume();
815
- const n = Number(t.value);
816
- if (!Number.isFinite(n)) {
817
- throw new ExtractError(
818
- "uidex-export-invalid-literal",
819
- `Invalid numeric literal "${t.value}" in \`export const uidex\`.`,
820
- t.pos
821
- );
822
- }
823
- return { kind: "number", value: n, pos: t.pos };
824
- }
825
- case "ident":
826
- if (t.value === "true" || t.value === "false") {
827
- this.consume();
828
- return {
829
- kind: "boolean",
830
- value: t.value === "true",
831
- pos: t.pos
832
- };
833
- }
834
- if (t.value === "null") {
835
- this.consume();
836
- return { kind: "null", pos: t.pos };
837
- }
838
- if (t.value === "undefined") {
839
- throw new ExtractError(
840
- "uidex-export-invalid-literal",
841
- "`undefined` is not allowed as a value in `export const uidex`; omit the field instead.",
842
- t.pos
843
- );
844
- }
845
- throw new ExtractError(
846
- "uidex-export-invalid-literal",
847
- `Identifier reference "${t.value}" is not allowed in \`export const uidex\`; the right-hand side must be a plain literal.`,
848
- t.pos
849
- );
850
- case "spread":
851
- throw new ExtractError(
852
- "uidex-export-invalid-literal",
853
- "Spread (`...`) is not allowed in `export const uidex`; the right-hand side must be a plain literal.",
854
- t.pos
855
- );
856
- case "lparen":
857
- throw new ExtractError(
858
- "uidex-export-invalid-literal",
859
- "Parenthesised or grouped expressions are not allowed in `export const uidex`.",
860
- t.pos
861
- );
862
- case "punct":
863
- throw new ExtractError(
864
- "uidex-export-invalid-literal",
865
- `Unexpected token "${t.value}" in \`export const uidex\`.`,
866
- t.pos
867
- );
868
- case "eof":
869
- throw new ExtractError(
870
- "uidex-export-invalid-literal",
871
- "Expected a value for `export const uidex` but reached end of file.",
872
- t.pos
873
- );
874
- default:
875
- throw new ExtractError(
876
- "uidex-export-invalid-literal",
877
- `Unexpected token in \`export const uidex\`.`,
878
- t.pos
588
+ pos
879
589
  );
880
- }
881
- }
882
- parseObject() {
883
- const open = this.consume();
884
- const entries = [];
885
- const seen = /* @__PURE__ */ new Set();
886
- while (true) {
887
- const t = this.peek();
888
- if (t.kind === "rbrace") {
889
- this.consume();
890
- break;
891
590
  }
892
- if (t.kind === "spread") {
591
+ const quasis = unwrapped.quasis ?? [];
592
+ const cooked = quasis[0]?.value?.cooked ?? "";
593
+ return { kind: "string", value: cooked, pos, span };
594
+ }
595
+ case "Identifier": {
596
+ const name = String(unwrapped.name);
597
+ if (name === "undefined") {
893
598
  throw new ExtractError(
894
599
  "uidex-export-invalid-literal",
895
- "Spread (`...`) is not allowed inside `export const uidex`.",
896
- t.pos
600
+ "`undefined` is not allowed as a value in `export const uidex`; omit the field instead.",
601
+ pos
897
602
  );
898
603
  }
899
- if (t.kind === "lbracket") {
900
- this.consume();
901
- const keyTok = this.peek();
902
- if (keyTok.kind !== "string") {
903
- throw new ExtractError(
904
- "uidex-export-invalid-literal",
905
- "Computed property keys must be string literals in `export const uidex`.",
906
- keyTok.pos
907
- );
908
- }
909
- this.consume();
910
- const close = this.peek();
911
- if (close.kind !== "rbracket") {
912
- throw new ExtractError(
913
- "uidex-export-invalid-literal",
914
- "Expected `]` after computed property key.",
915
- close.pos
916
- );
917
- }
918
- this.consume();
919
- const colon = this.peek();
920
- if (colon.kind !== "colon") {
921
- throw new ExtractError(
922
- "uidex-export-invalid-literal",
923
- "Expected `:` after computed property key.",
924
- colon.pos
925
- );
926
- }
927
- this.consume();
928
- const value = this.parseValue();
929
- this.recordEntry(entries, seen, keyTok.value, value, keyTok.pos);
930
- } else if (t.kind === "ident" || t.kind === "string") {
931
- const keyTok = this.consume();
932
- const next = this.peek();
933
- if (next.kind === "colon") {
934
- this.consume();
935
- const value = this.parseValue();
936
- this.recordEntry(entries, seen, keyTok.value, value, keyTok.pos);
937
- } else {
938
- throw new ExtractError(
939
- "uidex-export-invalid-literal",
940
- keyTok.kind === "ident" ? `Shorthand property "${keyTok.value}" is not allowed; write "${keyTok.value}: ..." with a literal value.` : "Expected `:` after property key.",
941
- keyTok.pos
942
- );
943
- }
944
- } else if (t.kind === "number") {
945
- throw new ExtractError(
946
- "uidex-export-invalid-literal",
947
- "Numeric property keys are not allowed in `export const uidex`.",
948
- t.pos
949
- );
950
- } else {
604
+ throw new ExtractError(
605
+ "uidex-export-invalid-literal",
606
+ `Identifier reference "${name}" is not allowed in \`export const uidex\`; the right-hand side must be a plain literal.`,
607
+ pos
608
+ );
609
+ }
610
+ case "ObjectExpression":
611
+ return objectLit(unwrapped, content, p2, pos, span);
612
+ case "ArrayExpression":
613
+ return arrayLit(unwrapped, content, p2, pos, span);
614
+ case "SpreadElement":
615
+ throw new ExtractError(
616
+ "uidex-export-invalid-literal",
617
+ "Spread (`...`) is not allowed in `export const uidex`; the right-hand side must be a plain literal.",
618
+ pos
619
+ );
620
+ case "CallExpression":
621
+ throw new ExtractError(
622
+ "uidex-export-invalid-literal",
623
+ "Function calls are not allowed in `export const uidex`; the right-hand side must be a plain literal.",
624
+ pos
625
+ );
626
+ case "ConditionalExpression":
627
+ throw new ExtractError(
628
+ "uidex-export-invalid-literal",
629
+ "Conditional expressions are not allowed in `export const uidex`; the right-hand side must be a plain literal.",
630
+ pos
631
+ );
632
+ default:
633
+ throw new ExtractError(
634
+ "uidex-export-invalid-literal",
635
+ `Computed expressions are not allowed in \`export const uidex\`; the right-hand side must be a plain literal.`,
636
+ pos
637
+ );
638
+ }
639
+ }
640
+ function objectLit(node, content, p2, pos, span) {
641
+ const entries = [];
642
+ const seen = /* @__PURE__ */ new Set();
643
+ for (const prop of node.properties ?? []) {
644
+ if (prop.type === "SpreadElement") {
645
+ throw new ExtractError(
646
+ "uidex-export-invalid-literal",
647
+ "Spread (`...`) is not allowed inside `export const uidex`.",
648
+ posAt(content, prop.start, p2)
649
+ );
650
+ }
651
+ if (prop.type !== "Property") {
652
+ throw new ExtractError(
653
+ "uidex-export-invalid-literal",
654
+ "Unexpected member inside `export const uidex` object.",
655
+ posAt(content, prop.start, p2)
656
+ );
657
+ }
658
+ const keyNode = prop.key;
659
+ const keyPos = posAt(content, keyNode.start, p2);
660
+ if (prop.shorthand) {
661
+ throw new ExtractError(
662
+ "uidex-export-invalid-literal",
663
+ `Shorthand property "${String(keyNode.name)}" is not allowed; write "${String(keyNode.name)}: ..." with a literal value.`,
664
+ keyPos
665
+ );
666
+ }
667
+ let key;
668
+ if (prop.computed) {
669
+ if (keyNode.type !== "Literal" || typeof keyNode.value !== "string") {
951
670
  throw new ExtractError(
952
671
  "uidex-export-invalid-literal",
953
- `Unexpected token "${t.value}" inside object.`,
954
- t.pos
672
+ "Computed property keys must be string literals in `export const uidex`.",
673
+ keyPos
955
674
  );
956
675
  }
957
- const after = this.peek();
958
- if (after.kind === "comma") {
959
- this.consume();
960
- continue;
961
- }
962
- if (after.kind === "rbrace") {
963
- this.consume();
964
- break;
965
- }
676
+ key = keyNode.value;
677
+ } else if (keyNode.type === "Identifier") {
678
+ key = String(keyNode.name);
679
+ } else if (keyNode.type === "Literal" && typeof keyNode.value === "string") {
680
+ key = keyNode.value;
681
+ } else {
966
682
  throw new ExtractError(
967
683
  "uidex-export-invalid-literal",
968
- `Expected \`,\` or \`}\`, got "${after.value}".`,
969
- after.pos
684
+ "Numeric property keys are not allowed in `export const uidex`.",
685
+ keyPos
970
686
  );
971
687
  }
972
- return { kind: "object", entries, pos: open.pos };
973
- }
974
- recordEntry(entries, seen, key, value, pos) {
975
688
  if (seen.has(key)) {
976
689
  throw new ExtractError(
977
690
  "uidex-export-duplicate-field",
978
691
  `Duplicate field "${key}" in \`export const uidex\`.`,
979
- pos
692
+ keyPos
980
693
  );
981
694
  }
982
695
  seen.add(key);
983
- entries.push([key, value]);
984
- }
985
- parseArray() {
986
- const open = this.consume();
987
- const items = [];
988
- while (true) {
989
- const t = this.peek();
990
- if (t.kind === "rbracket") {
991
- this.consume();
992
- break;
993
- }
994
- if (t.kind === "spread") {
995
- throw new ExtractError(
996
- "uidex-export-invalid-literal",
997
- "Spread (`...`) is not allowed inside `export const uidex`.",
998
- t.pos
999
- );
1000
- }
1001
- const value = this.parseValue();
1002
- if (value.kind === "object") {
1003
- }
1004
- items.push(value);
1005
- const after = this.peek();
1006
- if (after.kind === "comma") {
1007
- this.consume();
1008
- continue;
1009
- }
1010
- if (after.kind === "rbracket") {
1011
- this.consume();
1012
- break;
1013
- }
696
+ const value = toLitValue(prop.value, content, p2);
697
+ entries.push({
698
+ key,
699
+ value,
700
+ keyPos,
701
+ span: removalSpan(content, prop.start, prop.end)
702
+ });
703
+ }
704
+ return { kind: "object", entries, pos, span };
705
+ }
706
+ function arrayLit(node, content, p2, pos, span) {
707
+ const items = [];
708
+ for (const el of node.elements ?? []) {
709
+ if (el === null) {
710
+ throw new ExtractError(
711
+ "uidex-export-invalid-literal",
712
+ "Array holes are not allowed in `export const uidex`.",
713
+ pos
714
+ );
715
+ }
716
+ if (el.type === "SpreadElement") {
1014
717
  throw new ExtractError(
1015
718
  "uidex-export-invalid-literal",
1016
- `Expected \`,\` or \`]\`, got "${after.value}".`,
1017
- after.pos
719
+ "Spread (`...`) is not allowed inside `export const uidex`.",
720
+ posAt(content, el.start, p2)
1018
721
  );
1019
722
  }
1020
- return { kind: "array", items, pos: open.pos };
723
+ items.push(toLitValue(el, content, p2));
1021
724
  }
1022
- consumeTrailingAssertions() {
1023
- const first = this.peek();
1024
- if (first.kind === "ident" && first.value === "as") {
1025
- this.consume();
1026
- const next = this.peek();
1027
- if (next.kind === "ident" && next.value === "const") {
1028
- this.consume();
1029
- } else {
1030
- throw new ExtractError(
1031
- "uidex-export-invalid-literal",
1032
- "Only `as const` is allowed after the `export const uidex` value.",
1033
- next.pos
1034
- );
1035
- }
725
+ return { kind: "array", items, pos, span };
726
+ }
727
+ function checkSatisfies(init, metadata, file, p2, diagnostics) {
728
+ let node = init;
729
+ let satisfiesType;
730
+ while (isNode2(node)) {
731
+ if (node.type === "TSSatisfiesExpression") {
732
+ satisfiesType = node.typeAnnotation;
733
+ break;
1036
734
  }
1037
- const maybeSatisfies = this.peek();
1038
- if (maybeSatisfies.kind === "ident" && maybeSatisfies.value === "satisfies") {
1039
- return;
735
+ if (node.type === "TSAsExpression" || node.type === "TSNonNullExpression" || node.type === "TSTypeAssertion" || node.type === "ParenthesizedExpression") {
736
+ node = node.expression;
737
+ continue;
1040
738
  }
739
+ break;
1041
740
  }
1042
- };
1043
- function buildMetadata(value, file, headerPos, diagnostics) {
741
+ if (!isNode2(satisfiesType) || satisfiesType.type !== "TSTypeReference") return;
742
+ const typeName = satisfiesType.typeName;
743
+ if (!isNode2(typeName) || typeName.type !== "TSQualifiedName") return;
744
+ const left = typeName.left;
745
+ const right = typeName.right;
746
+ if (!isNode2(left) || left.type !== "Identifier" || left.name !== "Uidex") {
747
+ return;
748
+ }
749
+ if (!isNode2(right) || right.type !== "Identifier") return;
750
+ const actual = String(right.name);
751
+ if (!KNOWN_SATISFIES.has(actual)) return;
752
+ const discriminator = metadata.notFlow ? "notFlow" : metadata.kind;
753
+ const expected = SATISFIES_NAMES[discriminator];
754
+ if (actual === expected) return;
755
+ diagnostics.push({
756
+ code: "uidex-export-satisfies-mismatch",
757
+ severity: "warning",
758
+ message: `\`export const uidex\` declares kind "${discriminator}" but is annotated \`satisfies Uidex.${actual}\`; expected \`Uidex.${expected}\`.`,
759
+ file,
760
+ line: p2.lineAt(satisfiesType.start),
761
+ hint: `Change the annotation to \`satisfies Uidex.${expected}\` or fix the kind discriminator.`
762
+ });
763
+ }
764
+ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
1044
765
  if (value.kind !== "object") {
1045
766
  throw new ExtractError(
1046
767
  "uidex-export-invalid-literal",
@@ -1049,7 +770,7 @@ function buildMetadata(value, file, headerPos, diagnostics) {
1049
770
  );
1050
771
  }
1051
772
  const byKey = /* @__PURE__ */ new Map();
1052
- for (const [k, v] of value.entries) byKey.set(k, v);
773
+ for (const entry of value.entries) byKey.set(entry.key, entry);
1053
774
  const presentKinds = KIND_DISCRIMINATORS.filter(
1054
775
  (k) => byKey.has(k)
1055
776
  );
@@ -1072,49 +793,58 @@ function buildMetadata(value, file, headerPos, diagnostics) {
1072
793
  const discriminator = presentKinds[0];
1073
794
  const kind = discriminator === "notFlow" ? "flow" : discriminator;
1074
795
  const allowed = ALLOWED_FIELDS[kind];
1075
- for (const [k] of value.entries) {
1076
- if (!allowed.has(k)) {
1077
- const fieldVal = byKey.get(k);
796
+ for (const entry of value.entries) {
797
+ if (!allowed.has(entry.key)) {
1078
798
  throw new ExtractError(
1079
799
  "uidex-export-unknown-field",
1080
- `Unknown field "${k}" in \`export const uidex\` for kind "${kind}". Allowed: ${Array.from(
800
+ `Unknown field "${entry.key}" in \`export const uidex\` for kind "${kind}". Allowed: ${Array.from(
1081
801
  allowed
1082
802
  ).sort().join(", ")}.`,
1083
- fieldVal.pos
803
+ entry.value.pos
1084
804
  );
1085
805
  }
1086
806
  }
1087
807
  const idField = discriminator === "notFlow" ? "flow" : discriminator;
1088
- const idValue = byKey.get(discriminator);
808
+ const idValue = byKey.get(discriminator).value;
1089
809
  let id;
1090
810
  if (discriminator === "notFlow") {
1091
- const v = idValue;
1092
- if (v.kind !== "boolean" || v.value !== true) {
811
+ if (idValue.kind !== "boolean" || idValue.value !== true) {
1093
812
  throw new ExtractError(
1094
813
  "uidex-export-invalid-field",
1095
814
  "`notFlow` must be `true`.",
1096
- v.pos
815
+ idValue.pos
1097
816
  );
1098
817
  }
1099
818
  id = false;
1100
819
  } else {
1101
820
  id = readIdField(idValue, kind, idField);
1102
821
  }
1103
- const acceptance = readStringArrayField(byKey, "acceptance");
822
+ const acceptance = readStringArrayField(byKey, "acceptance")?.values;
1104
823
  const description = readStringField(byKey, "description");
1105
824
  const name = readStringField(byKey, "name");
1106
825
  if (name === "") {
1107
- const pos = byKey.get("name").pos;
826
+ const entry = byKey.get("name");
1108
827
  diagnostics.push({
1109
828
  code: "uidex-export-empty-name",
1110
829
  severity: "info",
1111
830
  message: "`name` is an empty string; treating as unset.",
1112
831
  file,
1113
- line: pos.line
832
+ line: entry.value.pos.line,
833
+ fix: {
834
+ description: "Remove the empty `name` field",
835
+ edits: [
836
+ {
837
+ path: sourcePath,
838
+ start: entry.span.start,
839
+ end: entry.span.end,
840
+ replacement: ""
841
+ }
842
+ ]
843
+ }
1114
844
  });
1115
845
  }
1116
- const features = kind === "page" || kind === "feature" ? readStringArrayField(byKey, "features") : void 0;
1117
- const widgets = kind === "page" ? readStringArrayField(byKey, "widgets") : void 0;
846
+ const featuresField = kind === "page" || kind === "feature" ? readStringArrayField(byKey, "features") : void 0;
847
+ const widgetsField = kind === "page" ? readStringArrayField(byKey, "widgets") : void 0;
1118
848
  const notFlow = kind === "flow" && discriminator === "notFlow" ? true : void 0;
1119
849
  const metadata = {
1120
850
  source: "ts-export",
@@ -1129,9 +859,21 @@ function buildMetadata(value, file, headerPos, diagnostics) {
1129
859
  if (name) metadata.name = name;
1130
860
  if (acceptance) metadata.acceptance = acceptance;
1131
861
  if (description) metadata.description = description;
1132
- if (features) metadata.features = features;
1133
- if (widgets) metadata.widgets = widgets;
862
+ if (featuresField) {
863
+ metadata.features = featuresField.values;
864
+ metadata.featureSpans = featuresField.spans;
865
+ }
866
+ if (widgetsField) {
867
+ metadata.widgets = widgetsField.values;
868
+ metadata.widgetSpans = widgetsField.spans;
869
+ }
1134
870
  if (notFlow) metadata.notFlow = true;
871
+ if (typeof id === "string" && idValue.kind === "string") {
872
+ metadata.idSpan = idValue.span;
873
+ }
874
+ const fieldSpans = {};
875
+ for (const entry of value.entries) fieldSpans[entry.key] = entry.span;
876
+ metadata.fieldSpans = fieldSpans;
1135
877
  return metadata;
1136
878
  }
1137
879
  function readIdField(value, kind, fieldName) {
@@ -1162,29 +904,30 @@ function readIdField(value, kind, fieldName) {
1162
904
  );
1163
905
  }
1164
906
  function readStringField(byKey, name) {
1165
- const v = byKey.get(name);
1166
- if (!v) return void 0;
1167
- if (v.kind !== "string") {
907
+ const entry = byKey.get(name);
908
+ if (!entry) return void 0;
909
+ if (entry.value.kind !== "string") {
1168
910
  throw new ExtractError(
1169
911
  "uidex-export-invalid-field",
1170
912
  `\`${name}\` must be a string.`,
1171
- v.pos
913
+ entry.value.pos
1172
914
  );
1173
915
  }
1174
- return v.value;
916
+ return entry.value.value;
1175
917
  }
1176
918
  function readStringArrayField(byKey, name) {
1177
- const v = byKey.get(name);
1178
- if (!v) return void 0;
1179
- if (v.kind !== "array") {
919
+ const entry = byKey.get(name);
920
+ if (!entry) return void 0;
921
+ if (entry.value.kind !== "array") {
1180
922
  throw new ExtractError(
1181
923
  "uidex-export-invalid-field",
1182
924
  `\`${name}\` must be an array of strings.`,
1183
- v.pos
925
+ entry.value.pos
1184
926
  );
1185
927
  }
1186
- const out2 = [];
1187
- for (const item of v.items) {
928
+ const values = [];
929
+ const spans = [];
930
+ for (const item of entry.value.items) {
1188
931
  if (item.kind !== "string") {
1189
932
  throw new ExtractError(
1190
933
  "uidex-export-invalid-field",
@@ -1192,316 +935,526 @@ function readStringArrayField(byKey, name) {
1192
935
  item.pos
1193
936
  );
1194
937
  }
1195
- out2.push(item.value);
938
+ values.push(item.value);
939
+ spans.push(item.span);
1196
940
  }
1197
- return out2;
941
+ return { values, spans };
1198
942
  }
1199
- function posAt(content, offset) {
1200
- let line = 1;
1201
- let lineStart = 0;
1202
- for (let i = 0; i < offset && i < content.length; i++) {
1203
- if (content[i] === "\n") {
1204
- line++;
1205
- lineStart = i + 1;
1206
- }
1207
- }
1208
- return { offset, line, column: offset - lineStart + 1 };
943
+ function posAt(content, offset, p2) {
944
+ const lineStart = content.lastIndexOf("\n", offset - 1) + 1;
945
+ return { offset, line: p2.lineAt(offset), column: offset - lineStart + 1 };
946
+ }
947
+ function removalSpan(content, start, end) {
948
+ let e = end;
949
+ while (e < content.length && /[ \t]/.test(content[e])) e++;
950
+ if (content[e] === ",") return { start, end: e + 1 };
951
+ let s = start;
952
+ while (s > 0 && /[\s]/.test(content[s - 1])) s--;
953
+ if (content[s - 1] === ",") return { start: s - 1, end };
954
+ return { start, end };
955
+ }
956
+ function statementSpan(stmt, content) {
957
+ let end = stmt.end;
958
+ while (end < content.length && /[ \t]/.test(content[end])) end++;
959
+ if (content[end] === ";") end++;
960
+ while (end < content.length && /[ \t]/.test(content[end])) end++;
961
+ if (content[end] === "\r") end++;
962
+ if (content[end] === "\n") end++;
963
+ return { start: stmt.start, end };
964
+ }
965
+ function isNode2(value) {
966
+ return typeof value === "object" && value !== null && typeof value.type === "string";
1209
967
  }
1210
968
 
1211
- // src/scanner/scan/jsx-ancestry.ts
1212
- var DATA_ATTR_RE = /\bdata-uidex(?:-(region|widget|primitive))?\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
1213
- function parseDataAttrs(tagSource) {
1214
- if (!tagSource.includes("data-uidex")) return [];
1215
- const out2 = [];
1216
- for (const m of tagSource.matchAll(DATA_ATTR_RE)) {
1217
- const kind = m[1] ?? "element";
1218
- const id = m[2] ?? m[3];
1219
- if (id) out2.push({ kind, id });
1220
- }
1221
- return out2;
969
+ // src/scanner/scan/flow-facts.ts
970
+ function collectFlowFacts(parsed, content) {
971
+ if (parsed.program === null || !content.includes("test.describe")) {
972
+ return [];
973
+ }
974
+ const facts = [];
975
+ walkAst(parsed.program, (node) => {
976
+ if (node.type !== "CallExpression") return void 0;
977
+ const fact = readTaggedDescribe(node, parsed);
978
+ if (fact) facts.push(fact);
979
+ return void 0;
980
+ });
981
+ return facts;
1222
982
  }
1223
- function collectJSXAncestry(content) {
1224
- if (!content.includes("data-uidex")) return [];
1225
- const out2 = [];
1226
- const ancestors = [];
1227
- const stack = [];
1228
- const N = content.length;
1229
- let i = 0;
1230
- let line = 1;
1231
- const advanceLines = (from, to) => {
1232
- for (let k = from; k < to; k++) {
1233
- if (content.charCodeAt(k) === 10) line++;
1234
- }
983
+ function readTaggedDescribe(call, parsed) {
984
+ const callee = call.callee;
985
+ if (!callee || callee.type !== "MemberExpression" || !isIdentifier(callee.object, "test") || !isIdentifier(callee.property, "describe")) {
986
+ return null;
987
+ }
988
+ const args = call.arguments ?? [];
989
+ const title = stringLiteralValue(args[0]);
990
+ if (title === null) return null;
991
+ if (!hasFlowTag(args[1])) return null;
992
+ const body = args[2];
993
+ const { calls, dynamicCalls } = body ? collectUidexCalls(body, parsed) : { calls: [], dynamicCalls: [] };
994
+ return {
995
+ title,
996
+ line: parsed.lineAt(call.start),
997
+ calls,
998
+ ...dynamicCalls.length > 0 ? { dynamicCalls } : {}
1235
999
  };
1236
- while (i < N) {
1237
- const c = content[i];
1238
- if (c === "\n") {
1239
- line++;
1240
- i++;
1241
- continue;
1242
- }
1243
- if (c === "/" && content[i + 1] === "/") {
1244
- while (i < N && content[i] !== "\n") i++;
1245
- continue;
1246
- }
1247
- if (c === "/" && content[i + 1] === "*") {
1248
- const end = content.indexOf("*/", i + 2);
1249
- const next = end === -1 ? N : end + 2;
1250
- advanceLines(i, next);
1251
- i = next;
1252
- continue;
1253
- }
1254
- if (c === '"' || c === "'") {
1255
- const next = skipString(content, i, c);
1256
- advanceLines(i, next);
1257
- i = next;
1258
- continue;
1000
+ }
1001
+ function hasFlowTag(node) {
1002
+ if (!node || node.type !== "ObjectExpression") return false;
1003
+ for (const prop of node.properties ?? []) {
1004
+ if (prop.type !== "Property") continue;
1005
+ const key = prop.key;
1006
+ const keyName = key?.type === "Identifier" ? String(key.name) : key?.type === "Literal" ? String(key.value) : null;
1007
+ if (keyName !== "tag") continue;
1008
+ const value = prop.value;
1009
+ if (!value) return false;
1010
+ if (stringLiteralValue(value) === "@uidex:flow") return true;
1011
+ if (value.type === "ArrayExpression") {
1012
+ for (const el of value.elements ?? []) {
1013
+ if (el && stringLiteralValue(el) === "@uidex:flow") return true;
1014
+ }
1259
1015
  }
1260
- if (c === "`") {
1261
- const next = skipTemplate(content, i);
1262
- advanceLines(i, next);
1263
- i = next;
1264
- continue;
1016
+ return false;
1017
+ }
1018
+ return false;
1019
+ }
1020
+ function collectUidexCalls(body, parsed) {
1021
+ const calls = [];
1022
+ const dynamicCalls = [];
1023
+ const claimed = /* @__PURE__ */ new Set();
1024
+ const record = (node, action) => {
1025
+ if (claimed.has(node)) return;
1026
+ claimed.add(node);
1027
+ const resolved = uidexCallId(node);
1028
+ if (resolved === null) {
1029
+ dynamicCalls.push({ line: parsed.lineAt(node.start) });
1030
+ return;
1265
1031
  }
1266
- if (c === "<") {
1267
- const nextCh = content[i + 1];
1268
- if (nextCh === "/") {
1269
- const end = content.indexOf(">", i);
1270
- if (end === -1) break;
1271
- const tagName = content.slice(i + 2, end).match(/^\s*([\w.-]*)/)?.[1] ?? "";
1272
- if (tagName) {
1273
- for (let k = stack.length - 1; k >= 0; k--) {
1274
- if (stack[k].tagName === tagName) {
1275
- for (let j = stack.length - 1; j >= k; j--) {
1276
- ancestors.length -= stack[j].pushed;
1277
- }
1278
- stack.length = k;
1279
- break;
1280
- }
1281
- }
1282
- }
1283
- advanceLines(i, end + 1);
1284
- i = end + 1;
1285
- continue;
1286
- }
1287
- if (nextCh && /[A-Za-z_]/.test(nextCh)) {
1288
- const end = findTagEnd(content, i + 1);
1289
- if (end === -1) break;
1290
- const tagSource = content.slice(i, end + 1);
1291
- const tagName = tagSource.match(/^<\s*([\w.-]*)/)?.[1] ?? "";
1292
- const isSelf = content[end - 1] === "/";
1293
- if (tagName) {
1294
- const attrs = parseDataAttrs(tagSource);
1295
- if (attrs.length > 0) {
1296
- const snapshot = ancestors.slice();
1297
- for (const a of attrs) {
1298
- out2.push({ kind: a.kind, id: a.id, line, ancestors: snapshot });
1299
- }
1300
- }
1301
- if (!isSelf) {
1302
- for (const a of attrs) ancestors.push(a);
1303
- stack.push({ tagName, pushed: attrs.length });
1304
- }
1305
- }
1306
- advanceLines(i, end + 1);
1307
- i = end + 1;
1032
+ calls.push({
1033
+ id: resolved.id,
1034
+ ...action ? { action } : {},
1035
+ line: parsed.lineAt(node.start),
1036
+ span: resolved.span
1037
+ });
1038
+ };
1039
+ walkAst(body, (node) => {
1040
+ if (node.type !== "CallExpression") return void 0;
1041
+ const callee = node.callee;
1042
+ if (callee?.type === "MemberExpression") {
1043
+ const inner = callee.object;
1044
+ if (inner && isUidexCall(inner)) {
1045
+ const property = callee.property;
1046
+ const action = property?.type === "Identifier" ? String(property.name) : void 0;
1047
+ record(inner, action);
1048
+ }
1049
+ return void 0;
1050
+ }
1051
+ if (isUidexCall(node)) record(node);
1052
+ return void 0;
1053
+ });
1054
+ return { calls, dynamicCalls };
1055
+ }
1056
+ function isUidexCall(node) {
1057
+ if (node.type !== "CallExpression") return false;
1058
+ if (!isIdentifier(node.callee, "uidex")) return false;
1059
+ return (node.arguments ?? []).length >= 1;
1060
+ }
1061
+ function uidexCallId(node) {
1062
+ const args = node.arguments ?? [];
1063
+ const arg = args[0];
1064
+ const id = stringLiteralValue(arg);
1065
+ if (id === null) return null;
1066
+ return { id, span: { start: arg.start, end: arg.end } };
1067
+ }
1068
+ function stringLiteralValue(node) {
1069
+ if (!node) return null;
1070
+ if (node.type === "Literal" && typeof node.value === "string") {
1071
+ return node.value.length > 0 ? node.value : null;
1072
+ }
1073
+ if (node.type === "TemplateLiteral") {
1074
+ const expressions = node.expressions ?? [];
1075
+ if (expressions.length > 0) return null;
1076
+ const quasis = node.quasis ?? [];
1077
+ const cooked = quasis[0]?.value?.cooked;
1078
+ return cooked && cooked.length > 0 ? cooked : null;
1079
+ }
1080
+ return null;
1081
+ }
1082
+ function isIdentifier(node, name) {
1083
+ return typeof node === "object" && node !== null && node.type === "Identifier" && String(node.name) === name;
1084
+ }
1085
+
1086
+ // src/scanner/scan/jsx-ancestry.ts
1087
+ var ATTR_NAME_RE = /^data-uidex(?:-(region|widget|primitive))?$/;
1088
+ var INTERACTIVE_TAGS = /* @__PURE__ */ new Set(["button", "a", "input", "select", "textarea"]);
1089
+ var LANDMARK_TAGS = /* @__PURE__ */ new Set(["header", "nav", "main", "aside", "footer"]);
1090
+ function attrKind(node) {
1091
+ const name = node.name;
1092
+ if (!name || name.type !== "JSXIdentifier") return null;
1093
+ const m = ATTR_NAME_RE.exec(String(name.name));
1094
+ if (!m) return null;
1095
+ return m[1] ?? "element";
1096
+ }
1097
+ function collectConstStrings(program) {
1098
+ const consts = /* @__PURE__ */ new Map();
1099
+ const seen = /* @__PURE__ */ new Set();
1100
+ walkAst(program, (node) => {
1101
+ if (node.type !== "VariableDeclaration" || node.kind !== "const") {
1102
+ return void 0;
1103
+ }
1104
+ for (const decl of node.declarations ?? []) {
1105
+ const id = decl.id;
1106
+ if (!id || id.type !== "Identifier") continue;
1107
+ const name = String(id.name);
1108
+ if (seen.has(name)) {
1109
+ consts.delete(name);
1308
1110
  continue;
1309
1111
  }
1112
+ seen.add(name);
1113
+ const init = decl.init;
1114
+ if (!init) continue;
1115
+ const value = staticString(unwrapTsExpression(init));
1116
+ if (value !== null) consts.set(name, value);
1310
1117
  }
1311
- i++;
1312
- }
1313
- return out2;
1118
+ return void 0;
1119
+ });
1120
+ return consts;
1314
1121
  }
1315
- function skipString(content, start, quote) {
1316
- const N = content.length;
1317
- let i = start + 1;
1318
- while (i < N) {
1319
- const c = content[i];
1320
- if (c === "\\") {
1321
- i += 2;
1322
- continue;
1323
- }
1324
- if (c === quote) return i + 1;
1325
- i++;
1122
+ function staticString(node) {
1123
+ if (node.type === "Literal" && typeof node.value === "string") {
1124
+ return node.value;
1326
1125
  }
1327
- return N;
1126
+ if (node.type === "TemplateLiteral") {
1127
+ const expressions = node.expressions ?? [];
1128
+ if (expressions.length > 0) return null;
1129
+ const quasis = node.quasis ?? [];
1130
+ return quasis[0]?.value?.cooked ?? "";
1131
+ }
1132
+ return null;
1328
1133
  }
1329
- function skipTemplate(content, start) {
1330
- const N = content.length;
1331
- let i = start + 1;
1332
- while (i < N) {
1333
- const c = content[i];
1334
- if (c === "\\") {
1335
- i += 2;
1336
- continue;
1337
- }
1338
- if (c === "`") return i + 1;
1339
- if (c === "$" && content[i + 1] === "{") {
1340
- i += 2;
1341
- let depth = 1;
1342
- while (i < N && depth > 0) {
1343
- const cj = content[i];
1344
- if (cj === '"' || cj === "'") {
1345
- i = skipString(content, i, cj);
1346
- continue;
1347
- }
1348
- if (cj === "`") {
1349
- i = skipTemplate(content, i);
1350
- continue;
1134
+ var UNRESOLVED = { resolved: false };
1135
+ function evalIdExpression(expr, consts) {
1136
+ const node = unwrapTsExpression(expr);
1137
+ const literal = staticString(node);
1138
+ if (literal !== null) {
1139
+ return literal.length > 0 ? { resolved: true, ids: [literal] } : UNRESOLVED;
1140
+ }
1141
+ if (node.type === "TemplateLiteral") {
1142
+ const quasis = node.quasis ?? [];
1143
+ const expressions = node.expressions ?? [];
1144
+ let out2 = "";
1145
+ for (let i = 0; i < quasis.length; i++) {
1146
+ out2 += quasis[i].value?.cooked ?? "";
1147
+ if (i < expressions.length) {
1148
+ const part = evalIdExpression(expressions[i], consts);
1149
+ out2 += part.resolved && part.ids.length === 1 ? part.ids[0] : "*";
1150
+ }
1151
+ }
1152
+ out2 = out2.replace(/\*{2,}/g, "*");
1153
+ if (!out2.includes("*")) {
1154
+ return out2.length > 0 ? { resolved: true, ids: [out2] } : UNRESOLVED;
1155
+ }
1156
+ return out2.replace(/\*/g, "").length > 0 ? { resolved: true, ids: [out2] } : UNRESOLVED;
1157
+ }
1158
+ if (node.type === "Identifier") {
1159
+ const value = consts.get(String(node.name));
1160
+ return value !== void 0 && value.length > 0 ? { resolved: true, ids: [value] } : UNRESOLVED;
1161
+ }
1162
+ if (node.type === "ConditionalExpression") {
1163
+ const left = evalIdExpression(node.consequent, consts);
1164
+ const right = evalIdExpression(node.alternate, consts);
1165
+ if (!left.resolved || !right.resolved) return UNRESOLVED;
1166
+ return { resolved: true, ids: [.../* @__PURE__ */ new Set([...left.ids, ...right.ids])] };
1167
+ }
1168
+ return UNRESOLVED;
1169
+ }
1170
+ function collectElementAttrs(opening, consts, dynamicAttrs, lineAt) {
1171
+ const statics = [];
1172
+ const patterns = [];
1173
+ const attributes = opening.attributes ?? [];
1174
+ for (const attr of attributes) {
1175
+ if (attr.type !== "JSXAttribute") continue;
1176
+ const kind = attrKind(attr);
1177
+ if (!kind) continue;
1178
+ const value = attr.value;
1179
+ if (!value) continue;
1180
+ let result = UNRESOLVED;
1181
+ let valueSpan;
1182
+ if (value.type === "Literal") {
1183
+ const v = staticString(value);
1184
+ result = v !== null && v.length > 0 ? { resolved: true, ids: [v] } : UNRESOLVED;
1185
+ if (result.resolved) valueSpan = { start: value.start, end: value.end };
1186
+ } else if (value.type === "JSXExpressionContainer") {
1187
+ const expr = value.expression;
1188
+ if (expr && expr.type !== "JSXEmptyExpression") {
1189
+ result = evalIdExpression(expr, consts);
1190
+ const inner = unwrapTsExpression(expr);
1191
+ if (result.resolved && staticString(inner) !== null) {
1192
+ valueSpan = { start: inner.start, end: inner.end };
1351
1193
  }
1352
- if (cj === "{") depth++;
1353
- else if (cj === "}") depth--;
1354
- i++;
1355
1194
  }
1356
- continue;
1195
+ if (!result.resolved) {
1196
+ dynamicAttrs.push({
1197
+ kind,
1198
+ attrName: kind === "element" ? "data-uidex" : `data-uidex-${kind}`,
1199
+ line: lineAt(attr.start)
1200
+ });
1201
+ continue;
1202
+ }
1203
+ }
1204
+ if (!result.resolved) continue;
1205
+ for (const id of result.ids) {
1206
+ const resolved = {
1207
+ kind,
1208
+ id,
1209
+ start: attr.start,
1210
+ isPattern: id.includes("*"),
1211
+ // Only a single plain string literal is renameable in place.
1212
+ ...result.ids.length === 1 && valueSpan ? { span: valueSpan } : {}
1213
+ };
1214
+ if (resolved.isPattern) patterns.push(resolved);
1215
+ else statics.push(resolved);
1357
1216
  }
1358
- i++;
1359
1217
  }
1360
- return N;
1218
+ return [...statics, ...patterns];
1361
1219
  }
1362
- function findTagEnd(content, start) {
1363
- const N = content.length;
1364
- let i = start;
1365
- while (i < N) {
1366
- const c = content[i];
1367
- if (c === '"' || c === "'") {
1368
- i = skipString(content, i, c);
1369
- continue;
1370
- }
1371
- if (c === "`") {
1372
- i = skipTemplate(content, i);
1373
- continue;
1374
- }
1375
- if (c === "{") {
1376
- let depth = 1;
1377
- i++;
1378
- while (i < N && depth > 0) {
1379
- const cj = content[i];
1380
- if (cj === '"' || cj === "'") {
1381
- i = skipString(content, i, cj);
1382
- continue;
1220
+ function collectJSXFacts(parsed) {
1221
+ const occurrences = [];
1222
+ const dynamicAttrs = [];
1223
+ const unannotatedInteractive = [];
1224
+ const landmarks = [];
1225
+ if (parsed.program === null) {
1226
+ return { occurrences, dynamicAttrs, unannotatedInteractive, landmarks };
1227
+ }
1228
+ const consts = collectConstStrings(parsed.program);
1229
+ const ancestors = [];
1230
+ const visit = (node) => {
1231
+ if (!isNode3(node)) return;
1232
+ if (node.type === "JSXElement") {
1233
+ const opening = node.openingElement;
1234
+ const attrs = collectElementAttrs(
1235
+ opening,
1236
+ consts,
1237
+ dynamicAttrs,
1238
+ parsed.lineAt
1239
+ );
1240
+ const interactive = readInteractive(node, parsed.lineAt);
1241
+ if (interactive) unannotatedInteractive.push(interactive);
1242
+ if (attrs.length > 0) {
1243
+ const snapshot = ancestors.slice();
1244
+ for (const a of attrs) {
1245
+ occurrences.push({
1246
+ kind: a.kind,
1247
+ id: a.id,
1248
+ line: parsed.lineAt(a.start),
1249
+ ancestors: snapshot,
1250
+ ...a.span ? { span: a.span } : {}
1251
+ });
1383
1252
  }
1384
- if (cj === "`") {
1385
- i = skipTemplate(content, i);
1386
- continue;
1253
+ }
1254
+ let pushed = attrs.length;
1255
+ for (const a of attrs) ancestors.push({ kind: a.kind, id: a.id });
1256
+ const landmark = readLandmark(opening, parsed.lineAt);
1257
+ if (landmark) {
1258
+ landmarks.push(landmark);
1259
+ if (!attrs.some((a) => a.kind === "region")) {
1260
+ ancestors.push({ kind: "region", id: landmark.tag });
1261
+ pushed++;
1387
1262
  }
1388
- if (cj === "{") depth++;
1389
- else if (cj === "}") depth--;
1390
- i++;
1391
1263
  }
1392
- continue;
1264
+ visitChildren(opening);
1265
+ for (const child of node.children ?? []) {
1266
+ visit(child);
1267
+ }
1268
+ const closing = node.closingElement;
1269
+ if (isNode3(closing)) visitChildren(closing);
1270
+ ancestors.length -= pushed;
1271
+ return;
1393
1272
  }
1394
- if (c === ">") return i;
1395
- i++;
1396
- }
1397
- return -1;
1273
+ visitChildren(node);
1274
+ };
1275
+ const visitChildren = (node) => {
1276
+ for (const key of Object.keys(node)) {
1277
+ if (key === "type" || key === "start" || key === "end") continue;
1278
+ const value = node[key];
1279
+ if (Array.isArray(value)) {
1280
+ for (const item of value) visit(item);
1281
+ } else {
1282
+ visit(value);
1283
+ }
1284
+ }
1285
+ };
1286
+ visit(parsed.program);
1287
+ return { occurrences, dynamicAttrs, unannotatedInteractive, landmarks };
1398
1288
  }
1399
-
1400
- // src/scanner/scan/extract.ts
1401
- var JSDOC_BLOCK = /\/\*\*([\s\S]*?)\*\//g;
1402
- function lineAt(content, index) {
1403
- let line = 1;
1404
- for (let i = 0; i < index && i < content.length; i++) {
1405
- if (content[i] === "\n") line++;
1289
+ function readLandmark(opening, lineAt) {
1290
+ const name = opening.name;
1291
+ if (!name || name.type !== "JSXIdentifier") return null;
1292
+ const tag = String(name.name);
1293
+ if (LANDMARK_TAGS.has(tag)) {
1294
+ return { tag, line: lineAt(opening.start) };
1406
1295
  }
1407
- return line;
1296
+ for (const attr of opening.attributes ?? []) {
1297
+ if (attr.type !== "JSXAttribute") continue;
1298
+ const attrName = attr.name;
1299
+ if (!attrName || String(attrName.name) !== "role") continue;
1300
+ const value = attr.value;
1301
+ if (value && value.type === "Literal" && value.value === "region") {
1302
+ return { tag: "region", line: lineAt(opening.start) };
1303
+ }
1304
+ }
1305
+ return null;
1408
1306
  }
1409
- function parseJSDoc(block) {
1410
- const lines = block.split("\n").map((l) => l.replace(/^\s*\*\s?/, "").replace(/^\s*\/?\*+/, ""));
1411
- let kind = null;
1412
- let id = null;
1413
- const acceptance = [];
1414
- const desc = [];
1415
- let notFlow = false;
1416
- for (const raw of lines) {
1417
- const line = raw.trim();
1418
- if (!line) continue;
1419
- const uidex = line.match(
1420
- /^@uidex\s+(page|feature|widget)\s+(\S+)(?:\s+-\s+(.+))?/
1421
- );
1422
- if (uidex) {
1423
- kind = uidex[1];
1424
- id = uidex[2];
1425
- if (uidex[3]) desc.push(uidex[3].trim());
1307
+ function readInteractive(element, lineAt) {
1308
+ const opening = element.openingElement;
1309
+ const name = opening.name;
1310
+ if (!name || name.type !== "JSXIdentifier") return null;
1311
+ const tag = String(name.name);
1312
+ if (!INTERACTIVE_TAGS.has(tag)) return null;
1313
+ let hasSpread = false;
1314
+ for (const attr of opening.attributes ?? []) {
1315
+ if (attr.type === "JSXSpreadAttribute") {
1316
+ hasSpread = true;
1426
1317
  continue;
1427
1318
  }
1428
- if (/^@uidex:not-flow\b/.test(line)) {
1429
- notFlow = true;
1430
- continue;
1319
+ if (attr.type === "JSXAttribute" && attrKind(attr) !== null) return null;
1320
+ }
1321
+ const nameHint = interactiveNameHint(element, opening);
1322
+ return {
1323
+ tag,
1324
+ line: lineAt(opening.start),
1325
+ hasSpread,
1326
+ nameEnd: name.end,
1327
+ ...nameHint ? { nameHint } : {}
1328
+ };
1329
+ }
1330
+ function staticAttrValue(opening, attrName) {
1331
+ for (const attr of opening.attributes ?? []) {
1332
+ if (attr.type !== "JSXAttribute") continue;
1333
+ const n = attr.name;
1334
+ if (!n || String(n.name) !== attrName) continue;
1335
+ const value = attr.value;
1336
+ if (!value) return null;
1337
+ if (value.type === "Literal") return staticString(value);
1338
+ if (value.type === "JSXExpressionContainer") {
1339
+ const expr = value.expression;
1340
+ return expr ? staticString(unwrapTsExpression(expr)) : null;
1431
1341
  }
1432
- const accept = line.match(/^@acceptance\s+(.+)$/);
1433
- if (accept) {
1434
- acceptance.push(accept[1].trim());
1435
- continue;
1342
+ return null;
1343
+ }
1344
+ return null;
1345
+ }
1346
+ function staticChildText(element) {
1347
+ const parts = [];
1348
+ for (const child of element.children ?? []) {
1349
+ if (child.type === "JSXText") {
1350
+ parts.push(String(child.value ?? ""));
1436
1351
  }
1437
- if (line.startsWith("@")) continue;
1438
- desc.push(line);
1439
1352
  }
1353
+ return parts.join(" ").replace(/\s+/g, " ").trim();
1354
+ }
1355
+ function interactiveNameHint(element, opening) {
1356
+ const ariaLabel = staticAttrValue(opening, "aria-label");
1357
+ if (ariaLabel) return ariaLabel;
1358
+ const text = staticChildText(element);
1359
+ if (text) return text;
1360
+ for (const attr of ["title", "name", "placeholder"]) {
1361
+ const v = staticAttrValue(opening, attr);
1362
+ if (v) return v;
1363
+ }
1364
+ return void 0;
1365
+ }
1366
+ function isNode3(value) {
1367
+ return typeof value === "object" && value !== null && typeof value.type === "string";
1368
+ }
1369
+
1370
+ // src/scanner/scan/extract.ts
1371
+ function parseFailureDiagnostic(file, parsed) {
1372
+ const fatal = parsed.program === null || parsed.hasErrors && parsed.program.body.length === 0;
1373
+ if (!fatal) return null;
1440
1374
  return {
1441
- kind,
1442
- id,
1443
- description: desc.join(" ").trim(),
1444
- acceptance,
1445
- notFlow
1375
+ code: "parse-error",
1376
+ severity: "warning",
1377
+ 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",
1378
+ file: file.displayPath,
1379
+ line: 1,
1380
+ hint: "Fix the file's syntax (or exclude it from .uidex.json sources) so the scanner can read its annotations"
1446
1381
  };
1447
1382
  }
1448
1383
  function extract(files) {
1449
1384
  return files.map((file) => {
1450
- const { exports, diagnostics } = extractUidexExports(file);
1451
- const out2 = {
1452
- file,
1453
- annotations: extractOne(file)
1454
- };
1385
+ const parsed = parseSource(file);
1386
+ const { exports, diagnostics } = extractUidexExports(file, parsed);
1387
+ const parseFailure = parseFailureDiagnostic(file, parsed);
1388
+ if (parseFailure) diagnostics.push(parseFailure);
1389
+ const out2 = { file, annotations: [] };
1390
+ out2.annotations = extractOne(file, parsed, out2);
1455
1391
  if (exports.length > 0) out2.metadata = exports;
1456
1392
  if (diagnostics.length > 0) out2.diagnostics = diagnostics;
1393
+ const flows = collectFlowFacts(parsed, file.content);
1394
+ if (flows.length > 0) out2.flows = flows;
1395
+ const imports = collectImportFacts(parsed);
1396
+ if (imports.length > 0) out2.imports = imports;
1457
1397
  return out2;
1458
1398
  });
1459
1399
  }
1460
- function extractOne(file) {
1400
+ function extractOne(file, parsed, out2) {
1461
1401
  const annotations = [];
1462
- const { content, displayPath } = file;
1463
- for (const occ of collectJSXAncestry(content)) {
1402
+ const { displayPath } = file;
1403
+ const jsx = collectJSXFacts(parsed);
1404
+ if (jsx.dynamicAttrs.length > 0) out2.dynamicAttrs = jsx.dynamicAttrs;
1405
+ if (jsx.unannotatedInteractive.length > 0) {
1406
+ out2.unannotatedInteractive = jsx.unannotatedInteractive;
1407
+ }
1408
+ if (jsx.landmarks.length > 0) out2.landmarks = jsx.landmarks;
1409
+ for (const occ of jsx.occurrences) {
1464
1410
  annotations.push({
1465
1411
  kind: occ.kind,
1466
1412
  id: occ.id,
1467
1413
  file: displayPath,
1468
1414
  line: occ.line,
1469
- ...occ.ancestors.length > 0 ? { ancestors: occ.ancestors } : {}
1415
+ ...occ.ancestors.length > 0 ? { ancestors: occ.ancestors } : {},
1416
+ ...occ.span ? { span: occ.span } : {}
1470
1417
  });
1471
1418
  }
1472
- JSDOC_BLOCK.lastIndex = 0;
1473
- let jm;
1474
- while ((jm = JSDOC_BLOCK.exec(content)) !== null) {
1475
- const parsed = parseJSDoc(jm[1]);
1476
- const line = lineAt(content, jm.index);
1477
- if (parsed.notFlow) {
1478
- annotations.push({ kind: "not-flow", id: "", file: displayPath, line });
1479
- }
1480
- if (parsed.kind && parsed.id) {
1481
- const kind = parsed.kind === "page" ? "page-doc" : parsed.kind === "feature" ? "feature-doc" : "widget-doc";
1482
- annotations.push({
1483
- kind,
1484
- id: parsed.id,
1485
- file: displayPath,
1486
- line,
1487
- description: parsed.description || void 0,
1488
- acceptance: parsed.acceptance.length ? parsed.acceptance : void 0
1489
- });
1490
- } else if (parsed.acceptance.length > 0) {
1491
- annotations.push({
1492
- kind: "orphan-acceptance",
1493
- id: "",
1494
- file: displayPath,
1495
- line,
1496
- acceptance: parsed.acceptance
1497
- });
1419
+ return annotations;
1420
+ }
1421
+ function collectImportFacts(parsed) {
1422
+ if (parsed.program === null) return [];
1423
+ const out2 = [];
1424
+ for (const stmt of parsed.program.body) {
1425
+ let source;
1426
+ let isTypeOnly = false;
1427
+ const names = [];
1428
+ if (stmt.type === "ImportDeclaration") {
1429
+ source = stmt.source;
1430
+ isTypeOnly = stmt.importKind === "type";
1431
+ for (const spec of stmt.specifiers ?? []) {
1432
+ const local = spec.local;
1433
+ if (local && local.type === "Identifier") {
1434
+ names.push(String(local.name));
1435
+ }
1436
+ }
1437
+ } else if ((stmt.type === "ExportNamedDeclaration" || stmt.type === "ExportAllDeclaration") && stmt.source) {
1438
+ source = stmt.source;
1439
+ isTypeOnly = stmt.exportKind === "type";
1440
+ } else {
1441
+ continue;
1498
1442
  }
1443
+ if (!source || source.type !== "Literal") continue;
1444
+ if (typeof source.value !== "string") continue;
1445
+ out2.push({
1446
+ specifier: source.value,
1447
+ line: parsed.lineAt(stmt.start),
1448
+ span: { start: stmt.start, end: stmt.end },
1449
+ isTypeOnly,
1450
+ names
1451
+ });
1499
1452
  }
1500
- return annotations;
1453
+ return out2;
1501
1454
  }
1502
1455
 
1503
1456
  // src/scanner/scan/resolve.ts
1504
- import * as path3 from "path";
1457
+ import * as path4 from "path";
1505
1458
 
1506
1459
  // src/shared/entities/types.ts
1507
1460
  var ENTITY_KINDS = [
@@ -1567,6 +1520,7 @@ function freezeEntity(entity, flows) {
1567
1520
  function createRegistry() {
1568
1521
  const store = emptyStore();
1569
1522
  let flowsCache = null;
1523
+ const patternCache = /* @__PURE__ */ new Map();
1570
1524
  const getFlows = () => {
1571
1525
  if (flowsCache === null) flowsCache = Array.from(store.flow.values());
1572
1526
  return flowsCache;
@@ -1576,6 +1530,7 @@ function createRegistry() {
1576
1530
  const key = entityKey(entity);
1577
1531
  store[entity.kind].set(key, entity);
1578
1532
  flowsCache = null;
1533
+ patternCache.delete(entity.kind);
1579
1534
  };
1580
1535
  const get = (kind, id) => {
1581
1536
  assertEntityKind(kind);
@@ -1583,6 +1538,51 @@ function createRegistry() {
1583
1538
  if (raw === void 0) return void 0;
1584
1539
  return freezeEntity(raw, getFlows());
1585
1540
  };
1541
+ const getPatternsForKind = (kind) => {
1542
+ const cached = patternCache.get(kind);
1543
+ if (cached !== void 0) return cached;
1544
+ const patterns = [];
1545
+ for (const [key, entity] of store[kind]) {
1546
+ if (key.includes("*")) {
1547
+ const segments = key.split("*");
1548
+ patterns.push({
1549
+ segments,
1550
+ staticLength: segments.reduce((n, s) => n + s.length, 0),
1551
+ entity
1552
+ });
1553
+ }
1554
+ }
1555
+ patternCache.set(
1556
+ kind,
1557
+ patterns
1558
+ );
1559
+ return patterns;
1560
+ };
1561
+ const matchesSegments = (segments, id) => {
1562
+ const first = segments[0];
1563
+ const last = segments[segments.length - 1];
1564
+ if (!id.startsWith(first)) return false;
1565
+ let pos = first.length;
1566
+ for (let i = 1; i < segments.length - 1; i++) {
1567
+ const idx = id.indexOf(segments[i], pos);
1568
+ if (idx === -1) return false;
1569
+ pos = idx + segments[i].length;
1570
+ }
1571
+ return id.endsWith(last) && id.length - last.length >= pos;
1572
+ };
1573
+ const matchPattern = (kind, id) => {
1574
+ assertEntityKind(kind);
1575
+ const patterns = getPatternsForKind(kind);
1576
+ if (patterns.length === 0) return void 0;
1577
+ let best;
1578
+ for (const entry of patterns) {
1579
+ if (matchesSegments(entry.segments, id) && (best === void 0 || entry.staticLength > best.staticLength)) {
1580
+ best = entry;
1581
+ }
1582
+ }
1583
+ if (best === void 0) return void 0;
1584
+ return freezeEntity(best.entity, getFlows());
1585
+ };
1586
1586
  const list = (kind) => {
1587
1587
  assertEntityKind(kind);
1588
1588
  const flows = getFlows();
@@ -1633,6 +1633,7 @@ function createRegistry() {
1633
1633
  return {
1634
1634
  add,
1635
1635
  get,
1636
+ matchPattern,
1636
1637
  list,
1637
1638
  query,
1638
1639
  byScope,
@@ -1729,21 +1730,9 @@ function resolveConventions(c) {
1729
1730
  function kebab(str) {
1730
1731
  return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").replace(/[^a-zA-Z0-9-]/g, "").toLowerCase();
1731
1732
  }
1732
- function baseName(file) {
1733
- const b = path3.posix.basename(file);
1734
- return b.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "");
1735
- }
1736
- var LANDMARK_RE = /<(header|nav|main|aside|footer)(\s[^>]*)?>|role=["']region["']/gi;
1737
- function extractLandmarks(file) {
1738
- const out2 = [];
1739
- LANDMARK_RE.lastIndex = 0;
1740
- let m;
1741
- while ((m = LANDMARK_RE.exec(file.content)) !== null) {
1742
- const tag = m[1] ?? "region";
1743
- const line = 1 + file.content.slice(0, m.index).split("\n").length - 1;
1744
- out2.push({ tag, line });
1745
- }
1746
- return out2;
1733
+ function baseName(file) {
1734
+ const b = path4.posix.basename(file);
1735
+ return b.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "");
1747
1736
  }
1748
1737
  function fileMatchesAny(displayPath, patterns) {
1749
1738
  return patterns.some((g) => globToRegExp(g).test(displayPath));
@@ -1805,7 +1794,7 @@ function resolve2(ctx) {
1805
1794
  const routes = conventions.pages === "auto" ? detectRoutes(ctx.extracted.map((e) => e.file)) : [];
1806
1795
  const handledPageFiles = /* @__PURE__ */ new Set();
1807
1796
  for (const route of routes) {
1808
- const routeDir = path3.posix.dirname(route.file);
1797
+ const routeDir = path4.posix.dirname(route.file);
1809
1798
  const wellKnownPath = `${routeDir}/${WELL_KNOWN_FILES.page}`;
1810
1799
  const wellKnownExp = exportFor(wellKnownPath, "page");
1811
1800
  const routeExp = exportFor(route.file, "page");
@@ -1859,7 +1848,7 @@ function resolve2(ctx) {
1859
1848
  const dir = extractFeatureDir(ef.file.displayPath, featureGlob);
1860
1849
  if (!dir) continue;
1861
1850
  conventionalFeatureDirs.add(dir);
1862
- const isWellKnown = path3.posix.basename(ef.file.displayPath) === WELL_KNOWN_FILES.feature;
1851
+ const isWellKnown = path4.posix.basename(ef.file.displayPath) === WELL_KNOWN_FILES.feature;
1863
1852
  if (isWellKnown) wellKnownFeatureFileByDir.set(dir, ef.file.displayPath);
1864
1853
  const exp = exportFor(ef.file.displayPath, "feature");
1865
1854
  if (exp) {
@@ -1896,7 +1885,7 @@ function resolve2(ctx) {
1896
1885
  } else if (allExports.length > 0) {
1897
1886
  exp = allExports[0].exp;
1898
1887
  }
1899
- const id = exp && typeof exp.id === "string" ? exp.id : path3.posix.basename(dir);
1888
+ const id = exp && typeof exp.id === "string" ? exp.id : path4.posix.basename(dir);
1900
1889
  const meta = exp ? buildMetaFromExport(exp) : void 0;
1901
1890
  const feature = {
1902
1891
  kind: "feature",
@@ -1992,8 +1981,8 @@ function resolve2(ctx) {
1992
1981
  }
1993
1982
  if (conventions.regions === "landmarks") {
1994
1983
  for (const ef of ctx.extracted) {
1995
- for (const lm of extractLandmarks(ef.file)) {
1996
- const id = kebab(`${lm.tag}`);
1984
+ for (const lm of ef.landmarks ?? []) {
1985
+ const id = lm.tag;
1997
1986
  if (!registry.get("region", id)) {
1998
1987
  const meta = metaWithComposes("region", id);
1999
1988
  const region = {
@@ -2104,7 +2093,7 @@ function resolve2(ctx) {
2104
2093
  const flowExport = (ff.metadata ?? []).find(
2105
2094
  (m) => m.kind === "flow" && typeof m.id === "string"
2106
2095
  );
2107
- const derived = extractFlowsFromSource(ff.file);
2096
+ const derived = flowsFromFacts(ff);
2108
2097
  if (flowExport && typeof flowExport.id === "string" && derived.length === 1) {
2109
2098
  const base = derived[0];
2110
2099
  const flow = {
@@ -2159,60 +2148,21 @@ function computeScope(displayPath) {
2159
2148
  }
2160
2149
  return null;
2161
2150
  }
2162
- function extractFlowsFromSource(file) {
2163
- const flows = [];
2164
- const source = file.content;
2165
- const describeRe = /test\.describe\(\s*(?:'([^']*)'|"([^"]*)")\s*,\s*\{[^}]*tag:\s*(?:'@uidex:flow'|"@uidex:flow"|\[[^\]]*@uidex:flow[^\]]*\])[^}]*\}/g;
2166
- let m;
2167
- while ((m = describeRe.exec(source)) !== null) {
2168
- const title = m[1] ?? m[2];
2169
- const id = kebab(title);
2170
- const line = 1 + source.slice(0, m.index).split("\n").length - 1;
2171
- const after = source.slice(m.index + m[0].length);
2172
- const arrow = after.match(/=>\s*\{/);
2173
- if (!arrow || arrow.index === void 0) continue;
2174
- const bodyStart = m.index + m[0].length + arrow.index + arrow[0].length;
2175
- let depth = 1;
2176
- let bodyEnd = -1;
2177
- for (let i = bodyStart; i < source.length; i++) {
2178
- if (source[i] === "{") depth++;
2179
- else if (source[i] === "}") {
2180
- depth--;
2181
- if (depth === 0) {
2182
- bodyEnd = i;
2183
- break;
2184
- }
2185
- }
2186
- }
2187
- if (bodyEnd === -1) continue;
2188
- const body = source.slice(bodyStart, bodyEnd);
2189
- const touches = captureUidexIds(body);
2190
- flows.push({
2191
- kind: "flow",
2192
- id,
2193
- loc: { file: file.displayPath, line },
2194
- touches: dedupe(touches.map((t) => t.id)),
2195
- steps: touches.filter((t) => t.action).map((t) => ({ entityId: t.id, action: t.action }))
2196
- });
2197
- }
2198
- return flows;
2199
- }
2200
- function captureUidexIds(body) {
2201
- const out2 = [];
2202
- const re = /uidex\(\s*(?:'([^']+)'|"([^"]+)"|`([^`$]+)`)\s*\)(?:\.(\w+)\s*\()?/g;
2203
- let m;
2204
- while ((m = re.exec(body)) !== null) {
2205
- out2.push({ id: m[1] || m[2] || m[3], action: m[4] });
2206
- }
2207
- return out2;
2151
+ function flowsFromFacts(ff) {
2152
+ return (ff.flows ?? []).map((fact) => ({
2153
+ kind: "flow",
2154
+ id: kebab(fact.title),
2155
+ loc: { file: ff.file.displayPath, line: fact.line },
2156
+ touches: dedupe(fact.calls.map((c) => c.id)),
2157
+ steps: fact.calls.filter((c) => c.action).map((c) => ({ entityId: c.id, action: c.action }))
2158
+ }));
2208
2159
  }
2209
2160
  function dedupe(arr) {
2210
2161
  return Array.from(new Set(arr));
2211
2162
  }
2212
2163
 
2213
2164
  // src/scanner/scan/audit.ts
2214
- import * as path4 from "path";
2215
- var MARKER_FILENAMES = ["UIDEX_PAGE.md", "UIDEX_FEATURE.md"];
2165
+ import * as path5 from "path";
2216
2166
  function audit(opts) {
2217
2167
  const diagnostics = [];
2218
2168
  const { registry, extracted, files, config } = opts;
@@ -2222,22 +2172,15 @@ function audit(opts) {
2222
2172
  const scopeLeakEnabled = config.audit?.scopeLeak ?? true;
2223
2173
  const coverageEnabled = config.audit?.coverage ?? true;
2224
2174
  if (opts.resolveDiagnostics) diagnostics.push(...opts.resolveDiagnostics);
2225
- if (check) {
2226
- for (const f of files) {
2227
- const base = f.displayPath.split("/").pop() ?? "";
2228
- if (MARKER_FILENAMES.includes(base)) {
2229
- diagnostics.push({
2230
- code: "marker-md-ignored",
2231
- severity: "warning",
2232
- message: `Marker file "${base}" is ignored in v2; migrate to \`export const uidex\``,
2233
- file: f.displayPath
2234
- });
2235
- }
2236
- }
2175
+ for (const ef of extracted) {
2176
+ if (ef.diagnostics) diagnostics.push(...ef.diagnostics);
2177
+ }
2178
+ for (const ef of opts.flowExtracted ?? []) {
2179
+ if (ef.diagnostics) diagnostics.push(...ef.diagnostics);
2237
2180
  }
2238
2181
  if (check && opts.generated !== void 0) {
2239
2182
  const outRel = opts.outputPath ?? config.output;
2240
- const fresh = normalizeLineEndings(opts.generated);
2183
+ const fresh = normalizeForCheck(opts.generated);
2241
2184
  if (opts.existingOnDisk === null || opts.existingOnDisk === void 0) {
2242
2185
  diagnostics.push({
2243
2186
  code: "gen-missing",
@@ -2247,7 +2190,7 @@ function audit(opts) {
2247
2190
  hint: "Run `uidex scan` (without --check) to regenerate"
2248
2191
  });
2249
2192
  } else {
2250
- const existing = normalizeLineEndings(opts.existingOnDisk);
2193
+ const existing = normalizeForCheck(opts.existingOnDisk);
2251
2194
  if (existing !== fresh) {
2252
2195
  const changed = diffEntities(existing, opts.generated, registry);
2253
2196
  const summary2 = formatChangedSummary(changed);
@@ -2261,22 +2204,6 @@ function audit(opts) {
2261
2204
  }
2262
2205
  }
2263
2206
  }
2264
- if (lint) {
2265
- for (const ef of extracted) {
2266
- for (const a of ef.annotations) {
2267
- const migration = legacyJsdocMigration(a);
2268
- if (!migration) continue;
2269
- diagnostics.push({
2270
- code: "legacy-jsdoc",
2271
- severity: "warning",
2272
- message: migration.message,
2273
- file: a.file,
2274
- line: a.line,
2275
- hint: migration.hint
2276
- });
2277
- }
2278
- }
2279
- }
2280
2207
  if (lint && acceptanceEnabled) {
2281
2208
  for (const kind of ["widget", "feature", "page"]) {
2282
2209
  for (const e of registry.list(kind)) {
@@ -2322,8 +2249,8 @@ function audit(opts) {
2322
2249
  if (typeof m.id !== "string") continue;
2323
2250
  const filePath = ef.file.displayPath;
2324
2251
  const wellKnownName = WELL_KNOWN_FILES[m.kind];
2325
- if (path4.posix.basename(filePath) === wellKnownName) continue;
2326
- const dir = path4.posix.dirname(filePath);
2252
+ if (path5.posix.basename(filePath) === wellKnownName) continue;
2253
+ const dir = path5.posix.dirname(filePath);
2327
2254
  const wellKnownPath = dir === "." ? wellKnownName : `${dir}/${wellKnownName}`;
2328
2255
  if (scannedPaths.has(wellKnownPath)) continue;
2329
2256
  const kindLabel = m.kind === "page" ? "Page" : "Feature";
@@ -2340,45 +2267,55 @@ function audit(opts) {
2340
2267
  }
2341
2268
  }
2342
2269
  if (lint) {
2343
- const dynamicAttrRe = /\bdata-uidex(?:-(region|widget|primitive))?\s*=\s*\{/g;
2344
- for (const f of files) {
2345
- let m;
2346
- dynamicAttrRe.lastIndex = 0;
2347
- while ((m = dynamicAttrRe.exec(f.content)) !== null) {
2348
- const kind = m[1] ?? "element";
2349
- let line = 1;
2350
- for (let i = 0; i < m.index; i++) if (f.content[i] === "\n") line++;
2351
- const attrName = m[1] ? `data-uidex-${m[1]}` : "data-uidex";
2270
+ for (const ef of extracted) {
2271
+ for (const fact of ef.dynamicAttrs ?? []) {
2352
2272
  diagnostics.push({
2353
2273
  code: "dynamic-attr",
2354
2274
  severity: "warning",
2355
- message: `\`${attrName}={\u2026}\` uses a dynamic expression; the scanner cannot resolve the ${kind} id statically`,
2356
- file: f.displayPath,
2357
- line,
2358
- hint: dynamicAttrHint(kind)
2275
+ message: `\`${fact.attrName}={\u2026}\` uses a dynamic expression; the scanner cannot resolve the ${fact.kind} id statically`,
2276
+ file: ef.file.displayPath,
2277
+ line: fact.line,
2278
+ hint: dynamicAttrHint(fact.kind)
2359
2279
  });
2360
2280
  }
2361
2281
  }
2362
2282
  }
2363
2283
  if (lint) {
2364
- for (const f of files) {
2365
- const tagRe = /<(button|a|input|select|textarea)(?=[\s/>])/g;
2366
- let m;
2367
- while ((m = tagRe.exec(f.content)) !== null) {
2368
- const afterTag = m.index + m[0].length;
2369
- const closeIdx = findJsxOpeningEnd(f.content, afterTag);
2370
- if (closeIdx === -1) continue;
2371
- const attrs = f.content.slice(afterTag, closeIdx);
2372
- if (attrs.includes("data-uidex")) continue;
2373
- let line = 1;
2374
- for (let i = 0; i < m.index; i++) if (f.content[i] === "\n") line++;
2375
- diagnostics.push({
2376
- code: "missing-element-annotation",
2377
- severity: "info",
2378
- message: `Interactive <${m[1].toLowerCase()}> without data-uidex annotation`,
2379
- file: f.displayPath,
2380
- line
2381
- });
2284
+ const usedElementIds = new Set(registry.list("element").map((e) => e.id));
2285
+ for (const ef of extracted) {
2286
+ for (const fact of ef.unannotatedInteractive ?? []) {
2287
+ if (fact.hasSpread) {
2288
+ diagnostics.push({
2289
+ code: "spread-attr",
2290
+ severity: "info",
2291
+ 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`,
2292
+ file: ef.file.displayPath,
2293
+ line: fact.line,
2294
+ hint: "Prefer a string-literal data-uidex on the element itself, or annotate at the call site."
2295
+ });
2296
+ } else {
2297
+ const id = uniqueElementId(fact, usedElementIds);
2298
+ usedElementIds.add(id);
2299
+ diagnostics.push({
2300
+ code: "missing-element-annotation",
2301
+ severity: "info",
2302
+ message: `Interactive <${fact.tag}> without data-uidex annotation`,
2303
+ file: ef.file.displayPath,
2304
+ line: fact.line,
2305
+ hint: `Add \`data-uidex="${id}"\` (or run \`uidex scan --fix\`).`,
2306
+ fix: {
2307
+ description: `Add data-uidex="${id}" to <${fact.tag}>`,
2308
+ edits: [
2309
+ {
2310
+ path: ef.file.sourcePath,
2311
+ start: fact.nameEnd,
2312
+ end: fact.nameEnd,
2313
+ replacement: ` data-uidex="${id}"`
2314
+ }
2315
+ ]
2316
+ }
2317
+ });
2318
+ }
2382
2319
  }
2383
2320
  }
2384
2321
  }
@@ -2395,12 +2332,11 @@ function audit(opts) {
2395
2332
  }
2396
2333
  }
2397
2334
  }
2398
- for (const f of files) {
2399
- const importRe = /import\s+(?:[^'"]+)\s+from\s+['"]([^'"]+)['"]/g;
2400
- let m;
2401
- while ((m = importRe.exec(f.content)) !== null) {
2402
- const spec = m[1];
2403
- const baseName2 = spec.split("/").pop() ?? "";
2335
+ for (const ef of extracted) {
2336
+ const displayPath = ef.file.displayPath;
2337
+ for (const imp of ef.imports ?? []) {
2338
+ if (imp.isTypeOnly) continue;
2339
+ const baseName2 = imp.specifier.split("/").pop() ?? "";
2404
2340
  const primitive = byName.get(
2405
2341
  baseName2.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
2406
2342
  );
@@ -2408,81 +2344,170 @@ function audit(opts) {
2408
2344
  const scope = primitive.scopes?.[0];
2409
2345
  if (!scope) continue;
2410
2346
  const [kind, id] = scope.split(":");
2411
- const importerSegments = f.displayPath.split("/");
2347
+ const importerSegments = displayPath.split("/");
2412
2348
  if (importerSegments.includes(id) && importerSegments.includes(kind + "s")) {
2413
2349
  continue;
2414
2350
  }
2415
2351
  if (kind === "feature" && importerSegments.includes(id)) continue;
2416
- if (kind === "feature" && declaredFeatures.get(f.displayPath)?.has(id)) {
2352
+ if (kind === "feature" && declaredFeatures.get(displayPath)?.has(id)) {
2417
2353
  continue;
2418
2354
  }
2419
2355
  diagnostics.push({
2420
2356
  code: "scope-leak",
2421
2357
  severity: "warning",
2422
- message: `Primitive "${primitive.id}" is scoped to ${scope} but is imported from ${f.displayPath}`,
2423
- file: f.displayPath
2358
+ message: `Primitive "${primitive.id}" is scoped to ${scope} but is imported from ${displayPath}`,
2359
+ file: displayPath,
2360
+ line: imp.line
2424
2361
  });
2425
2362
  }
2426
2363
  }
2427
2364
  }
2428
2365
  if (lint && coverageEnabled) {
2366
+ const factsByLoc = /* @__PURE__ */ new Map();
2367
+ for (const ef of opts.flowExtracted ?? []) {
2368
+ for (const fact of ef.flows ?? []) {
2369
+ const lines = /* @__PURE__ */ new Map();
2370
+ for (const call of fact.calls) {
2371
+ if (!lines.has(call.id)) lines.set(call.id, call.line);
2372
+ }
2373
+ factsByLoc.set(`${ef.file.displayPath}:${fact.line}`, lines);
2374
+ }
2375
+ }
2429
2376
  for (const flow of registry.list("flow")) {
2377
+ const callLines = factsByLoc.get(`${flow.loc.file}:${flow.loc.line}`);
2430
2378
  for (const touchedId of flow.touches) {
2431
- const found = registry.get("element", touchedId) ?? registry.get("widget", touchedId) ?? registry.get("region", touchedId);
2379
+ 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);
2432
2380
  if (!found) {
2433
2381
  diagnostics.push({
2434
2382
  code: "unknown-reference",
2435
2383
  severity: "warning",
2436
2384
  message: `Flow "${flow.id}" references unknown entity "${touchedId}"`,
2437
2385
  file: flow.loc.file,
2438
- line: flow.loc.line
2386
+ // Point at the uidex() call itself when the spec facts are
2387
+ // available; the describe line is the fallback.
2388
+ line: callLines?.get(touchedId) ?? flow.loc.line
2389
+ });
2390
+ }
2391
+ }
2392
+ }
2393
+ }
2394
+ if (lint) {
2395
+ const occurrences = /* @__PURE__ */ new Map();
2396
+ for (const ef of extracted) {
2397
+ for (const a of ef.annotations) {
2398
+ if (a.kind !== "element" && a.kind !== "region" && a.kind !== "widget" && a.kind !== "primitive") {
2399
+ continue;
2400
+ }
2401
+ const key = `${a.kind}:${a.id}`;
2402
+ let list = occurrences.get(key);
2403
+ if (!list) {
2404
+ list = [];
2405
+ occurrences.set(key, list);
2406
+ }
2407
+ list.push({ file: a.file, line: a.line });
2408
+ }
2409
+ }
2410
+ for (const [key, list] of occurrences) {
2411
+ const filesSeen = new Set(list.map((o) => o.file));
2412
+ if (filesSeen.size < 2) continue;
2413
+ const [kind, id] = key.split(/:(.*)/s);
2414
+ const others = list.slice(1).map((o) => `${o.file}:${o.line}`).join(", ");
2415
+ diagnostics.push({
2416
+ code: "duplicate-id",
2417
+ severity: kind === "widget" || kind === "primitive" ? "warning" : "info",
2418
+ message: `${kind} id "${id}" is declared in ${filesSeen.size} files (also at ${others}); the registry keeps only one entry`,
2419
+ file: list[0].file,
2420
+ line: list[0].line,
2421
+ entity: { kind, id },
2422
+ 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."
2423
+ });
2424
+ }
2425
+ }
2426
+ if (lint && coverageEnabled) {
2427
+ for (const ef of opts.flowExtracted ?? []) {
2428
+ for (const fact of ef.flows ?? []) {
2429
+ for (const dyn of fact.dynamicCalls ?? []) {
2430
+ diagnostics.push({
2431
+ code: "dynamic-flow-reference",
2432
+ severity: "warning",
2433
+ message: `\`uidex(\u2026)\` call in flow "${fact.title}" uses a dynamic expression; the id is invisible to coverage and registry validation`,
2434
+ file: ef.file.displayPath,
2435
+ line: dyn.line,
2436
+ hint: "Use a string-literal id (component ids inside uidex() must be statically analysable)."
2439
2437
  });
2440
2438
  }
2441
2439
  }
2442
2440
  }
2443
2441
  }
2442
+ if (lint && coverageEnabled) {
2443
+ for (const ef of extracted) {
2444
+ if (!ef.metadata) continue;
2445
+ for (const m of ef.metadata) {
2446
+ const check2 = (refKind, ids, spans) => {
2447
+ for (let i = 0; i < (ids?.length ?? 0); i++) {
2448
+ const refId = ids[i];
2449
+ const found = registry.get(refKind, refId) ?? registry.matchPattern(refKind, refId);
2450
+ if (found) continue;
2451
+ diagnostics.push({
2452
+ code: "unknown-reference",
2453
+ severity: "warning",
2454
+ message: `\`export const uidex\` in ${ef.file.displayPath} references unknown ${refKind} "${refId}"`,
2455
+ file: ef.file.displayPath,
2456
+ line: spans?.[i] ? lineOfOffset(ef.file.content, spans[i].start) : m.loc.line,
2457
+ hint: `No ${refKind} with id "${refId}" exists in the registry; fix the reference or add the ${refKind}.`
2458
+ });
2459
+ }
2460
+ };
2461
+ check2("feature", m.features, m.featureSpans);
2462
+ check2("widget", m.widgets, m.widgetSpans);
2463
+ }
2464
+ }
2465
+ }
2444
2466
  const summary = {
2445
2467
  errors: diagnostics.filter((d) => d.severity === "error").length,
2446
2468
  warnings: diagnostics.filter((d) => d.severity === "warning").length
2447
2469
  };
2448
2470
  return { diagnostics, summary };
2449
2471
  }
2450
- function legacyJsdocMigration(a) {
2451
- const quote = (s) => JSON.stringify(s);
2452
- const arr = (xs) => xs && xs.length > 0 ? `[${xs.map(quote).join(", ")}]` : "";
2453
- const entityHint = (kind) => {
2454
- const uidexKind = kind.charAt(0).toUpperCase() + kind.slice(1);
2455
- const parts = [`${kind}: ${quote(a.id)}`];
2456
- if (a.acceptance?.length) parts.push(`acceptance: ${arr(a.acceptance)}`);
2457
- return {
2458
- message: `Legacy JSDoc tag \`@uidex ${kind} ${a.id}\` is no longer recognised; migrate to \`export const uidex\``,
2459
- hint: `Replace with: export const uidex = { ${parts.join(", ")} } as const satisfies Uidex.${uidexKind}`
2460
- };
2461
- };
2462
- switch (a.kind) {
2463
- case "page-doc":
2464
- return entityHint("page");
2465
- case "feature-doc":
2466
- return entityHint("feature");
2467
- case "widget-doc":
2468
- return entityHint("widget");
2469
- case "not-flow":
2470
- return {
2471
- message: `Legacy JSDoc tag \`@uidex:not-flow\` is no longer recognised; migrate to \`export const uidex\``,
2472
- hint: `Replace with: export const uidex = { notFlow: true } as const satisfies Uidex.NotFlow`
2473
- };
2474
- case "orphan-acceptance":
2475
- return {
2476
- message: `Legacy JSDoc tag \`@acceptance\` is no longer recognised; migrate to the \`acceptance\` field on \`export const uidex\``,
2477
- hint: `Replace with: export const uidex = { /* kind */, acceptance: ${arr(a.acceptance)} } as const`
2478
- };
2479
- default:
2480
- return null;
2472
+ function lineOfOffset(content, offset) {
2473
+ let line = 1;
2474
+ for (let i = 0; i < offset && i < content.length; i++) {
2475
+ if (content[i] === "\n") line++;
2476
+ }
2477
+ return line;
2478
+ }
2479
+ var TAG_FALLBACK_ID = {
2480
+ a: "link",
2481
+ button: "button",
2482
+ input: "input",
2483
+ select: "select",
2484
+ textarea: "textarea"
2485
+ };
2486
+ function kebabId(str) {
2487
+ return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
2488
+ }
2489
+ function deriveElementId(fact) {
2490
+ const fromHint = fact.nameHint ? kebabId(fact.nameHint) : "";
2491
+ const capped = fromHint.split("-").filter(Boolean).slice(0, 5).join("-");
2492
+ return capped || TAG_FALLBACK_ID[fact.tag] || fact.tag;
2493
+ }
2494
+ function uniqueElementId(fact, used) {
2495
+ const base = deriveElementId(fact);
2496
+ if (!used.has(base)) return base;
2497
+ for (let n = 2; ; n++) {
2498
+ const candidate = `${base}-${n}`;
2499
+ if (!used.has(candidate)) return candidate;
2481
2500
  }
2482
2501
  }
2483
2502
  function normalizeLineEndings(s) {
2484
2503
  return s.replace(/\r\n/g, "\n");
2485
2504
  }
2505
+ function normalizeForCheck(s) {
2506
+ return normalizeLineEndings(s).replace(
2507
+ /export const gitContext = \{[\s\S]*?\} as const/,
2508
+ "export const gitContext = {} as const"
2509
+ );
2510
+ }
2486
2511
  function formatChangedSummary(change) {
2487
2512
  const parts = [];
2488
2513
  const fmt = (kind, names) => {
@@ -2581,62 +2606,11 @@ function extractEntitiesArray(source) {
2581
2606
  }
2582
2607
  return null;
2583
2608
  }
2584
- function findJsxOpeningEnd(src, start) {
2585
- let i = start;
2586
- while (i < src.length) {
2587
- const ch = src[i];
2588
- if (ch === ">" || ch === "/" && src[i + 1] === ">") return i;
2589
- if (ch === '"' || ch === "'" || ch === "`") {
2590
- i = skipString2(src, i);
2591
- } else if (ch === "{") {
2592
- i = skipBraces(src, i);
2593
- } else {
2594
- i++;
2595
- }
2596
- }
2597
- return -1;
2598
- }
2599
- function skipString2(src, start) {
2600
- const quote = src[start];
2601
- let i = start + 1;
2602
- while (i < src.length) {
2603
- if (src[i] === "\\" && quote !== "`") {
2604
- i += 2;
2605
- continue;
2606
- }
2607
- if (quote === "`" && src[i] === "$" && src[i + 1] === "{") {
2608
- i = skipBraces(src, i + 1);
2609
- continue;
2610
- }
2611
- if (src[i] === quote) return i + 1;
2612
- i++;
2613
- }
2614
- return i;
2615
- }
2616
- function skipBraces(src, start) {
2617
- let depth = 1;
2618
- let i = start + 1;
2619
- while (i < src.length && depth > 0) {
2620
- const ch = src[i];
2621
- if (ch === "{") {
2622
- depth++;
2623
- i++;
2624
- } else if (ch === "}") {
2625
- depth--;
2626
- i++;
2627
- } else if (ch === '"' || ch === "'" || ch === "`") {
2628
- i = skipString2(src, i);
2629
- } else {
2630
- i++;
2631
- }
2632
- }
2633
- return i;
2634
- }
2635
2609
  function dynamicAttrHint(kind) {
2636
2610
  if (kind === "region") {
2637
2611
  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`;
2638
2612
  }
2639
- 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`;
2613
+ 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)`;
2640
2614
  }
2641
2615
  function stableStringify(value) {
2642
2616
  return JSON.stringify(value, stableReplacer);
@@ -2669,9 +2643,7 @@ function replacerSorted(_key, value) {
2669
2643
  }
2670
2644
  return value;
2671
2645
  }
2672
- function emitIdUnion(name, ids, typeMode) {
2673
- if (typeMode === "loose") return `export type ${name} = string
2674
- `;
2646
+ function emitIdUnion(name, ids) {
2675
2647
  if (ids.length === 0) return `export type ${name} = never
2676
2648
  `;
2677
2649
  const sorted = [...ids].sort();
@@ -2681,12 +2653,7 @@ ${body}
2681
2653
  `;
2682
2654
  }
2683
2655
  function emit(opts) {
2684
- const {
2685
- registry,
2686
- gitContext,
2687
- uidexImport = "uidex",
2688
- typeMode = "strict"
2689
- } = opts;
2656
+ const { registry, gitContext, uidexImport = "uidex" } = opts;
2690
2657
  const routes = [...registry.list("route")].sort(
2691
2658
  (a, b) => a.path.localeCompare(b.path)
2692
2659
  );
@@ -2709,57 +2676,49 @@ function emit(opts) {
2709
2676
  lines.push(
2710
2677
  emitIdUnion(
2711
2678
  "PageId",
2712
- pages.map((e) => e.id),
2713
- typeMode
2679
+ pages.map((e) => e.id)
2714
2680
  )
2715
2681
  );
2716
2682
  lines.push(
2717
2683
  emitIdUnion(
2718
2684
  "FeatureId",
2719
- features.map((e) => e.id),
2720
- typeMode
2685
+ features.map((e) => e.id)
2721
2686
  )
2722
2687
  );
2723
2688
  lines.push(
2724
2689
  emitIdUnion(
2725
2690
  "WidgetId",
2726
- widgets.map((e) => e.id),
2727
- typeMode
2691
+ widgets.map((e) => e.id)
2728
2692
  )
2729
2693
  );
2730
2694
  lines.push(
2731
2695
  emitIdUnion(
2732
2696
  "RegionId",
2733
- regions.map((e) => e.id),
2734
- typeMode
2697
+ regions.map((e) => e.id)
2735
2698
  )
2736
2699
  );
2737
2700
  lines.push(
2738
2701
  emitIdUnion(
2739
2702
  "ElementId",
2740
- elements.map((e) => e.id),
2741
- typeMode
2703
+ elements.map((e) => e.id)
2742
2704
  )
2743
2705
  );
2744
2706
  lines.push(
2745
2707
  emitIdUnion(
2746
2708
  "PrimitiveId",
2747
- primitives.map((e) => e.id),
2748
- typeMode
2709
+ primitives.map((e) => e.id)
2749
2710
  )
2750
2711
  );
2751
2712
  lines.push(
2752
2713
  emitIdUnion(
2753
2714
  "FlowId",
2754
- flows.map((e) => e.id),
2755
- typeMode
2715
+ flows.map((e) => e.id)
2756
2716
  )
2757
2717
  );
2758
2718
  lines.push(
2759
2719
  emitIdUnion(
2760
2720
  "RouteId",
2761
- routes.map((e) => e.path),
2762
- typeMode
2721
+ routes.map((e) => e.path)
2763
2722
  )
2764
2723
  );
2765
2724
  lines.push("");
@@ -2872,22 +2831,33 @@ function parseGitHubRef(ref) {
2872
2831
 
2873
2832
  // src/scanner/scan/scaffold.ts
2874
2833
  import * as fs3 from "fs";
2875
- import * as path5 from "path";
2834
+ import * as path6 from "path";
2876
2835
  function scaffoldWidgetSpec(opts) {
2836
+ return scaffoldSpec({
2837
+ registry: opts.registry,
2838
+ kind: "widget",
2839
+ id: opts.widgetId,
2840
+ outDir: opts.outDir,
2841
+ force: opts.force,
2842
+ fixtureImport: opts.fixtureImport
2843
+ });
2844
+ }
2845
+ function scaffoldSpec(opts) {
2877
2846
  const {
2878
2847
  registry,
2879
- widgetId,
2848
+ kind,
2849
+ id,
2880
2850
  outDir,
2881
2851
  force = false,
2882
2852
  fixtureImport = "./fixtures"
2883
2853
  } = opts;
2884
- const widget = registry.get("widget", widgetId);
2885
- if (!widget) {
2886
- throw new Error(`Widget "${widgetId}" not found in registry`);
2854
+ const entity = registry.get(kind, id);
2855
+ if (!entity) {
2856
+ throw new Error(`${capitalize(kind)} "${id}" not found in registry`);
2887
2857
  }
2888
- const criteria = widget.meta?.acceptance ?? [];
2889
- const filename = `widget-${widgetId}.spec.ts`;
2890
- const outputPath = path5.resolve(outDir, filename);
2858
+ const criteria = entity.meta?.acceptance ?? [];
2859
+ const filename = kind === "widget" ? `widget-${id}.spec.ts` : `flow-${id}.spec.ts`;
2860
+ const outputPath = path6.resolve(outDir, filename);
2891
2861
  if (fs3.existsSync(outputPath) && !force) {
2892
2862
  return {
2893
2863
  outputPath,
@@ -2896,15 +2866,14 @@ function scaffoldWidgetSpec(opts) {
2896
2866
  reason: `spec already exists at ${outputPath}; pass --force to overwrite`
2897
2867
  };
2898
2868
  }
2899
- const content = renderSpec({
2900
- widgetId,
2901
- criteria,
2902
- fixtureImport
2903
- });
2904
- fs3.mkdirSync(path5.dirname(outputPath), { recursive: true });
2869
+ const content = renderSpec({ id, criteria, fixtureImport });
2870
+ fs3.mkdirSync(path6.dirname(outputPath), { recursive: true });
2905
2871
  fs3.writeFileSync(outputPath, content, "utf8");
2906
2872
  return { outputPath, written: true, skipped: false };
2907
2873
  }
2874
+ function capitalize(s) {
2875
+ return s.charAt(0).toUpperCase() + s.slice(1);
2876
+ }
2908
2877
  function renderSpec(args) {
2909
2878
  const lines = [];
2910
2879
  lines.push(
@@ -2912,7 +2881,7 @@ function renderSpec(args) {
2912
2881
  );
2913
2882
  lines.push("");
2914
2883
  lines.push(
2915
- `test.describe(${JSON.stringify(args.widgetId)}, { tag: "@uidex:flow" }, () => {`
2884
+ `test.describe(${JSON.stringify(args.id)}, { tag: "@uidex:flow" }, () => {`
2916
2885
  );
2917
2886
  if (args.criteria.length === 0) {
2918
2887
  lines.push(` test("TODO: add acceptance criteria", async () => {`);
@@ -2935,7 +2904,7 @@ function renderSpec(args) {
2935
2904
 
2936
2905
  // src/scanner/scan/pipeline.ts
2937
2906
  import * as fs4 from "fs";
2938
- import * as path6 from "path";
2907
+ import * as path7 from "path";
2939
2908
  function runScan(opts = {}) {
2940
2909
  const cwd = opts.cwd ?? process.cwd();
2941
2910
  const configs = opts.configs ?? discover({ cwd });
@@ -2964,10 +2933,9 @@ function runOne(dc, opts) {
2964
2933
  const gitContext = resolveGitContext({ cwd: configDir });
2965
2934
  const generated = emit({
2966
2935
  registry: resolved.registry,
2967
- gitContext,
2968
- typeMode: config.typeMode
2936
+ gitContext
2969
2937
  });
2970
- const outputPath = path6.resolve(configDir, config.output);
2938
+ const outputPath = path7.resolve(configDir, config.output);
2971
2939
  const outputRel = config.output;
2972
2940
  let existingOnDisk = null;
2973
2941
  if (opts.check) {
@@ -2977,12 +2945,16 @@ function runOne(dc, opts) {
2977
2945
  existingOnDisk = null;
2978
2946
  }
2979
2947
  }
2948
+ const hasExtractDiagnostics = [...extracted, ...extractedFlows].some(
2949
+ (ef) => (ef.diagnostics?.length ?? 0) > 0
2950
+ );
2980
2951
  let auditResult;
2981
- if (opts.check || opts.lint || resolved.diagnostics.length > 0) {
2952
+ if (opts.check || opts.lint || resolved.diagnostics.length > 0 || hasExtractDiagnostics) {
2982
2953
  auditResult = audit({
2983
2954
  registry: resolved.registry,
2984
2955
  extracted,
2985
2956
  files: sourceFiles,
2957
+ flowExtracted: extractedFlows,
2986
2958
  config,
2987
2959
  check: opts.check,
2988
2960
  lint: opts.lint,
@@ -3003,34 +2975,281 @@ function runOne(dc, opts) {
3003
2975
  };
3004
2976
  }
3005
2977
  function writeScanResult(result) {
3006
- fs4.mkdirSync(path6.dirname(result.outputPath), { recursive: true });
2978
+ fs4.mkdirSync(path7.dirname(result.outputPath), { recursive: true });
3007
2979
  fs4.writeFileSync(result.outputPath, result.generated, "utf8");
3008
2980
  }
3009
2981
 
2982
+ // src/scanner/scan/fix.ts
2983
+ import * as fs5 from "fs";
2984
+ import * as path8 from "path";
2985
+ function applyFixes(diagnostics) {
2986
+ const entries = [];
2987
+ for (const d of diagnostics) {
2988
+ if (!d.fix) continue;
2989
+ entries.push({
2990
+ code: d.code,
2991
+ description: d.fix.description,
2992
+ file: d.file,
2993
+ edits: d.fix.edits ?? [],
2994
+ createFiles: d.fix.createFiles ?? [],
2995
+ deleteFiles: d.fix.deleteFiles ?? []
2996
+ });
2997
+ }
2998
+ if (entries.length === 0) return { applied: [], skipped: [] };
2999
+ const seenEdits = /* @__PURE__ */ new Set();
3000
+ const editsByFile = /* @__PURE__ */ new Map();
3001
+ for (const entry of entries) {
3002
+ for (const edit of entry.edits) {
3003
+ const key = `${edit.path}:${edit.start}:${edit.end}:${edit.replacement}`;
3004
+ if (seenEdits.has(key)) continue;
3005
+ seenEdits.add(key);
3006
+ let list = editsByFile.get(edit.path);
3007
+ if (!list) {
3008
+ list = [];
3009
+ editsByFile.set(edit.path, list);
3010
+ }
3011
+ list.push({ ...edit, entry });
3012
+ }
3013
+ }
3014
+ for (const [filePath, edits] of editsByFile) {
3015
+ let content;
3016
+ try {
3017
+ content = fs5.readFileSync(filePath, "utf8");
3018
+ } catch {
3019
+ for (const e of edits) e.entry.skippedReason ??= "file is unreadable";
3020
+ continue;
3021
+ }
3022
+ edits.sort((a, b) => a.start - b.start || a.end - b.end);
3023
+ const kept = [];
3024
+ let prevEnd = -1;
3025
+ for (const edit of edits) {
3026
+ if (edit.start < prevEnd) {
3027
+ edit.entry.skippedReason ??= "overlapping edit";
3028
+ continue;
3029
+ }
3030
+ kept.push(edit);
3031
+ prevEnd = edit.end;
3032
+ }
3033
+ for (let i = kept.length - 1; i >= 0; i--) {
3034
+ const edit = kept[i];
3035
+ content = content.slice(0, edit.start) + edit.replacement + content.slice(edit.end);
3036
+ }
3037
+ if (kept.length > 0) fs5.writeFileSync(filePath, content, "utf8");
3038
+ }
3039
+ for (const entry of entries) {
3040
+ if (entry.skippedReason) continue;
3041
+ for (const create of entry.createFiles) {
3042
+ if (fs5.existsSync(create.path)) {
3043
+ entry.skippedReason = `${path8.basename(create.path)} already exists`;
3044
+ continue;
3045
+ }
3046
+ fs5.mkdirSync(path8.dirname(create.path), { recursive: true });
3047
+ fs5.writeFileSync(create.path, create.content, "utf8");
3048
+ }
3049
+ if (entry.skippedReason) continue;
3050
+ for (const del of entry.deleteFiles) {
3051
+ try {
3052
+ fs5.unlinkSync(del);
3053
+ } catch {
3054
+ }
3055
+ }
3056
+ }
3057
+ const applied = [];
3058
+ const skipped = [];
3059
+ for (const entry of entries) {
3060
+ const summary = {
3061
+ code: entry.code,
3062
+ description: entry.description,
3063
+ file: entry.file
3064
+ };
3065
+ if (entry.skippedReason) {
3066
+ skipped.push({ ...summary, reason: entry.skippedReason });
3067
+ } else {
3068
+ applied.push(summary);
3069
+ }
3070
+ }
3071
+ return { applied, skipped };
3072
+ }
3073
+
3074
+ // src/scanner/scan/rename.ts
3075
+ var ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
3076
+ function renameEntity(opts) {
3077
+ const { cwd, kind, oldId, newId, force = false } = opts;
3078
+ const manual = [];
3079
+ const errors = [];
3080
+ if (!ID_RE.test(newId)) {
3081
+ return {
3082
+ edits: 0,
3083
+ manual,
3084
+ errors: [`New id "${newId}" is not kebab-case`]
3085
+ };
3086
+ }
3087
+ const configs = discover({ cwd });
3088
+ if (configs.length === 0) {
3089
+ return { edits: 0, manual, errors: [`No .uidex.json found under ${cwd}`] };
3090
+ }
3091
+ const edits = [];
3092
+ for (const dc of configs) {
3093
+ const { config, configDir } = dc;
3094
+ const sourceFiles = walk(config.sources, {
3095
+ cwd: configDir,
3096
+ globalExcludes: config.exclude
3097
+ });
3098
+ const extracted = extract(sourceFiles);
3099
+ const flowFiles = config.flows ? walk(
3100
+ config.flows.map((glob) => ({ rootDir: ".", include: [glob] })),
3101
+ { cwd: configDir, includeTests: true }
3102
+ ) : [];
3103
+ const extractedFlows = extract(flowFiles);
3104
+ const scan = runScan({ cwd: configDir, configs: [dc] })[0];
3105
+ const registry = scan.registry;
3106
+ if (!registry.get(kind, oldId)) {
3107
+ if (registry.matchPattern(kind, oldId)) {
3108
+ errors.push(
3109
+ `${kind} "${oldId}" only matches via a pattern id; pattern-backed ids cannot be renamed mechanically`
3110
+ );
3111
+ } else {
3112
+ errors.push(`${kind} "${oldId}" not found in registry`);
3113
+ }
3114
+ continue;
3115
+ }
3116
+ if (registry.get(kind, newId) && !force) {
3117
+ errors.push(
3118
+ `${kind} "${newId}" already exists; pass --force to merge the ids`
3119
+ );
3120
+ continue;
3121
+ }
3122
+ const quoteAs = (content, start) => {
3123
+ const q = content[start];
3124
+ return q === '"' || q === "'" || q === "`" ? `${q}${newId}${q}` : `"${newId}"`;
3125
+ };
3126
+ for (const ef of extracted) {
3127
+ for (const a of ef.annotations) {
3128
+ if (a.kind !== kind || a.id !== oldId) continue;
3129
+ if (a.span) {
3130
+ edits.push({
3131
+ path: ef.file.sourcePath,
3132
+ start: a.span.start,
3133
+ end: a.span.end,
3134
+ replacement: quoteAs(ef.file.content, a.span.start)
3135
+ });
3136
+ } else {
3137
+ manual.push({
3138
+ file: a.file,
3139
+ line: a.line,
3140
+ reason: "attribute value is not a plain string literal (const reference, ternary, or template)"
3141
+ });
3142
+ }
3143
+ }
3144
+ for (const m of ef.metadata ?? []) {
3145
+ if (kind === "widget" && m.kind === "widget" && m.id === oldId) {
3146
+ if (m.idSpan) {
3147
+ edits.push({
3148
+ path: ef.file.sourcePath,
3149
+ start: m.idSpan.start,
3150
+ end: m.idSpan.end,
3151
+ replacement: quoteAs(ef.file.content, m.idSpan.start)
3152
+ });
3153
+ } else {
3154
+ manual.push({
3155
+ file: ef.file.displayPath,
3156
+ line: m.loc.line ?? 1,
3157
+ reason: "widget export id is not a plain string literal"
3158
+ });
3159
+ }
3160
+ }
3161
+ if (kind === "widget" && m.widgets) {
3162
+ for (let i = 0; i < m.widgets.length; i++) {
3163
+ if (m.widgets[i] !== oldId) continue;
3164
+ const span = m.widgetSpans?.[i];
3165
+ if (span) {
3166
+ edits.push({
3167
+ path: ef.file.sourcePath,
3168
+ start: span.start,
3169
+ end: span.end,
3170
+ replacement: quoteAs(ef.file.content, span.start)
3171
+ });
3172
+ }
3173
+ }
3174
+ }
3175
+ }
3176
+ }
3177
+ for (const ef of extractedFlows) {
3178
+ for (const fact of ef.flows ?? []) {
3179
+ for (const call of fact.calls) {
3180
+ if (call.id !== oldId) continue;
3181
+ if (call.span) {
3182
+ edits.push({
3183
+ path: ef.file.sourcePath,
3184
+ start: call.span.start,
3185
+ end: call.span.end,
3186
+ replacement: quoteAs(ef.file.content, call.span.start)
3187
+ });
3188
+ } else {
3189
+ manual.push({
3190
+ file: ef.file.displayPath,
3191
+ line: call.line,
3192
+ reason: "uidex() argument is not a plain string literal"
3193
+ });
3194
+ }
3195
+ }
3196
+ }
3197
+ }
3198
+ }
3199
+ if (errors.length > 0) {
3200
+ return { edits: 0, manual, errors };
3201
+ }
3202
+ if (edits.length === 0 && manual.length === 0) {
3203
+ return {
3204
+ edits: 0,
3205
+ manual,
3206
+ errors: [
3207
+ `${kind} "${oldId}" has no editable occurrences (convention-derived ids like landmarks cannot be renamed)`
3208
+ ]
3209
+ };
3210
+ }
3211
+ const result = applyFixes([
3212
+ {
3213
+ code: "rename",
3214
+ severity: "info",
3215
+ message: "",
3216
+ fix: {
3217
+ description: `Rename ${kind} "${oldId}" to "${newId}"`,
3218
+ edits
3219
+ }
3220
+ }
3221
+ ]);
3222
+ if (result.skipped.length > 0) {
3223
+ errors.push(`Some edits were skipped: ${result.skipped[0].reason}`);
3224
+ }
3225
+ for (const r of runScan({ cwd })) writeScanResult(r);
3226
+ return { edits: edits.length, manual, errors };
3227
+ }
3228
+
3010
3229
  // src/scanner/scan/cli.ts
3011
- import * as fs7 from "fs";
3012
- import * as path9 from "path";
3230
+ import * as fs8 from "fs";
3231
+ import * as path11 from "path";
3013
3232
 
3014
3233
  // src/scanner/scan/ai/index.ts
3015
3234
  import * as p from "@clack/prompts";
3016
3235
 
3017
3236
  // src/scanner/scan/ai/providers/claude.ts
3018
- import * as fs6 from "fs";
3019
- import * as path8 from "path";
3237
+ import * as fs7 from "fs";
3238
+ import * as path10 from "path";
3020
3239
 
3021
3240
  // src/scanner/scan/ai/templates.ts
3022
- import * as fs5 from "fs";
3023
- import * as path7 from "path";
3241
+ import * as fs6 from "fs";
3242
+ import * as path9 from "path";
3024
3243
  function templatePath(rel) {
3025
3244
  const candidates = [
3026
- path7.resolve(__dirname, "../../templates", rel),
3027
- // dist/cli/cli.cjs → ../../templates
3028
- path7.resolve(__dirname, "../../../templates", rel)
3029
- // src/scan/ai/foo.ts../../../templates
3245
+ path9.resolve(__dirname, "../../templates", rel),
3246
+ // dist/cli/cli.cjs → ../../templates
3247
+ path9.resolve(__dirname, "../../../../templates", rel)
3248
+ // src/scanner/scan/ai → ../../../../templates
3030
3249
  ];
3031
3250
  for (const c of candidates) {
3032
3251
  try {
3033
- fs5.accessSync(c, fs5.constants.R_OK);
3252
+ fs6.accessSync(c, fs6.constants.R_OK);
3034
3253
  return c;
3035
3254
  } catch {
3036
3255
  continue;
@@ -3042,24 +3261,39 @@ function templatePath(rel) {
3042
3261
  );
3043
3262
  }
3044
3263
  function readTemplate(rel) {
3045
- return fs5.readFileSync(templatePath(rel), "utf8");
3264
+ return fs6.readFileSync(templatePath(rel), "utf8");
3046
3265
  }
3047
3266
 
3048
3267
  // src/scanner/scan/ai/providers/claude.ts
3049
- var CLAUDE_FILES = [
3050
- { dest: ".claude/rules/uidex.md", template: "claude/rules.md" },
3051
- { dest: ".claude/commands/uidex/audit.md", template: "claude/audit.md" },
3052
- { dest: ".claude/commands/uidex/api.md", template: "claude/api.md" }
3268
+ var SKILL_FILES = [
3269
+ { dest: ".claude/skills/uidex/SKILL.md", template: "claude/SKILL.md" },
3270
+ {
3271
+ dest: ".claude/skills/uidex/references/conventions.md",
3272
+ template: "claude/references/conventions.md"
3273
+ },
3274
+ {
3275
+ dest: ".claude/skills/uidex/references/audit.md",
3276
+ template: "claude/references/audit.md"
3277
+ },
3278
+ {
3279
+ dest: ".claude/skills/uidex/references/api.md",
3280
+ template: "claude/references/api.md"
3281
+ }
3282
+ ];
3283
+ var LEGACY_FILES = [
3284
+ ".claude/rules/uidex.md",
3285
+ ".claude/commands/uidex/audit.md",
3286
+ ".claude/commands/uidex/api.md"
3053
3287
  ];
3054
3288
  var claudeProvider = {
3055
3289
  id: "claude",
3056
3290
  label: "Claude Code",
3057
- description: "Adds .claude/rules/uidex.md, /uidex:audit, and /uidex:api slash commands.",
3291
+ description: "Adds .claude/skills/uidex/ skill with conventions, audit, and API references.",
3058
3292
  async install({ cwd, force }) {
3059
3293
  const changes = [];
3060
- for (const file of CLAUDE_FILES) {
3061
- const dest = path8.join(cwd, file.dest);
3062
- const exists = fs6.existsSync(dest);
3294
+ for (const file of SKILL_FILES) {
3295
+ const dest = path10.join(cwd, file.dest);
3296
+ const exists = fs7.existsSync(dest);
3063
3297
  if (exists && !force) {
3064
3298
  changes.push({
3065
3299
  path: file.dest,
@@ -3068,36 +3302,56 @@ var claudeProvider = {
3068
3302
  });
3069
3303
  continue;
3070
3304
  }
3071
- fs6.mkdirSync(path8.dirname(dest), { recursive: true });
3072
- fs6.writeFileSync(dest, readTemplate(file.template));
3305
+ fs7.mkdirSync(path10.dirname(dest), { recursive: true });
3306
+ fs7.writeFileSync(dest, readTemplate(file.template));
3073
3307
  changes.push({
3074
3308
  path: file.dest,
3075
3309
  action: exists ? "overwritten" : "created"
3076
3310
  });
3077
3311
  }
3312
+ for (const rel of LEGACY_FILES) {
3313
+ const dest = path10.join(cwd, rel);
3314
+ if (fs7.existsSync(dest)) {
3315
+ fs7.unlinkSync(dest);
3316
+ changes.push({ path: rel, action: "removed" });
3317
+ }
3318
+ }
3319
+ cleanupEmpty(path10.join(cwd, ".claude/commands/uidex"));
3320
+ cleanupEmpty(path10.join(cwd, ".claude/commands"));
3321
+ cleanupEmpty(path10.join(cwd, ".claude/rules"));
3078
3322
  return { changes };
3079
3323
  },
3080
3324
  async uninstall({ cwd }) {
3081
3325
  const changes = [];
3082
- for (const file of CLAUDE_FILES) {
3083
- const dest = path8.join(cwd, file.dest);
3084
- if (!fs6.existsSync(dest)) {
3326
+ for (const file of SKILL_FILES) {
3327
+ const dest = path10.join(cwd, file.dest);
3328
+ if (!fs7.existsSync(dest)) {
3085
3329
  changes.push({ path: file.dest, action: "skipped", reason: "absent" });
3086
3330
  continue;
3087
3331
  }
3088
- fs6.unlinkSync(dest);
3332
+ fs7.unlinkSync(dest);
3089
3333
  changes.push({ path: file.dest, action: "removed" });
3090
3334
  }
3091
- cleanupEmpty(path8.join(cwd, ".claude/commands/uidex"));
3092
- cleanupEmpty(path8.join(cwd, ".claude/commands"));
3093
- cleanupEmpty(path8.join(cwd, ".claude/rules"));
3335
+ cleanupEmpty(path10.join(cwd, ".claude/skills/uidex/references"));
3336
+ cleanupEmpty(path10.join(cwd, ".claude/skills/uidex"));
3337
+ cleanupEmpty(path10.join(cwd, ".claude/skills"));
3338
+ for (const rel of LEGACY_FILES) {
3339
+ const dest = path10.join(cwd, rel);
3340
+ if (fs7.existsSync(dest)) {
3341
+ fs7.unlinkSync(dest);
3342
+ changes.push({ path: rel, action: "removed" });
3343
+ }
3344
+ }
3345
+ cleanupEmpty(path10.join(cwd, ".claude/commands/uidex"));
3346
+ cleanupEmpty(path10.join(cwd, ".claude/commands"));
3347
+ cleanupEmpty(path10.join(cwd, ".claude/rules"));
3094
3348
  return { changes };
3095
3349
  }
3096
3350
  };
3097
3351
  function cleanupEmpty(dir) {
3098
3352
  try {
3099
- const entries = fs6.readdirSync(dir);
3100
- if (entries.length === 0) fs6.rmdirSync(dir);
3353
+ const entries = fs7.readdirSync(dir);
3354
+ if (entries.length === 0) fs7.rmdirSync(dir);
3101
3355
  } catch {
3102
3356
  }
3103
3357
  }
@@ -3264,6 +3518,8 @@ async function run(opts) {
3264
3518
  return runScanCommand(cwd, flags, writer);
3265
3519
  case "scaffold":
3266
3520
  return runScaffold(cwd, positional.slice(1), flags, writer);
3521
+ case "rename":
3522
+ return runRename(cwd, positional.slice(1), flags, writer);
3267
3523
  case "ai": {
3268
3524
  const result = await runAiCommand({
3269
3525
  cwd,
@@ -3290,7 +3546,8 @@ function helpText2() {
3290
3546
  "Commands:",
3291
3547
  " init Create a .uidex.json",
3292
3548
  " scan [flags] Run the scanner pipeline",
3293
- " scaffold widget <id> Emit a Playwright spec from a widget's acceptance",
3549
+ " scaffold <widget|page|feature> <id> Emit a Playwright spec from declared acceptance",
3550
+ " rename <element|widget|region> <old-id> <new-id> Rename an id everywhere (DOM attr, flows, exports)",
3294
3551
  " ai <install|uninstall|providers> Manage AI assistant integrations",
3295
3552
  " api <METHOD> <PATH> Call the uidex API",
3296
3553
  " api --list Show available API routes",
@@ -3299,16 +3556,17 @@ function helpText2() {
3299
3556
  "",
3300
3557
  "Flags:",
3301
3558
  " --check Verify the on-disk gen file matches a fresh scan; exit non-zero on drift (read-only)",
3302
- " --lint Run lint diagnostics (missing annotations, scope leak, legacy JSDoc)",
3559
+ " --lint Run lint diagnostics (missing annotations, scope leak, duplicate ids, coverage)",
3303
3560
  " --audit Equivalent to --check --lint (read-only)",
3561
+ " --fix Apply machine-generated fixes (add data-uidex to unannotated interactive elements, drop empty names), then rescan and write",
3304
3562
  " --json Emit JSON diagnostics on stdout",
3305
3563
  " --force (scaffold) overwrite existing spec",
3306
3564
  ""
3307
3565
  ].join("\n");
3308
3566
  }
3309
3567
  function runInit(cwd, w) {
3310
- const configPath = path9.join(cwd, CONFIG_FILENAME);
3311
- if (fs7.existsSync(configPath)) {
3568
+ const configPath = path11.join(cwd, CONFIG_FILENAME);
3569
+ if (fs8.existsSync(configPath)) {
3312
3570
  w.err(`.uidex.json already exists at ${configPath}`);
3313
3571
  return w.result(1);
3314
3572
  }
@@ -3317,16 +3575,16 @@ function runInit(cwd, w) {
3317
3575
  sources: [{ rootDir: "src" }],
3318
3576
  output: "src/uidex.gen.ts"
3319
3577
  };
3320
- fs7.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
3578
+ fs8.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
3321
3579
  w.out(`Created ${configPath}`);
3322
- const gitignorePath = path9.join(cwd, ".gitignore");
3580
+ const gitignorePath = path11.join(cwd, ".gitignore");
3323
3581
  const entry = "*.gen.ts";
3324
- if (fs7.existsSync(gitignorePath)) {
3325
- const existing = fs7.readFileSync(gitignorePath, "utf8");
3582
+ if (fs8.existsSync(gitignorePath)) {
3583
+ const existing = fs8.readFileSync(gitignorePath, "utf8");
3326
3584
  const hasEntry = existing.split("\n").some((line) => line.trim() === entry);
3327
3585
  if (!hasEntry) {
3328
3586
  const needsNewline = existing.length > 0 && !existing.endsWith("\n");
3329
- fs7.appendFileSync(
3587
+ fs8.appendFileSync(
3330
3588
  gitignorePath,
3331
3589
  `${needsNewline ? "\n" : ""}${entry}
3332
3590
  `,
@@ -3335,21 +3593,33 @@ function runInit(cwd, w) {
3335
3593
  w.out(`Appended ${entry} to ${gitignorePath}`);
3336
3594
  }
3337
3595
  } else {
3338
- fs7.writeFileSync(gitignorePath, `${entry}
3596
+ fs8.writeFileSync(gitignorePath, `${entry}
3339
3597
  `, "utf8");
3340
3598
  w.out(`Created ${gitignorePath} with ${entry}`);
3341
3599
  }
3342
3600
  return w.result(0);
3343
3601
  }
3344
3602
  function runScanCommand(cwd, flags, w) {
3345
- const check = Boolean(flags.check || flags.audit);
3346
- const lint = Boolean(flags.lint || flags.audit);
3603
+ const fix = Boolean(flags.fix);
3604
+ const check = !fix && Boolean(flags.check || flags.audit);
3605
+ const lint = Boolean(flags.lint || flags.audit || fix);
3347
3606
  const asJson = Boolean(flags.json);
3348
- const configs = discover({ cwd });
3607
+ let configs = discover({ cwd });
3349
3608
  if (configs.length === 0) {
3350
3609
  w.err(`No ${CONFIG_FILENAME} found under ${cwd}`);
3351
3610
  return w.result(1);
3352
3611
  }
3612
+ let fixed = [];
3613
+ let fixSkipped = [];
3614
+ if (fix) {
3615
+ const discovery = runScan({ cwd, check: true, lint: true, configs });
3616
+ const result = applyFixes(
3617
+ discovery.flatMap((r) => r.audit?.diagnostics ?? [])
3618
+ );
3619
+ fixed = result.applied;
3620
+ fixSkipped = result.skipped;
3621
+ configs = discover({ cwd });
3622
+ }
3353
3623
  const results = runScan({ cwd, check, lint, configs });
3354
3624
  if (!check) {
3355
3625
  for (const r of results) writeScanResult(r);
@@ -3364,9 +3634,21 @@ function runScanCommand(cwd, flags, w) {
3364
3634
  { errors: 0, warnings: 0 }
3365
3635
  );
3366
3636
  if (asJson) {
3367
- const out2 = { diagnostics: allDiagnostics, summary };
3637
+ const out2 = {
3638
+ diagnostics: allDiagnostics.map(jsonDiagnostic),
3639
+ summary,
3640
+ ...fix ? { fixed, fixSkipped } : {}
3641
+ };
3368
3642
  w.out(JSON.stringify(out2, null, 2));
3369
3643
  } else {
3644
+ for (const f of fixed) {
3645
+ w.out(`FIXED [${f.code}] ${f.file ?? ""} ${f.description}`);
3646
+ }
3647
+ for (const s of fixSkipped) {
3648
+ w.out(
3649
+ `SKIPPED [${s.code}] ${s.file ?? ""} ${s.description} (${s.reason})`
3650
+ );
3651
+ }
3370
3652
  for (const r of results) {
3371
3653
  if (check) {
3372
3654
  w.out(`Checked ${r.outputPath}`);
@@ -3376,7 +3658,10 @@ function runScanCommand(cwd, flags, w) {
3376
3658
  for (const d of r.audit?.diagnostics ?? []) {
3377
3659
  const loc = d.file ? `${d.file}${d.line ? `:${d.line}` : ""}` : "";
3378
3660
  const stream = d.severity === "error" ? w.err : w.out;
3379
- stream(`${d.severity.toUpperCase()} [${d.code}] ${loc} ${d.message}`);
3661
+ const fixable = d.fix && !fix ? " [fixable: run with --fix]" : "";
3662
+ stream(
3663
+ `${d.severity.toUpperCase()} [${d.code}] ${loc} ${d.message}${fixable}`
3664
+ );
3380
3665
  if (d.hint) stream(` hint: ${d.hint}`);
3381
3666
  }
3382
3667
  }
@@ -3387,20 +3672,27 @@ function runScanCommand(cwd, flags, w) {
3387
3672
  const exit = summary.errors > 0 ? 1 : 0;
3388
3673
  return w.result(exit);
3389
3674
  }
3675
+ function jsonDiagnostic(d) {
3676
+ const { fix, ...rest } = d;
3677
+ return fix ? { ...rest, fixable: true } : rest;
3678
+ }
3679
+ var SCAFFOLD_KINDS = /* @__PURE__ */ new Set(["widget", "page", "feature"]);
3390
3680
  function runScaffold(cwd, args, flags, w) {
3391
3681
  const [kind, id] = args;
3392
- if (kind !== "widget" || !id) {
3393
- w.err("Usage: uidex scaffold widget <id> [--force]");
3682
+ if (!kind || !SCAFFOLD_KINDS.has(kind) || !id) {
3683
+ w.err("Usage: uidex scaffold <widget|page|feature> <id> [--force]");
3394
3684
  return w.result(1);
3395
3685
  }
3686
+ const scaffoldKind = kind;
3396
3687
  const results = runScan({ cwd });
3397
3688
  for (const r of results) {
3398
- const widget = r.registry.get("widget", id);
3399
- if (!widget) continue;
3400
- const outDir = path9.resolve(r.configDir, "e2e");
3401
- const result = scaffoldWidgetSpec({
3689
+ const entity = r.registry.get(scaffoldKind, id);
3690
+ if (!entity) continue;
3691
+ const outDir = path11.resolve(r.configDir, "e2e");
3692
+ const result = scaffoldSpec({
3402
3693
  registry: r.registry,
3403
- widgetId: id,
3694
+ kind: scaffoldKind,
3695
+ id,
3404
3696
  outDir,
3405
3697
  force: Boolean(flags.force)
3406
3698
  });
@@ -3411,9 +3703,43 @@ function runScaffold(cwd, args, flags, w) {
3411
3703
  w.out(`Wrote ${result.outputPath}`);
3412
3704
  return w.result(0);
3413
3705
  }
3414
- w.err(`Widget "${id}" not found in registry`);
3706
+ w.err(
3707
+ `${scaffoldKind.charAt(0).toUpperCase() + scaffoldKind.slice(1)} "${id}" not found in registry`
3708
+ );
3415
3709
  return w.result(1);
3416
3710
  }
3711
+ var RENAME_KINDS = /* @__PURE__ */ new Set(["element", "widget", "region"]);
3712
+ function runRename(cwd, args, flags, w) {
3713
+ const [kind, oldId, newId] = args;
3714
+ if (!kind || !RENAME_KINDS.has(kind) || !oldId || !newId) {
3715
+ w.err(
3716
+ "Usage: uidex rename <element|widget|region> <old-id> <new-id> [--force]"
3717
+ );
3718
+ return w.result(1);
3719
+ }
3720
+ const result = renameEntity({
3721
+ cwd,
3722
+ kind,
3723
+ oldId,
3724
+ newId,
3725
+ force: Boolean(flags.force)
3726
+ });
3727
+ for (const e of result.errors) w.err(e);
3728
+ for (const m of result.manual) {
3729
+ w.err(`MANUAL ${m.file}:${m.line} \u2014 ${m.reason}`);
3730
+ }
3731
+ if (result.errors.length > 0) return w.result(1);
3732
+ w.out(
3733
+ `Renamed ${kind} "${oldId}" \u2192 "${newId}" (${result.edits} edit(s)); gen file regenerated`
3734
+ );
3735
+ if (result.manual.length > 0) {
3736
+ w.err(
3737
+ `${result.manual.length} occurrence(s) need manual follow-up (listed above)`
3738
+ );
3739
+ return w.result(1);
3740
+ }
3741
+ return w.result(0);
3742
+ }
3417
3743
  function createWriter() {
3418
3744
  let stdout = "";
3419
3745
  let stderr = "";
@@ -3433,7 +3759,7 @@ export {
3433
3759
  CONFIG_FILENAME,
3434
3760
  ConfigError,
3435
3761
  DEFAULT_CONVENTIONS,
3436
- DEFAULT_TYPE_MODE,
3762
+ applyFixes,
3437
3763
  audit,
3438
3764
  detectRoutes,
3439
3765
  discover,
@@ -3443,10 +3769,12 @@ export {
3443
3769
  globToRegExp,
3444
3770
  parseConfig,
3445
3771
  pathToId,
3772
+ renameEntity,
3446
3773
  resolve2 as resolve,
3447
3774
  resolveGitContext,
3448
3775
  run as runCli,
3449
3776
  runScan,
3777
+ scaffoldSpec,
3450
3778
  scaffoldWidgetSpec,
3451
3779
  validateConfig,
3452
3780
  walk,