@skyf0xx/hedgehog 4.2.0 → 4.2.2

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 (44) hide show
  1. package/README.md +20 -0
  2. package/bin/cli.mjs +12 -0
  3. package/package.json +2 -2
  4. package/src/agents/backend-eng.md +30 -16
  5. package/src/agents/front-end-eng.md +29 -12
  6. package/src/db/next.mjs +46 -8
  7. package/src/golden-cores/full-stack-app/apps/api/package.json +19 -1
  8. package/src/golden-cores/full-stack-app/apps/api/src/app/app.module.spec.ts +15 -0
  9. package/src/golden-cores/full-stack-app/apps/api/src/app/app.module.ts +6 -1
  10. package/src/golden-cores/full-stack-app/apps/api/src/app/feature-modules.ts +8 -0
  11. package/src/golden-cores/full-stack-app/apps/api/tsconfig.app.json +3 -0
  12. package/src/golden-cores/full-stack-app/apps/api/tsconfig.json +3 -0
  13. package/src/golden-cores/full-stack-app/apps/api/tsconfig.spec.json +36 -0
  14. package/src/golden-cores/full-stack-app/apps/api/vitest.config.mts +18 -0
  15. package/src/golden-cores/full-stack-app/apps/web/src/components/theme-toggle.spec.tsx +20 -0
  16. package/src/golden-cores/full-stack-app/apps/web/src/test-setup.ts +1 -0
  17. package/src/golden-cores/full-stack-app/apps/web/tsconfig.json +6 -0
  18. package/src/golden-cores/full-stack-app/apps/web/tsconfig.spec.json +37 -0
  19. package/src/golden-cores/full-stack-app/apps/web/vitest.config.mts +27 -0
  20. package/src/golden-cores/full-stack-app/nx.json +4 -1
  21. package/src/golden-cores/full-stack-app/package.json +8 -0
  22. package/src/golden-cores/full-stack-app/pnpm-lock.yaml +8735 -2907
  23. package/src/golden-cores/full-stack-app/tools/generate-feature-modules.cjs +104 -0
  24. package/src/golden-cores/full-stack-app/tools/generators/contract/generator.ts +283 -0
  25. package/src/golden-cores/full-stack-app/tools/generators/contract/schema.json +20 -0
  26. package/src/golden-cores/full-stack-app/tools/generators/controller/generator.ts +323 -0
  27. package/src/golden-cores/full-stack-app/tools/generators/controller/schema.json +20 -0
  28. package/src/golden-cores/full-stack-app/tools/generators/fields.ts +126 -0
  29. package/src/golden-cores/full-stack-app/tools/generators/generators.json +42 -0
  30. package/src/golden-cores/full-stack-app/tools/generators/hook/generator.ts +274 -0
  31. package/src/golden-cores/full-stack-app/tools/generators/hook/schema.json +19 -0
  32. package/src/golden-cores/full-stack-app/tools/generators/lib-shell.ts +124 -0
  33. package/src/golden-cores/full-stack-app/tools/generators/naming.ts +84 -0
  34. package/src/golden-cores/full-stack-app/tools/generators/package.json +6 -0
  35. package/src/golden-cores/full-stack-app/tools/generators/repository/generator.ts +298 -0
  36. package/src/golden-cores/full-stack-app/tools/generators/repository/schema.json +15 -0
  37. package/src/golden-cores/full-stack-app/tools/generators/schema/generator.ts +169 -0
  38. package/src/golden-cores/full-stack-app/tools/generators/schema/schema.json +20 -0
  39. package/src/golden-cores/full-stack-app/tools/generators/screen/generator.ts +218 -0
  40. package/src/golden-cores/full-stack-app/tools/generators/screen/schema.json +15 -0
  41. package/src/golden-cores/full-stack-app/tools/generators/service/generator.ts +194 -0
  42. package/src/golden-cores/full-stack-app/tools/generators/service/schema.json +15 -0
  43. package/src/skills/hedgehog-bootstrap-full-stack-app-core/SKILL.md +67 -6
  44. package/src/skills/hedgehog-loop/SKILL.md +119 -53
@@ -0,0 +1,218 @@
1
+ import { formatFiles, Tree, updateJson } from '@nx/devkit';
2
+ import { moduleNames, ModuleNames } from '../naming';
3
+
4
+ interface ScreenGeneratorOptions {
5
+ module: string;
6
+ }
7
+
8
+ const WEB_ROOT = 'apps/web';
9
+
10
+ /**
11
+ * Deliberately a skeleton: it wires the hook layer to a route and leaves
12
+ * placeholders for the list, the filter shell, the empty state, and the
13
+ * create/edit form. Layout, information hierarchy, and every other UX
14
+ * judgment belong to ux-planner's rationale and front-end-eng's build, not
15
+ * to a template that would make every module's screen look identical
16
+ * before anyone decided it should.
17
+ */
18
+ export default async function screenGenerator(
19
+ tree: Tree,
20
+ options: ScreenGeneratorOptions,
21
+ ) {
22
+ const names = moduleNames(options.module);
23
+ const dir = `${WEB_ROOT}/src/app/${names.module}`;
24
+
25
+ tree.write(`${dir}/page.tsx`, pageFile(names));
26
+ tree.write(`${dir}/${names.module}-screen.tsx`, screenFile(names));
27
+ tree.write(`${dir}/${names.module}-form.tsx`, formFile(names));
28
+ tree.write(`${dir}/${names.module}-screen.spec.tsx`, specFile(names));
29
+
30
+ addWebDependencies(tree);
31
+
32
+ await formatFiles(tree);
33
+ }
34
+
35
+ function pageFile(names: ModuleNames): string {
36
+ return `import { ${names.pascal}Screen } from './${names.module}-screen';
37
+
38
+ export default function ${names.pascal}Page() {
39
+ return <${names.pascal}Screen />;
40
+ }
41
+ `;
42
+ }
43
+
44
+ function screenFile(names: ModuleNames): string {
45
+ const { pascal, camel, entityPascal } = names;
46
+
47
+ return `'use client';
48
+
49
+ import { useState } from 'react';
50
+ import { useRemove${entityPascal}, use${pascal} } from 'hooks';
51
+ import { ${pascal}Form } from './${names.module}-form';
52
+
53
+ export function ${pascal}Screen() {
54
+ const { data, isPending, isError } = use${pascal}();
55
+ const remove${entityPascal} = useRemove${entityPascal}();
56
+ const [editingId, setEditingId] = useState<string | null>(null);
57
+
58
+ if (isPending) {
59
+ return <p>Loading ${names.module}…</p>;
60
+ }
61
+
62
+ if (isError) {
63
+ return <p>${pascal} could not be loaded.</p>;
64
+ }
65
+
66
+ const ${camel} = data ?? [];
67
+
68
+ return (
69
+ <main>
70
+ <header>
71
+ <h1>${pascal}</h1>
72
+ {/* Filter and tab shell — front-end-eng decides which facets a
73
+ ${names.entityCamel} is filtered by, from ux-planner's rationale. */}
74
+ <nav aria-label="${pascal} filters" />
75
+ </header>
76
+
77
+ <${pascal}Form
78
+ editingId={editingId}
79
+ onDone={() => setEditingId(null)}
80
+ />
81
+
82
+ {${camel}.length === 0 ? (
83
+ <p>No ${names.module} yet.</p>
84
+ ) : (
85
+ <ul>
86
+ {${camel}.map((${names.entityCamel}) => (
87
+ <li key={${names.entityCamel}.id}>
88
+ <button type="button" onClick={() => setEditingId(${names.entityCamel}.id)}>
89
+ Edit
90
+ </button>
91
+ <button
92
+ type="button"
93
+ onClick={() => remove${entityPascal}.mutate(${names.entityCamel}.id)}
94
+ >
95
+ Delete
96
+ </button>
97
+ </li>
98
+ ))}
99
+ </ul>
100
+ )}
101
+ </main>
102
+ );
103
+ }
104
+ `;
105
+ }
106
+
107
+ function formFile(names: ModuleNames): string {
108
+ const { pascal, entityPascal, entityCamel } = names;
109
+
110
+ return `'use client';
111
+
112
+ import { useCreate${entityPascal} } from 'hooks';
113
+
114
+ interface ${pascal}FormProps {
115
+ editingId: string | null;
116
+ onDone: () => void;
117
+ }
118
+
119
+ export function ${pascal}Form({ editingId, onDone }: ${pascal}FormProps) {
120
+ const create${entityPascal} = useCreate${entityPascal}();
121
+
122
+ return (
123
+ <form
124
+ aria-label={editingId ? 'Edit ${entityCamel}' : 'Create ${entityCamel}'}
125
+ onSubmit={(event) => {
126
+ event.preventDefault();
127
+ onDone();
128
+ }}
129
+ >
130
+ {/* Fields, validation feedback, and submit affordance are
131
+ front-end-eng's, built against the contract's own create/update
132
+ schema rather than duplicated here. */}
133
+ <button type="submit" disabled={create${entityPascal}.isPending}>
134
+ Save
135
+ </button>
136
+ </form>
137
+ );
138
+ }
139
+ `;
140
+ }
141
+
142
+ function specFile(names: ModuleNames): string {
143
+ const { pascal, entityPascal } = names;
144
+
145
+ return `import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
146
+ import { render, screen } from '@testing-library/react';
147
+ import { describe, expect, it, vi } from 'vitest';
148
+ import { ${pascal}Screen } from './${names.module}-screen';
149
+
150
+ // Hoisted above the import above by Vitest, which is what lets the screen
151
+ // render against stubbed hooks instead of a live API.
152
+ vi.mock('hooks', () => ({
153
+ use${pascal}: vi.fn(() => ({ data: [], isPending: false, isError: false })),
154
+ useCreate${entityPascal}: vi.fn(() => ({ isPending: false, mutate: vi.fn() })),
155
+ useRemove${entityPascal}: vi.fn(() => ({ isPending: false, mutate: vi.fn() })),
156
+ }));
157
+
158
+ function renderScreen() {
159
+ const queryClient = new QueryClient({
160
+ defaultOptions: { queries: { retry: false } },
161
+ });
162
+
163
+ return render(
164
+ <QueryClientProvider client={queryClient}>
165
+ <${pascal}Screen />
166
+ </QueryClientProvider>,
167
+ );
168
+ }
169
+
170
+ describe('${pascal}Screen', () => {
171
+ it('renders the empty state when the hook returns no rows', () => {
172
+ renderScreen();
173
+
174
+ expect(screen.getByText(/no ${names.module} yet/i)).toBeInTheDocument();
175
+ });
176
+
177
+ it('renders the filter shell and the create form', () => {
178
+ renderScreen();
179
+
180
+ expect(
181
+ screen.getByRole('navigation', { name: /${names.module} filters/i }),
182
+ ).toBeInTheDocument();
183
+ expect(
184
+ screen.getByRole('form', { name: /create /i }),
185
+ ).toBeInTheDocument();
186
+ });
187
+ });
188
+ `;
189
+ }
190
+
191
+ /**
192
+ * apps/web consumes the hook package as TypeScript source, so the
193
+ * dependency and the project reference both have to exist before the
194
+ * screen's own import resolves.
195
+ */
196
+ function addWebDependencies(tree: Tree) {
197
+ updateJson(tree, `${WEB_ROOT}/package.json`, (json) => {
198
+ json.dependencies = {
199
+ ...json.dependencies,
200
+ contracts: 'workspace:*',
201
+ hooks: 'workspace:*',
202
+ };
203
+ return json;
204
+ });
205
+
206
+ for (const config of ['tsconfig.json', 'tsconfig.spec.json']) {
207
+ updateJson(tree, `${WEB_ROOT}/${config}`, (json) => {
208
+ const references: { path: string }[] = json.references ?? [];
209
+ for (const path of ['../../packages/contracts', '../../packages/hooks']) {
210
+ if (!references.some((entry) => entry.path === path)) {
211
+ references.push({ path });
212
+ }
213
+ }
214
+ json.references = references;
215
+ return json;
216
+ });
217
+ }
218
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "http://json-schema.org/schema",
3
+ "$id": "HedgehogScreenLayer",
4
+ "title": "Screen skeleton for one domain module",
5
+ "type": "object",
6
+ "properties": {
7
+ "module": {
8
+ "type": "string",
9
+ "description": "Domain module name, plural kebab-case (e.g. tasks, order-items).",
10
+ "$default": { "$source": "argv", "index": 0 },
11
+ "x-prompt": "Domain module name (plural kebab-case)?"
12
+ }
13
+ },
14
+ "required": ["module"]
15
+ }
@@ -0,0 +1,194 @@
1
+ import { formatFiles, Tree } from '@nx/devkit';
2
+ import { generateLibShell } from '../lib-shell';
3
+ import { moduleNames, ModuleNames } from '../naming';
4
+
5
+ interface ServiceGeneratorOptions {
6
+ module: string;
7
+ }
8
+
9
+ export default async function serviceGenerator(
10
+ tree: Tree,
11
+ options: ServiceGeneratorOptions,
12
+ ) {
13
+ const names = moduleNames(options.module);
14
+ const root = `libs/${names.module}/service`;
15
+
16
+ await generateLibShell(tree, {
17
+ directory: root,
18
+ importName: `${names.module}-service`,
19
+ tags: [`scope:${names.module}`, 'type:service'],
20
+ dependencies: { [`${names.module}-repository`]: 'workspace:*' },
21
+ references: ['../repository/tsconfig.lib.json'],
22
+ });
23
+
24
+ tree.write(`${root}/src/lib/${names.entityKebab}.errors.ts`, errorsFile(names));
25
+ tree.write(`${root}/src/lib/${names.entityKebab}.service.ts`, serviceFile(names));
26
+ tree.write(
27
+ `${root}/src/lib/${names.entityKebab}.service.spec.ts`,
28
+ serviceSpecFile(names),
29
+ );
30
+ tree.write(`${root}/src/index.ts`, barrelFile(names));
31
+
32
+ await formatFiles(tree);
33
+ }
34
+
35
+ /**
36
+ * A distinct `name` on each error is what the controller layer switches on
37
+ * to pick a status code — `instanceof` alone does not survive the class
38
+ * identity being duplicated across a bundling boundary.
39
+ */
40
+ function errorsFile(names: ModuleNames): string {
41
+ const { entityPascal, entityCamel } = names;
42
+
43
+ return `export class ${entityPascal}NotFoundError extends Error {
44
+ override readonly name = '${entityPascal}NotFoundError';
45
+
46
+ constructor(readonly id: string) {
47
+ super(\`No ${entityCamel} exists with id \${id}.\`);
48
+ }
49
+ }
50
+
51
+ export class ${entityPascal}InvalidStateError extends Error {
52
+ override readonly name = '${entityPascal}InvalidStateError';
53
+
54
+ constructor(message: string) {
55
+ super(message);
56
+ }
57
+ }
58
+ `;
59
+ }
60
+
61
+ function serviceFile(names: ModuleNames): string {
62
+ const { entityPascal, entityCamel, entityKebab, module } = names;
63
+
64
+ return `import type {
65
+ ${entityPascal},
66
+ ${entityPascal}Draft,
67
+ ${entityPascal}Patch,
68
+ ${entityPascal}Repository,
69
+ } from '${module}-repository';
70
+ import { ${entityPascal}NotFoundError } from './${entityKebab}.errors';
71
+
72
+ /**
73
+ * Domain logic only — the ts-rest contract has already validated every
74
+ * input by the time a method here runs, so nothing re-parses.
75
+ */
76
+ export class ${entityPascal}Service {
77
+ constructor(private readonly ${entityCamel}s: ${entityPascal}Repository) {}
78
+
79
+ async list(): Promise<${entityPascal}[]> {
80
+ return this.${entityCamel}s.findAll();
81
+ }
82
+
83
+ async getById(id: string): Promise<${entityPascal}> {
84
+ const ${entityCamel} = await this.${entityCamel}s.findById(id);
85
+ if (!${entityCamel}) {
86
+ throw new ${entityPascal}NotFoundError(id);
87
+ }
88
+ return ${entityCamel};
89
+ }
90
+
91
+ async create(draft: ${entityPascal}Draft): Promise<${entityPascal}> {
92
+ return this.${entityCamel}s.create(draft);
93
+ }
94
+
95
+ async update(id: string, patch: ${entityPascal}Patch): Promise<${entityPascal}> {
96
+ // Read-then-write is two operations against one row, so it runs in one
97
+ // transaction: without it a concurrent delete between the two leaves
98
+ // update() returning undefined for a row this method already proved
99
+ // present.
100
+ return this.${entityCamel}s.transaction(async (repository) => {
101
+ const existing = await repository.findById(id);
102
+ if (!existing) {
103
+ throw new ${entityPascal}NotFoundError(id);
104
+ }
105
+ const updated = await repository.update(id, patch);
106
+ if (!updated) {
107
+ throw new ${entityPascal}NotFoundError(id);
108
+ }
109
+ return updated;
110
+ });
111
+ }
112
+
113
+ async remove(id: string): Promise<void> {
114
+ const removed = await this.${entityCamel}s.remove(id);
115
+ if (!removed) {
116
+ throw new ${entityPascal}NotFoundError(id);
117
+ }
118
+ }
119
+ }
120
+ `;
121
+ }
122
+
123
+ function serviceSpecFile(names: ModuleNames): string {
124
+ const { entityPascal, entityCamel, entityKebab } = names;
125
+
126
+ return `import { describe, expect, it, vi } from 'vitest';
127
+ import type { ${entityPascal}Repository } from '${names.module}-repository';
128
+ import { ${entityPascal}NotFoundError } from './${entityKebab}.errors';
129
+ import { ${entityPascal}Service } from './${entityKebab}.service';
130
+
131
+ function repositoryDouble(
132
+ overrides: Partial<${entityPascal}Repository> = {},
133
+ ): ${entityPascal}Repository {
134
+ const repository: ${entityPascal}Repository = {
135
+ findAll: vi.fn(async () => []),
136
+ findById: vi.fn(async () => undefined),
137
+ create: vi.fn(async (draft) => ({ id: 'created', ...draft }) as never),
138
+ update: vi.fn(async () => undefined),
139
+ remove: vi.fn(async () => false),
140
+ transaction: vi.fn(async (work) => work(repository)),
141
+ ...overrides,
142
+ };
143
+ return repository;
144
+ }
145
+
146
+ describe('${entityPascal}Service', () => {
147
+ it('throws a named domain error when a ${entityCamel} is absent', async () => {
148
+ const service = new ${entityPascal}Service(repositoryDouble());
149
+
150
+ await expect(service.getById('missing')).rejects.toBeInstanceOf(
151
+ ${entityPascal}NotFoundError,
152
+ );
153
+ await expect(service.getById('missing')).rejects.toMatchObject({
154
+ name: '${entityPascal}NotFoundError',
155
+ });
156
+ });
157
+
158
+ it('returns the row the repository found, unparsed', async () => {
159
+ const row = { id: 'present' } as never;
160
+ const service = new ${entityPascal}Service(
161
+ repositoryDouble({ findById: vi.fn(async () => row) }),
162
+ );
163
+
164
+ await expect(service.getById('present')).resolves.toBe(row);
165
+ });
166
+
167
+ it('runs a read-then-write update inside one transaction', async () => {
168
+ const repository = repositoryDouble({
169
+ findById: vi.fn(async () => ({ id: 'present' }) as never),
170
+ update: vi.fn(async () => ({ id: 'present' }) as never),
171
+ });
172
+ const service = new ${entityPascal}Service(repository);
173
+
174
+ await service.update('present', {} as never);
175
+
176
+ expect(repository.transaction).toHaveBeenCalledOnce();
177
+ });
178
+
179
+ it('turns a delete that matched nothing into a not-found error', async () => {
180
+ const service = new ${entityPascal}Service(repositoryDouble());
181
+
182
+ await expect(service.remove('missing')).rejects.toBeInstanceOf(
183
+ ${entityPascal}NotFoundError,
184
+ );
185
+ });
186
+ });
187
+ `;
188
+ }
189
+
190
+ function barrelFile(names: ModuleNames): string {
191
+ return `export * from './lib/${names.entityKebab}.errors';
192
+ export * from './lib/${names.entityKebab}.service';
193
+ `;
194
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "http://json-schema.org/schema",
3
+ "$id": "HedgehogServiceLayer",
4
+ "title": "Domain service for one domain module",
5
+ "type": "object",
6
+ "properties": {
7
+ "module": {
8
+ "type": "string",
9
+ "description": "Domain module name, plural kebab-case (e.g. tasks, order-items).",
10
+ "$default": { "$source": "argv", "index": 0 },
11
+ "x-prompt": "Domain module name (plural kebab-case)?"
12
+ }
13
+ },
14
+ "required": ["module"]
15
+ }
@@ -48,9 +48,16 @@ copied to the repo root:
48
48
  line to (in that layer's scope — see `core.yaml`). Tagged `scope:db`,
49
49
  `type:adapter`.
50
50
  - `apps/api/` — Nest shell, `nestjs-pino` wired, CORS enabled for
51
- `WEB_ORIGIN`, health check only, no domain controllers. `apps/api-e2e`
52
- already converted to Vitest with an explicit `e2e` target. Tagged
53
- `scope:api`.
51
+ `WEB_ORIGIN`, health check only, no domain controllers. Vitest wired
52
+ (`vitest.config.mts`, `tsconfig.spec.json` with
53
+ `experimentalDecorators`/`emitDecoratorMetadata` for `Test.createTestingModule`),
54
+ with a smoke test (`app.module.spec.ts`) instantiating `AppModule`.
55
+ `apps/api/src/app/feature-modules.ts` is a generated barrel — see
56
+ **The controller barrel** below — that `AppModule` imports and spreads
57
+ into its `imports`, so no domain module's controller layer ever edits
58
+ `app.module.ts`. Depends on `packages/db` (the controller layer imports
59
+ it by construction). `apps/api-e2e` already converted to Vitest with an
60
+ explicit `e2e` target. Tagged `scope:api`.
54
61
  - `apps/web/` — `.env.example` (`NEXT_PUBLIC_API_BASE_URL`, copied to
55
62
  `apps/web/.env.local` in step 4 — Next loads env files from the app
56
63
  directory, so the root `.env` never reaches it; the value carries
@@ -59,9 +66,14 @@ copied to the repo root:
59
66
  `cn()` util, CSS variable theme, light/dark toggle via an inline
60
67
  pre-hydration script + a client-side `ThemeToggle`), TanStack Query
61
68
  provider at the root layout, `prettier-plugin-tailwindcss` scoped to
62
- its own `.prettierrc.js`. Tagged `scope:web`. `apps/web-e2e` (Playwright,
63
- scaffolded automatically by `@nx/next:app`) gets its own `e2e` target by
64
- default — no rename needed, unlike `apps/api-e2e`.
69
+ its own `.prettierrc.js`. Vitest wired for jsdom (`vitest.config.mts`
70
+ with the `@vitejs/plugin-react` plugin and the `@/*` -> `./src/*` alias
71
+ apps/web/tsconfig.json already declares, `tsconfig.spec.json`,
72
+ `src/test-setup.ts` loading `@testing-library/jest-dom`'s matchers),
73
+ with a smoke test (`theme-toggle.spec.tsx`) rendering and clicking
74
+ `ThemeToggle` via Testing Library. Tagged `scope:web`. `apps/web-e2e`
75
+ (Playwright, scaffolded automatically by `@nx/next:app`) gets its own
76
+ `e2e` target by default — no rename needed, unlike `apps/api-e2e`.
65
77
  - The full `@nx/enforce-module-boundaries` `depConstraints` list for
66
78
  exactly these tags, matching the project shape `core.yaml`'s layer
67
79
  sequence actually produces — plus the `no-restricted-imports` rules that
@@ -80,6 +92,46 @@ copied to the repo root:
80
92
  from the committed `pnpm-lock.yaml`, which is a fast resolve against a
81
93
  locked graph, not a fresh solve.
82
94
 
95
+ ## The controller barrel
96
+
97
+ `apps/api/src/app/app.module.ts` never takes a per-module edit.
98
+ `core.yaml`'s controller layer scope is `apps/api/src/app/{module}/**` —
99
+ module-disjoint by construction, so two modules' controller tasks never
100
+ touch the same file — but `app.module.ts` itself sits outside every
101
+ module's scope, and `src/db/core.mjs`'s `validateCore` rejects a
102
+ non-exclusive layer whose scope omits `{module}` on a module-axis core,
103
+ so the controller layer's scope can't be widened to include it either. A
104
+ file two concurrent module builds both hand-edited would also be
105
+ invisible to the scheduler's conflict check (`src/db/conflict.mjs` only
106
+ compares each task's own declared scope globs), so even a single shared
107
+ line to append to would race undetected.
108
+
109
+ `apps/api/src/app/feature-modules.ts` solves this by never being
110
+ hand-edited at all: `tools/generate-feature-modules.cjs` globs
111
+ `apps/api/src/app/*/*.module.ts` and writes it as a generated barrel — a
112
+ literal `import { XModule } from './x/x.module'` per domain module found,
113
+ plus an exported `featureModules` array — every time the
114
+ `generate-feature-modules` Nx target runs. `AppModule` imports that one
115
+ generated file and spreads `featureModules` into its own `imports`. A
116
+ module's controller layer only ever creates its own `{module}.module.ts`
117
+ inside its own `apps/api/src/app/{module}/` directory — always in scope,
118
+ never colliding with any other module's controller task. In the shipped
119
+ core, no domain module exists yet, so the glob finds nothing and
120
+ `feature-modules.ts` exports an empty array.
121
+
122
+ `generate-feature-modules` is wired as an explicit Nx target on `apps/api`
123
+ (`apps/api/package.json`'s `nx.targets`), cached, with `build` declaring
124
+ it as a `dependsOn`; `test` and `typecheck` pick it up the same way
125
+ through `nx.json`'s `targetDefaults` (a project with no matching target,
126
+ such as `db` or `web`, silently skips a missing `dependsOn` entry rather
127
+ than failing). Static imports rather than a runtime directory scan:
128
+ `apps/api` builds through `NxAppWebpackPlugin`, which bundles by
129
+ statically walking `main.ts`'s import graph — a file never reached by a
130
+ static `import`/`require` is dropped from the bundle entirely, so a
131
+ runtime `fs.readdirSync` scan for sibling files would find nothing in the
132
+ built output even though the same scan works when Vitest runs the same
133
+ source directly. Generating literal imports keeps both paths identical.
134
+
83
135
  ## Steps
84
136
 
85
137
  ### 1. Confirm this hasn't already run
@@ -271,6 +323,15 @@ bug by "cleaning up" what looks like an unnecessary pin or directive.
271
323
  connection error — easy to mistake for a routing bug in the api
272
324
  itself). Keep `apps/api`'s fallback at `3333` and don't let it drift
273
325
  back to matching Next's default.
326
+ - **`apps/web/vitest.config.mts` needs `environment: 'jsdom'`, the
327
+ `@vitejs/plugin-react` plugin, and the `@/*` -> `./src/*` alias resolved
328
+ explicitly.** `@nx/next:app --unitTestRunner=vitest` generates a
329
+ Node-flavored config with no JSX plugin and no alias resolution — wrong
330
+ for a React app, and Vitest doesn't read `tsconfig.json`'s `paths` on
331
+ its own. `apps/web/src/test-setup.ts` (loading
332
+ `@testing-library/jest-dom/vitest`) is wired as the config's
333
+ `setupFiles` entry so `toBeInTheDocument()` and friends resolve in every
334
+ spec without a per-file import.
274
335
  - **No `NODE_ENV=production` build-target override needed** (Nx
275
336
  23.1.0, Next 16.1.7). Targets are inferred from
276
337
  `package.json`/`next.config.js` via the `@nx/next` plugin, with no