docs(tooling): close the phantom-identifier blind spot for bare filename anchors

audit_anchors.py's phantom check only ran on anchors carrying a line number
(`.rs:NN`), so a claim written next to a bare filename was never checked. And
fixing the regex alone would not have been enough: the verdict was a substring
test, so `TelegramClient::send` passed twice over -- `send` is a substring of
`send_text`, and reqwest's `.send()` is `send` at a word boundary, both in
src/channel.rs.

Adds PAIR_BARE for the bare-filename shape plus a stricter member_present test
(a definition site, or a reference not preceded by `.` or a word char) for it.
The numbered PAIR branch is untouched, so both the established
"248 line-refs checked, 0 errors" baseline and the `checked` counter are
unchanged.

The numbered branch deliberately keeps the loose test: a `T::member` claim next
to `foo.rs:NN` still passes when the member only exists as a longer name, but
there the anchor hands the reader a line to read, which is a weaker but honest
guarantee than the bare shape can offer.
This commit is contained in:
anonsaber 2026-09-29 08:45:12 +08:00
parent af13e507a0
commit 1ecba9d961
2 changed files with 102 additions and 29 deletions

View File

@ -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'(?<![A-Za-z0-9_.])`?'
+ FILE + EXT
)
# "Backticked identifier, some slack, right next to a file name" -- the shape
# shared by PAIR and PAIR_BARE. They differ only in the file-name half (EXT vs
# EXT_BARE) and in how strict the identifier is allowed to be.
GAP = (r'`(' + IDENT + r')(?:\(\))?`'
r'[^`\n|]{0,40}?`?[^`\n|]{0,40}?'
r'(?<![A-Za-z0-9_.])`?')
GAP_NSP = GAP.replace(IDENT, IDENT_NSP)
# Ident backticked next to an anchored file name: `Foo::bar` (`src/foo.rs:12`).
PAIR = re.compile(GAP + FILE + EXT)
# Ident backticked next to a bare file name: `Foo::bar` (`src/foo.rs`).
PAIR_BARE = re.compile(GAP_NSP + FILE + EXT_BARE)
# A Rust/JS definition site, or a keyword that introduces a binding. Used by
# member_present to distinguish "defined here" from "mentioned in passing".
DEFN_KW = (r'\b(?:pub(?:\(crate\))?\s+)*(?:async\s+)?'
r'(?:fn|function|const|static|let|var|class|struct|enum|trait'
r'|type|union|interface|use)\s+')
def resolve(prefix, base, ext):
@ -66,6 +104,19 @@ def resolve(prefix, base, ext):
return next((c for c in candidates if c.is_file()), None)
def member_present(symbol, text):
"""`symbol` 在 text 里是否真实存在(被定义,或被非限定地引用)。
刻意比 `symbol in text` 严:文档声称 `T::send` 时,不能由 `send_text`
(子串)或 reqwest 的 `.send()`(词边界)满足——这两者都在 src/channel.rs
里真实存在。要求命中定义关键字,或命中一个前面不跟 `.` / 单词字符的引用。
只排除 `.` 而不排除 `:`,是为了让 `worker::claim_dedup`、`std::env::var`
这类限定调用仍然算数(排除 `:` 会产生 17 个假阳性)。
"""
return bool(re.search(DEFN_KW + symbol + r'\b', text)) or \
bool(re.search(r'(?<![.\w])' + symbol + r'\b', text))
errors = []
checked = 0
for d in DOCS:
@ -88,6 +139,25 @@ for d in DOCS:
symbol = ident.rsplit('::', 1)[-1]
if symbol not in p.read_text():
errors.append(f'{d}:{i} phantom identifier `{ident}` not in {raw}.{ext}')
# Same pair shape, but the file name has no line number. `checked`
# counts line-numbered references only, so this branch deliberately
# never adds to it -- the reported total must stay the number of
# anchors, not the number of identifier claims.
for m in PAIR_BARE.finditer(line):
ident, raw, ext = m.group(1), m.group(2), m.group(3)
raw = raw.rsplit('/', 1)[-1]
if '.' in raw:
continue
p = resolve(None, raw, ext)
if p is None:
continue
symbol = ident.rsplit('::', 1)[-1]
if not member_present(symbol, p.read_text()):
errors.append(
f'{d}:{i} phantom member `{ident}` not defined in {raw}.{ext}'
)
for m in REF.finditer(line):
prefix, base, ext, a, b = m.groups()
p = resolve(prefix, base, ext)

View File

@ -13,7 +13,10 @@
# audit_anchors every `foo.rs:N` / `foo.rs:N-M` anchor in the docs points at
# a line that EXISTS and is not blank. LIMITATION: it does not
# prove the line says what the prose claims. A doc can pass
# with anchors on the wrong line -- someone must read them.
# with anchors on the wrong line -- someone must read them. It
# additionally checks a "`T::member`" claim standing next to a
# BARE `foo.rs` (no line number) and reports it when that
# member is not defined in foo.rs.
# check_sec_refs every "§N.N" cross-reference resolves to a real heading in
# the document it is attributed to.
# check_file_size every source file over 500 lines carries the SPLIT-EVAL