diff --git a/src/reach.jl b/src/reach.jl index 8280e3e..32956fd 100644 --- a/src/reach.jl +++ b/src/reach.jl @@ -760,7 +760,15 @@ function _callee_name(ci, @nospecialize(x)) v isa Type && return nameof(v) end w = _widen(t === nothing ? Any : t) - w isa DataType && isdefined(w, :instance) && return nameof(w.instance) + if w isa DataType && isdefined(w, :instance) + # `nameof` accepts a `Function`, a `Type` or a `Module` and nothing else. A struct whose + # fields are all singletons is itself a singleton, so `w.instance` exists for callables + # that are none of the three — `Base.MappingRF{…}`, which is what `sum(f(x) for x in xs)` + # lowers to. Asking that for a name threw a `MethodError` out of an analysis whose entire + # contract is to come back with one of three verdicts. + inst = w.instance + inst isa Union{Function,Type,Module} && return nameof(inst) + end return :? end diff --git a/test/spec/README.md b/test/spec/README.md index dd060db..0ed6ca0 100644 --- a/test/spec/README.md +++ b/test/spec/README.md @@ -53,9 +53,9 @@ that is entirely `@test_broken` is a claim written down, not a check being run. | `test_spec_integration.jl` | 19 | 19 | 0 | where the mark has to surface: docs, Aqua, releases, provenance, CI | | `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` | 45 | 45 | 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_propagate.jl` | 21 | 21 | 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** | **186** | **186** | **0** | | +| **10 files** | **187** | **187** | **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_propagate.jl b/test/spec/test_spec_propagate.jl index d49043f..4d63cd2 100644 --- a/test/spec/test_spec_propagate.jl +++ b/test/spec/test_spec_propagate.jl @@ -35,7 +35,12 @@ public unstable, CONSTANT_BAD, top_uses_const, MarkedStruct, - top_constructs + top_constructs, + gen_bad, + gen_good, + comp_bad, + map_bad, + loop_good @experimental "convergence is not established below β ≈ 0.1" unstable(x::Float64) = x * 1.0000001 @@ -89,6 +94,20 @@ top_uses_const(x::Float64) = x + CONSTANT_BAD end top_constructs(x::Float64) = MarkedStruct(x).v +# Higher-order shapes. `sum(f(x) for x in xs)` is the idiom this package's own docstrings use, and +# it reaches the marked definition through a closure the caller never names. +gen_bad(xs::Vector{Float64}) = sum(unstable(x) for x in xs) +gen_good(xs::Vector{Float64}) = sum(solid(x) for x in xs) +comp_bad(xs::Vector{Float64}) = [unstable(x) for x in xs] +map_bad(xs::Vector{Float64}) = sum(map(unstable, xs)) +loop_good(xs::Vector{Float64}) = ( + t=0.0; + for x in xs + t += solid(x) + end; + t +) + end # module Chain const ENTRY = Tuple{Float64} @@ -199,6 +218,28 @@ end # ── termination ────────────────────────────────────────────────────────────────────────────── +@testset "a generator argument is answered, not thrown out of" begin + # `sum(f(x) for x in xs)` lowers to a `Base.MappingRF` whose two fields are both singletons, + # which makes the STRUCT a singleton — so `w.instance` exists for a callable that is neither a + # `Function` nor a `Type`. `nameof` has no method for that, and the analysis died with a + # `MethodError` instead of returning one of its three verdicts. Measured on the shape this + # package's own `@entered` docstring uses as its worked example. + # + # A throw is not a fourth verdict. `:unknown` is what "could not resolve this" is for. + for (f, want) in ( + (Chain.gen_bad, :depends), + (Chain.gen_good, :clean), + (Chain.comp_bad, :depends), + (Chain.map_bad, :depends), + (Chain.loop_good, :clean), + ) + @testset "$(nameof(f))" begin + r = ExperimentalAPI.reach(f, Tuple{Vector{Float64}}) + @test ExperimentalAPI.verdict(r) === want + end + end +end + @testset "self-recursion terminates and still finds the mark" begin @test ExperimentalAPI.verdict( ExperimentalAPI.reach(Chain.top_recursive, Tuple{Int,Float64})