tubuild: stop cutting declarations in half, and name the block when a definition is out of reach - #2072
Conversation
… definition is out of reach
`split_legacy_source` decided where a local declaration ended by counting
braces on its FIRST line only. Three shapes broke on that.
1. An Allman-braced record --
struct Obj
{
char pad[0x2a];
};
-- scored depth 0, so the block was cut to the bare words `struct Obj`.
The TU then carried an incomplete type AND a headless `{ ... };` at file
scope, and, because the same text is what _merge_field quotes, the
TUBUILD CONFLICT comment asked a reviewer to compare a full struct
against two words. 82 legacy sources tree-wide were handed a bare `{`
as their function body this way (53 opened by struct, 31 by typedef,
5 by class). `consume_block` now walks forward for the brace, stopping
at a `;` so a forward declaration still owns only its own line and a
declaration that merely wraps across lines stays whole.
2. `struct dActor_c *dCapEnemy_c::RespawnIfHasCap()` opens on a decl
keyword, but as an elaborated type specifier on the RETURN type -- a
spelling the flat-C sources use constantly. It was filed as a shadow
declaration, taking the function's own signature with it. A record head
never carries a `(` before its brace; a parameter list always does.
3. When the definition sits inside `namespace X { ... }` or `extern "C"
{ ... }`, every line of it is consumed as a declaration and nothing is
left to be the body. tubuild has nowhere to put a block-scoped member,
so refusing is right (plan sec 7.3: an unfitting shape is "assemble
this one by hand"), but the old message -- "scanned to end of file
without finding a function body" -- reads as "your file has no function
in it" and sends a reader hunting. It now names the block and the line
that opened it. All 693 files that already refused on base refuse here
too; 683 of them now say why, and 10 more split cleanly than before.
A fail-loud backstop covers any residual mis-split: a function body never
starts on a bare brace, so reaching one is refused rather than emitted.
Measured over all 11150 legacy sources, base vs this: 82 headless bodies
repaired, 17 files that errored now split, 7 that silently produced a
headless body now refuse with the block named, 0 regressions. No file
enrolled in a TU is among them.
Tests use the real failing inputs -- src/func_ov006_020f8224.c (the
MCarlo `struct Obj` that produced both ends of the bug at once),
src/_ZN11dCapEnemy_c15RespawnIfHasCapEv.cpp, src/func_02041b60.c,
src/_ZN6Memory8AllocateEj.cpp and src/func_ov006_021063a0.cpp -- plus the
shapes the fix must NOT disturb: a forward declaration, a wrapped
declaration, and a record whose member is a function pointer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhhAeJwXBnfPp7B5DNjCwh
✅ PR validation — Passednoverify: no source/build-data changes in this PR Each changed |
Reproduced every number independently — approving and merging. One claim in the body is narrower than you wrote it, and the truth is stronger.Reviewed at The zero is the zero. My headless count is 89 where you said 82 — I flag a file when the first non-blank line of
The one correction — and it argues for the PR, not against it
That is true of the 7 newly-refusing files. I checked each against every It is not true of the 89 headless ones. Seven of those are enrolled today: So I went and checked the landed content, because a headless block sitting in a landed TU would be a real defect and not a hypothetical one. Depth-tracking the four shadow sources on Every one is an Allman-styled function or record — Why that matters more than the wordingThose four TUs are clean because a human repaired them. The generator on On base the whole function signature is gone, and the shadow declaration is a one-line stub named literally What else I verifiedThe 17 files that stop refusing lose nothingThis was my main worry — a refusal becoming a wrong split is worse than the refusal, and the body lists these only as a table cell. For each of the 17 I reassembled every output part ( Spot-checking two by eye, they are exactly right: // src/func_02043f4c.c
[struct Inner] 'struct Inner { char pad[0xc]; unsigned short id; };'
[struct Node ] 'struct Node { char pad[4]; struct Node* next; struct Inner* inner; };'
body: struct Node* func_02043f4c(struct Node** pp, int key, struct Node* alt) {And the reason they refused before is the interesting part: The elaborated-return-type heuristic has no false positive in this tree
That file is in neither of my two difference sets, so its split is unchanged base vs branch. The heuristic is empirically safe on the tree as it stands. Worth knowing it is a shape-based guess, not a parse — if a The fail-loud backstop catches the residual case I went looking for
struct Obj // count; here
{
int a;
};I built that input to see whether it slips through silently. It does not: The bare-brace refusal catches it and names the line. No instance of that shape exists in the tree today, and when one appears it fails loudly instead of quietly. That is the right trade, and it is why I am not asking for anything here. Tests51 pass / 3 fail in my scratch tree. All three fail identically against unmodified base — because they shell out against the real working tree and my scratch archive has no Building the cases from the real failing inputs rather than synthetic ones is exactly what I asked for, and asserting VerdictApproved — merging. Tools-only, no Please fix the body forward when convenient — "none of the affected files is enrolled" should read "none of the seven newly-refusing files is enrolled; seven of the repaired-headless files are, in four |
…oted path (#2073) `dead references` is red on main. tools/test_tubuild.py, landed in #2072, cites `src/func_ov006_020f8224.c` as the real-world input its Allman-brace case was built from. #2071 promoted that file into the ov006/dScMgMCarlo_c TU an hour later, so the path is gone and the prose reference dangles. Neither PR could see it. #2072's dead-references run happened while the file still existed; #2071's premerge_check ran against a merge tree that predated #2072. The two are individually green and red in combination -- the merge-tree hazard, one step removed. Fix names the input by symbol instead of by path. `func_ov006_020f8224` survives both promotion and rename, and check_dead_references reads only `a/b`-shaped path tokens, so a symbol cannot dangle. The docstring says why, so the path does not get helpfully restored later. Not bundled, deliberately: `--update` would also drop the now-stale `src/game/actors` baseline entry. That is real but unrelated cleanup in a tracked config file, and main is red now.
Tools-only, off
origin/main. Nosrc/, noconfig/, no generated state — so nothing here can move a byte of the ROM. (The validator restores all oftools/from base, which is exactly why this cannot ride along with a promotion PR.)The bug
tubuild.split_legacy_sourcedecided where a local declaration ended by counting braces on its first line only:For an Allman-braced record that is
0, so the loop never runs and the block is cut to the bare wordsstruct Obj. Two things then go wrong at once, and #2071's MCarlo TU happened to show both:function_textas a headless{ ... };at file scope — the TU carries an incomplete type and a stray block;_merge_fieldquotes, so theTUBUILD CONFLICTcomment asks a reviewer to compare a full struct against two words.82 legacy sources tree-wide were handed a bare
{as their function body this way — 53 opened bystruct, 31 bytypedef, 5 byclass.Fixing the walk exposed two more shapes:
2. Elaborated return types.
struct dActor_c *dCapEnemy_c::RespawnIfHasCap()opens on a decl keyword, but as an elaborated type specifier on the return type — a spelling the flat-C sources use constantly. It was being filed as a shadow declaration, taking the function's own signature with it. A record head never carries a(before its brace; a parameter list always does, so that is the test (applied to the text before the opening brace, sostruct S { void (*fn)(void); };still reads as a record).3. Definitions inside a wrapper block. When the definition sits inside
namespace X { ... }orextern "C" { ... }, every line of it is consumed as a declaration and nothing is left to be the body. tubuild has nowhere to put a block-scoped member, so refusing is correct (plan sec 7.3: an unfitting shape is "assemble this one by hand"). But the old message —— reads as "your file has no function in it" and sends a reader hunting for one. It now names the block and the line that opened it:
Plus a fail-loud backstop for any residual mis-split: a function body never starts on a bare brace, so reaching one is refused rather than emitted.
Measured, base vs this branch, over all 11150 legacy sources
{handed back as the function bodyThe 7 newly-refusing files (
_ZN6Memory8Allocate*,_ZN2GX15SetBankForSubBGEt,func_ov006_021063a0) are exactly the wrapper-block shape: base produced a headless body for them, which is the outcome this PR exists to stop. None of the seven newly-refusing files is enrolled in a TU, so no manifest entry changes shape. An earlier revision of this sentence said "none of the affected files", which overstated it: seven of the headless-repaired files are enrolled, across four TUs —ov002/Enemy(3),ov062/Koopa+KoopaSmall(2),ov004/unit020b0a38(1) andov062/KoopaTheQuick(1). Nothing is damaged, and the correction strengthens the case rather than weakening it: all four aretext-verified, none of theirpromoted_sourcedestinations exists onmain, so none of that text is in the build — and none carries a headless block today only because a human repaired them by hand, which is precisely what this change makes unnecessary. (Measured by running the pre-#2072split_legacy_sourceover the 1246 legacy sources the manifest enrolls.)Tests
Five new cases, built from the real failing inputs rather than synthetic ones, per review feedback that
tools/failure paths here are under-tested:src/func_ov006_020f8224.c— the MCarlostruct Obj, asserted at both ends of the bug: the declaration keeps its body, and the conflict comment carries a real alternate body instead of two words;src/_ZN11dCapEnemy_c15RespawnIfHasCapEv.cppandsrc/func_02041b60.c— elaborated return type, method and free function;src/_ZN6Memory8AllocateEj.cppandsrc/func_ov006_021063a0.cpp— the two wrapper-block refusals, asserting the message names the block;The 2 deselected (
test_verify_reproduces_pilot_1s_7_of_7_and_clean_objisolate,test_compile_report_matches_the_pilots_object_inventory) fail identically on unmodifiedorigin/main— verified by stashing this diff and re-running. Pre-existing, not touched here.🤖 Generated with Claude Code
https://claude.ai/code/session_01QhhAeJwXBnfPp7B5DNjCwh