Skip to content

premerge_check: run the static gates on the MERGE RESULT, before the merge - #2035

Merged
andrewboudreau merged 8 commits into
mainfrom
tools/premerge-check
Aug 30, 2026
Merged

premerge_check: run the static gates on the MERGE RESULT, before the merge#2035
andrewboudreau merged 8 commits into
mainfrom
tools/premerge-check

Conversation

@andrewboudreau

Copy link
Copy Markdown
Collaborator

The gap

.github/workflows/pr-validate.yml -- the job that submits to the external ROM validator, and the only job in this tree that builds anything -- has exactly one trigger:

on:
  pull_request_target:

There is no push: branches: [main]. No ROM is built after a merge, ever, and every PR is judged against its own base. Two PRs each honestly green against base X can combine into a red main, and nothing observes it until a person notices. That has happened twice in four days (#1987, #1990 -- both tools/-only changes that landed against a base that had moved).

source-coverage.yml partly closes the hole with a push-to-main job against github.event.before. But that fires after the merge: it can tell you main is broken, it cannot tell you not to break it. And it is one metric out of seven.

tools/premerge_check.py fills exactly that gap: the static gates, run on the tree git merge would produce, before the merge.

git merge-tree --write-tree origin/main <head>   ->  a tree nothing has ever gated

Both trees are exported, every gate runs on both, and the verdicts print side by side.

It never blames a PR for a red base

base merge result verdict exits non-zero
pass pass ok no
pass fail REGRESSION yes
fail fail pre-existing no
fail pass fixed no

A gate already red on origin/main is not this PR's to own and can never fail the run. A tool that blames the wrong PR trains people to ignore it. This came up for real mid-development: dead-references went red on main (fixed since, in #2033) and the tool correctly reported pre-existing on every open PR rather than reddening all of them.

A git merge-tree conflict is its own outcome -- CONFLICT, no gates run, "rebase first" -- and is never reported as a gate failure.

The gates

Seven, all verified toolchain-free by reading each one, not by observing that it passed:

gate tool
converted-ratchet tiers_ratchet.py --check
dead-references check_dead_references.py
duplicate-sources check_duplicate_sources.py
header-offsets check_header_offsets.py (batched, 120 headers per call)
layout-check layout_check.py --quiet
src-tu-refs check_src_tu.py
source-coverage source_coverage.py --json, subtracted between the two trees

Deliberately excluded, with reasons in the docstring: rombuild.py and eligible.py (hard requirement -- build/ is shared between worktrees and a concurrent run produces fake failures for everyone); check_references.py (needs an eligibility report, i.e. a compiler); check_src_tu_compiles.py, build_pin, linkcheck, romdata_check (compiler / extracted/ / ROM); prepush_attribution.py (needs history, not a tree).

"Verified toolchain-free" here means the tool was read for the self-return-reporting-pass shape this repo has shipped before. None of the seven has it.

source-coverage is a byte delta, and only a byte delta

--check wants a git ref and a bare merge tree has no commit, so both trees are measured with --json and subtracted here. currentBytes and the per-section map decide the verdict; entries and modules are printed as context and can never fail a run.

That distinction is a measurement, not fastidiousness. The first version failed on a decrease in any of the four numbers, and the first time it was pointed at real PRs it called two of them red -- #2023 (entries 11025 -> 11011) and #2024 (11025 -> 11017) -- with currentBytes and every per-section total unchanged. Both are TU promotions, which re-partition the address space by construction: thirty-eight per-function delinks entries become three merged ones covering the same bytes, and nothing is handed back to the cartridge. That is precisely the false alarm source_coverage.py's own docstring exists to avoid. A gate that reddens on every TU promotion is a gate people learn to click past, which fails the same way as a gate that never reddens at all. Both PRs come back clean now, with the drop shown as info:.

Real runs

Against origin/main @ b309f5d3f069 and four open PRs. origin/main against itself is all-green with no diff, as it must be.

Full output (exit 1, because of #2001)
base   origin/main @ b309f5d3f069

target origin/main  head b309f5d3f069
  merge tree 9902c575e231
  gate                base     merge    verdict
  ------------------  -------  -------  ------------
  converted-ratchet   pass     pass     ok
  dead-references     pass     pass     ok
  duplicate-sources   pass     pass     ok
  header-offsets      pass     pass     ok
  layout-check        pass     pass     ok
  src-tu-refs         pass     pass     ok
  source-coverage     (ref)    pass     ok

  RESULT: nothing goes green -> red. Safe to merge as far as the STATIC gates can tell -- no ROM was built.

target #2001 Add fail-closed compiler-owned C++ TU promotion  head ef16a307356c
  merge tree f9da93d8e63b
  gate                base     merge    verdict
  ------------------  -------  -------  ------------
  converted-ratchet   pass     fail     REGRESSION
  dead-references     pass     pass     ok
  duplicate-sources   pass     pass     ok
  header-offsets      pass     pass     ok
  layout-check        pass     pass     ok
  src-tu-refs         pass     pass     ok
  source-coverage     (ref)    pass     ok

  converted-ratchet: green on origin/main, FAIL on the merge result.
      | CONVERTED backslide: 4 banked file(s) no longer pass all 5 criteria
      | 
      |   src/_ZN7dBase_c18AfterInitResourcesEj.cpp
      |       MOVED -- absorbed into src/actors/ActorDerived.cpp by TU promotion (arm9/ActorDerived); member regression: src/actors/ActorDerived.cpp#func_02013edc fails: Real function name (not func_<addr>, Unk_<addr>, Virtual<n>)
      |   src/_ZN7dBase_c5SpawnEjP7fBase_cii.cpp
      |       MOVED -- absorbed into src/actors/ActorDerived.cpp by TU promotion (arm9/ActorDerived); member regression: src/actors/ActorDerived.cpp#func_02013edc fails: Real function name (not func_<addr>, Unk_<addr>, Virtual<n>)
      |   src/_ZN7dBase_cD0Ev.cpp
      |       MOVED -- absorbed into src/actors/ActorDerived.cpp by TU promotion (arm9/ActorDerived); member regression: src/actors/ActorDerived.cpp#func_02013edc fails: Real function name (not func_<addr>, Unk_<addr>, Virtual<n>)
      |   src/_ZN7dBase_cD1Ev.cpp
      |       MOVED -- absorbed into src/actors/ActorDerived.cpp by TU promotion (arm9/ActorDerived); member regression: src/actors/ActorDerived.cpp#func_02013edc fails: Real function name (not func_<addr>, Unk_<addr>, Virtual<n>)
      | 
      | baseline 2554   current 2571   (+21 gained, -4 lost)
      | 
      | If a byte match REQUIRED this -- and it legitimately can; raw-cast
      | versus named member is decided per function by the byte gate -- bank
      | it with a reason instead of reverting readable code:
      |   python tools/tiers_ratchet.py --update --reason "<why>"
      | 
      | MOVED lines above are a TU promotion, not a deletion: the file
      | named absorbed the code and then failed a criterion for the whole
      | TU. A reconstructed TU must spell vague-linkage symbols directly
      | (_ZN7fBase_cnwEj, _ZN8dActor_cC2Ev, _ZN8dActor_cD2Ev) or the range
      | will not link, so no_mangled_refs cannot pass for one. Byte-match
      | outranks readability -- bank it with that as the reason.

  RESULT: 1 gate(s) go green -> red on the merge result. This PR is answerable for them.

target #2024 Promote daObjPushblock_c to a genuine C++ TU  head 714a6b800e23
  merge tree 898bf418a91d
  gate                base     merge    verdict
  ------------------  -------  -------  ------------
  converted-ratchet   pass     pass     ok
  dead-references     pass     pass     ok
  duplicate-sources   pass     pass     ok
  header-offsets      pass     pass     ok
  layout-check        pass     pass     ok
  src-tu-refs         pass     pass     ok
  source-coverage     (ref)    pass     ok
                          info: entries: 11026 -> 11018 (-8)  (consolidation, not a loss -- bytes are flat)

  RESULT: nothing goes green -> red. Safe to merge as far as the STATIC gates can tell -- no ROM was built.

target #2023 TU: promote seven policy-free translation units (#1993 slice 1)  head a13cfc343705
  merge tree ec88c00e22cc
  gate                base     merge    verdict
  ------------------  -------  -------  ------------
  converted-ratchet   pass     pass     ok
  dead-references     pass     pass     ok
  duplicate-sources   pass     pass     ok
  header-offsets      pass     pass     ok
  layout-check        pass     pass     ok
  src-tu-refs         pass     pass     ok
  source-coverage     (ref)    pass     ok
                          info: entries: 11026 -> 11012 (-14)  (consolidation, not a loss -- bytes are flat)

  RESULT: nothing goes green -> red. Safe to merge as far as the STATIC gates can tell -- no ROM was built.

target #2026 The Tick Tock beam swings byte-exact, and a ternary's arm order is  head effc8644d955
  merge tree 9902c575e231
  gate                base     merge    verdict
  ------------------  -------  -------  ------------
  converted-ratchet   pass     pass     ok
  dead-references     pass     pass     ok
  duplicate-sources   pass     pass     ok
  header-offsets      pass     pass     ok
  layout-check        pass     pass     ok
  src-tu-refs         pass     pass     ok
  source-coverage     (ref)    pass     ok

  RESULT: nothing goes green -> red. Safe to merge as far as the STATIC gates can tell -- no ROM was built.

Finding: PR #2001 is green everywhere and red on the tree that does not exist yet

#2001 (tools/tu-control-policy-0830, head ef16a307356c) is open, GitHub reports it MERGEABLE, it is green on its own PR and converted-ratchet is green on origin/main. Its merge result fails converted-ratchet:

CONVERTED backslide: 4 banked file(s) no longer pass all 5 criteria

  src/_ZN7dBase_c18AfterInitResourcesEj.cpp
      MOVED -- absorbed into src/actors/ActorDerived.cpp by TU promotion
      (arm9/ActorDerived); member regression:
      src/actors/ActorDerived.cpp#func_02013edc fails: Real function name
  ... and _ZN7dBase_c5SpawnEjP7fBase_cii, _ZN7dBase_cD0Ev, _ZN7dBase_cD1Ev

baseline 2554   current 2571   (+21 gained, -4 lost)

src/actors/ActorDerived.cpp landed on main in one of the commits #2001 is behind. Neither side can see the collision; only the merge result can.

Finding: the tool would have caught the failure that was live on main this morning

a8061b08d (tool-tests.yml) turned dead-references red on main and nothing noticed, because dead-references.yml filtered on **.py/**.md/src/**/etc. and that commit touched exactly one .yml. The gate ran neither on the PR nor on the push. Replaying it:

$ python tools/premerge_check.py a8061b08d --base a8061b08d^

  gate                base     merge    verdict
  ------------------  -------  -------  ------------
  dead-references     pass     fail     REGRESSION

  dead-references: green on a8061b08d^, FAIL on the merge result.
      | FAIL: 1 prose reference(s) name a path that does not exist:
      |   .github/workflows/tool-tests.yml
      |       names `tools/test_X.py`, which is not in the tree

This tool runs every gate on the whole merge tree unconditionally, so a paths: filter cannot hide a failure from it. (#2033 has since fixed both the reference and the filter, independently.)

Finding: #2004 does not merge, and GitHub says it does

gh pr list reports #2004 MERGEABLE. git merge-tree disagrees -- real content conflicts in attribution.json and notes/cpp-tu-current-state.md. GitHub's mergeability is computed against a base that has since moved; this is computed against origin/main as it stands right now.

Tests

47, in tools/test_premerge_check.py, enrolled in tool-tests.yml.

Every test runs with no git, no gh, no compiler and no extracted/. That is a requirement of the suite, not an accident: the general shape of a gate that reports a clean tree after its match count went to zero is a test file that self-returns when its inputs are missing. So the logic that decides the exit code -- the verdict diff, the coverage delta, the merge-tree parse, the PR resolution failures -- is written as pure functions over data and tested as such. The one test that genuinely needs a git binary calls self.skipTest and says why; a real skip is visible in the runner output, a bare return is not.

Includes test_no_gate_can_ever_invoke_a_rom_build, which asserts that "rombuild" and "eligible" appear in no gate's argv.

Advisory, on purpose

Nothing in .github/workflows/ runs this, and this PR adds no workflow that does. It is a command you run before you press merge. Wiring it into CI is a separate, deliberate decision -- it would need a call on where a pull_request job gets a base to compare against, and on whether a green->red verdict should block a merge or only comment. Landing the tool first means that decision can be made against something people have actually used.

Usage

python tools/premerge_check.py 2001 2023 2024        # PR numbers, via gh
python tools/premerge_check.py <sha> --base origin/main
python tools/premerge_check.py 2001 --json --keep-trees

Exit codes: 0 clean, 1 a gate went green->red, 2 a merge conflict, 3 the tool could not answer. PR numbers resolve through gh pr view N --json headRefOid, with a clear message rather than a traceback when gh is absent or unauthenticated. Exported trees are removed with shutil.rmtree (never git's own removal, which recurses through the worktree junctions).

Runtime is about 2 minutes per target on Windows; the base tree is exported and gated once and reused across every target in a run.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WdCK1xgrJdiJzPCh3bAJfQ

andrewboudreau and others added 4 commits August 30, 2026 13:19
… branch

pr-validate.yml has exactly one trigger, `pull_request_target:`. There is no
`push: branches: [main]`. Nothing builds a ROM after a merge, and every PR is
judged against its own base -- so two PRs each green against base X can combine
into a red main. That has happened twice in four days (#1987, #1990).

This runs the toolchain-free gates on `git merge-tree --write-tree origin/main
<head>` and diffs the verdicts against origin/main. It exits non-zero ONLY when
a gate is green on the base and red on the merge result. A gate already red on
the base is reported as `pre-existing` and can never fail the run: a tool that
blames the wrong PR trains people to ignore it.

Gates, all verified toolchain-free by reading them: converted-ratchet,
dead-references, duplicate-sources, header-offsets, layout-check, src-tu-refs,
and source-coverage as a byte-set delta. rombuild.py and eligible.py are
excluded by construction -- build/ is shared between worktrees.

Advisory. Nothing in .github/workflows/ runs this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdCK1xgrJdiJzPCh3bAJfQ
tool-tests.yml enumerates its modules on purpose -- "an explicit list makes
adding a module a reviewed decision" -- so a new suite has to be added by hand
or it never runs. This adds the 47 tests for premerge_check.py, in the
alphabetical position and with the annotation the header block asks for.

The suite qualifies on the header's own terms: unittest.TestCase throughout, so
`python -m unittest` really collects it (it is not pytest-style), and no
bare-`return` toolchain guard -- the single test that needs a git binary calls
self.skipTest and says so.

The timing note now separates the measured 312 from the 47 this adds rather
than restating 5.3s for a total that was never timed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdCK1xgrJdiJzPCh3bAJfQ
@tangos-validator

tangos-validator Bot commented Aug 30, 2026

Copy link
Copy Markdown

✅ PR validation — Passed

noverify: no source/build-data changes in this PR

Each changed src/*.c|*.cpp is compiled and its relocated bytes compared to the binary data on a private build box. Passing requires every changed file to reproduce the ROM byte-for-byte with correct relocation targets — this catches WRONG-DEST relocations and non-reproducing near-misses that ledger-scoped linkcheck skips.

@andrewboudreau

Copy link
Copy Markdown
Collaborator Author

Dogfooded against itself, at base deb1e1e7acc8:

$ python tools/premerge_check.py 2035

target #2035 premerge_check: run the static gates on the MERGE RESULT, before t  head f2fc411f0af2
  merge tree 5c3dda61f9a7
  gate                base     merge    verdict
  ------------------  -------  -------  ------------
  converted-ratchet   pass     pass     ok
  dead-references     pass     pass     ok
  duplicate-sources   pass     pass     ok
  header-offsets      pass     pass     ok
  layout-check        pass     pass     ok
  src-tu-refs         pass     pass     ok
  source-coverage     (ref)    pass     ok

  RESULT: nothing goes green -> red. Safe to merge as far as the STATIC gates can tell -- no ROM was built.

exit 0.

@andrewboudreau

Copy link
Copy Markdown
Collaborator Author

Gatekeeper review — MERGE AFTER FIXES (three blocking)

I put this through an adversarial pass rather than a read, because a tool whose whole job is to say "safe to merge" is one whose false negatives are invisible by construction. Summary: the tool is real, it is safe, and it works — I reproduced its headline claim against live #2001 on the current base today. It also violates its own stated anti-hollow principle for one of its seven gates, and I watched it print Safe to merge with exit 0 while a gate had silently not run.

Let me lead with the good part, because it is the reason I want this landed rather than reworked. Live run against #2001 at base deb1e1e7a:

converted-ratchet   pass     fail     REGRESSION
| CONVERTED backslide: 4 banked file(s) no longer pass all 5 criteria
|   src/_ZN7dBase_c18AfterInitResourcesEj.cpp
|       MOVED -- absorbed into src/actors/ActorDerived.cpp by TU promotion ...
| baseline 2554   current 2571   (+21 gained, -4 lost)
EXIT=1

Correct, actionable, and it is a defect no existing check can see#2001 is green on its own PR and green on main, red only on the tree that does not exist yet. Conflict goes to exit 3. A deleted gate tool goes ERROR to REGRESSION to exit 1. Those paths fail closed.

Safety is clean: no git worktree remove anywhere (only prose), no checkout/reset/stash/push, exports run under a scratch GIT_INDEX_FILE so the shared index is never touched, rombuild/eligible never invoked and a test asserts they cannot be. After a full real run the main checkout was byte-identical and extracted/ intact.

The test module is not hollow — 10 TestCase subclasses, 0 bare pytest-style def test_*, 47 methods, Ran 47 tests / OK, enumerated explicitly in tool-tests.yml rather than globbed. The one skip is a real self.skipTest that names its reason and does not fire in CI. That is a genuine green, and I checked because this tree has a documented Ran 0 tests ... OK problem.


Blocking

F1 — source-coverage fails open, observed. coverage_metrics() (:599-612) returns None on OSError, timeout, any non-zero exit, or unparseable JSON, and discards stdout/stderr. coverage_regressions() returns [] when either side is None (:379-380), so regs stays empty and exit_code stays 0:

source-coverage     (ref)    error    not measured on both trees -- see above
RESULT: nothing goes green -> red. Safe to merge ...
EXIT=0

Nothing was printed "above" — the output was thrown away. --json is worse: "outcome": "clean", "exit": 0, "coverage": null, and no unmeasured flag anywhere in the document, so a script gating on outcome == "clean" greenlights it.

This is the one that has to change, because classify()'s own docstring (:328-331) already says it: "a gate that could not run on the merge result but ran on the base is a real signal about the merge result, and calling it a pass is how gates come to mean nothing." The coverage gate is routed around classify() and gets exactly the treatment that sentence forbids. Make cov_measured is False a regression (or exit 2) and print the captured output.

Realistic trigger, and it is your own motivating case: two PRs editing source_coverage.py in different hunks that text-merge into broken Python. A syntax error there yields rc=1.

F2 — the coverage gate compares byte counts; CI compares the byte set. COVERAGE_BYTE_KEYS = ("currentBytes",) plus bySection totals (:315, :377-388). CI runs source_coverage.py --check --merge-base ..., whose report() computes lost = subtract(ab, ar) over merged intervals keyed by (module, section, start, end) and fails on unwaivedLostBytes > 0 (source_coverage.py:445-452).

Observed: relocating one .rodata range in config/arm9/delinks.txt by +0x400000 — 71,984 bytes of claimed coverage moved to a bogus address — gives

base currentBytes 2067148 -> moved 2067148     bySection equal? True
premerge_check verdict: ok

CI would report 71,984 B handed back with a blame line. And this is merge-shaped: two PRs editing the same module's delinks.txt merge cleanly at text level and can trade ranges.

So the docstring's THE UNIT IS THE BYTE, AND ONLY THE BYTE (:156) is not accurate — the unit is the byte count, and the waivers file CI honours is ignored.

To be explicit: your TU-promotion false-positive fix was correct and well-measured. Dropping entries/modules as failure signals was right. It is the replacement metric that is too coarse. Compare the set.

This is a family this project keeps hitting — the ROM-data metric that counted records, the coverage ratchet whose (+N) deltas drop on every consolidation. The headline number stays flat while the thing it summarises moves.

F3 — langmode_audit.py --check is in neither the included nor the excluded list. The docstring names seven included and six "deliberately EXCLUDED, each for a stated reason". This one is simply absent, yet langmode-ratchet.yml:82 runs it and langmode-baseline.json exists at main's root today. Verified it runs toolchain-free inside the tool's own exported base tree: langmode ratchet PASS, rc=0. So the omission is not a technical limit.

It matters because langmode is a ratchet against a banked baseline — precisely this tool's target class, and one with a documented root-baseline cascade hazard. Add it, or add it to the excluded list with a reason. One caveat to handle in the same edit: when the root override is absent CI falls back to _chaos_data/langmode-baseline.json, which is untracked and absent from an export — that case must be an ERROR, not a pass.

Strongly recommended

F7 — max(returncode) is wrong for signal-killed children. worst = max(worst, out.returncode) (:566), then status = "pass" if worst == 0. On POSIX a signal-killed child returns negative, and max(0, -11) == 0 gives PASS. header-offsets runs ~5 batches of 120 headers, so one OOM-killed batch among passing ones reports green. Confirmed it does not fire on Windows (returncode 4294967285 goes ERROR to REGRESSION, correctly). CI is Linux, and the PR positions CI wiring as the follow-up, so this is latent exactly where it will first be used.

F8 — the base ref is never refreshed. base_sha comes from git rev-parse origin/main^{commit} (:687) with no fetch; --fetch fetches only pull/N/head. The tool's entire premise is your base moved and you did not notice, and a day-stale clone yields a confident "Safe to merge" against a base that no longer exists. Fetch, or print the base's age and say so in the usage block.

An export-size floor. There is no equivalent of check_dead_references's SCAN TOO SMALL guard. An empty or truncated export makes every gate ERROR on both sides, which classifies as pre-existing and exits 0, "Safe to merge". Given how cheap it is, assert set(git ls-tree -r <tree>) == set(git ls-files) in the export and refuse below a floor. That also closes F6.

Worth fixing, not blocking

  • F4classify("fail","fail") = "pre-existing" never compares failure content. Base backslide: 1 file vs merge backslide: 500 files both print pre-existing, exit 0, and neither summary is rendered — they are captured in merge_verdicts and simply not printed (:823-835 iterates regs only). Your argument for not failing the exit code is sound and I would keep it; it does not justify hiding the numbers. Print both when they differ.
  • F5resolve_pr() (:461-463) runs gh pr view with no cwd= and no --repo owner/name, so the documented --repo flag is broken for the primary usage mode, and the failure message blames auth (If this is an auth problem, gh auth login) for what is a CWD problem. Fails closed, so not urgent. Thread repo through and pass cwd=repo.
  • F6export_tree does mkdir(exist_ok=True) plus checkout-index -a -f: overwrites, never deletes. With --tmp X --keep-trees, a reused export still contained a file deleted from the base, and the table reported src-tu-refs pass on a tree corresponding to no commit. Also scratch/"base" is a fixed name, so two concurrent runs sharing --tmp collide and each cleanup rmtrees the other's export. rmtree before mkdir, and document it.
  • F9 print_table:635-636 says "see above" where there is no above. F10 resolve_pr requests state and baseRefName and uses neither — a merged/closed PR, or one targeting a different base, produces a confident green. F12 shutil.copyfile raises OSError but callers catch only RuntimeError, giving a traceback and exit 1, indistinguishable from a REGRESSION.

Two notes, not change requests

I owe you a correction. I have been asserting that git checkout-index -a -f --prefix= silently drops files, and I briefed reviewers to that effect. It did not reproduce against your code. With -a -f and a fresh GIT_INDEX_FILE, your export carried 12,771 / 12,771 files versus git ls-tree -r origin/main, zero missing, and git ls-files in the export returned exactly 12,771. The incomplete export I remember was almost certainly a missing flag or a shared index, not the command. Your approach is sound and I have corrected my own note.

State the inherent limit in the docstring. The merge tree's own gate scripts are what run, so a PR that weakens a gate shows pass/pass. That is the right design after #1994 (a PR now tests its own tool change) — but it is a real blind spot and should be said out loud rather than discovered.

One coverage gap worth closing while you are in here: main(), coverage_metrics(), run_all_gates, _headers, _header_commands and print_table have zero test coverage — and F1, F4, F6 and F9 all live in exactly those functions. ExportTreeTests (test_premerge_check.py:408-409) asserts one file is present, which does not test completeness; the set-equality assertion above is a one-liner that would.


Fix F1, F2 and F3 and I will merge. This is a genuinely valuable tool and I want it in front of the TU queue rather than behind it — it is the only thing in the repo that evaluates a merge result, and it has already earned its place by finding #2001.


Addendum — a second live run, on #2023

Ran it against the live TU-promotion PR that is next in my merge queue, twice, same merge tree 8023885977b6 both times:

target #2023  TU: promote seven policy-free translation units (#1993 slice 1)
  converted-ratchet   pass     pass     ok
  ... all six static gates ok ...
  source-coverage     (ref)    pass     ok
      info: entries: 11026 -> 11012 (-14)  (consolidation, not a loss -- bytes are flat)
  RESULT: nothing goes green -> red.

That is the exact case the docstring's measurement narrative at :392-424 was written for, and it behaves correctly: the entries drop is reported, labelled, and non-fatal. My F2 objection is about the replacement metric being too coarse — it is not an argument for putting entries back. Dropping it was right.

So across two live targets the tool caught the one real regression and did not false-alarm on either TU promotion. That is the behaviour I need from it.

F14 — a hard kill leaks a 12k-file export. cleanup() is in a finally (:861-865), which survives KeyboardInterrupt but not SIGKILL. A run killed mid-target left three complete merge exports plus their index files behind:

merge-41a52556c494  merge-41a52556c494.index
merge-ba45ac104395  merge-ba45ac104395.index
merge-ef16a307356c  merge-ef16a307356c.index

Each ~12,700 files. With the default tempfile.mkdtemp(prefix="premerge-") they land under a fresh random name every time, so they are neither reused nor ever found again. The normal exit path cleans up perfectly (0 entries left). Pairs with F6 — a --tmp that reclaims or refuses a non-empty destination covers both, and gives the leak somewhere findable to accumulate.

One thing to know before wiring this into automation: exit_code = max(exit_code, 1) across targets (:852) means a multi-target run returns one code for the worst target. premerge_check.py 2001 2023 exits 1 even though #2023 is clean — from the exit code alone you cannot tell which PR is answerable, only from the table or --json. Correct as designed for a human gatekeeper; worth a sentence in the usage block for anything that consumes the code alone.

andrewboudreau and others added 4 commits August 30, 2026 14:45
Three blocking defects from review, plus the cheap ones flagged alongside them.

F1 source-coverage failed OPEN. coverage_metrics() returned None on OSError,
timeout, non-zero exit and unparseable JSON alike, DISCARDED the child's output,
and the delta of two Nones was []; the run printed "outcome": "clean", "exit": 0,
"coverage": null with nothing saying the gate had not run. Coverage is now a row
like any other, through classify(), with the child's stdout+stderr captured and
printed where the row is, and coverageMeasured/coverageStatus in --json.

F2 the gate compared byte COUNTS; CI compares the byte SET. Measured: relocating
one .rodata range in config/arm9/delinks.txt by +0x400000 hands back 71,984 B
with currentBytes and every bySection total IDENTICAL. It now runs the merge
tree's own source_coverage.py --check --json --base <base> --ref <merge tree>
-- the same comparison, the same waivers file, no second interval implementation
-- with a base-vs-base self-check so a source_coverage.py broken on main reads as
pre-existing rather than as this PR's fault. entries/modules stay INFO.

F3 langmode_audit.py --check ran in CI and appeared in neither list. It is a gate
now; a tree without the root langmode-baseline.json is an ERROR, because CI's
fallback is _chaos_data/, which is untracked and cannot appear in an export.

Also: worst_rc() replaces max() (a signal-killed child has a NEGATIVE returncode,
so max(0, -9) == 0 reported PASS); export_tree removes its destination and
verify_export refuses a short, index-mismatched or truncated export; a
pre-existing row now prints BOTH summaries when they differ; resolve_pr runs gh
with cwd=repo and its state/baseRefName are read; --fetch refreshes the base and
its age is always printed; exports are named per run so concurrent runs under one
--tmp cannot collide.

Tests: 47 -> 87, every one a method on a unittest.TestCase. main(), run_gate's
return-code handling, print_table and the export assertions had no coverage at
all; each defect above has a regression test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdCK1xgrJdiJzPCh3bAJfQ
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdCK1xgrJdiJzPCh3bAJfQ
@andrewboudreau

Copy link
Copy Markdown
Collaborator Author

Gatekeeper note: three PRs contend on the tool-tests.yml header count

origin/main currently reads runs 312 of (L13) and 306 of the 312 (L69). Three
open PRs rewrite both lines:

PR own module header total
#2035 premerge_check new, 87 312 → 399
#2038 test_symrecords new, 24 312 → 336
#2040 test_nearmiss_db 12 → 21 312 → 321

All land ⇒ the true total is 432, and 306 of the 312 moves accordingly.

Two things worth stating plainly:

  1. This number is prose, not an assertion. The workflow comment calls it "the number
    to compare against if this job's total ever moves" — nothing checks it. A wrong value
    here is a silent documentation lie that survives indefinitely, which is the exact
    failure class this repo keeps getting bitten by.
  2. It will conflict, and that is the good outcome. All three PRs touch the same two
    lines, so whichever lands second and third goes CONFLICTING rather than
    auto-merging a stale integer.

When you resolve it: do not take --ours/--theirs on this file — that silently
reverts the other side's unrelated paths: and enumeration edits. Hand-edit the two
numbers, and measure the new total by running the enumerated list exactly as the
workflow does rather than doing the arithmetic. (#2040 already did it that way; that is
the standard.)

I am serializing the merges and will hold each one to a measured number.

@andrewboudreau
andrewboudreau merged commit ec8fe22 into main Aug 30, 2026
6 checks passed
@andrewboudreau
andrewboudreau deleted the tools/premerge-check branch August 30, 2026 20:44
andrewboudreau added a commit that referenced this pull request Aug 30, 2026
The only conflict was .github/workflows/tool-tests.yml: #2035 wired
tools.test_premerge_check into the same enumerated list and rewrote the same
two prose passages this branch rewrites. Resolved by hand -- never with
--ours/--theirs, which would have taken one whole side of the file and
silently reverted the other's unrelated edits.

Both sides survive: test_premerge_check (87) and test_symrecords (24) are
both enumerated in the runner and both keep their legend block, and the
"Costs nothing" paragraph names both.

Every count in the header is now MEASURED on this merge result rather than
carried from either side, because both sides' numbers were stale:

  59 test_*.py modules       (git ls-tree; main said 57, this branch said 58)
  53 that ran only by hand   (59 - the 6 that already had a workflow)
  13 pytest-style modules    (no unittest.TestCase; the header said eleven)
  186 test functions in them (the header said 158)
  423 assertions, 23 modules (python -m unittest -v <the enumerated list>:
                              "Ran 423 tests ... OK". Main measures 399, so
                              the delta is exactly test_symrecords' 24 and no
                              module dropped out.)
  417 of the 423 assert      (423 - the 6 documented skips)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdCK1xgrJdiJzPCh3bAJfQ
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.

1 participant