Repository navigation
fix(hook): doctor gate only blocks on bloat in files the commit includes - #360
Conversation
…des (closes #357) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
stackbilt-admin
left a comment
There was a problem hiding this comment.
APPROVED
Adversarial review of #360 (closes #357) at 051c7f3. I built it on Windows and compared it with a build of main @ 581465c. Each scenario ran in a fresh scratch repo through the hook that hook install --pre-commit generates, with an npx shim pointing at the build under test. There are 28 scenarios: the 13 from the #356 review, X1–X3, and 12 new ones.
Required changes
None.
Results (generated hook; main vs PR)
"HEAD bloat" counts how many vendor files in the resulting commit still carry the bloat section (main/PR). "doctor identical" means the plain doctor, doctor --ci and doctor --adf-only --ci output (text and json, so 6 forms) is byte-identical between main and the PR, exit code included.
| Scen | main | PR | HEAD bloat | doctor identical |
|---|---|---|---|---|
| S0 staged CLAUDE.md bloat | pass | pass | 0/0 | yes |
| S5 planned module staged | pass | pass | 0/0 | yes |
| UN unborn repo | pass | pass | 0/0 | yes |
| CR autocrlf/CRLF | pass | pass | 0/0 | yes |
| N unstaged GEMINI.md bloat only | block | pass | 0/0 | yes |
S3 / S4 git rm / git mv (vendor mode) |
block | block | 0/0 | yes |
| B, B2 unstaged GEMINI.md bloat | block | pass | 0/0 | yes |
| C partial stage (CP-3) | block | pass | 0/1 | yes |
| D, D3 module WIP / untracked (CP-4) | block | pass | 0/1 | yes |
S1, S6 ignored .ai/ / module |
pass | pass | 0/0 | yes |
| X1 staged keyword bloat tidy can't extract | block | block | 0/0 | yes |
| X2 malformed VENDORS + CP-3 | block | block | 0/0 | yes |
X3 CHARTER_SKIP_TIDY=1 + staged bloat |
block | block | 0/0 | yes |
| E1 CP-3 evasion: one trailing-newline unstaged hunk | block | pass | 0/1 | yes |
| M1 vendor-mode drift (recorded compiled) + CP-3 | block | block (adf vendor mode WARN) |
0/0 | yes |
| F1 real tidy exits 1, staged bloat | block | block | 0/0 | yes |
| F2 real tidy runs but writes nothing, staged bloat | block | block | 0/0 | yes |
A1 lowercase agents.md partial stage |
block | pass (row AGENTS.md, labelled skipped) |
0/1 | yes |
A2 / A3 agents.md / AGENTS.md staged, tidy no-op |
block | block | 0/0 | yes |
H1 hand-authored unstaged GEMINI.md (adf agent config) |
block | block | 0/0 | yes |
| R1 staged deletion of GEMINI.md, WT bloat | block | pass | 0/0 | yes |
| G1 required wiring missing | block | block | 0/0 | yes |
This matches the implementer's table. Every newly passing case is one that #357 and the approved options B+C intend to pass.
Priority checks
-
Outside-hook invariance.
- All 6 doctor forms are byte-identical to main in all 28 repos.
--tidy-skippedwithout--stagedexits 2, in text and json. A missing value exits 2 whether it comes last, before--format, or before--staged.- Scoping is driven only by argv (
args.includes('--staged')).doctorCommandhas a single caller (index.ts), and no env var or config reaches it. I also triedCHARTER_STAGED=1and a bogusGIT_INDEX_FILEwithout the flag: both stay unscoped. - One difference from main is intended: a repo with a #356-era hook now shows the new INFO row.
-
What still blocks. All of these still fail the gated commit:
- vendor-mode drift (M1), a malformed VENDORS entry (X2) and missing required wiring (G1);
git rm/git mv(S3/S4) and a hand-authoredadf agent configfile (H1);- staged bloat that tidy cannot extract (X1) and staged bloat with a no-op tidy (F2/A2/A3);
- a failing tidy (F1; a dry-run failure leaves
TIDY_SKIPPEDempty the same way) andCHARTER_SKIP_TIDY=1(X3).
Ruling on
adf agent config: leaving it unscoped is correct. Tidy never processes non-pointer files, so no hook skip can produce that WARN. It is the same pre-existing state main blocked on, and scoping it would widen #357 beyond the bloat regression.CP-3 evasion, quantified (E1): stage a bloated vendor file, then add one unstaged byte (a trailing newline) to it. The file is "skipped", its bloat lands in HEAD, and the gate passes. Main blocked this.
- The same holds for CP-4, and it is broader there. Any unstaged or untracked work in one planned module puts every staged vendor file in
$NAMESintoTIDY_SKIPPED(D, E2). - The bloat self-heals: the next commit that stages the file cleanly runs tidy and fixes it. Until then it sits in history, and CI
doctor --adf-only --ci(in thesetup --ci githubworkflow) fails on it. - Judgement: this is acceptable. It is exactly the cost the operator accepted with option C ("bloat can reach a commit in C, D and D3 …
doctor --ciin CI still catches it"). The hook is not a security boundary, since--no-verifyexists, and the PR documents the behaviour. - On "exempt only bloat already in HEAD": I recommend against it in this PR. In C/E1 the bloat is new (HEAD is clean), so the change would bring back the #356 block for exactly the scenarios #357 was filed to unblock. If you want a tighter form later, do it in a follow-up: block a skipped file only when its staged blob adds bloat findings that HEAD's blob lacks, with a message naming the ways out. That is review option D, which needs index-blob reads.
-
--stagedindex read.- Timing: the index is read in doctor, after tidy has run and re-staged, so S0/S5 see the tidied pointer.
- Deletions and renames:
--diff-filter=ddrops staged deletions (R1; there is a test). A rename (S4) reports the new name. - Both spellings:
vendorFileGroups()maps a staged or skippedagents.mdonto theAGENTS.mdrow on NTFS (A1 is labelled skipped; A2 blocks). - Index failure: with a corrupt
GIT_INDEX_FILE, and outside a git repo, doctor falls back to enforcing every file. Both block on unstaged bloat. Verified. - Paths with spaces: a repo path containing a space works.
--tidy-skippedparsing: the value is one quoted argv element built only from the fixedVENDOR_FILESlist. A leading space (" CLAUDE.md") is handled. A value starting with--is rejected (exit 2), so--stagedor--formatcannot be swallowed. Unknown or non-canonical names (./CLAUDE.md,claude.md) match nothing and leave the file blocking, which fails closed. I found no injection path.
-
Old CLI + new hook.
- main build:
doctor --adf-only --ci --staged --tidy-skipped "" --format text(and with" CLAUDE.md") ignores both flags. It blocks on bloat exactly as before, with no error. - Released
@stackbilt/cli@1.9.3(vianpx -y): exit 1, and the output is identical to the same command without the flags.
Not a blocking finding: an old CLI degrades to the pre-#357 gate, as the CHANGELOG says.
- main build:
-
Upgrade marker.
- A main-generated (#356-era) hook gets the new INFO row, under
.git/hooksand undercore.hooksPath. - Pre-#320 hooks keep the old row. I checked both a released 1.9.3 install and a main hook with the scope marker removed.
- A PR-generated hook, a foreign hook or no hook gives no row. Reinstalling without
--forceclears the row. doctor --adf-only --ciexits 0 in every case.
- A main-generated (#356-era) hook gets the new INFO row, under
-
Hook text.
- The generated hook passes
sh -n,dash -nandbash --posix -n. The repo hook passessh -n. set -eusafety:TIDY_SKIPPEDis initialised before the guard, and${TIDY_SKIPPED# }is safe.- No new
npxcalls: the failure path now has fewer. - No dangling references: "Blocked because" appears only in negative test assertions.
- Parity tests: pass (96/96 across hook, precommit and doctor-staged-bloat, and the doctor suites).
- The generated hook passes
-
Scenarios: see the table above.
-
Docs and CHANGELOG are accurate on the reinstall instruction, on what still blocks, and on old-CLI behaviour. Small wording nits are below.
Non-blocking notes
-
Misleading PASS row (
doctor.tsstagedPass, ~line 265). When a staged file is skipped and bloated (E1, C, M1), the PASS row reads "0 staged vendor file(s) within bloat thresholds" while CLAUDE.md is staged. Consider counting "staged and checked" or saying "no staged vendor file to check" when all staged ones are skipped. -
Skip messages overstate in two hook variants (hook.ts:161/188, .githooks/pre-commit:28/55). "Bloat in it does not block this commit at the hook's doctor gate (doctor --staged)" is printed in hooks with no
doctor --stagedgate:- this repo's own
.githooks/pre-commit, which runsadf evidenceonly; - a generated hook in a repo with a
verify:adfscript, which may run unscopeddoctor --ciand block.
Parity forces identical text. A neutral phrasing would be accurate everywhere: "the generated hook's doctor gate does not block on it".
- this repo's own
-
Missing test: the index-read fallback (
resolveBloatScopecatch →null) has none. I verified it manually with a corruptGIT_INDEX_FILE, but a mutation that returns empty sets there would silently un-gate everything. A one-line integration test withGIT_INDEX_FILEpointed at garbage would pin it. -
CP-4 lifts the bloat gate for every staged vendor file in the commit, not only the file whose plan touches the dirty module. The tidy behaviour is pre-existing, but the gate exemption now follows it. Worth a sentence in the cli-reference.
-
The bloat check still reads the working tree, not the staged blob. With
CHARTER_SKIP_TIDY=1, a staged clean file that has an unstaged bloat-only hunk blocks. This fails closed and is the same as main, so it is fine. Just note that the "staged" scoping is by name, not by content. -
CHANGELOG "the hook makes one fewer CLI call": the removed loop ran one
adf tidy --dry-runper skipped file, and only when the gate failed. "No longer re-runs tidy per skipped file when the gate fails" is more precise.
Scripts and transcripts are in the reviewer's scratch dir rv360/ (e2e.sh, misc.sh, up.sh, out/).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#360 made the pre-commit doctor gate commit-scoped only for repos without a verify:adf script, but charter setup and bootstrap write one, so in most repos the hook still ran an unscoped doctor and blocked commits on bloat in files the commit left out; it also hard-coded pnpm. The gate now always runs the scoped doctor first, then verify:adf with the repo's own package manager. The doctor inside verify:adf reports vendor bloat as already checked only when it finds a one-time token file the hook wrote for that run, so setting the variable in CI or by hand has no effect; every other check still blocks. If the token can't be written the hook says so and stays strict. Doctor flags #360-era hooks in verify:adf repos for reinstall. Yarn Plug'n'Play remains unsupported (#366). Refs #357 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes #357
Summary
After #356, the pre-commit hook tidies only staged vendor files. Its evidence gate,
charter doctor --adf-only --ci, still scanned the whole working tree, so in the default generated hook (noverify:adfscript) a commit could be refused over bloat it did not contain. This PR applies options B and C from the #356 review.adf vendor bloatcheck counts only vendor files staged in this commit.Out-of-scope bloat is still reported, as a new INFO row,
adf vendor bloat (not blocking). The row names each file, the reason it does not block (not staged/auto-tidy skipped for it in this commit), and a per-fileadf tidy --sourcecommand.Mechanism and why
The mechanism is two additive doctor flags, set by the generated gate:
--staged: doctor reads the index itself (git diff --cached --name-only --diff-filter=d), so "staged" comes from git after tidy has run, not from a list the shell built. A file is matched under any spelling of its physical file, so a stagedagents.mdcovers theAGENTS.mdrow on NTFS. If the index cannot be read, doctor falls back to enforcing every vendor file.--tidy-skipped "<files>": carries theTIDY_SKIPPEDlist that the tidy block already builds. It may be empty. Without--stagedit is a usage error (exit 2), so it can never quietly downgrade a CI run.Why flags rather than an env var:
Only the bloat check is scoped.
adf agent config, vendor-mode drift, malformedVENDORS, required wiring, modules and LOC budgets all still block as before. JSON gets one additive check name, and with--stagedthe WARN row's advice is per-file (adf tidy --dry-run --source <f>) instead of the whole-treeadf tidy --dry-run(review item 4).Hook text
npxcall per skipped file. The reproduce line now includes the scope.charter doctor --cistill fails on it. They also give the ways to tidy it.sh -n,dash -nandbash --posix -npass on both hooks. The gate has no unquoted loops left.# Charter pre-commit evidence gate: blocks on vendor bloat only in staged files. Doctor'spre-commit hookINFO row now also fires for feat(hook): pre-commit tidies only staged vendor files (#318 4/6) #356-era hooks (with both hook: the compiled-file skip in both pre-commit hooks is dead code — tidy is invoked without --source #320 markers but not this one), with its own message. Pre-hook: the compiled-file skip in both pre-commit hooks is dead code — tidy is invoked without --source #320 hooks keep the existing message.E2E: main (581465c) vs PR, generated hook via
hook install --pre-commit+ npx shimEach scenario ran in a fresh
mktemp -drepo, with both builds. The last column compares plaincharter doctorwith each of"",--ciand--adf-only --ci, in both text and json, on the pre-commit state. The main build and the PR build gave byte-identical output in all 102 comparisons.core.autocrlf=true, CRLFgit rmrecorded CLAUDE.mdgit mvCLAUDE.md → AGENTS.md.ai/gitignored## Environment)VENDORSentry + CP-3 skipCHARTER_SKIP_TIDY=1Still blocks
CHARTER_SKIP_TIDY=1is set. These are not deliberate skips.adf evidenceandverify:adf.charter doctor --ci, which still enforces all bloat.Tests
precommit-hook.test.ts: the "option A" group is replaced by full-hook tests for B, B2, N, C, D, D3, X1, tidy-fails,CHARTER_SKIP_TIDYand malformedVENDORS. Each also checks that plain doctor still fails on the same state.doctor-staged-bloat.test.ts:hook.test.ts: the feat(hook): pre-commit tidies only staged vendor files (#318 4/6) #356-era hook gets the INFO row, reinstalling clears it, and the gate text is pinned. The whole-block parity test is unchanged and passes.--tidy-skippedfails 4;--stagedfails 5.verify:adfpass.Upgrade note
Existing installs keep the old gate until
charter hook install --pre-commitis re-run (no--forceneeded for a Charter-managed hook).charter doctorflags a #356-era hook with an INFOpre-commit hookrow. This repo's own.githooks/pre-commitrunsadf evidence, not doctor, so its behaviour is unchanged; only the shared tidy block's comments and messages changed, to stay in parity.Implemented via constellation-loop.
🤖 Generated with Claude Code