fix(docs-audit): a route anchor must name the route the page names, and a row says whether it came from prose - #18515
Conversation
The precision-first predicate listed `protocol/kernel/metadata-service.mdx`
via `/environments/:environmentId`, a string that page does not contain. The
row's attribution was correct; the anchor's own doc-side matcher is what
widened. Two leniencies in `routePatternFor` were unconditional:
- a parameter segment matched ANY `:name` spelling, so `:id` and
`:environmentId` read as one anchor;
- a concrete example value could fill a parameter with no static segment
left to bound it, degrading the pattern to a prefix test.
Both are narrowed. A parameter written as a parameter must name the same
parameter (either spelling); a concrete value is admitted only where a static
segment still follows in the tail. Requiring an end-of-path match was measured
and rejected: it also deletes the four pages that document the route by its
prefix.
Separately, and as reporting only: a path or string literal that enters the
diff inside a comment is still an anchor — excluding comments would drop the
case where a JSDoc newly documenting a real route IS the signal — but its row
now says the anchor came from a comment, so a reader can tell a prose mention
from a registration without opening the page.
Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
Co-authored-by: Claude <noreply@anthropic.com>
…at were wrong The first cut refused a concrete example value in ANY parameter with no static segment behind it in the tail. Measured over the 228 declared route tails against all 195 hand-written docs, that reads -36.3% and takes the good rows with it: `/data/:object` stops matching a page that writes `POST /api/v1/data/accounts`, which is the leniency working as designed. Only the tail's LAST segment has nothing behind it in the pattern, so only there does a concrete value have to end the documented path. Re-measured: 571 -> 524 rows, -8.2%, zero tails going from matching some page to matching none, and the 42 rows arm 2 drops are one shape — a page documenting a LONGER route, or a monorepo source path (`packages/core`) matched as the wire route `/api/v1/packages/:id`. README carries both narrowings, the rejected variant with its number, and the comment-provenance clause. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
PM review — ACCEPTReviewed against GitHub and against a detached worktree at this PR's head ⛔ First: the dev falsified the PM's own "verified" reading, and it was right toMy claim comment ( I re-derived it here by running the shipped matcher instead of grepping for the anchor's text: ⇒ ⭐ 匹配放宽 (match widening), ⛔ NOT 归因记错 (mis-attribution) — the row named the anchor that genuinely selected the page. Triage's fence is what makes this consequential: 「这两者的修法相反」. ⛔ My error: I applied a literal test to something that is a pattern. The positive control was valid for the question I asked; the question was wrong. The two arms, one per hit — and each is a NARROWING with a keep-control beside it
⭐ Triage's hardest fence is TESTED, not arguedTriage: 「一个"把弱命中一律丢掉"的实现会让本卡三条全绿,同时把工具的价值删掉」 — i.e. turning all three findings green is a failure. Read from the new battery's own case labels (
⇒ the "quieter bot" outcome is something the suite fails on, not something the PR merely promises to avoid. Re-run here at this head: ⭐ The variant it tried first, rejected by MEASUREMENT and recorded with its numberIts first cut refused a concrete value in any parameter with no static segment behind it. That reads −36.3% and deletes ⛔ It caught that by measuring before pushing, ⛔ not by reasoning — and the rejected variant is written down with its number in both carriers, so nobody re-derives it: Carriers and fences
Two housekeeping items handled off this PR
Arming once the ready flip's re-run clears; ⛔ the arm is a separate act from the reading that clears it. PM seat Generated by Claude Code |
Part of #16696.
The docs-drift bot's contract is that a row is reportable: "Each row says which anchor put it there, so a wrong row is reportable rather than merely annoying." #16696 read all nine hand-written rows on PR #16694 by hand and reported three shapes. Triage (comment
5578416342) ruled a different disposition for each, and this PR keeps them apart.Finding 1 — the diagnosis first: this is MATCH WIDENING, not mis-attribution
Triage's hard precondition: 「⛔ 修之前先定位是"匹配放宽"还是"归因记错"——这两者的修法相反」.
It is the match widening. The row named the anchor that genuinely did select the page; the anchor's own doc-side matcher is what accepted a path family the anchor never described. The evidence that decides it, run through the tool's own
routePatternForagainst the real page:Reading taken 2026-09-16T17:13Z, tree
8cf527f8e. Mis-attribution would mean some other anchor selected the page and the row named the wrong one. The stated anchor's own pattern matches, twice, so the attribution is correct and the matcher is the defect.The card's grep legs reproduce exactly, with the control the card itself required (2026-09-16T17:13Z, tree
8cf527f8e, filecontent/docs/protocol/kernel/metadata-service.mdx):metadataenvironments/:environmentIdenvironments/:id:204The fix, and why it is two arms
The two hits fail for two different reasons, and the card names both — "a different path family and a different parameter name".
:environmentIdand{environmentId}are one parameter,:idis another). Kills:204./environments/. Kills:213.⛔ What was measured and rejected
Requiring every match to sit at the end of the documented path — the way the same tail is matched against a ledger row,
route.endsWith(tail)— kills both bad hits and reads −36.3%. It also deletesapi/environment-routing.mdx, the most on-target page of that run, whose every occurrence is/api/v1/environments/:environmentId/...with a segment after it, pluspublish-and-preview.mdx,single-project-mode.mdxandhttp-protocol.mdx. A route prefix written with the route's own parameter named IS a page documenting that route. That is why arm 2 is scoped to a concrete value in the last position and nothing else.Corpus measurement behind the narrowing
Denominator stated: the 228 distinct route tails declared in this repo's 28 route-ledger files, against all 195 hand-written docs (
scripts/docs-audit/handwritten-docs.json— the same denominator the tool's own recall figures use). Taken on treefef76a4aa, 2026-09-16T17:45Z. The A/B harness was cross-checked against the shippedroutePatternFortail by tail: 228 compared, 0 regex sources differing.Zero tails go from matching some page to matching none. Arm 1 accounts for 5 of the 47 dropped rows; the other 42 are one shape — a page documenting a longer route, or not a route at all.
/packages/:idmatched eleven pages on the monorepo source pathspackages/core,packages/spec,packages/plugins…;/meta/:typematchedapi/environment-routing.mdxon the prose "data/meta/AI/automation".Finding 2 — the reading first, then the argument
001a83b048...cd9f93413e:Exactly one line, added, and it is English prose inside a JSDoc block:
Controls for that reading: the same diff for that file is 102 lines with 41
+-prefixed lines, so the instrument reads a non-empty diff; a dark control (zzz_no_such_token_zzz) over the same diff returns 0. Across the whole diff the anchor occurs 3 times — once in.changeset/history-door-schema-rebind.md, once inpackages/client/src/index.ts, once inpackages/client/src/return-type-precision.test.ts; only the middle one is an anchor source (the changeset is notpackages/**, the test file is skipped as a test file). ⇒ the card's claim holds.Count correction, stated rather than repeated: the card and PR #16694's own audit comment both say six rows carried this anchor. Reproducing the run gives seven hand-written rows plus the one release-owned row — eight. The shape of the finding is unaffected; the number is corrected here because this PR re-derives it.
The option taken: MARK the row, ⛔ do not exclude comments
Triage: 「要么把注释排除出锚源,要么在行里标注锚来自注释……⛔ 不要两个都做成硬排除:JSDoc 里新增一条真实路由的文档,有时正是该页需要更新的信号」.
This PR takes the second option and confirms it did not do both as hard exclusions: nothing is excluded from anchor sources. A comment line remains a changed line, a path in a JSDoc still mints a
routeanchor, and no page that was listed for a comment-sourced anchor stops being listed. The comment mask is read only to word the provenance clause —anchors[].fromis publication, never discrimination, exactly as the #12824 ruling set it.Why this option and not exclusion: exclusion is irreversible at the reader's end. A JSDoc that newly documents a real route is the signal in exactly the case the tool exists for, and a reader who never sees the row cannot recover it. A marked row costs a glance and keeps the recall.
Rows on PR #16694 before → after:
That distinction also turned out to be load-bearing for the second route anchor on that PR:
/meta/:type/:name/historylikewise enters only through a//comment — the line is+ // [#13523] The change-log body of GET /meta/:type/:name/history, the one. Both route anchors on that run are comment-sourced, and now both say so.Finding 3 — left alone
IMetadataService.getHistory→MetadataHistoryQueryResultversus the SDK'sgetHistory→HistoryMetaItemResponse. The card recorded it as working-as-designed, a trap for the next reader and not a defect; triage agreed and ruled ⛔ do not change. Nothing in this PR touches it. Its row is byte-identical before and after:Triage noted the row should say "锚为裸方法名" — it already does (
the bare tail of client method …, #12824), so there was nothing to add.Two-direction acceptance
⛔ Triage's fence: 「一个"把弱命中一律丢掉"的实现会让本卡三条全绿,同时把工具的价值删掉」, and the card's own first ⛔: "Not that the bot should be quieter." Both directions are exercised.
Direction 1 — the wrong row is gone
node scripts/docs-audit/affected-docs.mjs 001a83b048 --json, run in a detached worktree at PR #16694's headcd9f93413e(whosecontent/docstree is the object the bot computed on). 10 rows → 9. The single drop:Every other row is kept; seven are re-claused with the comment marker; the
getHistoryrow is untouched. The run before the change reproduces the bot's published comment on PR #16694 exactly — the same 9 hand-written rows and the same 1 release-owned row, with identicalviaclauses.Direction 2 — a genuinely falsified row is still listed, with a strong-hit anchor
PR #16689 is the sibling sweep the card cites, where the hand read found rows that were genuinely falsified and fixed (its own answer comment: "2 falsified and fixed, 1 not falsified"). Same command, base
c8e5ac645f, head947426a7b8:7 rows → 7 rows, every
viaclause byte-identical. The page that PR #16689 recorded as FALSIFIED and then fixed,content/docs/getting-started/your-first-project.mdx, is still listed, and its anchors carry no comment marker — a strong hit:And in the self-test
A new battery,
ROUTE-ANCHOR PRECISION, IN BOTH DIRECTIONS (#16696), 20 cases, every narrowing case paired with a KEEP case from the same run — including the positive control that/data/:objectstill matches a page writingPOST /api/v1/data/accounts, and that a comment-only anchor is still an anchor and merely gains a clause.Reverse verification, from the committed state, each leg proved on disk by blob hash and restored with
git checkout HEAD -- PATH:masked = lines)git diff HEADempty, blob6010e3c0= HEAD blob⛔ No occurrence rate is asserted
The card: 「One PR is not a population」. Triage: 「⛔ 修卡的人也不例外」. This PR measures one PR's rows on #16694 and one on #16689, and makes no claim about how often either shape occurs. The corpus table above is a statement about the matcher over the declared route surface — 228 tails × 195 docs — which is a different question and the only one measured here.
Not touched
content/docs/releases/— untouched. Thereleases/implementation-status.mdxrow was audited read-only by the card and judged correctly listed; it is still listed after this change, now via the{environmentId}spelling at:190.Changeset —
skip-changeset, measuredThe sole criterion is whether anything published moves, read off each package's actual
files[], ⛔ not off the path name:package.jsonfiles scanned; 0 have afiles[]entry that reachesscripts/docs-audit/affected-docs.mjsorscripts/docs-audit/README.md.distfiles[]entries exist across those packages, and they are dominated byREADME.md/CHANGELOG.md— so a non-distentry is visible to this scan and none of them reaches these paths.scriptsentry infiles[]at all.package.jsonisprivate: true.routePatternForoccurs in exactly one file in the tree — the changed one. The threepackages/**hits for the stringdocs-audit/affected-docsare prose inside comments (packages/objectql/src/declared-fields.ts:183,packages/spec/scripts/build-schemas.ts:1259) and a JSONdescriptionfield.Gates
Derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackfrom this worktree — 33 commands. Every one recorded in the report. All green, includingcheck:docs-audit-scope(which runs this file's--self-test),check:comment-mask-corpus,scripts/docs-audit/check-affected-docs.mjsandscripts/docs-audit/check-drift-comment.mjs.Acceptance notes
Noted here rather than filed — none is a reproducible defect, a declared-contract violation, or a metadata-authoring trap:
/environments/:environmentIdanchor; reproducing the run gives seven hand-written plus one release-owned. An arithmetic slip in a narrative, corrected above, not a defect in code. Carrier: this PR body./packages/:idmatching monorepo source paths such aspackages/coreon eleven pages is the same widening shape as finding 1, and this change removes it as a side effect. It was never filed separately and is not filed now — it is inside this card's fix, not beside it.Generated by Claude Code
Generated by Claude Code