@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,104 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Regenerates apps/api/src/app/feature-modules.ts — the barrel that lists
4
+ * every domain module's NestJS `@Module` class for AppModule to import.
5
+ *
6
+ * Written as CommonJS (`.cjs`) deliberately: root `package.json` sets
7
+ * `"type": "module"`, so a plain `.js` file here would be parsed as ESM
8
+ * and `require`/`module.exports` below would fail with "require is not
9
+ * defined".
10
+ *
11
+ * Why generated rather than hand-edited: the controller layer's scope is
12
+ * `apps/api/src/app/{module}/**` (core.yaml) — module-disjoint by
13
+ * construction, so two modules' controller tasks never touch the same
14
+ * file. AppModule itself is exclusive: true / once (see core.yaml), so it
15
+ * can't carry a per-module edit either. A file that every module's
16
+ * controller task hand-edited (even one line each) would sit outside
17
+ * every task's declared scope, invisible to the scheduler's conflict
18
+ * check (src/db/conflict.mjs only compares declared scope globs) — two
19
+ * concurrent module builds editing it would look conflict-free to the
20
+ * scheduler while actually racing on one file. Generating it instead
21
+ * removes the shared edit entirely: every module writes only inside its
22
+ * own `apps/api/src/app/{module}/` directory (a `{module}.module.ts`
23
+ * file, discovered here by naming convention), and this script is the
24
+ * only thing that ever writes feature-modules.ts.
25
+ *
26
+ * Convention: any file matching apps/api/src/app/*\/*.module.ts (module
27
+ * directories only, one level deep — not app.module.ts itself) is treated
28
+ * as a domain module and must have a named export ending in "Module".
29
+ *
30
+ * Run via the `generate-feature-modules` Nx target, which `test` and
31
+ * `build` depend on (nx.json targetDefaults) — never run by hand as part
32
+ * of normal development.
33
+ */
34
+ const fs = require('node:fs');
35
+ const path = require('node:path');
36
+
37
+ const APP_DIR = path.join(__dirname, '..', 'apps', 'api', 'src', 'app');
38
+ const OUTPUT_FILE = path.join(APP_DIR, 'feature-modules.ts');
39
+ const HEADER =
40
+ '// GENERATED FILE — do not hand-edit. Regenerated by\n' +
41
+ '// tools/generate-feature-modules.cjs (the generate-feature-modules Nx\n' +
42
+ '// target, which test and build depend on) from every\n' +
43
+ "// apps/api/src/app/*/*.module.ts file on disk. A module directory's\n" +
44
+ '// controller layer only ever creates its own *.module.ts inside its own\n' +
45
+ '// directory — never edits this file.\n';
46
+
47
+ function findModuleFiles() {
48
+ if (!fs.existsSync(APP_DIR)) return [];
49
+ const entries = fs.readdirSync(APP_DIR, { withFileTypes: true });
50
+ const files = [];
51
+ for (const entry of entries) {
52
+ if (!entry.isDirectory()) continue;
53
+ const moduleDir = path.join(APP_DIR, entry.name);
54
+ for (const file of fs.readdirSync(moduleDir)) {
55
+ if (file.endsWith('.module.ts')) {
56
+ files.push(path.join(entry.name, file.replace(/\.ts$/, '')));
57
+ }
58
+ }
59
+ }
60
+ return files.sort();
61
+ }
62
+
63
+ function exportNameFor(relativePath) {
64
+ // "tasks/tasks.module" -> "TasksModule". Every domain module generator
65
+ // (Nx's @nx/nest:module or hand-authored) names its class
66
+ // `<PascalCase>Module` to match the file's own `<kebab>.module.ts` name
67
+ // — the same convention @nx/nest itself uses.
68
+ const base = path.basename(relativePath).replace(/\.module$/, '');
69
+ const pascal = base
70
+ .split('-')
71
+ .map((seg) => seg.charAt(0).toUpperCase() + seg.slice(1))
72
+ .join('');
73
+ return `${pascal}Module`;
74
+ }
75
+
76
+ function main() {
77
+ const moduleFiles = findModuleFiles();
78
+ const imports = [];
79
+ const names = [];
80
+
81
+ for (const relativePath of moduleFiles) {
82
+ const importName = exportNameFor(relativePath);
83
+ imports.push(`import { ${importName} } from './${relativePath}';`);
84
+ names.push(importName);
85
+ }
86
+
87
+ const body =
88
+ names.length > 0
89
+ ? `export const featureModules = [${names.join(', ')}];\n`
90
+ : 'export const featureModules = [];\n';
91
+
92
+ const content =
93
+ HEADER +
94
+ (imports.length > 0 ? '\n' + imports.join('\n') + '\n' : '') +
95
+ '\n' +
96
+ body;
97
+
98
+ fs.writeFileSync(OUTPUT_FILE, content);
99
+ console.log(
100
+ `Generated ${path.relative(process.cwd(), OUTPUT_FILE)} with ${names.length} feature module(s).`,
101
+ );
102
+ }
103
+
104
+ main();
@@ -0,0 +1,283 @@
1
+ import { formatFiles, Tree } from '@nx/devkit';
2
+ import { Field, isDateField, parseFields, zodSchema } from '../fields';
3
+ import { appendBarrelExport, generateLibShell } from '../lib-shell';
4
+ import { moduleNames, ModuleNames } from '../naming';
5
+
6
+ interface ContractGeneratorOptions {
7
+ module: string;
8
+ fields: string;
9
+ }
10
+
11
+ const PACKAGE_ROOT = 'packages/contracts';
12
+
13
+ export default async function contractGenerator(
14
+ tree: Tree,
15
+ options: ContractGeneratorOptions,
16
+ ) {
17
+ const names = moduleNames(options.module);
18
+ const fields = parseFields(options.fields);
19
+
20
+ await generateLibShell(tree, {
21
+ directory: PACKAGE_ROOT,
22
+ importName: 'contracts',
23
+ tags: ['scope:contracts', 'type:contract'],
24
+ dependencies: {
25
+ '@ts-rest/core': '3.53.0-rc.1',
26
+ zod: '^4.4.3',
27
+ },
28
+ });
29
+
30
+ writeTimestampSchema(tree);
31
+
32
+ const dir = `${PACKAGE_ROOT}/src/${names.module}`;
33
+ tree.write(`${dir}/${names.module}.schema.ts`, entitySchemaFile(names, fields));
34
+ tree.write(`${dir}/${names.module}.errors.ts`, errorSchemaFile(names));
35
+ tree.write(`${dir}/${names.module}.contract.ts`, contractFile(names));
36
+ tree.write(`${dir}/index.ts`, barrelFile(names));
37
+ tree.write(`${dir}/${names.module}.spec.ts`, specFile(names, fields));
38
+
39
+ appendBarrelExport(tree, `${PACKAGE_ROOT}/src/index.ts`, `./${names.module}/index`);
40
+
41
+ await formatFiles(tree);
42
+ }
43
+
44
+ /**
45
+ * The shared half of the date-mode rule: a `timestamp` column declared
46
+ * `mode: 'date'` reflects through `createSelectSchema` as `z.date()`, which
47
+ * the server satisfies with a real `Date` and the browser never can — JSON
48
+ * carries it as an ISO string. Neither side alone is right, and no
49
+ * `.transform()` satisfies both, so the contract accepts the union.
50
+ */
51
+ function writeTimestampSchema(tree: Tree) {
52
+ const path = `${PACKAGE_ROOT}/src/timestamp.ts`;
53
+ if (tree.exists(path)) return;
54
+
55
+ tree.write(
56
+ path,
57
+ `import { z } from 'zod';
58
+
59
+ export const timestampSchema = z.union([z.date(), z.iso.datetime()]);
60
+
61
+ export type Timestamp = z.infer<typeof timestampSchema>;
62
+ `,
63
+ );
64
+ appendBarrelExport(tree, `${PACKAGE_ROOT}/src/index.ts`, './timestamp');
65
+ }
66
+
67
+ function entitySchemaFile(names: ModuleNames, fields: Field[]): string {
68
+ const bodyFields = fields
69
+ .map((field) => ` ${field.name}: ${zodSchema(field)},`)
70
+ .join('\n');
71
+ const createFields = fields
72
+ .map(
73
+ (field) =>
74
+ ` ${field.name}: ${zodSchema(field)}${field.nullable ? '.optional()' : ''},`,
75
+ )
76
+ .join('\n');
77
+
78
+ return `import { z } from 'zod';
79
+ import { timestampSchema } from '../timestamp';
80
+
81
+ export const ${names.entityCamel}Schema = z.object({
82
+ id: z.uuid(),
83
+ ${bodyFields}
84
+ createdAt: timestampSchema,
85
+ updatedAt: timestampSchema,
86
+ });
87
+
88
+ export const create${names.entityPascal}Schema = z.object({
89
+ ${createFields}
90
+ });
91
+
92
+ export const update${names.entityPascal}Schema = create${names.entityPascal}Schema.partial();
93
+
94
+ export type ${names.entityPascal} = z.infer<typeof ${names.entityCamel}Schema>;
95
+ export type Create${names.entityPascal} = z.infer<typeof create${names.entityPascal}Schema>;
96
+ export type Update${names.entityPascal} = z.infer<typeof update${names.entityPascal}Schema>;
97
+ `;
98
+ }
99
+
100
+ function errorSchemaFile(names: ModuleNames): string {
101
+ return `import { z } from 'zod';
102
+
103
+ export const ${names.entityCamel}NotFoundSchema = z.object({
104
+ error: z.literal('${names.entityPascal}NotFound'),
105
+ message: z.string(),
106
+ });
107
+
108
+ export const ${names.entityCamel}BadRequestSchema = z.object({
109
+ error: z.literal('${names.entityPascal}BadRequest'),
110
+ message: z.string(),
111
+ });
112
+
113
+ export type ${names.entityPascal}NotFound = z.infer<typeof ${names.entityCamel}NotFoundSchema>;
114
+ export type ${names.entityPascal}BadRequest = z.infer<typeof ${names.entityCamel}BadRequestSchema>;
115
+ `;
116
+ }
117
+
118
+ function contractFile(names: ModuleNames): string {
119
+ return `import { initContract } from '@ts-rest/core';
120
+ import { z } from 'zod';
121
+ import {
122
+ ${names.entityCamel}Schema,
123
+ create${names.entityPascal}Schema,
124
+ update${names.entityPascal}Schema,
125
+ } from './${names.module}.schema';
126
+ import {
127
+ ${names.entityCamel}BadRequestSchema,
128
+ ${names.entityCamel}NotFoundSchema,
129
+ } from './${names.module}.errors';
130
+
131
+ const c = initContract();
132
+
133
+ export const ${names.camel}Contract = c.router(
134
+ {
135
+ list: {
136
+ method: 'GET',
137
+ path: '/${names.module}',
138
+ responses: {
139
+ 200: z.array(${names.entityCamel}Schema),
140
+ },
141
+ summary: 'List every ${names.entityCamel}',
142
+ },
143
+ get: {
144
+ method: 'GET',
145
+ path: '/${names.module}/:id',
146
+ pathParams: z.object({ id: z.uuid() }),
147
+ responses: {
148
+ 200: ${names.entityCamel}Schema,
149
+ 404: ${names.entityCamel}NotFoundSchema,
150
+ },
151
+ summary: 'Fetch one ${names.entityCamel} by id',
152
+ },
153
+ create: {
154
+ method: 'POST',
155
+ path: '/${names.module}',
156
+ body: create${names.entityPascal}Schema,
157
+ responses: {
158
+ 201: ${names.entityCamel}Schema,
159
+ 400: ${names.entityCamel}BadRequestSchema,
160
+ },
161
+ summary: 'Create a ${names.entityCamel}',
162
+ },
163
+ update: {
164
+ method: 'PATCH',
165
+ path: '/${names.module}/:id',
166
+ pathParams: z.object({ id: z.uuid() }),
167
+ body: update${names.entityPascal}Schema,
168
+ responses: {
169
+ 200: ${names.entityCamel}Schema,
170
+ 400: ${names.entityCamel}BadRequestSchema,
171
+ 404: ${names.entityCamel}NotFoundSchema,
172
+ },
173
+ summary: 'Update a ${names.entityCamel}',
174
+ },
175
+ remove: {
176
+ method: 'DELETE',
177
+ path: '/${names.module}/:id',
178
+ pathParams: z.object({ id: z.uuid() }),
179
+ responses: {
180
+ 204: c.noBody(),
181
+ 404: ${names.entityCamel}NotFoundSchema,
182
+ },
183
+ summary: 'Delete a ${names.entityCamel}',
184
+ },
185
+ },
186
+ {
187
+ // apps/api sets /api as a global prefix at runtime (apps/api/src/main.ts),
188
+ // so the paths above stay prefix-free and the client adds the base URL.
189
+ strictStatusCodes: true,
190
+ },
191
+ );
192
+ `;
193
+ }
194
+
195
+ function barrelFile(names: ModuleNames): string {
196
+ return `export * from './${names.module}.contract';
197
+ export * from './${names.module}.errors';
198
+ export * from './${names.module}.schema';
199
+ `;
200
+ }
201
+
202
+ function specFile(names: ModuleNames, fields: Field[]): string {
203
+ const dateField = fields.find(isDateField);
204
+ const requiredField = fields.find((field) => !field.nullable);
205
+
206
+ return `import { describe, expect, it } from 'vitest';
207
+ import {
208
+ ${names.camel}Contract,
209
+ ${names.entityCamel}Schema,
210
+ create${names.entityPascal}Schema,
211
+ } from './index';
212
+
213
+ const sample = ${sampleLiteral(names, fields)};
214
+
215
+ describe('${names.camel} contract', () => {
216
+ it('exposes the full CRUD route set under /${names.module}', () => {
217
+ expect(${names.camel}Contract.list.method).toBe('GET');
218
+ expect(${names.camel}Contract.list.path).toBe('/${names.module}');
219
+ expect(${names.camel}Contract.get.path).toBe('/${names.module}/:id');
220
+ expect(${names.camel}Contract.create.method).toBe('POST');
221
+ expect(${names.camel}Contract.update.method).toBe('PATCH');
222
+ expect(${names.camel}Contract.remove.method).toBe('DELETE');
223
+ });
224
+
225
+ it('accepts a server-side row, where every timestamp is a Date', () => {
226
+ expect(${names.entityCamel}Schema.parse(sample)).toBeDefined();
227
+ });
228
+
229
+ it('accepts the same row as JSON, where every timestamp is an ISO string', () => {
230
+ const overTheWire = {
231
+ ...sample,${dateField ? `\n ${dateField.name}: sample.${dateField.name}.toISOString(),` : ''}
232
+ createdAt: sample.createdAt.toISOString(),
233
+ updatedAt: sample.updatedAt.toISOString(),
234
+ };
235
+
236
+ expect(${names.entityCamel}Schema.parse(overTheWire)).toBeDefined();
237
+ });
238
+ ${
239
+ requiredField
240
+ ? `
241
+ it('rejects a create body missing ${requiredField.name}', () => {
242
+ const rest = { ...createInput } as Partial<typeof createInput>;
243
+ delete rest.${requiredField.name};
244
+
245
+ expect(create${names.entityPascal}Schema.safeParse(rest).success).toBe(false);
246
+ });
247
+ `
248
+ : ''
249
+ }});
250
+
251
+ const createInput = ${createLiteral(fields)};
252
+ `;
253
+ }
254
+
255
+ const SAMPLE_UUID = '00000000-0000-4000-8000-000000000000';
256
+
257
+ function sampleLiteral(names: ModuleNames, fields: Field[]): string {
258
+ const entries = [
259
+ `id: '${SAMPLE_UUID}'`,
260
+ ...fields.map((field) => `${field.name}: ${sampleValue(field)}`),
261
+ 'createdAt: new Date()',
262
+ 'updatedAt: new Date()',
263
+ ];
264
+ return `{\n ${entries.join(',\n ')},\n}`;
265
+ }
266
+
267
+ function createLiteral(fields: Field[]): string {
268
+ const entries = fields
269
+ .filter((field) => !field.nullable)
270
+ .map((field) => `${field.name}: ${sampleValue(field)}`);
271
+ return `{\n ${entries.join(',\n ')},\n}`;
272
+ }
273
+
274
+ function sampleValue(field: Field): string {
275
+ const byType: Record<string, string> = {
276
+ string: `'sample'`,
277
+ text: `'sample'`,
278
+ boolean: 'false',
279
+ integer: '1',
280
+ timestamp: 'new Date()',
281
+ };
282
+ return byType[field.type] ?? 'null';
283
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "http://json-schema.org/schema",
3
+ "$id": "HedgehogContractLayer",
4
+ "title": "ts-rest contract 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
+ "fields": {
14
+ "type": "string",
15
+ "description": "The same name:type list the schema layer was generated with; a trailing ? marks the field nullable.",
16
+ "x-prompt": "Fields (name:type, comma-separated)?"
17
+ }
18
+ },
19
+ "required": ["module", "fields"]
20
+ }