uidex 0.3.0 → 0.4.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.
@@ -60,6 +60,10 @@ var path = __toESM(require("path"), 1);
60
60
 
61
61
  // src/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) {
@@ -1825,7 +1829,24 @@ function resolve2(ctx) {
1825
1829
  const routes = conventions.pages === "auto" ? detectRoutes(ctx.extracted.map((e) => e.file)) : [];
1826
1830
  const handledPageFiles = /* @__PURE__ */ new Set();
1827
1831
  for (const route of routes) {
1828
- const exp = exportFor(route.file, "page");
1832
+ const routeDir = path3.posix.dirname(route.file);
1833
+ const wellKnownPath = `${routeDir}/${WELL_KNOWN_FILES.page}`;
1834
+ const wellKnownExp = exportFor(wellKnownPath, "page");
1835
+ const routeExp = exportFor(route.file, "page");
1836
+ const exp = wellKnownExp ?? routeExp;
1837
+ const locFile = wellKnownExp ? wellKnownPath : route.file;
1838
+ if (wellKnownExp) handledPageFiles.add(wellKnownPath);
1839
+ handledPageFiles.add(route.file);
1840
+ if (wellKnownExp && routeExp) {
1841
+ diagnostics.push({
1842
+ code: "competing-uidex-export",
1843
+ severity: "warning",
1844
+ message: `Page metadata declared in both ${wellKnownPath} and ${route.file}; ${wellKnownPath} takes precedence.`,
1845
+ file: route.file,
1846
+ line: routeExp.loc.line,
1847
+ hint: `Remove the export from ${route.file} or delete ${wellKnownPath}.`
1848
+ });
1849
+ }
1829
1850
  if (exp && exp.id === false) continue;
1830
1851
  const effectiveId = exp && typeof exp.id === "string" ? exp.id : route.id;
1831
1852
  const meta = exp ? buildMetaFromExport(exp) : void 0;
@@ -1833,11 +1854,10 @@ function resolve2(ctx) {
1833
1854
  const page = {
1834
1855
  kind: "page",
1835
1856
  id: effectiveId,
1836
- loc: { file: route.file, line: exp?.loc.line },
1857
+ loc: { file: locFile, line: exp?.loc.line },
1837
1858
  ...meta ? { meta } : {}
1838
1859
  };
1839
1860
  registry.add(page);
1840
- handledPageFiles.add(route.file);
1841
1861
  }
1842
1862
  for (const ef of ctx.extracted) {
1843
1863
  const exp = exportFor(ef.file.displayPath, "page");
@@ -1853,7 +1873,8 @@ function resolve2(ctx) {
1853
1873
  }
1854
1874
  const featureGlob = typeof conventions.features === "string" ? conventions.features : null;
1855
1875
  const conventionalFeatureDirs = /* @__PURE__ */ new Set();
1856
- const featureExportsByDir = /* @__PURE__ */ new Map();
1876
+ const featureExportFilesByDir = /* @__PURE__ */ new Map();
1877
+ const wellKnownFeatureFileByDir = /* @__PURE__ */ new Map();
1857
1878
  const suppressedFeatureDirs = /* @__PURE__ */ new Set();
1858
1879
  if (featureGlob) {
1859
1880
  const re = globToRegExp(featureGlob + "/**");
@@ -1862,16 +1883,43 @@ function resolve2(ctx) {
1862
1883
  const dir = extractFeatureDir(ef.file.displayPath, featureGlob);
1863
1884
  if (!dir) continue;
1864
1885
  conventionalFeatureDirs.add(dir);
1886
+ const isWellKnown = path3.posix.basename(ef.file.displayPath) === WELL_KNOWN_FILES.feature;
1887
+ if (isWellKnown) wellKnownFeatureFileByDir.set(dir, ef.file.displayPath);
1865
1888
  const exp = exportFor(ef.file.displayPath, "feature");
1866
1889
  if (exp) {
1867
1890
  if (exp.id === false) suppressedFeatureDirs.add(dir);
1868
- else if (!featureExportsByDir.has(dir))
1869
- featureExportsByDir.set(dir, exp);
1891
+ else {
1892
+ let arr = featureExportFilesByDir.get(dir);
1893
+ if (!arr) {
1894
+ arr = [];
1895
+ featureExportFilesByDir.set(dir, arr);
1896
+ }
1897
+ arr.push({ file: ef.file.displayPath, exp });
1898
+ }
1870
1899
  }
1871
1900
  }
1872
1901
  for (const dir of conventionalFeatureDirs) {
1873
1902
  if (suppressedFeatureDirs.has(dir)) continue;
1874
- const exp = featureExportsByDir.get(dir);
1903
+ const allExports = featureExportFilesByDir.get(dir) ?? [];
1904
+ const wellKnownPath = wellKnownFeatureFileByDir.get(dir);
1905
+ const wellKnownEntry = wellKnownPath ? allExports.find((e) => e.file === wellKnownPath) : void 0;
1906
+ let exp;
1907
+ if (wellKnownEntry) {
1908
+ exp = wellKnownEntry.exp;
1909
+ for (const other of allExports) {
1910
+ if (other.file === wellKnownEntry.file) continue;
1911
+ diagnostics.push({
1912
+ code: "competing-uidex-export",
1913
+ severity: "warning",
1914
+ message: `Feature metadata declared in both ${wellKnownEntry.file} and ${other.file}; ${wellKnownEntry.file} takes precedence.`,
1915
+ file: other.file,
1916
+ line: other.exp.loc.line,
1917
+ hint: `Remove the export from ${other.file} or delete ${wellKnownEntry.file}.`
1918
+ });
1919
+ }
1920
+ } else if (allExports.length > 0) {
1921
+ exp = allExports[0].exp;
1922
+ }
1875
1923
  const id = exp && typeof exp.id === "string" ? exp.id : path3.posix.basename(dir);
1876
1924
  const meta = exp ? buildMetaFromExport(exp) : void 0;
1877
1925
  const feature = {
@@ -2004,11 +2052,12 @@ function resolve2(ctx) {
2004
2052
  exp.id,
2005
2053
  buildMetaFromExport(exp)
2006
2054
  );
2055
+ const scope = computeScope(file);
2007
2056
  const primitive = {
2008
2057
  kind: "primitive",
2009
2058
  id: exp.id,
2010
2059
  loc: { file, line: exp.loc.line },
2011
- scopes: [computeScope(file)],
2060
+ ...scope ? { scopes: [scope] } : {},
2012
2061
  ...meta ? { meta } : {}
2013
2062
  };
2014
2063
  registry.add(primitive);
@@ -2020,11 +2069,12 @@ function resolve2(ctx) {
2020
2069
  if (domPrimitives.length > 0) {
2021
2070
  for (const p2 of domPrimitives) {
2022
2071
  const meta = metaWithComposes("primitive", p2.id);
2072
+ const domScope = computeScope(p2.file);
2023
2073
  const primitive = {
2024
2074
  kind: "primitive",
2025
2075
  id: p2.id,
2026
2076
  loc: { file: p2.file, line: p2.line },
2027
- scopes: [computeScope(p2.file)],
2077
+ ...domScope ? { scopes: [domScope] } : {},
2028
2078
  ...meta ? { meta } : {}
2029
2079
  };
2030
2080
  registry.add(primitive);
@@ -2034,13 +2084,13 @@ function resolve2(ctx) {
2034
2084
  if (primitiveConventions && fileMatchesAny(file, primitiveConventions)) {
2035
2085
  const name = kebab(baseName(file));
2036
2086
  if (!name) continue;
2037
- const scope = computeScope(file);
2087
+ const convScope = computeScope(file);
2038
2088
  const meta = metaWithComposes("primitive", name);
2039
2089
  const primitive = {
2040
2090
  kind: "primitive",
2041
2091
  id: name,
2042
2092
  loc: { file },
2043
- scopes: [scope],
2093
+ ...convScope ? { scopes: [convScope] } : {},
2044
2094
  ...meta ? { meta } : {}
2045
2095
  };
2046
2096
  registry.add(primitive);
@@ -2070,7 +2120,8 @@ function resolve2(ctx) {
2070
2120
  kind: "flow",
2071
2121
  id: flowExport.id,
2072
2122
  loc: base.loc,
2073
- touches: base.touches
2123
+ touches: base.touches,
2124
+ steps: base.steps
2074
2125
  };
2075
2126
  registry.add(flow);
2076
2127
  } else {
@@ -2115,7 +2166,7 @@ function computeScope(displayPath) {
2115
2166
  if (pagesIdx !== -1 && parts[pagesIdx + 1]) {
2116
2167
  return `page:${parts[pagesIdx + 1]}`;
2117
2168
  }
2118
- return "global";
2169
+ return null;
2119
2170
  }
2120
2171
  function extractFlowsFromSource(file) {
2121
2172
  const flows = [];
@@ -2149,17 +2200,18 @@ function extractFlowsFromSource(file) {
2149
2200
  kind: "flow",
2150
2201
  id,
2151
2202
  loc: { file: file.displayPath, line },
2152
- touches: dedupe(touches.map((t) => t.id))
2203
+ touches: dedupe(touches.map((t) => t.id)),
2204
+ steps: touches.filter((t) => t.action).map((t) => ({ entityId: t.id, action: t.action }))
2153
2205
  });
2154
2206
  }
2155
2207
  return flows;
2156
2208
  }
2157
2209
  function captureUidexIds(body) {
2158
2210
  const out2 = [];
2159
- const re = /uidex\(\s*(?:'([^']+)'|"([^"]+)"|`([^`$]+)`)\s*\)/g;
2211
+ const re = /uidex\(\s*(?:'([^']+)'|"([^"]+)"|`([^`$]+)`)\s*\)(?:\.(\w+)\s*\()?/g;
2160
2212
  let m;
2161
2213
  while ((m = re.exec(body)) !== null) {
2162
- out2.push({ id: m[1] || m[2] || m[3] });
2214
+ out2.push({ id: m[1] || m[2] || m[3], action: m[4] });
2163
2215
  }
2164
2216
  return out2;
2165
2217
  }
@@ -2168,6 +2220,7 @@ function dedupe(arr) {
2168
2220
  }
2169
2221
 
2170
2222
  // src/scan/audit.ts
2223
+ var path4 = __toESM(require("path"), 1);
2171
2224
  var MARKER_FILENAMES = ["UIDEX_PAGE.md", "UIDEX_FEATURE.md"];
2172
2225
  function audit(opts) {
2173
2226
  const diagnostics = [];
@@ -2269,6 +2322,32 @@ function audit(opts) {
2269
2322
  }
2270
2323
  }
2271
2324
  }
2325
+ if (lint) {
2326
+ const scannedPaths = new Set(files.map((f) => f.displayPath));
2327
+ for (const ef of extracted) {
2328
+ if (!ef.metadata) continue;
2329
+ for (const m of ef.metadata) {
2330
+ if (m.kind !== "page" && m.kind !== "feature") continue;
2331
+ if (typeof m.id !== "string") continue;
2332
+ const filePath = ef.file.displayPath;
2333
+ const wellKnownName = WELL_KNOWN_FILES[m.kind];
2334
+ if (path4.posix.basename(filePath) === wellKnownName) continue;
2335
+ const dir = path4.posix.dirname(filePath);
2336
+ const wellKnownPath = dir === "." ? wellKnownName : `${dir}/${wellKnownName}`;
2337
+ if (scannedPaths.has(wellKnownPath)) continue;
2338
+ const kindLabel = m.kind === "page" ? "Page" : "Feature";
2339
+ diagnostics.push({
2340
+ code: "prefer-well-known-file",
2341
+ severity: "info",
2342
+ message: `${kindLabel} "${m.id}" metadata lives on ${filePath}; prefer ${wellKnownPath}`,
2343
+ file: filePath,
2344
+ line: m.loc.line,
2345
+ entity: { kind: m.kind, id: m.id },
2346
+ hint: `Move the \`export const uidex\` block to ${wellKnownPath} and remove it from ${filePath}.`
2347
+ });
2348
+ }
2349
+ }
2350
+ }
2272
2351
  if (lint) {
2273
2352
  for (const f of files) {
2274
2353
  const lines = f.content.split("\n");
@@ -2305,8 +2384,8 @@ function audit(opts) {
2305
2384
  baseName2.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
2306
2385
  );
2307
2386
  if (!primitive) continue;
2308
- const scope = primitive.scopes?.[0] ?? "global";
2309
- if (scope === "global") continue;
2387
+ const scope = primitive.scopes?.[0];
2388
+ if (!scope) continue;
2310
2389
  const [kind, id] = scope.split(":");
2311
2390
  const importerSegments = f.displayPath.split("/");
2312
2391
  if (!importerSegments.includes(id) || !importerSegments.includes(kind + "s")) {
@@ -2704,7 +2783,7 @@ function parseGitHubRef(ref) {
2704
2783
 
2705
2784
  // src/scan/scaffold.ts
2706
2785
  var fs3 = __toESM(require("fs"), 1);
2707
- var path4 = __toESM(require("path"), 1);
2786
+ var path5 = __toESM(require("path"), 1);
2708
2787
  function scaffoldWidgetSpec(opts) {
2709
2788
  const {
2710
2789
  registry,
@@ -2719,7 +2798,7 @@ function scaffoldWidgetSpec(opts) {
2719
2798
  }
2720
2799
  const criteria = widget.meta?.acceptance ?? [];
2721
2800
  const filename = `widget-${widgetId}.spec.ts`;
2722
- const outputPath = path4.resolve(outDir, filename);
2801
+ const outputPath = path5.resolve(outDir, filename);
2723
2802
  if (fs3.existsSync(outputPath) && !force) {
2724
2803
  return {
2725
2804
  outputPath,
@@ -2733,7 +2812,7 @@ function scaffoldWidgetSpec(opts) {
2733
2812
  criteria,
2734
2813
  fixtureImport
2735
2814
  });
2736
- fs3.mkdirSync(path4.dirname(outputPath), { recursive: true });
2815
+ fs3.mkdirSync(path5.dirname(outputPath), { recursive: true });
2737
2816
  fs3.writeFileSync(outputPath, content, "utf8");
2738
2817
  return { outputPath, written: true, skipped: false };
2739
2818
  }
@@ -2767,7 +2846,7 @@ function renderSpec(args) {
2767
2846
 
2768
2847
  // src/scan/pipeline.ts
2769
2848
  var fs4 = __toESM(require("fs"), 1);
2770
- var path5 = __toESM(require("path"), 1);
2849
+ var path6 = __toESM(require("path"), 1);
2771
2850
  function runScan(opts = {}) {
2772
2851
  const cwd = opts.cwd ?? process.cwd();
2773
2852
  const configs = opts.configs ?? discover({ cwd });
@@ -2799,7 +2878,7 @@ function runOne(dc, opts) {
2799
2878
  gitContext,
2800
2879
  typeMode: config.typeMode
2801
2880
  });
2802
- const outputPath = path5.resolve(configDir, config.output);
2881
+ const outputPath = path6.resolve(configDir, config.output);
2803
2882
  const outputRel = config.output;
2804
2883
  let existingOnDisk = null;
2805
2884
  if (opts.check) {
@@ -2835,29 +2914,29 @@ function runOne(dc, opts) {
2835
2914
  };
2836
2915
  }
2837
2916
  function writeScanResult(result) {
2838
- fs4.mkdirSync(path5.dirname(result.outputPath), { recursive: true });
2917
+ fs4.mkdirSync(path6.dirname(result.outputPath), { recursive: true });
2839
2918
  fs4.writeFileSync(result.outputPath, result.generated, "utf8");
2840
2919
  }
2841
2920
 
2842
2921
  // src/scan/cli.ts
2843
2922
  var fs7 = __toESM(require("fs"), 1);
2844
- var path8 = __toESM(require("path"), 1);
2923
+ var path9 = __toESM(require("path"), 1);
2845
2924
 
2846
2925
  // src/scan/ai/index.ts
2847
2926
  var p = __toESM(require("@clack/prompts"), 1);
2848
2927
 
2849
2928
  // src/scan/ai/providers/claude.ts
2850
2929
  var fs6 = __toESM(require("fs"), 1);
2851
- var path7 = __toESM(require("path"), 1);
2930
+ var path8 = __toESM(require("path"), 1);
2852
2931
 
2853
2932
  // src/scan/ai/templates.ts
2854
2933
  var fs5 = __toESM(require("fs"), 1);
2855
- var path6 = __toESM(require("path"), 1);
2934
+ var path7 = __toESM(require("path"), 1);
2856
2935
  function templatePath(rel) {
2857
2936
  const candidates = [
2858
- path6.resolve(__dirname, "../../templates", rel),
2937
+ path7.resolve(__dirname, "../../templates", rel),
2859
2938
  // dist/cli/cli.cjs → ../../templates
2860
- path6.resolve(__dirname, "../../../templates", rel)
2939
+ path7.resolve(__dirname, "../../../templates", rel)
2861
2940
  // src/scan/ai/foo.ts → ../../../templates
2862
2941
  ];
2863
2942
  for (const c of candidates) {
@@ -2889,7 +2968,7 @@ var claudeProvider = {
2889
2968
  async install({ cwd, force }) {
2890
2969
  const changes = [];
2891
2970
  for (const file of CLAUDE_FILES) {
2892
- const dest = path7.join(cwd, file.dest);
2971
+ const dest = path8.join(cwd, file.dest);
2893
2972
  const exists = fs6.existsSync(dest);
2894
2973
  if (exists && !force) {
2895
2974
  changes.push({
@@ -2899,7 +2978,7 @@ var claudeProvider = {
2899
2978
  });
2900
2979
  continue;
2901
2980
  }
2902
- fs6.mkdirSync(path7.dirname(dest), { recursive: true });
2981
+ fs6.mkdirSync(path8.dirname(dest), { recursive: true });
2903
2982
  fs6.writeFileSync(dest, readTemplate(file.template));
2904
2983
  changes.push({
2905
2984
  path: file.dest,
@@ -2911,7 +2990,7 @@ var claudeProvider = {
2911
2990
  async uninstall({ cwd }) {
2912
2991
  const changes = [];
2913
2992
  for (const file of CLAUDE_FILES) {
2914
- const dest = path7.join(cwd, file.dest);
2993
+ const dest = path8.join(cwd, file.dest);
2915
2994
  if (!fs6.existsSync(dest)) {
2916
2995
  changes.push({ path: file.dest, action: "skipped", reason: "absent" });
2917
2996
  continue;
@@ -2919,9 +2998,9 @@ var claudeProvider = {
2919
2998
  fs6.unlinkSync(dest);
2920
2999
  changes.push({ path: file.dest, action: "removed" });
2921
3000
  }
2922
- cleanupEmpty(path7.join(cwd, ".claude/commands/uidex"));
2923
- cleanupEmpty(path7.join(cwd, ".claude/commands"));
2924
- cleanupEmpty(path7.join(cwd, ".claude/rules"));
3001
+ cleanupEmpty(path8.join(cwd, ".claude/commands/uidex"));
3002
+ cleanupEmpty(path8.join(cwd, ".claude/commands"));
3003
+ cleanupEmpty(path8.join(cwd, ".claude/rules"));
2925
3004
  return { changes };
2926
3005
  }
2927
3006
  };
@@ -3132,7 +3211,7 @@ function helpText2() {
3132
3211
  ].join("\n");
3133
3212
  }
3134
3213
  function runInit(cwd, w) {
3135
- const configPath = path8.join(cwd, CONFIG_FILENAME);
3214
+ const configPath = path9.join(cwd, CONFIG_FILENAME);
3136
3215
  if (fs7.existsSync(configPath)) {
3137
3216
  w.err(`.uidex.json already exists at ${configPath}`);
3138
3217
  return w.result(1);
@@ -3144,7 +3223,7 @@ function runInit(cwd, w) {
3144
3223
  };
3145
3224
  fs7.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
3146
3225
  w.out(`Created ${configPath}`);
3147
- const gitignorePath = path8.join(cwd, ".gitignore");
3226
+ const gitignorePath = path9.join(cwd, ".gitignore");
3148
3227
  const entry = "*.gen.ts";
3149
3228
  if (fs7.existsSync(gitignorePath)) {
3150
3229
  const existing = fs7.readFileSync(gitignorePath, "utf8");
@@ -3222,7 +3301,7 @@ function runScaffold(cwd, args, flags, w) {
3222
3301
  for (const r of results) {
3223
3302
  const widget = r.registry.get("widget", id);
3224
3303
  if (!widget) continue;
3225
- const outDir = path8.resolve(r.configDir, "e2e");
3304
+ const outDir = path9.resolve(r.configDir, "e2e");
3226
3305
  const result = scaffoldWidgetSpec({
3227
3306
  registry: r.registry,
3228
3307
  widgetId: id,