A mistake caught an hour later on the build server, after someone has already spent time reviewing it. This catches it on your machine, before the commit.
Add "Pre-commit checks" from ntent to this repo.
Fetch https://ntent.app/r/f/pre-commit as plain text. Write it verbatim to scripts/git-hooks/pre-commit. Then make it executable.
Then check it: Stage a file that fails a check this tier installs and the commit is refused, naming the check and the fix. At tier 1 that is check-env: add a variable to the env schema and not to .env.example. The hardcoded-colour case needs the tier 2 lint rules.Reads https://ntent.app/r/f/pre-commit
Read the code
pre-commitscripts/git-hooks/pre-commit · make it executable
426 lines
#!/bin/bash
#
# Pre-commit gate.
#
# Wired by: git config core.hooksPath scripts/git-hooks
# which package.json runs for you: "prepare": "git config core.hooksPath scripts/git-hooks"
#
# Bypass in a real emergency: git commit --no-verify
# That is documented on purpose. A bypass people have to discover in a panic is
# a bypass they will keep using afterwards.
#
# FOUR THINGS MAKE THIS DIFFERENT FROM THE USUAL PRE-COMMIT HOOK.
#
# 1. It checks THE STAGED SNAPSHOT, not the files on disk. With a partial stage
# those are different, and checking the wrong one is worse than checking
# nothing: an unstaged fix hides a broken line that is actually being
# committed, and the hook reports a pass on code nobody has run. See SNAPSHOT.
#
# 2. It dispatches on the staged file list, DELETIONS INCLUDED. A commit that
# only touches the README does not run the i18n checker. This is what keeps
# the median commit under a couple of seconds, and commit speed is the whole
# game: a hook that takes 40 seconds gets bypassed, and a bypassed hook
# enforces nothing. What it must not do is confuse "nothing left to read"
# with "nothing to check": see the two lists below.
#
# 3. It runs the checks this repo actually installed. The tiers are cumulative,
# so a tier 1 repo has this file and none of the tier 3 checkers. A gate that
# dies on a missing module makes every commit impossible, which is how a hook
# gets deleted in week one. Missing checkers are named and skipped, never
# silently ignored: a skip you cannot see is a gate that quietly stopped.
#
# 4. It stops at the first failure. One actionable message beats a wall of
# output, and the second failure is often caused by the first.
#
# Every gate here must be satisfiable offline by anyone with a clone. A check
# that needs credentials, network, or a tool a fresh clone lacks belongs in the
# session-start nag instead. See chapter 2.
set -u
repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root" || exit 1
# TWO LISTS, AND THE DIFFERENCE BETWEEN THEM IS A WHOLE CLASS OF BUG.
#
# `changed` is every path this commit touches, deletions included, and it is
# what decides which gates run. Deleting a module breaks the build exactly as
# surely as editing one does: delete an imported file and the compiler says
# TS2307, while a hook filtering deletions out sees an empty list and exits 0 on
# a commit that does not compile. What is being removed is part of the change.
#
# `present` is the subset that still exists in the snapshot, and it is the only
# list safe to hand to a command that opens the files it is given. A checker
# that rescans the repo gets neither: it just needs to be told to run.
changed="$(git diff --cached --name-only --diff-filter=ACMRD)"
present="$(git diff --cached --name-only --diff-filter=ACMR)"
[ -z "$changed" ] && exit 0
status=0
ran=""
skipped=""
# ------------------------------------------------------------- SNAPSHOT ----
#
# Check what is being committed, not what is lying around.
#
# The bug this fixes, which every hook that lints the staged paths in place has: stage
# a file containing `debugger;`, then fix it in the editor without staging. The
# hook reads the fixed file from disk, passes, and commits the broken one. The
# reverse costs you too: an unstaged experiment blocks a commit that is fine.
#
# git checkout-index writes the index out as it stands, so the snapshot IS the
# commit. Dependencies are symlinked in rather than reinstalled, which is what
# keeps this at a second or two. Nothing here touches your worktree, so unlike
# the `git stash --keep-index` version of this trick, an interrupted hook cannot
# lose uncommitted work.
#
# If any of it fails we fall back to the worktree and SAY SO, because a hook
# that silently downgrades what it checks is the thing this file is about.
snapshot=""
cleanup () { [ -n "$snapshot" ] && rm -rf "$snapshot" ; }
trap cleanup EXIT
build_snapshot () {
local dir nm
dir="$(mktemp -d 2>/dev/null)" || return 1
git checkout-index --all --force --prefix="$dir/" 2>/dev/null || { rm -rf "$dir" ; return 1 ; }
# Link the installed dependencies in. Absolute targets, so the symlinks
# resolve from inside the snapshot. Depth 4 covers a workspace's packages
# without descending into node_modules itself.
while IFS= read -r nm ; do
[ -n "$nm" ] || continue
mkdir -p "$dir/$(dirname "$nm")" 2>/dev/null
[ -e "$dir/$nm" ] || ln -s "$repo_root/$nm" "$dir/$nm" 2>/dev/null
done <<EOF
$(find . -maxdepth 4 -type d -name node_modules -not -path '*/node_modules/*' 2>/dev/null | sed 's|^\./||')
EOF
snapshot="$dir"
return 0
}
if [ "${SKIP_SNAPSHOT:-}" = "1" ] ; then
echo " note: SKIP_SNAPSHOT=1. Checking the worktree, so a partial stage is not"
echo " what gets checked. Unset it before you trust a pass."
elif build_snapshot ; then
cd "$snapshot" || exit 1
else
echo " note: could not snapshot the index, so the checks below read the files"
echo " on disk. With a partial stage that is not what you are committing."
fi
# ---------------------------------------------------------------- HELPERS ---
# run_gate <name> <path-regex> <command> <fix-hint> [required-file ...]
#
# The fix hint is not decoration. "Validation failed" is a gate people route
# around; "add the key to messages/en.json and re-stage" is a gate people thank
# you for. Making the hint a required argument is how you guarantee one exists.
#
# Anything after the hint is a file the command needs. Absent means this repo has
# not installed that check yet, which is a skip with a printed reason, not a
# failure: at tier 1 the i18n and component checkers genuinely are not here.
run_gate () {
local name="$1" pattern="$2" cmd="$3" hint="$4" ; shift 4
local need
[ "$status" -eq 0 ] || return 0
printf '%s\n' "$changed" | grep -qE "$pattern" || return 0
for need in "$@" ; do
if [ ! -e "$need" ] ; then
skipped="${skipped:+$skipped }${name}(no ${need})"
return 0
fi
done
ran="${ran:+$ran }$name"
if ! eval "$cmd" ; then
echo "" >&2
echo " x ${name} failed." >&2
echo " ${hint}" >&2
echo "" >&2
status=1
fi
}
# The nearest ancestor directory holding one of these files. Falls back to the
# repo root, so a file under no config is still handled rather than skipped.
nearest () {
local dir="$(dirname "$1")" name
shift
while [ "$dir" != "." ] && [ "$dir" != "/" ] ; do
for name in "$@" ; do
[ -f "$dir/$name" ] && { printf '%s\n' "$dir" ; return 0 ; }
done
dir="$(dirname "$dir")"
done
printf '.\n'
}
# Files from one of the two lists matching a pattern, grouped by the directory
# that owns their config, as "<dir>\t<file>" pairs. The caller picks the list,
# because a project-wide command wants the deletions and a per-file one cannot
# have them.
owners_for () {
local list="$1" pattern="$2" ; shift 2
printf '%s\n' "$list" | grep -E "$pattern" | while IFS= read -r f ; do
[ -n "$f" ] || continue
printf '%s\t%s\n' "$(nearest "$f" "$@")" "$f"
done
}
# ---------------------------------------------------------------- GATES ----
# Order matters only in that cheap checks go first, so a broken commit fails
# fast rather than after the slow one.
# TYPECHECK, EACH FILE AGAINST THE PROJECT THAT OWNS IT.
#
# `tsc --noEmit` from the root typechecks the ROOT project, which in a monorepo
# is not the project the staged file belongs to: it either checks nothing or
# checks it under the wrong settings, and both read as a pass. Resolving the
# nearest tsconfig.json and running `-p` against it is the same behaviour in a
# single-package repo and the correct one everywhere else.
#
# A repo with no tsconfig anywhere is not a TypeScript repo. Skip, and say so.
#
# The owners come from `changed`, deletions included: tsc checks the project,
# not a file list, and the project is exactly what a deleted module breaks.
# `nearest` walks up from the path, so it resolves for a file that is gone.
typecheck_staged () {
local pairs dir rc=0
pairs="$(owners_for "$changed" '\.(ts|tsx)$' tsconfig.json)"
[ -n "$pairs" ] || return 0
for dir in $(printf '%s\n' "$pairs" | cut -f1 | sort -u) ; do
if [ ! -f "$dir/tsconfig.json" ] ; then
echo " note: no tsconfig.json above $dir, so those files are not typechecked."
continue
fi
( cd "$dir" && pnpm exec tsc --noEmit -p tsconfig.json ) || rc=1
[ "$rc" -eq 0 ] || break
done
return "$rc"
}
run_gate "typecheck" \
'\.(ts|tsx)$' \
'typecheck_staged' \
'Fix the type errors above.'
# ESLint, EACH FILE AGAINST THE CONFIG THAT OWNS IT.
#
# In a single-package repo this is one run and behaves exactly as the obvious
# one-liner did. In a monorepo it is the difference between the hook agreeing
# with `pnpm lint` and the hook being quietly stricter than it.
#
# The failure, found on a real repo: `pnpm lint` runs ESLint once per package,
# from that package's directory. A single run from the root does not, and a
# root config that deliberately registers no framework plugins — which is what
# you write when two apps sit on different framework majors and each registers
# its own, since a flat config may claim a plugin name only once — then fails
# every staged file carrying an `eslint-disable-next-line` for a rule only the
# package config knows. "Definition for rule ... was not found", on a file
# `pnpm lint` calls clean.
#
# That is the shape this whole file exists to avoid. A gate stricter than the
# documented command does not get fixed, it gets bypassed.
# Group the staged files by owner, then one run per owner, from inside it.
# Stops at the first failing group, for the reason the gates stop at the first
# failing gate: one actionable message beats a wall of output.
eslint_staged () {
local pairs dir files rc=0
# `present`, because ESLint is handed paths and has to be able to open them.
pairs="$(owners_for "$present" '\.(ts|tsx|js|jsx)$' \
eslint.config.mjs eslint.config.js eslint.config.cjs eslint.config.ts)"
if [ -z "$pairs" ] ; then
echo " note: the lintable paths in this commit are deletions, so there is"
echo " nothing for ESLint to read."
return 0
fi
for dir in $(printf '%s\n' "$pairs" | cut -f1 | sort -u) ; do
# `nearest` falls back to the repo root, which may hold no config either.
# A repo that does not lint is not a failing repo; it is a repo without
# that check, and it gets told so once rather than a resolver error.
if ! ls "$dir"/eslint.config.* >/dev/null 2>&1 ; then
echo " note: no ESLint config at or above $dir, so those files are not linted."
continue
fi
files="$(printf '%s\n' "$pairs" | awk -F'\t' -v d="$dir" '$1 == d { print $2 }')"
# Paths relative to the owner, because that is where ESLint is about to run.
[ "$dir" = "." ] || files="$(printf '%s\n' "$files" | sed "s|^$dir/||")"
( cd "$dir" && pnpm exec eslint $files ) || rc=1
[ "$rc" -eq 0 ] || break
done
return "$rc"
}
run_gate "lint" \
'\.(ts|tsx|js|jsx)$' \
'eslint_staged' \
'Fix the lint errors above. Each message says which rule and why it exists.'
# Exit 2 is the checker saying there is nothing to check yet: the locale
# directory does not exist. A repo that chose the locales capability on day one
# has the checker before it has a second language, and a gate that refuses
# every commit until messages/en.json exists is a gate that gets deleted. A
# note, not a refusal. Exit 1 is a missing key, and a missing key blocks.
i18n_staged () {
local rc=0
node scripts/check-locale-keys.mjs --check || rc=$?
if [ "$rc" -eq 2 ] ; then
echo " note: the locale check has no locale directory to read yet."
return 0
fi
return "$rc"
}
run_gate "i18n" \
'^(messages/|src/)' \
'i18n_staged' \
'Add the key to every locale file, then re-stage.' \
scripts/check-locale-keys.mjs
# ASK THE CHECKER WHERE ITS FILES ARE.
#
# Every source file, not just the schema, because the checker scans all of them
# for process.env reads: scoping the trigger to the schema meant a new direct
# read in a component was found by nothing until someone next edited env.ts.
#
# And the paths come from `--print-config` rather than from this file. Moving
# CONFIG.schemaFile is documented and supported, and a hook that hardcoded
# src/env.ts answered that by treating the check as not installed. Silently:
# the commit passed, the checker was right there, and running it by hand failed.
# A prerequisite worth having is one resolved from the same place the check
# reads it from. The defaults below are the fallback for an older check-env.mjs
# that does not understand the flag.
env_schema='src/env.ts'
env_pattern='^(src/|\.env\.example$)'
if [ -f scripts/check-env.mjs ] ; then
env_cfg="$(node scripts/check-env.mjs --print-config 2>/dev/null)"
env_from_cfg () { printf '%s\n' "$env_cfg" | awk -F'\t' -v k="$1" '$1 == k { print $2 }' ; }
# A regex-literal path: only the metacharacters a filename can hold.
env_escape () { printf '%s\n' "$1" | sed 's/[.[\*^$]/\\&/g' ; }
cfg_schema="$(env_from_cfg schemaFile)"
if [ -n "$cfg_schema" ] ; then
env_schema="$cfg_schema"
env_pattern=""
for d in $(env_from_cfg sourceDir) ; do
env_pattern="${env_pattern:+$env_pattern|}^$(env_escape "$d")/"
done
for f in "$(env_from_cfg exampleFile)" "$env_schema" ; do
[ -n "$f" ] || continue
env_pattern="${env_pattern:+$env_pattern|}^$(env_escape "$f")\$"
done
fi
fi
run_gate "env" \
"$env_pattern" \
'node scripts/check-env.mjs --check' \
"Add the variable to $env_schema AND the example file, with a comment saying where to get it." \
scripts/check-env.mjs "$env_schema"
run_gate "api" \
'^src/app/api/' \
'node scripts/check-api-routes.mjs --check' \
'Add the schema, the auth check, or a marker comment giving the reason it is exempt.' \
scripts/check-api-routes.mjs
# TOKENS. The registry calls this a gate, and until this line existed it was
# wired to nothing: a raw hex in a stylesheet passed every commit while the
# setup page said "blocks the commit". CSS as well as components, because a raw
# colour goes in either.
#
# The staged paths are passed in rather than letting it rescan the repo. Faster,
# and it means the gate is about the commit rather than about everything that
# was already there.
#
# Exit 2 is check-tokens saying it could not check anything: no stylesheet, or
# nothing scannable in what was staged. That is a note, not a refusal. Exit 1 is
# a finding, and a finding blocks.
tokens_staged () {
local files rc=0
files="$(printf '%s\n' "$present" | grep -E '\.(css|ts|tsx|js|jsx)$')"
[ -n "$files" ] || return 0
node scripts/check-tokens.mjs --check $files || rc=$?
if [ "$rc" -eq 2 ] ; then
echo " note: the token check had nothing it could measure in this commit."
return 0
fi
return "$rc"
}
run_gate "tokens" \
'\.(css|ts|tsx|js|jsx)$' \
'tokens_staged' \
'Use a token, or record a reviewed waiver with a reason and an expiry.' \
scripts/check-tokens.mjs
# Probes. Only when the lint setup itself changes, which is the moment a rule
# silently stops being loaded. Running it on every commit would be slow for no
# added information: the selectors do not change when a component does.
run_gate "probes" \
'(eslint\.config\.|scripts/eslint-rules\.mjs|scripts/probes/)' \
'node scripts/check-probes.mjs' \
'A house rule is not firing or not loaded. The output names which and where.' \
scripts/check-probes.mjs scripts/probes
run_gate "components" \
'^src/(app|components|features)/' \
'node scripts/check-stray-components.mjs --check' \
'Promote the component to src/components/, or allowlist it with a reason.' \
scripts/check-stray-components.mjs
# fixtures/ included, because that is where the plan puts captured payloads and
# where the checker reads them from. Leaving it out meant adding a fixture that
# no longer parsed was not checked until someone touched the schema.
run_gate "fixtures" \
'^(fixtures/|packages/schemas/|packages/fixtures/|src/lib/schemas)' \
'node scripts/verify-fixtures.mjs' \
'A schema no longer parses its fixture. One of the two is wrong; decide which.' \
scripts/verify-fixtures.mjs
# References only, so no TypeScript parse and no walk of the app: milliseconds
# on a commit that edits the contract. Coverage stays a nudge below, because
# it moves as you build. A dangling reference does not.
run_gate "contract" \
'^contract\.json$' \
'node scripts/check-contract-coverage.mjs --refs' \
'The contract contradicts itself: an id that resolves to nothing, a journey marked built on an open question, or a journey marked built with no acceptance record. Each finding names its fix.' \
scripts/check-contract-coverage.mjs contract.json
# ---------------------------------------------------------------- NUDGES ---
# Never set status. These report something worth knowing that the committer
# cannot necessarily fix right now, so they must not block.
if [ -f scripts/check-contract-coverage.mjs ] &&
printf '%s\n' "$changed" | grep -qE '^(contract\.json|src/types/)' ; then
node scripts/check-contract-coverage.mjs 2>/dev/null | grep -E 'Overall|UNMEASURED' || true
fi
if printf '%s\n' "$changed" | grep -qE '^prisma/migrations/' ; then
echo " note: a migration is staged. Its header comment should answer:"
echo " is it reversible, does it lock, what is the backfill, how do we roll back."
fi
# ---------------------------------------------------------------- RESULT ---
if [ "$status" -eq 0 ] ; then
[ -n "$ran" ] && echo " gates passed: $ran"
fi
# Printed pass or fail. A checker this repo has not installed is a real gap in
# what was just verified, and the commit message is the only place anyone would
# ever see it.
if [ -n "$skipped" ] ; then
echo " not installed, so not checked: $skipped"
fi
exit "$status"
Success check: Stage a file that fails a check this tier installs and the commit is refused, naming the check and the fix. At tier 1 that is check-env: add a variable to the env schema and not to .env.example. The hardcoded-colour case needs the tier 2 lint rules.