Skip to content

feat(hook): pre-commit tidies only staged vendor files (#318 4/6) - #356

Merged
stackbilt-admin merged 2 commits into
mainfrom
auto/feature/issue-320-hook-scope
Oct 16, 2026
Merged

stackbilt-admin merged 2 commits into
mainfrom
auto/feature/issue-320-hook-scope

Conversation

@stackbilt-admin

Copy link
Copy Markdown
Member

Closes #320
Refs #318

Summary

PR 4 of 6 in the #318 sequence, option 2 of #320. The pre-commit hook now tidies only the staged vendor files and re-stages only the files tidy wrote. The git add .ai/*.adf sweep is gone.

  • adf tidy
    • --source is repeatable. Values are processed in argument order, and two names for one file are de-duplicated by fileIdentity (a case-insensitive alias or a hard link).
    • A --source with no value is a usage error (exit 2). It used to fall back to tidying the whole tree silently.
    • New --print-paths prints only typed vendor <path> / module <path> lines on stdout. Warnings go to stderr. Combining it with --format json exits 2.
    • JSON gains the additive paths field.
  • Hook (hook.ts generator and .githooks/pre-commit)
    • Both copies use the same HOOK_TIDY_BLOCK_TEMPLATE. {CLI} is npx charter in the generator and node packages/cli/dist/bin.js in the repo copy.
    • Both carry the new PRE_COMMIT_SCOPE_MARKER.
    • The flow is one dry run and one real run, with --source per staged vendor file. Only paths reported by both runs are staged.
  • doctor: a new INFO pre-commit hook row appears only for an old Charter-managed hook, meaning one with PRE_COMMIT_HOOK_MARKER and without the scope marker. It honours core.hooksPath.
  • adf.ts: the tidy help line is edited in place (0 net lines, 1027/1041). adf-migrate.ts and index.ts are untouched.

Before → after (newly installed hook)

Case main @ 01d2834 this PR
Staged pointer with bloat whole-tree tidy; git add of staged vendor files and all of .ai/*.adf tidy runs on that file only; only the files tidy wrote are staged
Unrelated unstaged .ai/*.adf WIP swept into the commit (reproduced in E2E row U) never staged
Unstaged vendor file not in this commit rewritten by tidy in the working tree untouched
Staged vendor file that also has unstaged hunks tidied, and the whole file staged, so the unstaged hunks are committed skipped, with a message
Planned module has unstaged, deleted or untracked WIP tidied and swept no tidy for this commit, with a message
Only compiled files staged no CLI call no CLI call (unchanged)
Staged lowercase agents.md not tidied (#302 gap 1) tidied
Restore-only rewrite (bloat already in .ai/) not handled (totalExtracted = 0) vendor file rewritten and staged (CP-5)
tidy fails silent one line; the commit proceeds
Old installed hook — unchanged until reinstalled; doctor shows INFO

Operator sign-offs applied

  • CP-1: only paths tidy wrote are re-staged. There is no git add .ai/*.adf.
  • CP-2: lowercase agents.md is in the hook's vendor list.
  • CP-3 / CP-4: a partially staged vendor file is skipped, and planned-module WIP skips tidy for the commit. Both are non-blocking and print a message.
  • CP-5: the trigger is ^(vendor|module) , so a restore-only rewrite of an already-staged vendor file is staged.
  • CP-6: doctor reports INFO, not WARN, for an outdated Charter-managed pre-commit hook.
  • CP-7: the totalExtracted parity pin is replaced by pins on the scope marker, --print-paths, and the absence of git add .ai/*.

Deviations from the addendum's hook text (each needed to keep a stated invariant)

  1. CP-5 trigger. The addendum gates on grep '^module '. This PR gates on ^(vendor|module) so that restore-only rewrites are staged.
  2. WIP guard covers a tracked planned module deleted in the working tree. The addendum's [ -e "$p" ] && … misses this case, and tidy would then recreate the file and stage it, violating INV-P4-1. The guard is now ! git diff --quiet -- "$p" || { [ -e "$p" ] && ! tracked; }. The guard is tested.
  3. grep -xF instead of grep -qx for the staged-name match. .cursorrules would otherwise match as a regex.
  4. Pipeline greps use >/dev/null instead of -q. With -q, grep exits early, which can SIGPIPE the writer under the repo hook's set -o pipefail and turn a match into a miss.

Upgrade path

charter hook install --pre-commit already overwrites a Charter-managed hook without --force. Re-running it is the upgrade, and install semantics are unchanged. The CHANGELOG tells users to do this.

Verified end to end:

  1. Install main's generated hook. Doctor shows [info] pre-commit hook.
  2. Commit with that hook. It swept .ai/state.adf WIP into the commit.
  3. Run hook install --pre-commit with no --force. The hook is overwritten.
  4. Doctor shows no row, and the hook carries the scope marker.

Note: the evidence gate after the block is unchanged

In the generated hook (repos without a verify:adf script), the existing gate charter doctor --adf-only --ci fails on vendor bloat left in the working tree. So when tidy is skipped (CP-3, CP-4 or CHARTER_SKIP_TIDY), or when an unstaged vendor file is bloated, the commit is still blocked, by that pre-existing gate rather than by tidy. The hook's messages say to stage or stash and commit again. A repo with verify:adf, including this one, proceeds. This is pinned in a test ("H6 boundary") and listed in the CHANGELOG. Changing the gate is out of scope here.

Invariants and where they are tested

  • INV-P4-1 (never stages unrelated or pre-dirty paths): precommit-hook.test.ts
    • "unrelated .ai/ work in progress stays out of the commit"
    • "skips a staged vendor file that also has unstaged changes"
    • the CP-4 cases (edited, deleted and untracked planned module)
  • INV-P4-2 (never tidies an unstaged vendor file): precommit-hook.test.ts "never tidies a vendor file that is not staged", and adf-tidy-sources.test.ts "leaves an unnamed vendor file alone".
  • INV-P4-3 (no CLI call for compiled-only or no-vendor commits): precommit-hook.test.ts, the two INV-P4-3 cases. A logging shim records every CLI call.
  • INV-P4-4 (whole-block parity): hook.test.ts "the generated and repo blocks are the template, differing only in {CLI}".
  • INV-P4-5 (single or no --source is byte-identical to main): adf-tidy-sources.test.ts. It compares text, JSON and the resulting files against output captured from main @ 01d2834 (fixtures/tidy-single-source.main-01d2834.json).
  • INV-P4-6 (JSON is additive only): the same golden tests, with only paths removed, plus the --print-paths / paths equality test.
  • INV-P4-7 (POSIX sh): hook.test.ts runs sh -n, dash -n and bash --posix -n on both hooks, skipping any shell that is not installed, and has a syntax denylist. The tidy block was also executed under real dash.
  • INV-P4-8 (INFO only, no WARN): hook.test.ts "doctor: pre-commit hook row". It covers INFO for an old hook, no row for current, foreign or missing hooks, core.hooksPath, and that --ci is unaffected.
  • INV-P4-9 (adf.ts net 0; adf-migrate and index untouched): the diff, and adf evidence 1027/1041.

Windows harness: precommit-hook.test.ts now installs the generated hook with node "<forward-slash path>", behind a logging shim, under a repo path containing spaces. The 2 previously failing tests ("auto-tidies bloated CLAUDE.md", "re-stages modified .adf modules") now pass, and they assert that the real tidy calls ran.

NOT in PR4

  • Operating on staged blobs: tidy still works on the working tree.
  • Blocking commits on bloat: the tidy block stays non-blocking.
  • Registering modules that tidy creates.
  • The agents.md rename and record-key move (PR6).
  • Retained-section compile (PR5).
  • Changes to hook install overwrite semantics.
  • A global --format paths.

Implemented via constellation-loop against a locked blueprint.

🤖 Generated with Claude Code

…what it wrote (closes #320)

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.

CHANGES_REQUESTED

Adversarial review of #356 (PR 4/6 of #318, closes #320) at 33c0d1d. I built it on Windows and compared it with a build of main @ 01d2834. 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. The tidy core is solid. The golden fixture matches a real main build byte for byte, --print-paths stdout is clean, the upgrade path works and the mutation I tried was caught. Three problems in the hook's failure handling and messages need fixing before merge.

Required changes

  1. git add under set -e can abort the hook, so the tidy block can fail a commit. This is at packages/cli/src/commands/hook.ts:178 and .githooks/pre-commit:49.

    • Reproduced (S6): .ai/backend.adf is gitignored and absent, and a bloated CLAUDE.md is staged. Tidy creates the module, then git add -- .ai/backend.adf exits 1 ("paths are ignored"). set -eu kills the hook with git's raw advice text and no [pre-commit] line.
    • State left behind: the working-tree CLAUDE.md has already been rewritten and re-staged as the pointer, and the module is unstaged. Main passes this case, because its git add .ai/*.adf 2>/dev/null || true hides the failure.
    • Contradiction: the CHANGELOG says "The tidy block never fails a commit". The generated hook still runs set -eu, and the repo hook runs set -euo pipefail.
    • Fix: git add -- "$f" || echo "[pre-commit] could not stage $f; ...". Add an integration test with an ignored module path.
  2. Ignored module paths are reported as "unstaged work", which newly blocks a local-only .ai/ setup. This is at hook.ts:166 and .githooks/pre-commit:37.

    • Reproduced (S1): .ai/ is in .gitignore, which is a valid local-only Charter setup. A bloated CLAUDE.md is staged.
    • What happens: [ -e "$p" ] && ! git ls-files --error-unmatch treats the ignored module as untracked WIP. The hook prints "tidy would write into files with unstaged work: .ai/backend.adf", tidy is skipped, and doctor's bloat check then fails the commit.
    • No way out: the user cannot "stage or stash" an ignored file. Main passes this case.
    • Fix: exclude ignored paths from the WIP guard (git check-ignore -q -- "$p"). Do not try to stage them either, and print one line saying the module is ignored and was not staged.
  3. Divergence between the dry run and the real run is silent, and the success line is then wrong. This is at hook.ts:176-181 and .githooks/pre-commit:47-52.

    • Reproduced (DV): I used a shim whose real run also writes and reports module .ai/extra.adf. The hook correctly does not stage it. It still prints "Vendor files tidied; re-staged only the files tidy wrote." and the commit succeeds with ?? .ai/extra.adf left behind and no mention of it.
    • Second gap: a path that appears only in the real run never went through the WIP guard. Tidy may therefore have written into a file that has unstaged work.
    • Why it matters: CP-1 makes staging the intersection, which is correct. If the vendor pointer is staged but a module it now relies on is not, the commit drops content.
    • Fix: collect the paths in WROTE that are not in PLAN and print, for example, [pre-commit] tidy also wrote (left unstaged, review before committing): <paths>. Reword the success line to match, and add a shim-based test.
  4. Messages and tests around CP-3/CP-4 must match what the default generated hook actually does. See the item-4 analysis below; the operator still has to pick a direction.

    • Hook messages: at hook.ts:152 and hook.ts:171, the skip messages read as harmless ("not auto-tidying it"). In the default hook (no verify:adf script) the commit is then refused by the evidence gate (scenarios C, D, D3).
    • Minimum fix: whichever option is chosen, the skip message must say the gate will block the commit while the file holds bloat, and give the ways out: stage or stash and retry, run the adf tidy --source command and git add the result, or git commit --no-verify.
    • README: packages/cli/README.md:302 says the hook "skips the whole commit". It means it skips tidy for that commit.
    • Tests: the CP-4 tests at precommit-hook.test.ts:292 and :315 assert "the commit still succeeds" using the tidyOnly harness, which removes the gate. Either rename them ("the tidy block does not fail the commit") or add full-hook counterparts to the existing H6-boundary test that pin the real exit 1 for D and D3.

Item 4 — the evidence gate after a scoped tidy

When there is no verify:adf script, the generated hook runs charter doctor --adf-only --ci after tidy. Doctor's adf vendor bloat check scans the working tree: more than 80 lines, H2 overlap with ADF section keys, or trigger-keyword density. Main got past this by tidying every vendor file in the working tree, which is the bug #320 fixes. PR4 tidies only staged files, so the gate now sees bloat that main used to clear.

Results, PR build vs main build, generated hook:

Scenario main PR4 Message the user gets
S0 staged CLAUDE.md bloat pass pass —
S5 planned module fully staged pass pass (not wrongly skipped) —
UN unborn repo, first commit pass pass —
CR core.autocrlf=true, CRLF file pass pass —
N no vendor file staged, GEMINI.md WT bloat block block unchanged
S3/S4 git rm or git mv CLAUDE.md block block doctor vendor mode (PR3), unchanged
B staged clean CLAUDE.md edit, unstaged GEMINI.md bloat pass (silently rewrote GEMINI.md) block doctor names GEMINI.md, then "Run: charter adf tidy --dry-run"
B2 as B, with staged CLAUDE.md bloat pass block same; tidied CLAUDE.md and .ai/backend.adf are left staged
C partially staged CLAUDE.md (CP-3) pass (committed the unstaged hunk) block "not auto-tidying it… or run: charter adf tidy --source CLAUDE.md", then FAILED
D planned module has unstaged WIP (CP-4) pass (swept the WIP) block "Not auto-tidying…: .ai/backend.adf", then FAILED
D3 planned module untracked (CP-4) pass (swept it) block same
S1 .ai/ gitignored pass block wrong: "unstaged work" (required change 2)
S6 ignored module created by tidy pass block raw git error (required change 1)

Of 13 scenarios, PR4 newly blocks 7 that main passed. S1 and S6 are bugs, covered by required changes 1–2. The other 5 (B, B2, C, D, D3) are blocks that main avoided only by doing what #320 forbids. Repos that do have a verify:adf script, and this repo's own hook (adf evidence only), are unaffected (the impl's D2 run passes).

In every case the messages are accurate but don't help much. The user sees a benign-sounding skip line, then a 15-line doctor dump that ends in "ADF verification FAILED". Nothing says that the skip caused the failure, or that git commit --no-verify is the escape hatch. For B, doctor's own advice, a whole-tree charter adf tidy --dry-run, would also rewrite files the user did not stage.

So CP-3/CP-4 "skip, non-blocking" holds for the tidy block only. In practice the default hook blocks those commits. The CHANGELOG and cli-reference do say so ("still fails on vendor bloat left in the working tree"), so the documentation is accurate. But the operator signed off on "non-blocking", so this needs an explicit decision.

Options, cheapest first:

  • A. Accept the block and fix the messages (required change 4 minimum). The hook stays as it is. The skip lines say the gate will block and give the three ways out. Cost: a few echo lines. CP-3/CP-4 are re-signed as "skip, and blocked by the gate, with an explanation".
  • B. Limit doctor's bloat scan in hook context to the staged vendor files. For example, pass --vendor-files "$NAMES" or an env var such as CHARTER_HOOK_STAGED. This fixes B and B2, and also N, which improves on main. It does not fix C, D or D3, because those staged files are bloated themselves. Cost: a new doctor flag, plus a test that hook and doctor agree on the list.
  • C. Report adf vendor bloat as a warning, not a failure, in hook context for the files the tidy block skipped, with every other doctor check still blocking. This makes CP-3/CP-4 actually non-blocking, as signed off. Cost: bloat can reach a commit in C, D and D3 (main already committed it in C, along with the WIP), and doctor --ci in CI still catches it. Needs a hook-to-doctor channel, like B.
  • D. Make doctor read the staged blob for staged vendor files and ignore files that are only in the working tree. These are exact "what is being committed" semantics and give the best results, but it is the most work and touches the index reading that PR4 explicitly left out.

Recommendation: A in this PR, and B + C as one follow-up issue (they share the hook-to-doctor channel). D only if the index reading needs to happen anyway.

Verified OK

  • Shell quoting (both copies): vendor names come from a fixed list, and every git pathspec is -- "$x". grep -xF against --name-only output means a quoted name such as "a\nb" cannot match. --diff-filter=d excludes index deletions, so git rm --cached CLAUDE.md is not resurrected; main did resurrect it (S2). Using >/dev/null instead of -q on piped greps is correct under pipefail. Commands that can fail all sit inside if, || or $(…) ||, apart from the git add in required change 1. A repo path containing a space works (impl H and test). A file named release notes.md alongside a vendor file works.
  • CRLF: --print-paths emits \n only on Windows (checked with od -c), so $(…) leaves no \r.
  • No new npx calls. Two calls were replaced with two, same as main.
  • INV-P4-1:
    • A planned module that is fully staged is tidied and committed, not wrongly skipped (S5).
    • A tracked planned module deleted only in the working tree, or an untracked one, is guarded (D3 and the tests).
    • git rm CLAUDE.md is not resurrected (S3).
    • A rename onto a vendor name is tidied under its new name (S4).
  • Item 5:
    • Golden fixture: I wrote my own runner and checked the fixture against a real main build: all 7 cases match on exit code, stdout, stderr and files. The PR build also matches once paths is removed.
    • Exit 2 cases: a trailing --source exits 2, --source --print-paths exits 2, and --print-paths --format json exits 2. In each case stdout is empty and stderr gets one line.
    • De-dup: --source CLAUDE.md --source CLAUDE.md --source ./CLAUDE.md yields one row. AGENTS.md and agents.md on NTFS collapse into one row.
    • Warnings and exit codes: warnings go to stderr. --ci with bloat still exits 1.
  • Item 6:
    • A main-generated hook under core.hooksPath gives one INFO row, and doctor --ci --adf-only still exits 0.
    • Reinstalling without --force replaces it and the row disappears. A foreign or missing hook gives no row.
    • The whole-block parity test is strong: the generated block, the repo block and the template are compared exactly.
    • Mutation: removing the untracked branch of the WIP guard from the template makes the "CP-4: an untracked file at a planned module path" test fail. The mutation was reverted afterwards.
  • Item 7: the precommit, hook and tidy-sources suites pass locally (75/75). On main, the two precommit tests fail on Windows (2 of 5), and both now exercise real tidy (they assert the CLI calls and the committed pointer).
  • Item 8: in a fresh clone of the PR branch, built under the scratch dir with core.hooksPath=.githooks, a staged bloated CLAUDE.md commits as the pointer plus .ai/backend.adf. Unrelated M .ai/core.adf WIP stays unstaged, and the untracked .ai/context.snapshot.json and .ai/context.adf are never staged.

Non-blocking notes

  • The unquoted for p in $(…) and for line in $(…) loops are subject to globbing and word splitting. Tidy cannot currently produce module names with spaces or glob characters (my spaced-module attempt routed to backend.adf), but set -f around the loops would harden them cheaply.
  • Version skew fails safe but quietly: with a new hook and an older local CLI, --print-paths is ignored, so PLAN contains no typed lines and the hook prints "Vendor config files are clean." Worth noting in the CHANGELOG reinstall paragraph.
  • --source=CLAUDE.md (equals form) is silently ignored and a whole-tree tidy runs. This is not new (main's getFlag behaves the same), but getFlagValues could reject it with exit 2 now.
  • .github/copilot-instructions.md, the usual Copilot location, is not in VENDOR_FILES. Not new.
  • adf.ts:956: the tidy help line is now indented with 4 spaces instead of 2.
  • Tidy still creates modules that are not registered, such as .ai/backend.adf in this repo's own clone test. That gap is known and out of scope.
  • npx charter with a non-TTY stdin auto-installs whatever package is published under charter when @stackbilt/cli is not local. This is not new to PR4, but it deserves an issue.

Scripts and transcripts are in the reviewer's scratch dir rv356b/ (lib.sh, s1.sh–s4.sh, cli.sh, up.sh, golden.cjs, clone.sh).

…ks (refs #320)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@stackbilt-admin
stackbilt-admin merged commit 67f3c28 into main Oct 16, 2026
5 checks passed
@stackbilt-admin
stackbilt-admin deleted the auto/feature/issue-320-hook-scope branch October 16, 2026 19:26
stackbilt-admin added a commit that referenced this pull request Oct 16, 2026
…des (#360)

Since #356 the pre-commit hook tidies only staged vendor files, but its doctor gate still scanned the whole working tree for bloat, so a commit could be blocked by an unrelated unstaged file, or by a file the hook had deliberately skipped (partially staged, or a planned module with work in progress); the old hook had only avoided that by committing changes nobody staged. The generated gate now runs doctor with --staged and --tidy-skipped: only bloat in files the commit includes, and that tidy did not skip, blocks; the rest is reported as INFO. Every other doctor check still blocks, plain doctor and CI are byte-identical to before, and an unreadable index falls back to checking everything. #356-era hooks get a doctor INFO row asking for `charter hook install --pre-commit`.

Closes #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: the compiled-file skip in both pre-commit hooks is dead code — tidy is invoked without --source

2 participants