@clearlydev/nx-archweave 0.1.1 → 0.2.1

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 (54) hide show
  1. package/README.md +1 -1
  2. package/dist/eslint/fsd-boundaries.d.ts +25 -8
  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-review/files/dependabot.yml +7 -0
  11. package/dist/generators/add-review/generator.js +1100 -46
  12. package/dist/generators/doctor/generator.js +780 -96
  13. package/dist/generators/entity/generator.js +251 -32
  14. package/dist/generators/feature/generator.js +251 -32
  15. package/dist/generators/init/generator.js +1955 -549
  16. package/dist/generators/page/generator.js +251 -32
  17. package/dist/generators/shared/generator.js +251 -32
  18. package/dist/generators/widget/generator.js +251 -32
  19. package/package.json +4 -1
  20. package/src/generators/add-agent-rules/schema.json +5 -0
  21. package/src/generators/add-fsd-lite/schema.json +2 -2
  22. package/src/generators/entity/schema.json +11 -1
  23. package/src/generators/feature/schema.json +11 -1
  24. package/src/generators/init/schema.json +13 -2
  25. package/src/generators/page/schema.json +15 -3
  26. package/src/generators/shared/schema.json +7 -1
  27. package/src/generators/widget/schema.json +11 -1
  28. package/dist/eslint/fsd-boundaries.js +0 -144
  29. package/dist/eslint/tsconfig-aliases.js +0 -114
  30. package/dist/generators/add-agent-rules/render.js +0 -46
  31. package/dist/generators/add-backend/render.js +0 -102
  32. package/dist/generators/add-fsd-lite/render.js +0 -72
  33. package/dist/generators/add-github-templates/render.js +0 -26
  34. package/dist/generators/add-integration/render.js +0 -34
  35. package/dist/generators/add-review/render.js +0 -96
  36. package/dist/generators/allow-builds/render.js +0 -82
  37. package/dist/generators/doctor/integration-report.js +0 -70
  38. package/dist/generators/doctor/render.js +0 -10
  39. package/dist/generators/init/render.js +0 -125
  40. package/dist/generators/init/validation.js +0 -74
  41. package/dist/generators/install-integration/render.js +0 -128
  42. package/dist/generators/record-integration/render.js +0 -31
  43. package/dist/generators/shared/dry-run.js +0 -12
  44. package/dist/generators/shared/frontend-app.js +0 -57
  45. package/dist/generators/shared/project-type.js +0 -39
  46. package/dist/generators/shared/record-pipeline.js +0 -34
  47. package/dist/generators/shared/run-nx.js +0 -24
  48. package/dist/generators/shared/template-files.js +0 -38
  49. package/dist/generators/shared/toolkit-config-file.js +0 -110
  50. package/dist/generators/slice/core.js +0 -185
  51. package/dist/generators/slice/render.js +0 -70
  52. package/dist/lib/nx-plugin.js +0 -7
  53. package/dist/runtime/integrations-composition-root.js +0 -20
  54. package/dist/runtime/node-command-runner.js +0 -81
@@ -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("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clearlydev/nx-archweave",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Nx generators that make a workspace ready to work on in one preset: FSD Lite structure with enforced module boundaries, AI agent rules, GitHub templates, and optional AI PR review (Claude, Gemini, GPT, Cursor)",
5
5
  "license": "MIT",
6
6
  "author": "clearlydev (https://github.com/clearlydev)",
@@ -93,5 +93,8 @@
93
93
  },
94
94
  "dependencies": {
95
95
  "@nx/devkit": "^23.1.0"
96
+ },
97
+ "devDependencies": {
98
+ "jsonc-parser": "3.3.1"
96
99
  }
97
100
  }
@@ -33,6 +33,11 @@
33
33
  "type": "boolean",
34
34
  "description": "Include the Team Rules section (collaboration rules for a shared repository). Defaults to toolkit.config.json's agentRules.teamRules, else false"
35
35
  },
36
+ "overwriteBlock": {
37
+ "type": "boolean",
38
+ "default": false,
39
+ "description": "Replace the nx-archweave block even when it was edited by hand (otherwise an edited block is kept and the report shows the difference)"
40
+ },
36
41
  "dryRun": {
37
42
  "type": "boolean",
38
43
  "default": false,
@@ -7,8 +7,8 @@
7
7
  "properties": {
8
8
  "frontend": {
9
9
  "type": "string",
10
- "enum": ["react-vite", "next"],
11
- "description": "Target frontend — decides the layer set the guide prints. Next.js has no FSD 'app' layer (its App Router directory is it) and its pages layer is the _pages/ directory (FSD's Next.js guide: Next.js reads src/pages as its Pages Router). Omitted: toolkit.config.json's frontend (init records it); required when none is recorded"
10
+ "enum": ["react-vite", "next", "expo", "generic"],
11
+ "description": "Target frontend — decides the layer set the guide prints. Next.js has no FSD 'app' layer (its App Router directory is it) and its pages layer is the _pages/ directory (FSD's Next.js guide: Next.js reads src/pages as its Pages Router). 'generic' is any other framework: the common FSD rules, no renamed layers. Omitted: toolkit.config.json's frontend (init records it); required when none is recorded"
12
12
  },
13
13
  "project": {
14
14
  "type": "string",
@@ -7,7 +7,8 @@
7
7
  "properties": {
8
8
  "name": {
9
9
  "type": "string",
10
- "description": "Slice name in kebab-case (e.g. \"user\") — becomes <app>/src/entities/<name>. Invalid values are refused with a report, not thrown"
10
+ "description": "Slice name in kebab-case (e.g. \"user\") — becomes <app>/src/entities/<name>. Invalid values are refused with a report, not thrown",
11
+ "$default": { "$source": "argv", "index": 0 }
11
12
  },
12
13
  "publicApiName": {
13
14
  "type": "string",
@@ -18,6 +19,15 @@
18
19
  "items": { "type": "string" },
19
20
  "description": "Extra segments to start the slice with, each as <segment>/index.ts (e.g. ui,api). model always exists — it holds the stub. Names are by purpose: ui, api, lib, config … (components/hooks/utils/types are refused)"
20
21
  },
22
+ "group": {
23
+ "type": "string",
24
+ "description": "A slice group folder to put the slice in: <layer>/<group>/<name>/ (e.g. --group=mypage). The group folder itself gets no files — FSD keeps each slice in it separate"
25
+ },
26
+ "unitTestRunner": {
27
+ "type": "string",
28
+ "enum": ["auto", "jest", "vitest", "none"],
29
+ "description": "Whether the stub gets a .spec.ts. auto (default): when the app or workspace has jest or vitest (a test target, a runner config, or the dependency); toolkit.config.json's fsd.unitTestRunner comes before detection"
30
+ },
21
31
  "project": {
22
32
  "type": "string",
23
33
  "description": "The frontend app whose source root holds the FSD layers (run add-fsd-lite first). Omitted: the workspace's one frontend application; with more than one, the run is refused with the candidates listed"
@@ -7,7 +7,8 @@
7
7
  "properties": {
8
8
  "name": {
9
9
  "type": "string",
10
- "description": "Slice name in kebab-case (e.g. \"add-to-cart\") — becomes <app>/src/features/<name>. Invalid values are refused with a report, not thrown"
10
+ "description": "Slice name in kebab-case (e.g. \"add-to-cart\") — becomes <app>/src/features/<name>. Invalid values are refused with a report, not thrown",
11
+ "$default": { "$source": "argv", "index": 0 }
11
12
  },
12
13
  "publicApiName": {
13
14
  "type": "string",
@@ -18,6 +19,15 @@
18
19
  "items": { "type": "string" },
19
20
  "description": "Extra segments to start the slice with, each as <segment>/index.ts (e.g. ui,api). model always exists — it holds the stub. Names are by purpose: ui, api, lib, config … (components/hooks/utils/types are refused)"
20
21
  },
22
+ "group": {
23
+ "type": "string",
24
+ "description": "A slice group folder to put the slice in: <layer>/<group>/<name>/ (e.g. --group=mypage). The group folder itself gets no files — FSD keeps each slice in it separate"
25
+ },
26
+ "unitTestRunner": {
27
+ "type": "string",
28
+ "enum": ["auto", "jest", "vitest", "none"],
29
+ "description": "Whether the stub gets a .spec.ts. auto (default): when the app or workspace has jest or vitest (a test target, a runner config, or the dependency); toolkit.config.json's fsd.unitTestRunner comes before detection"
30
+ },
21
31
  "project": {
22
32
  "type": "string",
23
33
  "description": "The frontend app whose source root holds the FSD layers (run add-fsd-lite first). Omitted: the workspace's one frontend application; with more than one, the run is refused with the candidates listed"
@@ -19,9 +19,11 @@
19
19
  "enum": [
20
20
  "react-vite",
21
21
  "next",
22
+ "expo",
23
+ "generic",
22
24
  "none"
23
25
  ],
24
- "description": "Target frontend; 'none' is a backend-only workspace (needs --backend; FSD is skipped). Recorded in toolkit.config.json; omitted: the recorded one (required when none is recorded)"
26
+ "description": "Target frontend; 'generic' is a framework nx-archweave does not recognise (common FSD rules, no renamed layers); 'none' is a backend-only workspace (needs --backend; FSD is skipped). Recorded in toolkit.config.json; omitted: the recorded one (required when none is recorded)"
25
27
  },
26
28
  "backend": {
27
29
  "type": "string",
@@ -74,10 +76,19 @@
74
76
  "default": false,
75
77
  "description": "Preview the full pipeline: zero writes, and the post-run validation callback never runs"
76
78
  },
79
+ "project": {
80
+ "oneOf": [{ "type": "string" }, { "type": "array", "items": { "type": "string" } }],
81
+ "description": "The frontend app(s) the FSD section sets up, comma-separated (web,admin). Needed when the workspace has more than one frontend; each gets the rule and is recorded in toolkit.config.json's frontends"
82
+ },
83
+ "strict": {
84
+ "type": "boolean",
85
+ "default": false,
86
+ "description": "Fail the run (non-zero exit) when a section aborts or is refused. Files the other sections wrote stay; the report lists them. Without it init always succeeds and reports"
87
+ },
77
88
  "allowDirty": {
78
89
  "type": "boolean",
79
90
  "default": false,
80
- "description": "Run even when the working tree has uncommitted changes. The report then warns that `git restore .` also reverts those pre-existing changes. Used by create-nx-archweave, whose own plugin-install step dirties the fresh workspace right before init runs"
91
+ "description": "Run even when the working tree has uncommitted changes. The report then warns that restoring the files init modified also reverts those pre-existing changes. Used by create-nx-archweave, whose own plugin-install step dirties the fresh workspace right before init runs"
81
92
  },
82
93
  "applyEslint": {
83
94
  "type": "boolean",
@@ -7,7 +7,8 @@
7
7
  "properties": {
8
8
  "name": {
9
9
  "type": "string",
10
- "description": "Slice name in kebab-case (e.g. \"home\") — becomes <app>/src/pages/<name> (a screen composed from the layers below). Invalid values are refused with a report, not thrown"
10
+ "description": "Slice name in kebab-case (e.g. \"home\") — becomes <app>/src/pages/<name> (a screen composed from the layers below). Invalid values are refused with a report, not thrown",
11
+ "$default": { "$source": "argv", "index": 0 }
11
12
  },
12
13
  "publicApiName": {
13
14
  "type": "string",
@@ -20,6 +21,15 @@
20
21
  },
21
22
  "description": "Extra segments to start the slice with, each as <segment>/index.ts (e.g. ui,api). model always exists — it holds the stub. Names are by purpose: ui, api, lib, config … (components/hooks/utils/types are refused)"
22
23
  },
24
+ "group": {
25
+ "type": "string",
26
+ "description": "A slice group folder to put the slice in: <layer>/<group>/<name>/ (e.g. --group=mypage). The group folder itself gets no files — FSD keeps each slice in it separate"
27
+ },
28
+ "unitTestRunner": {
29
+ "type": "string",
30
+ "enum": ["auto", "jest", "vitest", "none"],
31
+ "description": "Whether the stub gets a .spec.ts. auto (default): when the app or workspace has jest or vitest (a test target, a runner config, or the dependency); toolkit.config.json's fsd.unitTestRunner comes before detection"
32
+ },
23
33
  "project": {
24
34
  "type": "string",
25
35
  "description": "The frontend app whose source root holds the FSD layers. Omitted: the workspace's one frontend application; with more than one, the run is refused with the candidates listed"
@@ -28,9 +38,11 @@
28
38
  "type": "string",
29
39
  "enum": [
30
40
  "react-vite",
31
- "next"
41
+ "next",
42
+ "expo",
43
+ "generic"
32
44
  ],
33
- "description": "Decides the layer directory: pages/ on react-vite, _pages/ on next (Next.js reads src/pages as its Pages Router). Omitted: toolkit.config.json's frontend (init records it), else detected from a next.config file in the app, else react-vite"
45
+ "description": "Decides the layer directory: pages/ on react-vite, _pages/ on next (Next.js reads src/pages as its Pages Router); 'generic' keeps every layer name. Omitted: toolkit.config.json's frontend (init records it), else detected from the app (its next.config / vite config, executors and dependencies), else generic"
34
46
  },
35
47
  "locale": {
36
48
  "type": "string",
@@ -7,12 +7,18 @@
7
7
  "properties": {
8
8
  "name": {
9
9
  "type": "string",
10
- "description": "Slice name in kebab-case (e.g. \"date-format\") — becomes <app>/src/shared/<name>. Invalid values are refused with a report, not thrown"
10
+ "description": "Slice name in kebab-case (e.g. \"date-format\") — becomes <app>/src/shared/<name>. Invalid values are refused with a report, not thrown",
11
+ "$default": { "$source": "argv", "index": 0 }
11
12
  },
12
13
  "publicApiName": {
13
14
  "type": "string",
14
15
  "description": "Exported stub function name. Defaults to the camelCase form of name"
15
16
  },
17
+ "unitTestRunner": {
18
+ "type": "string",
19
+ "enum": ["auto", "jest", "vitest", "none"],
20
+ "description": "Whether the stub gets a .spec.ts. auto (default): when the app or workspace has jest or vitest (a test target, a runner config, or the dependency); toolkit.config.json's fsd.unitTestRunner comes before detection"
21
+ },
16
22
  "project": {
17
23
  "type": "string",
18
24
  "description": "The frontend app whose source root holds the FSD layers (run add-fsd-lite first). Omitted: the workspace's one frontend application; with more than one, the run is refused with the candidates listed"
@@ -7,7 +7,8 @@
7
7
  "properties": {
8
8
  "name": {
9
9
  "type": "string",
10
- "description": "Slice name in kebab-case (e.g. \"header\") — becomes <app>/src/widgets/<name> (a self-contained block of UI composed from features and entities). Invalid values are refused with a report, not thrown"
10
+ "description": "Slice name in kebab-case (e.g. \"header\") — becomes <app>/src/widgets/<name> (a self-contained block of UI composed from features and entities). Invalid values are refused with a report, not thrown",
11
+ "$default": { "$source": "argv", "index": 0 }
11
12
  },
12
13
  "publicApiName": {
13
14
  "type": "string",
@@ -20,6 +21,15 @@
20
21
  },
21
22
  "description": "Extra segments to start the slice with, each as <segment>/index.ts (e.g. ui,api). model always exists — it holds the stub. Names are by purpose: ui, api, lib, config … (components/hooks/utils/types are refused)"
22
23
  },
24
+ "group": {
25
+ "type": "string",
26
+ "description": "A slice group folder to put the slice in: <layer>/<group>/<name>/ (e.g. --group=mypage). The group folder itself gets no files — FSD keeps each slice in it separate"
27
+ },
28
+ "unitTestRunner": {
29
+ "type": "string",
30
+ "enum": ["auto", "jest", "vitest", "none"],
31
+ "description": "Whether the stub gets a .spec.ts. auto (default): when the app or workspace has jest or vitest (a test target, a runner config, or the dependency); toolkit.config.json's fsd.unitTestRunner comes before detection"
32
+ },
23
33
  "project": {
24
34
  "type": "string",
25
35
  "description": "The frontend app whose source root holds the FSD layers. Omitted: the workspace's one frontend application; with more than one, the run is refused with the candidates listed"