Repository navigation
fix(hook): scope the doctor gate in verify:adf repos - #365
Conversation
…un it with the repo's package manager Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
stackbilt-admin
left a comment
There was a problem hiding this comment.
Adversarial review: PR #365 (head 1dcdcd9)
Verdict: changes requested. The fix works. One user-facing doc is still wrong, and there are some smaller points to look at.
What I checked and confirmed
- CI is green: build-and-test, Charter Governance, CodeQL, Dependency Review and SBOM all pass.
- I ran the suite locally in a fresh worktree (
pnpm install && pnpm build && pnpm test): 1437 pass and 3 fail. The 3 are the known Windows-only failures (bootstrap #307 fixture, context-refresh "captures git signals", serve-context "returns existing snapshot"). - I ran each scenario in a fresh
mktemp -drepo made with the PR build'sbootstrapandhook install --pre-commit.node_modules/.bin/charterpointed at the PR build, and the repo had an npm lockfile.- B/N, unstaged GEMINI.md bloat with notes.md staged: the commit passes. The scoped doctor shows the bloat as INFO.
npm run verify:adfran, and its doctor printedadf vendor bloat (checked by pre-commit hook)and the stderr line. No token file was left behind. - Variable set, no file (
CHARTER_HOOK_SCOPED_DOCTOR=1 charter doctor --adf-only --ci): WARN, rc=1. The relaxation does not apply. - verify:adf with a failing extra command (
&& node -e "process.exit(3)"): the commit fails with rc=1 and thenpm run verify:adfhint. The same happens when the hook runs underdash. The EXIT trap does not change the exit status. I also checked separately thatexit 7with the same traps under dash still returns 7. - Linked worktree at a path with spaces:
--git-pathresolves to.git/worktrees/<name>/charter-hook-scoped-doctor, a separate file for each worktree. Commits running at the same time in the main worktree and the linked one both pass, and each uses its own token. - Old local CLI (published 1.9.3): given
--staged --tidy-skipped ""and--tidy-skipped " GEMINI.md", it ignores them and runs a normal doctor, as the PR says. dash -non the generated hook passes.
- B/N, unstaged GEMINI.md bloat with notes.md staged: the commit passes. The scoped doctor shows the bloat as INFO.
- Doctor makes no git call when the variable is unset or empty (
if (!token) return falsecomes beforerunGit), and--stagedtakes precedence. Every non-bloat check is still pushed as before, because only the bloat branch changed.
Findings
-
[must fix]
packages/cli/README.md:303is stale. It still says the hook "validates ceilings withcharter adf evidence --auto-measure --ci(orpnpm run verify:adfwhen that script exists)". This PR stops the hook from calling pnpm by name and always runs the scoped doctor first. This README is the npm package page, and the PR updateddocs/cli-reference.mdand the CHANGELOG but not this file. The upgrade sentence at the end of that paragraph also does not mention the #360-era hook. -
[low] The #360-era INFO row also appears in repos with no
verify:adf, and there its wording is wrong.doctor-gate-enforcement.ts:71firesDOCTOR_VERIFY_ADF_GATE_MSGfor any hook that has the first three markers but not the fourth. It does not look atpackage.json. In a repo with noverify:adfscript, the #360 hook was already scoped. The row then tells the user their hook "runs pnpm run verify:adf instead … which fails where pnpm is not installed", which is not true for that repo. It is only INFO, but it is misleading. Either check for the script, or word the message as conditional. -
[low] When the token cannot be written, the fail-safe prints a raw shell error and gives no reason for the strict run. At
hook.ts:279the line isprintf … > "$SCOPE_FILE" 2>/dev/null. Redirections apply left to right, so if the>fails, the shell reports it before stderr is redirected. I reproduced this with dash:if printf '%s\n' x > /nonexistent/d/f 2>/dev/null; …printscannot create /nonexistent/d/f: Directory nonexistent.verify:adfthen runs at full strictness, so bloat in an unstaged file blocks the commit, and nothing says the relaxation was skipped. The fix is{ printf … > "$SCOPE_FILE"; } 2>/dev/nullplus a one-line notice. Separately,SCOPE_FILE=$(git rev-parse …)underset -ewould end the hook with no[pre-commit]message if git failed there. That is unlikely, since the hook has already run git several times by then. -
[low] Two commits in the same worktree at once share one token file, and the PR does not mention this. The PR's reasoning assumes git's index lock serialises hooks. It does not: my probe saw no
index.lockduring the hook, and twogit commits in the same worktree ran their hooks at the same time. Whichever hook finishes first runs its EXIT trap, which deletes the other hook's file, or the second write overwrites the first token. Either way the other doctor falls back to strict, so the gate never relaxes when it should not (it fails closed). The cost is a possible spurious block. A per-hook name such as--git-path charter-hook-scoped-doctor.$$would avoid it, and doctor could take the path from a second variable. -
[low, partly pre-existing] The Yarn PnP claim in the PR body is overstated. The body says the lockfile check keeps Yarn PnP on its own tool. But the gate's first step is now
npx charter doctor … --staged, which also applies in verify:adf repos. Under PnP there is nonode_modules/.bin, so npx cannot find the locally installed CLI. It falls back to$PATH, and then to the npm registry package namedcharter. That package is an unrelated 2022 package with nobin, so the gate fails beforeyarn run verify:adfis reached. I did not run this (it needs yarn berry and a network install). The samenpx charterproblem exists in the tidy block and in the oldelsebranch. -
[low] Behaviour change in verify:adf repos is not called out. The hook now always runs
npx charter doctor --adf-only --ci --staged, even when a user'sverify:adfleaves doctor out. The PR body's "users' custom commands in it still run" is true but incomplete. Also,setup.ts:1091rewrites a customverify:adfback to the default, so customisations are fragile anyway. -
[nit] The PR body is wrong about dash. It says
dashis not installed on this host, so the INV-P4-7 probe skips. In fact Git for Windows ships/usr/bin/dash, and node'sspawnSync('dash')finds it here, so the dash parse test ran and passed. That is good news for the PR; only the body needs correcting.
Not verified
- Yarn berry/PnP and yarn classic end to end (yarn here is only a corepack shim).
- pnpm-lock with pnpm missing inside a real pnpm workspace with
.npmrcsettings. Reasoning:npm runaddsnode_modules/.binto PATH, and pnpm puts thechartershim there, so it should work. - A read-only
.gitwith a full commit. Only the redirection behaviour was reproduced. - SIGKILL leaving a stale file (checked by reading the code only: the token is pid plus epoch second, so a stale file cannot match a new run).
- Submodules (by reasoning:
--git-pathresolves to the submodule's own git dir).
…INFO (refs #357) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Unreleased changelog had grown to ~6.5k words of per-PR narrative in which later PRs amended earlier entries, and nothing told a 1.9.3 user how to upgrade through the breaking compile change. Consolidate it into a Breaking lead plus Added/Changed/Fixed with one bullet per user-visible change in its final behavior (checked against a 175-claim inventory), and add docs/upgrading-to-2.0.md with copy-paste steps verified end to end on fresh 1.9.3 repos (pointer and compiled). Both READMEs and the CLI reference now match the shipped hook gate behavior from #365. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs #357
Bug
#360 made the pre-commit hook's doctor gate commit-scoped (
doctor --adf-only --ci --staged --tidy-skipped "$TIDY_SKIPPED"), but only in theelsebranch. Whenpackage.jsonhas averify:adfscript, the hook ranpnpm run verify:adfinstead.charter setupandcharter bootstrapwrite that script ascharter doctor --adf-only --ci --format json && charter adf evidence --auto-measure --ci --format json. So in every bootstrapped Node repo the scoped gate never ran, and commits were still blocked by bloat in unstaged or unrelated vendor files (the #356 regression that #357 was meant to fix).A second bug: the hook called
pnpmby name, so the gate failed in npm and yarn repos on machines without pnpm.Design
elsebranch, now outside the branch. If it fails, the gate stops there.verify:adfscript, the hook now runsnpx charter doctor --adf-only --ci --staged …on every commit, even when a customverify:adfleaves doctor out. The CHANGELOG anddocs/cli-reference.mdsay so too. (setupalso rewrites a customverify:adfback to the default, so customisations were already fragile.)verify:adfif the script exists, otherwiseadf evidence. Theverify:adfscript runs unchanged, so users' custom commands in it still run and still block.pnpmwhen there is apnpm-lock.yamlandcommand -v pnpmsucceeds;yarnwhen there is ayarn.lockand yarn is installed;npmotherwise. npm ships with node, and the hook'snpxneeds it anyway, so pnpm is never required. The lockfile check keeps pnpm repos on pnpm (and their.npmrcsettings). The failure hint names the runner the hook used.npx charter, which cannot find a CLI installed under PnP (there is nonode_modules/.bin). So the gate fails before it reachesyarn run verify:adf. That was already true before this PR, and hook: pre-commit gate fails under Yarn Plug'n'Play (npx charter can't resolve the PnP binary) #366 tracks it.verify:adf: for that run only, the hook setsCHARTER_HOOK_SCOPED_DOCTOR. When it is honoured, the only effect is that doctor'sadf vendor bloatcheck becomes an INFO row,adf vendor bloat (checked by pre-commit hook), which still lists the bloat it found. Doctor also prints a line on stderr:[doctor] adf vendor bloat: not enforced in this run; .... Every other doctor check still blocks.--stagedtakes precedence over the variable.CI safety: option (b), a one-time token
The hook makes a token
$$-$(date +%s)and writes it to its own file,$(git rev-parse --git-path charter-hook-scoped-doctor-<token>), one file per hook run. It puts the same value inCHARTER_HOOK_SCOPED_DOCTOR. Atrapon EXIT, HUP, INT and TERM removes only that run's file.Doctor accepts the variable only if:
<digits>-<digits>, so it is never turned into an arbitrary path;charter-hook-scoped-doctor-<token>exists;When the variable is unset, doctor does not call git at all.
Overlapping commits: two hooks running at once in one worktree each have their own file, so neither deletes or overwrites the other's. Git's index lock does not serialise hooks, and the review reproduced the overlap. This is tested with two overlapping
git hook run pre-commitruns: both relax, and each sees the right files.Write failure: both the token-file write (
{ printf … > "$SCOPE_FILE"; } 2>/dev/null) and thegit rev-parse --git-pathstep (|| SCOPE_FILE="") fail quietly. In either case the hook prints one line,[pre-commit] Could not write the scope token in the git dir, so this time the doctor inside verify:adf checks vendor bloat in every file, as CI does., then carries on withverify:adfat full strictness. There is no raw shell error, and no abort underset -e.Why this option:
GIT_INDEX_FILE=.git/index,GIT_EDITOR=:,GIT_PREFIXand the author variables. Those are ordinary environment variables that a CI job or wrapper can also carry, so they are weak evidence that a hook is running.CI=true. This depends on every CI system settingCI, and it would silently change local behaviour too.doctor --cistays byte-identical to main (tested). A stale file left by a SIGKILLed hook holds an old token, so it does not match either. The token is not a secret: the threat is accidental weakening, not an attacker, since anyone who controls the environment can skip the hook anyway. (c)'s loud notice is included as well.verify:adfruns at full strictness.The constants and
hookScopeFileName()live in a new import-free module,hook-scope.ts. Importing them into doctor fromhook.tshit a circular-import ordering under vitest that left the message readingundefined.Upgrade path
There is a fourth marker,
# Charter pre-commit evidence gate: scoped in verify:adf repos too, run with the repo package manager. A #360-era hook has the first three markers but not this one. Doctor'spre-commit hookINFO row fires for such a hook only whenpackage.jsonhas averify:adfscript, using the same"verify:adf"test the hook applies at the repo root. Without the script, a #360 hook already runs the scoped doctor and thenadf evidence, exactly as the new hook does, so there is nothing to fix and no row. This way the row never carries the pnpm wording where it does not apply. Existing installs keep the old text untilcharter hook install --pre-commitis re-run.This repo's own
.githooks/pre-commitneeds no change: its gate runs onlyadf evidence, never doctor orverify:adf. The tidy block is unchanged, and the parity test still passes.E2E: main (48cda68) vs PR
Each scenario ran in a fresh
mktemp -drepo under the scratchpad (set -euo pipefail; GIT_DIR, GIT_INDEX_FILE and GIT_WORK_TREE unset;git rev-parse --show-toplevelchecked before any write). The hook was installed with each build'shook install --pre-commit, andnpx/charterPATH shims pointed at that build. The verify:adf flavor uses setup's exactverify:adfplus apackage-lock.json.The last column compares plain doctor on the pre-commit state, with each of
"",--ciand--adf-only --ci, in text and json. Each combination compares the main build, the PR build, and the PR build withCHARTER_HOOK_SCOPED_DOCTOR=1set: 204 comparisons, all byte-identical. No token file was left behind in any run.core.autocrlf=true, CRLFgit rmrecorded CLAUDE.mdgit mvCLAUDE.md → AGENTS.md.ai/gitignoredVENDORS+ CP-3CHARTER_SKIP_TIDY=1Tests
precommit-hook.test.ts: the whole hook: doctor gate blocks commits on bloat in files that aren't part of the commit #357 full-hook group now runs in both flavors: without verify:adf, and with verify:adf plus an npm lockfile and a failingpnpmshim on PATH. In the verify flavor, each passing case also asserts thatverify:adfran via npm (never pnpm), that its doctor reported the INFO row and the stderr line, and that the token file is gone. Each blocking case asserts that nothing ran after the scoped doctor. New verify-only tests:verify:adfblocks;verify:adfsees (missing.charter/config.json) blocks;pnpm-lock.yamlwith pnpm on PATH uses pnpm;npm run verify:adfby hand withCHARTER_HOOK_SCOPED_DOCTOR=1still WARNs on bloat;--git-pathanswers with a nonexistent directory), andgit rev-parse --git-pathfails: each prints the one notice, shows no raw shell error, carries on intoverify:adf, runs it strict (the unstaged bloat blocks) and leaves no file. These run the hook directly undershwith agitshim first on PATH, because git puts its own dirs ahead of PATH for hooks;verify:adfsleeps 8s, and the second starts once the first's file exists. The second sees 2 scope files, finishes, and removes only its own. The first still relaxes. Both exit 0, and no file is left.doctor-staged-bloat.test.ts:1, or to the right token, with no file;../x) whose target holds it;--cipasses;VENDORSand a missing default module still fail;--stagedtakes precedence.hook.test.ts:verify:adfgets the new INFO row, and reinstalling clears it. With nopackage.json, or one withoutverify:adf, it gets no row;"$PM" run, nopnpm run, the per-run--git-pathwith|| SCOPE_FILE="", the braced quiet write, the notice, the trap lines and 5npx charter;sh -n,dash -nandbash --posix -nall pass. Git for Windows ships/usr/bin/dash, so the INV-P4-7 dash probe ran here too. (An earlier version of this description wrongly said it was skipped.)../xcase;rev-parsewithout|| SCOPE_FILE=""(aborts underset -e) fails 1;verify:adffails 2.pnpm runfails 9;package.jsonfails 10;CHARTER_HOOK_SCOPED_DOCTOR=1, is byte-identical to main in every comparison, and no token file is left.pnpm run verify:adfpass.Upgrade note
Re-run
charter hook install --pre-commit. Until you do,charter doctorreports a #360-era hook with an INFOpre-commit hookrow. With an older local CLI, the new hook's flags and variable are ignored and the gate behaves as before.🤖 Generated with Claude Code