From 1ad191859fbf990edd3975f36c1b3fb99e0c0156 Mon Sep 17 00:00:00 2001 From: sotashimozono Date: Sat, 5 Sep 2026 09:19:37 +0000 Subject: [PATCH 1/2] fix: reach_script read a binding younger than the frame reading it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `reach_script` evaluates a script's top-level `const` lines into a scratch module and then analyses a thunk that names them. The walk reads globals out of the IR, so it reaches a binding created after its own caller was entered — and Julia 1.12 says what that is: WARNING: Detected access to binding `ReachScript_figure.jl.RESULT` in a world prior to its definition world. !!! This code will error in future versions of Julia. Two per full suite run, scrolling past a green suite. `Base.invokelatest` at the boundary is the fix Julia's own hint names. The test that pins it cost two attempts, and both failures are worth recording because each produced a test that could not fail: * It has to call `reach_script` from inside a **function**. A caller's world age is fixed when it is entered; at top level it moves with every statement, and the first version of this test passed against the unfixed package because of that. * The child must run with the **default** `depwarn`. Measured on 1.12.2: `--depwarn=error` *suppresses* this warning rather than promoting it — the opposite of what the warning's own hint says — so the second version passed for the same reason as the first. Verified in both directions: the test fails against the unfixed `reach.jl` with the warning text in the child's stderr, and passes with it. Co-Authored-By: Claude Opus 5 --- src/reach.jl | 7 ++++- test/spec/test_spec_lifecycle.jl | 47 ++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/reach.jl b/src/reach.jl index a3f1be9..cb9b6ac 100644 --- a/src/reach.jl +++ b/src/reach.jl @@ -343,7 +343,12 @@ function reach_script( end end thunk = Core.eval(scratch, Expr(:function, Expr(:call, gensym(:script)), body)) - r = reach(thunk, Tuple{}; maxdepth, maxcandidates, ignore) + # `invokelatest`, because the walk reads the bindings the script's own `const` lines were just + # evaluated into and this call's world age was fixed before they existed. Julia 1.12 warns — + # "Detected access to binding … in a world prior to its definition world" — and says it will + # be an error in a future version. The analysis reads globals out of the IR, which is what + # makes this the one place in the package that reaches a binding younger than its caller. + r = Base.invokelatest(reach, thunk, Tuple{}; maxdepth, maxcandidates, ignore) return Reach( path, r.reached, diff --git a/test/spec/test_spec_lifecycle.jl b/test/spec/test_spec_lifecycle.jl index 0bc5f99..72cbabc 100644 --- a/test/spec/test_spec_lifecycle.jl +++ b/test/spec/test_spec_lifecycle.jl @@ -91,6 +91,53 @@ end @test ExperimentalAPI.verdict(ExperimentalAPI.reach(Main.CleanModule)) === :clean end +# Run in a child process and read its stderr, because the failure it guards is a WARNING today and +# an error later: reading a binding in a world prior to its definition world. `reach_script` +# evaluates a script's `const` lines into a scratch module and then analyses a thunk that names +# them, and the walk reads globals out of the IR — so it is the one place in this package that +# reaches a binding younger than its own caller. +# +# Two things about the child are load bearing, and the first cost a test that could not fail. +# `reach_script` is called from inside a FUNCTION: a caller's world age is fixed when it is +# entered, and at top level it moves with every statement, so nothing is caught there. And the +# child runs with the DEFAULT `depwarn`: measured on 1.12.2, `--depwarn=error` *suppresses* this +# warning rather than promoting it, which is the opposite of what Julia's own hint says. +const _WORLD_AGE_SCRIPT = """ +using ExperimentalAPI +module L +using ExperimentalAPI +public marked, settled +@experimental "why" marked(x::Float64) = x * 1.0000001 +"Settled." +settled(x::Float64) = x +end +# Called from inside a FUNCTION, which is the whole point: a caller's world age is fixed when it +# is entered, so the bindings `reach_script` creates while it runs are younger than the frame +# reading them. At top level the world age moves with each statement and nothing is caught. +function probe() + dir = mktempdir() + path = joinpath(dir, "figure.jl") + write(path, "using Main: L\nconst RESULT = L.marked(0.5)\nRESULT\n") + r = ExperimentalAPI.reach_script(path) + ExperimentalAPI.verdict(r) === :depends || error("expected :depends, got \$(ExperimentalAPI.verdict(r))") + return nothing +end +probe() +print("OK") +""" + +@testset "reach_script does not read a binding younger than its caller" begin + cmd = `$(Base.julia_cmd()) --startup-file=no --project=$(Base.active_project()) -e $_WORLD_AGE_SCRIPT` + out = IOBuffer() + ok = success(pipeline(ignorestatus(cmd); stdout=out, stderr=out)) + text = String(take!(out)) + # Non-vacuity: the child really did the analysis, so a child that exited early for an + # unrelated reason cannot pass this. + @test occursin("OK", text) + @test ok + @test !occursin("world prior to its definition", text) +end + @testset "a script can be the entry point" begin # The shape a researcher has: a file that produces a figure, not a package. The file must be # written — `tempname()` alone throws regardless of the implementation. From 02c82f1e9c073df8d626d87e13cdb46037282857 Mon Sep 17 00:00:00 2001 From: sotashimozono Date: Sat, 5 Sep 2026 09:23:32 +0000 Subject: [PATCH 2/2] test: regenerate the spec table for the testset this branch adds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test/spec/test_spec_lifecycle.jl` gained the world-age testset, so its behaviour count moved 15 → 16 and the total 178 → 179. The table in `test/spec/README.md` is generated and pinned by `test/test_spec_table.jl`, which is what caught this — the hand-written version drifted inside the change that introduced it, which is why it is pinned at all. Co-Authored-By: Claude Opus 5 --- test/spec/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/spec/README.md b/test/spec/README.md index c517726..461f87e 100644 --- a/test/spec/README.md +++ b/test/spec/README.md @@ -51,11 +51,11 @@ that is entirely `@test_broken` is a claim written down, not a check being run. | `test_spec_foreign.jl` | 13 | 13 | 0 | marking a method on somebody else's generic — the `QAtlas.fetch` case | | `test_spec_forms.jl` | 24 | 24 | 0 | the definition forms a real package hits on its second afternoon | | `test_spec_integration.jl` | 19 | 19 | 0 | where the mark has to surface: docs, Aqua, releases, provenance, CI | -| `test_spec_lifecycle.jl` | 15 | 15 | 0 | the mark's EXIT, and an entry point that is a module rather than a function | +| `test_spec_lifecycle.jl` | 16 | 16 | 0 | the mark's EXIT, and an entry point that is a module rather than a function | | `test_spec_profile.jl` | 42 | 42 | 0 | what a real run went through, how often, and how much of it | | `test_spec_propagate.jl` | 20 | 20 | 0 | a caller that never names a marked thing still depends on it | | `test_spec_verify.jl` | 9 | 9 | 0 | how well is a marked thing exercised by the tests | -| **10 files** | **178** | **178** | **0** | | +| **10 files** | **179** | **179** | **0** | | The table is generated and pinned by `test/test_spec_table.jl`, which fails if it goes stale —