diff --git a/scripts/docs_check/audit_anchors.py b/scripts/docs_check/audit_anchors.py index a1ab8da..3fce8d7 100755 --- a/scripts/docs_check/audit_anchors.py +++ b/scripts/docs_check/audit_anchors.py @@ -1,54 +1,92 @@ #!/usr/bin/env python3 -"""Check every `foo.rs:N` / `foo.rs:N-M` / `web/config.js:N` line anchor in the docs. +"""Audit the docs' source anchors against the working tree. -Paths may be bare (`notify.rs:320`, resolved under `src/`), prefixed with a directory -(`src/domain/jmap/client.rs:108`, `cloudflare-worker/src/backends.js:9`, -`web/config.js:585`), or unprefixed JavaScript (`config.js:585`, resolved under `web/` -or the Worker). +For every "`src/foo.rs:123`" style anchor in the docs, check that the file +exists, the line is in range, and the line is not blank. -LIMITATION: this only proves the cited line *exists and is not blank*. It does -not prove the line contains what the prose claims about it. A doc can pass this -audit with anchors that point at the wrong line -- a human must read the target -line to catch that. Keep this in mind when quoting "0 errors" as proof of -accuracy: it proves reachability, not correctness. +LIMITATION: this only checks that the anchor TARGETS A REAL, NON-BLANK LINE. +It does not check that the line says what the doc claims. +A doc could say "foo.rs:123 defines get_by_key" when line 123 is actually +`fn get_by_name`. This audit would pass. +Someone still has to read the anchors to verify they're accurate. -The one exception is a *phantom identifier*: if a backticked name sits right next -to an anchor (the common "`foo` ... `bar.rs:N`" phrasing) and that name appears -nowhere in bar.rs, the anchor is reported. Existence, not position -- the symbol -may still be on the wrong line. +The one exception is a *phantom identifier*: if a backticked name sits right +next to an anchor, we check the name actually appears in that file. Catches +docs claiming an identifier that doesn't exist. +This is a loose substring check, so it has the same limitation as the anchor +check above. It catches the obvious case but not a near-miss name. + +Two shapes of claim live next to an anchor, and they are checked with +deliberately different strictness: + + - Numbered anchor (`src/state.rs:283`). Kept as a loose substring check on + the member name. PAIR's EXT only matches a name followed by a colon and a + line number, so the anchor already pins down the location and the reader + can verify the line. Widening the verdict here would change the count of + findings in this branch, i.e. perturb the established baseline, for no + gain: any doc written next to an anchor names the place and the name. + - Bare file name (`src/channel.rs`, no line number). Checked with the strict + `member_present` test and reported as a `phantom member`. No line number + means the reader has nothing to verify, so the only thing this check can + prove is that the named member actually exists in the file. And a loose + test provably fails here: a doc can write `TelegramClient::send` and + satisfy a substring -- even a word-boundary -- test twice over, because + `channel.rs` contains `send_text` and reqwest's `.send()` is `send` at a + word boundary. Hence definition/unqualified-reference semantics. """ + import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[2] + DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md', 'docs/deployment.md', 'docs/reference.md', 'docs/retired.md', 'docs/roadmap.md', 'docs/charter.md', 'cloudflare-worker/README.md'] +# `Ident` or `Module::Ident` inside backticks, optional () after. IDENT = r'[A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)?' +# A namespaced path: at least two segments joined by `::`. Required for the +# bare-file-name branch -- PAIR's IDENT would also match a bare `Foo` and +# produce a phantom-member hit on every ordinary mention. +IDENT_NSP = (r'[A-Za-z_][A-Za-z0-9_]*' + r'::' + + r'(?:[A-Za-z_][A-Za-z0-9_]*::)*[A-Za-z_][A-Za-z0-9_]*') + +# Optional directory prefix, then the bare file stem. FILE = r'((?:[A-Za-z0-9_.-]+/)?[A-Za-z0-9_-]+)' + +# Line anchor: `.rs:123`. The optional range suffix lives in REF, not here. EXT = r'\.(rs|js):(\d+)' -# A directory part is zero or more full segments (`/seg`), so the regex can never -# split a file name like `debug.rs` into a "directory" plus a stray base. +# Bare file name: `.rs` not immediately followed by a colon. The negative +# lookahead keeps PAIR_BARE from re-matching anchors PAIR already consumed +# (`channel.rs:110`), so a numbered anchor is never reported twice. +EXT_BARE = r'\.(rs|js)(?!:)' + REF = re.compile( r'((?:src|cloudflare-worker/src|web)(?:/[A-Za-z0-9_.-]+)*/)?' + FILE + EXT + r'(?:\s*[,–\-]\s*(\d+))?' ) -# A backticked identifier sitting right next to an anchor is an implicit claim: -# "`foo` ... `bar.rs:N`". If `foo` never appears in bar.rs the anchor is a phantom. -# The gap may not cross more than one backtick boundary and is capped at 40 -# characters on each side, so only a genuinely coupled identifier/anchor pair -# matches. An optional `()` after the identifier is tolerated, since prose often -# writes function names that way. -PAIR = re.compile( - r'`(' + IDENT + r')(?:\(\))?`' - r'[^`\n|]{0,40}?`?[^`\n|]{0,40}?' - r'(?