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
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name = "ExperimentalAPI"
uuid = "fd2d14cb-3a46-42a9-afd8-e8499236f05e"
version = "0.1.0"
version = "0.1.1"
authors = ["sota shimozono <shimozono-sota631@g.ecc.u-tokyo.ac.jp>"]

[deps]
Expand Down
59 changes: 36 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@

`public` says who may call a name. Nothing says whether the name is finished.

So "this will change" lives in a docstring sentence, and no tool reads it. ExperimentalAPI puts
that claim at the definition site, in a form a tool can query — and then turns it into a check
your CI runs:
Julia already checks half of that. `Docs.undocumented_names` has been public API in Base since
1.11, and `Aqua.test_undocumented_names` ships it as a test: every public name must carry a
docstring. What neither can express is the **third option** —

**every public name is either documented or declared unfinished, and there is no third option.**
> this name is public, it has no docstring, and that is deliberate: the shape is not settled,
> and here is why.

ExperimentalAPI adds that option at the definition site, and makes it something a tool reads
rather than prose a human might happen to notice.

```julia
using ExperimentalAPI
Expand Down Expand Up @@ -129,13 +133,41 @@ promise withdrawn is a change to what callers were told.
> arguments changed is a breaking change it cannot see. Read the diff as a floor on breakage,
> never as a clearance.

## Why this cannot be a feature of Aqua

A mark is written in `src/`, on the line above the definition, so the package being marked has to
depend on whatever provides `@experimental` at run time. **Aqua is a test-only dependency.** It can
own the check; it structurally cannot own the declaration.

The check here is also not the same set difference. `Docs.undocumented_names` reports every public
name without a docstring — including names re-exported from a dependency, whose prose is somebody
else's job:

```julia
julia> names(Down) # `up` and `undoc_up` come from a dependency
4-element Vector{Symbol}:
:Down, :own_undoc, :undoc_up, :up

julia> Docs.undocumented_names(Down) # the dependency's gap, reported as yours
3-element Vector{Symbol}:
:Down, :own_undoc, :undoc_up

julia> audit(Down).unaccounted # only what this module actually owns
1-element Vector{Symbol}:
:own_undoc
```

`audit` separates those as `foreign`, and adds `dangling` — a mark on a name that was never made
public, which is the module contradicting itself and needs no reference to be wrong.

## What this is not

| axis | already solved by | this package |
|---|---|---|
| who may call a name | `export`, `public` (1.11) | orthogonal — a name can be public and unfinished |
| a name on its way out | `@deprecate` | opposite direction |
| type stability | DispatchDoctor | unrelated |
| every public name has a docstring | `Docs.undocumented_names` (Base 1.11+), `Aqua.test_undocumented_names` | the same check, plus a third answer |
| generating documentation | Documenter | only ever checks whether prose exists |
| run-time behaviour | — | calls are untouched |

Expand All @@ -151,25 +183,6 @@ reads as if it had none:
- **Whether a name appears in your guide, README or docs site.** Docstring presence is not
documentation-page presence, and those two gaps are usually different sets.

## Measured

Run against five packages that had never heard of it, September 2026 — the audit is only worth
having if a clean package comes back clean and a gap comes back named:

| package | public names | documented | foreign | unaccounted |
|---|---|---|---|---|
| Pinax | 46 | 45 | — | `Theme` |
| Archeion | 36 | 33 | — | `FTPSTransport`, `pull_file`, `push_dir` |
| TestShards | 3 | 3 | — | — |
| DataVault | 33 | 32 | `DataKey` | — |
| ParamIO | 13 | 13 | — | — |

`DataKey` is re-exported from a dependency, which is why it is not DataVault's to account for.

None of these had a single mark; the whole column came from docstrings. That is the expected
starting point — the marks are what the four remaining names get *instead of* a docstring, if
their authors decide they are not settled.

## License

MIT
25 changes: 21 additions & 4 deletions docs/src/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@ CurrentModule = ExperimentalAPI

`public` says who may call a name. Nothing says whether the name is finished.

So "this will change" lives in a docstring sentence, and no tool reads it. ExperimentalAPI puts
that claim at the definition site, in a form a tool can query — and then turns it into a check
your CI runs:
Julia already checks half of that. `Docs.undocumented_names` has been public API in Base since
1.11, and `Aqua.test_undocumented_names` ships it as a test: every public name must carry a
docstring. What neither can express is the **third option** —

**every public name is either documented or declared unfinished, and there is no third option.**
!!! note ""
this name is public, it has no docstring, and that is deliberate: the shape is not settled,
and here is why.

ExperimentalAPI adds that option at the definition site, and makes it something a tool reads
rather than prose a human might happen to notice.

```julia
using ExperimentalAPI
Expand Down Expand Up @@ -55,13 +60,25 @@ The two are genuinely independent. A name can be:

The second row is what [`@experimental`](@ref) is for.

## Why this cannot be a feature of Aqua

A mark is written in `src/`, on the line above the definition, so the marked package depends on
whatever provides [`@experimental`](@ref) at run time. **Aqua is a test-only dependency.** It can
own the check; it structurally cannot own the declaration.

The check is not the same set difference either. `Docs.undocumented_names` reports every public
name without a docstring, including names re-exported from a dependency whose prose is somebody
else's job. [`audit`](@ref) files those as `foreign`, and adds `dangling` — a mark on a name that
was never made public, which is the module contradicting itself.

## What this is not

| axis | already solved by | this package |
|---|---|---|
| who may call a name | `export`, `public` (1.11) | orthogonal |
| a name on its way out | `@deprecate` | opposite direction |
| type stability | DispatchDoctor | unrelated |
| every public name has a docstring | `Docs.undocumented_names`, `Aqua.test_undocumented_names` | the same check, plus a third answer |
| generating documentation | Documenter | only ever checks whether prose exists |
| run-time behaviour | — | calls are untouched |

Expand Down
8 changes: 5 additions & 3 deletions src/audit.jl
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@ This answers *whether prose exists*, never whether it is any good. A docstring r
is documented as far as this package is concerned.
"""
function isdocumented(m::Module, name::Symbol)
# `Docs.hasdoc` is not part of Base's public API. It is what Documenter's `checkdocs` uses,
# so it is de-facto stable; `test/test_audit.jl` pins both the documented/undocumented split
# AND the re-export behaviour relied on here, so this stops being an assumption.
# `Docs.hasdoc` is public API — `public`, and exported from `Base.Docs`. Its set-valued
# sibling `Docs.undocumented_names` answers the same question for a whole module at once and
# is what `Aqua.test_undocumented_names` is built on; the per-name form is used here because
# `audit` has to tell a module's own gap apart from a dependency's, and the set form reports
# a re-exported name's missing docstring as though it were this module's to fix.
return Base.Docs.hasdoc(m, name)
end

Expand Down
5 changes: 3 additions & 2 deletions test/test_audit.jl
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,9 @@ end
end

@testset "isdocumented reads prose, not marks" begin
# This pins `Base.Docs.hasdoc`, which is not public Base API — if it ever stops separating
# these two cases, this test is where that surfaces rather than in a consumer's CI.
# Pins `Base.Docs.hasdoc`'s behaviour, including that it follows a re-export back to the
# module the binding comes from. `audit` leans on that to keep `foreign` apart from a real
# gap, so if it ever changes this is where it surfaces rather than in a consumer's CI.
@test isdocumented(Audited, :exported_documented)
@test isdocumented(Audited, :public_documented)
@test !isdocumented(Audited, :public_silent)
Expand Down
Loading