Problem
The golden test harness compiles a runnable module (one with an eval/golden.txt) with a different link mode for its optimization phase than for its code-generation phase, and no dead-code-elimination (DCE) pass bridges the two. As a result, the committed golden.lua of a runnable module can contain dead top-level bindings that the real compiler (Backend.compileModules) would eliminate. The golden therefore misrepresents the compiler's actual output for such modules. This is a test-harness fidelity gap, not a compiler correctness bug: the production output is correct.
Why it matters:
- Runnable golden
.lua files can carry dead code that the real compiler never emits, so they misrepresent production output for exactly the modules that also run end-to-end.
- A DCE regression could be masked for application-linked modules: because the harness always optimizes
AsModule, a binding that should be pruned from an application but is not would still show up in the golden either way, so the golden could not distinguish the two.
This is currently latent in the committed corpus: the accompanying change padded DirectiveAccessor with subtraction (which no rewrite coalesces) specifically so its over-budget premise — and thus the whole test — survives constant-chain reassociation. The gap is structural and will resurface whenever an optimizer improvement folds away a binding's last use site in a runnable golden.
Background
"Linking" here means Language.PureScript.Backend.IR.Linker.makeUberModule, which flattens all reachable modules into one UberModule. It has two modes: LinkAsModule, where every top-level binding is an export (an external consumer may call any of them, so all are roots for DCE), and LinkAsApplication moduleName entry, where only the named entry point (e.g. main) is a root and everything it does not transitively reach is dead. DCE runs inside the IR optimizer (optimizedUberModule), pruning bindings that are not reachable from the uber-module's export set.
Production compilation, in Backend.compileModules (lib/Language/PureScript/Backend.hs), links once in the final mode and optimizes that:
let linkedModule = Linker.makeUberModule (linkerMode appOrModule) irModules
...
uberModule <- ... optimizedUberModule dataDecls liftedModule ...
chunk <- Lua.fromUberModule foreignDir needsRuntimeLazy appOrModule uberModule
When building an application, linkerMode appOrModule is LinkAsApplication, so DCE runs with main as the sole root and removes anything main no longer reaches.
The golden harness (test/Language/PureScript/Backend/Lua/Golden/Spec.hs) does not do this. compileCorefn always links LinkAsModule and runs the optimizer there:
let uberModule = Linker.makeUberModule (LinkAsModule uberModuleName) modules
...
optimized <- ... optimizedUberModuleChecked dataDecls liftedModule ...
so DCE keys off the module export set, where every top-level binding is a root and nothing is pruned. Only later, at code generation, does the harness pick the real mode — and only for the .lua golden of a runnable module:
appOrModule <-
doesFileExist evalGolden <&> \case
True -> AsApplication moduleName (PS.Ident "main")
False -> AsModule moduleName
cfn <- compileCorefn ... -- always AsModule-optimized
compileIr appOrModule lua51Limits cfn -- re-linked AsApplication only now
compileIr re-links AsApplication for code generation but does not re-run the optimizer, so the entry-point-driven DCE that production performs never happens. Any binding that became dead during optimization — because the optimizer folded away its last remaining use site — survives into the generated Lua.
Approach
Make the harness's .lua golden path optimize in the same mode it generates code for, matching Backend.compileModules. Either link compileCorefn in the real appOrModule mode for the .lua golden (keeping the always-AsModule link only for the .ir golden, which deliberately shows full module shape), or route the .lua golden through the production compileModules pipeline instead of the harness's re-implemented compileCorefn + compileIr. The latter also closes the standing "the golden harness re-implements the IR pipeline" fragility called out in CLAUDE.md.
Prerequisites / Relations
Independent — no issue must land first, and this blocks no other issue. Surfaced by the constant-chain reassociation work (#235, merged): that change had to pad DirectiveAccessor with subtraction to keep the leak from becoming a committed golden. Related to the standing "the golden harness re-implements the IR pipeline" note in CLAUDE.md (Testing Strategy) — the second fix direction in Approach would retire that re-implementation.
Reproduction
The Golden/DirectiveAccessor module has an eval/golden.txt, so the harness generates its .lua golden AsApplication with entry main. Its mkOps builds a four-method dictionary padded to sit over the inline-size budget; main's last statement is (mkOps 2).mul 3 4. Pad mkOps's methods with added constant chains (… + 10 + 20 + … + 60):
mkOps n =
{ add: \a b -> a + b + n + 10 + 20 + 30 + 40 + 50 + 60
, sub: \a b -> a - b + n + 10 + 20 + 30 + 40 + 50 + 60
, mul: \a b -> a * b + n + 10 + 20 + 30 + 40 + 50 + 60
, divide: \a b -> a * 2 + b * 3 + n + 10 + 20 + 30 + 40
}
With constant-chain reassociation in the optimizer (folding + 10 + 20 + … + 60 to + 210), each method shrinks below the inline-size budget, so mkOps gets pasted at the undirected (mkOps 2).mul site, the projection resolves, and the whole expression constant-folds to 224. That was mkOps's only surviving reference, so mkOps is now dead.
Harness actual.lua (link-as-module optimize, then link-as-application codegen):
M.Golden_DirectiveAccessor_Test_mkOps = function(n)
return {
add = function(a) return function(b) return a + b + n + 210 end end,
sub = function(a0) return function(b0) return a0 - b0 + n + 210 end end,
mul = function(a1) return function(b1) return a1 * b1 + n + 210 end end,
divide = function(a2)
return function(b2) return a2 * 2 + b2 * 3 + n + 100 end
end
}
end
return (function()
local _S_cse357 = Golden_DirectiveAccessor_Test_ops.mul
local _ = Effect_Console_log(Data_Show_showIntImpl(_S_cse357(6)(7)))()
local _ = Effect_Console_log(Data_Show_showIntImpl(_S_cse357(2)(3)))()
local _ = Effect_Console_log(Data_Show_showIntImpl(252))()
return Effect_Console_log(Data_Show_showIntImpl(224))()
end)()
mkOps is emitted in full, yet nothing references it.
Running the production compiler on the same CoreFn as an application removes it:
$ pslua --ps-output ./output --foreign-path ./foreign \
--entry Golden.DirectiveAccessor.Test.main --lua-output-file /tmp/app.lua
$ grep -c mkOps /tmp/app.lua
0
The main body of /tmp/app.lua is identical to the harness output above (same 252 / 224 folds); only the dead mkOps binding differs — present in the golden, absent in real output.
Verification / Measurement
The fix is confirmed when, for a runnable golden module whose optimizer folds away a binding's last use site, the harness-emitted .lua golden matches the production compiler's output — i.e. contains no top-level binding unreachable from the entry point. Concretely, reuse the reproduction: with DirectiveAccessor padded by addition (so mkOps becomes dead after reassociation), the regenerated golden.lua must contain zero mkOps references, matching grep -c mkOps on pslua --entry Golden.DirectiveAccessor.Test.main output (0). A cheap standing guard: assert that every runnable golden's .lua has no top-level binding absent from the corresponding production --entry build. The .ir golden (deliberately AsModule) and all eval oracles stay unchanged.
Problem
The golden test harness compiles a runnable module (one with an
eval/golden.txt) with a different link mode for its optimization phase than for its code-generation phase, and no dead-code-elimination (DCE) pass bridges the two. As a result, the committedgolden.luaof a runnable module can contain dead top-level bindings that the real compiler (Backend.compileModules) would eliminate. The golden therefore misrepresents the compiler's actual output for such modules. This is a test-harness fidelity gap, not a compiler correctness bug: the production output is correct.Why it matters:
.luafiles can carry dead code that the real compiler never emits, so they misrepresent production output for exactly the modules that also run end-to-end.AsModule, a binding that should be pruned from an application but is not would still show up in the golden either way, so the golden could not distinguish the two.This is currently latent in the committed corpus: the accompanying change padded
DirectiveAccessorwith subtraction (which no rewrite coalesces) specifically so its over-budget premise — and thus the whole test — survives constant-chain reassociation. The gap is structural and will resurface whenever an optimizer improvement folds away a binding's last use site in a runnable golden.Background
"Linking" here means
Language.PureScript.Backend.IR.Linker.makeUberModule, which flattens all reachable modules into oneUberModule. It has two modes:LinkAsModule, where every top-level binding is an export (an external consumer may call any of them, so all are roots for DCE), andLinkAsApplication moduleName entry, where only the named entry point (e.g.main) is a root and everything it does not transitively reach is dead. DCE runs inside the IR optimizer (optimizedUberModule), pruning bindings that are not reachable from the uber-module's export set.Production compilation, in
Backend.compileModules(lib/Language/PureScript/Backend.hs), links once in the final mode and optimizes that:When building an application,
linkerMode appOrModuleisLinkAsApplication, so DCE runs withmainas the sole root and removes anythingmainno longer reaches.The golden harness (
test/Language/PureScript/Backend/Lua/Golden/Spec.hs) does not do this.compileCorefnalways linksLinkAsModuleand runs the optimizer there:so DCE keys off the module export set, where every top-level binding is a root and nothing is pruned. Only later, at code generation, does the harness pick the real mode — and only for the
.luagolden of a runnable module:compileIrre-linksAsApplicationfor code generation but does not re-run the optimizer, so the entry-point-driven DCE that production performs never happens. Any binding that became dead during optimization — because the optimizer folded away its last remaining use site — survives into the generated Lua.Approach
Make the harness's
.luagolden path optimize in the same mode it generates code for, matchingBackend.compileModules. Either linkcompileCorefnin the realappOrModulemode for the.luagolden (keeping the always-AsModulelink only for the.irgolden, which deliberately shows full module shape), or route the.luagolden through the productioncompileModulespipeline instead of the harness's re-implementedcompileCorefn+compileIr. The latter also closes the standing "the golden harness re-implements the IR pipeline" fragility called out inCLAUDE.md.Prerequisites / Relations
Independent — no issue must land first, and this blocks no other issue. Surfaced by the constant-chain reassociation work (#235, merged): that change had to pad
DirectiveAccessorwith subtraction to keep the leak from becoming a committed golden. Related to the standing "the golden harness re-implements the IR pipeline" note inCLAUDE.md(Testing Strategy) — the second fix direction in Approach would retire that re-implementation.Reproduction
The
Golden/DirectiveAccessormodule has aneval/golden.txt, so the harness generates its.luagoldenAsApplicationwith entrymain. ItsmkOpsbuilds a four-method dictionary padded to sit over the inline-size budget;main's last statement is(mkOps 2).mul 3 4. PadmkOps's methods with added constant chains (… + 10 + 20 + … + 60):mkOps n = { add: \a b -> a + b + n + 10 + 20 + 30 + 40 + 50 + 60 , sub: \a b -> a - b + n + 10 + 20 + 30 + 40 + 50 + 60 , mul: \a b -> a * b + n + 10 + 20 + 30 + 40 + 50 + 60 , divide: \a b -> a * 2 + b * 3 + n + 10 + 20 + 30 + 40 }With constant-chain reassociation in the optimizer (folding
+ 10 + 20 + … + 60to+ 210), each method shrinks below the inline-size budget, somkOpsgets pasted at the undirected(mkOps 2).mulsite, the projection resolves, and the whole expression constant-folds to224. That wasmkOps's only surviving reference, somkOpsis now dead.Harness
actual.lua(link-as-module optimize, then link-as-application codegen):mkOpsis emitted in full, yet nothing references it.Running the production compiler on the same CoreFn as an application removes it:
The
mainbody of/tmp/app.luais identical to the harness output above (same252/224folds); only the deadmkOpsbinding differs — present in the golden, absent in real output.Verification / Measurement
The fix is confirmed when, for a runnable golden module whose optimizer folds away a binding's last use site, the harness-emitted
.luagolden matches the production compiler's output — i.e. contains no top-level binding unreachable from the entry point. Concretely, reuse the reproduction: withDirectiveAccessorpadded by addition (somkOpsbecomes dead after reassociation), the regeneratedgolden.luamust contain zeromkOpsreferences, matchinggrep -c mkOpsonpslua --entry Golden.DirectiveAccessor.Test.mainoutput (0). A cheap standing guard: assert that every runnable golden's.luahas no top-level binding absent from the corresponding production--entrybuild. The.irgolden (deliberatelyAsModule) and all eval oracles stay unchanged.