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.
package/dist/cli/cli.cjs CHANGED
@@ -25,7 +25,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
25
25
 
26
26
  // src/scan/cli.ts
27
27
  var fs7 = __toESM(require("fs"), 1);
28
- var path8 = __toESM(require("path"), 1);
28
+ var path9 = __toESM(require("path"), 1);
29
29
 
30
30
  // src/scan/ai/index.ts
31
31
  var p = __toESM(require("@clack/prompts"), 1);
@@ -240,6 +240,10 @@ var path3 = __toESM(require("path"), 1);
240
240
 
241
241
  // src/scan/config.ts
242
242
  var DEFAULT_TYPE_MODE = "strict";
243
+ var WELL_KNOWN_FILES = {
244
+ page: "uidex.page.ts",
245
+ feature: "uidex.feature.ts"
246
+ };
243
247
  var ConfigError = class extends Error {
244
248
  constructor(message) {
245
249
  super(message);
@@ -277,14 +281,14 @@ var ALLOWED_AUDIT_KEYS = /* @__PURE__ */ new Set(["scopeLeak", "coverage", "acce
277
281
  function fail(msg) {
278
282
  throw new ConfigError(`Invalid .uidex.json: ${msg}`);
279
283
  }
280
- function assertObject(value, path9) {
284
+ function assertObject(value, path10) {
281
285
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
282
- fail(`${path9} must be an object`);
286
+ fail(`${path10} must be an object`);
283
287
  }
284
288
  }
285
- function assertStringArray(value, path9) {
289
+ function assertStringArray(value, path10) {
286
290
  if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
287
- fail(`${path9} must be a string[]`);
291
+ fail(`${path10} must be a string[]`);
288
292
  }
289
293
  }
290
294
  function validateConfig(raw) {
@@ -459,7 +463,10 @@ function discover(options = {}) {
459
463
 
460
464
  // src/scan/pipeline.ts
461
465
  var fs5 = __toESM(require("fs"), 1);
462
- var path6 = __toESM(require("path"), 1);
466
+ var path7 = __toESM(require("path"), 1);
467
+
468
+ // src/scan/audit.ts
469
+ var path4 = __toESM(require("path"), 1);
463
470
 
464
471
  // src/entities/types.ts
465
472
  var ENTITY_KINDS = [
@@ -681,6 +688,32 @@ function audit(opts) {
681
688
  }
682
689
  }
683
690
  }
691
+ if (lint) {
692
+ const scannedPaths = new Set(files.map((f) => f.displayPath));
693
+ for (const ef of extracted) {
694
+ if (!ef.metadata) continue;
695
+ for (const m of ef.metadata) {
696
+ if (m.kind !== "page" && m.kind !== "feature") continue;
697
+ if (typeof m.id !== "string") continue;
698
+ const filePath = ef.file.displayPath;
699
+ const wellKnownName = WELL_KNOWN_FILES[m.kind];
700
+ if (path4.posix.basename(filePath) === wellKnownName) continue;
701
+ const dir = path4.posix.dirname(filePath);
702
+ const wellKnownPath = dir === "." ? wellKnownName : `${dir}/${wellKnownName}`;
703
+ if (scannedPaths.has(wellKnownPath)) continue;
704
+ const kindLabel = m.kind === "page" ? "Page" : "Feature";
705
+ diagnostics.push({
706
+ code: "prefer-well-known-file",
707
+ severity: "info",
708
+ message: `${kindLabel} "${m.id}" metadata lives on ${filePath}; prefer ${wellKnownPath}`,
709
+ file: filePath,
710
+ line: m.loc.line,
711
+ entity: { kind: m.kind, id: m.id },
712
+ hint: `Move the \`export const uidex\` block to ${wellKnownPath} and remove it from ${filePath}.`
713
+ });
714
+ }
715
+ }
716
+ }
684
717
  if (lint) {
685
718
  for (const f of files) {
686
719
  const lines = f.content.split("\n");
@@ -717,8 +750,8 @@ function audit(opts) {
717
750
  baseName2.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
718
751
  );
719
752
  if (!primitive) continue;
720
- const scope = primitive.scopes?.[0] ?? "global";
721
- if (scope === "global") continue;
753
+ const scope = primitive.scopes?.[0];
754
+ if (!scope) continue;
722
755
  const [kind, id] = scope.split(":");
723
756
  const importerSegments = f.displayPath.split("/");
724
757
  if (!importerSegments.includes(id) || !importerSegments.includes(kind + "s")) {
@@ -2256,7 +2289,7 @@ function parseGitHubRef(ref) {
2256
2289
  }
2257
2290
 
2258
2291
  // src/scan/resolve.ts
2259
- var path5 = __toESM(require("path"), 1);
2292
+ var path6 = __toESM(require("path"), 1);
2260
2293
 
2261
2294
  // src/scan/routes.ts
2262
2295
  var PAGE_BASENAME = /^page\.(tsx|ts|jsx|js|mjs|cjs)$/;
@@ -2326,7 +2359,7 @@ function pathToId(routePath) {
2326
2359
 
2327
2360
  // src/scan/walk.ts
2328
2361
  var fs4 = __toESM(require("fs"), 1);
2329
- var path4 = __toESM(require("path"), 1);
2362
+ var path5 = __toESM(require("path"), 1);
2330
2363
  var DEFAULT_INCLUDES = ["**/*.{ts,tsx,js,jsx,mjs,cjs}"];
2331
2364
  var BASE_EXCLUDES = [
2332
2365
  "**/node_modules/**",
@@ -2390,7 +2423,7 @@ function globToRegExp(glob) {
2390
2423
  return new RegExp(`^${out2}$`);
2391
2424
  }
2392
2425
  function toPosix(p2) {
2393
- return p2.split(path4.sep).join("/");
2426
+ return p2.split(path5.sep).join("/");
2394
2427
  }
2395
2428
  function matchesAny(rel, patterns) {
2396
2429
  return patterns.some((g) => globToRegExp(g).test(rel));
@@ -2406,9 +2439,9 @@ function walk(sources, options) {
2406
2439
  ...globalExcludes,
2407
2440
  ...source.exclude ?? []
2408
2441
  ];
2409
- const absRoot = path4.resolve(cwd, source.rootDir);
2442
+ const absRoot = path5.resolve(cwd, source.rootDir);
2410
2443
  for (const filePath of walkDir(absRoot, absRoot)) {
2411
- const rel = toPosix(path4.relative(absRoot, filePath));
2444
+ const rel = toPosix(path5.relative(absRoot, filePath));
2412
2445
  if (matchesAny(rel, excludes)) continue;
2413
2446
  if (!matchesAny(rel, includes)) continue;
2414
2447
  let content;
@@ -2417,7 +2450,7 @@ function walk(sources, options) {
2417
2450
  } catch {
2418
2451
  continue;
2419
2452
  }
2420
- const relFromCwd = toPosix(path4.relative(cwd, filePath));
2453
+ const relFromCwd = toPosix(path5.relative(cwd, filePath));
2421
2454
  const displayPath = source.prefix ? `${source.prefix.replace(/\/$/, "")}/${rel}` : relFromCwd;
2422
2455
  out2.push({
2423
2456
  sourcePath: filePath,
@@ -2437,7 +2470,7 @@ function* walkDir(root, dir) {
2437
2470
  return;
2438
2471
  }
2439
2472
  for (const entry of entries) {
2440
- const full = path4.join(dir, entry.name);
2473
+ const full = path5.join(dir, entry.name);
2441
2474
  if (entry.isDirectory()) {
2442
2475
  if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git" || entry.name === "build" || entry.name === ".next") {
2443
2476
  continue;
@@ -2469,7 +2502,7 @@ function kebab(str) {
2469
2502
  return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").replace(/[^a-zA-Z0-9-]/g, "").toLowerCase();
2470
2503
  }
2471
2504
  function baseName(file) {
2472
- const b = path5.posix.basename(file);
2505
+ const b = path6.posix.basename(file);
2473
2506
  return b.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "");
2474
2507
  }
2475
2508
  var LANDMARK_RE = /<(header|nav|main|aside|footer)(\s[^>]*)?>|role=["']region["']/gi;
@@ -2544,7 +2577,24 @@ function resolve3(ctx) {
2544
2577
  const routes = conventions.pages === "auto" ? detectRoutes(ctx.extracted.map((e) => e.file)) : [];
2545
2578
  const handledPageFiles = /* @__PURE__ */ new Set();
2546
2579
  for (const route of routes) {
2547
- const exp = exportFor(route.file, "page");
2580
+ const routeDir = path6.posix.dirname(route.file);
2581
+ const wellKnownPath = `${routeDir}/${WELL_KNOWN_FILES.page}`;
2582
+ const wellKnownExp = exportFor(wellKnownPath, "page");
2583
+ const routeExp = exportFor(route.file, "page");
2584
+ const exp = wellKnownExp ?? routeExp;
2585
+ const locFile = wellKnownExp ? wellKnownPath : route.file;
2586
+ if (wellKnownExp) handledPageFiles.add(wellKnownPath);
2587
+ handledPageFiles.add(route.file);
2588
+ if (wellKnownExp && routeExp) {
2589
+ diagnostics.push({
2590
+ code: "competing-uidex-export",
2591
+ severity: "warning",
2592
+ message: `Page metadata declared in both ${wellKnownPath} and ${route.file}; ${wellKnownPath} takes precedence.`,
2593
+ file: route.file,
2594
+ line: routeExp.loc.line,
2595
+ hint: `Remove the export from ${route.file} or delete ${wellKnownPath}.`
2596
+ });
2597
+ }
2548
2598
  if (exp && exp.id === false) continue;
2549
2599
  const effectiveId = exp && typeof exp.id === "string" ? exp.id : route.id;
2550
2600
  const meta = exp ? buildMetaFromExport(exp) : void 0;
@@ -2552,11 +2602,10 @@ function resolve3(ctx) {
2552
2602
  const page = {
2553
2603
  kind: "page",
2554
2604
  id: effectiveId,
2555
- loc: { file: route.file, line: exp?.loc.line },
2605
+ loc: { file: locFile, line: exp?.loc.line },
2556
2606
  ...meta ? { meta } : {}
2557
2607
  };
2558
2608
  registry.add(page);
2559
- handledPageFiles.add(route.file);
2560
2609
  }
2561
2610
  for (const ef of ctx.extracted) {
2562
2611
  const exp = exportFor(ef.file.displayPath, "page");
@@ -2572,7 +2621,8 @@ function resolve3(ctx) {
2572
2621
  }
2573
2622
  const featureGlob = typeof conventions.features === "string" ? conventions.features : null;
2574
2623
  const conventionalFeatureDirs = /* @__PURE__ */ new Set();
2575
- const featureExportsByDir = /* @__PURE__ */ new Map();
2624
+ const featureExportFilesByDir = /* @__PURE__ */ new Map();
2625
+ const wellKnownFeatureFileByDir = /* @__PURE__ */ new Map();
2576
2626
  const suppressedFeatureDirs = /* @__PURE__ */ new Set();
2577
2627
  if (featureGlob) {
2578
2628
  const re = globToRegExp(featureGlob + "/**");
@@ -2581,17 +2631,44 @@ function resolve3(ctx) {
2581
2631
  const dir = extractFeatureDir(ef.file.displayPath, featureGlob);
2582
2632
  if (!dir) continue;
2583
2633
  conventionalFeatureDirs.add(dir);
2634
+ const isWellKnown = path6.posix.basename(ef.file.displayPath) === WELL_KNOWN_FILES.feature;
2635
+ if (isWellKnown) wellKnownFeatureFileByDir.set(dir, ef.file.displayPath);
2584
2636
  const exp = exportFor(ef.file.displayPath, "feature");
2585
2637
  if (exp) {
2586
2638
  if (exp.id === false) suppressedFeatureDirs.add(dir);
2587
- else if (!featureExportsByDir.has(dir))
2588
- featureExportsByDir.set(dir, exp);
2639
+ else {
2640
+ let arr = featureExportFilesByDir.get(dir);
2641
+ if (!arr) {
2642
+ arr = [];
2643
+ featureExportFilesByDir.set(dir, arr);
2644
+ }
2645
+ arr.push({ file: ef.file.displayPath, exp });
2646
+ }
2589
2647
  }
2590
2648
  }
2591
2649
  for (const dir of conventionalFeatureDirs) {
2592
2650
  if (suppressedFeatureDirs.has(dir)) continue;
2593
- const exp = featureExportsByDir.get(dir);
2594
- const id = exp && typeof exp.id === "string" ? exp.id : path5.posix.basename(dir);
2651
+ const allExports = featureExportFilesByDir.get(dir) ?? [];
2652
+ const wellKnownPath = wellKnownFeatureFileByDir.get(dir);
2653
+ const wellKnownEntry = wellKnownPath ? allExports.find((e) => e.file === wellKnownPath) : void 0;
2654
+ let exp;
2655
+ if (wellKnownEntry) {
2656
+ exp = wellKnownEntry.exp;
2657
+ for (const other of allExports) {
2658
+ if (other.file === wellKnownEntry.file) continue;
2659
+ diagnostics.push({
2660
+ code: "competing-uidex-export",
2661
+ severity: "warning",
2662
+ message: `Feature metadata declared in both ${wellKnownEntry.file} and ${other.file}; ${wellKnownEntry.file} takes precedence.`,
2663
+ file: other.file,
2664
+ line: other.exp.loc.line,
2665
+ hint: `Remove the export from ${other.file} or delete ${wellKnownEntry.file}.`
2666
+ });
2667
+ }
2668
+ } else if (allExports.length > 0) {
2669
+ exp = allExports[0].exp;
2670
+ }
2671
+ const id = exp && typeof exp.id === "string" ? exp.id : path6.posix.basename(dir);
2595
2672
  const meta = exp ? buildMetaFromExport(exp) : void 0;
2596
2673
  const feature = {
2597
2674
  kind: "feature",
@@ -2723,11 +2800,12 @@ function resolve3(ctx) {
2723
2800
  exp.id,
2724
2801
  buildMetaFromExport(exp)
2725
2802
  );
2803
+ const scope = computeScope(file);
2726
2804
  const primitive = {
2727
2805
  kind: "primitive",
2728
2806
  id: exp.id,
2729
2807
  loc: { file, line: exp.loc.line },
2730
- scopes: [computeScope(file)],
2808
+ ...scope ? { scopes: [scope] } : {},
2731
2809
  ...meta ? { meta } : {}
2732
2810
  };
2733
2811
  registry.add(primitive);
@@ -2739,11 +2817,12 @@ function resolve3(ctx) {
2739
2817
  if (domPrimitives.length > 0) {
2740
2818
  for (const p2 of domPrimitives) {
2741
2819
  const meta = metaWithComposes("primitive", p2.id);
2820
+ const domScope = computeScope(p2.file);
2742
2821
  const primitive = {
2743
2822
  kind: "primitive",
2744
2823
  id: p2.id,
2745
2824
  loc: { file: p2.file, line: p2.line },
2746
- scopes: [computeScope(p2.file)],
2825
+ ...domScope ? { scopes: [domScope] } : {},
2747
2826
  ...meta ? { meta } : {}
2748
2827
  };
2749
2828
  registry.add(primitive);
@@ -2753,13 +2832,13 @@ function resolve3(ctx) {
2753
2832
  if (primitiveConventions && fileMatchesAny(file, primitiveConventions)) {
2754
2833
  const name = kebab(baseName(file));
2755
2834
  if (!name) continue;
2756
- const scope = computeScope(file);
2835
+ const convScope = computeScope(file);
2757
2836
  const meta = metaWithComposes("primitive", name);
2758
2837
  const primitive = {
2759
2838
  kind: "primitive",
2760
2839
  id: name,
2761
2840
  loc: { file },
2762
- scopes: [scope],
2841
+ ...convScope ? { scopes: [convScope] } : {},
2763
2842
  ...meta ? { meta } : {}
2764
2843
  };
2765
2844
  registry.add(primitive);
@@ -2789,7 +2868,8 @@ function resolve3(ctx) {
2789
2868
  kind: "flow",
2790
2869
  id: flowExport.id,
2791
2870
  loc: base.loc,
2792
- touches: base.touches
2871
+ touches: base.touches,
2872
+ steps: base.steps
2793
2873
  };
2794
2874
  registry.add(flow);
2795
2875
  } else {
@@ -2834,7 +2914,7 @@ function computeScope(displayPath) {
2834
2914
  if (pagesIdx !== -1 && parts[pagesIdx + 1]) {
2835
2915
  return `page:${parts[pagesIdx + 1]}`;
2836
2916
  }
2837
- return "global";
2917
+ return null;
2838
2918
  }
2839
2919
  function extractFlowsFromSource(file) {
2840
2920
  const flows = [];
@@ -2868,17 +2948,18 @@ function extractFlowsFromSource(file) {
2868
2948
  kind: "flow",
2869
2949
  id,
2870
2950
  loc: { file: file.displayPath, line },
2871
- touches: dedupe(touches.map((t) => t.id))
2951
+ touches: dedupe(touches.map((t) => t.id)),
2952
+ steps: touches.filter((t) => t.action).map((t) => ({ entityId: t.id, action: t.action }))
2872
2953
  });
2873
2954
  }
2874
2955
  return flows;
2875
2956
  }
2876
2957
  function captureUidexIds(body) {
2877
2958
  const out2 = [];
2878
- const re = /uidex\(\s*(?:'([^']+)'|"([^"]+)"|`([^`$]+)`)\s*\)/g;
2959
+ const re = /uidex\(\s*(?:'([^']+)'|"([^"]+)"|`([^`$]+)`)\s*\)(?:\.(\w+)\s*\()?/g;
2879
2960
  let m;
2880
2961
  while ((m = re.exec(body)) !== null) {
2881
- out2.push({ id: m[1] || m[2] || m[3] });
2962
+ out2.push({ id: m[1] || m[2] || m[3], action: m[4] });
2882
2963
  }
2883
2964
  return out2;
2884
2965
  }
@@ -2918,7 +2999,7 @@ function runOne(dc, opts) {
2918
2999
  gitContext,
2919
3000
  typeMode: config.typeMode
2920
3001
  });
2921
- const outputPath = path6.resolve(configDir, config.output);
3002
+ const outputPath = path7.resolve(configDir, config.output);
2922
3003
  const outputRel = config.output;
2923
3004
  let existingOnDisk = null;
2924
3005
  if (opts.check) {
@@ -2954,13 +3035,13 @@ function runOne(dc, opts) {
2954
3035
  };
2955
3036
  }
2956
3037
  function writeScanResult(result) {
2957
- fs5.mkdirSync(path6.dirname(result.outputPath), { recursive: true });
3038
+ fs5.mkdirSync(path7.dirname(result.outputPath), { recursive: true });
2958
3039
  fs5.writeFileSync(result.outputPath, result.generated, "utf8");
2959
3040
  }
2960
3041
 
2961
3042
  // src/scan/scaffold.ts
2962
3043
  var fs6 = __toESM(require("fs"), 1);
2963
- var path7 = __toESM(require("path"), 1);
3044
+ var path8 = __toESM(require("path"), 1);
2964
3045
  function scaffoldWidgetSpec(opts) {
2965
3046
  const {
2966
3047
  registry,
@@ -2975,7 +3056,7 @@ function scaffoldWidgetSpec(opts) {
2975
3056
  }
2976
3057
  const criteria = widget.meta?.acceptance ?? [];
2977
3058
  const filename = `widget-${widgetId}.spec.ts`;
2978
- const outputPath = path7.resolve(outDir, filename);
3059
+ const outputPath = path8.resolve(outDir, filename);
2979
3060
  if (fs6.existsSync(outputPath) && !force) {
2980
3061
  return {
2981
3062
  outputPath,
@@ -2989,7 +3070,7 @@ function scaffoldWidgetSpec(opts) {
2989
3070
  criteria,
2990
3071
  fixtureImport
2991
3072
  });
2992
- fs6.mkdirSync(path7.dirname(outputPath), { recursive: true });
3073
+ fs6.mkdirSync(path8.dirname(outputPath), { recursive: true });
2993
3074
  fs6.writeFileSync(outputPath, content, "utf8");
2994
3075
  return { outputPath, written: true, skipped: false };
2995
3076
  }
@@ -3103,7 +3184,7 @@ function helpText2() {
3103
3184
  ].join("\n");
3104
3185
  }
3105
3186
  function runInit(cwd, w) {
3106
- const configPath = path8.join(cwd, CONFIG_FILENAME);
3187
+ const configPath = path9.join(cwd, CONFIG_FILENAME);
3107
3188
  if (fs7.existsSync(configPath)) {
3108
3189
  w.err(`.uidex.json already exists at ${configPath}`);
3109
3190
  return w.result(1);
@@ -3115,7 +3196,7 @@ function runInit(cwd, w) {
3115
3196
  };
3116
3197
  fs7.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
3117
3198
  w.out(`Created ${configPath}`);
3118
- const gitignorePath = path8.join(cwd, ".gitignore");
3199
+ const gitignorePath = path9.join(cwd, ".gitignore");
3119
3200
  const entry = "*.gen.ts";
3120
3201
  if (fs7.existsSync(gitignorePath)) {
3121
3202
  const existing = fs7.readFileSync(gitignorePath, "utf8");
@@ -3193,7 +3274,7 @@ function runScaffold(cwd, args, flags, w) {
3193
3274
  for (const r of results) {
3194
3275
  const widget = r.registry.get("widget", id);
3195
3276
  if (!widget) continue;
3196
- const outDir = path8.resolve(r.configDir, "e2e");
3277
+ const outDir = path9.resolve(r.configDir, "e2e");
3197
3278
  const result = scaffoldWidgetSpec({
3198
3279
  registry: r.registry,
3199
3280
  widgetId: id,