@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.
- package/README.md +20 -0
- package/bin/cli.mjs +12 -0
- package/package.json +2 -2
- package/src/agents/backend-eng.md +30 -16
- package/src/agents/front-end-eng.md +29 -12
- package/src/db/next.mjs +46 -8
- package/src/golden-cores/full-stack-app/apps/api/package.json +19 -1
- package/src/golden-cores/full-stack-app/apps/api/src/app/app.module.spec.ts +15 -0
- package/src/golden-cores/full-stack-app/apps/api/src/app/app.module.ts +6 -1
- package/src/golden-cores/full-stack-app/apps/api/src/app/feature-modules.ts +8 -0
- package/src/golden-cores/full-stack-app/apps/api/tsconfig.app.json +3 -0
- package/src/golden-cores/full-stack-app/apps/api/tsconfig.json +3 -0
- package/src/golden-cores/full-stack-app/apps/api/tsconfig.spec.json +36 -0
- package/src/golden-cores/full-stack-app/apps/api/vitest.config.mts +18 -0
- package/src/golden-cores/full-stack-app/apps/web/src/components/theme-toggle.spec.tsx +20 -0
- package/src/golden-cores/full-stack-app/apps/web/src/test-setup.ts +1 -0
- package/src/golden-cores/full-stack-app/apps/web/tsconfig.json +6 -0
- package/src/golden-cores/full-stack-app/apps/web/tsconfig.spec.json +37 -0
- package/src/golden-cores/full-stack-app/apps/web/vitest.config.mts +27 -0
- package/src/golden-cores/full-stack-app/nx.json +4 -1
- package/src/golden-cores/full-stack-app/package.json +8 -0
- package/src/golden-cores/full-stack-app/pnpm-lock.yaml +8735 -2907
- package/src/golden-cores/full-stack-app/tools/generate-feature-modules.cjs +104 -0
- package/src/golden-cores/full-stack-app/tools/generators/contract/generator.ts +283 -0
- package/src/golden-cores/full-stack-app/tools/generators/contract/schema.json +20 -0
- package/src/golden-cores/full-stack-app/tools/generators/controller/generator.ts +323 -0
- package/src/golden-cores/full-stack-app/tools/generators/controller/schema.json +20 -0
- package/src/golden-cores/full-stack-app/tools/generators/fields.ts +126 -0
- package/src/golden-cores/full-stack-app/tools/generators/generators.json +42 -0
- package/src/golden-cores/full-stack-app/tools/generators/hook/generator.ts +274 -0
- package/src/golden-cores/full-stack-app/tools/generators/hook/schema.json +19 -0
- package/src/golden-cores/full-stack-app/tools/generators/lib-shell.ts +124 -0
- package/src/golden-cores/full-stack-app/tools/generators/naming.ts +84 -0
- package/src/golden-cores/full-stack-app/tools/generators/package.json +6 -0
- package/src/golden-cores/full-stack-app/tools/generators/repository/generator.ts +298 -0
- package/src/golden-cores/full-stack-app/tools/generators/repository/schema.json +15 -0
- package/src/golden-cores/full-stack-app/tools/generators/schema/generator.ts +169 -0
- package/src/golden-cores/full-stack-app/tools/generators/schema/schema.json +20 -0
- package/src/golden-cores/full-stack-app/tools/generators/screen/generator.ts +218 -0
- package/src/golden-cores/full-stack-app/tools/generators/screen/schema.json +15 -0
- package/src/golden-cores/full-stack-app/tools/generators/service/generator.ts +194 -0
- package/src/golden-cores/full-stack-app/tools/generators/service/schema.json +15 -0
- package/src/skills/hedgehog-bootstrap-full-stack-app-core/SKILL.md +67 -6
- 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.
|
|
52
|
-
|
|
53
|
-
`
|
|
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`.
|
|
63
|
-
|
|
64
|
-
|
|
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
|