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
4 changes: 4 additions & 0 deletions docs/make.jl
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ makedocs(;
edit_link="main",
),
modules=[ExperimentalAPI],
# A `jldoctest` whose output stops matching fails the build. The message in `declaring.md`
# had drifted from the one the macro throws — it named the signature the macro never sees —
# which is what this catches.
doctest=true,
# `:public` is the same claim this package makes about its users: a name on the public
# surface without a docstring fails the build. ExperimentalAPI's own release layer is
# declared @experimental AND documented, so it satisfies both.
Expand Down
2 changes: 1 addition & 1 deletion docs/src/adopting.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ and no idea which of them anybody meant.
```julia
julia> using MyPackage, ExperimentalAPI

julia> audit(MyPackage)
julia> ExperimentalAPI.audit(MyPackage)
```

Two numbers matter. `undocumented` is the backlog. `dangling` should be zero on day one, because
Expand Down
14 changes: 7 additions & 7 deletions docs/src/analysing.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ CurrentModule = ExperimentalAPI
*could* do — before running it, and including through code that never names the marked thing.

```julia
julia> r = reach(analyse, Tuple{Model,Float64});
julia> r = ExperimentalAPI.reach(analyse, Tuple{Model,Float64});

julia> verdict(r)
julia> ExperimentalAPI.verdict(r)
:depends

julia> r.reached
1-element Vector{ExperimentalAPI.Reached}:
Reached(MyModel.energy via analyse → sweep → inner → energy)
ExperimentalAPI.Reached(MyModel.energy via analyse → sweep → inner → energy)
```

The model is Lean's `sorry`: a proof that uses one is not a proof, however many layers down it
Expand Down Expand Up @@ -79,8 +79,8 @@ A more specific unmarked method shadowing a marked one is resolved as what actua
## Whole modules, and scripts

```julia
r = reach(MyPackage)
verdict(r) # one answer for the package
r = ExperimentalAPI.reach(MyPackage)
ExperimentalAPI.verdict(r) # one answer for the package
r.affected_entries # …and which public entry points are not clean
```

Expand All @@ -95,8 +95,8 @@ evaluated in a scratch module, because the analysis has to resolve the names the
## The exit, read backwards

```julia
dependents(MyPackage, :energy) # who reaches it
verdict(reach(MyPackage; ignore = [:energy])) # what removing the mark would change
ExperimentalAPI.dependents(MyPackage, :energy) # who reaches it
ExperimentalAPI.verdict(ExperimentalAPI.reach(MyPackage; ignore = [:energy])) # what removing the mark would change
```

`ignore` answers "what would removing this mark change?" without removing it. [`dependents`](@ref)
Expand Down
12 changes: 6 additions & 6 deletions docs/src/checking.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ A mark says the shape is unsettled. That is never a reason to say nothing about
does, so a marked name with no prose is a finding exactly as an unmarked one is.

```julia
julia> audit(Archeion)
julia> ExperimentalAPI.audit(Archeion)
Public surface of Archeion — 36 names
documented 33
experimental 0
Expand Down Expand Up @@ -111,12 +111,12 @@ see exactly which names it would have to argue about. Empty means the two agree.
## The method-level half

```julia
julia> audit(Downstream).contributed_methods
julia> ExperimentalAPI.audit(Downstream).contributed_methods
4-element Vector{Method}:
fetch_value(::Ising, ::Energy) …

julia> unaccounted_methods(Downstream) # neither a docstring nor a mark
julia> ExperimentalAPI.unaccounted_methods(Downstream) # neither a docstring nor a mark
2-element Vector{Method}:
```
Expand All @@ -139,11 +139,11 @@ The mark records where it was written, and `--code-coverage` records a count per
two answers the worst case a marked definition can be in:

```julia
julia> unverified(MyPackage) # marked AND never executed by the suite
julia> ExperimentalAPI.unverified(MyPackage) # marked AND never executed by the suite
1-element Vector{ExperimentalAPI.Mark}:
Mark(MyPackage.never_called, "shipped without ever being called")
ExperimentalAPI.Mark(MyPackage.never_called, "shipped without ever being called")

julia> coverage(MyPackage, :half_exercised)
julia> ExperimentalAPI.coverage(MyPackage, :half_exercised)
0.6
```

Expand Down
6 changes: 3 additions & 3 deletions docs/src/declaring.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ odd; they cannot see that it is odd *because* a refactor upstream has not landed
is required, and the mark carries it everywhere the name goes:

```julia
julia> mark(Archeion, :ingest)
julia> ExperimentalAPI.mark(Archeion, :ingest)
Archeion.ingest — experimental
reason: signature will be wrapped once the write-back refactor settles
since: v0.1.4
Expand Down Expand Up @@ -68,7 +68,7 @@ Anything else is **refused with a message naming the alternative**, never guesse

```julia
julia> @experimental "why" Base.sum(x::Int) = x
ERROR: @experimental: `Base.sum(x::Int)` defines a name owned by another module,
ERROR: ArgumentError: @experimental: `Base.sum` defines a name owned by another module,
which is not part of this module's public surface
```

Expand Down Expand Up @@ -117,7 +117,7 @@ end
```

```julia
julia> audit(M).dangling
julia> ExperimentalAPI.audit(M).dangling
1-element Vector{Symbol}:
:helper
```
Expand Down
6 changes: 4 additions & 2 deletions docs/src/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ had not validated.
`@experimental` is that mark, written at the definition site where the author is, and readable by
a machine so the answer arrives without anyone remembering to ask for it.

```julia
```@example frontpage
using ExperimentalAPI

@experimental "convergence not established below β ≈ 0.1" energy(m::Model) = m.β * correction(m)
@experimental "convergence not established below β ≈ 0.1" energy(β) = β * 1.0000001

energy(0.5)
```

```console
Expand Down
31 changes: 28 additions & 3 deletions docs/src/observing.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
```@meta
CurrentModule = ExperimentalAPI
DocTestSetup = quote
using ExperimentalAPI
end
```

# Observing
Expand Down Expand Up @@ -44,7 +47,29 @@ Three properties, each of them a decision:
```julia
julia> ExperimentalAPI.entered()
1-element Vector{ExperimentalAPI.Entry}:
Entry(Main.energy, "convergence not established below β ≈ 0.1")
ExperimentalAPI.Entry(Main.energy, "convergence not established below β ≈ 0.1")
```

The display above is a transcript rather than a doctest on purpose: `Entry` prints its module, and
Documenter evaluates doctests in a sandbox whose module does not print as `Main`, so a doctest here
would show a line no reader ever sees at their own REPL. The fields it is read for do not depend on
where it ran, so those are checked:

```jldoctest
julia> using ExperimentalAPI

julia> @experimental "convergence not established below β ≈ 0.1" energy(β) = β * 1.0000001

julia> energy(0.5);

julia> only(ExperimentalAPI.entered()).name
:energy

julia> only(ExperimentalAPI.entered()).reason
"convergence not established below β ≈ 0.1"

julia> only(ExperimentalAPI.entered()).count === nothing
true
```

A marked definition the run never entered is **absent**, not reported with a count of zero — the
Expand Down Expand Up @@ -92,7 +117,7 @@ table above.
## Recording: counts, paths and time

```julia
r = record() do
r = ExperimentalAPI.record() do
simulate(model; steps = 10_000)
end
```
Expand Down Expand Up @@ -152,7 +177,7 @@ make.
[`assert_clean`](@ref) turns a record into a refusal:

```julia
assert_clean() do
ExperimentalAPI.assert_clean() do
publish(compute(model))
end
```
Expand Down
6 changes: 3 additions & 3 deletions docs/src/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ tracking = "https://github.com/org/MyPackage.jl/issues/12"
On the next release, compare:

```julia
d = compare(read_snapshot("api.toml"), MyPackage)
isbreaking(d) && error("breaking: $(d.removed_stable) removed, $(d.demoted) demoted")
d = ExperimentalAPI.compare(ExperimentalAPI.read_snapshot("api.toml"), MyPackage)
ExperimentalAPI.isbreaking(d) && error("breaking: $(d.removed_stable) removed, $(d.demoted) demoted")
```

## What counts as breaking
Expand Down Expand Up @@ -115,7 +115,7 @@ ratchet, in the shape `test_surface`'s `skip` already has.
## Provenance next to the result

```julia
stamp("figures/energy_sweep.provenance.toml") do
ExperimentalAPI.stamp("figures/energy_sweep.provenance.toml") do
sweep(model; βs = 0.05:0.05:2.0)
end
```
Expand Down
5 changes: 4 additions & 1 deletion src/record.jl
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,10 @@ function record(
end
end
end
err === nothing || rethrow && Base.rethrow(err)
# `Base.rethrow(err)` is legal only inside a `catch`; here it raises
# "rethrow(exc) not allowed outside a catch block" and the caller never sees their own
# exception. `throw` gives a fresh backtrace, which is the price of building the record first.
err === nothing || rethrow && throw(err)

hits = Hit[]
for p in ps
Expand Down
6 changes: 6 additions & 0 deletions src/release.jl
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,12 @@ function stamp(path::AbstractString, f)
return stamp(path, record(f))
end

# `stamp(path) do … end` puts the function first, which is the form the documentation teaches and
# the order `record` already takes. Typed `::Function` so `stamp(path, value)` stays unambiguous.
function stamp(f::Function, path::AbstractString)
return stamp(path, f)
end

function stamp(path::AbstractString, r::Record)
d = Dict{String,Any}(
"generated" => string(Dates_now()),
Expand Down
6 changes: 3 additions & 3 deletions test/spec/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,12 @@ that is entirely `@test_broken` is a claim written down, not a check being run.
| `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_integration.jl` | 18 | 18 | 0 | where the mark has to surface: docs, Aqua, releases, provenance, CI |
| `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_profile.jl` | 41 | 41 | 0 | what a real run went through, how often, and how much of it |
| `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** | **176** | **176** | **0** | |
| **10 files** | **178** | **178** | **0** | |
<!-- END GENERATED -->

The table is generated and pinned by `test/test_spec_table.jl`, which fails if it goes stale —
Expand Down
13 changes: 13 additions & 0 deletions test/spec/test_spec_integration.jl
Original file line number Diff line number Diff line change
Expand Up @@ -291,3 +291,16 @@ end
@test isempty(ExperimentalAPI.stale_since(Shown, v"0.2.0"))
@test ExperimentalAPI.age(Shown, :provisional, v"0.9.0") == 7
end

@testset "stamp takes the do-block form the documentation teaches" begin
# `stamp(path) do … end` passes the function FIRST. Only `stamp(::AbstractString, ::Any)`
# existed, so the documented form raised `MethodError` — the docs taught a call that had no
# method. Typed `::Function` on the new one so `stamp(path, value)` stays unambiguous.
path = ExperimentalAPI.stamp(tempname()) do
1 + 1
end
@test isfile(path)
@test occursin("julia", read(path, String))
# …and the two-argument form still resolves the way it did.
@test ExperimentalAPI.stamp(tempname(), () -> 1 + 1) isa AbstractString
end
17 changes: 17 additions & 0 deletions test/spec/test_spec_profile.jl
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,23 @@ end
@test_throws ErrorException ExperimentalAPI.record(() -> error("boom"))
end

@testset "…and the default propagates the caller's own exception" begin
# `rethrow = true` is the default, and the assertion above it — `@test_throws
# ErrorException` — passed for the wrong reason. `Base.rethrow(err)` outside a `catch` raises
# `ErrorException("rethrow(exc) not allowed outside a catch block")`, which satisfies a check
# on the exception TYPE while the caller never saw their own error. Pin the message.
e = try
ExperimentalAPI.record(() -> error("the caller's own message"))
nothing
catch err
err
end
@test e isa ErrorException
@test occursin("the caller's own message", sprint(showerror, e))
# Control: the message that used to come out instead.
@test !occursin("not allowed outside a catch block", sprint(showerror, e))
end

# ── concurrency and distribution ─────────────────────────────────────────────────────────────

@testset "the suite runs with more than one thread" begin
Expand Down
88 changes: 88 additions & 0 deletions test/test_readme.jl
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,91 @@ end
lines = split(blocks[1][2], "\n")
@test count(l -> endswith(rstrip(l), "\\"), lines) == 1
end

# ── the documentation pages ──────────────────────────────────────────────────────────────────
#
# Scope: `docs/src` gets the same treatment the README does. Twenty julia blocks were shipped
# unexecuted, and two of them did not run — including the front page's, which is the defect the
# registry review named for the README and which was fixed there and not here.

const _DOCS = joinpath(@__DIR__, "..", "docs", "src")

"Every fenced julia block under `docs/src`, as (page, index, text). Any fence width."
function docs_blocks()
out = Tuple{String,Int,String}[]
for f in sort(readdir(_DOCS; join=true))
endswith(f, ".md") || continue
for (i, m) in enumerate(eachmatch(r"`{3,}julia\r?\n(.*?)`{3,}"s, read(f, String)))
push!(out, (basename(f), i, m.captures[1]))
end
end
return out
end

# A block is illustrative if it stands in for a package the reader supplies, and a transcript if
# it shows a REPL session — `pkg>` as much as `julia>`, which is why the install block is here.
# Names the documentation asks the reader to supply. Adding one is a deliberate act: a block that
# needs a new placeholder fails here first, naming the identifier, rather than being skipped.
const _PLACEHOLDERS = [
"MyPackage",
"MyPkg",
"Archeion",
"…",
"simulate",
"publish",
"compute",
"sweep",
"model",
]

_is_illustrative(b) = any(p -> occursin(p, b), _PLACEHOLDERS)
_is_transcript(b) = occursin("julia>", b) || occursin("pkg>", b)

@testset "every macro the documentation teaches exists" begin
blocks = docs_blocks()
@test !isempty(blocks)
@test "index.md" in [p for (p, _, _) in blocks]
foreign = Set([
Symbol("@info"), Symbol("@test"), Symbol("@testset"), Symbol("@deprecate")
])
named = Set{Symbol}()
for (_, _, b) in blocks, m in eachmatch(r"@[a-zA-Z_][a-zA-Z0-9_]*", b)
push!(named, Symbol(m.match))
end
@test Symbol("@experimental") in named # non-vacuity, anchored to content
missing_macros = sort!([
m for m in collect(named) if m ∉ foreign && !isdefined(ExperimentalAPI, m)
])
@test missing_macros == Symbol[]
end

@testset "every self-contained documentation example runs" begin
# Not "the page parses": `index.md` parsed perfectly and raised `UndefVarError: Model` on the
# first line, because the example named a type the reader was supposed to have.
failures = String[]
for (page, i, b) in docs_blocks()
(_is_illustrative(b) || _is_transcript(b)) && continue
m = Module(Symbol("DocsBlock_", replace(page, "." => "_"), "_", i))
try
Core.eval(m, :(using ExperimentalAPI))
Core.eval(m, Meta.parseall(b; filename=page))
catch e
err = e isa LoadError ? e.error : e
push!(failures, "$page[$i]: " * first(sprint(showerror, err), 60))
end
end
@test failures == String[]
end

@testset "…and something is actually being run" begin
# Control: the loop above skips illustrative and transcript blocks, so it could quietly skip
# everything. At least the front page and one `declaring.md` block have to survive the filter.
runnable = [
(p, i) for (p, i, b) in docs_blocks() if !_is_illustrative(b) && !_is_transcript(b)
]
@test length(runnable) >= 5
# The front page's example is absent from that list because it is an `@example` block —
# Documenter runs it during the docs build, which is the stronger guard. Assert that, so
# turning it back into an inert ```julia fence is caught here.
@test occursin("```@example", read(joinpath(_DOCS, "index.md"), String))
end
Loading