From 29df57c54323d6720d2e79e633695c56a572b6c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 14:52:14 +0000 Subject: [PATCH] =?UTF-8?q?feat(scripts):=20check-doc-links=20=E6=89=AB?= =?UTF-8?q?=E6=8F=8F=E9=9D=A2=E7=AC=AC=E4=B8=89=E6=89=A9=20CONTRIBUTING/RO?= =?UTF-8?q?ADMAP/docs=20(#3572)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SCAN_ROOTS` 追加三行,全部沿用既有的 `disk` 规则: { path: 'CONTRIBUTING.md', rule: 'disk' }, { path: 'ROADMAP.md', rule: 'disk' }, { path: 'docs', rule: 'disk' }, 这三面正是 #3536 头注释里「Still not bought」点名的下一批,当时标价 「one SCAN_ROOTS row each — plus fixing what that turns red」。红账已由 #3545 / PR #3571 单独付清(3 条死链),所以本次是纯三行:实测 70 条链接、 52 条可判定、落地当日零死链——扩展该有的形状,对照 #3479(16 个死目标) 与 #3490(18 个)那种「门禁与欠账同时到货」。 `docs/**` 是三者中从未被量过的一面:Lychee 的扫描范围虽然列了它,但那个 工作流只有 schedule + workflow_dispatch,`pull_request`/`push` 按 #3213 ruling B 被刻意注释掉,拦不住任何 PR;它的 49 条链接里有 47 条此前从未被 任何能让构建失败的东西解析过。 不新增规则类、不新增 reason、不新增 hint。 头注释如实写下已知边界:`stripCode()` 在扫描前抹掉围栏与行内代码,所以 **围栏内的链接对本门禁不可见,死活都不可见**。这不是假想——CONTRIBUTING.md 的 25 条链接里 10 条在围栏内,门禁真正判定的只有 1 条。那 10 条正是 #3570 的实例类(演示 docs 链接写法的围栏,其"正确示例"路由自己是死的);修那段 文字是 #3570 的事,指出没有门禁够得着它是本注释的事。 测试(+8): - 两个既有 pin 测试用 toEqual 硬钉 SCAN_ROOTS,新行会让它们**响亮地红** ——不是自动覆盖。逐根的 count 断言那一半才是不会自我扩展的:补齐三行 各自的下限,否则某行打错字导致整棵树扫空时上面的测试仍会绿。 - 新 describe 覆盖三面的 reject/accept,含 docs/ 递归遍历、离开 docs/ 的 链接照收(无 collection 可逃)、无扩展名拼写与绝对 `/...` 照拒。 - 围栏边界作为**决定**钉住,而非留成日后被误读为覆盖的静默缺口。该测试 与 docs/ vs content/docs 对照测试都带一条 control 链接:否则删掉扫描行 它们会因「什么都没扫」而空绿——本文件存在的意义就是防这个。 ci-cd-pipeline.md 三处被证伪,做最小事实订正:扫描面清单、disk 规则的 适用文件、双检查器对照表那一行;并把「若改用 docs 规则会拒掉 61 条」按新 扫描面重算为 111。另加一段说明围栏边界,以免读者把该表读成全覆盖。 Fixes #3572 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- content/docs/guide/ci-cd-pipeline.md | 30 ++-- scripts/__tests__/check-doc-links.test.ts | 181 +++++++++++++++++++++- scripts/check-doc-links.mjs | 68 +++++++- 3 files changed, 261 insertions(+), 18 deletions(-) diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 4ca81b325..c9e7ff1dd 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -251,8 +251,9 @@ all**, which is the point of the workflow. It appears in the checks list as **In Check**. Runs `scripts/check-doc-links.mjs`, which walks every `.md` / `.mdx` file in the surfaces listed in -its `SCAN_ROOTS` — `content/docs/`, `examples/` and the root `README.md` — and asks of each internal -markdown link whether its target is really there. +its `SCAN_ROOTS` — `content/docs/`, `examples/`, the root `README.md`, `CONTRIBUTING.md`, +`ROADMAP.md` and the internal `docs/` tree — and asks of each internal markdown link whether its +target is really there. **Two rules, because the two groups are read through different machinery** (objectui#3536). For `content/docs/` the question is the one a site reader cares about, **does the site serve this URL?** @@ -265,12 +266,23 @@ Four checks, by href shape: | absolute `/docs/...` | `content/docs/` as a **route** | no `foo.md`, `foo.mdx` or `foo/index.md*` backs it — a `.md`/`.mdx` suffix always fails, since that URL 404s whatever is on disk | | any other absolute (`/spec/...`, `/img/...`) | the **site itself**: route segments enumerated from `apps/site/app`, plus static files under `apps/site/public` | no route pattern or static file matches | -`examples/` and the root `README.md` are read on **GitHub**, not served by the site, so a relative -href there names a path on disk and is checked for existence only — a directory (`./packages/core`) -or a non-markdown file (`./vite.config.ts`) is a perfectly good target, and there is no collection -to escape. A leading `/` is rejected outright: GitHub resolves it against `github.com`, not against -this repository. Applying the `content/docs/` rules to these files instead would reject 61 links -that render correctly today. +Every other surface — `examples/`, `README.md`, `CONTRIBUTING.md`, `ROADMAP.md`, `docs/` — is read +on **GitHub**, not served by the site, so a relative href there names a path on disk and is checked +for existence only: a directory (`./packages/core`) or a non-markdown file (`./vite.config.ts`) is a +perfectly good target, and there is no collection to escape. A leading `/` is rejected outright: +GitHub resolves it against `github.com`, not against this repository. Applying the `content/docs/` +rules to these files instead would reject 111 links that render correctly today. + +The last three surfaces are objectui#3572. They cost one `SCAN_ROOTS` row each and no new rule, +because "read on GitHub" already had one; their own backlog — three dead links — was cleared first +and separately (objectui#3545), so the rows landed on a green tree. + +**One boundary, stated because it is easy to mistake for coverage:** links written inside a code +fence are invisible to this check. `stripCode()` blanks fenced blocks and inline spans before +scanning — required, since fenced code legitimately contains `[…](…)` that is not a link — so a +dead route in an illustrative snippet is not reported. `CONTRIBUTING.md` carries 10 such links +today against 15 outside fences, of which the gate judges one. Prose *about* links stays a human +review item. One href shape is checked in **every** surface: a `https://github.com/objectstack-ai/objectui/(blob|tree)/main/` URL points back into this @@ -322,7 +334,7 @@ There are **two** link checkers, and they cover different things (objectui#3213) | | Covers | Network | Runs | |---|---|---|---| -| `scripts/check-doc-links.mjs` | **Internal** links in `content/docs/` (relative hrefs, `/docs/...` routes, every other site-absolute href against `apps/site`), in `examples/` and the root `README.md` (as paths on disk), plus this repo's own `blob/main/` and `tree/main/` GitHub URLs everywhere | No | `docs-links.yml` — every push and PR, no path filter (previous section) | +| `scripts/check-doc-links.mjs` | **Internal** links in `content/docs/` (relative hrefs, `/docs/...` routes, every other site-absolute href against `apps/site`), in `examples/`, `README.md`, `CONTRIBUTING.md`, `ROADMAP.md` and `docs/` (as paths on disk), plus this repo's own `blob/main/` and `tree/main/` GitHub URLs everywhere — **except** anything inside a code fence | No | `docs-links.yml` — every push and PR, no path filter (previous section) | | Lychee (this workflow) | **External** URLs, plus **relative** in-repo file links, in `content/docs/`, `docs/` and `README.md` | Yes | Weekly cron and manual dispatch | Lychee sweeps **both** documentation trees: `content/docs/` (the 183 pages the site publishes) and diff --git a/scripts/__tests__/check-doc-links.test.ts b/scripts/__tests__/check-doc-links.test.ts index 9a73e323a..8cd71435c 100644 --- a/scripts/__tests__/check-doc-links.test.ts +++ b/scripts/__tests__/check-doc-links.test.ts @@ -58,6 +58,16 @@ import { * main/` is now resolved against the working tree in every surface — * an in-repo reference that had been skipped by scheme, and sat dead for * three months between two gates (#3507). + * + * objectui#3572 added the three surfaces #3536 had listed as "still not + * bought": `CONTRIBUTING.md`, `ROADMAP.md` and the internal `docs/` tree, all + * under the existing `disk` rule. Their backlog was paid first and separately + * (#3545 / PR #3571 — three dead links), so the rows arrived at zero. Two + * things in that describe are worth reading before editing it: the pair that + * keeps `docs/` and `content/docs/` on opposite rules, and the pair that pins + * the FENCE BOUNDARY — `stripCode()` hides a link inside a code fence from this + * gate, dead or not, and that limit is pinned as a decision rather than left to + * be discovered as coverage that was never there (#3570 is the live instance). */ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); @@ -338,6 +348,13 @@ describe('the repo it guards', () => { // returns [] for a root that is not there, so a typo in SCAN_ROOTS, or a // rename of `examples/`, would silently drop a whole surface and leave the // test above green about a tree it never opened. + // + // objectui#3572 — the per-root COUNT lines are the half that does not + // extend itself. The `Object.keys` assertion below fails loudly on a new + // row (it did, which is how this test was found to need work), but a row + // whose count nobody pins is still a surface that could go empty — a typo + // in `docs` would then leave the whole tree unopened and every test above + // green. Every root carries its own floor for that reason. const scanned = Object.fromEntries( SCAN_ROOTS.map((root: { path: string; rule: string }) => [ root.path, @@ -345,9 +362,19 @@ describe('the repo it guards', () => { ]), ); - expect(Object.keys(scanned)).toEqual(['content/docs', 'examples', 'README.md']); + expect(Object.keys(scanned)).toEqual([ + 'content/docs', + 'examples', + 'README.md', + 'CONTRIBUTING.md', + 'ROADMAP.md', + 'docs', + ]); expect(scanned['README.md']).toBe(1); + expect(scanned['CONTRIBUTING.md']).toBe(1); + expect(scanned['ROADMAP.md']).toBe(1); expect(scanned['examples']).toBeGreaterThanOrEqual(4); + expect(scanned['docs']).toBeGreaterThanOrEqual(15); expect(scanned['content/docs']).toBeGreaterThanOrEqual(100); }); @@ -355,10 +382,42 @@ describe('the repo it guards', () => { // The rule split is the design decision of objectui#3536, so it is pinned // as data: `examples/**` and the root README are read on GitHub (disk // paths), `content/docs` is served by fumadocs (site routes). + // + // objectui#3572 added the last three, all `disk` — they are read on GitHub + // like the first two. The table is the whole configuration surface, so it + // is pinned whole: a row silently changing rule would re-judge a tree. expect(SCAN_ROOTS).toEqual([ { path: 'content/docs', rule: 'docs' }, { path: 'examples', rule: 'disk' }, { path: 'README.md', rule: 'disk' }, + { path: 'CONTRIBUTING.md', rule: 'disk' }, + { path: 'ROADMAP.md', rule: 'disk' }, + { path: 'docs', rule: 'disk' }, + ]); + }); + + it('keeps `docs/` and `content/docs/` apart — same href, two rules', () => { + // objectui#3572. The two roots differ by one path segment and are judged by + // opposite rules, so a prefix-matching slip would hand one tree the other's + // semantics. `../packages/core/README.md` is the discriminator: out of + // `docs/` it is an ordinary GitHub path (accepted), out of `content/docs/` + // it leaves the fumadocs page index (rejected) — even though the same file + // backs both. + const repo = repoWith({ + ...SITE_FIXTURE, + 'docs/ARCHITECTURE.md': '[core](../packages/core/README.md) and [gone](./NOWHERE.md)', + 'content/docs/guide/a.md': '[core](../../../packages/core/README.md)', + 'packages/core/README.md': '# Core', + }); + + // The second `docs/` link is a control, for the reason the fence test spells + // out below: an expectation naming only the `content/docs` rejection would + // hold just as well if `docs/` were never scanned at all, so it would prove + // nothing about the half it exists to test. One rejection from EACH tree + // cannot pass that way. + expect(scan(repo).map((item) => [path.relative(repo, item.file), item.href, item.reason])).toEqual([ + [path.join('content', 'docs', 'guide', 'a.md'), '../../../packages/core/README.md', 'escapes-collection'], + [path.join('docs', 'ARCHITECTURE.md'), './NOWHERE.md', 'example-relative'], ]); }); @@ -667,6 +726,126 @@ describe('examples/** and the root README are scanned as DISK paths — objectui }); }); +describe('CONTRIBUTING.md, ROADMAP.md and docs/** joined the disk surface — objectui#3572', () => { + it('reports the #3545 shape: the dead links CONTRIBUTING.md really carried', () => { + // The two CONTRIBUTING.md links PR #3571 fixed were of exactly this shape — + // a relative path to a file that is not there. Nothing could have caught + // them: the file sat outside SCAN_ROOTS, and lychee does not gate. + expect( + rejections({ + 'CONTRIBUTING.md': '[architecture](./content/docs/guide/architecture.md) and [gone](./docs/NOWHERE.md)', + 'content/docs/guide/architecture.md': '# Architecture', + }), + ).toEqual([['./docs/NOWHERE.md', 'example-relative']]); + }); + + it('reports the third one: a dead link in ROADMAP.md', () => { + expect( + rejections({ + 'ROADMAP.md': '[adr](./docs/adr/0001-master-detail-subform.md) and [gone](./docs/adr/9999-nope.md)', + 'docs/adr/0001-master-detail-subform.md': '# ADR 1', + }), + ).toEqual([['./docs/adr/9999-nope.md', 'example-relative']]); + }); + + it('walks docs/ recursively, not just its top level', () => { + // 13 of the tree's 15 files are under `docs/adr/` and `docs/audits/`. A + // scan that opened only `docs/*.md` would report nothing and look green. + expect( + rejections({ + 'docs/ARCHITECTURE.md': '[fine](./adr/0001-a.md)', + 'docs/adr/0001-a.md': '[gone](./0002-missing.md)', + 'docs/audits/2026-06-ui-testability.md': '[gone](../nowhere/x.md)', + }), + ).toEqual([ + ['./0002-missing.md', 'example-relative'], + ['../nowhere/x.md', 'example-relative'], + ]); + }); + + it('accepts a docs/ link that leaves docs/ — there is no collection to escape', () => { + // The disk rule's whole point, on the new tree: ADRs cite package sources + // and published docs pages, and GitHub renders every one of them. + expect( + rejections({ + 'docs/adr/0054-ui-testability-contract.md': [ + '[shell](../../packages/app-shell/src/console/ConsoleShell.tsx)', + '[lookup](../../content/docs/fields/lookup.mdx)', + '[roadmap](../../ROADMAP.md)', + '[dir](../../packages/core)', + ].join('\n\n'), + 'packages/app-shell/src/console/ConsoleShell.tsx': 'export {}', + 'content/docs/fields/lookup.mdx': '# Lookup', + 'ROADMAP.md': '# Roadmap', + 'packages/core/index.ts': 'export {}', + }), + ).toEqual([]); + }); + + it('rejects an extensionless markdown spelling and an absolute /... href here too', () => { + // Same two rules as examples/**, since it is the same `disk` rule class — + // this pins that the new rows really got it, and did not quietly land on + // the docs rule (which accepts the extensionless form). + expect( + rejections({ + 'docs/ARCHITECTURE.md': '[adr](./adr/0001-a) and [abs](/packages/core)', + 'docs/adr/0001-a.md': '# ADR 1', + 'packages/core/index.ts': 'export {}', + }), + ).toEqual([ + ['./adr/0001-a', 'example-relative'], + ['/packages/core', 'example-absolute'], + ]); + }); + + it('does NOT see a dead link inside a code fence — the boundary, pinned deliberately', () => { + // objectui#3572's stated limit, and objectui#3570's instance class: + // `stripCode()` blanks fences before the scan, so an illustrative route in + // a fenced block is invisible to this gate whether or not it is dead. Real + // `CONTRIBUTING.md` carries 10 such links today. + // + // This test exists so the boundary is a DECISION with a name, not a silent + // gap someone later reads as coverage. If `stripCode()` is ever narrowed, + // this goes red and the two false positives it was built for come back. + // + // The CONTROL link matters more than the fenced ones: an `toEqual([])` here + // would also pass if CONTRIBUTING.md were dropped from SCAN_ROOTS entirely + // — green because nothing was scanned, which is the failure mode this whole + // file exists to prevent. Asserting "exactly the prose link, and none of + // the three fenced ones" cannot pass vacuously: delete the scan row and the + // control disappears too, turning this red. + expect( + rejections({ + 'CONTRIBUTING.md': [ + 'Link docs pages like this:', + FENCE + 'md', + '[Quick Start](/guide/quick-start)', + '[Core API](/api/core)', + FENCE, + 'and inline: `[Spec](/spec/component)`', + '', + 'See [the guide](./docs/GONE.md).', + ].join('\n'), + }), + ).toEqual([['./docs/GONE.md', 'example-relative']]); + }); + + it('leaves the anchors and external URLs these files are mostly made of alone', () => { + // CONTRIBUTING.md is 15 visible links, of which this gate judges exactly + // one — the rest are its own table-of-contents anchors and external URLs. + expect( + rejections({ + 'CONTRIBUTING.md': [ + '[Getting Started](#getting-started)', + '[Commit Guidelines](#commit-guidelines)', + '[Conventional Commits](https://www.conventionalcommits.org/)', + '[Changesets](https://github.com/changesets/changesets)', + ].join('\n\n'), + }), + ).toEqual([]); + }); +}); + describe("this repo's own GitHub blob/tree URLs are resolved offline — objectui#3536 extension 2", () => { it('reports the #3507 shape: a tree/main URL to a deleted example', () => { // `examples/crm` and `examples/todo` were deleted in 12b287d8b and the two diff --git a/scripts/check-doc-links.mjs b/scripts/check-doc-links.mjs index 60bdd9f08..63e08634c 100644 --- a/scripts/check-doc-links.mjs +++ b/scripts/check-doc-links.mjs @@ -177,14 +177,57 @@ * and the `workflows//badge.svg` badge URLs in the root README are * github.com web routes, not paths in this tree, and stay skipped. * + * ## Why this file changed again (objectui#3572) + * + * The section that stood here, "Still not bought", named the next surface and + * its entry price: `CONTRIBUTING.md`, `ROADMAP.md` and the internal `docs/` + * tree were unscanned, and adding them was "one `SCAN_ROOTS` row each — plus + * fixing what that turns red". Both halves came due, in that order. + * + * The red was paid first and separately (#3545 / PR #3571): three dead links, + * two in `CONTRIBUTING.md` and one in `ROADMAP.md`. So this change is the three + * rows and nothing else — measured at 70 links across the three surfaces, of + * which 52 are decidable here, and **zero** dead the day it landed. That is the + * shape an extension should have; contrast #3479 (16 dead targets) and #3490 + * (18), where the gate and its own backlog arrived together. + * + * `docs/**` is the one of the three nothing had ever measured. Lychee's scope + * does list it, but that workflow is `schedule` + `workflow_dispatch` only — + * `push` and `pull_request` are commented out on purpose (#3213 ruling B: an + * external link check goes over the network and would redden PRs their authors + * cannot fix), so it blocks nobody. 47 of its 49 links had therefore never been + * resolved by anything that could fail a build. + * + * All three take the `disk` rule, and correctly: they are read on GitHub, like + * `examples/**` and the root `README.md`. No new rule class, no new reason + * string, no new hint. + * + * ### The boundary this does NOT cover: links inside code fences + * + * Stated because the alternative is implying total coverage. `stripCode()` + * blanks fenced blocks and inline spans before the scan (see the section below + * — it is what makes the relative-link check safe at all), so **a link written + * inside a fence is invisible to this gate, dead or not**. + * + * That is not hypothetical on the very surface being added. `CONTRIBUTING.md` + * carries 25 markdown links; 10 sit inside fences, and this gate judges exactly + * **one** of the remaining 15 (the other 14 are `#anchors` and external URLs). + * Those 10 are objectui#3570's instance class — a fenced block illustrating + * docs-link conventions whose "correct example" routes (`/guide/quick-start`, + * `/api/core`, `/spec/component`, …) are themselves dead. A reader copies them; + * this script never sees them. Fixing that text is #3570's job; noticing that + * no gate can reach it is this comment's. + * + * Widening `stripCode()` is not the repair, and the two false positives it was + * built for are why: fenced code legitimately contains `[…](…)` that is not a + * link. A gate for prose *about* links inside fences would need to tell an + * illustrative route from an executable one, which is a different gate. + * * ### Still not bought * - * The other root-level markdown (`CONTRIBUTING.md`, `ROADMAP.md`, - * `QUICK_REFERENCE.md`, `AGENTS.md`) and the internal `docs/` tree remain - * unscanned. That is a surface, not an oversight: the scan found real dead - * links in two of those files while this was being written, filed separately - * rather than folded in. Adding them is a matter of one `SCAN_ROOTS` row each - * — plus fixing what that turns red. + * `QUICK_REFERENCE.md`, `AGENTS.md`, `CLAUDE.md` and `CHANGELOG.md` remain + * unscanned. Same one-row price, same caveat — measure the surface first, pay + * its backlog separately, then add the row. * * ## Code spans are stripped before scanning * @@ -233,12 +276,20 @@ const UNSCANNED_DIRS = new Set(['node_modules', 'dist', 'build', '.next', '.turb * `disk` — files read on GitHub: an href names a PATH in this repository. * * The split is the point (objectui#3536). See the header for why applying the - * docs rules to the second group would reject links that render perfectly well. + * docs rules to the second group would reject links that render perfectly well: + * over today's `disk` surface it would reject 111 links that all render. + * + * Adding a surface is one row. Adding one that is read on GitHub needs no new + * rule class at all — which is why objectui#3572 could take the last three for + * the price of the table entry. */ export const SCAN_ROOTS = [ { path: 'content/docs', rule: 'docs' }, { path: 'examples', rule: 'disk' }, { path: 'README.md', rule: 'disk' }, + { path: 'CONTRIBUTING.md', rule: 'disk' }, + { path: 'ROADMAP.md', rule: 'disk' }, + { path: 'docs', rule: 'disk' }, ]; const blank = (text) => text.replace(/[^\n]/g, ' '); @@ -615,7 +666,8 @@ const HINTS = { ' `https://github.com/objectstack-ai/objectui/blob/main/...` URL instead' + ' (the "Package README" form used throughout content/docs/plugins/).', 'example-relative': - 'Outside content/docs (examples/**, the root README) a relative link is a' + + 'Outside content/docs (examples/**, README.md, CONTRIBUTING.md, ROADMAP.md,' + + ' docs/**) a relative link is a' + ' PATH IN THIS REPO, resolved by GitHub against the linking file — so it' + ' must name something that exists and lives inside the repository. A' + ' directory or a non-markdown file is fine; an extensionless spelling of a' +