pre-commit

Pre-commit checks

Tier 1engineeringBlocks the commit

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 to your project
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.

Included in