diff --git a/.claude/commands/ci.md b/.claude/commands/ci.md new file mode 100644 index 0000000..549246b --- /dev/null +++ b/.claude/commands/ci.md @@ -0,0 +1,122 @@ +--- +allowed-tools: Bash, Read +--- + +# /ci — CI-Optimized Verification + +Runs the same checks as `/verify-all` but in non-interactive, machine-readable mode. Designed for CI pipelines, pre-merge gates, and automation: no prompts, JSON output, exit code reflects overall pass/fail. + +## Usage + +``` +/ci +/ci --skip script-tests,security +/ci --include shell-syntax,json,pipeline-config,doc-counts +``` + +The slash command wraps `./scripts/verify-all.sh --ci`. The `--ci` flag implies `--json`, exports `CI=1` and `NO_COLOR=1` to every check, and never reads stdin. Pass `--skip` or `--include` to filter checks; `--root ` points it at another project tree. + +## What runs + +Same ten checks as `/verify-all`, in the same order: + +``` +shell-syntax → js-syntax → json → pipeline-config → doc-counts → script-tests + → types → tests → security → destructive-migrations +``` + +Checks whose subject is absent skip automatically with a reason: `types`, `tests`, and `security` need an `api/` directory; `destructive-migrations` needs `api/src/db/migrations`; `script-tests` needs a `package.json` with a `test` script and `pnpm` on PATH; any check whose backing script is missing is skipped as `script not found`. Skips never fail the run. No check requires a dev server or a database connection, so all of them run in CI without extra setup. + +## Output + +JSON written to stdout: + +```json +{ + "status": "fail", + "checks": [ + { "name": "shell-syntax", "status": "pass", "exitCode": 0, "durationMs": 144 }, + { "name": "js-syntax", "status": "pass", "exitCode": 0, "durationMs": 314 }, + { "name": "json", "status": "pass", "exitCode": 0, "durationMs": 221 }, + { "name": "pipeline-config", "status": "pass", "exitCode": 0, "durationMs": 180 }, + { "name": "doc-counts", "status": "fail", "exitCode": 1, "durationMs": 1466, + "reason": "exit 1", + "command": "bash scripts/check-doc-counts.sh", + "output": "...last 15 lines of the check's output..." }, + { "name": "script-tests", "status": "pass", "exitCode": 0, "durationMs": 8120 }, + { "name": "types", "status": "skip", "exitCode": 0, "durationMs": 0, "reason": "no api/ directory" }, + { "name": "tests", "status": "skip", "exitCode": 0, "durationMs": 0, "reason": "no api/ directory" }, + { "name": "security", "status": "skip", "exitCode": 0, "durationMs": 0, "reason": "no api/ directory" }, + { "name": "destructive-migrations", "status": "skip", "exitCode": 0, "durationMs": 0, + "reason": "no migrations directory at api/src/db/migrations" } + ], + "summary": { "passed": 5, "failed": 1, "skipped": 4, "total": 10 } +} +``` + +| Field | Meaning | +|-------|---------| +| `status` | `"pass"` only when every check passed or was skipped; otherwise `"fail"` | +| `checks[].status` | `pass` \| `fail` \| `skip` | +| `checks[].exitCode` | The exit code returned by the underlying check | +| `checks[].durationMs` | Wall-clock duration (0 for skips) | +| `checks[].reason` | Present on `skip` (why) and `fail` (`exit N`); absent on `pass` | +| `checks[].command` | Present on `fail`: the command to reproduce it | +| `checks[].output` | Present on `fail`: last 15 lines of captured output, ANSI-stripped | +| `summary.passed` / `failed` / `skipped` / `total` | Counts across all checks | + +## Steps + +### 1. Run the orchestrator in CI mode + +```bash +./scripts/verify-all.sh --ci $ARGUMENTS +``` + +### 2. Surface results to the user + +Parse the JSON and report the summary in plain text: + +``` +5 passed, 1 failed, 4 skipped +Failing: doc-counts (exit 1, 1466ms) — bash scripts/check-doc-counts.sh +``` + +Include the `output` field of each failing check so the cause is visible without a re-run. When invoked from a CI pipeline, pipe the JSON straight to a downstream step or store it as a build artifact. + +### 3. In a GitHub Actions job + +```yaml +- run: pnpm install --frozen-lockfile +- run: pnpm verify:ci | tee verify-report.json +- uses: actions/upload-artifact@v4 + if: always() + with: + name: verify-report + path: verify-report.json +``` + +`pnpm verify:ci` propagates the script's exit code, so a failing check fails the job. + +## Exit codes + +- `0` — every check passed or was skipped +- `1` — one or more checks failed +- `2` — usage error (unknown flag or check name) + +The exit code is what CI runners pivot on, so do not swallow it. + +## Differences vs `/verify-all` + +| Aspect | `/verify-all` | `/ci` | +|--------|---------------|-------| +| Output | Human-readable progress + summary table | JSON to stdout only | +| Failing output | Inline tail under each failure | `output` field per failing check | +| Interactivity | Allowed (no prompts today, but reserved) | Forbidden; `CI=1` exported | +| Use case | Local development | CI pipelines, automation, scripts | +| Exit code on failure | 1 | 1 | + +## Related + +- `/verify-all` — human-readable version of the same checks +- `./scripts/verify-all.sh --help` — full flag documentation diff --git a/.claude/commands/verify-all.md b/.claude/commands/verify-all.md new file mode 100644 index 0000000..afe1206 --- /dev/null +++ b/.claude/commands/verify-all.md @@ -0,0 +1,138 @@ +--- +allowed-tools: Bash, Read +--- + +# /verify-all — Run All Local Quality Checks + +Runs every local quality check in sequence and reports a pass/fail summary. Wraps `./scripts/verify-all.sh` so you do not have to remember each individual script. + +## Purpose + +One command that answers "is this checkout healthy?" for both the Nerva framework itself (shell/JS syntax, config JSON, pipeline config, documentation counts, script tests) and a generated API project under `api/` (types, tests, security, migration safety). + +## Usage + +``` +/verify-all +/verify-all --skip script-tests,security +/verify-all --include shell-syntax,json,doc-counts +/verify-all --list +``` + +Optional flags forward to the underlying script: + +| Flag | Effect | +|------|--------| +| `--skip ` | Skip the named checks (comma-separated) | +| `--include ` | Run only the named checks; everything else is marked skipped | +| `--list` | Print the check names in run order and exit | +| `--root ` | Check a different project tree (defaults to the repo root) | +| `--json` | Emit the machine-readable report instead of the summary table | +| `--help` | Full flag documentation | + +Unknown flags and unknown check names exit `2`. For machine-readable output and CI-style behavior, use `/ci` instead. + +## What runs + +The orchestrator runs these checks in order. Each is independent — a failure in one does not stop the others. Checks whose backing script or subject is absent are **skipped with a reason**, never failed, so the command is safe on a bare framework checkout. + +| # | Check | Backing command | What it does | Skipped when | +|---|-------|-----------------|--------------|--------------| +| 1 | `shell-syntax` | `bash -n` | Parses `scripts/*.sh`, `scripts/lib/*.sh`, `.claude/hooks/*.sh` | no shell scripts found | +| 2 | `js-syntax` | `node --check` | Parses `scripts/*.js`, `scripts/lib/*.js` | no JS files / no node | +| 3 | `json` | `JSON.parse` | Parses `.claude/pipeline.config.json`, `.claude/settings.json` (if present), `package.json` | no JSON files / no node | +| 4 | `pipeline-config` | `node scripts/validate-pipeline-config.js` | Validates `pipeline.config.json` against its schema | script not found | +| 5 | `doc-counts` | `scripts/check-doc-counts.sh` | Agent/skill/script/command counts in docs vs disk | script not found | +| 6 | `script-tests` | `pnpm test` | Vitest suite for `scripts/` (slow) | no `package.json` / no `test` script / no pnpm | +| 7 | `types` | `scripts/check-types.sh` | `tsc --noEmit` on the API | no `api/` directory | +| 8 | `tests` | `scripts/run-tests.sh` | Vitest with coverage on the API | no `api/` directory | +| 9 | `security` | `scripts/security-scan.sh` | Dependency audit + anti-pattern scan | no `api/` directory | +| 10 | `destructive-migrations` | `node scripts/check-destructive-migrations.js` | Flags DROP/TRUNCATE/etc. in migrations | no `api/src/db/migrations` | + +`api/` is resolved via `NERVA_API_DIR` when set, otherwise `/api`. + +## Steps + +### 1. Run the orchestrator + +```bash +./scripts/verify-all.sh $ARGUMENTS +``` + +The script prints per-check progress and a summary table at the end: + +``` +=== verify-all === + +▸ shell-syntax … + ✓ pass (144ms) +▸ js-syntax … + ✓ pass (314ms) +▸ doc-counts … + ✗ fail (exit 1, 1466ms) + --- last 15 lines of output --- + | :: claims scripts, actual -> "" + --- + +=== Summary === +Check Status Duration Reason +----- ------ -------- ------ +shell-syntax pass 144ms +js-syntax pass 314ms +json pass 221ms +pipeline-config pass 180ms +doc-counts fail 1466ms exit 1 +script-tests pass 8120ms +types skip 0ms no api/ directory +tests skip 0ms no api/ directory +security skip 0ms no api/ directory +destructive-migrations skip 0ms no migrations directory at api/src/db/migrations + +Totals: 5 passed, 1 failed, 4 skipped + +✗ Some checks failed. Re-run each failing check individually for full output: + bash scripts/check-doc-counts.sh +``` + +### 2. If any check fails + +The last 15 lines of the failing check's output are shown inline, and the summary lists the exact command to reproduce each failure with full output. Re-run that one script to see the underlying errors: + +```bash +bash scripts/check-doc-counts.sh +``` + +Once the failing check is fixed, re-run `/verify-all` to confirm. + +### 3. Skipping the slow check during iteration + +`script-tests` runs the whole Vitest suite for `scripts/`. While iterating on something else: + +```bash +./scripts/verify-all.sh --skip script-tests +``` + +## Or use the package scripts + +```bash +pnpm verify # same as ./scripts/verify-all.sh +pnpm verify:ci # same as ./scripts/verify-all.sh --ci +``` + +## Exit codes + +- `0` — every check passed (or was skipped) +- `1` — one or more checks failed +- `2` — usage error (unknown flag or check name) + +## Common Issues + +- **`doc-counts` fails after adding a script/agent/skill/command**: update the counts in `CLAUDE.md`, `README.md`, and any other doc it lists (it prints `file:line`, the claimed number, and the actual number). +- **Everything under `types`/`tests`/`security` is skipped**: there is no `api/` directory yet. Run `./scripts/setup-project.sh` or set `NERVA_API_DIR`. +- **`script-tests` is skipped with "nested verify-all run"**: the orchestrator was invoked from within `pnpm test`; this is the recursion guard, not an error. + +## Related + +- `/ci` — same checks, JSON output, non-interactive (for automation / CI) +- `/lint` — lint and format only +- `/test` — API tests only diff --git a/.claude/hooks/coverage-check.sh b/.claude/hooks/coverage-check.sh new file mode 100755 index 0000000..1f5872d --- /dev/null +++ b/.claude/hooks/coverage-check.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# coverage-check.sh — PostToolUse hook (matcher: Bash) +# +# After a vitest run whose output mentions coverage, prints a one-line +# reminder with the threshold from tdd.coverageThreshold in +# .claude/pipeline.config.json (default 80). When the text reporter's +# "All files" row is present, the statement percentage is compared to the +# threshold so the message says whether coverage is above or below it. +# +# Contract: JSON on stdin (see lib/hook-input.sh). Always exits 0. Stdout is +# surfaced to Claude as a reminder; no output means silent. + +set -u +trap 'exit 0' ERR + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -f "$HOOK_DIR/../../scripts/lib/common.sh" ]]; then + # shellcheck source=../../scripts/lib/common.sh + source "$HOOK_DIR/../../scripts/lib/common.sh" +fi +# shellcheck source=lib/hook-input.sh +source "$HOOK_DIR/lib/hook-input.sh" + +hook_skip_requested && exit 0 +CMD="$(hook_tool_command)" +[[ -n "$CMD" ]] || exit 0 +printf '%s' "$CMD" | grep -qE 'vitest|run-tests\.sh' || exit 0 + +OUT="$(hook_tool_output)" +[[ -n "$OUT" ]] || exit 0 +printf '%s' "$OUT" | grep -qi 'coverage' || exit 0 + +NUM_RE='^[0-9]+(\.[0-9]+)?$' +THRESHOLD="$(common_config_get tdd.coverageThreshold 80)" +[[ "$THRESHOLD" =~ $NUM_RE ]] || THRESHOLD=80 + +# Statement % from the text reporter's "All files | 91.2 | ..." row, if present. +STMTS="$(printf '%s\n' "$OUT" | grep -E '^[[:space:]]*All files[[:space:]]*\|' | head -1 | awk -F'|' '{ gsub(/[[:space:]]/, "", $2); print $2 }')" || STMTS="" + +if [[ "$STMTS" =~ $NUM_RE ]]; then + if awk -v s="$STMTS" -v t="$THRESHOLD" 'BEGIN { exit !(s + 0 < t + 0) }'; then + echo "[coverage-check] Statement coverage ${STMTS}% is BELOW the ${THRESHOLD}% threshold (tdd.coverageThreshold in .claude/pipeline.config.json). Add tests before moving on." + else + echo "[coverage-check] Statement coverage ${STMTS}% meets the ${THRESHOLD}% threshold (tdd.coverageThreshold). Check the branches, functions, and lines columns too." + fi +else + echo "[coverage-check] Coverage output detected. Confirm it meets the ${THRESHOLD}% threshold (tdd.coverageThreshold in .claude/pipeline.config.json)." +fi +exit 0 diff --git a/.claude/hooks/lib/bash-cmd.sh b/.claude/hooks/lib/bash-cmd.sh new file mode 100644 index 0000000..897a0e4 --- /dev/null +++ b/.claude/hooks/lib/bash-cmd.sh @@ -0,0 +1,258 @@ +#!/usr/bin/env bash +# bash-cmd.sh — small shell-command parser for the PreToolUse guards +# +# The guards need to know which *simple commands* a Bash tool call contains +# and what their words are, without being fooled by quoted strings, heredoc +# bodies, or comments. This is a pragmatic single-pass parser, not a full +# bash grammar; the known limits are listed at the bottom. +# +# Source after hook-input.sh: +# +# source "$HOOK_DIR/lib/bash-cmd.sh" +# +# Provides: +# +# hook_split_commands +# Fills HOOK_SEGMENTS with one entry per simple command. Splits on +# newline ; | || && & ( ) and backticks (outside quotes), drops +# comments, and skips heredoc bodies (<&1 and &> are kept inside their segment. +# Segments may be empty; callers skip those. +# +# hook_tokenize +# Fills HOOK_TOKENS with the words of one segment. Single and double +# quotes and backslash escapes are honoured and removed. +# +# hook_strip_wrappers +# Reads HOOK_TOKENS and fills HOOK_ARGV with the same words minus leading +# VAR=value assignments and launcher words (sudo, env, command, time, +# nohup, exec, builtin, if/then/else/while/until/do, { } !). After this, +# HOOK_ARGV[0] is the program being run. +# +# Bash 3.2 compatible (macOS /bin/bash). All functions return 0. +# +# Limits (documented in docs/onboarding/hooks.md): +# - `bash -c "..."`, `eval "..."`, and `sh script.sh` bodies are not parsed. +# - $'...' ANSI-C strings and ${VAR} expansions are treated as plain text. +# - Only one heredoc per line is recognised. + +if [[ -n "${__NERVA_BASH_CMD_LOADED:-}" ]]; then + return 0 2>/dev/null || true +fi +__NERVA_BASH_CMD_LOADED=1 + +HOOK_SEGMENTS=() +HOOK_TOKENS=() +HOOK_ARGV=() + +hook_split_commands() { + local s="${1-}" + local n=${#s} + local i=0 j=0 ln=0 + local c="" nxt="" prev="" seg="" q="" hd="" w="" qq="" line="" t="" + HOOK_SEGMENTS=() + + while (( i < n )); do + c="${s:i:1}" + + # Inside quotes: copy verbatim until the matching quote. + if [[ -n "$q" ]]; then + seg+="$c" + if [[ "$c" == "$q" ]]; then + q="" + elif [[ "$q" == '"' && "$c" == '\' ]]; then + i=$((i + 1)) + seg+="${s:i:1}" + fi + i=$((i + 1)) + continue + fi + + nxt="${s:i+1:1}" + case "$c" in + \'|\") + q="$c" + seg+="$c" + ;; + \\) + # Backslash escape (including backslash-newline continuation). + seg+="$c$nxt" + i=$((i + 1)) + ;; + '#') + # A comment only starts at a word boundary. + if [[ -z "$seg" || "$seg" == *[[:space:]] ]]; then + while (( i < n )) && [[ "${s:i:1}" != $'\n' ]]; do + i=$((i + 1)) + done + continue + fi + seg+="$c" + ;; + '<') + if [[ "$nxt" == '<' && "${s:i+2:1}" != '<' ]]; then + # Heredoc operator: read the delimiter word, remember it, and skip + # the body once the current line ends. + j=$((i + 2)) + w="" + if [[ "${s:j:1}" == '-' ]]; then + j=$((j + 1)) + fi + while [[ "${s:j:1}" == ' ' || "${s:j:1}" == $'\t' ]]; do + j=$((j + 1)) + done + qq="${s:j:1}" + if [[ "$qq" == "'" || "$qq" == '"' ]]; then + j=$((j + 1)) + while (( j < n )) && [[ "${s:j:1}" != "$qq" ]]; do + w+="${s:j:1}" + j=$((j + 1)) + done + j=$((j + 1)) + else + while (( j < n )) && [[ "${s:j:1}" == [A-Za-z0-9_] ]]; do + w+="${s:j:1}" + j=$((j + 1)) + done + fi + hd="$w" + seg+="${s:i:j-i}" + i=$j + continue + fi + seg+="$c" + ;; + $'\n') + HOOK_SEGMENTS+=("$seg") + seg="" + if [[ -n "$hd" ]]; then + # Skip heredoc body lines up to and including the terminator. + i=$((i + 1)) + while (( i < n )); do + line="${s:i}" + line="${line%%$'\n'*}" + ln=${#line} + i=$((i + ln + 1)) + t="$line" + while [[ "$t" == $'\t'* ]]; do + t="${t:1}" + done + if [[ "$t" == "$hd" ]]; then + break + fi + done + hd="" + continue + fi + ;; + ';'|'('|')'|'`') + HOOK_SEGMENTS+=("$seg") + seg="" + ;; + '|') + HOOK_SEGMENTS+=("$seg") + seg="" + if [[ "$nxt" == '|' ]]; then + i=$((i + 1)) + fi + ;; + '&') + if [[ "$nxt" == '&' ]]; then + HOOK_SEGMENTS+=("$seg") + seg="" + i=$((i + 1)) + elif [[ "$nxt" == '>' ]]; then + seg+="$c" # &> redirect + else + prev="" + if (( i > 0 )); then + prev="${s:i-1:1}" + fi + if [[ "$prev" == '>' || "$prev" == '<' ]]; then + seg+="$c" # 2>&1, <&0 + else + HOOK_SEGMENTS+=("$seg") # background job separator + seg="" + fi + fi + ;; + *) + seg+="$c" + ;; + esac + i=$((i + 1)) + done + + if [[ -n "$seg" ]]; then + HOOK_SEGMENTS+=("$seg") + fi + return 0 +} + +hook_tokenize() { + local s="${1-}" + local n=${#s} + local i=0 intok=0 + local c="" tok="" q="" + HOOK_TOKENS=() + + while (( i < n )); do + c="${s:i:1}" + if [[ -n "$q" ]]; then + if [[ "$c" == "$q" ]]; then + q="" + elif [[ "$q" == '"' && "$c" == '\' ]]; then + i=$((i + 1)) + tok+="${s:i:1}" + else + tok+="$c" + fi + i=$((i + 1)) + continue + fi + case "$c" in + \'|\") + q="$c" + intok=1 + ;; + \\) + i=$((i + 1)) + tok+="${s:i:1}" + intok=1 + ;; + ' '|$'\t'|$'\n'|$'\r') + if (( intok )); then + HOOK_TOKENS+=("$tok") + tok="" + intok=0 + fi + ;; + *) + tok+="$c" + intok=1 + ;; + esac + i=$((i + 1)) + done + + if (( intok )); then + HOOK_TOKENS+=("$tok") + fi + return 0 +} + +hook_strip_wrappers() { + local -a rest + rest=(${HOOK_TOKENS[@]+"${HOOK_TOKENS[@]}"}) + while (( ${#rest[@]} > 0 )); do + case "${rest[0]}" in + sudo|command|env|time|nohup|exec|builtin|if|then|else|elif|while|until|do|\{|\}|\!) ;; + [A-Za-z_]*=*) ;; + *) break ;; + esac + rest=(${rest[@]+"${rest[@]:1}"}) + done + # shellcheck disable=SC2034 # consumed by the hook that sources this lib + HOOK_ARGV=(${rest[@]+"${rest[@]}"}) + return 0 +} diff --git a/.claude/hooks/lib/hook-input.sh b/.claude/hooks/lib/hook-input.sh new file mode 100644 index 0000000..590b408 --- /dev/null +++ b/.claude/hooks/lib/hook-input.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# hook-input.sh — stdin helpers for .claude/hooks/*.sh +# +# Claude Code passes ONE JSON object on stdin to every hook, for example: +# +# { +# "hook_event_name": "PreToolUse", +# "tool_name": "Bash", +# "tool_input": { "command": "git add .env" }, +# "tool_response": { "stdout": "...", "stderr": "..." }, // PostToolUse only +# "cwd": "/path/to/project" +# } +# +# Source this once near the top of a hook (after scripts/lib/common.sh when +# that file is available): +# +# HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# source "$HOOK_DIR/lib/hook-input.sh" +# +# It reads stdin exactly once into HOOK_INPUT and provides: +# +# hook_get jq-style path such as `.tool_input.command`. Prints "" +# (and returns 0) when the key is missing, the value is +# null/false, or stdin was not JSON. Uses jq when it is +# on PATH, otherwise node (always present in Nerva). +# Object/array values are printed as JSON text. +# hook_tool_name `hook_get .tool_name` +# hook_tool_command `hook_get .tool_input.command` +# hook_tool_output tool_response as plain text. The Bash tool returns an +# object ({stdout, stderr, ...}); other tools may return +# a string or an {output}/{content} object. All shapes +# come back as text (stdout then stderr for Bash). +# (set NERVA_HOOK_JSON_TOOL=node to force the node path; tests use this) +# hook_skip_requested returns 0 when NERVA_SKIP_HOOKS=1 (or "true") is set +# in the environment Claude Code was launched from. This +# is the user-level bypass: Claude's own Bash commands +# cannot influence the hook process environment. +# +# Fallbacks: when scripts/lib/common.sh was not sourced (for instance because +# the hooks were copied into a generated project), minimal `have_cmd` and +# `common_config_get` are defined here so hooks keep working. +# +# Nothing in this file exits the caller and every helper returns 0. + +if [[ -n "${__NERVA_HOOK_INPUT_LOADED:-}" ]]; then + return 0 2>/dev/null || true +fi +__NERVA_HOOK_INPUT_LOADED=1 + +if ! declare -F have_cmd >/dev/null 2>&1; then + have_cmd() { command -v "$1" >/dev/null 2>&1; } +fi + +if ! declare -F common_config_get >/dev/null 2>&1; then + # Fallback reader for .claude/pipeline.config.json (cwd-relative, or the file + # named by NERVA_PIPELINE_CONFIG). Mirrors scripts/lib/common.sh's contract: + # prints the default when anything is missing. + common_config_get() { + local key="${1:-}" + local default="${2-}" + local file="${NERVA_PIPELINE_CONFIG:-.claude/pipeline.config.json}" + local out="" + if [[ -z "$key" || ! -f "$file" ]] || ! have_cmd node; then + echo "$default" + return 0 + fi + out="$(HOOK_CFG_FILE="$file" HOOK_CFG_KEY="$key" node -e ' + const fs = require("fs"); + let v; + try { v = JSON.parse(fs.readFileSync(process.env.HOOK_CFG_FILE, "utf8")); } catch { process.exit(1); } + for (const p of process.env.HOOK_CFG_KEY.split(".").filter(Boolean)) { + if (v === null || typeof v !== "object" || !(p in v)) process.exit(1); + v = v[p]; + } + if (v === undefined || v === null) process.exit(1); + process.stdout.write(typeof v === "object" ? JSON.stringify(v) : String(v)); + ' 2>/dev/null)" || out="" + if [[ -n "$out" ]]; then + echo "$out" + else + echo "$default" + fi + return 0 + } +fi + +# --- Read stdin once ------------------------------------------------------- + +HOOK_INPUT="" +# Skip the read when stdin is a terminal (someone ran the hook by hand with +# no pipe) so the script does not hang waiting for input. +if [[ ! -t 0 ]]; then + HOOK_INPUT="$(cat 2>/dev/null || true)" +fi + +# --- Accessors ------------------------------------------------------------- + +hook_get() { + local path="${1:-}" + if [[ -z "$HOOK_INPUT" || -z "$path" ]]; then + return 0 + fi + [[ "$path" == .* ]] || path=".$path" + + # NERVA_HOOK_JSON_TOOL=node forces the node path (used by the test-suite). + if [[ "${NERVA_HOOK_JSON_TOOL:-}" != "node" ]] && have_cmd jq; then + printf '%s' "$HOOK_INPUT" | jq -r "$path // empty" 2>/dev/null || true + elif have_cmd node; then + # Dotted object keys only (".a.b.c"); that is all the hooks need. + printf '%s' "$HOOK_INPUT" | HOOK_PATH="$path" node -e ' + const fs = require("fs"); + let v; + try { v = JSON.parse(fs.readFileSync(0, "utf8")); } catch { process.exit(0); } + for (const p of process.env.HOOK_PATH.split(".").filter(Boolean)) { + if (v === null || typeof v !== "object" || !(p in v)) process.exit(0); + v = v[p]; + } + if (v === undefined || v === null || v === false) process.exit(0); + process.stdout.write(typeof v === "object" ? JSON.stringify(v) : String(v)); + ' 2>/dev/null || true + fi + return 0 +} + +hook_tool_name() { + hook_get .tool_name +} + +hook_tool_command() { + hook_get .tool_input.command +} + +hook_tool_output() { + local raw out err + raw="$(hook_get .tool_response)" + if [[ -z "$raw" ]]; then + return 0 + fi + case "$raw" in + \{*) + out="$(hook_get .tool_response.stdout)" + err="$(hook_get .tool_response.stderr)" + if [[ -n "$out" || -n "$err" ]]; then + printf '%s' "$out" + if [[ -n "$out" && -n "$err" ]]; then + printf '\n' + fi + printf '%s' "$err" + return 0 + fi + out="$(hook_get .tool_response.output)" + if [[ -n "$out" ]]; then + printf '%s' "$out" + return 0 + fi + out="$(hook_get .tool_response.content)" + if [[ -n "$out" ]]; then + printf '%s' "$out" + return 0 + fi + printf '%s' "$raw" + ;; + *) + printf '%s' "$raw" + ;; + esac + return 0 +} + +hook_skip_requested() { + [[ "${NERVA_SKIP_HOOKS:-}" == "1" || "${NERVA_SKIP_HOOKS:-}" == "true" ]] +} diff --git a/.claude/hooks/migration-safety-reminder.sh b/.claude/hooks/migration-safety-reminder.sh new file mode 100755 index 0000000..13ca2fb --- /dev/null +++ b/.claude/hooks/migration-safety-reminder.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# migration-safety-reminder.sh — PostToolUse hook (matcher: Bash) +# +# - After `drizzle-kit generate` (or `pnpm db:generate`, +# scripts/generate-migration.sh) succeeds: remind to run the +# destructive-DDL check and to read the generated SQL. The check command +# depends on the cwd: `node scripts/check-destructive-migrations.js` in the +# framework repo, `pnpm db:check-destructive` in a generated api/ project. +# - After any `drizzle-kit push` (or `pnpm db:push`): remind that push is +# dev-only and production uses migrations. +# +# Contract: JSON on stdin (see lib/hook-input.sh). Always exits 0. Stdout is +# surfaced to Claude as a reminder; no output means silent. + +set -u +trap 'exit 0' ERR + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -f "$HOOK_DIR/../../scripts/lib/common.sh" ]]; then + # shellcheck source=../../scripts/lib/common.sh + source "$HOOK_DIR/../../scripts/lib/common.sh" +fi +# shellcheck source=lib/hook-input.sh +source "$HOOK_DIR/lib/hook-input.sh" + +hook_skip_requested && exit 0 +CMD="$(hook_tool_command)" +[[ -n "$CMD" ]] || exit 0 + +PUSH_RE='drizzle-kit[[:space:]]+push|db:push' +GENERATE_RE='drizzle-kit[[:space:]]+generate|db:generate|generate-migration\.sh' + +if printf '%s' "$CMD" | grep -qE -- "$PUSH_RE"; then + echo "[migration-safety] drizzle-kit push applied schema changes straight to the database. That is dev-only: staging and production take generated migrations (pnpm drizzle-kit generate, review the SQL, pnpm drizzle-kit migrate)." +fi + +if printf '%s' "$CMD" | grep -qE -- "$GENERATE_RE"; then + OUT="$(hook_tool_output)" + if [[ -n "$OUT" ]] \ + && ! printf '%s' "$OUT" | grep -qi 'no schema changes' \ + && ! printf '%s' "$OUT" | grep -qiE '(^|[^[:alnum:]])(error|failed)([^[:alnum:]]|$)|✗|ERR_' \ + && printf '%s' "$OUT" | grep -qiE 'migration|[[:alnum:]_-]+\.sql'; then + if [[ -f scripts/check-destructive-migrations.js ]]; then + CHECK="node scripts/check-destructive-migrations.js" + elif [[ -f package.json ]] && grep -q '"db:check-destructive"' package.json 2>/dev/null; then + CHECK="pnpm db:check-destructive" + elif [[ -f api/package.json ]] && grep -q '"db:check-destructive"' api/package.json 2>/dev/null; then + CHECK="cd api && pnpm db:check-destructive" + else + CHECK="the destructive-DDL check (node scripts/check-destructive-migrations.js in the framework repo, pnpm db:check-destructive in a generated api/ project)" + fi + echo "[migration-safety] Migration generated. Before applying it: run ${CHECK} to flag destructive DDL (DROP, ALTER COLUMN ... TYPE, SET NOT NULL), read the new SQL file, and apply it with drizzle-kit migrate (never push) outside dev." + fi +fi +exit 0 diff --git a/.claude/hooks/prod-db-guard.sh b/.claude/hooks/prod-db-guard.sh new file mode 100755 index 0000000..d697b8c --- /dev/null +++ b/.claude/hooks/prod-db-guard.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# prod-db-guard.sh — PreToolUse hook (matcher: Bash) +# +# Blocks Bash commands that would change a production database outside the +# migration workflow: +# - `drizzle-kit push` (or `pnpm db:push`) when the same command references +# production: --env production, PROD_DATABASE_URL, NODE_ENV=production, +# or a database URL whose host/path contains "prod" +# - psql / `pnpm db:*` / drizzle-kit invocations that reference production +# AND contain DROP DATABASE, DROP SCHEMA, or TRUNCATE +# - `drizzle-kit drop` (rewrites migration history) — always +# +# Everything else is allowed. A plain `drizzle-kit push` in dev passes; the +# PostToolUse migration-safety-reminder.sh nudges instead of blocking. +# +# Contract: JSON on stdin (see lib/hook-input.sh). Exit 2 blocks the tool call +# and the one-line stderr message is shown to Claude. Exit 0 allows it. +# Fails open on empty/malformed stdin or unexpected errors. + +set -u +set -f +trap 'exit 0' ERR + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -f "$HOOK_DIR/../../scripts/lib/common.sh" ]]; then + # shellcheck source=../../scripts/lib/common.sh + source "$HOOK_DIR/../../scripts/lib/common.sh" +fi +# shellcheck source=lib/hook-input.sh +source "$HOOK_DIR/lib/hook-input.sh" +# shellcheck source=lib/bash-cmd.sh +source "$HOOK_DIR/lib/bash-cmd.sh" + +hook_skip_requested && exit 0 +[[ "$(hook_tool_name)" == "Bash" ]] || exit 0 +CMD="$(hook_tool_command)" +[[ -n "$CMD" ]] || exit 0 + +# --- Does the command reference a production database? ----------------------- + +PROD_RE='--env[= ]+prod(uction)?|(PROD|PRODUCTION)_DATABASE_URL|DATABASE_URL_PROD(UCTION)?|NODE_ENV=production|(postgres(ql)?|pg|mysql)://[^[:space:]"]*prod[^[:space:]"]*' +PROD_REF="$(printf '%s' "$CMD" | grep -oE -- "$PROD_RE" 2>/dev/null | head -1)" || PROD_REF="" + +DESTRUCTIVE_RE='DROP[[:space:]]+(DATABASE|SCHEMA)|(^|[^[:alnum:]_])TRUNCATE([^[:alnum:]_]|$)' + +# --- Walk every simple command in the call ----------------------------------- + +BLOCK="" +hook_split_commands "$CMD" +for seg in ${HOOK_SEGMENTS[@]+"${HOOK_SEGMENTS[@]}"}; do + [[ -n "${seg//[[:space:]]/}" ]] || continue + hook_tokenize "$seg" + hook_strip_wrappers + argv=(${HOOK_ARGV[@]+"${HOOK_ARGV[@]}"}) + (( ${#argv[@]} >= 1 )) || continue + + # Identify the database tool in this segment and its subcommand. + tool="" + sub="" + i=0 + while (( i < ${#argv[@]} )); do + t="${argv[i]##*/}" + case "$t" in + drizzle-kit) + tool="drizzle-kit" + sub="${argv[i+1]:-}" + break + ;; + psql|pgcli) + tool="$t" + break + ;; + db:*) + tool="pnpm $t" + sub="${t#db:}" + break + ;; + esac + i=$((i + 1)) + done + [[ -n "$tool" ]] || continue + + case "$sub" in + drop|drop:*) + BLOCK="$tool $sub rewrites migration history (deletes a migration and edits the journal). Remove the migration file and its journal entry by hand, and never for a migration that has been applied" + break + ;; + push|push:*) + if [[ -n "$PROD_REF" ]]; then + BLOCK="$tool push against a production target ($PROD_REF). push is dev-only; production changes go through drizzle-kit generate, review, then drizzle-kit migrate" + break + fi + ;; + esac + + if [[ -n "$PROD_REF" ]]; then + hit="$(printf '%s' "$seg" | grep -oiE -- "$DESTRUCTIVE_RE" 2>/dev/null | head -1 | tr -s '[:space:]' ' ')" || hit="" + if [[ -n "$hit" ]]; then + hit="${hit#"${hit%%[![:space:]]*}"}" + hit="${hit%"${hit##*[![:space:]]}"}" + hit="$(printf '%s' "$hit" | tr -d '"();,')" + BLOCK="destructive SQL ($hit) in a $tool command that references production ($PROD_REF). Put it in a reviewed migration instead" + break + fi + fi +done + +if [[ -n "$BLOCK" ]]; then + echo "[prod-db-guard] Blocked: $BLOCK (bypass: run it yourself outside Claude Code, or launch Claude with NERVA_SKIP_HOOKS=1)." >&2 + exit 2 +fi +exit 0 diff --git a/.claude/hooks/quality-gate-reminder.sh b/.claude/hooks/quality-gate-reminder.sh new file mode 100755 index 0000000..5e86438 --- /dev/null +++ b/.claude/hooks/quality-gate-reminder.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# quality-gate-reminder.sh — PostToolUse hook (matcher: Bash) +# +# When a vitest run reports that every test passed ("Tests N passed" with no +# "failed"), remind to run the rest of the quality gate: type check, security +# scan, and the coverage threshold from tdd.coverageThreshold. +# +# Contract: JSON on stdin (see lib/hook-input.sh). Always exits 0. Stdout is +# surfaced to Claude as a reminder; no output means silent. + +set -u +trap 'exit 0' ERR + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -f "$HOOK_DIR/../../scripts/lib/common.sh" ]]; then + # shellcheck source=../../scripts/lib/common.sh + source "$HOOK_DIR/../../scripts/lib/common.sh" +fi +# shellcheck source=lib/hook-input.sh +source "$HOOK_DIR/lib/hook-input.sh" + +hook_skip_requested && exit 0 +CMD="$(hook_tool_command)" +[[ -n "$CMD" ]] || exit 0 +printf '%s' "$CMD" | grep -qE 'vitest|run-tests\.sh|(pnpm|npm|yarn|bun)([[:space:]]+run)?[[:space:]]+test' || exit 0 + +OUT="$(hook_tool_output)" +[[ -n "$OUT" ]] || exit 0 +printf '%s' "$OUT" | grep -qE 'Tests[[:space:]]+[0-9]+ passed' || exit 0 +if printf '%s' "$OUT" | grep -qi 'failed'; then + exit 0 +fi + +NUM_RE='^[0-9]+(\.[0-9]+)?$' +THRESHOLD="$(common_config_get tdd.coverageThreshold 80)" +[[ "$THRESHOLD" =~ $NUM_RE ]] || THRESHOLD=80 + +echo "[quality-gate] All tests passed. Run the quality gate before calling this done: ./scripts/check-types.sh && ./scripts/security-scan.sh, then confirm coverage is at least ${THRESHOLD}% (pnpm vitest run --coverage; tdd.coverageThreshold in .claude/pipeline.config.json)." +exit 0 diff --git a/.claude/hooks/secret-guard.sh b/.claude/hooks/secret-guard.sh new file mode 100755 index 0000000..72711a1 --- /dev/null +++ b/.claude/hooks/secret-guard.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# secret-guard.sh — PreToolUse hook (matcher: Bash) +# +# Blocks Bash commands that would put secrets into git: +# - `git add` / `git stage` of .env, .env.* (except *.example), .dev.vars, +# .dev.vars.*, *.pem, *.key, *.p12, credentials.json +# - any `git add -f` / `git add --force` (it bypasses .gitignore) +# - `git commit -a` / `-am` / `--all` while such a file is already tracked +# +# Contract: JSON on stdin (see lib/hook-input.sh). Exit 2 blocks the tool call +# and the one-line stderr message is shown to Claude. Exit 0 allows it. +# +# Fails open: empty or malformed stdin, a non-Bash tool, or any unexpected +# runtime error exits 0. The only blocking exit is the explicit `exit 2`. +# +# Known limits (.gitignore is the real backstop): +# - `bash -c "git add .env"` and `eval` bodies are not inspected. +# - `git add .` / `git add -A` are allowed; .gitignore must cover secrets. +# - Only tracked files visible from the project root are checked for -a. + +set -u +set -f +trap 'exit 0' ERR + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -f "$HOOK_DIR/../../scripts/lib/common.sh" ]]; then + # shellcheck source=../../scripts/lib/common.sh + source "$HOOK_DIR/../../scripts/lib/common.sh" +fi +# shellcheck source=lib/hook-input.sh +source "$HOOK_DIR/lib/hook-input.sh" +# shellcheck source=lib/bash-cmd.sh +source "$HOOK_DIR/lib/bash-cmd.sh" + +hook_skip_requested && exit 0 +[[ "$(hook_tool_name)" == "Bash" ]] || exit 0 +CMD="$(hook_tool_command)" +[[ -n "$CMD" ]] || exit 0 + +# --- Secret file patterns ---------------------------------------------------- + +# is_secret_path → 0 when the basename looks like a secret file. +is_secret_path() { + local base="${1##*/}" + case "$base" in + .env.example|.env.*.example|.dev.vars.example|.dev.vars.*.example) return 1 ;; + .env|.env.*|.dev.vars|.dev.vars.*|*.pem|*.key|*.p12|credentials.json) return 0 ;; + esac + return 1 +} + +# tracked_secret → prints the first tracked secret file (if any). +tracked_secret() { + local f + while IFS= read -r f; do + [[ -n "$f" ]] || continue + if is_secret_path "$f"; then + printf '%s' "$f" + return 0 + fi + done < <(git ls-files -- '.env' '.env.*' '*/.env' '*/.env.*' '.dev.vars*' '*/.dev.vars*' \ + '*.pem' '*.key' '*.p12' 'credentials.json' '*/credentials.json' 2>/dev/null || true) + return 0 +} + +# --- Per-subcommand checks (read ARGS, set BLOCK) ---------------------------- + +BLOCK="" +ARGS=() + +check_add() { + local tok dd=0 + for tok in ${ARGS[@]+"${ARGS[@]}"}; do + if (( dd == 0 )); then + case "$tok" in + --) dd=1; continue ;; + -f|--force) BLOCK="a forced add ($tok), which bypasses .gitignore"; return 0 ;; + --*) continue ;; + -*f*) BLOCK="a forced add ($tok), which bypasses .gitignore"; return 0 ;; + -*) continue ;; + esac + fi + if is_secret_path "$tok"; then + BLOCK="a secret file ($tok)" + return 0 + fi + done + return 0 +} + +check_commit() { + local tok all=0 tracked="" + local cluster_re='^-[A-Za-z]+$' + for tok in ${ARGS[@]+"${ARGS[@]}"}; do + case "$tok" in + --) break ;; + -a|--all) all=1; break ;; + --*) continue ;; + esac + if [[ "$tok" =~ $cluster_re && "$tok" == *a* ]]; then + all=1 + break + fi + done + (( all )) || return 0 + tracked="$(tracked_secret)" + if [[ -n "$tracked" ]]; then + BLOCK="every tracked change via commit -a while a secret file is tracked ($tracked); untrack it first with: git rm --cached $tracked" + fi + return 0 +} + +# --- Walk every simple command in the call ----------------------------------- + +hook_split_commands "$CMD" +for seg in ${HOOK_SEGMENTS[@]+"${HOOK_SEGMENTS[@]}"}; do + [[ -n "${seg//[[:space:]]/}" ]] || continue + hook_tokenize "$seg" + hook_strip_wrappers + argv=(${HOOK_ARGV[@]+"${HOOK_ARGV[@]}"}) + (( ${#argv[@]} >= 2 )) || continue + [[ "${argv[0]##*/}" == "git" ]] || continue + + # Skip git's global options to find the subcommand (git -C api add .env). + idx=1 + while (( idx < ${#argv[@]} )); do + case "${argv[idx]}" in + -C|-c|--git-dir|--work-tree|--namespace|--exec-path) idx=$((idx + 2)) ;; + -*) idx=$((idx + 1)) ;; + *) break ;; + esac + done + (( idx < ${#argv[@]} )) || continue + + sub="${argv[idx]}" + ARGS=(${argv[@]+"${argv[@]:idx+1}"}) + case "$sub" in + add|stage) check_add ;; + commit) check_commit ;; + esac + [[ -z "$BLOCK" ]] || break +done + +if [[ -n "$BLOCK" ]]; then + echo "[secret-guard] Blocked: this command would stage $BLOCK. Secrets stay out of git: keep them in .gitignore and commit a .env.example instead (bypass: run it yourself outside Claude Code, or launch Claude with NERVA_SKIP_HOOKS=1)." >&2 + exit 2 +fi +exit 0 diff --git a/.claude/pipeline.config.json b/.claude/pipeline.config.json index 1b7b84f..67c23f6 100644 --- a/.claude/pipeline.config.json +++ b/.claude/pipeline.config.json @@ -1,5 +1,5 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", + "$schema": "./pipeline.config.schema.json", "description": "Configuration for the OpenAPI-to-Working-API autonomous build pipeline", "version": "1.0.0", "tdd": { diff --git a/.claude/pipeline.config.schema.json b/.claude/pipeline.config.schema.json new file mode 100644 index 0000000..cecb6be --- /dev/null +++ b/.claude/pipeline.config.schema.json @@ -0,0 +1,510 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/PMDevSolutions/Nerva/blob/main/.claude/pipeline.config.schema.json", + "title": "Nerva Pipeline Configuration", + "description": "Schema for .claude/pipeline.config.json — controls TDD enforcement, database defaults, auth, testing, quality gates, deployment targets, security, API conventions, reporting, and orchestration for the OpenAPI-to-Working-API pipeline.", + "type": "object", + "required": ["version"], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema reference for IDE validation and autocompletion. Should point at ./pipeline.config.schema.json." + }, + "description": { + "type": "string", + "description": "Human-readable summary of what this config controls." + }, + "version": { + "$ref": "#/$defs/semver", + "description": "Semantic version of the config document." + }, + + "tdd": { + "type": "object", + "additionalProperties": false, + "description": "Test-driven development enforcement for the build pipeline (Phase 2 hard gate).", + "properties": { + "enforced": { "type": "boolean", "description": "Hard-gate Phase 3 (route generation) on Phase 2 (failing tests) completion." }, + "redPhaseRequired": { "type": "boolean", "description": "Tests must be confirmed failing (RED) before handlers are written." }, + "greenPhaseRequired": { "type": "boolean", "description": "Tests must pass (GREEN) before the phase is considered complete." }, + "refactorPhaseOptional": { "type": "boolean", "description": "Whether the refactor step may be skipped after GREEN." }, + "coverageThreshold": { "$ref": "#/$defs/percentage", "description": "Minimum line coverage percentage the TDD phase targets." }, + "integrationTestRequired": { "type": "boolean", "description": "Every endpoint must have an integration test before its handler exists." }, + "contractTestRequired": { "type": "boolean", "description": "Responses must be validated against the OpenAPI spec." }, + "testExistenceGate": { "type": "boolean", "description": "Block route generation until a test file exists for every endpoint." } + } + }, + + "database": { + "type": "object", + "additionalProperties": false, + "description": "Database provider, ORM, connection pooling, and schema defaults.", + "properties": { + "defaultProvider": { "type": "string", "enum": ["postgresql"], "description": "Database engine the generated API targets." }, + "orm": { "type": "string", "enum": ["drizzle"], "description": "ORM used for schema, queries, and migrations." }, + "connectionPoolSize": { "$ref": "#/$defs/positiveInt", "description": "Maximum connections in the pool." }, + "connectionPoolMin": { "type": "integer", "minimum": 0, "description": "Minimum idle connections kept open in the pool." }, + "idleTimeoutMs": { "$ref": "#/$defs/positiveInt", "description": "Milliseconds an idle connection stays open before being closed." }, + "migrationSafety": { "type": "boolean", "description": "Review generated migrations for destructive operations before applying." }, + "blockDestructiveMigrations": { "type": "boolean", "description": "Refuse to apply migrations that drop tables or columns." }, + "softDeleteDefault": { "type": "boolean", "description": "Generate a deletedAt column and soft-delete semantics by default." }, + "timestampsDefault": { "type": "boolean", "description": "Add createdAt/updatedAt columns to every table by default." }, + "uuidPrimaryKeys": { "type": "boolean", "description": "Use UUID primary keys for generated tables." }, + "serialPrimaryKeys": { "type": "boolean", "description": "Use serial (auto-increment integer) primary keys for generated tables." } + } + }, + + "auth": { + "type": "object", + "additionalProperties": false, + "description": "Authentication and authorization defaults applied by the auth-middleware phase.", + "properties": { + "defaultStrategy": { "type": "string", "enum": ["jwt", "oauth2", "api-key", "session"], "description": "Authentication scheme generated when the OpenAPI spec does not specify one." }, + "jwtAlgorithm": { "type": "string", "enum": ["HS256", "HS384", "HS512", "RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "EdDSA"], "description": "JOSE algorithm used to sign JWTs." }, + "tokenExpiry": { "$ref": "#/$defs/duration", "description": "Access token lifetime (e.g. \"15m\")." }, + "refreshTokenExpiry": { "$ref": "#/$defs/duration", "description": "Refresh token lifetime (e.g. \"7d\")." }, + "bcryptRounds": { "type": "integer", "minimum": 4, "maximum": 31, "description": "bcrypt cost factor for password hashing." }, + "apiKeyLength": { "type": "integer", "minimum": 16, "description": "Byte length of generated API keys." }, + "rateLimitPerUser": { "$ref": "#/$defs/positiveInt", "description": "Requests per window allowed per authenticated user." }, + "sessionStrategy": { "type": "string", "enum": ["stateless", "stateful"], "description": "Whether sessions are stored server-side (stateful) or encoded in the token (stateless)." }, + "rbacEnabled": { "type": "boolean", "description": "Generate role-based access control middleware." } + } + }, + + "testing": { + "type": "object", + "additionalProperties": false, + "description": "Test framework, timeouts, load testing, and test database strategy.", + "properties": { + "framework": { "type": "string", "enum": ["vitest"], "description": "Unit and integration test runner." }, + "httpClient": { "type": "string", "enum": ["supertest", "hono-testing"], "description": "HTTP client used by integration tests." }, + "integrationTimeout": { "$ref": "#/$defs/positiveInt", "description": "Per-test timeout in milliseconds for integration tests." }, + "unitTimeout": { "$ref": "#/$defs/positiveInt", "description": "Per-test timeout in milliseconds for unit tests." }, + "contractTestEnabled": { "type": "boolean", "description": "Run contract tests that validate responses against the OpenAPI spec." }, + "loadTestEnabled": { "type": "boolean", "description": "Generate and run k6 load tests." }, + "loadTestVUs": { "$ref": "#/$defs/positiveInt", "description": "Number of k6 virtual users." }, + "loadTestDuration": { "$ref": "#/$defs/duration", "description": "k6 steady-state duration (e.g. \"30s\")." }, + "loadTestRampUp": { "$ref": "#/$defs/duration", "description": "k6 ramp-up duration (e.g. \"10s\")." }, + "loadTestThresholds": { + "type": "object", + "additionalProperties": false, + "description": "k6 pass/fail thresholds.", + "properties": { + "http_req_duration_p95": { "$ref": "#/$defs/positiveInt", "description": "Maximum p95 request duration in milliseconds." }, + "http_req_duration_p99": { "$ref": "#/$defs/positiveInt", "description": "Maximum p99 request duration in milliseconds." }, + "http_req_failed_rate": { "$ref": "#/$defs/ratio", "description": "Maximum fraction of failed requests (0-1)." } + } + }, + "testDatabase": { + "type": "object", + "additionalProperties": false, + "description": "How integration tests obtain an isolated database.", + "properties": { + "strategy": { "$ref": "#/$defs/testDatabaseStrategy", "description": "Primary isolation strategy." }, + "alternativeStrategy": { "$ref": "#/$defs/testDatabaseStrategy", "description": "Fallback isolation strategy when the primary is unavailable." }, + "seedBeforeTests": { "type": "boolean", "description": "Run the seed script before the integration suite." } + } + } + } + }, + + "qualityGate": { + "type": "object", + "additionalProperties": false, + "description": "Criteria that must pass in Phase 7 before deployment config and the report are produced.", + "properties": { + "testsRequired": { "type": "boolean", "description": "All tests must pass." }, + "typescriptRequired": { "type": "boolean", "description": "tsc --noEmit must succeed under strict mode." }, + "buildRequired": { "type": "boolean", "description": "The production build must succeed." }, + "securityAuditRequired": { "type": "boolean", "description": "security-scan.sh must report no vulnerabilities at or above the configured level." }, + "coverageThreshold": { "$ref": "#/$defs/percentage", "description": "Minimum line coverage percentage enforced at the gate." }, + "maxFixAttempts": { "type": "integer", "minimum": 0, "description": "How many automatic fix-and-retry loops run before the gate fails." }, + "eslintRequired": { "type": "boolean", "description": "ESLint must report zero errors." }, + "noAnyTypes": { "type": "boolean", "description": "Fail the gate if any `any` types are present in generated code." } + } + }, + + "deployment": { + "type": "object", + "additionalProperties": false, + "description": "Supported deployment targets and their per-target defaults.", + "properties": { + "targets": { + "type": "array", + "uniqueItems": true, + "minItems": 1, + "items": { "$ref": "#/$defs/deploymentTarget" }, + "description": "Deployment targets the pipeline can generate configuration for." + }, + "defaultTarget": { "$ref": "#/$defs/deploymentTarget", "description": "Target used when build-spec.json does not name one. Must appear in targets." }, + "cloudflareWorkers": { + "type": "object", + "additionalProperties": false, + "description": "Defaults for the cloudflare-workers target (wrangler.toml).", + "properties": { + "compatibilityDate": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$", "description": "Workers runtime compatibility date (YYYY-MM-DD)." }, + "nodeCompat": { "type": "boolean", "description": "Enable the nodejs_compat compatibility flag." }, + "hyperdrive": { "type": "boolean", "description": "Bind a Hyperdrive connection pool for PostgreSQL." }, + "kv": { "type": "boolean", "description": "Bind a Workers KV namespace." }, + "d1": { "type": "boolean", "description": "Bind a D1 database." }, + "r2": { "type": "boolean", "description": "Bind an R2 bucket." }, + "environments": { "$ref": "#/$defs/environmentList", "description": "Wrangler environments to generate." } + } + }, + "nodeDocker": { + "type": "object", + "additionalProperties": false, + "description": "Defaults for the node-docker target (Dockerfile, docker-compose).", + "properties": { + "nodeVersion": { "type": "string", "minLength": 1, "description": "Docker image tag for the node base image (e.g. \"22-alpine\")." }, + "port": { "$ref": "#/$defs/port", "description": "Container port the server listens on." }, + "healthCheckPath": { "$ref": "#/$defs/urlPath", "description": "HTTP path polled by the container health check." }, + "processManager": { "type": "string", "enum": ["node", "pm2"], "description": "Process supervisor used inside the container." }, + "multiStage": { "type": "boolean", "description": "Generate a multi-stage Dockerfile (build stage + runtime stage)." } + } + }, + "awsLambda": { + "type": "object", + "additionalProperties": false, + "description": "Defaults for the aws-lambda target (SAM template).", + "properties": { + "runtime": { "type": "string", "pattern": "^nodejs\\d+\\.x$", "description": "Lambda runtime identifier (e.g. \"nodejs22.x\")." }, + "architecture": { "type": "string", "enum": ["arm64", "x86_64"], "description": "Lambda CPU architecture." }, + "memorySizeMb": { "type": "integer", "minimum": 128, "maximum": 10240, "description": "Function memory in MB." }, + "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 900, "description": "Function timeout in seconds (API Gateway caps at 29-30s)." }, + "apiGatewayType": { "type": "string", "enum": ["http-api", "rest-api"], "description": "API Gateway flavour fronting the function." }, + "bundler": { "type": "string", "enum": ["esbuild"], "description": "Bundler used to package the handler." }, + "framework": { "type": "string", "enum": ["sam"], "description": "Infrastructure-as-code tool used to deploy." }, + "provisionedConcurrency": { "type": "integer", "minimum": 0, "description": "Provisioned concurrency units (0 disables)." } + } + }, + "railway": { + "type": "object", + "additionalProperties": false, + "description": "Defaults for the railway target (railway.toml, nixpacks.toml).", + "properties": { + "builder": { "$ref": "#/$defs/paasBuilder", "description": "Build strategy Railway uses." }, + "healthcheckPath": { "$ref": "#/$defs/urlPath", "description": "HTTP path Railway polls before routing traffic." }, + "healthcheckTimeoutSeconds": { "$ref": "#/$defs/positiveInt", "description": "Seconds to wait for the health check to pass." }, + "restartPolicy": { "type": "string", "enum": ["on-failure", "always", "never"], "description": "Restart policy for crashed containers." }, + "restartPolicyMaxRetries": { "type": "integer", "minimum": 0, "description": "Maximum restart attempts under the on-failure policy." }, + "managedPostgres": { "type": "boolean", "description": "Provision a Railway-managed PostgreSQL service." }, + "autoDeployFromGitHub": { "type": "boolean", "description": "Deploy automatically on push to the connected GitHub branch." } + } + }, + "fly": { + "type": "object", + "additionalProperties": false, + "description": "Defaults for the fly target (fly.toml).", + "properties": { + "builder": { "$ref": "#/$defs/paasBuilder", "description": "Build strategy Fly uses." }, + "internalPort": { "$ref": "#/$defs/port", "description": "Port the app listens on inside the Machine." }, + "healthcheckPath": { "$ref": "#/$defs/urlPath", "description": "HTTP path Fly polls for Machine health." }, + "autoStopMachines": { "type": "string", "enum": ["off", "stop", "suspend"], "description": "What Fly does with idle Machines." }, + "autoStartMachines": { "type": "boolean", "description": "Start stopped Machines on incoming traffic." }, + "minMachinesRunning": { "type": "integer", "minimum": 0, "description": "Machines kept running regardless of traffic." }, + "maxMachines": { "$ref": "#/$defs/positiveInt", "description": "Upper bound on Machines under autoscaling." }, + "managedPostgres": { "type": "boolean", "description": "Attach a Fly Managed Postgres cluster." }, + "autoDeployFromGitHub": { "type": "boolean", "description": "Deploy automatically from GitHub Actions on push." } + } + }, + "cicd": { + "type": "object", + "additionalProperties": false, + "description": "Continuous integration and delivery workflow generation.", + "properties": { + "provider": { "type": "string", "enum": ["github-actions"], "description": "CI/CD platform the workflow is generated for." }, + "runTests": { "type": "boolean", "description": "Run the test suite in CI." }, + "runTypeCheck": { "type": "boolean", "description": "Run tsc --noEmit in CI." }, + "runSecurityAudit": { "type": "boolean", "description": "Run the dependency security audit in CI." }, + "runBuild": { "type": "boolean", "description": "Run the production build in CI." }, + "autoDeployStaging": { "type": "boolean", "description": "Deploy to staging automatically when CI passes on the main branch." }, + "autoDeployProduction": { "type": "boolean", "description": "Deploy to production automatically (usually false; use a manual approval)." } + } + } + } + }, + + "security": { + "type": "object", + "additionalProperties": false, + "description": "Security middleware defaults and audit policy.", + "properties": { + "rateLimiting": { + "type": "object", + "additionalProperties": false, + "description": "Global rate limiting middleware.", + "properties": { + "enabled": { "type": "boolean", "description": "Apply rate limiting to every route." }, + "requests": { "$ref": "#/$defs/positiveInt", "description": "Requests allowed per window." }, + "window": { "$ref": "#/$defs/duration", "description": "Window length (e.g. \"1m\")." }, + "strategy": { "type": "string", "enum": ["sliding-window", "fixed-window", "token-bucket"], "description": "Rate limiting algorithm." } + } + }, + "cors": { + "type": "object", + "additionalProperties": false, + "description": "Cross-origin resource sharing middleware.", + "properties": { + "enabled": { "type": "boolean", "description": "Emit CORS headers." }, + "origins": { "type": "array", "items": { "type": "string", "minLength": 1 }, "description": "Allowed origins. \"*\" is acceptable in development only." }, + "methods": { "type": "array", "uniqueItems": true, "items": { "type": "string", "enum": ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] }, "description": "Allowed HTTP methods." }, + "allowHeaders": { "type": "array", "items": { "type": "string", "minLength": 1 }, "description": "Allowed request headers." }, + "maxAge": { "type": "integer", "minimum": 0, "description": "Seconds a preflight response may be cached." } + } + }, + "headers": { + "type": "object", + "additionalProperties": false, + "description": "Helmet-equivalent security response headers.", + "properties": { + "hsts": { "type": "boolean", "description": "Send Strict-Transport-Security." }, + "noSniff": { "type": "boolean", "description": "Send X-Content-Type-Options: nosniff." }, + "xssProtection": { "type": "boolean", "description": "Send X-XSS-Protection." }, + "frameOptions": { "type": "string", "enum": ["DENY", "SAMEORIGIN"], "description": "Value for X-Frame-Options." } + } + }, + "audit": { + "type": "object", + "additionalProperties": false, + "description": "Dependency vulnerability audit policy (security-scan.sh).", + "properties": { + "level": { "type": "string", "enum": ["low", "moderate", "high", "critical"], "description": "Minimum severity that fails the audit." }, + "failOnVulnerability": { "type": "boolean", "description": "Fail the quality gate when a vulnerability at or above level is found." }, + "checkLockfile": { "type": "boolean", "description": "Verify the lockfile is present and in sync." } + } + }, + "inputValidation": { + "type": "object", + "additionalProperties": false, + "description": "Request size and sanitization limits.", + "properties": { + "maxBodySizeKb": { "$ref": "#/$defs/positiveInt", "description": "Maximum request body size in kilobytes." }, + "maxUrlLengthChars": { "$ref": "#/$defs/positiveInt", "description": "Maximum URL length in characters." }, + "sanitizeStrings": { "type": "boolean", "description": "Strip control characters and trim string inputs." } + } + } + } + }, + + "api": { + "type": "object", + "additionalProperties": false, + "description": "API response conventions shared by generated routes.", + "properties": { + "pagination": { + "type": "object", + "additionalProperties": false, + "description": "List endpoint pagination defaults.", + "properties": { + "defaultLimit": { "$ref": "#/$defs/positiveInt", "description": "Page size when the client omits limit." }, + "maxLimit": { "$ref": "#/$defs/positiveInt", "description": "Largest page size a client may request." }, + "strategy": { "type": "string", "enum": ["offset", "cursor", "keyset"], "description": "Default pagination style." }, + "cursorPaginationAvailable": { "type": "boolean", "description": "Also expose cursor pagination on list endpoints." } + } + }, + "responseFormat": { "type": "string", "enum": ["json"], "description": "Wire format for responses." }, + "envelope": { + "type": "object", + "additionalProperties": false, + "description": "Field names of the standard response envelope.", + "properties": { + "dataField": { "type": "string", "minLength": 1, "description": "Key holding the payload." }, + "metaField": { "type": "string", "minLength": 1, "description": "Key holding pagination and request metadata." }, + "errorField": { "type": "string", "minLength": 1, "description": "Key holding structured error details." } + } + }, + "versioning": { + "type": "object", + "additionalProperties": false, + "description": "API versioning strategy.", + "properties": { + "strategy": { "type": "string", "enum": ["url-prefix", "header", "query", "none"], "description": "Where the API version is carried." }, + "currentVersion": { "type": "string", "pattern": "^v\\d+$", "description": "Current version label (e.g. \"v1\")." }, + "deprecationHeaderEnabled": { "type": "boolean", "description": "Send Deprecation/Sunset headers on deprecated versions." } + } + }, + "filtering": { + "type": "object", + "additionalProperties": false, + "description": "Query-string filtering on list endpoints.", + "properties": { + "enabled": { "type": "boolean", "description": "Generate filter parsing for list endpoints." }, + "operators": { "type": "array", "uniqueItems": true, "items": { "type": "string", "enum": ["eq", "neq", "gt", "gte", "lt", "lte", "like", "ilike", "in", "nin", "between", "isnull"] }, "description": "Filter operators clients may use." } + } + }, + "sorting": { + "type": "object", + "additionalProperties": false, + "description": "Sorting on list endpoints.", + "properties": { + "enabled": { "type": "boolean", "description": "Generate sort parsing for list endpoints." }, + "defaultField": { "type": "string", "minLength": 1, "description": "Field used when the client omits a sort." }, + "defaultOrder": { "type": "string", "enum": ["asc", "desc"], "description": "Direction used when the client omits one." }, + "maxFields": { "$ref": "#/$defs/positiveInt", "description": "Maximum number of sort fields per request." } + } + }, + "healthCheck": { + "type": "object", + "additionalProperties": false, + "description": "Health endpoint configuration.", + "properties": { + "path": { "$ref": "#/$defs/urlPath", "description": "Route path of the health endpoint." }, + "includeDbStatus": { "type": "boolean", "description": "Report database connectivity in the health response." }, + "includeVersion": { "type": "boolean", "description": "Report the application version in the health response." } + } + } + } + }, + + "reporting": { + "type": "object", + "additionalProperties": false, + "description": "Where and what the Phase 9 build report writes.", + "properties": { + "outputDir": { "type": "string", "minLength": 1, "description": "Directory (relative to the project root) that receives report files." }, + "buildReportFile": { "type": "string", "minLength": 1, "description": "Filename of the main build report." }, + "testReportFile": { "type": "string", "minLength": 1, "description": "Filename of the test results report." }, + "loadTestReportFile": { "type": "string", "minLength": 1, "description": "Filename of the k6 load test report." }, + "securityReportFile": { "type": "string", "minLength": 1, "description": "Filename of the security audit report." }, + "includeEndpointInventory": { "type": "boolean", "description": "List every generated endpoint in the build report." }, + "includeSchemaDiagram": { "type": "boolean", "description": "Embed an entity-relationship diagram in the build report." } + } + }, + + "orchestration": { + "type": "object", + "additionalProperties": false, + "description": "Phase dependency graph and concurrency for the autonomous pipeline.", + "properties": { + "enabled": { "type": "boolean", "description": "Run independent phases concurrently according to the dependency graph." }, + "maxConcurrent": { "$ref": "#/$defs/positiveInt", "description": "Maximum phases running at once." }, + "phases": { + "type": "object", + "description": "Pipeline phases keyed by name. Names must be kebab-case.", + "minProperties": 1, + "propertyNames": { "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$" }, + "additionalProperties": { "$ref": "#/$defs/phase" } + }, + "qualityGateSubtasks": { + "type": "object", + "description": "Independent checks run in parallel inside the quality-gate phase, keyed by name.", + "propertyNames": { "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$" }, + "additionalProperties": { "$ref": "#/$defs/subtask" } + }, + "reporting": { + "type": "object", + "additionalProperties": false, + "description": "Progress output while phases run.", + "properties": { + "showStreamingResults": { "type": "boolean", "description": "Print each phase result as it completes." }, + "showBatchSummary": { "type": "boolean", "description": "Print a summary after each concurrent batch." }, + "showSpeedupEstimate": { "type": "boolean", "description": "Estimate wall-clock savings versus sequential execution." }, + "includeTimeline": { "type": "boolean", "description": "Include a phase timeline in the final report." } + } + } + } + } + }, + + "$defs": { + "semver": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$", + "description": "Semantic version string (MAJOR.MINOR.PATCH with optional pre-release and build metadata)." + }, + "percentage": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Percentage between 0 and 100." + }, + "ratio": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Fraction between 0 and 1 (e.g. 0.01 = 1%)." + }, + "positiveInt": { + "type": "integer", + "minimum": 1, + "description": "Whole number greater than zero." + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": "TCP port number." + }, + "duration": { + "type": "string", + "pattern": "^\\d+(ms|s|m|h|d)$", + "description": "Duration string: integer followed by ms, s, m, h, or d (e.g. \"30s\", \"15m\", \"7d\")." + }, + "urlPath": { + "type": "string", + "pattern": "^/", + "description": "Absolute URL path beginning with a slash." + }, + "environmentList": { + "type": "array", + "uniqueItems": true, + "minItems": 1, + "items": { "type": "string", "enum": ["development", "staging", "production"] }, + "description": "Named deployment environments." + }, + "deploymentTarget": { + "type": "string", + "enum": ["cloudflare-workers", "node-docker", "aws-lambda", "railway", "fly"], + "description": "A deployment platform the pipeline can generate configuration for." + }, + "paasBuilder": { + "type": "string", + "enum": ["dockerfile", "nixpacks"], + "description": "Build strategy for PaaS targets." + }, + "testDatabaseStrategy": { + "type": "string", + "enum": ["transaction-rollback", "testcontainers", "pglite", "schema-per-worker"], + "description": "How each test run gets an isolated database." + }, + "resourceList": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*:[a-z][a-z0-9-]*$", + "description": "Resource identifier in category:name form (e.g. \"filesystem:schema\"). Two phases declaring the same resource cannot run concurrently." + }, + "description": "Exclusive resources a phase or subtask holds while running." + }, + "phase": { + "type": "object", + "additionalProperties": false, + "required": ["depends", "description"], + "description": "One pipeline phase in the orchestration graph.", + "properties": { + "depends": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$" }, + "description": "Phase names that must complete before this phase starts." + }, + "resources": { "$ref": "#/$defs/resourceList" }, + "blocking": { "type": "boolean", "description": "When true, failure of this phase fails the whole pipeline; when false it is reported and the pipeline continues." }, + "description": { "type": "string", "minLength": 1, "description": "What the phase does." } + } + }, + "subtask": { + "type": "object", + "additionalProperties": false, + "required": ["description"], + "description": "One independent check inside the quality gate.", + "properties": { + "resources": { "$ref": "#/$defs/resourceList" }, + "description": { "type": "string", "minLength": 1, "description": "What the subtask runs." } + } + } + } +} diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..c358805 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,43 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash .claude/hooks/secret-guard.sh", + "description": "Block git add of .env/.dev.vars/keys/credentials, forced adds, and commit -a while a secret is tracked" + }, + { + "type": "command", + "command": "bash .claude/hooks/prod-db-guard.sh", + "description": "Block drizzle-kit push and destructive SQL against production databases, and drizzle-kit drop" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash .claude/hooks/coverage-check.sh", + "description": "Compare vitest coverage output with tdd.coverageThreshold from pipeline.config.json" + }, + { + "type": "command", + "command": "bash .claude/hooks/migration-safety-reminder.sh", + "description": "After drizzle-kit generate, remind to run the destructive-DDL check; after drizzle-kit push, remind that push is dev-only" + }, + { + "type": "command", + "command": "bash .claude/hooks/quality-gate-reminder.sh", + "description": "After all vitest tests pass, remind to run check-types, security-scan, and the coverage threshold" + } + ] + } + ] + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7fee2f..acd0af8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,12 +21,27 @@ jobs: - name: Check shell script syntax run: | errors=0 - for f in scripts/*.sh; do + count=0 + for f in scripts/*.sh scripts/lib/*.sh .claude/hooks/*.sh .claude/hooks/lib/*.sh; do if [ -f "$f" ]; then + count=$((count + 1)) bash -n "$f" || { echo "FAIL: $f"; errors=$((errors + 1)); } fi done - echo "Checked $(ls scripts/*.sh 2>/dev/null | wc -l) scripts, $errors failed" + echo "Checked $count shell files, $errors failed" + exit $errors + + - name: Check JS script syntax + run: | + errors=0 + count=0 + for f in scripts/*.js scripts/lib/*.js; do + if [ -f "$f" ]; then + count=$((count + 1)) + node --check "$f" || { echo "FAIL: $f"; errors=$((errors + 1)); } + fi + done + echo "Checked $count JS files, $errors failed" exit $errors - name: Run ShellCheck @@ -35,10 +50,16 @@ jobs: scandir: './scripts' severity: warning + - name: Run ShellCheck on hooks + uses: ludeeus/action-shellcheck@master + with: + scandir: './.claude/hooks' + severity: warning + - name: Validate JSON configs run: | errors=0 - for f in .claude/pipeline.config.json package.json; do + for f in .claude/pipeline.config.json .claude/pipeline.config.schema.json .claude/settings.json package.json; do if [ -f "$f" ]; then python3 -m json.tool "$f" > /dev/null || { echo "FAIL: $f"; errors=$((errors + 1)); } fi @@ -82,6 +103,14 @@ jobs: "scripts/check-types.sh" "scripts/security-scan.sh" "scripts/generate-migration.sh" + "scripts/check-destructive-migrations.js" + "scripts/check-doc-counts.sh" + "scripts/check-prerequisites.sh" + "scripts/validate-pipeline-config.js" + "scripts/verify-all.sh" + "scripts/lib/common.sh" + ".claude/pipeline.config.schema.json" + ".claude/settings.json" ) for f in "${required_files[@]}"; do if [ -f "$f" ]; then @@ -178,6 +207,35 @@ jobs: echo "$errors template validation failures" exit $errors + # ── Script tests, config schema, doc-count drift (needs Node + deps) ───── + script-tests: + name: Script Tests + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Validate pipeline.config.json against schema + run: node scripts/validate-pipeline-config.js + + - name: Check doc counts match agents/skills/scripts on disk + run: bash scripts/check-doc-counts.sh + + - name: Prerequisites check (output contract smoke test) + run: bash scripts/check-prerequisites.sh || true + + - name: Run script and hook tests + run: pnpm test -- --reporter=verbose + node-compat: name: Node.js ${{ matrix.node-version }} Compatibility runs-on: ubuntu-latest diff --git a/.husky/pre-commit b/.husky/pre-commit index 433e16e..8f77146 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,13 +1,30 @@ -# Validate shell script syntax -for f in scripts/*.sh; do +# Validate shell script syntax (scripts, shared lib, Claude Code hooks) +for f in scripts/*.sh scripts/lib/*.sh .claude/hooks/*.sh .claude/hooks/lib/*.sh; do if [ -f "$f" ]; then bash -n "$f" || exit 1 fi done +# Validate JS script syntax +for f in scripts/*.js scripts/lib/*.js; do + if [ -f "$f" ]; then + node --check "$f" || exit 1 + fi +done + # Validate JSON configs -for f in .claude/pipeline.config.json package.json; do +for f in .claude/pipeline.config.json .claude/pipeline.config.schema.json .claude/settings.json package.json; do if [ -f "$f" ]; then node -e "JSON.parse(require('fs').readFileSync('$f','utf8'))" || exit 1 fi done + +# Validate pipeline.config.json against its schema (structural checks included) +if [ -f scripts/validate-pipeline-config.js ]; then + node scripts/validate-pipeline-config.js || exit 1 +fi + +# Flag drift between documented agent/skill/script counts and what is on disk +if [ -f scripts/check-doc-counts.sh ]; then + bash scripts/check-doc-counts.sh || exit 1 +fi diff --git a/CLAUDE.md b/CLAUDE.md index f5a03c6..bd0b5d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,9 @@ project-root/ │ │ ├── unit/ # Service unit tests │ │ └── fixtures/ # Test data factories │ └── package.json -├── scripts/ # Automation scripts +├── scripts/ # Automation scripts (see scripts/README.md) +│ ├── lib/ # Shared bash/JS helpers, pipeline.config.json reader +│ └── __tests__/ # Vitest tests for scripts and hooks (pnpm test) ├── templates/ # Starter configs │ ├── shared/ # ESLint, Prettier, TypeScript configs │ ├── cloudflare-workers/ # Wrangler config @@ -50,7 +52,10 @@ project-root/ │ ├── agents/ # 24 specialized agents │ ├── skills/ # 12 API development skills │ ├── commands/ # Slash commands +│ ├── hooks/ # Claude Code hooks (secret guard, prod-DB guard, reminders) +│ ├── settings.json # Hook wiring │ ├── pipeline.config.json +│ ├── pipeline.config.schema.json │ ├── CUSTOM-AGENTS-GUIDE.md │ └── PLUGINS-REFERENCE.md ├── CLAUDE.md # This file @@ -87,8 +92,21 @@ project-root/ # Generate typed API client (for Aurelius frontends) ./scripts/generate-client.sh + +# Run every local quality check (also: /verify-all, /ci) +./scripts/verify-all.sh + +# Framework integrity: config schema, doc-count drift, prerequisites, migration guard +node scripts/validate-pipeline-config.js +./scripts/check-doc-counts.sh +./scripts/check-prerequisites.sh +node scripts/check-destructive-migrations.js ``` +Scripts read thresholds and policies from `.claude/pipeline.config.json` +(coverage threshold, audit level, load-test VUs, destructive-migration +blocking). Full reference: `scripts/README.md`. + ## Development Commands ### Package Management (always use pnpm) @@ -139,7 +157,7 @@ pnpm drizzle-kit studio # Open Drizzle Studio (database GUI) ## Claude Code Architecture & Configuration -### Installed Plugins (5 Total) +### Installed Plugins (4 Total) - **episodic-memory** - Conversation search and memory - **commit-commands** - Git workflow automation @@ -200,6 +218,24 @@ Agents are invoked automatically based on task context. **Full catalog:** `.claude/skills/README.md` +### Claude Code Hooks + +`.claude/settings.json` wires five hooks from `.claude/hooks/` (stdin JSON contract): + +| Event | Hook | Effect | +|-------|------|--------| +| PreToolUse (Bash) | secret-guard | Blocks `git add` of `.env`, `.dev.vars`, keys, forced adds, and `commit -a` with a tracked secret | +| PreToolUse (Bash) | prod-db-guard | Blocks `drizzle-kit push` and DROP/TRUNCATE against production databases | +| PostToolUse (Bash) | coverage-check | Compares vitest coverage with `tdd.coverageThreshold` | +| PostToolUse (Bash) | migration-safety-reminder | After `drizzle-kit generate`, reminds to run the destructive-DDL check | +| PostToolUse (Bash) | quality-gate-reminder | After a green test run, reminds to run types, security, coverage | + +Bypass for a legitimate case with `NERVA_SKIP_HOOKS=1`. Docs: `docs/onboarding/hooks.md`. + +### Slash Commands + +`/build-from-schema`, `/build-from-conversation`, `/build-from-aurelius`, `/test`, `/lint`, `/verify-all` (all local checks), `/ci` (same, machine-readable, non-interactive). + --- ### Schema-to-API Pipeline @@ -479,14 +515,28 @@ gh issue create # Create issue ./scripts/load-test.sh # k6 load tests ./scripts/generate-openapi-docs.sh # OpenAPI documentation ./scripts/generate-client.sh # Typed API client for Aurelius +./scripts/verify-all.sh # All checks in one run (/verify-all, /ci) +pnpm test # Script + hook tests (scripts/__tests__) +``` + +**Framework Integrity:** +```bash +node scripts/validate-pipeline-config.js # pipeline.config.json vs schema + phase graph +./scripts/check-doc-counts.sh # Doc count claims vs agents/skills/scripts on disk +./scripts/check-prerequisites.sh # Required/optional tooling report ``` **Database:** ```bash -./scripts/generate-migration.sh # Generate Drizzle migration +./scripts/generate-migration.sh # Generate Drizzle migration (runs destructive-DDL guard) +node scripts/check-destructive-migrations.js # Scan migrations for DROP/TRUNCATE ./scripts/seed-database.sh # Seed database with test data pnpm drizzle-kit studio # Open database GUI ``` +Generated projects also get `pnpm db:check-destructive`, `pnpm db:check-drift` +(prod schema vs Drizzle schema, lenient) and `pnpm db:check-drift:strict`, plus +`schema-drift.yml` / `schema-applied.yml` workflows. See +`docs/api-development/migration-safety.md`. **Project Setup:** ```bash @@ -500,5 +550,5 @@ pnpm drizzle-kit studio # Open database GUI --- -**Last Updated:** 2026-07-07 -**Architecture:** 24 agents, 12 skills, 4 plugins + gh CLI, 9 scripts \ No newline at end of file +**Last Updated:** 2026-09-09 +**Architecture:** 24 agents, 12 skills, 4 plugins + gh CLI, 14 scripts, 7 commands, 5 hooks \ No newline at end of file diff --git a/README.md b/README.md index bdd67f9..b3165b9 100644 --- a/README.md +++ b/README.md @@ -107,13 +107,14 @@ project-root/ │ │ ├── unit/ # Service unit tests │ │ └── fixtures/ # Test data factories │ └── package.json -├── scripts/ # Automation scripts (9 total) +├── scripts/ # Automation scripts (14 total) ├── templates/ # Starter configs ├── docs/ # Documentation ├── .claude/ # Claude Code configuration │ ├── agents/ # 24 custom agents │ ├── skills/ # 12 development skills │ ├── commands/ # Slash commands +│ ├── hooks/ # Claude Code hooks (secret guard, prod-DB guard, reminders) │ └── pipeline.config.json ├── CLAUDE.md # Claude Code project instructions └── README.md # This file @@ -171,6 +172,11 @@ Full catalog: [`.claude/CUSTOM-AGENTS-GUIDE.md`](.claude/CUSTOM-AGENTS-GUIDE.md) ./scripts/load-test.sh --vus 50 --duration 30s # Load test ./scripts/generate-openapi-docs.sh # OpenAPI docs ./scripts/generate-client.sh --spec docs/openapi.yaml # Typed client +./scripts/verify-all.sh # Every local check in one run +node scripts/validate-pipeline-config.js # pipeline.config.json vs schema +./scripts/check-doc-counts.sh # Doc counts vs disk +./scripts/check-prerequisites.sh # Tooling report +node scripts/check-destructive-migrations.js # Destructive-DDL guard ``` ## Templates diff --git a/docs/api-development/README.md b/docs/api-development/README.md index 77910c3..611cb19 100644 --- a/docs/api-development/README.md +++ b/docs/api-development/README.md @@ -208,6 +208,8 @@ const result = await db.transaction(async (tx) => { }); ``` +See the [migration safety guide](./migration-safety.md) for the destructive-migration guard, the `-- nerva:allow-destructive:` allowlist marker, the production schema drift check, and the two GitHub Actions workflows generated projects ship with. + ## Testing Strategy ### Unit Tests (Services) diff --git a/docs/api-development/migration-safety.md b/docs/api-development/migration-safety.md new file mode 100644 index 0000000..6b59311 --- /dev/null +++ b/docs/api-development/migration-safety.md @@ -0,0 +1,95 @@ +# Migration Safety + +Two guards keep a Nerva project's database schema and its Drizzle declarations from silently diverging: + +- **Destructive migration guard** (`check-destructive-migrations`) — a static scan of committed `*.sql` migration files. No database needed. +- **Schema drift check** (`schema-drift.ts`) — compares what `src/db/schema.ts` declares with what the production database actually has. + +Both ship in every generated project (`api/scripts/check-destructive-migrations.mjs`, `api/src/db/schema-drift.ts`) together with two GitHub Actions workflows, and the framework repo runs the destructive guard itself from `scripts/generate-migration.sh`. + +## Destructive migration guard + +`drizzle-kit generate` diffs the schema against its snapshot. If a developer's local database (or the snapshot) has drifted, the generated migration can contain a "reconcile" plan — `DROP TABLE`, `DROP COLUMN`, `ALTER COLUMN ... DROP NOT NULL` — that deletes production data when applied. Nerva migrations are additive by policy, so any destructive DDL in a committed migration is treated as a mistake until a human says otherwise. + +The guard strips `--` and `/* */` comments (string literals and `$$` bodies are preserved) and flags: + +| Pattern | Example | +|---------|---------| +| `DROP TABLE` | `DROP TABLE "sessions";` | +| `DROP COLUMN` | `ALTER TABLE "users" DROP COLUMN "email";` | +| `DROP CONSTRAINT` | `ALTER TABLE "users" DROP CONSTRAINT "users_email_unique";` | +| `DROP NOT NULL` | `ALTER TABLE "users" ALTER COLUMN "name" DROP NOT NULL;` | +| `DROP TYPE` / `DROP SCHEMA` / `DROP INDEX` | `DROP TYPE "role";` | +| `TRUNCATE` | `TRUNCATE "audit_log";` | +| `DELETE FROM` without `WHERE` | `DELETE FROM "sessions";` (also inside `DO $$ ... $$` blocks) | + +Prose mentions in comments are never flagged. + +```bash +# Framework repo (scans ./src/db/migrations, ./api/src/db/migrations, ./drizzle, +# or $NERVA_API_DIR/src/db/migrations — first one that exists) +pnpm check:destructive +node scripts/check-destructive-migrations.js --dir path/to/migrations --json + +# Generated project (from api/) +pnpm db:check-destructive +``` + +Flags: `--dir `, `--json` (machine-readable report), `--warn-only` (report but exit 0), `--help`. + +### Blocking vs. warn-only + +Inside the framework repo the guard reads `database.blockDestructiveMigrations` from `.claude/pipeline.config.json` (default `true`). `scripts/generate-migration.sh` runs the guard right after `drizzle-kit generate`; when blocking is on and findings exist, the migration is **not applied** and the script exits 1. Set the key to `false` to downgrade findings to warnings. The standalone copy in a generated project has no config and always blocks unless `--warn-only` is passed. + +### Allowlisting an intended destructive migration + +A reviewed destructive change opts out with a header comment line in the migration file. The reason is mandatory — a bare marker is itself reported as a finding. + +```sql +-- nerva:allow-destructive: sessions moved to Redis in #42; table has been empty since v1.3 +DROP TABLE "sessions"; +``` + +Allowlisted files are reported as skipped with their reason, so the exception stays visible in CI output and code review. + +## Schema drift check + +`schema-drift.ts` introspects `information_schema` / `pg_catalog` and asserts that every `pgTable`, column, `NOT NULL` constraint and `pgEnum` value declared in `src/db/schema.ts` exists in the target database. The check is asymmetric on purpose: extra tables or columns in the database are fine (in-flight deprecations); anything *missing* is drift, because code that depends on it will fail at runtime. Column types, indexes and foreign keys are not compared. + +The target comes from **`PROD_DATABASE_URL` only**. `DATABASE_URL` is never used as a fallback, so a local database can never be mistaken for production. The connection string is printed with the password masked. A read-only role is sufficient. + +```bash +cd api +PROD_DATABASE_URL=postgres://... pnpm db:check-drift # lenient +PROD_DATABASE_URL=postgres://... pnpm db:check-drift:strict # strict +``` + +### Modes + +| Mode | Trigger | Behavior | +|------|---------|----------| +| **Lenient** (default) | `pnpm db:check-drift` | Drift that a committed file in `src/db/migrations/*.sql` would fix (a `CREATE TABLE`, `ADD COLUMN`, `SET NOT NULL`, `CREATE TYPE` or `ADD VALUE` for the missing object) is reported as *pending* and tolerated. This is the PR gate: the migration ships in the same PR. | +| **Strict** | `--strict` or `SCHEMA_DRIFT_STRICT=1` | A covering migration does not excuse drift. If production is missing it, the migration merged but was never applied — the check fails. This is the post-deploy / scheduled gate. | + +To cover additional schema modules (for example `src/tenancy/schema.ts` in a `--multi-tenant` project), add them to the `SCHEMA_MODULES` list at the top of `schema-drift.ts`. + +## Workflows + +Generated projects get two workflows in `.github/workflows/`: + +- **`schema-drift.yml`** — runs on pull requests that touch `api/src/db/**`. Step 1 runs the destructive guard (no secrets needed). Step 2 runs the lenient drift check against production. +- **`schema-applied.yml`** — runs daily at 13:00 UTC and on `workflow_dispatch`, in strict mode. On failure it opens (or comments on) a single issue labeled `schema-drift`; on success it comments on and closes any open `schema-drift` issue, so the alert self-heals once production is reconciled. It is deliberately not a push gate: production legitimately lags a merged schema PR until the deploy applies the migration. + +### The `PROD_DATABASE_URL` secret + +Add `PROD_DATABASE_URL` under **Settings → Secrets and variables → Actions** with a read-only connection string to the production database. Until it is set, both workflows print a `::notice::` explaining how to configure it and exit green — the destructive guard still runs, only the database-backed steps are skipped. + +## Exit codes + +| Code | `check-destructive-migrations` | `schema-drift.ts` | +|------|-------------------------------|-------------------| +| `0` | No findings, or findings with `--warn-only` / blocking disabled | No unresolved drift (lenient: pending migrations tolerated) | +| `1` | Destructive findings while blocking | Unresolved drift | +| `2` | Usage error or migrations directory missing/unreadable | `PROD_DATABASE_URL` unset, or the database is unreachable | + +`scripts/generate-migration.sh` exits 1 when the guard blocks the freshly generated migration and prints the allowlist instructions. diff --git a/docs/onboarding/README.md b/docs/onboarding/README.md index 98c839f..7ffe72a 100644 --- a/docs/onboarding/README.md +++ b/docs/onboarding/README.md @@ -21,6 +21,7 @@ Welcome to the Nerva documentation. Nerva is a Claude Code-integrated API & back | [Agent Catalog](../../.claude/CUSTOM-AGENTS-GUIDE.md) | All 24 Claude Code agents | | [Pipeline Configuration](../../.claude/pipeline.config.json) | Pipeline thresholds and settings | | [Patreon Voting](../community/patreon-voting.md) | Community roadmap voting process | +| [Hooks](hooks.md) | Claude Code hook guards (secrets, production DB) and post-run reminders | ## Technology Stack diff --git a/docs/onboarding/hooks.md b/docs/onboarding/hooks.md new file mode 100644 index 0000000..276fa4f --- /dev/null +++ b/docs/onboarding/hooks.md @@ -0,0 +1,242 @@ +# Claude Code Hooks + +Hooks are small bash scripts that Claude Code runs automatically around its own tool calls. Nerva ships two kinds: + +- **PreToolUse guards** that block a dangerous Bash command before it runs (staging secrets into git, pushing schema changes at a production database). +- **PostToolUse reminders** that surface a one-line nudge after a Bash command finishes (coverage threshold, migration review, quality gate). + +Every hook is a file under `.claude/hooks/`, wired in `.claude/settings.json`, and covered by `scripts/__tests__/hooks.test.js`. + +``` +.claude/ +├── settings.json # wiring (committed; settings.local.json stays personal) +└── hooks/ + ├── lib/ + │ ├── hook-input.sh # stdin JSON helpers (hook_get, hook_tool_output, ...) + │ └── bash-cmd.sh # splits a Bash command into simple commands + words + ├── secret-guard.sh # PreToolUse + ├── prod-db-guard.sh # PreToolUse + ├── coverage-check.sh # PostToolUse + ├── migration-safety-reminder.sh # PostToolUse + └── quality-gate-reminder.sh # PostToolUse +``` + +## The stdin contract + +Claude Code passes **one JSON object on stdin** to each hook. Hooks read it once and never take positional arguments. + +```json +{ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { "command": "git add .env" }, + "tool_response": { "stdout": "...", "stderr": "...", "interrupted": false }, + "cwd": "/path/to/project" +} +``` + +| Field | Present on | Notes | +|-------|-----------|-------| +| `tool_name` | both | Hooks in this repo only act on `Bash` | +| `tool_input.command` | both | The exact command Claude asked to run | +| `tool_response` | PostToolUse | For Bash an object with `stdout`/`stderr`; other tools may return a string | + +What the exit code means: + +| Event | Exit 0 | Exit 2 | Other | +|-------|--------|--------|-------| +| PreToolUse | allow the call | **block** the call; stderr is shown to Claude | non-blocking error, call proceeds | +| PostToolUse | done; stdout is surfaced to Claude as a reminder (no output = silent) | n/a | treated as a hook error | + +Hooks run with the **project root as cwd**, which is why `settings.json` references them as `bash .claude/hooks/.sh`. + +Two rules every hook here follows: + +1. **Never crash the tool call.** `set -u` plus `trap 'exit 0' ERR`, and every helper returns 0. Empty, truncated, or non-JSON stdin exits 0 with no output. A PreToolUse guard therefore *fails open*: the only way it blocks is its explicit `exit 2`. +2. **Read stdin through the shared helper**, never with ad-hoc `jq` calls, so the jq/node fallback and the `tool_response` shape are handled in one place. + +## Shared libraries + +`.claude/hooks/lib/hook-input.sh` (sourced by every hook): + +| Helper | Purpose | +|--------|---------| +| `HOOK_INPUT` | Raw stdin, read once | +| `hook_get .tool_input.command` | jq-style lookup; `""` when missing or not JSON. Uses `jq` when installed, otherwise `node` (always present in Nerva). `NERVA_HOOK_JSON_TOOL=node` forces the node path (tests use it) | +| `hook_tool_name`, `hook_tool_command` | Shorthands | +| `hook_tool_output` | `tool_response` as text, whatever its shape (`{stdout,stderr}`, `{output}`, or a string) | +| `hook_skip_requested` | True when `NERVA_SKIP_HOOKS=1` is in the hook's environment | +| `common_config_get` fallback | Defined only if `scripts/lib/common.sh` was not sourced, so copied hooks still read `pipeline.config.json` | + +`.claude/hooks/lib/bash-cmd.sh` (PreToolUse guards): + +| Helper | Purpose | +|--------|---------| +| `hook_split_commands "$CMD"` | Fills `HOOK_SEGMENTS`: one simple command per entry. Splits on newline, `;`, `|`, `||`, `&&`, `&`, `(`, `)` and backticks outside quotes, drops comments, skips heredoc bodies (`<&1`-style redirects intact | +| `hook_tokenize "$seg"` | Fills `HOOK_TOKENS`: words with quotes and backslash escapes removed | +| `hook_strip_wrappers` | Fills `HOOK_ARGV`: `HOOK_TOKENS` minus leading `VAR=value`, `sudo`, `env`, `command`, `time`, `if`, `{`, ... so `HOOK_ARGV[0]` is the program | + +Both libraries are Bash 3.2 compatible (macOS `/bin/bash`). + +`scripts/lib/common.sh` is sourced via an absolute path derived from the hook's own location (`$HOOK_DIR/../../scripts/lib/common.sh`). It supplies `have_cmd` and `common_config_get [default]`, which reads `.claude/pipeline.config.json` (or the file named by `NERVA_PIPELINE_CONFIG`). + +## Built-in hooks + +### secret-guard.sh (PreToolUse, Bash) + +| | | +|---|---| +| Triggers on | `git add` / `git stage` of `.env`, `.env.*`, `.dev.vars`, `.dev.vars.*`, `*.pem`, `*.key`, `*.p12`, `credentials.json`; any `git add -f` / `--force` (including clusters such as `-Af`); `git commit -a` / `-am` / `--all` while one of those files is already tracked (`git ls-files`) | +| Not blocked | `.env.example`, `.env.*.example`, `.dev.vars.example`; `git add .` / `-A` (rely on `.gitignore`); a mention inside a quoted string, comment, or heredoc body; `git commit -m "add .env support"` | +| Result | exit 2, stderr `[secret-guard] Blocked: this command would stage a secret file (.env). ...` | +| Compound commands | Every simple command is inspected, so `cd api && git add .env`, `git -C api add .env`, `(cd api && git add .env)` and `FOO=1 git add .env` are all caught | +| Bypass | Commit `.env.example` instead; untrack a secret with `git rm --cached `; or run the command yourself in a terminal (see below) | + +### prod-db-guard.sh (PreToolUse, Bash) + +| | | +|---|---| +| Triggers on | (1) `drizzle-kit push` / `pnpm db:push` when the same command references production: `--env production`, `PROD_DATABASE_URL`, `NODE_ENV=production`, or a `postgres://...` URL containing `prod`. (2) `psql`, `pnpm db:*`, or `drizzle-kit` invocations that reference production **and** contain `DROP DATABASE`, `DROP SCHEMA`, or `TRUNCATE`. (3) `drizzle-kit drop`, always | +| Not blocked | Plain `drizzle-kit push` / `pnpm db:push` in dev (the PostToolUse reminder fires instead); `drizzle-kit generate` / `migrate`; read-only `psql` against production; `TRUNCATE` against a non-production URL | +| Result | exit 2, stderr `[prod-db-guard] Blocked: drizzle-kit push against a production target (--env production). ...` naming the matched token | +| Bypass | Production schema changes go through `drizzle-kit generate`, review, `drizzle-kit migrate`. For a genuine one-off, run it yourself in a terminal | + +### coverage-check.sh (PostToolUse, Bash) + +| | | +|---|---| +| Triggers on | Command contains `vitest` (or `run-tests.sh`) **and** the output mentions coverage | +| Prints | `[coverage-check] Statement coverage 91.2% meets the 80% threshold ...` when the text reporter's `All files` row is present, otherwise a generic reminder. Threshold comes from `tdd.coverageThreshold` (default 80) | +| Silent when | No coverage in the output, or the command is not a test run | + +### migration-safety-reminder.sh (PostToolUse, Bash) + +| | | +|---|---| +| Triggers on | `drizzle-kit generate` / `pnpm db:generate` / `scripts/generate-migration.sh` whose output reports a generated migration; any `drizzle-kit push` / `pnpm db:push` | +| Prints | After generate: run the destructive-DDL check and read the SQL. The command is chosen from the cwd: `node scripts/check-destructive-migrations.js` when that file exists (framework repo), `pnpm db:check-destructive` when `package.json` defines it (generated `api/` project). After push: push is dev-only; staging and production take migrations | +| Silent when | Generate reported `No schema changes` or an error | + +### quality-gate-reminder.sh (PostToolUse, Bash) + +| | | +|---|---| +| Triggers on | A test command (`vitest`, `pnpm test`, `run-tests.sh`) whose output contains `Tests N passed` and no `failed` | +| Prints | `[quality-gate] All tests passed. Run the quality gate ...: ./scripts/check-types.sh && ./scripts/security-scan.sh, then confirm coverage is at least 80% ...` | +| Silent when | Any test failed, or there is no summary line | + +## Bypassing a guard legitimately + +The guards govern **Claude's Bash tool only**. Options, from most to least targeted: + +1. **Do the safe thing instead.** Stage `.env.example`; generate a migration instead of pushing; put the `TRUNCATE` in a reviewed migration. +2. **Run the command yourself** in your own terminal. Hooks do not run there. +3. **Launch Claude with `NERVA_SKIP_HOOKS=1`** (`NERVA_SKIP_HOOKS=1 claude`). Every hook exits 0 immediately. This has to be set in the environment Claude Code starts from; a `NERVA_SKIP_HOOKS=1 git add .env` typed by Claude does *not* work, because the prefix is stripped by the parser and the hook's own environment is untouched. +4. **Remove the entry from `.claude/settings.json`** for the session and restore it afterwards. Prefer the options above; `settings.local.json` cannot disable a hook defined in `settings.json`. + +## Adding a hook + +### 1. Write the script + +`.claude/hooks/my-hook.sh`: + +```bash +#!/usr/bin/env bash +# my-hook.sh — PostToolUse hook (matcher: Bash): one line on what it does. +set -u +trap 'exit 0' ERR + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -f "$HOOK_DIR/../../scripts/lib/common.sh" ]]; then + source "$HOOK_DIR/../../scripts/lib/common.sh" +fi +source "$HOOK_DIR/lib/hook-input.sh" + +hook_skip_requested && exit 0 +CMD="$(hook_tool_command)" +[[ -n "$CMD" ]] || exit 0 +printf '%s' "$CMD" | grep -qE 'git tag' || exit 0 + +OUT="$(hook_tool_output)" +THRESHOLD="$(common_config_get tdd.coverageThreshold 80)" +echo "[my-hook] New tag created. Update CHANGELOG.md if you have not." +exit 0 +``` + +For a PreToolUse guard, add `set -f`, source `lib/bash-cmd.sh`, walk `HOOK_SEGMENTS`, and finish with: + +```bash +if [[ -n "$BLOCK" ]]; then + echo "[my-guard] Blocked: $BLOCK" >&2 + exit 2 +fi +exit 0 +``` + +Keep it fast (well under a second: it runs on every Bash call), prefix output with `[hook-name]`, and never `set -e`. + +### 2. Register it + +Append to the matching group in `.claude/settings.json`: + +```json +{ + "type": "command", + "command": "bash .claude/hooks/my-hook.sh", + "description": "Remind to update the changelog after creating a git tag" +} +``` + +Entries in a group run in order; each is independent. + +### 3. Test it + +Try it by hand with a crafted payload: + +```bash +echo '{"tool_name":"Bash","tool_input":{"command":"git tag v1.2.0"},"tool_response":{"stdout":"","stderr":""}}' \ + | bash .claude/hooks/my-hook.sh; echo "exit=$?" + +echo 'garbage' | bash .claude/hooks/my-hook.sh; echo "exit=$?" # must be 0 and silent +bash -n .claude/hooks/my-hook.sh +``` + +Then add a `describe` block to `scripts/__tests__/hooks.test.js`. The file already has `runHook(name, payload, { cwd, env })`, `pre(command)`, `post(command, stdout, stderr)`, `configEnv({...})` for a throwaway `pipeline.config.json` (via `NERVA_PIPELINE_CONFIG`), and `gitRepo({...})` for a throwaway repository: + +```js +describe("my-hook.sh (PostToolUse)", () => { + it("fires after git tag", () => { + const r = runHook("my-hook.sh", post("git tag v1.2.0", "")); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("[my-hook]"); + }); + + it("stays silent otherwise", () => { + expect(runHook("my-hook.sh", post("git status", "")).stdout).toBe(""); + }); +}); +``` + +Add the file name to `PRE_HOOKS` or `POST_HOOKS` at the top of the test file: that enrols it in the `bash -n`, settings.json wiring, and bad-input robustness suites automatically. Run: + +```bash +pnpm test -- scripts/__tests__/hooks.test.js +``` + +## Known limits + +- The command parser does not descend into `bash -c "..."`, `eval`, or scripts invoked by path. `.gitignore` and database credentials scoping remain the real backstops. +- `git add .` / `git add -A` are allowed; they are far too common to block and `.gitignore` covers them. +- `git commit -a` checks tracked files from the project root; a nested repository under `api/` is not inspected. +- SQL inside a file (`psql -f drop.sql`) is invisible to prod-db-guard. +- Only one heredoc per line is recognised. + +## Troubleshooting + +**A guard blocked something legitimate.** The stderr line names the token that matched. Rename to `*.example`, untrack the file, or use one of the bypasses above. + +**A hook does nothing.** Run it by hand with the payload shown in "Test it" and check `echo $?`. Then confirm the entry exists in `.claude/settings.json` (hooks are read at session start; restart Claude Code after editing). + +**"jq: command not found" style noise.** Hooks should never print that; `hook_get` falls back to node. If you see it, the hook is calling jq directly instead of using `hook_get`. + +**Hook output not visible.** PostToolUse reminders go to stdout; PreToolUse block reasons go to stderr. Swapping them makes the message disappear. diff --git a/package.json b/package.json index 09e3107..67d897c 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,15 @@ "release:dry": "commit-and-tag-version --dry-run", "release:first": "commit-and-tag-version --first-release", "setup": "bash scripts/setup-project.sh", - "prepare": "husky" + "prepare": "husky", + "test": "vitest run --config scripts/__tests__/vitest.config.js", + "test:watch": "vitest --config scripts/__tests__/vitest.config.js", + "verify": "bash scripts/verify-all.sh", + "verify:ci": "bash scripts/verify-all.sh --ci", + "validate:config": "node scripts/validate-pipeline-config.js", + "check:prereqs": "bash scripts/check-prerequisites.sh", + "check:doc-counts": "bash scripts/check-doc-counts.sh", + "check:destructive": "node scripts/check-destructive-migrations.js" }, "repository": { "type": "git", @@ -45,7 +53,9 @@ "devDependencies": { "@commitlint/cli": "^21.2.2", "@commitlint/config-conventional": "^21.2.2", + "ajv": "^8.20.0", "commit-and-tag-version": "^13.1.2", - "husky": "^9.1.7" + "husky": "^9.1.7", + "vitest": "^4.1.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f75bc2..588ff4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,12 +18,18 @@ importers: '@commitlint/config-conventional': specifier: ^21.2.2 version: 21.2.2 + ajv: + specifier: ^8.20.0 + version: 8.20.0 commit-and-tag-version: specifier: ^13.1.2 version: 13.1.2(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.1) husky: specifier: ^9.1.7 version: 9.1.7 + vitest: + specifier: ^4.1.2 + version: 4.1.11(@types/node@25.5.2)(vite@8.2.2(@types/node@25.5.2)(jiti@2.6.1)(yaml@2.9.0)) packages: @@ -120,9 +126,108 @@ packages: resolution: {integrity: sha512-TzlTVpKPjaqW6qOYjQcYUDuGsLCNsvFHVBXkYGTAnf5V37jCWrE5haKNXzz0WZUtVHjrpV76L1buANjwXMfT8w==} engines: {node: '>=22'} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@nodable/entities@3.0.0': resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} + '@oxc-project/types@0.149.0': + resolution: {integrity: sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==} + + '@rolldown/binding-android-arm-eabi@1.2.8': + resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.8': + resolution: {integrity: sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.8': + resolution: {integrity: sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.8': + resolution: {integrity: sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.8': + resolution: {integrity: sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.8': + resolution: {integrity: sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.8': + resolution: {integrity: sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.2.8': + resolution: {integrity: sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.2.8': + resolution: {integrity: sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.2.8': + resolution: {integrity: sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.2.8': + resolution: {integrity: sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.2.8': + resolution: {integrity: sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.2.8': + resolution: {integrity: sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.8': + resolution: {integrity: sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.8': + resolution: {integrity: sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@simple-libs/child-process-utils@2.0.0': resolution: {integrity: sha512-dvNoRKLijXnD0XoJAz94pbNuB5GQgDr55UhpSPhffDkTT0Cmcqh9jSCOtwfT2d4H6MI9E7c4SgtMuJXZ6F3c6A==} engines: {node: '>=22'} @@ -139,9 +244,50 @@ packages: resolution: {integrity: sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==} engines: {node: '>=22'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@25.5.2': resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} @@ -171,6 +317,10 @@ packages: resolution: {integrity: sha512-DhBpBfXL4SS2uC0N922MMajKR3CdrTG0u2or1PNYgXMsrSzViJrbtvT0nCLlLGUI0plam/ZZCs7aAauHtW9thw==} engines: {node: '>=22'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -181,6 +331,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -247,6 +401,9 @@ packages: engines: {node: '>=22'} hasBin: true + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cosmiconfig-typescript-loader@6.3.0: resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==} engines: {node: '>=v18'} @@ -268,6 +425,10 @@ packages: resolution: {integrity: sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==} engines: {node: '>=12.20'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + detect-newline@3.1.0: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} @@ -289,6 +450,9 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-toolkit@1.49.0: resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} @@ -300,6 +464,13 @@ packages: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -316,6 +487,15 @@ packages: fd-package-json@2.0.0: resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -328,6 +508,11 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -384,6 +569,76 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -395,9 +650,21 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -438,9 +705,20 @@ packages: resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} engines: {node: '>=14.0.0'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -457,11 +735,29 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + rolldown@1.2.8: + resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -481,10 +777,21 @@ packages: strnum@2.4.1: resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.2.4: resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + typescript@6.0.2: resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} engines: {node: '>=14.17'} @@ -493,10 +800,99 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + walk-up-path@4.0.0: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -668,8 +1064,59 @@ snapshots: '@conventional-changelog/template@1.2.1': {} + '@jridgewell/sourcemap-codec@1.6.0': {} + '@nodable/entities@3.0.0': {} + '@oxc-project/types@0.149.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.8': + optional: true + + '@rolldown/binding-android-arm64@1.2.8': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.8': + optional: true + + '@rolldown/binding-darwin-x64@1.2.8': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.8': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.8': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.8': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.8': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.8': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.8': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.8': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@simple-libs/child-process-utils@2.0.0': dependencies: '@simple-libs/stream-utils': 2.0.0 @@ -683,10 +1130,62 @@ snapshots: '@simple-libs/stream-utils@2.0.0': {} + '@standard-schema/spec@1.1.0': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + '@types/node@25.5.2': dependencies: undici-types: 7.18.2 + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@25.5.2)(jiti@2.6.1)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.2(@types/node@25.5.2)(jiti@2.6.1)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.11': {} + + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 @@ -710,6 +1209,8 @@ snapshots: argue-cli@3.1.0: {} + assertion-error@2.0.1: {} + balanced-match@1.0.2: {} brace-expansion@1.1.17: @@ -719,6 +1220,8 @@ snapshots: callsites@3.1.0: {} + chai@6.2.2: {} + chalk@5.6.2: {} cliui@8.0.1: @@ -810,6 +1313,8 @@ snapshots: conventional-commits-filter: 6.0.1 conventional-commits-parser: 7.1.1 + convert-source-map@2.0.0: {} + cosmiconfig-typescript-loader@6.3.0(@types/node@25.5.2)(cosmiconfig@9.0.2(typescript@6.0.2))(typescript@6.0.2): dependencies: '@types/node': 25.5.2 @@ -828,6 +1333,8 @@ snapshots: detect-indent@7.0.2: {} + detect-libc@2.1.2: {} + detect-newline@3.1.0: {} dotgitignore@2.1.0: @@ -845,12 +1352,20 @@ snapshots: dependencies: is-arrayish: 0.2.1 + es-module-lexer@2.3.2: {} + es-toolkit@1.49.0: {} escalade@3.2.0: {} escape-string-regexp@1.0.5: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + fast-deep-equal@3.1.3: {} fast-uri@4.1.0: {} @@ -873,6 +1388,10 @@ snapshots: dependencies: walk-up-path: 4.0.0 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -886,6 +1405,9 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + fsevents@2.3.3: + optional: true + get-caller-file@2.0.5: {} get-east-asian-width@1.6.0: {} @@ -923,6 +1445,55 @@ snapshots: json-schema-traverse@1.0.0: {} + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + lines-and-columns@1.2.4: {} locate-path@3.0.0: @@ -934,10 +1505,18 @@ snapshots: dependencies: p-locate: 5.0.0 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + minimatch@3.1.5: dependencies: brace-expansion: 1.1.17 + nanoid@3.3.18: {} + + obug@2.2.1: {} + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -973,8 +1552,18 @@ snapshots: path-expression-matcher@1.6.2: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -983,8 +1572,37 @@ snapshots: resolve-from@5.0.0: {} + rolldown@1.2.8: + dependencies: + '@oxc-project/types': 0.149.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.8 + '@rolldown/binding-android-arm64': 1.2.8 + '@rolldown/binding-darwin-arm64': 1.2.8 + '@rolldown/binding-darwin-x64': 1.2.8 + '@rolldown/binding-freebsd-x64': 1.2.8 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.8 + '@rolldown/binding-linux-arm64-gnu': 1.2.8 + '@rolldown/binding-linux-arm64-musl': 1.2.8 + '@rolldown/binding-linux-ppc64-gnu': 1.2.8 + '@rolldown/binding-linux-s390x-gnu': 1.2.8 + '@rolldown/binding-linux-x64-gnu': 1.2.8 + '@rolldown/binding-linux-x64-musl': 1.2.8 + '@rolldown/binding-openharmony-arm64': 1.2.8 + '@rolldown/binding-win32-arm64-msvc': 1.2.8 + '@rolldown/binding-win32-x64-msvc': 1.2.8 + semver@7.8.5: {} + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -1009,14 +1627,68 @@ snapshots: dependencies: anynum: 1.0.1 + tinybench@2.9.0: {} + tinyexec@1.2.4: {} + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinyrainbow@3.1.1: {} + typescript@6.0.2: {} undici-types@7.18.2: {} + vite@8.2.2(@types/node@25.5.2)(jiti@2.6.1)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.8 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.5.2 + fsevents: 2.3.3 + jiti: 2.6.1 + yaml: 2.9.0 + + vitest@4.1.11(@types/node@25.5.2)(vite@8.2.2(@types/node@25.5.2)(jiti@2.6.1)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@25.5.2)(jiti@2.6.1)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.2.1 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.2(@types/node@25.5.2)(jiti@2.6.1)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.5.2 + transitivePeerDependencies: + - msw + walk-up-path@4.0.0: {} + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..9f5faf6 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,99 @@ +# Scripts Reference + +All scripts live in `scripts/` and run from the framework root. Scripts that +operate on a generated API project look for it at `api/` (override with +`NERVA_API_DIR`). Thresholds and policies come from +[`.claude/pipeline.config.json`](../.claude/pipeline.config.json); the shared +helpers live in [`scripts/lib/`](lib/). + +Run everything at once with `pnpm verify` (or `/verify-all` inside Claude Code). + +## Quality checks + +### Verify All (`verify-all.sh`) +- **Purpose**: Run every local check in sequence and print a pass/fail summary. Backs the `/verify-all` and `/ci` slash commands. +- **Usage**: `./scripts/verify-all.sh [--json] [--ci] [--skip a,b] [--include a,b] [--list]` +- **Checks**: shell-syntax, js-syntax, json, pipeline-config, doc-counts, script-tests, types, tests, security, destructive-migrations. Checks whose subject is missing (no `api/`, no migrations) are skipped with a reason. +- **Exit codes**: 0 all passed or skipped, 1 a check failed, 2 usage error. + +### Type Check (`check-types.sh`) +- **Purpose**: `tsc --noEmit` against `api/`. When `qualityGate.noAnyTypes` is on, also lists uses of `any` in `src/` as an advisory. +- **Usage**: `./scripts/check-types.sh [--strict] [--verbose]` + +### Run Tests (`run-tests.sh`) +- **Purpose**: Vitest in `api/`. With `--coverage`, reads `coverage/coverage-summary.json` and fails when line coverage is below `tdd.coverageThreshold`. +- **Usage**: `./scripts/run-tests.sh [--unit|--integration|--all] [--coverage] [--watch]` +- **Config**: `testing.integrationTimeout`, `tdd.coverageThreshold` + +### Security Scan (`security-scan.sh`) +- **Purpose**: `pnpm audit` plus a scan for hardcoded secrets, SQL interpolation, missing rate limiting or CORS, and stray `console.log`. +- **Usage**: `./scripts/security-scan.sh [--json] [--audit-only] [--patterns-only] [--level ] [--no-fail]` +- **Config**: `security.audit.level`, `security.audit.failOnVulnerability`, `security.audit.checkLockfile` + +### Load Test (`load-test.sh`) +- **Purpose**: Run a k6 script; creates a baseline script on first run. +- **Usage**: `./scripts/load-test.sh [--vus N] [--duration 30s] [--script path] [--base-url url] [--json]` +- **Config**: `testing.loadTestVUs`, `testing.loadTestDuration`, `testing.loadTestThresholds.*` + +## Framework integrity + +### Validate Pipeline Config (`validate-pipeline-config.js`) +- **Purpose**: Validate `.claude/pipeline.config.json` against [`pipeline.config.schema.json`](../.claude/pipeline.config.schema.json), then check what the schema cannot express: phase dependency graph (unknown targets, cycles, reachability), `deployment.defaultTarget` in `targets`, semver `version`, coverage-threshold consistency. +- **Usage**: `node scripts/validate-pipeline-config.js [--config path] [--schema path] [--json]` +- **Exit codes**: 0 valid, 1 invalid, 2 usage/IO error. + +### Check Doc Counts (`check-doc-counts.sh`) +- **Purpose**: Count agents, skills, scripts, and commands on disk and fail when a Markdown claim such as "24 specialized agents" disagrees. CHANGELOG, release notes, `docs/plans/`, and `examples/` are excluded. +- **Usage**: `./scripts/check-doc-counts.sh [--json] [--root dir]` +- **Runs on**: husky pre-commit and CI. + +### Check Prerequisites (`check-prerequisites.sh`) +- **Purpose**: Report required software (git, Node, pnpm, bash), optional tools (docker, psql, jq, gh, shellcheck, k6, wrangler, sam, flyctl, railway), Claude Code plugins, and project state in a stable `[PASS]|[FAIL]|[SKIP]|[INFO]` layout. +- **Usage**: `./scripts/check-prerequisites.sh [--json]` +- **Exit codes**: 0 ready, 1 a required item is missing, 2 script error. The `claude` CLI and plugins never block. + +## Database + +### Generate Migration (`generate-migration.sh`) +- **Purpose**: `drizzle-kit generate`, print the SQL, run the destructive-DDL guard, then optionally apply. +- **Usage**: `./scripts/generate-migration.sh [name] [--apply]` +- **Config**: `database.blockDestructiveMigrations` (when true, findings block the apply step) + +### Check Destructive Migrations (`check-destructive-migrations.js`) +- **Purpose**: Scan migration SQL for DROP TABLE/COLUMN/CONSTRAINT/NOT NULL/TYPE/SCHEMA/INDEX, TRUNCATE, and unbounded DELETE after stripping comments. A file opts out with a header line `-- nerva:allow-destructive: `. Copied into every generated project as `api/scripts/check-destructive-migrations.mjs` (`pnpm db:check-destructive`). +- **Usage**: `node scripts/check-destructive-migrations.js [--dir path] [--json] [--warn-only]` +- **Exit codes**: 0 clean, 1 findings, 2 usage/IO error. +- **Docs**: [docs/api-development/migration-safety.md](../docs/api-development/migration-safety.md) + +### Seed Database (`seed-database.sh`) +- **Purpose**: Run `src/db/seed.ts` against the selected environment; production requires typing `yes`. +- **Usage**: `./scripts/seed-database.sh [--env development|staging|production]` + +## Generation + +### Setup Project (`setup-project.sh`) +- **Purpose**: Scaffold a new API project from `templates/` for one deployment target. +- **Usage**: `./scripts/setup-project.sh [--cloudflare|--node|--lambda|--railway|--fly] [--multi-tenant] [--dry-run]` +- **Generated extras**: `api/pnpm-workspace.yaml` (allows esbuild/workerd install scripts on pnpm 10 through 12), `api/scripts/check-destructive-migrations.mjs`, `api/src/db/schema-drift.ts` (`pnpm db:check-drift[:strict]`), `.github/workflows/schema-drift.yml` and `schema-applied.yml`. Cloudflare projects get `deploy:staging` and `deploy:production` scripts and a `deploy` script that refuses to publish the development environment. + +### Generate OpenAPI Docs (`generate-openapi-docs.sh`) +- **Usage**: `./scripts/generate-openapi-docs.sh [--serve] [--port 8080] [--output path]` + +### Generate Client (`generate-client.sh`) +- **Purpose**: Typed TypeScript client from the OpenAPI spec for Aurelius frontends. +- **Usage**: `./scripts/generate-client.sh [--spec path] [--output dir] [--runtime]` + +## Shared library (`scripts/lib/`) + +| File | Provides | +|------|----------| +| `common.sh` | `info`/`success`/`warn`/`error`/`step`, `say_*` status prefixes, `common_project_root`, `common_api_dir`, `have_cmd`, `require_cmd`, `common_track_tmpfile`, `common_config_get [default]` | +| `colors.sh` | ANSI color variables; empty when piped or when `NO_COLOR` is set | +| `pipeline-config.js` | `getValue(path, default)` / `loadConfig()`; CLI `node scripts/lib/pipeline-config.js get [default]`; honors `NERVA_PIPELINE_CONFIG` | + +## Tests + +Script and hook tests live in `scripts/__tests__/` and run with `pnpm test` +(Vitest, sequential, `NO_COLOR` set). Each test shells out to the real script +with a fixture directory or the repo itself. CI runs them in the `script-tests` +job. diff --git a/scripts/__tests__/check-destructive-migrations.test.js b/scripts/__tests__/check-destructive-migrations.test.js new file mode 100644 index 0000000..2fc2b1e --- /dev/null +++ b/scripts/__tests__/check-destructive-migrations.test.js @@ -0,0 +1,296 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { join } from "path"; +import { realpathSync } from "fs"; +import { runNode, tmpProject, SCRIPTS_DIR } from "./helpers.js"; +import { + stripSqlComments, + scanSql, + parseAllowlistReason, + hasAllowlistMarker, + splitStatements, + DELETE_WITHOUT_WHERE_LABEL, + INVALID_ALLOWLIST_LABEL, +} from "../check-destructive-migrations.js"; + +const SCRIPT = join(SCRIPTS_DIR, "check-destructive-migrations.js"); + +/** Run the CLI with a pipeline config that pins blockDestructiveMigrations. */ +function runCheck(project, args, { block = true } = {}) { + const configPath = project.write( + `pipeline.${block}.json`, + JSON.stringify({ database: { blockDestructiveMigrations: block } }), + ); + return runNode(SCRIPT, args, { + cwd: project.dir, + env: { NERVA_PIPELINE_CONFIG: configPath }, + }); +} + +describe("stripSqlComments", () => { + it("removes line and block comments but keeps string literals", () => { + const sql = [ + "-- DROP TABLE prose", + "INSERT INTO t VALUES ('-- not a comment', '/* nor this */'); /* DROP COLUMN x */", + "SELECT 1;", + ].join("\n"); + const out = stripSqlComments(sql); + expect(out).not.toContain("DROP TABLE"); + expect(out).not.toContain("DROP COLUMN"); + expect(out).toContain("'-- not a comment'"); + expect(out).toContain("'/* nor this */'"); + expect(out).toContain("SELECT 1;"); + }); + + it("preserves line numbers across multi-line block comments", () => { + const sql = "/* a\nb\nc */\nSELECT 1;"; + expect(stripSqlComments(sql).split("\n").length).toBe(sql.split("\n").length); + }); + + it("keeps dollar-quoted bodies intact", () => { + const sql = "DO $$ BEGIN -- keep\nEND $$;"; + expect(stripSqlComments(sql)).toContain("-- keep"); + }); +}); + +describe("splitStatements", () => { + it("splits on semicolons outside of string literals and descends into DO bodies", () => { + const stmts = splitStatements("SELECT 'a;b'; DO $$ BEGIN DELETE FROM c; END $$; SELECT 2"); + const texts = stmts.map((s) => s.text.trim()); + expect(texts).toContain("SELECT 'a;b'"); + expect(texts).toContain("SELECT 2"); + expect(texts.some((t) => /DELETE FROM c/.test(t))).toBe(true); + }); +}); + +describe("scanSql", () => { + const cases = [ + ["DROP TABLE", 'DROP TABLE "users";'], + ["DROP TABLE", 'DROP TABLE IF EXISTS "users" CASCADE;'], + ["DROP COLUMN", 'ALTER TABLE "users" DROP COLUMN "email";'], + ["DROP CONSTRAINT", 'ALTER TABLE "users" DROP CONSTRAINT "users_email_unique";'], + ["DROP NOT NULL", 'ALTER TABLE "users" ALTER COLUMN "name" DROP NOT NULL;'], + ["DROP TYPE", 'DROP TYPE "role";'], + ["DROP SCHEMA", 'DROP SCHEMA "tenant_acme" CASCADE;'], + ["DROP INDEX", 'DROP INDEX "users_email_idx";'], + ["TRUNCATE", 'TRUNCATE TABLE "sessions";'], + [DELETE_WITHOUT_WHERE_LABEL, 'DELETE FROM "sessions";'], + ]; + + it.each(cases)("flags %s", (label, sql) => { + const findings = scanSql("x.sql", sql); + expect(findings.map((f) => f.label)).toContain(label); + expect(findings[0].file).toBe("x.sql"); + expect(findings[0].line).toBe(1); + }); + + it("flags every destructive statement, case-insensitively, with line numbers", () => { + const sql = 'CREATE TABLE "a" ("id" serial);\ndrop table "b";\nALTER TABLE "c" drop column "x";'; + const findings = scanSql("multi.sql", sql); + expect(findings.map((f) => [f.label, f.line])).toEqual([ + ["DROP TABLE", 2], + ["DROP COLUMN", 3], + ]); + }); + + it("does not flag destructive keywords that appear only in comments", () => { + const sql = [ + "-- DROP TABLE legacy", + "/* ALTER TABLE x DROP COLUMN y;", + " TRUNCATE z; DELETE FROM q; */", + 'ALTER TABLE "users" ADD COLUMN "nickname" text;', + ].join("\n"); + expect(scanSql("comments.sql", sql)).toEqual([]); + }); + + it("does not flag DELETE FROM with a WHERE clause", () => { + expect(scanSql("d.sql", "DELETE FROM sessions WHERE expires_at < now();")).toEqual([]); + }); + + it("flags DELETE FROM without WHERE inside a DO block", () => { + const findings = scanSql("do.sql", "DO $$ BEGIN\n DELETE FROM sessions;\nEND $$;"); + expect(findings.map((f) => f.label)).toEqual([DELETE_WITHOUT_WHERE_LABEL]); + expect(findings[0].line).toBe(2); + }); + + it("is clean for additive migrations", () => { + const sql = [ + 'CREATE TYPE "public"."role" AS ENUM(\'admin\', \'user\');', + 'CREATE TABLE "users" ("id" uuid PRIMARY KEY, "role" "role" NOT NULL);', + 'ALTER TABLE "users" ADD COLUMN "nickname" text;', + 'CREATE INDEX "users_role_idx" ON "users" ("role");', + 'ALTER TABLE "users" ALTER COLUMN "nickname" SET NOT NULL;', + ].join("\n"); + expect(scanSql("clean.sql", sql)).toEqual([]); + }); +}); + +describe("parseAllowlistReason", () => { + it("returns the reason from a header marker", () => { + const sql = "-- nerva:allow-destructive: drops the legacy sessions table (moved to Redis)\nDROP TABLE sessions;"; + expect(parseAllowlistReason(sql)).toBe("drops the legacy sessions table (moved to Redis)"); + expect(hasAllowlistMarker(sql)).toBe(true); + }); + + it("tolerates spacing variants", () => { + expect(parseAllowlistReason("--nerva:allow-destructive:reason here\n")).toBe("reason here"); + expect(parseAllowlistReason(" -- nerva:allow-destructive: spaced \n")).toBe("spaced"); + }); + + it("rejects a marker without a reason", () => { + expect(parseAllowlistReason("-- nerva:allow-destructive:\nDROP TABLE x;")).toBeNull(); + expect(parseAllowlistReason("-- nerva:allow-destructive: \nDROP TABLE x;")).toBeNull(); + expect(hasAllowlistMarker("-- nerva:allow-destructive:\n")).toBe(true); + }); + + it("returns null when no marker is present", () => { + expect(parseAllowlistReason("DROP TABLE x;")).toBeNull(); + expect(hasAllowlistMarker("DROP TABLE x;")).toBe(false); + }); + + it("ignores the marker outside a comment line", () => { + expect(parseAllowlistReason("INSERT INTO t VALUES ('nerva:allow-destructive: nope');")).toBeNull(); + }); +}); + +describe("CLI", () => { + let project; + + beforeAll(() => { + project = tmpProject("nerva-destructive-"); + project.write("clean/0000_init.sql", 'CREATE TABLE "users" ("id" serial PRIMARY KEY);\n'); + project.write( + "clean/0001_add.sql", + '-- DROP TABLE mentioned in prose only\nALTER TABLE "users" ADD COLUMN "email" text;\n', + ); + + project.write("bad/0000_init.sql", 'CREATE TABLE "users" ("id" serial PRIMARY KEY);\n'); + project.write("bad/0001_drop.sql", 'ALTER TABLE "users" DROP COLUMN "email";\nTRUNCATE "audit";\n'); + + project.write( + "allowed/0000_drop.sql", + '-- nerva:allow-destructive: legacy table replaced by KV cache in #42\nDROP TABLE "legacy";\n', + ); + + project.write("noreason/0000_drop.sql", '-- nerva:allow-destructive:\nDROP TABLE "legacy";\n'); + + project.write("empty/.keep", ""); + }); + + afterAll(() => project.cleanup()); + + it("exits 0 on a clean directory and reports the scan", () => { + const r = runCheck(project, ["--dir", "clean"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("Scanned 2 of 2 migration file(s)"); + expect(r.stdout).toContain("No destructive DDL found"); + }); + + it("exits 0 on a directory with no .sql files", () => { + const r = runCheck(project, ["--dir", "empty"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("Scanned 0 of 0"); + }); + + it("exits 1 with findings and allowlist instructions when blocking", () => { + const r = runCheck(project, ["--dir", "bad"]); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain("0001_drop.sql:1 DROP COLUMN"); + expect(r.stderr).toContain("0001_drop.sql:2 TRUNCATE"); + expect(r.stderr).toContain("-- nerva:allow-destructive:"); + }); + + it("exits 0 with findings when --warn-only", () => { + const r = runCheck(project, ["--dir", "bad", "--warn-only"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("DROP COLUMN"); + expect(r.stdout).toContain("Not blocking (warn-only)"); + }); + + it("exits 0 with findings when the pipeline config disables blocking", () => { + const r = runCheck(project, ["--dir", "bad"], { block: false }); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("DROP COLUMN"); + }); + + it("skips allowlisted files and reports the reason", () => { + const r = runCheck(project, ["--dir", "allowed"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("1 allowlisted, skipped"); + expect(r.stdout).toContain("0000_drop.sql: allowlisted — legacy table replaced by KV cache in #42"); + }); + + it("rejects an allowlist marker without a reason", () => { + const r = runCheck(project, ["--dir", "noreason"]); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain(INVALID_ALLOWLIST_LABEL); + expect(r.stderr).toContain("DROP TABLE"); + }); + + it("emits a JSON report with --json", () => { + const r = runCheck(project, ["--dir", "bad", "--json"]); + expect(r.exitCode).toBe(1); + const report = JSON.parse(r.stdout); + expect(report).toMatchObject({ + ok: false, + blocking: true, + scanned: 2, + files: ["0000_init.sql", "0001_drop.sql"], + skipped: [], + }); + // macOS tmpdir is a symlink (/var -> /private/var); the child resolves it. + expect(report.dir).toBe(realpathSync(join(project.dir, "bad"))); + expect(report.findings).toHaveLength(2); + expect(report.findings[0]).toEqual({ + file: "0001_drop.sql", + label: "DROP COLUMN", + line: 1, + excerpt: expect.stringContaining("DROP COLUMN"), + }); + }); + + it("JSON report lists allowlisted files under skipped", () => { + const r = runCheck(project, ["--dir", "allowed", "--json"]); + expect(r.exitCode).toBe(0); + const report = JSON.parse(r.stdout); + expect(report.ok).toBe(true); + expect(report.skipped).toEqual([ + { file: "0000_drop.sql", reason: "legacy table replaced by KV cache in #42" }, + ]); + }); + + it("exits 2 when the migrations directory is missing", () => { + const r = runCheck(project, ["--dir", "does-not-exist"]); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("migrations directory not found"); + }); + + it("exits 2 when no default directory can be resolved", () => { + const r = runNode(SCRIPT, [], { cwd: join(project.dir, "empty"), env: { NERVA_API_DIR: "" } }); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("no migrations directory found"); + }); + + it("resolves the default directory from NERVA_API_DIR", () => { + project.write("apiroot/src/db/migrations/0000_init.sql", 'CREATE TABLE "t" ("id" serial);\n'); + const r = runNode(SCRIPT, [], { + cwd: join(project.dir, "empty"), + env: { NERVA_API_DIR: join(project.dir, "apiroot") }, + }); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("Scanned 1 of 1"); + }); + + it("exits 2 on an unknown option and prints usage", () => { + const r = runCheck(project, ["--bogus"]); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("Unknown option: --bogus"); + expect(r.stderr).toContain("Usage:"); + }); + + it("prints help with --help", () => { + const r = runNode(SCRIPT, ["--help"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("Usage:"); + expect(r.stdout).toContain("nerva:allow-destructive:"); + }); +}); diff --git a/scripts/__tests__/check-doc-counts.test.js b/scripts/__tests__/check-doc-counts.test.js new file mode 100644 index 0000000..468b49e --- /dev/null +++ b/scripts/__tests__/check-doc-counts.test.js @@ -0,0 +1,293 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { join } from "path"; +import { readdirSync, existsSync, statSync } from "fs"; +import { runBash, tmpProject, SCRIPTS_DIR, REPO_ROOT } from "./helpers.js"; + +const SCRIPT = join(SCRIPTS_DIR, "check-doc-counts.sh"); + +/** + * Tests for scripts/check-doc-counts.sh + * + * Two strategies: + * 1. A throwaway fixture tree (via --root) exercises the in-sync and drift + * paths deterministically, including the exclusion rules. + * 2. A run against the real repo asserts that the counts the tool reports + * match what is on disk and that every reported violation is internally + * consistent. The real repo is allowed to have drift (other work may add + * scripts faster than the docs are updated), so this never asserts that + * drift is zero — only that it is reported correctly. + */ + +function run(args = [], opts = {}) { + return runBash(SCRIPT, args, opts); +} + +// --- Disk counts, computed independently of the script ------------------- + +function countAgents(root) { + const dir = join(root, ".claude", "agents"); + if (!existsSync(dir)) return 0; + return readdirSync(dir).filter( + (f) => f.endsWith(".md") && f.toLowerCase() !== "readme.md" && statSync(join(dir, f)).isFile(), + ).length; +} + +function countSkills(root) { + const dir = join(root, ".claude", "skills"); + if (!existsSync(dir)) return 0; + return readdirSync(dir).filter((name) => { + const full = join(dir, name); + return statSync(full).isDirectory() && existsSync(join(full, "SKILL.md")); + }).length; +} + +function countScripts(root) { + const dir = join(root, "scripts"); + if (!existsSync(dir)) return 0; + return readdirSync(dir).filter( + (f) => (f.endsWith(".sh") || f.endsWith(".js")) && statSync(join(dir, f)).isFile(), + ).length; +} + +function countCommands(root) { + const dir = join(root, ".claude", "commands"); + if (!existsSync(dir)) return 0; + return readdirSync(dir).filter((f) => f.endsWith(".md") && statSync(join(dir, f)).isFile()) + .length; +} + +function diskCounts(root) { + return { + agents: countAgents(root), + skills: countSkills(root), + scripts: countScripts(root), + commands: countCommands(root), + }; +} + +// --- Fixture -------------------------------------------------------------- + +/** 2 agents, 2 skills, 2 scripts, 1 command. */ +function buildFixture(project) { + project.write(".claude/agents/alpha.md", "# alpha\n"); + project.write(".claude/agents/beta.md", "# beta\n"); + project.write(".claude/agents/README.md", "# not an agent\n"); + project.write(".claude/skills/x/SKILL.md", "# x\n"); + project.write(".claude/skills/y/SKILL.md", "# y\n"); + project.write(".claude/skills/z/notes.md", "# z has no SKILL.md\n"); + project.write("scripts/one.sh", "#!/usr/bin/env bash\n"); + project.write("scripts/two.js", "// js\n"); + project.write("scripts/lib/common.sh", "# excluded: lib\n"); + project.write("scripts/__tests__/two.test.js", "// excluded: tests\n"); + project.write(".claude/commands/go.md", "# go\n"); + + // Files that must be ignored even though they contain wrong counts. + project.write("CHANGELOG.md", "## 1.0.0\n- shipped 99 agents and 99 skills\n"); + project.write("RELEASE_NOTES.md", "99 scripts\n"); + project.write("docs/plans/2026-01-01-plan.md", "Plan for 99 skills\n"); + project.write("examples/demo/README.md", "This example has 99 scripts and 99 agents\n"); + project.write("node_modules/pkg/README.md", "99 agents\n"); +} + +const IN_SYNC_README = [ + "# Fixture", + "", + "Ships with 2 specialized agents and 2 skills.", + "", + "### Custom Agents (2 Total)", + "", + "**Total Skills:** 2", + "", + "| Category | Count |", + "|----------|-------|", + "| Engineering | 6 |", + "", + "Automation scripts (2 total). Load tests use k6 scripts in tests/load/.", + "", + "**Total Commands:** 1", + "", +].join("\n"); + +describe("check-doc-counts.sh — help flag", () => { + it("shows usage and exits 0", () => { + const r = run(["--help"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("Usage:"); + expect(r.stdout).toContain("--json"); + expect(r.stdout).toContain("--root"); + }); +}); + +describe("check-doc-counts.sh — usage errors", () => { + it("exits 2 on an unknown argument", () => { + const r = run(["--bogus"]); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("Unknown argument"); + }); + + it("exits 2 when --root is not a directory", () => { + const r = run(["--root", "/definitely/not/here"]); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("Not a directory"); + }); + + it("exits 2 when --root has no argument", () => { + const r = run(["--root"]); + expect(r.exitCode).toBe(2); + }); +}); + +describe("check-doc-counts.sh — fixture: in sync", () => { + let project; + beforeAll(() => { + project = tmpProject("nerva-doc-counts-sync-"); + buildFixture(project); + project.write("README.md", IN_SYNC_README); + }); + afterAll(() => project.cleanup()); + + it("counts entries on disk using the documented rules", () => { + const r = run(["--root", project.dir, "--json"]); + const json = JSON.parse(r.stdout); + expect(json.agents).toBe(2); // README.md excluded + expect(json.skills).toBe(2); // z/ has no SKILL.md + expect(json.scripts).toBe(2); // lib/ and __tests__/ excluded + expect(json.commands).toBe(1); + expect(diskCounts(project.dir)).toEqual({ agents: 2, skills: 2, scripts: 2, commands: 1 }); + }); + + it("exits 0 and reports all counts in sync", () => { + const r = run(["--root", project.dir]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("Documentation Count Check"); + expect(r.stdout).toContain("On disk: 2 agents, 2 skills, 2 scripts, 1 commands"); + expect(r.stdout).toContain("match the entries on disk"); + }); + + it("ignores table cells, k6 scripts, and excluded files", () => { + const r = run(["--root", project.dir, "--json"]); + const json = JSON.parse(r.stdout); + expect(json.drift).toBe(0); + expect(json.violations).toEqual([]); + // Scanned: README.md, 3 agent .md files, 3 skill .md files, 1 command .md. + // CHANGELOG.md, RELEASE_NOTES.md, docs/plans/, examples/, node_modules/ are not. + expect(json.docsScanned).toBe(8); + }); +}); + +describe("check-doc-counts.sh — fixture: drift", () => { + let project; + beforeAll(() => { + project = tmpProject("nerva-doc-counts-drift-"); + buildFixture(project); + project.write( + "README.md", + [ + "# Fixture", + "", + "Ships with 5 custom agents and 2 skills.", + "", + "### Skills (7 Total)", + "", + "**Total Scripts:** 2", + "", + ].join("\n"), + ); + project.write("docs/guide.md", "There are 4 commands available.\n"); + }); + afterAll(() => project.cleanup()); + + it("exits 1 and lists file:line with claimed vs actual", () => { + const r = run(["--root", project.dir]); + expect(r.exitCode).toBe(1); + expect(r.stdout).toContain("3 documentation count claim(s) drifted"); + expect(r.stdout).toContain("README.md:3: claims 5 agents, actual 2"); + expect(r.stdout).toContain("README.md:5: claims 7 skills, actual 2"); + expect(r.stdout).toContain("docs/guide.md:1: claims 4 commands, actual 1"); + // The correct claims on the same lines are not reported. + expect(r.stdout).not.toContain("claims 2 skills"); + expect(r.stdout).not.toContain("claims 2 scripts"); + }); + + it("--json describes each violation as a structured object", () => { + const r = run(["--root", project.dir, "--json"]); + expect(r.exitCode).toBe(1); + const json = JSON.parse(r.stdout); + expect(json).toMatchObject({ agents: 2, skills: 2, scripts: 2, commands: 1, drift: 3 }); + expect(json.violations).toHaveLength(3); + for (const v of json.violations) { + expect(typeof v.file).toBe("string"); + expect(typeof v.line).toBe("number"); + expect(typeof v.claimed).toBe("number"); + expect(typeof v.actual).toBe("number"); + expect(typeof v.text).toBe("string"); + expect(["agents", "skills", "scripts", "commands"]).toContain(v.noun); + expect(v.claimed).not.toBe(v.actual); + } + expect(json.violations).toContainEqual({ + file: "README.md", + line: 3, + noun: "agents", + claimed: 5, + actual: 2, + text: "5 custom agents", + }); + expect(json.violations).toContainEqual({ + file: "docs/guide.md", + line: 1, + noun: "commands", + claimed: 4, + actual: 1, + text: "4 commands", + }); + }); +}); + +describe("check-doc-counts.sh — real repository", () => { + it("reports on-disk counts that match an independent count", () => { + const r = run(["--json"], { cwd: REPO_ROOT }); + const json = JSON.parse(r.stdout); + const disk = diskCounts(REPO_ROOT); + expect(json.agents).toBe(disk.agents); + expect(json.skills).toBe(disk.skills); + expect(json.scripts).toBe(disk.scripts); + expect(json.commands).toBe(disk.commands); + expect(json.docsScanned).toBeGreaterThan(0); + }); + + it("reports drift consistently: violations, exit code, and actual counts agree", () => { + const r = run(["--json"], { cwd: REPO_ROOT }); + const json = JSON.parse(r.stdout); + const disk = diskCounts(REPO_ROOT); + expect(Array.isArray(json.violations)).toBe(true); + expect(json.violations).toHaveLength(json.drift); + expect(r.exitCode).toBe(json.drift === 0 ? 0 : 1); + for (const v of json.violations) { + expect(v.actual).toBe(disk[v.noun]); + expect(v.claimed).not.toBe(v.actual); + expect(existsSync(join(REPO_ROOT, v.file))).toBe(true); + expect(v.line).toBeGreaterThan(0); + } + }); + + it("human-readable output names each drifted claim as file:line", () => { + const json = JSON.parse(run(["--json"], { cwd: REPO_ROOT }).stdout); + const r = run([], { cwd: REPO_ROOT }); + const disk = diskCounts(REPO_ROOT); + expect(r.stdout).toContain( + `On disk: ${disk.agents} agents, ${disk.skills} skills, ${disk.scripts} scripts, ${disk.commands} commands`, + ); + for (const v of json.violations) { + expect(r.stdout).toContain( + `${v.file}:${v.line}: claims ${v.claimed} ${v.noun}, actual ${v.actual}`, + ); + } + }); + + it("never treats 'k6 scripts' as a count claim", () => { + const json = JSON.parse(run(["--json"], { cwd: REPO_ROOT }).stdout); + for (const v of json.violations) { + expect(v.text).not.toMatch(/k6/i); + } + }); +}); diff --git a/scripts/__tests__/check-prerequisites.test.js b/scripts/__tests__/check-prerequisites.test.js new file mode 100644 index 0000000..4e2a336 --- /dev/null +++ b/scripts/__tests__/check-prerequisites.test.js @@ -0,0 +1,204 @@ +/** + * Tests for scripts/check-prerequisites.sh + * + * The script's output is a stable, machine-parseable contract (section + * headers, [PASS]/[FAIL]/[SKIP]/[INFO] lines, the summary block, exit codes). + * Tool detection depends on the machine, so assertions on this machine only + * pin the contract; NERVA_PROJECT_ROOT and NERVA_PLUGINS_FILE drive the + * deterministic PROJECT and CLAUDE CODE cases. + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { join } from "path"; +import { mkdirSync, writeFileSync } from "fs"; +import { run, runBash, tmpProject, SCRIPTS_DIR } from "./helpers.js"; + +const SCRIPT = join(SCRIPTS_DIR, "check-prerequisites.sh"); +const SECTIONS = ["REQUIRED SOFTWARE", "OPTIONAL SOFTWARE", "CLAUDE CODE", "PROJECT"]; + +let tmp; +beforeAll(() => { + tmp = tmpProject("nerva-prereq-"); +}); +afterAll(() => tmp.cleanup()); + +describe("check-prerequisites.sh", () => { + it("passes bash -n", () => { + expect(run("bash", ["-n", SCRIPT]).exitCode).toBe(0); + }); + + it("emits every section header with a dashed underline, in order", () => { + const { stdout } = runBash(SCRIPT); + let last = -1; + for (const h of SECTIONS) { + const idx = stdout.indexOf(`\n${h}\n${"-".repeat(h.length)}\n`); + expect(idx, `section ${h}`).toBeGreaterThan(last); + last = idx; + } + }); + + it("every result line uses a known status tag and hints are indented", () => { + const { stdout } = runBash(SCRIPT); + const body = stdout.split("=== Summary ===")[0]; + const lines = body.split("\n").filter((l) => l.trim() !== ""); + const resultLines = lines.filter((l) => l.startsWith("[")); + expect(resultLines.length).toBeGreaterThan(10); + for (const l of resultLines) { + expect(l).toMatch(/^\[(PASS|FAIL|SKIP|INFO)\] \S/); + } + const hintLines = lines.filter((l) => l.startsWith(" ")); + for (const l of hintLines) expect(l).toMatch(/^ {7}\S/); + }); + + it("prints the summary block and a YES|NO verdict with a matching exit code", () => { + const { stdout, exitCode } = runBash(SCRIPT); + expect(stdout).toContain("=== Summary ==="); + expect(stdout).toMatch(/^Required: \d+\/4 passed$/m); + expect(stdout).toMatch(/^Optional: \d+\/10 installed$/m); + expect(stdout).toMatch(/^Claude Code: \d+\/4 ready$/m); + expect(stdout).toMatch(/^Project: \d+\/5 ready$/m); + const verdict = stdout.match(/^Ready to use Nerva: (YES|NO)$/m); + expect(verdict).not.toBeNull(); + expect([0, 1]).toContain(exitCode); + expect(exitCode).toBe(verdict[1] === "YES" ? 0 : 1); + }); + + it("reports required tool minimums and the Node 22 recommendation", () => { + const { stdout } = runBash(SCRIPT); + expect(stdout).toMatch(/\[(PASS|FAIL)\] Git .*minimum: 2\.30\.0/); + expect(stdout).toMatch(/\[(PASS|FAIL)\] Node\.js .*minimum: 20\.0\.0, recommended: 22/); + expect(stdout).toMatch(/\[(PASS|FAIL)\] pnpm .*minimum: 9\.0\.0/); + expect(stdout).toMatch(/\[PASS\] Bash \d/); + }); + + it("the framework checkout reports a ready PROJECT section with api/ as INFO", () => { + const { stdout } = runBash(SCRIPT); + expect(stdout).toContain("[PASS] node_modules present"); + expect(stdout).toContain("[PASS] .claude/pipeline.config.json parses"); + expect(stdout).toContain("[PASS] .husky/pre-commit hook present"); + expect(stdout).toMatch(/\[PASS\] \.claude\/agents contains \d+ agent\(s\)/); + expect(stdout).toMatch(/\[PASS\] \.claude\/skills contains \d+ skill\(s\)/); + expect(stdout).toContain("[INFO] api/ not present"); + expect(stdout).toContain("Project: 5/5 ready"); + }); + + it("NERVA_PROJECT_ROOT pointing at an empty dir makes the PROJECT section fail", () => { + const empty = join(tmp.dir, "empty"); + mkdirSync(empty, { recursive: true }); + const { stdout, exitCode } = runBash(SCRIPT, [], { env: { NERVA_PROJECT_ROOT: empty } }); + expect(stdout).toContain("[FAIL] node_modules missing at repo root"); + expect(stdout).toContain(" Run: pnpm install"); + expect(stdout).toContain("[FAIL] .claude/pipeline.config.json not found"); + expect(stdout).toContain("[FAIL] .husky/pre-commit hook missing"); + expect(stdout).toContain("[FAIL] .claude/agents is missing or empty"); + expect(stdout).toContain("[FAIL] .claude/skills is missing or empty"); + expect(stdout).toContain("Project: 0/5 ready"); + expect(stdout).toContain("Ready to use Nerva: NO"); + expect(exitCode).toBe(1); + }); + + it("a malformed pipeline.config.json is a PROJECT failure", () => { + const root = join(tmp.dir, "broken-config"); + mkdirSync(join(root, ".claude"), { recursive: true }); + writeFileSync(join(root, ".claude", "pipeline.config.json"), "{ nope"); + const { stdout, exitCode } = runBash(SCRIPT, [], { env: { NERVA_PROJECT_ROOT: root } }); + expect(stdout).toContain("[FAIL] .claude/pipeline.config.json is not valid JSON"); + expect(exitCode).toBe(1); + }); + + it("a non-existent NERVA_PROJECT_ROOT is a script error (exit 2)", () => { + const r = runBash(SCRIPT, [], { env: { NERVA_PROJECT_ROOT: join(tmp.dir, "nowhere") } }); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("Project root does not exist"); + }); + + it("NERVA_PLUGINS_FILE pointing at a plugin registry lists installed plugins", () => { + const pluginsFile = tmp.write( + "installed_plugins.json", + JSON.stringify({ + version: 2, + plugins: { + "superpowers@claude-plugins-official": [{ version: "6.3.0" }], + "episodic-memory@claude-plugins-official": [{ version: "1.2.0" }], + }, + }), + ); + const { stdout } = runBash(SCRIPT, [], { env: { NERVA_PLUGINS_FILE: pluginsFile } }); + expect(stdout).toContain("[PASS] superpowers plugin 6.3.0"); + expect(stdout).toContain("[PASS] episodic-memory plugin 1.2.0"); + expect(stdout).toContain("[SKIP] commit-commands plugin not installed"); + expect(stdout).toContain(" Install in Claude Code: /plugin install commit-commands"); + expect(stdout).toContain("[INFO] ai-taskmaster is a local plugin"); + expect(stdout).toMatch(/^Claude Code: [23]\/4 ready$/m); + }); + + it("a missing plugin registry SKIPs every plugin instead of failing", () => { + const missing = join(tmp.dir, "no-such-plugins.json"); + const { stdout } = runBash(SCRIPT, [], { env: { NERVA_PLUGINS_FILE: missing } }); + for (const p of ["episodic-memory", "commit-commands", "superpowers"]) { + expect(stdout).toContain(`[SKIP] ${p} plugin: cannot verify`); + } + expect(stdout).toContain(` Plugin registry not found at ${missing}`); + }); + + it("--json emits the same data as JSON", () => { + const text = runBash(SCRIPT); + const r = runBash(SCRIPT, ["--json"]); + expect(r.exitCode).toBe(text.exitCode); + const out = JSON.parse(r.stdout); + expect(out.ready).toBe(text.exitCode === 0); + expect(out.exitCode).toBe(text.exitCode); + expect(out.summary.required.total).toBe(4); + expect(out.summary.optional.total).toBe(10); + expect(out.summary.claudeCode.total).toBe(4); + expect(out.summary.project.total).toBe(5); + expect(Array.isArray(out.checks)).toBe(true); + // Same results as the text report, in the same order. + const textResults = text.stdout + .split("=== Summary ===")[0] + .split("\n") + .filter((l) => /^\[(PASS|FAIL|SKIP|INFO)\] /.test(l)) + .map((l) => ({ status: l.slice(1, 5), detail: l.slice(7) })); + expect(out.checks.map((c) => ({ status: c.status, detail: c.detail }))).toEqual(textResults); + for (const c of out.checks) { + expect(SECTIONS).toContain(c.section); + expect(["PASS", "FAIL", "SKIP", "INFO"]).toContain(c.status); + expect(Array.isArray(c.hints)).toBe(true); + } + const nm = out.checks.find((c) => c.detail === "node_modules present"); + expect(nm.section).toBe("PROJECT"); + // Summary numbers agree with the per-check statuses. + const passed = out.checks.filter((c) => c.section === "PROJECT" && c.status === "PASS"); + expect(passed).toHaveLength(out.summary.project.ready); + }); + + it("--json carries hints for failing checks", () => { + const empty = join(tmp.dir, "empty-json"); + mkdirSync(empty, { recursive: true }); + const r = runBash(SCRIPT, ["--json"], { env: { NERVA_PROJECT_ROOT: empty } }); + expect(r.exitCode).toBe(1); + const out = JSON.parse(r.stdout); + expect(out.ready).toBe(false); + const nm = out.checks.find((c) => c.detail === "node_modules missing at repo root"); + expect(nm.status).toBe("FAIL"); + expect(nm.hints).toEqual(["Run: pnpm install"]); + }); + + it("--help exits 0 with the output contract", () => { + const r = runBash(SCRIPT, ["--help"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("Output contract"); + expect(r.stdout).toContain("NERVA_PROJECT_ROOT"); + }); + + it("an unknown flag exits 2", () => { + const r = runBash(SCRIPT, ["--wat"]); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("Unknown argument"); + }); + + it("disables colors under NO_COLOR", () => { + const { stdout } = runBash(SCRIPT); + // eslint-disable-next-line no-control-regex + expect(stdout).not.toMatch(/\x1b\[/); + }); +}); diff --git a/scripts/__tests__/generate-migration.test.js b/scripts/__tests__/generate-migration.test.js new file mode 100644 index 0000000..8553a09 --- /dev/null +++ b/scripts/__tests__/generate-migration.test.js @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { join } from "path"; +import { run, runBash, tmpProject, SCRIPTS_DIR } from "./helpers.js"; + +const SCRIPT = join(SCRIPTS_DIR, "generate-migration.sh"); + +describe("generate-migration.sh", () => { + let project; + + beforeAll(() => { + project = tmpProject("nerva-genmig-"); + }); + + afterAll(() => project.cleanup()); + + it("passes bash -n syntax check", () => { + const r = run("bash", ["-n", SCRIPT]); + expect(r.exitCode).toBe(0); + expect(r.stderr).toBe(""); + }); + + it("prints usage and exits 0 with --help", () => { + const r = runBash(SCRIPT, ["--help"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("Usage:"); + expect(r.stdout).toContain("--apply"); + expect(r.stdout).toContain("nerva:allow-destructive:"); + }); + + it("exits 1 with a clear message when the API directory is missing", () => { + const missing = join(project.dir, "no-such-api"); + const r = runBash(SCRIPT, [], { env: { NERVA_API_DIR: missing } }); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain("[ERROR] API directory not found at:"); + expect(r.stderr).toContain(missing); + }); + + it("exits 1 when drizzle.config.ts is missing from the API directory", () => { + project.write("api-no-drizzle/.keep", ""); + const r = runBash(SCRIPT, ["add_users"], { + env: { NERVA_API_DIR: join(project.dir, "api-no-drizzle") }, + }); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain("drizzle.config.ts not found"); + }); + + it("exits 1 when dependencies are not installed", () => { + project.write("api-no-deps/drizzle.config.ts", "export default {};\n"); + const r = runBash(SCRIPT, ["add_users"], { + env: { NERVA_API_DIR: join(project.dir, "api-no-deps") }, + }); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain("Dependencies not installed"); + }); + + it("rejects unknown options with exit 1 and usage", () => { + const r = runBash(SCRIPT, ["--bogus"], { env: { NERVA_API_DIR: project.dir } }); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain("Unknown option: --bogus"); + expect(r.stderr).toContain("Usage:"); + }); +}); diff --git a/scripts/__tests__/helpers.js b/scripts/__tests__/helpers.js new file mode 100644 index 0000000..098cb10 --- /dev/null +++ b/scripts/__tests__/helpers.js @@ -0,0 +1,62 @@ +/** + * Shared helpers for scripts/__tests__. + * + * run(cmd, args, opts) shells out with NO_COLOR set and returns + * { stdout, stderr, exitCode } without throwing on non-zero exit. + */ +import { execFileSync } from "child_process"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "fs"; +import { join, dirname, resolve } from "path"; +import { tmpdir } from "os"; +import { fileURLToPath } from "url"; + +export const TESTS_DIR = dirname(fileURLToPath(import.meta.url)); +export const SCRIPTS_DIR = resolve(TESTS_DIR, ".."); +export const REPO_ROOT = resolve(SCRIPTS_DIR, ".."); +export const HOOKS_DIR = join(REPO_ROOT, ".claude", "hooks"); + +export function run(cmd, args = [], opts = {}) { + const { cwd = REPO_ROOT, env = {}, input, timeout = 60000 } = opts; + try { + const stdout = execFileSync(cmd, args, { + encoding: "utf-8", + cwd, + timeout, + input, + env: { ...process.env, NO_COLOR: "1", ...env }, + stdio: ["pipe", "pipe", "pipe"], + }); + return { stdout, stderr: "", exitCode: 0 }; + } catch (err) { + return { + stdout: err.stdout?.toString() ?? "", + stderr: err.stderr?.toString() ?? "", + exitCode: typeof err.status === "number" ? err.status : 1, + }; + } +} + +export function runBash(scriptPath, args = [], opts = {}) { + return run("bash", [scriptPath, ...args], opts); +} + +export function runNode(scriptPath, args = [], opts = {}) { + return run("node", [scriptPath, ...args], opts); +} + +/** Create a throwaway directory; call the returned cleanup() in afterAll/afterEach. */ +export function tmpProject(prefix = "nerva-test-") { + const dir = mkdtempSync(join(tmpdir(), prefix)); + return { + dir, + write(relPath, content) { + const full = join(dir, relPath); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, content); + return full; + }, + cleanup() { + rmSync(dir, { recursive: true, force: true }); + }, + }; +} diff --git a/scripts/__tests__/hooks.test.js b/scripts/__tests__/hooks.test.js new file mode 100644 index 0000000..39aa6ca --- /dev/null +++ b/scripts/__tests__/hooks.test.js @@ -0,0 +1,465 @@ +/** + * Tests for the Claude Code hooks in .claude/hooks/. + * + * Every hook reads one JSON object on stdin (the Claude Code hook contract): + * { tool_name, tool_input: { command }, tool_response? } + * + * PreToolUse hooks exit 2 to block (message on stderr) and 0 to allow. + * PostToolUse hooks always exit 0; a reminder on stdout, or nothing. + * All hooks must exit 0 (and stay quiet) on empty or malformed stdin. + */ +import { describe, it, expect, afterAll } from "vitest"; +import { join } from "path"; +import { existsSync, readFileSync, readdirSync } from "fs"; +import { run, tmpProject, HOOKS_DIR, REPO_ROOT } from "./helpers.js"; + +const PRE_HOOKS = ["secret-guard.sh", "prod-db-guard.sh"]; +const POST_HOOKS = ["coverage-check.sh", "migration-safety-reminder.sh", "quality-gate-reminder.sh"]; +const ALL_HOOKS = [...PRE_HOOKS, ...POST_HOOKS]; + +const projects = []; +afterAll(() => { + for (const p of projects) p.cleanup(); +}); + +function scratch(prefix = "nerva-hooks-") { + const p = tmpProject(prefix); + projects.push(p); + return p; +} + +function runHook(name, payload, opts = {}) { + const input = typeof payload === "string" ? payload : JSON.stringify(payload); + const { env = {}, ...rest } = opts; + return run("bash", [join(HOOKS_DIR, name)], { + input, + // Neutralise any user-level bypass so assertions are deterministic. + env: { NERVA_SKIP_HOOKS: "", ...env }, + ...rest, + }); +} + +const pre = (command) => ({ + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command }, + cwd: REPO_ROOT, +}); + +const post = (command, stdout = "", stderr = "") => ({ + hook_event_name: "PostToolUse", + tool_name: "Bash", + tool_input: { command }, + tool_response: { stdout, stderr, interrupted: false }, + cwd: REPO_ROOT, +}); + +/** A pipeline.config.json in a scratch dir, returned as env for NERVA_PIPELINE_CONFIG. */ +function configEnv(config) { + const p = scratch("nerva-hooks-cfg-"); + const file = p.write(".claude/pipeline.config.json", JSON.stringify(config)); + return { NERVA_PIPELINE_CONFIG: file }; +} + +/** A throwaway git repo with the given files staged (tracked). */ +function gitRepo(files = {}) { + const p = scratch("nerva-hooks-git-"); + const env = { GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" }; + expect(run("git", ["init", "-q"], { cwd: p.dir, env }).exitCode).toBe(0); + const names = Object.keys(files); + for (const name of names) p.write(name, files[name]); + if (names.length > 0) { + // -f so a global excludes file that ignores .env cannot break the fixture. + expect(run("git", ["add", "-f", "--", ...names], { cwd: p.dir, env }).exitCode).toBe(0); + } + return p; +} + +// --------------------------------------------------------------------------- + +describe("hook files", () => { + const libs = readdirSync(join(HOOKS_DIR, "lib")).filter((f) => f.endsWith(".sh")); + + for (const file of [...ALL_HOOKS, ...libs.map((l) => join("lib", l))]) { + it(`${file} passes bash -n`, () => { + const r = run("bash", ["-n", join(HOOKS_DIR, file)]); + expect(r.stderr).toBe(""); + expect(r.exitCode).toBe(0); + }); + } + + it("lib/hook-input.sh and lib/bash-cmd.sh exist", () => { + expect(existsSync(join(HOOKS_DIR, "lib", "hook-input.sh"))).toBe(true); + expect(existsSync(join(HOOKS_DIR, "lib", "bash-cmd.sh"))).toBe(true); + }); +}); + +describe(".claude/settings.json", () => { + const settings = JSON.parse(readFileSync(join(REPO_ROOT, ".claude", "settings.json"), "utf8")); + + function entries(event) { + const groups = settings.hooks?.[event] ?? []; + return groups.flatMap((g) => { + expect(g.matcher).toBe("Bash"); + return g.hooks; + }); + } + + it("wires every PreToolUse hook with a description and an existing script", () => { + const wired = entries("PreToolUse"); + for (const h of wired) { + expect(h.type).toBe("command"); + expect(h.description?.length ?? 0).toBeGreaterThan(10); + const m = h.command.match(/^bash \.claude\/hooks\/([\w-]+\.sh)$/); + expect(m, `unexpected command: ${h.command}`).not.toBeNull(); + expect(existsSync(join(HOOKS_DIR, m[1]))).toBe(true); + } + expect(wired.map((h) => h.command.split("/").pop()).sort()).toEqual([...PRE_HOOKS].sort()); + }); + + it("wires every PostToolUse hook with a description and an existing script", () => { + const wired = entries("PostToolUse"); + for (const h of wired) { + expect(h.type).toBe("command"); + expect(h.description?.length ?? 0).toBeGreaterThan(10); + const m = h.command.match(/^bash \.claude\/hooks\/([\w-]+\.sh)$/); + expect(m, `unexpected command: ${h.command}`).not.toBeNull(); + expect(existsSync(join(HOOKS_DIR, m[1]))).toBe(true); + } + expect(wired.map((h) => h.command.split("/").pop()).sort()).toEqual([...POST_HOOKS].sort()); + }); +}); + +// --------------------------------------------------------------------------- + +describe("secret-guard.sh (PreToolUse)", () => { + const blocks = [ + "git add .env", + "git add -f secret.pem", + "git add --force src/", + "git add -Af src/", + "git add api/.env.local", + "git add config/credentials.json", + "git add certs/server.key", + "git add certs/client.p12", + "git add .dev.vars", + "git stage ./.env", + 'git add "api/.env"', + "cd api && git add .env", + "git add src/ ; git add .env", + "git add src/ || git add .env", + "(cd api && git add .env)", + "git -C api add .env", + "FOO=1 git add .env", + "echo `git add .env`", + "cat < x\nfoo\nEOF\ngit add .env", + ]; + for (const command of blocks) { + it(`blocks: ${JSON.stringify(command)}`, () => { + const r = runHook("secret-guard.sh", pre(command)); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("[secret-guard]"); + expect(r.stdout).toBe(""); + }); + } + + it("names the offending token", () => { + expect(runHook("secret-guard.sh", pre("git add .env")).stderr).toContain("(.env)"); + expect(runHook("secret-guard.sh", pre("git add -f secret.pem")).stderr).toContain("(-f)"); + expect(runHook("secret-guard.sh", pre("git add api/.env.local")).stderr).toContain("api/.env.local"); + }); + + const allows = [ + "git add .env.example", + "git add .env.local.example", + "git add src/", + "git add .", + "git add -- src", + "git add src/env.ts", + 'git commit -m "add .env support"', + 'git commit -m "chore: git add .env to ignore"', + 'echo "git add .env"', + "# git add .env", + "cat <<'EOF' > notes.md\ngit add .env\nEOF\ngit status", + "cat <<-EOF > notes.md\n\tgit add .env\n\tEOF\ngit status 2>&1", + "git log --oneline | head", + "git status && git diff", + "ls -la", + ]; + for (const command of allows) { + it(`allows: ${JSON.stringify(command)}`, () => { + const r = runHook("secret-guard.sh", pre(command)); + expect(r.exitCode).toBe(0); + expect(r.stderr).toBe(""); + }); + } + + it("blocks commit -a / -am / --all when a secret file is tracked", () => { + const repo = gitRepo({ ".env": "SECRET=1\n", "src/index.ts": "export {};\n" }); + for (const command of ['git commit -am "wip"', 'git commit -a -m "wip"', 'git commit --all -m "wip"']) { + const r = runHook("secret-guard.sh", pre(command), { cwd: repo.dir }); + expect(r.exitCode, command).toBe(2); + expect(r.stderr).toContain("[secret-guard]"); + expect(r.stderr).toContain(".env"); + } + }); + + it("allows commit -a when only .env.example is tracked, and plain commit anywhere", () => { + const clean = gitRepo({ ".env.example": "SECRET=\n", "src/index.ts": "export {};\n" }); + expect(runHook("secret-guard.sh", pre('git commit -am "wip"'), { cwd: clean.dir }).exitCode).toBe(0); + + const dirty = gitRepo({ "certs/server.pem": "---\n" }); + expect(runHook("secret-guard.sh", pre('git commit -m "wip"'), { cwd: dirty.dir }).exitCode).toBe(0); + expect(runHook("secret-guard.sh", pre('git commit -am "wip"'), { cwd: dirty.dir }).exitCode).toBe(2); + }); + + it("ignores non-Bash tools", () => { + const r = runHook("secret-guard.sh", { tool_name: "Write", tool_input: { file_path: ".env", content: "x" } }); + expect(r.exitCode).toBe(0); + }); + + it("honours NERVA_SKIP_HOOKS=1 from the hook environment", () => { + const r = runHook("secret-guard.sh", pre("git add .env"), { env: { NERVA_SKIP_HOOKS: "1" } }); + expect(r.exitCode).toBe(0); + }); + + it("works through the node JSON fallback", () => { + const r = runHook("secret-guard.sh", pre("git add .env"), { env: { NERVA_HOOK_JSON_TOOL: "node" } }); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("(.env)"); + }); +}); + +// --------------------------------------------------------------------------- + +describe("prod-db-guard.sh (PreToolUse)", () => { + const blocks = [ + ["wrangler secret put X && drizzle-kit push --env production", "production"], + ["npx drizzle-kit push --env=production", "production"], + ["DATABASE_URL=postgres://user@db.prod.internal/app pnpm drizzle-kit push", "prod"], + ["NODE_ENV=production pnpm db:push", "NODE_ENV=production"], + ['PROD_DATABASE_URL=postgres://x psql -c "TRUNCATE users"', "TRUNCATE"], + ['psql "$PROD_DATABASE_URL" -c "DROP SCHEMA public CASCADE"', "DROP SCHEMA"], + ['PROD_DATABASE_URL=postgres://x pnpm db:seed -- --sql "drop database app"', /drop database/i], + ["drizzle-kit drop", "drop"], + ["pnpm drizzle-kit drop", "drop"], + ["cd api && npx drizzle-kit drop", "drop"], + ]; + for (const [command, token] of blocks) { + it(`blocks: ${JSON.stringify(command)}`, () => { + const r = runHook("prod-db-guard.sh", pre(command)); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("[prod-db-guard]"); + if (token instanceof RegExp) expect(r.stderr).toMatch(token); + else expect(r.stderr).toContain(token); + expect(r.stdout).toBe(""); + }); + } + + const allows = [ + "drizzle-kit push", + "pnpm drizzle-kit push", + "pnpm db:push", + "drizzle-kit generate", + "pnpm drizzle-kit generate && pnpm drizzle-kit migrate", + 'psql $PROD_DATABASE_URL -c "SELECT count(*) FROM users"', + "PROD_DATABASE_URL=postgres://x pnpm db:migrate", + 'psql dev -c "TRUNCATE users"', + 'git commit -m "docs: drop database section"', + 'echo "drizzle-kit drop"', + 'grep -r "drizzle-kit push" docs/', + "pnpm test", + ]; + for (const command of allows) { + it(`allows: ${JSON.stringify(command)}`, () => { + const r = runHook("prod-db-guard.sh", pre(command)); + expect(r.exitCode).toBe(0); + expect(r.stderr).toBe(""); + }); + } + + it("honours NERVA_SKIP_HOOKS=1 from the hook environment", () => { + const r = runHook("prod-db-guard.sh", pre("drizzle-kit drop"), { env: { NERVA_SKIP_HOOKS: "1" } }); + expect(r.exitCode).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- + +describe("coverage-check.sh (PostToolUse)", () => { + const coverageTable = [ + "% Coverage report from v8", + "-----------|---------|----------|---------|---------|", + "File | % Stmts | % Branch | % Funcs | % Lines |", + "All files | 91.2 | 80.5 | 88.9 | 91.2 |", + ].join("\n"); + + it("fires for vitest + coverage using tdd.coverageThreshold from NERVA_PIPELINE_CONFIG", () => { + const env = configEnv({ tdd: { coverageThreshold: 85 } }); + const r = runHook("coverage-check.sh", post("pnpm vitest run --coverage", coverageTable), { env }); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("[coverage-check]"); + expect(r.stdout).toContain("85%"); + expect(r.stdout).toContain("91.2%"); + expect(r.stdout).toMatch(/meets/); + }); + + it("flags coverage below the threshold", () => { + const env = configEnv({ tdd: { coverageThreshold: 85 } }); + const r = runHook("coverage-check.sh", post("vitest --coverage", "% Coverage report from v8\nAll files | 62.5 | 50 |"), { env }); + expect(r.stdout).toContain("62.5%"); + expect(r.stdout).toContain("BELOW"); + expect(r.stdout).toContain("85%"); + }); + + it("falls back to 80% when the config file is missing", () => { + const p = scratch(); + const env = { NERVA_PIPELINE_CONFIG: join(p.dir, "does-not-exist.json") }; + const r = runHook("coverage-check.sh", post("pnpm vitest run --coverage", "Coverage enabled with v8"), { env }); + expect(r.stdout).toContain("[coverage-check]"); + expect(r.stdout).toContain("80%"); + }); + + it("accepts a string tool_response", () => { + const env = configEnv({ tdd: { coverageThreshold: 90 } }); + const payload = { tool_name: "Bash", tool_input: { command: "vitest run --coverage" }, tool_response: "Coverage summary" }; + const r = runHook("coverage-check.sh", payload, { env }); + expect(r.stdout).toContain("90%"); + }); + + it("reads coverage from stderr too", () => { + const r = runHook("coverage-check.sh", post("pnpm vitest run --coverage", "", "Coverage enabled")); + expect(r.stdout).toContain("[coverage-check]"); + }); + + it("works through the node JSON fallback", () => { + const env = { ...configEnv({ tdd: { coverageThreshold: 77 } }), NERVA_HOOK_JSON_TOOL: "node" }; + const r = runHook("coverage-check.sh", post("pnpm vitest run --coverage", coverageTable), { env }); + expect(r.stdout).toContain("77%"); + }); + + it("stays silent for vitest without coverage output", () => { + const r = runHook("coverage-check.sh", post("pnpm vitest run", "Tests 12 passed (12)")); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe(""); + }); + + it("stays silent for non-vitest commands even when the output mentions coverage", () => { + const r = runHook("coverage-check.sh", post("pnpm build", "Coverage: full")); + expect(r.stdout).toBe(""); + }); +}); + +// --------------------------------------------------------------------------- + +describe("migration-safety-reminder.sh (PostToolUse)", () => { + const generated = "[✓] Your SQL migration file ➜ drizzle/0001_bold_hulk.sql 🚀"; + + it("after drizzle-kit generate in the framework repo, points at check-destructive-migrations.js", () => { + const p = scratch(); + p.write("scripts/check-destructive-migrations.js", "// stub\n"); + const r = runHook("migration-safety-reminder.sh", post("pnpm drizzle-kit generate", generated), { cwd: p.dir }); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("[migration-safety]"); + expect(r.stdout).toContain("node scripts/check-destructive-migrations.js"); + expect(r.stdout).toMatch(/read the new SQL/i); + }); + + it("after drizzle-kit generate in a generated api/ project, points at pnpm db:check-destructive", () => { + const p = scratch(); + p.write("package.json", JSON.stringify({ scripts: { "db:check-destructive": "node scripts/check-destructive.js" } })); + const r = runHook("migration-safety-reminder.sh", post("pnpm db:generate", generated), { cwd: p.dir }); + expect(r.stdout).toContain("pnpm db:check-destructive"); + expect(r.stdout).not.toContain("node scripts/check-destructive-migrations.js"); + }); + + it("stays silent when generate produced nothing or failed", () => { + const p = scratch(); + p.write("scripts/check-destructive-migrations.js", "// stub\n"); + const none = runHook("migration-safety-reminder.sh", post("drizzle-kit generate", "No schema changes, nothing to migrate 😴"), { cwd: p.dir }); + expect(none.stdout).toBe(""); + const failed = runHook("migration-safety-reminder.sh", post("drizzle-kit generate", "", "Error: cannot find drizzle.config.ts"), { cwd: p.dir }); + expect(failed.stdout).toBe(""); + const empty = runHook("migration-safety-reminder.sh", post("drizzle-kit generate", ""), { cwd: p.dir }); + expect(empty.stdout).toBe(""); + }); + + it("reminds that push is dev-only after any drizzle-kit push", () => { + for (const command of ["drizzle-kit push", "pnpm drizzle-kit push --force", "pnpm db:push"]) { + const r = runHook("migration-safety-reminder.sh", post(command, "[✓] Changes applied")); + expect(r.exitCode, command).toBe(0); + expect(r.stdout).toContain("[migration-safety]"); + expect(r.stdout).toMatch(/dev-only/); + expect(r.stdout).toContain("migrate"); + } + }); + + it("stays silent for unrelated commands", () => { + const r = runHook("migration-safety-reminder.sh", post("pnpm drizzle-kit studio", "Drizzle Studio is up")); + expect(r.stdout).toBe(""); + }); +}); + +// --------------------------------------------------------------------------- + +describe("quality-gate-reminder.sh (PostToolUse)", () => { + const passed = "Test Files 3 passed (3)\n Tests 42 passed (42)\n Duration 1.20s"; + + it("fires when every vitest test passed, with the configured threshold", () => { + const env = configEnv({ tdd: { coverageThreshold: 85 } }); + const r = runHook("quality-gate-reminder.sh", post("pnpm vitest run", passed), { env }); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("[quality-gate]"); + expect(r.stdout).toContain("./scripts/check-types.sh"); + expect(r.stdout).toContain("./scripts/security-scan.sh"); + expect(r.stdout).toContain("85%"); + }); + + it("also fires for pnpm test and run-tests.sh", () => { + expect(runHook("quality-gate-reminder.sh", post("pnpm test", passed)).stdout).toContain("[quality-gate]"); + expect(runHook("quality-gate-reminder.sh", post("./scripts/run-tests.sh", passed)).stdout).toContain("[quality-gate]"); + }); + + it("stays silent when any test failed", () => { + const r = runHook("quality-gate-reminder.sh", post("pnpm vitest run", "Tests 1 failed | 41 passed (42)")); + expect(r.stdout).toBe(""); + }); + + it("stays silent without a passing summary or for non-test commands", () => { + expect(runHook("quality-gate-reminder.sh", post("pnpm vitest run", "compiling...")).stdout).toBe(""); + expect(runHook("quality-gate-reminder.sh", post("pnpm build", passed)).stdout).toBe(""); + expect(runHook("quality-gate-reminder.sh", post("pnpm vitest run", "")).stdout).toBe(""); + }); +}); + +// --------------------------------------------------------------------------- + +describe("robustness: every hook fails open on bad input", () => { + const badInputs = [ + ["empty stdin", ""], + ["plain text", "not json at all"], + ["empty object", "{}"], + ["missing tool_input", JSON.stringify({ tool_name: "Bash" })], + ["tool_input is a string", JSON.stringify({ tool_name: "Bash", tool_input: "git add .env" })], + ["array", "[1,2,3]"], + ["truncated JSON", '{"tool_name":"Bash","tool_input":{"command":"git add .env"'], + ]; + + for (const hook of ALL_HOOKS) { + for (const [label, input] of badInputs) { + it(`${hook} exits 0 silently on ${label}`, () => { + const r = runHook(hook, input); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe(""); + expect(r.stderr).toBe(""); + }); + } + } + + for (const hook of POST_HOOKS) { + it(`${hook} exits 0 when tool_response is missing`, () => { + const r = runHook(hook, { tool_name: "Bash", tool_input: { command: "pnpm vitest run --coverage && drizzle-kit generate" } }); + expect(r.exitCode).toBe(0); + }); + } +}); diff --git a/scripts/__tests__/pipeline-config-lib.test.js b/scripts/__tests__/pipeline-config-lib.test.js new file mode 100644 index 0000000..9040ab3 --- /dev/null +++ b/scripts/__tests__/pipeline-config-lib.test.js @@ -0,0 +1,150 @@ +/** + * Tests for scripts/lib/pipeline-config.js — the shared config reader used by + * scripts and hooks (getValue/loadConfig as ESM, and the `get` CLI that + * common.sh's common_config_get wraps). + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { join } from "path"; +import { runNode, run, tmpProject, SCRIPTS_DIR, REPO_ROOT } from "./helpers.js"; +import { getValue, loadConfig, renderForShell, DEFAULT_CONFIG_PATH } from "../lib/pipeline-config.js"; + +const LIB = join(SCRIPTS_DIR, "lib", "pipeline-config.js"); +const COMMON = join(SCRIPTS_DIR, "lib", "common.sh"); + +let tmp; +let customConfig; + +beforeAll(() => { + tmp = tmpProject("nerva-config-lib-"); + customConfig = tmp.write( + "custom.json", + JSON.stringify({ + version: "9.9.9", + tdd: { coverageThreshold: 42, enforced: false }, + nested: { list: [1, 2], obj: { a: "b" }, nul: null }, + }), + ); +}); +afterAll(() => tmp.cleanup()); + +describe("pipeline-config.js (library)", () => { + it("DEFAULT_CONFIG_PATH points at .claude/pipeline.config.json in the repo", () => { + expect(DEFAULT_CONFIG_PATH).toBe(join(REPO_ROOT, ".claude", "pipeline.config.json")); + }); + + it("loadConfig reads the real config", () => { + const cfg = loadConfig(); + expect(cfg.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(cfg.tdd.enforced).toBe(true); + }); + + it("getValue reads dotted paths from the real config", () => { + expect(getValue("tdd.coverageThreshold")).toBe(80); + expect(getValue("deployment.defaultTarget")).toBe("cloudflare-workers"); + expect(getValue("orchestration.phases.report.depends")).toEqual([ + "quality-gate", + "documentation", + ]); + }); + + it("getValue returns the default for a missing key", () => { + expect(getValue("does.not.exist", "fallback")).toBe("fallback"); + expect(getValue("does.not.exist")).toBeUndefined(); + expect(getValue("tdd.coverageThreshold.deeper", 7)).toBe(7); + }); + + it("getValue honors a boolean false default", () => { + expect(getValue("nope.nothing", false)).toBe(false); + expect(getValue("nope.nothing", 0)).toBe(0); + expect(getValue("nope.nothing", "")).toBe(""); + }); + + it("getValue returns a stored false rather than the default", () => { + expect(getValue("database.uuidPrimaryKeys", true)).toBe(false); + }); + + it("getValue accepts an explicit config path", () => { + expect(getValue("tdd.coverageThreshold", 80, customConfig)).toBe(42); + expect(getValue("tdd.enforced", true, customConfig)).toBe(false); + expect(getValue("nested.nul", "dflt", customConfig)).toBeNull(); + }); + + it("loadConfig returns {} for a missing or malformed file", () => { + expect(loadConfig(join(tmp.dir, "missing.json"))).toEqual({}); + const broken = tmp.write("broken.json", "{ nope"); + expect(loadConfig(broken)).toEqual({}); + }); + + it("renderForShell formats values for bash consumption", () => { + expect(renderForShell(true)).toBe("true"); + expect(renderForShell(false)).toBe("false"); + expect(renderForShell(80)).toBe("80"); + expect(renderForShell("x")).toBe("x"); + expect(renderForShell(null)).toBe(""); + expect(renderForShell(undefined)).toBe(""); + expect(renderForShell([1, 2])).toBe("[1,2]"); + expect(renderForShell({ a: "b" })).toBe('{"a":"b"}'); + }); +}); + +describe("pipeline-config.js (CLI)", () => { + it("get prints a value from the real config", () => { + const r = runNode(LIB, ["get", "tdd.coverageThreshold"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe("80"); + }); + + it("get prints the default for a missing key", () => { + const r = runNode(LIB, ["get", "no.such.key", "dflt"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe("dflt"); + }); + + it("get prints an empty string for a missing key with no default", () => { + const r = runNode(LIB, ["get", "no.such.key"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe(""); + }); + + it("get round-trips a false default", () => { + const r = runNode(LIB, ["get", "no.such.key", "false"]); + expect(r.stdout).toBe("false"); + }); + + it("get renders booleans and objects for shell", () => { + expect(runNode(LIB, ["get", "tdd.enforced"]).stdout).toBe("true"); + expect(runNode(LIB, ["get", "database.uuidPrimaryKeys", "true"]).stdout).toBe("false"); + expect(JSON.parse(runNode(LIB, ["get", "deployment.targets"]).stdout)).toContain("fly"); + }); + + it("NERVA_PIPELINE_CONFIG overrides the config path", () => { + const env = { NERVA_PIPELINE_CONFIG: customConfig }; + expect(runNode(LIB, ["get", "tdd.coverageThreshold", "80"], { env }).stdout).toBe("42"); + expect(runNode(LIB, ["get", "version"], { env }).stdout).toBe("9.9.9"); + expect(runNode(LIB, ["get", "tdd.enforced", "true"], { env }).stdout).toBe("false"); + }); + + it("NERVA_PIPELINE_CONFIG pointing at a missing file yields the default, exit 0", () => { + const env = { NERVA_PIPELINE_CONFIG: join(tmp.dir, "absent.json") }; + const r = runNode(LIB, ["get", "tdd.coverageThreshold", "55"], { env }); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe("55"); + }); + + it("bad usage still exits 0 and prints the default", () => { + const r = runNode(LIB, ["frobnicate", "x", "d"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe("d"); + // helpers.run() drops stderr on a zero exit, so the usage line printed to + // stderr is not observable here; the contract under test is exit 0 + default. + }); + + it("common_config_get in common.sh wraps the CLI", () => { + const script = `source "${COMMON}"; common_config_get tdd.coverageThreshold 0; echo; common_config_get missing.key dflt`; + const r = run("bash", ["-c", script]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe("80\ndflt"); + const env = { NERVA_PIPELINE_CONFIG: customConfig }; + expect(run("bash", ["-c", `source "${COMMON}"; common_config_get tdd.coverageThreshold 0`], { env }).stdout).toBe("42"); + }); +}); diff --git a/scripts/__tests__/validate-pipeline-config.test.js b/scripts/__tests__/validate-pipeline-config.test.js new file mode 100644 index 0000000..df674d3 --- /dev/null +++ b/scripts/__tests__/validate-pipeline-config.test.js @@ -0,0 +1,258 @@ +/** + * Tests for scripts/validate-pipeline-config.js + * + * The real .claude/pipeline.config.json must validate cleanly against + * .claude/pipeline.config.schema.json. Mutated copies exercise the schema + * (unknown keys) and the structural checks (phase graph, deployment targets). + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { readFileSync } from "fs"; +import { join } from "path"; +import { run, runNode, tmpProject, REPO_ROOT, SCRIPTS_DIR } from "./helpers.js"; + +const SCRIPT = join(SCRIPTS_DIR, "validate-pipeline-config.js"); +const CONFIG = join(REPO_ROOT, ".claude", "pipeline.config.json"); +const SCHEMA = join(REPO_ROOT, ".claude", "pipeline.config.schema.json"); + +let tmp; +let baseConfig; + +beforeAll(() => { + tmp = tmpProject("nerva-validate-"); + baseConfig = JSON.parse(readFileSync(CONFIG, "utf8")); +}); +afterAll(() => tmp.cleanup()); + +/** Write a mutated copy of the real config and validate it with --json. */ +function validateMutated(name, mutate) { + const config = structuredClone(baseConfig); + mutate(config); + const path = tmp.write(`${name}.json`, JSON.stringify(config, null, 2)); + const r = runNode(SCRIPT, ["--config", path, "--json"]); + return { ...r, result: r.stdout ? JSON.parse(r.stdout) : null }; +} + +describe("validate-pipeline-config.js", () => { + it("passes node --check", () => { + expect(run("node", ["--check", SCRIPT]).exitCode).toBe(0); + }); + + it("the real config validates against the real schema (exit 0)", () => { + const r = runNode(SCRIPT, []); + expect(r.stderr).toBe(""); + expect(r.stdout).toContain("VALID: pipeline config passes"); + expect(r.exitCode).toBe(0); + }); + + it("the config's $schema points at the local schema file", () => { + expect(baseConfig.$schema).toBe("./pipeline.config.schema.json"); + }); + + it("--json emits {valid, errors, warnings} for the real config", () => { + const r = runNode(SCRIPT, ["--json"]); + expect(r.exitCode).toBe(0); + const out = JSON.parse(r.stdout); + expect(out).toEqual({ valid: true, errors: [], warnings: [] }); + }); + + it("--help exits 0 and prints usage", () => { + const r = runNode(SCRIPT, ["--help"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("Usage:"); + expect(r.stdout).toContain("--config"); + expect(r.stdout).toContain("--json"); + }); + + it("an unknown flag exits 2", () => { + const r = runNode(SCRIPT, ["--bogus"]); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("Unknown argument"); + }); + + it("a missing config file exits 2", () => { + const r = runNode(SCRIPT, ["--config", join(tmp.dir, "does-not-exist.json")]); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("not found"); + }); + + it("a missing config file with --json exits 2 and reports valid:false", () => { + const r = runNode(SCRIPT, ["--config", join(tmp.dir, "nope.json"), "--json"]); + expect(r.exitCode).toBe(2); + const out = JSON.parse(r.stdout); + expect(out.valid).toBe(false); + expect(out.errors[0].message).toContain("not found"); + }); + + it("malformed JSON exits 2", () => { + const path = tmp.write("broken.json", "{ not json"); + const r = runNode(SCRIPT, ["--config", path]); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("Failed to parse"); + }); + + it("honors NERVA_PIPELINE_CONFIG when --config is omitted", () => { + const path = tmp.write("env.json", JSON.stringify({ version: "1.0.0" })); + const r = runNode(SCRIPT, ["--json"], { env: { NERVA_PIPELINE_CONFIG: path } }); + expect(r.exitCode).toBe(0); + expect(JSON.parse(r.stdout).valid).toBe(true); + }); + + it("an unknown top-level key fails with a JSON-pointer path", () => { + const { exitCode, result } = validateMutated("unknown-top", (c) => { + c.bogusSection = { a: 1 }; + }); + expect(exitCode).toBe(1); + expect(result.valid).toBe(false); + expect(result.errors).toContainEqual({ + path: "/", + message: 'unexpected property "bogusSection"', + }); + }); + + it("an unknown nested key fails at every object level", () => { + const { exitCode, result } = validateMutated("unknown-nested", (c) => { + c.deployment.awsLambda.coldStartMagic = true; + }); + expect(exitCode).toBe(1); + expect(result.errors.map((e) => e.path)).toContain("/deployment/awsLambda"); + expect(result.errors[0].message).toContain("coldStartMagic"); + }); + + it("an enum violation lists the allowed values", () => { + const { exitCode, result } = validateMutated("bad-enum", (c) => { + c.auth.defaultStrategy = "magic-link"; + }); + expect(exitCode).toBe(1); + const err = result.errors.find((e) => e.path === "/auth/defaultStrategy"); + expect(err).toBeDefined(); + expect(err.message).toContain('"jwt"'); + }); + + it("a percentage above 100 fails", () => { + const { exitCode, result } = validateMutated("bad-pct", (c) => { + c.qualityGate.coverageThreshold = 150; + }); + expect(exitCode).toBe(1); + expect(result.errors.some((e) => e.path === "/qualityGate/coverageThreshold")).toBe(true); + }); + + it("a malformed duration fails", () => { + const { exitCode, result } = validateMutated("bad-duration", (c) => { + c.auth.tokenExpiry = "fifteen minutes"; + }); + expect(exitCode).toBe(1); + expect(result.errors.some((e) => e.path === "/auth/tokenExpiry")).toBe(true); + }); + + it("a non-semver version fails", () => { + const { exitCode, result } = validateMutated("bad-version", (c) => { + c.version = "1.0"; + }); + expect(exitCode).toBe(1); + const versionErrors = result.errors.filter((e) => e.path === "/version"); + expect(versionErrors).toHaveLength(1); + }); + + it("a phase dependency cycle fails", () => { + const { exitCode, result } = validateMutated("cycle", (c) => { + c.orchestration.phases["schema-intake"].depends = ["report"]; + }); + expect(exitCode).toBe(1); + const cycle = result.errors.find((e) => e.path === "/orchestration/phases"); + expect(cycle).toBeDefined(); + expect(cycle.message).toContain("dependency cycle"); + expect(cycle.message).toContain("schema-intake"); + expect(cycle.message).toContain("report"); + }); + + it("a depends entry naming a missing phase fails", () => { + const { exitCode, result } = validateMutated("missing-dep", (c) => { + c.orchestration.phases["report"].depends.push("nonexistent-phase"); + }); + expect(exitCode).toBe(1); + expect(result.errors).toContainEqual({ + path: "/orchestration/phases/report/depends", + message: 'references unknown phase "nonexistent-phase"', + }); + // A missing dependency must not also be reported as a cycle. + expect(result.errors.some((e) => e.message.includes("dependency cycle"))).toBe(false); + // report is blocking, so it is also flagged as unreachable. + expect(result.errors.some((e) => e.path === "/orchestration/phases/report")).toBe(true); + }); + + it("a non-blocking phase with a missing dependency is not flagged as unreachable", () => { + const { result } = validateMutated("missing-dep-nonblocking", (c) => { + c.orchestration.phases["documentation"].depends = ["ghost"]; + }); + expect(result.errors.some((e) => e.path === "/orchestration/phases/documentation")).toBe( + false, + ); + expect( + result.errors.some((e) => e.path === "/orchestration/phases/documentation/depends"), + ).toBe(true); + }); + + it("empty qualityGateSubtasks fails when orchestration is enabled", () => { + const { exitCode, result } = validateMutated("no-subtasks", (c) => { + c.orchestration.qualityGateSubtasks = {}; + }); + expect(exitCode).toBe(1); + expect(result.errors.some((e) => e.path === "/orchestration/qualityGateSubtasks")).toBe(true); + }); + + it("empty qualityGateSubtasks is allowed when orchestration is disabled", () => { + const { exitCode } = validateMutated("no-subtasks-disabled", (c) => { + c.orchestration.enabled = false; + c.orchestration.qualityGateSubtasks = {}; + }); + expect(exitCode).toBe(0); + }); + + it("defaultTarget not in targets fails", () => { + const { exitCode, result } = validateMutated("bad-target", (c) => { + c.deployment.targets = ["node-docker", "fly"]; + c.deployment.defaultTarget = "cloudflare-workers"; + }); + expect(exitCode).toBe(1); + const err = result.errors.find((e) => e.path === "/deployment/defaultTarget"); + expect(err).toBeDefined(); + expect(err.message).toContain("cloudflare-workers"); + }); + + it("coverage threshold drift is a warning, not an error", () => { + const { exitCode, result } = validateMutated("coverage-drift", (c) => { + c.tdd.coverageThreshold = 70; + }); + expect(exitCode).toBe(0); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].path).toBe("/tdd/coverageThreshold"); + expect(result.warnings[0].message).toContain("70 vs 80"); + }); + + it("human output lists errors with paths", () => { + const config = structuredClone(baseConfig); + config.deployment.defaultTarget = "railway"; + config.deployment.targets = ["fly"]; + const path = tmp.write("human.json", JSON.stringify(config)); + const r = runNode(SCRIPT, ["--config", path]); + expect(r.exitCode).toBe(1); + expect(r.stdout).toContain("Errors (1):"); + expect(r.stdout).toContain("/deployment/defaultTarget"); + expect(r.stdout).toContain("INVALID"); + }); + + it("accepts an alternate --schema", () => { + const schema = JSON.parse(readFileSync(SCHEMA, "utf8")); + schema.required = ["version", "database"]; + const schemaPath = tmp.write("alt-schema.json", JSON.stringify(schema)); + const configPath = tmp.write("alt-config.json", JSON.stringify({ version: "1.0.0" })); + const r = runNode(SCRIPT, ["--config", configPath, "--schema", schemaPath, "--json"]); + expect(r.exitCode).toBe(1); + expect(JSON.parse(r.stdout).errors).toContainEqual({ + path: "/", + message: 'missing required property "database"', + }); + }); +}); diff --git a/scripts/__tests__/verify-all.test.js b/scripts/__tests__/verify-all.test.js new file mode 100644 index 0000000..06c1984 --- /dev/null +++ b/scripts/__tests__/verify-all.test.js @@ -0,0 +1,333 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { join } from "path"; +import { runBash, tmpProject, SCRIPTS_DIR, REPO_ROOT } from "./helpers.js"; + +const SCRIPT = join(SCRIPTS_DIR, "verify-all.sh"); + +/** + * Tests for scripts/verify-all.sh + * + * Fast, deterministic checks (shell-syntax, json) are exercised against the + * real repo via --include. Everything that depends on which backing scripts + * exist, or on a check failing, uses a throwaway fixture tree via --root with + * stub scripts that exit with a known code. Fixtures never contain a + * package.json, so the (slow, recursive) script-tests check always skips. + */ + +const ALL_CHECKS = [ + "shell-syntax", + "js-syntax", + "json", + "pipeline-config", + "doc-counts", + "script-tests", + "types", + "tests", + "security", + "destructive-migrations", +]; + +const projects = []; +afterAll(() => { + for (const p of projects) p.cleanup(); +}); + +function fixture() { + const p = tmpProject("nerva-verify-all-"); + projects.push(p); + return p; +} + +function run(args = [], opts = {}) { + return runBash(SCRIPT, args, { cwd: REPO_ROOT, ...opts }); +} + +function parse(r) { + let json; + try { + json = JSON.parse(r.stdout); + } catch (err) { + throw new Error(`stdout is not JSON (${err.message}):\n${r.stdout}\n--- stderr ---\n${r.stderr}`); + } + return json; +} + +function byName(json, name) { + return json.checks.find((c) => c.name === name); +} + +describe("--list", () => { + it("prints every check name in run order and exits 0", () => { + const r = run(["--list"]); + expect(r.exitCode).toBe(0); + expect(r.stdout.trim().split("\n")).toEqual(ALL_CHECKS); + }); +}); + +describe("--help", () => { + it("prints usage and exits 0", () => { + const r = run(["--help"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("Usage:"); + for (const flag of ["--json", "--ci", "--skip", "--include", "--list", "--root"]) { + expect(r.stdout).toContain(flag); + } + }); +}); + +describe("usage errors", () => { + it("exits 2 on an unknown flag", () => { + const r = run(["--nope"]); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("Unknown argument"); + }); + + it("exits 2 when --include names an unknown check", () => { + const r = run(["--include", "shell-syntax,not-a-check"]); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("Unknown check name for --include: not-a-check"); + expect(r.stderr).toContain("Valid names:"); + }); + + it("exits 2 when --skip names an unknown check", () => { + const r = run(["--skip", "nope"]); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("Unknown check name for --skip: nope"); + }); + + it("exits 2 when --root is not a directory", () => { + const r = run(["--root", "/definitely/not/here", "--json"]); + expect(r.exitCode).toBe(2); + }); +}); + +describe("--json against the real repo", () => { + it("--include shell-syntax,json runs only those and reports the documented shape", () => { + const r = run(["--json", "--include", "shell-syntax,json"]); + expect(r.exitCode).toBe(0); + const json = parse(r); + + expect(json.status).toBe("pass"); + expect(json.checks.map((c) => c.name)).toEqual(ALL_CHECKS); + expect(json.summary).toMatchObject({ passed: 2, failed: 0, skipped: 8 }); + + for (const check of json.checks) { + expect(typeof check.name).toBe("string"); + expect(["pass", "fail", "skip"]).toContain(check.status); + expect(typeof check.exitCode).toBe("number"); + expect(typeof check.durationMs).toBe("number"); + if (check.status === "pass") { + expect(check).not.toHaveProperty("reason"); + } else { + expect(typeof check.reason).toBe("string"); + } + } + + expect(byName(json, "shell-syntax")).toMatchObject({ status: "pass", exitCode: 0 }); + expect(byName(json, "json")).toMatchObject({ status: "pass", exitCode: 0 }); + expect(byName(json, "script-tests").reason).toContain("filtered by --skip/--include"); + expect(byName(json, "types").reason).toContain("filtered by --skip/--include"); + }); + + it("--ci implies --json", () => { + const r = run(["--ci", "--include", "json"]); + expect(r.exitCode).toBe(0); + const json = parse(r); + expect(json.status).toBe("pass"); + expect(byName(json, "json").status).toBe("pass"); + }); + + it("human mode prints progress and a summary table", () => { + const r = run(["--include", "shell-syntax"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("=== verify-all ==="); + expect(r.stdout).toContain("shell-syntax"); + expect(r.stdout).toContain("=== Summary ==="); + expect(r.stdout).toContain("Totals: 1 passed, 0 failed, 9 skipped"); + expect(r.stdout).toContain("All checks passed."); + }); +}); + +describe("--skip", () => { + it("marks skipped checks with the filter reason and still runs the rest", () => { + const p = fixture(); + p.write("scripts/check-doc-counts.sh", "#!/usr/bin/env bash\necho ok\nexit 0\n"); + p.write("scripts/validate-pipeline-config.js", "console.log('ok');\n"); + + const withSkip = parse( + run(["--json", "--root", p.dir, "--skip", "doc-counts,shell-syntax"]), + ); + expect(byName(withSkip, "doc-counts")).toMatchObject({ + status: "skip", + reason: "filtered by --skip/--include", + }); + expect(byName(withSkip, "shell-syntax").status).toBe("skip"); + expect(byName(withSkip, "pipeline-config").status).toBe("pass"); + + const without = parse(run(["--json", "--root", p.dir])); + expect(byName(without, "doc-counts").status).toBe("pass"); + expect(byName(without, "shell-syntax").status).toBe("pass"); + }); +}); + +describe("conditional skips", () => { + it("skips a check whose backing script does not exist, with a reason", () => { + const p = fixture(); + p.write("scripts/.keep", ""); + const r = run(["--json", "--root", p.dir, "--include", "pipeline-config,doc-counts"]); + expect(r.exitCode).toBe(0); + const json = parse(r); + expect(json.status).toBe("pass"); + expect(byName(json, "pipeline-config")).toMatchObject({ + status: "skip", + exitCode: 0, + reason: "script not found: scripts/validate-pipeline-config.js", + }); + expect(byName(json, "doc-counts")).toMatchObject({ + status: "skip", + reason: "script not found: scripts/check-doc-counts.sh", + }); + expect(json.summary.skipped).toBe(10); + }); + + it("skips api-dependent checks when there is no api/ directory", () => { + const p = fixture(); + for (const s of ["check-types.sh", "run-tests.sh", "security-scan.sh"]) { + p.write(`scripts/${s}`, "#!/usr/bin/env bash\nexit 0\n"); + } + p.write("scripts/check-destructive-migrations.js", "process.exit(0);\n"); + const json = parse(run(["--json", "--root", p.dir])); + for (const name of ["types", "tests", "security"]) { + expect(byName(json, name)).toMatchObject({ status: "skip", reason: "no api/ directory" }); + } + expect(byName(json, "destructive-migrations").status).toBe("skip"); + expect(byName(json, "destructive-migrations").reason).toContain("no migrations directory"); + expect(byName(json, "script-tests")).toMatchObject({ status: "skip", reason: "no package.json" }); + expect(json.status).toBe("pass"); + }); + + it("runs api-dependent checks when api/ and migrations exist", () => { + const p = fixture(); + for (const s of ["check-types.sh", "run-tests.sh", "security-scan.sh"]) { + p.write(`scripts/${s}`, "#!/usr/bin/env bash\nexit 0\n"); + } + p.write("scripts/check-destructive-migrations.js", "process.exit(0);\n"); + p.write("api/src/db/migrations/0000_init.sql", "CREATE TABLE t (id int);\n"); + const json = parse(run(["--json", "--root", p.dir])); + for (const name of ["types", "tests", "security", "destructive-migrations"]) { + expect(byName(json, name)).toMatchObject({ status: "pass", exitCode: 0 }); + } + }); + + it("honours NERVA_API_DIR for the api/ location", () => { + const p = fixture(); + p.write("scripts/check-types.sh", "#!/usr/bin/env bash\nexit 0\n"); + p.write("elsewhere/package.json", "{}"); + const json = parse( + run(["--json", "--root", p.dir, "--include", "types"], { + env: { NERVA_API_DIR: join(p.dir, "elsewhere") }, + }), + ); + expect(byName(json, "types").status).toBe("pass"); + }); +}); + +describe("failing checks", () => { + function failingFixture() { + const p = fixture(); + p.write( + "scripts/validate-pipeline-config.js", + [ + "for (let i = 1; i <= 20; i++) console.log('line ' + i);", + "console.error('\\u001b[31mboom\\u001b[0m');", + "process.exit(3);", + ].join("\n"), + ); + p.write("scripts/check-doc-counts.sh", "#!/usr/bin/env bash\necho fine\n"); + return p; + } + + it("exits 1, reports the exit code, and captures the last 15 lines of output", () => { + const p = failingFixture(); + const r = run(["--json", "--root", p.dir, "--include", "pipeline-config,doc-counts"]); + expect(r.exitCode).toBe(1); + const json = parse(r); + expect(json.status).toBe("fail"); + expect(json.summary).toMatchObject({ passed: 1, failed: 1, skipped: 8 }); + + const failing = byName(json, "pipeline-config"); + expect(failing).toMatchObject({ + status: "fail", + exitCode: 3, + reason: "exit 3", + command: "node scripts/validate-pipeline-config.js", + }); + const lines = failing.output.split("\n"); + expect(lines).toHaveLength(15); + expect(lines[0]).toBe("line 7"); + expect(lines[13]).toBe("line 20"); + expect(lines[14]).toBe("boom"); // ANSI escapes stripped + expect(failing.durationMs).toBeGreaterThanOrEqual(0); + + expect(byName(json, "doc-counts")).toMatchObject({ status: "pass" }); + }); + + it("human mode shows the output tail and the command to reproduce", () => { + const p = failingFixture(); + const r = run(["--root", p.dir, "--include", "pipeline-config"]); + expect(r.exitCode).toBe(1); + expect(r.stdout).toContain("fail (exit 3"); + expect(r.stdout).toContain("last 15 lines of output"); + expect(r.stdout).toContain("| line 20"); + expect(r.stdout).toContain("| boom"); + expect(r.stdout).not.toContain("| line 6"); + expect(r.stdout).toContain("Some checks failed"); + expect(r.stdout).toContain("node scripts/validate-pipeline-config.js"); + expect(r.stdout).toContain("Totals: 0 passed, 1 failed, 9 skipped"); + }); + + it("a failure inside --include still exits 1 even when everything else is skipped", () => { + const p = fixture(); + p.write("scripts/check-doc-counts.sh", "#!/usr/bin/env bash\nexit 1\n"); + const r = run(["--ci", "--root", p.dir, "--include", "doc-counts"]); + expect(r.exitCode).toBe(1); + expect(parse(r).status).toBe("fail"); + }); + + it("inline checks fail on a syntax error and name the offending file", () => { + const p = fixture(); + p.write("scripts/broken.sh", "#!/usr/bin/env bash\nif [ x ; then\n"); + p.write("scripts/broken.js", "function (\n"); + const r = run(["--json", "--root", p.dir, "--include", "shell-syntax,js-syntax"]); + expect(r.exitCode).toBe(1); + const json = parse(r); + expect(byName(json, "shell-syntax").status).toBe("fail"); + expect(byName(json, "shell-syntax").output).toContain("scripts/broken.sh"); + expect(byName(json, "js-syntax").status).toBe("fail"); + expect(byName(json, "js-syntax").output).toContain("scripts/broken.js"); + }); + + it("the json check fails on malformed JSON", () => { + const p = fixture(); + p.write(".claude/pipeline.config.json", "{ not json"); + const r = run(["--json", "--root", p.dir, "--include", "json"]); + expect(r.exitCode).toBe(1); + const json = parse(r); + expect(byName(json, "json").status).toBe("fail"); + expect(byName(json, "json").output).toContain(".claude/pipeline.config.json"); + }); +}); + +describe("recursion guard", () => { + it("skips script-tests when invoked from inside a verify-all run", () => { + const p = fixture(); + p.write("package.json", JSON.stringify({ scripts: { test: "exit 1" } })); + const json = parse( + run(["--json", "--root", p.dir, "--include", "script-tests"], { + env: { NERVA_VERIFY_ALL_ACTIVE: "1" }, + }), + ); + expect(byName(json, "script-tests")).toMatchObject({ status: "skip" }); + expect(byName(json, "script-tests").reason).toContain("nested"); + }); +}); diff --git a/scripts/__tests__/vitest.config.js b/scripts/__tests__/vitest.config.js new file mode 100644 index 0000000..db7a4e0 --- /dev/null +++ b/scripts/__tests__/vitest.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["scripts/__tests__/**/*.test.js"], + // Script tests shell out and some share fixture directories on disk, so + // run files sequentially to keep them deterministic. + fileParallelism: false, + testTimeout: 60000, + env: { + // Keep script output free of ANSI codes so assertions can match literals. + NO_COLOR: "1", + }, + }, +}); diff --git a/scripts/check-destructive-migrations.js b/scripts/check-destructive-migrations.js new file mode 100644 index 0000000..2faf16c --- /dev/null +++ b/scripts/check-destructive-migrations.js @@ -0,0 +1,537 @@ +#!/usr/bin/env node +/** + * check-destructive-migrations.js — Fail (or warn) when a Drizzle migration + * contains destructive DDL. + * + * Why + * --- + * Nerva migrations are additive by policy. A DROP/TRUNCATE in a committed + * *.sql file usually means `drizzle-kit generate` ran against a database that + * had drifted from the schema and emitted a "reconcile" plan — the kind of + * migration that deletes production data when it is applied. This guard is a + * pure static scan (no database needed) so it can run in CI, in a pre-commit + * hook, and from scripts/generate-migration.sh right after a migration is + * generated. + * + * What is flagged (after stripping -- and /* *\/ comments): + * DROP TABLE, DROP COLUMN, DROP CONSTRAINT, DROP NOT NULL, DROP TYPE, + * DROP SCHEMA, DROP INDEX, TRUNCATE, and DELETE FROM without a WHERE clause. + * + * Opting out + * ---------- + * A reviewed, intentional destructive migration can allowlist itself with a + * header comment (the reason is mandatory and shows up in the report): + * + * -- nerva:allow-destructive: drops the legacy sessions table (moved to Redis in #42) + * + * Usage + * ----- + * node scripts/check-destructive-migrations.js [--dir ] [--json] [--warn-only] + * + * --dir Migrations directory. Default: the first existing of + * ./src/db/migrations, ./api/src/db/migrations, ./drizzle, + * $NERVA_API_DIR/src/db/migrations. + * --json Machine-readable report on stdout. + * --warn-only Report findings but exit 0. + * --help Show this help. + * + * Blocking mode is read from database.blockDestructiveMigrations in + * .claude/pipeline.config.json when this script runs inside the Nerva + * framework repo (default true). When copied standalone into a generated + * project (api/scripts/check-destructive-migrations.mjs) it has no config and + * blocks by default; use --warn-only to soften it. + * + * Exit codes: 0 clean (or warn-only), 1 destructive findings, 2 usage/IO error. + * + * The pure helpers (stripSqlComments, scanSql, parseAllowlistReason, ...) are + * exported for tests; main() only runs when the file is invoked directly. + */ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +// --------------------------------------------------------------------------- +// Patterns +// --------------------------------------------------------------------------- + +/** Destructive tokens, matched against comment-stripped SQL. */ +export const DESTRUCTIVE_PATTERNS = Object.freeze([ + { label: "DROP TABLE", re: /\bDROP\s+TABLE\b/gi }, + { label: "DROP COLUMN", re: /\bDROP\s+COLUMN\b/gi }, + { label: "DROP CONSTRAINT", re: /\bDROP\s+CONSTRAINT\b/gi }, + { label: "DROP NOT NULL", re: /\bDROP\s+NOT\s+NULL\b/gi }, + { label: "DROP TYPE", re: /\bDROP\s+TYPE\b/gi }, + { label: "DROP SCHEMA", re: /\bDROP\s+SCHEMA\b/gi }, + { label: "DROP INDEX", re: /\bDROP\s+INDEX\b/gi }, + { label: "TRUNCATE", re: /\bTRUNCATE\b/gi }, +]); + +export const DELETE_WITHOUT_WHERE_LABEL = "DELETE FROM without WHERE"; +export const INVALID_ALLOWLIST_LABEL = "allow-destructive marker without a reason"; + +export const ALLOWLIST_MARKER = "nerva:allow-destructive:"; +const ALLOWLIST_LINE_RE = /^[ \t]*--[ \t]*nerva:allow-destructive:(.*)$/im; + +export const DEFAULT_DIR_CANDIDATES = Object.freeze([ + "src/db/migrations", + "api/src/db/migrations", + "drizzle", +]); + +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- + +/** + * Remove SQL comments while preserving string literals and line structure. + * + * - `-- ...` line comments and `/* ... *\/` block comments (nesting allowed) + * are replaced by whitespace; newlines inside block comments are kept so + * line numbers in findings stay accurate. + * - Single-quoted strings ('' escapes a quote) and dollar-quoted strings + * ($$ ... $$ or $tag$ ... $tag$) are copied verbatim, so a `--` inside a + * literal never starts a comment. + */ +export function stripSqlComments(sql) { + let out = ""; + let i = 0; + const n = sql.length; + while (i < n) { + const ch = sql[i]; + const next = sql[i + 1]; + + if (ch === "'") { + out += ch; + i++; + while (i < n) { + out += sql[i]; + if (sql[i] === "'") { + if (sql[i + 1] === "'") { + out += sql[i + 1]; + i += 2; + continue; + } + i++; + break; + } + i++; + } + continue; + } + + if (ch === "$") { + const m = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i)); + if (m) { + const tag = m[0]; + const end = sql.indexOf(tag, i + tag.length); + const stop = end === -1 ? n : end + tag.length; + out += sql.slice(i, stop); + i = stop; + continue; + } + } + + if (ch === "-" && next === "-") { + let j = i + 2; + while (j < n && sql[j] !== "\n") j++; + out += " "; + i = j; + continue; + } + + if (ch === "/" && next === "*") { + let depth = 1; + let j = i + 2; + let newlines = ""; + while (j < n && depth > 0) { + if (sql[j] === "/" && sql[j + 1] === "*") { + depth++; + j += 2; + } else if (sql[j] === "*" && sql[j + 1] === "/") { + depth--; + j += 2; + } else { + if (sql[j] === "\n") newlines += "\n"; + j++; + } + } + out += " " + newlines; + i = j; + continue; + } + + out += ch; + i++; + } + return out; +} + +/** + * Split (comment-stripped) SQL into top-level statements on `;`, honoring + * single-quoted and dollar-quoted strings. Returns { text, offset } pairs so + * callers can map findings back to line numbers. Dollar-quoted bodies (DO + * blocks, function bodies) are also returned as nested statements so a + * DELETE hidden inside `DO $$ ... $$` is still inspected. + */ +export function splitStatements(sql) { + const statements = []; + const nestedBodies = []; + let start = 0; + let i = 0; + const n = sql.length; + while (i < n) { + const ch = sql[i]; + if (ch === "'") { + i++; + while (i < n) { + if (sql[i] === "'") { + if (sql[i + 1] === "'") { + i += 2; + continue; + } + i++; + break; + } + i++; + } + continue; + } + if (ch === "$") { + const m = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i)); + if (m) { + const tag = m[0]; + const end = sql.indexOf(tag, i + tag.length); + const bodyStart = i + tag.length; + const bodyEnd = end === -1 ? n : end; + nestedBodies.push({ text: sql.slice(bodyStart, bodyEnd), offset: bodyStart }); + i = end === -1 ? n : end + tag.length; + continue; + } + } + if (ch === ";") { + statements.push({ text: sql.slice(start, i), offset: start }); + start = i + 1; + } + i++; + } + if (start < n) statements.push({ text: sql.slice(start), offset: start }); + + for (const body of nestedBodies) { + for (const nested of splitStatements(body.text)) { + statements.push({ text: nested.text, offset: body.offset + nested.offset }); + } + } + return statements; +} + +function lineOf(text, index) { + let line = 1; + for (let i = 0; i < index && i < text.length; i++) { + if (text[i] === "\n") line++; + } + return line; +} + +function excerptAround(text, index, before = 30, after = 50) { + const start = Math.max(0, index - before); + return text + .slice(start, index + after) + .replace(/\s+/g, " ") + .trim(); +} + +/** + * Scan one migration's SQL text. Returns findings of the shape + * { file, label, line, excerpt }. Comments are stripped first so prose that + * mentions "DROP TABLE" never trips the guard. + */ +export function scanSql(file, sqlText) { + const stripped = stripSqlComments(sqlText); + const findings = []; + + for (const { label, re } of DESTRUCTIVE_PATTERNS) { + const regex = new RegExp(re.source, re.flags); + let m; + while ((m = regex.exec(stripped)) !== null) { + findings.push({ + file, + label, + line: lineOf(stripped, m.index), + excerpt: excerptAround(stripped, m.index), + }); + } + } + + for (const stmt of splitStatements(stripped)) { + // Statements inside DO/function bodies may start with BEGIN. + const m = /^\s*(?:BEGIN\s+)?(DELETE\s+FROM)\b/i.exec(stmt.text); + if (!m) continue; + if (/\bWHERE\b/i.test(stmt.text)) continue; + const index = stmt.offset + m.index + m[0].length - m[1].length; + findings.push({ + file, + label: DELETE_WITHOUT_WHERE_LABEL, + line: lineOf(stripped, index), + excerpt: stmt.text.replace(/\s+/g, " ").trim().slice(0, 80), + }); + } + + findings.sort((a, b) => a.line - b.line || a.label.localeCompare(b.label)); + return findings; +} + +/** True when the file carries a `-- nerva:allow-destructive:` line at all. */ +export function hasAllowlistMarker(sqlText) { + return ALLOWLIST_LINE_RE.test(sqlText); +} + +/** + * Return the allowlist reason from a `-- nerva:allow-destructive: ` + * comment line, or null when the marker is absent or its reason is empty. + */ +export function parseAllowlistReason(sqlText) { + const m = ALLOWLIST_LINE_RE.exec(sqlText); + if (!m) return null; + const reason = (m[1] ?? "").trim(); + return reason.length > 0 ? reason : null; +} + +export function listMigrationFiles(dir) { + return readdirSync(dir) + .filter((f) => f.toLowerCase().endsWith(".sql")) + .sort(); +} + +/** + * Scan a directory of migrations. Returns a report object: + * { dir, files, scanned, skipped: [{file, reason}], findings: [...] } + */ +export function scanDirectory(dir) { + const files = listMigrationFiles(dir); + const skipped = []; + const findings = []; + + for (const file of files) { + const text = readFileSync(join(dir, file), "utf8"); + const reason = parseAllowlistReason(text); + if (reason !== null) { + skipped.push({ file, reason }); + continue; + } + if (hasAllowlistMarker(text)) { + findings.push({ + file, + label: INVALID_ALLOWLIST_LABEL, + line: lineOf(text, text.search(ALLOWLIST_LINE_RE)), + excerpt: `-- ${ALLOWLIST_MARKER} `, + }); + } + findings.push(...scanSql(file, text)); + } + + return { dir, files, scanned: files.length - skipped.length, skipped, findings }; +} + +// --------------------------------------------------------------------------- +// Directory + config resolution +// --------------------------------------------------------------------------- + +export function resolveMigrationsDir(explicitDir, cwd = process.cwd(), env = process.env) { + if (explicitDir) return resolve(cwd, explicitDir); + const candidates = DEFAULT_DIR_CANDIDATES.map((c) => resolve(cwd, c)); + if (env.NERVA_API_DIR) candidates.push(resolve(env.NERVA_API_DIR, "src/db/migrations")); + for (const candidate of candidates) { + if (existsSync(candidate) && statSync(candidate).isDirectory()) return candidate; + } + return null; +} + +/** + * Read database.blockDestructiveMigrations from pipeline.config.json via + * scripts/lib/pipeline-config.js when that helper sits next to this script. + * Standalone copies (generated projects) have no helper and default to true. + */ +export async function readBlockingFromConfig(defaultValue = true) { + const helperUrl = new URL("./lib/pipeline-config.js", import.meta.url); + let helperPath; + try { + helperPath = fileURLToPath(helperUrl); + } catch { + return defaultValue; + } + if (!existsSync(helperPath)) return defaultValue; + try { + const mod = await import(helperUrl.href); + if (typeof mod.getValue !== "function") return defaultValue; + const value = mod.getValue("database.blockDestructiveMigrations", defaultValue); + if (typeof value === "boolean") return value; + if (value === "false") return false; + if (value === "true") return true; + return defaultValue; + } catch { + return defaultValue; + } +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +const USAGE = `Usage: node check-destructive-migrations.js [--dir ] [--json] [--warn-only] + +Scans *.sql migration files for destructive DDL (DROP TABLE/COLUMN/CONSTRAINT/ +NOT NULL/TYPE/SCHEMA/INDEX, TRUNCATE, DELETE FROM without WHERE). + +Options: + --dir Migrations directory (default: first existing of + ./src/db/migrations, ./api/src/db/migrations, ./drizzle, + $NERVA_API_DIR/src/db/migrations) + --json Print a JSON report to stdout + --warn-only Report findings but exit 0 + --help, -h Show this help + +Allowlist a reviewed destructive migration by adding a header comment line: + -- ${ALLOWLIST_MARKER} + +Exit codes: 0 clean or warn-only, 1 destructive findings, 2 usage/IO error.`; + +export function parseArgs(argv) { + const opts = { dir: null, json: false, warnOnly: false, help: false }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--dir") { + const value = argv[i + 1]; + if (!value || value.startsWith("--")) throw new Error("--dir requires a path"); + opts.dir = value; + i++; + } else if (arg.startsWith("--dir=")) { + opts.dir = arg.slice("--dir=".length); + if (!opts.dir) throw new Error("--dir requires a path"); + } else if (arg === "--json") { + opts.json = true; + } else if (arg === "--warn-only") { + opts.warnOnly = true; + } else if (arg === "--help" || arg === "-h") { + opts.help = true; + } else { + throw new Error(`Unknown option: ${arg}`); + } + } + return opts; +} + +function printTextReport(report, blocking) { + const { dir, files, skipped, findings } = report; + console.log( + `Scanned ${report.scanned} of ${files.length} migration file(s) in ${dir} (${skipped.length} allowlisted, skipped).`, + ); + for (const s of skipped) { + console.log(` ⊘ ${s.file}: allowlisted — ${s.reason}`); + } + + if (findings.length === 0) { + console.log(" ✓ No destructive DDL found in migrations."); + return; + } + + const stream = blocking ? console.error : console.log; + stream(""); + stream(` ✗ Destructive DDL found in ${new Set(findings.map((f) => f.file)).size} migration file(s):`); + stream(""); + for (const f of findings) { + stream(` ${f.file}:${f.line} ${f.label} …${f.excerpt}…`); + } + stream(""); + stream( + [ + "Migrations are expected to be additive. A DROP/TRUNCATE usually means", + "`drizzle-kit generate` ran against a drifted database and emitted a", + "reconcile plan; do not apply it. If the destructive change is intended and", + "reviewed, allowlist the file by adding a header comment line:", + "", + ` -- ${ALLOWLIST_MARKER} `, + "", + blocking + ? "Blocking (database.blockDestructiveMigrations=true); use --warn-only to report without failing." + : "Not blocking (warn-only); findings reported for review.", + ].join("\n"), + ); +} + +export async function main(argv = process.argv.slice(2)) { + let opts; + try { + opts = parseArgs(argv); + } catch (err) { + console.error(`check-destructive-migrations: ${err.message}`); + console.error(USAGE); + return 2; + } + if (opts.help) { + console.log(USAGE); + return 0; + } + + const dir = resolveMigrationsDir(opts.dir); + if (!dir) { + console.error( + "check-destructive-migrations: no migrations directory found. Pass --dir or run from a project containing " + + DEFAULT_DIR_CANDIDATES.join(", ") + + ".", + ); + return 2; + } + if (!existsSync(dir) || !statSync(dir).isDirectory()) { + console.error(`check-destructive-migrations: migrations directory not found: ${dir}`); + return 2; + } + + let report; + try { + report = scanDirectory(dir); + } catch (err) { + console.error(`check-destructive-migrations: failed to read ${dir}: ${err.message}`); + return 2; + } + + const blocking = opts.warnOnly ? false : await readBlockingFromConfig(true); + const ok = report.findings.length === 0; + + if (opts.json) { + console.log( + JSON.stringify( + { + ok, + blocking, + dir: report.dir, + scanned: report.scanned, + files: report.files, + skipped: report.skipped, + findings: report.findings, + }, + null, + 2, + ), + ); + } else { + printTextReport(report, blocking); + } + + if (ok) return 0; + return blocking ? 1 : 0; +} + +const invokedDirectly = + typeof process.argv[1] === "string" && + (resolve(process.argv[1]) === fileURLToPath(import.meta.url) || + basename(process.argv[1]) === basename(fileURLToPath(import.meta.url))); + +if (invokedDirectly) { + main().then( + (code) => process.exit(code), + (err) => { + console.error(`check-destructive-migrations: unexpected error: ${err?.stack ?? err}`); + process.exit(2); + }, + ); +} diff --git a/scripts/check-doc-counts.sh b/scripts/check-doc-counts.sh new file mode 100755 index 0000000..edd3042 --- /dev/null +++ b/scripts/check-doc-counts.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +# check-doc-counts.sh — Flag documentation drift in agent/skill/script/command counts. +# +# Counts the real entries on disk: +# agents → *.md files directly in .claude/agents/ (excluding README) +# skills → subdirectories of .claude/skills/ that contain a SKILL.md +# scripts → *.sh / *.js files directly in scripts/ (lib/ and __tests__/ excluded) +# commands → *.md files directly in .claude/commands/ +# +# Then scans the live Markdown docs for any "N agents" / "N skills" / +# "N scripts" / "N commands" claim and fails if a claim disagrees with the +# on-disk count. Historical, generated, and sample records (CHANGELOG.md, +# RELEASE_NOTES.md, docs/plans/, examples/) are intentionally excluded — they +# describe a past release or a separate project and must not be rewritten. +# +# Usage: +# ./scripts/check-doc-counts.sh # human-readable report, exit 1 on drift +# ./scripts/check-doc-counts.sh --json # machine-readable output +# ./scripts/check-doc-counts.sh --root # check a different project tree +# ./scripts/check-doc-counts.sh --help # this text +# +# Exit codes: 0 = in sync, 1 = drift detected, 2 = usage/IO error. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" + +JSON=0 +ROOT="" +while [[ $# -gt 0 ]]; do + case "$1" in + --json) JSON=1 ;; + --root) + if [[ -z "${2:-}" ]]; then + say_err "--root requires a directory argument" + exit 2 + fi + ROOT="$2" + shift + ;; + -h|--help) + # Print the leading comment block (skip shebang, stop at first code line). + awk 'NR==1{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "${BASH_SOURCE[0]}" + exit 0 + ;; + *) say_err "Unknown argument: $1 (try --help)"; exit 2 ;; + esac + shift +done + +if [[ -z "$ROOT" ]]; then + ROOT="$(common_project_root)" +fi +if [[ ! -d "$ROOT" ]]; then + say_err "Not a directory: $ROOT" + exit 2 +fi +cd "$ROOT" + +# --- Count entries on disk ------------------------------------------------ + +AGENT_DIR=".claude/agents" +SKILL_DIR=".claude/skills" +SCRIPTS_DIR="scripts" +COMMAND_DIR=".claude/commands" + +agent_count=0 +if [[ -d "$AGENT_DIR" ]]; then + while IFS= read -r f; do + [[ -z "$f" ]] && continue + case "$(basename "$f" | tr '[:upper:]' '[:lower:]')" in + readme.md) continue ;; + esac + agent_count=$((agent_count + 1)) + done < <(find "$AGENT_DIR" -maxdepth 1 -type f -name '*.md') +fi + +skill_count=0 +if [[ -d "$SKILL_DIR" ]]; then + while IFS= read -r d; do + [[ -z "$d" ]] && continue + [[ -f "$d/SKILL.md" ]] && skill_count=$((skill_count + 1)) + done < <(find "$SKILL_DIR" -mindepth 1 -maxdepth 1 -type d) +fi + +script_count=0 +if [[ -d "$SCRIPTS_DIR" ]]; then + script_count=$(find "$SCRIPTS_DIR" -maxdepth 1 -type f \( -name '*.sh' -o -name '*.js' \) | wc -l | tr -d ' ') +fi + +command_count=0 +if [[ -d "$COMMAND_DIR" ]]; then + command_count=$(find "$COMMAND_DIR" -maxdepth 1 -type f -name '*.md' | wc -l | tr -d ' ') +fi + +# --- Scan docs for count claims ------------------------------------------- +# +# A "claim" is a number adjacent to one of the PLURAL nouns agents / skills / +# scripts / commands. Count statements are always plural, so requiring the +# plural form avoids matching incidental phrases like "Phase 1 skill". Three +# forms are recognized: +# A: " [adj]{0,3} agents" e.g. "24 specialized agents", "12 skills" +# B: "Agents ( Total)" e.g. "Skills (12 Total)", "scripts (9 total)" +# C: "Total Agents:** " e.g. "**Total Skills:** 12" +# +# Form A requires the number to start a word (preceded by start-of-line or a +# non-alphanumeric character) so that "k6 scripts" is not read as "6 scripts". +# Table cells like "| Engineering | 6 |" never match: there is no noun. +# +# Each match is reduced to (noun, number) and compared against the disk count. + +NOUNS='(agents|skills|scripts|commands)' +COMBINED="(^|[^A-Za-z0-9_])[0-9]+( +[a-z][a-z./+-]*){0,3} +${NOUNS}" +COMBINED+="|${NOUNS} *\\(?[0-9]+ +total" +COMBINED+="|total +${NOUNS}[^0-9]{0,8}[0-9]+" + +is_excluded_path() { + case "$1" in + docs/plans/*|examples/*|*/node_modules/*|node_modules/*|.git/*) return 0 ;; + esac + case "$(basename "$1")" in + CHANGELOG.md|RELEASE_NOTES.md) return 0 ;; + esac + return 1 +} + +# Count the docs in scope for the report. +DOC_COUNT=0 +while IFS= read -r f; do + [[ -z "$f" ]] && continue + is_excluded_path "${f#./}" && continue + DOC_COUNT=$((DOC_COUNT + 1)) +done < <(find . \( -name .git -o -name node_modules \) -prune -o -type f -name '*.md' -print) + +# Run the combined matcher once recursively (a single grep process) for speed. +# grep -rIno output is "path:lineno:matched-text". Each match contains exactly +# one number in every form, so stripping non-digits yields the claimed count. +# Field separators: file, line, noun, claimed, actual, text (tab-delimited). +RAW_DRIFT="" +while IFS= read -r match; do + [[ -z "$match" ]] && continue + file="${match%%:*}"; rest="${match#*:}" + lineno="${rest%%:*}"; text="${rest#*:}" + file="${file#./}" + is_excluded_path "$file" && continue + # Form A's leading boundary character (space, quote, asterisk, ...) is part + # of the match; drop it so identical claims on one line de-duplicate. + text="${text#[^A-Za-z0-9]}" + num="${text//[!0-9]/}" + [[ -z "$num" ]] && continue + # nocasematch keeps this a pure-bash comparison (no per-match subprocess). + shopt -s nocasematch + case "$text" in + *agents*) noun="agents"; expected="$agent_count" ;; + *skills*) noun="skills"; expected="$skill_count" ;; + *scripts*) noun="scripts"; expected="$script_count" ;; + *commands*) noun="commands"; expected="$command_count" ;; + *) shopt -u nocasematch; continue ;; + esac + shopt -u nocasematch + if [[ "$num" != "$expected" ]]; then + squished="$(printf '%s' "$text" | tr '\t' ' ' | sed -e 's/ */ /g' -e 's/^ *//' -e 's/ *$//')" + RAW_DRIFT+="${file} ${lineno} ${noun} ${num} ${expected} ${squished}"$'\n' + fi +done < <(grep -riInoE "$COMBINED" \ + --include='*.md' \ + --exclude='CHANGELOG.md' \ + --exclude='RELEASE_NOTES.md' \ + --exclude-dir='.git' \ + --exclude-dir='node_modules' \ + . 2>/dev/null || true) + +# A single claim can match more than one form; de-duplicate before counting. +DRIFT_FILE=() +DRIFT_LINE=() +DRIFT_NOUN=() +DRIFT_CLAIMED=() +DRIFT_ACTUAL=() +DRIFT_TEXT=() +if [[ -n "$RAW_DRIFT" ]]; then + while IFS=$'\t' read -r d_file d_line d_noun d_claimed d_actual d_text; do + [[ -z "$d_file" ]] && continue + DRIFT_FILE+=("$d_file") + DRIFT_LINE+=("$d_line") + DRIFT_NOUN+=("$d_noun") + DRIFT_CLAIMED+=("$d_claimed") + DRIFT_ACTUAL+=("$d_actual") + DRIFT_TEXT+=("$d_text") + done < <(printf '%s' "$RAW_DRIFT" | sort -u) +fi +DRIFT=${#DRIFT_FILE[@]} + +json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\t'/\\t}" + s="${s//$'\r'/\\r}" + printf '%s' "$s" +} + +# --- Report --------------------------------------------------------------- + +if [[ "$JSON" -eq 1 ]]; then + printf '{\n' + printf ' "agents": %d,\n' "$agent_count" + printf ' "skills": %d,\n' "$skill_count" + printf ' "scripts": %d,\n' "$script_count" + printf ' "commands": %d,\n' "$command_count" + printf ' "docsScanned": %d,\n' "$DOC_COUNT" + printf ' "drift": %d,\n' "$DRIFT" + printf ' "violations": [' + if [[ "$DRIFT" -gt 0 ]]; then + printf '\n' + i=0 + while [[ $i -lt $DRIFT ]]; do + printf ' { "file": "%s", "line": %d, "noun": "%s", "claimed": %d, "actual": %d, "text": "%s" }' \ + "$(json_escape "${DRIFT_FILE[$i]}")" "${DRIFT_LINE[$i]}" "${DRIFT_NOUN[$i]}" \ + "${DRIFT_CLAIMED[$i]}" "${DRIFT_ACTUAL[$i]}" "$(json_escape "${DRIFT_TEXT[$i]}")" + i=$((i + 1)) + [[ $i -lt $DRIFT ]] && printf ',' + printf '\n' + done + printf ' ' + fi + printf ']\n}\n' +else + say_banner "Documentation Count Check" + echo "" + say_step "On disk: ${agent_count} agents, ${skill_count} skills, ${script_count} scripts, ${command_count} commands" + say_step "Scanned ${DOC_COUNT} Markdown files (CHANGELOG.md, RELEASE_NOTES.md, docs/plans/, examples/ excluded)" + echo "" + if [[ "$DRIFT" -eq 0 ]]; then + say_pass "All agent/skill/script/command counts in docs match the entries on disk" + else + say_fail "${DRIFT} documentation count claim(s) drifted from disk:" + i=0 + while [[ $i -lt $DRIFT ]]; do + echo " ${RED}${DRIFT_FILE[$i]}:${DRIFT_LINE[$i]}: claims ${DRIFT_CLAIMED[$i]} ${DRIFT_NOUN[$i]}, actual ${DRIFT_ACTUAL[$i]}${NC} -> \"${DRIFT_TEXT[$i]}\"" + i=$((i + 1)) + done + echo "" + echo " Update the counts above, or re-run after adding/removing agents, skills, scripts, or commands." + fi +fi + +[[ "$DRIFT" -eq 0 ]] || exit 1 diff --git a/scripts/check-prerequisites.sh b/scripts/check-prerequisites.sh new file mode 100644 index 0000000..bfd6258 --- /dev/null +++ b/scripts/check-prerequisites.sh @@ -0,0 +1,400 @@ +#!/usr/bin/env bash +# +# check-prerequisites.sh — Verify the tools Nerva needs and report them in a +# stable, machine-parseable layout. +# +# Usage: +# ./scripts/check-prerequisites.sh # human-readable report +# ./scripts/check-prerequisites.sh --json # same data as JSON +# ./scripts/check-prerequisites.sh --help +# +# Output contract (do not change casually — scripts/__tests__ pins it): +# - Section headers on their own line, each followed by a dashed underline: +# REQUIRED SOFTWARE, OPTIONAL SOFTWARE, CLAUDE CODE, PROJECT +# - One result per line: [PASS] | [FAIL] | [SKIP] | [INFO] +# - Indented continuation lines under a result are hints for that result +# - A "=== Summary ===" block with +# "Required: x/y passed", "Optional: x/y installed", +# "Claude Code: x/y ready", "Project: x/y ready", +# then "Ready to use Nerva: YES|NO" +# +# JSON mode (--json) emits one object: +# { "ready": bool, "exitCode": n, +# "summary": { "required": {passed,total}, "optional": {installed,total}, +# "claudeCode": {passed,total}, "project": {ready,total} }, +# "checks": [ { "section", "status", "detail", "hints": [] }, ... ] } +# +# What blocks readiness: any [FAIL] in REQUIRED SOFTWARE or any [FAIL] in +# PROJECT. The claude CLI, plugins, optional software, and [INFO]/[SKIP] +# lines never block (the IDE extensions run Claude Code without a CLI on PATH). +# +# Exit codes: 0 = ready, 1 = a required item is missing or the project is +# broken, 2 = script/usage error. +# +# Env overrides (tests): NERVA_PROJECT_ROOT, NERVA_PLUGINS_FILE. +# Colors come from scripts/lib/colors.sh and are disabled under NO_COLOR. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" + +MIN_GIT_VERSION="2.30.0" +MIN_NODE_VERSION="20.0.0" +RECOMMENDED_NODE_MAJOR=22 +MIN_PNPM_VERSION="9.0.0" +# Marketplace plugins CLAUDE.md lists as installed (ai-taskmaster is local). +EXPECTED_PLUGINS="episodic-memory commit-commands superpowers" + +JSON_MODE=0 +for arg in "$@"; do + case "$arg" in + --json) JSON_MODE=1 ;; + -h|--help) + sed -n '2,/^set -u/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + error "Unknown argument: $arg (expected --json or --help)" + exit 2 + ;; + esac +done + +ROOT="${NERVA_PROJECT_ROOT:-$(common_project_root)}" +PLUGINS_FILE="${NERVA_PLUGINS_FILE:-$HOME/.claude/plugins/installed_plugins.json}" + +if [[ ! -d "$ROOT" ]]; then + error "Project root does not exist: $ROOT" + exit 2 +fi + +# ── Counters ──────────────────────────────────────────────────────────────── +REQUIRED_PASS=0; REQUIRED_TOTAL=0 +OPTIONAL_PASS=0; OPTIONAL_TOTAL=0 +CLAUDE_PASS=0; CLAUDE_TOTAL=0 +PROJECT_PASS=0; PROJECT_TOTAL=0 +BLOCKING=0 + +# ── Result recording (text or JSON) ───────────────────────────────────────── +CURRENT_SECTION="" +ITEM_SECTION=(); ITEM_STATUS=(); ITEM_DETAIL=(); ITEM_HINTS=() +HINT_SEP=$'\x1f' + +header() { + CURRENT_SECTION="$1" + [[ "$JSON_MODE" -eq 1 ]] && return 0 + echo "" + printf '%s%s%s\n' "$CYAN" "$1" "$NC" + printf '%s\n' "$(printf '%s' "$1" | sed 's/./-/g')" +} + +record() { + local status="$1" detail="$2" color="" + ITEM_SECTION+=("$CURRENT_SECTION"); ITEM_STATUS+=("$status") + ITEM_DETAIL+=("$detail"); ITEM_HINTS+=("") + [[ "$JSON_MODE" -eq 1 ]] && return 0 + case "$status" in + PASS) color="$GREEN" ;; + FAIL) color="$RED" ;; + SKIP) color="$YELLOW" ;; + INFO) color="$BLUE" ;; + esac + printf '%s[%s]%s %s\n' "$color" "$status" "$NC" "$detail" +} +pass() { record PASS "$1"; } +fail() { record FAIL "$1"; } +skip() { record SKIP "$1"; } +note() { record INFO "$1"; } +hint() { + local last=$(( ${#ITEM_HINTS[@]} - 1 )) + if [[ "$last" -ge 0 ]]; then + if [[ -n "${ITEM_HINTS[$last]}" ]]; then + ITEM_HINTS[$last]="${ITEM_HINTS[$last]}${HINT_SEP}$1" + else + ITEM_HINTS[$last]="$1" + fi + fi + [[ "$JSON_MODE" -eq 1 ]] && return 0 + printf ' %s\n' "$1" +} + +# ── Version helpers ───────────────────────────────────────────────────────── +first_version() { printf '%s' "$1" | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -n1; } + +# version_gte A B → 0 when A >= B (dotted numeric versions; pure bash, no sort -V) +version_gte() { + local a b i ai bi + IFS='.' read -r -a a <<< "$(printf '%s' "$1" | sed 's/[^0-9.]//g')" + IFS='.' read -r -a b <<< "$(printf '%s' "$2" | sed 's/[^0-9.]//g')" + for i in 0 1 2; do + ai="${a[$i]:-0}"; bi="${b[$i]:-0}" + if (( 10#$ai > 10#$bi )); then return 0; fi + if (( 10#$ai < 10#$bi )); then return 1; fi + done + return 0 +} + +# check_optional