@biffo/cli 0.170.0 → 0.172.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_skeletons/plugin-template/.githooks/commit-msg +21 -0
- package/_skeletons/plugin-template/.githooks/pre-commit +21 -0
- package/_skeletons/plugin-template/.githooks/pre-push +28 -0
- package/_skeletons/plugin-template/scripts/hook-audit.sh +145 -0
- package/_skeletons/plugin-template/scripts/install-hooks.sh +74 -0
- package/_skeletons/plugin-template/scripts/verify.sh +164 -0
- package/_skeletons/sibling-template/.githooks/commit-msg +21 -0
- package/_skeletons/sibling-template/.githooks/pre-commit +21 -0
- package/_skeletons/sibling-template/.githooks/pre-push +28 -0
- package/_skeletons/sibling-template/scripts/hook-audit.sh +145 -0
- package/_skeletons/sibling-template/scripts/install-hooks.sh +74 -0
- package/_skeletons/sibling-template/scripts/verify.sh +164 -0
- package/package.json +1 -1
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# Conventional Commits, and the core-ownership boundary.
|
|
3
|
+
# See docs/practices/standards/local-gates.md
|
|
4
|
+
root=$(git rev-parse --show-toplevel) || exit 0
|
|
5
|
+
cd "$root" || exit 0
|
|
6
|
+
|
|
7
|
+
# commitlint only where it is configured. The release derives its version bump
|
|
8
|
+
# from the squash-merge subject (ADR-0006), so this matters most in repos that
|
|
9
|
+
# cut releases -- but a repo without the config must not be blocked by it.
|
|
10
|
+
if [ -f commitlint.config.js ] || [ -f commitlint.config.mjs ] || [ -f .commitlintrc.json ]; then
|
|
11
|
+
pnpm exec commitlint --edit "$1" || exit 1
|
|
12
|
+
else
|
|
13
|
+
echo "commit-msg: no commitlint config — subject not checked (see local-gates.md)" >&2
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
# Refuse commits that edit template-owned paths (#370). Inert in the template
|
|
17
|
+
# and absent in sibling/plugin repos, which own all of their own paths.
|
|
18
|
+
# CI runs the same check, which --no-verify cannot skip.
|
|
19
|
+
if [ -f scripts/biffo.sh ]; then
|
|
20
|
+
sh scripts/biffo.sh check ownership --staged "$1" || exit 1
|
|
21
|
+
fi
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# Per-file, auto-fixing, sub-second. See docs/practices/standards/local-gates.md
|
|
3
|
+
#
|
|
4
|
+
# Defensive by design: these three hooks are dropped verbatim into every repo in
|
|
5
|
+
# the estate, and they differ in what they have. A hook that fails because the
|
|
6
|
+
# repo has no lint-staged config would block all work there, which is how a
|
|
7
|
+
# safety measure gets ripped out. Absent tooling is reported, never fatal;
|
|
8
|
+
# tooling that IS present and fails is fatal.
|
|
9
|
+
root=$(git rev-parse --show-toplevel) || exit 0
|
|
10
|
+
cd "$root" || exit 0
|
|
11
|
+
|
|
12
|
+
if [ ! -f package.json ]; then
|
|
13
|
+
echo "pre-commit: no package.json — nothing staged-file-level to run" >&2
|
|
14
|
+
exit 0
|
|
15
|
+
fi
|
|
16
|
+
if ! node -e "const p=require('./package.json');process.exit(p['lint-staged']||p.config?.['lint-staged']?0:1)" 2>/dev/null \
|
|
17
|
+
&& [ ! -f .lintstagedrc ] && [ ! -f .lintstagedrc.json ] && [ ! -f lint-staged.config.js ]; then
|
|
18
|
+
echo "pre-commit: no lint-staged config — skipping (see local-gates.md)" >&2
|
|
19
|
+
exit 0
|
|
20
|
+
fi
|
|
21
|
+
exec pnpm exec lint-staged
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# Run the checks CI runs, before paying for the round trip that would find them.
|
|
3
|
+
#
|
|
4
|
+
# Over the 30 days to 2026-07-29 the estate had 373 failed CI runs, 211 of 342
|
|
5
|
+
# failing steps locally catchable. scripts/verify.sh documents the breakdown and
|
|
6
|
+
# what it deliberately leaves out.
|
|
7
|
+
#
|
|
8
|
+
# Skip in CI. This is a *local* guard; CI runs each check in its own job with its
|
|
9
|
+
# own reporting. CI jobs that push (notably the core-v* tag workflow) have no
|
|
10
|
+
# `uv` on PATH, so firing here made tag pushes fail with `uv: not found` (code
|
|
11
|
+
# 127) and blocked releases. GitHub Actions and every mainstream CI set $CI.
|
|
12
|
+
[ -n "${CI:-}" ] && exit 0
|
|
13
|
+
|
|
14
|
+
# Escape hatch, deliberately explicit rather than a flag on git. `--no-verify`
|
|
15
|
+
# would skip this too, but it also skips every other hook silently; this leaves a
|
|
16
|
+
# decision in the shell history. Its usage rate is an H4 counter-metric.
|
|
17
|
+
if [ -n "${BIFFO_SKIP_VERIFY:-}" ]; then
|
|
18
|
+
echo "pre-push: verify skipped (BIFFO_SKIP_VERIFY set) — CI will run it anyway." >&2
|
|
19
|
+
exit 0
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
root=$(git rev-parse --show-toplevel) || exit 0
|
|
23
|
+
cd "$root" || exit 0
|
|
24
|
+
if [ ! -f scripts/verify.sh ]; then
|
|
25
|
+
echo "pre-push: no scripts/verify.sh — NO checks ran (see local-gates.md)" >&2
|
|
26
|
+
exit 0
|
|
27
|
+
fi
|
|
28
|
+
exec sh scripts/verify.sh
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Is a git hook actually going to execute here?
|
|
4
|
+
#
|
|
5
|
+
# ## Why this exists
|
|
6
|
+
#
|
|
7
|
+
# A configured hook that does not run is worse than no hook, because it is
|
|
8
|
+
# assumed to be protecting you. On 2026-07-29 the estate was in exactly that
|
|
9
|
+
# state: `core.hooksPath` pointed at `.husky/_`, a **gitignored** directory
|
|
10
|
+
# created only by `prepare: husky` on `pnpm install` — and git resolves that
|
|
11
|
+
# relative path against *each worktree's* root. Every fresh worktree therefore
|
|
12
|
+
# had no hooks, and git said nothing: no warning, no error, no output.
|
|
13
|
+
#
|
|
14
|
+
# AGENTS.md §1 mandates a fresh worktree per unit of work, so the required
|
|
15
|
+
# workflow disarmed its own gates. 6 of 32 working trees were armed. The pre-push
|
|
16
|
+
# pyright, the pre-commit lint-staged and commitlint had all been silently
|
|
17
|
+
# skipped in the other 26 for as long as anyone had been using worktrees.
|
|
18
|
+
#
|
|
19
|
+
# Nothing detected it because nothing looked. This is the thing that looks.
|
|
20
|
+
#
|
|
21
|
+
# ## Verdicts
|
|
22
|
+
#
|
|
23
|
+
# ARMED git will execute a hook here.
|
|
24
|
+
# DEAD core.hooksPath is set and its target is missing or carries no
|
|
25
|
+
# hooks. Git skips silently. **This is the state that lies to you**,
|
|
26
|
+
# and the only one that makes this script exit non-zero.
|
|
27
|
+
# NO-HOOKS no hooks configured at all. Honest, and visible in this report.
|
|
28
|
+
#
|
|
29
|
+
# Usage:
|
|
30
|
+
# sh scripts/hook-audit.sh # this repo and its worktrees
|
|
31
|
+
# sh scripts/hook-audit.sh --estate ~/code # every repo under a directory
|
|
32
|
+
# sh scripts/hook-audit.sh --quiet # verdict counts only
|
|
33
|
+
|
|
34
|
+
set -uo pipefail
|
|
35
|
+
|
|
36
|
+
ESTATE=""
|
|
37
|
+
QUIET=""
|
|
38
|
+
while [ $# -gt 0 ]; do
|
|
39
|
+
case "$1" in
|
|
40
|
+
--estate) ESTATE="$2"; shift 2 ;;
|
|
41
|
+
--quiet) QUIET=1; shift ;;
|
|
42
|
+
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
|
43
|
+
esac
|
|
44
|
+
done
|
|
45
|
+
|
|
46
|
+
armed=0
|
|
47
|
+
dead=0
|
|
48
|
+
nohooks=0
|
|
49
|
+
dead_list=""
|
|
50
|
+
|
|
51
|
+
# The three hooks this standard cares about. A hooksPath directory containing
|
|
52
|
+
# none of them is not armed for our purposes even if it holds something else.
|
|
53
|
+
WANTED='^(pre-commit|pre-push|commit-msg)$'
|
|
54
|
+
|
|
55
|
+
report() {
|
|
56
|
+
tree="$1"
|
|
57
|
+
label="$2"
|
|
58
|
+
hp=$(git -C "$tree" config core.hooksPath 2>/dev/null)
|
|
59
|
+
|
|
60
|
+
if [ -z "$hp" ]; then
|
|
61
|
+
# The default hooks directory is in the **common** git dir, which linked
|
|
62
|
+
# worktrees share. Reading "$tree/.git/hooks" is wrong for exactly the trees
|
|
63
|
+
# this audit exists to check: in a linked worktree `.git` is a *file*
|
|
64
|
+
# containing a gitdir pointer, so that path does not exist and every armed
|
|
65
|
+
# worktree was about to be reported NO-HOOKS. Ask git where it actually is.
|
|
66
|
+
#
|
|
67
|
+
# Git ships .sample files there that never execute, so counting the
|
|
68
|
+
# directory as armed merely for being non-empty would be exactly the false
|
|
69
|
+
# comfort this script exists to remove.
|
|
70
|
+
common=$(git -C "$tree" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
|
|
71
|
+
real=$(ls "${common:-$tree/.git}/hooks" 2>/dev/null | grep -vc '\.sample$' || true)
|
|
72
|
+
if [ "${real:-0}" -gt 0 ]; then
|
|
73
|
+
armed=$((armed + 1))
|
|
74
|
+
[ -n "$QUIET" ] || printf '%-56s %-14s \033[32mARMED\033[0m %s\n' "$label" "(default)" "$real hook(s) in .git/hooks"
|
|
75
|
+
else
|
|
76
|
+
nohooks=$((nohooks + 1))
|
|
77
|
+
[ -n "$QUIET" ] || printf '%-56s %-14s \033[33mNO-HOOKS\033[0m %s\n' "$label" "(default)" "no hooks configured"
|
|
78
|
+
fi
|
|
79
|
+
return
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
# Relative hooksPath resolves against the working tree root — the whole bug.
|
|
83
|
+
case "$hp" in
|
|
84
|
+
/*) dir="$hp" ;;
|
|
85
|
+
*) dir="$tree/$hp" ;;
|
|
86
|
+
esac
|
|
87
|
+
|
|
88
|
+
present=$(ls "$dir" 2>/dev/null | grep -E "$WANTED" | tr '\n' ',' || true)
|
|
89
|
+
if [ -z "$present" ]; then
|
|
90
|
+
dead=$((dead + 1))
|
|
91
|
+
dead_list="$dead_list $label ($hp)
|
|
92
|
+
"
|
|
93
|
+
[ -n "$QUIET" ] || printf '%-56s %-14s \033[31mDEAD\033[0m %s\n' "$label" "$hp" "$hp missing or holds no hooks — git skips ALL hooks silently"
|
|
94
|
+
else
|
|
95
|
+
armed=$((armed + 1))
|
|
96
|
+
[ -n "$QUIET" ] || printf '%-56s %-14s \033[32mARMED\033[0m %s\n' "$label" "$hp" "${present%,}"
|
|
97
|
+
fi
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
walk_repo() {
|
|
101
|
+
root="${1%/}"
|
|
102
|
+
name="$2"
|
|
103
|
+
# Every working tree, not just the primary — the primary is usually the one
|
|
104
|
+
# that IS armed, which is how this went unnoticed for so long.
|
|
105
|
+
git -C "$root" worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2}' | while read -r t; do
|
|
106
|
+
[ -d "$t" ] || continue
|
|
107
|
+
if [ "$t" = "$root" ]; then echo "$t|$name"; else echo "$t|$name${t#$root}"; fi
|
|
108
|
+
done
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
[ -n "$QUIET" ] || printf '%-56s %-14s %-8s %s\n' "WORKING TREE" "hooksPath" "VERDICT" "detail"
|
|
112
|
+
|
|
113
|
+
if [ -n "$ESTATE" ]; then
|
|
114
|
+
targets=$(for d in "$ESTATE"/*/; do
|
|
115
|
+
[ -e "$d/.git" ] || continue
|
|
116
|
+
walk_repo "$d" "$(basename "${d%/}")"
|
|
117
|
+
done)
|
|
118
|
+
else
|
|
119
|
+
root=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "not a git repo" >&2; exit 2; }
|
|
120
|
+
# From inside a worktree, --show-toplevel gives the worktree; walk from the
|
|
121
|
+
# common repo so sibling worktrees are audited too.
|
|
122
|
+
common=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
|
|
123
|
+
root=$(dirname "$common")
|
|
124
|
+
targets=$(walk_repo "$root" "$(basename "$root")")
|
|
125
|
+
fi
|
|
126
|
+
|
|
127
|
+
while IFS='|' read -r tree label; do
|
|
128
|
+
[ -n "$tree" ] || continue
|
|
129
|
+
report "$tree" "$label"
|
|
130
|
+
done <<EOF
|
|
131
|
+
$targets
|
|
132
|
+
EOF
|
|
133
|
+
|
|
134
|
+
total=$((armed + dead + nohooks))
|
|
135
|
+
printf '\n%s working trees — \033[32m%s armed\033[0m, \033[31m%s dead\033[0m, %s without hooks' "$total" "$armed" "$dead" "$nohooks"
|
|
136
|
+
[ "$total" -gt 0 ] && printf ' (%s%% armed)' "$((100 * armed / total))"
|
|
137
|
+
printf '\n'
|
|
138
|
+
|
|
139
|
+
if [ "$dead" -gt 0 ]; then
|
|
140
|
+
printf '\n\033[31mDEAD working trees — hooks are configured here and are NOT running:\033[0m\n%s' "$dead_list"
|
|
141
|
+
printf 'Every commit and push made in these is unguarded, and nothing says so.\n'
|
|
142
|
+
printf 'Fix: run `pnpm install` there, or move the repo to tracked .githooks/.\n'
|
|
143
|
+
exit 1
|
|
144
|
+
fi
|
|
145
|
+
exit 0
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
#
|
|
3
|
+
# Install hook dispatchers into the repository's **shared** hooks directory, so
|
|
4
|
+
# every worktree is guarded — including ones that already exist.
|
|
5
|
+
#
|
|
6
|
+
# ## Why not core.hooksPath
|
|
7
|
+
#
|
|
8
|
+
# #838 moved the hooks into a tracked `.githooks/` and pointed
|
|
9
|
+
# `core.hooksPath` there. That fixed the original bug (a gitignored runner
|
|
10
|
+
# directory that only `pnpm install` created), but it leaves a gap that matters
|
|
11
|
+
# for reaching full coverage:
|
|
12
|
+
#
|
|
13
|
+
# `core.hooksPath` is a **relative** path resolved against each worktree's root,
|
|
14
|
+
# and it lives in the shared config. So the moment it is set, every worktree
|
|
15
|
+
# checked out on a branch that predates `.githooks/` has the config but not the
|
|
16
|
+
# directory — and git is back to skipping silently. AGENTS.md forbids modifying
|
|
17
|
+
# a worktree you did not create, so those cannot simply be rebased.
|
|
18
|
+
#
|
|
19
|
+
# Git's own default is better than the override. With `core.hooksPath` **unset**,
|
|
20
|
+
# a linked worktree runs the hooks in the **common** `.git/hooks` directory —
|
|
21
|
+
# verified 2026-07-29: a dispatcher installed once in the main checkout fired in
|
|
22
|
+
# a pre-existing linked worktree and in one created afterwards, blocking the
|
|
23
|
+
# commit in both, with the hook's `pwd` set to the worktree.
|
|
24
|
+
#
|
|
25
|
+
# So the shared directory is the delivery mechanism and `.githooks/` stays the
|
|
26
|
+
# source of truth for the logic. One install per clone arms every worktree that
|
|
27
|
+
# clone will ever have.
|
|
28
|
+
#
|
|
29
|
+
# ## What a dispatcher does when there is nothing to dispatch to
|
|
30
|
+
#
|
|
31
|
+
# It warns and exits 0. A branch without `.githooks/` has no repo-defined hooks,
|
|
32
|
+
# which is the state it was already in — blocking every commit there would be
|
|
33
|
+
# inventing a gate that branch never had, and would break other agents
|
|
34
|
+
# mid-flight. But it says so, every time, because "no checks ran" being
|
|
35
|
+
# invisible is the whole defect (#838, #839).
|
|
36
|
+
#
|
|
37
|
+
# Run by `prepare`, so `pnpm install` is still what arms a fresh clone. There is
|
|
38
|
+
# no way to ship an armed hook in a repository — that is git's design, not an
|
|
39
|
+
# oversight — so the goal is to need it once per clone rather than once per
|
|
40
|
+
# worktree.
|
|
41
|
+
|
|
42
|
+
set -eu
|
|
43
|
+
|
|
44
|
+
HOOKS_DIR="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)/hooks"
|
|
45
|
+
mkdir -p "$HOOKS_DIR"
|
|
46
|
+
|
|
47
|
+
# An inherited core.hooksPath would make every dispatcher below unreachable, and
|
|
48
|
+
# the failure would be silent. Clearing it is the entire point of the exercise.
|
|
49
|
+
if [ -n "$(git config --local core.hooksPath 2>/dev/null || true)" ]; then
|
|
50
|
+
git config --local --unset core.hooksPath
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
for hook in pre-commit pre-push commit-msg; do
|
|
54
|
+
cat > "$HOOKS_DIR/$hook" <<EOF
|
|
55
|
+
#!/usr/bin/env sh
|
|
56
|
+
# Generated by scripts/install-hooks.sh — do not edit.
|
|
57
|
+
# The logic lives in the tracked .githooks/ of whichever worktree is running.
|
|
58
|
+
root=\$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
|
|
59
|
+
if [ -x "\$root/.githooks/$hook" ]; then
|
|
60
|
+
exec "\$root/.githooks/$hook" "\$@"
|
|
61
|
+
fi
|
|
62
|
+
if [ -f "\$root/.githooks/$hook" ]; then
|
|
63
|
+
exec sh "\$root/.githooks/$hook" "\$@"
|
|
64
|
+
fi
|
|
65
|
+
# Nothing to run. Say so — an unguarded commit that looks guarded is the defect
|
|
66
|
+
# this whole mechanism exists to remove (#839).
|
|
67
|
+
echo "warning: no .githooks/$hook in \$root — this tree predates the local-gate" >&2
|
|
68
|
+
echo " standard, so NO checks ran. See docs/practices/standards/local-gates.md" >&2
|
|
69
|
+
exit 0
|
|
70
|
+
EOF
|
|
71
|
+
chmod +x "$HOOKS_DIR/$hook"
|
|
72
|
+
done
|
|
73
|
+
|
|
74
|
+
echo "hooks armed: $HOOKS_DIR (shared — every worktree of this clone, now and later)"
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
#
|
|
3
|
+
# Run the checks CI runs, here, before the push that would have found them.
|
|
4
|
+
#
|
|
5
|
+
# ## Why this exists
|
|
6
|
+
#
|
|
7
|
+
# Until 2026-07-29 the only local gate was a whole-project `pyright` in a
|
|
8
|
+
# pre-push hook -- in the three repos that had hooks at all. Everything else
|
|
9
|
+
# (eslint, prettier, tsc, vitest, ruff, terraform fmt, the plugin guards) ran for
|
|
10
|
+
# the first time on a GitHub runner, after a push, after a PR, after the merge
|
|
11
|
+
# race.
|
|
12
|
+
#
|
|
13
|
+
# Over the 30 days to 2026-07-29, across the twelve repos in the estate that run
|
|
14
|
+
# CI: 373 failed runs, and 211 of 342 failing steps (62%) were locally
|
|
15
|
+
# catchable -- deterministic, offline, no credentials. By kind: tests 49,
|
|
16
|
+
# format 53, typecheck 20, lint 16, terraform fmt 12, and the core ownership
|
|
17
|
+
# guard 11 -- a check already wired as a commit hook, being discovered in the
|
|
18
|
+
# pipeline because the hook was not running.
|
|
19
|
+
#
|
|
20
|
+
# The same file failed the same check across consecutive runs -- e.g.
|
|
21
|
+
# services/api/src/api/routing/crud_handlers.py failing `ruff format --check` on
|
|
22
|
+
# four separate runs. That is the signature of a round trip being used as the
|
|
23
|
+
# check: push, wait for CI, read the failure, fix, push again.
|
|
24
|
+
#
|
|
25
|
+
# ## Why it adapts instead of being tailored
|
|
26
|
+
#
|
|
27
|
+
# This one file runs in the template, in instances, in sibling apps and in
|
|
28
|
+
# plugin repos, whose CI check sets differ. It could have been forked per repo;
|
|
29
|
+
# forks drift, and a gate that has drifted from CI reports a green CI will not
|
|
30
|
+
# honour.
|
|
31
|
+
#
|
|
32
|
+
# So every check is conditional on the repo actually having it, and an
|
|
33
|
+
# inapplicable check prints `n/a` rather than being silently absent. Absence and
|
|
34
|
+
# inapplicability look identical in a summary that omits both, and telling them
|
|
35
|
+
# apart is the entire point (docs/practices/standards/local-gates.md).
|
|
36
|
+
#
|
|
37
|
+
# ## What is deliberately excluded
|
|
38
|
+
#
|
|
39
|
+
# - pytest -- 56s in the template, more than the rest of the gate combined,
|
|
40
|
+
# and it failed once there in 30 days. Opt in per repo with
|
|
41
|
+
# BIFFO_VERIFY_PYTEST=1 where the suite is fast.
|
|
42
|
+
# - app/portal build -- a full Next build.
|
|
43
|
+
# - dependency audits, pip-audit, pnpm audit -- network.
|
|
44
|
+
# - gitleaks -- scans history, not the working tree.
|
|
45
|
+
#
|
|
46
|
+
# cli/src/lib/verify-parity.test.ts fails if the template's CI grows a check
|
|
47
|
+
# that is neither here nor in that written exclusion list.
|
|
48
|
+
#
|
|
49
|
+
# Usage:
|
|
50
|
+
# sh scripts/verify.sh # everything applicable to this repo
|
|
51
|
+
# pnpm run verify # same
|
|
52
|
+
# BIFFO_SKIP_VERIFY=1 git push # escape hatch, for when you mean it
|
|
53
|
+
|
|
54
|
+
set -u
|
|
55
|
+
|
|
56
|
+
FAILED=""
|
|
57
|
+
PASSED=""
|
|
58
|
+
SKIPPED=""
|
|
59
|
+
|
|
60
|
+
PYTEST="${BIFFO_VERIFY_PYTEST:-}"
|
|
61
|
+
|
|
62
|
+
have_script() {
|
|
63
|
+
[ -f package.json ] || return 1
|
|
64
|
+
node -e "process.exit(JSON.parse(require('fs').readFileSync('package.json','utf8')).scripts?.['$1']?0:1)" 2>/dev/null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
run_check() {
|
|
68
|
+
name="$1"
|
|
69
|
+
shift
|
|
70
|
+
start=$(date +%s)
|
|
71
|
+
if "$@" >"/tmp/biffo-verify.$$" 2>&1; then
|
|
72
|
+
PASSED="$PASSED $name"
|
|
73
|
+
printf ' \033[32mOK\033[0m %-16s %ss\n' "$name" "$(($(date +%s) - start))"
|
|
74
|
+
else
|
|
75
|
+
FAILED="$FAILED $name"
|
|
76
|
+
printf ' \033[31mFAIL\033[0m %-16s %ss\n' "$name" "$(($(date +%s) - start))"
|
|
77
|
+
sed 's/^/ /' "/tmp/biffo-verify.$$" | tail -25
|
|
78
|
+
fi
|
|
79
|
+
rm -f "/tmp/biffo-verify.$$"
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
skip() {
|
|
83
|
+
SKIPPED="$SKIPPED $1"
|
|
84
|
+
printf ' \033[90m-- %-16s n/a - %s\033[0m\n' "$1" "$2"
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
printf '\nverify - the checks CI runs, before the push\n\n'
|
|
88
|
+
|
|
89
|
+
# Python first: ruff is near-instant, so the cheapest feedback on the largest
|
|
90
|
+
# single class of failure comes back immediately.
|
|
91
|
+
if [ -f pyproject.toml ]; then
|
|
92
|
+
if command -v uv >/dev/null 2>&1; then
|
|
93
|
+
run_check ruff-check uv run ruff check .
|
|
94
|
+
run_check ruff-format uv run ruff format --check .
|
|
95
|
+
run_check pyright uv run pyright
|
|
96
|
+
if [ -n "$PYTEST" ]; then
|
|
97
|
+
run_check pytest uv run pytest -q
|
|
98
|
+
else
|
|
99
|
+
skip pytest "excluded - set BIFFO_VERIFY_PYTEST=1 where the suite is fast"
|
|
100
|
+
fi
|
|
101
|
+
else
|
|
102
|
+
skip python "uv not installed"
|
|
103
|
+
fi
|
|
104
|
+
else
|
|
105
|
+
skip python "no pyproject.toml in this repo"
|
|
106
|
+
fi
|
|
107
|
+
|
|
108
|
+
# Terraform, wherever this repo keeps it: modules/ in the template and
|
|
109
|
+
# instances, infra/ and modules/ in siblings.
|
|
110
|
+
if command -v terraform >/dev/null 2>&1; then
|
|
111
|
+
# Scope must match this repo's CI, not exceed it. The template and instances
|
|
112
|
+
# deliberately fmt-check modules/ ONLY: infra/environments/ is user-owned, and
|
|
113
|
+
# a template-shipped check asserting over paths the template does not own is
|
|
114
|
+
# the #325 trap -- it reds an instance on content it neither wrote nor can
|
|
115
|
+
# repair. Siblings own their whole infra/ and their CI checks it, so they get
|
|
116
|
+
# both. biffo.sibling.json is what tells them apart.
|
|
117
|
+
tf_dirs=""
|
|
118
|
+
[ -d modules ] && tf_dirs="$tf_dirs modules/"
|
|
119
|
+
[ -f biffo.sibling.json ] && [ -d infra ] && tf_dirs="$tf_dirs infra/"
|
|
120
|
+
if [ -n "$tf_dirs" ]; then
|
|
121
|
+
# shellcheck disable=SC2086
|
|
122
|
+
run_check terraform-fmt terraform fmt -check -recursive $tf_dirs
|
|
123
|
+
else
|
|
124
|
+
skip terraform-fmt "no terraform in this repo"
|
|
125
|
+
fi
|
|
126
|
+
else
|
|
127
|
+
skip terraform-fmt "terraform not installed"
|
|
128
|
+
fi
|
|
129
|
+
|
|
130
|
+
# The Biffo guards, where the dispatcher exists. Cheap, and two of them
|
|
131
|
+
# (ownership, plugin-terraform) were being caught in CI.
|
|
132
|
+
if [ -f scripts/biffo.sh ]; then
|
|
133
|
+
run_check plugin-tf sh scripts/biffo.sh check plugin-terraform
|
|
134
|
+
run_check plugin-names sh scripts/biffo.sh check plugin-collisions
|
|
135
|
+
else
|
|
136
|
+
skip biffo-guards "no scripts/biffo.sh in this repo"
|
|
137
|
+
fi
|
|
138
|
+
|
|
139
|
+
# JS, cheapest first; `test` last because it is slowest and the most likely to
|
|
140
|
+
# be interrupted by an impatient reader.
|
|
141
|
+
if [ -f package.json ]; then
|
|
142
|
+
skip build "excluded - a full app build is too slow for a push gate"
|
|
143
|
+
for s in lint typecheck format:check test; do
|
|
144
|
+
label=$(printf '%s' "$s" | tr -d ':')
|
|
145
|
+
if have_script "$s"; then
|
|
146
|
+
run_check "$label" pnpm run "$s"
|
|
147
|
+
else
|
|
148
|
+
skip "$label" "no \"$s\" script in package.json"
|
|
149
|
+
fi
|
|
150
|
+
done
|
|
151
|
+
else
|
|
152
|
+
skip javascript "no package.json in this repo"
|
|
153
|
+
fi
|
|
154
|
+
|
|
155
|
+
printf '\n'
|
|
156
|
+
if [ -n "$FAILED" ]; then
|
|
157
|
+
printf '\033[31mverify failed:\033[0m%s\n' "$FAILED"
|
|
158
|
+
printf 'Fix these here - CI will find them anyway, three minutes and a merge race later.\n'
|
|
159
|
+
printf 'Most format failures are one command: pnpm run format\n\n'
|
|
160
|
+
exit 1
|
|
161
|
+
fi
|
|
162
|
+
printf '\033[32mverify passed\033[0m -%s\n' "$PASSED"
|
|
163
|
+
[ -n "$SKIPPED" ] && printf '\033[90mnot applicable here:%s\033[0m\n' "$SKIPPED"
|
|
164
|
+
printf '\n'
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# Conventional Commits, and the core-ownership boundary.
|
|
3
|
+
# See docs/practices/standards/local-gates.md
|
|
4
|
+
root=$(git rev-parse --show-toplevel) || exit 0
|
|
5
|
+
cd "$root" || exit 0
|
|
6
|
+
|
|
7
|
+
# commitlint only where it is configured. The release derives its version bump
|
|
8
|
+
# from the squash-merge subject (ADR-0006), so this matters most in repos that
|
|
9
|
+
# cut releases -- but a repo without the config must not be blocked by it.
|
|
10
|
+
if [ -f commitlint.config.js ] || [ -f commitlint.config.mjs ] || [ -f .commitlintrc.json ]; then
|
|
11
|
+
pnpm exec commitlint --edit "$1" || exit 1
|
|
12
|
+
else
|
|
13
|
+
echo "commit-msg: no commitlint config — subject not checked (see local-gates.md)" >&2
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
# Refuse commits that edit template-owned paths (#370). Inert in the template
|
|
17
|
+
# and absent in sibling/plugin repos, which own all of their own paths.
|
|
18
|
+
# CI runs the same check, which --no-verify cannot skip.
|
|
19
|
+
if [ -f scripts/biffo.sh ]; then
|
|
20
|
+
sh scripts/biffo.sh check ownership --staged "$1" || exit 1
|
|
21
|
+
fi
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# Per-file, auto-fixing, sub-second. See docs/practices/standards/local-gates.md
|
|
3
|
+
#
|
|
4
|
+
# Defensive by design: these three hooks are dropped verbatim into every repo in
|
|
5
|
+
# the estate, and they differ in what they have. A hook that fails because the
|
|
6
|
+
# repo has no lint-staged config would block all work there, which is how a
|
|
7
|
+
# safety measure gets ripped out. Absent tooling is reported, never fatal;
|
|
8
|
+
# tooling that IS present and fails is fatal.
|
|
9
|
+
root=$(git rev-parse --show-toplevel) || exit 0
|
|
10
|
+
cd "$root" || exit 0
|
|
11
|
+
|
|
12
|
+
if [ ! -f package.json ]; then
|
|
13
|
+
echo "pre-commit: no package.json — nothing staged-file-level to run" >&2
|
|
14
|
+
exit 0
|
|
15
|
+
fi
|
|
16
|
+
if ! node -e "const p=require('./package.json');process.exit(p['lint-staged']||p.config?.['lint-staged']?0:1)" 2>/dev/null \
|
|
17
|
+
&& [ ! -f .lintstagedrc ] && [ ! -f .lintstagedrc.json ] && [ ! -f lint-staged.config.js ]; then
|
|
18
|
+
echo "pre-commit: no lint-staged config — skipping (see local-gates.md)" >&2
|
|
19
|
+
exit 0
|
|
20
|
+
fi
|
|
21
|
+
exec pnpm exec lint-staged
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# Run the checks CI runs, before paying for the round trip that would find them.
|
|
3
|
+
#
|
|
4
|
+
# Over the 30 days to 2026-07-29 the estate had 373 failed CI runs, 211 of 342
|
|
5
|
+
# failing steps locally catchable. scripts/verify.sh documents the breakdown and
|
|
6
|
+
# what it deliberately leaves out.
|
|
7
|
+
#
|
|
8
|
+
# Skip in CI. This is a *local* guard; CI runs each check in its own job with its
|
|
9
|
+
# own reporting. CI jobs that push (notably the core-v* tag workflow) have no
|
|
10
|
+
# `uv` on PATH, so firing here made tag pushes fail with `uv: not found` (code
|
|
11
|
+
# 127) and blocked releases. GitHub Actions and every mainstream CI set $CI.
|
|
12
|
+
[ -n "${CI:-}" ] && exit 0
|
|
13
|
+
|
|
14
|
+
# Escape hatch, deliberately explicit rather than a flag on git. `--no-verify`
|
|
15
|
+
# would skip this too, but it also skips every other hook silently; this leaves a
|
|
16
|
+
# decision in the shell history. Its usage rate is an H4 counter-metric.
|
|
17
|
+
if [ -n "${BIFFO_SKIP_VERIFY:-}" ]; then
|
|
18
|
+
echo "pre-push: verify skipped (BIFFO_SKIP_VERIFY set) — CI will run it anyway." >&2
|
|
19
|
+
exit 0
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
root=$(git rev-parse --show-toplevel) || exit 0
|
|
23
|
+
cd "$root" || exit 0
|
|
24
|
+
if [ ! -f scripts/verify.sh ]; then
|
|
25
|
+
echo "pre-push: no scripts/verify.sh — NO checks ran (see local-gates.md)" >&2
|
|
26
|
+
exit 0
|
|
27
|
+
fi
|
|
28
|
+
exec sh scripts/verify.sh
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Is a git hook actually going to execute here?
|
|
4
|
+
#
|
|
5
|
+
# ## Why this exists
|
|
6
|
+
#
|
|
7
|
+
# A configured hook that does not run is worse than no hook, because it is
|
|
8
|
+
# assumed to be protecting you. On 2026-07-29 the estate was in exactly that
|
|
9
|
+
# state: `core.hooksPath` pointed at `.husky/_`, a **gitignored** directory
|
|
10
|
+
# created only by `prepare: husky` on `pnpm install` — and git resolves that
|
|
11
|
+
# relative path against *each worktree's* root. Every fresh worktree therefore
|
|
12
|
+
# had no hooks, and git said nothing: no warning, no error, no output.
|
|
13
|
+
#
|
|
14
|
+
# AGENTS.md §1 mandates a fresh worktree per unit of work, so the required
|
|
15
|
+
# workflow disarmed its own gates. 6 of 32 working trees were armed. The pre-push
|
|
16
|
+
# pyright, the pre-commit lint-staged and commitlint had all been silently
|
|
17
|
+
# skipped in the other 26 for as long as anyone had been using worktrees.
|
|
18
|
+
#
|
|
19
|
+
# Nothing detected it because nothing looked. This is the thing that looks.
|
|
20
|
+
#
|
|
21
|
+
# ## Verdicts
|
|
22
|
+
#
|
|
23
|
+
# ARMED git will execute a hook here.
|
|
24
|
+
# DEAD core.hooksPath is set and its target is missing or carries no
|
|
25
|
+
# hooks. Git skips silently. **This is the state that lies to you**,
|
|
26
|
+
# and the only one that makes this script exit non-zero.
|
|
27
|
+
# NO-HOOKS no hooks configured at all. Honest, and visible in this report.
|
|
28
|
+
#
|
|
29
|
+
# Usage:
|
|
30
|
+
# sh scripts/hook-audit.sh # this repo and its worktrees
|
|
31
|
+
# sh scripts/hook-audit.sh --estate ~/code # every repo under a directory
|
|
32
|
+
# sh scripts/hook-audit.sh --quiet # verdict counts only
|
|
33
|
+
|
|
34
|
+
set -uo pipefail
|
|
35
|
+
|
|
36
|
+
ESTATE=""
|
|
37
|
+
QUIET=""
|
|
38
|
+
while [ $# -gt 0 ]; do
|
|
39
|
+
case "$1" in
|
|
40
|
+
--estate) ESTATE="$2"; shift 2 ;;
|
|
41
|
+
--quiet) QUIET=1; shift ;;
|
|
42
|
+
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
|
43
|
+
esac
|
|
44
|
+
done
|
|
45
|
+
|
|
46
|
+
armed=0
|
|
47
|
+
dead=0
|
|
48
|
+
nohooks=0
|
|
49
|
+
dead_list=""
|
|
50
|
+
|
|
51
|
+
# The three hooks this standard cares about. A hooksPath directory containing
|
|
52
|
+
# none of them is not armed for our purposes even if it holds something else.
|
|
53
|
+
WANTED='^(pre-commit|pre-push|commit-msg)$'
|
|
54
|
+
|
|
55
|
+
report() {
|
|
56
|
+
tree="$1"
|
|
57
|
+
label="$2"
|
|
58
|
+
hp=$(git -C "$tree" config core.hooksPath 2>/dev/null)
|
|
59
|
+
|
|
60
|
+
if [ -z "$hp" ]; then
|
|
61
|
+
# The default hooks directory is in the **common** git dir, which linked
|
|
62
|
+
# worktrees share. Reading "$tree/.git/hooks" is wrong for exactly the trees
|
|
63
|
+
# this audit exists to check: in a linked worktree `.git` is a *file*
|
|
64
|
+
# containing a gitdir pointer, so that path does not exist and every armed
|
|
65
|
+
# worktree was about to be reported NO-HOOKS. Ask git where it actually is.
|
|
66
|
+
#
|
|
67
|
+
# Git ships .sample files there that never execute, so counting the
|
|
68
|
+
# directory as armed merely for being non-empty would be exactly the false
|
|
69
|
+
# comfort this script exists to remove.
|
|
70
|
+
common=$(git -C "$tree" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
|
|
71
|
+
real=$(ls "${common:-$tree/.git}/hooks" 2>/dev/null | grep -vc '\.sample$' || true)
|
|
72
|
+
if [ "${real:-0}" -gt 0 ]; then
|
|
73
|
+
armed=$((armed + 1))
|
|
74
|
+
[ -n "$QUIET" ] || printf '%-56s %-14s \033[32mARMED\033[0m %s\n' "$label" "(default)" "$real hook(s) in .git/hooks"
|
|
75
|
+
else
|
|
76
|
+
nohooks=$((nohooks + 1))
|
|
77
|
+
[ -n "$QUIET" ] || printf '%-56s %-14s \033[33mNO-HOOKS\033[0m %s\n' "$label" "(default)" "no hooks configured"
|
|
78
|
+
fi
|
|
79
|
+
return
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
# Relative hooksPath resolves against the working tree root — the whole bug.
|
|
83
|
+
case "$hp" in
|
|
84
|
+
/*) dir="$hp" ;;
|
|
85
|
+
*) dir="$tree/$hp" ;;
|
|
86
|
+
esac
|
|
87
|
+
|
|
88
|
+
present=$(ls "$dir" 2>/dev/null | grep -E "$WANTED" | tr '\n' ',' || true)
|
|
89
|
+
if [ -z "$present" ]; then
|
|
90
|
+
dead=$((dead + 1))
|
|
91
|
+
dead_list="$dead_list $label ($hp)
|
|
92
|
+
"
|
|
93
|
+
[ -n "$QUIET" ] || printf '%-56s %-14s \033[31mDEAD\033[0m %s\n' "$label" "$hp" "$hp missing or holds no hooks — git skips ALL hooks silently"
|
|
94
|
+
else
|
|
95
|
+
armed=$((armed + 1))
|
|
96
|
+
[ -n "$QUIET" ] || printf '%-56s %-14s \033[32mARMED\033[0m %s\n' "$label" "$hp" "${present%,}"
|
|
97
|
+
fi
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
walk_repo() {
|
|
101
|
+
root="${1%/}"
|
|
102
|
+
name="$2"
|
|
103
|
+
# Every working tree, not just the primary — the primary is usually the one
|
|
104
|
+
# that IS armed, which is how this went unnoticed for so long.
|
|
105
|
+
git -C "$root" worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2}' | while read -r t; do
|
|
106
|
+
[ -d "$t" ] || continue
|
|
107
|
+
if [ "$t" = "$root" ]; then echo "$t|$name"; else echo "$t|$name${t#$root}"; fi
|
|
108
|
+
done
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
[ -n "$QUIET" ] || printf '%-56s %-14s %-8s %s\n' "WORKING TREE" "hooksPath" "VERDICT" "detail"
|
|
112
|
+
|
|
113
|
+
if [ -n "$ESTATE" ]; then
|
|
114
|
+
targets=$(for d in "$ESTATE"/*/; do
|
|
115
|
+
[ -e "$d/.git" ] || continue
|
|
116
|
+
walk_repo "$d" "$(basename "${d%/}")"
|
|
117
|
+
done)
|
|
118
|
+
else
|
|
119
|
+
root=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "not a git repo" >&2; exit 2; }
|
|
120
|
+
# From inside a worktree, --show-toplevel gives the worktree; walk from the
|
|
121
|
+
# common repo so sibling worktrees are audited too.
|
|
122
|
+
common=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
|
|
123
|
+
root=$(dirname "$common")
|
|
124
|
+
targets=$(walk_repo "$root" "$(basename "$root")")
|
|
125
|
+
fi
|
|
126
|
+
|
|
127
|
+
while IFS='|' read -r tree label; do
|
|
128
|
+
[ -n "$tree" ] || continue
|
|
129
|
+
report "$tree" "$label"
|
|
130
|
+
done <<EOF
|
|
131
|
+
$targets
|
|
132
|
+
EOF
|
|
133
|
+
|
|
134
|
+
total=$((armed + dead + nohooks))
|
|
135
|
+
printf '\n%s working trees — \033[32m%s armed\033[0m, \033[31m%s dead\033[0m, %s without hooks' "$total" "$armed" "$dead" "$nohooks"
|
|
136
|
+
[ "$total" -gt 0 ] && printf ' (%s%% armed)' "$((100 * armed / total))"
|
|
137
|
+
printf '\n'
|
|
138
|
+
|
|
139
|
+
if [ "$dead" -gt 0 ]; then
|
|
140
|
+
printf '\n\033[31mDEAD working trees — hooks are configured here and are NOT running:\033[0m\n%s' "$dead_list"
|
|
141
|
+
printf 'Every commit and push made in these is unguarded, and nothing says so.\n'
|
|
142
|
+
printf 'Fix: run `pnpm install` there, or move the repo to tracked .githooks/.\n'
|
|
143
|
+
exit 1
|
|
144
|
+
fi
|
|
145
|
+
exit 0
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
#
|
|
3
|
+
# Install hook dispatchers into the repository's **shared** hooks directory, so
|
|
4
|
+
# every worktree is guarded — including ones that already exist.
|
|
5
|
+
#
|
|
6
|
+
# ## Why not core.hooksPath
|
|
7
|
+
#
|
|
8
|
+
# #838 moved the hooks into a tracked `.githooks/` and pointed
|
|
9
|
+
# `core.hooksPath` there. That fixed the original bug (a gitignored runner
|
|
10
|
+
# directory that only `pnpm install` created), but it leaves a gap that matters
|
|
11
|
+
# for reaching full coverage:
|
|
12
|
+
#
|
|
13
|
+
# `core.hooksPath` is a **relative** path resolved against each worktree's root,
|
|
14
|
+
# and it lives in the shared config. So the moment it is set, every worktree
|
|
15
|
+
# checked out on a branch that predates `.githooks/` has the config but not the
|
|
16
|
+
# directory — and git is back to skipping silently. AGENTS.md forbids modifying
|
|
17
|
+
# a worktree you did not create, so those cannot simply be rebased.
|
|
18
|
+
#
|
|
19
|
+
# Git's own default is better than the override. With `core.hooksPath` **unset**,
|
|
20
|
+
# a linked worktree runs the hooks in the **common** `.git/hooks` directory —
|
|
21
|
+
# verified 2026-07-29: a dispatcher installed once in the main checkout fired in
|
|
22
|
+
# a pre-existing linked worktree and in one created afterwards, blocking the
|
|
23
|
+
# commit in both, with the hook's `pwd` set to the worktree.
|
|
24
|
+
#
|
|
25
|
+
# So the shared directory is the delivery mechanism and `.githooks/` stays the
|
|
26
|
+
# source of truth for the logic. One install per clone arms every worktree that
|
|
27
|
+
# clone will ever have.
|
|
28
|
+
#
|
|
29
|
+
# ## What a dispatcher does when there is nothing to dispatch to
|
|
30
|
+
#
|
|
31
|
+
# It warns and exits 0. A branch without `.githooks/` has no repo-defined hooks,
|
|
32
|
+
# which is the state it was already in — blocking every commit there would be
|
|
33
|
+
# inventing a gate that branch never had, and would break other agents
|
|
34
|
+
# mid-flight. But it says so, every time, because "no checks ran" being
|
|
35
|
+
# invisible is the whole defect (#838, #839).
|
|
36
|
+
#
|
|
37
|
+
# Run by `prepare`, so `pnpm install` is still what arms a fresh clone. There is
|
|
38
|
+
# no way to ship an armed hook in a repository — that is git's design, not an
|
|
39
|
+
# oversight — so the goal is to need it once per clone rather than once per
|
|
40
|
+
# worktree.
|
|
41
|
+
|
|
42
|
+
set -eu
|
|
43
|
+
|
|
44
|
+
HOOKS_DIR="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)/hooks"
|
|
45
|
+
mkdir -p "$HOOKS_DIR"
|
|
46
|
+
|
|
47
|
+
# An inherited core.hooksPath would make every dispatcher below unreachable, and
|
|
48
|
+
# the failure would be silent. Clearing it is the entire point of the exercise.
|
|
49
|
+
if [ -n "$(git config --local core.hooksPath 2>/dev/null || true)" ]; then
|
|
50
|
+
git config --local --unset core.hooksPath
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
for hook in pre-commit pre-push commit-msg; do
|
|
54
|
+
cat > "$HOOKS_DIR/$hook" <<EOF
|
|
55
|
+
#!/usr/bin/env sh
|
|
56
|
+
# Generated by scripts/install-hooks.sh — do not edit.
|
|
57
|
+
# The logic lives in the tracked .githooks/ of whichever worktree is running.
|
|
58
|
+
root=\$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
|
|
59
|
+
if [ -x "\$root/.githooks/$hook" ]; then
|
|
60
|
+
exec "\$root/.githooks/$hook" "\$@"
|
|
61
|
+
fi
|
|
62
|
+
if [ -f "\$root/.githooks/$hook" ]; then
|
|
63
|
+
exec sh "\$root/.githooks/$hook" "\$@"
|
|
64
|
+
fi
|
|
65
|
+
# Nothing to run. Say so — an unguarded commit that looks guarded is the defect
|
|
66
|
+
# this whole mechanism exists to remove (#839).
|
|
67
|
+
echo "warning: no .githooks/$hook in \$root — this tree predates the local-gate" >&2
|
|
68
|
+
echo " standard, so NO checks ran. See docs/practices/standards/local-gates.md" >&2
|
|
69
|
+
exit 0
|
|
70
|
+
EOF
|
|
71
|
+
chmod +x "$HOOKS_DIR/$hook"
|
|
72
|
+
done
|
|
73
|
+
|
|
74
|
+
echo "hooks armed: $HOOKS_DIR (shared — every worktree of this clone, now and later)"
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
#
|
|
3
|
+
# Run the checks CI runs, here, before the push that would have found them.
|
|
4
|
+
#
|
|
5
|
+
# ## Why this exists
|
|
6
|
+
#
|
|
7
|
+
# Until 2026-07-29 the only local gate was a whole-project `pyright` in a
|
|
8
|
+
# pre-push hook -- in the three repos that had hooks at all. Everything else
|
|
9
|
+
# (eslint, prettier, tsc, vitest, ruff, terraform fmt, the plugin guards) ran for
|
|
10
|
+
# the first time on a GitHub runner, after a push, after a PR, after the merge
|
|
11
|
+
# race.
|
|
12
|
+
#
|
|
13
|
+
# Over the 30 days to 2026-07-29, across the twelve repos in the estate that run
|
|
14
|
+
# CI: 373 failed runs, and 211 of 342 failing steps (62%) were locally
|
|
15
|
+
# catchable -- deterministic, offline, no credentials. By kind: tests 49,
|
|
16
|
+
# format 53, typecheck 20, lint 16, terraform fmt 12, and the core ownership
|
|
17
|
+
# guard 11 -- a check already wired as a commit hook, being discovered in the
|
|
18
|
+
# pipeline because the hook was not running.
|
|
19
|
+
#
|
|
20
|
+
# The same file failed the same check across consecutive runs -- e.g.
|
|
21
|
+
# services/api/src/api/routing/crud_handlers.py failing `ruff format --check` on
|
|
22
|
+
# four separate runs. That is the signature of a round trip being used as the
|
|
23
|
+
# check: push, wait for CI, read the failure, fix, push again.
|
|
24
|
+
#
|
|
25
|
+
# ## Why it adapts instead of being tailored
|
|
26
|
+
#
|
|
27
|
+
# This one file runs in the template, in instances, in sibling apps and in
|
|
28
|
+
# plugin repos, whose CI check sets differ. It could have been forked per repo;
|
|
29
|
+
# forks drift, and a gate that has drifted from CI reports a green CI will not
|
|
30
|
+
# honour.
|
|
31
|
+
#
|
|
32
|
+
# So every check is conditional on the repo actually having it, and an
|
|
33
|
+
# inapplicable check prints `n/a` rather than being silently absent. Absence and
|
|
34
|
+
# inapplicability look identical in a summary that omits both, and telling them
|
|
35
|
+
# apart is the entire point (docs/practices/standards/local-gates.md).
|
|
36
|
+
#
|
|
37
|
+
# ## What is deliberately excluded
|
|
38
|
+
#
|
|
39
|
+
# - pytest -- 56s in the template, more than the rest of the gate combined,
|
|
40
|
+
# and it failed once there in 30 days. Opt in per repo with
|
|
41
|
+
# BIFFO_VERIFY_PYTEST=1 where the suite is fast.
|
|
42
|
+
# - app/portal build -- a full Next build.
|
|
43
|
+
# - dependency audits, pip-audit, pnpm audit -- network.
|
|
44
|
+
# - gitleaks -- scans history, not the working tree.
|
|
45
|
+
#
|
|
46
|
+
# cli/src/lib/verify-parity.test.ts fails if the template's CI grows a check
|
|
47
|
+
# that is neither here nor in that written exclusion list.
|
|
48
|
+
#
|
|
49
|
+
# Usage:
|
|
50
|
+
# sh scripts/verify.sh # everything applicable to this repo
|
|
51
|
+
# pnpm run verify # same
|
|
52
|
+
# BIFFO_SKIP_VERIFY=1 git push # escape hatch, for when you mean it
|
|
53
|
+
|
|
54
|
+
set -u
|
|
55
|
+
|
|
56
|
+
FAILED=""
|
|
57
|
+
PASSED=""
|
|
58
|
+
SKIPPED=""
|
|
59
|
+
|
|
60
|
+
PYTEST="${BIFFO_VERIFY_PYTEST:-}"
|
|
61
|
+
|
|
62
|
+
have_script() {
|
|
63
|
+
[ -f package.json ] || return 1
|
|
64
|
+
node -e "process.exit(JSON.parse(require('fs').readFileSync('package.json','utf8')).scripts?.['$1']?0:1)" 2>/dev/null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
run_check() {
|
|
68
|
+
name="$1"
|
|
69
|
+
shift
|
|
70
|
+
start=$(date +%s)
|
|
71
|
+
if "$@" >"/tmp/biffo-verify.$$" 2>&1; then
|
|
72
|
+
PASSED="$PASSED $name"
|
|
73
|
+
printf ' \033[32mOK\033[0m %-16s %ss\n' "$name" "$(($(date +%s) - start))"
|
|
74
|
+
else
|
|
75
|
+
FAILED="$FAILED $name"
|
|
76
|
+
printf ' \033[31mFAIL\033[0m %-16s %ss\n' "$name" "$(($(date +%s) - start))"
|
|
77
|
+
sed 's/^/ /' "/tmp/biffo-verify.$$" | tail -25
|
|
78
|
+
fi
|
|
79
|
+
rm -f "/tmp/biffo-verify.$$"
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
skip() {
|
|
83
|
+
SKIPPED="$SKIPPED $1"
|
|
84
|
+
printf ' \033[90m-- %-16s n/a - %s\033[0m\n' "$1" "$2"
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
printf '\nverify - the checks CI runs, before the push\n\n'
|
|
88
|
+
|
|
89
|
+
# Python first: ruff is near-instant, so the cheapest feedback on the largest
|
|
90
|
+
# single class of failure comes back immediately.
|
|
91
|
+
if [ -f pyproject.toml ]; then
|
|
92
|
+
if command -v uv >/dev/null 2>&1; then
|
|
93
|
+
run_check ruff-check uv run ruff check .
|
|
94
|
+
run_check ruff-format uv run ruff format --check .
|
|
95
|
+
run_check pyright uv run pyright
|
|
96
|
+
if [ -n "$PYTEST" ]; then
|
|
97
|
+
run_check pytest uv run pytest -q
|
|
98
|
+
else
|
|
99
|
+
skip pytest "excluded - set BIFFO_VERIFY_PYTEST=1 where the suite is fast"
|
|
100
|
+
fi
|
|
101
|
+
else
|
|
102
|
+
skip python "uv not installed"
|
|
103
|
+
fi
|
|
104
|
+
else
|
|
105
|
+
skip python "no pyproject.toml in this repo"
|
|
106
|
+
fi
|
|
107
|
+
|
|
108
|
+
# Terraform, wherever this repo keeps it: modules/ in the template and
|
|
109
|
+
# instances, infra/ and modules/ in siblings.
|
|
110
|
+
if command -v terraform >/dev/null 2>&1; then
|
|
111
|
+
# Scope must match this repo's CI, not exceed it. The template and instances
|
|
112
|
+
# deliberately fmt-check modules/ ONLY: infra/environments/ is user-owned, and
|
|
113
|
+
# a template-shipped check asserting over paths the template does not own is
|
|
114
|
+
# the #325 trap -- it reds an instance on content it neither wrote nor can
|
|
115
|
+
# repair. Siblings own their whole infra/ and their CI checks it, so they get
|
|
116
|
+
# both. biffo.sibling.json is what tells them apart.
|
|
117
|
+
tf_dirs=""
|
|
118
|
+
[ -d modules ] && tf_dirs="$tf_dirs modules/"
|
|
119
|
+
[ -f biffo.sibling.json ] && [ -d infra ] && tf_dirs="$tf_dirs infra/"
|
|
120
|
+
if [ -n "$tf_dirs" ]; then
|
|
121
|
+
# shellcheck disable=SC2086
|
|
122
|
+
run_check terraform-fmt terraform fmt -check -recursive $tf_dirs
|
|
123
|
+
else
|
|
124
|
+
skip terraform-fmt "no terraform in this repo"
|
|
125
|
+
fi
|
|
126
|
+
else
|
|
127
|
+
skip terraform-fmt "terraform not installed"
|
|
128
|
+
fi
|
|
129
|
+
|
|
130
|
+
# The Biffo guards, where the dispatcher exists. Cheap, and two of them
|
|
131
|
+
# (ownership, plugin-terraform) were being caught in CI.
|
|
132
|
+
if [ -f scripts/biffo.sh ]; then
|
|
133
|
+
run_check plugin-tf sh scripts/biffo.sh check plugin-terraform
|
|
134
|
+
run_check plugin-names sh scripts/biffo.sh check plugin-collisions
|
|
135
|
+
else
|
|
136
|
+
skip biffo-guards "no scripts/biffo.sh in this repo"
|
|
137
|
+
fi
|
|
138
|
+
|
|
139
|
+
# JS, cheapest first; `test` last because it is slowest and the most likely to
|
|
140
|
+
# be interrupted by an impatient reader.
|
|
141
|
+
if [ -f package.json ]; then
|
|
142
|
+
skip build "excluded - a full app build is too slow for a push gate"
|
|
143
|
+
for s in lint typecheck format:check test; do
|
|
144
|
+
label=$(printf '%s' "$s" | tr -d ':')
|
|
145
|
+
if have_script "$s"; then
|
|
146
|
+
run_check "$label" pnpm run "$s"
|
|
147
|
+
else
|
|
148
|
+
skip "$label" "no \"$s\" script in package.json"
|
|
149
|
+
fi
|
|
150
|
+
done
|
|
151
|
+
else
|
|
152
|
+
skip javascript "no package.json in this repo"
|
|
153
|
+
fi
|
|
154
|
+
|
|
155
|
+
printf '\n'
|
|
156
|
+
if [ -n "$FAILED" ]; then
|
|
157
|
+
printf '\033[31mverify failed:\033[0m%s\n' "$FAILED"
|
|
158
|
+
printf 'Fix these here - CI will find them anyway, three minutes and a merge race later.\n'
|
|
159
|
+
printf 'Most format failures are one command: pnpm run format\n\n'
|
|
160
|
+
exit 1
|
|
161
|
+
fi
|
|
162
|
+
printf '\033[32mverify passed\033[0m -%s\n' "$PASSED"
|
|
163
|
+
[ -n "$SKIPPED" ] && printf '\033[90mnot applicable here:%s\033[0m\n' "$SKIPPED"
|
|
164
|
+
printf '\n'
|