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/README.md b/test/spec/README.md index 4bbbe17..d3bce0e 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` | 43 | 43 | 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** | **179** | **179** | **0** | | +| **10 files** | **180** | **180** | **0** | | The table is generated and pinned by `test/test_spec_table.jl`, which fails if it goes stale — 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.