Skip to content

docs(adr): migrate rotted ADR line anchors to resolver-gated symbol anchors (#13556) - #14277

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-13556-adr-symbol-anchors
Sep 3, 2026
Merged

docs(adr): migrate rotted ADR line anchors to resolver-gated symbol anchors (#13556)#14277
hotlong merged 2 commits into
mainfrom
claude/issue-13556-adr-symbol-anchors

Conversation

@claude

@claude claude Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes #13556

DRAFT, and it stays a draft. docs/adr/** is a governed surface: a human merges this. Never marked ready, never auto-merge.


The ruling this implements (verbatim)

Maintainer 2026-09-01, 逐字「同意」, 总监批 #27 — quoted from the director comment on the card:

  1. A:ADR 行号锚整体迁为符号锚 + resolver 门禁(缺符号变红)—— 与 platform-checklist step (2): re-author the 1,647 source citations as symbol anchors and add a resolver that reds on an absent symbol #13788 已裁方向同构,共享同一个 resolver,⛔ 不造第二套」
  2. C 不作过渡(不考虑渐进):243 个已证烂行号在 A 的一次迁移中同笔消失,⛔ 不先删再迁两道工;B 排除(片段照样烂);D 排除
  3. 普查即工单:343 锚(+52 CONTINUATION = 395 面)的分类表原样作实施输入 —— 11 个跨仓锚(objectui)迁为跨仓符号引用或降为文件级;172 个裸文件名锚保持文件级(无行号可烂);136 个无可查对象的按类降级或删;4 HISTORICAL / 2 EXTERNAL 豁免注记」
  4. ADR-0113 语义反转独立立卡…实施者查无则立,⛔ 不折进迁移 PR;ADR-0119 的陈旧反驳(turso-driver.ts 已存在)同扫」
  5. 受治面:docs/adr/** draft + 人工合并;72.1% 是单向误差下界的申报原样入册」

Premise re-verified before implementing. The census reproduces exactly on today's main: 376 raw tokens → 343 distinct anchors across 35 records, matching the census's own per-ADR counts. Nothing had been migrated.


The anchor grammar

Documented once in scripts/symbol-anchors.mjs's header (ANCHOR_GRAMMAR is that text, so a failure message cannot drift from the spec):

`<dir>/<file>.ts#<symbol>`    symbol anchor — <symbol> must have a declaration site in that file
`<dir>/<file>.ts`             file-level anchor — the file must exist
`objectui:<dir>/<file>.ts`    cross-repo anchor — verified only when a checkout is available
`…#first`, `#second`          continuation — inherits the path from the anchor before it
⛔ `<dir>/<file>.ts:4901`     A LINE NUMBER IS NOT AN ANCHOR FORM.

It satisfies the three properties the form had to have: (a) mechanically verifiable — #symbol is checked against declaration sites, so an absent symbol is a loud red; (b) survives line churn — nothing encodes a position, so an unrelated edit above the target cannot rot it; (c) reads naturally — #fragment is the syntax a reader already knows from URLs, and it survives copy-paste into a GitHub link. Markdown links keep their link; only the rotted #L<n> fragment is dropped, so check-adr-links still cross-checks them.


The resolver's acceptance semantics — what "resolves" actually means

The census's caveat is binding and is why this is not includes(). Comments are stripped from the target before anything is matched, and a token that merely repeats the anchor's own path is refused outright (the census states this rule for its own classifier: otherwise every anchor "resolves" on the word registry / permission / metadata). Two acceptance classes, reported separately and never merged:

class what it accepts count
declaration a declaration site in the target's language — function/class/interface/type/enum/namespace, a const/let/var binding, a member or object-literal key at the start of a line (name:, 'name':, name(, get name(), a named re-export; for markdown a heading (text or slug); for JSON/YAML a key 173
literal the symbol as a complete quoted string token ('sys_metadata'). Exists because much of this platform's vocabulary is DATA identifiers — object and field API names — declared as strings with no binding site. Whole-token only, never a substring 15

literal is deliberately weaker and the migration prefers declaration wherever one exists.

⭐ This confirms the ruling's point 5 — 72.1% was a floor, and the direction is one-way. The census's mechanical test accepted a symbol appearing anywhere in the cited range, including inside a comment. Worked example found while migrating: the census credited object.zod.ts with stateMachines; on today's tree the only occurrence is a comment saying that map no longer exists. Under this resolver it correctly does not resolve, and the anchor drops to file level.

What the gate does NOT claim. It verifies an anchor points at something real. It cannot verify the sentence is true about that code — no static check can. That is a separate defect class with its own card (see the carve-outs).

One scope boundary, stated rather than hidden. Bare file-level path mentions are not judged in this corpus (checkBarePaths: false). Measured, not assumed: judging every bare path in docs/adr/** yields 1,056 findings across all 134 records, 616 distinct paths — abbreviated spellings, sibling-repo paths, and files that moved years ago. A real defect class, but not the line-anchor rot #13556 measured, and folding it in would bury this gate's signal under a corpus-wide cleanup nobody ruled on. It is a per-corpus flag, so a corpus with uniform citations (#13788) can switch it on.


Per-class counts, migrated vs the census

469 occurrences rewritten across 35 records.

disposition count notes
→ symbol anchor 188 173 declaration, 15 literal
→ file-level anchor 211 the ruled downgrade where the sentence names nothing checkable
→ cross-repo file-level 15 objectui: prefix, target NAMED (see below)
dead path — number dropped 10 census's 9 DEAD PATH + 1
duplicate / orphaned continuations removed 24 see below
HISTORICAL / EXTERNAL exempt 6 4 + 2, inline <!-- anchor-exempt: … -->
tilde-form bare numbers 15 a spelling the census did not count

Reconciliation with the census's 343 + 52 = 395. Non-continuation occurrences came to 376 raw — exactly the census's 376 — deduping to 340 under my key against the census's 343 (dedupe-key nuance: one comma-list occurrence covers what the census counted as separate rows). Continuations came to 78, not 52, and the tilde form added 15 more. The real surface was larger than 395, because three spellings the census's extractor could not see were found:

  • comma and slash listsregistry.ts:13,346-389, errors.zod.ts:29-39,147-152, engine.ts:2956/2991
  • cross-line continuations — the census's rule was same-line-only; in this corpus they run on across a paragraph
  • the tilde form~`326`, a bare backticked number with no path at all; ADR-0056 alone held 13

Nine spellings in total are now recognised, each provoked by name in the resolver's self-test.

Per-class dispositions from ruling point 3:

  • 11 cross-repo (objectui) → the ruled fallback. No objectui checkout is in tree, so they are downgraded to file-level anchors that NAME the cross-repo target (objectui:packages/types/src/layout.ts). The resolver verifies them only when $OBJECTUI_CHECKOUT is set; without it they are reported, never red — a check that reddens on the healthy case is the permanently-red gate this repo retired. (15 occurrences of the 11 distinct anchors.)
  • 172 bare-filename anchors → every one disambiguated to a full repo-root path, then symbol-anchored where a symbol resolves and file-level otherwise. ⚠️ Reading declared: I read 「保持文件级」 as a floor for anchors with nothing checkable, not a ban on symbol-anchoring a bare-filename citation — because the adopted four-dimension analysis says under A 「172 个裸文件名锚被迫消歧,正是把模糊消灭在编写期」. Banning it would discard the locating power option A exists to preserve. Flagged for the reviewer.
  • 136 with no checkable object → downgraded to file level, or the number deleted where the path was dead.
  • 4 HISTORICAL / 2 EXTERNAL → annotated inline. ⛔ Adding an exemption is marked MAINTAINER-ONLY in the gate: it is the one remedy that makes the gate quieter instead of satisfying it.

24 continuations removed. A continuation landing on the same (file, symbol) as its parent says nothing new — it was a different line number in the same function, and once the position is gone so is the distinction; keeping them produced #installPackage/#installPackage/#installPackage. It can also be wrong: ADR-0119 reads "implemented by driver-sql (), driver-memory (:595+), and driver-mongodb (:545+)", where inheriting the preceding path would claim driver-memory's method lives in sql-driver.ts. The prose already names the right component; the anchor must not contradict it.


Negative control (evidence)

Run on the migrated tree, mutation reverted before commit — git status clean, verified.

1 — symbol mutated to a nonsense name (RESERVED_NAMESPACESRESERVED_NAMESPACEZZZ in ADR-0029):

BEFORE  ✅ 1854 anchors … 0 line anchors survive.          exit 0
AFTER   ❌ check-adr-symbol-anchors: 1 finding(s) across 134 records.
  [unresolved-symbol] docs/adr/0029-…:59  `packages/objectql/src/registry.ts#RESERVED_NAMESPACEZZZ`
      `RESERVED_NAMESPACEZZZ` has no declaration site or string-literal token in
      `packages/objectql/src/registry.ts`
                                                            exit 1

2 — a line anchor re-introduced (the migration's permanence, ruling point 2):

❌ [line-anchor] docs/adr/0029-…:59  packages/objectql/src/registry.ts:45
      a line number is not an anchor form — cite the symbol
      (`packages/objectql/src/registry.ts#symbol`) or drop to a file-level anchor
                                                            exit 1

Both restored; gate green again, git status --porcelain empty.


20-anchor spot audit

Deterministic sample (seed 13556) over the migrated symbol anchors, each read as ADR sentence vs the newly anchored declaration site. 19 of 20 point at the mechanism the sentence names.

# ADR anchor declaration site
1 0130 stack.zod.ts#validateSingleApp function validateSingleApp(
2 0117 sharing-service.ts#OWNER_FIELD const OWNER_FIELD = 'owner_id'
3 0055 sharing-service.ts#buildReadFilter async buildReadFilter(
4 0118 ai-service.ts#ToolExecutionContext export interface ToolExecutionContext {
5 0055 security-plugin.ts#rlsFilter const rlsFilter = await this.computeRlsFilter(
6 0053 sql-driver.ts#formatInput method declaration
7 0029 object.zod.ts#managedBy managedBy: z.enum([…])
8 0029 protocol.ts#restoreArtifactRegistryView private async restoreArtifactRegistryView(
9 0029 registry.ts#mergeObjectDefinitions function mergeObjectDefinitions(
10 0057 setup-nav.contributions.ts#group_people_org group: 'group_people_org', (literal)
11 0057 team-graph.ts#expandRoleUsers async expandRoleUsers(roleName, organizationId?)
12 0105 execution-context.zod.ts#ExecutionContext export type ExecutionContext =
13 0086 security-plugin.ts#permissions ⚠️ the one miss — repaired, see below
14 0089 field.zod.ts#requiredWhen requiredWhen: ExpressionInputSchema…
15 0130 registry.ts#RESERVED_NAMESPACES export const RESERVED_NAMESPACES = new Set([…])
16 0126 metadata-plugin.zod.ts#DEFAULT_METADATA_TYPE_REGISTRY export const DEFAULT_METADATA_TYPE_REGISTRY:
17 0104 field.zod.ts#LocationCoordinatesSchema export const LocationCoordinatesSchema = lazySchema(
18 0096 security-plugin.ts#getReadFilter getReadFilter: (object, context?) =>
19 0055 security-plugin.ts#computeRlsFilter computeRlsFilter: (sets, o, engineOp, c) =>
20 0130 registry.ts#unregisterObjectsByPackage unregisterObjectsByPackage(packageId, force)

The audit changed the output, which is the point of running it. An earlier pass had picked #opCtx (a bare function parameter), #automation for domains/automation.ts (a token straight off the anchor's own path — the census's named trap), and #permissions/#description/#properties (too generic to locate anything). The path-derived-token rule and a measured weak-token list were added, and every one of those was re-picked or dropped to file level; rows 5 and 19 above are the repaired #opCtx case.

Row 13 is the residual: security-plugin.ts#permissions resolved only to a local const permissions inside an unrelated function. The sentence is locating bootstrapDeclaredRoles, the "exact sibling" it names — and that symbol is not in that file (the census's DEAD PATH verdict on bootstrap-declared-roles.ts says the same from the other side). Repaired to a file-level anchor; ⛔ the sentence itself is untouched.


The #13788 reuse seam

Ruling: 「共享同一个 resolver,⛔ 不造第二套」. The split is structural, not a convention:

  • scripts/symbol-anchors.mjs — grammar, extractor, resolution rule, defineCorpus, sweepCorpus. Sweeps nothing on its own and knows about no corpus.
  • scripts/check-adr-symbol-anchors.mjs — a defineCorpus({ id, label, docRoots, docPattern, crossRepos, checkBarePaths }) call plus a population declaration and an exit contract. 241 lines, almost all of it header.

#13788 joins by adding one registration for the platform-checklist corpus. The knobs it will need already exist and are exercised: docPattern (its corpus is .mdx), crossRepos, and checkBarePaths (its citations are uniform, so it can turn the bare-path check on and get it for free). Why one resolver, mechanically: the expensive part is the resolution rule, and two copies drift silently — each gate stays green on its own corpus while meaning something different by "resolves". One implementation, one self-test.

⚠️ Name note: scripts/check-adr-anchors.mjs already exists and is an unrelated gate (code → ADR citations, the #3723 direction). This is the opposite direction — ADR → code — hence check-adr-symbol-anchors.


Wiring — the gate RUNS

Registered the way sibling gates are, following check-system-context-census:

Checks run

pnpm lint ✅ · symbol-anchors --self-test ✅ · check-adr-symbol-anchors --self-test ✅ (every finding class provoked on a synthetic corpus, healthy anchors silent, population live, wiring pinned) · gate on the migrated tree ✅ 1,854 anchors across 134 records, 0 line anchors survive

Derived from node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, plus the two mandatory for any scripts/** touch:

check-ratchet-remedy-authority ✅ · check:declared-population-live ✅ · check-watch-hint-literal ✅ · check-step-collectors ✅ · check-self-test-wired ✅ · check-adr-links ✅ · check-adr-anchors ✅ · check-doc-anchors ✅ · check-doc-frontmatter ✅ · check-docs-single-h1 ✅ · check-ci-filter-parity ✅ · check-aggregator-roster ✅ · check-required-contexts ✅ · check-adr-0087-registration ✅ · check-empty-changeset ✅ · check-changeset-no-major ✅ · check-self-test-workflow-commands ✅ · check-workflow-status-functions ✅ · check-keyed-text-bounds

Two gates legitimately failed against the new code and were fixed, not worked around: check-declared-population-live (the shared core declared a path-shaped literal in its grammar examples that named no tracked file — the examples are now <dir>/<file>.ts placeholders and the module carries a dispatch-gates: no-path-population marker, since a corpus supplies its own roots) and check-watch-hint-literal (the declaration was spelled WATCH_HINTS, which is not on the roster — now ROOT_DIR_WATCH_HINTS, pinned in the self-test against the roots the corpus actually sweeps).

Changeset route

skip-changeset, by the workflow's own prescription: lint.yml states that "this PR edits a CI-internal script" is the textbook skip-changeset case — such a PR releases nothing. This PR is docs/adr/** + scripts/** + CI wiring and releases nothing user-facing. Label applied.


Carve-outs held OUT of this PR (ruling point 4)

Open questions for the reviewer

  1. The 「172 保持文件级」 reading (above) — floor, or ceiling? I took it as a floor and disambiguated all bare filenames to full paths, symbol-anchoring where a symbol resolves. If the intent was strictly file-level for all 172, that is a one-flag change to the migration.
  2. 61 stale full-path mentions in ADRs outside the census's 35 (e.g. packages/runtime/src/kernel-manager.ts in ADR-0004) — real rot of a different class, found while measuring the bare-path option. Recorded here, ⛔ not fixed, and not carded pending direction on whether it wants its own card.
  3. ADR-0020's object.stateMachines row now describes a map whose only trace in object.zod.ts is a comment saying it no longer exists. Plausibly that ADR's own decision landing rather than a defect, so no card — flagging it rather than deciding it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Mszp61SetNhQfVGvWh9bNn


Generated by Claude Code

…rs (#13556)

The #13556 census enumerated every line anchor in `docs/adr/**` — 343 across
35 of the 134 records, plus 52 continuation anchors — and resolved each against
main. Excluding 4 HISTORICAL and 2 EXTERNAL, 243 of 337 live anchors were
broken: 72.1%, declared as a one-way LOWER bound. Rot tracked target-file CHURN
rather than record age: the four hottest targets were 100% broken across 50
anchors. A rotted line anchor does not fail — it silently points a reader at
unrelated code inside the document whose purpose is to be a durable record.

Maintainer ruling 2026-09-01 (总监批 #27), option A: migrate the corpus to
symbol anchors behind a resolver gate that reds on an absent symbol, in ONE
migration with no transition period, sharing a single resolver with #13788.

- `scripts/symbol-anchors.mjs` — the SHARED core: grammar, extractor and the
  resolution rule. A corpus is a `defineCorpus` registration, so #13788 joins
  by registering rather than by forking the resolver.
- `scripts/check-adr-symbol-anchors.mjs` — the `docs/adr/**` registration.
- 469 occurrences rewritten across 35 records: 188 became symbol anchors (173
  resolved at a declaration site, 15 as a quoted data identifier), 211 dropped
  to file level, 15 to cross-repo file level, 10 dead paths lost their number,
  24 duplicate or orphaned continuations were removed, and 6 HISTORICAL /
  EXTERNAL anchors carry an inline exemption marker.

Resolution is deliberately stricter than the census: comments are stripped
before matching, so a symbol named only in a comment does not resolve, and a
token that merely repeats the anchor's own path is refused. That strictness is
what confirms 72.1% was a floor — `object.zod.ts` was credited with
`stateMachines` by the census, and the only occurrence today is a comment
saying that map no longer exists.

Nine spellings of a line reference are recognised, three of which the census's
own extractor could not see: comma and slash lists, the trailing `+` form, bare
anchors in running prose, fenced comment headers, and the tilde form.

⛔ No ADR's decision content changes; only anchor form does. ADR-0113's Context
row is known to state the inverse of today's mechanism — carded as #14193,
deliberately NOT repaired here, with a note in the record pointing at it.

Refs #13556

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mszp61SetNhQfVGvWh9bNn
`check-step-collectors --self-test` was red on this branch:

    • lint.yml "ADR symbol anchors resolve (no line numbers survive)":
      all green => every command runs (3/2)

The counts are (executed / collected), so three ran where two were collected —
not the reverse. That gate drives the real `run:` block under `bash -e` with a
STUB planted at each collected command's SCRIPT PATH, then holds "all green =>
every command runs" as an equality. The step ended with a bare
`node scripts/check-adr-symbol-anchors.mjs` after the collector's exit guard,
which re-invokes the very stub planted for that script's `--self-test` entry —
a third execution against two collected commands.

Split into two steps: the collector carries the two self-tests and nothing
else, and the production sweep is its own step. That is also the shape both
existing collectors in this file already have, and it restores the property
the split was for — the sweep now runs on its own footing instead of behind
the self-tests' `exit 1`.

⛔ No scope change: `docs/adr/**`, the resolver and its corpus registration are
untouched. Both invocations the gate's own `--self-test` pins in lint.yml are
still present, so the wiring assertion still holds.

Refs #13556

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mszp61SetNhQfVGvWh9bNn
@hotlong
hotlong requested review from hotlong and os-zhuang September 3, 2026 14:36

hotlong commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Maintainer ruling, 2026-09-03, given in chat to the director seat (session_01WXyGTWPbbreqXow7Z2pZCk) on decision batch 1, item 1 (this PR, option A: merge as-is, including the implementer's floor reading of the 172 bare-filename anchors — anchors that resolve to a symbol are symbol-anchored). Verbatim: 「以上都同意,你帮我合并pr」.

Executing on that instruction: marking ready and merging directly (squash) from the maintainer's own account. No approving review is submitted by any seat; under the governed-surface regime the human merge is the review record (see the header of scripts/pm/check-governed-queue-guard.mjs, "the direct merge stays the fallback path"). Governed paths in this PR: docs/adr/** (35 records). CI on head f2d8b5c-class reading: all 33 check runs completed with conclusion success or skipped at merge time.

Of the PR's three open questions, only the 61 stale full paths outside the 35 records needed a maintainer answer; none was given in the batch reply, so the director seat records it as a finding (observation, unassigned) rather than a queue card.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants