ci(check-links): bring docs/adr/ under a link gate, with fence/code-span discrimination and a shrink-only baseline - #6731
Open
os-project-manager wants to merge 1 commit into
Open
Conversation
`.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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
os-project-manager
marked this pull request as ready for review
August 8, 2026 13:46
os-project-manager
enabled auto-merge
August 8, 2026 13:47
github-merge-queue
Bot
removed this pull request from the merge queue due to a conflict with the base branch
Aug 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6592
docs/adr/was outside every link gate..github/workflows/check-links.ymlruns lychee over exactlycontent/**/*.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.mdthat has never existed) survived long enough for a triage comment to inherit and repeat its wrong attribution.This adds
scripts/check-adr-links.mjsand runs it as a second step in the same advisoryCheck Linksjob.The gate lands ADVISORY, and stays advisory
Check Linksis not in the required set and carries nomerge_grouptrigger, 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_groupMUST 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:
(`[guide](./crm_lead_guide.md)`)— inline code span[lead guide](./crm_lead_guide.md#qualification)— inside a```mdfenceThose 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:
(Measured on lychee 0.24.2, the version
lycheeverse/lychee-action@v2pins, 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-testrather than assumed.What actually blocked the naive widening: 8 pre-existing breaks
All 8 are ADR → source-tree links whose targets left this repository (
apps/here now holds onlydocs/; 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 editingdocs/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 shapecheck-adr-anchors'sKNOWN_NUMBER_COLLISIONS,check:role-wordand 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/**/*.mdis NOT simply added to the lychee globs, and the workflow now says so at the glob site: lychee has no shrink-only exclusion..lycheeignore/excludenever expire and never tell you an entry stopped being needed, and widening the globs today would have made the sharedCheck Linksjob red on every open PR from the moment it merged — which is how an advisory lane becomes a lane nobody reads.check-links.ymlalready 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.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, andlychee.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).Coverage parity, measured. Dumping both extractors over
docs/adr/**/*.md(lychee--dumpvsextractRelativeLinks): 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):Note the direction: the probe also carried a code-span link and a
```mdfenced link to the same nonexistent doc, and the gate reported 1 finding, not 3.The ADR-0046 pin.
--self-testasserts 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)check:workflow-status-functionswas 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.