uidex 0.10.0 → 0.11.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.
@@ -55,13 +55,9 @@ __export(scan_exports, {
55
55
  extractUidexExports: () => extractUidexExports,
56
56
  findWorkspaceRoot: () => findWorkspaceRoot,
57
57
  globToRegExp: () => globToRegExp,
58
- isLegacyBaseline: () => isLegacyBaseline,
59
58
  loadCoverageBaseline: () => loadCoverageBaseline,
60
59
  matchUrlToRoute: () => matchUrlToRoute,
61
60
  mergeStates: () => mergeStates,
62
- migrateBaseline: () => migrateBaseline,
63
- migrateLegacyBaseline: () => migrateLegacyBaseline,
64
- migrateLegacyFields: () => migrateLegacyFields,
65
61
  parseConfig: () => parseConfig,
66
62
  parseCoverageBaseline: () => parseCoverageBaseline,
67
63
  pathToId: () => pathToId,
@@ -110,6 +106,7 @@ var ALLOWED_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
110
106
  "exclude",
111
107
  "output",
112
108
  "flows",
109
+ "resolve",
113
110
  "audit",
114
111
  "conventions",
115
112
  "statesManifest",
@@ -128,24 +125,32 @@ var ALLOWED_AUDIT_KEYS = /* @__PURE__ */ new Set([
128
125
  "scopeLeak",
129
126
  "coverage",
130
127
  "acceptance",
128
+ "surfaces",
129
+ "requireAcceptance",
131
130
  "states",
132
131
  "pageCoverage",
133
132
  "stateMatrix",
134
133
  "severity"
135
134
  ]);
136
135
  var DIAGNOSTIC_CODES = [
136
+ "acceptance-ambiguous",
137
+ "acceptance-duplicate-id",
138
+ "acceptance-orphaned",
139
+ "acceptance-outdated",
137
140
  "acceptance-uncovered",
138
141
  "acknowledged-elsewhere",
139
142
  "competing-uidex-export",
140
143
  "component-state-uncaptured",
141
144
  "core-kind",
145
+ "covers-missing-rev",
142
146
  "duplicate-id",
143
147
  "dynamic-attr",
144
148
  "dynamic-flow-reference",
149
+ "feature-missing-acceptance",
150
+ "feature-surface-uncaptured",
151
+ "feature-surface-unreferenced",
145
152
  "gen-missing",
146
153
  "gen-stale",
147
- "legacy-acknowledge-field",
148
- "legacy-coverage-baseline",
149
154
  "matrix-backlog",
150
155
  "missing-element-annotation",
151
156
  "missing-state-capture",
@@ -180,14 +185,14 @@ var SEVERITY_LEVELS = /* @__PURE__ */ new Set(["error", "warning", "info", "off"
180
185
  function fail(msg) {
181
186
  throw new ConfigError(`Invalid .uidex.json: ${msg}`);
182
187
  }
183
- function assertObject(value, path13) {
188
+ function assertObject(value, path14) {
184
189
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
185
- fail(`${path13} must be an object`);
190
+ fail(`${path14} must be an object`);
186
191
  }
187
192
  }
188
- function assertStringArray(value, path13) {
193
+ function assertStringArray(value, path14) {
189
194
  if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
190
- fail(`${path13} must be a string[]`);
195
+ fail(`${path14} must be a string[]`);
191
196
  }
192
197
  }
193
198
  function validateConfig(raw) {
@@ -244,6 +249,23 @@ function validateConfig(raw) {
244
249
  if (raw.workspace !== void 0 && raw.workspace !== false && typeof raw.workspace !== "string") {
245
250
  fail(`"workspace" must be a path string or false`);
246
251
  }
252
+ if (raw.resolve !== void 0) {
253
+ assertObject(raw.resolve, "resolve");
254
+ for (const key of Object.keys(raw.resolve)) {
255
+ if (key !== "aliases") fail(`unknown key "${key}" in resolve`);
256
+ }
257
+ const aliases = raw.resolve.aliases;
258
+ if (aliases === void 0) {
259
+ fail(`resolve.aliases is required when "resolve" is present`);
260
+ }
261
+ assertObject(aliases, "resolve.aliases");
262
+ for (const [alias, target] of Object.entries(aliases)) {
263
+ if (alias.length === 0) fail(`resolve.aliases keys must be non-empty`);
264
+ if (typeof target !== "string") {
265
+ fail(`resolve.aliases["${alias}"] must be a string`);
266
+ }
267
+ }
268
+ }
247
269
  if (raw.audit !== void 0) {
248
270
  assertObject(raw.audit, "audit");
249
271
  for (const key of Object.keys(raw.audit)) {
@@ -304,6 +326,7 @@ function validateConfig(raw) {
304
326
  exclude: raw.exclude,
305
327
  output: raw.output,
306
328
  flows: raw.flows,
329
+ resolve: raw.resolve,
307
330
  audit: raw.audit,
308
331
  conventions: raw.conventions,
309
332
  statesManifest: raw.statesManifest,
@@ -831,7 +854,6 @@ var CORE_SET = new Set(CORE_STATE_KINDS);
831
854
  function isCoreKind(scope) {
832
855
  return CORE_SET.has(scope);
833
856
  }
834
- var PLACEHOLDER_REASON = "TODO: why can this never be captured?";
835
857
  function isPlaceholderReason(reason) {
836
858
  return typeof reason === "string" && /^\s*TODO\b/i.test(reason);
837
859
  }
@@ -909,28 +931,6 @@ function parseCoverageBaseline(raw) {
909
931
  }
910
932
  return { version: 2, acknowledge };
911
933
  }
912
- function isLegacyBaseline(raw) {
913
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
914
- const r = raw;
915
- if ("acknowledge" in r) return false;
916
- return "uncapturedPages" in r || "missingKinds" in r;
917
- }
918
- function migrateBaseline(legacy) {
919
- const acknowledge = {};
920
- const at = (route) => acknowledge[route] ??= {};
921
- for (const route of legacy.uncapturedPages ?? []) {
922
- if (typeof route === "string") at(route)[ACK_ALL] = { type: "backlog" };
923
- }
924
- for (const [route, kinds] of Object.entries(legacy.missingKinds ?? {})) {
925
- if (!Array.isArray(kinds)) continue;
926
- for (const kind of kinds) {
927
- if (typeof kind === "string" && isCoreKind(kind)) {
928
- at(route)[kind] = { type: "backlog" };
929
- }
930
- }
931
- }
932
- return { version: 2, acknowledge };
933
- }
934
934
  function serializeCoverageBaseline(baseline) {
935
935
  const routes = Object.keys(baseline.acknowledge).sort();
936
936
  const ordered = {};
@@ -964,24 +964,21 @@ var ALLOWED_FIELDS = {
964
964
  "acceptance",
965
965
  "states",
966
966
  "acknowledge",
967
- "capture",
968
- "waivers",
969
967
  "description"
970
968
  ]),
971
969
  // A page's `acknowledge` accepts both scope shapes: `"*"` (the whole route)
972
970
  // and a core kind (one cell of its matrix). A feature/widget has no route, so
973
971
  // the core-kind matrix is meaningless there and only `"*"` is accepted —
974
972
  // giving a component a scoped opt-out instead of forcing you to delete its
975
- // whole `states` block. `capture` / `waivers` are REMOVED; they stay parseable
976
- // only so `--fix` can rewrite them into `acknowledge` (see MIGRATION below).
973
+ // whole `states` block.
977
974
  feature: /* @__PURE__ */ new Set([
978
975
  "feature",
979
976
  "name",
980
977
  "features",
978
+ "surfaces",
981
979
  "acceptance",
982
980
  "states",
983
981
  "acknowledge",
984
- "capture",
985
982
  "description"
986
983
  ]),
987
984
  primitive: /* @__PURE__ */ new Set(["primitive", "name", "description"]),
@@ -991,20 +988,19 @@ var ALLOWED_FIELDS = {
991
988
  "acceptance",
992
989
  "states",
993
990
  "acknowledge",
994
- "capture",
995
991
  "description"
996
992
  ]),
997
993
  region: /* @__PURE__ */ new Set(["region", "name", "description"]),
998
994
  flow: /* @__PURE__ */ new Set(["flow", "notFlow", "name", "description"])
999
995
  };
1000
996
  var ACK_FIELDS = /* @__PURE__ */ new Set(["type", "reason"]);
997
+ var ACCEPTANCE_FIELDS = /* @__PURE__ */ new Set(["id", "rev", "text"]);
1001
998
  var STATE_FIELDS = /* @__PURE__ */ new Set([
1002
999
  "name",
1003
1000
  "kind",
1004
1001
  "acceptance",
1005
1002
  "description"
1006
1003
  ]);
1007
- var CORE_STATE_KINDS2 = new Set(CORE_STATE_KINDS);
1008
1004
  var STATE_KINDS = /* @__PURE__ */ new Set([...CORE_STATE_KINDS, "variant"]);
1009
1005
  var FALSEABLE = /* @__PURE__ */ new Set([
1010
1006
  "page",
@@ -1370,7 +1366,8 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
1370
1366
  } else {
1371
1367
  id = readIdField(idValue, kind, idField);
1372
1368
  }
1373
- const acceptance = readStringArrayField(byKey, "acceptance")?.values;
1369
+ const criterionIds = /* @__PURE__ */ new Set();
1370
+ const acceptance = readAcceptanceField(byKey, criterionIds);
1374
1371
  const description = readStringField(byKey, "description");
1375
1372
  const name = readStringField(byKey, "name");
1376
1373
  if (name === "") {
@@ -1396,11 +1393,10 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
1396
1393
  }
1397
1394
  const featuresField = kind === "page" || kind === "feature" ? readStringArrayField(byKey, "features") : void 0;
1398
1395
  const widgetsField = kind === "page" ? readStringArrayField(byKey, "widgets") : void 0;
1399
- const states = kind === "page" || kind === "feature" || kind === "widget" ? readStatesField(byKey) : void 0;
1396
+ const surfacesField = kind === "feature" ? readStringArrayField(byKey, "surfaces") : void 0;
1397
+ const states = kind === "page" || kind === "feature" || kind === "widget" ? readStatesField(byKey, criterionIds) : void 0;
1400
1398
  const stateBearing = kind === "page" || kind === "feature" || kind === "widget";
1401
1399
  const acknowledge = stateBearing ? readAcknowledgeField(byKey, kind === "page") : void 0;
1402
- const legacyCapture = stateBearing ? readBooleanField(byKey, "capture") : void 0;
1403
- const legacyWaivers = kind === "page" ? readWaiversField(byKey) : void 0;
1404
1400
  const notFlow = kind === "flow" && discriminator === "notFlow" ? true : void 0;
1405
1401
  const metadata = {
1406
1402
  source: "ts-export",
@@ -1423,14 +1419,14 @@ function buildMetadata(value, file, sourcePath, headerPos, diagnostics) {
1423
1419
  metadata.widgets = widgetsField.values;
1424
1420
  metadata.widgetSpans = widgetsField.spans;
1425
1421
  }
1422
+ if (surfacesField) {
1423
+ metadata.surfaces = surfacesField.values;
1424
+ metadata.surfaceSpans = surfacesField.spans;
1425
+ }
1426
1426
  if (states?.length) metadata.states = states;
1427
1427
  if (acknowledge && Object.keys(acknowledge).length > 0) {
1428
1428
  metadata.acknowledge = acknowledge;
1429
1429
  }
1430
- if (legacyCapture !== void 0) metadata.legacyCapture = legacyCapture;
1431
- if (legacyWaivers && Object.keys(legacyWaivers).length > 0) {
1432
- metadata.legacyWaivers = legacyWaivers;
1433
- }
1434
1430
  if (notFlow) metadata.notFlow = true;
1435
1431
  if (typeof id === "string" && idValue.kind === "string") {
1436
1432
  metadata.idSpan = idValue.span;
@@ -1546,47 +1542,111 @@ function readAcknowledgeField(byKey, isPage) {
1546
1542
  }
1547
1543
  return out2;
1548
1544
  }
1549
- function readWaiversField(byKey) {
1550
- const entry = byKey.get("waivers");
1545
+ function acceptanceSlug(text) {
1546
+ return text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).slice(0, 8).join("-");
1547
+ }
1548
+ function readAcceptanceField(byKey, seen) {
1549
+ const entry = byKey.get("acceptance");
1551
1550
  if (!entry) return void 0;
1552
- if (entry.value.kind !== "object") {
1551
+ return readAcceptanceItems(entry, seen);
1552
+ }
1553
+ function readAcceptanceItems(entry, seen) {
1554
+ if (entry.value.kind !== "array") {
1553
1555
  throw new ExtractError(
1554
1556
  "uidex-export-invalid-field",
1555
- "`waivers` must be an object mapping a core kind to a reason.",
1557
+ "`acceptance` must be an array of strings or `{ id, rev?, text }` objects.",
1556
1558
  entry.value.pos
1557
1559
  );
1558
1560
  }
1559
- const waivers = {};
1560
- for (const e of entry.value.entries) {
1561
- if (!CORE_STATE_KINDS2.has(e.key)) {
1561
+ const criteria = [];
1562
+ for (const item of entry.value.items) {
1563
+ let criterion;
1564
+ if (item.kind === "string") {
1565
+ if (item.value.length === 0) {
1566
+ throw new ExtractError(
1567
+ "uidex-export-invalid-field",
1568
+ "An `acceptance` entry is an empty string.",
1569
+ item.pos
1570
+ );
1571
+ }
1572
+ const id = acceptanceSlug(item.value);
1573
+ if (!id) {
1574
+ throw new ExtractError(
1575
+ "uidex-export-invalid-field",
1576
+ `Acceptance text "${item.value}" contains no alphanumeric characters to derive an id from; use the object form with an explicit \`id\`.`,
1577
+ item.pos
1578
+ );
1579
+ }
1580
+ criterion = { id, rev: 1, text: item.value };
1581
+ } else if (item.kind === "object") {
1582
+ const fieldByKey = /* @__PURE__ */ new Map();
1583
+ for (const e of item.entries) {
1584
+ if (!ACCEPTANCE_FIELDS.has(e.key)) {
1585
+ throw new ExtractError(
1586
+ "uidex-export-invalid-field",
1587
+ `Unknown field "${e.key}" in an \`acceptance\` entry. Allowed: ${[
1588
+ ...ACCEPTANCE_FIELDS
1589
+ ].sort().join(", ")}.`,
1590
+ e.value.pos
1591
+ );
1592
+ }
1593
+ fieldByKey.set(e.key, e);
1594
+ }
1595
+ const id = readStringField(fieldByKey, "id");
1596
+ if (id === void 0 || id.length === 0) {
1597
+ throw new ExtractError(
1598
+ "uidex-export-invalid-field",
1599
+ "An object `acceptance` entry needs a non-empty string `id`.",
1600
+ item.pos
1601
+ );
1602
+ }
1603
+ if (id.includes("@")) {
1604
+ throw new ExtractError(
1605
+ "uidex-export-invalid-field",
1606
+ `acceptance id "${id}" must not contain "@" \u2014 it is reserved for the \`@<rev>\` pin in \`uidexCovers\` claims.`,
1607
+ item.pos
1608
+ );
1609
+ }
1610
+ const text = readStringField(fieldByKey, "text");
1611
+ if (text === void 0 || text.length === 0) {
1612
+ throw new ExtractError(
1613
+ "uidex-export-invalid-field",
1614
+ `acceptance "${id}" needs a non-empty string \`text\`.`,
1615
+ item.pos
1616
+ );
1617
+ }
1618
+ let rev = 1;
1619
+ const revEntry = fieldByKey.get("rev");
1620
+ if (revEntry) {
1621
+ if (revEntry.value.kind !== "number" || !Number.isInteger(revEntry.value.value) || revEntry.value.value < 1) {
1622
+ throw new ExtractError(
1623
+ "uidex-export-invalid-field",
1624
+ `acceptance "${id}" \`rev\` must be a positive integer.`,
1625
+ revEntry.value.pos
1626
+ );
1627
+ }
1628
+ rev = revEntry.value.value;
1629
+ }
1630
+ criterion = { id, rev, text };
1631
+ } else {
1562
1632
  throw new ExtractError(
1563
1633
  "uidex-export-invalid-field",
1564
- `waiver key "${e.key}" is not a core kind; only ${[...CORE_STATE_KINDS2].join(", ")} participate in the matrix.`,
1565
- e.keyPos
1634
+ "`acceptance` items must be strings or `{ id, rev?, text }` objects.",
1635
+ item.pos
1566
1636
  );
1567
1637
  }
1568
- if (e.value.kind !== "string" || e.value.value.length === 0) {
1638
+ if (seen.has(criterion.id)) {
1569
1639
  throw new ExtractError(
1570
- "uidex-export-invalid-field",
1571
- `waiver "${e.key}" needs a non-empty reason (why the route cannot render this kind).`,
1572
- e.value.pos
1640
+ "acceptance-duplicate-id",
1641
+ `Duplicate acceptance criterion id "${criterion.id}"; each criterion id must be unique within the entity.`,
1642
+ item.pos,
1643
+ "Give the criterion an explicit distinct `id` (object form) \u2014 two string criteria can slug to the same id."
1573
1644
  );
1574
1645
  }
1575
- waivers[e.key] = e.value.value;
1646
+ seen.add(criterion.id);
1647
+ criteria.push(criterion);
1576
1648
  }
1577
- return waivers;
1578
- }
1579
- function readBooleanField(byKey, name) {
1580
- const entry = byKey.get(name);
1581
- if (!entry) return void 0;
1582
- if (entry.value.kind !== "boolean") {
1583
- throw new ExtractError(
1584
- "uidex-export-invalid-field",
1585
- `\`${name}\` must be a boolean.`,
1586
- entry.value.pos
1587
- );
1588
- }
1589
- return entry.value.value;
1649
+ return criteria;
1590
1650
  }
1591
1651
  function readStringField(byKey, name) {
1592
1652
  const entry = byKey.get(name);
@@ -1625,7 +1685,7 @@ function readStringArrayField(byKey, name) {
1625
1685
  }
1626
1686
  return { values, spans };
1627
1687
  }
1628
- function readStatesField(byKey) {
1688
+ function readStatesField(byKey, criterionIds) {
1629
1689
  const entry = byKey.get("states");
1630
1690
  if (!entry) return void 0;
1631
1691
  if (entry.value.kind !== "array") {
@@ -1675,8 +1735,11 @@ function readStatesField(byKey) {
1675
1735
  }
1676
1736
  state.kind = kind;
1677
1737
  }
1678
- const acceptance = readStringArrayField(fieldByKey, "acceptance")?.values;
1679
- if (acceptance) state.acceptance = acceptance;
1738
+ const accEntry = fieldByKey.get("acceptance");
1739
+ if (accEntry) {
1740
+ const acceptance = readAcceptanceItems(accEntry, criterionIds);
1741
+ if (acceptance.length > 0) state.acceptance = acceptance;
1742
+ }
1680
1743
  const description = readStringField(fieldByKey, "description");
1681
1744
  if (description) state.description = description;
1682
1745
  } else {
@@ -1732,20 +1795,21 @@ function isNode2(value) {
1732
1795
  }
1733
1796
 
1734
1797
  // src/scanner/scan/flow-facts.ts
1735
- function collectFlowFacts(parsed, content) {
1798
+ function collectFlowFacts(parsed, content, displayPath) {
1736
1799
  if (parsed.program === null || !content.includes("test.describe")) {
1737
- return [];
1800
+ return { facts: [], diagnostics: [] };
1738
1801
  }
1739
1802
  const facts = [];
1803
+ const diagnostics = [];
1740
1804
  walkAst(parsed.program, (node) => {
1741
1805
  if (node.type !== "CallExpression") return void 0;
1742
- const fact = readTaggedDescribe(node, parsed);
1806
+ const fact = readTaggedDescribe(node, parsed, diagnostics, displayPath);
1743
1807
  if (fact) facts.push(fact);
1744
1808
  return void 0;
1745
1809
  });
1746
- return facts;
1810
+ return { facts, diagnostics };
1747
1811
  }
1748
- function readTaggedDescribe(call, parsed) {
1812
+ function readTaggedDescribe(call, parsed, diagnostics, displayPath) {
1749
1813
  const callee = call.callee;
1750
1814
  if (!callee || callee.type !== "MemberExpression" || !isIdentifier(callee.object, "test") || !isIdentifier(callee.property, "describe")) {
1751
1815
  return null;
@@ -1756,13 +1820,83 @@ function readTaggedDescribe(call, parsed) {
1756
1820
  if (!hasFlowTag(args[1])) return null;
1757
1821
  const body = args[2];
1758
1822
  const { calls, dynamicCalls } = body ? collectUidexCalls(body, parsed) : { calls: [], dynamicCalls: [] };
1823
+ const covers = body ? collectCoversClaims(body, parsed, title, diagnostics, displayPath) : [];
1759
1824
  return {
1760
1825
  title,
1761
1826
  line: parsed.lineAt(call.start),
1762
1827
  calls,
1763
- ...dynamicCalls.length > 0 ? { dynamicCalls } : {}
1828
+ ...dynamicCalls.length > 0 ? { dynamicCalls } : {},
1829
+ ...covers.length > 0 ? { covers } : {}
1764
1830
  };
1765
1831
  }
1832
+ function collectCoversClaims(body, parsed, flowTitle, diagnostics, displayPath) {
1833
+ const covers = [];
1834
+ walkAst(body, (node) => {
1835
+ if (node.type !== "CallExpression") return void 0;
1836
+ if (!isIdentifier(node.callee, "uidexCovers")) return void 0;
1837
+ const line = parsed.lineAt(node.start);
1838
+ const args = node.arguments ?? [];
1839
+ const entity = stringLiteralValue(args[0]);
1840
+ if (entity === null) {
1841
+ diagnostics.push({
1842
+ code: "covers-missing-rev",
1843
+ severity: "error",
1844
+ message: `\`uidexCovers(\u2026)\` in flow "${flowTitle}" needs a static string entity id as its first argument.`,
1845
+ file: displayPath,
1846
+ line,
1847
+ hint: 'Write `uidexCovers("<entityId>", "<criterionId>@<rev>")` with string literals.'
1848
+ });
1849
+ return void 0;
1850
+ }
1851
+ if (args.length < 2) {
1852
+ diagnostics.push({
1853
+ code: "covers-missing-rev",
1854
+ severity: "error",
1855
+ message: `\`uidexCovers("${entity}")\` in flow "${flowTitle}" names no criterion; a claim covers nothing without at least one \`"<criterionId>@<rev>"\` argument.`,
1856
+ file: displayPath,
1857
+ line,
1858
+ hint: 'List the criteria this flow verifies: `uidexCovers("<entityId>", "<criterionId>@<rev>", ...)`.'
1859
+ });
1860
+ return void 0;
1861
+ }
1862
+ for (const arg of args.slice(1)) {
1863
+ const claimLine = parsed.lineAt(arg.start);
1864
+ const raw = stringLiteralValue(arg);
1865
+ const at = raw === null ? -1 : raw.lastIndexOf("@");
1866
+ if (raw === null || at <= 0) {
1867
+ diagnostics.push({
1868
+ code: "covers-missing-rev",
1869
+ severity: "error",
1870
+ message: `\`uidexCovers(\u2026)\` claim ${raw === null ? "" : `"${raw}" `}in flow "${flowTitle}" is missing its \`@<rev>\` pin.`,
1871
+ file: displayPath,
1872
+ line: claimLine,
1873
+ hint: 'Pin the criterion rev \u2014 `uidexCovers("<entityId>", "<criterionId>@<rev>")` \u2014 so a later rev bump surfaces this claim as outdated.'
1874
+ });
1875
+ continue;
1876
+ }
1877
+ const revStr = raw.slice(at + 1);
1878
+ if (!/^[1-9]\d*$/.test(revStr)) {
1879
+ diagnostics.push({
1880
+ code: "covers-missing-rev",
1881
+ severity: "error",
1882
+ message: `\`uidexCovers(\u2026)\` claim "${raw}" in flow "${flowTitle}" has an invalid rev "@${revStr}"; the rev must be a positive integer (no leading zeros).`,
1883
+ file: displayPath,
1884
+ line: claimLine,
1885
+ hint: 'Write the pin as `"<criterionId>@<rev>"` with a positive integer rev, e.g. `"gated@1"`.'
1886
+ });
1887
+ continue;
1888
+ }
1889
+ covers.push({
1890
+ entity,
1891
+ criterion: raw.slice(0, at),
1892
+ rev: Number(revStr),
1893
+ line: claimLine
1894
+ });
1895
+ }
1896
+ return void 0;
1897
+ });
1898
+ return covers;
1899
+ }
1766
1900
  function hasFlowTag(node) {
1767
1901
  if (!node || node.type !== "ObjectExpression") return false;
1768
1902
  for (const prop of node.properties ?? []) {
@@ -2154,11 +2288,13 @@ function extract(files) {
2154
2288
  const out2 = { file, annotations: [] };
2155
2289
  out2.annotations = extractOne(file, parsed, out2);
2156
2290
  if (exports2.length > 0) out2.metadata = exports2;
2291
+ const flowFacts = collectFlowFacts(parsed, file.content, file.displayPath);
2292
+ diagnostics.push(...flowFacts.diagnostics);
2157
2293
  if (diagnostics.length > 0) out2.diagnostics = diagnostics;
2158
- const flows = collectFlowFacts(parsed, file.content);
2159
- if (flows.length > 0) out2.flows = flows;
2294
+ if (flowFacts.facts.length > 0) out2.flows = flowFacts.facts;
2160
2295
  const imports = collectImportFacts(parsed);
2161
2296
  if (imports.length > 0) out2.imports = imports;
2297
+ if (containsJsx(parsed)) out2.hasJsx = true;
2162
2298
  return out2;
2163
2299
  });
2164
2300
  }
@@ -2183,6 +2319,32 @@ function extractOne(file, parsed, out2) {
2183
2319
  }
2184
2320
  return annotations;
2185
2321
  }
2322
+ function containsJsx(parsed) {
2323
+ if (parsed.program === null) return false;
2324
+ let found = false;
2325
+ walkAst(parsed.program, (node) => {
2326
+ if (node.type === "JSXElement" || node.type === "JSXFragment") {
2327
+ found = true;
2328
+ return false;
2329
+ }
2330
+ if (node.type === "CallExpression" && isCreateElementCallee(node.callee)) {
2331
+ found = true;
2332
+ return false;
2333
+ }
2334
+ return void 0;
2335
+ });
2336
+ return found;
2337
+ }
2338
+ function isCreateElementCallee(callee) {
2339
+ const node = callee;
2340
+ if (!node) return false;
2341
+ if (node.type === "Identifier") return String(node.name) === "createElement";
2342
+ if (node.type === "MemberExpression") {
2343
+ const prop = node.property;
2344
+ return prop?.type === "Identifier" && String(prop.name) === "createElement";
2345
+ }
2346
+ return false;
2347
+ }
2186
2348
  function collectImportFacts(parsed) {
2187
2349
  if (parsed.program === null) return [];
2188
2350
  const out2 = [];
@@ -2215,6 +2377,21 @@ function collectImportFacts(parsed) {
2215
2377
  names
2216
2378
  });
2217
2379
  }
2380
+ walkAst(parsed.program, (node) => {
2381
+ if (node.type !== "ImportExpression") return void 0;
2382
+ const source = node.source;
2383
+ if (!source || source.type !== "Literal") return void 0;
2384
+ if (typeof source.value !== "string") return void 0;
2385
+ out2.push({
2386
+ specifier: source.value,
2387
+ line: parsed.lineAt(node.start),
2388
+ span: { start: node.start, end: node.end },
2389
+ isTypeOnly: false,
2390
+ names: [],
2391
+ dynamic: true
2392
+ });
2393
+ return void 0;
2394
+ });
2218
2395
  return out2;
2219
2396
  }
2220
2397
 
@@ -2386,6 +2563,7 @@ function buildMetaFromExport(exp) {
2386
2563
  if (exp.acceptance?.length) meta.acceptance = exp.acceptance;
2387
2564
  if (exp.features?.length) meta.features = exp.features;
2388
2565
  if (exp.widgets?.length) meta.widgets = exp.widgets;
2566
+ if (exp.surfaces?.length) meta.surfaces = exp.surfaces;
2389
2567
  if (exp.states?.length) {
2390
2568
  meta.states = exp.states.map((s) => ({
2391
2569
  name: s.name,
@@ -2799,7 +2977,8 @@ function resolve2(ctx) {
2799
2977
  id: flowExport.id,
2800
2978
  loc: base.loc,
2801
2979
  touches: base.touches,
2802
- steps: base.steps
2980
+ steps: base.steps,
2981
+ ...base.covers ? { covers: base.covers } : {}
2803
2982
  };
2804
2983
  registry.add(flow);
2805
2984
  } else {
@@ -2847,20 +3026,42 @@ function computeScope(displayPath) {
2847
3026
  return null;
2848
3027
  }
2849
3028
  function flowsFromFacts(ff) {
2850
- return (ff.flows ?? []).map((fact) => ({
2851
- kind: "flow",
2852
- id: kebab(fact.title),
2853
- loc: { file: ff.file.displayPath, line: fact.line },
2854
- touches: dedupe(fact.calls.map((c) => c.id)),
2855
- steps: fact.calls.filter((c) => c.action).map((c) => ({ entityId: c.id, action: c.action }))
2856
- }));
3029
+ return (ff.flows ?? []).map((fact) => {
3030
+ const covers = dedupeBy(
3031
+ (fact.covers ?? []).map((c) => ({
3032
+ entity: c.entity,
3033
+ criterion: c.criterion,
3034
+ rev: c.rev
3035
+ })),
3036
+ (c) => `${c.entity} ${c.criterion} ${c.rev}`
3037
+ );
3038
+ return {
3039
+ kind: "flow",
3040
+ id: kebab(fact.title),
3041
+ loc: { file: ff.file.displayPath, line: fact.line },
3042
+ touches: dedupe(fact.calls.map((c) => c.id)),
3043
+ steps: fact.calls.filter((c) => c.action).map((c) => ({ entityId: c.id, action: c.action })),
3044
+ ...covers.length > 0 ? { covers } : {}
3045
+ };
3046
+ });
3047
+ }
3048
+ function dedupeBy(arr, key) {
3049
+ const seen = /* @__PURE__ */ new Set();
3050
+ const out2 = [];
3051
+ for (const item of arr) {
3052
+ const k = key(item);
3053
+ if (seen.has(k)) continue;
3054
+ seen.add(k);
3055
+ out2.push(item);
3056
+ }
3057
+ return out2;
2857
3058
  }
2858
3059
  function dedupe(arr) {
2859
3060
  return Array.from(new Set(arr));
2860
3061
  }
2861
3062
 
2862
3063
  // src/scanner/scan/audit.ts
2863
- var path6 = __toESM(require("path"), 1);
3064
+ var path7 = __toESM(require("path"), 1);
2864
3065
 
2865
3066
  // src/scanner/scan/component-coverage.ts
2866
3067
  var COMPOSED_KINDS = ["widget", "feature"];
@@ -2915,141 +3116,6 @@ function checkComponentCoverage(registry, manifest) {
2915
3116
  return diagnostics;
2916
3117
  }
2917
3118
 
2918
- // src/scanner/scan/migrate-acknowledge.ts
2919
- var CORE = CORE_STATE_KINDS;
2920
- function orderScopes(scopes) {
2921
- const rank = (s) => s === ACK_ALL ? -1 : CORE.indexOf(s);
2922
- return [...scopes].sort((a, b) => rank(a) - rank(b));
2923
- }
2924
- function renderKey(scope) {
2925
- return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(scope) ? scope : JSON.stringify(scope);
2926
- }
2927
- function renderAck(a) {
2928
- const parts = [`type: ${JSON.stringify(a.type)}`];
2929
- if (a.reason) parts.push(`reason: ${JSON.stringify(a.reason)}`);
2930
- return `{ ${parts.join(", ")} }`;
2931
- }
2932
- function renderAcknowledge(entries, indent) {
2933
- const inner = indent + " ";
2934
- const lines = orderScopes(Object.keys(entries)).map(
2935
- (scope) => `${inner}${renderKey(scope)}: ${renderAck(entries[scope])},`
2936
- );
2937
- return ["acknowledge: {", ...lines, `${indent}}`].join("\n");
2938
- }
2939
- function indentAt(content, offset) {
2940
- const lineStart = content.lastIndexOf("\n", offset - 1) + 1;
2941
- const m = /^[ \t]*/.exec(content.slice(lineStart, offset));
2942
- return m ? m[0] : "";
2943
- }
2944
- function plannedEntries(m) {
2945
- const entries = {};
2946
- if (m.legacyCapture === false) {
2947
- entries[ACK_ALL] = {
2948
- type: "impossible",
2949
- reason: m.description || PLACEHOLDER_REASON
2950
- };
2951
- }
2952
- for (const kind of CORE) {
2953
- const reason = m.legacyWaivers?.[kind];
2954
- if (reason) entries[kind] = { type: "impossible", reason };
2955
- }
2956
- return entries;
2957
- }
2958
- function migrateLegacyFields(ef) {
2959
- const out2 = [];
2960
- const { content, sourcePath, displayPath } = ef.file;
2961
- for (const m of ef.metadata ?? []) {
2962
- const legacy = [];
2963
- if (m.legacyWaivers) legacy.push("waivers");
2964
- if (m.legacyCapture !== void 0) legacy.push("capture");
2965
- if (legacy.length === 0) continue;
2966
- const fields = legacy.join(" and ");
2967
- const base = {
2968
- severity: "error",
2969
- code: "legacy-acknowledge-field",
2970
- file: displayPath,
2971
- line: m.loc.line
2972
- };
2973
- if (m.acknowledge) {
2974
- out2.push({
2975
- ...base,
2976
- message: `\`${fields}\` was replaced by \`acknowledge\`, and this declaration already has an \`acknowledge\` field`,
2977
- hint: `Merge the remaining \`${fields}\` entries into \`acknowledge\` by hand and delete them; --fix will not merge two maps for you.`
2978
- });
2979
- continue;
2980
- }
2981
- const entries = plannedEntries(m);
2982
- const edits = [];
2983
- 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);
2984
- if (present.length !== legacy.length) {
2985
- out2.push({
2986
- ...base,
2987
- message: `\`${fields}\` was replaced by \`acknowledge\`, but the field spans needed to rewrite it are unavailable`,
2988
- hint: "Rewrite the declaration by hand: `acknowledge: { <scope>: { type, reason? } }`."
2989
- });
2990
- continue;
2991
- }
2992
- const [anchor, ...rest] = present;
2993
- if (Object.keys(entries).length === 0) {
2994
- edits.push({
2995
- path: sourcePath,
2996
- start: anchor.del.start,
2997
- end: anchor.del.end,
2998
- replacement: ""
2999
- });
3000
- } else {
3001
- edits.push({
3002
- path: sourcePath,
3003
- start: anchor.prop.start,
3004
- end: anchor.prop.end,
3005
- replacement: renderAcknowledge(
3006
- entries,
3007
- indentAt(content, anchor.prop.start)
3008
- )
3009
- });
3010
- }
3011
- for (const r of rest) {
3012
- edits.push({
3013
- path: sourcePath,
3014
- start: r.del.start,
3015
- end: r.del.end,
3016
- replacement: ""
3017
- });
3018
- }
3019
- out2.push({
3020
- ...base,
3021
- message: `\`${fields}\` was replaced by the unified \`acknowledge\` field`,
3022
- 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" : ""}.`,
3023
- fix: {
3024
- description: `Rewrite \`${fields}\` as \`acknowledge\``,
3025
- edits
3026
- }
3027
- });
3028
- }
3029
- return out2;
3030
- }
3031
- function migrateLegacyBaseline(opts) {
3032
- if (!isLegacyBaseline(opts.parsed)) return void 0;
3033
- return {
3034
- code: "legacy-coverage-baseline",
3035
- severity: "error",
3036
- message: `coverage baseline "${opts.displayPath}" uses the removed \`uncapturedPages\` / \`missingKinds\` shape`,
3037
- file: opts.displayPath,
3038
- hint: "Run `uidex scan --fix` to rewrite it as `acknowledge` entries of type `backlog` (identical content, new shape \u2014 no capture run needed).",
3039
- fix: {
3040
- description: `Rewrite ${opts.displayPath} as an \`acknowledge\` baseline`,
3041
- edits: [
3042
- {
3043
- path: opts.path,
3044
- start: 0,
3045
- end: opts.raw.length,
3046
- replacement: serializeCoverageBaseline(migrateBaseline(opts.parsed))
3047
- }
3048
- ]
3049
- }
3050
- };
3051
- }
3052
-
3053
3119
  // src/scanner/scan/page-coverage.ts
3054
3120
  function compileRoute(pattern) {
3055
3121
  const segs = pattern.split("/").filter(Boolean);
@@ -3071,11 +3137,11 @@ function compileRoute(pattern) {
3071
3137
  return { test: (url) => re.test(url), specificity };
3072
3138
  }
3073
3139
  function matchUrlToRoute(url, routePaths) {
3074
- const path13 = stripQuery(url);
3140
+ const path14 = stripQuery(url);
3075
3141
  let best = null;
3076
3142
  for (const rp of routePaths) {
3077
3143
  const { test, specificity } = compileRoute(rp);
3078
- if (!test(path13)) continue;
3144
+ if (!test(path14)) continue;
3079
3145
  if (!best || specificity > best.specificity)
3080
3146
  best = { path: rp, specificity };
3081
3147
  }
@@ -3483,6 +3549,182 @@ function computeMatrixBacklog(registry, manifest) {
3483
3549
  return out2;
3484
3550
  }
3485
3551
 
3552
+ // src/scanner/scan/surface-check.ts
3553
+ var path6 = __toESM(require("path"), 1);
3554
+ var EXT_CANDIDATES = [
3555
+ "",
3556
+ ".tsx",
3557
+ ".ts",
3558
+ ".jsx",
3559
+ ".js",
3560
+ "/index.tsx",
3561
+ "/index.ts",
3562
+ "/index.jsx",
3563
+ "/index.js"
3564
+ ];
3565
+ function checkFeatureSurfaces(registry, extracted, config) {
3566
+ const declarations = [];
3567
+ for (const ef of extracted) {
3568
+ for (const m of ef.metadata ?? []) {
3569
+ if (m.kind !== "feature") continue;
3570
+ if (typeof m.id !== "string") continue;
3571
+ if (!m.surfaces || m.surfaces.length === 0) continue;
3572
+ if (m.acknowledge?.["*"]) continue;
3573
+ declarations.push({
3574
+ ef,
3575
+ id: m.id,
3576
+ surfaces: m.surfaces,
3577
+ surfaceSpans: m.surfaceSpans,
3578
+ line: m.loc.line ?? 1
3579
+ });
3580
+ }
3581
+ }
3582
+ if (declarations.length === 0) return [];
3583
+ const fileSet = /* @__PURE__ */ new Set();
3584
+ const jsxFiles = /* @__PURE__ */ new Set();
3585
+ for (const ef of extracted) {
3586
+ fileSet.add(ef.file.displayPath);
3587
+ if (ef.hasJsx) jsxFiles.add(ef.file.displayPath);
3588
+ }
3589
+ const aliases = Object.entries(config.resolve?.aliases ?? {}).sort(
3590
+ (a, b) => b[0].length - a[0].length
3591
+ );
3592
+ const resolveSpecifier = (importer, spec) => {
3593
+ const bases = [];
3594
+ if (spec.startsWith("./") || spec.startsWith("../")) {
3595
+ bases.push(path6.posix.join(path6.posix.dirname(importer), spec));
3596
+ } else {
3597
+ const alias = aliases.find(([prefix]) => spec.startsWith(prefix));
3598
+ if (alias) {
3599
+ bases.push(alias[1] + spec.slice(alias[0].length));
3600
+ } else if (aliases.length === 0) {
3601
+ if (spec.startsWith("@/") || spec.startsWith("~/")) {
3602
+ bases.push("src/" + spec.slice(2));
3603
+ }
3604
+ }
3605
+ bases.push(spec);
3606
+ }
3607
+ const hits = [];
3608
+ for (const base of bases) {
3609
+ const normalized = path6.posix.normalize(base);
3610
+ for (const ext of EXT_CANDIDATES) {
3611
+ const candidate = normalized + ext;
3612
+ if (fileSet.has(candidate)) hits.push(candidate);
3613
+ }
3614
+ }
3615
+ return hits;
3616
+ };
3617
+ const adjacency = /* @__PURE__ */ new Map();
3618
+ for (const ef of extracted) {
3619
+ const targets = [];
3620
+ for (const imp of ef.imports ?? []) {
3621
+ if (imp.isTypeOnly) continue;
3622
+ targets.push(...resolveSpecifier(ef.file.displayPath, imp.specifier));
3623
+ }
3624
+ if (targets.length > 0) adjacency.set(ef.file.displayPath, targets);
3625
+ }
3626
+ const inDir = (file, dir) => file === dir || file.startsWith(dir + "/");
3627
+ const reachesDir = (seeds, dir) => {
3628
+ const queue = [...seeds];
3629
+ const visited = new Set(queue);
3630
+ while (queue.length > 0) {
3631
+ const current = queue.pop();
3632
+ if (inDir(current, dir) && jsxFiles.has(current)) return true;
3633
+ for (const next of adjacency.get(current) ?? []) {
3634
+ if (visited.has(next)) continue;
3635
+ visited.add(next);
3636
+ queue.push(next);
3637
+ }
3638
+ }
3639
+ return false;
3640
+ };
3641
+ const featuresConvention = config.conventions?.features === void 0 ? DEFAULT_CONVENTIONS.features : config.conventions.features;
3642
+ const featureGlob = typeof featuresConvention === "string" ? featuresConvention : null;
3643
+ const diagnostics = [];
3644
+ for (const decl of declarations) {
3645
+ const declPath = decl.ef.file.displayPath;
3646
+ const featureDir = (featureGlob !== null ? extractFeatureDir(declPath, featureGlob) : null) ?? path6.posix.dirname(declPath);
3647
+ for (let i = 0; i < decl.surfaces.length; i++) {
3648
+ const pageId = decl.surfaces[i];
3649
+ const line = decl.surfaceSpans?.[i] ? lineOfOffset(decl.ef.file.content, decl.surfaceSpans[i].start) : decl.line;
3650
+ const page = registry.get("page", pageId) ?? registry.matchPattern("page", pageId);
3651
+ if (!page) {
3652
+ diagnostics.push({
3653
+ code: "unknown-reference",
3654
+ severity: "warning",
3655
+ message: `Feature "${decl.id}" declares a surface on unknown page "${pageId}"`,
3656
+ file: decl.ef.file.displayPath,
3657
+ line,
3658
+ entity: { kind: "feature", id: decl.id },
3659
+ hint: `No page with id "${pageId}" exists in the registry; fix the reference or add the page.`
3660
+ });
3661
+ continue;
3662
+ }
3663
+ const pageFile = page.loc?.file;
3664
+ if (!pageFile) continue;
3665
+ const seeds = [pageFile];
3666
+ if (path6.posix.basename(pageFile) === WELL_KNOWN_FILES.page) {
3667
+ const dir = path6.posix.dirname(pageFile);
3668
+ for (const f of fileSet) {
3669
+ if (f !== pageFile && path6.posix.dirname(f) === dir) seeds.push(f);
3670
+ }
3671
+ }
3672
+ if (reachesDir(seeds, featureDir)) continue;
3673
+ diagnostics.push({
3674
+ code: "feature-surface-unreferenced",
3675
+ severity: "warning",
3676
+ message: `Feature "${decl.id}" declares page "${pageId}" as a surface, but nothing reachable from ${pageFile} references a component file in ${featureDir}/`,
3677
+ file: decl.ef.file.displayPath,
3678
+ line,
3679
+ entity: { kind: "feature", id: decl.id },
3680
+ 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\`.`
3681
+ });
3682
+ }
3683
+ }
3684
+ return diagnostics;
3685
+ }
3686
+ function checkFeatureSurfaceCaptures(registry, manifest) {
3687
+ const routes = registry.list("route");
3688
+ if (routes.length === 0) return [];
3689
+ const routePaths = routes.map((r) => r.path);
3690
+ const captured = manifest.captured ?? [];
3691
+ const diagnostics = [];
3692
+ for (const feature of registry.list("feature")) {
3693
+ const surfaces = feature.meta?.surfaces ?? [];
3694
+ if (surfaces.length === 0) continue;
3695
+ if (entityAcknowledge(feature.meta)) continue;
3696
+ const coveredRoutes = /* @__PURE__ */ new Set();
3697
+ for (const c of captured) {
3698
+ if (c.kind !== "feature" || c.entity !== feature.id) continue;
3699
+ const route = routeForCapture(c, routes, routePaths);
3700
+ if (route) coveredRoutes.add(route);
3701
+ }
3702
+ for (const pageId of surfaces) {
3703
+ const pageRoutes = routes.filter((r) => r.page === pageId);
3704
+ if (pageRoutes.length === 0) continue;
3705
+ if (pageRoutes.some((r) => coveredRoutes.has(r.path))) continue;
3706
+ const routeList = pageRoutes.map((r) => `"${r.path}"`).join(", ");
3707
+ diagnostics.push({
3708
+ code: "feature-surface-uncaptured",
3709
+ severity: "warning",
3710
+ 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})`}`,
3711
+ file: feature.loc?.file,
3712
+ line: feature.loc?.line,
3713
+ entity: { kind: "feature", id: feature.id },
3714
+ 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 }\`.`
3715
+ });
3716
+ }
3717
+ }
3718
+ return diagnostics;
3719
+ }
3720
+ function lineOfOffset(content, offset) {
3721
+ let line = 1;
3722
+ for (let i = 0; i < offset && i < content.length; i++) {
3723
+ if (content[i] === "\n") line++;
3724
+ }
3725
+ return line;
3726
+ }
3727
+
3486
3728
  // src/scanner/scan/audit.ts
3487
3729
  function audit(opts) {
3488
3730
  const diagnostics = [];
@@ -3490,6 +3732,8 @@ function audit(opts) {
3490
3732
  const check = opts.check ?? false;
3491
3733
  const lint = opts.lint ?? false;
3492
3734
  const acceptanceEnabled = config.audit?.acceptance ?? true;
3735
+ const surfacesEnabled = config.audit?.surfaces ?? true;
3736
+ const requireAcceptanceEnabled = config.audit?.requireAcceptance ?? true;
3493
3737
  const scopeLeakEnabled = config.audit?.scopeLeak ?? true;
3494
3738
  const coverageEnabled = config.audit?.coverage ?? true;
3495
3739
  const statesEnabled = config.audit?.states ?? true;
@@ -3502,8 +3746,6 @@ function audit(opts) {
3502
3746
  for (const ef of opts.flowExtracted ?? []) {
3503
3747
  if (ef.diagnostics) diagnostics.push(...ef.diagnostics);
3504
3748
  }
3505
- for (const ef of extracted) diagnostics.push(...migrateLegacyFields(ef));
3506
- if (opts.baselineDiagnostic) diagnostics.push(opts.baselineDiagnostic);
3507
3749
  for (const ef of extracted) {
3508
3750
  for (const m of ef.metadata ?? []) {
3509
3751
  for (const [scope, ack] of Object.entries(m.acknowledge ?? {})) {
@@ -3588,9 +3830,66 @@ function audit(opts) {
3588
3830
  }
3589
3831
  }
3590
3832
  if (lint && acceptanceEnabled) {
3591
- for (const kind of ["widget", "feature", "page"]) {
3833
+ const flowEntities = registry.list("flow");
3834
+ const acceptanceKinds = ["widget", "feature", "page"];
3835
+ const claims = /* @__PURE__ */ new Map();
3836
+ const addClaim = (kind, entityId, criterion, claim) => {
3837
+ const key = `${kind}:${entityId}`;
3838
+ let byCrit = claims.get(key);
3839
+ if (!byCrit) {
3840
+ byCrit = /* @__PURE__ */ new Map();
3841
+ claims.set(key, byCrit);
3842
+ }
3843
+ let list = byCrit.get(criterion);
3844
+ if (!list) {
3845
+ list = [];
3846
+ byCrit.set(criterion, list);
3847
+ }
3848
+ list.push(claim);
3849
+ };
3850
+ for (const flow of flowEntities) {
3851
+ for (const c of flow.covers ?? []) {
3852
+ const candidates = acceptanceKinds.flatMap((k) => {
3853
+ const e = registry.get(k, c.entity);
3854
+ return e ? [e] : [];
3855
+ });
3856
+ const matching = candidates.filter(
3857
+ (e) => entityCriteria(e).some((crit) => crit.id === c.criterion)
3858
+ );
3859
+ if (matching.length === 0) {
3860
+ diagnostics.push({
3861
+ code: "acceptance-orphaned",
3862
+ severity: "warning",
3863
+ 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`}`,
3864
+ file: flow.loc.file,
3865
+ line: flow.loc.line,
3866
+ entity: { kind: "flow", id: flow.id },
3867
+ hint: "Remove the stale uidexCovers claim, or fix the entity/criterion id it names."
3868
+ });
3869
+ continue;
3870
+ }
3871
+ if (matching.length > 1) {
3872
+ diagnostics.push({
3873
+ code: "acceptance-ambiguous",
3874
+ severity: "warning",
3875
+ 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`,
3876
+ file: flow.loc.file,
3877
+ line: flow.loc.line,
3878
+ entity: { kind: "flow", id: flow.id },
3879
+ 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."
3880
+ });
3881
+ }
3882
+ for (const target of matching) {
3883
+ addClaim(target.kind, target.id, c.criterion, {
3884
+ flowId: flow.id,
3885
+ rev: c.rev
3886
+ });
3887
+ }
3888
+ }
3889
+ }
3890
+ for (const kind of acceptanceKinds) {
3592
3891
  for (const e of registry.list(kind)) {
3593
- const criteria = e.meta?.acceptance ?? [];
3892
+ const criteria = entityCriteria(e);
3594
3893
  if (criteria.length === 0) {
3595
3894
  if (kind === "widget") {
3596
3895
  diagnostics.push({
@@ -3605,14 +3904,27 @@ function audit(opts) {
3605
3904
  }
3606
3905
  continue;
3607
3906
  }
3608
- const flowIds = e.meta?.flows ?? [];
3609
- if (flowIds.length === 0) {
3610
- 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)`;
3611
- for (const c of criteria) {
3907
+ const entityClaims = claims.get(`${kind}:${e.id}`);
3908
+ for (const c of criteria) {
3909
+ const critClaims = entityClaims?.get(c.id) ?? [];
3910
+ const stale = critClaims.filter((cl) => cl.rev !== c.rev);
3911
+ for (const cl of stale) {
3912
+ diagnostics.push({
3913
+ code: "acceptance-outdated",
3914
+ severity: "warning",
3915
+ message: `Flow "${cl.flowId}" covers ${kind} "${e.id}" criterion "${c.id}" at rev ${cl.rev}, but the criterion is now rev ${c.rev}`,
3916
+ file: e.loc?.file,
3917
+ line: e.loc?.line,
3918
+ entity: { kind, id: e.id },
3919
+ hint: `Re-verify the flow against the current criterion text, then update its claim to \`uidexCovers("${e.id}", "${c.id}@${c.rev}")\`.`
3920
+ });
3921
+ }
3922
+ if (critClaims.length === 0) {
3923
+ 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}")\``;
3612
3924
  diagnostics.push({
3613
3925
  code: "acceptance-uncovered",
3614
3926
  severity: "warning",
3615
- message: `${kind} "${e.id}" has acceptance criterion not covered by any flow: "${c}"`,
3927
+ message: `${kind} "${e.id}" has acceptance criterion "${c.id}" not covered by any flow: "${c.text}"`,
3616
3928
  file: e.loc?.file,
3617
3929
  line: e.loc?.line,
3618
3930
  entity: { kind, id: e.id },
@@ -3623,7 +3935,31 @@ function audit(opts) {
3623
3935
  }
3624
3936
  }
3625
3937
  }
3938
+ if (lint && surfacesEnabled) {
3939
+ diagnostics.push(...checkFeatureSurfaces(registry, extracted, config));
3940
+ }
3941
+ if (lint && surfacesEnabled && opts.capturedStates) {
3942
+ diagnostics.push(
3943
+ ...checkFeatureSurfaceCaptures(registry, opts.capturedStates)
3944
+ );
3945
+ }
3946
+ if (lint && requireAcceptanceEnabled) {
3947
+ for (const e of registry.list("feature")) {
3948
+ if (entityCriteria(e).length > 0) continue;
3949
+ diagnostics.push({
3950
+ code: "feature-missing-acceptance",
3951
+ severity: "warning",
3952
+ message: `Feature "${e.id}" declares no acceptance criteria`,
3953
+ file: e.loc?.file,
3954
+ line: e.loc?.line,
3955
+ entity: { kind: "feature", id: e.id },
3956
+ 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.`
3957
+ });
3958
+ }
3959
+ }
3626
3960
  if (lint) {
3961
+ const featuresConvention = config.conventions?.features === void 0 ? DEFAULT_CONVENTIONS.features : config.conventions.features;
3962
+ const featureGlob = typeof featuresConvention === "string" ? featuresConvention : null;
3627
3963
  const scannedPaths = new Set(files.map((f) => f.displayPath));
3628
3964
  for (const ef of extracted) {
3629
3965
  if (!ef.metadata) continue;
@@ -3632,10 +3968,24 @@ function audit(opts) {
3632
3968
  if (typeof m.id !== "string") continue;
3633
3969
  const filePath = ef.file.displayPath;
3634
3970
  const wellKnownName = WELL_KNOWN_FILES[m.kind];
3635
- if (path6.posix.basename(filePath) === wellKnownName) continue;
3636
- const dir = path6.posix.dirname(filePath);
3971
+ if (path7.posix.basename(filePath) === wellKnownName) continue;
3972
+ const featureDir = m.kind === "feature" && featureGlob !== null ? extractFeatureDir(filePath, featureGlob) : null;
3973
+ const dir = featureDir ?? path7.posix.dirname(filePath);
3637
3974
  const wellKnownPath = dir === "." ? wellKnownName : `${dir}/${wellKnownName}`;
3638
3975
  if (scannedPaths.has(wellKnownPath)) continue;
3976
+ const inConventionDir = featureDir !== null;
3977
+ if (inConventionDir) {
3978
+ diagnostics.push({
3979
+ code: "prefer-well-known-file",
3980
+ severity: "error",
3981
+ message: `Feature "${m.id}" metadata lives on ${filePath}; a feature in a convention-derived directory must declare its metadata in ${wellKnownPath}`,
3982
+ file: filePath,
3983
+ line: m.loc.line,
3984
+ entity: { kind: "feature", id: m.id },
3985
+ hint: `Move the \`export const uidex\` block to ${wellKnownPath} and remove it from ${filePath}.`
3986
+ });
3987
+ continue;
3988
+ }
3639
3989
  const kindLabel = m.kind === "page" ? "Page" : "Feature";
3640
3990
  diagnostics.push({
3641
3991
  code: "prefer-well-known-file",
@@ -3719,6 +4069,7 @@ function audit(opts) {
3719
4069
  const displayPath = ef.file.displayPath;
3720
4070
  for (const imp of ef.imports ?? []) {
3721
4071
  if (imp.isTypeOnly) continue;
4072
+ if (imp.dynamic) continue;
3722
4073
  const baseName2 = imp.specifier.split("/").pop() ?? "";
3723
4074
  const primitive = byName.get(
3724
4075
  baseName2.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
@@ -3836,7 +4187,7 @@ function audit(opts) {
3836
4187
  severity: "warning",
3837
4188
  message: `\`export const uidex\` in ${ef.file.displayPath} references unknown ${refKind} "${refId}"`,
3838
4189
  file: ef.file.displayPath,
3839
- line: spans?.[i] ? lineOfOffset(ef.file.content, spans[i].start) : m.loc.line,
4190
+ line: spans?.[i] ? lineOfOffset2(ef.file.content, spans[i].start) : m.loc.line,
3840
4191
  hint: `No ${refKind} with id "${refId}" exists in the registry; fix the reference or add the ${refKind}.`
3841
4192
  });
3842
4193
  }
@@ -3883,13 +4234,21 @@ function audit(opts) {
3883
4234
  };
3884
4235
  return { diagnostics: governed, summary };
3885
4236
  }
3886
- function lineOfOffset(content, offset) {
4237
+ function lineOfOffset2(content, offset) {
3887
4238
  let line = 1;
3888
4239
  for (let i = 0; i < offset && i < content.length; i++) {
3889
4240
  if (content[i] === "\n") line++;
3890
4241
  }
3891
4242
  return line;
3892
4243
  }
4244
+ function entityCriteria(e) {
4245
+ const meta = e.meta;
4246
+ if (!meta) return [];
4247
+ return [
4248
+ ...meta.acceptance ?? [],
4249
+ ...(meta.states ?? []).flatMap((s) => s.acceptance ?? [])
4250
+ ];
4251
+ }
3893
4252
  var TAG_FALLBACK_ID = {
3894
4253
  a: "link",
3895
4254
  button: "button",
@@ -4334,10 +4693,16 @@ function emit(opts) {
4334
4693
  ' export type CoreStateKind = "loading" | "empty" | "populated" | "error"'
4335
4694
  );
4336
4695
  t.push(' export type StateKind = CoreStateKind | "variant"');
4696
+ t.push(" export interface AcceptanceCriterion {");
4697
+ t.push(" id: string");
4698
+ t.push(" rev?: number");
4699
+ t.push(" text: string");
4700
+ t.push(" }");
4701
+ t.push(" export type Acceptance = readonly (string | AcceptanceCriterion)[]");
4337
4702
  t.push(" export interface State {");
4338
4703
  t.push(" name: string");
4339
4704
  t.push(" kind?: StateKind");
4340
- t.push(" acceptance?: readonly string[]");
4705
+ t.push(" acceptance?: Acceptance");
4341
4706
  t.push(" description?: string");
4342
4707
  t.push(" }");
4343
4708
  t.push(' export type AcknowledgeScope = CoreStateKind | "*"');
@@ -4359,7 +4724,7 @@ function emit(opts) {
4359
4724
  t.push(" name?: string");
4360
4725
  t.push(" features?: readonly FeatureId[]");
4361
4726
  t.push(" widgets?: readonly WidgetId[]");
4362
- t.push(" acceptance?: readonly string[]");
4727
+ t.push(" acceptance?: Acceptance");
4363
4728
  t.push(" states?: readonly (string | State)[]");
4364
4729
  t.push(" acknowledge?: Acknowledge");
4365
4730
  t.push(" description?: string");
@@ -4368,7 +4733,8 @@ function emit(opts) {
4368
4733
  t.push(" feature: FeatureId | false");
4369
4734
  t.push(" name?: string");
4370
4735
  t.push(" features?: readonly FeatureId[]");
4371
- t.push(" acceptance?: readonly string[]");
4736
+ t.push(" surfaces?: readonly PageId[]");
4737
+ t.push(" acceptance?: Acceptance");
4372
4738
  t.push(" states?: readonly (string | State)[]");
4373
4739
  t.push(" acknowledge?: ComponentAcknowledge");
4374
4740
  t.push(" description?: string");
@@ -4381,7 +4747,7 @@ function emit(opts) {
4381
4747
  t.push(" export interface Widget {");
4382
4748
  t.push(" widget: WidgetId");
4383
4749
  t.push(" name?: string");
4384
- t.push(" acceptance?: readonly string[]");
4750
+ t.push(" acceptance?: Acceptance");
4385
4751
  t.push(" states?: readonly (string | State)[]");
4386
4752
  t.push(" acknowledge?: ComponentAcknowledge");
4387
4753
  t.push(" description?: string");
@@ -4480,7 +4846,7 @@ function parseGitHubRef(ref) {
4480
4846
 
4481
4847
  // src/scanner/scan/scaffold.ts
4482
4848
  var fs4 = __toESM(require("fs"), 1);
4483
- var path7 = __toESM(require("path"), 1);
4849
+ var path8 = __toESM(require("path"), 1);
4484
4850
  function scaffoldWidgetSpec(opts) {
4485
4851
  return scaffoldSpec({
4486
4852
  registry: opts.registry,
@@ -4504,9 +4870,9 @@ function scaffoldSpec(opts) {
4504
4870
  if (!entity) {
4505
4871
  throw new Error(`${capitalize(kind)} "${id}" not found in registry`);
4506
4872
  }
4507
- const criteria = entity.meta?.acceptance ?? [];
4873
+ const criteria = entityCriteria(entity);
4508
4874
  const filename = kind === "widget" ? `widget-${id}.spec.ts` : `flow-${id}.spec.ts`;
4509
- const outputPath = path7.resolve(outDir, filename);
4875
+ const outputPath = path8.resolve(outDir, filename);
4510
4876
  if (fs4.existsSync(outputPath) && !force) {
4511
4877
  return {
4512
4878
  outputPath,
@@ -4515,8 +4881,8 @@ function scaffoldSpec(opts) {
4515
4881
  reason: `spec already exists at ${outputPath}; pass --force to overwrite`
4516
4882
  };
4517
4883
  }
4518
- const content = renderSpec({ id, criteria, fixtureImport });
4519
- fs4.mkdirSync(path7.dirname(outputPath), { recursive: true });
4884
+ const content = renderSpec({ id, entityId: id, criteria, fixtureImport });
4885
+ fs4.mkdirSync(path8.dirname(outputPath), { recursive: true });
4520
4886
  fs4.writeFileSync(outputPath, content, "utf8");
4521
4887
  return { outputPath, written: true, skipped: false };
4522
4888
  }
@@ -4543,8 +4909,9 @@ function renderSpec(args) {
4543
4909
  ]);
4544
4910
  } else {
4545
4911
  for (const criterion of args.criteria) {
4546
- stub(criterion, [
4547
- "// TODO: drive this criterion, then remove `.fixme` to arm the test."
4912
+ stub(`${criterion.id}: ${criterion.text}`, [
4913
+ "// TODO: drive this criterion, then remove `.fixme` to arm the test",
4914
+ `// and claim it: uidexCovers(${JSON.stringify(args.entityId)}, ${JSON.stringify(`${criterion.id}@${criterion.rev}`)})`
4548
4915
  ]);
4549
4916
  }
4550
4917
  }
@@ -4555,36 +4922,29 @@ function renderSpec(args) {
4555
4922
 
4556
4923
  // src/scanner/scan/pipeline.ts
4557
4924
  var fs5 = __toESM(require("fs"), 1);
4558
- var path8 = __toESM(require("path"), 1);
4925
+ var path9 = __toESM(require("path"), 1);
4559
4926
  var DEFAULT_STATES_MANIFEST2 = "uidex-states.json";
4560
4927
  var DEFAULT_COVERAGE_BASELINE = "uidex-coverage-baseline.json";
4561
4928
  function loadCoverageBaseline(configDir, config) {
4562
4929
  const rel = config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
4563
- const abs = path8.resolve(configDir, rel);
4930
+ const abs = path9.resolve(configDir, rel);
4564
4931
  let raw;
4565
4932
  try {
4566
4933
  raw = fs5.readFileSync(abs, "utf8");
4567
4934
  } catch {
4568
- return {};
4935
+ return void 0;
4569
4936
  }
4570
4937
  let parsed;
4571
4938
  try {
4572
4939
  parsed = JSON.parse(raw);
4573
4940
  } catch {
4574
- return {};
4941
+ return void 0;
4575
4942
  }
4576
- const legacy = migrateLegacyBaseline({
4577
- path: abs,
4578
- raw,
4579
- parsed,
4580
- displayPath: rel
4581
- });
4582
- if (legacy) return { legacy };
4583
- return { baseline: parseCoverageBaseline(parsed) ?? void 0 };
4943
+ return parseCoverageBaseline(parsed) ?? void 0;
4584
4944
  }
4585
4945
  function loadCapturedStates(configDir, config) {
4586
4946
  const rel = config.statesManifest ?? DEFAULT_STATES_MANIFEST2;
4587
- const abs = path8.resolve(configDir, rel);
4947
+ const abs = path9.resolve(configDir, rel);
4588
4948
  let raw;
4589
4949
  try {
4590
4950
  raw = fs5.readFileSync(abs, "utf8");
@@ -4642,11 +5002,11 @@ function runOne(dc, opts) {
4642
5002
  gitContext
4643
5003
  });
4644
5004
  const typesRel = config.output;
4645
- const typesPath = path8.resolve(configDir, typesRel);
5005
+ const typesPath = path9.resolve(configDir, typesRel);
4646
5006
  const dataRel = dataOutputPath(config.output);
4647
- const dataPath = path8.resolve(configDir, dataRel);
5007
+ const dataPath = path9.resolve(configDir, dataRel);
4648
5008
  const locsRel = locsOutputPath(config.output);
4649
- const locsPath = path8.resolve(configDir, locsRel);
5009
+ const locsPath = path9.resolve(configDir, locsRel);
4650
5010
  let typesOnDisk = null;
4651
5011
  let dataOnDisk = null;
4652
5012
  let locsOnDisk = null;
@@ -4659,11 +5019,10 @@ function runOne(dc, opts) {
4659
5019
  (ef) => (ef.diagnostics?.length ?? 0) > 0
4660
5020
  );
4661
5021
  const capturedStates = opts.capturedStates ?? loadCapturedStates(configDir, config);
4662
- const loadedBaseline = opts.coverageBaseline ? { baseline: opts.coverageBaseline } : loadCoverageBaseline(configDir, config);
4663
- const coverageBaseline = loadedBaseline.baseline;
5022
+ const coverageBaseline = opts.coverageBaseline ?? loadCoverageBaseline(configDir, config);
4664
5023
  const workspace = opts.workspace ?? resolveWorkspace({ configDir, config });
4665
5024
  let auditResult;
4666
- if (opts.check || opts.lint || resolved.diagnostics.length > 0 || hasExtractDiagnostics || loadedBaseline.legacy) {
5025
+ if (opts.check || opts.lint || resolved.diagnostics.length > 0 || hasExtractDiagnostics) {
4667
5026
  auditResult = audit({
4668
5027
  registry: resolved.registry,
4669
5028
  extracted,
@@ -4687,7 +5046,6 @@ function runOne(dc, opts) {
4687
5046
  locsOutputPath: locsRel,
4688
5047
  capturedStates,
4689
5048
  coverageBaseline,
4690
- baselineDiagnostic: loadedBaseline.legacy,
4691
5049
  workspace
4692
5050
  });
4693
5051
  }
@@ -4716,7 +5074,7 @@ function readOrNull(p2) {
4716
5074
  }
4717
5075
  function writeArtifact(artifact) {
4718
5076
  if (readOrNull(artifact.outputPath) === artifact.generated) return false;
4719
- fs5.mkdirSync(path8.dirname(artifact.outputPath), { recursive: true });
5077
+ fs5.mkdirSync(path9.dirname(artifact.outputPath), { recursive: true });
4720
5078
  fs5.writeFileSync(artifact.outputPath, artifact.generated, "utf8");
4721
5079
  return true;
4722
5080
  }
@@ -4728,7 +5086,7 @@ function writeScanResult(result) {
4728
5086
  }
4729
5087
  function coverageBaselinePath(result) {
4730
5088
  const rel = result.config.coverageBaseline ?? DEFAULT_COVERAGE_BASELINE;
4731
- return path8.resolve(result.configDir, rel);
5089
+ return path9.resolve(result.configDir, rel);
4732
5090
  }
4733
5091
 
4734
5092
  // src/scanner/scan/states-merge.ts
@@ -4764,7 +5122,7 @@ function mergeStates(committed, partial) {
4764
5122
 
4765
5123
  // src/scanner/scan/fix.ts
4766
5124
  var fs6 = __toESM(require("fs"), 1);
4767
- var path9 = __toESM(require("path"), 1);
5125
+ var path10 = __toESM(require("path"), 1);
4768
5126
  function applyFixes(diagnostics) {
4769
5127
  const entries = [];
4770
5128
  for (const d of diagnostics) {
@@ -4823,10 +5181,10 @@ function applyFixes(diagnostics) {
4823
5181
  if (entry.skippedReason) continue;
4824
5182
  for (const create of entry.createFiles) {
4825
5183
  if (fs6.existsSync(create.path)) {
4826
- entry.skippedReason = `${path9.basename(create.path)} already exists`;
5184
+ entry.skippedReason = `${path10.basename(create.path)} already exists`;
4827
5185
  continue;
4828
5186
  }
4829
- fs6.mkdirSync(path9.dirname(create.path), { recursive: true });
5187
+ fs6.mkdirSync(path10.dirname(create.path), { recursive: true });
4830
5188
  fs6.writeFileSync(create.path, create.content, "utf8");
4831
5189
  }
4832
5190
  if (entry.skippedReason) continue;
@@ -5011,23 +5369,23 @@ function renameEntity(opts) {
5011
5369
 
5012
5370
  // src/scanner/scan/cli.ts
5013
5371
  var fs9 = __toESM(require("fs"), 1);
5014
- var path12 = __toESM(require("path"), 1);
5372
+ var path13 = __toESM(require("path"), 1);
5015
5373
 
5016
5374
  // src/scanner/scan/ai/index.ts
5017
5375
  var p = __toESM(require("@clack/prompts"), 1);
5018
5376
 
5019
5377
  // src/scanner/scan/ai/providers/claude.ts
5020
5378
  var fs8 = __toESM(require("fs"), 1);
5021
- var path11 = __toESM(require("path"), 1);
5379
+ var path12 = __toESM(require("path"), 1);
5022
5380
 
5023
5381
  // src/scanner/scan/ai/templates.ts
5024
5382
  var fs7 = __toESM(require("fs"), 1);
5025
- var path10 = __toESM(require("path"), 1);
5383
+ var path11 = __toESM(require("path"), 1);
5026
5384
  function templatePath(rel) {
5027
5385
  const candidates = [
5028
- path10.resolve(__dirname, "../../templates", rel),
5386
+ path11.resolve(__dirname, "../../templates", rel),
5029
5387
  // dist/cli/cli.cjs → ../../templates
5030
- path10.resolve(__dirname, "../../../../templates", rel)
5388
+ path11.resolve(__dirname, "../../../../templates", rel)
5031
5389
  // src/scanner/scan/ai → ../../../../templates
5032
5390
  ];
5033
5391
  for (const c of candidates) {
@@ -5075,7 +5433,7 @@ var claudeProvider = {
5075
5433
  async install({ cwd, force }) {
5076
5434
  const changes = [];
5077
5435
  for (const file of SKILL_FILES) {
5078
- const dest = path11.join(cwd, file.dest);
5436
+ const dest = path12.join(cwd, file.dest);
5079
5437
  const exists = fs8.existsSync(dest);
5080
5438
  if (exists && !force) {
5081
5439
  changes.push({
@@ -5085,7 +5443,7 @@ var claudeProvider = {
5085
5443
  });
5086
5444
  continue;
5087
5445
  }
5088
- fs8.mkdirSync(path11.dirname(dest), { recursive: true });
5446
+ fs8.mkdirSync(path12.dirname(dest), { recursive: true });
5089
5447
  fs8.writeFileSync(dest, readTemplate(file.template));
5090
5448
  changes.push({
5091
5449
  path: file.dest,
@@ -5093,21 +5451,21 @@ var claudeProvider = {
5093
5451
  });
5094
5452
  }
5095
5453
  for (const rel of LEGACY_FILES) {
5096
- const dest = path11.join(cwd, rel);
5454
+ const dest = path12.join(cwd, rel);
5097
5455
  if (fs8.existsSync(dest)) {
5098
5456
  fs8.unlinkSync(dest);
5099
5457
  changes.push({ path: rel, action: "removed" });
5100
5458
  }
5101
5459
  }
5102
- cleanupEmpty(path11.join(cwd, ".claude/commands/uidex"));
5103
- cleanupEmpty(path11.join(cwd, ".claude/commands"));
5104
- cleanupEmpty(path11.join(cwd, ".claude/rules"));
5460
+ cleanupEmpty(path12.join(cwd, ".claude/commands/uidex"));
5461
+ cleanupEmpty(path12.join(cwd, ".claude/commands"));
5462
+ cleanupEmpty(path12.join(cwd, ".claude/rules"));
5105
5463
  return { changes };
5106
5464
  },
5107
5465
  async uninstall({ cwd }) {
5108
5466
  const changes = [];
5109
5467
  for (const file of SKILL_FILES) {
5110
- const dest = path11.join(cwd, file.dest);
5468
+ const dest = path12.join(cwd, file.dest);
5111
5469
  if (!fs8.existsSync(dest)) {
5112
5470
  changes.push({ path: file.dest, action: "skipped", reason: "absent" });
5113
5471
  continue;
@@ -5115,19 +5473,19 @@ var claudeProvider = {
5115
5473
  fs8.unlinkSync(dest);
5116
5474
  changes.push({ path: file.dest, action: "removed" });
5117
5475
  }
5118
- cleanupEmpty(path11.join(cwd, ".claude/skills/uidex/references"));
5119
- cleanupEmpty(path11.join(cwd, ".claude/skills/uidex"));
5120
- cleanupEmpty(path11.join(cwd, ".claude/skills"));
5476
+ cleanupEmpty(path12.join(cwd, ".claude/skills/uidex/references"));
5477
+ cleanupEmpty(path12.join(cwd, ".claude/skills/uidex"));
5478
+ cleanupEmpty(path12.join(cwd, ".claude/skills"));
5121
5479
  for (const rel of LEGACY_FILES) {
5122
- const dest = path11.join(cwd, rel);
5480
+ const dest = path12.join(cwd, rel);
5123
5481
  if (fs8.existsSync(dest)) {
5124
5482
  fs8.unlinkSync(dest);
5125
5483
  changes.push({ path: rel, action: "removed" });
5126
5484
  }
5127
5485
  }
5128
- cleanupEmpty(path11.join(cwd, ".claude/commands/uidex"));
5129
- cleanupEmpty(path11.join(cwd, ".claude/commands"));
5130
- cleanupEmpty(path11.join(cwd, ".claude/rules"));
5486
+ cleanupEmpty(path12.join(cwd, ".claude/commands/uidex"));
5487
+ cleanupEmpty(path12.join(cwd, ".claude/commands"));
5488
+ cleanupEmpty(path12.join(cwd, ".claude/rules"));
5131
5489
  return { changes };
5132
5490
  }
5133
5491
  };
@@ -5340,7 +5698,7 @@ function helpText2() {
5340
5698
  " --check Verify the on-disk gen file matches a fresh scan; exit non-zero on drift (read-only)",
5341
5699
  " --lint Run lint diagnostics (missing annotations, scope leak, duplicate ids, coverage)",
5342
5700
  " --audit Equivalent to --check --lint (read-only)",
5343
- " --fix Apply machine-generated fixes (add data-uidex to unannotated interactive elements, drop empty names, migrate waivers/capture to acknowledge), then rescan and write",
5701
+ " --fix Apply machine-generated fixes (add data-uidex to unannotated interactive elements, drop empty names), then rescan and write",
5344
5702
  " --update-baseline Regenerate the coverage baseline (uidex-coverage-baseline.json) as `acknowledge` backlog entries",
5345
5703
  " --json Emit JSON diagnostics on stdout",
5346
5704
  " --force (scaffold) overwrite existing spec",
@@ -5348,7 +5706,7 @@ function helpText2() {
5348
5706
  ].join("\n");
5349
5707
  }
5350
5708
  function runInit(cwd, w) {
5351
- const configPath = path12.join(cwd, CONFIG_FILENAME);
5709
+ const configPath = path13.join(cwd, CONFIG_FILENAME);
5352
5710
  if (fs9.existsSync(configPath)) {
5353
5711
  w.err(`.uidex.json already exists at ${configPath}`);
5354
5712
  return w.result(1);
@@ -5360,7 +5718,7 @@ function runInit(cwd, w) {
5360
5718
  };
5361
5719
  fs9.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
5362
5720
  w.out(`Created ${configPath}`);
5363
- const gitignorePath = path12.join(cwd, ".gitignore");
5721
+ const gitignorePath = path13.join(cwd, ".gitignore");
5364
5722
  const entry = "*.gen.*";
5365
5723
  if (fs9.existsSync(gitignorePath)) {
5366
5724
  const existing = fs9.readFileSync(gitignorePath, "utf8");
@@ -5418,7 +5776,7 @@ function runScanCommand(cwd, flags, w) {
5418
5776
  const acknowledge = {};
5419
5777
  const merge = (from) => {
5420
5778
  for (const [route, scopes] of Object.entries(from)) {
5421
- acknowledge[route] = { ...acknowledge[route] ?? {}, ...scopes };
5779
+ acknowledge[route] = { ...acknowledge[route], ...scopes };
5422
5780
  }
5423
5781
  };
5424
5782
  merge(computePageBacklog(r.registry, manifest));
@@ -5506,7 +5864,7 @@ function runScaffold(cwd, args, flags, w) {
5506
5864
  for (const r of results) {
5507
5865
  const entity = r.registry.get(scaffoldKind, id);
5508
5866
  if (!entity) continue;
5509
- const outDir = path12.resolve(r.configDir, "e2e");
5867
+ const outDir = path13.resolve(r.configDir, "e2e");
5510
5868
  const result = scaffoldSpec({
5511
5869
  registry: r.registry,
5512
5870
  kind: scaffoldKind,
@@ -5541,8 +5899,8 @@ function runStates(cwd, args, w) {
5541
5899
  let merged = 0;
5542
5900
  for (const { configDir, config } of configs) {
5543
5901
  const rel = config.statesManifest ?? "uidex-states.json";
5544
- const target = path12.resolve(configDir, rel);
5545
- const partial = explicitPartial ? path12.resolve(cwd, explicitPartial) : target.replace(/\.json$/i, "") + ".partial.json";
5902
+ const target = path13.resolve(configDir, rel);
5903
+ const partial = explicitPartial ? path13.resolve(cwd, explicitPartial) : target.replace(/\.json$/i, "") + ".partial.json";
5546
5904
  const committedRaw = readJson(target);
5547
5905
  const partialRaw = readJson(partial);
5548
5906
  if (!partialRaw) continue;
@@ -5554,7 +5912,7 @@ function runStates(cwd, args, w) {
5554
5912
  fs9.writeFileSync(target, JSON.stringify(result.manifest, null, 2) + "\n");
5555
5913
  merged++;
5556
5914
  w.out(
5557
- `Merged ${path12.basename(partial)} \u2192 ${rel}: ${result.added.length} added, ${result.updated.length} updated, ${result.manifest.captured.length} total`
5915
+ `Merged ${path13.basename(partial)} \u2192 ${rel}: ${result.added.length} added, ${result.updated.length} updated, ${result.manifest.captured.length} total`
5558
5916
  );
5559
5917
  for (const k of result.added) w.out(` + ${k}`);
5560
5918
  for (const k of result.updated) w.out(` ~ ${k}`);
@@ -5647,13 +6005,9 @@ function createWriter() {
5647
6005
  extractUidexExports,
5648
6006
  findWorkspaceRoot,
5649
6007
  globToRegExp,
5650
- isLegacyBaseline,
5651
6008
  loadCoverageBaseline,
5652
6009
  matchUrlToRoute,
5653
6010
  mergeStates,
5654
- migrateBaseline,
5655
- migrateLegacyBaseline,
5656
- migrateLegacyFields,
5657
6011
  parseConfig,
5658
6012
  parseCoverageBaseline,
5659
6013
  pathToId,