@clearlydev/nx-archweave 0.1.0 → 0.2.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 (59) hide show
  1. package/README.md +2 -2
  2. package/dist/eslint/fsd-boundaries.d.ts +27 -10
  3. package/dist/eslint/fsd-policy.d.ts +38 -0
  4. package/dist/eslint/index.d.ts +10 -9
  5. package/dist/eslint/index.js +227 -34
  6. package/dist/eslint/slice-groups.d.ts +12 -0
  7. package/dist/generators/add-agent-rules/generator.js +1175 -23
  8. package/dist/generators/add-backend/generator.js +1127 -75
  9. package/dist/generators/add-fsd-lite/generator.js +1274 -96
  10. package/dist/generators/add-integration/generator.js +4 -35
  11. package/dist/generators/add-review/files/dependabot.yml +7 -0
  12. package/dist/generators/add-review/generator.js +1100 -46
  13. package/dist/generators/doctor/generator.js +784 -131
  14. package/dist/generators/entity/generator.js +251 -32
  15. package/dist/generators/feature/generator.js +251 -32
  16. package/dist/generators/init/generator.js +1955 -549
  17. package/dist/generators/install-integration/generator.js +5 -37
  18. package/dist/generators/page/generator.js +251 -32
  19. package/dist/generators/record-integration/generator.js +5 -36
  20. package/dist/generators/shared/generator.js +251 -32
  21. package/dist/generators/widget/generator.js +251 -32
  22. package/dist/index.js +4 -35
  23. package/package.json +5 -3
  24. package/src/generators/add-agent-rules/schema.json +5 -0
  25. package/src/generators/add-fsd-lite/schema.json +2 -2
  26. package/src/generators/add-integration/schema.json +1 -1
  27. package/src/generators/entity/schema.json +11 -1
  28. package/src/generators/feature/schema.json +11 -1
  29. package/src/generators/init/schema.json +13 -2
  30. package/src/generators/page/schema.json +15 -3
  31. package/src/generators/shared/schema.json +7 -1
  32. package/src/generators/widget/schema.json +11 -1
  33. package/dist/eslint/fsd-boundaries.js +0 -144
  34. package/dist/eslint/tsconfig-aliases.js +0 -114
  35. package/dist/generators/add-agent-rules/render.js +0 -46
  36. package/dist/generators/add-backend/render.js +0 -102
  37. package/dist/generators/add-fsd-lite/render.js +0 -72
  38. package/dist/generators/add-github-templates/render.js +0 -26
  39. package/dist/generators/add-integration/render.js +0 -34
  40. package/dist/generators/add-review/render.js +0 -96
  41. package/dist/generators/allow-builds/render.js +0 -82
  42. package/dist/generators/doctor/integration-report.js +0 -70
  43. package/dist/generators/doctor/render.js +0 -10
  44. package/dist/generators/init/render.js +0 -125
  45. package/dist/generators/init/validation.js +0 -74
  46. package/dist/generators/install-integration/render.js +0 -128
  47. package/dist/generators/record-integration/render.js +0 -31
  48. package/dist/generators/shared/dry-run.js +0 -12
  49. package/dist/generators/shared/frontend-app.js +0 -57
  50. package/dist/generators/shared/project-type.js +0 -39
  51. package/dist/generators/shared/record-pipeline.js +0 -34
  52. package/dist/generators/shared/run-nx.js +0 -24
  53. package/dist/generators/shared/template-files.js +0 -38
  54. package/dist/generators/shared/toolkit-config-file.js +0 -110
  55. package/dist/generators/slice/core.js +0 -185
  56. package/dist/generators/slice/render.js +0 -70
  57. package/dist/lib/nx-plugin.js +0 -7
  58. package/dist/runtime/integrations-composition-root.js +0 -20
  59. package/dist/runtime/node-command-runner.js +0 -81
@@ -247,33 +247,6 @@ function recordMetadataFor(entry) {
247
247
  return { source: claude.marketplace, repository: entry.repository, method: CLAUDE_MARKETPLACE_METHOD };
248
248
  }
249
249
 
250
- // ../integrations/dist/catalog/resolve-version.js
251
- import { get } from "node:https";
252
- function resolveVersionFromNpmRegistry(npmPackage) {
253
- const registryUrl = `https://registry.npmjs.org/${encodeURIComponent(npmPackage)}`;
254
- return new Promise((resolve2, reject) => {
255
- get(registryUrl, (response) => {
256
- let body = "";
257
- response.on("data", (chunk) => {
258
- body += chunk;
259
- });
260
- response.on("end", () => {
261
- try {
262
- const parsed = JSON.parse(body);
263
- const latest = parsed?.["dist-tags"]?.["latest"];
264
- if (typeof latest !== "string") {
265
- reject(new Error(`Could not resolve the latest version of ${npmPackage} from the npm registry.`));
266
- return;
267
- }
268
- resolve2(latest);
269
- } catch (error) {
270
- reject(error instanceof Error ? error : new Error(String(error)));
271
- }
272
- });
273
- }).on("error", reject);
274
- });
275
- }
276
-
277
250
  // ../integrations/dist/hosts/claude-code/detect.js
278
251
  import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
279
252
  import { join as join3 } from "node:path";
@@ -314,14 +287,14 @@ var WORKSPACE_FILES_BY_SCOPE = {
314
287
  local: [".claude/settings.local.json"]
315
288
  };
316
289
  var DEFAULT_PLUGIN_SCOPE = "project";
317
- function createClaudeMarketplacePlan(entry, deps) {
290
+ function createClaudeMarketplacePlan(entry) {
318
291
  return async (options) => {
319
292
  const distribution = entry.hosts["claude-code"];
320
293
  if (options.targetEnvironment !== "claude-code" || distribution === void 0) {
321
294
  const supported = Object.entries(entry.hosts).filter(([, value]) => value !== void 0).map(([host]) => host).join(", ");
322
295
  throw new Error(`Target environment "${options.targetEnvironment}" is not supported by this integration. Supported: ${supported}.`);
323
296
  }
324
- const resolvedVersion = options.versionRange ?? (options.resolveLatestVersion === false ? VERSION_DECIDED_BY_MARKETPLACE : await deps.resolveVersion(options.versionRange));
297
+ const resolvedVersion = options.versionRange ?? VERSION_DECIDED_BY_MARKETPLACE;
325
298
  const scope = options.scope ?? DEFAULT_PLUGIN_SCOPE;
326
299
  return {
327
300
  officialCommand: {
@@ -752,10 +725,6 @@ function createClaudeMarketplaceVerifier(entry, deps) {
752
725
  }
753
726
 
754
727
  // ../integrations/dist/catalog/create-catalog-integration.js
755
- function defaultResolveVersion(entry) {
756
- const npmPackage = entry.npmPackage;
757
- return npmPackage === void 0 ? async () => VERSION_DECIDED_BY_MARKETPLACE : () => resolveVersionFromNpmRegistry(npmPackage);
758
- }
759
728
  function createCatalogIntegration(entry, deps) {
760
729
  return {
761
730
  id: entry.id,
@@ -765,7 +734,7 @@ function createCatalogIntegration(entry, deps) {
765
734
  license: entry.license,
766
735
  supportedEnvironments: PLUGIN_HOSTS.filter((host) => entry.hosts[host] !== void 0),
767
736
  detect: (context) => detectWorkspacePackage(entry, context),
768
- plan: createClaudeMarketplacePlan(entry, { resolveVersion: deps.resolveVersion ?? defaultResolveVersion(entry) }),
737
+ plan: createClaudeMarketplacePlan(entry),
769
738
  install: createClaudeMarketplaceInstall(entry, { runner: deps.runner }),
770
739
  verify: createClaudeMarketplaceVerifier(entry, { runner: deps.runner }),
771
740
  uninstallInstructions: async () => [...entry.uninstallSteps]
@@ -1074,7 +1043,7 @@ var NodeCommandRunner = class {
1074
1043
  // src/runtime/integrations-composition-root.ts
1075
1044
  function createIntegrationsCompositionRoot(deps = {}) {
1076
1045
  const runner = deps.runner ?? new NodeCommandRunner();
1077
- const registry = createIntegrationRegistry(createCatalogIntegrations({ runner, resolveVersion: deps.resolveVersion }));
1046
+ const registry = createIntegrationRegistry(createCatalogIntegrations({ runner }));
1078
1047
  return {
1079
1048
  registry,
1080
1049
  runner,
@@ -1110,8 +1079,7 @@ async function runInstallIntegration(tree, options, deps = {}) {
1110
1079
  const plan = await integration.plan({
1111
1080
  targetEnvironment: target,
1112
1081
  versionRange: options.versionRange,
1113
- scope,
1114
- resolveLatestVersion: false
1082
+ scope
1115
1083
  });
1116
1084
  const pre = await verifyViaClaudeCli(root.runner, pluginId, tree.root);
1117
1085
  let currentState;
@@ -1,6 +1,113 @@
1
1
  // src/generators/slice/core.ts
2
2
  import { join } from "node:path";
3
3
 
4
+ // ../template-engine/dist/backend/eslint-boundaries.js
5
+ var DEFAULT_BOUNDARY_TAGS = { frontend: "scope:frontend", backend: "scope:backend", shared: "scope:shared" };
6
+ function readBoundaryTags(config) {
7
+ const boundaries = config?.["boundaries"];
8
+ const tags = typeof boundaries === "object" && boundaries !== null ? boundaries["tags"] : void 0;
9
+ const pick = (role) => {
10
+ const value = typeof tags === "object" && tags !== null ? tags[role] : void 0;
11
+ return typeof value === "string" && value.trim() !== "" ? value : DEFAULT_BOUNDARY_TAGS[role];
12
+ };
13
+ return { frontend: pick("frontend"), backend: pick("backend"), shared: pick("shared") };
14
+ }
15
+ function scopeConstraints(tags = DEFAULT_BOUNDARY_TAGS) {
16
+ return [
17
+ { sourceTag: tags.frontend, notDependOnLibsWithTags: [tags.backend] },
18
+ { sourceTag: tags.backend, notDependOnLibsWithTags: [tags.frontend] },
19
+ { sourceTag: tags.shared, notDependOnLibsWithTags: [tags.frontend, tags.backend] }
20
+ ];
21
+ }
22
+ var SCOPE_CONSTRAINTS = scopeConstraints();
23
+
24
+ // ../template-engine/dist/frameworks/profiles.js
25
+ var ALL_LAYERS = ["app", "pages", "features", "entities", "shared"];
26
+ var NEXT = {
27
+ id: "next",
28
+ recognised: true,
29
+ // Next.js's own app/ (App Router) directory is the composition root; FSD's Next.js guide
30
+ // names the FSD app and pages layers _app and _pages, since Next.js reads src/app and
31
+ // src/pages as its routers.
32
+ layers: ["pages", "features", "entities", "shared"],
33
+ layerDirs: { app: "_app", pages: "_pages" },
34
+ buildOutputDirs: [".next", "out"],
35
+ platformSuffixes: [],
36
+ routerDirs: ["pages"]
37
+ };
38
+ var REACT_VITE = {
39
+ id: "react-vite",
40
+ recognised: true,
41
+ layers: ALL_LAYERS,
42
+ layerDirs: {},
43
+ buildOutputDirs: ["dist"],
44
+ platformSuffixes: [],
45
+ routerDirs: []
46
+ };
47
+ var EXPO_ROUTER = {
48
+ id: "expo",
49
+ recognised: true,
50
+ layers: ALL_LAYERS,
51
+ layerDirs: { app: "_app" },
52
+ buildOutputDirs: [".expo", "dist"],
53
+ platformSuffixes: ["ios", "android", "native", "web"],
54
+ routerDirs: []
55
+ };
56
+ var EXPO = { ...EXPO_ROUTER, layerDirs: {} };
57
+ var GENERIC = {
58
+ id: "generic",
59
+ recognised: false,
60
+ layers: ALL_LAYERS,
61
+ layerDirs: {},
62
+ buildOutputDirs: ["dist", "build"],
63
+ platformSuffixes: [],
64
+ routerDirs: []
65
+ };
66
+ var PROFILES = { next: NEXT, "react-vite": REACT_VITE, expo: EXPO_ROUTER, generic: GENERIC };
67
+ function profileById(id) {
68
+ return PROFILES[id];
69
+ }
70
+ var NEXT_CONFIGS = ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"];
71
+ var VITE_CONFIGS = ["vite.config.ts", "vite.config.mts", "vite.config.js", "vite.config.mjs"];
72
+ var EXPO_CONFIGS = ["app.config.ts", "app.config.js", "app.config.mjs"];
73
+ function hasExpoKey(input, file) {
74
+ const text = input.read(file);
75
+ if (typeof text !== "string")
76
+ return false;
77
+ try {
78
+ return typeof JSON.parse(text)["expo"] === "object";
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+ function dependsOn(input, manifest, name) {
84
+ const text = input.read(manifest);
85
+ if (typeof text !== "string")
86
+ return false;
87
+ try {
88
+ const parsed = JSON.parse(text);
89
+ return ["dependencies", "devDependencies", "peerDependencies"].some((field) => parsed[field]?.[name] !== void 0);
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+ function detectFramework(input) {
95
+ const prefix = input.projectRoot === "" || input.projectRoot === "." ? "" : `${input.projectRoot}/`;
96
+ const executors = input.executors ?? [];
97
+ if (NEXT_CONFIGS.some((file) => input.exists(`${prefix}${file}`)) || executors.some((executor) => executor.startsWith("@nx/next:"))) {
98
+ return NEXT;
99
+ }
100
+ const dependsAnywhere = (name) => dependsOn(input, `${prefix}package.json`, name) || dependsOn(input, "package.json", name);
101
+ const usesExpo = executors.some((executor) => executor.startsWith("@nx/expo:")) || hasExpoKey(input, `${prefix}app.json`) || EXPO_CONFIGS.some((file) => input.exists(`${prefix}${file}`)) || dependsOn(input, `${prefix}package.json`, "expo");
102
+ if (usesExpo)
103
+ return dependsAnywhere("expo-router") ? EXPO_ROUTER : EXPO;
104
+ const usesReact = dependsAnywhere("react");
105
+ const usesVite = VITE_CONFIGS.some((file) => input.exists(`${prefix}${file}`)) || executors.some((executor) => executor.startsWith("@nx/vite:"));
106
+ if (usesVite && usesReact)
107
+ return REACT_VITE;
108
+ return GENERIC;
109
+ }
110
+
4
111
  // ../template-engine/dist/fsd/slice-rules.js
5
112
  function toCamelCase(kebabName) {
6
113
  return kebabName.split("-").filter((part) => part.length > 0).map((part, index) => index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)).join("");
@@ -102,7 +209,14 @@ function resolveLocaleOption(option, config) {
102
209
  const locale = config?.["locale"];
103
210
  return locale === "ko" || locale === "en" ? locale : "en";
104
211
  }
105
- var RECORDED_FRONTENDS = ["react-vite", "next", "none"];
212
+ var RECORDED_FRONTENDS = ["react-vite", "next", "expo", "generic", "none"];
213
+ function recordedFrontends(config) {
214
+ const frontends = config?.["frontends"];
215
+ if (!Array.isArray(frontends)) return [];
216
+ return frontends.flatMap(
217
+ (entry) => typeof entry === "object" && entry !== null && typeof entry["project"] === "string" ? [entry.project] : []
218
+ );
219
+ }
106
220
 
107
221
  // src/generators/shared/frontend-app.ts
108
222
  import { getProjects } from "@nx/devkit";
@@ -130,12 +244,27 @@ function isEndToEnd(root) {
130
244
  const dir = baseNameOf(root);
131
245
  return dir === "e2e" || dir.endsWith("-e2e");
132
246
  }
133
- function isBackend(root, config) {
134
- return baseNameOf(root) === "api" || (config.tags ?? []).includes("scope:backend");
247
+ function isBackend(root, config, backendTag) {
248
+ return baseNameOf(root) === "api" || (config.tags ?? []).includes(backendTag);
135
249
  }
136
- function sourceRootOf(config) {
137
- if (config.sourceRoot) return config.sourceRoot;
138
- return config.root === "" || config.root === "." ? "src" : `${config.root}/src`;
250
+ function prefixOf(root) {
251
+ return root === "" || root === "." ? "" : `${root}/`;
252
+ }
253
+ function sourceRootOf(tree, config) {
254
+ const prefix = prefixOf(config.root);
255
+ const declared = config.sourceRoot?.replace(/\/$/, "");
256
+ const atRoot = declared !== void 0 && (declared === config.root || prefix === "" && (declared === "." || declared === ""));
257
+ if (declared && !atRoot) return declared;
258
+ if (atRoot && !tree.exists(`${prefix}src`)) return prefix === "" ? "." : config.root;
259
+ return `${prefix}src`;
260
+ }
261
+ function frameworkOf(tree, config) {
262
+ return detectFramework({
263
+ exists: (path) => tree.exists(path),
264
+ read: (path) => tree.read(path, "utf-8"),
265
+ projectRoot: config.root,
266
+ executors: Object.values(config.targets ?? {}).flatMap((target) => target.executor ? [target.executor] : [])
267
+ });
139
268
  }
140
269
  function resolveFrontendApp(tree, explicitProject) {
141
270
  let projects;
@@ -146,27 +275,51 @@ function resolveFrontendApp(tree, explicitProject) {
146
275
  const pathMatch = /Cannot parse ([^:]+):/.exec(message);
147
276
  return { kind: "invalid-project-json", detail: pathMatch ? pathMatch[1] : message };
148
277
  }
149
- const found = (project2, config2) => ({
150
- kind: "found",
151
- project: project2,
152
- root: config2.root,
153
- sourceRoot: sourceRootOf(config2)
154
- });
278
+ const found = (project2, config2) => {
279
+ const profile = frameworkOf(tree, config2);
280
+ return { kind: "found", project: project2, root: config2.root, sourceRoot: sourceRootOf(tree, config2), framework: profile.id, profile };
281
+ };
155
282
  if (explicitProject !== void 0) {
156
283
  const config2 = projects.get(explicitProject);
157
- return config2 ? found(explicitProject, config2) : { kind: "not-found", project: explicitProject };
284
+ if (config2) return found(explicitProject, config2);
285
+ const rootApp = inferredRootApp(tree, projects);
286
+ return rootApp?.kind === "found" && rootApp.project === explicitProject ? rootApp : { kind: "not-found", project: explicitProject };
158
287
  }
159
- const eligible = [...projects.entries()].filter(([, config2]) => !isEndToEnd(config2.root) && !isBackend(config2.root, config2));
288
+ const backendTag = readBoundaryTags(readToolkitConfig(tree)).backend;
289
+ const eligible = [...projects.entries()].filter(([, config2]) => !isEndToEnd(config2.root) && !isBackend(config2.root, config2, backendTag));
160
290
  const declared = eligible.filter(([, config2]) => config2.projectType === "application");
161
- const inferred = eligible.filter(
162
- ([, config2]) => config2.projectType === void 0 && config2.root !== "" && config2.root !== "." && resolveProjectType(tree, config2.root, void 0) === "application"
163
- );
291
+ const inferred = eligible.filter(([, config2]) => {
292
+ if (config2.projectType !== void 0) return false;
293
+ if (config2.root === "" || config2.root === ".") return frameworkOf(tree, config2).recognised;
294
+ return resolveProjectType(tree, config2.root, void 0) === "application";
295
+ });
164
296
  const candidates = (declared.length > 0 ? declared : inferred).sort(([a], [b]) => a.localeCompare(b));
165
- if (candidates.length === 0) return { kind: "none" };
297
+ if (candidates.length === 0) {
298
+ const rootApp = inferredRootApp(tree, projects);
299
+ if (rootApp !== null) return rootApp;
300
+ const otherProjects = [...projects.keys()].filter((name) => !isEndToEnd(projects.get(name)?.root ?? "")).sort();
301
+ return { kind: "none", otherProjects };
302
+ }
166
303
  if (candidates.length > 1) return { kind: "ambiguous", candidates: candidates.map(([name]) => name) };
167
304
  const [project, config] = candidates[0];
168
305
  return found(project, config);
169
306
  }
307
+ function inferredRootApp(tree, projects) {
308
+ if ([...projects.values()].some((config2) => config2.root === "" || config2.root === ".")) return null;
309
+ const raw = tree.read("package.json", "utf-8");
310
+ if (raw === null) return null;
311
+ let name;
312
+ try {
313
+ name = JSON.parse(raw).name;
314
+ } catch {
315
+ return null;
316
+ }
317
+ if (typeof name !== "string" || name === "") return null;
318
+ const config = { name, root: "." };
319
+ const framework = frameworkOf(tree, config);
320
+ if (!framework.recognised) return null;
321
+ return { kind: "found", project: name, root: ".", sourceRoot: sourceRootOf(tree, config), framework: framework.id, profile: framework, inferredRoot: true };
322
+ }
170
323
 
171
324
  // src/generators/shared/template-files.ts
172
325
  import { existsSync } from "node:fs";
@@ -192,7 +345,7 @@ function renderTemplateFiles(srcFolder, target, substitutions) {
192
345
  // src/generators/slice/core.ts
193
346
  function composeSlice(input) {
194
347
  const files = generatorFilesDir("slice", import.meta.url);
195
- const directory = `${input.sourceRoot}/${input.layerDirName}/${input.name}`;
348
+ const directory = `${input.sourceRoot}/${input.layerDirName}/${input.group === void 0 ? "" : `${input.group}/`}${input.name}`;
196
349
  const shared = input.layer === "shared";
197
350
  const values = {
198
351
  name: input.name,
@@ -201,7 +354,7 @@ function composeSlice(input) {
201
354
  locale: input.locale,
202
355
  modulePath: shared ? `./${input.name}` : `./model/${input.name}`
203
356
  };
204
- const module = renderTemplateFiles(join(files, "module"), shared ? directory : `${directory}/model`, values).sort((a, b) => Number(a.filePath.endsWith(".spec.ts")) - Number(b.filePath.endsWith(".spec.ts")));
357
+ const module = renderTemplateFiles(join(files, "module"), shared ? directory : `${directory}/model`, values).filter((file) => input.withSpec !== false || !file.filePath.endsWith(".spec.ts")).sort((a, b) => Number(a.filePath.endsWith(".spec.ts")) - Number(b.filePath.endsWith(".spec.ts")));
205
358
  const segments = shared ? [] : Array.from(new Set(input.segments)).filter((segment) => segment !== "model").flatMap((segment) => renderTemplateFiles(join(files, "segment"), `${directory}/${segment}`, { ...values, segment }));
206
359
  return {
207
360
  directory,
@@ -211,20 +364,18 @@ function composeSlice(input) {
211
364
  }
212
365
  var KEBAB_CASE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
213
366
  function layerDirNameFor(layer, frontend) {
214
- return layer === "pages" && frontend === "next" ? "_pages" : layer;
367
+ return profileById(frontend).layerDirs[layer] ?? layer;
215
368
  }
216
369
  function layerDirFor(layer, sourceRoot, frontend = "react-vite") {
217
370
  return `${sourceRoot}/${layerDirNameFor(layer, frontend)}`;
218
371
  }
219
- var NEXT_CONFIG_FILES = ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"];
220
- function resolveFrontendForApp(option, config, appRoot, tree) {
221
- if (option === "react-vite" || option === "next") return option;
372
+ function resolveFrontendForApp(option, config, detected) {
373
+ if (option !== void 0) return option;
222
374
  const recorded = config?.["frontend"];
223
375
  if (typeof recorded === "string" && RECORDED_FRONTENDS.includes(recorded) && recorded !== "none") {
224
376
  return recorded;
225
377
  }
226
- const prefix = appRoot === "" || appRoot === "." ? "" : `${appRoot}/`;
227
- return NEXT_CONFIG_FILES.some((file) => tree.exists(`${prefix}${file}`)) ? "next" : "react-vite";
378
+ return detected;
228
379
  }
229
380
  function listFilesUnder(tree, dir, prefix = "") {
230
381
  return tree.children(dir).flatMap((child) => {
@@ -279,15 +430,23 @@ function runFsdSlice(tree, layer, options) {
279
430
  if (badSegment !== void 0) return refusal("invalid-segment", dryRun, layer, options.name, unresolved, { detail: badSegment });
280
431
  const essenceSegment = segments.find((segment) => isEssenceSegmentName(segment));
281
432
  if (essenceSegment !== void 0) return refusal("segment-by-essence", dryRun, layer, options.name, unresolved, { detail: essenceSegment });
433
+ if (options.group !== void 0 && layer === "shared") return refusal("group-on-shared", dryRun, layer, options.name, unresolved);
434
+ if (options.group !== void 0 && (!KEBAB_CASE.test(options.group) || SEGMENT_FOLDERS.includes(options.group) || isEssenceSegmentName(options.group))) {
435
+ return refusal("invalid-group", dryRun, layer, options.name, unresolved, { detail: options.group });
436
+ }
282
437
  const app = resolveFrontendApp(tree, options.project);
283
438
  if (app.kind === "not-found") return refusal("project-not-found", dryRun, layer, options.name, { project: app.project, sourceRoot: unresolved.sourceRoot });
284
- if (app.kind === "ambiguous") return refusal("ambiguous-frontend", dryRun, layer, options.name, unresolved, { candidates: app.candidates });
439
+ if (app.kind === "ambiguous") {
440
+ return refusal("ambiguous-frontend", dryRun, layer, options.name, unresolved, { candidates: app.candidates, recordedFrontends: recordedFrontends(readToolkitConfig(tree)) });
441
+ }
285
442
  if (app.kind === "invalid-project-json") return refusal("invalid-project-json", dryRun, layer, options.name, unresolved, { detail: app.detail });
286
- if (app.kind === "none") return refusal("no-frontend-app", dryRun, layer, options.name, unresolved);
443
+ if (app.kind === "none") return refusal("no-frontend-app", dryRun, layer, options.name, unresolved, { candidates: app.otherProjects });
287
444
  const config = readToolkitConfig(tree);
288
- const frontend = resolveFrontendForApp(options.frontend, config, app.root, tree);
445
+ const frontend = resolveFrontendForApp(options.frontend, config, app.framework);
289
446
  const layerDir = layerDirFor(layer, app.sourceRoot, frontend);
290
447
  const locale = resolveLocaleOption(options.locale, config);
448
+ const requested = options.unitTestRunner !== void 0 && options.unitTestRunner !== "auto" ? options.unitTestRunner : recordedUnitTestRunner(config);
449
+ const runner = requested ?? detectUnitTestRunner(tree, app.root);
291
450
  const plan = composeSlice({
292
451
  layer,
293
452
  name: options.name,
@@ -295,7 +454,9 @@ function runFsdSlice(tree, layer, options) {
295
454
  locale,
296
455
  sourceRoot: app.sourceRoot,
297
456
  layerDirName: layerDirNameFor(layer, frontend),
298
- segments
457
+ segments,
458
+ group: options.group,
459
+ withSpec: runner !== "none"
299
460
  });
300
461
  const action = computeSliceAction(tree, plan);
301
462
  if (action === "created" && !dryRun) {
@@ -312,9 +473,53 @@ function runFsdSlice(tree, layer, options) {
312
473
  refused: false,
313
474
  refusalReason: null,
314
475
  action,
315
- files: plan.files
476
+ files: plan.files,
477
+ ...runner === "none" ? { specSkipped: requested === "none" ? "none-requested" : "no-test-runner" } : {}
316
478
  };
317
479
  }
480
+ var SEGMENT_FOLDERS = ["ui", "model", "api", "lib", "config"];
481
+ function recordedUnitTestRunner(config) {
482
+ const fsd = config?.["fsd"];
483
+ const value = typeof fsd === "object" && fsd !== null ? fsd["unitTestRunner"] : void 0;
484
+ return value === "jest" || value === "vitest" || value === "none" ? value : void 0;
485
+ }
486
+ function hasDependency(tree, manifest, name) {
487
+ const raw = tree.read(manifest, "utf-8");
488
+ if (raw === null) return false;
489
+ try {
490
+ const parsed = JSON.parse(raw);
491
+ return parsed["dependencies"]?.[name] !== void 0 || parsed["devDependencies"]?.[name] !== void 0;
492
+ } catch {
493
+ return false;
494
+ }
495
+ }
496
+ function detectUnitTestRunner(tree, projectRoot) {
497
+ const prefix = projectRoot === "" || projectRoot === "." ? "" : `${projectRoot}/`;
498
+ const project = tree.read(`${prefix}project.json`, "utf-8");
499
+ if (project !== null) {
500
+ try {
501
+ const targets = JSON.parse(project).targets ?? {};
502
+ for (const target of Object.values(targets)) {
503
+ if (target?.executor?.startsWith("@nx/jest:")) return "jest";
504
+ if (target?.executor?.startsWith("@nx/vite:test") || target?.executor?.startsWith("@nx/vitest:")) return "vitest";
505
+ }
506
+ } catch {
507
+ }
508
+ }
509
+ for (const dir of prefix === "" ? [""] : [prefix, ""]) {
510
+ if (["ts", "mts", "cts", "js", "mjs", "cjs"].some((ext) => tree.exists(`${dir}jest.config.${ext}`))) return "jest";
511
+ if (["ts", "mts", "js", "mjs"].some((ext) => tree.exists(`${dir}vitest.config.${ext}`) || tree.exists(`${dir}vitest.workspace.${ext}`))) return "vitest";
512
+ for (const ext of ["ts", "mts", "js", "mjs"]) {
513
+ const vite = tree.read(`${dir}vite.config.${ext}`, "utf-8");
514
+ if (vite !== null && /\btest\s*:/.test(vite)) return "vitest";
515
+ }
516
+ }
517
+ for (const manifest of prefix === "" ? ["package.json"] : [`${prefix}package.json`, "package.json"]) {
518
+ if (hasDependency(tree, manifest, "vitest")) return "vitest";
519
+ if (hasDependency(tree, manifest, "jest")) return "jest";
520
+ }
521
+ return "none";
522
+ }
318
523
 
319
524
  // src/generators/slice/render.ts
320
525
  var ACTION_LINES = {
@@ -340,13 +545,22 @@ function renderSliceReport(report) {
340
545
  } else if (report.refusalReason === "ambiguous-frontend") {
341
546
  lines.push(" More than one frontend app was found \u2014 rerun with --project=<one of them>:");
342
547
  for (const name of report.candidates ?? []) lines.push(` - ${name}`);
548
+ if ((report.recordedFrontends ?? []).length > 0) lines.push(` (toolkit.config.json records: ${(report.recordedFrontends ?? []).join(", ")})`);
343
549
  } else if (report.refusalReason === "invalid-project-json") {
344
550
  lines.push(` A project.json could not be parsed (${report.detail ?? "unknown"}) \u2014 fix it, then rerun.`);
551
+ } else if (report.refusalReason === "no-frontend-app" && (report.candidates ?? []).length > 0) {
552
+ lines.push(" No project was recognised as a frontend app. If one of these is it, rerun with --project=<name>:");
553
+ for (const name of report.candidates ?? []) lines.push(` - ${name}`);
345
554
  } else if (report.refusalReason === "no-frontend-app") {
346
- lines.push(" The workspace has no frontend application to hold FSD layers \u2014 create one, or pass --project.");
555
+ lines.push(" The workspace has no projects to hold FSD layers \u2014 create a frontend app first.");
347
556
  } else if (report.refusalReason === "invalid-segment") {
348
557
  lines.push(` --segments: "${report.detail ?? ""}" is not a valid segment name.`);
349
558
  lines.push(" Use lowercase letters, digits, and single hyphens, starting with a letter (e.g. ui, api, lib).");
559
+ } else if (report.refusalReason === "group-on-shared") {
560
+ lines.push(" --group does not apply to shared: shared holds segments, not slices.");
561
+ } else if (report.refusalReason === "invalid-group") {
562
+ lines.push(` --group: "${report.detail ?? ""}" is not a usable group name.`);
563
+ lines.push(' Use kebab-case (e.g. "mypage"), and not a segment name (ui, model, api, lib, config).');
350
564
  } else if (report.refusalReason === "segments-on-shared") {
351
565
  lines.push(" --segments does not apply to shared: a shared segment holds no segments of its own.");
352
566
  lines.push(" Run the generator once per segment instead (e.g. --name=ui, then --name=api).");
@@ -369,6 +583,11 @@ function renderSliceReport(report) {
369
583
  lines.push(" \u2014 or remove the existing directory manually first if you intended to replace it.");
370
584
  return lines.join("\n");
371
585
  }
586
+ if (report.specSkipped === "no-test-runner") {
587
+ lines.push(" no test runner found (jest or vitest) \u2014 no .spec.ts was written; pass --unitTestRunner=jest|vitest to add one.");
588
+ } else if (report.specSkipped === "none-requested") {
589
+ lines.push(" no .spec.ts was written (unitTestRunner: none).");
590
+ }
372
591
  if (report.dryRun) {
373
592
  for (const file of report.files) {
374
593
  lines.push("");
@@ -221,33 +221,6 @@ function recordMetadataFor(entry) {
221
221
  return { source: claude.marketplace, repository: entry.repository, method: CLAUDE_MARKETPLACE_METHOD };
222
222
  }
223
223
 
224
- // ../integrations/dist/catalog/resolve-version.js
225
- import { get } from "node:https";
226
- function resolveVersionFromNpmRegistry(npmPackage) {
227
- const registryUrl = `https://registry.npmjs.org/${encodeURIComponent(npmPackage)}`;
228
- return new Promise((resolve2, reject) => {
229
- get(registryUrl, (response) => {
230
- let body = "";
231
- response.on("data", (chunk) => {
232
- body += chunk;
233
- });
234
- response.on("end", () => {
235
- try {
236
- const parsed = JSON.parse(body);
237
- const latest = parsed?.["dist-tags"]?.["latest"];
238
- if (typeof latest !== "string") {
239
- reject(new Error(`Could not resolve the latest version of ${npmPackage} from the npm registry.`));
240
- return;
241
- }
242
- resolve2(latest);
243
- } catch (error) {
244
- reject(error instanceof Error ? error : new Error(String(error)));
245
- }
246
- });
247
- }).on("error", reject);
248
- });
249
- }
250
-
251
224
  // ../integrations/dist/hosts/claude-code/detect.js
252
225
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
253
226
  import { join as join2 } from "node:path";
@@ -288,14 +261,14 @@ var WORKSPACE_FILES_BY_SCOPE = {
288
261
  local: [".claude/settings.local.json"]
289
262
  };
290
263
  var DEFAULT_PLUGIN_SCOPE = "project";
291
- function createClaudeMarketplacePlan(entry, deps) {
264
+ function createClaudeMarketplacePlan(entry) {
292
265
  return async (options) => {
293
266
  const distribution = entry.hosts["claude-code"];
294
267
  if (options.targetEnvironment !== "claude-code" || distribution === void 0) {
295
268
  const supported = Object.entries(entry.hosts).filter(([, value]) => value !== void 0).map(([host]) => host).join(", ");
296
269
  throw new Error(`Target environment "${options.targetEnvironment}" is not supported by this integration. Supported: ${supported}.`);
297
270
  }
298
- const resolvedVersion = options.versionRange ?? (options.resolveLatestVersion === false ? VERSION_DECIDED_BY_MARKETPLACE : await deps.resolveVersion(options.versionRange));
271
+ const resolvedVersion = options.versionRange ?? VERSION_DECIDED_BY_MARKETPLACE;
299
272
  const scope = options.scope ?? DEFAULT_PLUGIN_SCOPE;
300
273
  return {
301
274
  officialCommand: {
@@ -713,10 +686,6 @@ function createClaudeMarketplaceVerifier(entry, deps) {
713
686
  }
714
687
 
715
688
  // ../integrations/dist/catalog/create-catalog-integration.js
716
- function defaultResolveVersion(entry) {
717
- const npmPackage = entry.npmPackage;
718
- return npmPackage === void 0 ? async () => VERSION_DECIDED_BY_MARKETPLACE : () => resolveVersionFromNpmRegistry(npmPackage);
719
- }
720
689
  function createCatalogIntegration(entry, deps) {
721
690
  return {
722
691
  id: entry.id,
@@ -726,7 +695,7 @@ function createCatalogIntegration(entry, deps) {
726
695
  license: entry.license,
727
696
  supportedEnvironments: PLUGIN_HOSTS.filter((host) => entry.hosts[host] !== void 0),
728
697
  detect: (context) => detectWorkspacePackage(entry, context),
729
- plan: createClaudeMarketplacePlan(entry, { resolveVersion: deps.resolveVersion ?? defaultResolveVersion(entry) }),
698
+ plan: createClaudeMarketplacePlan(entry),
730
699
  install: createClaudeMarketplaceInstall(entry, { runner: deps.runner }),
731
700
  verify: createClaudeMarketplaceVerifier(entry, { runner: deps.runner }),
732
701
  uninstallInstructions: async () => [...entry.uninstallSteps]
@@ -803,7 +772,7 @@ var NodeCommandRunner = class {
803
772
  // src/runtime/integrations-composition-root.ts
804
773
  function createIntegrationsCompositionRoot(deps = {}) {
805
774
  const runner = deps.runner ?? new NodeCommandRunner();
806
- const registry = createIntegrationRegistry(createCatalogIntegrations({ runner, resolveVersion: deps.resolveVersion }));
775
+ const registry = createIntegrationRegistry(createCatalogIntegrations({ runner }));
807
776
  return {
808
777
  registry,
809
778
  runner,
@@ -858,7 +827,7 @@ async function recordIntegrationGenerator(tree, options, deps = {}) {
858
827
  if (!metadata) {
859
828
  throw new Error(`No manifest metadata is defined for "${options.tool}".`);
860
829
  }
861
- await integration.plan({ targetEnvironment: target, resolveLatestVersion: false });
830
+ await integration.plan({ targetEnvironment: target });
862
831
  const outcome = await runRecordPipeline({
863
832
  id: integration.id,
864
833
  runner: root.runner,