Skip to content

fix(hook): doctor gate only blocks on bloat in files the commit includes - #360

Merged
stackbilt-admin merged 3 commits into
mainfrom
auto/bugfix/issue-357
Oct 16, 2026
Merged

stackbilt-admin merged 3 commits into
mainfrom
auto/bugfix/issue-357

Conversation

@stackbilt-admin

Copy link
Copy Markdown
Member

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 (no verify:adf script) a commit could be refused over bloat it did not contain. This PR applies options B and C from the #356 review.

  • B: in the hook's gate, the adf vendor bloat check counts only vendor files staged in this commit.
  • C: bloat in a staged file that the tidy block deliberately skipped (CP-3 partially staged, CP-4 planned-module WIP) does not block either.

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-file adf tidy --source command.

Mechanism and why

The mechanism is two additive doctor flags, set by the generated gate:

npx charter doctor --adf-only --ci --staged --tidy-skipped "$TIDY_SKIPPED" --format text
  • --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 staged agents.md covers the AGENTS.md row on NTFS. If the index cannot be read, doctor falls back to enforcing every vendor file.
  • --tidy-skipped "<files>": carries the TIDY_SKIPPED list that the tidy block already builds. It may be empty. Without --staged it is a usage error (exit 2), so it can never quietly downgrade a CI run.

Why flags rather than an env var:

  • A flag cannot leak in from a user's or CI's environment, so doctor outside the hook is guaranteed unchanged.
  • The scope shows in the hook text and in the reproduce line the gate prints when it fails.
  • Older CLIs ignore unknown doctor args. A new hook on an old CLI therefore behaves exactly like the feat(hook): pre-commit tidies only staged vendor files (#318 4/6) #356 hook, failing safe.

Only the bloat check is scoped. adf agent config, vendor-mode drift, malformed VENDORS, required wiring, modules and LOC budgets all still block as before. JSON gets one additive check name, and with --staged the WARN row's advice is per-file (adf tidy --dry-run --source <f>) instead of the whole-tree adf tidy --dry-run (review item 4).

Hook text

E2E: main (581465c) vs PR, generated hook via hook install --pre-commit + npx shim

Each scenario ran in a fresh mktemp -d repo, with both builds. The last column compares plain charter doctor with each of "", --ci and --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.

Scenario main PR plain doctor identical
S0 staged bloated CLAUDE.md pass pass yes
S5 planned module fully staged pass pass yes
UN unborn repo, first commit pass pass yes
CR core.autocrlf=true, CRLF pass pass yes
N no vendor staged, GEMINI.md bloat in the working tree block pass (INFO) yes
S3 git rm recorded CLAUDE.md block block (vendor mode) yes
S4 git mv CLAUDE.md → AGENTS.md block block (vendor mode) yes
B clean staged CLAUDE.md, unstaged GEMINI.md bloat block pass (INFO) yes
B2 as B, CLAUDE.md bloated block pass (CLAUDE.md tidied) yes
C partially staged CLAUDE.md (CP-3) block pass (skip explained) yes
D planned module unstaged WIP (CP-4) block pass yes
D3 planned module untracked (CP-4) block pass yes
S1 .ai/ gitignored pass pass yes
S6 ignored module created by tidy pass pass yes
X1 staged bloat tidy cannot move (keyword density in ## Environment) block block yes
X2 malformed VENDORS entry + CP-3 skip block block (vendor mode) yes
X3 staged bloat with CHARTER_SKIP_TIDY=1 block block yes

Still blocks

  • A staged vendor file that is still bloated after tidy ran (X1).
  • A staged vendor file left untidied because tidy failed (H6) or CHARTER_SKIP_TIDY=1 is set. These are not deliberate skips.
  • Every non-bloat doctor WARN, plus adf evidence and verify:adf.
  • CI's plain 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_TIDY and malformed VENDORS. Each also checks that plain doctor still fails on the same state.
  • New doctor-staged-bloat.test.ts:
    • unscoped output is pinned exactly;
    • usage errors;
    • nothing staged, staged, skipped, and staged-deletion cases;
    • non-bloat WARNs still fail;
    • the text row.
  • 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.
  • Mutation checks:
    • an unscoped gate fails 9 tests;
    • dropping --tidy-skipped fails 4;
    • a doctor that never blocks under --staged fails 5.
  • Full suite: 1414 passed, plus the 3 known Windows-only failures (bootstrap bootstrap: phases 6/7 collect warnings without printing them; absoluteAiDirArg misses Windows paths on POSIX #307 fixture, context-refresh "captures git signals", serve-context "returns existing snapshot"). Typecheck and verify:adf pass.

Upgrade note

Existing installs keep the old gate until charter hook install --pre-commit is re-run (no --force needed for a Charter-managed hook). charter doctor flags a #356-era hook with an INFO pre-commit hook row. This repo's own .githooks/pre-commit runs adf 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

…des (closes #357)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@stackbilt-admin stackbilt-admin left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Outside-hook invariance.

    • All 6 doctor forms are byte-identical to main in all 28 repos.
    • --tidy-skipped without --staged exits 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')). doctorCommand has a single caller (index.ts), and no env var or config reaches it. I also tried CHARTER_STAGED=1 and a bogus GIT_INDEX_FILE without the flag: both stay unscoped.
    • One difference from main is intended: a repo with a #356-era hook now shows the new INFO row.
  2. 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-authored adf agent config file (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_SKIPPED empty the same way) and CHARTER_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 $NAMES into TIDY_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 the setup --ci github workflow) 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 --ci in CI still catches it"). The hook is not a security boundary, since --no-verify exists, 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.
  3. --staged index 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=d drops staged deletions (R1; there is a test). A rename (S4) reports the new name.
    • Both spellings: vendorFileGroups() maps a staged or skipped agents.md onto the AGENTS.md row 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-skipped parsing: the value is one quoted argv element built only from the fixed VENDOR_FILES list. A leading space (" CLAUDE.md") is handled. A value starting with -- is rejected (exit 2), so --staged or --format cannot 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.
  4. 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 (via npx -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.

  5. Upgrade marker.

    • A main-generated (#356-era) hook gets the new INFO row, under .git/hooks and under core.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 --force clears the row.
    • doctor --adf-only --ci exits 0 in every case.
  6. Hook text.

    • The generated hook passes sh -n, dash -n and bash --posix -n. The repo hook passes sh -n.
    • set -eu safety: TIDY_SKIPPED is initialised before the guard, and ${TIDY_SKIPPED# } is safe.
    • No new npx calls: 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).
  7. Scenarios: see the table above.

  8. 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.ts stagedPass, ~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 --staged gate:

    • this repo's own .githooks/pre-commit, which runs adf evidence only;
    • a generated hook in a repo with a verify:adf script, which may run unscoped doctor --ci and block.

    Parity forces identical text. A neutral phrasing would be accurate everywhere: "the generated hook's doctor gate does not block on it".

  • Missing test: the index-read fallback (resolveBloatScope catch → null) has none. I verified it manually with a corrupt GIT_INDEX_FILE, but a mutation that returns empty sets there would silently un-gate everything. A one-line integration test with GIT_INDEX_FILE pointed 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-run per 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/).

kovermier and others added 2 commits October 16, 2026 18:33
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@stackbilt-admin
stackbilt-admin merged commit b63f26b into main Oct 16, 2026
5 checks passed
@stackbilt-admin
stackbilt-admin deleted the auto/bugfix/issue-357 branch October 16, 2026 23:38
stackbilt-admin added a commit that referenced this pull request Oct 17, 2026
#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

hook: doctor gate blocks commits on bloat in files that aren't part of the commit

2 participants