@skyf0xx/hedgehog 4.1.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 (63) hide show
  1. package/README.md +21 -1
  2. package/bin/cli.mjs +108 -8
  3. package/package.json +2 -2
  4. package/src/agents/backend-eng.md +62 -16
  5. package/src/agents/front-end-eng.md +48 -14
  6. package/src/agents/reviewer.md +9 -4
  7. package/src/agents/ux-planner.md +2 -2
  8. package/src/db/next.mjs +95 -8
  9. package/src/db/status.mjs +140 -15
  10. package/src/golden-cores/full-stack-app/.env.example +1 -0
  11. package/src/golden-cores/full-stack-app/apps/api/package.json +19 -1
  12. package/src/golden-cores/full-stack-app/apps/api/src/app/app.module.spec.ts +15 -0
  13. package/src/golden-cores/full-stack-app/apps/api/src/app/app.module.ts +6 -1
  14. package/src/golden-cores/full-stack-app/apps/api/src/app/feature-modules.ts +8 -0
  15. package/src/golden-cores/full-stack-app/apps/api/src/main.ts +10 -1
  16. package/src/golden-cores/full-stack-app/apps/api/tsconfig.app.json +3 -0
  17. package/src/golden-cores/full-stack-app/apps/api/tsconfig.json +3 -0
  18. package/src/golden-cores/full-stack-app/apps/api/tsconfig.spec.json +36 -0
  19. package/src/golden-cores/full-stack-app/apps/api/vitest.config.mts +18 -0
  20. package/src/golden-cores/full-stack-app/apps/web/.env.example +12 -0
  21. package/src/golden-cores/full-stack-app/apps/web/package.json +3 -0
  22. package/src/golden-cores/full-stack-app/apps/web/src/components/theme-toggle.spec.tsx +20 -0
  23. package/src/golden-cores/full-stack-app/apps/web/src/test-setup.ts +1 -0
  24. package/src/golden-cores/full-stack-app/apps/web/tsconfig.json +6 -0
  25. package/src/golden-cores/full-stack-app/apps/web/tsconfig.spec.json +37 -0
  26. package/src/golden-cores/full-stack-app/apps/web/vitest.config.mts +27 -0
  27. package/src/golden-cores/full-stack-app/core.yaml +11 -3
  28. package/src/golden-cores/full-stack-app/nx.json +4 -1
  29. package/src/golden-cores/full-stack-app/package.json +8 -0
  30. package/src/golden-cores/full-stack-app/packages/config/eslint-base.js +108 -13
  31. package/src/golden-cores/full-stack-app/packages/config/src/env.schema.spec.ts +14 -1
  32. package/src/golden-cores/full-stack-app/packages/config/src/env.schema.ts +1 -0
  33. package/src/golden-cores/full-stack-app/packages/config/src/index.ts +1 -1
  34. package/src/golden-cores/full-stack-app/packages/db/src/index.ts +2 -1
  35. package/src/golden-cores/full-stack-app/packages/db/src/lib/db.spec.ts +3 -3
  36. package/src/golden-cores/full-stack-app/packages/db/src/schema/index.ts +4 -0
  37. package/src/golden-cores/full-stack-app/pnpm-lock.yaml +8735 -2907
  38. package/src/golden-cores/full-stack-app/pnpm-workspace.yaml +4 -0
  39. package/src/golden-cores/full-stack-app/tools/generate-feature-modules.cjs +104 -0
  40. package/src/golden-cores/full-stack-app/tools/generators/contract/generator.ts +283 -0
  41. package/src/golden-cores/full-stack-app/tools/generators/contract/schema.json +20 -0
  42. package/src/golden-cores/full-stack-app/tools/generators/controller/generator.ts +323 -0
  43. package/src/golden-cores/full-stack-app/tools/generators/controller/schema.json +20 -0
  44. package/src/golden-cores/full-stack-app/tools/generators/fields.ts +126 -0
  45. package/src/golden-cores/full-stack-app/tools/generators/generators.json +42 -0
  46. package/src/golden-cores/full-stack-app/tools/generators/hook/generator.ts +274 -0
  47. package/src/golden-cores/full-stack-app/tools/generators/hook/schema.json +19 -0
  48. package/src/golden-cores/full-stack-app/tools/generators/lib-shell.ts +124 -0
  49. package/src/golden-cores/full-stack-app/tools/generators/naming.ts +84 -0
  50. package/src/golden-cores/full-stack-app/tools/generators/package.json +6 -0
  51. package/src/golden-cores/full-stack-app/tools/generators/repository/generator.ts +298 -0
  52. package/src/golden-cores/full-stack-app/tools/generators/repository/schema.json +15 -0
  53. package/src/golden-cores/full-stack-app/tools/generators/schema/generator.ts +169 -0
  54. package/src/golden-cores/full-stack-app/tools/generators/schema/schema.json +20 -0
  55. package/src/golden-cores/full-stack-app/tools/generators/screen/generator.ts +218 -0
  56. package/src/golden-cores/full-stack-app/tools/generators/screen/schema.json +15 -0
  57. package/src/golden-cores/full-stack-app/tools/generators/service/generator.ts +194 -0
  58. package/src/golden-cores/full-stack-app/tools/generators/service/schema.json +15 -0
  59. package/src/golden-cores/full-stack-app/tsconfig.base.json +2 -2
  60. package/src/skills/conventional-commits/SKILL.md +2 -3
  61. package/src/skills/hedgehog-bootstrap/SKILL.md +25 -7
  62. package/src/skills/hedgehog-bootstrap-full-stack-app-core/SKILL.md +135 -20
  63. package/src/skills/hedgehog-loop/SKILL.md +161 -8
package/src/db/status.mjs CHANGED
@@ -11,7 +11,10 @@
11
11
  // every task meeting that condition, not just the one `hedgehog next`
12
12
  // would pick.
13
13
 
14
+ import { listDebt } from './debt.mjs';
14
15
  import { detectDrift, formatDrift } from './drift.mjs';
16
+ import { listFriction } from './friction.mjs';
17
+ import { orphanedOverrides } from './overrides.mjs';
15
18
  import { formatMissingRequirements } from './requires.mjs';
16
19
  import { readyTasks, heldBackReason } from './ready.mjs';
17
20
 
@@ -82,16 +85,49 @@ function loadAttentionTasks(db) {
82
85
  return db.prepare(ATTENTION_TASKS_SQL).all();
83
86
  }
84
87
 
85
- // Returns { counts, ready, heldBack, inFlight, attention, drift, total } —
86
- // counts keyed by every status in the tasks CHECK constraint (present even
87
- // at zero), ready the full list of currently-pickable tasks, heldBack the
88
- // subset of those that `hedgehog claim` would skip over right now because
89
- // they conflict with in-flight work or another ready task ahead of them
90
- // (ready.mjs's own simulation, reused rather than reimplemented — without
91
- // this a task can sit in READY indefinitely with no visible reason, the
92
- // same invisible-stall shape blocked tasks had before `attention` existed),
93
- // inFlight the tasks currently leased (building or verifying), attention
94
- // the stalled tasks needing a fix, total the sum across all statuses.
88
+ // Collapses every debt row to one entry per declaring task: { taskId, n }
89
+ // in task-id order. The notes themselves stay out — a status overview
90
+ // that reprints them is `debt list` with extra steps, and the note only
91
+ // becomes actionable in the packet of a task that inherits it, where
92
+ // next.mjs already renders it in full. The task id is the part that has
93
+ // to be here, because it is the argument `debt list <task>` needs and
94
+ // the thing an operator has no other way to guess.
95
+ function loadDebtByTask(db) {
96
+ const byTask = new Map();
97
+ for (const { taskId } of listDebt(db)) {
98
+ byTask.set(taskId, (byTask.get(taskId) ?? 0) + 1);
99
+ }
100
+ return [...byTask.entries()]
101
+ .map(([taskId, n]) => ({ taskId, n }))
102
+ .sort((a, b) => a.taskId.localeCompare(b.taskId));
103
+ }
104
+
105
+ // The friction row count, or 0. `listFriction` reads the table
106
+ // unguarded, so a build graph predating it throws here where `listDebt`
107
+ // would return [] — caught rather than propagated for the same reason
108
+ // the CLI tolerates an unparseable core.yaml: status is the command
109
+ // every session starts with, and one absent table must not cost the
110
+ // operator the whole overview. Every other section is still true.
111
+ function countFriction(db) {
112
+ try {
113
+ return listFriction(db).length;
114
+ } catch {
115
+ return 0;
116
+ }
117
+ }
118
+
119
+ // Returns { counts, ready, heldBack, inFlight, attention, drift,
120
+ // orphanedOverrides, debt, frictionCount, total } — counts keyed by
121
+ // every status in the tasks CHECK constraint (present even at zero),
122
+ // ready the full list of currently-pickable tasks, heldBack the subset
123
+ // of those that `hedgehog claim` would skip over right now because they
124
+ // conflict with in-flight work or another ready task ahead of them
125
+ // (ready.mjs's own simulation, reused rather than reimplemented —
126
+ // without this a task can sit in READY indefinitely with no visible
127
+ // reason, the same invisible-stall shape blocked tasks had before
128
+ // `attention` existed), inFlight the tasks currently leased (building or
129
+ // verifying), attention the stalled tasks needing a fix, total the sum
130
+ // across all statuses.
95
131
  //
96
132
  // `core` is optional and, when given, adds `drift`: the tasks whose
97
133
  // layer-derived fields no longer match core.yaml (drift.mjs). It's a
@@ -103,6 +139,26 @@ function loadAttentionTasks(db) {
103
139
  // resolve and pass in — detectDrift needs it composed against `core` or
104
140
  // every task widened by `.hedgehog/overrides/*.json` reports permanent,
105
141
  // spurious drift here.
142
+ //
143
+ // `orphanedOverrides` (overrides.mjs) is the override ids matching no
144
+ // task row. It rides on `status` rather than on `override list` alone
145
+ // because a dead override is invisible by construction — composeScope
146
+ // missing a key is a no-op, so the file widens nothing and raises
147
+ // nothing, and `override list` is a command an operator only runs once
148
+ // they already suspect something. It reads only `tasks`, not core.yaml,
149
+ // so it is computed unconditionally: an override id is stale or it
150
+ // isn't, whether or not a core definition is loadable.
151
+ //
152
+ // `debt` (per declaring task) and `frictionCount` are counts, not
153
+ // listings: both tables are append-only with no resolved column, so
154
+ // there is no "open" subset to filter to and this can only ever report
155
+ // what has been declared. That is the honest reading and also the
156
+ // useful one — a debt note is consumed by whichever downstream task
157
+ // inherits it, and friction is reviewed in a batch by tweaker, so
158
+ // neither is a queue `status` could burn down. What was missing was
159
+ // only the existence signal: `debt list` needs a task id the operator
160
+ // has no way to guess, and `friction list` needs the operator to
161
+ // already suspect there is something to read.
106
162
  export function graphStatus(db, { core = null, overrides = new Map() } = {}) {
107
163
  const counts = countTasksByStatus(db);
108
164
  const ready = loadReadyTasks(db);
@@ -110,8 +166,22 @@ export function graphStatus(db, { core = null, overrides = new Map() } = {}) {
110
166
  const inFlight = loadInFlightTasks(db);
111
167
  const attention = loadAttentionTasks(db);
112
168
  const drift = core ? detectDrift(db, core, { overrides }) : [];
169
+ const orphaned = orphanedOverrides(db, overrides);
170
+ const debt = loadDebtByTask(db);
171
+ const frictionCount = countFriction(db);
113
172
  const total = Object.values(counts).reduce((a, b) => a + b, 0);
114
- return { counts, ready, heldBack, inFlight, attention, drift, total };
173
+ return {
174
+ counts,
175
+ ready,
176
+ heldBack,
177
+ inFlight,
178
+ attention,
179
+ drift,
180
+ orphanedOverrides: orphaned,
181
+ debt,
182
+ frictionCount,
183
+ total,
184
+ };
115
185
  }
116
186
 
117
187
  const BLOCKED_REASON_LABELS = {
@@ -123,7 +193,9 @@ const BLOCKED_REASON_LABELS = {
123
193
  // Renders a graphStatus() result into a plain-text overview: counts by
124
194
  // status (only non-zero ones, in lifecycle order), any declared binary
125
195
  // this environment can't resolve, the ready list, tasks currently in
126
- // flight, anything needing attention, and core.yaml drift.
196
+ // flight, anything needing attention, core.yaml drift, overrides
197
+ // pointing at no task, and what has been recorded in the two
198
+ // append-only side channels (declared debt, logged friction).
127
199
  //
128
200
  // `missingRequirements` comes from the core definition rather than the
129
201
  // database (src/db/requires.mjs#coreMissingRequirements), so the caller
@@ -138,6 +210,9 @@ export function formatStatus({
138
210
  inFlight,
139
211
  attention,
140
212
  drift,
213
+ orphanedOverrides = [],
214
+ debt = [],
215
+ frictionCount = 0,
141
216
  total,
142
217
  missingRequirements,
143
218
  }) {
@@ -191,13 +266,63 @@ export function formatStatus({
191
266
  lines.push(' Fix the work, then: hedgehog retry <task-id> && hedgehog claim <task-id> --owner <owner>');
192
267
  }
193
268
 
194
- // Last, because it's a condition of the graph as a whole rather than
195
- // of any one task, and because an operator reading top-down should
196
- // reach it after knowing what's ready and what's stuck.
269
+ // Last two, because both are conditions of the graph as a whole rather
270
+ // than of any one task, and because an operator reading top-down should
271
+ // reach them after knowing what's ready and what's stuck.
197
272
  if (drift && drift.length > 0) {
198
273
  lines.push('');
199
274
  lines.push(formatDrift(drift));
200
275
  }
201
276
 
277
+ // Below drift because it's the rarer of the two and never blocks the
278
+ // build: an orphaned override is work the operator asked for that isn't
279
+ // happening, not work the graph is getting wrong. `hedgehog override
280
+ // list` carries the full explanation of how an id goes stale; here the
281
+ // consequence is the whole point, since this is the only place an
282
+ // operator who doesn't already suspect it will ever see it.
283
+ if (orphanedOverrides.length > 0) {
284
+ lines.push('');
285
+ lines.push('ORPHANED OVERRIDES');
286
+ for (const taskId of orphanedOverrides) {
287
+ lines.push(` ${taskId} no task with this id in the build graph`);
288
+ }
289
+ lines.push('');
290
+ lines.push(' Each widens nothing until the id matches a real task. See: hedgehog override list');
291
+ }
292
+
293
+ // Bottom, below every condition above, and two sections rather than
294
+ // one. Everything above is something wrong — work stalled, a graph
295
+ // that disagrees with its core, an override doing nothing. These two
296
+ // are neither wrong nor actionable here: both tables are append-only,
297
+ // so nothing printed can be cleared by acting on it, and an operator
298
+ // scanning top-down should hit what needs a decision first.
299
+ //
300
+ // Kept apart because they are read by different people at different
301
+ // times through different commands. Debt is in-build traffic between
302
+ // two tasks — it arrives on its own in the inheriting task's packet,
303
+ // and naming the declaring tasks here is only so `debt list <task>`
304
+ // is reachable without guessing the id. Friction is about Hedgehog
305
+ // itself, batch-reviewed by tweaker at the end of a build, and has no
306
+ // per-task reading at all (a row's task_id is nullable). Merging them
307
+ // into one "notes" count would imply a single follow-up action where
308
+ // there are two unrelated ones.
309
+ if (debt.length > 0) {
310
+ const rows = debt.reduce((sum, { n }) => sum + n, 0);
311
+ lines.push('');
312
+ lines.push(`DECLARED DEBT ${rows}`);
313
+ for (const { taskId, n } of debt) {
314
+ lines.push(` ${taskId} ${n} note${n === 1 ? '' : 's'}`);
315
+ }
316
+ lines.push('');
317
+ lines.push(' Reaches each dependent task\'s packet on its own. See: hedgehog debt list <task-id>');
318
+ }
319
+
320
+ if (frictionCount > 0) {
321
+ lines.push('');
322
+ lines.push(`FRICTION LOGGED ${frictionCount}`);
323
+ lines.push('');
324
+ lines.push(' Reviewed as a batch at the end of a build. See: hedgehog friction list');
325
+ }
326
+
202
327
  return lines.join('\n');
203
328
  }
@@ -2,3 +2,4 @@
2
2
  # service — no edits needed unless you've changed those credentials.
3
3
  DATABASE_URL=postgresql://postgres:postgres@localhost:5432/app
4
4
  NODE_ENV=development
5
+ WEB_ORIGIN=http://localhost:3000
@@ -4,8 +4,25 @@
4
4
  "private": true,
5
5
  "nx": {
6
6
  "targets": {
7
+ "generate-feature-modules": {
8
+ "executor": "nx:run-commands",
9
+ "cache": true,
10
+ "inputs": [
11
+ "{projectRoot}/src/app/*/*.module.ts"
12
+ ],
13
+ "outputs": [
14
+ "{projectRoot}/src/app/feature-modules.ts"
15
+ ],
16
+ "options": {
17
+ "command": "node tools/generate-feature-modules.cjs",
18
+ "cwd": "{workspaceRoot}"
19
+ }
20
+ },
7
21
  "build": {
8
22
  "executor": "nx:run-commands",
23
+ "dependsOn": [
24
+ "generate-feature-modules"
25
+ ],
9
26
  "options": {
10
27
  "command": "webpack-cli build",
11
28
  "env": {
@@ -91,7 +108,8 @@
91
108
  "tslib": "^2.3.0",
92
109
  "nestjs-pino": "^4.6.1",
93
110
  "pino-http": "^11.0.0",
94
- "config": "workspace:*"
111
+ "config": "workspace:*",
112
+ "db": "workspace:*"
95
113
  },
96
114
  "devDependencies": {
97
115
  "@nestjs/testing": "^11.0.0"
@@ -0,0 +1,15 @@
1
+ import { Test } from '@nestjs/testing';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { AppModule } from './app.module';
4
+
5
+ describe('AppModule', () => {
6
+ it('compiles with the health check controller wired', async () => {
7
+ const moduleRef = await Test.createTestingModule({
8
+ imports: [AppModule],
9
+ }).compile();
10
+
11
+ expect(moduleRef).toBeDefined();
12
+
13
+ await moduleRef.close();
14
+ });
15
+ });
@@ -1,9 +1,14 @@
1
1
  import { Module } from '@nestjs/common';
2
2
  import { LoggerModule } from 'nestjs-pino';
3
3
  import { HealthController } from './health.controller';
4
+ // Generated by tools/generate-feature-modules.cjs (see that file's header)
5
+ // from every apps/api/src/app/*/*.module.ts on disk — never hand-edited,
6
+ // so no domain module's controller task ever edits this file or
7
+ // app.module.ts itself to register.
8
+ import { featureModules } from './feature-modules';
4
9
 
5
10
  @Module({
6
- imports: [LoggerModule.forRoot()],
11
+ imports: [LoggerModule.forRoot(), ...featureModules],
7
12
  controllers: [HealthController],
8
13
  providers: [],
9
14
  })
@@ -0,0 +1,8 @@
1
+ // GENERATED FILE — do not hand-edit. Regenerated by
2
+ // tools/generate-feature-modules.cjs (the generate-feature-modules Nx
3
+ // target, which test and build depend on) from every
4
+ // apps/api/src/app/*/*.module.ts file on disk. A module directory's
5
+ // controller layer only ever creates its own *.module.ts inside its own
6
+ // directory — never edits this file.
7
+
8
+ export const featureModules = [];
@@ -3,11 +3,20 @@ import { Logger } from 'nestjs-pino';
3
3
  import { NestFactory } from '@nestjs/core';
4
4
  import { AppModule } from './app/app.module';
5
5
 
6
- loadEnv();
6
+ const env = loadEnv();
7
7
 
8
8
  async function bootstrap() {
9
9
  const app = await NestFactory.create(AppModule, { bufferLogs: true });
10
10
  app.useLogger(app.get(Logger));
11
+ // apps/web runs on its own origin in local dev (see the port comment
12
+ // below), so the browser's fetches to this api are cross-origin by
13
+ // default — enable CORS for that origin rather than rediscovering this
14
+ // per project.
15
+ app.enableCors({ origin: env.WEB_ORIGIN });
16
+ // Every route lives under /api. apps/web's NEXT_PUBLIC_API_BASE_URL
17
+ // (apps/web/.env.example) has to carry the same suffix or every request
18
+ // from the browser lands one path segment short of a route that exists —
19
+ // change one and change the other.
11
20
  const globalPrefix = 'api';
12
21
  app.setGlobalPrefix(globalPrefix);
13
22
  // Defaults to 3333, not 3000 — `apps/web`'s `next dev` also defaults to
@@ -14,6 +14,9 @@
14
14
  "references": [
15
15
  {
16
16
  "path": "../../packages/config/tsconfig.lib.json"
17
+ },
18
+ {
19
+ "path": "../../packages/db/tsconfig.lib.json"
17
20
  }
18
21
  ]
19
22
  }
@@ -5,6 +5,9 @@
5
5
  "references": [
6
6
  {
7
7
  "path": "./tsconfig.app.json"
8
+ },
9
+ {
10
+ "path": "./tsconfig.spec.json"
8
11
  }
9
12
  ]
10
13
  }
@@ -0,0 +1,36 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./out-tsc/vitest",
5
+ "types": [
6
+ "vitest/globals",
7
+ "vitest/importMeta",
8
+ "vite/client",
9
+ "node",
10
+ "vitest"
11
+ ],
12
+ "experimentalDecorators": true,
13
+ "emitDecoratorMetadata": true,
14
+ "forceConsistentCasingInFileNames": true
15
+ },
16
+ "include": [
17
+ "vite.config.ts",
18
+ "vite.config.mts",
19
+ "vitest.config.ts",
20
+ "vitest.config.mts",
21
+ "src/**/*.test.ts",
22
+ "src/**/*.spec.ts",
23
+ "src/**/*.test.tsx",
24
+ "src/**/*.spec.tsx",
25
+ "src/**/*.test.js",
26
+ "src/**/*.spec.js",
27
+ "src/**/*.test.jsx",
28
+ "src/**/*.spec.jsx",
29
+ "src/**/*.d.ts"
30
+ ],
31
+ "references": [
32
+ {
33
+ "path": "./tsconfig.app.json"
34
+ }
35
+ ]
36
+ }
@@ -0,0 +1,18 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig(() => ({
4
+ root: __dirname,
5
+ cacheDir: '../../node_modules/.vite/apps/api',
6
+ test: {
7
+ name: 'api',
8
+ watch: false,
9
+ globals: true,
10
+ environment: 'node',
11
+ include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
12
+ reporters: ['default'],
13
+ coverage: {
14
+ reportsDirectory: './test-output/vitest/coverage',
15
+ provider: 'v8' as const,
16
+ },
17
+ },
18
+ }));
@@ -0,0 +1,12 @@
1
+ # Copy to apps/web/.env.local for local dev. Next.js loads env files from
2
+ # the app directory, not the workspace root — the root .env.example is
3
+ # apps/api's, and nothing in this file reaches the server.
4
+ #
5
+ # The /api suffix is apps/api's global route prefix (app.setGlobalPrefix in
6
+ # apps/api/src/main.ts). Without it every request lands one path segment
7
+ # short and 404s against a route that plainly exists. 3333 is apps/api's
8
+ # dev port; apps/web itself runs on 3000.
9
+ #
10
+ # NEXT_PUBLIC_ is required: this value is read in the browser, so Next has
11
+ # to inline it at build time.
12
+ NEXT_PUBLIC_API_BASE_URL=http://localhost:3333/api
@@ -19,5 +19,8 @@
19
19
  },
20
20
  "devDependencies": {
21
21
  "prettier-plugin-tailwindcss": "^0.7.4"
22
+ },
23
+ "nx": {
24
+ "tags": ["scope:web"]
22
25
  }
23
26
  }
@@ -0,0 +1,20 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { ThemeToggle } from './theme-toggle';
5
+
6
+ describe('ThemeToggle', () => {
7
+ it('renders a toggle button and flips the document theme class on click', async () => {
8
+ const user = userEvent.setup();
9
+ render(<ThemeToggle />);
10
+
11
+ const toggle = screen.getByRole('button', { name: /toggle theme/i });
12
+ expect(toggle).toBeInTheDocument();
13
+
14
+ await user.click(toggle);
15
+ expect(document.documentElement.classList.contains('dark')).toBe(true);
16
+
17
+ await user.click(toggle);
18
+ expect(document.documentElement.classList.contains('dark')).toBe(false);
19
+ });
20
+ });
@@ -0,0 +1 @@
1
+ import '@testing-library/jest-dom/vitest';
@@ -41,8 +41,11 @@
41
41
  "node_modules",
42
42
  "jest.config.ts",
43
43
  "jest.config.cts",
44
+ "src/test-setup.ts",
44
45
  "src/**/*.spec.ts",
45
46
  "src/**/*.test.ts",
47
+ "src/**/*.spec.tsx",
48
+ "src/**/*.test.tsx",
46
49
  ".next",
47
50
  "eslint.config.js",
48
51
  "eslint.config.cjs",
@@ -51,6 +54,9 @@
51
54
  "references": [
52
55
  {
53
56
  "path": "../../packages/config"
57
+ },
58
+ {
59
+ "path": "./tsconfig.spec.json"
54
60
  }
55
61
  ]
56
62
  }
@@ -0,0 +1,37 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./out-tsc/vitest",
5
+ "jsx": "preserve",
6
+ "types": [
7
+ "vitest/globals",
8
+ "vitest/importMeta",
9
+ "vite/client",
10
+ "node",
11
+ "vitest",
12
+ "@testing-library/jest-dom"
13
+ ],
14
+ "forceConsistentCasingInFileNames": true
15
+ },
16
+ "include": [
17
+ "vite.config.ts",
18
+ "vite.config.mts",
19
+ "vitest.config.ts",
20
+ "vitest.config.mts",
21
+ "src/test-setup.ts",
22
+ "src/**/*.test.ts",
23
+ "src/**/*.spec.ts",
24
+ "src/**/*.test.tsx",
25
+ "src/**/*.spec.tsx",
26
+ "src/**/*.test.js",
27
+ "src/**/*.spec.js",
28
+ "src/**/*.test.jsx",
29
+ "src/**/*.spec.jsx",
30
+ "src/**/*.d.ts"
31
+ ],
32
+ "references": [
33
+ {
34
+ "path": "../../packages/config"
35
+ }
36
+ ]
37
+ }
@@ -0,0 +1,27 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ import react from '@vitejs/plugin-react';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ export default defineConfig(() => ({
6
+ root: __dirname,
7
+ cacheDir: '../../node_modules/.vite/apps/web',
8
+ plugins: [react()],
9
+ resolve: {
10
+ alias: {
11
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
12
+ },
13
+ },
14
+ test: {
15
+ name: 'web',
16
+ watch: false,
17
+ globals: true,
18
+ environment: 'jsdom',
19
+ setupFiles: ['./src/test-setup.ts'],
20
+ include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
21
+ reporters: ['default'],
22
+ coverage: {
23
+ reportsDirectory: './test-output/vitest/coverage',
24
+ provider: 'v8' as const,
25
+ },
26
+ },
27
+ }));
@@ -19,7 +19,7 @@
19
19
  id: full-stack-app
20
20
  layers:
21
21
  - id: schema
22
- scope: ["packages/db/src/schema/{module}/**"]
22
+ scope: ["packages/db/src/schema/{module}/**", "packages/db/src/schema/index.ts"]
23
23
  verify: "pnpm nx test db -- src/schema/{module}/"
24
24
  commit: "feat({module}): schema"
25
25
  - id: contract
@@ -47,11 +47,19 @@ layers:
47
47
  scope: ["packages/hooks/src/{module}/**"]
48
48
  verify: "pnpm nx test hooks -- src/{module}/"
49
49
  commit: "feat({module}): hooks"
50
+ # Web only, because apps/mobile only exists when the Mobile add-on is
51
+ # on. A layer scoped to a project that was never scaffolded fails its
52
+ # own verify on every module, so the Mobile clause is added by
53
+ # hedgehog-bootstrap's Expo step — which is also what creates the Nx
54
+ # project the extra verify target needs — rather than shipped here and
55
+ # broken by default.
50
56
  - id: screen
51
57
  depends_on: hook
52
- scope: ["apps/web/src/app/{module}/**", "apps/mobile/src/{module}/**"]
53
- verify: "pnpm nx test web -- src/app/{module}/ && pnpm nx test mobile -- src/{module}/"
58
+ scope: ["apps/web/src/app/{module}/**"]
59
+ verify: "pnpm nx test web -- src/app/{module}/"
54
60
  commit: "feat({module}): screen-web"
61
+ # Workspace-wide by design: the only gate that can see cross-module
62
+ # breakage, since every layer above is parallel and module-scoped.
55
63
  - id: join
56
64
  depends_on: screen
57
65
  scope: ["**"]
@@ -6,7 +6,10 @@
6
6
  },
7
7
  "test": {
8
8
  "cache": true,
9
- "dependsOn": ["^build"]
9
+ "dependsOn": ["^build", "generate-feature-modules"]
10
+ },
11
+ "typecheck": {
12
+ "dependsOn": ["generate-feature-modules"]
10
13
  },
11
14
  "lint": {
12
15
  "cache": true
@@ -29,12 +29,16 @@
29
29
  "@swc/cli": "~0.8.1",
30
30
  "@swc/core": "~1.15.5",
31
31
  "@swc/helpers": "~0.5.18",
32
+ "@testing-library/jest-dom": "^7.0.1",
33
+ "@testing-library/react": "^16.3.2",
34
+ "@testing-library/user-event": "^14.6.4",
32
35
  "@types/node": "^22.0.0",
33
36
  "@types/pg": "^8.20.0",
34
37
  "@types/react": "^19.0.0",
35
38
  "@types/react-dom": "^19.0.0",
36
39
  "@typescript-eslint/eslint-plugin": "^8.65.0",
37
40
  "@typescript-eslint/parser": "^8.65.0",
41
+ "@vitejs/plugin-react": "^6.0.5",
38
42
  "@vitest/coverage-v8": "~4.1.0",
39
43
  "drizzle-kit": "^0.31.10",
40
44
  "drizzle-zod": "^0.8.3",
@@ -45,6 +49,7 @@
45
49
  "eslint-plugin-playwright": "^1.6.2",
46
50
  "eslint-plugin-react": "^7.35.0",
47
51
  "eslint-plugin-react-hooks": "5.0.0",
52
+ "jsdom": "^30.0.1",
48
53
  "lefthook": "^2.1.10",
49
54
  "nx": "23.1.0",
50
55
  "prettier": "~3.6.2",
@@ -74,6 +79,9 @@
74
79
  "zod": "^4.4.3"
75
80
  },
76
81
  "pnpm": {
82
+ "onlyBuiltDependencies": [
83
+ "lefthook"
84
+ ],
77
85
  "overrides": {
78
86
  "esbuild": "0.25.12",
79
87
  "axios": "^1.18.0",