uidex 0.6.0 → 0.8.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 (57) hide show
  1. package/README.md +3 -3
  2. package/dist/cli/cli.cjs +2502 -2253
  3. package/dist/cli/cli.cjs.map +1 -1
  4. package/dist/headless/index.cjs +86 -703
  5. package/dist/headless/index.cjs.map +1 -1
  6. package/dist/headless/index.d.cts +46 -22
  7. package/dist/headless/index.d.ts +46 -22
  8. package/dist/headless/index.js +86 -707
  9. package/dist/headless/index.js.map +1 -1
  10. package/dist/index.cjs +712 -4149
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +82 -366
  13. package/dist/index.d.ts +82 -366
  14. package/dist/index.js +708 -4156
  15. package/dist/index.js.map +1 -1
  16. package/dist/playwright/index.cjs +175 -0
  17. package/dist/playwright/index.cjs.map +1 -1
  18. package/dist/playwright/index.d.cts +2 -0
  19. package/dist/playwright/index.d.ts +2 -0
  20. package/dist/playwright/index.js +167 -0
  21. package/dist/playwright/index.js.map +1 -1
  22. package/dist/playwright/states-reporter.cjs +123 -0
  23. package/dist/playwright/states-reporter.cjs.map +1 -0
  24. package/dist/playwright/states-reporter.d.cts +46 -0
  25. package/dist/playwright/states-reporter.d.ts +46 -0
  26. package/dist/playwright/states-reporter.js +88 -0
  27. package/dist/playwright/states-reporter.js.map +1 -0
  28. package/dist/playwright/states.cjs +118 -0
  29. package/dist/playwright/states.cjs.map +1 -0
  30. package/dist/playwright/states.d.cts +120 -0
  31. package/dist/playwright/states.d.ts +120 -0
  32. package/dist/playwright/states.js +88 -0
  33. package/dist/playwright/states.js.map +1 -0
  34. package/dist/react/index.cjs +750 -4113
  35. package/dist/react/index.cjs.map +1 -1
  36. package/dist/react/index.d.cts +78 -278
  37. package/dist/react/index.d.ts +78 -278
  38. package/dist/react/index.js +748 -4135
  39. package/dist/react/index.js.map +1 -1
  40. package/dist/scan/index.cjs +2694 -1541
  41. package/dist/scan/index.cjs.map +1 -1
  42. package/dist/scan/index.d.cts +482 -19
  43. package/dist/scan/index.d.ts +482 -19
  44. package/dist/scan/index.js +2682 -1540
  45. package/dist/scan/index.js.map +1 -1
  46. package/package.json +14 -17
  47. package/templates/claude/SKILL.md +71 -0
  48. package/templates/claude/references/audit.md +43 -0
  49. package/templates/claude/{rules.md → references/conventions.md} +25 -28
  50. package/dist/cloud/index.cjs +0 -472
  51. package/dist/cloud/index.cjs.map +0 -1
  52. package/dist/cloud/index.d.cts +0 -82
  53. package/dist/cloud/index.d.ts +0 -82
  54. package/dist/cloud/index.js +0 -445
  55. package/dist/cloud/index.js.map +0 -1
  56. package/templates/claude/audit.md +0 -43
  57. /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,11 @@ var ALLOWED_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
28
27
  "exclude",
29
28
  "output",
30
29
  "flows",
31
- "typeMode",
32
30
  "audit",
33
- "conventions"
31
+ "conventions",
32
+ "statesManifest",
33
+ "coverageBaseline"
34
34
  ]);
35
- var ALLOWED_TYPE_MODES = /* @__PURE__ */ new Set(["strict", "loose"]);
36
35
  var ALLOWED_SOURCE_KEYS = /* @__PURE__ */ new Set(["rootDir", "include", "exclude", "prefix"]);
37
36
  var ALLOWED_CONVENTIONS_KEYS = /* @__PURE__ */ new Set([
38
37
  "primitives",
@@ -41,18 +40,25 @@ var ALLOWED_CONVENTIONS_KEYS = /* @__PURE__ */ new Set([
41
40
  "flows",
42
41
  "regions"
43
42
  ]);
44
- var ALLOWED_AUDIT_KEYS = /* @__PURE__ */ new Set(["scopeLeak", "coverage", "acceptance"]);
43
+ var ALLOWED_AUDIT_KEYS = /* @__PURE__ */ new Set([
44
+ "scopeLeak",
45
+ "coverage",
46
+ "acceptance",
47
+ "states",
48
+ "pageCoverage",
49
+ "stateMatrix"
50
+ ]);
45
51
  function fail(msg) {
46
52
  throw new ConfigError(`Invalid .uidex.json: ${msg}`);
47
53
  }
48
- function assertObject(value, path10) {
54
+ function assertObject(value, path12) {
49
55
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
50
- fail(`${path10} must be an object`);
56
+ fail(`${path12} must be an object`);
51
57
  }
52
58
  }
53
- function assertStringArray(value, path10) {
59
+ function assertStringArray(value, path12) {
54
60
  if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
55
- fail(`${path10} must be a string[]`);
61
+ fail(`${path12} must be a string[]`);
56
62
  }
57
63
  }
58
64
  function validateConfig(raw) {
@@ -100,10 +106,11 @@ function validateConfig(raw) {
100
106
  }
101
107
  if (raw.exclude !== void 0) assertStringArray(raw.exclude, `exclude`);
102
108
  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
- }
109
+ if (raw.statesManifest !== void 0 && typeof raw.statesManifest !== "string") {
110
+ fail(`"statesManifest" must be a string`);
111
+ }
112
+ if (raw.coverageBaseline !== void 0 && typeof raw.coverageBaseline !== "string") {
113
+ fail(`"coverageBaseline" must be a string`);
107
114
  }
108
115
  if (raw.audit !== void 0) {
109
116
  assertObject(raw.audit, "audit");
@@ -143,9 +150,10 @@ function validateConfig(raw) {
143
150
  exclude: raw.exclude,
144
151
  output: raw.output,
145
152
  flows: raw.flows,
146
- typeMode: raw.typeMode ?? DEFAULT_TYPE_MODE,
147
153
  audit: raw.audit,
148
- conventions: raw.conventions
154
+ conventions: raw.conventions,
155
+ statesManifest: raw.statesManifest,
156
+ coverageBaseline: raw.coverageBaseline
149
157
  };
150
158
  }
151
159
  function parseConfig(json) {
@@ -234,7 +242,12 @@ var BASE_EXCLUDES = [
234
242
  "**/dist/**",
235
243
  "**/build/**",
236
244
  "**/.next/**",
237
- "**/*.gen.ts"
245
+ // Exclude the scanner's own generated siblings so a scan never re-ingests its
246
+ // output. Enumerated (not `*.gen.*`) so a hand-written file that merely contains
247
+ // a `.gen.` segment, e.g. `foo.gen.helpers.ts`, is still scanned.
248
+ "**/*.gen.ts",
249
+ "**/*.gen.data.ts",
250
+ "**/*.gen.locs.ts"
238
251
  ];
239
252
  var TEST_EXCLUDES = [
240
253
  "**/*.test.ts",
@@ -350,6 +363,285 @@ function* walkDir(root, dir) {
350
363
  }
351
364
  }
352
365
 
366
+ // src/scanner/scan/ast.ts
367
+ import * as path3 from "path";
368
+ import { parseSync } from "oxc-parser";
369
+ function langFor(sourcePath) {
370
+ switch (path3.extname(sourcePath)) {
371
+ case ".tsx":
372
+ return "tsx";
373
+ case ".ts":
374
+ case ".mts":
375
+ case ".cts":
376
+ return "ts";
377
+ default:
378
+ return "jsx";
379
+ }
380
+ }
381
+ function makeLineAt(content) {
382
+ const starts = [0];
383
+ for (let i = 0; i < content.length; i++) {
384
+ if (content[i] === "\n") starts.push(i + 1);
385
+ }
386
+ return (offset) => {
387
+ let lo = 0;
388
+ let hi = starts.length - 1;
389
+ while (lo < hi) {
390
+ const mid = lo + hi + 1 >> 1;
391
+ if (starts[mid] <= offset) lo = mid;
392
+ else hi = mid - 1;
393
+ }
394
+ return lo + 1;
395
+ };
396
+ }
397
+ function parseSource(file) {
398
+ const lineAt = makeLineAt(file.content);
399
+ try {
400
+ const result = parseSync(file.sourcePath, file.content, {
401
+ lang: langFor(file.sourcePath),
402
+ sourceType: "module"
403
+ });
404
+ return {
405
+ program: result.program,
406
+ hasErrors: result.errors.length > 0,
407
+ comments: result.comments ?? [],
408
+ lineAt
409
+ };
410
+ } catch {
411
+ return { program: null, hasErrors: true, comments: [], lineAt };
412
+ }
413
+ }
414
+ function isNode(value) {
415
+ return typeof value === "object" && value !== null && typeof value.type === "string";
416
+ }
417
+ function walkAst(root, visit) {
418
+ if (!isNode(root)) return;
419
+ if (visit(root) === false) return;
420
+ for (const key of Object.keys(root)) {
421
+ if (key === "type" || key === "start" || key === "end") continue;
422
+ const value = root[key];
423
+ if (Array.isArray(value)) {
424
+ for (const item of value) walkAst(item, visit);
425
+ } else if (isNode(value)) {
426
+ walkAst(value, visit);
427
+ }
428
+ }
429
+ }
430
+ function unwrapTsExpression(expr) {
431
+ let node = expr;
432
+ for (; ; ) {
433
+ switch (node.type) {
434
+ case "TSAsExpression":
435
+ case "TSSatisfiesExpression":
436
+ case "TSNonNullExpression":
437
+ case "TSTypeAssertion":
438
+ case "ParenthesizedExpression": {
439
+ const inner = node.expression;
440
+ if (!isNode(inner)) return node;
441
+ node = inner;
442
+ break;
443
+ }
444
+ default:
445
+ return node;
446
+ }
447
+ }
448
+ }
449
+
450
+ // src/shared/entities/types.ts
451
+ var ENTITY_KINDS = [
452
+ "route",
453
+ "page",
454
+ "feature",
455
+ "widget",
456
+ "region",
457
+ "element",
458
+ "primitive",
459
+ "flow"
460
+ ];
461
+ var CORE_STATE_KINDS = [
462
+ "loading",
463
+ "empty",
464
+ "populated",
465
+ "error"
466
+ ];
467
+ function isMetaKind(kind) {
468
+ return kind !== "route" && kind !== "flow";
469
+ }
470
+ function isMetaEntity(entity) {
471
+ return isMetaKind(entity.kind);
472
+ }
473
+ function entityKey(entity) {
474
+ return entity.kind === "route" ? entity.path : entity.id;
475
+ }
476
+ var UnknownEntityKindError = class extends Error {
477
+ kind;
478
+ constructor(kind) {
479
+ super(`Unknown entity kind: ${kind}`);
480
+ this.name = "UnknownEntityKindError";
481
+ this.kind = kind;
482
+ }
483
+ };
484
+ var KIND_SET = new Set(ENTITY_KINDS);
485
+ function assertEntityKind(kind) {
486
+ if (!KIND_SET.has(kind)) throw new UnknownEntityKindError(kind);
487
+ }
488
+
489
+ // src/shared/entities/registry.ts
490
+ function emptyStore() {
491
+ return {
492
+ route: /* @__PURE__ */ new Map(),
493
+ page: /* @__PURE__ */ new Map(),
494
+ feature: /* @__PURE__ */ new Map(),
495
+ widget: /* @__PURE__ */ new Map(),
496
+ region: /* @__PURE__ */ new Map(),
497
+ element: /* @__PURE__ */ new Map(),
498
+ primitive: /* @__PURE__ */ new Map(),
499
+ flow: /* @__PURE__ */ new Map()
500
+ };
501
+ }
502
+ function computeFlowIds(flows, targetId) {
503
+ const ids = [];
504
+ for (const flow of flows) {
505
+ if (flow.touches.includes(targetId)) ids.push(flow.id);
506
+ }
507
+ return ids;
508
+ }
509
+ function freezeEntity(entity, flows) {
510
+ if (!isMetaKind(entity.kind)) return entity;
511
+ const withMeta = entity;
512
+ if (withMeta.meta === void 0) return entity;
513
+ const computedFlows = Object.freeze(computeFlowIds(flows, withMeta.id));
514
+ const mergedMeta = { ...withMeta.meta, flows: computedFlows };
515
+ return { ...entity, meta: Object.freeze(mergedMeta) };
516
+ }
517
+ function createRegistry() {
518
+ const store = emptyStore();
519
+ let flowsCache = null;
520
+ const patternCache = /* @__PURE__ */ new Map();
521
+ const getFlows = () => {
522
+ if (flowsCache === null) flowsCache = Array.from(store.flow.values());
523
+ return flowsCache;
524
+ };
525
+ const add = (entity) => {
526
+ assertEntityKind(entity.kind);
527
+ const key = entityKey(entity);
528
+ store[entity.kind].set(key, entity);
529
+ flowsCache = null;
530
+ patternCache.delete(entity.kind);
531
+ };
532
+ const get = (kind, id) => {
533
+ assertEntityKind(kind);
534
+ const raw = store[kind].get(id);
535
+ if (raw === void 0) return void 0;
536
+ return freezeEntity(raw, getFlows());
537
+ };
538
+ const getPatternsForKind = (kind) => {
539
+ const cached = patternCache.get(kind);
540
+ if (cached !== void 0) return cached;
541
+ const patterns = [];
542
+ for (const [key, entity] of store[kind]) {
543
+ if (key.includes("*")) {
544
+ const segments = key.split("*");
545
+ patterns.push({
546
+ segments,
547
+ staticLength: segments.reduce((n, s) => n + s.length, 0),
548
+ entity
549
+ });
550
+ }
551
+ }
552
+ patternCache.set(
553
+ kind,
554
+ patterns
555
+ );
556
+ return patterns;
557
+ };
558
+ const matchesSegments = (segments, id) => {
559
+ const first = segments[0];
560
+ const last = segments[segments.length - 1];
561
+ if (!id.startsWith(first)) return false;
562
+ let pos = first.length;
563
+ for (let i = 1; i < segments.length - 1; i++) {
564
+ const idx = id.indexOf(segments[i], pos);
565
+ if (idx === -1) return false;
566
+ pos = idx + segments[i].length;
567
+ }
568
+ return id.endsWith(last) && id.length - last.length >= pos;
569
+ };
570
+ const matchPattern = (kind, id) => {
571
+ assertEntityKind(kind);
572
+ const patterns = getPatternsForKind(kind);
573
+ if (patterns.length === 0) return void 0;
574
+ let best;
575
+ for (const entry of patterns) {
576
+ if (matchesSegments(entry.segments, id) && (best === void 0 || entry.staticLength > best.staticLength)) {
577
+ best = entry;
578
+ }
579
+ }
580
+ if (best === void 0) return void 0;
581
+ return freezeEntity(best.entity, getFlows());
582
+ };
583
+ const list = (kind) => {
584
+ assertEntityKind(kind);
585
+ const flows = getFlows();
586
+ return Array.from(
587
+ store[kind].values(),
588
+ (e) => freezeEntity(e, flows)
589
+ );
590
+ };
591
+ const allEntities = function* () {
592
+ for (const kind of Object.keys(store)) {
593
+ for (const entity of store[kind].values()) {
594
+ yield entity;
595
+ }
596
+ }
597
+ };
598
+ const query = (predicate) => {
599
+ const flows = getFlows();
600
+ const result = [];
601
+ for (const entity of allEntities()) {
602
+ if (predicate(entity)) result.push(freezeEntity(entity, flows));
603
+ }
604
+ return result;
605
+ };
606
+ const byScope = (scope) => query(
607
+ (entity) => "scopes" in entity && Array.isArray(entity.scopes) && entity.scopes.includes(scope)
608
+ );
609
+ const touchedBy = (flowId) => {
610
+ const flow = store.flow.get(flowId);
611
+ if (flow === void 0) return [];
612
+ const ids = new Set(flow.touches);
613
+ return query((entity) => {
614
+ if (!isMetaEntity(entity)) return false;
615
+ return ids.has(entity.id);
616
+ });
617
+ };
618
+ const reports = /* @__PURE__ */ new Map();
619
+ const reportsCbs = /* @__PURE__ */ new Set();
620
+ const setReports = (kind, id, records) => {
621
+ reports.set(`${kind}:${id}`, records);
622
+ for (const cb of reportsCbs) cb();
623
+ };
624
+ const getReports = (kind, id) => reports.get(`${kind}:${id}`) ?? [];
625
+ const listReportKeys = () => Array.from(reports.keys());
626
+ const onReportsChange = (cb) => {
627
+ reportsCbs.add(cb);
628
+ return () => reportsCbs.delete(cb);
629
+ };
630
+ return {
631
+ add,
632
+ get,
633
+ matchPattern,
634
+ list,
635
+ query,
636
+ byScope,
637
+ touchedBy,
638
+ setReports,
639
+ getReports,
640
+ listReportKeys,
641
+ onReportsChange
642
+ };
643
+ }
644
+
353
645
  // src/scanner/scan/extract-uidex-export.ts
354
646
  var KIND_DISCRIMINATORS = [
355
647
  "page",
@@ -367,26 +659,51 @@ var ALLOWED_FIELDS = {
367
659
  "features",
368
660
  "widgets",
369
661
  "acceptance",
662
+ "states",
663
+ "capture",
664
+ "waivers",
370
665
  "description"
371
666
  ]),
667
+ // `capture` / `waivers` are route-scoped, and only a page maps to a route — so
668
+ // they are page-only (the gates read them off the page). A feature/widget that
669
+ // sets them gets an `uidex-export-unknown-field` error rather than a silent no-op.
372
670
  feature: /* @__PURE__ */ new Set([
373
671
  "feature",
374
672
  "name",
375
673
  "features",
376
674
  "acceptance",
675
+ "states",
377
676
  "description"
378
677
  ]),
379
678
  primitive: /* @__PURE__ */ new Set(["primitive", "name", "description"]),
380
- widget: /* @__PURE__ */ new Set(["widget", "name", "acceptance", "description"]),
679
+ widget: /* @__PURE__ */ new Set(["widget", "name", "acceptance", "states", "description"]),
381
680
  region: /* @__PURE__ */ new Set(["region", "name", "description"]),
382
681
  flow: /* @__PURE__ */ new Set(["flow", "notFlow", "name", "description"])
383
682
  };
683
+ var STATE_FIELDS = /* @__PURE__ */ new Set([
684
+ "name",
685
+ "kind",
686
+ "acceptance",
687
+ "description"
688
+ ]);
689
+ var CORE_STATE_KINDS2 = new Set(CORE_STATE_KINDS);
690
+ var STATE_KINDS = /* @__PURE__ */ new Set([...CORE_STATE_KINDS, "variant"]);
384
691
  var FALSEABLE = /* @__PURE__ */ new Set([
385
692
  "page",
386
693
  "feature",
387
694
  "primitive",
388
695
  "region"
389
696
  ]);
697
+ var SATISFIES_NAMES = {
698
+ page: "Page",
699
+ feature: "Feature",
700
+ primitive: "Primitive",
701
+ widget: "Widget",
702
+ region: "Region",
703
+ flow: "Flow",
704
+ notFlow: "NotFlow"
705
+ };
706
+ var KNOWN_SATISFIES = new Set(Object.values(SATISFIES_NAMES));
390
707
  var ExtractError = class extends Error {
391
708
  code;
392
709
  hint;
@@ -398,649 +715,285 @@ var ExtractError = class extends Error {
398
715
  this.hint = hint;
399
716
  }
400
717
  };
401
- function extractUidexExports(file) {
718
+ function extractUidexExports(file, parsed) {
402
719
  const exports = [];
403
720
  const diagnostics = [];
404
721
  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;
722
+ const p2 = parsed ?? parseSource(file);
723
+ if (p2.program === null) return { exports, diagnostics };
724
+ for (const stmt of p2.program.body) {
725
+ if (stmt.type !== "ExportNamedDeclaration") continue;
726
+ const decl = stmt.declaration;
727
+ if (!isNode2(decl) || decl.type !== "VariableDeclaration") continue;
728
+ if (decl.kind !== "const") continue;
729
+ for (const declarator of decl.declarations ?? []) {
730
+ const id = declarator.id;
731
+ if (!id || id.type !== "Identifier" || String(id.name) !== "uidex") {
732
+ continue;
733
+ }
734
+ const headerPos = posAt(content, stmt.start, p2);
735
+ try {
736
+ const init = declarator.init;
737
+ if (!isNode2(init)) {
738
+ throw new ExtractError(
739
+ "uidex-export-invalid-literal",
740
+ "`export const uidex` must be assigned an object literal.",
741
+ headerPos
742
+ );
743
+ }
744
+ const value = toLitValue(unwrapTsExpression(init), content, p2);
745
+ const metadata = buildMetadata(
746
+ value,
747
+ displayPath,
748
+ file.sourcePath,
749
+ headerPos,
750
+ diagnostics
751
+ );
752
+ metadata.span = statementSpan(stmt, content);
753
+ checkSatisfies(init, metadata, displayPath, p2, diagnostics);
754
+ exports.push(metadata);
755
+ } catch (e) {
756
+ if (e instanceof ExtractError) {
757
+ diagnostics.push({
758
+ code: e.code,
759
+ severity: "error",
760
+ message: e.message,
761
+ file: displayPath,
762
+ line: e.pos.line,
763
+ hint: e.hint
764
+ });
765
+ } else {
766
+ throw e;
767
+ }
427
768
  }
428
769
  }
429
770
  }
430
771
  return { exports, diagnostics };
431
772
  }
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;
773
+ function toLitValue(node, content, p2) {
774
+ const unwrapped = unwrapTsExpression(node);
775
+ const pos = posAt(content, unwrapped.start, p2);
776
+ const span = { start: unwrapped.start, end: unwrapped.end };
777
+ switch (unwrapped.type) {
778
+ case "Literal": {
779
+ const v = unwrapped.value;
780
+ if (typeof v === "string") return { kind: "string", value: v, pos, span };
781
+ if (typeof v === "number") {
782
+ if (!Number.isFinite(v)) {
783
+ throw new ExtractError(
784
+ "uidex-export-invalid-literal",
785
+ `Invalid numeric literal in \`export const uidex\`.`,
786
+ pos
787
+ );
788
+ }
789
+ return { kind: "number", value: v, pos, span };
790
+ }
791
+ if (typeof v === "boolean") {
792
+ return { kind: "boolean", value: v, pos, span };
793
+ }
794
+ if (v === null && unwrapped.raw === "null") {
795
+ return { kind: "null", pos, span };
796
+ }
797
+ throw new ExtractError(
798
+ "uidex-export-invalid-literal",
799
+ `Unsupported literal in \`export const uidex\`; only strings, numbers, booleans, and null are allowed.`,
800
+ pos
801
+ );
463
802
  }
464
- if (inBlockComment) {
465
- if (c === "*" && n === "/") {
466
- inBlockComment = false;
467
- i += 2;
468
- continue;
803
+ case "UnaryExpression": {
804
+ const arg = unwrapped.argument;
805
+ if (unwrapped.operator === "-" && isNode2(arg) && arg.type === "Literal" && typeof arg.value === "number") {
806
+ return { kind: "number", value: -arg.value, pos, span };
469
807
  }
470
- i++;
471
- continue;
808
+ throw new ExtractError(
809
+ "uidex-export-invalid-literal",
810
+ "Unary expressions are not allowed in `export const uidex`.",
811
+ pos
812
+ );
472
813
  }
473
- if (stringDelim !== null) {
474
- if (c === "\\") {
475
- i += 2;
476
- continue;
814
+ case "TemplateLiteral": {
815
+ const expressions = unwrapped.expressions ?? [];
816
+ if (expressions.length > 0) {
817
+ throw new ExtractError(
818
+ "uidex-export-invalid-literal",
819
+ "Template literal with expression parts is not allowed in `export const uidex`; use a plain string literal.",
820
+ pos
821
+ );
477
822
  }
478
- if (c === stringDelim) stringDelim = null;
479
- i++;
480
- continue;
823
+ const quasis = unwrapped.quasis ?? [];
824
+ const cooked = quasis[0]?.value?.cooked ?? "";
825
+ return { kind: "string", value: cooked, pos, span };
481
826
  }
482
- if (inTemplate) {
483
- if (c === "\\") {
484
- i += 2;
485
- continue;
486
- }
487
- if (c === "$" && n === "{") {
488
- templateDepth++;
489
- i += 2;
490
- continue;
491
- }
492
- if (c === "`" && templateDepth === 0) {
493
- inTemplate = false;
494
- i++;
495
- continue;
496
- }
497
- if (templateDepth > 0 && c === "}") {
498
- templateDepth--;
499
- i++;
500
- continue;
827
+ case "Identifier": {
828
+ const name = String(unwrapped.name);
829
+ if (name === "undefined") {
830
+ throw new ExtractError(
831
+ "uidex-export-invalid-literal",
832
+ "`undefined` is not allowed as a value in `export const uidex`; omit the field instead.",
833
+ pos
834
+ );
501
835
  }
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;
836
+ throw new ExtractError(
837
+ "uidex-export-invalid-literal",
838
+ `Identifier reference "${name}" is not allowed in \`export const uidex\`; the right-hand side must be a plain literal.`,
839
+ pos
840
+ );
524
841
  }
525
- i++;
842
+ case "ObjectExpression":
843
+ return objectLit(unwrapped, content, p2, pos, span);
844
+ case "ArrayExpression":
845
+ return arrayLit(unwrapped, content, p2, pos, span);
846
+ case "SpreadElement":
847
+ throw new ExtractError(
848
+ "uidex-export-invalid-literal",
849
+ "Spread (`...`) is not allowed in `export const uidex`; the right-hand side must be a plain literal.",
850
+ pos
851
+ );
852
+ case "CallExpression":
853
+ throw new ExtractError(
854
+ "uidex-export-invalid-literal",
855
+ "Function calls are not allowed in `export const uidex`; the right-hand side must be a plain literal.",
856
+ pos
857
+ );
858
+ case "ConditionalExpression":
859
+ throw new ExtractError(
860
+ "uidex-export-invalid-literal",
861
+ "Conditional expressions are not allowed in `export const uidex`; the right-hand side must be a plain literal.",
862
+ pos
863
+ );
864
+ default:
865
+ throw new ExtractError(
866
+ "uidex-export-invalid-literal",
867
+ `Computed expressions are not allowed in \`export const uidex\`; the right-hand side must be a plain literal.`,
868
+ pos
869
+ );
526
870
  }
527
- return inLineComment || inBlockComment || stringDelim !== null || inTemplate;
528
871
  }
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();
575
- }
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();
586
- }
587
- continue;
588
- }
589
- break;
590
- }
591
- }
592
- next() {
593
- this.skipTrivia();
594
- if (this.pos >= this.src.length) {
595
- return { kind: "eof", value: "", pos: this.currentPos(), end: this.pos };
596
- }
597
- const pos = this.currentPos();
598
- const c = this.src[this.pos];
599
- switch (c) {
600
- case "{":
601
- this.advance();
602
- return { kind: "lbrace", value: c, pos, end: this.pos };
603
- case "}":
604
- this.advance();
605
- return { kind: "rbrace", value: c, pos, end: this.pos };
606
- case "[":
607
- this.advance();
608
- return { kind: "lbracket", value: c, pos, end: this.pos };
609
- case "]":
610
- this.advance();
611
- return { kind: "rbracket", value: c, pos, end: this.pos };
612
- case "(":
613
- this.advance();
614
- return { kind: "lparen", value: c, pos, end: this.pos };
615
- case ")":
616
- this.advance();
617
- return { kind: "rparen", value: c, pos, end: this.pos };
618
- case ",":
619
- this.advance();
620
- return { kind: "comma", value: c, pos, end: this.pos };
621
- case ":":
622
- this.advance();
623
- return { kind: "colon", value: c, pos, end: this.pos };
624
- }
625
- if (c === "." && this.src[this.pos + 1] === "." && this.src[this.pos + 2] === ".") {
626
- this.advance(3);
627
- return { kind: "spread", value: "...", pos, end: this.pos };
628
- }
629
- if (c === '"' || c === "'") {
630
- return this.readString(pos, c);
631
- }
632
- if (c === "`") {
633
- return this.readTemplate(pos);
634
- }
635
- if (isDigit(c) || c === "-" && isDigit(this.src[this.pos + 1])) {
636
- return this.readNumber(pos);
637
- }
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();
689
- }
690
- continue;
691
- }
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 };
698
- }
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();
714
- }
715
- }
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();
720
- }
721
- while (this.pos < this.src.length && isDigit(this.src[this.pos])) {
722
- this.advance();
723
- }
872
+ function objectLit(node, content, p2, pos, span) {
873
+ const entries = [];
874
+ const seen = /* @__PURE__ */ new Set();
875
+ for (const prop of node.properties ?? []) {
876
+ if (prop.type === "SpreadElement") {
877
+ throw new ExtractError(
878
+ "uidex-export-invalid-literal",
879
+ "Spread (`...`) is not allowed inside `export const uidex`.",
880
+ posAt(content, prop.start, p2)
881
+ );
724
882
  }
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();
883
+ if (prop.type !== "Property") {
884
+ throw new ExtractError(
885
+ "uidex-export-invalid-literal",
886
+ "Unexpected member inside `export const uidex` object.",
887
+ posAt(content, prop.start, p2)
888
+ );
732
889
  }
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":
808
- throw new ExtractError(
809
- "uidex-export-invalid-literal",
810
- "Template literal with expression parts is not allowed in `export const uidex`; use a plain string literal.",
811
- t.pos
812
- );
813
- case "number": {
814
- this.consume();
815
- const n = Number(t.value);
816
- if (!Number.isFinite(n)) {
817
- throw new ExtractError(
818
- "uidex-export-invalid-literal",
819
- `Invalid numeric literal "${t.value}" in \`export const uidex\`.`,
820
- t.pos
821
- );
822
- }
823
- return { kind: "number", value: n, pos: t.pos };
824
- }
825
- case "ident":
826
- if (t.value === "true" || t.value === "false") {
827
- this.consume();
828
- return {
829
- kind: "boolean",
830
- value: t.value === "true",
831
- pos: t.pos
832
- };
833
- }
834
- if (t.value === "null") {
835
- this.consume();
836
- return { kind: "null", pos: t.pos };
837
- }
838
- if (t.value === "undefined") {
839
- throw new ExtractError(
840
- "uidex-export-invalid-literal",
841
- "`undefined` is not allowed as a value in `export const uidex`; omit the field instead.",
842
- t.pos
843
- );
844
- }
845
- throw new ExtractError(
846
- "uidex-export-invalid-literal",
847
- `Identifier reference "${t.value}" is not allowed in \`export const uidex\`; the right-hand side must be a plain literal.`,
848
- t.pos
849
- );
850
- case "spread":
851
- throw new ExtractError(
852
- "uidex-export-invalid-literal",
853
- "Spread (`...`) is not allowed in `export const uidex`; the right-hand side must be a plain literal.",
854
- t.pos
855
- );
856
- case "lparen":
857
- throw new ExtractError(
858
- "uidex-export-invalid-literal",
859
- "Parenthesised or grouped expressions are not allowed in `export const uidex`.",
860
- t.pos
861
- );
862
- case "punct":
863
- throw new ExtractError(
864
- "uidex-export-invalid-literal",
865
- `Unexpected token "${t.value}" in \`export const uidex\`.`,
866
- t.pos
867
- );
868
- case "eof":
869
- throw new ExtractError(
870
- "uidex-export-invalid-literal",
871
- "Expected a value for `export const uidex` but reached end of file.",
872
- t.pos
873
- );
874
- default:
875
- throw new ExtractError(
876
- "uidex-export-invalid-literal",
877
- `Unexpected token in \`export const uidex\`.`,
878
- t.pos
879
- );
890
+ const keyNode = prop.key;
891
+ const keyPos = posAt(content, keyNode.start, p2);
892
+ if (prop.shorthand) {
893
+ throw new ExtractError(
894
+ "uidex-export-invalid-literal",
895
+ `Shorthand property "${String(keyNode.name)}" is not allowed; write "${String(keyNode.name)}: ..." with a literal value.`,
896
+ keyPos
897
+ );
880
898
  }
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
- }
892
- if (t.kind === "spread") {
893
- throw new ExtractError(
894
- "uidex-export-invalid-literal",
895
- "Spread (`...`) is not allowed inside `export const uidex`.",
896
- t.pos
897
- );
898
- }
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 {
899
+ let key;
900
+ if (prop.computed) {
901
+ if (keyNode.type !== "Literal" || typeof keyNode.value !== "string") {
951
902
  throw new ExtractError(
952
903
  "uidex-export-invalid-literal",
953
- `Unexpected token "${t.value}" inside object.`,
954
- t.pos
904
+ "Computed property keys must be string literals in `export const uidex`.",
905
+ keyPos
955
906
  );
956
907
  }
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
- }
908
+ key = keyNode.value;
909
+ } else if (keyNode.type === "Identifier") {
910
+ key = String(keyNode.name);
911
+ } else if (keyNode.type === "Literal" && typeof keyNode.value === "string") {
912
+ key = keyNode.value;
913
+ } else {
966
914
  throw new ExtractError(
967
915
  "uidex-export-invalid-literal",
968
- `Expected \`,\` or \`}\`, got "${after.value}".`,
969
- after.pos
916
+ "Numeric property keys are not allowed in `export const uidex`.",
917
+ keyPos
970
918
  );
971
919
  }
972
- return { kind: "object", entries, pos: open.pos };
973
- }
974
- recordEntry(entries, seen, key, value, pos) {
975
920
  if (seen.has(key)) {
976
921
  throw new ExtractError(
977
922
  "uidex-export-duplicate-field",
978
923
  `Duplicate field "${key}" in \`export const uidex\`.`,
979
- pos
924
+ keyPos
980
925
  );
981
926
  }
982
927
  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
- }
928
+ const value = toLitValue(prop.value, content, p2);
929
+ entries.push({
930
+ key,
931
+ value,
932
+ keyPos,
933
+ span: removalSpan(content, prop.start, prop.end)
934
+ });
935
+ }
936
+ return { kind: "object", entries, pos, span };
937
+ }
938
+ function arrayLit(node, content, p2, pos, span) {
939
+ const items = [];
940
+ for (const el of node.elements ?? []) {
941
+ if (el === null) {
942
+ throw new ExtractError(
943
+ "uidex-export-invalid-literal",
944
+ "Array holes are not allowed in `export const uidex`.",
945
+ pos
946
+ );
947
+ }
948
+ if (el.type === "SpreadElement") {
1014
949
  throw new ExtractError(
1015
950
  "uidex-export-invalid-literal",
1016
- `Expected \`,\` or \`]\`, got "${after.value}".`,
1017
- after.pos
951
+ "Spread (`...`) is not allowed inside `export const uidex`.",
952
+ posAt(content, el.start, p2)
1018
953
  );
1019
954
  }
1020
- return { kind: "array", items, pos: open.pos };
955
+ items.push(toLitValue(el, content, p2));
1021
956
  }
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
- }
957
+ return { kind: "array", items, pos, span };
958
+ }
959
+ function checkSatisfies(init, metadata, file, p2, diagnostics) {
960
+ let node = init;
961
+ let satisfiesType;
962
+ while (isNode2(node)) {
963
+ if (node.type === "TSSatisfiesExpression") {
964
+ satisfiesType = node.typeAnnotation;
965
+ break;
1036
966
  }
1037
- const maybeSatisfies = this.peek();
1038
- if (maybeSatisfies.kind === "ident" && maybeSatisfies.value === "satisfies") {
1039
- return;
967
+ if (node.type === "TSAsExpression" || node.type === "TSNonNullExpression" || node.type === "TSTypeAssertion" || node.type === "ParenthesizedExpression") {
968
+ node = node.expression;
969
+ continue;
1040
970
  }
971
+ break;
1041
972
  }
1042
- };
1043
- function buildMetadata(value, file, headerPos, diagnostics) {
973
+ if (!isNode2(satisfiesType) || satisfiesType.type !== "TSTypeReference") return;
974
+ const typeName = satisfiesType.typeName;
975
+ if (!isNode2(typeName) || typeName.type !== "TSQualifiedName") return;
976
+ const left = typeName.left;
977
+ const right = typeName.right;
978
+ if (!isNode2(left) || left.type !== "Identifier" || left.name !== "Uidex") {
979
+ return;
980
+ }
981
+ if (!isNode2(right) || right.type !== "Identifier") return;
982
+ const actual = String(right.name);
983
+ if (!KNOWN_SATISFIES.has(actual)) return;
984
+ const discriminator = metadata.notFlow ? "notFlow" : metadata.kind;
985
+ const expected = SATISFIES_NAMES[discriminator];
986
+ if (actual === expected) return;
987
+ diagnostics.push({
988
+ code: "uidex-export-satisfies-mismatch",
989
+ severity: "warning",
990
+ message: `\`export const uidex\` declares kind "${discriminator}" but is annotated \`satisfies Uidex.${actual}\`; expected \`Uidex.${expected}\`.`,
991
+ file,
992
+ line: p2.lineAt(satisfiesType.start),
993
+ hint: `Change the annotation to \`satisfies Uidex.${expected}\` or fix the kind discriminator.`
994
+ });
995
+ }
996
+ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
1044
997
  if (value.kind !== "object") {
1045
998
  throw new ExtractError(
1046
999
  "uidex-export-invalid-literal",
@@ -1049,7 +1002,7 @@ function buildMetadata(value, file, headerPos, diagnostics) {
1049
1002
  );
1050
1003
  }
1051
1004
  const byKey = /* @__PURE__ */ new Map();
1052
- for (const [k, v] of value.entries) byKey.set(k, v);
1005
+ for (const entry of value.entries) byKey.set(entry.key, entry);
1053
1006
  const presentKinds = KIND_DISCRIMINATORS.filter(
1054
1007
  (k) => byKey.has(k)
1055
1008
  );
@@ -1072,49 +1025,61 @@ function buildMetadata(value, file, headerPos, diagnostics) {
1072
1025
  const discriminator = presentKinds[0];
1073
1026
  const kind = discriminator === "notFlow" ? "flow" : discriminator;
1074
1027
  const allowed = ALLOWED_FIELDS[kind];
1075
- for (const [k] of value.entries) {
1076
- if (!allowed.has(k)) {
1077
- const fieldVal = byKey.get(k);
1028
+ for (const entry of value.entries) {
1029
+ if (!allowed.has(entry.key)) {
1078
1030
  throw new ExtractError(
1079
1031
  "uidex-export-unknown-field",
1080
- `Unknown field "${k}" in \`export const uidex\` for kind "${kind}". Allowed: ${Array.from(
1032
+ `Unknown field "${entry.key}" in \`export const uidex\` for kind "${kind}". Allowed: ${Array.from(
1081
1033
  allowed
1082
1034
  ).sort().join(", ")}.`,
1083
- fieldVal.pos
1035
+ entry.value.pos
1084
1036
  );
1085
1037
  }
1086
1038
  }
1087
1039
  const idField = discriminator === "notFlow" ? "flow" : discriminator;
1088
- const idValue = byKey.get(discriminator);
1040
+ const idValue = byKey.get(discriminator).value;
1089
1041
  let id;
1090
1042
  if (discriminator === "notFlow") {
1091
- const v = idValue;
1092
- if (v.kind !== "boolean" || v.value !== true) {
1043
+ if (idValue.kind !== "boolean" || idValue.value !== true) {
1093
1044
  throw new ExtractError(
1094
1045
  "uidex-export-invalid-field",
1095
1046
  "`notFlow` must be `true`.",
1096
- v.pos
1047
+ idValue.pos
1097
1048
  );
1098
1049
  }
1099
1050
  id = false;
1100
1051
  } else {
1101
1052
  id = readIdField(idValue, kind, idField);
1102
1053
  }
1103
- const acceptance = readStringArrayField(byKey, "acceptance");
1054
+ const acceptance = readStringArrayField(byKey, "acceptance")?.values;
1104
1055
  const description = readStringField(byKey, "description");
1105
1056
  const name = readStringField(byKey, "name");
1106
1057
  if (name === "") {
1107
- const pos = byKey.get("name").pos;
1058
+ const entry = byKey.get("name");
1108
1059
  diagnostics.push({
1109
1060
  code: "uidex-export-empty-name",
1110
1061
  severity: "info",
1111
1062
  message: "`name` is an empty string; treating as unset.",
1112
1063
  file,
1113
- line: pos.line
1064
+ line: entry.value.pos.line,
1065
+ fix: {
1066
+ description: "Remove the empty `name` field",
1067
+ edits: [
1068
+ {
1069
+ path: sourcePath,
1070
+ start: entry.span.start,
1071
+ end: entry.span.end,
1072
+ replacement: ""
1073
+ }
1074
+ ]
1075
+ }
1114
1076
  });
1115
1077
  }
1116
- const features = kind === "page" || kind === "feature" ? readStringArrayField(byKey, "features") : void 0;
1117
- const widgets = kind === "page" ? readStringArrayField(byKey, "widgets") : void 0;
1078
+ const featuresField = kind === "page" || kind === "feature" ? readStringArrayField(byKey, "features") : void 0;
1079
+ const widgetsField = kind === "page" ? readStringArrayField(byKey, "widgets") : void 0;
1080
+ const states = kind === "page" || kind === "feature" || kind === "widget" ? readStatesField(byKey) : void 0;
1081
+ const capture = kind === "page" ? readBooleanField(byKey, "capture") : void 0;
1082
+ const waivers = kind === "page" ? readWaiversField(byKey) : void 0;
1118
1083
  const notFlow = kind === "flow" && discriminator === "notFlow" ? true : void 0;
1119
1084
  const metadata = {
1120
1085
  source: "ts-export",
@@ -1129,9 +1094,24 @@ function buildMetadata(value, file, headerPos, diagnostics) {
1129
1094
  if (name) metadata.name = name;
1130
1095
  if (acceptance) metadata.acceptance = acceptance;
1131
1096
  if (description) metadata.description = description;
1132
- if (features) metadata.features = features;
1133
- if (widgets) metadata.widgets = widgets;
1097
+ if (featuresField) {
1098
+ metadata.features = featuresField.values;
1099
+ metadata.featureSpans = featuresField.spans;
1100
+ }
1101
+ if (widgetsField) {
1102
+ metadata.widgets = widgetsField.values;
1103
+ metadata.widgetSpans = widgetsField.spans;
1104
+ }
1105
+ if (states?.length) metadata.states = states;
1106
+ if (capture !== void 0) metadata.capture = capture;
1107
+ if (waivers && Object.keys(waivers).length > 0) metadata.waivers = waivers;
1134
1108
  if (notFlow) metadata.notFlow = true;
1109
+ if (typeof id === "string" && idValue.kind === "string") {
1110
+ metadata.idSpan = idValue.span;
1111
+ }
1112
+ const fieldSpans = {};
1113
+ for (const entry of value.entries) fieldSpans[entry.key] = entry.span;
1114
+ metadata.fieldSpans = fieldSpans;
1135
1115
  return metadata;
1136
1116
  }
1137
1117
  function readIdField(value, kind, fieldName) {
@@ -1161,30 +1141,73 @@ function readIdField(value, kind, fieldName) {
1161
1141
  value.pos
1162
1142
  );
1163
1143
  }
1144
+ function readWaiversField(byKey) {
1145
+ const entry = byKey.get("waivers");
1146
+ if (!entry) return void 0;
1147
+ if (entry.value.kind !== "object") {
1148
+ throw new ExtractError(
1149
+ "uidex-export-invalid-field",
1150
+ "`waivers` must be an object mapping a core kind to a reason.",
1151
+ entry.value.pos
1152
+ );
1153
+ }
1154
+ const waivers = {};
1155
+ for (const e of entry.value.entries) {
1156
+ if (!CORE_STATE_KINDS2.has(e.key)) {
1157
+ throw new ExtractError(
1158
+ "uidex-export-invalid-field",
1159
+ `waiver key "${e.key}" is not a core kind; only ${[...CORE_STATE_KINDS2].join(", ")} participate in the matrix.`,
1160
+ e.keyPos
1161
+ );
1162
+ }
1163
+ if (e.value.kind !== "string" || e.value.value.length === 0) {
1164
+ throw new ExtractError(
1165
+ "uidex-export-invalid-field",
1166
+ `waiver "${e.key}" needs a non-empty reason (why the route cannot render this kind).`,
1167
+ e.value.pos
1168
+ );
1169
+ }
1170
+ waivers[e.key] = e.value.value;
1171
+ }
1172
+ return waivers;
1173
+ }
1174
+ function readBooleanField(byKey, name) {
1175
+ const entry = byKey.get(name);
1176
+ if (!entry) return void 0;
1177
+ if (entry.value.kind !== "boolean") {
1178
+ throw new ExtractError(
1179
+ "uidex-export-invalid-field",
1180
+ `\`${name}\` must be a boolean.`,
1181
+ entry.value.pos
1182
+ );
1183
+ }
1184
+ return entry.value.value;
1185
+ }
1164
1186
  function readStringField(byKey, name) {
1165
- const v = byKey.get(name);
1166
- if (!v) return void 0;
1167
- if (v.kind !== "string") {
1187
+ const entry = byKey.get(name);
1188
+ if (!entry) return void 0;
1189
+ if (entry.value.kind !== "string") {
1168
1190
  throw new ExtractError(
1169
1191
  "uidex-export-invalid-field",
1170
1192
  `\`${name}\` must be a string.`,
1171
- v.pos
1193
+ entry.value.pos
1172
1194
  );
1173
1195
  }
1174
- return v.value;
1196
+ return entry.value.value;
1175
1197
  }
1176
1198
  function readStringArrayField(byKey, name) {
1177
- const v = byKey.get(name);
1178
- if (!v) return void 0;
1179
- if (v.kind !== "array") {
1199
+ const entry = byKey.get(name);
1200
+ if (!entry) return void 0;
1201
+ if (entry.value.kind !== "array") {
1180
1202
  throw new ExtractError(
1181
1203
  "uidex-export-invalid-field",
1182
1204
  `\`${name}\` must be an array of strings.`,
1183
- v.pos
1205
+ entry.value.pos
1184
1206
  );
1185
1207
  }
1186
- const out2 = [];
1187
- for (const item of v.items) {
1208
+ const values = [];
1209
+ const spans = [];
1210
+ for (const item of entry.value.items) {
1188
1211
  if (item.kind !== "string") {
1189
1212
  throw new ExtractError(
1190
1213
  "uidex-export-invalid-field",
@@ -1192,498 +1215,606 @@ function readStringArrayField(byKey, name) {
1192
1215
  item.pos
1193
1216
  );
1194
1217
  }
1195
- out2.push(item.value);
1196
- }
1197
- return out2;
1198
- }
1199
- function posAt(content, offset) {
1200
- let line = 1;
1201
- let lineStart = 0;
1202
- for (let i = 0; i < offset && i < content.length; i++) {
1203
- if (content[i] === "\n") {
1204
- line++;
1205
- lineStart = i + 1;
1206
- }
1218
+ values.push(item.value);
1219
+ spans.push(item.span);
1207
1220
  }
1208
- return { offset, line, column: offset - lineStart + 1 };
1221
+ return { values, spans };
1209
1222
  }
1210
-
1211
- // 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 });
1221
- }
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}*` });
1223
+ function readStatesField(byKey) {
1224
+ const entry = byKey.get("states");
1225
+ if (!entry) return void 0;
1226
+ if (entry.value.kind !== "array") {
1227
+ throw new ExtractError(
1228
+ "uidex-export-invalid-field",
1229
+ "`states` must be an array of state names or `{ name, acceptance?, description? }` objects.",
1230
+ entry.value.pos
1231
+ );
1226
1232
  }
1227
- return out2;
1228
- }
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
- }
1233
+ const states = [];
1234
+ const seen = /* @__PURE__ */ new Set();
1235
+ for (const item of entry.value.items) {
1236
+ let state;
1237
+ if (item.kind === "string") {
1238
+ state = { name: item.value, nameSpan: item.span };
1239
+ } else if (item.kind === "object") {
1240
+ const fieldByKey = /* @__PURE__ */ new Map();
1241
+ for (const e of item.entries) fieldByKey.set(e.key, e);
1242
+ for (const e of item.entries) {
1243
+ if (!STATE_FIELDS.has(e.key)) {
1244
+ throw new ExtractError(
1245
+ "uidex-export-invalid-field",
1246
+ `Unknown field "${e.key}" in a \`states\` entry. Allowed: ${Array.from(
1247
+ STATE_FIELDS
1248
+ ).sort().join(", ")}.`,
1249
+ e.value.pos
1250
+ );
1288
1251
  }
1289
- advanceLines(i, end + 1);
1290
- i = end + 1;
1291
- continue;
1292
1252
  }
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;
1314
- continue;
1253
+ const nameEntry = fieldByKey.get("name");
1254
+ if (!nameEntry || nameEntry.value.kind !== "string") {
1255
+ throw new ExtractError(
1256
+ "uidex-export-invalid-field",
1257
+ "Each object `states` entry must have a string `name`.",
1258
+ item.pos
1259
+ );
1315
1260
  }
1316
- }
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;
1329
- }
1330
- if (c === quote) return i + 1;
1331
- i++;
1332
- }
1333
- return N;
1334
- }
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;
1353
- }
1354
- if (cj === "`") {
1355
- i = skipTemplate(content, i);
1356
- continue;
1261
+ state = { name: nameEntry.value.value, nameSpan: nameEntry.value.span };
1262
+ const kind = readStringField(fieldByKey, "kind");
1263
+ if (kind !== void 0) {
1264
+ if (!STATE_KINDS.has(kind)) {
1265
+ throw new ExtractError(
1266
+ "uidex-export-invalid-field",
1267
+ `state \`kind\` must be one of ${[...STATE_KINDS].join(", ")} (got "${kind}").`,
1268
+ fieldByKey.get("kind").value.pos
1269
+ );
1357
1270
  }
1358
- if (cj === "{") depth++;
1359
- else if (cj === "}") depth--;
1360
- i++;
1271
+ state.kind = kind;
1361
1272
  }
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;
1273
+ const acceptance = readStringArrayField(fieldByKey, "acceptance")?.values;
1274
+ if (acceptance) state.acceptance = acceptance;
1275
+ const description = readStringField(fieldByKey, "description");
1276
+ if (description) state.description = description;
1277
+ } else {
1278
+ throw new ExtractError(
1279
+ "uidex-export-invalid-field",
1280
+ "`states` items must be strings or `{ name, ... }` objects.",
1281
+ item.pos
1282
+ );
1376
1283
  }
1377
- if (c === "`") {
1378
- i = skipTemplate(content, i);
1379
- continue;
1284
+ if (state.name.length === 0) {
1285
+ throw new ExtractError(
1286
+ "uidex-export-invalid-field",
1287
+ "A `states` entry has an empty `name`.",
1288
+ item.pos
1289
+ );
1380
1290
  }
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++;
1397
- }
1398
- continue;
1291
+ if (seen.has(state.name)) {
1292
+ throw new ExtractError(
1293
+ "uidex-export-duplicate-state",
1294
+ `Duplicate state "${state.name}" in \`states\`; each state name must be unique within the entity.`,
1295
+ item.pos
1296
+ );
1399
1297
  }
1400
- if (c === ">") return i;
1401
- i++;
1298
+ seen.add(state.name);
1299
+ states.push(state);
1402
1300
  }
1403
- return -1;
1301
+ return states;
1302
+ }
1303
+ function posAt(content, offset, p2) {
1304
+ const lineStart = content.lastIndexOf("\n", offset - 1) + 1;
1305
+ return { offset, line: p2.lineAt(offset), column: offset - lineStart + 1 };
1306
+ }
1307
+ function removalSpan(content, start, end) {
1308
+ let e = end;
1309
+ while (e < content.length && /[ \t]/.test(content[e])) e++;
1310
+ if (content[e] === ",") return { start, end: e + 1 };
1311
+ let s = start;
1312
+ while (s > 0 && /[\s]/.test(content[s - 1])) s--;
1313
+ if (content[s - 1] === ",") return { start: s - 1, end };
1314
+ return { start, end };
1315
+ }
1316
+ function statementSpan(stmt, content) {
1317
+ let end = stmt.end;
1318
+ while (end < content.length && /[ \t]/.test(content[end])) end++;
1319
+ if (content[end] === ";") end++;
1320
+ while (end < content.length && /[ \t]/.test(content[end])) end++;
1321
+ if (content[end] === "\r") end++;
1322
+ if (content[end] === "\n") end++;
1323
+ return { start: stmt.start, end };
1324
+ }
1325
+ function isNode2(value) {
1326
+ return typeof value === "object" && value !== null && typeof value.type === "string";
1404
1327
  }
1405
1328
 
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++;
1412
- }
1413
- return line;
1329
+ // src/scanner/scan/flow-facts.ts
1330
+ function collectFlowFacts(parsed, content) {
1331
+ if (parsed.program === null || !content.includes("test.describe")) {
1332
+ return [];
1333
+ }
1334
+ const facts = [];
1335
+ walkAst(parsed.program, (node) => {
1336
+ if (node.type !== "CallExpression") return void 0;
1337
+ const fact = readTaggedDescribe(node, parsed);
1338
+ if (fact) facts.push(fact);
1339
+ return void 0;
1340
+ });
1341
+ return facts;
1414
1342
  }
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());
1432
- continue;
1433
- }
1434
- if (/^@uidex:not-flow\b/.test(line)) {
1435
- notFlow = true;
1436
- continue;
1437
- }
1438
- const accept = line.match(/^@acceptance\s+(.+)$/);
1439
- if (accept) {
1440
- acceptance.push(accept[1].trim());
1441
- continue;
1442
- }
1443
- if (line.startsWith("@")) continue;
1444
- desc.push(line);
1343
+ function readTaggedDescribe(call, parsed) {
1344
+ const callee = call.callee;
1345
+ if (!callee || callee.type !== "MemberExpression" || !isIdentifier(callee.object, "test") || !isIdentifier(callee.property, "describe")) {
1346
+ return null;
1445
1347
  }
1348
+ const args = call.arguments ?? [];
1349
+ const title = stringLiteralValue(args[0]);
1350
+ if (title === null) return null;
1351
+ if (!hasFlowTag(args[1])) return null;
1352
+ const body = args[2];
1353
+ const { calls, dynamicCalls } = body ? collectUidexCalls(body, parsed) : { calls: [], dynamicCalls: [] };
1446
1354
  return {
1447
- kind,
1448
- id,
1449
- description: desc.join(" ").trim(),
1450
- acceptance,
1451
- notFlow
1355
+ title,
1356
+ line: parsed.lineAt(call.start),
1357
+ calls,
1358
+ ...dynamicCalls.length > 0 ? { dynamicCalls } : {}
1452
1359
  };
1453
1360
  }
1454
- function extract(files) {
1455
- return files.map((file) => {
1456
- const { exports, diagnostics } = extractUidexExports(file);
1457
- const out2 = {
1458
- file,
1459
- annotations: extractOne(file)
1460
- };
1461
- if (exports.length > 0) out2.metadata = exports;
1462
- if (diagnostics.length > 0) out2.diagnostics = diagnostics;
1463
- return out2;
1464
- });
1465
- }
1466
- function extractOne(file) {
1467
- const annotations = [];
1468
- const { content, displayPath } = file;
1469
- for (const occ of collectJSXAncestry(content)) {
1470
- annotations.push({
1471
- kind: occ.kind,
1472
- id: occ.id,
1473
- file: displayPath,
1474
- line: occ.line,
1475
- ...occ.ancestors.length > 0 ? { ancestors: occ.ancestors } : {}
1476
- });
1477
- }
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
- });
1361
+ function hasFlowTag(node) {
1362
+ if (!node || node.type !== "ObjectExpression") return false;
1363
+ for (const prop of node.properties ?? []) {
1364
+ if (prop.type !== "Property") continue;
1365
+ const key = prop.key;
1366
+ const keyName = key?.type === "Identifier" ? String(key.name) : key?.type === "Literal" ? String(key.value) : null;
1367
+ if (keyName !== "tag") continue;
1368
+ const value = prop.value;
1369
+ if (!value) return false;
1370
+ if (stringLiteralValue(value) === "@uidex:flow") return true;
1371
+ if (value.type === "ArrayExpression") {
1372
+ for (const el of value.elements ?? []) {
1373
+ if (el && stringLiteralValue(el) === "@uidex:flow") return true;
1374
+ }
1504
1375
  }
1376
+ return false;
1505
1377
  }
1506
- return annotations;
1378
+ return false;
1507
1379
  }
1508
-
1509
- // src/scanner/scan/resolve.ts
1510
- import * as path3 from "path";
1511
-
1512
- // src/shared/entities/types.ts
1513
- var ENTITY_KINDS = [
1514
- "route",
1515
- "page",
1516
- "feature",
1517
- "widget",
1518
- "region",
1519
- "element",
1520
- "primitive",
1521
- "flow"
1522
- ];
1523
- function isMetaKind(kind) {
1524
- return kind !== "route" && kind !== "flow";
1380
+ function collectUidexCalls(body, parsed) {
1381
+ const calls = [];
1382
+ const dynamicCalls = [];
1383
+ const claimed = /* @__PURE__ */ new Set();
1384
+ const record = (node, action) => {
1385
+ if (claimed.has(node)) return;
1386
+ claimed.add(node);
1387
+ const resolved = uidexCallId(node);
1388
+ if (resolved === null) {
1389
+ dynamicCalls.push({ line: parsed.lineAt(node.start) });
1390
+ return;
1391
+ }
1392
+ calls.push({
1393
+ id: resolved.id,
1394
+ ...action ? { action } : {},
1395
+ line: parsed.lineAt(node.start),
1396
+ span: resolved.span
1397
+ });
1398
+ };
1399
+ walkAst(body, (node) => {
1400
+ if (node.type !== "CallExpression") return void 0;
1401
+ const callee = node.callee;
1402
+ if (callee?.type === "MemberExpression") {
1403
+ const inner = callee.object;
1404
+ if (inner && isUidexCall(inner)) {
1405
+ const property = callee.property;
1406
+ const action = property?.type === "Identifier" ? String(property.name) : void 0;
1407
+ record(inner, action);
1408
+ }
1409
+ return void 0;
1410
+ }
1411
+ if (isUidexCall(node)) record(node);
1412
+ return void 0;
1413
+ });
1414
+ return { calls, dynamicCalls };
1525
1415
  }
1526
- function isMetaEntity(entity) {
1527
- return isMetaKind(entity.kind);
1416
+ function isUidexCall(node) {
1417
+ if (node.type !== "CallExpression") return false;
1418
+ if (!isIdentifier(node.callee, "uidex")) return false;
1419
+ return (node.arguments ?? []).length >= 1;
1528
1420
  }
1529
- function entityKey(entity) {
1530
- return entity.kind === "route" ? entity.path : entity.id;
1421
+ function uidexCallId(node) {
1422
+ const args = node.arguments ?? [];
1423
+ const arg = args[0];
1424
+ const id = stringLiteralValue(arg);
1425
+ if (id === null) return null;
1426
+ return { id, span: { start: arg.start, end: arg.end } };
1531
1427
  }
1532
- var UnknownEntityKindError = class extends Error {
1533
- kind;
1534
- constructor(kind) {
1535
- super(`Unknown entity kind: ${kind}`);
1536
- this.name = "UnknownEntityKindError";
1537
- this.kind = kind;
1428
+ function stringLiteralValue(node) {
1429
+ if (!node) return null;
1430
+ if (node.type === "Literal" && typeof node.value === "string") {
1431
+ return node.value.length > 0 ? node.value : null;
1432
+ }
1433
+ if (node.type === "TemplateLiteral") {
1434
+ const expressions = node.expressions ?? [];
1435
+ if (expressions.length > 0) return null;
1436
+ const quasis = node.quasis ?? [];
1437
+ const cooked = quasis[0]?.value?.cooked;
1438
+ return cooked && cooked.length > 0 ? cooked : null;
1538
1439
  }
1539
- };
1540
- var KIND_SET = new Set(ENTITY_KINDS);
1541
- function assertEntityKind(kind) {
1542
- if (!KIND_SET.has(kind)) throw new UnknownEntityKindError(kind);
1440
+ return null;
1441
+ }
1442
+ function isIdentifier(node, name) {
1443
+ return typeof node === "object" && node !== null && node.type === "Identifier" && String(node.name) === name;
1543
1444
  }
1544
1445
 
1545
- // src/shared/entities/registry.ts
1546
- function emptyStore() {
1547
- return {
1548
- route: /* @__PURE__ */ new Map(),
1549
- page: /* @__PURE__ */ new Map(),
1550
- feature: /* @__PURE__ */ new Map(),
1551
- widget: /* @__PURE__ */ new Map(),
1552
- region: /* @__PURE__ */ new Map(),
1553
- element: /* @__PURE__ */ new Map(),
1554
- primitive: /* @__PURE__ */ new Map(),
1555
- flow: /* @__PURE__ */ new Map()
1556
- };
1446
+ // src/scanner/scan/jsx-ancestry.ts
1447
+ var ATTR_NAME_RE = /^data-uidex(?:-(region|widget|primitive))?$/;
1448
+ var INTERACTIVE_TAGS = /* @__PURE__ */ new Set(["button", "a", "input", "select", "textarea"]);
1449
+ var LANDMARK_TAGS = /* @__PURE__ */ new Set(["header", "nav", "main", "aside", "footer"]);
1450
+ function attrKind(node) {
1451
+ const name = node.name;
1452
+ if (!name || name.type !== "JSXIdentifier") return null;
1453
+ const m = ATTR_NAME_RE.exec(String(name.name));
1454
+ if (!m) return null;
1455
+ return m[1] ?? "element";
1557
1456
  }
1558
- function computeFlowIds(flows, targetId) {
1559
- const ids = [];
1560
- for (const flow of flows) {
1561
- if (flow.touches.includes(targetId)) ids.push(flow.id);
1457
+ function collectConstStrings(program) {
1458
+ const consts = /* @__PURE__ */ new Map();
1459
+ const seen = /* @__PURE__ */ new Set();
1460
+ walkAst(program, (node) => {
1461
+ if (node.type !== "VariableDeclaration" || node.kind !== "const") {
1462
+ return void 0;
1463
+ }
1464
+ for (const decl of node.declarations ?? []) {
1465
+ const id = decl.id;
1466
+ if (!id || id.type !== "Identifier") continue;
1467
+ const name = String(id.name);
1468
+ if (seen.has(name)) {
1469
+ consts.delete(name);
1470
+ continue;
1471
+ }
1472
+ seen.add(name);
1473
+ const init = decl.init;
1474
+ if (!init) continue;
1475
+ const value = staticString(unwrapTsExpression(init));
1476
+ if (value !== null) consts.set(name, value);
1477
+ }
1478
+ return void 0;
1479
+ });
1480
+ return consts;
1481
+ }
1482
+ function staticString(node) {
1483
+ if (node.type === "Literal" && typeof node.value === "string") {
1484
+ return node.value;
1562
1485
  }
1563
- return ids;
1486
+ if (node.type === "TemplateLiteral") {
1487
+ const expressions = node.expressions ?? [];
1488
+ if (expressions.length > 0) return null;
1489
+ const quasis = node.quasis ?? [];
1490
+ return quasis[0]?.value?.cooked ?? "";
1491
+ }
1492
+ return null;
1564
1493
  }
1565
- function freezeEntity(entity, flows) {
1566
- if (!isMetaKind(entity.kind)) return entity;
1567
- const withMeta = entity;
1568
- if (withMeta.meta === void 0) return entity;
1569
- const computedFlows = Object.freeze(computeFlowIds(flows, withMeta.id));
1570
- const mergedMeta = { ...withMeta.meta, flows: computedFlows };
1571
- return { ...entity, meta: Object.freeze(mergedMeta) };
1494
+ var UNRESOLVED = { resolved: false };
1495
+ function evalIdExpression(expr, consts) {
1496
+ const node = unwrapTsExpression(expr);
1497
+ const literal = staticString(node);
1498
+ if (literal !== null) {
1499
+ return literal.length > 0 ? { resolved: true, ids: [literal] } : UNRESOLVED;
1500
+ }
1501
+ if (node.type === "TemplateLiteral") {
1502
+ const quasis = node.quasis ?? [];
1503
+ const expressions = node.expressions ?? [];
1504
+ let out2 = "";
1505
+ for (let i = 0; i < quasis.length; i++) {
1506
+ out2 += quasis[i].value?.cooked ?? "";
1507
+ if (i < expressions.length) {
1508
+ const part = evalIdExpression(expressions[i], consts);
1509
+ out2 += part.resolved && part.ids.length === 1 ? part.ids[0] : "*";
1510
+ }
1511
+ }
1512
+ out2 = out2.replace(/\*{2,}/g, "*");
1513
+ if (!out2.includes("*")) {
1514
+ return out2.length > 0 ? { resolved: true, ids: [out2] } : UNRESOLVED;
1515
+ }
1516
+ return out2.replace(/\*/g, "").length > 0 ? { resolved: true, ids: [out2] } : UNRESOLVED;
1517
+ }
1518
+ if (node.type === "Identifier") {
1519
+ const value = consts.get(String(node.name));
1520
+ return value !== void 0 && value.length > 0 ? { resolved: true, ids: [value] } : UNRESOLVED;
1521
+ }
1522
+ if (node.type === "ConditionalExpression") {
1523
+ const left = evalIdExpression(node.consequent, consts);
1524
+ const right = evalIdExpression(node.alternate, consts);
1525
+ if (!left.resolved || !right.resolved) return UNRESOLVED;
1526
+ return { resolved: true, ids: [.../* @__PURE__ */ new Set([...left.ids, ...right.ids])] };
1527
+ }
1528
+ return UNRESOLVED;
1572
1529
  }
1573
- function createRegistry() {
1574
- const store = emptyStore();
1575
- let flowsCache = null;
1576
- const patternCache = /* @__PURE__ */ new Map();
1577
- const getFlows = () => {
1578
- if (flowsCache === null) flowsCache = Array.from(store.flow.values());
1579
- return flowsCache;
1580
- };
1581
- const add = (entity) => {
1582
- assertEntityKind(entity.kind);
1583
- const key = entityKey(entity);
1584
- store[entity.kind].set(key, entity);
1585
- flowsCache = null;
1586
- patternCache.delete(entity.kind);
1587
- };
1588
- const get = (kind, id) => {
1589
- assertEntityKind(kind);
1590
- const raw = store[kind].get(id);
1591
- if (raw === void 0) return void 0;
1592
- return freezeEntity(raw, getFlows());
1593
- };
1594
- const getPatternsForKind = (kind) => {
1595
- const cached = patternCache.get(kind);
1596
- if (cached !== void 0)
1597
- return cached;
1598
- const patterns = [];
1599
- for (const [key, entity] of store[kind]) {
1600
- if (key.endsWith("*")) {
1601
- patterns.push({
1602
- prefix: key.slice(0, -1),
1603
- entity
1530
+ function collectElementAttrs(opening, consts, dynamicAttrs, lineAt) {
1531
+ const statics = [];
1532
+ const patterns = [];
1533
+ const attributes = opening.attributes ?? [];
1534
+ for (const attr of attributes) {
1535
+ if (attr.type !== "JSXAttribute") continue;
1536
+ const kind = attrKind(attr);
1537
+ if (!kind) continue;
1538
+ const value = attr.value;
1539
+ if (!value) continue;
1540
+ let result = UNRESOLVED;
1541
+ let valueSpan;
1542
+ if (value.type === "Literal") {
1543
+ const v = staticString(value);
1544
+ result = v !== null && v.length > 0 ? { resolved: true, ids: [v] } : UNRESOLVED;
1545
+ if (result.resolved) valueSpan = { start: value.start, end: value.end };
1546
+ } else if (value.type === "JSXExpressionContainer") {
1547
+ const expr = value.expression;
1548
+ if (expr && expr.type !== "JSXEmptyExpression") {
1549
+ result = evalIdExpression(expr, consts);
1550
+ const inner = unwrapTsExpression(expr);
1551
+ if (result.resolved && staticString(inner) !== null) {
1552
+ valueSpan = { start: inner.start, end: inner.end };
1553
+ }
1554
+ }
1555
+ if (!result.resolved) {
1556
+ dynamicAttrs.push({
1557
+ kind,
1558
+ attrName: kind === "element" ? "data-uidex" : `data-uidex-${kind}`,
1559
+ line: lineAt(attr.start)
1604
1560
  });
1561
+ continue;
1605
1562
  }
1606
1563
  }
1607
- patternCache.set(
1608
- kind,
1609
- patterns
1610
- );
1611
- return patterns;
1612
- };
1613
- const matchPattern = (kind, id) => {
1614
- assertEntityKind(kind);
1615
- const patterns = getPatternsForKind(kind);
1616
- if (patterns.length === 0) return void 0;
1617
- let best;
1618
- for (const entry of patterns) {
1619
- if (id.startsWith(entry.prefix) && (best === void 0 || entry.prefix.length > best.prefix.length)) {
1620
- best = entry;
1564
+ if (!result.resolved) continue;
1565
+ for (const id of result.ids) {
1566
+ const resolved = {
1567
+ kind,
1568
+ id,
1569
+ start: attr.start,
1570
+ isPattern: id.includes("*"),
1571
+ // Only a single plain string literal is renameable in place.
1572
+ ...result.ids.length === 1 && valueSpan ? { span: valueSpan } : {}
1573
+ };
1574
+ if (resolved.isPattern) patterns.push(resolved);
1575
+ else statics.push(resolved);
1576
+ }
1577
+ }
1578
+ return [...statics, ...patterns];
1579
+ }
1580
+ function collectJSXFacts(parsed) {
1581
+ const occurrences = [];
1582
+ const dynamicAttrs = [];
1583
+ const unannotatedInteractive = [];
1584
+ const landmarks = [];
1585
+ if (parsed.program === null) {
1586
+ return { occurrences, dynamicAttrs, unannotatedInteractive, landmarks };
1587
+ }
1588
+ const consts = collectConstStrings(parsed.program);
1589
+ const ancestors = [];
1590
+ const visit = (node) => {
1591
+ if (!isNode3(node)) return;
1592
+ if (node.type === "JSXElement") {
1593
+ const opening = node.openingElement;
1594
+ const attrs = collectElementAttrs(
1595
+ opening,
1596
+ consts,
1597
+ dynamicAttrs,
1598
+ parsed.lineAt
1599
+ );
1600
+ const interactive = readInteractive(node, parsed.lineAt);
1601
+ if (interactive) unannotatedInteractive.push(interactive);
1602
+ if (attrs.length > 0) {
1603
+ const snapshot = ancestors.slice();
1604
+ for (const a of attrs) {
1605
+ occurrences.push({
1606
+ kind: a.kind,
1607
+ id: a.id,
1608
+ line: parsed.lineAt(a.start),
1609
+ ancestors: snapshot,
1610
+ ...a.span ? { span: a.span } : {}
1611
+ });
1612
+ }
1613
+ }
1614
+ let pushed = attrs.length;
1615
+ for (const a of attrs) ancestors.push({ kind: a.kind, id: a.id });
1616
+ const landmark = readLandmark(opening, parsed.lineAt);
1617
+ if (landmark) {
1618
+ landmarks.push(landmark);
1619
+ if (!attrs.some((a) => a.kind === "region")) {
1620
+ ancestors.push({ kind: "region", id: landmark.tag });
1621
+ pushed++;
1622
+ }
1621
1623
  }
1624
+ visitChildren(opening);
1625
+ for (const child of node.children ?? []) {
1626
+ visit(child);
1627
+ }
1628
+ const closing = node.closingElement;
1629
+ if (isNode3(closing)) visitChildren(closing);
1630
+ ancestors.length -= pushed;
1631
+ return;
1622
1632
  }
1623
- if (best === void 0) return void 0;
1624
- return freezeEntity(best.entity, getFlows());
1625
- };
1626
- const list = (kind) => {
1627
- assertEntityKind(kind);
1628
- const flows = getFlows();
1629
- return Array.from(
1630
- store[kind].values(),
1631
- (e) => freezeEntity(e, flows)
1632
- );
1633
+ visitChildren(node);
1633
1634
  };
1634
- const allEntities = function* () {
1635
- for (const kind of Object.keys(store)) {
1636
- for (const entity of store[kind].values()) {
1637
- yield entity;
1635
+ const visitChildren = (node) => {
1636
+ for (const key of Object.keys(node)) {
1637
+ if (key === "type" || key === "start" || key === "end") continue;
1638
+ const value = node[key];
1639
+ if (Array.isArray(value)) {
1640
+ for (const item of value) visit(item);
1641
+ } else {
1642
+ visit(value);
1638
1643
  }
1639
1644
  }
1640
1645
  };
1641
- const query = (predicate) => {
1642
- const flows = getFlows();
1643
- const result = [];
1644
- for (const entity of allEntities()) {
1645
- if (predicate(entity)) result.push(freezeEntity(entity, flows));
1646
+ visit(parsed.program);
1647
+ return { occurrences, dynamicAttrs, unannotatedInteractive, landmarks };
1648
+ }
1649
+ function readLandmark(opening, lineAt) {
1650
+ const name = opening.name;
1651
+ if (!name || name.type !== "JSXIdentifier") return null;
1652
+ const tag = String(name.name);
1653
+ if (LANDMARK_TAGS.has(tag)) {
1654
+ return { tag, line: lineAt(opening.start) };
1655
+ }
1656
+ for (const attr of opening.attributes ?? []) {
1657
+ if (attr.type !== "JSXAttribute") continue;
1658
+ const attrName = attr.name;
1659
+ if (!attrName || String(attrName.name) !== "role") continue;
1660
+ const value = attr.value;
1661
+ if (value && value.type === "Literal" && value.value === "region") {
1662
+ return { tag: "region", line: lineAt(opening.start) };
1646
1663
  }
1647
- return result;
1648
- };
1649
- const byScope = (scope) => query(
1650
- (entity) => "scopes" in entity && Array.isArray(entity.scopes) && entity.scopes.includes(scope)
1651
- );
1652
- const touchedBy = (flowId) => {
1653
- const flow = store.flow.get(flowId);
1654
- if (flow === void 0) return [];
1655
- const ids = new Set(flow.touches);
1656
- return query((entity) => {
1657
- if (!isMetaEntity(entity)) return false;
1658
- return ids.has(entity.id);
1659
- });
1660
- };
1661
- const reports = /* @__PURE__ */ new Map();
1662
- const reportsCbs = /* @__PURE__ */ new Set();
1663
- const setReports = (kind, id, records) => {
1664
- reports.set(`${kind}:${id}`, records);
1665
- for (const cb of reportsCbs) cb();
1666
- };
1667
- const getReports = (kind, id) => reports.get(`${kind}:${id}`) ?? [];
1668
- const listReportKeys = () => Array.from(reports.keys());
1669
- const onReportsChange = (cb) => {
1670
- reportsCbs.add(cb);
1671
- return () => reportsCbs.delete(cb);
1672
- };
1664
+ }
1665
+ return null;
1666
+ }
1667
+ function readInteractive(element, lineAt) {
1668
+ const opening = element.openingElement;
1669
+ const name = opening.name;
1670
+ if (!name || name.type !== "JSXIdentifier") return null;
1671
+ const tag = String(name.name);
1672
+ if (!INTERACTIVE_TAGS.has(tag)) return null;
1673
+ let hasSpread = false;
1674
+ for (const attr of opening.attributes ?? []) {
1675
+ if (attr.type === "JSXSpreadAttribute") {
1676
+ hasSpread = true;
1677
+ continue;
1678
+ }
1679
+ if (attr.type === "JSXAttribute" && attrKind(attr) !== null) return null;
1680
+ }
1681
+ const nameHint = interactiveNameHint(element, opening);
1673
1682
  return {
1674
- add,
1675
- get,
1676
- matchPattern,
1677
- list,
1678
- query,
1679
- byScope,
1680
- touchedBy,
1681
- setReports,
1682
- getReports,
1683
- listReportKeys,
1684
- onReportsChange
1683
+ tag,
1684
+ line: lineAt(opening.start),
1685
+ hasSpread,
1686
+ nameEnd: name.end,
1687
+ ...nameHint ? { nameHint } : {}
1688
+ };
1689
+ }
1690
+ function staticAttrValue(opening, attrName) {
1691
+ for (const attr of opening.attributes ?? []) {
1692
+ if (attr.type !== "JSXAttribute") continue;
1693
+ const n = attr.name;
1694
+ if (!n || String(n.name) !== attrName) continue;
1695
+ const value = attr.value;
1696
+ if (!value) return null;
1697
+ if (value.type === "Literal") return staticString(value);
1698
+ if (value.type === "JSXExpressionContainer") {
1699
+ const expr = value.expression;
1700
+ return expr ? staticString(unwrapTsExpression(expr)) : null;
1701
+ }
1702
+ return null;
1703
+ }
1704
+ return null;
1705
+ }
1706
+ function staticChildText(element) {
1707
+ const parts = [];
1708
+ for (const child of element.children ?? []) {
1709
+ if (child.type === "JSXText") {
1710
+ parts.push(String(child.value ?? ""));
1711
+ }
1712
+ }
1713
+ return parts.join(" ").replace(/\s+/g, " ").trim();
1714
+ }
1715
+ function interactiveNameHint(element, opening) {
1716
+ const ariaLabel = staticAttrValue(opening, "aria-label");
1717
+ if (ariaLabel) return ariaLabel;
1718
+ const text = staticChildText(element);
1719
+ if (text) return text;
1720
+ for (const attr of ["title", "name", "placeholder"]) {
1721
+ const v = staticAttrValue(opening, attr);
1722
+ if (v) return v;
1723
+ }
1724
+ return void 0;
1725
+ }
1726
+ function isNode3(value) {
1727
+ return typeof value === "object" && value !== null && typeof value.type === "string";
1728
+ }
1729
+
1730
+ // src/scanner/scan/extract.ts
1731
+ function parseFailureDiagnostic(file, parsed) {
1732
+ const fatal = parsed.program === null || parsed.hasErrors && parsed.program.body.length === 0;
1733
+ if (!fatal) return null;
1734
+ return {
1735
+ code: "parse-error",
1736
+ severity: "warning",
1737
+ 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",
1738
+ file: file.displayPath,
1739
+ line: 1,
1740
+ hint: "Fix the file's syntax (or exclude it from .uidex.json sources) so the scanner can read its annotations"
1685
1741
  };
1686
1742
  }
1743
+ function extract(files) {
1744
+ return files.map((file) => {
1745
+ const parsed = parseSource(file);
1746
+ const { exports, diagnostics } = extractUidexExports(file, parsed);
1747
+ const parseFailure = parseFailureDiagnostic(file, parsed);
1748
+ if (parseFailure) diagnostics.push(parseFailure);
1749
+ const out2 = { file, annotations: [] };
1750
+ out2.annotations = extractOne(file, parsed, out2);
1751
+ if (exports.length > 0) out2.metadata = exports;
1752
+ if (diagnostics.length > 0) out2.diagnostics = diagnostics;
1753
+ const flows = collectFlowFacts(parsed, file.content);
1754
+ if (flows.length > 0) out2.flows = flows;
1755
+ const imports = collectImportFacts(parsed);
1756
+ if (imports.length > 0) out2.imports = imports;
1757
+ return out2;
1758
+ });
1759
+ }
1760
+ function extractOne(file, parsed, out2) {
1761
+ const annotations = [];
1762
+ const { displayPath } = file;
1763
+ const jsx = collectJSXFacts(parsed);
1764
+ if (jsx.dynamicAttrs.length > 0) out2.dynamicAttrs = jsx.dynamicAttrs;
1765
+ if (jsx.unannotatedInteractive.length > 0) {
1766
+ out2.unannotatedInteractive = jsx.unannotatedInteractive;
1767
+ }
1768
+ if (jsx.landmarks.length > 0) out2.landmarks = jsx.landmarks;
1769
+ for (const occ of jsx.occurrences) {
1770
+ annotations.push({
1771
+ kind: occ.kind,
1772
+ id: occ.id,
1773
+ file: displayPath,
1774
+ line: occ.line,
1775
+ ...occ.ancestors.length > 0 ? { ancestors: occ.ancestors } : {},
1776
+ ...occ.span ? { span: occ.span } : {}
1777
+ });
1778
+ }
1779
+ return annotations;
1780
+ }
1781
+ function collectImportFacts(parsed) {
1782
+ if (parsed.program === null) return [];
1783
+ const out2 = [];
1784
+ for (const stmt of parsed.program.body) {
1785
+ let source;
1786
+ let isTypeOnly = false;
1787
+ const names = [];
1788
+ if (stmt.type === "ImportDeclaration") {
1789
+ source = stmt.source;
1790
+ isTypeOnly = stmt.importKind === "type";
1791
+ for (const spec of stmt.specifiers ?? []) {
1792
+ const local = spec.local;
1793
+ if (local && local.type === "Identifier") {
1794
+ names.push(String(local.name));
1795
+ }
1796
+ }
1797
+ } else if ((stmt.type === "ExportNamedDeclaration" || stmt.type === "ExportAllDeclaration") && stmt.source) {
1798
+ source = stmt.source;
1799
+ isTypeOnly = stmt.exportKind === "type";
1800
+ } else {
1801
+ continue;
1802
+ }
1803
+ if (!source || source.type !== "Literal") continue;
1804
+ if (typeof source.value !== "string") continue;
1805
+ out2.push({
1806
+ specifier: source.value,
1807
+ line: parsed.lineAt(stmt.start),
1808
+ span: { start: stmt.start, end: stmt.end },
1809
+ isTypeOnly,
1810
+ names
1811
+ });
1812
+ }
1813
+ return out2;
1814
+ }
1815
+
1816
+ // src/scanner/scan/resolve.ts
1817
+ import * as path4 from "path";
1687
1818
 
1688
1819
  // src/scanner/scan/routes.ts
1689
1820
  var PAGE_BASENAME = /^page\.(tsx|ts|jsx|js|mjs|cjs)$/;
@@ -1771,21 +1902,9 @@ function kebab(str) {
1771
1902
  return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").replace(/[^a-zA-Z0-9-]/g, "").toLowerCase();
1772
1903
  }
1773
1904
  function baseName(file) {
1774
- const b = path3.posix.basename(file);
1905
+ const b = path4.posix.basename(file);
1775
1906
  return b.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "");
1776
1907
  }
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;
1788
- }
1789
1908
  function fileMatchesAny(displayPath, patterns) {
1790
1909
  return patterns.some((g) => globToRegExp(g).test(displayPath));
1791
1910
  }
@@ -1796,6 +1915,18 @@ function buildMetaFromExport(exp) {
1796
1915
  if (exp.acceptance?.length) meta.acceptance = exp.acceptance;
1797
1916
  if (exp.features?.length) meta.features = exp.features;
1798
1917
  if (exp.widgets?.length) meta.widgets = exp.widgets;
1918
+ if (exp.states?.length) {
1919
+ meta.states = exp.states.map((s) => ({
1920
+ name: s.name,
1921
+ ...s.kind ? { kind: s.kind } : {},
1922
+ ...s.acceptance?.length ? { acceptance: s.acceptance } : {},
1923
+ ...s.description ? { description: s.description } : {}
1924
+ }));
1925
+ }
1926
+ if (exp.capture === false) meta.capture = false;
1927
+ if (exp.waivers && Object.keys(exp.waivers).length > 0) {
1928
+ meta.waivers = exp.waivers;
1929
+ }
1799
1930
  return Object.keys(meta).length > 0 ? meta : void 0;
1800
1931
  }
1801
1932
  function resolve2(ctx) {
@@ -1846,7 +1977,7 @@ function resolve2(ctx) {
1846
1977
  const routes = conventions.pages === "auto" ? detectRoutes(ctx.extracted.map((e) => e.file)) : [];
1847
1978
  const handledPageFiles = /* @__PURE__ */ new Set();
1848
1979
  for (const route of routes) {
1849
- const routeDir = path3.posix.dirname(route.file);
1980
+ const routeDir = path4.posix.dirname(route.file);
1850
1981
  const wellKnownPath = `${routeDir}/${WELL_KNOWN_FILES.page}`;
1851
1982
  const wellKnownExp = exportFor(wellKnownPath, "page");
1852
1983
  const routeExp = exportFor(route.file, "page");
@@ -1900,7 +2031,7 @@ function resolve2(ctx) {
1900
2031
  const dir = extractFeatureDir(ef.file.displayPath, featureGlob);
1901
2032
  if (!dir) continue;
1902
2033
  conventionalFeatureDirs.add(dir);
1903
- const isWellKnown = path3.posix.basename(ef.file.displayPath) === WELL_KNOWN_FILES.feature;
2034
+ const isWellKnown = path4.posix.basename(ef.file.displayPath) === WELL_KNOWN_FILES.feature;
1904
2035
  if (isWellKnown) wellKnownFeatureFileByDir.set(dir, ef.file.displayPath);
1905
2036
  const exp = exportFor(ef.file.displayPath, "feature");
1906
2037
  if (exp) {
@@ -1937,7 +2068,7 @@ function resolve2(ctx) {
1937
2068
  } else if (allExports.length > 0) {
1938
2069
  exp = allExports[0].exp;
1939
2070
  }
1940
- const id = exp && typeof exp.id === "string" ? exp.id : path3.posix.basename(dir);
2071
+ const id = exp && typeof exp.id === "string" ? exp.id : path4.posix.basename(dir);
1941
2072
  const meta = exp ? buildMetaFromExport(exp) : void 0;
1942
2073
  const feature = {
1943
2074
  kind: "feature",
@@ -2033,8 +2164,8 @@ function resolve2(ctx) {
2033
2164
  }
2034
2165
  if (conventions.regions === "landmarks") {
2035
2166
  for (const ef of ctx.extracted) {
2036
- for (const lm of extractLandmarks(ef.file)) {
2037
- const id = kebab(`${lm.tag}`);
2167
+ for (const lm of ef.landmarks ?? []) {
2168
+ const id = lm.tag;
2038
2169
  if (!registry.get("region", id)) {
2039
2170
  const meta = metaWithComposes("region", id);
2040
2171
  const region = {
@@ -2145,7 +2276,7 @@ function resolve2(ctx) {
2145
2276
  const flowExport = (ff.metadata ?? []).find(
2146
2277
  (m) => m.kind === "flow" && typeof m.id === "string"
2147
2278
  );
2148
- const derived = extractFlowsFromSource(ff.file);
2279
+ const derived = flowsFromFacts(ff);
2149
2280
  if (flowExport && typeof flowExport.id === "string" && derived.length === 1) {
2150
2281
  const base = derived[0];
2151
2282
  const flow = {
@@ -2200,60 +2331,312 @@ function computeScope(displayPath) {
2200
2331
  }
2201
2332
  return null;
2202
2333
  }
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
- }
2334
+ function flowsFromFacts(ff) {
2335
+ return (ff.flows ?? []).map((fact) => ({
2336
+ kind: "flow",
2337
+ id: kebab(fact.title),
2338
+ loc: { file: ff.file.displayPath, line: fact.line },
2339
+ touches: dedupe(fact.calls.map((c) => c.id)),
2340
+ steps: fact.calls.filter((c) => c.action).map((c) => ({ entityId: c.id, action: c.action }))
2341
+ }));
2342
+ }
2343
+ function dedupe(arr) {
2344
+ return Array.from(new Set(arr));
2345
+ }
2346
+
2347
+ // src/scanner/scan/audit.ts
2348
+ import * as path5 from "path";
2349
+
2350
+ // src/scanner/scan/page-coverage.ts
2351
+ function compileRoute(pattern) {
2352
+ const segs = pattern.split("/").filter(Boolean);
2353
+ let specificity = 0;
2354
+ const optionalTail = segs.length > 0 && /^\[\[\.{3}.+\]\]$/.test(segs[segs.length - 1]);
2355
+ const effective = optionalTail ? segs.slice(0, -1) : segs;
2356
+ const parts = effective.map((seg) => {
2357
+ const catchAll = /^\[\.{3}.+\]$/.test(seg);
2358
+ const dynamic = /^\[.+\]$/.test(seg);
2359
+ if (catchAll) return "(?:[^/]+)(?:/[^/]+)*";
2360
+ if (dynamic) return "[^/]+";
2361
+ specificity++;
2362
+ return seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2363
+ });
2364
+ let body = parts.length === 0 ? "" : "/" + parts.join("/");
2365
+ if (optionalTail) body += "(?:/[^/]+)*";
2366
+ if (body === "") body = "/";
2367
+ const re = new RegExp(`^${body}/?$`);
2368
+ return { test: (url) => re.test(url), specificity };
2369
+ }
2370
+ function matchUrlToRoute(url, routePaths) {
2371
+ const path12 = stripQuery(url);
2372
+ let best = null;
2373
+ for (const rp of routePaths) {
2374
+ const { test, specificity } = compileRoute(rp);
2375
+ if (!test(path12)) continue;
2376
+ if (!best || specificity > best.specificity)
2377
+ best = { path: rp, specificity };
2378
+ }
2379
+ return best?.path ?? null;
2380
+ }
2381
+ function stripQuery(url) {
2382
+ const q = url.indexOf("?");
2383
+ const h = url.indexOf("#");
2384
+ let end = url.length;
2385
+ if (q !== -1) end = Math.min(end, q);
2386
+ if (h !== -1) end = Math.min(end, h);
2387
+ return url.slice(0, end);
2388
+ }
2389
+ function routeForCapture(c, routes, routePaths) {
2390
+ if (c.route && routePaths.includes(c.route)) return c.route;
2391
+ if (c.url) {
2392
+ const m = matchUrlToRoute(c.url, routePaths);
2393
+ if (m) return m;
2394
+ }
2395
+ if (c.kind === "page") {
2396
+ const r = routes.find((r2) => r2.page === c.entity);
2397
+ if (r) return r.path;
2398
+ }
2399
+ return null;
2400
+ }
2401
+ function checkPageCoverage(registry, manifest, baseline) {
2402
+ const routes = registry.list("route");
2403
+ if (routes.length === 0) return [];
2404
+ const routePaths = routes.map((r) => r.path);
2405
+ const captured = manifest.captured ?? [];
2406
+ const optedOut = /* @__PURE__ */ new Set();
2407
+ for (const r of routes) {
2408
+ const page = registry.get("page", r.page);
2409
+ if (page?.meta?.capture === false) optedOut.add(r.path);
2410
+ }
2411
+ const covered = /* @__PURE__ */ new Set();
2412
+ for (const c of captured) {
2413
+ const route = routeForCapture(c, routes, routePaths);
2414
+ if (route) covered.add(route);
2415
+ }
2416
+ const baselineSet = new Set(baseline?.uncapturedPages ?? []);
2417
+ const diagnostics = [];
2418
+ for (const r of routes) {
2419
+ if (covered.has(r.path) || optedOut.has(r.path)) continue;
2420
+ const loc = registry.get("page", r.page)?.loc;
2421
+ if (baselineSet.has(r.path)) {
2422
+ diagnostics.push({
2423
+ code: "page-backlog",
2424
+ severity: "info",
2425
+ message: `route "${r.path}" has no visual-states capture (acknowledged backlog)`,
2426
+ file: loc?.file,
2427
+ line: loc?.line,
2428
+ hint: `Capture it (drive the route in a states spec) and it drops off the baseline, or opt it out with \`export const uidex = { page: "${r.page}", capture: false }\`.`
2429
+ });
2430
+ continue;
2227
2431
  }
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 }))
2432
+ diagnostics.push({
2433
+ code: "page-no-capture",
2434
+ severity: "error",
2435
+ message: `route "${r.path}" has no visual-states capture and is not accounted for`,
2436
+ file: loc?.file,
2437
+ line: loc?.line,
2438
+ entity: { kind: "route", id: r.path },
2439
+ hint: `Capture the route in a states spec, opt it out with \`export const uidex = { page: "${r.page}", capture: false }\`, or grandfather it as acknowledged backlog with \`uidex scan --update-baseline\` (the baseline change lands in the diff for review).`
2237
2440
  });
2238
2441
  }
2239
- return flows;
2442
+ for (const b of baselineSet) {
2443
+ if (!routePaths.includes(b)) {
2444
+ diagnostics.push({
2445
+ code: "stale-page-baseline",
2446
+ severity: "warning",
2447
+ message: `coverage baseline lists "${b}", which is not a route in the registry`,
2448
+ hint: `Remove it from the baseline (\`uidex scan --update-baseline\`); the route no longer exists.`
2449
+ });
2450
+ } else if (covered.has(b) || optedOut.has(b)) {
2451
+ diagnostics.push({
2452
+ code: "page-captured",
2453
+ severity: "warning",
2454
+ message: `route "${b}" is now captured but is still in the coverage baseline`,
2455
+ hint: `Prune it with \`uidex scan --update-baseline\` so the backlog reflects reality.`
2456
+ });
2457
+ }
2458
+ }
2459
+ return diagnostics;
2240
2460
  }
2241
- function captureUidexIds(body) {
2461
+ function computePageBaseline(registry, manifest) {
2462
+ const uncaptured = [];
2463
+ for (const d of checkPageCoverage(registry, manifest, {
2464
+ uncapturedPages: []
2465
+ })) {
2466
+ if (d.code === "page-no-capture" && d.entity) uncaptured.push(d.entity.id);
2467
+ }
2468
+ return { uncapturedPages: uncaptured.sort() };
2469
+ }
2470
+
2471
+ // src/scanner/scan/state-coverage.ts
2472
+ var STATE_KINDS2 = ["page", "feature", "widget"];
2473
+ function declaredStateEntities(registry) {
2242
2474
  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] });
2475
+ for (const kind of STATE_KINDS2) {
2476
+ for (const e of registry.list(kind)) {
2477
+ const states = e.meta?.states;
2478
+ if (!states || states.length === 0) continue;
2479
+ out2.push({
2480
+ kind,
2481
+ id: e.id,
2482
+ states: states.map((s) => s.name),
2483
+ loc: e.loc
2484
+ });
2485
+ }
2247
2486
  }
2248
2487
  return out2;
2249
2488
  }
2250
- function dedupe(arr) {
2251
- return Array.from(new Set(arr));
2489
+ function captureMatchesEntity(cap, ent) {
2490
+ if (cap.entity !== ent.id) return false;
2491
+ return cap.kind === void 0 || cap.kind === ent.kind;
2492
+ }
2493
+ function checkStateCoverage(registry, manifest) {
2494
+ const diagnostics = [];
2495
+ const declared = declaredStateEntities(registry);
2496
+ const captured = manifest.captured ?? [];
2497
+ for (const ent of declared) {
2498
+ for (const state of ent.states) {
2499
+ const hit = captured.some(
2500
+ (c) => captureMatchesEntity(c, ent) && c.state === state
2501
+ );
2502
+ if (hit) continue;
2503
+ diagnostics.push({
2504
+ code: "missing-state-capture",
2505
+ severity: "warning",
2506
+ message: `${ent.kind} "${ent.id}" declares state "${state}" but no capture produced it`,
2507
+ file: ent.loc?.file,
2508
+ line: ent.loc?.line,
2509
+ entity: { kind: ent.kind, id: ent.id },
2510
+ hint: `Add a capture for "${ent.id}/${state}" to the states matrix, or remove the state from \`export const uidex = { states: [...] }\` if it no longer exists.`
2511
+ });
2512
+ }
2513
+ }
2514
+ for (const cap of captured) {
2515
+ const candidates = declared.filter((ent2) => captureMatchesEntity(cap, ent2));
2516
+ if (candidates.length === 0) continue;
2517
+ const declaredSomewhere = candidates.some(
2518
+ (ent2) => ent2.states.includes(cap.state)
2519
+ );
2520
+ if (declaredSomewhere) continue;
2521
+ const ent = candidates[0];
2522
+ diagnostics.push({
2523
+ code: "orphan-state-capture",
2524
+ severity: "warning",
2525
+ message: `Capture produced state "${cap.state}" for ${ent.kind} "${ent.id}", which is not in its declared states [${ent.states.join(", ")}]`,
2526
+ file: ent.loc?.file,
2527
+ line: ent.loc?.line,
2528
+ entity: { kind: ent.kind, id: ent.id },
2529
+ hint: `Declare "${cap.state}" in \`export const uidex = { states: [...] }\` for "${ent.id}", or rename the capture to a declared state.`
2530
+ });
2531
+ }
2532
+ return diagnostics;
2533
+ }
2534
+
2535
+ // src/scanner/scan/state-matrix.ts
2536
+ var CORE_KINDS = CORE_STATE_KINDS;
2537
+ var STATE_ENTITY_KINDS = ["page", "feature", "widget"];
2538
+ function resolveMatrixKind(registry, c) {
2539
+ if (c.stateKind) return c.stateKind;
2540
+ for (const kind of STATE_ENTITY_KINDS) {
2541
+ const declared = registry.get(kind, c.entity)?.meta?.states?.find((s) => s.name === c.state)?.kind;
2542
+ if (declared) return declared;
2543
+ }
2544
+ return CORE_KINDS.includes(c.state) ? c.state : "variant";
2545
+ }
2546
+ function coreKindsByRoute(registry, manifest) {
2547
+ const routes = registry.list("route");
2548
+ const routePaths = routes.map((r) => r.path);
2549
+ const byRoute = /* @__PURE__ */ new Map();
2550
+ for (const c of manifest.captured ?? []) {
2551
+ const route = routeForCapture(c, routes, routePaths);
2552
+ if (!route) continue;
2553
+ if (!byRoute.has(route)) byRoute.set(route, /* @__PURE__ */ new Set());
2554
+ const kind = resolveMatrixKind(registry, c);
2555
+ if (CORE_KINDS.includes(kind)) byRoute.get(route).add(kind);
2556
+ }
2557
+ return byRoute;
2558
+ }
2559
+ function pageWaivers(registry, routePage) {
2560
+ return registry.get("page", routePage)?.meta?.waivers ?? {};
2561
+ }
2562
+ function isOptedOut(registry, page) {
2563
+ return page ? registry.get("page", page)?.meta?.capture === false : false;
2564
+ }
2565
+ function checkStateMatrix(registry, manifest, baseline) {
2566
+ const routes = registry.list("route");
2567
+ if (routes.length === 0) return [];
2568
+ const pageOf = new Map(routes.map((r) => [r.path, r.page]));
2569
+ const captured = coreKindsByRoute(registry, manifest);
2570
+ const baselined = baseline?.missingKinds ?? {};
2571
+ const diagnostics = [];
2572
+ for (const [route, kinds] of captured) {
2573
+ const page = pageOf.get(route);
2574
+ if (isOptedOut(registry, page)) continue;
2575
+ const waivers = page ? pageWaivers(registry, page) : {};
2576
+ const loc = page ? registry.get("page", page)?.loc : void 0;
2577
+ const baselinedKinds = new Set(baselined[route] ?? []);
2578
+ for (const kind of CORE_KINDS) {
2579
+ if (kinds.has(kind)) continue;
2580
+ if (waivers[kind]) continue;
2581
+ if (baselinedKinds.has(kind)) {
2582
+ diagnostics.push({
2583
+ code: "matrix-backlog",
2584
+ severity: "info",
2585
+ message: `route "${route}" does not render core kind "${kind}" (acknowledged MISSING_KINDS backlog)`,
2586
+ file: loc?.file,
2587
+ line: loc?.line,
2588
+ hint: `Capture the "${kind}" state for "${route}", waive it (\`waivers: { ${kind}: "why it can't" }\` on the page), or leave it in the baseline.`
2589
+ });
2590
+ continue;
2591
+ }
2592
+ diagnostics.push({
2593
+ code: "core-kind",
2594
+ severity: "error",
2595
+ message: `route "${route}" is captured but never renders core kind "${kind}"`,
2596
+ file: loc?.file,
2597
+ line: loc?.line,
2598
+ entity: { kind: "route", id: route },
2599
+ hint: `Capture the "${kind}" state, or if the route cannot render it add \`export const uidex = { page: "${page}", waivers: { ${kind}: "why it can't" } }\`, or grandfather it via \`uidex scan --update-baseline\` (the baseline change lands in the diff for review).`
2600
+ });
2601
+ }
2602
+ }
2603
+ for (const [route, kinds] of captured) {
2604
+ const page = pageOf.get(route);
2605
+ if (!page || isOptedOut(registry, page)) continue;
2606
+ const waivers = pageWaivers(registry, page);
2607
+ const loc = registry.get("page", page)?.loc;
2608
+ for (const kind of Object.keys(waivers)) {
2609
+ if (kinds.has(kind)) {
2610
+ diagnostics.push({
2611
+ code: "stale-waiver",
2612
+ severity: "warning",
2613
+ message: `page "${page}" waives core kind "${kind}", but route "${route}" does render it`,
2614
+ file: loc?.file,
2615
+ line: loc?.line,
2616
+ entity: { kind: "page", id: page },
2617
+ hint: `Remove the "${kind}" waiver \u2014 the route renders that kind, so the waiver's reason is stale.`
2618
+ });
2619
+ }
2620
+ }
2621
+ }
2622
+ return diagnostics;
2623
+ }
2624
+ function computeMissingKinds(registry, manifest) {
2625
+ const routes = registry.list("route");
2626
+ const pageOf = new Map(routes.map((r) => [r.path, r.page]));
2627
+ const captured = coreKindsByRoute(registry, manifest);
2628
+ const out2 = {};
2629
+ for (const [route, kinds] of captured) {
2630
+ const page = pageOf.get(route);
2631
+ if (isOptedOut(registry, page)) continue;
2632
+ const waivers = page ? pageWaivers(registry, page) : {};
2633
+ const missing = CORE_KINDS.filter((k) => !kinds.has(k) && !waivers[k]);
2634
+ if (missing.length > 0) out2[route] = missing;
2635
+ }
2636
+ return out2;
2252
2637
  }
2253
2638
 
2254
2639
  // src/scanner/scan/audit.ts
2255
- import * as path4 from "path";
2256
- var MARKER_FILENAMES = ["UIDEX_PAGE.md", "UIDEX_FEATURE.md"];
2257
2640
  function audit(opts) {
2258
2641
  const diagnostics = [];
2259
2642
  const { registry, extracted, files, config } = opts;
@@ -2262,23 +2645,19 @@ function audit(opts) {
2262
2645
  const acceptanceEnabled = config.audit?.acceptance ?? true;
2263
2646
  const scopeLeakEnabled = config.audit?.scopeLeak ?? true;
2264
2647
  const coverageEnabled = config.audit?.coverage ?? true;
2648
+ const statesEnabled = config.audit?.states ?? true;
2649
+ const pageCoverageEnabled = config.audit?.pageCoverage ?? true;
2650
+ const stateMatrixEnabled = config.audit?.stateMatrix ?? true;
2265
2651
  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
- }
2652
+ for (const ef of extracted) {
2653
+ if (ef.diagnostics) diagnostics.push(...ef.diagnostics);
2654
+ }
2655
+ for (const ef of opts.flowExtracted ?? []) {
2656
+ if (ef.diagnostics) diagnostics.push(...ef.diagnostics);
2278
2657
  }
2279
2658
  if (check && opts.generated !== void 0) {
2280
2659
  const outRel = opts.outputPath ?? config.output;
2281
- const fresh = normalizeLineEndings(opts.generated);
2660
+ const fresh = normalizeForCheck(opts.generated);
2282
2661
  if (opts.existingOnDisk === null || opts.existingOnDisk === void 0) {
2283
2662
  diagnostics.push({
2284
2663
  code: "gen-missing",
@@ -2288,7 +2667,7 @@ function audit(opts) {
2288
2667
  hint: "Run `uidex scan` (without --check) to regenerate"
2289
2668
  });
2290
2669
  } else {
2291
- const existing = normalizeLineEndings(opts.existingOnDisk);
2670
+ const existing = normalizeForCheck(opts.existingOnDisk);
2292
2671
  if (existing !== fresh) {
2293
2672
  const changed = diffEntities(existing, opts.generated, registry);
2294
2673
  const summary2 = formatChangedSummary(changed);
@@ -2302,20 +2681,46 @@ function audit(opts) {
2302
2681
  }
2303
2682
  }
2304
2683
  }
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
- }
2684
+ if (check && opts.typesGenerated !== void 0) {
2685
+ const outRel = opts.typesOutputPath ?? config.output;
2686
+ const fresh = normalizeForCheck(opts.typesGenerated);
2687
+ if (opts.typesOnDisk === null || opts.typesOnDisk === void 0) {
2688
+ diagnostics.push({
2689
+ code: "gen-missing",
2690
+ severity: "error",
2691
+ message: `Generated file "${outRel}" does not exist on disk; run \`uidex scan\` and commit the result`,
2692
+ file: outRel,
2693
+ hint: "Run `uidex scan` (without --check) to regenerate"
2694
+ });
2695
+ } else if (normalizeForCheck(opts.typesOnDisk) !== fresh) {
2696
+ diagnostics.push({
2697
+ code: "gen-stale",
2698
+ severity: "error",
2699
+ message: `Generated file "${outRel}" is stale`,
2700
+ file: outRel,
2701
+ hint: "Run `uidex scan` (without --check) to regenerate and commit the result"
2702
+ });
2703
+ }
2704
+ }
2705
+ if (check && opts.locsGenerated !== void 0) {
2706
+ const outRel = opts.locsOutputPath ?? config.output;
2707
+ const fresh = normalizeForCheck(opts.locsGenerated);
2708
+ if (opts.locsOnDisk === null || opts.locsOnDisk === void 0) {
2709
+ diagnostics.push({
2710
+ code: "gen-missing",
2711
+ severity: "error",
2712
+ message: `Generated file "${outRel}" does not exist on disk; run \`uidex scan\` and commit the result`,
2713
+ file: outRel,
2714
+ hint: "Run `uidex scan` (without --check) to regenerate"
2715
+ });
2716
+ } else if (normalizeForCheck(opts.locsOnDisk) !== fresh) {
2717
+ diagnostics.push({
2718
+ code: "gen-stale",
2719
+ severity: "error",
2720
+ message: `Generated file "${outRel}" is stale`,
2721
+ file: outRel,
2722
+ hint: "Run `uidex scan` (without --check) to regenerate and commit the result"
2723
+ });
2319
2724
  }
2320
2725
  }
2321
2726
  if (lint && acceptanceEnabled) {
@@ -2363,8 +2768,8 @@ function audit(opts) {
2363
2768
  if (typeof m.id !== "string") continue;
2364
2769
  const filePath = ef.file.displayPath;
2365
2770
  const wellKnownName = WELL_KNOWN_FILES[m.kind];
2366
- if (path4.posix.basename(filePath) === wellKnownName) continue;
2367
- const dir = path4.posix.dirname(filePath);
2771
+ if (path5.posix.basename(filePath) === wellKnownName) continue;
2772
+ const dir = path5.posix.dirname(filePath);
2368
2773
  const wellKnownPath = dir === "." ? wellKnownName : `${dir}/${wellKnownName}`;
2369
2774
  if (scannedPaths.has(wellKnownPath)) continue;
2370
2775
  const kindLabel = m.kind === "page" ? "Page" : "Feature";
@@ -2381,53 +2786,55 @@ function audit(opts) {
2381
2786
  }
2382
2787
  }
2383
2788
  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";
2789
+ for (const ef of extracted) {
2790
+ for (const fact of ef.dynamicAttrs ?? []) {
2401
2791
  diagnostics.push({
2402
2792
  code: "dynamic-attr",
2403
2793
  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)
2794
+ message: `\`${fact.attrName}={\u2026}\` uses a dynamic expression; the scanner cannot resolve the ${fact.kind} id statically`,
2795
+ file: ef.file.displayPath,
2796
+ line: fact.line,
2797
+ hint: dynamicAttrHint(fact.kind)
2408
2798
  });
2409
2799
  }
2410
2800
  }
2411
2801
  }
2412
2802
  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
- });
2803
+ const usedElementIds = new Set(registry.list("element").map((e) => e.id));
2804
+ for (const ef of extracted) {
2805
+ for (const fact of ef.unannotatedInteractive ?? []) {
2806
+ if (fact.hasSpread) {
2807
+ diagnostics.push({
2808
+ code: "spread-attr",
2809
+ severity: "info",
2810
+ 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`,
2811
+ file: ef.file.displayPath,
2812
+ line: fact.line,
2813
+ hint: "Prefer a string-literal data-uidex on the element itself, or annotate at the call site."
2814
+ });
2815
+ } else {
2816
+ const id = uniqueElementId(fact, usedElementIds);
2817
+ usedElementIds.add(id);
2818
+ diagnostics.push({
2819
+ code: "missing-element-annotation",
2820
+ severity: "info",
2821
+ message: `Interactive <${fact.tag}> without data-uidex annotation`,
2822
+ file: ef.file.displayPath,
2823
+ line: fact.line,
2824
+ hint: `Add \`data-uidex="${id}"\` (or run \`uidex scan --fix\`).`,
2825
+ fix: {
2826
+ description: `Add data-uidex="${id}" to <${fact.tag}>`,
2827
+ edits: [
2828
+ {
2829
+ path: ef.file.sourcePath,
2830
+ start: fact.nameEnd,
2831
+ end: fact.nameEnd,
2832
+ replacement: ` data-uidex="${id}"`
2833
+ }
2834
+ ]
2835
+ }
2836
+ });
2837
+ }
2431
2838
  }
2432
2839
  }
2433
2840
  }
@@ -2444,12 +2851,11 @@ function audit(opts) {
2444
2851
  }
2445
2852
  }
2446
2853
  }
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() ?? "";
2854
+ for (const ef of extracted) {
2855
+ const displayPath = ef.file.displayPath;
2856
+ for (const imp of ef.imports ?? []) {
2857
+ if (imp.isTypeOnly) continue;
2858
+ const baseName2 = imp.specifier.split("/").pop() ?? "";
2453
2859
  const primitive = byName.get(
2454
2860
  baseName2.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
2455
2861
  );
@@ -2457,25 +2863,37 @@ function audit(opts) {
2457
2863
  const scope = primitive.scopes?.[0];
2458
2864
  if (!scope) continue;
2459
2865
  const [kind, id] = scope.split(":");
2460
- const importerSegments = f.displayPath.split("/");
2866
+ const importerSegments = displayPath.split("/");
2461
2867
  if (importerSegments.includes(id) && importerSegments.includes(kind + "s")) {
2462
2868
  continue;
2463
2869
  }
2464
2870
  if (kind === "feature" && importerSegments.includes(id)) continue;
2465
- if (kind === "feature" && declaredFeatures.get(f.displayPath)?.has(id)) {
2871
+ if (kind === "feature" && declaredFeatures.get(displayPath)?.has(id)) {
2466
2872
  continue;
2467
2873
  }
2468
2874
  diagnostics.push({
2469
2875
  code: "scope-leak",
2470
2876
  severity: "warning",
2471
- message: `Primitive "${primitive.id}" is scoped to ${scope} but is imported from ${f.displayPath}`,
2472
- file: f.displayPath
2877
+ message: `Primitive "${primitive.id}" is scoped to ${scope} but is imported from ${displayPath}`,
2878
+ file: displayPath,
2879
+ line: imp.line
2473
2880
  });
2474
2881
  }
2475
2882
  }
2476
2883
  }
2477
2884
  if (lint && coverageEnabled) {
2885
+ const factsByLoc = /* @__PURE__ */ new Map();
2886
+ for (const ef of opts.flowExtracted ?? []) {
2887
+ for (const fact of ef.flows ?? []) {
2888
+ const lines = /* @__PURE__ */ new Map();
2889
+ for (const call of fact.calls) {
2890
+ if (!lines.has(call.id)) lines.set(call.id, call.line);
2891
+ }
2892
+ factsByLoc.set(`${ef.file.displayPath}:${fact.line}`, lines);
2893
+ }
2894
+ }
2478
2895
  for (const flow of registry.list("flow")) {
2896
+ const callLines = factsByLoc.get(`${flow.loc.file}:${flow.loc.line}`);
2479
2897
  for (const touchedId of flow.touches) {
2480
2898
  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
2899
  if (!found) {
@@ -2484,54 +2902,146 @@ function audit(opts) {
2484
2902
  severity: "warning",
2485
2903
  message: `Flow "${flow.id}" references unknown entity "${touchedId}"`,
2486
2904
  file: flow.loc.file,
2487
- line: flow.loc.line
2905
+ // Point at the uidex() call itself when the spec facts are
2906
+ // available; the describe line is the fallback.
2907
+ line: callLines?.get(touchedId) ?? flow.loc.line
2908
+ });
2909
+ }
2910
+ }
2911
+ }
2912
+ }
2913
+ if (lint) {
2914
+ const occurrences = /* @__PURE__ */ new Map();
2915
+ for (const ef of extracted) {
2916
+ for (const a of ef.annotations) {
2917
+ if (a.kind !== "element" && a.kind !== "region" && a.kind !== "widget" && a.kind !== "primitive") {
2918
+ continue;
2919
+ }
2920
+ const key = `${a.kind}:${a.id}`;
2921
+ let list = occurrences.get(key);
2922
+ if (!list) {
2923
+ list = [];
2924
+ occurrences.set(key, list);
2925
+ }
2926
+ list.push({ file: a.file, line: a.line });
2927
+ }
2928
+ }
2929
+ for (const [key, list] of occurrences) {
2930
+ const filesSeen = new Set(list.map((o) => o.file));
2931
+ if (filesSeen.size < 2) continue;
2932
+ const [kind, id] = key.split(/:(.*)/s);
2933
+ const others = list.slice(1).map((o) => `${o.file}:${o.line}`).join(", ");
2934
+ diagnostics.push({
2935
+ code: "duplicate-id",
2936
+ severity: kind === "widget" || kind === "primitive" ? "warning" : "info",
2937
+ message: `${kind} id "${id}" is declared in ${filesSeen.size} files (also at ${others}); the registry keeps only one entry`,
2938
+ file: list[0].file,
2939
+ line: list[0].line,
2940
+ entity: { kind, id },
2941
+ 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."
2942
+ });
2943
+ }
2944
+ }
2945
+ if (lint && coverageEnabled) {
2946
+ for (const ef of opts.flowExtracted ?? []) {
2947
+ for (const fact of ef.flows ?? []) {
2948
+ for (const dyn of fact.dynamicCalls ?? []) {
2949
+ diagnostics.push({
2950
+ code: "dynamic-flow-reference",
2951
+ severity: "warning",
2952
+ message: `\`uidex(\u2026)\` call in flow "${fact.title}" uses a dynamic expression; the id is invisible to coverage and registry validation`,
2953
+ file: ef.file.displayPath,
2954
+ line: dyn.line,
2955
+ hint: "Use a string-literal id (component ids inside uidex() must be statically analysable)."
2488
2956
  });
2489
2957
  }
2490
2958
  }
2491
2959
  }
2492
2960
  }
2961
+ if (lint && coverageEnabled) {
2962
+ for (const ef of extracted) {
2963
+ if (!ef.metadata) continue;
2964
+ for (const m of ef.metadata) {
2965
+ const check2 = (refKind, ids, spans) => {
2966
+ for (let i = 0; i < (ids?.length ?? 0); i++) {
2967
+ const refId = ids[i];
2968
+ const found = registry.get(refKind, refId) ?? registry.matchPattern(refKind, refId);
2969
+ if (found) continue;
2970
+ diagnostics.push({
2971
+ code: "unknown-reference",
2972
+ severity: "warning",
2973
+ message: `\`export const uidex\` in ${ef.file.displayPath} references unknown ${refKind} "${refId}"`,
2974
+ file: ef.file.displayPath,
2975
+ line: spans?.[i] ? lineOfOffset(ef.file.content, spans[i].start) : m.loc.line,
2976
+ hint: `No ${refKind} with id "${refId}" exists in the registry; fix the reference or add the ${refKind}.`
2977
+ });
2978
+ }
2979
+ };
2980
+ check2("feature", m.features, m.featureSpans);
2981
+ check2("widget", m.widgets, m.widgetSpans);
2982
+ }
2983
+ }
2984
+ }
2985
+ if (lint && statesEnabled && opts.capturedStates) {
2986
+ diagnostics.push(...checkStateCoverage(registry, opts.capturedStates));
2987
+ }
2988
+ if (lint && pageCoverageEnabled && opts.capturedStates) {
2989
+ diagnostics.push(
2990
+ ...checkPageCoverage(registry, opts.capturedStates, opts.pageBaseline)
2991
+ );
2992
+ }
2993
+ if (lint && stateMatrixEnabled && opts.capturedStates) {
2994
+ diagnostics.push(
2995
+ ...checkStateMatrix(registry, opts.capturedStates, {
2996
+ missingKinds: opts.pageBaseline?.missingKinds ?? {}
2997
+ })
2998
+ );
2999
+ }
2493
3000
  const summary = {
2494
3001
  errors: diagnostics.filter((d) => d.severity === "error").length,
2495
3002
  warnings: diagnostics.filter((d) => d.severity === "warning").length
2496
3003
  };
2497
3004
  return { diagnostics, summary };
2498
3005
  }
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;
3006
+ function lineOfOffset(content, offset) {
3007
+ let line = 1;
3008
+ for (let i = 0; i < offset && i < content.length; i++) {
3009
+ if (content[i] === "\n") line++;
3010
+ }
3011
+ return line;
3012
+ }
3013
+ var TAG_FALLBACK_ID = {
3014
+ a: "link",
3015
+ button: "button",
3016
+ input: "input",
3017
+ select: "select",
3018
+ textarea: "textarea"
3019
+ };
3020
+ function kebabId(str) {
3021
+ return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
3022
+ }
3023
+ function deriveElementId(fact) {
3024
+ const fromHint = fact.nameHint ? kebabId(fact.nameHint) : "";
3025
+ const capped = fromHint.split("-").filter(Boolean).slice(0, 5).join("-");
3026
+ return capped || TAG_FALLBACK_ID[fact.tag] || fact.tag;
3027
+ }
3028
+ function uniqueElementId(fact, used) {
3029
+ const base = deriveElementId(fact);
3030
+ if (!used.has(base)) return base;
3031
+ for (let n = 2; ; n++) {
3032
+ const candidate = `${base}-${n}`;
3033
+ if (!used.has(candidate)) return candidate;
2530
3034
  }
2531
3035
  }
2532
3036
  function normalizeLineEndings(s) {
2533
3037
  return s.replace(/\r\n/g, "\n");
2534
3038
  }
3039
+ function normalizeForCheck(s) {
3040
+ return normalizeLineEndings(s).replace(
3041
+ /export const gitContext = \{[\s\S]*?\} as const/,
3042
+ "export const gitContext = {} as const"
3043
+ );
3044
+ }
2535
3045
  function formatChangedSummary(change) {
2536
3046
  const parts = [];
2537
3047
  const fmt = (kind, names) => {
@@ -2615,77 +3125,26 @@ function extractEntitiesArray(source) {
2615
3125
  inStr = c;
2616
3126
  continue;
2617
3127
  }
2618
- if (c === "[") depth++;
2619
- else if (c === "]") {
2620
- depth--;
2621
- if (depth === 0) {
2622
- const json = source.slice(start, i + 1);
2623
- try {
2624
- return JSON.parse(json);
2625
- } catch {
2626
- return null;
2627
- }
2628
- }
2629
- }
2630
- }
2631
- return null;
2632
- }
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 === "}") {
3128
+ if (c === "[") depth++;
3129
+ else if (c === "]") {
2674
3130
  depth--;
2675
- i++;
2676
- } else if (ch === '"' || ch === "'" || ch === "`") {
2677
- i = skipString2(src, i);
2678
- } else {
2679
- i++;
3131
+ if (depth === 0) {
3132
+ const json = source.slice(start, i + 1);
3133
+ try {
3134
+ return JSON.parse(json);
3135
+ } catch {
3136
+ return null;
3137
+ }
3138
+ }
2680
3139
  }
2681
3140
  }
2682
- return i;
3141
+ return null;
2683
3142
  }
2684
3143
  function dynamicAttrHint(kind) {
2685
3144
  if (kind === "region") {
2686
3145
  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
3146
  }
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`;
3147
+ 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
3148
  }
2690
3149
  function stableStringify(value) {
2691
3150
  return JSON.stringify(value, stableReplacer);
@@ -2702,6 +3161,148 @@ function stableReplacer(_key, value) {
2702
3161
  }
2703
3162
 
2704
3163
  // src/scanner/scan/emit.ts
3164
+ var AUTHORED_KINDS = /* @__PURE__ */ new Set([
3165
+ "route",
3166
+ "page",
3167
+ "feature",
3168
+ "widget",
3169
+ "primitive",
3170
+ "flow"
3171
+ ]);
3172
+ function internedPayload(entities) {
3173
+ const fileIndex = /* @__PURE__ */ new Map();
3174
+ const files = [];
3175
+ const compact = entities.map((e) => {
3176
+ const loc = e.loc;
3177
+ if (!loc || typeof loc.file !== "string") return e;
3178
+ let idx = fileIndex.get(loc.file);
3179
+ if (idx === void 0) {
3180
+ idx = files.length;
3181
+ files.push(loc.file);
3182
+ fileIndex.set(loc.file, idx);
3183
+ }
3184
+ const rest = { ...loc };
3185
+ delete rest.file;
3186
+ return { ...e, loc: { f: idx, ...rest } };
3187
+ });
3188
+ return {
3189
+ filesDecl: `const files: readonly string[] = ${jsonStable(files)}`,
3190
+ parseExpr: `(JSON.parse(${JSON.stringify(
3191
+ JSON.stringify(compact, replacerSorted)
3192
+ )}) as any[]).map(hydrate)`
3193
+ };
3194
+ }
3195
+ var HYDRATE_FN = [
3196
+ "function hydrate(e: any): any {",
3197
+ ' if (e.loc && typeof e.loc.f === "number") {',
3198
+ " const { f, ...rest } = e.loc",
3199
+ " e.loc = { file: files[f], ...rest }",
3200
+ " }",
3201
+ " return e",
3202
+ "}"
3203
+ ].join("\n");
3204
+ function compactMeta(meta) {
3205
+ if (!meta || typeof meta !== "object") return meta;
3206
+ const composes = meta.composes;
3207
+ if (!Array.isArray(composes)) return meta;
3208
+ return {
3209
+ ...meta,
3210
+ composes: composes.map(
3211
+ (c) => c && typeof c === "object" && c.kind === "element" && Object.keys(c).length === 2 && typeof c.id === "string" ? c.id : c
3212
+ )
3213
+ };
3214
+ }
3215
+ function emitLocsModule(thin, uidexImport) {
3216
+ const kinds = [...new Set(thin.map((e) => e.kind))].sort();
3217
+ const kindIdx = new Map(kinds.map((k, i) => [k, i]));
3218
+ const fileIndex = /* @__PURE__ */ new Map();
3219
+ const files = [];
3220
+ const fi = (f) => {
3221
+ let i = fileIndex.get(f);
3222
+ if (i === void 0) {
3223
+ i = files.length;
3224
+ files.push(f);
3225
+ fileIndex.set(f, i);
3226
+ }
3227
+ return i;
3228
+ };
3229
+ const bare = [];
3230
+ const rich = [];
3231
+ for (const e of thin) {
3232
+ const loc = e.loc;
3233
+ const topOnlyKindIdLoc = Object.keys(e).every(
3234
+ (k) => k === "kind" || k === "id" || k === "loc"
3235
+ );
3236
+ const locOnlyKnown = !!loc && typeof loc.file === "string" && Object.keys(loc).every(
3237
+ (k) => k === "file" || k === "line" || k === "column"
3238
+ );
3239
+ if (topOnlyKindIdLoc && locOnlyKnown) {
3240
+ const row = [
3241
+ kindIdx.get(e.kind),
3242
+ e.id,
3243
+ fi(loc.file)
3244
+ ];
3245
+ if (loc.line != null || loc.column != null)
3246
+ row.push(Number(loc.line ?? 0));
3247
+ if (loc.column != null) row.push(Number(loc.column));
3248
+ bare.push(row);
3249
+ } else {
3250
+ const richEntity = { ...e };
3251
+ if (loc && typeof loc.file === "string") {
3252
+ const { file, ...rest } = loc;
3253
+ richEntity.loc = { f: fi(file), ...rest };
3254
+ }
3255
+ if (richEntity.meta) richEntity.meta = compactMeta(richEntity.meta);
3256
+ rich.push(richEntity);
3257
+ }
3258
+ }
3259
+ const str = (v) => JSON.stringify(JSON.stringify(v, replacerSorted));
3260
+ const l = [];
3261
+ l.push("// THIS FILE IS AUTO-GENERATED BY `uidex scan`. DO NOT EDIT.");
3262
+ l.push(
3263
+ "// Deferred element/region source locations for the inspector's copy-path."
3264
+ );
3265
+ l.push("// @ts-nocheck");
3266
+ l.push("/* eslint-disable */");
3267
+ l.push(`import type { Entity, Registry } from ${JSON.stringify(uidexImport)}`);
3268
+ l.push("");
3269
+ l.push(`const files: readonly string[] = ${jsonStable(files)}`);
3270
+ l.push(`const kinds: readonly string[] = ${jsonStable(kinds)}`);
3271
+ l.push("function loc(fi: number, line?: number, col?: number) {");
3272
+ l.push(" const o: any = { file: files[fi] }");
3273
+ l.push(" if (line) o.line = line");
3274
+ l.push(" if (col) o.column = col");
3275
+ l.push(" return o");
3276
+ l.push("}");
3277
+ l.push(`const bare: any[] = JSON.parse(${str(bare)})`);
3278
+ l.push(`const rich: any[] = JSON.parse(${str(rich)})`);
3279
+ l.push("");
3280
+ l.push(
3281
+ "/** Add the thin element/region entities (source locations) to a registry. */"
3282
+ );
3283
+ l.push("export function loadLocs(target: Registry): Registry {");
3284
+ l.push(" for (const r of bare)");
3285
+ l.push(
3286
+ " target.add({ kind: kinds[r[0]], id: r[1], loc: loc(r[2], r[3], r[4]) } as Entity)"
3287
+ );
3288
+ l.push(" for (const e of rich) {");
3289
+ l.push(" const out: any = { ...e }");
3290
+ l.push(' if (e.loc && typeof e.loc.f === "number") {');
3291
+ l.push(" const { f, ...rest } = e.loc");
3292
+ l.push(" out.loc = { file: files[f], ...rest }");
3293
+ l.push(" }");
3294
+ l.push(" if (e.meta && Array.isArray(e.meta.composes)) {");
3295
+ l.push(
3296
+ ' out.meta = { ...e.meta, composes: e.meta.composes.map((c: any) => typeof c === "string" ? { id: c, kind: "element" } : c) }'
3297
+ );
3298
+ l.push(" }");
3299
+ l.push(" target.add(out)");
3300
+ l.push(" }");
3301
+ l.push(" return target");
3302
+ l.push("}");
3303
+ l.push("");
3304
+ return l.join("\n");
3305
+ }
2705
3306
  function sortById(arr) {
2706
3307
  return [...arr].sort((a, b) => a.id.localeCompare(b.id));
2707
3308
  }
@@ -2718,9 +3319,7 @@ function replacerSorted(_key, value) {
2718
3319
  }
2719
3320
  return value;
2720
3321
  }
2721
- function emitIdUnion(name, ids, typeMode) {
2722
- if (typeMode === "loose") return `export type ${name} = string
2723
- `;
3322
+ function emitIdUnion(name, ids) {
2724
3323
  if (ids.length === 0) return `export type ${name} = never
2725
3324
  `;
2726
3325
  const sorted = [...ids].sort();
@@ -2729,133 +3328,153 @@ function emitIdUnion(name, ids, typeMode) {
2729
3328
  ${body}
2730
3329
  `;
2731
3330
  }
3331
+ var OPAQUE_ID_UNIONS = /* @__PURE__ */ new Set(["ElementId"]);
3332
+ function emitId(name, ids) {
3333
+ if (OPAQUE_ID_UNIONS.has(name)) {
3334
+ return `export type ${name} = string & { readonly __uidex?: ${JSON.stringify(name)} }
3335
+ `;
3336
+ }
3337
+ return emitIdUnion(name, ids);
3338
+ }
3339
+ function dataOutputPath(output) {
3340
+ return output.replace(/\.(d\.ts|ts|tsx|mts|cts|js|mjs|cjs)$/, ".data.$1");
3341
+ }
3342
+ function locsOutputPath(output) {
3343
+ return output.replace(/\.(d\.ts|ts|tsx|mts|cts|js|mjs|cjs)$/, ".locs.$1");
3344
+ }
2732
3345
  function emit(opts) {
2733
- const {
2734
- registry,
2735
- gitContext,
2736
- uidexImport = "uidex",
2737
- typeMode = "strict"
2738
- } = opts;
3346
+ const { registry, gitContext, uidexImport = "uidex" } = opts;
2739
3347
  const routes = [...registry.list("route")].sort(
2740
3348
  (a, b) => a.path.localeCompare(b.path)
2741
3349
  );
2742
3350
  const pages = sortById(registry.list("page"));
2743
3351
  const features = sortById(registry.list("feature"));
2744
3352
  const widgets = sortById(registry.list("widget"));
3353
+ const stateNames = /* @__PURE__ */ new Set();
3354
+ for (const e of [...pages, ...features, ...widgets]) {
3355
+ for (const s of e.meta?.states ?? []) stateNames.add(s.name);
3356
+ }
2745
3357
  const regions = sortById(registry.list("region"));
2746
3358
  const elements = sortById(registry.list("element"));
2747
3359
  const primitives = sortById(registry.list("primitive"));
2748
3360
  const flows = sortById(registry.list("flow"));
2749
- const lines = [];
2750
- lines.push("// THIS FILE IS AUTO-GENERATED BY `uidex scan`. DO NOT EDIT.");
2751
- lines.push("/* eslint-disable */");
2752
- lines.push(`import { createUidex } from ${JSON.stringify(uidexImport)}`);
2753
- lines.push(
2754
- `import type { Entity, Registry } from ${JSON.stringify(uidexImport)}`
3361
+ const t = [];
3362
+ t.push("// THIS FILE IS AUTO-GENERATED BY `uidex scan`. DO NOT EDIT.");
3363
+ t.push(
3364
+ "// Types only \u2014 the runtime registry lives in the sibling `.data` module."
2755
3365
  );
2756
- lines.push("");
2757
- lines.push("// ---- id unions ----");
2758
- lines.push(
2759
- emitIdUnion(
3366
+ t.push("/* eslint-disable */");
3367
+ t.push("");
3368
+ t.push("// ---- id unions ----");
3369
+ t.push(
3370
+ emitId(
2760
3371
  "PageId",
2761
- pages.map((e) => e.id),
2762
- typeMode
3372
+ pages.map((e) => e.id)
2763
3373
  )
2764
3374
  );
2765
- lines.push(
2766
- emitIdUnion(
3375
+ t.push(
3376
+ emitId(
2767
3377
  "FeatureId",
2768
- features.map((e) => e.id),
2769
- typeMode
3378
+ features.map((e) => e.id)
2770
3379
  )
2771
3380
  );
2772
- lines.push(
2773
- emitIdUnion(
3381
+ t.push(
3382
+ emitId(
2774
3383
  "WidgetId",
2775
- widgets.map((e) => e.id),
2776
- typeMode
3384
+ widgets.map((e) => e.id)
2777
3385
  )
2778
3386
  );
2779
- lines.push(
2780
- emitIdUnion(
3387
+ t.push(
3388
+ emitId(
2781
3389
  "RegionId",
2782
- regions.map((e) => e.id),
2783
- typeMode
3390
+ regions.map((e) => e.id)
2784
3391
  )
2785
3392
  );
2786
- lines.push(
2787
- emitIdUnion(
3393
+ t.push(
3394
+ emitId(
2788
3395
  "ElementId",
2789
- elements.map((e) => e.id),
2790
- typeMode
3396
+ elements.map((e) => e.id)
2791
3397
  )
2792
3398
  );
2793
- lines.push(
2794
- emitIdUnion(
3399
+ t.push(
3400
+ emitId(
2795
3401
  "PrimitiveId",
2796
- primitives.map((e) => e.id),
2797
- typeMode
3402
+ primitives.map((e) => e.id)
2798
3403
  )
2799
3404
  );
2800
- lines.push(
2801
- emitIdUnion(
3405
+ t.push(
3406
+ emitId(
2802
3407
  "FlowId",
2803
- flows.map((e) => e.id),
2804
- typeMode
3408
+ flows.map((e) => e.id)
2805
3409
  )
2806
3410
  );
2807
- lines.push(
2808
- emitIdUnion(
3411
+ t.push(
3412
+ emitId(
2809
3413
  "RouteId",
2810
- routes.map((e) => e.path),
2811
- typeMode
3414
+ routes.map((e) => e.path)
2812
3415
  )
2813
3416
  );
2814
- lines.push("");
2815
- lines.push("// ---- authoring-surface shape types ----");
2816
- lines.push("export namespace Uidex {");
2817
- lines.push(" export interface Page {");
2818
- lines.push(" page: PageId | false");
2819
- lines.push(" name?: string");
2820
- lines.push(" features?: readonly FeatureId[]");
2821
- lines.push(" widgets?: readonly WidgetId[]");
2822
- lines.push(" acceptance?: readonly string[]");
2823
- lines.push(" description?: string");
2824
- lines.push(" }");
2825
- lines.push(" export interface Feature {");
2826
- lines.push(" feature: FeatureId | false");
2827
- lines.push(" name?: string");
2828
- lines.push(" features?: readonly FeatureId[]");
2829
- lines.push(" acceptance?: readonly string[]");
2830
- lines.push(" description?: string");
2831
- lines.push(" }");
2832
- lines.push(" export interface Primitive {");
2833
- lines.push(" primitive: PrimitiveId");
2834
- lines.push(" name?: string");
2835
- lines.push(" description?: string");
2836
- lines.push(" }");
2837
- lines.push(" export interface Widget {");
2838
- lines.push(" widget: WidgetId");
2839
- lines.push(" name?: string");
2840
- lines.push(" acceptance?: readonly string[]");
2841
- lines.push(" description?: string");
2842
- lines.push(" }");
2843
- lines.push(" export interface Region {");
2844
- lines.push(" region: RegionId | false");
2845
- lines.push(" name?: string");
2846
- lines.push(" description?: string");
2847
- lines.push(" }");
2848
- lines.push(" export interface Flow {");
2849
- lines.push(" flow: FlowId");
2850
- lines.push(" name?: string");
2851
- lines.push(" description?: string");
2852
- lines.push(" }");
2853
- lines.push(" export interface NotFlow {");
2854
- lines.push(" notFlow: true");
2855
- lines.push(" }");
2856
- lines.push("}");
2857
- lines.push("");
2858
- lines.push("// ---- entities ----");
3417
+ t.push(emitId("StateId", [...stateNames]));
3418
+ t.push("");
3419
+ t.push("// ---- authoring-surface shape types ----");
3420
+ t.push("export namespace Uidex {");
3421
+ t.push(
3422
+ ' export type CoreStateKind = "loading" | "empty" | "populated" | "error"'
3423
+ );
3424
+ t.push(' export type StateKind = CoreStateKind | "variant"');
3425
+ t.push(" export interface State {");
3426
+ t.push(" name: string");
3427
+ t.push(" kind?: StateKind");
3428
+ t.push(" acceptance?: readonly string[]");
3429
+ t.push(" description?: string");
3430
+ t.push(" }");
3431
+ t.push(" export type Waivers = Partial<Record<CoreStateKind, string>>");
3432
+ t.push(" export interface Page {");
3433
+ t.push(" page: PageId | false");
3434
+ t.push(" name?: string");
3435
+ t.push(" features?: readonly FeatureId[]");
3436
+ t.push(" widgets?: readonly WidgetId[]");
3437
+ t.push(" acceptance?: readonly string[]");
3438
+ t.push(" states?: readonly (string | State)[]");
3439
+ t.push(" capture?: boolean");
3440
+ t.push(" waivers?: Waivers");
3441
+ t.push(" description?: string");
3442
+ t.push(" }");
3443
+ t.push(" export interface Feature {");
3444
+ t.push(" feature: FeatureId | false");
3445
+ t.push(" name?: string");
3446
+ t.push(" features?: readonly FeatureId[]");
3447
+ t.push(" acceptance?: readonly string[]");
3448
+ t.push(" states?: readonly (string | State)[]");
3449
+ t.push(" description?: string");
3450
+ t.push(" }");
3451
+ t.push(" export interface Primitive {");
3452
+ t.push(" primitive: PrimitiveId");
3453
+ t.push(" name?: string");
3454
+ t.push(" description?: string");
3455
+ t.push(" }");
3456
+ t.push(" export interface Widget {");
3457
+ t.push(" widget: WidgetId");
3458
+ t.push(" name?: string");
3459
+ t.push(" acceptance?: readonly string[]");
3460
+ t.push(" states?: readonly (string | State)[]");
3461
+ t.push(" description?: string");
3462
+ t.push(" }");
3463
+ t.push(" export interface Region {");
3464
+ t.push(" region: RegionId | false");
3465
+ t.push(" name?: string");
3466
+ t.push(" description?: string");
3467
+ t.push(" }");
3468
+ t.push(" export interface Flow {");
3469
+ t.push(" flow: FlowId");
3470
+ t.push(" name?: string");
3471
+ t.push(" description?: string");
3472
+ t.push(" }");
3473
+ t.push(" export interface NotFlow {");
3474
+ t.push(" notFlow: true");
3475
+ t.push(" }");
3476
+ t.push("}");
3477
+ t.push("");
2859
3478
  const allEntities = [
2860
3479
  ...routes,
2861
3480
  ...pages,
@@ -2866,25 +3485,39 @@ function emit(opts) {
2866
3485
  ...primitives,
2867
3486
  ...flows
2868
3487
  ];
2869
- lines.push(
2870
- `export const entities: ReadonlyArray<Entity> = ${jsonStable(allEntities)}`
2871
- );
2872
- lines.push("");
2873
- lines.push("// ---- git context ----");
3488
+ const authored = allEntities.filter((e) => AUTHORED_KINDS.has(e.kind));
3489
+ const thin = allEntities.filter((e) => !AUTHORED_KINDS.has(e.kind));
2874
3490
  const gc = gitContext ?? { branch: null, commit: null, pr: null };
2875
- lines.push(`export const gitContext = ${jsonStable(gc)} as const`);
2876
- lines.push("");
2877
- lines.push("// ---- registry factory ----");
2878
- lines.push("export function loadRegistry(target: Registry): Registry {");
2879
- lines.push(" for (const entity of entities) target.add(entity)");
2880
- lines.push(" return target");
2881
- lines.push("}");
2882
- lines.push("");
2883
- lines.push("// ---- preconfigured uidex instance ----");
2884
- lines.push("export const uidex = createUidex()");
2885
- lines.push("for (const entity of entities) uidex.registry.add(entity)");
2886
- lines.push("");
2887
- return lines.join("\n");
3491
+ const authoredPayload = internedPayload(authored);
3492
+ const d = [];
3493
+ d.push("// THIS FILE IS AUTO-GENERATED BY `uidex scan`. DO NOT EDIT.");
3494
+ d.push(
3495
+ "// Authored-metadata registry. The thin element/region entities (source"
3496
+ );
3497
+ d.push(
3498
+ "// locations only) live in the sibling `.locs` module, loaded lazily."
3499
+ );
3500
+ d.push("// @ts-nocheck");
3501
+ d.push("/* eslint-disable */");
3502
+ d.push(`import { createUidex } from ${JSON.stringify(uidexImport)}`);
3503
+ d.push(`import type { Registry } from ${JSON.stringify(uidexImport)}`);
3504
+ d.push("");
3505
+ d.push(authoredPayload.filesDecl);
3506
+ d.push(HYDRATE_FN);
3507
+ d.push(`export const entities = ${authoredPayload.parseExpr}`);
3508
+ d.push("");
3509
+ d.push(`export const gitContext = ${jsonStable(gc)} as const`);
3510
+ d.push("");
3511
+ d.push("export function loadRegistry(target: Registry): Registry {");
3512
+ d.push(" for (const entity of entities) target.add(entity)");
3513
+ d.push(" return target");
3514
+ d.push("}");
3515
+ d.push("");
3516
+ d.push("export const uidex = createUidex()");
3517
+ d.push("for (const entity of entities) uidex.registry.add(entity)");
3518
+ d.push("");
3519
+ const locs = emitLocsModule(thin, uidexImport);
3520
+ return { types: t.join("\n"), data: d.join("\n"), locs };
2888
3521
  }
2889
3522
 
2890
3523
  // src/scanner/scan/git.ts
@@ -2921,22 +3554,33 @@ function parseGitHubRef(ref) {
2921
3554
 
2922
3555
  // src/scanner/scan/scaffold.ts
2923
3556
  import * as fs3 from "fs";
2924
- import * as path5 from "path";
3557
+ import * as path6 from "path";
2925
3558
  function scaffoldWidgetSpec(opts) {
3559
+ return scaffoldSpec({
3560
+ registry: opts.registry,
3561
+ kind: "widget",
3562
+ id: opts.widgetId,
3563
+ outDir: opts.outDir,
3564
+ force: opts.force,
3565
+ fixtureImport: opts.fixtureImport
3566
+ });
3567
+ }
3568
+ function scaffoldSpec(opts) {
2926
3569
  const {
2927
3570
  registry,
2928
- widgetId,
3571
+ kind,
3572
+ id,
2929
3573
  outDir,
2930
3574
  force = false,
2931
3575
  fixtureImport = "./fixtures"
2932
3576
  } = opts;
2933
- const widget = registry.get("widget", widgetId);
2934
- if (!widget) {
2935
- throw new Error(`Widget "${widgetId}" not found in registry`);
3577
+ const entity = registry.get(kind, id);
3578
+ if (!entity) {
3579
+ throw new Error(`${capitalize(kind)} "${id}" not found in registry`);
2936
3580
  }
2937
- const criteria = widget.meta?.acceptance ?? [];
2938
- const filename = `widget-${widgetId}.spec.ts`;
2939
- const outputPath = path5.resolve(outDir, filename);
3581
+ const criteria = entity.meta?.acceptance ?? [];
3582
+ const filename = kind === "widget" ? `widget-${id}.spec.ts` : `flow-${id}.spec.ts`;
3583
+ const outputPath = path6.resolve(outDir, filename);
2940
3584
  if (fs3.existsSync(outputPath) && !force) {
2941
3585
  return {
2942
3586
  outputPath,
@@ -2945,15 +3589,14 @@ function scaffoldWidgetSpec(opts) {
2945
3589
  reason: `spec already exists at ${outputPath}; pass --force to overwrite`
2946
3590
  };
2947
3591
  }
2948
- const content = renderSpec({
2949
- widgetId,
2950
- criteria,
2951
- fixtureImport
2952
- });
2953
- fs3.mkdirSync(path5.dirname(outputPath), { recursive: true });
3592
+ const content = renderSpec({ id, criteria, fixtureImport });
3593
+ fs3.mkdirSync(path6.dirname(outputPath), { recursive: true });
2954
3594
  fs3.writeFileSync(outputPath, content, "utf8");
2955
3595
  return { outputPath, written: true, skipped: false };
2956
3596
  }
3597
+ function capitalize(s) {
3598
+ return s.charAt(0).toUpperCase() + s.slice(1);
3599
+ }
2957
3600
  function renderSpec(args) {
2958
3601
  const lines = [];
2959
3602
  lines.push(
@@ -2961,7 +3604,7 @@ function renderSpec(args) {
2961
3604
  );
2962
3605
  lines.push("");
2963
3606
  lines.push(
2964
- `test.describe(${JSON.stringify(args.widgetId)}, { tag: "@uidex:flow" }, () => {`
3607
+ `test.describe(${JSON.stringify(args.id)}, { tag: "@uidex:flow" }, () => {`
2965
3608
  );
2966
3609
  if (args.criteria.length === 0) {
2967
3610
  lines.push(` test("TODO: add acceptance criteria", async () => {`);
@@ -2984,7 +3627,69 @@ function renderSpec(args) {
2984
3627
 
2985
3628
  // src/scanner/scan/pipeline.ts
2986
3629
  import * as fs4 from "fs";
2987
- import * as path6 from "path";
3630
+ import * as path7 from "path";
3631
+ var DEFAULT_STATES_MANIFEST = "uidex-states.json";
3632
+ var DEFAULT_COVERAGE_BASELINE = "uidex-coverage-baseline.json";
3633
+ function loadPageBaseline(configDir, config) {
3634
+ const rel = config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
3635
+ const abs = path7.resolve(configDir, rel);
3636
+ let raw;
3637
+ try {
3638
+ raw = fs4.readFileSync(abs, "utf8");
3639
+ } catch {
3640
+ return void 0;
3641
+ }
3642
+ try {
3643
+ const parsed = JSON.parse(raw);
3644
+ if (!parsed || !Array.isArray(parsed.uncapturedPages)) return void 0;
3645
+ const uncapturedPages = parsed.uncapturedPages.filter(
3646
+ (p2) => typeof p2 === "string"
3647
+ );
3648
+ let missingKinds;
3649
+ const mkRaw = parsed.missingKinds;
3650
+ if (mkRaw && typeof mkRaw === "object" && !Array.isArray(mkRaw)) {
3651
+ const mk = {};
3652
+ for (const [route, kinds] of Object.entries(mkRaw)) {
3653
+ if (Array.isArray(kinds)) {
3654
+ mk[route] = kinds.filter((k) => typeof k === "string");
3655
+ }
3656
+ }
3657
+ missingKinds = mk;
3658
+ }
3659
+ return { uncapturedPages, ...missingKinds ? { missingKinds } : {} };
3660
+ } catch {
3661
+ return void 0;
3662
+ }
3663
+ }
3664
+ function loadCapturedStates(configDir, config) {
3665
+ const rel = config.statesManifest ?? DEFAULT_STATES_MANIFEST;
3666
+ const abs = path7.resolve(configDir, rel);
3667
+ let raw;
3668
+ try {
3669
+ raw = fs4.readFileSync(abs, "utf8");
3670
+ } catch {
3671
+ return void 0;
3672
+ }
3673
+ try {
3674
+ const parsed = JSON.parse(raw);
3675
+ if (!parsed || !Array.isArray(parsed.captured)) return void 0;
3676
+ const captured = [];
3677
+ for (const entry of parsed.captured) {
3678
+ if (!entry || typeof entry !== "object") continue;
3679
+ const c = entry;
3680
+ if (typeof c.entity !== "string" || typeof c.state !== "string") continue;
3681
+ const rec = { entity: c.entity, state: c.state };
3682
+ if (typeof c.kind === "string") rec.kind = c.kind;
3683
+ if (typeof c.stateKind === "string") rec.stateKind = c.stateKind;
3684
+ if (typeof c.url === "string") rec.url = c.url;
3685
+ if (typeof c.route === "string") rec.route = c.route;
3686
+ captured.push(rec);
3687
+ }
3688
+ return { captured };
3689
+ } catch {
3690
+ return void 0;
3691
+ }
3692
+ }
2988
3693
  function runScan(opts = {}) {
2989
3694
  const cwd = opts.cwd ?? process.cwd();
2990
3695
  const configs = opts.configs ?? discover({ cwd });
@@ -3011,34 +3716,54 @@ function runOne(dc, opts) {
3011
3716
  flowFiles: extractedFlows
3012
3717
  });
3013
3718
  const gitContext = resolveGitContext({ cwd: configDir });
3014
- const generated = emit({
3719
+ const emitted = emit({
3015
3720
  registry: resolved.registry,
3016
- gitContext,
3017
- typeMode: config.typeMode
3721
+ gitContext
3018
3722
  });
3019
- const outputPath = path6.resolve(configDir, config.output);
3020
- const outputRel = config.output;
3021
- let existingOnDisk = null;
3723
+ const typesRel = config.output;
3724
+ const typesPath = path7.resolve(configDir, typesRel);
3725
+ const dataRel = dataOutputPath(config.output);
3726
+ const dataPath = path7.resolve(configDir, dataRel);
3727
+ const locsRel = locsOutputPath(config.output);
3728
+ const locsPath = path7.resolve(configDir, locsRel);
3729
+ let typesOnDisk = null;
3730
+ let dataOnDisk = null;
3731
+ let locsOnDisk = null;
3022
3732
  if (opts.check) {
3023
- try {
3024
- existingOnDisk = fs4.readFileSync(outputPath, "utf8");
3025
- } catch {
3026
- existingOnDisk = null;
3027
- }
3733
+ typesOnDisk = readOrNull(typesPath);
3734
+ dataOnDisk = readOrNull(dataPath);
3735
+ locsOnDisk = readOrNull(locsPath);
3028
3736
  }
3737
+ const hasExtractDiagnostics = [...extracted, ...extractedFlows].some(
3738
+ (ef) => (ef.diagnostics?.length ?? 0) > 0
3739
+ );
3740
+ const capturedStates = opts.capturedStates ?? loadCapturedStates(configDir, config);
3741
+ const pageBaseline = opts.pageBaseline ?? loadPageBaseline(configDir, config);
3029
3742
  let auditResult;
3030
- if (opts.check || opts.lint || resolved.diagnostics.length > 0) {
3743
+ if (opts.check || opts.lint || resolved.diagnostics.length > 0 || hasExtractDiagnostics) {
3031
3744
  auditResult = audit({
3032
3745
  registry: resolved.registry,
3033
3746
  extracted,
3034
3747
  files: sourceFiles,
3748
+ flowExtracted: extractedFlows,
3035
3749
  config,
3036
3750
  check: opts.check,
3037
3751
  lint: opts.lint,
3038
3752
  resolveDiagnostics: resolved.diagnostics,
3039
- generated,
3040
- existingOnDisk,
3041
- outputPath: outputRel
3753
+ // The entity array (and thus the meaningful drift) lives in the data file,
3754
+ // so the entity-aware gen-stale diff runs against it; the types file gets a
3755
+ // plain content compare (see audit()).
3756
+ generated: emitted.data,
3757
+ existingOnDisk: dataOnDisk,
3758
+ outputPath: dataRel,
3759
+ typesGenerated: emitted.types,
3760
+ typesOnDisk,
3761
+ typesOutputPath: typesRel,
3762
+ locsGenerated: emitted.locs,
3763
+ locsOnDisk,
3764
+ locsOutputPath: locsRel,
3765
+ capturedStates,
3766
+ pageBaseline
3042
3767
  });
3043
3768
  }
3044
3769
  return {
@@ -3047,39 +3772,311 @@ function runOne(dc, opts) {
3047
3772
  registry: resolved.registry,
3048
3773
  gitContext,
3049
3774
  audit: auditResult,
3050
- generated,
3051
- outputPath
3775
+ types: {
3776
+ generated: emitted.types,
3777
+ outputPath: typesPath,
3778
+ outputRel: typesRel
3779
+ },
3780
+ data: { generated: emitted.data, outputPath: dataPath, outputRel: dataRel },
3781
+ locs: { generated: emitted.locs, outputPath: locsPath, outputRel: locsRel },
3782
+ capturedStates
3052
3783
  };
3053
3784
  }
3785
+ function readOrNull(p2) {
3786
+ try {
3787
+ return fs4.readFileSync(p2, "utf8");
3788
+ } catch {
3789
+ return null;
3790
+ }
3791
+ }
3792
+ function writeArtifact(artifact) {
3793
+ if (readOrNull(artifact.outputPath) === artifact.generated) return false;
3794
+ fs4.mkdirSync(path7.dirname(artifact.outputPath), { recursive: true });
3795
+ fs4.writeFileSync(artifact.outputPath, artifact.generated, "utf8");
3796
+ return true;
3797
+ }
3054
3798
  function writeScanResult(result) {
3055
- fs4.mkdirSync(path6.dirname(result.outputPath), { recursive: true });
3056
- fs4.writeFileSync(result.outputPath, result.generated, "utf8");
3799
+ const wroteTypes = writeArtifact(result.types);
3800
+ const wroteData = writeArtifact(result.data);
3801
+ const wroteLocs = writeArtifact(result.locs);
3802
+ return wroteTypes || wroteData || wroteLocs;
3803
+ }
3804
+ function pageBaselinePath(result) {
3805
+ const rel = result.config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
3806
+ return path7.resolve(result.configDir, rel);
3807
+ }
3808
+
3809
+ // src/scanner/scan/fix.ts
3810
+ import * as fs5 from "fs";
3811
+ import * as path8 from "path";
3812
+ function applyFixes(diagnostics) {
3813
+ const entries = [];
3814
+ for (const d of diagnostics) {
3815
+ if (!d.fix) continue;
3816
+ entries.push({
3817
+ code: d.code,
3818
+ description: d.fix.description,
3819
+ file: d.file,
3820
+ edits: d.fix.edits ?? [],
3821
+ createFiles: d.fix.createFiles ?? [],
3822
+ deleteFiles: d.fix.deleteFiles ?? []
3823
+ });
3824
+ }
3825
+ if (entries.length === 0) return { applied: [], skipped: [] };
3826
+ const seenEdits = /* @__PURE__ */ new Set();
3827
+ const editsByFile = /* @__PURE__ */ new Map();
3828
+ for (const entry of entries) {
3829
+ for (const edit of entry.edits) {
3830
+ const key = `${edit.path}:${edit.start}:${edit.end}:${edit.replacement}`;
3831
+ if (seenEdits.has(key)) continue;
3832
+ seenEdits.add(key);
3833
+ let list = editsByFile.get(edit.path);
3834
+ if (!list) {
3835
+ list = [];
3836
+ editsByFile.set(edit.path, list);
3837
+ }
3838
+ list.push({ ...edit, entry });
3839
+ }
3840
+ }
3841
+ for (const [filePath, edits] of editsByFile) {
3842
+ let content;
3843
+ try {
3844
+ content = fs5.readFileSync(filePath, "utf8");
3845
+ } catch {
3846
+ for (const e of edits) e.entry.skippedReason ??= "file is unreadable";
3847
+ continue;
3848
+ }
3849
+ edits.sort((a, b) => a.start - b.start || a.end - b.end);
3850
+ const kept = [];
3851
+ let prevEnd = -1;
3852
+ for (const edit of edits) {
3853
+ if (edit.start < prevEnd) {
3854
+ edit.entry.skippedReason ??= "overlapping edit";
3855
+ continue;
3856
+ }
3857
+ kept.push(edit);
3858
+ prevEnd = edit.end;
3859
+ }
3860
+ for (let i = kept.length - 1; i >= 0; i--) {
3861
+ const edit = kept[i];
3862
+ content = content.slice(0, edit.start) + edit.replacement + content.slice(edit.end);
3863
+ }
3864
+ if (kept.length > 0) fs5.writeFileSync(filePath, content, "utf8");
3865
+ }
3866
+ for (const entry of entries) {
3867
+ if (entry.skippedReason) continue;
3868
+ for (const create of entry.createFiles) {
3869
+ if (fs5.existsSync(create.path)) {
3870
+ entry.skippedReason = `${path8.basename(create.path)} already exists`;
3871
+ continue;
3872
+ }
3873
+ fs5.mkdirSync(path8.dirname(create.path), { recursive: true });
3874
+ fs5.writeFileSync(create.path, create.content, "utf8");
3875
+ }
3876
+ if (entry.skippedReason) continue;
3877
+ for (const del of entry.deleteFiles) {
3878
+ try {
3879
+ fs5.unlinkSync(del);
3880
+ } catch {
3881
+ }
3882
+ }
3883
+ }
3884
+ const applied = [];
3885
+ const skipped = [];
3886
+ for (const entry of entries) {
3887
+ const summary = {
3888
+ code: entry.code,
3889
+ description: entry.description,
3890
+ file: entry.file
3891
+ };
3892
+ if (entry.skippedReason) {
3893
+ skipped.push({ ...summary, reason: entry.skippedReason });
3894
+ } else {
3895
+ applied.push(summary);
3896
+ }
3897
+ }
3898
+ return { applied, skipped };
3899
+ }
3900
+
3901
+ // src/scanner/scan/rename.ts
3902
+ var ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
3903
+ function renameEntity(opts) {
3904
+ const { cwd, kind, oldId, newId, force = false } = opts;
3905
+ const manual = [];
3906
+ const errors = [];
3907
+ if (!ID_RE.test(newId)) {
3908
+ return {
3909
+ edits: 0,
3910
+ manual,
3911
+ errors: [`New id "${newId}" is not kebab-case`]
3912
+ };
3913
+ }
3914
+ const configs = discover({ cwd });
3915
+ if (configs.length === 0) {
3916
+ return { edits: 0, manual, errors: [`No .uidex.json found under ${cwd}`] };
3917
+ }
3918
+ const edits = [];
3919
+ for (const dc of configs) {
3920
+ const { config, configDir } = dc;
3921
+ const sourceFiles = walk(config.sources, {
3922
+ cwd: configDir,
3923
+ globalExcludes: config.exclude
3924
+ });
3925
+ const extracted = extract(sourceFiles);
3926
+ const flowFiles = config.flows ? walk(
3927
+ config.flows.map((glob) => ({ rootDir: ".", include: [glob] })),
3928
+ { cwd: configDir, includeTests: true }
3929
+ ) : [];
3930
+ const extractedFlows = extract(flowFiles);
3931
+ const scan = runScan({ cwd: configDir, configs: [dc] })[0];
3932
+ const registry = scan.registry;
3933
+ if (!registry.get(kind, oldId)) {
3934
+ if (registry.matchPattern(kind, oldId)) {
3935
+ errors.push(
3936
+ `${kind} "${oldId}" only matches via a pattern id; pattern-backed ids cannot be renamed mechanically`
3937
+ );
3938
+ } else {
3939
+ errors.push(`${kind} "${oldId}" not found in registry`);
3940
+ }
3941
+ continue;
3942
+ }
3943
+ if (registry.get(kind, newId) && !force) {
3944
+ errors.push(
3945
+ `${kind} "${newId}" already exists; pass --force to merge the ids`
3946
+ );
3947
+ continue;
3948
+ }
3949
+ const quoteAs = (content, start) => {
3950
+ const q = content[start];
3951
+ return q === '"' || q === "'" || q === "`" ? `${q}${newId}${q}` : `"${newId}"`;
3952
+ };
3953
+ for (const ef of extracted) {
3954
+ for (const a of ef.annotations) {
3955
+ if (a.kind !== kind || a.id !== oldId) continue;
3956
+ if (a.span) {
3957
+ edits.push({
3958
+ path: ef.file.sourcePath,
3959
+ start: a.span.start,
3960
+ end: a.span.end,
3961
+ replacement: quoteAs(ef.file.content, a.span.start)
3962
+ });
3963
+ } else {
3964
+ manual.push({
3965
+ file: a.file,
3966
+ line: a.line,
3967
+ reason: "attribute value is not a plain string literal (const reference, ternary, or template)"
3968
+ });
3969
+ }
3970
+ }
3971
+ for (const m of ef.metadata ?? []) {
3972
+ if (kind === "widget" && m.kind === "widget" && m.id === oldId) {
3973
+ if (m.idSpan) {
3974
+ edits.push({
3975
+ path: ef.file.sourcePath,
3976
+ start: m.idSpan.start,
3977
+ end: m.idSpan.end,
3978
+ replacement: quoteAs(ef.file.content, m.idSpan.start)
3979
+ });
3980
+ } else {
3981
+ manual.push({
3982
+ file: ef.file.displayPath,
3983
+ line: m.loc.line ?? 1,
3984
+ reason: "widget export id is not a plain string literal"
3985
+ });
3986
+ }
3987
+ }
3988
+ if (kind === "widget" && m.widgets) {
3989
+ for (let i = 0; i < m.widgets.length; i++) {
3990
+ if (m.widgets[i] !== oldId) continue;
3991
+ const span = m.widgetSpans?.[i];
3992
+ if (span) {
3993
+ edits.push({
3994
+ path: ef.file.sourcePath,
3995
+ start: span.start,
3996
+ end: span.end,
3997
+ replacement: quoteAs(ef.file.content, span.start)
3998
+ });
3999
+ }
4000
+ }
4001
+ }
4002
+ }
4003
+ }
4004
+ for (const ef of extractedFlows) {
4005
+ for (const fact of ef.flows ?? []) {
4006
+ for (const call of fact.calls) {
4007
+ if (call.id !== oldId) continue;
4008
+ if (call.span) {
4009
+ edits.push({
4010
+ path: ef.file.sourcePath,
4011
+ start: call.span.start,
4012
+ end: call.span.end,
4013
+ replacement: quoteAs(ef.file.content, call.span.start)
4014
+ });
4015
+ } else {
4016
+ manual.push({
4017
+ file: ef.file.displayPath,
4018
+ line: call.line,
4019
+ reason: "uidex() argument is not a plain string literal"
4020
+ });
4021
+ }
4022
+ }
4023
+ }
4024
+ }
4025
+ }
4026
+ if (errors.length > 0) {
4027
+ return { edits: 0, manual, errors };
4028
+ }
4029
+ if (edits.length === 0 && manual.length === 0) {
4030
+ return {
4031
+ edits: 0,
4032
+ manual,
4033
+ errors: [
4034
+ `${kind} "${oldId}" has no editable occurrences (convention-derived ids like landmarks cannot be renamed)`
4035
+ ]
4036
+ };
4037
+ }
4038
+ const result = applyFixes([
4039
+ {
4040
+ code: "rename",
4041
+ severity: "info",
4042
+ message: "",
4043
+ fix: {
4044
+ description: `Rename ${kind} "${oldId}" to "${newId}"`,
4045
+ edits
4046
+ }
4047
+ }
4048
+ ]);
4049
+ if (result.skipped.length > 0) {
4050
+ errors.push(`Some edits were skipped: ${result.skipped[0].reason}`);
4051
+ }
4052
+ for (const r of runScan({ cwd })) writeScanResult(r);
4053
+ return { edits: edits.length, manual, errors };
3057
4054
  }
3058
4055
 
3059
4056
  // src/scanner/scan/cli.ts
3060
- import * as fs7 from "fs";
3061
- import * as path9 from "path";
4057
+ import * as fs8 from "fs";
4058
+ import * as path11 from "path";
3062
4059
 
3063
4060
  // src/scanner/scan/ai/index.ts
3064
4061
  import * as p from "@clack/prompts";
3065
4062
 
3066
4063
  // src/scanner/scan/ai/providers/claude.ts
3067
- import * as fs6 from "fs";
3068
- import * as path8 from "path";
4064
+ import * as fs7 from "fs";
4065
+ import * as path10 from "path";
3069
4066
 
3070
4067
  // src/scanner/scan/ai/templates.ts
3071
- import * as fs5 from "fs";
3072
- import * as path7 from "path";
4068
+ import * as fs6 from "fs";
4069
+ import * as path9 from "path";
3073
4070
  function templatePath(rel) {
3074
4071
  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
4072
+ path9.resolve(__dirname, "../../templates", rel),
4073
+ // dist/cli/cli.cjs → ../../templates
4074
+ path9.resolve(__dirname, "../../../../templates", rel)
4075
+ // src/scanner/scan/ai → ../../../../templates
3079
4076
  ];
3080
4077
  for (const c of candidates) {
3081
4078
  try {
3082
- fs5.accessSync(c, fs5.constants.R_OK);
4079
+ fs6.accessSync(c, fs6.constants.R_OK);
3083
4080
  return c;
3084
4081
  } catch {
3085
4082
  continue;
@@ -3091,24 +4088,39 @@ function templatePath(rel) {
3091
4088
  );
3092
4089
  }
3093
4090
  function readTemplate(rel) {
3094
- return fs5.readFileSync(templatePath(rel), "utf8");
4091
+ return fs6.readFileSync(templatePath(rel), "utf8");
3095
4092
  }
3096
4093
 
3097
4094
  // 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" }
4095
+ var SKILL_FILES = [
4096
+ { dest: ".claude/skills/uidex/SKILL.md", template: "claude/SKILL.md" },
4097
+ {
4098
+ dest: ".claude/skills/uidex/references/conventions.md",
4099
+ template: "claude/references/conventions.md"
4100
+ },
4101
+ {
4102
+ dest: ".claude/skills/uidex/references/audit.md",
4103
+ template: "claude/references/audit.md"
4104
+ },
4105
+ {
4106
+ dest: ".claude/skills/uidex/references/api.md",
4107
+ template: "claude/references/api.md"
4108
+ }
4109
+ ];
4110
+ var LEGACY_FILES = [
4111
+ ".claude/rules/uidex.md",
4112
+ ".claude/commands/uidex/audit.md",
4113
+ ".claude/commands/uidex/api.md"
3102
4114
  ];
3103
4115
  var claudeProvider = {
3104
4116
  id: "claude",
3105
4117
  label: "Claude Code",
3106
- description: "Adds .claude/rules/uidex.md, /uidex:audit, and /uidex:api slash commands.",
4118
+ description: "Adds .claude/skills/uidex/ skill with conventions, audit, and API references.",
3107
4119
  async install({ cwd, force }) {
3108
4120
  const changes = [];
3109
- for (const file of CLAUDE_FILES) {
3110
- const dest = path8.join(cwd, file.dest);
3111
- const exists = fs6.existsSync(dest);
4121
+ for (const file of SKILL_FILES) {
4122
+ const dest = path10.join(cwd, file.dest);
4123
+ const exists = fs7.existsSync(dest);
3112
4124
  if (exists && !force) {
3113
4125
  changes.push({
3114
4126
  path: file.dest,
@@ -3117,36 +4129,56 @@ var claudeProvider = {
3117
4129
  });
3118
4130
  continue;
3119
4131
  }
3120
- fs6.mkdirSync(path8.dirname(dest), { recursive: true });
3121
- fs6.writeFileSync(dest, readTemplate(file.template));
4132
+ fs7.mkdirSync(path10.dirname(dest), { recursive: true });
4133
+ fs7.writeFileSync(dest, readTemplate(file.template));
3122
4134
  changes.push({
3123
4135
  path: file.dest,
3124
4136
  action: exists ? "overwritten" : "created"
3125
4137
  });
3126
4138
  }
4139
+ for (const rel of LEGACY_FILES) {
4140
+ const dest = path10.join(cwd, rel);
4141
+ if (fs7.existsSync(dest)) {
4142
+ fs7.unlinkSync(dest);
4143
+ changes.push({ path: rel, action: "removed" });
4144
+ }
4145
+ }
4146
+ cleanupEmpty(path10.join(cwd, ".claude/commands/uidex"));
4147
+ cleanupEmpty(path10.join(cwd, ".claude/commands"));
4148
+ cleanupEmpty(path10.join(cwd, ".claude/rules"));
3127
4149
  return { changes };
3128
4150
  },
3129
4151
  async uninstall({ cwd }) {
3130
4152
  const changes = [];
3131
- for (const file of CLAUDE_FILES) {
3132
- const dest = path8.join(cwd, file.dest);
3133
- if (!fs6.existsSync(dest)) {
4153
+ for (const file of SKILL_FILES) {
4154
+ const dest = path10.join(cwd, file.dest);
4155
+ if (!fs7.existsSync(dest)) {
3134
4156
  changes.push({ path: file.dest, action: "skipped", reason: "absent" });
3135
4157
  continue;
3136
4158
  }
3137
- fs6.unlinkSync(dest);
4159
+ fs7.unlinkSync(dest);
3138
4160
  changes.push({ path: file.dest, action: "removed" });
3139
4161
  }
3140
- cleanupEmpty(path8.join(cwd, ".claude/commands/uidex"));
3141
- cleanupEmpty(path8.join(cwd, ".claude/commands"));
3142
- cleanupEmpty(path8.join(cwd, ".claude/rules"));
4162
+ cleanupEmpty(path10.join(cwd, ".claude/skills/uidex/references"));
4163
+ cleanupEmpty(path10.join(cwd, ".claude/skills/uidex"));
4164
+ cleanupEmpty(path10.join(cwd, ".claude/skills"));
4165
+ for (const rel of LEGACY_FILES) {
4166
+ const dest = path10.join(cwd, rel);
4167
+ if (fs7.existsSync(dest)) {
4168
+ fs7.unlinkSync(dest);
4169
+ changes.push({ path: rel, action: "removed" });
4170
+ }
4171
+ }
4172
+ cleanupEmpty(path10.join(cwd, ".claude/commands/uidex"));
4173
+ cleanupEmpty(path10.join(cwd, ".claude/commands"));
4174
+ cleanupEmpty(path10.join(cwd, ".claude/rules"));
3143
4175
  return { changes };
3144
4176
  }
3145
4177
  };
3146
4178
  function cleanupEmpty(dir) {
3147
4179
  try {
3148
- const entries = fs6.readdirSync(dir);
3149
- if (entries.length === 0) fs6.rmdirSync(dir);
4180
+ const entries = fs7.readdirSync(dir);
4181
+ if (entries.length === 0) fs7.rmdirSync(dir);
3150
4182
  } catch {
3151
4183
  }
3152
4184
  }
@@ -3313,6 +4345,8 @@ async function run(opts) {
3313
4345
  return runScanCommand(cwd, flags, writer);
3314
4346
  case "scaffold":
3315
4347
  return runScaffold(cwd, positional.slice(1), flags, writer);
4348
+ case "rename":
4349
+ return runRename(cwd, positional.slice(1), flags, writer);
3316
4350
  case "ai": {
3317
4351
  const result = await runAiCommand({
3318
4352
  cwd,
@@ -3339,25 +4373,24 @@ function helpText2() {
3339
4373
  "Commands:",
3340
4374
  " init Create a .uidex.json",
3341
4375
  " scan [flags] Run the scanner pipeline",
3342
- " scaffold widget <id> Emit a Playwright spec from a widget's acceptance",
4376
+ " scaffold <widget|page|feature> <id> Emit a Playwright spec from declared acceptance",
4377
+ " rename <element|widget|region> <old-id> <new-id> Rename an id everywhere (DOM attr, flows, exports)",
3343
4378
  " ai <install|uninstall|providers> Manage AI assistant integrations",
3344
- " api <METHOD> <PATH> Call the uidex API",
3345
- " api --list Show available API routes",
3346
- " api login Authenticate via browser",
3347
- " api login --token <tok> Store an auth token directly",
3348
4379
  "",
3349
4380
  "Flags:",
3350
4381
  " --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)",
4382
+ " --lint Run lint diagnostics (missing annotations, scope leak, duplicate ids, coverage)",
3352
4383
  " --audit Equivalent to --check --lint (read-only)",
4384
+ " --fix Apply machine-generated fixes (add data-uidex to unannotated interactive elements, drop empty names), then rescan and write",
4385
+ " --update-baseline Regenerate the page-coverage baseline (uidex-coverage-baseline.json) from the current uncaptured routes",
3353
4386
  " --json Emit JSON diagnostics on stdout",
3354
4387
  " --force (scaffold) overwrite existing spec",
3355
4388
  ""
3356
4389
  ].join("\n");
3357
4390
  }
3358
4391
  function runInit(cwd, w) {
3359
- const configPath = path9.join(cwd, CONFIG_FILENAME);
3360
- if (fs7.existsSync(configPath)) {
4392
+ const configPath = path11.join(cwd, CONFIG_FILENAME);
4393
+ if (fs8.existsSync(configPath)) {
3361
4394
  w.err(`.uidex.json already exists at ${configPath}`);
3362
4395
  return w.result(1);
3363
4396
  }
@@ -3366,16 +4399,16 @@ function runInit(cwd, w) {
3366
4399
  sources: [{ rootDir: "src" }],
3367
4400
  output: "src/uidex.gen.ts"
3368
4401
  };
3369
- fs7.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
4402
+ fs8.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
3370
4403
  w.out(`Created ${configPath}`);
3371
- const gitignorePath = path9.join(cwd, ".gitignore");
3372
- const entry = "*.gen.ts";
3373
- if (fs7.existsSync(gitignorePath)) {
3374
- const existing = fs7.readFileSync(gitignorePath, "utf8");
4404
+ const gitignorePath = path11.join(cwd, ".gitignore");
4405
+ const entry = "*.gen.*";
4406
+ if (fs8.existsSync(gitignorePath)) {
4407
+ const existing = fs8.readFileSync(gitignorePath, "utf8");
3375
4408
  const hasEntry = existing.split("\n").some((line) => line.trim() === entry);
3376
4409
  if (!hasEntry) {
3377
4410
  const needsNewline = existing.length > 0 && !existing.endsWith("\n");
3378
- fs7.appendFileSync(
4411
+ fs8.appendFileSync(
3379
4412
  gitignorePath,
3380
4413
  `${needsNewline ? "\n" : ""}${entry}
3381
4414
  `,
@@ -3384,24 +4417,62 @@ function runInit(cwd, w) {
3384
4417
  w.out(`Appended ${entry} to ${gitignorePath}`);
3385
4418
  }
3386
4419
  } else {
3387
- fs7.writeFileSync(gitignorePath, `${entry}
4420
+ fs8.writeFileSync(gitignorePath, `${entry}
3388
4421
  `, "utf8");
3389
4422
  w.out(`Created ${gitignorePath} with ${entry}`);
3390
4423
  }
3391
4424
  return w.result(0);
3392
4425
  }
3393
4426
  function runScanCommand(cwd, flags, w) {
3394
- const check = Boolean(flags.check || flags.audit);
3395
- const lint = Boolean(flags.lint || flags.audit);
4427
+ const fix = Boolean(flags.fix);
4428
+ const updateBaseline = Boolean(flags["update-baseline"]);
4429
+ const check = !fix && !updateBaseline && Boolean(flags.check || flags.audit);
4430
+ const lint = Boolean(flags.lint || flags.audit || fix || updateBaseline);
3396
4431
  const asJson = Boolean(flags.json);
3397
- const configs = discover({ cwd });
4432
+ let configs = discover({ cwd });
3398
4433
  if (configs.length === 0) {
3399
4434
  w.err(`No ${CONFIG_FILENAME} found under ${cwd}`);
3400
4435
  return w.result(1);
3401
4436
  }
4437
+ let fixed = [];
4438
+ let fixSkipped = [];
4439
+ if (fix) {
4440
+ const discovery = runScan({ cwd, check: true, lint: true, configs });
4441
+ const result = applyFixes(
4442
+ discovery.flatMap((r) => r.audit?.diagnostics ?? [])
4443
+ );
4444
+ fixed = result.applied;
4445
+ fixSkipped = result.skipped;
4446
+ configs = discover({ cwd });
4447
+ }
3402
4448
  const results = runScan({ cwd, check, lint, configs });
4449
+ const artifacts = results.flatMap((r) => [r.types, r.data, r.locs]);
4450
+ const wrote = /* @__PURE__ */ new Set();
3403
4451
  if (!check) {
3404
- for (const r of results) writeScanResult(r);
4452
+ for (const a of artifacts) {
4453
+ if (writeArtifact(a)) wrote.add(a);
4454
+ }
4455
+ }
4456
+ if (updateBaseline) {
4457
+ for (const r of results) {
4458
+ const manifest = r.capturedStates ?? { captured: [] };
4459
+ const pageBaseline = computePageBaseline(r.registry, manifest);
4460
+ const missingKinds = computeMissingKinds(r.registry, manifest);
4461
+ const baseline = {
4462
+ ...pageBaseline,
4463
+ ...Object.keys(missingKinds).length > 0 ? { missingKinds } : {}
4464
+ };
4465
+ const p2 = pageBaselinePath(r);
4466
+ fs8.writeFileSync(p2, JSON.stringify(baseline, null, 2) + "\n");
4467
+ const kindGaps = Object.values(missingKinds).reduce(
4468
+ (n, ks) => n + ks.length,
4469
+ 0
4470
+ );
4471
+ w.out(
4472
+ `Wrote ${p2} \u2014 ${baseline.uncapturedPages.length} uncaptured page(s), ${kindGaps} missing core-kind(s) baselined`
4473
+ );
4474
+ }
4475
+ return w.result(0);
3405
4476
  }
3406
4477
  const allDiagnostics = results.flatMap((r) => r.audit?.diagnostics ?? []);
3407
4478
  const summary = results.reduce(
@@ -3413,19 +4484,38 @@ function runScanCommand(cwd, flags, w) {
3413
4484
  { errors: 0, warnings: 0 }
3414
4485
  );
3415
4486
  if (asJson) {
3416
- const out2 = { diagnostics: allDiagnostics, summary };
4487
+ const out2 = {
4488
+ diagnostics: allDiagnostics.map(jsonDiagnostic),
4489
+ summary,
4490
+ ...fix ? { fixed, fixSkipped } : {}
4491
+ };
3417
4492
  w.out(JSON.stringify(out2, null, 2));
3418
4493
  } else {
4494
+ for (const f of fixed) {
4495
+ w.out(`FIXED [${f.code}] ${f.file ?? ""} ${f.description}`);
4496
+ }
4497
+ for (const s of fixSkipped) {
4498
+ w.out(
4499
+ `SKIPPED [${s.code}] ${s.file ?? ""} ${s.description} (${s.reason})`
4500
+ );
4501
+ }
3419
4502
  for (const r of results) {
3420
- if (check) {
3421
- w.out(`Checked ${r.outputPath}`);
3422
- } else {
3423
- w.out(`Wrote ${r.outputPath}`);
4503
+ for (const art of [r.types, r.data, r.locs]) {
4504
+ if (check) {
4505
+ w.out(`Checked ${art.outputPath}`);
4506
+ } else if (wrote.has(art)) {
4507
+ w.out(`Wrote ${art.outputPath}`);
4508
+ } else {
4509
+ w.out(`Unchanged ${art.outputPath}`);
4510
+ }
3424
4511
  }
3425
4512
  for (const d of r.audit?.diagnostics ?? []) {
3426
4513
  const loc = d.file ? `${d.file}${d.line ? `:${d.line}` : ""}` : "";
3427
4514
  const stream = d.severity === "error" ? w.err : w.out;
3428
- stream(`${d.severity.toUpperCase()} [${d.code}] ${loc} ${d.message}`);
4515
+ const fixable = d.fix && !fix ? " [fixable: run with --fix]" : "";
4516
+ stream(
4517
+ `${d.severity.toUpperCase()} [${d.code}] ${loc} ${d.message}${fixable}`
4518
+ );
3429
4519
  if (d.hint) stream(` hint: ${d.hint}`);
3430
4520
  }
3431
4521
  }
@@ -3436,20 +4526,27 @@ function runScanCommand(cwd, flags, w) {
3436
4526
  const exit = summary.errors > 0 ? 1 : 0;
3437
4527
  return w.result(exit);
3438
4528
  }
4529
+ function jsonDiagnostic(d) {
4530
+ const { fix, ...rest } = d;
4531
+ return fix ? { ...rest, fixable: true } : rest;
4532
+ }
4533
+ var SCAFFOLD_KINDS = /* @__PURE__ */ new Set(["widget", "page", "feature"]);
3439
4534
  function runScaffold(cwd, args, flags, w) {
3440
4535
  const [kind, id] = args;
3441
- if (kind !== "widget" || !id) {
3442
- w.err("Usage: uidex scaffold widget <id> [--force]");
4536
+ if (!kind || !SCAFFOLD_KINDS.has(kind) || !id) {
4537
+ w.err("Usage: uidex scaffold <widget|page|feature> <id> [--force]");
3443
4538
  return w.result(1);
3444
4539
  }
4540
+ const scaffoldKind = kind;
3445
4541
  const results = runScan({ cwd });
3446
4542
  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({
4543
+ const entity = r.registry.get(scaffoldKind, id);
4544
+ if (!entity) continue;
4545
+ const outDir = path11.resolve(r.configDir, "e2e");
4546
+ const result = scaffoldSpec({
3451
4547
  registry: r.registry,
3452
- widgetId: id,
4548
+ kind: scaffoldKind,
4549
+ id,
3453
4550
  outDir,
3454
4551
  force: Boolean(flags.force)
3455
4552
  });
@@ -3460,9 +4557,43 @@ function runScaffold(cwd, args, flags, w) {
3460
4557
  w.out(`Wrote ${result.outputPath}`);
3461
4558
  return w.result(0);
3462
4559
  }
3463
- w.err(`Widget "${id}" not found in registry`);
4560
+ w.err(
4561
+ `${scaffoldKind.charAt(0).toUpperCase() + scaffoldKind.slice(1)} "${id}" not found in registry`
4562
+ );
3464
4563
  return w.result(1);
3465
4564
  }
4565
+ var RENAME_KINDS = /* @__PURE__ */ new Set(["element", "widget", "region"]);
4566
+ function runRename(cwd, args, flags, w) {
4567
+ const [kind, oldId, newId] = args;
4568
+ if (!kind || !RENAME_KINDS.has(kind) || !oldId || !newId) {
4569
+ w.err(
4570
+ "Usage: uidex rename <element|widget|region> <old-id> <new-id> [--force]"
4571
+ );
4572
+ return w.result(1);
4573
+ }
4574
+ const result = renameEntity({
4575
+ cwd,
4576
+ kind,
4577
+ oldId,
4578
+ newId,
4579
+ force: Boolean(flags.force)
4580
+ });
4581
+ for (const e of result.errors) w.err(e);
4582
+ for (const m of result.manual) {
4583
+ w.err(`MANUAL ${m.file}:${m.line} \u2014 ${m.reason}`);
4584
+ }
4585
+ if (result.errors.length > 0) return w.result(1);
4586
+ w.out(
4587
+ `Renamed ${kind} "${oldId}" \u2192 "${newId}" (${result.edits} edit(s)); gen file regenerated`
4588
+ );
4589
+ if (result.manual.length > 0) {
4590
+ w.err(
4591
+ `${result.manual.length} occurrence(s) need manual follow-up (listed above)`
4592
+ );
4593
+ return w.result(1);
4594
+ }
4595
+ return w.result(0);
4596
+ }
3466
4597
  function createWriter() {
3467
4598
  let stdout = "";
3468
4599
  let stderr = "";
@@ -3482,20 +4613,31 @@ export {
3482
4613
  CONFIG_FILENAME,
3483
4614
  ConfigError,
3484
4615
  DEFAULT_CONVENTIONS,
3485
- DEFAULT_TYPE_MODE,
4616
+ applyFixes,
3486
4617
  audit,
4618
+ checkPageCoverage,
4619
+ checkStateCoverage,
4620
+ checkStateMatrix,
4621
+ compileRoute,
4622
+ computeMissingKinds,
4623
+ computePageBaseline,
3487
4624
  detectRoutes,
3488
4625
  discover,
3489
4626
  emit,
3490
4627
  extract,
3491
4628
  extractUidexExports,
3492
4629
  globToRegExp,
4630
+ loadPageBaseline,
4631
+ matchUrlToRoute,
4632
+ pageBaselinePath,
3493
4633
  parseConfig,
3494
4634
  pathToId,
4635
+ renameEntity,
3495
4636
  resolve2 as resolve,
3496
4637
  resolveGitContext,
3497
4638
  run as runCli,
3498
4639
  runScan,
4640
+ scaffoldSpec,
3499
4641
  scaffoldWidgetSpec,
3500
4642
  validateConfig,
3501
4643
  walk,