Skip to content

fix(gate): the file-size ratchet judges the change, not the tree (#2004) - #2267

Merged
macanderson merged 4 commits into
mainfrom
fix/2004-change-relative-ratchet
Aug 8, 2026
Merged

fix(gate): the file-size ratchet judges the change, not the tree (#2004)#2267
macanderson merged 4 commits into
mainfrom
fix/2004-change-relative-ratchet

Conversation

@macanderson

@macanderson macanderson commented Aug 8, 2026

Copy link
Copy Markdown
Owner

The bug

scripts/file-size-baseline.txt is one shared cell that every growing PR must write, and three times running (#1761, #1782, #2003) two PRs that each wrote it correctly composed into a red main. Each regenerates the whole baseline against a snapshot of main that does not yet carry the other's growth, so each records a stale ceiling for a file it never touched. The merge is textually clean — the two sides edit different lines — and the result is a ceiling one line below an actual. main then stays red and every subsequent PR inherits a failure it did not cause; #1992 sat blocked on exactly this with a tree byte-identical to main.

The detail that names the fix: in that composition the file never grew. Its ceiling moved down underneath it. A guard with one tree to look at cannot tell those two apart, because "is this tree consistent with this baseline snapshot?" is a whole-tree question and the thing worth preventing is a per-change one.

The fix

The ratchet now judges the change:

fail only when  current > max(ceiling, size at base)

Both halves of the max earn their place, and that is the whole design:

  • ceiling alone is the old check, and it fails the innocent PR above.
  • size at base alone would pass a file that is already over its ceiling and that this change grows further — turning inherited drift into a standing licence to bloat. That is strictly worse than the red main being fixed here.

Taking the larger fails a change that genuinely grows a god file past what it inherited, and stays silent when the violation arrived from somewhere else. A ceiling raised deliberately via --update passes exactly as before, since the regenerated ceiling equals the current size — that path is untouched.

Where the base comes from is the same pair scripts/check-deleted-tests.sh already uses, for the same reason: on a pull_request the checkout is refs/pull/N/merge, so HEAD^1 is the base branch tip and the question becomes "does this merge grow a god file?" — the question a required check should answer. ci.yml already sets the fetch-depth: 2 that needs, for that guard. Locally it is the merge base with origin/main; on a linear push it is HEAD^1.

When no base resolves — shallow clone, root commit, no origin — the guard falls back to the old whole-tree check. That direction is deliberate and is pinned by a test: an unresolvable base must make the ratchet stricter, never weaker.

Drift is still reported, on stderr and in the summary line, so the baseline debt stays visible rather than merely tolerated. It just no longer fails the next PR to walk past it.

Witness tests

scripts/test-file-size.sh gains six hermetic cases that rebuild the skew from two commits in a throwaway repo — no network, no reliance on this repository's real history. Verified the artisanal way, by running the new suite against origin/main's guard:

$ git show origin/main:scripts/check-file-size.sh > scripts/check-file-size.sh
$ ./scripts/test-file-size.sh
FAIL B1 a ceiling lowered under an untouched file is not this change's failure
FAIL B2 inherited drift does not fail a change that touched something else
ok   B3 a change that genuinely grows a god file still fails
ok   B4 growth on top of inherited drift still fails
ok   B5 an unresolvable base falls back to the strict whole-tree check
FAIL B6 a refs/pull/N/merge checkout finds its base branch tip unaided
passed 10, failed 3

B1, B2, B6 are the witnesses — they fail on the old code and pass on the new. B3, B4, B5 pass on both, which is the point of them: they constrain the fix rather than being satisfied by it. B4 is the one I would ask a reviewer to look at hardest — it is the case a naive "already over at the base, so ignore it" rule would wave through, and it is why the rule takes the max rather than either term alone. B6 exists because B1–B4 all pass the base in by hand, which left the rung production actually depends on as the one rung nothing exercised.

With the fix in place: 13 passed, 0 failed (the 7 pre-existing language-coverage cases are untouched and still green).

Verification

./scripts/test-file-size.sh          13 passed, 0 failed
make guards-fast                     exit 0
shellcheck scripts/check-file-size.sh scripts/test-file-size.sh    clean
./scripts/check-file-size.sh         OK — 1187 files … (none grew by this change)
./scripts/check-god-files.sh         OK — 22 god files, named identically in AGENTS.md and every crate README

make gate's Rust tiers are unaffected — this change is shell and markdown only.

Definition of done, from the issue

  • Two PRs growing different god files, each regenerating against a main lacking the other's growth, compose green — B1/B2, and B6 in the real merge shape.
  • A PR that genuinely grows a god file past its ceiling still fails, message unchanged — B3, asserting the exact original string.
  • A hermetic case covering the skew, built like the issue's repro.
  • AGENTS.md § "God files" updated to state what the ratchet now judges.
  • check-god-files.sh still green; guards green.

Scope I deliberately did not take

The same shared-cell shape could in principle red an innocent PR through the OBSOLETE and STALE branches (a sibling retires a baseline entry you did not). That has never been observed, and widening the change-relative rule to those paths without a witness would be speculative — the three recorded occurrences are all the GREW branch. Noted here rather than silently left.

Also worth stating plainly, since two issues were recently written on the opposite premise: splitting a god file buys structure, not slack. --update retightens every ceiling to its file's current size, so a freshly split file sits at zero headroom again — see my comment on this issue. This PR is what removes the tax; the split does not.

Closes #2004


Follow-up found in this PR's own CI log

The first green run exposed something no red check would have: .github/workflows/file-size.yml — the cheap, requirable status context for this very guard — checked out at the default depth of 1, under a comment stating full history was unnecessary because the guard "reads the working tree and the committed baseline, never a diff against the base."

That was true until this PR and is now false. At depth 1 the merge commit's parents are not in the clone, so the base cannot resolve and the guard falls back to the strict whole-tree check. The job stays green and reports the ratchet exactly as before — so the fix would have been silently absent from the one job whose purpose is to report this guard cheaply enough to be required. ci.yml's guards job was already at fetch-depth: 2 and unaffected; this workflow is now too, the same trade it already makes for check-deleted-tests.sh.

To stop that class of silent non-application recurring, the guard now names its mode in the summary line:

check-file-size: OK — 1187 … (none grew by this change). Judged against 20e47d9f.
check-file-size: OK — 1187 … (none grew by this change). No base resolved — strict whole-tree check.

The change-relative rule is silent by nature — it only shows itself when something drifts — so without that line a too-shallow checkout is indistinguishable from a clean run in every log it will ever print. Now it is one glance.

`scripts/file-size-baseline.txt` is one shared cell every growing PR must
write, and three times running (#1761, #1782, #2003) two PRs that each
wrote it CORRECTLY composed into a red `main`. Each regenerates the whole
baseline against a snapshot of `main` that does not yet carry the other's
growth, so each records a stale ceiling for a file it never touched. The
merge is textually clean — the two sides edit different lines — and the
result is a ceiling one line below an actual. `main` then stays red and
every subsequent PR inherits a failure it did not cause; #1992 sat blocked
on exactly this with a tree byte-identical to `main`.

Note what did not happen in that composition: the file never grew. Its
ceiling moved down underneath it. A guard with one tree to look at cannot
tell those apart, so it now asks the per-change question against the base:

    fail only when  current > max(ceiling, size at base)

Both halves of the `max` earn their place. `ceiling` alone is the old check
and fails the innocent PR. `size at base` alone would pass a file already
over its ceiling that THIS change grows further — turning inherited drift
into a standing licence to bloat, which is worse than the red main being
fixed. Taking the larger fails real growth and is silent about inherited
violations. A ceiling raised deliberately via `--update` still passes
exactly as before, since the regenerated ceiling equals the current size.

The base is the pair check-deleted-tests.sh already uses, for the same
reason: on a `pull_request` the checkout is `refs/pull/N/merge`, so HEAD^1
is the base branch tip and the question becomes "does this MERGE grow a god
file?". ci.yml already sets the `fetch-depth: 2` that needs. Locally it is
the merge base with origin/main; on a linear push it is HEAD^1. When no
base resolves — shallow clone, root commit, no origin — the guard falls
back to the old whole-tree check, because an unresolvable base must make
the ratchet stricter, never weaker.

Drift is still reported, on stderr and in the summary line, so the baseline
debt stays visible instead of merely being tolerated. It just no longer
fails the next PR to walk past it.

Witness: scripts/test-file-size.sh gains five hermetic cases that build the
skew from two commits in a throwaway repo. B1 (a ceiling lowered under an
untouched file) and B2 (drift inherited by an unrelated change) both FAIL
on the old guard and pass now. B3, B4 and B5 pass on both, which is the
point of them — they constrain the fix rather than being satisfied by it,
and B4 in particular pins the case a naive "already over, so ignore it"
rule would wave through.

Closes #2004
B1-B4 pass the base in by hand, so the rung that resolves it unaided -
HEAD^1 on a `refs/pull/N/merge` checkout - was the one rung nothing
exercised, and it is the one production depends on. B6 builds a real merge
commit whose base branch drifted while the PR was open, and asserts the
guard finds its own base. It fails on the old whole-tree guard alongside B1
and B2.

Refs #2004

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry @macanderson, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
stella-cli-docs Ignored Ignored Preview Aug 8, 2026 8:07am

@sourcery-ai

sourcery-ai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Adjusts the file-size guard to judge per-change growth against both the baseline ceiling and the size in the base tree, adds hermetic tests for baseline skew scenarios, and updates documentation and Makefile help text to reflect the new behavior.

File-Level Changes

Change Details Files
Refactor the file-size ratchet to compare current file size against max(baseline ceiling, size at base commit) and to distinguish inherited drift from growth caused by the current change.
  • Introduce resolve_base_commit to determine the appropriate base commit in CI, local branches, and linear histories, with a strict fallback when no base is resolvable.
  • Add size_at_base helper to read a file’s line count from the base tree, failing closed when the file does not exist.
  • Change awk output to emit GREWCAND records for potentially over-ceiling files instead of immediate GREW verdicts.
  • Add a shell post-processing loop that reclassifies GREWCAND records into GREW or drift based on the max(ceiling, size at base) rule and preserves section ordering.
  • Emit detailed drift information to stderr and include a drift summary in the success message, clarifying that no files grew by this change.
scripts/check-file-size.sh
Extend and restructure the file-size test suite to cover baseline skew and base-resolution scenarios hermetically.
  • Add set_baseline helper to write test baselines directly and commit helper to create history for base-tree comparisons.
  • Introduce six new B-series tests that model ceiling-lowered skew, inherited drift, genuine growth, growth on drift, missing base, and refs/pull/N/merge behavior via FILE_SIZE_BASE_REF and real commits.
  • Reuse existing want harness to assert pass/fail expectations and specific output substrings for the new scenarios.
  • Ensure the suite still ends with a summary of passed/failed tests and exits non-zero on failure.
scripts/test-file-size.sh
Update documentation and tooling descriptions to reflect that the file-size ratchet judges changes relative to a base tree.
  • Document the change-relative ratchet rule and its implications in AGENTS.md, including guidance on handling drift and god-file splits.
  • Adjust the Makefile’s file-size-test target description to mention both language coverage and change-relative judgement.
AGENTS.md
Makefile

Assessment against linked issues

Issue Objective Addressed Explanation
#2004 Change the file-size ratchet to judge per-change rather than whole-tree, using a merge-base-aware comparison so that (a) genuine growth beyond the ceiling still fails with the same message, (b) inherited baseline drift does not fail innocent PRs, and (c) when no base can be resolved the guard falls back to the original strict whole-tree behavior.
#2004 Extend scripts/test-file-size.sh with hermetic tests that reconstruct the baseline skew scenario and cover the new change-relative behavior (including merge-base resolution, drift handling, genuine growth, growth on drift, and no-base cases).
#2004 Update AGENTS.md to document that the file-size ratchet now judges the change (current > max(ceiling, size at base)), and adjust related tooling/documentation (e.g., Makefile target description) to reflect this behavior while keeping existing guards green.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

`file-size.yml` is the cheap, requirable status context for the ratchet, and
it checked out at the default depth of 1 under a comment stating that full
history was not needed because the guard "reads the working tree and the
committed baseline, never a diff against the base". That was true until the
previous commit and is now false.

At depth 1 the merge commit's parents are not in the clone, so
`resolve_base_commit` finds nothing and falls back to the strict whole-tree
check. The job stays green and reports the ratchet exactly as before, which
is the failure mode worth naming: the fix would have been silently absent
from the one job whose entire purpose is to report this guard cheaply enough
to be required. Found by reading the CI log rather than by a red check,
because there is nothing red to see.

`fetch-depth: 2` is the same trade ci.yml's guards job already makes for
check-deleted-tests.sh — two trees, not two histories, one extra commit.

The guard now also names its mode in the summary line ("Judged against
<sha>" or "No base resolved — strict whole-tree check"). The change-relative
rule is silent by nature, showing itself only when something drifts, so
without that line a too-shallow checkout is indistinguishable from a clean
run in every log it will ever print.

Refs #2004
@macanderson

Copy link
Copy Markdown
Owner Author

Review: changes requested — one rung of resolve_base_commit makes the pre-push gate false-green

The core design holds up. I verified the ratchet direction empirically rather than by reading the description: file at ceiling C=1600, PR A grows to 1610 with --update and merges, PR B branches after and adds +10 more with the baseline untouched → B fails (max(ceiling=1610, base=1610) = 1610 < 1620, exit 1). The invariant that survives is the right one: a PR passes only when current ≤ ceiling (a reviewable raise) or current ≤ base (no growth), and base itself can only rise through a PR that took a visible baseline diff. Merge skew can now produce stale ceilings but never uncounted lines. The .github/workflows/file-size.yml depth-2 fix is real and necessary — without it the required context would have silently judged the whole tree forever.

The hermetic tests are a genuine witness: 13/13 pass against this branch's guard, and against origin/main's guard exactly B1, B2 and B6 fail. B4 (growth on top of inherited drift still fails) passes on both, which is what pins the fix from over-reaching. Guard conventions are respected — git ls-files enumeration untouched, consistent wc -l on both sides, no stat, shellcheck clean, and GATE_STEPS genuinely needs no change since only file-size-test's help text moved.

The blocking defect — check-file-size.sh:187-190

The comment says "A merge commit means a refs/pull/N/merge checkout". That is false locally, and I built the counterexample: grow a god file +100 on a feature branch with no baseline touch, then merge trunk into the branch — the update-branch shape, i.e. the commit immediately before git push. HEAD^2 now exists, so base = HEAD^1 = the developer's own pre-merge tip, which already contains the growth. The guard prints

src/big.rs is 1700 lines against a ceiling of 1600 — already so at the base ... another change put them there

and exits 0. The pre-push hook passes, CI reds an hour later, and the diagnostic actively blames someone else for your own growth. check-deleted-tests.sh gets away with the same HEAD^1/HEAD^2 pair only because it runs solely on pull_request in CI; this guard borrowed the pair without that precondition.

Fix: reorder — try the merge-base HEAD origin/main rung (line 199) before the HEAD^2 rung. In CI's PR/merge-queue checkout origin/main does not exist (single-ref fetch), so HEAD^2 still fires there; and even if a future config materializes origin/main, merge-base(refs/pull/N/merge, origin/main) is the base tip = HEAD^1, so the reorder is identical under every checkout config. Locally it yields the true fork base and catches the growth. Please add a B7 case: merge trunk into feature after growing a god file, with an origin/main ref present, expect-fail.

Everything else about the fallback behavior is correct and I confirmed it fails closed: a bad FILE_SIZE_BASE_REF exits 1, and a shallow depth-1 clone makes both HEAD^1/HEAD^2 and origin/main unresolvable → strict fallback, i.e. stricter, never open. That was the axis most likely to gut the gate silently, and it doesn't.

Also required before merge (per "nothing left behind")

Two things this PR names only in prose and neither is filed:

  1. Drift convergence. Nothing now guarantees the baseline retightens; drift persists green forever behind a stderr note (check-file-size.sh:316). It heals in practice only because any future --update regenerates all entries — an accident of the file format, not a mechanism.
  2. The OBSOLETE/STALE inheritance gap the description explicitly declines to take.

A prose note in a PR body is not a handoff. Both need issues written as handoffs before this lands.

@macanderson

Copy link
Copy Markdown
Owner Author

Filed the two residue items so they are handoffs rather than prose: #2310 (nothing retightens the baseline now that inherited drift is non-fatal) and #2311 (the OBSOLETE/STALE classification did not inherit the base-relative reasoning). Reference them from the description and the remaining blocker is the resolve_base_commit rung reorder plus its B7 case.

@macanderson
macanderson merged commit 73d2f2b into main Aug 8, 2026
15 checks passed
@macanderson
macanderson deleted the fix/2004-change-relative-ratchet branch August 8, 2026 20:26
@macanderson

Copy link
Copy Markdown
Owner Author

This merged before the resolve_base_commit fix landed, so the local false-green is now live in the gate machinery — filed as #2317 with the reproduction and the exact reorder. #2310 and #2311 carry the two residue items. The core design and the hermetic suite are good; it is one rung ordering.

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.

gate: the file-size baseline is a shared cell — two correct PRs compose into a red main (3rd occurrence)

1 participant