uidex 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/cli/cli.cjs +1116 -112
  2. package/dist/cli/cli.cjs.map +1 -1
  3. package/dist/cloud/index.cjs +395 -72
  4. package/dist/cloud/index.cjs.map +1 -1
  5. package/dist/cloud/index.d.cts +60 -86
  6. package/dist/cloud/index.d.ts +60 -86
  7. package/dist/cloud/index.js +396 -71
  8. package/dist/cloud/index.js.map +1 -1
  9. package/dist/headless/index.cjs +1505 -791
  10. package/dist/headless/index.cjs.map +1 -1
  11. package/dist/headless/index.d.cts +83 -75
  12. package/dist/headless/index.d.ts +83 -75
  13. package/dist/headless/index.js +1514 -791
  14. package/dist/headless/index.js.map +1 -1
  15. package/dist/index.cjs +6281 -3190
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +337 -229
  18. package/dist/index.d.ts +337 -229
  19. package/dist/index.js +6362 -3231
  20. package/dist/index.js.map +1 -1
  21. package/dist/playwright/index.cjs +4 -4
  22. package/dist/playwright/index.cjs.map +1 -1
  23. package/dist/playwright/index.js +3 -3
  24. package/dist/playwright/index.js.map +1 -1
  25. package/dist/playwright/reporter.cjs +3 -3
  26. package/dist/playwright/reporter.cjs.map +1 -1
  27. package/dist/playwright/reporter.js +3 -3
  28. package/dist/playwright/reporter.js.map +1 -1
  29. package/dist/react/index.cjs +6291 -3206
  30. package/dist/react/index.cjs.map +1 -1
  31. package/dist/react/index.d.cts +239 -186
  32. package/dist/react/index.d.ts +239 -186
  33. package/dist/react/index.js +6338 -3208
  34. package/dist/react/index.js.map +1 -1
  35. package/dist/scan/index.cjs +212 -82
  36. package/dist/scan/index.cjs.map +1 -1
  37. package/dist/scan/index.d.cts +31 -0
  38. package/dist/scan/index.d.ts +31 -0
  39. package/dist/scan/index.js +211 -81
  40. package/dist/scan/index.js.map +1 -1
  41. package/package.json +10 -8
  42. package/templates/claude/api.md +110 -0
  43. package/templates/claude/audit.md +8 -2
  44. package/templates/claude/rules.md +15 -0
@@ -27,7 +27,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
27
27
  ));
28
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
29
 
30
- // src/scan/index.ts
30
+ // src/scanner/scan/index.ts
31
31
  var scan_exports = {};
32
32
  __export(scan_exports, {
33
33
  CONFIG_FILENAME: () => CONFIG_FILENAME,
@@ -54,12 +54,16 @@ __export(scan_exports, {
54
54
  });
55
55
  module.exports = __toCommonJS(scan_exports);
56
56
 
57
- // src/scan/discover.ts
57
+ // src/scanner/scan/discover.ts
58
58
  var fs = __toESM(require("fs"), 1);
59
59
  var path = __toESM(require("path"), 1);
60
60
 
61
- // src/scan/config.ts
61
+ // src/scanner/scan/config.ts
62
62
  var DEFAULT_TYPE_MODE = "strict";
63
+ var WELL_KNOWN_FILES = {
64
+ page: "uidex.page.ts",
65
+ feature: "uidex.feature.ts"
66
+ };
63
67
  var ConfigError = class extends Error {
64
68
  constructor(message) {
65
69
  super(message);
@@ -97,14 +101,14 @@ var ALLOWED_AUDIT_KEYS = /* @__PURE__ */ new Set(["scopeLeak", "coverage", "acce
97
101
  function fail(msg) {
98
102
  throw new ConfigError(`Invalid .uidex.json: ${msg}`);
99
103
  }
100
- function assertObject(value, path9) {
104
+ function assertObject(value, path10) {
101
105
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
102
- fail(`${path9} must be an object`);
106
+ fail(`${path10} must be an object`);
103
107
  }
104
108
  }
105
- function assertStringArray(value, path9) {
109
+ function assertStringArray(value, path10) {
106
110
  if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
107
- fail(`${path9} must be a string[]`);
111
+ fail(`${path10} must be a string[]`);
108
112
  }
109
113
  }
110
114
  function validateConfig(raw) {
@@ -218,7 +222,7 @@ var DEFAULT_CONVENTIONS = {
218
222
  regions: "landmarks"
219
223
  };
220
224
 
221
- // src/scan/discover.ts
225
+ // src/scanner/scan/discover.ts
222
226
  var CONFIG_FILENAME = ".uidex.json";
223
227
  var SKIP_DIRS = /* @__PURE__ */ new Set([
224
228
  "node_modules",
@@ -277,7 +281,7 @@ function discover(options = {}) {
277
281
  return results.sort((a, b) => a.configPath.localeCompare(b.configPath));
278
282
  }
279
283
 
280
- // src/scan/walk.ts
284
+ // src/scanner/scan/walk.ts
281
285
  var fs2 = __toESM(require("fs"), 1);
282
286
  var path2 = __toESM(require("path"), 1);
283
287
  var DEFAULT_INCLUDES = ["**/*.{ts,tsx,js,jsx,mjs,cjs}"];
@@ -402,7 +406,7 @@ function* walkDir(root, dir) {
402
406
  }
403
407
  }
404
408
 
405
- // src/scan/extract-uidex-export.ts
409
+ // src/scanner/scan/extract-uidex-export.ts
406
410
  var KIND_DISCRIMINATORS = [
407
411
  "page",
408
412
  "feature",
@@ -420,7 +424,13 @@ var ALLOWED_FIELDS = {
420
424
  "acceptance",
421
425
  "description"
422
426
  ]),
423
- feature: /* @__PURE__ */ new Set(["feature", "name", "acceptance", "description"]),
427
+ feature: /* @__PURE__ */ new Set([
428
+ "feature",
429
+ "name",
430
+ "features",
431
+ "acceptance",
432
+ "description"
433
+ ]),
424
434
  primitive: /* @__PURE__ */ new Set(["primitive", "name", "description"]),
425
435
  widget: /* @__PURE__ */ new Set(["widget", "name", "acceptance", "description"]),
426
436
  flow: /* @__PURE__ */ new Set(["flow", "notFlow", "name", "description"])
@@ -1156,7 +1166,7 @@ function buildMetadata(value, file, headerPos, diagnostics) {
1156
1166
  line: pos.line
1157
1167
  });
1158
1168
  }
1159
- const features = kind === "page" ? readStringArrayField(byKey, "features") : void 0;
1169
+ const features = kind === "page" || kind === "feature" ? readStringArrayField(byKey, "features") : void 0;
1160
1170
  const widgets = kind === "page" ? readStringArrayField(byKey, "widgets") : void 0;
1161
1171
  const notFlow = kind === "flow" && discriminator === "notFlow" ? true : void 0;
1162
1172
  const metadata = {
@@ -1251,7 +1261,7 @@ function posAt(content, offset) {
1251
1261
  return { offset, line, column: offset - lineStart + 1 };
1252
1262
  }
1253
1263
 
1254
- // src/scan/jsx-ancestry.ts
1264
+ // src/scanner/scan/jsx-ancestry.ts
1255
1265
  var DATA_ATTR_RE = /\bdata-uidex(?:-(region|widget|primitive))?\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
1256
1266
  function parseDataAttrs(tagSource) {
1257
1267
  if (!tagSource.includes("data-uidex")) return [];
@@ -1440,7 +1450,7 @@ function findTagEnd(content, start) {
1440
1450
  return -1;
1441
1451
  }
1442
1452
 
1443
- // src/scan/extract.ts
1453
+ // src/scanner/scan/extract.ts
1444
1454
  var JSDOC_BLOCK = /\/\*\*([\s\S]*?)\*\//g;
1445
1455
  function lineAt(content, index) {
1446
1456
  let line = 1;
@@ -1543,10 +1553,10 @@ function extractOne(file) {
1543
1553
  return annotations;
1544
1554
  }
1545
1555
 
1546
- // src/scan/resolve.ts
1556
+ // src/scanner/scan/resolve.ts
1547
1557
  var path3 = __toESM(require("path"), 1);
1548
1558
 
1549
- // src/entities/types.ts
1559
+ // src/shared/entities/types.ts
1550
1560
  var ENTITY_KINDS = [
1551
1561
  "route",
1552
1562
  "page",
@@ -1579,7 +1589,7 @@ function assertEntityKind(kind) {
1579
1589
  if (!KIND_SET.has(kind)) throw new UnknownEntityKindError(kind);
1580
1590
  }
1581
1591
 
1582
- // src/entities/registry.ts
1592
+ // src/shared/entities/registry.ts
1583
1593
  function emptyStore() {
1584
1594
  return {
1585
1595
  route: /* @__PURE__ */ new Map(),
@@ -1661,10 +1671,33 @@ function createRegistry() {
1661
1671
  return ids.has(entity.id);
1662
1672
  });
1663
1673
  };
1664
- return { add, get, list, query, byScope, touchedBy };
1674
+ const reports = /* @__PURE__ */ new Map();
1675
+ const reportsCbs = /* @__PURE__ */ new Set();
1676
+ const setReports = (kind, id, records) => {
1677
+ reports.set(`${kind}:${id}`, records);
1678
+ for (const cb of reportsCbs) cb();
1679
+ };
1680
+ const getReports = (kind, id) => reports.get(`${kind}:${id}`) ?? [];
1681
+ const listReportKeys = () => Array.from(reports.keys());
1682
+ const onReportsChange = (cb) => {
1683
+ reportsCbs.add(cb);
1684
+ return () => reportsCbs.delete(cb);
1685
+ };
1686
+ return {
1687
+ add,
1688
+ get,
1689
+ list,
1690
+ query,
1691
+ byScope,
1692
+ touchedBy,
1693
+ setReports,
1694
+ getReports,
1695
+ listReportKeys,
1696
+ onReportsChange
1697
+ };
1665
1698
  }
1666
1699
 
1667
- // src/scan/routes.ts
1700
+ // src/scanner/scan/routes.ts
1668
1701
  var PAGE_BASENAME = /^page\.(tsx|ts|jsx|js|mjs|cjs)$/;
1669
1702
  var PAGES_ROUTER_BASENAME = /\.(tsx|ts|jsx|js|mjs|cjs)$/;
1670
1703
  var ROUTE_BASENAME = /^route\.(tsx|ts|jsx|js|mjs|cjs)$/;
@@ -1730,7 +1763,7 @@ function pathToId(routePath) {
1730
1763
  return routePath.replace(/^\/+/, "").replace(/\[\.{3}([^\]]+)\]/g, "$1").replace(/\[([^\]]+)\]/g, "$1").replace(/\//g, "-").replace(/[^a-zA-Z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
1731
1764
  }
1732
1765
 
1733
- // src/scan/resolve.ts
1766
+ // src/scanner/scan/resolve.ts
1734
1767
  var DOM_ATTR_KINDS = /* @__PURE__ */ new Set([
1735
1768
  "element",
1736
1769
  "region",
@@ -1825,7 +1858,24 @@ function resolve2(ctx) {
1825
1858
  const routes = conventions.pages === "auto" ? detectRoutes(ctx.extracted.map((e) => e.file)) : [];
1826
1859
  const handledPageFiles = /* @__PURE__ */ new Set();
1827
1860
  for (const route of routes) {
1828
- const exp = exportFor(route.file, "page");
1861
+ const routeDir = path3.posix.dirname(route.file);
1862
+ const wellKnownPath = `${routeDir}/${WELL_KNOWN_FILES.page}`;
1863
+ const wellKnownExp = exportFor(wellKnownPath, "page");
1864
+ const routeExp = exportFor(route.file, "page");
1865
+ const exp = wellKnownExp ?? routeExp;
1866
+ const locFile = wellKnownExp ? wellKnownPath : route.file;
1867
+ if (wellKnownExp) handledPageFiles.add(wellKnownPath);
1868
+ handledPageFiles.add(route.file);
1869
+ if (wellKnownExp && routeExp) {
1870
+ diagnostics.push({
1871
+ code: "competing-uidex-export",
1872
+ severity: "warning",
1873
+ message: `Page metadata declared in both ${wellKnownPath} and ${route.file}; ${wellKnownPath} takes precedence.`,
1874
+ file: route.file,
1875
+ line: routeExp.loc.line,
1876
+ hint: `Remove the export from ${route.file} or delete ${wellKnownPath}.`
1877
+ });
1878
+ }
1829
1879
  if (exp && exp.id === false) continue;
1830
1880
  const effectiveId = exp && typeof exp.id === "string" ? exp.id : route.id;
1831
1881
  const meta = exp ? buildMetaFromExport(exp) : void 0;
@@ -1833,11 +1883,10 @@ function resolve2(ctx) {
1833
1883
  const page = {
1834
1884
  kind: "page",
1835
1885
  id: effectiveId,
1836
- loc: { file: route.file, line: exp?.loc.line },
1886
+ loc: { file: locFile, line: exp?.loc.line },
1837
1887
  ...meta ? { meta } : {}
1838
1888
  };
1839
1889
  registry.add(page);
1840
- handledPageFiles.add(route.file);
1841
1890
  }
1842
1891
  for (const ef of ctx.extracted) {
1843
1892
  const exp = exportFor(ef.file.displayPath, "page");
@@ -1853,7 +1902,8 @@ function resolve2(ctx) {
1853
1902
  }
1854
1903
  const featureGlob = typeof conventions.features === "string" ? conventions.features : null;
1855
1904
  const conventionalFeatureDirs = /* @__PURE__ */ new Set();
1856
- const featureExportsByDir = /* @__PURE__ */ new Map();
1905
+ const featureExportFilesByDir = /* @__PURE__ */ new Map();
1906
+ const wellKnownFeatureFileByDir = /* @__PURE__ */ new Map();
1857
1907
  const suppressedFeatureDirs = /* @__PURE__ */ new Set();
1858
1908
  if (featureGlob) {
1859
1909
  const re = globToRegExp(featureGlob + "/**");
@@ -1862,16 +1912,43 @@ function resolve2(ctx) {
1862
1912
  const dir = extractFeatureDir(ef.file.displayPath, featureGlob);
1863
1913
  if (!dir) continue;
1864
1914
  conventionalFeatureDirs.add(dir);
1915
+ const isWellKnown = path3.posix.basename(ef.file.displayPath) === WELL_KNOWN_FILES.feature;
1916
+ if (isWellKnown) wellKnownFeatureFileByDir.set(dir, ef.file.displayPath);
1865
1917
  const exp = exportFor(ef.file.displayPath, "feature");
1866
1918
  if (exp) {
1867
1919
  if (exp.id === false) suppressedFeatureDirs.add(dir);
1868
- else if (!featureExportsByDir.has(dir))
1869
- featureExportsByDir.set(dir, exp);
1920
+ else {
1921
+ let arr = featureExportFilesByDir.get(dir);
1922
+ if (!arr) {
1923
+ arr = [];
1924
+ featureExportFilesByDir.set(dir, arr);
1925
+ }
1926
+ arr.push({ file: ef.file.displayPath, exp });
1927
+ }
1870
1928
  }
1871
1929
  }
1872
1930
  for (const dir of conventionalFeatureDirs) {
1873
1931
  if (suppressedFeatureDirs.has(dir)) continue;
1874
- const exp = featureExportsByDir.get(dir);
1932
+ const allExports = featureExportFilesByDir.get(dir) ?? [];
1933
+ const wellKnownPath = wellKnownFeatureFileByDir.get(dir);
1934
+ const wellKnownEntry = wellKnownPath ? allExports.find((e) => e.file === wellKnownPath) : void 0;
1935
+ let exp;
1936
+ if (wellKnownEntry) {
1937
+ exp = wellKnownEntry.exp;
1938
+ for (const other of allExports) {
1939
+ if (other.file === wellKnownEntry.file) continue;
1940
+ diagnostics.push({
1941
+ code: "competing-uidex-export",
1942
+ severity: "warning",
1943
+ message: `Feature metadata declared in both ${wellKnownEntry.file} and ${other.file}; ${wellKnownEntry.file} takes precedence.`,
1944
+ file: other.file,
1945
+ line: other.exp.loc.line,
1946
+ hint: `Remove the export from ${other.file} or delete ${wellKnownEntry.file}.`
1947
+ });
1948
+ }
1949
+ } else if (allExports.length > 0) {
1950
+ exp = allExports[0].exp;
1951
+ }
1875
1952
  const id = exp && typeof exp.id === "string" ? exp.id : path3.posix.basename(dir);
1876
1953
  const meta = exp ? buildMetaFromExport(exp) : void 0;
1877
1954
  const feature = {
@@ -2004,11 +2081,12 @@ function resolve2(ctx) {
2004
2081
  exp.id,
2005
2082
  buildMetaFromExport(exp)
2006
2083
  );
2084
+ const scope = computeScope(file);
2007
2085
  const primitive = {
2008
2086
  kind: "primitive",
2009
2087
  id: exp.id,
2010
2088
  loc: { file, line: exp.loc.line },
2011
- scopes: [computeScope(file)],
2089
+ ...scope ? { scopes: [scope] } : {},
2012
2090
  ...meta ? { meta } : {}
2013
2091
  };
2014
2092
  registry.add(primitive);
@@ -2020,11 +2098,12 @@ function resolve2(ctx) {
2020
2098
  if (domPrimitives.length > 0) {
2021
2099
  for (const p2 of domPrimitives) {
2022
2100
  const meta = metaWithComposes("primitive", p2.id);
2101
+ const domScope = computeScope(p2.file);
2023
2102
  const primitive = {
2024
2103
  kind: "primitive",
2025
2104
  id: p2.id,
2026
2105
  loc: { file: p2.file, line: p2.line },
2027
- scopes: [computeScope(p2.file)],
2106
+ ...domScope ? { scopes: [domScope] } : {},
2028
2107
  ...meta ? { meta } : {}
2029
2108
  };
2030
2109
  registry.add(primitive);
@@ -2034,13 +2113,13 @@ function resolve2(ctx) {
2034
2113
  if (primitiveConventions && fileMatchesAny(file, primitiveConventions)) {
2035
2114
  const name = kebab(baseName(file));
2036
2115
  if (!name) continue;
2037
- const scope = computeScope(file);
2116
+ const convScope = computeScope(file);
2038
2117
  const meta = metaWithComposes("primitive", name);
2039
2118
  const primitive = {
2040
2119
  kind: "primitive",
2041
2120
  id: name,
2042
2121
  loc: { file },
2043
- scopes: [scope],
2122
+ ...convScope ? { scopes: [convScope] } : {},
2044
2123
  ...meta ? { meta } : {}
2045
2124
  };
2046
2125
  registry.add(primitive);
@@ -2070,7 +2149,8 @@ function resolve2(ctx) {
2070
2149
  kind: "flow",
2071
2150
  id: flowExport.id,
2072
2151
  loc: base.loc,
2073
- touches: base.touches
2152
+ touches: base.touches,
2153
+ steps: base.steps
2074
2154
  };
2075
2155
  registry.add(flow);
2076
2156
  } else {
@@ -2115,7 +2195,7 @@ function computeScope(displayPath) {
2115
2195
  if (pagesIdx !== -1 && parts[pagesIdx + 1]) {
2116
2196
  return `page:${parts[pagesIdx + 1]}`;
2117
2197
  }
2118
- return "global";
2198
+ return null;
2119
2199
  }
2120
2200
  function extractFlowsFromSource(file) {
2121
2201
  const flows = [];
@@ -2149,17 +2229,18 @@ function extractFlowsFromSource(file) {
2149
2229
  kind: "flow",
2150
2230
  id,
2151
2231
  loc: { file: file.displayPath, line },
2152
- touches: dedupe(touches.map((t) => t.id))
2232
+ touches: dedupe(touches.map((t) => t.id)),
2233
+ steps: touches.filter((t) => t.action).map((t) => ({ entityId: t.id, action: t.action }))
2153
2234
  });
2154
2235
  }
2155
2236
  return flows;
2156
2237
  }
2157
2238
  function captureUidexIds(body) {
2158
2239
  const out2 = [];
2159
- const re = /uidex\(\s*(?:'([^']+)'|"([^"]+)"|`([^`$]+)`)\s*\)/g;
2240
+ const re = /uidex\(\s*(?:'([^']+)'|"([^"]+)"|`([^`$]+)`)\s*\)(?:\.(\w+)\s*\()?/g;
2160
2241
  let m;
2161
2242
  while ((m = re.exec(body)) !== null) {
2162
- out2.push({ id: m[1] || m[2] || m[3] });
2243
+ out2.push({ id: m[1] || m[2] || m[3], action: m[4] });
2163
2244
  }
2164
2245
  return out2;
2165
2246
  }
@@ -2167,7 +2248,8 @@ function dedupe(arr) {
2167
2248
  return Array.from(new Set(arr));
2168
2249
  }
2169
2250
 
2170
- // src/scan/audit.ts
2251
+ // src/scanner/scan/audit.ts
2252
+ var path4 = __toESM(require("path"), 1);
2171
2253
  var MARKER_FILENAMES = ["UIDEX_PAGE.md", "UIDEX_FEATURE.md"];
2172
2254
  function audit(opts) {
2173
2255
  const diagnostics = [];
@@ -2269,6 +2351,32 @@ function audit(opts) {
2269
2351
  }
2270
2352
  }
2271
2353
  }
2354
+ if (lint) {
2355
+ const scannedPaths = new Set(files.map((f) => f.displayPath));
2356
+ for (const ef of extracted) {
2357
+ if (!ef.metadata) continue;
2358
+ for (const m of ef.metadata) {
2359
+ if (m.kind !== "page" && m.kind !== "feature") continue;
2360
+ if (typeof m.id !== "string") continue;
2361
+ const filePath = ef.file.displayPath;
2362
+ const wellKnownName = WELL_KNOWN_FILES[m.kind];
2363
+ if (path4.posix.basename(filePath) === wellKnownName) continue;
2364
+ const dir = path4.posix.dirname(filePath);
2365
+ const wellKnownPath = dir === "." ? wellKnownName : `${dir}/${wellKnownName}`;
2366
+ if (scannedPaths.has(wellKnownPath)) continue;
2367
+ const kindLabel = m.kind === "page" ? "Page" : "Feature";
2368
+ diagnostics.push({
2369
+ code: "prefer-well-known-file",
2370
+ severity: "info",
2371
+ message: `${kindLabel} "${m.id}" metadata lives on ${filePath}; prefer ${wellKnownPath}`,
2372
+ file: filePath,
2373
+ line: m.loc.line,
2374
+ entity: { kind: m.kind, id: m.id },
2375
+ hint: `Move the \`export const uidex\` block to ${wellKnownPath} and remove it from ${filePath}.`
2376
+ });
2377
+ }
2378
+ }
2379
+ }
2272
2380
  if (lint) {
2273
2381
  for (const f of files) {
2274
2382
  const lines = f.content.split("\n");
@@ -2295,6 +2403,15 @@ function audit(opts) {
2295
2403
  const primitives = registry.list("primitive");
2296
2404
  const byName = /* @__PURE__ */ new Map();
2297
2405
  for (const p2 of primitives) byName.set(p2.id, p2);
2406
+ const declaredFeatures = /* @__PURE__ */ new Map();
2407
+ for (const ef of extracted) {
2408
+ if (!ef.metadata) continue;
2409
+ for (const m of ef.metadata) {
2410
+ if (m.features && m.features.length > 0) {
2411
+ declaredFeatures.set(ef.file.displayPath, new Set(m.features));
2412
+ }
2413
+ }
2414
+ }
2298
2415
  for (const f of files) {
2299
2416
  const importRe = /import\s+(?:[^'"]+)\s+from\s+['"]([^'"]+)['"]/g;
2300
2417
  let m;
@@ -2305,18 +2422,23 @@ function audit(opts) {
2305
2422
  baseName2.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
2306
2423
  );
2307
2424
  if (!primitive) continue;
2308
- const scope = primitive.scopes?.[0] ?? "global";
2309
- if (scope === "global") continue;
2425
+ const scope = primitive.scopes?.[0];
2426
+ if (!scope) continue;
2310
2427
  const [kind, id] = scope.split(":");
2311
2428
  const importerSegments = f.displayPath.split("/");
2312
- if (!importerSegments.includes(id) || !importerSegments.includes(kind + "s")) {
2313
- diagnostics.push({
2314
- code: "scope-leak",
2315
- severity: "warning",
2316
- message: `Primitive "${primitive.id}" is scoped to ${scope} but is imported from ${f.displayPath}`,
2317
- file: f.displayPath
2318
- });
2429
+ if (importerSegments.includes(id) && importerSegments.includes(kind + "s")) {
2430
+ continue;
2431
+ }
2432
+ if (kind === "feature" && importerSegments.includes(id)) continue;
2433
+ if (kind === "feature" && declaredFeatures.get(f.displayPath)?.has(id)) {
2434
+ continue;
2319
2435
  }
2436
+ diagnostics.push({
2437
+ code: "scope-leak",
2438
+ severity: "warning",
2439
+ message: `Primitive "${primitive.id}" is scoped to ${scope} but is imported from ${f.displayPath}`,
2440
+ file: f.displayPath
2441
+ });
2320
2442
  }
2321
2443
  }
2322
2444
  }
@@ -2490,7 +2612,7 @@ function stableReplacer(_key, value) {
2490
2612
  return value;
2491
2613
  }
2492
2614
 
2493
- // src/scan/emit.ts
2615
+ // src/scanner/scan/emit.ts
2494
2616
  function sortById(arr) {
2495
2617
  return [...arr].sort((a, b) => a.id.localeCompare(b.id));
2496
2618
  }
@@ -2614,6 +2736,7 @@ function emit(opts) {
2614
2736
  lines.push(" export interface Feature {");
2615
2737
  lines.push(" feature: FeatureId | false");
2616
2738
  lines.push(" name?: string");
2739
+ lines.push(" features?: readonly FeatureId[]");
2617
2740
  lines.push(" acceptance?: readonly string[]");
2618
2741
  lines.push(" description?: string");
2619
2742
  lines.push(" }");
@@ -2670,7 +2793,7 @@ function emit(opts) {
2670
2793
  return lines.join("\n");
2671
2794
  }
2672
2795
 
2673
- // src/scan/git.ts
2796
+ // src/scanner/scan/git.ts
2674
2797
  var import_node_child_process = require("child_process");
2675
2798
  function runGit(args, cwd) {
2676
2799
  try {
@@ -2702,9 +2825,9 @@ function parseGitHubRef(ref) {
2702
2825
  return m ? m[1] : null;
2703
2826
  }
2704
2827
 
2705
- // src/scan/scaffold.ts
2828
+ // src/scanner/scan/scaffold.ts
2706
2829
  var fs3 = __toESM(require("fs"), 1);
2707
- var path4 = __toESM(require("path"), 1);
2830
+ var path5 = __toESM(require("path"), 1);
2708
2831
  function scaffoldWidgetSpec(opts) {
2709
2832
  const {
2710
2833
  registry,
@@ -2719,7 +2842,7 @@ function scaffoldWidgetSpec(opts) {
2719
2842
  }
2720
2843
  const criteria = widget.meta?.acceptance ?? [];
2721
2844
  const filename = `widget-${widgetId}.spec.ts`;
2722
- const outputPath = path4.resolve(outDir, filename);
2845
+ const outputPath = path5.resolve(outDir, filename);
2723
2846
  if (fs3.existsSync(outputPath) && !force) {
2724
2847
  return {
2725
2848
  outputPath,
@@ -2733,7 +2856,7 @@ function scaffoldWidgetSpec(opts) {
2733
2856
  criteria,
2734
2857
  fixtureImport
2735
2858
  });
2736
- fs3.mkdirSync(path4.dirname(outputPath), { recursive: true });
2859
+ fs3.mkdirSync(path5.dirname(outputPath), { recursive: true });
2737
2860
  fs3.writeFileSync(outputPath, content, "utf8");
2738
2861
  return { outputPath, written: true, skipped: false };
2739
2862
  }
@@ -2765,9 +2888,9 @@ function renderSpec(args) {
2765
2888
  return lines.join("\n");
2766
2889
  }
2767
2890
 
2768
- // src/scan/pipeline.ts
2891
+ // src/scanner/scan/pipeline.ts
2769
2892
  var fs4 = __toESM(require("fs"), 1);
2770
- var path5 = __toESM(require("path"), 1);
2893
+ var path6 = __toESM(require("path"), 1);
2771
2894
  function runScan(opts = {}) {
2772
2895
  const cwd = opts.cwd ?? process.cwd();
2773
2896
  const configs = opts.configs ?? discover({ cwd });
@@ -2799,7 +2922,7 @@ function runOne(dc, opts) {
2799
2922
  gitContext,
2800
2923
  typeMode: config.typeMode
2801
2924
  });
2802
- const outputPath = path5.resolve(configDir, config.output);
2925
+ const outputPath = path6.resolve(configDir, config.output);
2803
2926
  const outputRel = config.output;
2804
2927
  let existingOnDisk = null;
2805
2928
  if (opts.check) {
@@ -2835,29 +2958,29 @@ function runOne(dc, opts) {
2835
2958
  };
2836
2959
  }
2837
2960
  function writeScanResult(result) {
2838
- fs4.mkdirSync(path5.dirname(result.outputPath), { recursive: true });
2961
+ fs4.mkdirSync(path6.dirname(result.outputPath), { recursive: true });
2839
2962
  fs4.writeFileSync(result.outputPath, result.generated, "utf8");
2840
2963
  }
2841
2964
 
2842
- // src/scan/cli.ts
2965
+ // src/scanner/scan/cli.ts
2843
2966
  var fs7 = __toESM(require("fs"), 1);
2844
- var path8 = __toESM(require("path"), 1);
2967
+ var path9 = __toESM(require("path"), 1);
2845
2968
 
2846
- // src/scan/ai/index.ts
2969
+ // src/scanner/scan/ai/index.ts
2847
2970
  var p = __toESM(require("@clack/prompts"), 1);
2848
2971
 
2849
- // src/scan/ai/providers/claude.ts
2972
+ // src/scanner/scan/ai/providers/claude.ts
2850
2973
  var fs6 = __toESM(require("fs"), 1);
2851
- var path7 = __toESM(require("path"), 1);
2974
+ var path8 = __toESM(require("path"), 1);
2852
2975
 
2853
- // src/scan/ai/templates.ts
2976
+ // src/scanner/scan/ai/templates.ts
2854
2977
  var fs5 = __toESM(require("fs"), 1);
2855
- var path6 = __toESM(require("path"), 1);
2978
+ var path7 = __toESM(require("path"), 1);
2856
2979
  function templatePath(rel) {
2857
2980
  const candidates = [
2858
- path6.resolve(__dirname, "../../templates", rel),
2981
+ path7.resolve(__dirname, "../../templates", rel),
2859
2982
  // dist/cli/cli.cjs → ../../templates
2860
- path6.resolve(__dirname, "../../../templates", rel)
2983
+ path7.resolve(__dirname, "../../../templates", rel)
2861
2984
  // src/scan/ai/foo.ts → ../../../templates
2862
2985
  ];
2863
2986
  for (const c of candidates) {
@@ -2877,19 +3000,20 @@ function readTemplate(rel) {
2877
3000
  return fs5.readFileSync(templatePath(rel), "utf8");
2878
3001
  }
2879
3002
 
2880
- // src/scan/ai/providers/claude.ts
3003
+ // src/scanner/scan/ai/providers/claude.ts
2881
3004
  var CLAUDE_FILES = [
2882
3005
  { dest: ".claude/rules/uidex.md", template: "claude/rules.md" },
2883
- { dest: ".claude/commands/uidex/audit.md", template: "claude/audit.md" }
3006
+ { dest: ".claude/commands/uidex/audit.md", template: "claude/audit.md" },
3007
+ { dest: ".claude/commands/uidex/api.md", template: "claude/api.md" }
2884
3008
  ];
2885
3009
  var claudeProvider = {
2886
3010
  id: "claude",
2887
3011
  label: "Claude Code",
2888
- description: "Adds .claude/rules/uidex.md and the /uidex:audit slash command.",
3012
+ description: "Adds .claude/rules/uidex.md, /uidex:audit, and /uidex:api slash commands.",
2889
3013
  async install({ cwd, force }) {
2890
3014
  const changes = [];
2891
3015
  for (const file of CLAUDE_FILES) {
2892
- const dest = path7.join(cwd, file.dest);
3016
+ const dest = path8.join(cwd, file.dest);
2893
3017
  const exists = fs6.existsSync(dest);
2894
3018
  if (exists && !force) {
2895
3019
  changes.push({
@@ -2899,7 +3023,7 @@ var claudeProvider = {
2899
3023
  });
2900
3024
  continue;
2901
3025
  }
2902
- fs6.mkdirSync(path7.dirname(dest), { recursive: true });
3026
+ fs6.mkdirSync(path8.dirname(dest), { recursive: true });
2903
3027
  fs6.writeFileSync(dest, readTemplate(file.template));
2904
3028
  changes.push({
2905
3029
  path: file.dest,
@@ -2911,7 +3035,7 @@ var claudeProvider = {
2911
3035
  async uninstall({ cwd }) {
2912
3036
  const changes = [];
2913
3037
  for (const file of CLAUDE_FILES) {
2914
- const dest = path7.join(cwd, file.dest);
3038
+ const dest = path8.join(cwd, file.dest);
2915
3039
  if (!fs6.existsSync(dest)) {
2916
3040
  changes.push({ path: file.dest, action: "skipped", reason: "absent" });
2917
3041
  continue;
@@ -2919,9 +3043,9 @@ var claudeProvider = {
2919
3043
  fs6.unlinkSync(dest);
2920
3044
  changes.push({ path: file.dest, action: "removed" });
2921
3045
  }
2922
- cleanupEmpty(path7.join(cwd, ".claude/commands/uidex"));
2923
- cleanupEmpty(path7.join(cwd, ".claude/commands"));
2924
- cleanupEmpty(path7.join(cwd, ".claude/rules"));
3046
+ cleanupEmpty(path8.join(cwd, ".claude/commands/uidex"));
3047
+ cleanupEmpty(path8.join(cwd, ".claude/commands"));
3048
+ cleanupEmpty(path8.join(cwd, ".claude/rules"));
2925
3049
  return { changes };
2926
3050
  }
2927
3051
  };
@@ -2933,13 +3057,13 @@ function cleanupEmpty(dir) {
2933
3057
  }
2934
3058
  }
2935
3059
 
2936
- // src/scan/ai/providers/index.ts
3060
+ // src/scanner/scan/ai/providers/index.ts
2937
3061
  var PROVIDERS = [claudeProvider];
2938
3062
  function getProvider(id) {
2939
3063
  return PROVIDERS.find((p2) => p2.id === id);
2940
3064
  }
2941
3065
 
2942
- // src/scan/ai/index.ts
3066
+ // src/scanner/scan/ai/index.ts
2943
3067
  async function runAiCommand(opts) {
2944
3068
  const { cwd, argv } = opts;
2945
3069
  const sub = argv[0];
@@ -3050,8 +3174,8 @@ function err(exitCode, stderr) {
3050
3174
  return { exitCode, stdout: "", stderr };
3051
3175
  }
3052
3176
 
3053
- // src/scan/cli.ts
3054
- function parseFlags2(args) {
3177
+ // src/scanner/cli/parse-args.ts
3178
+ function parseArgs(args) {
3055
3179
  const positional = [];
3056
3180
  const flags = {};
3057
3181
  for (let i = 0; i < args.length; i++) {
@@ -3075,9 +3199,11 @@ function parseFlags2(args) {
3075
3199
  }
3076
3200
  return { positional, flags };
3077
3201
  }
3202
+
3203
+ // src/scanner/scan/cli.ts
3078
3204
  async function run(opts) {
3079
3205
  const cwd = opts.cwd ?? process.cwd();
3080
- const { positional, flags } = parseFlags2(opts.argv);
3206
+ const { positional, flags } = parseArgs(opts.argv);
3081
3207
  const command = positional[0] ?? "help";
3082
3208
  const writer = createWriter();
3083
3209
  try {
@@ -3121,6 +3247,10 @@ function helpText2() {
3121
3247
  " scan [flags] Run the scanner pipeline",
3122
3248
  " scaffold widget <id> Emit a Playwright spec from a widget's acceptance",
3123
3249
  " ai <install|uninstall|providers> Manage AI assistant integrations",
3250
+ " api <METHOD> <PATH> Call the uidex API",
3251
+ " api --list Show available API routes",
3252
+ " api login Authenticate via browser",
3253
+ " api login --token <tok> Store an auth token directly",
3124
3254
  "",
3125
3255
  "Flags:",
3126
3256
  " --check Verify the on-disk gen file matches a fresh scan; exit non-zero on drift (read-only)",
@@ -3132,7 +3262,7 @@ function helpText2() {
3132
3262
  ].join("\n");
3133
3263
  }
3134
3264
  function runInit(cwd, w) {
3135
- const configPath = path8.join(cwd, CONFIG_FILENAME);
3265
+ const configPath = path9.join(cwd, CONFIG_FILENAME);
3136
3266
  if (fs7.existsSync(configPath)) {
3137
3267
  w.err(`.uidex.json already exists at ${configPath}`);
3138
3268
  return w.result(1);
@@ -3144,7 +3274,7 @@ function runInit(cwd, w) {
3144
3274
  };
3145
3275
  fs7.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
3146
3276
  w.out(`Created ${configPath}`);
3147
- const gitignorePath = path8.join(cwd, ".gitignore");
3277
+ const gitignorePath = path9.join(cwd, ".gitignore");
3148
3278
  const entry = "*.gen.ts";
3149
3279
  if (fs7.existsSync(gitignorePath)) {
3150
3280
  const existing = fs7.readFileSync(gitignorePath, "utf8");
@@ -3222,7 +3352,7 @@ function runScaffold(cwd, args, flags, w) {
3222
3352
  for (const r of results) {
3223
3353
  const widget = r.registry.get("widget", id);
3224
3354
  if (!widget) continue;
3225
- const outDir = path8.resolve(r.configDir, "e2e");
3355
+ const outDir = path9.resolve(r.configDir, "e2e");
3226
3356
  const result = scaffoldWidgetSpec({
3227
3357
  registry: r.registry,
3228
3358
  widgetId: id,