Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/detect.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions src/mark.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions test/spec/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** | |
<!-- END GENERATED -->

The table is generated and pinned by `test/test_spec_table.jl`, which fails if it goes stale —
Expand Down
131 changes: 124 additions & 7 deletions test/spec/test_spec_forms.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading