Skip to content

Commit 5f5c586

Browse files
os-steveclaude
andauthored
fix(docs-audit): admit only LEAF symbols into the route bridge (#9431)
A symbol derived from a CONTAINER declaration (class / interface / type / enum / namespace / key-owning const) is a doc anchor but not a route's implementation, so it no longer feeds the symbol -> route -> sdk bridge. Measured on 9e2e682: a 27-line edit confined to `RestServer.probeMcpServeable` (17 of them its doc comment, which attributes to the enclosing class) put `RestServer` in the anchor set; two handlers ~1350 lines away call `RestServer.` statics, and the bridge's bare-identifier handler scan read that qualifier as "this handler implements the changed symbol". Result: `/book/:name/tree`, `/:type/:name/layers`, `getBookTree` and `meta.getBookTree`. Two routes is under MAX_ROUTES_PER_SYMBOL, so the cross-cutting cap never fired. The `RestServer (symbol)` row is correct and survives untouched — only the bridge hop is cut. Self-test 197 -> 212, pinned in both directions including a counterfactual that the raw anchor set still selects the book route. Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 4d320a4 commit 5f5c586

1 file changed

Lines changed: 146 additions & 13 deletions

File tree

scripts/docs-audit/affected-docs.mjs

Lines changed: 146 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -808,23 +808,30 @@ function declarationChainAt(lines, idx) {
808808
}
809809

810810
/**
811-
* The DOCUMENTABLE declarations a changed line belongs to — at most one name.
811+
* The DOCUMENTABLE declarations a changed line belongs to — at most one, as
812+
* `{ name, container }`.
812813
*
813814
* Most-specific-wins: a changed method body anchors on the METHOD, not on its class.
814815
* Emitting the container too would mean every edit anywhere in a 20k-line class flagged
815816
* every page that names the class — the coarse-proxy failure this rewrite is undoing,
816817
* reintroduced one level down. The container is the FALLBACK, used when the inner name is
817818
* generic or absent (a changed entry inside `export const FIELD_TYPES = [...]` has no
818819
* declaration of its own, and `FIELD_TYPES` is the right anchor for it).
820+
*
821+
* `container` reports WHICH of those two the name came from, because the answer is a
822+
* doc anchor either way but a ROUTE-BRIDGE symbol only one way (#9294 — see the
823+
* `bridgeSymbols` block in §3b). It is read off the winning declaration rather than
824+
* from the branch, so a container reached as `inner` (an interface nested in a
825+
* namespace) is reported the same as one reached as the fallback.
819826
*/
820827
function documentableDeclarationsAt(lines, idx) {
821828
const chain = declarationChainAt(lines, idx);
822829
if (!chain.length) return [];
823830
const outer = chain[chain.length - 1];
824831
const inner = chain.length > 1 ? chain[chain.length - 2] : null;
825832
const usable = (d) => d && !GENERIC_ANCHOR_NAMES.has(d.name) && !GENERIC_ANCHOR_NAMES.has(d.name.toLowerCase()) && d.name.length >= 3;
826-
if (inner && outer.container && usable(inner)) return [inner.name];
827-
if (usable(outer) && outer.kind !== 'member') return [outer.name];
833+
if (inner && outer.container && usable(inner)) return [{ name: inner.name, container: !!inner.container }];
834+
if (usable(outer) && outer.kind !== 'member') return [{ name: outer.name, container: !!outer.container }];
828835
return [];
829836
}
830837

@@ -909,15 +916,27 @@ function literalAnchorsFromLines(lines, changed) {
909916
return { routes, literals };
910917
}
911918

912-
/** Documentable declaration names touched on one side of one file's diff. */
919+
/**
920+
* Documentable declaration names touched on one side of one file's diff.
921+
*
922+
* Two sets, and the second one is the point: `names` is every symbol anchor the side
923+
* yields, `bridgeable` is the subset that named a LEAF declaration rather than a
924+
* container. Positive, not subtractive — a name that reached the set through a leaf
925+
* derivation anywhere stays bridgeable even if some other line derived it as a
926+
* container. `bridgeable ⊆ names` always.
927+
*/
913928
function symbolAnchorsFromSource(text, changed) {
914929
const lines = text.split('\n');
915930
const names = new Set();
931+
const bridgeable = new Set();
916932
for (const n of changed) {
917933
if (n - 1 < 0 || n - 1 >= lines.length) continue;
918-
for (const name of documentableDeclarationsAt(lines, n - 1)) names.add(name);
934+
for (const d of documentableDeclarationsAt(lines, n - 1)) {
935+
names.add(d.name);
936+
if (!d.container) bridgeable.add(d.name);
937+
}
919938
}
920-
return names;
939+
return { names, bridgeable };
921940
}
922941

923942
/**
@@ -1144,7 +1163,8 @@ function selfTest() {
11441163
' }',
11451164
'}',
11461165
].join('\n');
1147-
const anchorsAt = (src, lineNo) => symbolAnchorsFromSource(src, [lineNo]);
1166+
const anchorsAt = (src, lineNo) => symbolAnchorsFromSource(src, [lineNo]).names;
1167+
const bridgeableAt = (src, lineNo) => symbolAnchorsFromSource(src, [lineNo]).bridgeable;
11481168
const symbolCases = [
11491169
// [1-based line, expected anchor set, label]
11501170
[9, ['auditMetaItem'], 'a changed METHOD BODY anchors on the method, not on its 20k-line class'],
@@ -1303,6 +1323,87 @@ function selfTest() {
13031323
const bridgeRow = ledger.find((r) => bridged && r.route.endsWith('/:type/:name/audit'));
13041324
check('bridge', 'a changed protocol method reaches the SDK method the docs name', 'auditMetaItem → getAudit', 'getAudit', bridgeRow?.client?.split('.').pop());
13051325

1326+
// ---- the route bridge admits LEAF symbols only (#9294) ---------------------
1327+
// MEASURED FAILURE (9e2e68206): a 27-line edit confined to `RestServer.probeMcpServeable`
1328+
// — 17 of those lines its own doc comment — listed `api/client-sdk.mdx` via
1329+
// `getBookTree (sdk)` / `meta.getBookTree (sdk)` and `releases/v14.mdx` via
1330+
// `/book/:name/tree (route)`. No changed line relates to book trees; the nearest
1331+
// `/book/:name/tree` literal sits ~1350 lines away.
1332+
//
1333+
// THE CHAIN, each link real and each one but the last correct: a doc-comment line has no
1334+
// declaration of its own and its indent walk climbs past every sibling member to the
1335+
// CLASS, so `RestServer` enters the anchor set (a correct row — three release pages name
1336+
// that class) → the route bridge accepts it as a bridge symbol → `parseRegistrarSource`
1337+
// scans handler windows for the BARE IDENTIFIER and two unrelated handlers call
1338+
// `RestServer.` statics → two route anchors → the ledger maps one to `meta.getBookTree`.
1339+
// Two routes is UNDER `MAX_ROUTES_PER_SYMBOL`, so the cross-cutting cap never saw it.
1340+
//
1341+
// ⚠️ PINNED IN BOTH DIRECTIONS. A test that only asserts the wrong rows vanish passes
1342+
// just as happily on an over-correction that also drops `RestServer (symbol)` — which
1343+
// would trade a precision bug for a coverage hole, strictly worse than the bug (this
1344+
// defect over-reports and misses nothing). So the class must stay a symbol anchor, the
1345+
// method must stay bridgeable, and the identifier scan must still SEE the qualifier —
1346+
// that last one is what stops this block going green because the fixture drifted into
1347+
// deriving no route at all.
1348+
const serverSource = [
1349+
'export class RestServer {',
1350+
' /**',
1351+
' * [#9120] Resolve the environment through the shared entry point.',
1352+
' */',
1353+
' private async probeMcpServeable(req: any): Promise<boolean | null> {',
1354+
' return this.resolveRequestEnvironmentId(req);',
1355+
' }',
1356+
'',
1357+
' private registerBookRoutes() {',
1358+
' this.routeManager.register({',
1359+
" method: 'GET',",
1360+
' path: `${metaPath}/book/:name/tree`,',
1361+
' handler: async (req, res) => {',
1362+
' return RestServer.anyPermissionSetAudience(books);',
1363+
' },',
1364+
' });',
1365+
' }',
1366+
'}',
1367+
].join('\n');
1368+
const bookRegistrar = parseRegistrarSource(serverSource);
1369+
const bookIds = bookRegistrar.get('/book/:name/tree');
1370+
check('parseRegistrarSource', 'the mechanism is real: a static-call QUALIFIER lands in the handler window', 'RestServer', true, !!bookIds?.has('RestServer'));
1371+
check('parseRegistrarSource', 'and so does the handler\'s own implementation symbol', 'anyPermissionSetAudience', true, !!bookIds?.has('anyPermissionSetAudience'));
1372+
1373+
const docCommentLine = 3; // `* [#9120] Resolve the environment …` — inside the JSDoc
1374+
const methodBodyLine = 6; // `return this.resolveRequestEnvironmentId(req);`
1375+
check('symbolAnchorsFromSource', 'a changed DOC COMMENT above a method still anchors on the enclosing class — the correct row that must survive', `line ${docCommentLine}`, true, anchorsAt(serverSource, docCommentLine).has('RestServer'));
1376+
check('symbolAnchorsFromSource.bridgeable', 'but the CLASS is not a route\'s implementation, so it never enters the bridge', `line ${docCommentLine}`, false, bridgeableAt(serverSource, docCommentLine).has('RestServer'));
1377+
check('symbolAnchorsFromSource.bridgeable', 'the METHOD is a leaf and stays bridgeable — the #9192 recall win is untouched', `line ${methodBodyLine}`, true, bridgeableAt(serverSource, methodBodyLine).has('probeMcpServeable'));
1378+
check('symbolAnchorsFromSource', 'and the method is still the anchor for its own body (most-specific-wins)', `line ${methodBodyLine}`, true, anchorsAt(serverSource, methodBodyLine).has('probeMcpServeable'));
1379+
1380+
// End to end over the fixture: the doc-comment edit selects NO route, and the method
1381+
// edit selects no route HERE either (it appears in no handler) — while `auditMetaItem`
1382+
// above still selects its own. Absence proved by the same selection step the bridge
1383+
// runs, not by asserting on a different quantity.
1384+
const tailsSelectedBy = (symbols, registrarMap) => {
1385+
const tails = [];
1386+
for (const [tail, ids] of registrarMap) if ([...symbols].some((sym) => ids.has(sym))) tails.push(tail);
1387+
return tails;
1388+
};
1389+
check('bridge', 'a doc-comment-only edit inside a class selects no route at all', 'tails', JSON.stringify([]), JSON.stringify(tailsSelectedBy(bridgeableAt(serverSource, docCommentLine), bookRegistrar)));
1390+
check('bridge', 'the pre-fix behaviour, held as the counterfactual: the raw anchor set DID select the book route', 'tails', JSON.stringify(['/book/:name/tree']), JSON.stringify(tailsSelectedBy(anchorsAt(serverSource, docCommentLine), bookRegistrar)));
1391+
check('bridge', 'a changed protocol METHOD still selects its own route', 'tails', JSON.stringify(['/:type/:name/audit']), JSON.stringify(tailsSelectedBy(bridgeableAt(protocolSource, 9), registrar)));
1392+
1393+
// The container/leaf split on the two fixtures the derivation is already pinned against,
1394+
// so the new flag is read off the same shapes the anchor cases use.
1395+
const bridgeableCases = [
1396+
[protocolSource, 1, 'ObjectStackProtocolImplementation', false, 'a changed CLASS LINE anchors, but a class is a scope, not a route implementation'],
1397+
[protocolSource, 9, 'auditMetaItem', true, 'a changed method body is a leaf'],
1398+
[protocolSource, 16, 'historyMetaItem', true, 'a method reached past an intermediate block is still a leaf'],
1399+
[schemaSource, 1, 'ObjectSchema', false, 'a `const` object that owns its keys is a container'],
1400+
[schemaSource, 2, 'controlled_by_parent', true, 'a schema KEY is a leaf — it names one property, not a scope'],
1401+
[schemaSource, 6, 'buildObject', true, 'a function is a leaf: it holds locals, it does not own surface'],
1402+
];
1403+
for (const [src, line, name, want, label] of bridgeableCases) {
1404+
check('symbolAnchorsFromSource.bridgeable', label, `${name} @ line ${line}`, want, bridgeableAt(src, line).has(name));
1405+
}
1406+
13061407
// ---- the CLI command anchor kind (#9230) ----------------------------------
13071408
// The recall class: a CLI-surface change derives `MetaResync` / a lowercase `resync`,
13081409
// and neither reaches the page that documents the command. The phrase does. Both halves
@@ -1555,6 +1656,10 @@ for (const dir of pkgRoots) {
15551656
// One pass per changed file, both sides of the diff: the HEAD side for what the change
15561657
// now declares, the base side so a REMOVED export still anchors the pages naming it.
15571658
const symbolAnchors = new Set();
1659+
// The subset of `symbolAnchors` eligible to enter the route bridge — see §3b. Kept as
1660+
// its own set rather than recomputed there, because the container/leaf distinction is
1661+
// only knowable at DERIVATION time: by §3b a symbol is just a string.
1662+
const bridgeableSymbols = new Set();
15581663
const routeAnchors = new Set();
15591664
const literalAnchors = new Set();
15601665
const commandAnchors = new Map(); // canonical phrase → { id, bins }
@@ -1587,7 +1692,9 @@ for (const f of implementationChanges) {
15871692
let ruleSpansHere = 0;
15881693
for (const [text, changed] of [[after, newLines], [before, oldLines]]) {
15891694
if (!text) continue;
1590-
for (const name of symbolAnchorsFromSource(text, changed)) { symbolAnchors.add(name); found++; }
1695+
const sym = symbolAnchorsFromSource(text, changed);
1696+
for (const name of sym.names) { symbolAnchors.add(name); found++; }
1697+
for (const name of sym.bridgeable) bridgeableSymbols.add(name);
15911698
const { routes, literals } = literalAnchorsFromLines(text.split('\n'), changed);
15921699
for (const r of routes) { routeAnchors.add(r); found++; }
15931700
for (const l of literals) { literalAnchors.add(l); found++; }
@@ -1652,11 +1759,37 @@ function admitAnchor(kind, token, re) {
16521759
const bridgeSymbols = [];
16531760
for (const name of [...symbolAnchors].sort()) {
16541761
if (!admitAnchor('symbol', name, symbolRe(name))) continue;
1655-
// A SCREAMING_SNAKE constant is a data table, not a route's implementation: it is
1656-
// referenced by handlers that merely consult it. Admitted as a doc anchor (it names a
1657-
// real surface — `ERROR_CODE_LEDGER` found 4 pages on 30b1c636a), but kept OUT of the
1658-
// route bridge, where it dragged `/approvals/requests/:id/remind` into a wire-code
1659-
// registration change.
1762+
// TWO KINDS OF SYMBOL ARE DOC ANCHORS BUT NOT BRIDGE SYMBOLS. The bridge's premise is
1763+
// "this name IS some route's implementation, so the handler that implements that route
1764+
// mentions it" — and `parseRegistrarSource` tests that premise by scanning a handler
1765+
// window for the BARE IDENTIFIER. Any name a handler mentions for some OTHER reason
1766+
// satisfies the scan without satisfying the premise, and mints a route (and, through
1767+
// the ledger, an sdk) anchor from a diff that never came near that route.
1768+
//
1769+
// 1. A SCREAMING_SNAKE constant is a data table, not a route's implementation: it is
1770+
// referenced by handlers that merely consult it. `ERROR_CODE_LEDGER` names a real
1771+
// surface (4 pages on 30b1c636a) and stays a doc anchor, but in the bridge it
1772+
// dragged `/approvals/requests/:id/remind` into a wire-code registration change.
1773+
// 2. A CONTAINER name — a class, interface, type, enum, namespace, or a `const`
1774+
// object that owns its keys — is the SCOPE a route's implementation lives in, not
1775+
// the implementation. Handlers mention it as a static-call qualifier
1776+
// (`RestServer.metaTypeSingular(…)`), a `new`, or a type annotation. Measured on
1777+
// 9e2e68206 (#9294): a 27-line edit confined to `probeMcpServeable` — 17 of those
1778+
// lines its doc comment, which attributes to the enclosing class — put `RestServer`
1779+
// in the anchor set, and two handlers calling `RestServer.` statics ~1350 lines away
1780+
// bridged it to `/:type/:name/layers` and `/book/:name/tree`, and thence to
1781+
// `meta.getBookTree` / `getBookTree` on `api/client-sdk.mdx`. Three wrong rows off
1782+
// one qualifier. The cross-cutting cap could not catch it: two routes, cap three.
1783+
//
1784+
// ⭐ NEITHER is an exclusion from the anchor set. `RestServer (symbol)` is a CORRECT
1785+
// row — the edited method really is in that class — and it survives this untouched;
1786+
// only the bridge hop it was feeding is cut. The container's own members are unaffected:
1787+
// most-specific-wins already anchors a changed method body on the METHOD, and a method
1788+
// is a leaf, so `auditMetaItem` → `/:type/:name/audit` → `meta.getAudit` still bridges.
1789+
// The failure direction if a container ever did belong in the bridge is a RECALL miss on
1790+
// the `route`/`sdk` kinds only; the over-report this replaces was on `client-sdk.mdx`,
1791+
// the page the bridge exists to reach, which is where a wrong row costs the most.
1792+
if (!bridgeableSymbols.has(name)) continue;
16601793
if (!/^[A-Z0-9_$]+$/.test(name)) bridgeSymbols.push(name);
16611794
}
16621795
for (const name of [...literalAnchors].sort()) admitAnchor('literal', name, symbolRe(name));

0 commit comments

Comments
 (0)