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
@@ -1,9 +1,13 @@
1
- // src/scan/discover.ts
1
+ // src/scanner/scan/discover.ts
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
4
 
5
- // src/scan/config.ts
5
+ // src/scanner/scan/config.ts
6
6
  var DEFAULT_TYPE_MODE = "strict";
7
+ var WELL_KNOWN_FILES = {
8
+ page: "uidex.page.ts",
9
+ feature: "uidex.feature.ts"
10
+ };
7
11
  var ConfigError = class extends Error {
8
12
  constructor(message) {
9
13
  super(message);
@@ -41,14 +45,14 @@ var ALLOWED_AUDIT_KEYS = /* @__PURE__ */ new Set(["scopeLeak", "coverage", "acce
41
45
  function fail(msg) {
42
46
  throw new ConfigError(`Invalid .uidex.json: ${msg}`);
43
47
  }
44
- function assertObject(value, path9) {
48
+ function assertObject(value, path10) {
45
49
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
46
- fail(`${path9} must be an object`);
50
+ fail(`${path10} must be an object`);
47
51
  }
48
52
  }
49
- function assertStringArray(value, path9) {
53
+ function assertStringArray(value, path10) {
50
54
  if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
51
- fail(`${path9} must be a string[]`);
55
+ fail(`${path10} must be a string[]`);
52
56
  }
53
57
  }
54
58
  function validateConfig(raw) {
@@ -162,7 +166,7 @@ var DEFAULT_CONVENTIONS = {
162
166
  regions: "landmarks"
163
167
  };
164
168
 
165
- // src/scan/discover.ts
169
+ // src/scanner/scan/discover.ts
166
170
  var CONFIG_FILENAME = ".uidex.json";
167
171
  var SKIP_DIRS = /* @__PURE__ */ new Set([
168
172
  "node_modules",
@@ -221,7 +225,7 @@ function discover(options = {}) {
221
225
  return results.sort((a, b) => a.configPath.localeCompare(b.configPath));
222
226
  }
223
227
 
224
- // src/scan/walk.ts
228
+ // src/scanner/scan/walk.ts
225
229
  import * as fs2 from "fs";
226
230
  import * as path2 from "path";
227
231
  var DEFAULT_INCLUDES = ["**/*.{ts,tsx,js,jsx,mjs,cjs}"];
@@ -346,7 +350,7 @@ function* walkDir(root, dir) {
346
350
  }
347
351
  }
348
352
 
349
- // src/scan/extract-uidex-export.ts
353
+ // src/scanner/scan/extract-uidex-export.ts
350
354
  var KIND_DISCRIMINATORS = [
351
355
  "page",
352
356
  "feature",
@@ -364,7 +368,13 @@ var ALLOWED_FIELDS = {
364
368
  "acceptance",
365
369
  "description"
366
370
  ]),
367
- feature: /* @__PURE__ */ new Set(["feature", "name", "acceptance", "description"]),
371
+ feature: /* @__PURE__ */ new Set([
372
+ "feature",
373
+ "name",
374
+ "features",
375
+ "acceptance",
376
+ "description"
377
+ ]),
368
378
  primitive: /* @__PURE__ */ new Set(["primitive", "name", "description"]),
369
379
  widget: /* @__PURE__ */ new Set(["widget", "name", "acceptance", "description"]),
370
380
  flow: /* @__PURE__ */ new Set(["flow", "notFlow", "name", "description"])
@@ -1100,7 +1110,7 @@ function buildMetadata(value, file, headerPos, diagnostics) {
1100
1110
  line: pos.line
1101
1111
  });
1102
1112
  }
1103
- const features = kind === "page" ? readStringArrayField(byKey, "features") : void 0;
1113
+ const features = kind === "page" || kind === "feature" ? readStringArrayField(byKey, "features") : void 0;
1104
1114
  const widgets = kind === "page" ? readStringArrayField(byKey, "widgets") : void 0;
1105
1115
  const notFlow = kind === "flow" && discriminator === "notFlow" ? true : void 0;
1106
1116
  const metadata = {
@@ -1195,7 +1205,7 @@ function posAt(content, offset) {
1195
1205
  return { offset, line, column: offset - lineStart + 1 };
1196
1206
  }
1197
1207
 
1198
- // src/scan/jsx-ancestry.ts
1208
+ // src/scanner/scan/jsx-ancestry.ts
1199
1209
  var DATA_ATTR_RE = /\bdata-uidex(?:-(region|widget|primitive))?\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
1200
1210
  function parseDataAttrs(tagSource) {
1201
1211
  if (!tagSource.includes("data-uidex")) return [];
@@ -1384,7 +1394,7 @@ function findTagEnd(content, start) {
1384
1394
  return -1;
1385
1395
  }
1386
1396
 
1387
- // src/scan/extract.ts
1397
+ // src/scanner/scan/extract.ts
1388
1398
  var JSDOC_BLOCK = /\/\*\*([\s\S]*?)\*\//g;
1389
1399
  function lineAt(content, index) {
1390
1400
  let line = 1;
@@ -1487,10 +1497,10 @@ function extractOne(file) {
1487
1497
  return annotations;
1488
1498
  }
1489
1499
 
1490
- // src/scan/resolve.ts
1500
+ // src/scanner/scan/resolve.ts
1491
1501
  import * as path3 from "path";
1492
1502
 
1493
- // src/entities/types.ts
1503
+ // src/shared/entities/types.ts
1494
1504
  var ENTITY_KINDS = [
1495
1505
  "route",
1496
1506
  "page",
@@ -1523,7 +1533,7 @@ function assertEntityKind(kind) {
1523
1533
  if (!KIND_SET.has(kind)) throw new UnknownEntityKindError(kind);
1524
1534
  }
1525
1535
 
1526
- // src/entities/registry.ts
1536
+ // src/shared/entities/registry.ts
1527
1537
  function emptyStore() {
1528
1538
  return {
1529
1539
  route: /* @__PURE__ */ new Map(),
@@ -1605,10 +1615,33 @@ function createRegistry() {
1605
1615
  return ids.has(entity.id);
1606
1616
  });
1607
1617
  };
1608
- return { add, get, list, query, byScope, touchedBy };
1618
+ const reports = /* @__PURE__ */ new Map();
1619
+ const reportsCbs = /* @__PURE__ */ new Set();
1620
+ const setReports = (kind, id, records) => {
1621
+ reports.set(`${kind}:${id}`, records);
1622
+ for (const cb of reportsCbs) cb();
1623
+ };
1624
+ const getReports = (kind, id) => reports.get(`${kind}:${id}`) ?? [];
1625
+ const listReportKeys = () => Array.from(reports.keys());
1626
+ const onReportsChange = (cb) => {
1627
+ reportsCbs.add(cb);
1628
+ return () => reportsCbs.delete(cb);
1629
+ };
1630
+ return {
1631
+ add,
1632
+ get,
1633
+ list,
1634
+ query,
1635
+ byScope,
1636
+ touchedBy,
1637
+ setReports,
1638
+ getReports,
1639
+ listReportKeys,
1640
+ onReportsChange
1641
+ };
1609
1642
  }
1610
1643
 
1611
- // src/scan/routes.ts
1644
+ // src/scanner/scan/routes.ts
1612
1645
  var PAGE_BASENAME = /^page\.(tsx|ts|jsx|js|mjs|cjs)$/;
1613
1646
  var PAGES_ROUTER_BASENAME = /\.(tsx|ts|jsx|js|mjs|cjs)$/;
1614
1647
  var ROUTE_BASENAME = /^route\.(tsx|ts|jsx|js|mjs|cjs)$/;
@@ -1674,7 +1707,7 @@ function pathToId(routePath) {
1674
1707
  return routePath.replace(/^\/+/, "").replace(/\[\.{3}([^\]]+)\]/g, "$1").replace(/\[([^\]]+)\]/g, "$1").replace(/\//g, "-").replace(/[^a-zA-Z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
1675
1708
  }
1676
1709
 
1677
- // src/scan/resolve.ts
1710
+ // src/scanner/scan/resolve.ts
1678
1711
  var DOM_ATTR_KINDS = /* @__PURE__ */ new Set([
1679
1712
  "element",
1680
1713
  "region",
@@ -1769,7 +1802,24 @@ function resolve2(ctx) {
1769
1802
  const routes = conventions.pages === "auto" ? detectRoutes(ctx.extracted.map((e) => e.file)) : [];
1770
1803
  const handledPageFiles = /* @__PURE__ */ new Set();
1771
1804
  for (const route of routes) {
1772
- const exp = exportFor(route.file, "page");
1805
+ const routeDir = path3.posix.dirname(route.file);
1806
+ const wellKnownPath = `${routeDir}/${WELL_KNOWN_FILES.page}`;
1807
+ const wellKnownExp = exportFor(wellKnownPath, "page");
1808
+ const routeExp = exportFor(route.file, "page");
1809
+ const exp = wellKnownExp ?? routeExp;
1810
+ const locFile = wellKnownExp ? wellKnownPath : route.file;
1811
+ if (wellKnownExp) handledPageFiles.add(wellKnownPath);
1812
+ handledPageFiles.add(route.file);
1813
+ if (wellKnownExp && routeExp) {
1814
+ diagnostics.push({
1815
+ code: "competing-uidex-export",
1816
+ severity: "warning",
1817
+ message: `Page metadata declared in both ${wellKnownPath} and ${route.file}; ${wellKnownPath} takes precedence.`,
1818
+ file: route.file,
1819
+ line: routeExp.loc.line,
1820
+ hint: `Remove the export from ${route.file} or delete ${wellKnownPath}.`
1821
+ });
1822
+ }
1773
1823
  if (exp && exp.id === false) continue;
1774
1824
  const effectiveId = exp && typeof exp.id === "string" ? exp.id : route.id;
1775
1825
  const meta = exp ? buildMetaFromExport(exp) : void 0;
@@ -1777,11 +1827,10 @@ function resolve2(ctx) {
1777
1827
  const page = {
1778
1828
  kind: "page",
1779
1829
  id: effectiveId,
1780
- loc: { file: route.file, line: exp?.loc.line },
1830
+ loc: { file: locFile, line: exp?.loc.line },
1781
1831
  ...meta ? { meta } : {}
1782
1832
  };
1783
1833
  registry.add(page);
1784
- handledPageFiles.add(route.file);
1785
1834
  }
1786
1835
  for (const ef of ctx.extracted) {
1787
1836
  const exp = exportFor(ef.file.displayPath, "page");
@@ -1797,7 +1846,8 @@ function resolve2(ctx) {
1797
1846
  }
1798
1847
  const featureGlob = typeof conventions.features === "string" ? conventions.features : null;
1799
1848
  const conventionalFeatureDirs = /* @__PURE__ */ new Set();
1800
- const featureExportsByDir = /* @__PURE__ */ new Map();
1849
+ const featureExportFilesByDir = /* @__PURE__ */ new Map();
1850
+ const wellKnownFeatureFileByDir = /* @__PURE__ */ new Map();
1801
1851
  const suppressedFeatureDirs = /* @__PURE__ */ new Set();
1802
1852
  if (featureGlob) {
1803
1853
  const re = globToRegExp(featureGlob + "/**");
@@ -1806,16 +1856,43 @@ function resolve2(ctx) {
1806
1856
  const dir = extractFeatureDir(ef.file.displayPath, featureGlob);
1807
1857
  if (!dir) continue;
1808
1858
  conventionalFeatureDirs.add(dir);
1859
+ const isWellKnown = path3.posix.basename(ef.file.displayPath) === WELL_KNOWN_FILES.feature;
1860
+ if (isWellKnown) wellKnownFeatureFileByDir.set(dir, ef.file.displayPath);
1809
1861
  const exp = exportFor(ef.file.displayPath, "feature");
1810
1862
  if (exp) {
1811
1863
  if (exp.id === false) suppressedFeatureDirs.add(dir);
1812
- else if (!featureExportsByDir.has(dir))
1813
- featureExportsByDir.set(dir, exp);
1864
+ else {
1865
+ let arr = featureExportFilesByDir.get(dir);
1866
+ if (!arr) {
1867
+ arr = [];
1868
+ featureExportFilesByDir.set(dir, arr);
1869
+ }
1870
+ arr.push({ file: ef.file.displayPath, exp });
1871
+ }
1814
1872
  }
1815
1873
  }
1816
1874
  for (const dir of conventionalFeatureDirs) {
1817
1875
  if (suppressedFeatureDirs.has(dir)) continue;
1818
- const exp = featureExportsByDir.get(dir);
1876
+ const allExports = featureExportFilesByDir.get(dir) ?? [];
1877
+ const wellKnownPath = wellKnownFeatureFileByDir.get(dir);
1878
+ const wellKnownEntry = wellKnownPath ? allExports.find((e) => e.file === wellKnownPath) : void 0;
1879
+ let exp;
1880
+ if (wellKnownEntry) {
1881
+ exp = wellKnownEntry.exp;
1882
+ for (const other of allExports) {
1883
+ if (other.file === wellKnownEntry.file) continue;
1884
+ diagnostics.push({
1885
+ code: "competing-uidex-export",
1886
+ severity: "warning",
1887
+ message: `Feature metadata declared in both ${wellKnownEntry.file} and ${other.file}; ${wellKnownEntry.file} takes precedence.`,
1888
+ file: other.file,
1889
+ line: other.exp.loc.line,
1890
+ hint: `Remove the export from ${other.file} or delete ${wellKnownEntry.file}.`
1891
+ });
1892
+ }
1893
+ } else if (allExports.length > 0) {
1894
+ exp = allExports[0].exp;
1895
+ }
1819
1896
  const id = exp && typeof exp.id === "string" ? exp.id : path3.posix.basename(dir);
1820
1897
  const meta = exp ? buildMetaFromExport(exp) : void 0;
1821
1898
  const feature = {
@@ -1948,11 +2025,12 @@ function resolve2(ctx) {
1948
2025
  exp.id,
1949
2026
  buildMetaFromExport(exp)
1950
2027
  );
2028
+ const scope = computeScope(file);
1951
2029
  const primitive = {
1952
2030
  kind: "primitive",
1953
2031
  id: exp.id,
1954
2032
  loc: { file, line: exp.loc.line },
1955
- scopes: [computeScope(file)],
2033
+ ...scope ? { scopes: [scope] } : {},
1956
2034
  ...meta ? { meta } : {}
1957
2035
  };
1958
2036
  registry.add(primitive);
@@ -1964,11 +2042,12 @@ function resolve2(ctx) {
1964
2042
  if (domPrimitives.length > 0) {
1965
2043
  for (const p2 of domPrimitives) {
1966
2044
  const meta = metaWithComposes("primitive", p2.id);
2045
+ const domScope = computeScope(p2.file);
1967
2046
  const primitive = {
1968
2047
  kind: "primitive",
1969
2048
  id: p2.id,
1970
2049
  loc: { file: p2.file, line: p2.line },
1971
- scopes: [computeScope(p2.file)],
2050
+ ...domScope ? { scopes: [domScope] } : {},
1972
2051
  ...meta ? { meta } : {}
1973
2052
  };
1974
2053
  registry.add(primitive);
@@ -1978,13 +2057,13 @@ function resolve2(ctx) {
1978
2057
  if (primitiveConventions && fileMatchesAny(file, primitiveConventions)) {
1979
2058
  const name = kebab(baseName(file));
1980
2059
  if (!name) continue;
1981
- const scope = computeScope(file);
2060
+ const convScope = computeScope(file);
1982
2061
  const meta = metaWithComposes("primitive", name);
1983
2062
  const primitive = {
1984
2063
  kind: "primitive",
1985
2064
  id: name,
1986
2065
  loc: { file },
1987
- scopes: [scope],
2066
+ ...convScope ? { scopes: [convScope] } : {},
1988
2067
  ...meta ? { meta } : {}
1989
2068
  };
1990
2069
  registry.add(primitive);
@@ -2014,7 +2093,8 @@ function resolve2(ctx) {
2014
2093
  kind: "flow",
2015
2094
  id: flowExport.id,
2016
2095
  loc: base.loc,
2017
- touches: base.touches
2096
+ touches: base.touches,
2097
+ steps: base.steps
2018
2098
  };
2019
2099
  registry.add(flow);
2020
2100
  } else {
@@ -2059,7 +2139,7 @@ function computeScope(displayPath) {
2059
2139
  if (pagesIdx !== -1 && parts[pagesIdx + 1]) {
2060
2140
  return `page:${parts[pagesIdx + 1]}`;
2061
2141
  }
2062
- return "global";
2142
+ return null;
2063
2143
  }
2064
2144
  function extractFlowsFromSource(file) {
2065
2145
  const flows = [];
@@ -2093,17 +2173,18 @@ function extractFlowsFromSource(file) {
2093
2173
  kind: "flow",
2094
2174
  id,
2095
2175
  loc: { file: file.displayPath, line },
2096
- touches: dedupe(touches.map((t) => t.id))
2176
+ touches: dedupe(touches.map((t) => t.id)),
2177
+ steps: touches.filter((t) => t.action).map((t) => ({ entityId: t.id, action: t.action }))
2097
2178
  });
2098
2179
  }
2099
2180
  return flows;
2100
2181
  }
2101
2182
  function captureUidexIds(body) {
2102
2183
  const out2 = [];
2103
- const re = /uidex\(\s*(?:'([^']+)'|"([^"]+)"|`([^`$]+)`)\s*\)/g;
2184
+ const re = /uidex\(\s*(?:'([^']+)'|"([^"]+)"|`([^`$]+)`)\s*\)(?:\.(\w+)\s*\()?/g;
2104
2185
  let m;
2105
2186
  while ((m = re.exec(body)) !== null) {
2106
- out2.push({ id: m[1] || m[2] || m[3] });
2187
+ out2.push({ id: m[1] || m[2] || m[3], action: m[4] });
2107
2188
  }
2108
2189
  return out2;
2109
2190
  }
@@ -2111,7 +2192,8 @@ function dedupe(arr) {
2111
2192
  return Array.from(new Set(arr));
2112
2193
  }
2113
2194
 
2114
- // src/scan/audit.ts
2195
+ // src/scanner/scan/audit.ts
2196
+ import * as path4 from "path";
2115
2197
  var MARKER_FILENAMES = ["UIDEX_PAGE.md", "UIDEX_FEATURE.md"];
2116
2198
  function audit(opts) {
2117
2199
  const diagnostics = [];
@@ -2213,6 +2295,32 @@ function audit(opts) {
2213
2295
  }
2214
2296
  }
2215
2297
  }
2298
+ if (lint) {
2299
+ const scannedPaths = new Set(files.map((f) => f.displayPath));
2300
+ for (const ef of extracted) {
2301
+ if (!ef.metadata) continue;
2302
+ for (const m of ef.metadata) {
2303
+ if (m.kind !== "page" && m.kind !== "feature") continue;
2304
+ if (typeof m.id !== "string") continue;
2305
+ const filePath = ef.file.displayPath;
2306
+ const wellKnownName = WELL_KNOWN_FILES[m.kind];
2307
+ if (path4.posix.basename(filePath) === wellKnownName) continue;
2308
+ const dir = path4.posix.dirname(filePath);
2309
+ const wellKnownPath = dir === "." ? wellKnownName : `${dir}/${wellKnownName}`;
2310
+ if (scannedPaths.has(wellKnownPath)) continue;
2311
+ const kindLabel = m.kind === "page" ? "Page" : "Feature";
2312
+ diagnostics.push({
2313
+ code: "prefer-well-known-file",
2314
+ severity: "info",
2315
+ message: `${kindLabel} "${m.id}" metadata lives on ${filePath}; prefer ${wellKnownPath}`,
2316
+ file: filePath,
2317
+ line: m.loc.line,
2318
+ entity: { kind: m.kind, id: m.id },
2319
+ hint: `Move the \`export const uidex\` block to ${wellKnownPath} and remove it from ${filePath}.`
2320
+ });
2321
+ }
2322
+ }
2323
+ }
2216
2324
  if (lint) {
2217
2325
  for (const f of files) {
2218
2326
  const lines = f.content.split("\n");
@@ -2239,6 +2347,15 @@ function audit(opts) {
2239
2347
  const primitives = registry.list("primitive");
2240
2348
  const byName = /* @__PURE__ */ new Map();
2241
2349
  for (const p2 of primitives) byName.set(p2.id, p2);
2350
+ const declaredFeatures = /* @__PURE__ */ new Map();
2351
+ for (const ef of extracted) {
2352
+ if (!ef.metadata) continue;
2353
+ for (const m of ef.metadata) {
2354
+ if (m.features && m.features.length > 0) {
2355
+ declaredFeatures.set(ef.file.displayPath, new Set(m.features));
2356
+ }
2357
+ }
2358
+ }
2242
2359
  for (const f of files) {
2243
2360
  const importRe = /import\s+(?:[^'"]+)\s+from\s+['"]([^'"]+)['"]/g;
2244
2361
  let m;
@@ -2249,18 +2366,23 @@ function audit(opts) {
2249
2366
  baseName2.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
2250
2367
  );
2251
2368
  if (!primitive) continue;
2252
- const scope = primitive.scopes?.[0] ?? "global";
2253
- if (scope === "global") continue;
2369
+ const scope = primitive.scopes?.[0];
2370
+ if (!scope) continue;
2254
2371
  const [kind, id] = scope.split(":");
2255
2372
  const importerSegments = f.displayPath.split("/");
2256
- if (!importerSegments.includes(id) || !importerSegments.includes(kind + "s")) {
2257
- diagnostics.push({
2258
- code: "scope-leak",
2259
- severity: "warning",
2260
- message: `Primitive "${primitive.id}" is scoped to ${scope} but is imported from ${f.displayPath}`,
2261
- file: f.displayPath
2262
- });
2373
+ if (importerSegments.includes(id) && importerSegments.includes(kind + "s")) {
2374
+ continue;
2375
+ }
2376
+ if (kind === "feature" && importerSegments.includes(id)) continue;
2377
+ if (kind === "feature" && declaredFeatures.get(f.displayPath)?.has(id)) {
2378
+ continue;
2263
2379
  }
2380
+ diagnostics.push({
2381
+ code: "scope-leak",
2382
+ severity: "warning",
2383
+ message: `Primitive "${primitive.id}" is scoped to ${scope} but is imported from ${f.displayPath}`,
2384
+ file: f.displayPath
2385
+ });
2264
2386
  }
2265
2387
  }
2266
2388
  }
@@ -2434,7 +2556,7 @@ function stableReplacer(_key, value) {
2434
2556
  return value;
2435
2557
  }
2436
2558
 
2437
- // src/scan/emit.ts
2559
+ // src/scanner/scan/emit.ts
2438
2560
  function sortById(arr) {
2439
2561
  return [...arr].sort((a, b) => a.id.localeCompare(b.id));
2440
2562
  }
@@ -2558,6 +2680,7 @@ function emit(opts) {
2558
2680
  lines.push(" export interface Feature {");
2559
2681
  lines.push(" feature: FeatureId | false");
2560
2682
  lines.push(" name?: string");
2683
+ lines.push(" features?: readonly FeatureId[]");
2561
2684
  lines.push(" acceptance?: readonly string[]");
2562
2685
  lines.push(" description?: string");
2563
2686
  lines.push(" }");
@@ -2614,7 +2737,7 @@ function emit(opts) {
2614
2737
  return lines.join("\n");
2615
2738
  }
2616
2739
 
2617
- // src/scan/git.ts
2740
+ // src/scanner/scan/git.ts
2618
2741
  import { execSync } from "child_process";
2619
2742
  function runGit(args, cwd) {
2620
2743
  try {
@@ -2646,9 +2769,9 @@ function parseGitHubRef(ref) {
2646
2769
  return m ? m[1] : null;
2647
2770
  }
2648
2771
 
2649
- // src/scan/scaffold.ts
2772
+ // src/scanner/scan/scaffold.ts
2650
2773
  import * as fs3 from "fs";
2651
- import * as path4 from "path";
2774
+ import * as path5 from "path";
2652
2775
  function scaffoldWidgetSpec(opts) {
2653
2776
  const {
2654
2777
  registry,
@@ -2663,7 +2786,7 @@ function scaffoldWidgetSpec(opts) {
2663
2786
  }
2664
2787
  const criteria = widget.meta?.acceptance ?? [];
2665
2788
  const filename = `widget-${widgetId}.spec.ts`;
2666
- const outputPath = path4.resolve(outDir, filename);
2789
+ const outputPath = path5.resolve(outDir, filename);
2667
2790
  if (fs3.existsSync(outputPath) && !force) {
2668
2791
  return {
2669
2792
  outputPath,
@@ -2677,7 +2800,7 @@ function scaffoldWidgetSpec(opts) {
2677
2800
  criteria,
2678
2801
  fixtureImport
2679
2802
  });
2680
- fs3.mkdirSync(path4.dirname(outputPath), { recursive: true });
2803
+ fs3.mkdirSync(path5.dirname(outputPath), { recursive: true });
2681
2804
  fs3.writeFileSync(outputPath, content, "utf8");
2682
2805
  return { outputPath, written: true, skipped: false };
2683
2806
  }
@@ -2709,9 +2832,9 @@ function renderSpec(args) {
2709
2832
  return lines.join("\n");
2710
2833
  }
2711
2834
 
2712
- // src/scan/pipeline.ts
2835
+ // src/scanner/scan/pipeline.ts
2713
2836
  import * as fs4 from "fs";
2714
- import * as path5 from "path";
2837
+ import * as path6 from "path";
2715
2838
  function runScan(opts = {}) {
2716
2839
  const cwd = opts.cwd ?? process.cwd();
2717
2840
  const configs = opts.configs ?? discover({ cwd });
@@ -2743,7 +2866,7 @@ function runOne(dc, opts) {
2743
2866
  gitContext,
2744
2867
  typeMode: config.typeMode
2745
2868
  });
2746
- const outputPath = path5.resolve(configDir, config.output);
2869
+ const outputPath = path6.resolve(configDir, config.output);
2747
2870
  const outputRel = config.output;
2748
2871
  let existingOnDisk = null;
2749
2872
  if (opts.check) {
@@ -2779,29 +2902,29 @@ function runOne(dc, opts) {
2779
2902
  };
2780
2903
  }
2781
2904
  function writeScanResult(result) {
2782
- fs4.mkdirSync(path5.dirname(result.outputPath), { recursive: true });
2905
+ fs4.mkdirSync(path6.dirname(result.outputPath), { recursive: true });
2783
2906
  fs4.writeFileSync(result.outputPath, result.generated, "utf8");
2784
2907
  }
2785
2908
 
2786
- // src/scan/cli.ts
2909
+ // src/scanner/scan/cli.ts
2787
2910
  import * as fs7 from "fs";
2788
- import * as path8 from "path";
2911
+ import * as path9 from "path";
2789
2912
 
2790
- // src/scan/ai/index.ts
2913
+ // src/scanner/scan/ai/index.ts
2791
2914
  import * as p from "@clack/prompts";
2792
2915
 
2793
- // src/scan/ai/providers/claude.ts
2916
+ // src/scanner/scan/ai/providers/claude.ts
2794
2917
  import * as fs6 from "fs";
2795
- import * as path7 from "path";
2918
+ import * as path8 from "path";
2796
2919
 
2797
- // src/scan/ai/templates.ts
2920
+ // src/scanner/scan/ai/templates.ts
2798
2921
  import * as fs5 from "fs";
2799
- import * as path6 from "path";
2922
+ import * as path7 from "path";
2800
2923
  function templatePath(rel) {
2801
2924
  const candidates = [
2802
- path6.resolve(__dirname, "../../templates", rel),
2925
+ path7.resolve(__dirname, "../../templates", rel),
2803
2926
  // dist/cli/cli.cjs → ../../templates
2804
- path6.resolve(__dirname, "../../../templates", rel)
2927
+ path7.resolve(__dirname, "../../../templates", rel)
2805
2928
  // src/scan/ai/foo.ts → ../../../templates
2806
2929
  ];
2807
2930
  for (const c of candidates) {
@@ -2821,19 +2944,20 @@ function readTemplate(rel) {
2821
2944
  return fs5.readFileSync(templatePath(rel), "utf8");
2822
2945
  }
2823
2946
 
2824
- // src/scan/ai/providers/claude.ts
2947
+ // src/scanner/scan/ai/providers/claude.ts
2825
2948
  var CLAUDE_FILES = [
2826
2949
  { dest: ".claude/rules/uidex.md", template: "claude/rules.md" },
2827
- { dest: ".claude/commands/uidex/audit.md", template: "claude/audit.md" }
2950
+ { dest: ".claude/commands/uidex/audit.md", template: "claude/audit.md" },
2951
+ { dest: ".claude/commands/uidex/api.md", template: "claude/api.md" }
2828
2952
  ];
2829
2953
  var claudeProvider = {
2830
2954
  id: "claude",
2831
2955
  label: "Claude Code",
2832
- description: "Adds .claude/rules/uidex.md and the /uidex:audit slash command.",
2956
+ description: "Adds .claude/rules/uidex.md, /uidex:audit, and /uidex:api slash commands.",
2833
2957
  async install({ cwd, force }) {
2834
2958
  const changes = [];
2835
2959
  for (const file of CLAUDE_FILES) {
2836
- const dest = path7.join(cwd, file.dest);
2960
+ const dest = path8.join(cwd, file.dest);
2837
2961
  const exists = fs6.existsSync(dest);
2838
2962
  if (exists && !force) {
2839
2963
  changes.push({
@@ -2843,7 +2967,7 @@ var claudeProvider = {
2843
2967
  });
2844
2968
  continue;
2845
2969
  }
2846
- fs6.mkdirSync(path7.dirname(dest), { recursive: true });
2970
+ fs6.mkdirSync(path8.dirname(dest), { recursive: true });
2847
2971
  fs6.writeFileSync(dest, readTemplate(file.template));
2848
2972
  changes.push({
2849
2973
  path: file.dest,
@@ -2855,7 +2979,7 @@ var claudeProvider = {
2855
2979
  async uninstall({ cwd }) {
2856
2980
  const changes = [];
2857
2981
  for (const file of CLAUDE_FILES) {
2858
- const dest = path7.join(cwd, file.dest);
2982
+ const dest = path8.join(cwd, file.dest);
2859
2983
  if (!fs6.existsSync(dest)) {
2860
2984
  changes.push({ path: file.dest, action: "skipped", reason: "absent" });
2861
2985
  continue;
@@ -2863,9 +2987,9 @@ var claudeProvider = {
2863
2987
  fs6.unlinkSync(dest);
2864
2988
  changes.push({ path: file.dest, action: "removed" });
2865
2989
  }
2866
- cleanupEmpty(path7.join(cwd, ".claude/commands/uidex"));
2867
- cleanupEmpty(path7.join(cwd, ".claude/commands"));
2868
- cleanupEmpty(path7.join(cwd, ".claude/rules"));
2990
+ cleanupEmpty(path8.join(cwd, ".claude/commands/uidex"));
2991
+ cleanupEmpty(path8.join(cwd, ".claude/commands"));
2992
+ cleanupEmpty(path8.join(cwd, ".claude/rules"));
2869
2993
  return { changes };
2870
2994
  }
2871
2995
  };
@@ -2877,13 +3001,13 @@ function cleanupEmpty(dir) {
2877
3001
  }
2878
3002
  }
2879
3003
 
2880
- // src/scan/ai/providers/index.ts
3004
+ // src/scanner/scan/ai/providers/index.ts
2881
3005
  var PROVIDERS = [claudeProvider];
2882
3006
  function getProvider(id) {
2883
3007
  return PROVIDERS.find((p2) => p2.id === id);
2884
3008
  }
2885
3009
 
2886
- // src/scan/ai/index.ts
3010
+ // src/scanner/scan/ai/index.ts
2887
3011
  async function runAiCommand(opts) {
2888
3012
  const { cwd, argv } = opts;
2889
3013
  const sub = argv[0];
@@ -2994,8 +3118,8 @@ function err(exitCode, stderr) {
2994
3118
  return { exitCode, stdout: "", stderr };
2995
3119
  }
2996
3120
 
2997
- // src/scan/cli.ts
2998
- function parseFlags2(args) {
3121
+ // src/scanner/cli/parse-args.ts
3122
+ function parseArgs(args) {
2999
3123
  const positional = [];
3000
3124
  const flags = {};
3001
3125
  for (let i = 0; i < args.length; i++) {
@@ -3019,9 +3143,11 @@ function parseFlags2(args) {
3019
3143
  }
3020
3144
  return { positional, flags };
3021
3145
  }
3146
+
3147
+ // src/scanner/scan/cli.ts
3022
3148
  async function run(opts) {
3023
3149
  const cwd = opts.cwd ?? process.cwd();
3024
- const { positional, flags } = parseFlags2(opts.argv);
3150
+ const { positional, flags } = parseArgs(opts.argv);
3025
3151
  const command = positional[0] ?? "help";
3026
3152
  const writer = createWriter();
3027
3153
  try {
@@ -3065,6 +3191,10 @@ function helpText2() {
3065
3191
  " scan [flags] Run the scanner pipeline",
3066
3192
  " scaffold widget <id> Emit a Playwright spec from a widget's acceptance",
3067
3193
  " ai <install|uninstall|providers> Manage AI assistant integrations",
3194
+ " api <METHOD> <PATH> Call the uidex API",
3195
+ " api --list Show available API routes",
3196
+ " api login Authenticate via browser",
3197
+ " api login --token <tok> Store an auth token directly",
3068
3198
  "",
3069
3199
  "Flags:",
3070
3200
  " --check Verify the on-disk gen file matches a fresh scan; exit non-zero on drift (read-only)",
@@ -3076,7 +3206,7 @@ function helpText2() {
3076
3206
  ].join("\n");
3077
3207
  }
3078
3208
  function runInit(cwd, w) {
3079
- const configPath = path8.join(cwd, CONFIG_FILENAME);
3209
+ const configPath = path9.join(cwd, CONFIG_FILENAME);
3080
3210
  if (fs7.existsSync(configPath)) {
3081
3211
  w.err(`.uidex.json already exists at ${configPath}`);
3082
3212
  return w.result(1);
@@ -3088,7 +3218,7 @@ function runInit(cwd, w) {
3088
3218
  };
3089
3219
  fs7.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
3090
3220
  w.out(`Created ${configPath}`);
3091
- const gitignorePath = path8.join(cwd, ".gitignore");
3221
+ const gitignorePath = path9.join(cwd, ".gitignore");
3092
3222
  const entry = "*.gen.ts";
3093
3223
  if (fs7.existsSync(gitignorePath)) {
3094
3224
  const existing = fs7.readFileSync(gitignorePath, "utf8");
@@ -3166,7 +3296,7 @@ function runScaffold(cwd, args, flags, w) {
3166
3296
  for (const r of results) {
3167
3297
  const widget = r.registry.get("widget", id);
3168
3298
  if (!widget) continue;
3169
- const outDir = path8.resolve(r.configDir, "e2e");
3299
+ const outDir = path9.resolve(r.configDir, "e2e");
3170
3300
  const result = scaffoldWidgetSpec({
3171
3301
  registry: r.registry,
3172
3302
  widgetId: id,