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.
@@ -31,11 +31,16 @@ interface Route {
31
31
  path: string;
32
32
  page: string;
33
33
  }
34
+ interface FlowStep {
35
+ entityId: string;
36
+ action?: string;
37
+ }
34
38
  interface Flow {
35
39
  kind: "flow";
36
40
  id: string;
37
41
  loc: Location;
38
42
  touches: string[];
43
+ steps: FlowStep[];
39
44
  }
40
45
  interface Page extends EntityWithMetaBase {
41
46
  kind: "page";
@@ -31,11 +31,16 @@ interface Route {
31
31
  path: string;
32
32
  page: string;
33
33
  }
34
+ interface FlowStep {
35
+ entityId: string;
36
+ action?: string;
37
+ }
34
38
  interface Flow {
35
39
  kind: "flow";
36
40
  id: string;
37
41
  loc: Location;
38
42
  touches: string[];
43
+ steps: FlowStep[];
39
44
  }
40
45
  interface Page extends EntityWithMetaBase {
41
46
  kind: "page";
@@ -4,6 +4,10 @@ import * as path from "path";
4
4
 
5
5
  // src/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) {
@@ -1769,7 +1773,24 @@ function resolve2(ctx) {
1769
1773
  const routes = conventions.pages === "auto" ? detectRoutes(ctx.extracted.map((e) => e.file)) : [];
1770
1774
  const handledPageFiles = /* @__PURE__ */ new Set();
1771
1775
  for (const route of routes) {
1772
- const exp = exportFor(route.file, "page");
1776
+ const routeDir = path3.posix.dirname(route.file);
1777
+ const wellKnownPath = `${routeDir}/${WELL_KNOWN_FILES.page}`;
1778
+ const wellKnownExp = exportFor(wellKnownPath, "page");
1779
+ const routeExp = exportFor(route.file, "page");
1780
+ const exp = wellKnownExp ?? routeExp;
1781
+ const locFile = wellKnownExp ? wellKnownPath : route.file;
1782
+ if (wellKnownExp) handledPageFiles.add(wellKnownPath);
1783
+ handledPageFiles.add(route.file);
1784
+ if (wellKnownExp && routeExp) {
1785
+ diagnostics.push({
1786
+ code: "competing-uidex-export",
1787
+ severity: "warning",
1788
+ message: `Page metadata declared in both ${wellKnownPath} and ${route.file}; ${wellKnownPath} takes precedence.`,
1789
+ file: route.file,
1790
+ line: routeExp.loc.line,
1791
+ hint: `Remove the export from ${route.file} or delete ${wellKnownPath}.`
1792
+ });
1793
+ }
1773
1794
  if (exp && exp.id === false) continue;
1774
1795
  const effectiveId = exp && typeof exp.id === "string" ? exp.id : route.id;
1775
1796
  const meta = exp ? buildMetaFromExport(exp) : void 0;
@@ -1777,11 +1798,10 @@ function resolve2(ctx) {
1777
1798
  const page = {
1778
1799
  kind: "page",
1779
1800
  id: effectiveId,
1780
- loc: { file: route.file, line: exp?.loc.line },
1801
+ loc: { file: locFile, line: exp?.loc.line },
1781
1802
  ...meta ? { meta } : {}
1782
1803
  };
1783
1804
  registry.add(page);
1784
- handledPageFiles.add(route.file);
1785
1805
  }
1786
1806
  for (const ef of ctx.extracted) {
1787
1807
  const exp = exportFor(ef.file.displayPath, "page");
@@ -1797,7 +1817,8 @@ function resolve2(ctx) {
1797
1817
  }
1798
1818
  const featureGlob = typeof conventions.features === "string" ? conventions.features : null;
1799
1819
  const conventionalFeatureDirs = /* @__PURE__ */ new Set();
1800
- const featureExportsByDir = /* @__PURE__ */ new Map();
1820
+ const featureExportFilesByDir = /* @__PURE__ */ new Map();
1821
+ const wellKnownFeatureFileByDir = /* @__PURE__ */ new Map();
1801
1822
  const suppressedFeatureDirs = /* @__PURE__ */ new Set();
1802
1823
  if (featureGlob) {
1803
1824
  const re = globToRegExp(featureGlob + "/**");
@@ -1806,16 +1827,43 @@ function resolve2(ctx) {
1806
1827
  const dir = extractFeatureDir(ef.file.displayPath, featureGlob);
1807
1828
  if (!dir) continue;
1808
1829
  conventionalFeatureDirs.add(dir);
1830
+ const isWellKnown = path3.posix.basename(ef.file.displayPath) === WELL_KNOWN_FILES.feature;
1831
+ if (isWellKnown) wellKnownFeatureFileByDir.set(dir, ef.file.displayPath);
1809
1832
  const exp = exportFor(ef.file.displayPath, "feature");
1810
1833
  if (exp) {
1811
1834
  if (exp.id === false) suppressedFeatureDirs.add(dir);
1812
- else if (!featureExportsByDir.has(dir))
1813
- featureExportsByDir.set(dir, exp);
1835
+ else {
1836
+ let arr = featureExportFilesByDir.get(dir);
1837
+ if (!arr) {
1838
+ arr = [];
1839
+ featureExportFilesByDir.set(dir, arr);
1840
+ }
1841
+ arr.push({ file: ef.file.displayPath, exp });
1842
+ }
1814
1843
  }
1815
1844
  }
1816
1845
  for (const dir of conventionalFeatureDirs) {
1817
1846
  if (suppressedFeatureDirs.has(dir)) continue;
1818
- const exp = featureExportsByDir.get(dir);
1847
+ const allExports = featureExportFilesByDir.get(dir) ?? [];
1848
+ const wellKnownPath = wellKnownFeatureFileByDir.get(dir);
1849
+ const wellKnownEntry = wellKnownPath ? allExports.find((e) => e.file === wellKnownPath) : void 0;
1850
+ let exp;
1851
+ if (wellKnownEntry) {
1852
+ exp = wellKnownEntry.exp;
1853
+ for (const other of allExports) {
1854
+ if (other.file === wellKnownEntry.file) continue;
1855
+ diagnostics.push({
1856
+ code: "competing-uidex-export",
1857
+ severity: "warning",
1858
+ message: `Feature metadata declared in both ${wellKnownEntry.file} and ${other.file}; ${wellKnownEntry.file} takes precedence.`,
1859
+ file: other.file,
1860
+ line: other.exp.loc.line,
1861
+ hint: `Remove the export from ${other.file} or delete ${wellKnownEntry.file}.`
1862
+ });
1863
+ }
1864
+ } else if (allExports.length > 0) {
1865
+ exp = allExports[0].exp;
1866
+ }
1819
1867
  const id = exp && typeof exp.id === "string" ? exp.id : path3.posix.basename(dir);
1820
1868
  const meta = exp ? buildMetaFromExport(exp) : void 0;
1821
1869
  const feature = {
@@ -1948,11 +1996,12 @@ function resolve2(ctx) {
1948
1996
  exp.id,
1949
1997
  buildMetaFromExport(exp)
1950
1998
  );
1999
+ const scope = computeScope(file);
1951
2000
  const primitive = {
1952
2001
  kind: "primitive",
1953
2002
  id: exp.id,
1954
2003
  loc: { file, line: exp.loc.line },
1955
- scopes: [computeScope(file)],
2004
+ ...scope ? { scopes: [scope] } : {},
1956
2005
  ...meta ? { meta } : {}
1957
2006
  };
1958
2007
  registry.add(primitive);
@@ -1964,11 +2013,12 @@ function resolve2(ctx) {
1964
2013
  if (domPrimitives.length > 0) {
1965
2014
  for (const p2 of domPrimitives) {
1966
2015
  const meta = metaWithComposes("primitive", p2.id);
2016
+ const domScope = computeScope(p2.file);
1967
2017
  const primitive = {
1968
2018
  kind: "primitive",
1969
2019
  id: p2.id,
1970
2020
  loc: { file: p2.file, line: p2.line },
1971
- scopes: [computeScope(p2.file)],
2021
+ ...domScope ? { scopes: [domScope] } : {},
1972
2022
  ...meta ? { meta } : {}
1973
2023
  };
1974
2024
  registry.add(primitive);
@@ -1978,13 +2028,13 @@ function resolve2(ctx) {
1978
2028
  if (primitiveConventions && fileMatchesAny(file, primitiveConventions)) {
1979
2029
  const name = kebab(baseName(file));
1980
2030
  if (!name) continue;
1981
- const scope = computeScope(file);
2031
+ const convScope = computeScope(file);
1982
2032
  const meta = metaWithComposes("primitive", name);
1983
2033
  const primitive = {
1984
2034
  kind: "primitive",
1985
2035
  id: name,
1986
2036
  loc: { file },
1987
- scopes: [scope],
2037
+ ...convScope ? { scopes: [convScope] } : {},
1988
2038
  ...meta ? { meta } : {}
1989
2039
  };
1990
2040
  registry.add(primitive);
@@ -2014,7 +2064,8 @@ function resolve2(ctx) {
2014
2064
  kind: "flow",
2015
2065
  id: flowExport.id,
2016
2066
  loc: base.loc,
2017
- touches: base.touches
2067
+ touches: base.touches,
2068
+ steps: base.steps
2018
2069
  };
2019
2070
  registry.add(flow);
2020
2071
  } else {
@@ -2059,7 +2110,7 @@ function computeScope(displayPath) {
2059
2110
  if (pagesIdx !== -1 && parts[pagesIdx + 1]) {
2060
2111
  return `page:${parts[pagesIdx + 1]}`;
2061
2112
  }
2062
- return "global";
2113
+ return null;
2063
2114
  }
2064
2115
  function extractFlowsFromSource(file) {
2065
2116
  const flows = [];
@@ -2093,17 +2144,18 @@ function extractFlowsFromSource(file) {
2093
2144
  kind: "flow",
2094
2145
  id,
2095
2146
  loc: { file: file.displayPath, line },
2096
- touches: dedupe(touches.map((t) => t.id))
2147
+ touches: dedupe(touches.map((t) => t.id)),
2148
+ steps: touches.filter((t) => t.action).map((t) => ({ entityId: t.id, action: t.action }))
2097
2149
  });
2098
2150
  }
2099
2151
  return flows;
2100
2152
  }
2101
2153
  function captureUidexIds(body) {
2102
2154
  const out2 = [];
2103
- const re = /uidex\(\s*(?:'([^']+)'|"([^"]+)"|`([^`$]+)`)\s*\)/g;
2155
+ const re = /uidex\(\s*(?:'([^']+)'|"([^"]+)"|`([^`$]+)`)\s*\)(?:\.(\w+)\s*\()?/g;
2104
2156
  let m;
2105
2157
  while ((m = re.exec(body)) !== null) {
2106
- out2.push({ id: m[1] || m[2] || m[3] });
2158
+ out2.push({ id: m[1] || m[2] || m[3], action: m[4] });
2107
2159
  }
2108
2160
  return out2;
2109
2161
  }
@@ -2112,6 +2164,7 @@ function dedupe(arr) {
2112
2164
  }
2113
2165
 
2114
2166
  // src/scan/audit.ts
2167
+ import * as path4 from "path";
2115
2168
  var MARKER_FILENAMES = ["UIDEX_PAGE.md", "UIDEX_FEATURE.md"];
2116
2169
  function audit(opts) {
2117
2170
  const diagnostics = [];
@@ -2213,6 +2266,32 @@ function audit(opts) {
2213
2266
  }
2214
2267
  }
2215
2268
  }
2269
+ if (lint) {
2270
+ const scannedPaths = new Set(files.map((f) => f.displayPath));
2271
+ for (const ef of extracted) {
2272
+ if (!ef.metadata) continue;
2273
+ for (const m of ef.metadata) {
2274
+ if (m.kind !== "page" && m.kind !== "feature") continue;
2275
+ if (typeof m.id !== "string") continue;
2276
+ const filePath = ef.file.displayPath;
2277
+ const wellKnownName = WELL_KNOWN_FILES[m.kind];
2278
+ if (path4.posix.basename(filePath) === wellKnownName) continue;
2279
+ const dir = path4.posix.dirname(filePath);
2280
+ const wellKnownPath = dir === "." ? wellKnownName : `${dir}/${wellKnownName}`;
2281
+ if (scannedPaths.has(wellKnownPath)) continue;
2282
+ const kindLabel = m.kind === "page" ? "Page" : "Feature";
2283
+ diagnostics.push({
2284
+ code: "prefer-well-known-file",
2285
+ severity: "info",
2286
+ message: `${kindLabel} "${m.id}" metadata lives on ${filePath}; prefer ${wellKnownPath}`,
2287
+ file: filePath,
2288
+ line: m.loc.line,
2289
+ entity: { kind: m.kind, id: m.id },
2290
+ hint: `Move the \`export const uidex\` block to ${wellKnownPath} and remove it from ${filePath}.`
2291
+ });
2292
+ }
2293
+ }
2294
+ }
2216
2295
  if (lint) {
2217
2296
  for (const f of files) {
2218
2297
  const lines = f.content.split("\n");
@@ -2249,8 +2328,8 @@ function audit(opts) {
2249
2328
  baseName2.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
2250
2329
  );
2251
2330
  if (!primitive) continue;
2252
- const scope = primitive.scopes?.[0] ?? "global";
2253
- if (scope === "global") continue;
2331
+ const scope = primitive.scopes?.[0];
2332
+ if (!scope) continue;
2254
2333
  const [kind, id] = scope.split(":");
2255
2334
  const importerSegments = f.displayPath.split("/");
2256
2335
  if (!importerSegments.includes(id) || !importerSegments.includes(kind + "s")) {
@@ -2648,7 +2727,7 @@ function parseGitHubRef(ref) {
2648
2727
 
2649
2728
  // src/scan/scaffold.ts
2650
2729
  import * as fs3 from "fs";
2651
- import * as path4 from "path";
2730
+ import * as path5 from "path";
2652
2731
  function scaffoldWidgetSpec(opts) {
2653
2732
  const {
2654
2733
  registry,
@@ -2663,7 +2742,7 @@ function scaffoldWidgetSpec(opts) {
2663
2742
  }
2664
2743
  const criteria = widget.meta?.acceptance ?? [];
2665
2744
  const filename = `widget-${widgetId}.spec.ts`;
2666
- const outputPath = path4.resolve(outDir, filename);
2745
+ const outputPath = path5.resolve(outDir, filename);
2667
2746
  if (fs3.existsSync(outputPath) && !force) {
2668
2747
  return {
2669
2748
  outputPath,
@@ -2677,7 +2756,7 @@ function scaffoldWidgetSpec(opts) {
2677
2756
  criteria,
2678
2757
  fixtureImport
2679
2758
  });
2680
- fs3.mkdirSync(path4.dirname(outputPath), { recursive: true });
2759
+ fs3.mkdirSync(path5.dirname(outputPath), { recursive: true });
2681
2760
  fs3.writeFileSync(outputPath, content, "utf8");
2682
2761
  return { outputPath, written: true, skipped: false };
2683
2762
  }
@@ -2711,7 +2790,7 @@ function renderSpec(args) {
2711
2790
 
2712
2791
  // src/scan/pipeline.ts
2713
2792
  import * as fs4 from "fs";
2714
- import * as path5 from "path";
2793
+ import * as path6 from "path";
2715
2794
  function runScan(opts = {}) {
2716
2795
  const cwd = opts.cwd ?? process.cwd();
2717
2796
  const configs = opts.configs ?? discover({ cwd });
@@ -2743,7 +2822,7 @@ function runOne(dc, opts) {
2743
2822
  gitContext,
2744
2823
  typeMode: config.typeMode
2745
2824
  });
2746
- const outputPath = path5.resolve(configDir, config.output);
2825
+ const outputPath = path6.resolve(configDir, config.output);
2747
2826
  const outputRel = config.output;
2748
2827
  let existingOnDisk = null;
2749
2828
  if (opts.check) {
@@ -2779,29 +2858,29 @@ function runOne(dc, opts) {
2779
2858
  };
2780
2859
  }
2781
2860
  function writeScanResult(result) {
2782
- fs4.mkdirSync(path5.dirname(result.outputPath), { recursive: true });
2861
+ fs4.mkdirSync(path6.dirname(result.outputPath), { recursive: true });
2783
2862
  fs4.writeFileSync(result.outputPath, result.generated, "utf8");
2784
2863
  }
2785
2864
 
2786
2865
  // src/scan/cli.ts
2787
2866
  import * as fs7 from "fs";
2788
- import * as path8 from "path";
2867
+ import * as path9 from "path";
2789
2868
 
2790
2869
  // src/scan/ai/index.ts
2791
2870
  import * as p from "@clack/prompts";
2792
2871
 
2793
2872
  // src/scan/ai/providers/claude.ts
2794
2873
  import * as fs6 from "fs";
2795
- import * as path7 from "path";
2874
+ import * as path8 from "path";
2796
2875
 
2797
2876
  // src/scan/ai/templates.ts
2798
2877
  import * as fs5 from "fs";
2799
- import * as path6 from "path";
2878
+ import * as path7 from "path";
2800
2879
  function templatePath(rel) {
2801
2880
  const candidates = [
2802
- path6.resolve(__dirname, "../../templates", rel),
2881
+ path7.resolve(__dirname, "../../templates", rel),
2803
2882
  // dist/cli/cli.cjs → ../../templates
2804
- path6.resolve(__dirname, "../../../templates", rel)
2883
+ path7.resolve(__dirname, "../../../templates", rel)
2805
2884
  // src/scan/ai/foo.ts → ../../../templates
2806
2885
  ];
2807
2886
  for (const c of candidates) {
@@ -2833,7 +2912,7 @@ var claudeProvider = {
2833
2912
  async install({ cwd, force }) {
2834
2913
  const changes = [];
2835
2914
  for (const file of CLAUDE_FILES) {
2836
- const dest = path7.join(cwd, file.dest);
2915
+ const dest = path8.join(cwd, file.dest);
2837
2916
  const exists = fs6.existsSync(dest);
2838
2917
  if (exists && !force) {
2839
2918
  changes.push({
@@ -2843,7 +2922,7 @@ var claudeProvider = {
2843
2922
  });
2844
2923
  continue;
2845
2924
  }
2846
- fs6.mkdirSync(path7.dirname(dest), { recursive: true });
2925
+ fs6.mkdirSync(path8.dirname(dest), { recursive: true });
2847
2926
  fs6.writeFileSync(dest, readTemplate(file.template));
2848
2927
  changes.push({
2849
2928
  path: file.dest,
@@ -2855,7 +2934,7 @@ var claudeProvider = {
2855
2934
  async uninstall({ cwd }) {
2856
2935
  const changes = [];
2857
2936
  for (const file of CLAUDE_FILES) {
2858
- const dest = path7.join(cwd, file.dest);
2937
+ const dest = path8.join(cwd, file.dest);
2859
2938
  if (!fs6.existsSync(dest)) {
2860
2939
  changes.push({ path: file.dest, action: "skipped", reason: "absent" });
2861
2940
  continue;
@@ -2863,9 +2942,9 @@ var claudeProvider = {
2863
2942
  fs6.unlinkSync(dest);
2864
2943
  changes.push({ path: file.dest, action: "removed" });
2865
2944
  }
2866
- cleanupEmpty(path7.join(cwd, ".claude/commands/uidex"));
2867
- cleanupEmpty(path7.join(cwd, ".claude/commands"));
2868
- cleanupEmpty(path7.join(cwd, ".claude/rules"));
2945
+ cleanupEmpty(path8.join(cwd, ".claude/commands/uidex"));
2946
+ cleanupEmpty(path8.join(cwd, ".claude/commands"));
2947
+ cleanupEmpty(path8.join(cwd, ".claude/rules"));
2869
2948
  return { changes };
2870
2949
  }
2871
2950
  };
@@ -3076,7 +3155,7 @@ function helpText2() {
3076
3155
  ].join("\n");
3077
3156
  }
3078
3157
  function runInit(cwd, w) {
3079
- const configPath = path8.join(cwd, CONFIG_FILENAME);
3158
+ const configPath = path9.join(cwd, CONFIG_FILENAME);
3080
3159
  if (fs7.existsSync(configPath)) {
3081
3160
  w.err(`.uidex.json already exists at ${configPath}`);
3082
3161
  return w.result(1);
@@ -3088,7 +3167,7 @@ function runInit(cwd, w) {
3088
3167
  };
3089
3168
  fs7.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
3090
3169
  w.out(`Created ${configPath}`);
3091
- const gitignorePath = path8.join(cwd, ".gitignore");
3170
+ const gitignorePath = path9.join(cwd, ".gitignore");
3092
3171
  const entry = "*.gen.ts";
3093
3172
  if (fs7.existsSync(gitignorePath)) {
3094
3173
  const existing = fs7.readFileSync(gitignorePath, "utf8");
@@ -3166,7 +3245,7 @@ function runScaffold(cwd, args, flags, w) {
3166
3245
  for (const r of results) {
3167
3246
  const widget = r.registry.get("widget", id);
3168
3247
  if (!widget) continue;
3169
- const outDir = path8.resolve(r.configDir, "e2e");
3248
+ const outDir = path9.resolve(r.configDir, "e2e");
3170
3249
  const result = scaffoldWidgetSpec({
3171
3250
  registry: r.registry,
3172
3251
  widgetId: id,