diff --git a/src/detect.jl b/src/detect.jl index 059f7d4..5f8a1fc 100644 --- a/src/detect.jl +++ b/src/detect.jl @@ -207,6 +207,20 @@ _probe(flag) = :($flag[] || ($flag[] = true)) # marked definition. `record`'s call paths are built out of exactly that. function _instrument(def, flag, src::LineNumberNode) def isa Expr || return nothing + if def.head === :macrocall + # An annotating macro — `@inline` and its neighbours — leaves the body alone, so the probe + # rides inside the definition it wraps and the wrapper is rebuilt around the result. Only + # those reach here: `_subject` marks a macrocall instrumentable exactly when the macro is + # in `_ANNOTATING_MACROS`, and refuses or opts out of every other one. + # + # Returning `nothing` here instead — which is what this did — did not merely lose the + # observation. The flag is registered either way, so `@experimental "…" @inline f(x) = x` + # counted as an observable definition that no call could ever set: `entered` reported it + # as not entered no matter what ran, and `unverified` reported it forever. + inner = _instrument(def.args[end], flag, src) + inner === nothing && return nothing + return Expr(:macrocall, def.args[1:(end - 1)]..., inner) + end (def.head === :function || def.head === :(=)) || return nothing _is_signature(def.args[1]) || return nothing length(def.args) == 2 || return nothing diff --git a/src/mark.jl b/src/mark.jl index 94ce547..de65b76 100644 --- a/src/mark.jl +++ b/src/mark.jl @@ -259,9 +259,10 @@ The difference shows up in three places: [`reach`](@ref) only reports the marked # What gets observed A definition **with a body** — `function` and short-form `f(x) = …`, including parametric, -callable-object and return-type-annotated signatures — also gets a flag in that body, so -[`entered`](@ref) can report whether the run went through it. The flag is one short-circuit read: -`1.03x` on one thread and `0.985x` on eight, measured over 10M calls of a numeric body. +callable-object and return-type-annotated signatures, and those wrapped in an annotating macro +such as `@inline` — also gets a flag in that body, so [`entered`](@ref) can report whether the run +went through it. The flag is one short-circuit read: `1.03x` on one thread and `0.985x` on eight, +measured over 10M calls of a numeric body. Every other form is a **declaration only** — recorded, queryable and audited, but not observed: @@ -272,8 +273,9 @@ Every other form is a **declaration only** — recorded, queryable and audited, | a name list (`@experimental "…" a b c`) | no — those definitions are elsewhere and already compiled | | `struct`, `abstract type`, `primitive type`, `const`, assignment | no — nothing is *entered* | | `macro` | no | +| `@inline`, `@noinline`, `@propagate_inbounds`, `@assume_effects`, `@constprop`, `@nospecializeinfer` | yes — they annotate a definition and leave its body alone, so the flag rides inside | | `@generated function` | no — the body returns an expression, so a probe there would be generated rather than run | -| `Base.@kwdef`, `@inline`, `@noinline` and the other pass-through macros | no | +| `Base.@kwdef` | no — it wraps a `struct`, which has no body | One flag per marked **name**, not per method: flags are name-keyed, so two methods of a marked name share one. diff --git a/test/spec/README.md b/test/spec/README.md index ef39068..dd060db 100644 --- a/test/spec/README.md +++ b/test/spec/README.md @@ -49,13 +49,13 @@ that is entirely `@test_broken` is a claim written down, not a check being run. | `test_spec_dispatch.jl` | 15 | 15 | 0 | one call site, several methods, only some marked — the branch | | `test_spec_docstring.jl` | 9 | 9 | 0 | a mark and a docstring are different accounts and must coexist | | `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_forms.jl` | 28 | 28 | 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` | 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_verify.jl` | 9 | 9 | 0 | how well is a marked thing exercised by the tests | -| **10 files** | **182** | **182** | **0** | | +| **10 files** | **186** | **186** | **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_forms.jl b/test/spec/test_spec_forms.jl index 8fc408f..4dad829 100644 --- a/test/spec/test_spec_forms.jl +++ b/test/spec/test_spec_forms.jl @@ -159,14 +159,51 @@ end end @testset "@inline and the mark compose in both orders" begin - @test begin - @eval module InlineMarked - using ExperimentalAPI - @experimental "kernel unverified" @inline f(x) = x - @inline @experimental "kernel unverified" g(x) = x - end - Set([mk.name for mk in experimental(Main.InlineMarked)]) == Set([:f, :g]) + # "Compose" was asserted as `Set([:f, :g])` — the names are marked — and that is satisfied by + # a mark that can never fire. Measured: the two orders did NOT compose the same way. With the + # mark outside, `_instrument` saw a `:macrocall` and returned `nothing`, so the flag was + # registered and nothing ever set it; with the mark inside, the flag worked. `entered` said + # `[:g]` after calling both. An `@inline` kernel is exactly what this package is for, so the + # claim has to be about the observation, not about the name. + @eval module InlineMarked + using ExperimentalAPI + @experimental "kernel unverified" @inline f(x) = x + @inline @experimental "kernel unverified" g(x) = x + end + @test Set([mk.name for mk in experimental(Main.InlineMarked)]) == Set([:f, :g]) + @test Set(p.name for p in ExperimentalAPI.probes(Main.InlineMarked)) == Set([:f, :g]) + Main.InlineMarked.f(1) + Main.InlineMarked.g(1) + @test Set(e.name for e in ExperimentalAPI.entered(Main.InlineMarked)) == Set([:f, :g]) +end + +@testset "every annotating macro carries the flag, and the opaque ones still do not" begin + # The control the testset above cannot be on its own: a fix that instrumented EVERY macrocall + # would satisfy it while putting a probe inside `@generated`'s returned expression, where it + # is generated rather than run. The split between the two lists is the claim. + @eval module Annotated + using ExperimentalAPI + @experimental "a" @inline a(x) = x + @experimental "b" @noinline b(x) = x + @experimental "c" Base.@propagate_inbounds c(x) = x + @experimental "d" Base.@assume_effects :terminates_locally d(x) = x + @experimental "e" @generated e(x) = :(x) + @experimental "f" Base.@kwdef struct Opaque + n::Int = 1 + end end + for n in (:a, :b, :c, :d) + Core.eval(Main.Annotated, :($n(1))) + end + Main.Annotated.e(1) + Main.Annotated.Opaque() + @test Set(e.name for e in ExperimentalAPI.entered(Main.Annotated)) == + Set([:a, :b, :c, :d]) + # …and the two that are not observed are still marked, queryable and audited. + @test Set(mk.name for mk in experimental(Main.Annotated)) == + Set([:a, :b, :c, :d, :e, :Opaque]) + @test Set(p.name for p in ExperimentalAPI.probes(Main.Annotated)) == + Set([:a, :b, :c, :d]) end @testset "a definition produced by @eval can be marked by name" begin @@ -259,6 +296,86 @@ end @test !occursin("mark.jl", msg) end +# Each of these three is refused today, and none of the messages was pinned by anything — measured +# 2026-09-08 by grepping `test/` for their text and finding zero hits. A refusal is half of what +# this macro does: the other half of "cannot guess which name this defines" is saying what to +# write instead, and a message can rot into a bare failure without a single test going red. +@testset "a wrapping macro this cannot read is refused by name, and points at the name list" begin + for (label, body) in ( + "another @experimental" => """@experimental "outer" @experimental "inner" f(x) = x""", + "an unknown macro" => """@experimental "why" @assert true""", + ) + @testset "$label" begin + e = try + include_string( + Main, + "module Wrap_$(hash(label))\nusing ExperimentalAPI\n$body\nend", + "wrap.jl", + ) + nothing + catch err + err + end + # Unwrapped in a loop, not once: a macro that throws while expanding a `module` body + # passed through `include_string` comes back wrapped TWICE, and a single `.error` + # leaves a `LoadError` that reads exactly like the failure it is hiding. + while e isa LoadError + e = e.error + end + @test e isa ArgumentError + msg = sprint(showerror, e) + # Names the macro it could not read, so the author knows which line to change… + @test occursin("@", msg) + # …and the form that always works, which is what makes it actionable. + @test occursin("@experimental \"why\" the_name", msg) + end + end +end + +@testset "a block of definitions is refused rather than half-marked" begin + # The dangerous silence: `begin f(x)=x; g(x)=x end` has two names and the macro can only + # record one. Marking the first and dropping the second would be a covenant that omits a + # definition without saying so. + e = try + @eval module BlockSubject + using ExperimentalAPI + @experimental "why" begin + f(x) = x + g(x) = x + end + end + nothing + catch err + err isa LoadError ? err.error : err + end + @test e isa ArgumentError + msg = sprint(showerror, e) + @test occursin("block", msg) + @test occursin("the_name", msg) +end + +@testset "a bare qualified name is refused, because the module it names is not ours to mark" begin + # `@experimental "why" Sub.g` reads as marking somebody else's `g`. The name list records into + # the module the macro ran in, so accepting it would file the mark in the wrong registry. + e = try + @eval module QualifiedBare + using ExperimentalAPI + module Sub + g(x) = x + end + @experimental "why" Sub.g + end + nothing + catch err + err isa LoadError ? err.error : err + end + @test e isa ArgumentError + msg = sprint(showerror, e) + @test occursin("Sub.g", msg) # the expression the author wrote, not a generic complaint + @test occursin("WHICH method", msg) # why a bare qualified name is not enough… + @test occursin("Sub.g(::", msg) # …and the form that is, spelled out with their own name +end + # ── metadata ───────────────────────────────────────────────────────────────────────────────── @testset "since must be a version, and the refusal must say so" begin