uidex 0.10.0 → 0.11.1

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.
@@ -27,6 +27,7 @@ var ALLOWED_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
27
27
  "exclude",
28
28
  "output",
29
29
  "flows",
30
+ "resolve",
30
31
  "audit",
31
32
  "conventions",
32
33
  "statesManifest",
@@ -45,24 +46,32 @@ var ALLOWED_AUDIT_KEYS = /* @__PURE__ */ new Set([
45
46
  "scopeLeak",
46
47
  "coverage",
47
48
  "acceptance",
49
+ "surfaces",
50
+ "requireAcceptance",
48
51
  "states",
49
52
  "pageCoverage",
50
53
  "stateMatrix",
51
54
  "severity"
52
55
  ]);
53
56
  var DIAGNOSTIC_CODES = [
57
+ "acceptance-ambiguous",
58
+ "acceptance-duplicate-id",
59
+ "acceptance-orphaned",
60
+ "acceptance-outdated",
54
61
  "acceptance-uncovered",
55
62
  "acknowledged-elsewhere",
56
63
  "competing-uidex-export",
57
64
  "component-state-uncaptured",
58
65
  "core-kind",
66
+ "covers-missing-rev",
59
67
  "duplicate-id",
60
68
  "dynamic-attr",
61
69
  "dynamic-flow-reference",
70
+ "feature-missing-acceptance",
71
+ "feature-surface-uncaptured",
72
+ "feature-surface-unreferenced",
62
73
  "gen-missing",
63
74
  "gen-stale",
64
- "legacy-acknowledge-field",
65
- "legacy-coverage-baseline",
66
75
  "matrix-backlog",
67
76
  "missing-element-annotation",
68
77
  "missing-state-capture",
@@ -97,14 +106,14 @@ var SEVERITY_LEVELS = /* @__PURE__ */ new Set(["error", "warning", "info", "off"
97
106
  function fail(msg) {
98
107
  throw new ConfigError(`Invalid .uidex.json: ${msg}`);
99
108
  }
100
- function assertObject(value, path13) {
109
+ function assertObject(value, path14) {
101
110
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
102
- fail(`${path13} must be an object`);
111
+ fail(`${path14} must be an object`);
103
112
  }
104
113
  }
105
- function assertStringArray(value, path13) {
114
+ function assertStringArray(value, path14) {
106
115
  if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
107
- fail(`${path13} must be a string[]`);
116
+ fail(`${path14} must be a string[]`);
108
117
  }
109
118
  }
110
119
  function validateConfig(raw) {
@@ -161,6 +170,23 @@ function validateConfig(raw) {
161
170
  if (raw.workspace !== void 0 && raw.workspace !== false && typeof raw.workspace !== "string") {
162
171
  fail(`"workspace" must be a path string or false`);
163
172
  }
173
+ if (raw.resolve !== void 0) {
174
+ assertObject(raw.resolve, "resolve");
175
+ for (const key of Object.keys(raw.resolve)) {
176
+ if (key !== "aliases") fail(`unknown key "${key}" in resolve`);
177
+ }
178
+ const aliases = raw.resolve.aliases;
179
+ if (aliases === void 0) {
180
+ fail(`resolve.aliases is required when "resolve" is present`);
181
+ }
182
+ assertObject(aliases, "resolve.aliases");
183
+ for (const [alias, target] of Object.entries(aliases)) {
184
+ if (alias.length === 0) fail(`resolve.aliases keys must be non-empty`);
185
+ if (typeof target !== "string") {
186
+ fail(`resolve.aliases["${alias}"] must be a string`);
187
+ }
188
+ }
189
+ }
164
190
  if (raw.audit !== void 0) {
165
191
  assertObject(raw.audit, "audit");
166
192
  for (const key of Object.keys(raw.audit)) {
@@ -221,6 +247,7 @@ function validateConfig(raw) {
221
247
  exclude: raw.exclude,
222
248
  output: raw.output,
223
249
  flows: raw.flows,
250
+ resolve: raw.resolve,
224
251
  audit: raw.audit,
225
252
  conventions: raw.conventions,
226
253
  statesManifest: raw.statesManifest,
@@ -748,7 +775,6 @@ var CORE_SET = new Set(CORE_STATE_KINDS);
748
775
  function isCoreKind(scope) {
749
776
  return CORE_SET.has(scope);
750
777
  }
751
- var PLACEHOLDER_REASON = "TODO: why can this never be captured?";
752
778
  function isPlaceholderReason(reason) {
753
779
  return typeof reason === "string" && /^\s*TODO\b/i.test(reason);
754
780
  }
@@ -826,28 +852,6 @@ function parseCoverageBaseline(raw) {
826
852
  }
827
853
  return { version: 2, acknowledge };
828
854
  }
829
- function isLegacyBaseline(raw) {
830
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
831
- const r = raw;
832
- if ("acknowledge" in r) return false;
833
- return "uncapturedPages" in r || "missingKinds" in r;
834
- }
835
- function migrateBaseline(legacy) {
836
- const acknowledge = {};
837
- const at = (route) => acknowledge[route] ??= {};
838
- for (const route of legacy.uncapturedPages ?? []) {
839
- if (typeof route === "string") at(route)[ACK_ALL] = { type: "backlog" };
840
- }
841
- for (const [route, kinds] of Object.entries(legacy.missingKinds ?? {})) {
842
- if (!Array.isArray(kinds)) continue;
843
- for (const kind of kinds) {
844
- if (typeof kind === "string" && isCoreKind(kind)) {
845
- at(route)[kind] = { type: "backlog" };
846
- }
847
- }
848
- }
849
- return { version: 2, acknowledge };
850
- }
851
855
  function serializeCoverageBaseline(baseline) {
852
856
  const routes = Object.keys(baseline.acknowledge).sort();
853
857
  const ordered = {};
@@ -881,24 +885,21 @@ var ALLOWED_FIELDS = {
881
885
  "acceptance",
882
886
  "states",
883
887
  "acknowledge",
884
- "capture",
885
- "waivers",
886
888
  "description"
887
889
  ]),
888
890
  // A page's `acknowledge` accepts both scope shapes: `"*"` (the whole route)
889
891
  // and a core kind (one cell of its matrix). A feature/widget has no route, so
890
892
  // the core-kind matrix is meaningless there and only `"*"` is accepted —
891
893
  // giving a component a scoped opt-out instead of forcing you to delete its
892
- // whole `states` block. `capture` / `waivers` are REMOVED; they stay parseable
893
- // only so `--fix` can rewrite them into `acknowledge` (see MIGRATION below).
894
+ // whole `states` block.
894
895
  feature: /* @__PURE__ */ new Set([
895
896
  "feature",
896
897
  "name",
897
898
  "features",
899
+ "surfaces",
898
900
  "acceptance",
899
901
  "states",
900
902
  "acknowledge",
901
- "capture",
902
903
  "description"
903
904
  ]),
904
905
  primitive: /* @__PURE__ */ new Set(["primitive", "name", "description"]),
@@ -908,20 +909,19 @@ var ALLOWED_FIELDS = {
908
909
  "acceptance",
909
910
  "states",
910
911
  "acknowledge",
911
- "capture",
912
912
  "description"
913
913
  ]),
914
914
  region: /* @__PURE__ */ new Set(["region", "name", "description"]),
915
915
  flow: /* @__PURE__ */ new Set(["flow", "notFlow", "name", "description"])
916
916
  };
917
917
  var ACK_FIELDS = /* @__PURE__ */ new Set(["type", "reason"]);
918
+ var ACCEPTANCE_FIELDS = /* @__PURE__ */ new Set(["id", "rev", "text"]);
918
919
  var STATE_FIELDS = /* @__PURE__ */ new Set([
919
920
  "name",
920
921
  "kind",
921
922
  "acceptance",
922
923
  "description"
923
924
  ]);
924
- var CORE_STATE_KINDS2 = new Set(CORE_STATE_KINDS);
925
925
  var STATE_KINDS = /* @__PURE__ */ new Set([...CORE_STATE_KINDS, "variant"]);
926
926
  var FALSEABLE = /* @__PURE__ */ new Set([
927
927
  "page",
@@ -1287,7 +1287,8 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
1287
1287
  } else {
1288
1288
  id = readIdField(idValue, kind, idField);
1289
1289
  }
1290
- const acceptance = readStringArrayField(byKey, "acceptance")?.values;
1290
+ const criterionIds = /* @__PURE__ */ new Set();
1291
+ const acceptance = readAcceptanceField(byKey, criterionIds);
1291
1292
  const description = readStringField(byKey, "description");
1292
1293
  const name = readStringField(byKey, "name");
1293
1294
  if (name === "") {
@@ -1313,11 +1314,10 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
1313
1314
  }
1314
1315
  const featuresField = kind === "page" || kind === "feature" ? readStringArrayField(byKey, "features") : void 0;
1315
1316
  const widgetsField = kind === "page" ? readStringArrayField(byKey, "widgets") : void 0;
1316
- const states = kind === "page" || kind === "feature" || kind === "widget" ? readStatesField(byKey) : void 0;
1317
+ const surfacesField = kind === "feature" ? readStringArrayField(byKey, "surfaces") : void 0;
1318
+ const states = kind === "page" || kind === "feature" || kind === "widget" ? readStatesField(byKey, criterionIds) : void 0;
1317
1319
  const stateBearing = kind === "page" || kind === "feature" || kind === "widget";
1318
1320
  const acknowledge = stateBearing ? readAcknowledgeField(byKey, kind === "page") : void 0;
1319
- const legacyCapture = stateBearing ? readBooleanField(byKey, "capture") : void 0;
1320
- const legacyWaivers = kind === "page" ? readWaiversField(byKey) : void 0;
1321
1321
  const notFlow = kind === "flow" && discriminator === "notFlow" ? true : void 0;
1322
1322
  const metadata = {
1323
1323
  source: "ts-export",
@@ -1340,14 +1340,14 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
1340
1340
  metadata.widgets = widgetsField.values;
1341
1341
  metadata.widgetSpans = widgetsField.spans;
1342
1342
  }
1343
+ if (surfacesField) {
1344
+ metadata.surfaces = surfacesField.values;
1345
+ metadata.surfaceSpans = surfacesField.spans;
1346
+ }
1343
1347
  if (states?.length) metadata.states = states;
1344
1348
  if (acknowledge && Object.keys(acknowledge).length > 0) {
1345
1349
  metadata.acknowledge = acknowledge;
1346
1350
  }
1347
- if (legacyCapture !== void 0) metadata.legacyCapture = legacyCapture;
1348
- if (legacyWaivers && Object.keys(legacyWaivers).length > 0) {
1349
- metadata.legacyWaivers = legacyWaivers;
1350
- }
1351
1351
  if (notFlow) metadata.notFlow = true;
1352
1352
  if (typeof id === "string" && idValue.kind === "string") {
1353
1353
  metadata.idSpan = idValue.span;
@@ -1463,47 +1463,111 @@ function readAcknowledgeField(byKey, isPage) {
1463
1463
  }
1464
1464
  return out2;
1465
1465
  }
1466
- function readWaiversField(byKey) {
1467
- const entry = byKey.get("waivers");
1466
+ function acceptanceSlug(text) {
1467
+ return text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).slice(0, 8).join("-");
1468
+ }
1469
+ function readAcceptanceField(byKey, seen) {
1470
+ const entry = byKey.get("acceptance");
1468
1471
  if (!entry) return void 0;
1469
- if (entry.value.kind !== "object") {
1472
+ return readAcceptanceItems(entry, seen);
1473
+ }
1474
+ function readAcceptanceItems(entry, seen) {
1475
+ if (entry.value.kind !== "array") {
1470
1476
  throw new ExtractError(
1471
1477
  "uidex-export-invalid-field",
1472
- "`waivers` must be an object mapping a core kind to a reason.",
1478
+ "`acceptance` must be an array of strings or `{ id, rev?, text }` objects.",
1473
1479
  entry.value.pos
1474
1480
  );
1475
1481
  }
1476
- const waivers = {};
1477
- for (const e of entry.value.entries) {
1478
- if (!CORE_STATE_KINDS2.has(e.key)) {
1482
+ const criteria = [];
1483
+ for (const item of entry.value.items) {
1484
+ let criterion;
1485
+ if (item.kind === "string") {
1486
+ if (item.value.length === 0) {
1487
+ throw new ExtractError(
1488
+ "uidex-export-invalid-field",
1489
+ "An `acceptance` entry is an empty string.",
1490
+ item.pos
1491
+ );
1492
+ }
1493
+ const id = acceptanceSlug(item.value);
1494
+ if (!id) {
1495
+ throw new ExtractError(
1496
+ "uidex-export-invalid-field",
1497
+ `Acceptance text "${item.value}" contains no alphanumeric characters to derive an id from; use the object form with an explicit \`id\`.`,
1498
+ item.pos
1499
+ );
1500
+ }
1501
+ criterion = { id, rev: 1, text: item.value };
1502
+ } else if (item.kind === "object") {
1503
+ const fieldByKey = /* @__PURE__ */ new Map();
1504
+ for (const e of item.entries) {
1505
+ if (!ACCEPTANCE_FIELDS.has(e.key)) {
1506
+ throw new ExtractError(
1507
+ "uidex-export-invalid-field",
1508
+ `Unknown field "${e.key}" in an \`acceptance\` entry. Allowed: ${[
1509
+ ...ACCEPTANCE_FIELDS
1510
+ ].sort().join(", ")}.`,
1511
+ e.value.pos
1512
+ );
1513
+ }
1514
+ fieldByKey.set(e.key, e);
1515
+ }
1516
+ const id = readStringField(fieldByKey, "id");
1517
+ if (id === void 0 || id.length === 0) {
1518
+ throw new ExtractError(
1519
+ "uidex-export-invalid-field",
1520
+ "An object `acceptance` entry needs a non-empty string `id`.",
1521
+ item.pos
1522
+ );
1523
+ }
1524
+ if (id.includes("@")) {
1525
+ throw new ExtractError(
1526
+ "uidex-export-invalid-field",
1527
+ `acceptance id "${id}" must not contain "@" \u2014 it is reserved for the \`@<rev>\` pin in \`uidexCovers\` claims.`,
1528
+ item.pos
1529
+ );
1530
+ }
1531
+ const text = readStringField(fieldByKey, "text");
1532
+ if (text === void 0 || text.length === 0) {
1533
+ throw new ExtractError(
1534
+ "uidex-export-invalid-field",
1535
+ `acceptance "${id}" needs a non-empty string \`text\`.`,
1536
+ item.pos
1537
+ );
1538
+ }
1539
+ let rev = 1;
1540
+ const revEntry = fieldByKey.get("rev");
1541
+ if (revEntry) {
1542
+ if (revEntry.value.kind !== "number" || !Number.isInteger(revEntry.value.value) || revEntry.value.value < 1) {
1543
+ throw new ExtractError(
1544
+ "uidex-export-invalid-field",
1545
+ `acceptance "${id}" \`rev\` must be a positive integer.`,
1546
+ revEntry.value.pos
1547
+ );
1548
+ }
1549
+ rev = revEntry.value.value;
1550
+ }
1551
+ criterion = { id, rev, text };
1552
+ } else {
1479
1553
  throw new ExtractError(
1480
1554
  "uidex-export-invalid-field",
1481
- `waiver key "${e.key}" is not a core kind; only ${[...CORE_STATE_KINDS2].join(", ")} participate in the matrix.`,
1482
- e.keyPos
1555
+ "`acceptance` items must be strings or `{ id, rev?, text }` objects.",
1556
+ item.pos
1483
1557
  );
1484
1558
  }
1485
- if (e.value.kind !== "string" || e.value.value.length === 0) {
1559
+ if (seen.has(criterion.id)) {
1486
1560
  throw new ExtractError(
1487
- "uidex-export-invalid-field",
1488
- `waiver "${e.key}" needs a non-empty reason (why the route cannot render this kind).`,
1489
- e.value.pos
1561
+ "acceptance-duplicate-id",
1562
+ `Duplicate acceptance criterion id "${criterion.id}"; each criterion id must be unique within the entity.`,
1563
+ item.pos,
1564
+ "Give the criterion an explicit distinct `id` (object form) \u2014 two string criteria can slug to the same id."
1490
1565
  );
1491
1566
  }
1492
- waivers[e.key] = e.value.value;
1567
+ seen.add(criterion.id);
1568
+ criteria.push(criterion);
1493
1569
  }
1494
- return waivers;
1495
- }
1496
- function readBooleanField(byKey, name) {
1497
- const entry = byKey.get(name);
1498
- if (!entry) return void 0;
1499
- if (entry.value.kind !== "boolean") {
1500
- throw new ExtractError(
1501
- "uidex-export-invalid-field",
1502
- `\`${name}\` must be a boolean.`,
1503
- entry.value.pos
1504
- );
1505
- }
1506
- return entry.value.value;
1570
+ return criteria;
1507
1571
  }
1508
1572
  function readStringField(byKey, name) {
1509
1573
  const entry = byKey.get(name);
@@ -1542,7 +1606,7 @@ function readStringArrayField(byKey, name) {
1542
1606
  }
1543
1607
  return { values, spans };
1544
1608
  }
1545
- function readStatesField(byKey) {
1609
+ function readStatesField(byKey, criterionIds) {
1546
1610
  const entry = byKey.get("states");
1547
1611
  if (!entry) return void 0;
1548
1612
  if (entry.value.kind !== "array") {
@@ -1592,8 +1656,11 @@ function readStatesField(byKey) {
1592
1656
  }
1593
1657
  state.kind = kind;
1594
1658
  }
1595
- const acceptance = readStringArrayField(fieldByKey, "acceptance")?.values;
1596
- if (acceptance) state.acceptance = acceptance;
1659
+ const accEntry = fieldByKey.get("acceptance");
1660
+ if (accEntry) {
1661
+ const acceptance = readAcceptanceItems(accEntry, criterionIds);
1662
+ if (acceptance.length > 0) state.acceptance = acceptance;
1663
+ }
1597
1664
  const description = readStringField(fieldByKey, "description");
1598
1665
  if (description) state.description = description;
1599
1666
  } else {
@@ -1649,20 +1716,21 @@ function isNode2(value) {
1649
1716
  }
1650
1717
 
1651
1718
  // src/scanner/scan/flow-facts.ts
1652
- function collectFlowFacts(parsed, content) {
1719
+ function collectFlowFacts(parsed, content, displayPath) {
1653
1720
  if (parsed.program === null || !content.includes("test.describe")) {
1654
- return [];
1721
+ return { facts: [], diagnostics: [] };
1655
1722
  }
1656
1723
  const facts = [];
1724
+ const diagnostics = [];
1657
1725
  walkAst(parsed.program, (node) => {
1658
1726
  if (node.type !== "CallExpression") return void 0;
1659
- const fact = readTaggedDescribe(node, parsed);
1727
+ const fact = readTaggedDescribe(node, parsed, diagnostics, displayPath);
1660
1728
  if (fact) facts.push(fact);
1661
1729
  return void 0;
1662
1730
  });
1663
- return facts;
1731
+ return { facts, diagnostics };
1664
1732
  }
1665
- function readTaggedDescribe(call, parsed) {
1733
+ function readTaggedDescribe(call, parsed, diagnostics, displayPath) {
1666
1734
  const callee = call.callee;
1667
1735
  if (!callee || callee.type !== "MemberExpression" || !isIdentifier(callee.object, "test") || !isIdentifier(callee.property, "describe")) {
1668
1736
  return null;
@@ -1673,13 +1741,83 @@ function readTaggedDescribe(call, parsed) {
1673
1741
  if (!hasFlowTag(args[1])) return null;
1674
1742
  const body = args[2];
1675
1743
  const { calls, dynamicCalls } = body ? collectUidexCalls(body, parsed) : { calls: [], dynamicCalls: [] };
1744
+ const covers = body ? collectCoversClaims(body, parsed, title, diagnostics, displayPath) : [];
1676
1745
  return {
1677
1746
  title,
1678
1747
  line: parsed.lineAt(call.start),
1679
1748
  calls,
1680
- ...dynamicCalls.length > 0 ? { dynamicCalls } : {}
1749
+ ...dynamicCalls.length > 0 ? { dynamicCalls } : {},
1750
+ ...covers.length > 0 ? { covers } : {}
1681
1751
  };
1682
1752
  }
1753
+ function collectCoversClaims(body, parsed, flowTitle, diagnostics, displayPath) {
1754
+ const covers = [];
1755
+ walkAst(body, (node) => {
1756
+ if (node.type !== "CallExpression") return void 0;
1757
+ if (!isIdentifier(node.callee, "uidexCovers")) return void 0;
1758
+ const line = parsed.lineAt(node.start);
1759
+ const args = node.arguments ?? [];
1760
+ const entity = stringLiteralValue(args[0]);
1761
+ if (entity === null) {
1762
+ diagnostics.push({
1763
+ code: "covers-missing-rev",
1764
+ severity: "error",
1765
+ message: `\`uidexCovers(\u2026)\` in flow "${flowTitle}" needs a static string entity id as its first argument.`,
1766
+ file: displayPath,
1767
+ line,
1768
+ hint: 'Write `uidexCovers("<entityId>", "<criterionId>@<rev>")` with string literals.'
1769
+ });
1770
+ return void 0;
1771
+ }
1772
+ if (args.length < 2) {
1773
+ diagnostics.push({
1774
+ code: "covers-missing-rev",
1775
+ severity: "error",
1776
+ message: `\`uidexCovers("${entity}")\` in flow "${flowTitle}" names no criterion; a claim covers nothing without at least one \`"<criterionId>@<rev>"\` argument.`,
1777
+ file: displayPath,
1778
+ line,
1779
+ hint: 'List the criteria this flow verifies: `uidexCovers("<entityId>", "<criterionId>@<rev>", ...)`.'
1780
+ });
1781
+ return void 0;
1782
+ }
1783
+ for (const arg of args.slice(1)) {
1784
+ const claimLine = parsed.lineAt(arg.start);
1785
+ const raw = stringLiteralValue(arg);
1786
+ const at = raw === null ? -1 : raw.lastIndexOf("@");
1787
+ if (raw === null || at <= 0) {
1788
+ diagnostics.push({
1789
+ code: "covers-missing-rev",
1790
+ severity: "error",
1791
+ message: `\`uidexCovers(\u2026)\` claim ${raw === null ? "" : `"${raw}" `}in flow "${flowTitle}" is missing its \`@<rev>\` pin.`,
1792
+ file: displayPath,
1793
+ line: claimLine,
1794
+ hint: 'Pin the criterion rev \u2014 `uidexCovers("<entityId>", "<criterionId>@<rev>")` \u2014 so a later rev bump surfaces this claim as outdated.'
1795
+ });
1796
+ continue;
1797
+ }
1798
+ const revStr = raw.slice(at + 1);
1799
+ if (!/^[1-9]\d*$/.test(revStr)) {
1800
+ diagnostics.push({
1801
+ code: "covers-missing-rev",
1802
+ severity: "error",
1803
+ message: `\`uidexCovers(\u2026)\` claim "${raw}" in flow "${flowTitle}" has an invalid rev "@${revStr}"; the rev must be a positive integer (no leading zeros).`,
1804
+ file: displayPath,
1805
+ line: claimLine,
1806
+ hint: 'Write the pin as `"<criterionId>@<rev>"` with a positive integer rev, e.g. `"gated@1"`.'
1807
+ });
1808
+ continue;
1809
+ }
1810
+ covers.push({
1811
+ entity,
1812
+ criterion: raw.slice(0, at),
1813
+ rev: Number(revStr),
1814
+ line: claimLine
1815
+ });
1816
+ }
1817
+ return void 0;
1818
+ });
1819
+ return covers;
1820
+ }
1683
1821
  function hasFlowTag(node) {
1684
1822
  if (!node || node.type !== "ObjectExpression") return false;
1685
1823
  for (const prop of node.properties ?? []) {
@@ -2071,11 +2209,13 @@ function extract(files) {
2071
2209
  const out2 = { file, annotations: [] };
2072
2210
  out2.annotations = extractOne(file, parsed, out2);
2073
2211
  if (exports.length > 0) out2.metadata = exports;
2212
+ const flowFacts = collectFlowFacts(parsed, file.content, file.displayPath);
2213
+ diagnostics.push(...flowFacts.diagnostics);
2074
2214
  if (diagnostics.length > 0) out2.diagnostics = diagnostics;
2075
- const flows = collectFlowFacts(parsed, file.content);
2076
- if (flows.length > 0) out2.flows = flows;
2215
+ if (flowFacts.facts.length > 0) out2.flows = flowFacts.facts;
2077
2216
  const imports = collectImportFacts(parsed);
2078
2217
  if (imports.length > 0) out2.imports = imports;
2218
+ if (containsJsx(parsed)) out2.hasJsx = true;
2079
2219
  return out2;
2080
2220
  });
2081
2221
  }
@@ -2100,6 +2240,32 @@ function extractOne(file, parsed, out2) {
2100
2240
  }
2101
2241
  return annotations;
2102
2242
  }
2243
+ function containsJsx(parsed) {
2244
+ if (parsed.program === null) return false;
2245
+ let found = false;
2246
+ walkAst(parsed.program, (node) => {
2247
+ if (node.type === "JSXElement" || node.type === "JSXFragment") {
2248
+ found = true;
2249
+ return false;
2250
+ }
2251
+ if (node.type === "CallExpression" && isCreateElementCallee(node.callee)) {
2252
+ found = true;
2253
+ return false;
2254
+ }
2255
+ return void 0;
2256
+ });
2257
+ return found;
2258
+ }
2259
+ function isCreateElementCallee(callee) {
2260
+ const node = callee;
2261
+ if (!node) return false;
2262
+ if (node.type === "Identifier") return String(node.name) === "createElement";
2263
+ if (node.type === "MemberExpression") {
2264
+ const prop = node.property;
2265
+ return prop?.type === "Identifier" && String(prop.name) === "createElement";
2266
+ }
2267
+ return false;
2268
+ }
2103
2269
  function collectImportFacts(parsed) {
2104
2270
  if (parsed.program === null) return [];
2105
2271
  const out2 = [];
@@ -2132,6 +2298,21 @@ function collectImportFacts(parsed) {
2132
2298
  names
2133
2299
  });
2134
2300
  }
2301
+ walkAst(parsed.program, (node) => {
2302
+ if (node.type !== "ImportExpression") return void 0;
2303
+ const source = node.source;
2304
+ if (!source || source.type !== "Literal") return void 0;
2305
+ if (typeof source.value !== "string") return void 0;
2306
+ out2.push({
2307
+ specifier: source.value,
2308
+ line: parsed.lineAt(node.start),
2309
+ span: { start: node.start, end: node.end },
2310
+ isTypeOnly: false,
2311
+ names: [],
2312
+ dynamic: true
2313
+ });
2314
+ return void 0;
2315
+ });
2135
2316
  return out2;
2136
2317
  }
2137
2318
 
@@ -2303,6 +2484,7 @@ function buildMetaFromExport(exp) {
2303
2484
  if (exp.acceptance?.length) meta.acceptance = exp.acceptance;
2304
2485
  if (exp.features?.length) meta.features = exp.features;
2305
2486
  if (exp.widgets?.length) meta.widgets = exp.widgets;
2487
+ if (exp.surfaces?.length) meta.surfaces = exp.surfaces;
2306
2488
  if (exp.states?.length) {
2307
2489
  meta.states = exp.states.map((s) => ({
2308
2490
  name: s.name,
@@ -2716,7 +2898,8 @@ function resolve2(ctx) {
2716
2898
  id: flowExport.id,
2717
2899
  loc: base.loc,
2718
2900
  touches: base.touches,
2719
- steps: base.steps
2901
+ steps: base.steps,
2902
+ ...base.covers ? { covers: base.covers } : {}
2720
2903
  };
2721
2904
  registry.add(flow);
2722
2905
  } else {
@@ -2764,20 +2947,42 @@ function computeScope(displayPath) {
2764
2947
  return null;
2765
2948
  }
2766
2949
  function flowsFromFacts(ff) {
2767
- return (ff.flows ?? []).map((fact) => ({
2768
- kind: "flow",
2769
- id: kebab(fact.title),
2770
- loc: { file: ff.file.displayPath, line: fact.line },
2771
- touches: dedupe(fact.calls.map((c) => c.id)),
2772
- steps: fact.calls.filter((c) => c.action).map((c) => ({ entityId: c.id, action: c.action }))
2773
- }));
2950
+ return (ff.flows ?? []).map((fact) => {
2951
+ const covers = dedupeBy(
2952
+ (fact.covers ?? []).map((c) => ({
2953
+ entity: c.entity,
2954
+ criterion: c.criterion,
2955
+ rev: c.rev
2956
+ })),
2957
+ (c) => `${c.entity} ${c.criterion} ${c.rev}`
2958
+ );
2959
+ return {
2960
+ kind: "flow",
2961
+ id: kebab(fact.title),
2962
+ loc: { file: ff.file.displayPath, line: fact.line },
2963
+ touches: dedupe(fact.calls.map((c) => c.id)),
2964
+ steps: fact.calls.filter((c) => c.action).map((c) => ({ entityId: c.id, action: c.action })),
2965
+ ...covers.length > 0 ? { covers } : {}
2966
+ };
2967
+ });
2968
+ }
2969
+ function dedupeBy(arr, key) {
2970
+ const seen = /* @__PURE__ */ new Set();
2971
+ const out2 = [];
2972
+ for (const item of arr) {
2973
+ const k = key(item);
2974
+ if (seen.has(k)) continue;
2975
+ seen.add(k);
2976
+ out2.push(item);
2977
+ }
2978
+ return out2;
2774
2979
  }
2775
2980
  function dedupe(arr) {
2776
2981
  return Array.from(new Set(arr));
2777
2982
  }
2778
2983
 
2779
2984
  // src/scanner/scan/audit.ts
2780
- import * as path6 from "path";
2985
+ import * as path7 from "path";
2781
2986
 
2782
2987
  // src/scanner/scan/component-coverage.ts
2783
2988
  var COMPOSED_KINDS = ["widget", "feature"];
@@ -2832,141 +3037,6 @@ function checkComponentCoverage(registry, manifest) {
2832
3037
  return diagnostics;
2833
3038
  }
2834
3039
 
2835
- // src/scanner/scan/migrate-acknowledge.ts
2836
- var CORE = CORE_STATE_KINDS;
2837
- function orderScopes(scopes) {
2838
- const rank = (s) => s === ACK_ALL ? -1 : CORE.indexOf(s);
2839
- return [...scopes].sort((a, b) => rank(a) - rank(b));
2840
- }
2841
- function renderKey(scope) {
2842
- return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(scope) ? scope : JSON.stringify(scope);
2843
- }
2844
- function renderAck(a) {
2845
- const parts = [`type: ${JSON.stringify(a.type)}`];
2846
- if (a.reason) parts.push(`reason: ${JSON.stringify(a.reason)}`);
2847
- return `{ ${parts.join(", ")} }`;
2848
- }
2849
- function renderAcknowledge(entries, indent) {
2850
- const inner = indent + " ";
2851
- const lines = orderScopes(Object.keys(entries)).map(
2852
- (scope) => `${inner}${renderKey(scope)}: ${renderAck(entries[scope])},`
2853
- );
2854
- return ["acknowledge: {", ...lines, `${indent}}`].join("\n");
2855
- }
2856
- function indentAt(content, offset) {
2857
- const lineStart = content.lastIndexOf("\n", offset - 1) + 1;
2858
- const m = /^[ \t]*/.exec(content.slice(lineStart, offset));
2859
- return m ? m[0] : "";
2860
- }
2861
- function plannedEntries(m) {
2862
- const entries = {};
2863
- if (m.legacyCapture === false) {
2864
- entries[ACK_ALL] = {
2865
- type: "impossible",
2866
- reason: m.description || PLACEHOLDER_REASON
2867
- };
2868
- }
2869
- for (const kind of CORE) {
2870
- const reason = m.legacyWaivers?.[kind];
2871
- if (reason) entries[kind] = { type: "impossible", reason };
2872
- }
2873
- return entries;
2874
- }
2875
- function migrateLegacyFields(ef) {
2876
- const out2 = [];
2877
- const { content, sourcePath, displayPath } = ef.file;
2878
- for (const m of ef.metadata ?? []) {
2879
- const legacy = [];
2880
- if (m.legacyWaivers) legacy.push("waivers");
2881
- if (m.legacyCapture !== void 0) legacy.push("capture");
2882
- if (legacy.length === 0) continue;
2883
- const fields = legacy.join(" and ");
2884
- const base = {
2885
- severity: "error",
2886
- code: "legacy-acknowledge-field",
2887
- file: displayPath,
2888
- line: m.loc.line
2889
- };
2890
- if (m.acknowledge) {
2891
- out2.push({
2892
- ...base,
2893
- message: `\`${fields}\` was replaced by \`acknowledge\`, and this declaration already has an \`acknowledge\` field`,
2894
- hint: `Merge the remaining \`${fields}\` entries into \`acknowledge\` by hand and delete them; --fix will not merge two maps for you.`
2895
- });
2896
- continue;
2897
- }
2898
- const entries = plannedEntries(m);
2899
- const edits = [];
2900
- const present = legacy.map((f) => ({ f, prop: m.fieldPropSpans?.[f], del: m.fieldSpans?.[f] })).filter((x) => x.prop && x.del).sort((a, b) => a.prop.start - b.prop.start);
2901
- if (present.length !== legacy.length) {
2902
- out2.push({
2903
- ...base,
2904
- message: `\`${fields}\` was replaced by \`acknowledge\`, but the field spans needed to rewrite it are unavailable`,
2905
- hint: "Rewrite the declaration by hand: `acknowledge: { <scope>: { type, reason? } }`."
2906
- });
2907
- continue;
2908
- }
2909
- const [anchor, ...rest] = present;
2910
- if (Object.keys(entries).length === 0) {
2911
- edits.push({
2912
- path: sourcePath,
2913
- start: anchor.del.start,
2914
- end: anchor.del.end,
2915
- replacement: ""
2916
- });
2917
- } else {
2918
- edits.push({
2919
- path: sourcePath,
2920
- start: anchor.prop.start,
2921
- end: anchor.prop.end,
2922
- replacement: renderAcknowledge(
2923
- entries,
2924
- indentAt(content, anchor.prop.start)
2925
- )
2926
- });
2927
- }
2928
- for (const r of rest) {
2929
- edits.push({
2930
- path: sourcePath,
2931
- start: r.del.start,
2932
- end: r.del.end,
2933
- replacement: ""
2934
- });
2935
- }
2936
- out2.push({
2937
- ...base,
2938
- message: `\`${fields}\` was replaced by the unified \`acknowledge\` field`,
2939
- hint: `Run \`uidex scan --fix\` to rewrite it${m.legacyCapture === false && !m.description ? ", then replace the TODO reason it plants \u2014 `capture: false` carried no explanation and `impossible` requires one" : ""}.`,
2940
- fix: {
2941
- description: `Rewrite \`${fields}\` as \`acknowledge\``,
2942
- edits
2943
- }
2944
- });
2945
- }
2946
- return out2;
2947
- }
2948
- function migrateLegacyBaseline(opts) {
2949
- if (!isLegacyBaseline(opts.parsed)) return void 0;
2950
- return {
2951
- code: "legacy-coverage-baseline",
2952
- severity: "error",
2953
- message: `coverage baseline "${opts.displayPath}" uses the removed \`uncapturedPages\` / \`missingKinds\` shape`,
2954
- file: opts.displayPath,
2955
- hint: "Run `uidex scan --fix` to rewrite it as `acknowledge` entries of type `backlog` (identical content, new shape \u2014 no capture run needed).",
2956
- fix: {
2957
- description: `Rewrite ${opts.displayPath} as an \`acknowledge\` baseline`,
2958
- edits: [
2959
- {
2960
- path: opts.path,
2961
- start: 0,
2962
- end: opts.raw.length,
2963
- replacement: serializeCoverageBaseline(migrateBaseline(opts.parsed))
2964
- }
2965
- ]
2966
- }
2967
- };
2968
- }
2969
-
2970
3040
  // src/scanner/scan/page-coverage.ts
2971
3041
  function compileRoute(pattern) {
2972
3042
  const segs = pattern.split("/").filter(Boolean);
@@ -2988,11 +3058,11 @@ function compileRoute(pattern) {
2988
3058
  return { test: (url) => re.test(url), specificity };
2989
3059
  }
2990
3060
  function matchUrlToRoute(url, routePaths) {
2991
- const path13 = stripQuery(url);
3061
+ const path14 = stripQuery(url);
2992
3062
  let best = null;
2993
3063
  for (const rp of routePaths) {
2994
3064
  const { test, specificity } = compileRoute(rp);
2995
- if (!test(path13)) continue;
3065
+ if (!test(path14)) continue;
2996
3066
  if (!best || specificity > best.specificity)
2997
3067
  best = { path: rp, specificity };
2998
3068
  }
@@ -3400,6 +3470,182 @@ function computeMatrixBacklog(registry, manifest) {
3400
3470
  return out2;
3401
3471
  }
3402
3472
 
3473
+ // src/scanner/scan/surface-check.ts
3474
+ import * as path6 from "path";
3475
+ var EXT_CANDIDATES = [
3476
+ "",
3477
+ ".tsx",
3478
+ ".ts",
3479
+ ".jsx",
3480
+ ".js",
3481
+ "/index.tsx",
3482
+ "/index.ts",
3483
+ "/index.jsx",
3484
+ "/index.js"
3485
+ ];
3486
+ function checkFeatureSurfaces(registry, extracted, config) {
3487
+ const declarations = [];
3488
+ for (const ef of extracted) {
3489
+ for (const m of ef.metadata ?? []) {
3490
+ if (m.kind !== "feature") continue;
3491
+ if (typeof m.id !== "string") continue;
3492
+ if (!m.surfaces || m.surfaces.length === 0) continue;
3493
+ if (m.acknowledge?.["*"]) continue;
3494
+ declarations.push({
3495
+ ef,
3496
+ id: m.id,
3497
+ surfaces: m.surfaces,
3498
+ surfaceSpans: m.surfaceSpans,
3499
+ line: m.loc.line ?? 1
3500
+ });
3501
+ }
3502
+ }
3503
+ if (declarations.length === 0) return [];
3504
+ const fileSet = /* @__PURE__ */ new Set();
3505
+ const jsxFiles = /* @__PURE__ */ new Set();
3506
+ for (const ef of extracted) {
3507
+ fileSet.add(ef.file.displayPath);
3508
+ if (ef.hasJsx) jsxFiles.add(ef.file.displayPath);
3509
+ }
3510
+ const aliases = Object.entries(config.resolve?.aliases ?? {}).sort(
3511
+ (a, b) => b[0].length - a[0].length
3512
+ );
3513
+ const resolveSpecifier = (importer, spec) => {
3514
+ const bases = [];
3515
+ if (spec.startsWith("./") || spec.startsWith("../")) {
3516
+ bases.push(path6.posix.join(path6.posix.dirname(importer), spec));
3517
+ } else {
3518
+ const alias = aliases.find(([prefix]) => spec.startsWith(prefix));
3519
+ if (alias) {
3520
+ bases.push(alias[1] + spec.slice(alias[0].length));
3521
+ } else if (aliases.length === 0) {
3522
+ if (spec.startsWith("@/") || spec.startsWith("~/")) {
3523
+ bases.push("src/" + spec.slice(2));
3524
+ }
3525
+ }
3526
+ bases.push(spec);
3527
+ }
3528
+ const hits = [];
3529
+ for (const base of bases) {
3530
+ const normalized = path6.posix.normalize(base);
3531
+ for (const ext of EXT_CANDIDATES) {
3532
+ const candidate = normalized + ext;
3533
+ if (fileSet.has(candidate)) hits.push(candidate);
3534
+ }
3535
+ }
3536
+ return hits;
3537
+ };
3538
+ const adjacency = /* @__PURE__ */ new Map();
3539
+ for (const ef of extracted) {
3540
+ const targets = [];
3541
+ for (const imp of ef.imports ?? []) {
3542
+ if (imp.isTypeOnly) continue;
3543
+ targets.push(...resolveSpecifier(ef.file.displayPath, imp.specifier));
3544
+ }
3545
+ if (targets.length > 0) adjacency.set(ef.file.displayPath, targets);
3546
+ }
3547
+ const inDir = (file, dir) => file === dir || file.startsWith(dir + "/");
3548
+ const reachesDir = (seeds, dir) => {
3549
+ const queue = [...seeds];
3550
+ const visited = new Set(queue);
3551
+ while (queue.length > 0) {
3552
+ const current = queue.pop();
3553
+ if (inDir(current, dir) && jsxFiles.has(current)) return true;
3554
+ for (const next of adjacency.get(current) ?? []) {
3555
+ if (visited.has(next)) continue;
3556
+ visited.add(next);
3557
+ queue.push(next);
3558
+ }
3559
+ }
3560
+ return false;
3561
+ };
3562
+ const featuresConvention = config.conventions?.features === void 0 ? DEFAULT_CONVENTIONS.features : config.conventions.features;
3563
+ const featureGlob = typeof featuresConvention === "string" ? featuresConvention : null;
3564
+ const diagnostics = [];
3565
+ for (const decl of declarations) {
3566
+ const declPath = decl.ef.file.displayPath;
3567
+ const featureDir = (featureGlob !== null ? extractFeatureDir(declPath, featureGlob) : null) ?? path6.posix.dirname(declPath);
3568
+ for (let i = 0; i < decl.surfaces.length; i++) {
3569
+ const pageId = decl.surfaces[i];
3570
+ const line = decl.surfaceSpans?.[i] ? lineOfOffset(decl.ef.file.content, decl.surfaceSpans[i].start) : decl.line;
3571
+ const page = registry.get("page", pageId) ?? registry.matchPattern("page", pageId);
3572
+ if (!page) {
3573
+ diagnostics.push({
3574
+ code: "unknown-reference",
3575
+ severity: "warning",
3576
+ message: `Feature "${decl.id}" declares a surface on unknown page "${pageId}"`,
3577
+ file: decl.ef.file.displayPath,
3578
+ line,
3579
+ entity: { kind: "feature", id: decl.id },
3580
+ hint: `No page with id "${pageId}" exists in the registry; fix the reference or add the page.`
3581
+ });
3582
+ continue;
3583
+ }
3584
+ const pageFile = page.loc?.file;
3585
+ if (!pageFile) continue;
3586
+ const seeds = [pageFile];
3587
+ if (path6.posix.basename(pageFile) === WELL_KNOWN_FILES.page) {
3588
+ const dir = path6.posix.dirname(pageFile);
3589
+ for (const f of fileSet) {
3590
+ if (f !== pageFile && path6.posix.dirname(f) === dir) seeds.push(f);
3591
+ }
3592
+ }
3593
+ if (reachesDir(seeds, featureDir)) continue;
3594
+ diagnostics.push({
3595
+ code: "feature-surface-unreferenced",
3596
+ severity: "warning",
3597
+ message: `Feature "${decl.id}" declares page "${pageId}" as a surface, but nothing reachable from ${pageFile} references a component file in ${featureDir}/`,
3598
+ file: decl.ef.file.displayPath,
3599
+ line,
3600
+ entity: { kind: "feature", id: decl.id },
3601
+ hint: `This is a static tripwire, not proof either way: verify page "${pageId}" really mounts a component from ${featureDir}/ (and that the import resolves within the scanned sources), or remove "${pageId}" from the feature's \`surfaces\`.`
3602
+ });
3603
+ }
3604
+ }
3605
+ return diagnostics;
3606
+ }
3607
+ function checkFeatureSurfaceCaptures(registry, manifest) {
3608
+ const routes = registry.list("route");
3609
+ if (routes.length === 0) return [];
3610
+ const routePaths = routes.map((r) => r.path);
3611
+ const captured = manifest.captured ?? [];
3612
+ const diagnostics = [];
3613
+ for (const feature of registry.list("feature")) {
3614
+ const surfaces = feature.meta?.surfaces ?? [];
3615
+ if (surfaces.length === 0) continue;
3616
+ if (entityAcknowledge(feature.meta)) continue;
3617
+ const coveredRoutes = /* @__PURE__ */ new Set();
3618
+ for (const c of captured) {
3619
+ if (c.kind !== "feature" || c.entity !== feature.id) continue;
3620
+ const route = routeForCapture(c, routes, routePaths);
3621
+ if (route) coveredRoutes.add(route);
3622
+ }
3623
+ for (const pageId of surfaces) {
3624
+ const pageRoutes = routes.filter((r) => r.page === pageId);
3625
+ if (pageRoutes.length === 0) continue;
3626
+ if (pageRoutes.some((r) => coveredRoutes.has(r.path))) continue;
3627
+ const routeList = pageRoutes.map((r) => `"${r.path}"`).join(", ");
3628
+ diagnostics.push({
3629
+ code: "feature-surface-uncaptured",
3630
+ severity: "warning",
3631
+ message: `Feature "${feature.id}" declares page "${pageId}" as a surface, but no capture rendered the feature on ${pageRoutes.length === 1 ? `route ${routeList}` : `any of its routes (${routeList})`}`,
3632
+ file: feature.loc?.file,
3633
+ line: feature.loc?.line,
3634
+ entity: { kind: "feature", id: feature.id },
3635
+ hint: `Author a states capture spec that renders feature "${feature.id}" on page "${pageId}" (a capture with kind "feature" driving ${routeList}), or acknowledge the feature with \`acknowledge: { "*": \u2026 }\`.`
3636
+ });
3637
+ }
3638
+ }
3639
+ return diagnostics;
3640
+ }
3641
+ function lineOfOffset(content, offset) {
3642
+ let line = 1;
3643
+ for (let i = 0; i < offset && i < content.length; i++) {
3644
+ if (content[i] === "\n") line++;
3645
+ }
3646
+ return line;
3647
+ }
3648
+
3403
3649
  // src/scanner/scan/audit.ts
3404
3650
  function audit(opts) {
3405
3651
  const diagnostics = [];
@@ -3407,6 +3653,8 @@ function audit(opts) {
3407
3653
  const check = opts.check ?? false;
3408
3654
  const lint = opts.lint ?? false;
3409
3655
  const acceptanceEnabled = config.audit?.acceptance ?? true;
3656
+ const surfacesEnabled = config.audit?.surfaces ?? true;
3657
+ const requireAcceptanceEnabled = config.audit?.requireAcceptance ?? true;
3410
3658
  const scopeLeakEnabled = config.audit?.scopeLeak ?? true;
3411
3659
  const coverageEnabled = config.audit?.coverage ?? true;
3412
3660
  const statesEnabled = config.audit?.states ?? true;
@@ -3419,8 +3667,6 @@ function audit(opts) {
3419
3667
  for (const ef of opts.flowExtracted ?? []) {
3420
3668
  if (ef.diagnostics) diagnostics.push(...ef.diagnostics);
3421
3669
  }
3422
- for (const ef of extracted) diagnostics.push(...migrateLegacyFields(ef));
3423
- if (opts.baselineDiagnostic) diagnostics.push(opts.baselineDiagnostic);
3424
3670
  for (const ef of extracted) {
3425
3671
  for (const m of ef.metadata ?? []) {
3426
3672
  for (const [scope, ack] of Object.entries(m.acknowledge ?? {})) {
@@ -3505,9 +3751,66 @@ function audit(opts) {
3505
3751
  }
3506
3752
  }
3507
3753
  if (lint && acceptanceEnabled) {
3508
- for (const kind of ["widget", "feature", "page"]) {
3754
+ const flowEntities = registry.list("flow");
3755
+ const acceptanceKinds = ["widget", "feature", "page"];
3756
+ const claims = /* @__PURE__ */ new Map();
3757
+ const addClaim = (kind, entityId, criterion, claim) => {
3758
+ const key = `${kind}:${entityId}`;
3759
+ let byCrit = claims.get(key);
3760
+ if (!byCrit) {
3761
+ byCrit = /* @__PURE__ */ new Map();
3762
+ claims.set(key, byCrit);
3763
+ }
3764
+ let list = byCrit.get(criterion);
3765
+ if (!list) {
3766
+ list = [];
3767
+ byCrit.set(criterion, list);
3768
+ }
3769
+ list.push(claim);
3770
+ };
3771
+ for (const flow of flowEntities) {
3772
+ for (const c of flow.covers ?? []) {
3773
+ const candidates = acceptanceKinds.flatMap((k) => {
3774
+ const e = registry.get(k, c.entity);
3775
+ return e ? [e] : [];
3776
+ });
3777
+ const matching = candidates.filter(
3778
+ (e) => entityCriteria(e).some((crit) => crit.id === c.criterion)
3779
+ );
3780
+ if (matching.length === 0) {
3781
+ diagnostics.push({
3782
+ code: "acceptance-orphaned",
3783
+ severity: "warning",
3784
+ message: `Flow "${flow.id}" covers criterion "${c.criterion}" on "${c.entity}", but ${candidates.length > 0 ? `no criterion with that id exists on ${candidates.map((e) => `${e.kind} "${c.entity}"`).join(" or ")}` : `no page/feature/widget "${c.entity}" exists`}`,
3785
+ file: flow.loc.file,
3786
+ line: flow.loc.line,
3787
+ entity: { kind: "flow", id: flow.id },
3788
+ hint: "Remove the stale uidexCovers claim, or fix the entity/criterion id it names."
3789
+ });
3790
+ continue;
3791
+ }
3792
+ if (matching.length > 1) {
3793
+ diagnostics.push({
3794
+ code: "acceptance-ambiguous",
3795
+ severity: "warning",
3796
+ message: `Flow "${flow.id}" covers criterion "${c.criterion}" on "${c.entity}", which matches ${matching.map((e) => `${e.kind} "${c.entity}"`).join(" and ")}; the claim credits all of them`,
3797
+ file: flow.loc.file,
3798
+ line: flow.loc.line,
3799
+ entity: { kind: "flow", id: flow.id },
3800
+ hint: "The claim syntax carries no kind, so a criterion id shared by same-id entities cannot be told apart \u2014 rename one entity or criterion id to disambiguate."
3801
+ });
3802
+ }
3803
+ for (const target of matching) {
3804
+ addClaim(target.kind, target.id, c.criterion, {
3805
+ flowId: flow.id,
3806
+ rev: c.rev
3807
+ });
3808
+ }
3809
+ }
3810
+ }
3811
+ for (const kind of acceptanceKinds) {
3509
3812
  for (const e of registry.list(kind)) {
3510
- const criteria = e.meta?.acceptance ?? [];
3813
+ const criteria = entityCriteria(e);
3511
3814
  if (criteria.length === 0) {
3512
3815
  if (kind === "widget") {
3513
3816
  diagnostics.push({
@@ -3522,14 +3825,27 @@ function audit(opts) {
3522
3825
  }
3523
3826
  continue;
3524
3827
  }
3525
- const flowIds = e.meta?.flows ?? [];
3526
- if (flowIds.length === 0) {
3527
- const hint = kind === "widget" ? `uidex scaffold widget ${e.id}` : `Add a Playwright flow under e2e/** that touches ${kind} "${e.id}" (tag the describe with @uidex:flow)`;
3528
- for (const c of criteria) {
3828
+ const entityClaims = claims.get(`${kind}:${e.id}`);
3829
+ for (const c of criteria) {
3830
+ const critClaims = entityClaims?.get(c.id) ?? [];
3831
+ const stale = critClaims.filter((cl) => cl.rev !== c.rev);
3832
+ for (const cl of stale) {
3833
+ diagnostics.push({
3834
+ code: "acceptance-outdated",
3835
+ severity: "warning",
3836
+ message: `Flow "${cl.flowId}" covers ${kind} "${e.id}" criterion "${c.id}" at rev ${cl.rev}, but the criterion is now rev ${c.rev}`,
3837
+ file: e.loc?.file,
3838
+ line: e.loc?.line,
3839
+ entity: { kind, id: e.id },
3840
+ hint: `Re-verify the flow against the current criterion text, then update its claim to \`uidexCovers("${e.id}", "${c.id}@${c.rev}")\`.`
3841
+ });
3842
+ }
3843
+ if (critClaims.length === 0) {
3844
+ const hint = kind === "widget" ? `uidex scaffold widget ${e.id}, then claim it with \`uidexCovers("${e.id}", "${c.id}@${c.rev}")\`` : `Add a Playwright flow under e2e/** that touches ${kind} "${e.id}" (tag the describe with @uidex:flow) and claim the criterion with \`uidexCovers("${e.id}", "${c.id}@${c.rev}")\``;
3529
3845
  diagnostics.push({
3530
3846
  code: "acceptance-uncovered",
3531
3847
  severity: "warning",
3532
- message: `${kind} "${e.id}" has acceptance criterion not covered by any flow: "${c}"`,
3848
+ message: `${kind} "${e.id}" has acceptance criterion "${c.id}" not covered by any flow: "${c.text}"`,
3533
3849
  file: e.loc?.file,
3534
3850
  line: e.loc?.line,
3535
3851
  entity: { kind, id: e.id },
@@ -3540,7 +3856,31 @@ function audit(opts) {
3540
3856
  }
3541
3857
  }
3542
3858
  }
3859
+ if (lint && surfacesEnabled) {
3860
+ diagnostics.push(...checkFeatureSurfaces(registry, extracted, config));
3861
+ }
3862
+ if (lint && surfacesEnabled && opts.capturedStates) {
3863
+ diagnostics.push(
3864
+ ...checkFeatureSurfaceCaptures(registry, opts.capturedStates)
3865
+ );
3866
+ }
3867
+ if (lint && requireAcceptanceEnabled) {
3868
+ for (const e of registry.list("feature")) {
3869
+ if (entityCriteria(e).length > 0) continue;
3870
+ diagnostics.push({
3871
+ code: "feature-missing-acceptance",
3872
+ severity: "warning",
3873
+ message: `Feature "${e.id}" declares no acceptance criteria`,
3874
+ file: e.loc?.file,
3875
+ line: e.loc?.line,
3876
+ entity: { kind: "feature", id: e.id },
3877
+ hint: `Add \`acceptance: [...]\` to the feature's \`export const uidex\` \u2014 observable outcomes, not implementation. They feed \`uidex scaffold feature ${e.id}\` and the acceptance-uncovered gate.`
3878
+ });
3879
+ }
3880
+ }
3543
3881
  if (lint) {
3882
+ const featuresConvention = config.conventions?.features === void 0 ? DEFAULT_CONVENTIONS.features : config.conventions.features;
3883
+ const featureGlob = typeof featuresConvention === "string" ? featuresConvention : null;
3544
3884
  const scannedPaths = new Set(files.map((f) => f.displayPath));
3545
3885
  for (const ef of extracted) {
3546
3886
  if (!ef.metadata) continue;
@@ -3549,10 +3889,24 @@ function audit(opts) {
3549
3889
  if (typeof m.id !== "string") continue;
3550
3890
  const filePath = ef.file.displayPath;
3551
3891
  const wellKnownName = WELL_KNOWN_FILES[m.kind];
3552
- if (path6.posix.basename(filePath) === wellKnownName) continue;
3553
- const dir = path6.posix.dirname(filePath);
3892
+ if (path7.posix.basename(filePath) === wellKnownName) continue;
3893
+ const featureDir = m.kind === "feature" && featureGlob !== null ? extractFeatureDir(filePath, featureGlob) : null;
3894
+ const dir = featureDir ?? path7.posix.dirname(filePath);
3554
3895
  const wellKnownPath = dir === "." ? wellKnownName : `${dir}/${wellKnownName}`;
3555
3896
  if (scannedPaths.has(wellKnownPath)) continue;
3897
+ const inConventionDir = featureDir !== null;
3898
+ if (inConventionDir) {
3899
+ diagnostics.push({
3900
+ code: "prefer-well-known-file",
3901
+ severity: "error",
3902
+ message: `Feature "${m.id}" metadata lives on ${filePath}; a feature in a convention-derived directory must declare its metadata in ${wellKnownPath}`,
3903
+ file: filePath,
3904
+ line: m.loc.line,
3905
+ entity: { kind: "feature", id: m.id },
3906
+ hint: `Move the \`export const uidex\` block to ${wellKnownPath} and remove it from ${filePath}.`
3907
+ });
3908
+ continue;
3909
+ }
3556
3910
  const kindLabel = m.kind === "page" ? "Page" : "Feature";
3557
3911
  diagnostics.push({
3558
3912
  code: "prefer-well-known-file",
@@ -3636,6 +3990,7 @@ function audit(opts) {
3636
3990
  const displayPath = ef.file.displayPath;
3637
3991
  for (const imp of ef.imports ?? []) {
3638
3992
  if (imp.isTypeOnly) continue;
3993
+ if (imp.dynamic) continue;
3639
3994
  const baseName2 = imp.specifier.split("/").pop() ?? "";
3640
3995
  const primitive = byName.get(
3641
3996
  baseName2.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
@@ -3753,7 +4108,7 @@ function audit(opts) {
3753
4108
  severity: "warning",
3754
4109
  message: `\`export const uidex\` in ${ef.file.displayPath} references unknown ${refKind} "${refId}"`,
3755
4110
  file: ef.file.displayPath,
3756
- line: spans?.[i] ? lineOfOffset(ef.file.content, spans[i].start) : m.loc.line,
4111
+ line: spans?.[i] ? lineOfOffset2(ef.file.content, spans[i].start) : m.loc.line,
3757
4112
  hint: `No ${refKind} with id "${refId}" exists in the registry; fix the reference or add the ${refKind}.`
3758
4113
  });
3759
4114
  }
@@ -3800,13 +4155,21 @@ function audit(opts) {
3800
4155
  };
3801
4156
  return { diagnostics: governed, summary };
3802
4157
  }
3803
- function lineOfOffset(content, offset) {
4158
+ function lineOfOffset2(content, offset) {
3804
4159
  let line = 1;
3805
4160
  for (let i = 0; i < offset && i < content.length; i++) {
3806
4161
  if (content[i] === "\n") line++;
3807
4162
  }
3808
4163
  return line;
3809
4164
  }
4165
+ function entityCriteria(e) {
4166
+ const meta = e.meta;
4167
+ if (!meta) return [];
4168
+ return [
4169
+ ...meta.acceptance ?? [],
4170
+ ...(meta.states ?? []).flatMap((s) => s.acceptance ?? [])
4171
+ ];
4172
+ }
3810
4173
  var TAG_FALLBACK_ID = {
3811
4174
  a: "link",
3812
4175
  button: "button",
@@ -4251,10 +4614,16 @@ function emit(opts) {
4251
4614
  ' export type CoreStateKind = "loading" | "empty" | "populated" | "error"'
4252
4615
  );
4253
4616
  t.push(' export type StateKind = CoreStateKind | "variant"');
4617
+ t.push(" export interface AcceptanceCriterion {");
4618
+ t.push(" id: string");
4619
+ t.push(" rev?: number");
4620
+ t.push(" text: string");
4621
+ t.push(" }");
4622
+ t.push(" export type Acceptance = readonly (string | AcceptanceCriterion)[]");
4254
4623
  t.push(" export interface State {");
4255
4624
  t.push(" name: string");
4256
4625
  t.push(" kind?: StateKind");
4257
- t.push(" acceptance?: readonly string[]");
4626
+ t.push(" acceptance?: Acceptance");
4258
4627
  t.push(" description?: string");
4259
4628
  t.push(" }");
4260
4629
  t.push(' export type AcknowledgeScope = CoreStateKind | "*"');
@@ -4276,7 +4645,7 @@ function emit(opts) {
4276
4645
  t.push(" name?: string");
4277
4646
  t.push(" features?: readonly FeatureId[]");
4278
4647
  t.push(" widgets?: readonly WidgetId[]");
4279
- t.push(" acceptance?: readonly string[]");
4648
+ t.push(" acceptance?: Acceptance");
4280
4649
  t.push(" states?: readonly (string | State)[]");
4281
4650
  t.push(" acknowledge?: Acknowledge");
4282
4651
  t.push(" description?: string");
@@ -4285,7 +4654,8 @@ function emit(opts) {
4285
4654
  t.push(" feature: FeatureId | false");
4286
4655
  t.push(" name?: string");
4287
4656
  t.push(" features?: readonly FeatureId[]");
4288
- t.push(" acceptance?: readonly string[]");
4657
+ t.push(" surfaces?: readonly PageId[]");
4658
+ t.push(" acceptance?: Acceptance");
4289
4659
  t.push(" states?: readonly (string | State)[]");
4290
4660
  t.push(" acknowledge?: ComponentAcknowledge");
4291
4661
  t.push(" description?: string");
@@ -4298,7 +4668,7 @@ function emit(opts) {
4298
4668
  t.push(" export interface Widget {");
4299
4669
  t.push(" widget: WidgetId");
4300
4670
  t.push(" name?: string");
4301
- t.push(" acceptance?: readonly string[]");
4671
+ t.push(" acceptance?: Acceptance");
4302
4672
  t.push(" states?: readonly (string | State)[]");
4303
4673
  t.push(" acknowledge?: ComponentAcknowledge");
4304
4674
  t.push(" description?: string");
@@ -4397,7 +4767,7 @@ function parseGitHubRef(ref) {
4397
4767
 
4398
4768
  // src/scanner/scan/scaffold.ts
4399
4769
  import * as fs4 from "fs";
4400
- import * as path7 from "path";
4770
+ import * as path8 from "path";
4401
4771
  function scaffoldWidgetSpec(opts) {
4402
4772
  return scaffoldSpec({
4403
4773
  registry: opts.registry,
@@ -4421,9 +4791,9 @@ function scaffoldSpec(opts) {
4421
4791
  if (!entity) {
4422
4792
  throw new Error(`${capitalize(kind)} "${id}" not found in registry`);
4423
4793
  }
4424
- const criteria = entity.meta?.acceptance ?? [];
4794
+ const criteria = entityCriteria(entity);
4425
4795
  const filename = kind === "widget" ? `widget-${id}.spec.ts` : `flow-${id}.spec.ts`;
4426
- const outputPath = path7.resolve(outDir, filename);
4796
+ const outputPath = path8.resolve(outDir, filename);
4427
4797
  if (fs4.existsSync(outputPath) && !force) {
4428
4798
  return {
4429
4799
  outputPath,
@@ -4432,8 +4802,8 @@ function scaffoldSpec(opts) {
4432
4802
  reason: `spec already exists at ${outputPath}; pass --force to overwrite`
4433
4803
  };
4434
4804
  }
4435
- const content = renderSpec({ id, criteria, fixtureImport });
4436
- fs4.mkdirSync(path7.dirname(outputPath), { recursive: true });
4805
+ const content = renderSpec({ id, entityId: id, criteria, fixtureImport });
4806
+ fs4.mkdirSync(path8.dirname(outputPath), { recursive: true });
4437
4807
  fs4.writeFileSync(outputPath, content, "utf8");
4438
4808
  return { outputPath, written: true, skipped: false };
4439
4809
  }
@@ -4460,8 +4830,9 @@ function renderSpec(args) {
4460
4830
  ]);
4461
4831
  } else {
4462
4832
  for (const criterion of args.criteria) {
4463
- stub(criterion, [
4464
- "// TODO: drive this criterion, then remove `.fixme` to arm the test."
4833
+ stub(`${criterion.id}: ${criterion.text}`, [
4834
+ "// TODO: drive this criterion, then remove `.fixme` to arm the test",
4835
+ `// and claim it: uidexCovers(${JSON.stringify(args.entityId)}, ${JSON.stringify(`${criterion.id}@${criterion.rev}`)})`
4465
4836
  ]);
4466
4837
  }
4467
4838
  }
@@ -4472,36 +4843,29 @@ function renderSpec(args) {
4472
4843
 
4473
4844
  // src/scanner/scan/pipeline.ts
4474
4845
  import * as fs5 from "fs";
4475
- import * as path8 from "path";
4846
+ import * as path9 from "path";
4476
4847
  var DEFAULT_STATES_MANIFEST2 = "uidex-states.json";
4477
4848
  var DEFAULT_COVERAGE_BASELINE = "uidex-coverage-baseline.json";
4478
4849
  function loadCoverageBaseline(configDir, config) {
4479
4850
  const rel = config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
4480
- const abs = path8.resolve(configDir, rel);
4851
+ const abs = path9.resolve(configDir, rel);
4481
4852
  let raw;
4482
4853
  try {
4483
4854
  raw = fs5.readFileSync(abs, "utf8");
4484
4855
  } catch {
4485
- return {};
4856
+ return void 0;
4486
4857
  }
4487
4858
  let parsed;
4488
4859
  try {
4489
4860
  parsed = JSON.parse(raw);
4490
4861
  } catch {
4491
- return {};
4862
+ return void 0;
4492
4863
  }
4493
- const legacy = migrateLegacyBaseline({
4494
- path: abs,
4495
- raw,
4496
- parsed,
4497
- displayPath: rel
4498
- });
4499
- if (legacy) return { legacy };
4500
- return { baseline: parseCoverageBaseline(parsed) ?? void 0 };
4864
+ return parseCoverageBaseline(parsed) ?? void 0;
4501
4865
  }
4502
4866
  function loadCapturedStates(configDir, config) {
4503
4867
  const rel = config.statesManifest ?? DEFAULT_STATES_MANIFEST2;
4504
- const abs = path8.resolve(configDir, rel);
4868
+ const abs = path9.resolve(configDir, rel);
4505
4869
  let raw;
4506
4870
  try {
4507
4871
  raw = fs5.readFileSync(abs, "utf8");
@@ -4559,11 +4923,11 @@ function runOne(dc, opts) {
4559
4923
  gitContext
4560
4924
  });
4561
4925
  const typesRel = config.output;
4562
- const typesPath = path8.resolve(configDir, typesRel);
4926
+ const typesPath = path9.resolve(configDir, typesRel);
4563
4927
  const dataRel = dataOutputPath(config.output);
4564
- const dataPath = path8.resolve(configDir, dataRel);
4928
+ const dataPath = path9.resolve(configDir, dataRel);
4565
4929
  const locsRel = locsOutputPath(config.output);
4566
- const locsPath = path8.resolve(configDir, locsRel);
4930
+ const locsPath = path9.resolve(configDir, locsRel);
4567
4931
  let typesOnDisk = null;
4568
4932
  let dataOnDisk = null;
4569
4933
  let locsOnDisk = null;
@@ -4576,11 +4940,10 @@ function runOne(dc, opts) {
4576
4940
  (ef) => (ef.diagnostics?.length ?? 0) > 0
4577
4941
  );
4578
4942
  const capturedStates = opts.capturedStates ?? loadCapturedStates(configDir, config);
4579
- const loadedBaseline = opts.coverageBaseline ? { baseline: opts.coverageBaseline } : loadCoverageBaseline(configDir, config);
4580
- const coverageBaseline = loadedBaseline.baseline;
4943
+ const coverageBaseline = opts.coverageBaseline ?? loadCoverageBaseline(configDir, config);
4581
4944
  const workspace = opts.workspace ?? resolveWorkspace({ configDir, config });
4582
4945
  let auditResult;
4583
- if (opts.check || opts.lint || resolved.diagnostics.length > 0 || hasExtractDiagnostics || loadedBaseline.legacy) {
4946
+ if (opts.check || opts.lint || resolved.diagnostics.length > 0 || hasExtractDiagnostics) {
4584
4947
  auditResult = audit({
4585
4948
  registry: resolved.registry,
4586
4949
  extracted,
@@ -4604,7 +4967,6 @@ function runOne(dc, opts) {
4604
4967
  locsOutputPath: locsRel,
4605
4968
  capturedStates,
4606
4969
  coverageBaseline,
4607
- baselineDiagnostic: loadedBaseline.legacy,
4608
4970
  workspace
4609
4971
  });
4610
4972
  }
@@ -4633,7 +4995,7 @@ function readOrNull(p2) {
4633
4995
  }
4634
4996
  function writeArtifact(artifact) {
4635
4997
  if (readOrNull(artifact.outputPath) === artifact.generated) return false;
4636
- fs5.mkdirSync(path8.dirname(artifact.outputPath), { recursive: true });
4998
+ fs5.mkdirSync(path9.dirname(artifact.outputPath), { recursive: true });
4637
4999
  fs5.writeFileSync(artifact.outputPath, artifact.generated, "utf8");
4638
5000
  return true;
4639
5001
  }
@@ -4645,7 +5007,7 @@ function writeScanResult(result) {
4645
5007
  }
4646
5008
  function coverageBaselinePath(result) {
4647
5009
  const rel = result.config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
4648
- return path8.resolve(result.configDir, rel);
5010
+ return path9.resolve(result.configDir, rel);
4649
5011
  }
4650
5012
 
4651
5013
  // src/scanner/scan/states-merge.ts
@@ -4681,7 +5043,7 @@ function mergeStates(committed, partial) {
4681
5043
 
4682
5044
  // src/scanner/scan/fix.ts
4683
5045
  import * as fs6 from "fs";
4684
- import * as path9 from "path";
5046
+ import * as path10 from "path";
4685
5047
  function applyFixes(diagnostics) {
4686
5048
  const entries = [];
4687
5049
  for (const d of diagnostics) {
@@ -4740,10 +5102,10 @@ function applyFixes(diagnostics) {
4740
5102
  if (entry.skippedReason) continue;
4741
5103
  for (const create of entry.createFiles) {
4742
5104
  if (fs6.existsSync(create.path)) {
4743
- entry.skippedReason = `${path9.basename(create.path)} already exists`;
5105
+ entry.skippedReason = `${path10.basename(create.path)} already exists`;
4744
5106
  continue;
4745
5107
  }
4746
- fs6.mkdirSync(path9.dirname(create.path), { recursive: true });
5108
+ fs6.mkdirSync(path10.dirname(create.path), { recursive: true });
4747
5109
  fs6.writeFileSync(create.path, create.content, "utf8");
4748
5110
  }
4749
5111
  if (entry.skippedReason) continue;
@@ -4928,23 +5290,23 @@ function renameEntity(opts) {
4928
5290
 
4929
5291
  // src/scanner/scan/cli.ts
4930
5292
  import * as fs9 from "fs";
4931
- import * as path12 from "path";
5293
+ import * as path13 from "path";
4932
5294
 
4933
5295
  // src/scanner/scan/ai/index.ts
4934
5296
  import * as p from "@clack/prompts";
4935
5297
 
4936
5298
  // src/scanner/scan/ai/providers/claude.ts
4937
5299
  import * as fs8 from "fs";
4938
- import * as path11 from "path";
5300
+ import * as path12 from "path";
4939
5301
 
4940
5302
  // src/scanner/scan/ai/templates.ts
4941
5303
  import * as fs7 from "fs";
4942
- import * as path10 from "path";
5304
+ import * as path11 from "path";
4943
5305
  function templatePath(rel) {
4944
5306
  const candidates = [
4945
- path10.resolve(__dirname, "../../templates", rel),
5307
+ path11.resolve(__dirname, "../../templates", rel),
4946
5308
  // dist/cli/cli.cjs → ../../templates
4947
- path10.resolve(__dirname, "../../../../templates", rel)
5309
+ path11.resolve(__dirname, "../../../../templates", rel)
4948
5310
  // src/scanner/scan/ai → ../../../../templates
4949
5311
  ];
4950
5312
  for (const c of candidates) {
@@ -4992,7 +5354,7 @@ var claudeProvider = {
4992
5354
  async install({ cwd, force }) {
4993
5355
  const changes = [];
4994
5356
  for (const file of SKILL_FILES) {
4995
- const dest = path11.join(cwd, file.dest);
5357
+ const dest = path12.join(cwd, file.dest);
4996
5358
  const exists = fs8.existsSync(dest);
4997
5359
  if (exists && !force) {
4998
5360
  changes.push({
@@ -5002,7 +5364,7 @@ var claudeProvider = {
5002
5364
  });
5003
5365
  continue;
5004
5366
  }
5005
- fs8.mkdirSync(path11.dirname(dest), { recursive: true });
5367
+ fs8.mkdirSync(path12.dirname(dest), { recursive: true });
5006
5368
  fs8.writeFileSync(dest, readTemplate(file.template));
5007
5369
  changes.push({
5008
5370
  path: file.dest,
@@ -5010,21 +5372,21 @@ var claudeProvider = {
5010
5372
  });
5011
5373
  }
5012
5374
  for (const rel of LEGACY_FILES) {
5013
- const dest = path11.join(cwd, rel);
5375
+ const dest = path12.join(cwd, rel);
5014
5376
  if (fs8.existsSync(dest)) {
5015
5377
  fs8.unlinkSync(dest);
5016
5378
  changes.push({ path: rel, action: "removed" });
5017
5379
  }
5018
5380
  }
5019
- cleanupEmpty(path11.join(cwd, ".claude/commands/uidex"));
5020
- cleanupEmpty(path11.join(cwd, ".claude/commands"));
5021
- cleanupEmpty(path11.join(cwd, ".claude/rules"));
5381
+ cleanupEmpty(path12.join(cwd, ".claude/commands/uidex"));
5382
+ cleanupEmpty(path12.join(cwd, ".claude/commands"));
5383
+ cleanupEmpty(path12.join(cwd, ".claude/rules"));
5022
5384
  return { changes };
5023
5385
  },
5024
5386
  async uninstall({ cwd }) {
5025
5387
  const changes = [];
5026
5388
  for (const file of SKILL_FILES) {
5027
- const dest = path11.join(cwd, file.dest);
5389
+ const dest = path12.join(cwd, file.dest);
5028
5390
  if (!fs8.existsSync(dest)) {
5029
5391
  changes.push({ path: file.dest, action: "skipped", reason: "absent" });
5030
5392
  continue;
@@ -5032,19 +5394,19 @@ var claudeProvider = {
5032
5394
  fs8.unlinkSync(dest);
5033
5395
  changes.push({ path: file.dest, action: "removed" });
5034
5396
  }
5035
- cleanupEmpty(path11.join(cwd, ".claude/skills/uidex/references"));
5036
- cleanupEmpty(path11.join(cwd, ".claude/skills/uidex"));
5037
- cleanupEmpty(path11.join(cwd, ".claude/skills"));
5397
+ cleanupEmpty(path12.join(cwd, ".claude/skills/uidex/references"));
5398
+ cleanupEmpty(path12.join(cwd, ".claude/skills/uidex"));
5399
+ cleanupEmpty(path12.join(cwd, ".claude/skills"));
5038
5400
  for (const rel of LEGACY_FILES) {
5039
- const dest = path11.join(cwd, rel);
5401
+ const dest = path12.join(cwd, rel);
5040
5402
  if (fs8.existsSync(dest)) {
5041
5403
  fs8.unlinkSync(dest);
5042
5404
  changes.push({ path: rel, action: "removed" });
5043
5405
  }
5044
5406
  }
5045
- cleanupEmpty(path11.join(cwd, ".claude/commands/uidex"));
5046
- cleanupEmpty(path11.join(cwd, ".claude/commands"));
5047
- cleanupEmpty(path11.join(cwd, ".claude/rules"));
5407
+ cleanupEmpty(path12.join(cwd, ".claude/commands/uidex"));
5408
+ cleanupEmpty(path12.join(cwd, ".claude/commands"));
5409
+ cleanupEmpty(path12.join(cwd, ".claude/rules"));
5048
5410
  return { changes };
5049
5411
  }
5050
5412
  };
@@ -5257,7 +5619,7 @@ function helpText2() {
5257
5619
  " --check Verify the on-disk gen file matches a fresh scan; exit non-zero on drift (read-only)",
5258
5620
  " --lint Run lint diagnostics (missing annotations, scope leak, duplicate ids, coverage)",
5259
5621
  " --audit Equivalent to --check --lint (read-only)",
5260
- " --fix Apply machine-generated fixes (add data-uidex to unannotated interactive elements, drop empty names, migrate waivers/capture to acknowledge), then rescan and write",
5622
+ " --fix Apply machine-generated fixes (add data-uidex to unannotated interactive elements, drop empty names), then rescan and write",
5261
5623
  " --update-baseline Regenerate the coverage baseline (uidex-coverage-baseline.json) as `acknowledge` backlog entries",
5262
5624
  " --json Emit JSON diagnostics on stdout",
5263
5625
  " --force (scaffold) overwrite existing spec",
@@ -5265,7 +5627,7 @@ function helpText2() {
5265
5627
  ].join("\n");
5266
5628
  }
5267
5629
  function runInit(cwd, w) {
5268
- const configPath = path12.join(cwd, CONFIG_FILENAME);
5630
+ const configPath = path13.join(cwd, CONFIG_FILENAME);
5269
5631
  if (fs9.existsSync(configPath)) {
5270
5632
  w.err(`.uidex.json already exists at ${configPath}`);
5271
5633
  return w.result(1);
@@ -5277,7 +5639,7 @@ function runInit(cwd, w) {
5277
5639
  };
5278
5640
  fs9.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
5279
5641
  w.out(`Created ${configPath}`);
5280
- const gitignorePath = path12.join(cwd, ".gitignore");
5642
+ const gitignorePath = path13.join(cwd, ".gitignore");
5281
5643
  const entry = "*.gen.*";
5282
5644
  if (fs9.existsSync(gitignorePath)) {
5283
5645
  const existing = fs9.readFileSync(gitignorePath, "utf8");
@@ -5335,7 +5697,7 @@ function runScanCommand(cwd, flags, w) {
5335
5697
  const acknowledge = {};
5336
5698
  const merge = (from) => {
5337
5699
  for (const [route, scopes] of Object.entries(from)) {
5338
- acknowledge[route] = { ...acknowledge[route] ?? {}, ...scopes };
5700
+ acknowledge[route] = { ...acknowledge[route], ...scopes };
5339
5701
  }
5340
5702
  };
5341
5703
  merge(computePageBacklog(r.registry, manifest));
@@ -5423,7 +5785,7 @@ function runScaffold(cwd, args, flags, w) {
5423
5785
  for (const r of results) {
5424
5786
  const entity = r.registry.get(scaffoldKind, id);
5425
5787
  if (!entity) continue;
5426
- const outDir = path12.resolve(r.configDir, "e2e");
5788
+ const outDir = path13.resolve(r.configDir, "e2e");
5427
5789
  const result = scaffoldSpec({
5428
5790
  registry: r.registry,
5429
5791
  kind: scaffoldKind,
@@ -5458,8 +5820,8 @@ function runStates(cwd, args, w) {
5458
5820
  let merged = 0;
5459
5821
  for (const { configDir, config } of configs) {
5460
5822
  const rel = config.statesManifest ?? "uidex-states.json";
5461
- const target = path12.resolve(configDir, rel);
5462
- const partial = explicitPartial ? path12.resolve(cwd, explicitPartial) : target.replace(/\.json$/i, "") + ".partial.json";
5823
+ const target = path13.resolve(configDir, rel);
5824
+ const partial = explicitPartial ? path13.resolve(cwd, explicitPartial) : target.replace(/\.json$/i, "") + ".partial.json";
5463
5825
  const committedRaw = readJson(target);
5464
5826
  const partialRaw = readJson(partial);
5465
5827
  if (!partialRaw) continue;
@@ -5471,7 +5833,7 @@ function runStates(cwd, args, w) {
5471
5833
  fs9.writeFileSync(target, JSON.stringify(result.manifest, null, 2) + "\n");
5472
5834
  merged++;
5473
5835
  w.out(
5474
- `Merged ${path12.basename(partial)} \u2192 ${rel}: ${result.added.length} added, ${result.updated.length} updated, ${result.manifest.captured.length} total`
5836
+ `Merged ${path13.basename(partial)} \u2192 ${rel}: ${result.added.length} added, ${result.updated.length} updated, ${result.manifest.captured.length} total`
5475
5837
  );
5476
5838
  for (const k of result.added) w.out(` + ${k}`);
5477
5839
  for (const k of result.updated) w.out(` ~ ${k}`);
@@ -5563,13 +5925,9 @@ export {
5563
5925
  extractUidexExports,
5564
5926
  findWorkspaceRoot,
5565
5927
  globToRegExp,
5566
- isLegacyBaseline,
5567
5928
  loadCoverageBaseline,
5568
5929
  matchUrlToRoute,
5569
5930
  mergeStates,
5570
- migrateBaseline,
5571
- migrateLegacyBaseline,
5572
- migrateLegacyFields,
5573
5931
  parseConfig,
5574
5932
  parseCoverageBaseline,
5575
5933
  pathToId,