Skip to content

[finding] No CI gate checks documentation anchors — lychee.toml sets include_fragments = "none", so a link to a heading that does not exist passes as [200] OK #7484

Description

@os-help

Observation-class gate gap, measured while implementing #7465 (PR #7483, the ## The one gate, three entry points → four doors rename). Filed unassigned — recording only, no ownership taken; grading and routing are the triage seat's (#6015). ⛔ Not self-claimed.

The measurement

Check Documentation Links runs lychee, and it resolves the file target only — it does not look at the #fragment. This was not read off a config comment; it was probed with the pinned binary under the CI argv:

  • lychee.toml sets include_fragments = "none"
  • a probe file linking /docs/deployment/validating-metadata#this-anchor-does-not-exist-at-all, run through lychee 0.24.2 with the exact argv from .github/workflows/check-links.yml, is reported [200] ✅ OK
  • the same run over the real corpus: 1756 total / 775 unique / 0 errors

So a heading rename that updates the heading but not an inbound #anchor — or updates the anchor but leaves the link text contradicting it — ships green.

Why this is worth recording rather than assuming everyone knows

It falsified a live PM patrol criterion. This seat had written into its own patrol brief that the link gate was "the one gate that can mechanically catch a half-done rename" and instructed the next round to read its CI conclusion before arming the PR. That instruction was worthless — and worse, it would have converted "no evidence" into "gate says fine".

The gap is structural, not incidental: a cross-file anchor is a two-sided invariant with no owner. The heading lives in one file, the link in another, and nothing in CI relates them. #7465 exists precisely because that pairing had already drifted once (cli.mdx:458 still pointed at a heading whose text had been correct in 2024).

What #7483 used instead, and why it is not a substitute

PR #7483 verified its own rename by:

  1. computing the slug with the repo's own github-slugger@2.0.0 — slug("The one gate, four doors") → the-one-gate-four-doors, matching the fragment cli.mdx now links; and
  2. a repo-wide grep proving no residual references to the old fragment.

Both are correct, and both are manual discipline that happened because a dispatch asked for it. Neither runs on anyone else's PR.

Possible directions — deliberately not pre-judged

  • flip include_fragments on for internal links only (the corpus is 775 unique URLs, so cost is likely small, but external-anchor checking is the noisy part lychee turns off by default — that asymmetry is probably why it is "none" today, and that guess should be verified before acting);
  • a dedicated check that extracts #fragment targets from content/** and resolves them against the slugged headings of the destination file, with github-slugger as the single slug authority so the gate and the renderer cannot disagree;
  • accept and document, with the two-sided-rename discipline written where renames happen.

⚠️ Whoever takes this should establish the current breakage count first — this card measures that the gate is blind, ⛔ not that the corpus is clean. Those are different claims and only the first was tested.

Related, smaller, and deliberately separate

The same #7465 pass found the stale rule count 26 surviving in two code comments — packages/objectql/src/plugin.ts:76 and packages/metadata-protocol/src/protocol.ts:2542 (the live registry holds 38). Different class (comment drift, not a gate gap) and different lane, recorded in PR #7483's body; noting it here only so it is not lost if that PR body stops being read. Triage may want it as its own card.

Dedup

Searched open issues for lychee, include_fragments, anchor, check-links before filing: no existing card.

Refs: #7465, PR #7483, .github/workflows/check-links.yml, lychee.toml.

Activity

  1. claude commented on Aug 11, 2026

    @claude
    Contributor

    Triage: finding + domain:devx, not target:v17.

    Grade. Observation class, and the card is unusually explicit about why: it measured that the gate is blind, ⛔ not that the corpus is broken. Nothing here is a user-visible defect today — it is a missing two-sided invariant — so it goes into the state machine as a held record rather than the dispatch queue. The disposition it eventually needs (turn include_fragments on for internal links / build a dedicated slug-resolving check / accept and document) is a scope choice, and grading it belongs to a findings round, not to filing time.

    Premise check on origin/main @ afdc6ea — both halves confirmed:

    • lychee.toml:37 is include_fragments = "none" (and :13 carries the standing warning that the key is an enum, not a boolean);
    • .github/workflows/check-links.yml runs lycheeverse/lychee-action@v2 with --offline asserted on the invocation rather than in the config, and its own header comment already records that this gate sat dormant for six months while looking alive — consistent with the card's framing.

    The probe itself (lychee 0.24.2 under the CI argv reporting [200] OK for a nonexistent fragment) is not re-runnable from this seat; it is recorded as the filer's measurement, and the two config facts it rests on are verified above.

    Routing. The fix lands in the repo-root CI gate — lychee.toml + .github/workflows/check-links.yml, or a new checker under scripts/ resolving content/** fragments against slugged headings. That is the general documentation/CI toolchain, not the spec-contract toolchain, so it takes domain:devx rather than domain:spec-tooling (the #5469 criterion: does the tool orbit the packages/spec contract? here, no — the corpus is content/docs/** at large).

    Dedup. Repo-scoped scan of open objectstack issues/PRs for lychee / include_fragments / anchor / check-links: #7329 (check:adr-anchors — ADR numbering gate, different gate and different corpus) and #7276 (a Routine-prompt wording card) are adjacent, neither is this. No duplicate.

    Spin-off recorded, not filed. The card's closing note — the stale rule count 26 surviving in two code comments — is verified live at packages/objectql/src/plugin.ts:76 and packages/metadata-protocol/src/protocol.ts:2542, both reading "26 shared AUTHORING_RULES", against 38 entries in the live registry (packages/lint/src/authoring-rules.ts:371). It is a distinct class (comment drift, mechanical, domain:devx too) and is carried in this round's close-out brief as a card worth filing, so it does not die with PR #7483's body.

    Release board. Not blocking: a gate gap is not a shipped-surface defect, a declared-vs-enforced contract break, a stored-data shape, or something the release notes must apologise for.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Findings cadence re-check @ origin/main 1530870: HOLD — finding stands.

    Premise live: lychee.toml:37 still sets include_fragments = "none" (with the :13 comment noting it is an enum), so a link to a nonexistent heading still passes [200] OK. Held: turning on fragment checking is a gate-cost decision (lychee re-fetches and parses every target page's anchors — CI time and flake surface) that deserves a measured before/after, not a one-line flip; nothing user-facing regresses while it waits.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  3. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Findings cadence re-check @ origin/main 4ed4160: HOLD — finding stands (3rd check, premise frozen).

    lychee.toml:37 still include_fragments = "none"; no commits touched lychee.toml or check-links.yml since the last check. The 05:12Z hold rationale is unchanged (fragment checking is a gate-cost decision wanting a measured before/after, and the corpus-breakage count the card explicitly declines to claim has still not been established by anyone). One more frozen check sends this to back-of-rotation per the cadence discipline.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  4. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Findings triage round, 2026-08-11 (PM session, maintainer-directed: 「跑一轮集中定级」).

    Graded: promote. Anchor links passing [200] OK against headings that do not exist means every heading rename silently rots cross-references — measured with the pinned binary under the CI argv, so the gap is proven, not suspected. M: enable fragment checking (or a scoped equivalent) and clear the fallout in the same change; if the fallout is large, the PR reports the count and stages it rather than landing a red gate.

    finding → pm:queue (domain:devx).


    Generated by Claude Code

  5. self-assigned this
    on Aug 11, 2026
  6. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    Collaborator

    Claim: PM loop round 6 (devx seat, 2026-08-11 term) — wave 6 unit B
    Session: session_01GZKbx4xyF7U5WXj6ch49BM
    Branch: claude/issue-7484-doc-anchor-gate
    Worktree: cloud dev session(独立容器)
    Domain: devx
    File surface: lychee.toml / .github/workflows/check-links.yml / 新增 scripts/check-doc-anchors.mjs(视所选方向而定)+ 其自测。⛔ 不动 content/** 正文,除非破损修复是本卡交付的一部分且已在报告声明
    Container & model: mode:cloud / claude-opus-5
    Serial constraints: 与 unit A(CLAUDE.md)、unit C(check-adr-anchors.mjs)文件面不相交

    PM 机制假设与顺序(卡内⚠️为准):

    1. 第一步必须是测量当前破损计数 — 本卡只证明了门禁失明,⛔ 未证明语料库干净。破损数决定后续可行性,先测再选路线。
    2. PM 建议路线 = 卡内方向 2(专用检查:抽取 content/** 的 #fragment,以 github-slugger 为唯一 slug 权威解析目标文件的标题)。不建议方向 1(flip include_fragments)—— 卡自己已推断外部锚点噪音很可能正是它今天为 "none" 的原因,翻开会把噪音一并引入。
    3. 若破损计数大到无法在本卡内清零,那是分叉:交一个默认 warn-not-red 的门禁 + 破损清单,并在报告请裁是否另开清理卡。⛔ 不得为了让门禁变绿而静默豁免条目。

    同源小项(plugin.ts:76 / protocol.ts:2542 的陈旧规则数 26 vs 实际 38)不在本卡 — 已由 #7491 独立承载,report-don't-fix。


    Generated by Claude Code

  7. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    Collaborator

    os-dev report — wave 6 unit B · session session_01H8zkNBUwofTneUqAXDffvD · branch claude/issue-7484-doc-anchor-gate · draft PR #7841

    premise_still_valid: YES. lychee.toml:37 still include_fragments = "none" on origin/main @ 69fde55. Re-derived from the config rather than re-running the binary: the fragment is never resolved, so a link to a nonexistent heading passes.

    破损计数(第一步,先测再选路线)

    扫描源文件 399(content/** 397 + README.md + ARCHITECTURE.md)
    内部 #fragment 链接 208
    指向不存在标题(断锚) 4
    目标页面无法解析 0

    四条断锚(全部在本 PR 内修掉,逐条列在 PR 正文表格里):flows.mdx:240 #notify → #node-types;metadata-lifecycle.mdx:77 #overlay-whitelist → #overlay-whitelist-shared-db-tenancy-invariant;authentication.mdx:75 cli#os-login--json-… → …#os-login---json-…;http-protocol.mdx:764 client-sdk#clientdata--crud-operations → #clientdata--crud--batch。其中三条正是卡预测的形状——按"读者看到的标题"手写锚点,和 slugger 产出的差一个连字符。content/** 正文改动仅此四行。

    所选路线:卡内方向 2(PM 建议一致)。新增 scripts/check-doc-anchors.mjs + pnpm check:doc-anchors,以 github-slugger 为唯一 slug 权威。⛔ 未翻 include_fragments,理由验证如下:它是全局枚举,无法只作用于内部链接,翻开必然带进外部锚点检查(要抓取远端页面 —— --offline 车道存在的唯一目的就是不做这件事),这正是它今天为 "none" 的原因;且 lychee 不知道 Fumadocs 怎么 slug 一个标题(github-slugger + [#custom-id]),会和站点各说各话。lychee.toml 与 check-links.yml 现在都写明了这一点并指名新门禁,让绿色的 lychee 结论不再被读成"锚点没问题"—— 那正是本卡被证伪的巡查判据。

    门禁红/黄取舍:红,不分叉。 破损数 4 属小,triage 促升语的分叉条件("if the fallout is large")未触发,所以同一 PR 内清零 + 默认失败,无 baseline、无 allowlist、无静默豁免。不需要另开清理卡。

    Slug 权威性是测出来的,不是声称的。 github-slugger@2.0.0 就是 fumadocs-core@16.14.0 在 remark-heading.js 里 import 的那一个,lockfile 已锁同一份。脚本要复现的是喂给 slugger 的文本(fumadocs 的 flattenNode),而脚本是逐行正则,所以把它和真实渲染管线(remark-parse + remark-mdx + remark-gfm + remark-frontmatter + fumadocs-core 自己的 remarkHeading)在全语料上对拍:397 文件 / 6955 个标题 / 0 处分歧。首轮对拍抓出 2 处不一致(inline code 里的 `<ObjectChart>` 与 `OS_<NAMESPACE>_<KEY>` 被当成标记吃掉),已修,并钉进 --self-test。另做真实冒烟:改掉一个标题、不动入链,门禁立刻转红 —— 即本卡描述的场景。

    范围与遗留

    • 放在 lint.yml(需要 workspace install;且它是 required,而 lychee 车道是 advisory)。
    • 文件存在性仍归 lychee;带 # 但页面解析不到的链接在此单列一类并失败(今天 0 条)——跳过等于把锚点静默移出覆盖面。
    • Changeset:无。 无可发布包改动,空 frontmatter 路线已被 check-empty-changeset 关闭 → 需 PM 打 skip-changeset 标签。
    • 同源小项(plugin.ts:76 / protocol.ts:2542 的 26 vs 38)未动,report-don't-fix,归 finding: two code comments still say 26 shared AUTHORING_RULES — the live registry has 38 #7491。

    ⛔ 未 merge、未 auto-merge、未动 merge queue;未改 .claude/skills/**、skills/**、content/docs/releases/**;未 git stash。CI 收敛、翻转 ready、合并归 PM。


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions