@skyf0xx/hedgehog 4.2.0 → 4.2.3

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 (46) 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-authored-loop/SKILL.md +1 -1
  44. package/src/skills/hedgehog-bootstrap-full-stack-app-core/SKILL.md +72 -7
  45. package/src/skills/hedgehog-loop/SKILL.md +123 -55
  46. package/src/templates/CLAUDE.md +3 -2
@@ -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
+ }
@@ -284,7 +284,7 @@ the ones that were designed.
284
284
  Same fresh-context handoff as `hedgehog-loop`'s Stop Condition (offer it
285
285
  once every task is `complete`, `hedgehog boundary` exits 0, and
286
286
  scope isn't genuinely ambiguous; the permanent record is the committed
287
- intents, friction log, and `core.yaml`, not `.hedgehog/hedgehog.db`,
287
+ intents, friction log, and `.hedgehog/core.yaml`, not `.hedgehog/hedgehog.db`,
288
288
  which is gitignored and derived; a `tweaker` session in a *new* chat
289
289
  window handles adjustments, using the same paste-in prompt that skill's
290
290
  Stop Condition gives). On a module axis, "every task complete" means
@@ -36,7 +36,11 @@ copied to the repo root:
36
36
  (`DATABASE_URL`/`NODE_ENV`/`WEB_ORIGIN`, copied to `.env` in step 4),
37
37
  `lefthook.yml`, `commitlint.config.cjs`,
38
38
  `tools/phase-gate.cjs`, `.github/workflows/phase-gate.yml`,
39
- `tsconfig.base.json`, `pnpm-lock.yaml`.
39
+ `tsconfig.base.json`, `pnpm-lock.yaml`, and `core.yaml` — the shipped
40
+ layer sequence `hedgehog plan`/`verify`/`next` read for this project.
41
+ This root `core.yaml` is a different file from `.hedgehog/core.yaml`,
42
+ which only exists on an authored core (see `hedgehog-core-design`) —
43
+ the two never coexist on the same project.
40
44
  - `packages/config/` — `eslint-base.js`, `prettier.js` (no
41
45
  `prettier-plugin-tailwindcss` — that's `apps/web`'s own config, already
42
46
  wired), `env.schema.ts` (core fields only: `DATABASE_URL`, `NODE_ENV`,
@@ -48,9 +52,16 @@ copied to the repo root:
48
52
  line to (in that layer's scope — see `core.yaml`). Tagged `scope:db`,
49
53
  `type:adapter`.
50
54
  - `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`.
55
+ `WEB_ORIGIN`, health check only, no domain controllers. Vitest wired
56
+ (`vitest.config.mts`, `tsconfig.spec.json` with
57
+ `experimentalDecorators`/`emitDecoratorMetadata` for `Test.createTestingModule`),
58
+ with a smoke test (`app.module.spec.ts`) instantiating `AppModule`.
59
+ `apps/api/src/app/feature-modules.ts` is a generated barrel — see
60
+ **The controller barrel** below — that `AppModule` imports and spreads
61
+ into its `imports`, so no domain module's controller layer ever edits
62
+ `app.module.ts`. Depends on `packages/db` (the controller layer imports
63
+ it by construction). `apps/api-e2e` already converted to Vitest with an
64
+ explicit `e2e` target. Tagged `scope:api`.
54
65
  - `apps/web/` — `.env.example` (`NEXT_PUBLIC_API_BASE_URL`, copied to
55
66
  `apps/web/.env.local` in step 4 — Next loads env files from the app
56
67
  directory, so the root `.env` never reaches it; the value carries
@@ -59,9 +70,14 @@ copied to the repo root:
59
70
  `cn()` util, CSS variable theme, light/dark toggle via an inline
60
71
  pre-hydration script + a client-side `ThemeToggle`), TanStack Query
61
72
  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`.
73
+ its own `.prettierrc.js`. Vitest wired for jsdom (`vitest.config.mts`
74
+ with the `@vitejs/plugin-react` plugin and the `@/*` -> `./src/*` alias
75
+ apps/web/tsconfig.json already declares, `tsconfig.spec.json`,
76
+ `src/test-setup.ts` loading `@testing-library/jest-dom`'s matchers),
77
+ with a smoke test (`theme-toggle.spec.tsx`) rendering and clicking
78
+ `ThemeToggle` via Testing Library. Tagged `scope:web`. `apps/web-e2e`
79
+ (Playwright, scaffolded automatically by `@nx/next:app`) gets its own
80
+ `e2e` target by default — no rename needed, unlike `apps/api-e2e`.
65
81
  - The full `@nx/enforce-module-boundaries` `depConstraints` list for
66
82
  exactly these tags, matching the project shape `core.yaml`'s layer
67
83
  sequence actually produces — plus the `no-restricted-imports` rules that
@@ -80,6 +96,46 @@ copied to the repo root:
80
96
  from the committed `pnpm-lock.yaml`, which is a fast resolve against a
81
97
  locked graph, not a fresh solve.
82
98
 
99
+ ## The controller barrel
100
+
101
+ `apps/api/src/app/app.module.ts` never takes a per-module edit.
102
+ `core.yaml`'s controller layer scope is `apps/api/src/app/{module}/**` —
103
+ module-disjoint by construction, so two modules' controller tasks never
104
+ touch the same file — but `app.module.ts` itself sits outside every
105
+ module's scope, and `src/db/core.mjs`'s `validateCore` rejects a
106
+ non-exclusive layer whose scope omits `{module}` on a module-axis core,
107
+ so the controller layer's scope can't be widened to include it either. A
108
+ file two concurrent module builds both hand-edited would also be
109
+ invisible to the scheduler's conflict check (`src/db/conflict.mjs` only
110
+ compares each task's own declared scope globs), so even a single shared
111
+ line to append to would race undetected.
112
+
113
+ `apps/api/src/app/feature-modules.ts` solves this by never being
114
+ hand-edited at all: `tools/generate-feature-modules.cjs` globs
115
+ `apps/api/src/app/*/*.module.ts` and writes it as a generated barrel — a
116
+ literal `import { XModule } from './x/x.module'` per domain module found,
117
+ plus an exported `featureModules` array — every time the
118
+ `generate-feature-modules` Nx target runs. `AppModule` imports that one
119
+ generated file and spreads `featureModules` into its own `imports`. A
120
+ module's controller layer only ever creates its own `{module}.module.ts`
121
+ inside its own `apps/api/src/app/{module}/` directory — always in scope,
122
+ never colliding with any other module's controller task. In the shipped
123
+ core, no domain module exists yet, so the glob finds nothing and
124
+ `feature-modules.ts` exports an empty array.
125
+
126
+ `generate-feature-modules` is wired as an explicit Nx target on `apps/api`
127
+ (`apps/api/package.json`'s `nx.targets`), cached, with `build` declaring
128
+ it as a `dependsOn`; `test` and `typecheck` pick it up the same way
129
+ through `nx.json`'s `targetDefaults` (a project with no matching target,
130
+ such as `db` or `web`, silently skips a missing `dependsOn` entry rather
131
+ than failing). Static imports rather than a runtime directory scan:
132
+ `apps/api` builds through `NxAppWebpackPlugin`, which bundles by
133
+ statically walking `main.ts`'s import graph — a file never reached by a
134
+ static `import`/`require` is dropped from the bundle entirely, so a
135
+ runtime `fs.readdirSync` scan for sibling files would find nothing in the
136
+ built output even though the same scan works when Vitest runs the same
137
+ source directly. Generating literal imports keeps both paths identical.
138
+
83
139
  ## Steps
84
140
 
85
141
  ### 1. Confirm this hasn't already run
@@ -271,6 +327,15 @@ bug by "cleaning up" what looks like an unnecessary pin or directive.
271
327
  connection error — easy to mistake for a routing bug in the api
272
328
  itself). Keep `apps/api`'s fallback at `3333` and don't let it drift
273
329
  back to matching Next's default.
330
+ - **`apps/web/vitest.config.mts` needs `environment: 'jsdom'`, the
331
+ `@vitejs/plugin-react` plugin, and the `@/*` -> `./src/*` alias resolved
332
+ explicitly.** `@nx/next:app --unitTestRunner=vitest` generates a
333
+ Node-flavored config with no JSX plugin and no alias resolution — wrong
334
+ for a React app, and Vitest doesn't read `tsconfig.json`'s `paths` on
335
+ its own. `apps/web/src/test-setup.ts` (loading
336
+ `@testing-library/jest-dom/vitest`) is wired as the config's
337
+ `setupFiles` entry so `toBeInTheDocument()` and friends resolve in every
338
+ spec without a per-file import.
274
339
  - **No `NODE_ENV=production` build-target override needed** (Nx
275
340
  23.1.0, Next 16.1.7). Targets are inferred from
276
341
  `package.json`/`next.config.js` via the `@nx/next` plugin, with no