Skip to content

ci(check-links): bring docs/adr/ under a link gate, with fence/code-span discrimination and a shrink-only baseline - #6731

Merged
os-project-manager merged 3 commits into
mainfrom
claude/issue-6592-adr-link-checking
Aug 8, 2026
Merged

ci(check-links): bring docs/adr/ under a link gate, with fence/code-span discrimination and a shrink-only baseline#6731
os-project-manager merged 3 commits into
mainfrom
claude/issue-6592-adr-link-checking

Conversation

@os-project-manager

@os-project-manager os-project-manager commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Fixes #6592

docs/adr/ was outside every link gate. .github/workflows/check-links.yml runs lychee over exactly content/**/*.md, content/**/*.mdx, README.md, so the 527 relative destinations the decision registry writes were checked by nothing — and records link each other heavily (most carry a **Builds on**: line with 3-10 of them). The break #6593 fixed (ADR-0057 pointing at a ./0010-metadata-protection.md that has never existed) survived long enough for a triage comment to inherit and repeat its wrong attribution.

This adds scripts/check-adr-links.mjs and runs it as a second step in the same advisory Check Links job.

The gate lands ADVISORY, and stays advisory

Check Links is not in the required set and carries no merge_group trigger, by the #6028 ruling; this step inherits both. Nothing in this PR promotes it.

That is also a recommendation, not just compliance: the extractor is new, and its one interesting behaviour (skipping verbatim regions) is exactly the kind of rule that is discovered to be slightly wrong by meeting real documents. Advisory-first lets that happen without holding anyone's merge. When it is promoted, merge_group MUST be added in the same change or the queue stalls on a required check that never reports (#6121) — the note is already in the workflow header.

Discrimination: fenced blocks and code spans, not files

ADR-0046 is the record that defines the package-docs link convention, so it documents the convention by example, naming a doc that deliberately does not exist here:

line shape
30 (`[guide](./crm_lead_guide.md)`) — inline code span
163 [lead guide](./crm_lead_guide.md#qualification) — inside a ```md fence
188 the image form of the same shape (bang + empty label + an ellipsis destination), inside an inline code span

Those three are correct as written, so extraction strips fenced blocks and code spans before it looks for links. ⛔ Excluding the FILE (lychee's exclude_path, the tempting one-liner) is the recorded trap: it would blind the gate to that record's 4 real cross-links forever.

One correction to the issue's premise, measured rather than assumed. The naive lychee widening does not go red on ADR-0046. lychee's Markdown extractor works off a CommonMark parse, so verbatim text is never a link event to begin with:

$ lychee --offline --config lychee.toml 'docs/adr/0046-package-docs-as-metadata.md'
🔍 4 Total 🔗 4 Unique ✅ 4 OK 🚫 0 Errors
# and with --include-verbatim, which adds only two EXCLUDED http links:
🔍 6 Total 🔗 5 Unique ✅ 4 OK 🚫 0 Errors 👻 2 Excluded

(Measured on lychee 0.24.2, the version lycheeverse/lychee-action@v2 pins, built from source locally.) The issue's "3 unresolved" came from a raw ](./…) regex sweep — which is exactly the shape this script is. So the discrimination requirement is real here even though it was not real for lychee, and it is asserted in --self-test rather than assumed.

What actually blocked the naive widening: 8 pre-existing breaks

$ lychee --offline --root-dir $PWD/content --fallback-extensions mdx,md \
         --config lychee.toml 'docs/adr/**/*.md'
[docs/adr/0004-cloud-multi-kernel.md]:
[ERROR] .../apps/studio/src/routes/projects.$projectId.index.tsx (at 94:19) | File not found
[ERROR] .../apps/studio/src/routes/projects.index.tsx            (at 93:17) | File not found
[ERROR] .../packages/runtime/src/kernel-manager.ts               (at 67:24) | File not found
[ERROR] .../packages/runtime/src/project-kernel-factory.ts       (at 68:38) | File not found
[docs/adr/0020-state-machine-converge-and-enforce.md]:
[ERROR] .../examples/app-crm/src/flows/high-value-deal.flow.ts            (at 138:240) | File not found
[ERROR] .../examples/app-crm/src/flows/stale-opportunity.flow.ts         (at 138:327) | File not found
[ERROR] .../examples/app-crm/src/workflows/stale-opportunity.workflow.ts (at  48:108) | File not found
[ERROR] .../packages/spec/src/contracts/workflow-service.ts              (at  43:23)  | File not found
🔍 649 Total 🔗 227 Unique ✅ 525 OK 🚫 8 Errors 👻 116 Excluded

All 8 are ADR → source-tree links whose targets left this repository (apps/ here now holds only docs/; the runtime/spec files are gone outright). None is a filename near-miss, so none is a trivially-safe correction — and this PR is barred from editing docs/adr/ this round anyway. Filed as #6726 with the per-link evidence.

They are frozen individually on KNOWN_DEAD_TARGETS, the same shrink-only-baseline shape check-adr-anchors's KNOWN_NUMBER_COLLISIONS, check:role-word and the slot-lookup ratchet already use. Shrink-only in both directions: a new dead link fails, and a baseline entry that no longer matches a live finding fails as stale, so whoever fixes ADR-0004/0020 is told to delete the entry in the same change. The baseline cannot outlive its excuse.

That is also why docs/adr/**/*.md is NOT simply added to the lychee globs, and the workflow now says so at the glob site: lychee has no shrink-only exclusion. .lycheeignore / exclude never expire and never tell you an entry stopped being needed, and widening the globs today would have made the shared Check Links job red on every open PR from the moment it merged — which is how an advisory lane becomes a lane nobody reads. check-links.yml already sat dormant for six months once.

Why a repo-owned script rather than lychee

  • docs/adr/ is already governed by a repo-owned Node gate (check-adr-anchors.mjs: record filenames, ADR-number uniqueness). Splitting one registry's rules across a Node script and a Rust binary means neither file is where you look.
  • It runs offline with plain node, in any container, with zero dependencies — which is why the workflow step needs no setup-node / corepack / pnpm install. lychee cannot offer that: it is installed by the action at CI time, and lychee.toml's own header asks authors to "run lychee against it locally before pushing", an instruction that costs a from-source Rust build in an agent container (this PR paid it once, deliberately, to get the numbers above).
  • Only a script can express the shrink-only baseline above.

Coverage parity, measured. Dumping both extractors over docs/adr/**/*.md (lychee --dump vs extractRelativeLinks): every file destination lychee finds, this finds. lychee's only extras are 4 bare same-document anchors ([Phasing](#phasing)), which name no file to resolve and are out of scope — include_fragments = "none" does not check fragments either.

Verification

Sweep of the current surface. 527 relative destinations resolve; 8 frozen; 0 ADR-to-ADR breaks — the class the card was filed for is clean today, after #6593 fixed ADR-0057's.

Reverse verification — the gate names a real break. A scratch record was added under docs/adr/ carrying the exact #5992 dead target plus both illustrative shapes, then removed (never committed):

### RED probe:
❌ check-adr-links

1 broken relative link(s) under docs/adr/:
  docs/adr/0999-scratch-link-probe.md:3  ->  ./0010-metadata-protection.md
      resolves to: docs/adr/0010-metadata-protection.md (missing)
exit=1

### GREEN after removing the probe:
✅ check-adr-links: 527 relative link destination(s) under docs/adr/ resolve (8 frozen on the shrink-only baseline)
exit=0

Note the direction: the probe also carried a code-span link and a ```md fenced link to the same nonexistent doc, and the gate reported 1 finding, not 3.

The ADR-0046 pin. --self-test asserts against the real record, both halves: the three illustrative destinations must still be in the file (otherwise the pin measures nothing and fails as stale, telling you to re-point it), and none of them may reach the extractor. Plus synthetic fixtures for tilde fences, http destinations, bare anchors, line-number survival through stripping, a red-to-green transition when the missing target appears, and the stale-baseline path.

A green run over nothing is not a green run. The sweep fails if the census of repo-relative destinations is zero, so a broken extractor cannot pass as a clean tree.

Gates run locally (enumerated from .github/workflows/lint.yml, after commit)

pnpm lint                                PASS (eslint . --no-inline-config, clean)
pnpm check:nul-bytes                     PASS (6259 tracked text files, no raw control bytes)
pnpm check:workflow-status-functions     PASS (22 workflows, 41 jobs, 24 job-level if:)
pnpm check:adr-anchors                   PASS
pnpm check:adr-links                     PASS (new)
pnpm check:doc-authoring                 PASS (365 files clean)
pnpm check:docs-audit-scope              PASS
pnpm check:role-word                     PASS
pnpm check:quick-reference-counts        PASS
pnpm check:node-version                  PASS
pnpm check:published-files               PASS
pnpm check:release-notes                 PASS

check:workflow-status-functions was run specifically because this PR edits a workflow; it does a real YAML parse of all 22, so it also confirms the new step parses.

No changeset

CI configuration plus a repo-internal gate script: nothing published changes, no user-visible behaviour. Labelled skip-changeset.

`.github/workflows/check-links.yml` 只扫 content/** 与 README.md,docs/adr/
从未被检查过 —— 决策记录之间互相引用极密集(多数 `**Builds on**:` 行带
3-10 条相对链接),#5992 修掉的那条死链(0057 指向不存在的
`./0010-metadata-protection.md`)因此长期存活,久到一条 triage 评论把错误
归属继承并重复了一遍。

新增 `scripts/check-adr-links.mjs`(零依赖,`node` 直接跑),在同一条
advisory 车道(check-links.yml)里解析 docs/adr/ 的每一条相对链接目标。

判别机制:提取前先剥掉**围栏代码块与行内代码 span**。ADR-0046 是定义
package-docs 链接约定的那份记录,它按约定举例,故意写了本仓库不存在的
`./crm_lead_guide.md`(第 30 行代码 span、第 163 行 ```md 围栏、第 188 行
`![](…)`)—— 这三处写法正确,该学会读它们的是门,不是让文档迁就门。
⛔ 按文件 exclude 是被记录在案的陷阱:那会永久遮蔽该记录的 4 条真实互链。

8 条既有死链(全部是 ADR → 源码树、目标已移出本仓库)冻结在
`KNOWN_DEAD_TARGETS` 只减不增基线上,形状与 `check-adr-anchors` 的
`KNOWN_NUMBER_COLLISIONS`、`check:role-word` 一致:新增死链红,基线条目
不再对应死链也红(stale),基线活不过它的理由。lychee 的 `.lycheeignore`
表达不了这一点,而实测把 `docs/adr/**/*.md` 直接加进 lychee glob 会当场
报 8 条错 —— 让一条 advisory 车道从落地那天起长红,就是没人再看的门。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn
@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 8, 2026 5:52pm

Request Review

@github-actions github-actions Bot added the size/l label Aug 8, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review August 8, 2026 13:46
@github-actions github-actions Bot added ci/cd dependencies Pull requests that update a dependency file labels Aug 8, 2026
@os-project-manager os-project-manager added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 8, 2026 — with Claude
@os-project-manager
os-project-manager added this pull request to the merge queue Aug 8, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Aug 8, 2026
…package.json scripts conflict)

package.json 冲突:check:adr-anchors 之后本分支加了 check:adr-links,
main(#6732 之后)加了 check:platform-checklist —— 两行都保留。
其余文件全部干净合并;check-links.yml 的 ADR 链接门与 main 加宽后的
check-adr-anchors 全树引用审计互补共存(链接 vs 引用),合并树上两门均绿。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn
main 在第一次合并后又前进了一个提交(#6770,仅触及 packages/metadata-protocol
与 changeset),与本分支无重叠,干净合并。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn

Copy link
Copy Markdown
Collaborator Author

Merge-queue eviction (MERGE_CONFLICT) resolved — branch is now current with origin/main via merge commits c916e169c + 6a315fa61 (no force-push).

What conflicted: one hunk, in package.json scripts — this PR adds check:adr-links directly after check:adr-anchors, and main (after #6732's checklist work) added check:platform-checklist at the same spot. Resolution: keep both lines. Everything else auto-merged cleanly, including .github/workflows/check-links.yml (this PR's gate wiring survived intact) and #6732's rewritten scripts/check-adr-anchors.mjs.

Baseline staleness check (the semantic trap): no KNOWN_DEAD_TARGETS entries pruned. All 8 frozen entries point at source-tree targets that left this repository (ADR-0004 → runtime/studio files, ADR-0020 → CRM flow/workflow files) — none targets an ADR record, so #6732's reconstructed 0079-record-display-name.md and #6671's ADR-0048 addendum resurrect none of them. Verified by the gate itself, not by inspection: the full run's shrink-only staleness check exits 0 with all 8 still dead.

Complementarity with #6732's widened anchors gate: confirmed coexisting, not overlapping — check:adr-anchors audits ADR-NNNN citations tree-wide, check:adr-links resolves relative link destinations under docs/adr/. The new script's own ADR citations resolve under the widened audit.

Re-verification on the merged tree (delta vs origin/main is exactly this PR's 3 files):

  • check-adr-links.mjs --self-test + full run: 531 destinations resolve (up from 527 — main's new ADR files are now scanned), 8 frozen
  • check-adr-anchors.mjs --self-test + full run: 19813 citations across 3230 files resolve
  • pnpm lint (full ESLint): clean
  • all 31 remaining lint.yml gate scripts (check:nul-bytescheck:spec-parsed-alias): PASS

Not re-queued — reporting only.


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 size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

docs/adr/ is outside the Check Links scan surface — ADR cross-links are unverified, and the obvious widening goes red on ADR-0046's illustrative links

2 participants