diff --git a/Project.toml b/Project.toml index b1dcbda..14f2427 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "ExperimentalAPI" uuid = "fd2d14cb-3a46-42a9-afd8-e8499236f05e" -version = "0.1.0" +version = "0.1.1" authors = ["sota shimozono "] [deps] diff --git a/README.md b/README.md index ba09b96..358f2d9 100644 --- a/README.md +++ b/README.md @@ -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 @@ -129,6 +133,33 @@ 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 | @@ -136,6 +167,7 @@ promise withdrawn is a change to what callers were told. | 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 | @@ -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 diff --git a/docs/src/index.md b/docs/src/index.md index 8e617b7..bcbc6d4 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -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 @@ -55,6 +60,17 @@ 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 | @@ -62,6 +78,7 @@ The second row is what [`@experimental`](@ref) is for. | 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 | diff --git a/src/audit.jl b/src/audit.jl index 88f12ab..a74a291 100644 --- a/src/audit.jl +++ b/src/audit.jl @@ -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 diff --git a/test/test_audit.jl b/test/test_audit.jl index 9e7a53c..cca506a 100644 --- a/test/test_audit.jl +++ b/test/test_audit.jl @@ -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)