@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,323 @@
1
+ import { controllerGenerator, moduleGenerator } from '@nx/nest';
2
+ import { formatFiles, Tree, updateJson } from '@nx/devkit';
3
+ import { Field, isDateField, parseFields } from '../fields';
4
+ import { moduleNames, ModuleNames } from '../naming';
5
+
6
+ interface ControllerGeneratorOptions {
7
+ module: string;
8
+ fields: string;
9
+ }
10
+
11
+ const API_ROOT = 'apps/api';
12
+
13
+ export default async function controllerGenerator_(
14
+ tree: Tree,
15
+ options: ControllerGeneratorOptions,
16
+ ) {
17
+ const names = moduleNames(options.module);
18
+ const fields = parseFields(options.fields);
19
+ const dir = `${API_ROOT}/src/app/${names.module}`;
20
+ const stem = `${dir}/${names.module}`;
21
+
22
+ // The Nest generators own the module/controller shell so it cannot drift
23
+ // from a hand-copied sibling. `skipImport` is required: registration
24
+ // happens through the generated feature-modules.ts barrel, which globs
25
+ // apps/api/src/app/*/*.module.ts — the file this generator writes is
26
+ // picked up automatically by the generate-feature-modules Nx target that
27
+ // build, typecheck, and test all depend on. Nothing here edits
28
+ // app.module.ts.
29
+ await moduleGenerator(tree, {
30
+ path: stem,
31
+ skipImport: true,
32
+ skipFormat: true,
33
+ });
34
+ await controllerGenerator(tree, {
35
+ path: stem,
36
+ skipImport: true,
37
+ unitTestRunner: 'none',
38
+ skipFormat: true,
39
+ });
40
+
41
+ // @nx/nest:controller emits @Controller('<name>') — a base path derived
42
+ // from the file name. The ts-rest contract already encodes every route's
43
+ // full path, so leaving it would prefix each route twice.
44
+ stripControllerBasePath(tree, `${stem}.controller.ts`);
45
+
46
+ tree.write(`${stem}.controller.ts`, controllerFile(names, fields));
47
+ tree.write(`${stem}.module.ts`, moduleFile(names));
48
+ tree.write(`${stem}.controller.spec.ts`, controllerSpecFile(names));
49
+
50
+ addApiDependencies(tree, names);
51
+
52
+ await formatFiles(tree);
53
+ }
54
+
55
+ /**
56
+ * Rewrites the generator's own decorator in place, so the strip is proved
57
+ * against what it actually emitted rather than assumed from its templates.
58
+ */
59
+ function stripControllerBasePath(tree: Tree, path: string) {
60
+ const source = tree.read(path, 'utf-8');
61
+ if (!source) return;
62
+ tree.write(path, source.replace(/@Controller\((['"]).*?\1\)/, '@Controller()'));
63
+ }
64
+
65
+ function controllerFile(names: ModuleNames, fields: Field[]): string {
66
+ const { entityPascal, entityCamel, camel, module } = names;
67
+ const dateFields = fields.filter(isDateField);
68
+ const wireShape = dateFields.length
69
+ ? `{ ${dateFields
70
+ .map(
71
+ (field) =>
72
+ `${field.name}${field.nullable ? '?' : ''}: Date | string${field.nullable ? ' | null' : ''}`,
73
+ )
74
+ .join('; ')} }`
75
+ : 'object';
76
+
77
+ return `import { Controller, Inject } from '@nestjs/common';
78
+ import { TsRestHandler, tsRestHandler } from '@ts-rest/nest';
79
+ import { ${camel}Contract } from 'contracts';
80
+ import { ${entityPascal}Service } from '${module}-service';
81
+
82
+ // No base path: the ts-rest contract carries each route's full path, and
83
+ // apps/api/src/main.ts adds the one /api prefix the whole app shares.
84
+ @Controller()
85
+ export class ${names.pascal}Controller {
86
+ constructor(
87
+ @Inject(${entityPascal}Service) private readonly ${entityCamel}s: ${entityPascal}Service,
88
+ ) {}
89
+
90
+ @TsRestHandler(${camel}Contract)
91
+ async handler() {
92
+ return tsRestHandler(${camel}Contract, {
93
+ list: async () => ({
94
+ status: 200 as const,
95
+ body: await this.${entityCamel}s.list(),
96
+ }),
97
+
98
+ get: async ({ params }) => {
99
+ try {
100
+ return {
101
+ status: 200 as const,
102
+ body: await this.${entityCamel}s.getById(params.id),
103
+ };
104
+ } catch (error) {
105
+ return notFoundOr(error, params.id);
106
+ }
107
+ },
108
+
109
+ create: async ({ body }) => ({
110
+ status: 201 as const,
111
+ body: await this.${entityCamel}s.create(fromWire(body)),
112
+ }),
113
+
114
+ update: async ({ params, body }) => {
115
+ try {
116
+ return {
117
+ status: 200 as const,
118
+ body: await this.${entityCamel}s.update(params.id, fromWire(body)),
119
+ };
120
+ } catch (error) {
121
+ return notFoundOr(error, params.id);
122
+ }
123
+ },
124
+
125
+ remove: async ({ params }) => {
126
+ try {
127
+ await this.${entityCamel}s.remove(params.id);
128
+ return { status: 204 as const, body: undefined };
129
+ } catch (error) {
130
+ return notFoundOr(error, params.id);
131
+ }
132
+ },
133
+ });
134
+ }
135
+ }
136
+
137
+ /**
138
+ * The one place a domain error becomes a status code — services throw
139
+ * ${entityPascal}NotFoundError knowing nothing about HTTP. Matching on
140
+ * \`name\` rather than \`instanceof\` survives the error class arriving
141
+ * through a separately bundled copy of ${module}-service.
142
+ */
143
+ function notFoundOr(error: unknown, id: string) {
144
+ if (error instanceof Error && error.name === '${entityPascal}NotFoundError') {
145
+ return {
146
+ status: 404 as const,
147
+ body: {
148
+ error: '${entityPascal}NotFound' as const,
149
+ message: \`No ${entityCamel} exists with id \${id}.\`,
150
+ },
151
+ };
152
+ }
153
+ throw error;
154
+ }
155
+
156
+ /**
157
+ * The seam the contract's date-mode timestamp union creates: the same
158
+ * field is a real Date server-side and an ISO string over JSON, so the
159
+ * contract accepts both and this boundary picks one — the Date the
160
+ * repository's column type actually stores. Past here nothing re-parses.
161
+ */
162
+ function fromWire<T extends ${wireShape}>(body: T) {
163
+ return {
164
+ ...body,${dateFields
165
+ .map(
166
+ (field) =>
167
+ `\n ${field.name}: toDate(body.${field.name}),`,
168
+ )
169
+ .join('')}
170
+ };
171
+ }
172
+
173
+ function toDate(value: Date | string | null | undefined) {
174
+ return typeof value === 'string' ? new Date(value) : value;
175
+ }
176
+ `;
177
+ }
178
+
179
+ function moduleFile(names: ModuleNames): string {
180
+ const { entityPascal, entityConstant, pascal, module } = names;
181
+
182
+ return `import { Module } from '@nestjs/common';
183
+ import { getDb } from 'db';
184
+ import {
185
+ ${entityConstant}_REPOSITORY,
186
+ Drizzle${entityPascal}Repository,
187
+ } from '${module}-repository';
188
+ import { ${entityPascal}Service } from '${module}-service';
189
+ import { ${pascal}Controller } from './${names.module}.controller';
190
+
191
+ // The composition root for this module, and the only file in apps/api
192
+ // allowed to name a *.adapter (eslint-base.js exempts *.module.ts from the
193
+ // port-discipline rule precisely for this binding).
194
+ //
195
+ // Registration is automatic: tools/generate-feature-modules.cjs globs
196
+ // every module directory's own *.module.ts into feature-modules.ts, which
197
+ // app.module.ts spreads — so this file is never referenced by hand.
198
+ @Module({
199
+ controllers: [${pascal}Controller],
200
+ providers: [
201
+ {
202
+ provide: ${entityConstant}_REPOSITORY,
203
+ // getDb is passed unevaluated: the DI container builds every provider
204
+ // eagerly, and calling it here would open a database connection just
205
+ // to compile the module — including inside a test.
206
+ useFactory: () => new Drizzle${entityPascal}Repository(getDb),
207
+ },
208
+ {
209
+ provide: ${entityPascal}Service,
210
+ inject: [${entityConstant}_REPOSITORY],
211
+ useFactory: (repository: Drizzle${entityPascal}Repository) =>
212
+ new ${entityPascal}Service(repository),
213
+ },
214
+ ],
215
+ })
216
+ export class ${pascal}Module {}
217
+ `;
218
+ }
219
+
220
+ function controllerSpecFile(names: ModuleNames): string {
221
+ const { entityPascal, pascal, module } = names;
222
+
223
+ return `import { Test } from '@nestjs/testing';
224
+ import { describe, expect, it, vi } from 'vitest';
225
+ import { ${entityPascal}Service } from '${module}-service';
226
+ import { ${pascal}Controller } from './${names.module}.controller';
227
+
228
+ async function controllerWith(service: Partial<${entityPascal}Service>) {
229
+ const moduleRef = await Test.createTestingModule({
230
+ controllers: [${pascal}Controller],
231
+ providers: [{ provide: ${entityPascal}Service, useValue: service }],
232
+ }).compile();
233
+
234
+ return moduleRef.get(${pascal}Controller);
235
+ }
236
+
237
+ describe('${pascal}Controller', () => {
238
+ it('serves the contract without a base path of its own', () => {
239
+ const path = Reflect.getMetadata('path', ${pascal}Controller);
240
+
241
+ expect(path).toBe('/');
242
+ });
243
+
244
+ it('answers list with 200 and the rows the service returned', async () => {
245
+ const rows = [{ id: 'one' }];
246
+ const controller = await controllerWith({
247
+ list: vi.fn(async () => rows as never),
248
+ });
249
+
250
+ const handler = await controller.handler();
251
+ const response = await handler.list({} as never);
252
+
253
+ expect(response).toMatchObject({ status: 200, body: rows });
254
+ });
255
+
256
+ it('maps a not-found domain error to 404 rather than letting it escape', async () => {
257
+ const notFound = Object.assign(new Error('absent'), {
258
+ name: '${entityPascal}NotFoundError',
259
+ });
260
+ const controller = await controllerWith({
261
+ getById: vi.fn(async () => {
262
+ throw notFound;
263
+ }),
264
+ });
265
+
266
+ const handler = await controller.handler();
267
+ const response = await handler.get({
268
+ params: { id: 'missing' },
269
+ } as never);
270
+
271
+ expect(response).toMatchObject({ status: 404 });
272
+ });
273
+
274
+ it('lets an error it has no mapping for propagate', async () => {
275
+ const boom = new Error('database is on fire');
276
+ const controller = await controllerWith({
277
+ getById: vi.fn(async () => {
278
+ throw boom;
279
+ }),
280
+ });
281
+
282
+ const handler = await controller.handler();
283
+
284
+ await expect(
285
+ handler.get({ params: { id: 'any' } } as never),
286
+ ).rejects.toBe(boom);
287
+ });
288
+ });
289
+ `;
290
+ }
291
+
292
+ /**
293
+ * apps/api reaches the module's service and repository libs, plus the
294
+ * contract package, by construction — declared here so the first module
295
+ * through this layer does not leave them unlinked for a later layer to
296
+ * trip over.
297
+ */
298
+ function addApiDependencies(tree: Tree, names: ModuleNames) {
299
+ updateJson(tree, `${API_ROOT}/package.json`, (json) => {
300
+ json.dependencies = {
301
+ ...json.dependencies,
302
+ contracts: 'workspace:*',
303
+ [`${names.module}-repository`]: 'workspace:*',
304
+ [`${names.module}-service`]: 'workspace:*',
305
+ };
306
+ return json;
307
+ });
308
+
309
+ updateJson(tree, `${API_ROOT}/tsconfig.app.json`, (json) => {
310
+ const references: { path: string }[] = json.references ?? [];
311
+ for (const path of [
312
+ '../../packages/contracts/tsconfig.lib.json',
313
+ `../../libs/${names.module}/repository/tsconfig.lib.json`,
314
+ `../../libs/${names.module}/service/tsconfig.lib.json`,
315
+ ]) {
316
+ if (!references.some((entry) => entry.path === path)) {
317
+ references.push({ path });
318
+ }
319
+ }
320
+ json.references = references;
321
+ return json;
322
+ });
323
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "http://json-schema.org/schema",
3
+ "$id": "HedgehogControllerLayer",
4
+ "title": "NestJS module and ts-rest controller 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
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The field DSL every layer generator shares, plus the one mapping from a
3
+ * field type to its Drizzle column, its TypeScript type, and its Zod
4
+ * schema. Owned here so the seven generators cannot drift from each other
5
+ * on what `dueDate:timestamp?` means.
6
+ *
7
+ * Wire format (the `--fields` option): a comma-separated list of
8
+ * `name:type` pairs, with a trailing `?` on the type marking the column
9
+ * nullable — `title:string,done:boolean,dueDate:timestamp?`.
10
+ */
11
+
12
+ export type FieldType =
13
+ | 'string'
14
+ | 'text'
15
+ | 'boolean'
16
+ | 'integer'
17
+ | 'timestamp';
18
+
19
+ export interface Field {
20
+ name: string;
21
+ type: FieldType;
22
+ nullable: boolean;
23
+ }
24
+
25
+ const FIELD_TYPES: readonly FieldType[] = [
26
+ 'string',
27
+ 'text',
28
+ 'boolean',
29
+ 'integer',
30
+ 'timestamp',
31
+ ];
32
+
33
+ export function parseFields(raw: string): Field[] {
34
+ const fields = raw
35
+ .split(',')
36
+ .map((entry) => entry.trim())
37
+ .filter((entry) => entry.length > 0)
38
+ .map((entry) => {
39
+ const [name, rawType] = entry.split(':').map((part) => part.trim());
40
+ if (!name || !rawType) {
41
+ throw new Error(
42
+ `Field "${entry}" is not in name:type form (e.g. title:string, dueDate:timestamp?).`,
43
+ );
44
+ }
45
+ const nullable = rawType.endsWith('?');
46
+ const type = (nullable ? rawType.slice(0, -1) : rawType) as FieldType;
47
+ if (!FIELD_TYPES.includes(type)) {
48
+ throw new Error(
49
+ `Field "${entry}" has unknown type "${type}". Known types: ${FIELD_TYPES.join(', ')}.`,
50
+ );
51
+ }
52
+ if (RESERVED_FIELD_NAMES.includes(name)) {
53
+ throw new Error(
54
+ `Field "${name}" collides with a column every table already carries (${RESERVED_FIELD_NAMES.join(', ')}).`,
55
+ );
56
+ }
57
+ return { name, type, nullable };
58
+ });
59
+
60
+ if (fields.length === 0) {
61
+ throw new Error('At least one field is required.');
62
+ }
63
+ return fields;
64
+ }
65
+
66
+ // Columns the schema generator writes on every table — a field list may
67
+ // not redeclare them.
68
+ const RESERVED_FIELD_NAMES = ['id', 'createdAt', 'updatedAt'];
69
+
70
+ /** snake_case, the database column name for a camelCase field. */
71
+ export function columnName(name: string): string {
72
+ return name.replace(/[A-Z]/g, (char) => `_${char.toLowerCase()}`);
73
+ }
74
+
75
+ export function drizzleImports(fields: Field[]): string[] {
76
+ const imports = new Set<string>(['pgTable', 'uuid', 'timestamp']);
77
+ for (const field of fields) {
78
+ imports.add(DRIZZLE_IMPORT[field.type]);
79
+ }
80
+ return [...imports].sort();
81
+ }
82
+
83
+ const DRIZZLE_IMPORT: Record<FieldType, string> = {
84
+ string: 'varchar',
85
+ text: 'text',
86
+ boolean: 'boolean',
87
+ integer: 'integer',
88
+ timestamp: 'timestamp',
89
+ };
90
+
91
+ export function drizzleColumn(field: Field): string {
92
+ const column = `'${columnName(field.name)}'`;
93
+ const base: Record<FieldType, string> = {
94
+ string: `varchar(${column}, { length: 255 })`,
95
+ text: `text(${column})`,
96
+ boolean: `boolean(${column})`,
97
+ integer: `integer(${column})`,
98
+ // `mode: 'date'` keeps the column a real `Date` on the server; the
99
+ // contract layer's timestamp union is what reconciles that with the
100
+ // ISO string the same field arrives as over JSON.
101
+ timestamp: `timestamp(${column}, { mode: 'date', withTimezone: true })`,
102
+ };
103
+ const modifiers = field.nullable
104
+ ? ''
105
+ : field.type === 'boolean'
106
+ ? '.notNull().default(false)'
107
+ : '.notNull()';
108
+ return `${field.name}: ${base[field.type]}${modifiers},`;
109
+ }
110
+
111
+ /** True when a field reflects through drizzle-zod as a `z.date()`. */
112
+ export function isDateField(field: Field): boolean {
113
+ return field.type === 'timestamp';
114
+ }
115
+
116
+ /** The Zod schema for a field as it appears in a hand-written override. */
117
+ export function zodSchema(field: Field): string {
118
+ const base: Record<FieldType, string> = {
119
+ string: 'z.string().min(1).max(255)',
120
+ text: 'z.string()',
121
+ boolean: 'z.boolean()',
122
+ integer: 'z.number().int()',
123
+ timestamp: 'timestampSchema',
124
+ };
125
+ return field.nullable ? `${base[field.type]}.nullable()` : base[field.type];
126
+ }
@@ -0,0 +1,42 @@
1
+ {
2
+ "$schema": "http://json-schema.org/schema",
3
+ "name": "hedgehog",
4
+ "version": "0.0.1",
5
+ "generators": {
6
+ "schema": {
7
+ "factory": "./schema/generator",
8
+ "schema": "./schema/schema.json",
9
+ "description": "Drizzle table + drizzle-zod pair for one domain module (core.yaml's schema layer)."
10
+ },
11
+ "contract": {
12
+ "factory": "./contract/generator",
13
+ "schema": "./contract/schema.json",
14
+ "description": "ts-rest router + error schemas for one domain module (core.yaml's contract layer)."
15
+ },
16
+ "repository": {
17
+ "factory": "./repository/generator",
18
+ "schema": "./repository/schema.json",
19
+ "description": "Port interface, DI token, and Drizzle adapter lib for one domain module (core.yaml's repository layer)."
20
+ },
21
+ "service": {
22
+ "factory": "./service/generator",
23
+ "schema": "./service/schema.json",
24
+ "description": "Domain service lib with typed domain errors for one domain module (core.yaml's service layer)."
25
+ },
26
+ "controller": {
27
+ "factory": "./controller/generator",
28
+ "schema": "./controller/schema.json",
29
+ "description": "NestJS module + ts-rest controller for one domain module (core.yaml's controller layer)."
30
+ },
31
+ "hook": {
32
+ "factory": "./hook/generator",
33
+ "schema": "./hook/schema.json",
34
+ "description": "TanStack Query hook set for one domain module (core.yaml's hook layer)."
35
+ },
36
+ "screen": {
37
+ "factory": "./screen/generator",
38
+ "schema": "./screen/schema.json",
39
+ "description": "Screen skeleton wired to the hook layer for one domain module (core.yaml's screen layer)."
40
+ }
41
+ }
42
+ }