@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
@@ -1,57 +0,0 @@
1
- import { getProjects } from '@nx/devkit';
2
- import { resolveProjectType } from "./project-type.js";
3
- const baseNameOf = (root) => root.split('/').pop() ?? root;
4
- function isEndToEnd(root) {
5
- const dir = baseNameOf(root);
6
- return dir === 'e2e' || dir.endsWith('-e2e');
7
- }
8
- /** add-backend's api app (default name `api`) and anything tagged as the backend scope are
9
- * applications, but not frontends. */
10
- function isBackend(root, config) {
11
- return baseNameOf(root) === 'api' || (config.tags ?? []).includes('scope:backend');
12
- }
13
- function sourceRootOf(config) {
14
- if (config.sourceRoot)
15
- return config.sourceRoot;
16
- return config.root === '' || config.root === '.' ? 'src' : `${config.root}/src`;
17
- }
18
- /**
19
- * Same two tiers as add-backend's frontend lookup: declared applications first; only when
20
- * there are none do undeclared projects count, typed the way Nx infers them (Nx's Next.js
21
- * and classic-preset apps are bare package.json projects). A declared root application
22
- * (a standalone workspace, root `.`) counts; an undeclared root package.json never does.
23
- */
24
- export function resolveFrontendApp(tree, explicitProject) {
25
- let projects;
26
- try {
27
- projects = getProjects(tree);
28
- }
29
- catch (error) {
30
- const message = error instanceof Error ? error.message : String(error);
31
- const pathMatch = /Cannot parse ([^:]+):/.exec(message);
32
- return { kind: 'invalid-project-json', detail: pathMatch ? pathMatch[1] : message };
33
- }
34
- const found = (project, config) => ({
35
- kind: 'found',
36
- project,
37
- root: config.root,
38
- sourceRoot: sourceRootOf(config),
39
- });
40
- if (explicitProject !== undefined) {
41
- const config = projects.get(explicitProject);
42
- return config ? found(explicitProject, config) : { kind: 'not-found', project: explicitProject };
43
- }
44
- const eligible = [...projects.entries()].filter(([, config]) => !isEndToEnd(config.root) && !isBackend(config.root, config));
45
- const declared = eligible.filter(([, config]) => config.projectType === 'application');
46
- const inferred = eligible.filter(([, config]) => config.projectType === undefined &&
47
- config.root !== '' &&
48
- config.root !== '.' &&
49
- resolveProjectType(tree, config.root, undefined) === 'application');
50
- const candidates = (declared.length > 0 ? declared : inferred).sort(([a], [b]) => a.localeCompare(b));
51
- if (candidates.length === 0)
52
- return { kind: 'none' };
53
- if (candidates.length > 1)
54
- return { kind: 'ambiguous', candidates: candidates.map(([name]) => name) };
55
- const [project, config] = candidates[0];
56
- return found(project, config);
57
- }
@@ -1,39 +0,0 @@
1
- import { parseJson } from '@nx/devkit';
2
- /**
3
- * The project type Nx itself would use when a project declares none — the
4
- * classic-preset Nx 23 layout configures apps through a bare package.json, so a
5
- * filter on the declared `projectType` alone misses every one of them.
6
- *
7
- * Mirrors the project graph's rule (nx 23 core `normalize-project-nodes`
8
- * `getProjectType`): a declared type wins (anything but `library` counts as an
9
- * application, as in Nx); then `tsconfig.lib.json` → library; `tsconfig.app.json`
10
- * → application; a readable package.json with none of exports/main/module/bin
11
- * set → application; anything else — including a missing or unparseable
12
- * package.json — → library. Mirrored, not imported: the plugin's runtime
13
- * dependencies stay @nx/devkit + tslib. Not mirrored: the package-json plugin's
14
- * `workspaceLayout.appsDir`/`libsDir` override, which devkit's tree-based
15
- * `getProjects` never applies either.
16
- */
17
- export function resolveProjectType(tree, root, declared) {
18
- if (declared)
19
- return declared === 'library' ? 'library' : 'application';
20
- const prefix = root === '' || root === '.' ? '' : `${root}/`;
21
- if (tree.exists(`${prefix}tsconfig.lib.json`))
22
- return 'library';
23
- if (tree.exists(`${prefix}tsconfig.app.json`))
24
- return 'application';
25
- const raw = tree.read(`${prefix}package.json`, 'utf-8');
26
- if (raw === null)
27
- return 'library';
28
- try {
29
- // parseJson accepts comments and trailing commas, like Nx's readJsonFile
30
- const manifest = parseJson(raw);
31
- // truthiness, like Nx: an empty "main": "" is no entry point
32
- if (manifest && !['exports', 'main', 'module', 'bin'].some((key) => Boolean(manifest[key])))
33
- return 'application';
34
- }
35
- catch {
36
- // unparseable — Nx's graph falls through to library as well
37
- }
38
- return 'library';
39
- }
@@ -1,34 +0,0 @@
1
- import { buildRecordPlan, getCatalogEntry, loadManifest, recordMetadataFor, verifyClaudeMarketplacePlugin, writeManifestAtomic, } from '@nx-archweave/integrations';
2
- /**
3
- * Live verify → manifest record, shared by record-integration and install-integration.
4
- * Always a fresh call — never a cached result. Only a
5
- * claude-cli-sourced verified:true is recordable (buildRecordPlan enforces it); the write
6
- * is the atomic manifest write and happens only when `write` is set.
7
- */
8
- export async function runRecordPipeline(input) {
9
- const entry = getCatalogEntry(input.id);
10
- // Where it came from comes from the catalog entry.
11
- const metadata = entry === undefined ? undefined : recordMetadataFor(entry);
12
- if (!entry || !metadata) {
13
- throw new Error(`No manifest metadata is defined for "${input.id}".`);
14
- }
15
- const { result: verification, source: verificationSource } = await verifyClaudeMarketplacePlugin(entry, { runner: input.runner }, { workspaceRoot: input.workspaceRoot, targetEnvironment: input.target });
16
- const manifest = loadManifest(input.workspaceRoot);
17
- const existingEntry = manifest.integrations[input.id];
18
- const plan = buildRecordPlan({ id: input.id, target: input.target, verificationSource, verification, existingEntry, metadata });
19
- let wasWritten = false;
20
- if (input.write && plan.proposedEntry) {
21
- writeManifestAtomic(input.workspaceRoot, {
22
- integrations: { ...manifest.integrations, [input.id]: plan.proposedEntry },
23
- });
24
- wasWritten = true;
25
- }
26
- return {
27
- verificationSource,
28
- verification,
29
- existingEntry,
30
- proposedEntry: plan.proposedEntry,
31
- hasConflict: plan.hasConflict,
32
- wasWritten,
33
- };
34
- }
@@ -1,24 +0,0 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { packageManagerCommands } from '@nx-archweave/template-engine';
4
- import { TOOLKIT_CONFIG_PATH, workspacePackageManager } from "./toolkit-config-file.js";
5
- /** How a person runs the workspace's own nx — `pnpm nx`, `yarn nx`, `bun nx` or
6
- * `npm exec nx --` — for the commands generators print. The detected package manager, then
7
- * the one init recorded, else pnpm (the same order init, add-backend and add-review use). */
8
- export function runNxFor(exists, read) {
9
- let config = null;
10
- try {
11
- const parsed = JSON.parse(read(TOOLKIT_CONFIG_PATH) ?? 'null');
12
- if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed))
13
- config = parsed;
14
- }
15
- catch {
16
- // an unreadable config only means nothing is recorded
17
- }
18
- return packageManagerCommands(workspacePackageManager(exists, read, config) ?? 'pnpm').runNx;
19
- }
20
- /** From the workspace on disk — doctor and the integration generators, which act on the
21
- * real workspace (tree.root) rather than the Nx Tree. */
22
- export function diskRunNx(root) {
23
- return runNxFor((path) => existsSync(join(root, path)), (path) => (existsSync(join(root, path)) ? readFileSync(join(root, path), 'utf-8') : undefined));
24
- }
@@ -1,38 +0,0 @@
1
- import { existsSync } from 'node:fs';
2
- import { fileURLToPath } from 'node:url';
3
- import { generateFiles } from '@nx/devkit';
4
- // createTree is the devkit's in-memory Tree; it holds the rendered files until the caller has
5
- // compared each one with the workspace.
6
- import { createTree } from '@nx/devkit/testing';
7
- /**
8
- * The `files/` directory of a generator. `fromUrl` is the calling module's `import.meta.url`:
9
- * from source and from a generator's own bundle that is `../<generator>/files`; from a bundle
10
- * that inlines another generator (init runs add-github-templates) the sibling directory is
11
- * the same, and from the package's root `index.js` it is `./generators/<generator>/files`.
12
- */
13
- export function generatorFilesDir(generator, fromUrl) {
14
- for (const relative of [`../${generator}/files`, `./generators/${generator}/files`]) {
15
- const dir = fileURLToPath(new URL(relative, fromUrl));
16
- if (existsSync(dir))
17
- return dir;
18
- }
19
- throw new Error(`The "${generator}" generator's template files are missing next to ${fileURLToPath(fromUrl)}`);
20
- }
21
- /**
22
- * Renders a template folder the way every Nx generator does — `generateFiles`: `__key__` in
23
- * paths and EJS in contents take the substitutions, a `.template` suffix is dropped — into a
24
- * scratch tree, and returns the files sorted by path. The caller decides what reaches the
25
- * workspace (create, leave an existing file alone, or only report it in a dry run).
26
- */
27
- export function renderTemplateFiles(srcFolder, target, substitutions) {
28
- const scratch = createTree();
29
- // createTree seeds a .prettierrc; only what generateFiles adds belongs to the result.
30
- const seeded = new Set(scratch.listChanges().map((change) => change.path));
31
- generateFiles(scratch, srcFolder, target, substitutions);
32
- return scratch
33
- .listChanges()
34
- .flatMap((change) => change.type === 'CREATE' && change.content !== null && !seeded.has(change.path)
35
- ? [{ filePath: change.path, content: change.content.toString('utf-8') }]
36
- : [])
37
- .sort((a, b) => a.filePath.localeCompare(b.filePath));
38
- }
@@ -1,110 +0,0 @@
1
- import { DEFAULT_AI_HOSTS, detectWorkspacePackageManager, isAiHost } from '@nx-archweave/template-engine';
2
- export const TOOLKIT_CONFIG_PATH = 'toolkit.config.json';
3
- /** The indentation unit an existing JSON file uses, so an update preserves the
4
- * author's formatting: the first indented line's leading run of tabs or spaces,
5
- * else two spaces (e.g. minified JSON or no file). */
6
- export function detectIndent(raw) {
7
- for (const line of raw.split('\n')) {
8
- const match = /^(\t+| +)/.exec(line);
9
- if (match)
10
- return match[1];
11
- }
12
- return ' ';
13
- }
14
- /** Best-effort read of toolkit.config.json. Never throws: the generators that read
15
- * defaults from it (locale, boundaries, teamRules) must not abort on a broken file
16
- * they do not own, and the ones that merge into it refuse to rewrite an
17
- * unrecognized shape — both decide from this one result. */
18
- export function readToolkitConfigFile(tree) {
19
- const raw = tree.read(TOOLKIT_CONFIG_PATH, 'utf-8');
20
- if (raw === null)
21
- return { raw: null, parsed: null };
22
- try {
23
- const parsed = JSON.parse(raw);
24
- return {
25
- raw,
26
- parsed: typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : null,
27
- };
28
- }
29
- catch {
30
- return { raw, parsed: null };
31
- }
32
- }
33
- /** The parsed object only — for readers that just want defaults. */
34
- export function readToolkitConfig(tree) {
35
- return readToolkitConfigFile(tree).parsed;
36
- }
37
- /** option > toolkit.config.json `locale` > 'en' — the fallback every content
38
- * generator shares; anything that is not ko/en is ignored, never an abort. */
39
- export function resolveLocaleOption(option, config) {
40
- if (option)
41
- return option;
42
- const locale = config?.['locale'];
43
- return locale === 'ko' || locale === 'en' ? locale : 'en';
44
- }
45
- /** A recorded choice init wrote, when it is one of `allowed`; anything else is ignored. */
46
- export function recordedChoice(config, key, allowed) {
47
- const value = config?.[key];
48
- return typeof value === 'string' && allowed.includes(value) ? value : undefined;
49
- }
50
- export const RECORDED_FRONTENDS = ['react-vite', 'next', 'none'];
51
- export const RECORDED_BACKENDS = ['express', 'nest', 'none'];
52
- export const RECORDED_PRESETS = ['quick-start', 'solo', 'team'];
53
- export const RECORDED_PACKAGE_MANAGERS = ['pnpm', 'npm', 'yarn', 'bun'];
54
- /** option > toolkit.config.json `frontend` (init records it); a run without either cannot
55
- * pick the layer set, so it stops with the flag to pass. A backend-only workspace
56
- * (`frontend: none`) has no frontend to structure, which stops the FSD generators too. */
57
- export function resolveFrontendOption(option, config, generator) {
58
- const frontend = option ?? recordedChoice(config, 'frontend', RECORDED_FRONTENDS);
59
- if (frontend === undefined) {
60
- throw new Error(`${generator}: pass --frontend=react-vite|next (toolkit.config.json records none — init writes it).`);
61
- }
62
- if (frontend === 'none') {
63
- throw new Error(`${generator}: this is a backend-only workspace (toolkit.config.json frontend: none) — FSD structures a frontend app. Pass --frontend=react-vite|next if you added one.`);
64
- }
65
- return frontend;
66
- }
67
- /** init's frontend axis: like resolveFrontendOption, but `none` (backend-only) is a choice. */
68
- export function resolveInitFrontend(option, config) {
69
- const frontend = option ?? recordedChoice(config, 'frontend', RECORDED_FRONTENDS);
70
- if (frontend === undefined) {
71
- throw new Error('init: pass --frontend=react-vite|next|none (toolkit.config.json records none — init writes it).');
72
- }
73
- return frontend;
74
- }
75
- /** The workspace's package manager: its lockfiles first (they are what installs run with,
76
- * even after a switch), then the one init recorded; null when neither says. */
77
- export function workspacePackageManager(exists, read, config) {
78
- return detectWorkspacePackageManager(exists, read) ?? recordedChoice(config, 'packageManager', RECORDED_PACKAGE_MANAGERS) ?? null;
79
- }
80
- export function treePackageManager(tree, config) {
81
- return workspacePackageManager((p) => tree.exists(p), (p) => tree.read(p, 'utf-8'), config);
82
- }
83
- /** Option (non-empty, deduped) > toolkit.config.json `aiHosts`
84
- * (its known ids) > Claude Code. Shared by add-agent-rules and init, so a standalone run of
85
- * either keeps the hosts a workspace was set up for. */
86
- export function resolveAiHostsOption(option, config) {
87
- if (option !== undefined && option.length > 0)
88
- return [...new Set(option)];
89
- const recorded = config?.['aiHosts'];
90
- const fromConfig = Array.isArray(recorded) ? [...new Set(recorded.filter(isAiHost))] : [];
91
- return fromConfig.length > 0 ? fromConfig : [...DEFAULT_AI_HOSTS];
92
- }
93
- /** Writes `aiHosts` into toolkit.config.json: merge-preserving (every other key survives),
94
- * indent-preserving, idempotent; a file that is not a JSON object is never rewritten. */
95
- export function recordAiHosts(tree, hosts, write) {
96
- const { raw, parsed } = readToolkitConfigFile(tree);
97
- if (raw !== null && parsed === null)
98
- return 'unrecognized';
99
- const existing = parsed ?? {};
100
- const recorded = existing['aiHosts'];
101
- if (Array.isArray(recorded) && recorded.length === hosts.length && recorded.every((id, index) => id === hosts[index])) {
102
- return 'unchanged';
103
- }
104
- if (write) {
105
- const indent = raw === null ? ' ' : detectIndent(raw);
106
- const body = JSON.stringify({ ...existing, aiHosts: [...hosts] }, null, indent);
107
- tree.write(TOOLKIT_CONFIG_PATH, raw === null || raw.endsWith('\n') ? `${body}\n` : body);
108
- }
109
- return raw === null ? 'created' : 'updated';
110
- }
@@ -1,185 +0,0 @@
1
- import { join } from 'node:path';
2
- import { isEssenceSegmentName, isUsableSliceApiName, resolveSlicePublicApiName, SEGMENT_NAME_PATTERN, } from '@nx-archweave/template-engine';
3
- import { readToolkitConfig, RECORDED_FRONTENDS, resolveLocaleOption } from "../shared/toolkit-config-file.js";
4
- import { resolveFrontendApp } from "../shared/frontend-app.js";
5
- import { generatorFilesDir, renderTemplateFiles } from "../shared/template-files.js";
6
- /**
7
- * A slice on a sliced layer, from this generator's `files/`:
8
- * <src>/<layer>/<name>/index.ts public API (files/index)
9
- * <src>/<layer>/<name>/model/<name>.ts the stub (files/module)
10
- * <src>/<layer>/<name>/model/<name>.spec.ts
11
- * <src>/<layer>/<name>/<segment>/index.ts per --segments (files/segment)
12
- * A shared segment holds no segments of its own, so its stub sits next to the index.
13
- */
14
- export function composeSlice(input) {
15
- const files = generatorFilesDir('slice', import.meta.url);
16
- const directory = `${input.sourceRoot}/${input.layerDirName}/${input.name}`;
17
- const shared = input.layer === 'shared';
18
- const values = {
19
- name: input.name,
20
- publicApiName: input.publicApiName,
21
- layer: input.layer,
22
- locale: input.locale,
23
- modulePath: shared ? `./${input.name}` : `./model/${input.name}`,
24
- };
25
- const module = renderTemplateFiles(join(files, 'module'), shared ? directory : `${directory}/model`, values)
26
- // the stub before its test
27
- .sort((a, b) => Number(a.filePath.endsWith('.spec.ts')) - Number(b.filePath.endsWith('.spec.ts')));
28
- const segments = shared
29
- ? []
30
- : Array.from(new Set(input.segments))
31
- .filter((segment) => segment !== 'model')
32
- .flatMap((segment) => renderTemplateFiles(join(files, 'segment'), `${directory}/${segment}`, { ...values, segment }));
33
- return {
34
- directory,
35
- publicApiName: input.publicApiName,
36
- files: [...renderTemplateFiles(join(files, 'index'), directory, values), ...module, ...segments],
37
- };
38
- }
39
- /** Lowercase letters/digits, single hyphens, no leading/trailing/doubled hyphen, must
40
- * start with a letter (so `toCamelCase`/directory names stay valid identifiers). */
41
- const KEBAB_CASE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
42
- /** Next.js reads `src/pages` as its Pages Router, so FSD's Next.js guide puts the pages
43
- * layer in `_pages/` there (as add-fsd-lite's guide says). Every other layer is its id. */
44
- function layerDirNameFor(layer, frontend) {
45
- return layer === 'pages' && frontend === 'next' ? '_pages' : layer;
46
- }
47
- function layerDirFor(layer, sourceRoot, frontend = 'react-vite') {
48
- return `${sourceRoot}/${layerDirNameFor(layer, frontend)}`;
49
- }
50
- const NEXT_CONFIG_FILES = ['next.config.js', 'next.config.mjs', 'next.config.ts', 'next.config.cjs'];
51
- /** option > toolkit.config.json's frontend (init records it) > a next.config file in the app
52
- * (Nx's Next.js app always has one) > react-vite. Best-effort: it only moves `pages` to
53
- * `_pages`, so a wrong guess costs a rename, never a broken file. */
54
- function resolveFrontendForApp(option, config, appRoot, tree) {
55
- if (option === 'react-vite' || option === 'next')
56
- return option;
57
- const recorded = config?.['frontend'];
58
- if (typeof recorded === 'string' && RECORDED_FRONTENDS.includes(recorded) && recorded !== 'none') {
59
- return recorded;
60
- }
61
- const prefix = appRoot === '' || appRoot === '.' ? '' : `${appRoot}/`;
62
- return NEXT_CONFIG_FILES.some((file) => tree.exists(`${prefix}${file}`)) ? 'next' : 'react-vite';
63
- }
64
- /** A slice directory is:
65
- * - 'created' when it doesn't exist yet;
66
- * - 'unchanged' when it exists and contains exactly our generated files, byte-for-byte;
67
- * - 'skipped' for anything else (same-name slice already there with different/extra
68
- * content — a name collision is a signal to pick a new name; merge is never proposed). */
69
- /** Every file under `dir`, as paths relative to it (the generated set spans model/). */
70
- function listFilesUnder(tree, dir, prefix = '') {
71
- return tree.children(dir).flatMap((child) => {
72
- const path = `${dir}/${child}`;
73
- return tree.isFile(path) ? [`${prefix}${child}`] : listFilesUnder(tree, path, `${prefix}${child}/`);
74
- });
75
- }
76
- function computeSliceAction(tree, plan) {
77
- if (!tree.exists(plan.directory))
78
- return 'created';
79
- const expectedNames = plan.files.map((f) => f.filePath.slice(plan.directory.length + 1)).sort();
80
- const actualNames = listFilesUnder(tree, plan.directory).sort();
81
- if (actualNames.length !== expectedNames.length || !actualNames.every((n, i) => n === expectedNames[i])) {
82
- return 'skipped';
83
- }
84
- const allMatch = plan.files.every((f) => tree.read(f.filePath, 'utf-8') === f.content);
85
- return allMatch ? 'unchanged' : 'skipped';
86
- }
87
- function refusal(reason, dryRun, layer, name, app, extra = {}) {
88
- const layerDir = layerDirFor(layer, app.sourceRoot);
89
- return {
90
- dryRun,
91
- layer,
92
- name,
93
- project: app.project,
94
- layerDir,
95
- directory: `${layerDir}/${name}`,
96
- publicApiName: null,
97
- refused: true,
98
- refusalReason: reason,
99
- action: null,
100
- files: [],
101
- ...extra,
102
- };
103
- }
104
- /**
105
- * Pure core shared by the `page` / `widget` / `feature` / `entity` / `shared` generators.
106
- * `layer` is fixed per top-level generator — only the option parsing, tree reads, and
107
- * report shape are shared here.
108
- *
109
- * The layer directory (`<app sourceRoot>/<layer>`) appears with its first slice (canonical
110
- * FSD) — nothing has to exist beforehand but the frontend app.
111
- */
112
- export function runFsdSlice(tree, layer, options) {
113
- const dryRun = options.dryRun ?? false;
114
- // Placeholder location for refusals raised before an app is picked.
115
- const unresolved = { project: null, sourceRoot: '<app>/src' };
116
- if (!KEBAB_CASE.test(options.name)) {
117
- return refusal('invalid-name', dryRun, layer, options.name, unresolved);
118
- }
119
- // Validate the SAME effective value the composer will embed — an explicit
120
- // publicApiName that isn't an identifier, or a slice name whose camelCase
121
- // default is a reserved word (e.g. "class", "delete"), would otherwise
122
- // silently produce syntactically broken TypeScript.
123
- if (!isUsableSliceApiName(resolveSlicePublicApiName(options.name, options.publicApiName))) {
124
- return refusal('invalid-public-api-name', dryRun, layer, options.name, unresolved);
125
- }
126
- // canonical FSD: a shared segment is named by purpose (ui, api, model, lib, config), never
127
- // by what it contains.
128
- if (layer === 'shared' && isEssenceSegmentName(options.name)) {
129
- return refusal('segment-by-essence', dryRun, layer, options.name, unresolved);
130
- }
131
- // --segments: a shared segment holds no segments; elsewhere each
132
- // name must be a directory-safe name by purpose, like the shared generator's own names.
133
- const segments = Array.from(new Set(options.segments ?? []));
134
- if (segments.length > 0 && layer === 'shared') {
135
- return refusal('segments-on-shared', dryRun, layer, options.name, unresolved);
136
- }
137
- const badSegment = segments.find((segment) => !SEGMENT_NAME_PATTERN.test(segment));
138
- if (badSegment !== undefined)
139
- return refusal('invalid-segment', dryRun, layer, options.name, unresolved, { detail: badSegment });
140
- const essenceSegment = segments.find((segment) => isEssenceSegmentName(segment));
141
- if (essenceSegment !== undefined)
142
- return refusal('segment-by-essence', dryRun, layer, options.name, unresolved, { detail: essenceSegment });
143
- const app = resolveFrontendApp(tree, options.project);
144
- if (app.kind === 'not-found')
145
- return refusal('project-not-found', dryRun, layer, options.name, { project: app.project, sourceRoot: unresolved.sourceRoot });
146
- if (app.kind === 'ambiguous')
147
- return refusal('ambiguous-frontend', dryRun, layer, options.name, unresolved, { candidates: app.candidates });
148
- if (app.kind === 'invalid-project-json')
149
- return refusal('invalid-project-json', dryRun, layer, options.name, unresolved, { detail: app.detail });
150
- if (app.kind === 'none')
151
- return refusal('no-frontend-app', dryRun, layer, options.name, unresolved);
152
- // The layer directory is created with its first slice (canonical FSD) — add-fsd-lite makes
153
- // no folders. tree.write creates every missing parent.
154
- const config = readToolkitConfig(tree);
155
- const frontend = resolveFrontendForApp(options.frontend, config, app.root, tree);
156
- const layerDir = layerDirFor(layer, app.sourceRoot, frontend);
157
- const locale = resolveLocaleOption(options.locale, config);
158
- const plan = composeSlice({
159
- layer,
160
- name: options.name,
161
- publicApiName: resolveSlicePublicApiName(options.name, options.publicApiName),
162
- locale,
163
- sourceRoot: app.sourceRoot,
164
- layerDirName: layerDirNameFor(layer, frontend),
165
- segments,
166
- });
167
- const action = computeSliceAction(tree, plan);
168
- if (action === 'created' && !dryRun) {
169
- for (const file of plan.files)
170
- tree.write(file.filePath, file.content);
171
- }
172
- return {
173
- dryRun,
174
- layer,
175
- name: options.name,
176
- project: app.project,
177
- layerDir,
178
- directory: plan.directory,
179
- publicApiName: plan.publicApiName,
180
- refused: false,
181
- refusalReason: null,
182
- action,
183
- files: plan.files,
184
- };
185
- }
@@ -1,70 +0,0 @@
1
- const ACTION_LINES = {
2
- created: 'created',
3
- unchanged: 'unchanged (already matches the generated content)',
4
- skipped: 'skipped — a slice with this name already exists with different content',
5
- };
6
- export function renderSliceReport(report) {
7
- const lines = [];
8
- const label = `${report.layer} "${report.name}"`;
9
- if (report.refused) {
10
- lines.push(`${label}: refused — nothing was written.`);
11
- lines.push('');
12
- if (report.refusalReason === 'invalid-name') {
13
- lines.push(` "${report.name}" is not a valid kebab-case slice name.`);
14
- lines.push(' Use lowercase letters, digits, and single hyphens, starting with a letter (e.g. "add-to-cart").');
15
- }
16
- else if (report.refusalReason === 'invalid-public-api-name') {
17
- lines.push(' The public API name is not a usable JavaScript identifier (or is a reserved word).');
18
- lines.push(' Pass --publicApiName with a valid identifier, e.g. "addToCart" (this also happens when');
19
- lines.push(' the slice name camelCases into a reserved word, like "class" or "delete").');
20
- }
21
- else if (report.refusalReason === 'project-not-found') {
22
- lines.push(` --project=${report.project} names no project in this workspace.`);
23
- }
24
- else if (report.refusalReason === 'ambiguous-frontend') {
25
- lines.push(' More than one frontend app was found — rerun with --project=<one of them>:');
26
- for (const name of report.candidates ?? [])
27
- lines.push(` - ${name}`);
28
- }
29
- else if (report.refusalReason === 'invalid-project-json') {
30
- lines.push(` A project.json could not be parsed (${report.detail ?? 'unknown'}) — fix it, then rerun.`);
31
- }
32
- else if (report.refusalReason === 'no-frontend-app') {
33
- lines.push(' The workspace has no frontend application to hold FSD layers — create one, or pass --project.');
34
- }
35
- else if (report.refusalReason === 'invalid-segment') {
36
- lines.push(` --segments: "${report.detail ?? ''}" is not a valid segment name.`);
37
- lines.push(' Use lowercase letters, digits, and single hyphens, starting with a letter (e.g. ui, api, lib).');
38
- }
39
- else if (report.refusalReason === 'segments-on-shared') {
40
- lines.push(' --segments does not apply to shared: a shared segment holds no segments of its own.');
41
- lines.push(' Run the generator once per segment instead (e.g. --name=ui, then --name=api).');
42
- }
43
- else {
44
- const offender = report.detail ?? report.name;
45
- lines.push(` "${offender}" names what a segment contains, not what it is for.`);
46
- lines.push(' Canonical FSD names segments by purpose — ui, api, model, lib, config — so the code stays');
47
- lines.push(' findable by what it does (e.g. a date helper lives in shared/lib, a Button in shared/ui).');
48
- }
49
- return lines.join('\n');
50
- }
51
- if (report.dryRun) {
52
- lines.push('dry-run — no files were written.');
53
- lines.push('');
54
- }
55
- lines.push(`${label}: ${ACTION_LINES[report.action]}`);
56
- if (report.action === 'skipped') {
57
- lines.push(` ${report.directory} already exists and does not exactly match the generated content.`);
58
- lines.push(' Pick a new slice name (a name collision is a signal to rename, not to merge)');
59
- lines.push(' — or remove the existing directory manually first if you intended to replace it.');
60
- return lines.join('\n');
61
- }
62
- if (report.dryRun) {
63
- for (const file of report.files) {
64
- lines.push('');
65
- lines.push(`${file.filePath}:`);
66
- lines.push(file.content);
67
- }
68
- }
69
- return lines.join('\n');
70
- }
@@ -1,7 +0,0 @@
1
- import { describeEngine } from '@nx-archweave/template-engine';
2
- import { createIntegrationsCompositionRoot } from "../runtime/integrations-composition-root.js";
3
- export function describePlugin() {
4
- // Constructing the composition root only counts registered integrations —
5
- // it never invokes the runner (no verify() call happens here).
6
- return `nx-plugin(${describeEngine()}, integrations=${createIntegrationsCompositionRoot().listIntegrations().length})`;
7
- }
@@ -1,20 +0,0 @@
1
- import { createCatalogIntegrations, createIntegrationRegistry, getIntegration as getIntegrationFromRegistry, listIntegrations as listIntegrationsFromRegistry, } from '@nx-archweave/integrations';
2
- import { NodeCommandRunner } from "./node-command-runner.js";
3
- // The single place every generator (doctor, add-integration,
4
- // record-integration) obtains its ToolIntegration objects from — never a
5
- // package-level singleton (factory-based
6
- // construction). Each call constructs a fresh registry; there is no shared,
7
- // mutated module state between calls. Defaults to the real, Node-backed
8
- // runner; tests inject a fake one. The tool list is the plugin catalog (data, in
9
- // @nx-archweave/integrations); the generators' --tool/--target schema enums are kept
10
- // equal to it by a test (catalog-schema-enums.spec.ts).
11
- export function createIntegrationsCompositionRoot(deps = {}) {
12
- const runner = deps.runner ?? new NodeCommandRunner();
13
- const registry = createIntegrationRegistry(createCatalogIntegrations({ runner, resolveVersion: deps.resolveVersion }));
14
- return {
15
- registry,
16
- runner,
17
- getIntegration: (id) => getIntegrationFromRegistry(registry, id),
18
- listIntegrations: () => listIntegrationsFromRegistry(registry),
19
- };
20
- }