diff --git a/.claude/hooks/verify.sh b/.claude/hooks/verify.sh new file mode 100755 index 0000000..0a35040 --- /dev/null +++ b/.claude/hooks/verify.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Stop hook: the agent may only finish when typecheck and unit tests pass on the current code. +set -uo pipefail +cd "${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel)}" + +payload="$(cat)" +# Avoid a loop: if we already blocked once in this turn, let it stop and show the failure to the user. +if printf '%s' "$payload" | grep -q '"stop_hook_active"[[:space:]]*:[[:space:]]*true'; then + exit 0 +fi + +# Nothing changed in the app, nothing to verify. +if [ -z "$(git status --porcelain -- apps/web package.json pnpm-lock.yaml)" ]; then + exit 0 +fi + +run() { + local label="$1"; shift + local out + if ! out="$("$@" 2>&1)"; then + { + echo "VERIFY FAILED: $label" + echo "$out" | tail -n 40 + echo "Fix the cause (do not weaken tests), then finish again." + } >&2 + exit 2 + fi +} + +run "typecheck" pnpm --filter @mintel/web typecheck +run "unit tests" pnpm --filter @mintel/web test +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..15a96dd --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,41 @@ +{ + "permissions": { + "deny": [ + "Read(./.env)", + "Read(./.env.*)", + "Read(./apps/web/.env)", + "Read(./apps/web/.env.*)", + "Edit(./.env)", + "Edit(./.env.*)", + "Bash(git push --force*)", + "Bash(git push -f*)", + "Bash(git push * main*)", + "Bash(git push * master*)", + "Bash(git commit *--no-verify*)", + "Bash(git commit * -n *)", + "Bash(git reset --hard*)", + "Bash(git clean*)", + "Bash(rm -rf*)" + ], + "ask": [ + "Edit(./.gitea/workflows/**)", + "Write(./.gitea/workflows/**)", + "Edit(./Dockerfile*)", + "Edit(./docker-compose*)", + "Bash(git push*)" + ] + }, + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/verify.sh", + "timeout": 180 + } + ] + } + ] + } +} diff --git a/.husky/pre-commit b/.husky/pre-commit index 5ee7abd..d6fd4ea 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,3 @@ pnpm exec lint-staged +pnpm --filter @mintel/web typecheck +pnpm --filter @mintel/web test diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..ef97f4f --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,8 @@ +# Pushing is gated harder than committing: full coverage, and mutation when the domain core changed. +pnpm --filter @mintel/web test:coverage || exit 1 + +base="$(git merge-base HEAD origin/main 2>/dev/null || git rev-list --max-parents=0 HEAD | tail -n 1)" +if git diff --name-only "$base"...HEAD | grep -q '^apps/web/src/domain/'; then + echo "src/domain changed: running mutation gate (about 2 minutes)" + pnpm --filter @mintel/web test:mutation || exit 1 +fi diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8e861f6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,46 @@ +# mintel.me + +Personal website of a senior freelance engineer. Next.js 16 (App Router) + Payload, pnpm monorepo. +App lives in `apps/web`. Concept and scope: `apps/web/plans/freelance-relaunch.md` (read it first). + +## Commands (run from repo root) + +- `pnpm --filter @mintel/web typecheck` — must pass with zero errors +- `pnpm --filter @mintel/web test` — unit specs (Vitest); 0 tests run counts as a failure +- `pnpm --filter @mintel/web test:coverage` — `src/domain` must stay at 100% +- `pnpm --filter @mintel/web test:mutation` — Stryker on `src/domain`, ~2 min, must score 100 +- `pnpm --filter @mintel/web lint` + +## How to work here (spec first) + +1. Write the behavior as concrete cases (inputs, outputs, edge cases, errors) as a `*.spec.ts`. +2. Run it and confirm it fails for the right reason (missing behavior, not an import error). +3. Implement the simplest clean solution, run again. +4. Run typecheck, coverage and mutation. Survivors mean weak specs or dead code: fix one or the other. +5. Report the exact commands and results. "Done" means they were run on the current code. + Never weaken, skip or delete a test to make it pass. + +## Architecture rules + +- Functional core, imperative shell. Pure logic goes in `apps/web/src/domain/` (no I/O, no clock, no + randomness, no framework imports; pass `today` etc. in as arguments). Everything there is covered 100% + and mutation-tested. Pages, routes and adapters stay thin and call into the core. +- Parse at the boundary into precise types (`Result`, discriminated unions); no nullable state bags. +- No `any`, `@ts-ignore`, `!` non-null assertions or eslint-disable to silence errors. Fix the cause. + +## Do not + +- Read or edit `.env*` (only `.env.example`). Never print or log secrets. +- Commit scratch files (`tmp*`, `*.log`, `*.tmp`, debug HTML, build output). They are gitignored; keep it so. +- Use `--no-verify`, force-push, push to `main`, or run `reset --hard`, `clean`, `rm -rf` without asking. +- Edit `.gitea/workflows/*` or deploy config without asking. +- Upgrade `vitest` past 4.1.x: `@stryker-mutator/vitest-runner` 10.0.0 runs zero tests per mutant under + vitest 5 and reports false survivors. Re-verify mutation results after any tooling bump. +- Add dependencies for trivial things, or touch unrelated code (no drive-by refactors). + +## Gotchas + +- Stryker needs `plugins` listed explicitly in `stryker.config.json` (pnpm layout) and ignores the large + asset folders via `ignorePatterns`; keep them in sync when adding top-level folders. +- `@mintel/*` packages are `link:` dependencies on a sibling `at-mintel` checkout. +- Git branches: work on `feat/*`, never directly on `main`.