uidex 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +3 -3
  2. package/dist/cli/cli.cjs +1510 -1244
  3. package/dist/cli/cli.cjs.map +1 -1
  4. package/dist/cloud/index.cjs +385 -175
  5. package/dist/cloud/index.cjs.map +1 -1
  6. package/dist/cloud/index.d.cts +192 -4
  7. package/dist/cloud/index.d.ts +192 -4
  8. package/dist/cloud/index.js +377 -177
  9. package/dist/cloud/index.js.map +1 -1
  10. package/dist/headless/index.cjs +82 -255
  11. package/dist/headless/index.cjs.map +1 -1
  12. package/dist/headless/index.d.cts +5 -11
  13. package/dist/headless/index.d.ts +5 -11
  14. package/dist/headless/index.js +82 -257
  15. package/dist/headless/index.js.map +1 -1
  16. package/dist/index.cjs +721 -1053
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +149 -160
  19. package/dist/index.d.ts +149 -160
  20. package/dist/index.js +741 -1068
  21. package/dist/index.js.map +1 -1
  22. package/dist/react/index.cjs +729 -1000
  23. package/dist/react/index.cjs.map +1 -1
  24. package/dist/react/index.d.cts +99 -86
  25. package/dist/react/index.d.ts +99 -86
  26. package/dist/react/index.js +745 -1015
  27. package/dist/react/index.js.map +1 -1
  28. package/dist/scan/index.cjs +1518 -1237
  29. package/dist/scan/index.cjs.map +1 -1
  30. package/dist/scan/index.d.cts +209 -12
  31. package/dist/scan/index.d.ts +209 -12
  32. package/dist/scan/index.js +1515 -1236
  33. package/dist/scan/index.js.map +1 -1
  34. package/package.json +22 -21
  35. package/templates/claude/SKILL.md +71 -0
  36. package/templates/claude/references/audit.md +43 -0
  37. package/templates/claude/{rules.md → references/conventions.md} +25 -28
  38. package/templates/claude/audit.md +0 -43
  39. /package/templates/claude/{api.md → references/api.md} +0 -0
@@ -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;
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") {
485
500
  continue;
486
501
  }
487
- if (c === "$" && n === "{") {
488
- templateDepth++;
489
- i += 2;
490
- continue;
491
- }
492
- if (c === "`" && templateDepth === 0) {
493
- inTemplate = false;
494
- i++;
495
- continue;
496
- }
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
537
  }
591
538
  }
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
- }
638
- if (isIdentStart(c)) {
639
- return this.readIdent(pos);
640
- }
641
- this.advance();
642
- return { kind: "punct", value: c, pos, end: this.pos };
643
- }
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
588
+ pos
849
589
  );
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
879
- );
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,322 +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;
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";
967
+ }
968
+
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;
982
+ }
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 } : {}
999
+ };
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
+ }
1206
1015
  }
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;
1031
+ }
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;
1207
1079
  }
1208
- return { offset, line, column: offset - lineStart + 1 };
1080
+ return null;
1081
+ }
1082
+ function isIdentifier(node, name) {
1083
+ return typeof node === "object" && node !== null && node.type === "Identifier" && String(node.name) === name;
1209
1084
  }
1210
1085
 
1211
1086
  // src/scanner/scan/jsx-ancestry.ts
1212
- var DATA_ATTR_RE = /\bdata-uidex(?:-(region|widget|primitive))?\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
1213
- var PATTERN_ATTR_RE = /\bdata-uidex(?:-(region|widget|primitive))?\s*=\s*\{\s*`([^`$]+)\$\{/g;
1214
- function parseDataAttrs(tagSource) {
1215
- if (!tagSource.includes("data-uidex")) return [];
1216
- const out2 = [];
1217
- for (const m of tagSource.matchAll(DATA_ATTR_RE)) {
1218
- const kind = m[1] ?? "element";
1219
- const id = m[2] ?? m[3];
1220
- if (id) out2.push({ kind, id });
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);
1110
+ continue;
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);
1117
+ }
1118
+ return void 0;
1119
+ });
1120
+ return consts;
1121
+ }
1122
+ function staticString(node) {
1123
+ if (node.type === "Literal" && typeof node.value === "string") {
1124
+ return node.value;
1221
1125
  }
1222
- for (const m of tagSource.matchAll(PATTERN_ATTR_RE)) {
1223
- const kind = m[1] ?? "element";
1224
- const prefix = m[2];
1225
- if (prefix) out2.push({ kind, id: `${prefix}*` });
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 ?? "";
1226
1131
  }
1227
- return out2;
1132
+ return null;
1228
1133
  }
1229
- function collectJSXAncestry(content) {
1230
- if (!content.includes("data-uidex")) return [];
1231
- const out2 = [];
1232
- const ancestors = [];
1233
- const stack = [];
1234
- const N = content.length;
1235
- let i = 0;
1236
- let line = 1;
1237
- const advanceLines = (from, to) => {
1238
- for (let k = from; k < to; k++) {
1239
- if (content.charCodeAt(k) === 10) line++;
1240
- }
1241
- };
1242
- while (i < N) {
1243
- const c = content[i];
1244
- if (c === "\n") {
1245
- line++;
1246
- i++;
1247
- continue;
1248
- }
1249
- if (c === "/" && content[i + 1] === "/") {
1250
- while (i < N && content[i] !== "\n") i++;
1251
- continue;
1252
- }
1253
- if (c === "/" && content[i + 1] === "*") {
1254
- const end = content.indexOf("*/", i + 2);
1255
- const next = end === -1 ? N : end + 2;
1256
- advanceLines(i, next);
1257
- i = next;
1258
- continue;
1259
- }
1260
- if (c === '"' || c === "'") {
1261
- const next = skipString(content, i, c);
1262
- advanceLines(i, next);
1263
- i = next;
1264
- continue;
1265
- }
1266
- if (c === "`") {
1267
- const next = skipTemplate(content, i);
1268
- advanceLines(i, next);
1269
- i = next;
1270
- continue;
1271
- }
1272
- if (c === "<") {
1273
- const nextCh = content[i + 1];
1274
- if (nextCh === "/") {
1275
- const end = content.indexOf(">", i);
1276
- if (end === -1) break;
1277
- const tagName = content.slice(i + 2, end).match(/^\s*([\w.-]*)/)?.[1] ?? "";
1278
- if (tagName) {
1279
- for (let k = stack.length - 1; k >= 0; k--) {
1280
- if (stack[k].tagName === tagName) {
1281
- for (let j = stack.length - 1; j >= k; j--) {
1282
- ancestors.length -= stack[j].pushed;
1283
- }
1284
- stack.length = k;
1285
- break;
1286
- }
1287
- }
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 };
1288
1193
  }
1289
- advanceLines(i, end + 1);
1290
- i = end + 1;
1291
- continue;
1292
1194
  }
1293
- if (nextCh && /[A-Za-z_]/.test(nextCh)) {
1294
- const end = findTagEnd(content, i + 1);
1295
- if (end === -1) break;
1296
- const tagSource = content.slice(i, end + 1);
1297
- const tagName = tagSource.match(/^<\s*([\w.-]*)/)?.[1] ?? "";
1298
- const isSelf = content[end - 1] === "/";
1299
- if (tagName) {
1300
- const attrs = parseDataAttrs(tagSource);
1301
- if (attrs.length > 0) {
1302
- const snapshot = ancestors.slice();
1303
- for (const a of attrs) {
1304
- out2.push({ kind: a.kind, id: a.id, line, ancestors: snapshot });
1305
- }
1306
- }
1307
- if (!isSelf) {
1308
- for (const a of attrs) ancestors.push(a);
1309
- stack.push({ tagName, pushed: attrs.length });
1310
- }
1311
- }
1312
- advanceLines(i, end + 1);
1313
- i = end + 1;
1195
+ if (!result.resolved) {
1196
+ dynamicAttrs.push({
1197
+ kind,
1198
+ attrName: kind === "element" ? "data-uidex" : `data-uidex-${kind}`,
1199
+ line: lineAt(attr.start)
1200
+ });
1314
1201
  continue;
1315
1202
  }
1316
1203
  }
1317
- i++;
1318
- }
1319
- return out2;
1320
- }
1321
- function skipString(content, start, quote) {
1322
- const N = content.length;
1323
- let i = start + 1;
1324
- while (i < N) {
1325
- const c = content[i];
1326
- if (c === "\\") {
1327
- i += 2;
1328
- continue;
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);
1329
1216
  }
1330
- if (c === quote) return i + 1;
1331
- i++;
1332
1217
  }
1333
- return N;
1218
+ return [...statics, ...patterns];
1334
1219
  }
1335
- function skipTemplate(content, start) {
1336
- const N = content.length;
1337
- let i = start + 1;
1338
- while (i < N) {
1339
- const c = content[i];
1340
- if (c === "\\") {
1341
- i += 2;
1342
- continue;
1343
- }
1344
- if (c === "`") return i + 1;
1345
- if (c === "$" && content[i + 1] === "{") {
1346
- i += 2;
1347
- let depth = 1;
1348
- while (i < N && depth > 0) {
1349
- const cj = content[i];
1350
- if (cj === '"' || cj === "'") {
1351
- i = skipString(content, i, cj);
1352
- 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
+ });
1353
1252
  }
1354
- if (cj === "`") {
1355
- i = skipTemplate(content, i);
1356
- 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++;
1357
1262
  }
1358
- if (cj === "{") depth++;
1359
- else if (cj === "}") depth--;
1360
- i++;
1361
1263
  }
1362
- continue;
1363
- }
1364
- i++;
1365
- }
1366
- return N;
1367
- }
1368
- function findTagEnd(content, start) {
1369
- const N = content.length;
1370
- let i = start;
1371
- while (i < N) {
1372
- const c = content[i];
1373
- if (c === '"' || c === "'") {
1374
- i = skipString(content, i, c);
1375
- continue;
1376
- }
1377
- if (c === "`") {
1378
- i = skipTemplate(content, i);
1379
- 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;
1380
1272
  }
1381
- if (c === "{") {
1382
- let depth = 1;
1383
- i++;
1384
- while (i < N && depth > 0) {
1385
- const cj = content[i];
1386
- if (cj === '"' || cj === "'") {
1387
- i = skipString(content, i, cj);
1388
- continue;
1389
- }
1390
- if (cj === "`") {
1391
- i = skipTemplate(content, i);
1392
- continue;
1393
- }
1394
- if (cj === "{") depth++;
1395
- else if (cj === "}") depth--;
1396
- i++;
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);
1397
1283
  }
1398
- continue;
1399
1284
  }
1400
- if (c === ">") return i;
1401
- i++;
1402
- }
1403
- return -1;
1285
+ };
1286
+ visit(parsed.program);
1287
+ return { occurrences, dynamicAttrs, unannotatedInteractive, landmarks };
1404
1288
  }
1405
-
1406
- // src/scanner/scan/extract.ts
1407
- var JSDOC_BLOCK = /\/\*\*([\s\S]*?)\*\//g;
1408
- function lineAt(content, index) {
1409
- let line = 1;
1410
- for (let i = 0; i < index && i < content.length; i++) {
1411
- 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) };
1412
1295
  }
1413
- 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;
1414
1306
  }
1415
- function parseJSDoc(block) {
1416
- const lines = block.split("\n").map((l) => l.replace(/^\s*\*\s?/, "").replace(/^\s*\/?\*+/, ""));
1417
- let kind = null;
1418
- let id = null;
1419
- const acceptance = [];
1420
- const desc = [];
1421
- let notFlow = false;
1422
- for (const raw of lines) {
1423
- const line = raw.trim();
1424
- if (!line) continue;
1425
- const uidex = line.match(
1426
- /^@uidex\s+(page|feature|widget)\s+(\S+)(?:\s+-\s+(.+))?/
1427
- );
1428
- if (uidex) {
1429
- kind = uidex[1];
1430
- id = uidex[2];
1431
- 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;
1432
1317
  continue;
1433
1318
  }
1434
- if (/^@uidex:not-flow\b/.test(line)) {
1435
- notFlow = true;
1436
- 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;
1437
1341
  }
1438
- const accept = line.match(/^@acceptance\s+(.+)$/);
1439
- if (accept) {
1440
- acceptance.push(accept[1].trim());
1441
- 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 ?? ""));
1442
1351
  }
1443
- if (line.startsWith("@")) continue;
1444
- desc.push(line);
1445
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;
1446
1374
  return {
1447
- kind,
1448
- id,
1449
- description: desc.join(" ").trim(),
1450
- acceptance,
1451
- 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"
1452
1381
  };
1453
1382
  }
1454
1383
  function extract(files) {
1455
1384
  return files.map((file) => {
1456
- const { exports, diagnostics } = extractUidexExports(file);
1457
- const out2 = {
1458
- file,
1459
- annotations: extractOne(file)
1460
- };
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);
1461
1391
  if (exports.length > 0) out2.metadata = exports;
1462
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;
1463
1397
  return out2;
1464
1398
  });
1465
1399
  }
1466
- function extractOne(file) {
1400
+ function extractOne(file, parsed, out2) {
1467
1401
  const annotations = [];
1468
- const { content, displayPath } = file;
1469
- 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) {
1470
1410
  annotations.push({
1471
1411
  kind: occ.kind,
1472
1412
  id: occ.id,
1473
1413
  file: displayPath,
1474
1414
  line: occ.line,
1475
- ...occ.ancestors.length > 0 ? { ancestors: occ.ancestors } : {}
1415
+ ...occ.ancestors.length > 0 ? { ancestors: occ.ancestors } : {},
1416
+ ...occ.span ? { span: occ.span } : {}
1476
1417
  });
1477
1418
  }
1478
- JSDOC_BLOCK.lastIndex = 0;
1479
- let jm;
1480
- while ((jm = JSDOC_BLOCK.exec(content)) !== null) {
1481
- const parsed = parseJSDoc(jm[1]);
1482
- const line = lineAt(content, jm.index);
1483
- if (parsed.notFlow) {
1484
- annotations.push({ kind: "not-flow", id: "", file: displayPath, line });
1485
- }
1486
- if (parsed.kind && parsed.id) {
1487
- const kind = parsed.kind === "page" ? "page-doc" : parsed.kind === "feature" ? "feature-doc" : "widget-doc";
1488
- annotations.push({
1489
- kind,
1490
- id: parsed.id,
1491
- file: displayPath,
1492
- line,
1493
- description: parsed.description || void 0,
1494
- acceptance: parsed.acceptance.length ? parsed.acceptance : void 0
1495
- });
1496
- } else if (parsed.acceptance.length > 0) {
1497
- annotations.push({
1498
- kind: "orphan-acceptance",
1499
- id: "",
1500
- file: displayPath,
1501
- line,
1502
- acceptance: parsed.acceptance
1503
- });
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;
1504
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
+ });
1505
1452
  }
1506
- return annotations;
1453
+ return out2;
1507
1454
  }
1508
1455
 
1509
1456
  // src/scanner/scan/resolve.ts
1510
- import * as path3 from "path";
1457
+ import * as path4 from "path";
1511
1458
 
1512
1459
  // src/shared/entities/types.ts
1513
1460
  var ENTITY_KINDS = [
@@ -1593,13 +1540,14 @@ function createRegistry() {
1593
1540
  };
1594
1541
  const getPatternsForKind = (kind) => {
1595
1542
  const cached = patternCache.get(kind);
1596
- if (cached !== void 0)
1597
- return cached;
1543
+ if (cached !== void 0) return cached;
1598
1544
  const patterns = [];
1599
1545
  for (const [key, entity] of store[kind]) {
1600
- if (key.endsWith("*")) {
1546
+ if (key.includes("*")) {
1547
+ const segments = key.split("*");
1601
1548
  patterns.push({
1602
- prefix: key.slice(0, -1),
1549
+ segments,
1550
+ staticLength: segments.reduce((n, s) => n + s.length, 0),
1603
1551
  entity
1604
1552
  });
1605
1553
  }
@@ -1610,13 +1558,25 @@ function createRegistry() {
1610
1558
  );
1611
1559
  return patterns;
1612
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
+ };
1613
1573
  const matchPattern = (kind, id) => {
1614
1574
  assertEntityKind(kind);
1615
1575
  const patterns = getPatternsForKind(kind);
1616
1576
  if (patterns.length === 0) return void 0;
1617
1577
  let best;
1618
1578
  for (const entry of patterns) {
1619
- if (id.startsWith(entry.prefix) && (best === void 0 || entry.prefix.length > best.prefix.length)) {
1579
+ if (matchesSegments(entry.segments, id) && (best === void 0 || entry.staticLength > best.staticLength)) {
1620
1580
  best = entry;
1621
1581
  }
1622
1582
  }
@@ -1770,21 +1730,9 @@ function resolveConventions(c) {
1770
1730
  function kebab(str) {
1771
1731
  return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").replace(/[^a-zA-Z0-9-]/g, "").toLowerCase();
1772
1732
  }
1773
- function baseName(file) {
1774
- const b = path3.posix.basename(file);
1775
- return b.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "");
1776
- }
1777
- var LANDMARK_RE = /<(header|nav|main|aside|footer)(\s[^>]*)?>|role=["']region["']/gi;
1778
- function extractLandmarks(file) {
1779
- const out2 = [];
1780
- LANDMARK_RE.lastIndex = 0;
1781
- let m;
1782
- while ((m = LANDMARK_RE.exec(file.content)) !== null) {
1783
- const tag = m[1] ?? "region";
1784
- const line = 1 + file.content.slice(0, m.index).split("\n").length - 1;
1785
- out2.push({ tag, line });
1786
- }
1787
- return out2;
1733
+ function baseName(file) {
1734
+ const b = path4.posix.basename(file);
1735
+ return b.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "");
1788
1736
  }
1789
1737
  function fileMatchesAny(displayPath, patterns) {
1790
1738
  return patterns.some((g) => globToRegExp(g).test(displayPath));
@@ -1846,7 +1794,7 @@ function resolve2(ctx) {
1846
1794
  const routes = conventions.pages === "auto" ? detectRoutes(ctx.extracted.map((e) => e.file)) : [];
1847
1795
  const handledPageFiles = /* @__PURE__ */ new Set();
1848
1796
  for (const route of routes) {
1849
- const routeDir = path3.posix.dirname(route.file);
1797
+ const routeDir = path4.posix.dirname(route.file);
1850
1798
  const wellKnownPath = `${routeDir}/${WELL_KNOWN_FILES.page}`;
1851
1799
  const wellKnownExp = exportFor(wellKnownPath, "page");
1852
1800
  const routeExp = exportFor(route.file, "page");
@@ -1900,7 +1848,7 @@ function resolve2(ctx) {
1900
1848
  const dir = extractFeatureDir(ef.file.displayPath, featureGlob);
1901
1849
  if (!dir) continue;
1902
1850
  conventionalFeatureDirs.add(dir);
1903
- 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;
1904
1852
  if (isWellKnown) wellKnownFeatureFileByDir.set(dir, ef.file.displayPath);
1905
1853
  const exp = exportFor(ef.file.displayPath, "feature");
1906
1854
  if (exp) {
@@ -1937,7 +1885,7 @@ function resolve2(ctx) {
1937
1885
  } else if (allExports.length > 0) {
1938
1886
  exp = allExports[0].exp;
1939
1887
  }
1940
- 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);
1941
1889
  const meta = exp ? buildMetaFromExport(exp) : void 0;
1942
1890
  const feature = {
1943
1891
  kind: "feature",
@@ -2033,8 +1981,8 @@ function resolve2(ctx) {
2033
1981
  }
2034
1982
  if (conventions.regions === "landmarks") {
2035
1983
  for (const ef of ctx.extracted) {
2036
- for (const lm of extractLandmarks(ef.file)) {
2037
- const id = kebab(`${lm.tag}`);
1984
+ for (const lm of ef.landmarks ?? []) {
1985
+ const id = lm.tag;
2038
1986
  if (!registry.get("region", id)) {
2039
1987
  const meta = metaWithComposes("region", id);
2040
1988
  const region = {
@@ -2145,7 +2093,7 @@ function resolve2(ctx) {
2145
2093
  const flowExport = (ff.metadata ?? []).find(
2146
2094
  (m) => m.kind === "flow" && typeof m.id === "string"
2147
2095
  );
2148
- const derived = extractFlowsFromSource(ff.file);
2096
+ const derived = flowsFromFacts(ff);
2149
2097
  if (flowExport && typeof flowExport.id === "string" && derived.length === 1) {
2150
2098
  const base = derived[0];
2151
2099
  const flow = {
@@ -2200,60 +2148,21 @@ function computeScope(displayPath) {
2200
2148
  }
2201
2149
  return null;
2202
2150
  }
2203
- function extractFlowsFromSource(file) {
2204
- const flows = [];
2205
- const source = file.content;
2206
- const describeRe = /test\.describe\(\s*(?:'([^']*)'|"([^"]*)")\s*,\s*\{[^}]*tag:\s*(?:'@uidex:flow'|"@uidex:flow"|\[[^\]]*@uidex:flow[^\]]*\])[^}]*\}/g;
2207
- let m;
2208
- while ((m = describeRe.exec(source)) !== null) {
2209
- const title = m[1] ?? m[2];
2210
- const id = kebab(title);
2211
- const line = 1 + source.slice(0, m.index).split("\n").length - 1;
2212
- const after = source.slice(m.index + m[0].length);
2213
- const arrow = after.match(/=>\s*\{/);
2214
- if (!arrow || arrow.index === void 0) continue;
2215
- const bodyStart = m.index + m[0].length + arrow.index + arrow[0].length;
2216
- let depth = 1;
2217
- let bodyEnd = -1;
2218
- for (let i = bodyStart; i < source.length; i++) {
2219
- if (source[i] === "{") depth++;
2220
- else if (source[i] === "}") {
2221
- depth--;
2222
- if (depth === 0) {
2223
- bodyEnd = i;
2224
- break;
2225
- }
2226
- }
2227
- }
2228
- if (bodyEnd === -1) continue;
2229
- const body = source.slice(bodyStart, bodyEnd);
2230
- const touches = captureUidexIds(body);
2231
- flows.push({
2232
- kind: "flow",
2233
- id,
2234
- loc: { file: file.displayPath, line },
2235
- touches: dedupe(touches.map((t) => t.id)),
2236
- steps: touches.filter((t) => t.action).map((t) => ({ entityId: t.id, action: t.action }))
2237
- });
2238
- }
2239
- return flows;
2240
- }
2241
- function captureUidexIds(body) {
2242
- const out2 = [];
2243
- const re = /uidex\(\s*(?:'([^']+)'|"([^"]+)"|`([^`$]+)`)\s*\)(?:\.(\w+)\s*\()?/g;
2244
- let m;
2245
- while ((m = re.exec(body)) !== null) {
2246
- out2.push({ id: m[1] || m[2] || m[3], action: m[4] });
2247
- }
2248
- 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
+ }));
2249
2159
  }
2250
2160
  function dedupe(arr) {
2251
2161
  return Array.from(new Set(arr));
2252
2162
  }
2253
2163
 
2254
2164
  // src/scanner/scan/audit.ts
2255
- import * as path4 from "path";
2256
- var MARKER_FILENAMES = ["UIDEX_PAGE.md", "UIDEX_FEATURE.md"];
2165
+ import * as path5 from "path";
2257
2166
  function audit(opts) {
2258
2167
  const diagnostics = [];
2259
2168
  const { registry, extracted, files, config } = opts;
@@ -2263,22 +2172,15 @@ function audit(opts) {
2263
2172
  const scopeLeakEnabled = config.audit?.scopeLeak ?? true;
2264
2173
  const coverageEnabled = config.audit?.coverage ?? true;
2265
2174
  if (opts.resolveDiagnostics) diagnostics.push(...opts.resolveDiagnostics);
2266
- if (check) {
2267
- for (const f of files) {
2268
- const base = f.displayPath.split("/").pop() ?? "";
2269
- if (MARKER_FILENAMES.includes(base)) {
2270
- diagnostics.push({
2271
- code: "marker-md-ignored",
2272
- severity: "warning",
2273
- message: `Marker file "${base}" is ignored in v2; migrate to \`export const uidex\``,
2274
- file: f.displayPath
2275
- });
2276
- }
2277
- }
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);
2278
2180
  }
2279
2181
  if (check && opts.generated !== void 0) {
2280
2182
  const outRel = opts.outputPath ?? config.output;
2281
- const fresh = normalizeLineEndings(opts.generated);
2183
+ const fresh = normalizeForCheck(opts.generated);
2282
2184
  if (opts.existingOnDisk === null || opts.existingOnDisk === void 0) {
2283
2185
  diagnostics.push({
2284
2186
  code: "gen-missing",
@@ -2288,7 +2190,7 @@ function audit(opts) {
2288
2190
  hint: "Run `uidex scan` (without --check) to regenerate"
2289
2191
  });
2290
2192
  } else {
2291
- const existing = normalizeLineEndings(opts.existingOnDisk);
2193
+ const existing = normalizeForCheck(opts.existingOnDisk);
2292
2194
  if (existing !== fresh) {
2293
2195
  const changed = diffEntities(existing, opts.generated, registry);
2294
2196
  const summary2 = formatChangedSummary(changed);
@@ -2302,22 +2204,6 @@ function audit(opts) {
2302
2204
  }
2303
2205
  }
2304
2206
  }
2305
- if (lint) {
2306
- for (const ef of extracted) {
2307
- for (const a of ef.annotations) {
2308
- const migration = legacyJsdocMigration(a);
2309
- if (!migration) continue;
2310
- diagnostics.push({
2311
- code: "legacy-jsdoc",
2312
- severity: "warning",
2313
- message: migration.message,
2314
- file: a.file,
2315
- line: a.line,
2316
- hint: migration.hint
2317
- });
2318
- }
2319
- }
2320
- }
2321
2207
  if (lint && acceptanceEnabled) {
2322
2208
  for (const kind of ["widget", "feature", "page"]) {
2323
2209
  for (const e of registry.list(kind)) {
@@ -2363,8 +2249,8 @@ function audit(opts) {
2363
2249
  if (typeof m.id !== "string") continue;
2364
2250
  const filePath = ef.file.displayPath;
2365
2251
  const wellKnownName = WELL_KNOWN_FILES[m.kind];
2366
- if (path4.posix.basename(filePath) === wellKnownName) continue;
2367
- const dir = path4.posix.dirname(filePath);
2252
+ if (path5.posix.basename(filePath) === wellKnownName) continue;
2253
+ const dir = path5.posix.dirname(filePath);
2368
2254
  const wellKnownPath = dir === "." ? wellKnownName : `${dir}/${wellKnownName}`;
2369
2255
  if (scannedPaths.has(wellKnownPath)) continue;
2370
2256
  const kindLabel = m.kind === "page" ? "Page" : "Feature";
@@ -2381,53 +2267,55 @@ function audit(opts) {
2381
2267
  }
2382
2268
  }
2383
2269
  if (lint) {
2384
- const dynamicAttrRe = /\bdata-uidex(?:-(region|widget|primitive))?\s*=\s*\{/g;
2385
- const templateWithPrefixRe = /\bdata-uidex(?:-(region|widget|primitive))?\s*=\s*\{\s*`[^`$]+\$\{/g;
2386
- for (const f of files) {
2387
- const templatePrefixPositions = /* @__PURE__ */ new Set();
2388
- templateWithPrefixRe.lastIndex = 0;
2389
- let tm;
2390
- while ((tm = templateWithPrefixRe.exec(f.content)) !== null) {
2391
- templatePrefixPositions.add(tm.index);
2392
- }
2393
- let m;
2394
- dynamicAttrRe.lastIndex = 0;
2395
- while ((m = dynamicAttrRe.exec(f.content)) !== null) {
2396
- if (templatePrefixPositions.has(m.index)) continue;
2397
- const kind = m[1] ?? "element";
2398
- let line = 1;
2399
- for (let i = 0; i < m.index; i++) if (f.content[i] === "\n") line++;
2400
- const attrName = m[1] ? `data-uidex-${m[1]}` : "data-uidex";
2270
+ for (const ef of extracted) {
2271
+ for (const fact of ef.dynamicAttrs ?? []) {
2401
2272
  diagnostics.push({
2402
2273
  code: "dynamic-attr",
2403
2274
  severity: "warning",
2404
- message: `\`${attrName}={\u2026}\` uses a dynamic expression; the scanner cannot resolve the ${kind} id statically`,
2405
- file: f.displayPath,
2406
- line,
2407
- 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)
2408
2279
  });
2409
2280
  }
2410
2281
  }
2411
2282
  }
2412
2283
  if (lint) {
2413
- for (const f of files) {
2414
- const tagRe = /<(button|a|input|select|textarea)(?=[\s/>])/g;
2415
- let m;
2416
- while ((m = tagRe.exec(f.content)) !== null) {
2417
- const afterTag = m.index + m[0].length;
2418
- const closeIdx = findJsxOpeningEnd(f.content, afterTag);
2419
- if (closeIdx === -1) continue;
2420
- const attrs = f.content.slice(afterTag, closeIdx);
2421
- if (attrs.includes("data-uidex")) continue;
2422
- let line = 1;
2423
- for (let i = 0; i < m.index; i++) if (f.content[i] === "\n") line++;
2424
- diagnostics.push({
2425
- code: "missing-element-annotation",
2426
- severity: "info",
2427
- message: `Interactive <${m[1].toLowerCase()}> without data-uidex annotation`,
2428
- file: f.displayPath,
2429
- line
2430
- });
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
+ }
2431
2319
  }
2432
2320
  }
2433
2321
  }
@@ -2444,12 +2332,11 @@ function audit(opts) {
2444
2332
  }
2445
2333
  }
2446
2334
  }
2447
- for (const f of files) {
2448
- const importRe = /import\s+(?:[^'"]+)\s+from\s+['"]([^'"]+)['"]/g;
2449
- let m;
2450
- while ((m = importRe.exec(f.content)) !== null) {
2451
- const spec = m[1];
2452
- 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() ?? "";
2453
2340
  const primitive = byName.get(
2454
2341
  baseName2.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
2455
2342
  );
@@ -2457,25 +2344,37 @@ function audit(opts) {
2457
2344
  const scope = primitive.scopes?.[0];
2458
2345
  if (!scope) continue;
2459
2346
  const [kind, id] = scope.split(":");
2460
- const importerSegments = f.displayPath.split("/");
2347
+ const importerSegments = displayPath.split("/");
2461
2348
  if (importerSegments.includes(id) && importerSegments.includes(kind + "s")) {
2462
2349
  continue;
2463
2350
  }
2464
2351
  if (kind === "feature" && importerSegments.includes(id)) continue;
2465
- if (kind === "feature" && declaredFeatures.get(f.displayPath)?.has(id)) {
2352
+ if (kind === "feature" && declaredFeatures.get(displayPath)?.has(id)) {
2466
2353
  continue;
2467
2354
  }
2468
2355
  diagnostics.push({
2469
2356
  code: "scope-leak",
2470
2357
  severity: "warning",
2471
- message: `Primitive "${primitive.id}" is scoped to ${scope} but is imported from ${f.displayPath}`,
2472
- file: f.displayPath
2358
+ message: `Primitive "${primitive.id}" is scoped to ${scope} but is imported from ${displayPath}`,
2359
+ file: displayPath,
2360
+ line: imp.line
2473
2361
  });
2474
2362
  }
2475
2363
  }
2476
2364
  }
2477
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
+ }
2478
2376
  for (const flow of registry.list("flow")) {
2377
+ const callLines = factsByLoc.get(`${flow.loc.file}:${flow.loc.line}`);
2479
2378
  for (const touchedId of flow.touches) {
2480
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);
2481
2380
  if (!found) {
@@ -2484,54 +2383,131 @@ function audit(opts) {
2484
2383
  severity: "warning",
2485
2384
  message: `Flow "${flow.id}" references unknown entity "${touchedId}"`,
2486
2385
  file: flow.loc.file,
2487
- 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)."
2488
2437
  });
2489
2438
  }
2490
2439
  }
2491
2440
  }
2492
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
+ }
2493
2466
  const summary = {
2494
2467
  errors: diagnostics.filter((d) => d.severity === "error").length,
2495
2468
  warnings: diagnostics.filter((d) => d.severity === "warning").length
2496
2469
  };
2497
2470
  return { diagnostics, summary };
2498
2471
  }
2499
- function legacyJsdocMigration(a) {
2500
- const quote = (s) => JSON.stringify(s);
2501
- const arr = (xs) => xs && xs.length > 0 ? `[${xs.map(quote).join(", ")}]` : "";
2502
- const entityHint = (kind) => {
2503
- const uidexKind = kind.charAt(0).toUpperCase() + kind.slice(1);
2504
- const parts = [`${kind}: ${quote(a.id)}`];
2505
- if (a.acceptance?.length) parts.push(`acceptance: ${arr(a.acceptance)}`);
2506
- return {
2507
- message: `Legacy JSDoc tag \`@uidex ${kind} ${a.id}\` is no longer recognised; migrate to \`export const uidex\``,
2508
- hint: `Replace with: export const uidex = { ${parts.join(", ")} } as const satisfies Uidex.${uidexKind}`
2509
- };
2510
- };
2511
- switch (a.kind) {
2512
- case "page-doc":
2513
- return entityHint("page");
2514
- case "feature-doc":
2515
- return entityHint("feature");
2516
- case "widget-doc":
2517
- return entityHint("widget");
2518
- case "not-flow":
2519
- return {
2520
- message: `Legacy JSDoc tag \`@uidex:not-flow\` is no longer recognised; migrate to \`export const uidex\``,
2521
- hint: `Replace with: export const uidex = { notFlow: true } as const satisfies Uidex.NotFlow`
2522
- };
2523
- case "orphan-acceptance":
2524
- return {
2525
- message: `Legacy JSDoc tag \`@acceptance\` is no longer recognised; migrate to the \`acceptance\` field on \`export const uidex\``,
2526
- hint: `Replace with: export const uidex = { /* kind */, acceptance: ${arr(a.acceptance)} } as const`
2527
- };
2528
- default:
2529
- 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;
2530
2500
  }
2531
2501
  }
2532
2502
  function normalizeLineEndings(s) {
2533
2503
  return s.replace(/\r\n/g, "\n");
2534
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
+ }
2535
2511
  function formatChangedSummary(change) {
2536
2512
  const parts = [];
2537
2513
  const fmt = (kind, names) => {
@@ -2630,62 +2606,11 @@ function extractEntitiesArray(source) {
2630
2606
  }
2631
2607
  return null;
2632
2608
  }
2633
- function findJsxOpeningEnd(src, start) {
2634
- let i = start;
2635
- while (i < src.length) {
2636
- const ch = src[i];
2637
- if (ch === ">" || ch === "/" && src[i + 1] === ">") return i;
2638
- if (ch === '"' || ch === "'" || ch === "`") {
2639
- i = skipString2(src, i);
2640
- } else if (ch === "{") {
2641
- i = skipBraces(src, i);
2642
- } else {
2643
- i++;
2644
- }
2645
- }
2646
- return -1;
2647
- }
2648
- function skipString2(src, start) {
2649
- const quote = src[start];
2650
- let i = start + 1;
2651
- while (i < src.length) {
2652
- if (src[i] === "\\" && quote !== "`") {
2653
- i += 2;
2654
- continue;
2655
- }
2656
- if (quote === "`" && src[i] === "$" && src[i + 1] === "{") {
2657
- i = skipBraces(src, i + 1);
2658
- continue;
2659
- }
2660
- if (src[i] === quote) return i + 1;
2661
- i++;
2662
- }
2663
- return i;
2664
- }
2665
- function skipBraces(src, start) {
2666
- let depth = 1;
2667
- let i = start + 1;
2668
- while (i < src.length && depth > 0) {
2669
- const ch = src[i];
2670
- if (ch === "{") {
2671
- depth++;
2672
- i++;
2673
- } else if (ch === "}") {
2674
- depth--;
2675
- i++;
2676
- } else if (ch === '"' || ch === "'" || ch === "`") {
2677
- i = skipString2(src, i);
2678
- } else {
2679
- i++;
2680
- }
2681
- }
2682
- return i;
2683
- }
2684
2609
  function dynamicAttrHint(kind) {
2685
2610
  if (kind === "region") {
2686
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`;
2687
2612
  }
2688
- 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)`;
2689
2614
  }
2690
2615
  function stableStringify(value) {
2691
2616
  return JSON.stringify(value, stableReplacer);
@@ -2718,9 +2643,7 @@ function replacerSorted(_key, value) {
2718
2643
  }
2719
2644
  return value;
2720
2645
  }
2721
- function emitIdUnion(name, ids, typeMode) {
2722
- if (typeMode === "loose") return `export type ${name} = string
2723
- `;
2646
+ function emitIdUnion(name, ids) {
2724
2647
  if (ids.length === 0) return `export type ${name} = never
2725
2648
  `;
2726
2649
  const sorted = [...ids].sort();
@@ -2730,12 +2653,7 @@ ${body}
2730
2653
  `;
2731
2654
  }
2732
2655
  function emit(opts) {
2733
- const {
2734
- registry,
2735
- gitContext,
2736
- uidexImport = "uidex",
2737
- typeMode = "strict"
2738
- } = opts;
2656
+ const { registry, gitContext, uidexImport = "uidex" } = opts;
2739
2657
  const routes = [...registry.list("route")].sort(
2740
2658
  (a, b) => a.path.localeCompare(b.path)
2741
2659
  );
@@ -2758,57 +2676,49 @@ function emit(opts) {
2758
2676
  lines.push(
2759
2677
  emitIdUnion(
2760
2678
  "PageId",
2761
- pages.map((e) => e.id),
2762
- typeMode
2679
+ pages.map((e) => e.id)
2763
2680
  )
2764
2681
  );
2765
2682
  lines.push(
2766
2683
  emitIdUnion(
2767
2684
  "FeatureId",
2768
- features.map((e) => e.id),
2769
- typeMode
2685
+ features.map((e) => e.id)
2770
2686
  )
2771
2687
  );
2772
2688
  lines.push(
2773
2689
  emitIdUnion(
2774
2690
  "WidgetId",
2775
- widgets.map((e) => e.id),
2776
- typeMode
2691
+ widgets.map((e) => e.id)
2777
2692
  )
2778
2693
  );
2779
2694
  lines.push(
2780
2695
  emitIdUnion(
2781
2696
  "RegionId",
2782
- regions.map((e) => e.id),
2783
- typeMode
2697
+ regions.map((e) => e.id)
2784
2698
  )
2785
2699
  );
2786
2700
  lines.push(
2787
2701
  emitIdUnion(
2788
2702
  "ElementId",
2789
- elements.map((e) => e.id),
2790
- typeMode
2703
+ elements.map((e) => e.id)
2791
2704
  )
2792
2705
  );
2793
2706
  lines.push(
2794
2707
  emitIdUnion(
2795
2708
  "PrimitiveId",
2796
- primitives.map((e) => e.id),
2797
- typeMode
2709
+ primitives.map((e) => e.id)
2798
2710
  )
2799
2711
  );
2800
2712
  lines.push(
2801
2713
  emitIdUnion(
2802
2714
  "FlowId",
2803
- flows.map((e) => e.id),
2804
- typeMode
2715
+ flows.map((e) => e.id)
2805
2716
  )
2806
2717
  );
2807
2718
  lines.push(
2808
2719
  emitIdUnion(
2809
2720
  "RouteId",
2810
- routes.map((e) => e.path),
2811
- typeMode
2721
+ routes.map((e) => e.path)
2812
2722
  )
2813
2723
  );
2814
2724
  lines.push("");
@@ -2921,22 +2831,33 @@ function parseGitHubRef(ref) {
2921
2831
 
2922
2832
  // src/scanner/scan/scaffold.ts
2923
2833
  import * as fs3 from "fs";
2924
- import * as path5 from "path";
2834
+ import * as path6 from "path";
2925
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) {
2926
2846
  const {
2927
2847
  registry,
2928
- widgetId,
2848
+ kind,
2849
+ id,
2929
2850
  outDir,
2930
2851
  force = false,
2931
2852
  fixtureImport = "./fixtures"
2932
2853
  } = opts;
2933
- const widget = registry.get("widget", widgetId);
2934
- if (!widget) {
2935
- 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`);
2936
2857
  }
2937
- const criteria = widget.meta?.acceptance ?? [];
2938
- const filename = `widget-${widgetId}.spec.ts`;
2939
- 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);
2940
2861
  if (fs3.existsSync(outputPath) && !force) {
2941
2862
  return {
2942
2863
  outputPath,
@@ -2945,15 +2866,14 @@ function scaffoldWidgetSpec(opts) {
2945
2866
  reason: `spec already exists at ${outputPath}; pass --force to overwrite`
2946
2867
  };
2947
2868
  }
2948
- const content = renderSpec({
2949
- widgetId,
2950
- criteria,
2951
- fixtureImport
2952
- });
2953
- fs3.mkdirSync(path5.dirname(outputPath), { recursive: true });
2869
+ const content = renderSpec({ id, criteria, fixtureImport });
2870
+ fs3.mkdirSync(path6.dirname(outputPath), { recursive: true });
2954
2871
  fs3.writeFileSync(outputPath, content, "utf8");
2955
2872
  return { outputPath, written: true, skipped: false };
2956
2873
  }
2874
+ function capitalize(s) {
2875
+ return s.charAt(0).toUpperCase() + s.slice(1);
2876
+ }
2957
2877
  function renderSpec(args) {
2958
2878
  const lines = [];
2959
2879
  lines.push(
@@ -2961,7 +2881,7 @@ function renderSpec(args) {
2961
2881
  );
2962
2882
  lines.push("");
2963
2883
  lines.push(
2964
- `test.describe(${JSON.stringify(args.widgetId)}, { tag: "@uidex:flow" }, () => {`
2884
+ `test.describe(${JSON.stringify(args.id)}, { tag: "@uidex:flow" }, () => {`
2965
2885
  );
2966
2886
  if (args.criteria.length === 0) {
2967
2887
  lines.push(` test("TODO: add acceptance criteria", async () => {`);
@@ -2984,7 +2904,7 @@ function renderSpec(args) {
2984
2904
 
2985
2905
  // src/scanner/scan/pipeline.ts
2986
2906
  import * as fs4 from "fs";
2987
- import * as path6 from "path";
2907
+ import * as path7 from "path";
2988
2908
  function runScan(opts = {}) {
2989
2909
  const cwd = opts.cwd ?? process.cwd();
2990
2910
  const configs = opts.configs ?? discover({ cwd });
@@ -3013,10 +2933,9 @@ function runOne(dc, opts) {
3013
2933
  const gitContext = resolveGitContext({ cwd: configDir });
3014
2934
  const generated = emit({
3015
2935
  registry: resolved.registry,
3016
- gitContext,
3017
- typeMode: config.typeMode
2936
+ gitContext
3018
2937
  });
3019
- const outputPath = path6.resolve(configDir, config.output);
2938
+ const outputPath = path7.resolve(configDir, config.output);
3020
2939
  const outputRel = config.output;
3021
2940
  let existingOnDisk = null;
3022
2941
  if (opts.check) {
@@ -3026,12 +2945,16 @@ function runOne(dc, opts) {
3026
2945
  existingOnDisk = null;
3027
2946
  }
3028
2947
  }
2948
+ const hasExtractDiagnostics = [...extracted, ...extractedFlows].some(
2949
+ (ef) => (ef.diagnostics?.length ?? 0) > 0
2950
+ );
3029
2951
  let auditResult;
3030
- if (opts.check || opts.lint || resolved.diagnostics.length > 0) {
2952
+ if (opts.check || opts.lint || resolved.diagnostics.length > 0 || hasExtractDiagnostics) {
3031
2953
  auditResult = audit({
3032
2954
  registry: resolved.registry,
3033
2955
  extracted,
3034
2956
  files: sourceFiles,
2957
+ flowExtracted: extractedFlows,
3035
2958
  config,
3036
2959
  check: opts.check,
3037
2960
  lint: opts.lint,
@@ -3052,34 +2975,281 @@ function runOne(dc, opts) {
3052
2975
  };
3053
2976
  }
3054
2977
  function writeScanResult(result) {
3055
- fs4.mkdirSync(path6.dirname(result.outputPath), { recursive: true });
2978
+ fs4.mkdirSync(path7.dirname(result.outputPath), { recursive: true });
3056
2979
  fs4.writeFileSync(result.outputPath, result.generated, "utf8");
3057
2980
  }
3058
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
+
3059
3229
  // src/scanner/scan/cli.ts
3060
- import * as fs7 from "fs";
3061
- import * as path9 from "path";
3230
+ import * as fs8 from "fs";
3231
+ import * as path11 from "path";
3062
3232
 
3063
3233
  // src/scanner/scan/ai/index.ts
3064
3234
  import * as p from "@clack/prompts";
3065
3235
 
3066
3236
  // src/scanner/scan/ai/providers/claude.ts
3067
- import * as fs6 from "fs";
3068
- import * as path8 from "path";
3237
+ import * as fs7 from "fs";
3238
+ import * as path10 from "path";
3069
3239
 
3070
3240
  // src/scanner/scan/ai/templates.ts
3071
- import * as fs5 from "fs";
3072
- import * as path7 from "path";
3241
+ import * as fs6 from "fs";
3242
+ import * as path9 from "path";
3073
3243
  function templatePath(rel) {
3074
3244
  const candidates = [
3075
- path7.resolve(__dirname, "../../templates", rel),
3076
- // dist/cli/cli.cjs → ../../templates
3077
- path7.resolve(__dirname, "../../../templates", rel)
3078
- // 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
3079
3249
  ];
3080
3250
  for (const c of candidates) {
3081
3251
  try {
3082
- fs5.accessSync(c, fs5.constants.R_OK);
3252
+ fs6.accessSync(c, fs6.constants.R_OK);
3083
3253
  return c;
3084
3254
  } catch {
3085
3255
  continue;
@@ -3091,24 +3261,39 @@ function templatePath(rel) {
3091
3261
  );
3092
3262
  }
3093
3263
  function readTemplate(rel) {
3094
- return fs5.readFileSync(templatePath(rel), "utf8");
3264
+ return fs6.readFileSync(templatePath(rel), "utf8");
3095
3265
  }
3096
3266
 
3097
3267
  // src/scanner/scan/ai/providers/claude.ts
3098
- var CLAUDE_FILES = [
3099
- { dest: ".claude/rules/uidex.md", template: "claude/rules.md" },
3100
- { dest: ".claude/commands/uidex/audit.md", template: "claude/audit.md" },
3101
- { 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"
3102
3287
  ];
3103
3288
  var claudeProvider = {
3104
3289
  id: "claude",
3105
3290
  label: "Claude Code",
3106
- 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.",
3107
3292
  async install({ cwd, force }) {
3108
3293
  const changes = [];
3109
- for (const file of CLAUDE_FILES) {
3110
- const dest = path8.join(cwd, file.dest);
3111
- 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);
3112
3297
  if (exists && !force) {
3113
3298
  changes.push({
3114
3299
  path: file.dest,
@@ -3117,36 +3302,56 @@ var claudeProvider = {
3117
3302
  });
3118
3303
  continue;
3119
3304
  }
3120
- fs6.mkdirSync(path8.dirname(dest), { recursive: true });
3121
- fs6.writeFileSync(dest, readTemplate(file.template));
3305
+ fs7.mkdirSync(path10.dirname(dest), { recursive: true });
3306
+ fs7.writeFileSync(dest, readTemplate(file.template));
3122
3307
  changes.push({
3123
3308
  path: file.dest,
3124
3309
  action: exists ? "overwritten" : "created"
3125
3310
  });
3126
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"));
3127
3322
  return { changes };
3128
3323
  },
3129
3324
  async uninstall({ cwd }) {
3130
3325
  const changes = [];
3131
- for (const file of CLAUDE_FILES) {
3132
- const dest = path8.join(cwd, file.dest);
3133
- if (!fs6.existsSync(dest)) {
3326
+ for (const file of SKILL_FILES) {
3327
+ const dest = path10.join(cwd, file.dest);
3328
+ if (!fs7.existsSync(dest)) {
3134
3329
  changes.push({ path: file.dest, action: "skipped", reason: "absent" });
3135
3330
  continue;
3136
3331
  }
3137
- fs6.unlinkSync(dest);
3332
+ fs7.unlinkSync(dest);
3138
3333
  changes.push({ path: file.dest, action: "removed" });
3139
3334
  }
3140
- cleanupEmpty(path8.join(cwd, ".claude/commands/uidex"));
3141
- cleanupEmpty(path8.join(cwd, ".claude/commands"));
3142
- 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"));
3143
3348
  return { changes };
3144
3349
  }
3145
3350
  };
3146
3351
  function cleanupEmpty(dir) {
3147
3352
  try {
3148
- const entries = fs6.readdirSync(dir);
3149
- if (entries.length === 0) fs6.rmdirSync(dir);
3353
+ const entries = fs7.readdirSync(dir);
3354
+ if (entries.length === 0) fs7.rmdirSync(dir);
3150
3355
  } catch {
3151
3356
  }
3152
3357
  }
@@ -3313,6 +3518,8 @@ async function run(opts) {
3313
3518
  return runScanCommand(cwd, flags, writer);
3314
3519
  case "scaffold":
3315
3520
  return runScaffold(cwd, positional.slice(1), flags, writer);
3521
+ case "rename":
3522
+ return runRename(cwd, positional.slice(1), flags, writer);
3316
3523
  case "ai": {
3317
3524
  const result = await runAiCommand({
3318
3525
  cwd,
@@ -3339,7 +3546,8 @@ function helpText2() {
3339
3546
  "Commands:",
3340
3547
  " init Create a .uidex.json",
3341
3548
  " scan [flags] Run the scanner pipeline",
3342
- " 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)",
3343
3551
  " ai <install|uninstall|providers> Manage AI assistant integrations",
3344
3552
  " api <METHOD> <PATH> Call the uidex API",
3345
3553
  " api --list Show available API routes",
@@ -3348,16 +3556,17 @@ function helpText2() {
3348
3556
  "",
3349
3557
  "Flags:",
3350
3558
  " --check Verify the on-disk gen file matches a fresh scan; exit non-zero on drift (read-only)",
3351
- " --lint Run lint diagnostics (missing annotations, scope leak, legacy JSDoc)",
3559
+ " --lint Run lint diagnostics (missing annotations, scope leak, duplicate ids, coverage)",
3352
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",
3353
3562
  " --json Emit JSON diagnostics on stdout",
3354
3563
  " --force (scaffold) overwrite existing spec",
3355
3564
  ""
3356
3565
  ].join("\n");
3357
3566
  }
3358
3567
  function runInit(cwd, w) {
3359
- const configPath = path9.join(cwd, CONFIG_FILENAME);
3360
- if (fs7.existsSync(configPath)) {
3568
+ const configPath = path11.join(cwd, CONFIG_FILENAME);
3569
+ if (fs8.existsSync(configPath)) {
3361
3570
  w.err(`.uidex.json already exists at ${configPath}`);
3362
3571
  return w.result(1);
3363
3572
  }
@@ -3366,16 +3575,16 @@ function runInit(cwd, w) {
3366
3575
  sources: [{ rootDir: "src" }],
3367
3576
  output: "src/uidex.gen.ts"
3368
3577
  };
3369
- fs7.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
3578
+ fs8.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
3370
3579
  w.out(`Created ${configPath}`);
3371
- const gitignorePath = path9.join(cwd, ".gitignore");
3580
+ const gitignorePath = path11.join(cwd, ".gitignore");
3372
3581
  const entry = "*.gen.ts";
3373
- if (fs7.existsSync(gitignorePath)) {
3374
- const existing = fs7.readFileSync(gitignorePath, "utf8");
3582
+ if (fs8.existsSync(gitignorePath)) {
3583
+ const existing = fs8.readFileSync(gitignorePath, "utf8");
3375
3584
  const hasEntry = existing.split("\n").some((line) => line.trim() === entry);
3376
3585
  if (!hasEntry) {
3377
3586
  const needsNewline = existing.length > 0 && !existing.endsWith("\n");
3378
- fs7.appendFileSync(
3587
+ fs8.appendFileSync(
3379
3588
  gitignorePath,
3380
3589
  `${needsNewline ? "\n" : ""}${entry}
3381
3590
  `,
@@ -3384,21 +3593,33 @@ function runInit(cwd, w) {
3384
3593
  w.out(`Appended ${entry} to ${gitignorePath}`);
3385
3594
  }
3386
3595
  } else {
3387
- fs7.writeFileSync(gitignorePath, `${entry}
3596
+ fs8.writeFileSync(gitignorePath, `${entry}
3388
3597
  `, "utf8");
3389
3598
  w.out(`Created ${gitignorePath} with ${entry}`);
3390
3599
  }
3391
3600
  return w.result(0);
3392
3601
  }
3393
3602
  function runScanCommand(cwd, flags, w) {
3394
- const check = Boolean(flags.check || flags.audit);
3395
- 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);
3396
3606
  const asJson = Boolean(flags.json);
3397
- const configs = discover({ cwd });
3607
+ let configs = discover({ cwd });
3398
3608
  if (configs.length === 0) {
3399
3609
  w.err(`No ${CONFIG_FILENAME} found under ${cwd}`);
3400
3610
  return w.result(1);
3401
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
+ }
3402
3623
  const results = runScan({ cwd, check, lint, configs });
3403
3624
  if (!check) {
3404
3625
  for (const r of results) writeScanResult(r);
@@ -3413,9 +3634,21 @@ function runScanCommand(cwd, flags, w) {
3413
3634
  { errors: 0, warnings: 0 }
3414
3635
  );
3415
3636
  if (asJson) {
3416
- const out2 = { diagnostics: allDiagnostics, summary };
3637
+ const out2 = {
3638
+ diagnostics: allDiagnostics.map(jsonDiagnostic),
3639
+ summary,
3640
+ ...fix ? { fixed, fixSkipped } : {}
3641
+ };
3417
3642
  w.out(JSON.stringify(out2, null, 2));
3418
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
+ }
3419
3652
  for (const r of results) {
3420
3653
  if (check) {
3421
3654
  w.out(`Checked ${r.outputPath}`);
@@ -3425,7 +3658,10 @@ function runScanCommand(cwd, flags, w) {
3425
3658
  for (const d of r.audit?.diagnostics ?? []) {
3426
3659
  const loc = d.file ? `${d.file}${d.line ? `:${d.line}` : ""}` : "";
3427
3660
  const stream = d.severity === "error" ? w.err : w.out;
3428
- 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
+ );
3429
3665
  if (d.hint) stream(` hint: ${d.hint}`);
3430
3666
  }
3431
3667
  }
@@ -3436,20 +3672,27 @@ function runScanCommand(cwd, flags, w) {
3436
3672
  const exit = summary.errors > 0 ? 1 : 0;
3437
3673
  return w.result(exit);
3438
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"]);
3439
3680
  function runScaffold(cwd, args, flags, w) {
3440
3681
  const [kind, id] = args;
3441
- if (kind !== "widget" || !id) {
3442
- 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]");
3443
3684
  return w.result(1);
3444
3685
  }
3686
+ const scaffoldKind = kind;
3445
3687
  const results = runScan({ cwd });
3446
3688
  for (const r of results) {
3447
- const widget = r.registry.get("widget", id);
3448
- if (!widget) continue;
3449
- const outDir = path9.resolve(r.configDir, "e2e");
3450
- 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({
3451
3693
  registry: r.registry,
3452
- widgetId: id,
3694
+ kind: scaffoldKind,
3695
+ id,
3453
3696
  outDir,
3454
3697
  force: Boolean(flags.force)
3455
3698
  });
@@ -3460,9 +3703,43 @@ function runScaffold(cwd, args, flags, w) {
3460
3703
  w.out(`Wrote ${result.outputPath}`);
3461
3704
  return w.result(0);
3462
3705
  }
3463
- 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
+ );
3464
3709
  return w.result(1);
3465
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
+ }
3466
3743
  function createWriter() {
3467
3744
  let stdout = "";
3468
3745
  let stderr = "";
@@ -3482,7 +3759,7 @@ export {
3482
3759
  CONFIG_FILENAME,
3483
3760
  ConfigError,
3484
3761
  DEFAULT_CONVENTIONS,
3485
- DEFAULT_TYPE_MODE,
3762
+ applyFixes,
3486
3763
  audit,
3487
3764
  detectRoutes,
3488
3765
  discover,
@@ -3492,10 +3769,12 @@ export {
3492
3769
  globToRegExp,
3493
3770
  parseConfig,
3494
3771
  pathToId,
3772
+ renameEntity,
3495
3773
  resolve2 as resolve,
3496
3774
  resolveGitContext,
3497
3775
  run as runCli,
3498
3776
  runScan,
3777
+ scaffoldSpec,
3499
3778
  scaffoldWidgetSpec,
3500
3779
  validateConfig,
3501
3780
  walk,