@skyf0xx/hedgehog-core-full-stack-app 1.0.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 (137) hide show
  1. package/CLAUDE.core.md +147 -0
  2. package/LICENSE +21 -0
  3. package/README.md +41 -0
  4. package/agents/backend-eng.md +189 -0
  5. package/agents/front-end-eng.md +176 -0
  6. package/agents/ux-planner.md +174 -0
  7. package/hedgehog-core.yaml +20 -0
  8. package/package.json +41 -0
  9. package/skills/hedgehog-bootstrap/SKILL.md +324 -0
  10. package/skills/hedgehog-bootstrap-full-stack-app-core/SKILL.md +411 -0
  11. package/skills/hedgehog-loop/SKILL.md +643 -0
  12. package/skills/link-workspace-packages/SKILL.md +41 -0
  13. package/skills/nx-generate/SKILL.md +165 -0
  14. package/skills/nx-run-tasks/SKILL.md +52 -0
  15. package/skills/nx-workspace/SKILL.md +261 -0
  16. package/skills/nx-workspace/references/AFFECTED.md +27 -0
  17. package/vendor-skills/GSAP/ATTRIBUTION.md +30 -0
  18. package/vendor-skills/GSAP/LICENSE +21 -0
  19. package/vendor-skills/GSAP/gsap-core/SKILL.md +254 -0
  20. package/vendor-skills/GSAP/gsap-frameworks/SKILL.md +266 -0
  21. package/vendor-skills/GSAP/gsap-performance/SKILL.md +79 -0
  22. package/vendor-skills/GSAP/gsap-plugins/SKILL.md +433 -0
  23. package/vendor-skills/GSAP/gsap-react/SKILL.md +136 -0
  24. package/vendor-skills/GSAP/gsap-scrolltrigger/SKILL.md +296 -0
  25. package/vendor-skills/GSAP/gsap-timeline/SKILL.md +107 -0
  26. package/vendor-skills/GSAP/gsap-utils/SKILL.md +284 -0
  27. package/vendor-skills/GSAP/llms.txt +39 -0
  28. package/workspace/.env.example +5 -0
  29. package/workspace/.github/workflows/phase-gate.yml +17 -0
  30. package/workspace/.prettierignore +5 -0
  31. package/workspace/.prettierrc +3 -0
  32. package/workspace/.vscode/extensions.json +3 -0
  33. package/workspace/apps/api/eslint.config.mjs +3 -0
  34. package/workspace/apps/api/package.json +117 -0
  35. package/workspace/apps/api/src/app/app.module.spec.ts +15 -0
  36. package/workspace/apps/api/src/app/app.module.ts +15 -0
  37. package/workspace/apps/api/src/app/feature-modules.ts +8 -0
  38. package/workspace/apps/api/src/app/health.controller.ts +9 -0
  39. package/workspace/apps/api/src/assets/.gitkeep +0 -0
  40. package/workspace/apps/api/src/main.ts +32 -0
  41. package/workspace/apps/api/tsconfig.app.json +22 -0
  42. package/workspace/apps/api/tsconfig.json +13 -0
  43. package/workspace/apps/api/tsconfig.spec.json +36 -0
  44. package/workspace/apps/api/vitest.config.mts +18 -0
  45. package/workspace/apps/api/webpack.config.cjs +25 -0
  46. package/workspace/apps/api-e2e/eslint.config.mjs +3 -0
  47. package/workspace/apps/api-e2e/package.json +28 -0
  48. package/workspace/apps/api-e2e/src/api/api.spec.ts +11 -0
  49. package/workspace/apps/api-e2e/src/support/global-setup.ts +17 -0
  50. package/workspace/apps/api-e2e/src/support/test-setup.ts +6 -0
  51. package/workspace/apps/api-e2e/tsconfig.json +10 -0
  52. package/workspace/apps/api-e2e/tsconfig.spec.json +28 -0
  53. package/workspace/apps/api-e2e/vitest.config.mts +20 -0
  54. package/workspace/apps/web/.env.example +12 -0
  55. package/workspace/apps/web/.prettierrc.js +10 -0
  56. package/workspace/apps/web/.swcrc +30 -0
  57. package/workspace/apps/web/components.json +21 -0
  58. package/workspace/apps/web/eslint.config.mjs +12 -0
  59. package/workspace/apps/web/index.d.ts +7 -0
  60. package/workspace/apps/web/next-env.d.ts +7 -0
  61. package/workspace/apps/web/next.config.js +9 -0
  62. package/workspace/apps/web/package.json +38 -0
  63. package/workspace/apps/web/postcss.config.mjs +7 -0
  64. package/workspace/apps/web/public/.gitkeep +0 -0
  65. package/workspace/apps/web/public/favicon.ico +0 -0
  66. package/workspace/apps/web/src/app/global.css +83 -0
  67. package/workspace/apps/web/src/app/layout.tsx +34 -0
  68. package/workspace/apps/web/src/app/module-routes.ts +13 -0
  69. package/workspace/apps/web/src/app/page.tsx +34 -0
  70. package/workspace/apps/web/src/app/providers.tsx +12 -0
  71. package/workspace/apps/web/src/components/theme-toggle.spec.tsx +20 -0
  72. package/workspace/apps/web/src/components/theme-toggle.tsx +32 -0
  73. package/workspace/apps/web/src/components/ui/button.tsx +60 -0
  74. package/workspace/apps/web/src/lib/utils.ts +6 -0
  75. package/workspace/apps/web/src/test-setup.ts +1 -0
  76. package/workspace/apps/web/tsconfig.json +62 -0
  77. package/workspace/apps/web/tsconfig.spec.json +37 -0
  78. package/workspace/apps/web/vitest.config.mts +27 -0
  79. package/workspace/apps/web-e2e/eslint.config.mjs +12 -0
  80. package/workspace/apps/web-e2e/package.json +10 -0
  81. package/workspace/apps/web-e2e/playwright.config.mts +75 -0
  82. package/workspace/apps/web-e2e/src/example.spec.ts +7 -0
  83. package/workspace/apps/web-e2e/tsconfig.json +26 -0
  84. package/workspace/commitlint.config.cjs +3 -0
  85. package/workspace/core.yaml +68 -0
  86. package/workspace/docker-compose.yml +14 -0
  87. package/workspace/eslint.config.mjs +15 -0
  88. package/workspace/gitignore.template +24 -0
  89. package/workspace/lefthook.yml +52 -0
  90. package/workspace/nx.json +90 -0
  91. package/workspace/package.json +99 -0
  92. package/workspace/packages/config/eslint-base.js +205 -0
  93. package/workspace/packages/config/package.json +28 -0
  94. package/workspace/packages/config/prettier.js +13 -0
  95. package/workspace/packages/config/src/env.schema.spec.ts +38 -0
  96. package/workspace/packages/config/src/env.schema.ts +18 -0
  97. package/workspace/packages/config/src/index.ts +1 -0
  98. package/workspace/packages/config/tsconfig.json +13 -0
  99. package/workspace/packages/config/tsconfig.lib.json +27 -0
  100. package/workspace/packages/config/tsconfig.spec.json +34 -0
  101. package/workspace/packages/config/vitest.config.mts +18 -0
  102. package/workspace/packages/db/package.json +27 -0
  103. package/workspace/packages/db/src/index.ts +2 -0
  104. package/workspace/packages/db/src/lib/db.spec.ts +48 -0
  105. package/workspace/packages/db/src/lib/db.ts +54 -0
  106. package/workspace/packages/db/src/schema/index.ts +4 -0
  107. package/workspace/packages/db/tsconfig.json +13 -0
  108. package/workspace/packages/db/tsconfig.lib.json +27 -0
  109. package/workspace/packages/db/tsconfig.spec.json +34 -0
  110. package/workspace/packages/db/vitest.config.mts +18 -0
  111. package/workspace/pnpm-lock.yaml +23719 -0
  112. package/workspace/pnpm-workspace.yaml +7 -0
  113. package/workspace/tools/generate-feature-modules.cjs +104 -0
  114. package/workspace/tools/generate-module-routes.cjs +89 -0
  115. package/workspace/tools/generators/contract/generator.ts +336 -0
  116. package/workspace/tools/generators/contract/schema.json +24 -0
  117. package/workspace/tools/generators/controller/generator.ts +390 -0
  118. package/workspace/tools/generators/controller/schema.json +24 -0
  119. package/workspace/tools/generators/fields.ts +160 -0
  120. package/workspace/tools/generators/generators.json +42 -0
  121. package/workspace/tools/generators/hook/generator.ts +291 -0
  122. package/workspace/tools/generators/hook/schema.json +19 -0
  123. package/workspace/tools/generators/lib-shell.ts +124 -0
  124. package/workspace/tools/generators/naming.ts +84 -0
  125. package/workspace/tools/generators/package.json +6 -0
  126. package/workspace/tools/generators/repository/generator.ts +298 -0
  127. package/workspace/tools/generators/repository/schema.json +15 -0
  128. package/workspace/tools/generators/schema/generator.ts +169 -0
  129. package/workspace/tools/generators/schema/schema.json +20 -0
  130. package/workspace/tools/generators/screen/generator.ts +218 -0
  131. package/workspace/tools/generators/screen/schema.json +15 -0
  132. package/workspace/tools/generators/service/generator.ts +270 -0
  133. package/workspace/tools/generators/service/schema.json +19 -0
  134. package/workspace/tools/phase-gate.cjs +77 -0
  135. package/workspace/tsconfig.base.json +21 -0
  136. package/workspace/tsconfig.json +25 -0
  137. package/workspace/vitest.workspace.ts +4 -0
@@ -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,270 @@
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
+ toggleField?: string;
8
+ }
9
+
10
+ export default async function serviceGenerator(
11
+ tree: Tree,
12
+ options: ServiceGeneratorOptions,
13
+ ) {
14
+ const names = moduleNames(options.module);
15
+ const root = `libs/${names.module}/service`;
16
+
17
+ await generateLibShell(tree, {
18
+ directory: root,
19
+ importName: `${names.module}-service`,
20
+ tags: [`scope:${names.module}`, 'type:service'],
21
+ dependencies: { [`${names.module}-repository`]: 'workspace:*' },
22
+ references: ['../repository/tsconfig.lib.json'],
23
+ });
24
+
25
+ tree.write(`${root}/src/lib/${names.entityKebab}.errors.ts`, errorsFile(names));
26
+ tree.write(
27
+ `${root}/src/lib/${names.entityKebab}.service.ts`,
28
+ serviceFile(names, options.toggleField),
29
+ );
30
+ tree.write(
31
+ `${root}/src/lib/${names.entityKebab}.service.spec.ts`,
32
+ serviceSpecFile(names, options.toggleField),
33
+ );
34
+ tree.write(`${root}/src/index.ts`, barrelFile(names));
35
+
36
+ await formatFiles(tree);
37
+ }
38
+
39
+ /**
40
+ * A distinct `name` on each error is what the controller layer switches on
41
+ * to pick a status code — `instanceof` alone does not survive the class
42
+ * identity being duplicated across a bundling boundary.
43
+ */
44
+ function errorsFile(names: ModuleNames): string {
45
+ const { entityPascal, entityCamel } = names;
46
+
47
+ return `export class ${entityPascal}NotFoundError extends Error {
48
+ override readonly name = '${entityPascal}NotFoundError';
49
+
50
+ constructor(readonly id: string) {
51
+ super(\`No ${entityCamel} exists with id \${id}.\`);
52
+ }
53
+ }
54
+
55
+ export class ${entityPascal}InvalidStateError extends Error {
56
+ override readonly name = '${entityPascal}InvalidStateError';
57
+
58
+ constructor(message: string) {
59
+ super(message);
60
+ }
61
+ }
62
+ `;
63
+ }
64
+
65
+ /**
66
+ * The flip is computed from the row this transaction just read, never
67
+ * from a value the caller supplied: the client has no way to send a
68
+ * stale one, so two toggles racing against each other serialize into two
69
+ * flips instead of both writing the same value and losing one.
70
+ */
71
+ function toggleMethod(names: ModuleNames, toggleField: string): string {
72
+ const { entityPascal, entityCamel } = names;
73
+
74
+ return `
75
+
76
+ async toggle${entityPascal}(id: string): Promise<${entityPascal}> {
77
+ return this.${entityCamel}s.transaction(async (repository) => {
78
+ const existing = await repository.findById(id);
79
+ if (!existing) {
80
+ throw new ${entityPascal}NotFoundError(id);
81
+ }
82
+ const updated = await repository.update(id, {
83
+ ${toggleField}: !existing.${toggleField},
84
+ } as ${entityPascal}Patch);
85
+ if (!updated) {
86
+ throw new ${entityPascal}NotFoundError(id);
87
+ }
88
+ return updated;
89
+ });
90
+ }`;
91
+ }
92
+
93
+ function serviceFile(names: ModuleNames, toggleField?: string): string {
94
+ const { entityPascal, entityCamel, entityKebab, module } = names;
95
+
96
+ return `import type {
97
+ ${entityPascal},
98
+ ${entityPascal}Draft,
99
+ ${entityPascal}Patch,
100
+ ${entityPascal}Repository,
101
+ } from '${module}-repository';
102
+ import { ${entityPascal}NotFoundError } from './${entityKebab}.errors';
103
+
104
+ /**
105
+ * Domain logic only — the ts-rest contract has already validated every
106
+ * input by the time a method here runs, so nothing re-parses.
107
+ */
108
+ export class ${entityPascal}Service {
109
+ constructor(private readonly ${entityCamel}s: ${entityPascal}Repository) {}
110
+
111
+ async list(): Promise<${entityPascal}[]> {
112
+ return this.${entityCamel}s.findAll();
113
+ }
114
+
115
+ async getById(id: string): Promise<${entityPascal}> {
116
+ const ${entityCamel} = await this.${entityCamel}s.findById(id);
117
+ if (!${entityCamel}) {
118
+ throw new ${entityPascal}NotFoundError(id);
119
+ }
120
+ return ${entityCamel};
121
+ }
122
+
123
+ async create(draft: ${entityPascal}Draft): Promise<${entityPascal}> {
124
+ return this.${entityCamel}s.create(draft);
125
+ }
126
+
127
+ async update(id: string, patch: ${entityPascal}Patch): Promise<${entityPascal}> {
128
+ // Read-then-write is two operations against one row, so it runs in one
129
+ // transaction: without it a concurrent delete between the two leaves
130
+ // update() returning undefined for a row this method already proved
131
+ // present.
132
+ return this.${entityCamel}s.transaction(async (repository) => {
133
+ const existing = await repository.findById(id);
134
+ if (!existing) {
135
+ throw new ${entityPascal}NotFoundError(id);
136
+ }
137
+ const updated = await repository.update(id, patch);
138
+ if (!updated) {
139
+ throw new ${entityPascal}NotFoundError(id);
140
+ }
141
+ return updated;
142
+ });
143
+ }
144
+
145
+ async remove(id: string): Promise<void> {
146
+ const removed = await this.${entityCamel}s.remove(id);
147
+ if (!removed) {
148
+ throw new ${entityPascal}NotFoundError(id);
149
+ }
150
+ }${toggleField ? toggleMethod(names, toggleField) : ''}
151
+ }
152
+ `;
153
+ }
154
+
155
+ function serviceSpecFile(names: ModuleNames, toggleField?: string): string {
156
+ const { entityPascal, entityCamel, entityKebab } = names;
157
+
158
+ return `import { describe, expect, it, vi } from 'vitest';
159
+ import type { ${entityPascal}Repository } from '${names.module}-repository';
160
+ import { ${entityPascal}NotFoundError } from './${entityKebab}.errors';
161
+ import { ${entityPascal}Service } from './${entityKebab}.service';
162
+
163
+ function repositoryDouble(
164
+ overrides: Partial<${entityPascal}Repository> = {},
165
+ ): ${entityPascal}Repository {
166
+ const repository: ${entityPascal}Repository = {
167
+ findAll: vi.fn(async () => []),
168
+ findById: vi.fn(async () => undefined),
169
+ create: vi.fn(async (draft) => ({ id: 'created', ...draft }) as never),
170
+ update: vi.fn(async () => undefined),
171
+ remove: vi.fn(async () => false),
172
+ transaction: vi.fn(async (work) => work(repository)),
173
+ ...overrides,
174
+ };
175
+ return repository;
176
+ }
177
+
178
+ describe('${entityPascal}Service', () => {
179
+ it('throws a named domain error when a ${entityCamel} is absent', async () => {
180
+ const service = new ${entityPascal}Service(repositoryDouble());
181
+
182
+ await expect(service.getById('missing')).rejects.toBeInstanceOf(
183
+ ${entityPascal}NotFoundError,
184
+ );
185
+ await expect(service.getById('missing')).rejects.toMatchObject({
186
+ name: '${entityPascal}NotFoundError',
187
+ });
188
+ });
189
+
190
+ it('returns the row the repository found, unparsed', async () => {
191
+ const row = { id: 'present' } as never;
192
+ const service = new ${entityPascal}Service(
193
+ repositoryDouble({ findById: vi.fn(async () => row) }),
194
+ );
195
+
196
+ await expect(service.getById('present')).resolves.toBe(row);
197
+ });
198
+
199
+ it('runs a read-then-write update inside one transaction', async () => {
200
+ const repository = repositoryDouble({
201
+ findById: vi.fn(async () => ({ id: 'present' }) as never),
202
+ update: vi.fn(async () => ({ id: 'present' }) as never),
203
+ });
204
+ const service = new ${entityPascal}Service(repository);
205
+
206
+ await service.update('present', {} as never);
207
+
208
+ expect(repository.transaction).toHaveBeenCalledOnce();
209
+ });
210
+
211
+ it('turns a delete that matched nothing into a not-found error', async () => {
212
+ const service = new ${entityPascal}Service(repositoryDouble());
213
+
214
+ await expect(service.remove('missing')).rejects.toBeInstanceOf(
215
+ ${entityPascal}NotFoundError,
216
+ );
217
+ });
218
+ ${
219
+ toggleField
220
+ ? `
221
+ it('derives the new ${toggleField} from stored state, not from the caller', async () => {
222
+ const repository = repositoryDouble({
223
+ findById: vi.fn(async () => ({ id: 'present', ${toggleField}: true }) as never),
224
+ update: vi.fn(async (_id, patch) => ({ id: 'present', ...patch }) as never),
225
+ });
226
+ const service = new ${entityPascal}Service(repository);
227
+
228
+ await service.toggle${entityPascal}('present');
229
+
230
+ expect(repository.update).toHaveBeenCalledWith('present', {
231
+ ${toggleField}: false,
232
+ });
233
+ expect(repository.transaction).toHaveBeenCalledOnce();
234
+ });
235
+
236
+ it('flips twice back to the original value, so no flip is lost', async () => {
237
+ let stored = false;
238
+ const repository = repositoryDouble({
239
+ findById: vi.fn(async () => ({ id: 'present', ${toggleField}: stored }) as never),
240
+ update: vi.fn(async (_id, patch) => {
241
+ stored = (patch as { ${toggleField}: boolean }).${toggleField};
242
+ return { id: 'present', ${toggleField}: stored } as never;
243
+ }),
244
+ });
245
+ const service = new ${entityPascal}Service(repository);
246
+
247
+ await service.toggle${entityPascal}('present');
248
+ expect(stored).toBe(true);
249
+ await service.toggle${entityPascal}('present');
250
+ expect(stored).toBe(false);
251
+ });
252
+
253
+ it('rejects a toggle against a ${entityCamel} that is not there', async () => {
254
+ const service = new ${entityPascal}Service(repositoryDouble());
255
+
256
+ await expect(service.toggle${entityPascal}('missing')).rejects.toBeInstanceOf(
257
+ ${entityPascal}NotFoundError,
258
+ );
259
+ });
260
+ `
261
+ : ''
262
+ }});
263
+ `;
264
+ }
265
+
266
+ function barrelFile(names: ModuleNames): string {
267
+ return `export * from './lib/${names.entityKebab}.errors';
268
+ export * from './lib/${names.entityKebab}.service';
269
+ `;
270
+ }
@@ -0,0 +1,19 @@
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
+ "toggleField": {
14
+ "type": "string",
15
+ "description": "A boolean field from the schema layer to expose as a server-side toggle. Pass the same value to the schema, contract, service, controller, and hook layers. Omit when the module has none."
16
+ }
17
+ },
18
+ "required": ["module"]
19
+ }
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Phase gate: "Phase A closes before Phase B opens", enforced in CI.
4
+ *
5
+ * Blocks a PR that introduces a `feat(<module>): hooks` or
6
+ * `feat(<module>): screen-*` commit for a module with no prior
7
+ * `feat(<module>): api` commit already on the branch (or on the base
8
+ * branch it diverged from).
9
+ *
10
+ * Written as CommonJS (`.cjs`) deliberately: root `package.json` sets
11
+ * `"type": "module"`, so a plain `.js` file here would be parsed as ESM
12
+ * and `require`/`module.exports` below would fail with "require is not
13
+ * defined".
14
+ */
15
+ const { execFileSync } = require('node:child_process');
16
+
17
+ // git without a shell: `base` comes from the environment
18
+ // (GITHUB_BASE_REF is set from the pull request's branch name, which a
19
+ // fork controls), so the range reaches git as one literal argument
20
+ // rather than as text a shell reparses.
21
+ function getCommitSubjects(base) {
22
+ const range = base ? `${base}..HEAD` : 'HEAD';
23
+ const out = execFileSync('git', ['log', '--format=%s', range], {
24
+ encoding: 'utf8',
25
+ });
26
+ return out.split('\n').filter(Boolean);
27
+ }
28
+
29
+ function main() {
30
+ const base =
31
+ process.env.PHASE_GATE_BASE || process.env.GITHUB_BASE_REF || 'origin/main';
32
+
33
+ let subjects;
34
+ try {
35
+ subjects = getCommitSubjects(base);
36
+ } catch {
37
+ // Base ref not resolvable (e.g. shallow clone, no such ref) — fall
38
+ // back to the full local history rather than failing the check for
39
+ // an unrelated git plumbing reason.
40
+ subjects = getCommitSubjects(null);
41
+ }
42
+
43
+ const phaseBRe = /^feat\(([a-z0-9-]+)\): (hooks|screen-[\w-]+)/;
44
+ const phaseARe = (mod) => new RegExp(`^feat\\(${mod}\\): api\\b`);
45
+
46
+ const violations = [];
47
+
48
+ for (const subject of subjects) {
49
+ const match = subject.match(phaseBRe);
50
+ if (!match) continue;
51
+
52
+ const mod = match[1];
53
+ const hasPhaseACommit = subjects.some((s) => phaseARe(mod).test(s));
54
+
55
+ if (!hasPhaseACommit) {
56
+ violations.push({ subject, mod });
57
+ }
58
+ }
59
+
60
+ if (violations.length > 0) {
61
+ console.error(
62
+ 'Phase gate failed: Phase B commit(s) with no prior Phase A "api" commit:',
63
+ );
64
+ for (const { subject, mod } of violations) {
65
+ console.error(
66
+ ` - "${subject}" (module: ${mod}) — expected a prior "feat(${mod}): api" commit`,
67
+ );
68
+ }
69
+ process.exit(1);
70
+ }
71
+
72
+ console.log(
73
+ 'Phase gate passed: every Phase B commit has a prior Phase A "api" commit.',
74
+ );
75
+ }
76
+
77
+ main();
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "declarationMap": true,
5
+ "emitDeclarationOnly": true,
6
+ "importHelpers": true,
7
+ "isolatedModules": true,
8
+ "lib": ["es2022"],
9
+ "module": "esnext",
10
+ "moduleResolution": "bundler",
11
+ "noEmitOnError": true,
12
+ "noFallthroughCasesInSwitch": true,
13
+ "noImplicitOverride": true,
14
+ "noImplicitReturns": true,
15
+ "noUnusedLocals": true,
16
+ "skipLibCheck": true,
17
+ "strict": true,
18
+ "target": "es2022",
19
+ "customConditions": ["app"]
20
+ }
21
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "extends": "./tsconfig.base.json",
3
+ "compileOnSave": false,
4
+ "files": [],
5
+ "references": [
6
+ {
7
+ "path": "./packages/config"
8
+ },
9
+ {
10
+ "path": "./packages/db"
11
+ },
12
+ {
13
+ "path": "./apps/api-e2e"
14
+ },
15
+ {
16
+ "path": "./apps/api"
17
+ },
18
+ {
19
+ "path": "./apps/web"
20
+ },
21
+ {
22
+ "path": "./apps/web-e2e"
23
+ }
24
+ ]
25
+ }
@@ -0,0 +1,4 @@
1
+ export default [
2
+ '**/vite.config.{mjs,js,ts,mts}',
3
+ '**/vitest.config.{mjs,js,ts,mts}',
4
+ ];