From 8087897a7a706086ceb5099f605aff6e49a84809 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 08:42:57 +0000 Subject: [PATCH] feat(docs-audit): state where every emitted anchor came from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each row of the docs-drift advisory now names the declaration that minted its anchor, so a reader can judge the row instead of guessing: organizationId (symbol, a field of interface MetaOverlayCacheKey) userActions (symbol, a field of const object ObjectSchemaBase) Those two are the same syntactic form — `name:` inside an object or interface — and that is the finding this implements: one is the noisiest anchor the tool mints, the other the most valuable, and no syntactic or per-package test separates them. The declaring container does, and no row printed it before. Publication only. No guard, no threshold and no bridge hop reads the new field; the emitted anchor set is byte-identical before and after, with only the rendering differing. `--self-test` pins the provenance key set as exactly the anchor set in both directions, so a later change cannot start deciding with it without going red. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC --- scripts/docs-audit/README.md | 45 +++++ scripts/docs-audit/affected-docs.mjs | 275 +++++++++++++++++++++++++-- 2 files changed, 303 insertions(+), 17 deletions(-) diff --git a/scripts/docs-audit/README.md b/scripts/docs-audit/README.md index 7662c02500..553d4a6468 100644 --- a/scripts/docs-audit/README.md +++ b/scripts/docs-audit/README.md @@ -63,6 +63,51 @@ false positive: `const singular = request.type;` inside a method body is not an longer listed. A `const` object **is** a container (its keys are metadata property names, which docs do name); a function body is not. +### Every row says WHERE its anchor came from (#12824) + +A row used to read `organizationId (symbol)`, and a reader had no way to tell the single +most on-target anchor the tool mints from pure noise. Now it reads: + +``` +- `content/docs/deployment/seed-tenancy-repair.mdx` _(via organizationId (symbol, a field of interface MetaOverlayCacheKey))_ +- `content/docs/data-modeling/objects.mdx` _(via userActions (symbol, a field of const object ObjectSchemaBase))_ +``` + +Those two are **the same syntactic form** — `name:` inside an object or interface — and +that is the point. One is a field of an internal cache struct and lands 10 pages that +document an unrelated `organizationId`; the other is the canonical authorable key, and its +row is the best one this tool produces. No syntactic test separates them (measured: the +declaring *package* does not either — `schemaMode` is authorable and lives in +`packages/objectql`). The **declaring container** does, and it is the field no row printed +before. + +Each anchor kind names its own origin, from the same field the JSON publishes as +`anchors[].from` — one derivation, never a second spelling: + +| kind | the clause | +|:--|:--| +| `symbol` | `a field of interface MetaOverlayCacheKey` · `a method of class RestServer` · `a top-level function` | +| `route` | `a path literal in RestServer` · `bridged from symbol enforceEnvironmentOwnership — its registrar handler names it` | +| `sdk` | `the route ledger binds it to GET /api/v1/ui/view/:object/:type` | +| `literal` | `a string literal in cacheKeyOf` | +| `command` | `read off packages/cli/src/commands/environments/bind.ts` | +| `rule` | `a @docs-rule block in packages/objectql/src/engine.ts` | + +The bridge clauses are load-bearing for the same reason: an `sdk` row on a diff that never +came near that route is the amplification this machinery is measured to produce, and it is +only judgeable when the row names the hop it rode. + +⛔ **This is publication, not discrimination, and the difference is the ruling.** The +maintainer's decision of 2026-08-31 took this (option C) and ruled OUT filtering on it +(option B): dropping data-property anchors buys a 17.9% shorter list and pays in the +`userActions` → `data-modeling/objects.mdx` and `schemaMode` → `data-modeling/drivers.mdx` +rows. A false positive costs a reader a minute; a false negative ships a falsified page. +So no guard, no threshold and no bridge hop reads `from` — `--self-test` pins that the +provenance key set is exactly the anchor set, in both directions, so a future change +cannot start deciding with it without going red. Container-qualified *discrimination* +lives on #13712 (the spec half) and #13713 (the docs-audit half), and needs a TS-name → +spec-name mapping this does not have. + ### Two guards, and both publish what they removed The first build of this derivation was, on some PRs, *noisier* than the proxy it replaced diff --git a/scripts/docs-audit/affected-docs.mjs b/scripts/docs-audit/affected-docs.mjs index 5abd9b27b6..be15848692 100644 --- a/scripts/docs-audit/affected-docs.mjs +++ b/scripts/docs-audit/affected-docs.mjs @@ -317,6 +317,69 @@ const DECL_PATTERNS = [ { kind: 'member', container: false, re: /^\s*(?:(?:public|private|protected|static|readonly|abstract|override|async|get|set)\s+)*\*?\s*([A-Za-z_$][\w$]*)\s*(?:\?\s*)?(?:[(<:]|=[^=>])/ }, ]; +/** + * How each declaration kind READS in an anchor's provenance clause (#12824). + * + * ⭐ RENDERING ONLY. Everything in this block and the two functions below it exists to + * SAY where an anchor came from; not one of them is consulted by `admitAnchor`, by either + * guard, or by the bridge. The anchor set a run emits is byte-identical with the whole + * block deleted — which is the property #12824's ruling asks for ("零 recall 变化、不动 + * 阈值") and which `--self-test` pins directly (see `provenanceCases`). + * + * A `binding` splits, because the split is the reader's whole question: `export const + * ObjectSchema = z.object({` owns authorable keys and reads as a **const object**, while a + * plain `const` is a value. That is `declarationOn`'s own `isContainer` verdict, reused + * rather than re-derived. + */ +const DECL_NOUN = { + class: 'class', interface: 'interface', enum: 'enum', namespace: 'namespace', + type: 'type', function: 'function', binding: 'const', member: 'member', +}; + +/** The noun for one declaration — `binding` reads as `const object` when it owns keys. */ +const declNoun = (d) => (d.kind === 'binding' ? (d.container ? 'const object' : 'const') : (DECL_NOUN[d.kind] || d.kind)); + +/** `a`/`an`, so a provenance clause reads as English rather than as a template. */ +const article = (word) => (/^[aeiou]/i.test(word) ? 'an' : 'a'); + +/** + * Is a matched MEMBER a data property or a callable? `name(` / `name<` are callable; + * `name:`, `name?:` and `name =` declare data. THIS IS THE CONSTRUCT #12824 IS ABOUT — + * the same syntactic form mints the best anchor the tool has (`userActions`, a key of an + * authorable spec object) and the worst (`organizationId`, a field of an internal cache + * struct) — so the clause names which one it saw and lets the reader judge the row. + * + * ⛔ It decides NOTHING. A member whose form this cannot read degrades to the neutral + * `member`, never to a dropped or reweighted anchor: option B — discriminating ON this + * distinction — is ruled down on two rounds of measurement, and this function is the + * deliberate opposite of it (publish the distinction, act on none of it). + */ +function memberFormOn(line, name) { + const esc = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const m = String(line).match(new RegExp(`(?= 0; i--) { const line = lines[i]; if (!line.trim()) continue; @@ -1061,7 +1126,7 @@ function declarationChainAt(lines, idx) { const li = indentOf(line); if (li >= indent) continue; const d = declarationOn(line); - if (d) chain.push({ ...d, indent: li }); + if (d) chain.push({ ...d, indent: li, line }); indent = li; if (li === 0) break; } @@ -1084,6 +1149,13 @@ function declarationChainAt(lines, idx) { * `bridgeSymbols` block in §3b). It is read off the winning declaration rather than * from the branch, so a container reached as `inner` (an interface nested in a * namespace) is reported the same as one reached as the fallback. + * + * `from` is the third field and it is PURE REPORTING (#12824): the clause naming the + * declaration that minted this anchor, so an emitted row reads "via `organizationId`, a + * field of interface `MetaOverlayCacheKey`" instead of leaving the reader to guess whether + * that is an authorable key or an internal cache struct. ⛔ It is not consulted here, in + * `symbolAnchorsFromSource`, in `admitAnchor` or in the bridge — the two returns below + * select exactly the names they selected before it existed. */ function documentableDeclarationsAt(lines, idx) { const chain = declarationChainAt(lines, idx); @@ -1091,8 +1163,8 @@ function documentableDeclarationsAt(lines, idx) { const outer = chain[chain.length - 1]; const inner = chain.length > 1 ? chain[chain.length - 2] : null; const usable = (d) => d && !GENERIC_ANCHOR_NAMES.has(d.name) && !GENERIC_ANCHOR_NAMES.has(d.name.toLowerCase()) && d.name.length >= 3; - if (inner && outer.container && usable(inner)) return [{ name: inner.name, container: !!inner.container }]; - if (usable(outer) && outer.kind !== 'member') return [{ name: outer.name, container: !!outer.container }]; + if (inner && outer.container && usable(inner)) return [{ name: inner.name, container: !!inner.container, from: declarationProvenance(inner, outer, inner.line) }]; + if (usable(outer) && outer.kind !== 'member') return [{ name: outer.name, container: !!outer.container, from: declarationProvenance(outer, null, outer.line) }]; return []; } @@ -1154,16 +1226,42 @@ function routePatternFor(tail) { return new RegExp(`/${body}(?![\\w-])`); } +/** + * Accumulate provenance clauses for one token, in a `Map>` (#12824). + * A token reached through more than one declaration keeps every clause — the row then + * says so, rather than silently picking one and reading like the only answer. + */ +function noteFrom(map, token, clause) { + if (!clause) return; + let set = map.get(token); + if (!set) map.set(token, (set = new Set())); + set.add(clause); +} + /** Route tails and identifier-shaped string literals appearing on the changed lines. */ function literalAnchorsFromLines(lines, changed) { const routes = new Set(); const literals = new Set(); + // WHERE each one sat, for the emitted row (#12824). Derived lazily — the enclosing + // declaration is only walked for a line that actually yielded a literal, so a run that + // mints none pays nothing. Reporting only: no route and no literal is admitted, + // dropped or reordered by anything below. + const from = new Map(); for (const n of changed) { const line = lines[n - 1]; if (line === undefined) continue; + let enclosing; + const enclosingName = () => { + if (enclosing === undefined) enclosing = documentableDeclarationsAt(lines, n - 1)[0] || null; + return enclosing ? enclosing.name : null; + }; for (const m of line.replace(/\$\{[^}]*\}/g, '').matchAll(/(?:\/[A-Za-z0-9_:.$*{}-]+){2,}/g)) { const tail = routeTailOf(m[0]); - if (tail) routes.add(tail); + if (tail) { + routes.add(tail); + const where = enclosingName(); + noteFrom(from, tail, where ? `a path literal in ${where}` : 'a path literal on a changed line'); + } } for (const m of line.matchAll(/['"]([A-Za-z][\w.$-]{3,63})['"]/g)) { const lit = m[1]; @@ -1172,9 +1270,11 @@ function literalAnchorsFromLines(lines, changed) { // ('ignore', 'utf8') is not a surface anyone documents by that spelling. if (!/^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/.test(lit) && !/^[a-z]+(?:[A-Z][A-Za-z0-9]*)+$/.test(lit) && !/^[a-z][a-z0-9]*(?:\.[a-z][A-Za-z0-9]*)+$/.test(lit)) continue; literals.add(lit); + const where = enclosingName(); + noteFrom(from, lit, where ? `a string literal in ${where}` : 'a string literal on a changed line'); } } - return { routes, literals }; + return { routes, literals, from }; } /** @@ -1190,14 +1290,19 @@ function symbolAnchorsFromSource(text, changed) { const lines = text.split('\n'); const names = new Set(); const bridgeable = new Set(); + // The #12824 third answer: `Map>`, the declaration each name was + // minted from. Accumulated like `names` and never consulted by it — deleting the + // `noteFrom` line below leaves both sets byte-identical. + const from = new Map(); for (const n of changed) { if (n - 1 < 0 || n - 1 >= lines.length) continue; for (const d of documentableDeclarationsAt(lines, n - 1)) { names.add(d.name); + noteFrom(from, d.name, d.from); if (!d.container) bridgeable.add(d.name); } } - return { names, bridgeable }; + return { names, bridgeable, from }; } /** @@ -2611,6 +2716,84 @@ function selfTest() { check('symbolAnchorsFromSource', label, `line ${line}`, JSON.stringify(want), JSON.stringify([...anchorsAt(schemaSource, line)])); } + // ---- anchor PROVENANCE (#12824) ------------------------------------------- + // The ruling of 2026-08-31 took option C — say where each anchor came from — and ruled + // OUT option B, discriminating on it. These pins hold both halves: the clause is + // derived and correct, AND it moves no anchor. + // + // The two fixtures below are THE case the card is about, and they are deliberately the + // same syntactic form: `name:` inside an object/interface. One is the noisiest anchor + // the tool mints, the other is the most valuable one — disproven discriminator #1 on + // the card is exactly that no syntactic test separates them. The container does, and + // that is the field these rows now print. + const cacheStructSource = [ + '/** The identity of one cached overlay read. */', + 'export interface MetaOverlayCacheKey {', + ' type: string;', + ' packageId?: string;', + ' organizationId?: string;', + '}', + ].join('\n'); + const authorableSource = [ + 'const ObjectSchemaBase = strictObject({', + ' userActions: strictObject({', + ' create: z.boolean().optional(),', + ' }),', + '});', + ].join('\n'); + const fromAt = (src, lineNo) => { + const d = documentableDeclarationsAt(src.split('\n'), lineNo - 1)[0]; + return d ? `${d.name} — ${d.from}` : null; + }; + const provenanceCases = [ + [cacheStructSource, 5, 'organizationId — a field of interface MetaOverlayCacheKey', + 'the NOISY face: a data property of an internal cache struct, and the row now says so'], + [authorableSource, 2, 'userActions — a field of const object ObjectSchemaBase', + 'the VALUABLE face: the same `name:` form on an authorable spec object — option B dropped this row, C prints it'], + [protocolSource, 9, 'auditMetaItem — a method of class ObjectStackProtocolImplementation', + 'a changed method body names its method AND the class it lives in'], + [protocolSource, 1, 'ObjectStackProtocolImplementation — a top-level class', + 'a top-level declaration says so rather than naming a container it has not got'], + [schemaSource, 2, 'controlled_by_parent — a field of const object ObjectSchema', + 'a schema KEY reads as a field of the const object that owns it'], + [schemaSource, 6, 'buildObject — a top-level function', + 'a local inside a function anchors on the function, and the clause says top-level'], + ]; + for (const [src, line, want, label] of provenanceCases) { + check('documentableDeclarationsAt.from', label, `line ${line}`, want, fromAt(src, line)); + } + + // The member FORM is what the two faces above differ by, so it is pinned on its own — + // including its degraded answer, which is the shape that guarantees a form this cannot + // read costs a WORD and never an anchor. + const memberFormCases = [ + [' organizationId?: string;', 'organizationId', 'field', 'an optional data property'], + [' userActions: strictObject({', 'userActions', 'field', 'a schema key'], + [' async auditMetaItem(request: {', 'auditMetaItem', 'method', 'a class method'], + [' getHistory?(type: string): Promise;', 'getHistory', 'method', 'an optional interface method'], + [' handler = () => {};', 'handler', 'field', 'a class field initialised with an arrow'], + [' somethingUnreadable', 'somethingUnreadable', 'member', 'a form this cannot read degrades to the neutral word — never to a dropped anchor'], + ]; + for (const [line, name, want, label] of memberFormCases) { + check('memberFormOn', label, line.trim(), want, memberFormOn(line, name)); + } + + // ⭐ THE INVARIANT THAT MAKES "零 recall 变化" A PROPERTY OF THE CODE. Provenance is a + // parallel map, so the only way it could move the list is by introducing or withholding + // a NAME. Both directions are pinned: its key set is exactly the anchor set, never a + // superset (a name nothing minted) and never a subset (an anchor with no clause, which + // is how a row would silently go back to being unjudgeable). + for (const [src, line, label] of [ + [protocolSource, 9, 'a method body'], + [cacheStructSource, 5, 'a data property of an internal struct'], + [authorableSource, 2, 'a data property of an authorable schema'], + [schemaSource, 6, 'a function-local line'], + ]) { + const got = symbolAnchorsFromSource(src, [line]); + check('symbolAnchorsFromSource.from', `the provenance key set IS the anchor set — ${label}`, `line ${line}`, + JSON.stringify([...got.names].sort()), JSON.stringify([...got.from.keys()].sort())); + } + // Statement heads must never be read as declarations — `if (x) {` has the same shape as // a class member, and a control-flow line becoming an anchor is silent noise. const declCases = [ @@ -4579,6 +4762,14 @@ const ruleAnchors = new Set(); const unmappedCommandFiles = []; const unanchoredRuleBlocks = []; const anchorlessChanges = []; +// WHERE EVERY ANCHOR CAME FROM (#12824), keyed `kind::token` — the kinds carry no colon, +// so the key is unambiguous whatever a `rule` span contains. Every emitted row reads its +// clause from here, and NOTHING else does: no guard, no bridge and no admission consults +// this map, which is what makes "零 recall 变化" a property of the code rather than a +// claim about it. A token with several origins keeps them all. +const anchorFrom = new Map(); +const noteAnchorFrom = (kind, token, clause) => noteFrom(anchorFrom, `${kind}::${token}`, clause); +const anchorFromOf = (kind, token) => [...(anchorFrom.get(`${kind}::${token}`) || [])].sort(); const readAt = (ref, file) => { try { return sh(`git show ${ref}:${file}`); } catch { return null; } @@ -4590,7 +4781,12 @@ for (const f of implementationChanges) { // not in the contents. const cmd = commandAnchorFor(f); if (cmd?.unmapped) unmappedCommandFiles.push(f); - if (cmd?.token) commandAnchors.set(cmd.token, { id: cmd.id, bins: cmd.bins }); + if (cmd?.token) { + commandAnchors.set(cmd.token, { id: cmd.id, bins: cmd.bins }); + // The token IS the command phrase, so restating its id here says nothing; the FILE + // the id was read off is the fact the row does not already carry. + noteAnchorFrom('command', cmd.token, `read off ${f}`); + } if (!/\.(?:ts|tsx|js|mjs|cjs)$/.test(f)) { anchorlessChanges.push(f); continue; } let diffText = ''; try { diffText = sh(`git diff -U0 ${baseRef} HEAD -- ${JSON.stringify(f)}`); } catch { /* keep empty */ } @@ -4607,12 +4803,15 @@ for (const f of implementationChanges) { const sym = symbolAnchorsFromSource(text, changed); for (const name of sym.names) { symbolAnchors.add(name); found++; } for (const name of sym.bridgeable) bridgeableSymbols.add(name); - const { routes, literals } = literalAnchorsFromLines(text.split('\n'), changed); + for (const [name, clauses] of sym.from) for (const c of clauses) noteAnchorFrom('symbol', name, c); + const { routes, literals, from: literalFrom } = literalAnchorsFromLines(text.split('\n'), changed); for (const r of routes) { routeAnchors.add(r); found++; } for (const l of literals) { literalAnchors.add(l); found++; } + for (const r of routes) for (const c of literalFrom.get(r) || []) noteAnchorFrom('route', r, c); + for (const l of literals) for (const c of literalFrom.get(l) || []) noteAnchorFrom('literal', l, c); const rule = ruleAnchorsFromSource(text, changed); if (rule.touched) ruleBlockTouched = true; - for (const s of rule.spans) { ruleAnchors.add(s); ruleSpansHere++; found++; } + for (const s of rule.spans) { ruleAnchors.add(s); ruleSpansHere++; found++; noteAnchorFrom('rule', s, `a ${DOCS_RULE_TAG} block in ${f}`); } } // A tagged block that changed and produced nothing is a DECLARED blind spot, published // like `unmappedCommandFiles` rather than left to be inferred from a gap. The file may @@ -4662,7 +4861,9 @@ function admitAnchor(kind, token, re) { const docs = []; for (let i = 0; i < handwritten.length; i++) if (re.test(docTexts[i])) docs.push(i); if (docs.length > overbroadLimit) { overbroadAnchors.push(`${token} (${kind}, ${docs.length} pages)`); return false; } - anchors.push({ kind, token }); + // The clause rides ALONG with the admitted anchor; it is read after both guards have + // already spoken, so it cannot participate in either verdict (#12824). + anchors.push({ kind, token, from: anchorFromOf(kind, token) }); hitsByAnchor.push(docs); return true; } @@ -4774,23 +4975,44 @@ if (bridgeSymbols.length) { } for (const [s, tails] of routesBySymbol) { if (tails.size > MAX_ROUTES_PER_SYMBOL) { crossCuttingSymbols.push(`${s} (${tails.size} routes)`); continue; } - for (const t of tails) routeAnchors.add(t); + for (const t of tails) { + routeAnchors.add(t); + noteAnchorFrom('route', t, `bridged from symbol ${s} — its registrar handler names it`); + } } // route → client method (the ledger's declared binding), and the reverse direction for // free: a changed SDK method name pulls in the route it is bound to. for (const { route, client } of ledgerRows) { if (!client) continue; const tail = client.split('.').pop(); - if ([...routeAnchors].some((t) => route.endsWith(t))) { + // `.find` where this used to say `.some`, over the SAME insertion-ordered snapshot: + // the boolean is identical (first match / any match), and capturing the selecting tail + // is what lets the emitted row name the hop it rode (#12824). The amplification this + // card records — an `sdk` row on a diff that never came near that route — is only + // judgeable when the row says which route anchor selected it. + const selectedBy = [...routeAnchors].find((t) => route.endsWith(t)); + if (selectedBy !== undefined) { sdkAnchors.add(client); + // The selecting tail is named only when it is not simply the route itself — on the + // common case the two spellings are the same path and printing both is noise. + const bare = route.replace(/^[A-Z]+\s+/, ''); + noteAnchorFrom('sdk', client, bare === selectedBy + ? `the route ledger binds it to ${route}` + : `the route ledger binds it to ${route}, selected by route anchor ${selectedBy}`); // The BARE tail is an anchor only when its own spelling is distinctive. // `getBookTree` identifies one method; `import` / `query` / `revoke` are English, // and matching them corpus-wide put 116 and 84 pages on the list respectively // (measured, 0668f02a6). The dotted form (`data.query`) stays, and it is precise. - if (tail && isCodeShaped(tail) && !GENERIC_ANCHOR_NAMES.has(tail.toLowerCase())) sdkAnchors.add(tail); + if (tail && isCodeShaped(tail) && !GENERIC_ANCHOR_NAMES.has(tail.toLowerCase())) { + sdkAnchors.add(tail); + noteAnchorFrom('sdk', tail, `the bare tail of client method ${client}, bound to ${route}`); + } } else if (tail && bridgeSymbols.includes(tail)) { const routeTail = routeTailOf(route.replace(/^[A-Z]+\s+/, '')); - if (routeTail) routeAnchors.add(routeTail); + if (routeTail) { + routeAnchors.add(routeTail); + noteAnchorFrom('route', routeTail, `the route ledger binds it to client method ${client}`); + } } } } @@ -4801,12 +5023,29 @@ for (const name of [...sdkAnchors].sort()) admitAnchor('sdk', name, dottedRe(nam for (const tail of [...routeAnchors].sort()) admitAnchor('route', tail, routePatternFor(tail)); // --- 3c. the pages that name a surviving anchor ---------------------------- +/** + * ONE `via` clause of an emitted row — the anchor, its kind, and WHERE it came from + * (#12824). Before this, a row read `organizationId (symbol)` and a reader had no way to + * tell the single most on-target anchor the tool mints from pure noise: the same `name:` + * form declares `userActions` on an authorable spec object and `organizationId` on an + * internal cache struct, and the container is the only thing that separates them. Now it + * reads `organizationId (symbol, a field of interface MetaOverlayCacheKey)`. + * + * ⛔ Rendering only, and deliberately so. The ruling this implements (2026-08-31) took + * option C — publish the provenance — and ruled OUT option B, discriminating on it: the + * `-17.9%` a data-property filter buys was measured against an oracle that could see 10 of + * 46 pages (re-derived to 48.8% on #13306), and the rows it drops include `userActions` → + * `data-modeling/objects.mdx` and `schemaMode` → `data-modeling/drivers.mdx`. A false + * negative here ships a falsified page; a false positive costs a reader a minute. + */ +const anchorRow = (a) => `${a.token} (${a.kind}${a.from && a.from.length ? `, ${a.from.join('; ')}` : ''})`; + const affectedByDoc = new Map(); for (let k = 0; k < anchors.length; k++) { for (const i of hitsByAnchor[k]) { let via = affectedByDoc.get(i); if (!via) affectedByDoc.set(i, (via = [])); - via.push(`${anchors[k].token} (${anchors[k].kind})`); + via.push(anchorRow(anchors[k])); } } const affected = []; @@ -4868,7 +5107,9 @@ emit( affected, { testFilesSkipped, scriptFilesSkipped, devOnlyManifestsSkipped }, { - anchors: anchors.map((a) => ({ kind: a.kind, token: a.token })), + // `from` is the same clause the rows above render, from the same field — never a + // second derivation, which is how the list and the rows start disagreeing (#12824). + anchors: anchors.map((a) => ({ kind: a.kind, token: a.token, from: a.from })), anchorlessChanges, unmappedCommandFiles, unanchoredRuleBlocks,