From eb7f60d076ffdc956fb0fab7d62c54fa81f200b3 Mon Sep 17 00:00:00 2001 From: sotashimozono Date: Tue, 1 Sep 2026 15:22:07 +0000 Subject: [PATCH] docs: lead with the third option, and correct a false claim about Base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things, both about what this package says rather than what it does. The README opened on "every public name is documented or declared", which is the half Julia and Aqua already cover: `Docs.undocumented_names` has been public API in Base since 1.11, and `Aqua.test_undocumented_names` ships it as a test. Opening there invites the only reaction it deserves. It now opens on the part neither can express — the third answer — and says plainly why the declaration cannot live in Aqua: a mark is written in `src/`, and Aqua is a test-only dependency. The second is a correction. A comment in `audit.jl` and another in the tests claimed `Docs.hasdoc` is not public Base API. It is — `public`, and exported from `Base.Docs`. The claim was wrong and is replaced with the real reason the per-name form is used: `Docs.undocumented_names` reports a re-exported name's missing docstring as though it were this module's to fix, and `audit` has to keep `foreign` apart from a genuine gap. Verified: names(Down) = [:Down, :own_undoc, :undoc_up, :up] Docs.undocumented_names(Down) = [:Down, :own_undoc, :undoc_up] audit(Down).unaccounted = [:own_undoc] audit(Down).foreign = [:undoc_up, :up] Drops the README's measured table: five packages' counts age the moment any of them gains a docstring, and nothing checks it. Co-Authored-By: Claude Opus 5 (1M context) --- Project.toml | 2 +- README.md | 59 ++++++++++++++++++++++++++++------------------ docs/src/index.md | 25 ++++++++++++++++---- src/audit.jl | 8 ++++--- test/test_audit.jl | 5 ++-- 5 files changed, 66 insertions(+), 33 deletions(-) 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)