From 8b292b4285e73e3db1241faddab80d13c39b60c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 20:13:17 +0000 Subject: [PATCH] feat(ci): resolve docs `#anchor` links against real headings (#7484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Check Documentation Links` sets `include_fragments = "none"` in `lychee.toml`, so lychee resolves a link's FILE and never its `#anchor` — measured with the pinned 0.24.2 under the CI argv, a link to a heading that does not exist is reported `[200] OK`. A cross-file anchor was a two-sided invariant with no owner: rename the heading, leave the inbound link, ship green. `scripts/check-doc-anchors.mjs` owns the fragment half. It extracts every internal `#fragment` from `content/**`, `README.md` and `ARCHITECTURE.md` and resolves it against the destination page's heading ids, computed with `github-slugger` — the same package `fumadocs-core@16.14.0` renders the site with — so the gate and the renderer cannot disagree about a slug. The text handed to the slugger is a regex approximation of fumadocs' `flattenNode`, so the equivalence is measured, not asserted: diffed against the real pipeline (remark-parse + remark-mdx + remark-gfm + remark-frontmatter + fumadocs-core's own `remarkHeading`) over 397 files and 6955 headings, zero disagreements. The two mismatches that sweep found were the same bug — markup inside an inline code span read as markup — and both are pinned in `--self-test`. The fallout was 4 dead anchors out of 200 internal fragment links, all fixed here, so the gate lands with no baseline and no allowlist: content/docs/automation/flows.mdx:240 #notify -> #node-types content/docs/concepts/metadata-lifecycle.mdx:77 #overlay-whitelist -> #overlay-whitelist-shared-db-tenancy-invariant content/docs/permissions/authentication.mdx:75 cli#os-login--json-... -> cli#os-login---json-... content/docs/protocol/kernel/http-protocol.mdx:764 client-sdk#clientdata--crud-operations -> #clientdata--crud--batch `include_fragments` is deliberately NOT flipped: it is a global enum that cannot be scoped to internal links, so it would import external-anchor checking (which needs the remote page fetched — the one thing the `--offline` lane exists to never do), and it cannot know how Fumadocs slugs a heading. `lychee.toml` and `check-links.yml` now record that, and stop reading as "anchors are fine" to the next author. Refs #7484 --- .github/workflows/check-links.yml | 7 + .github/workflows/lint.yml | 18 + content/docs/automation/flows.mdx | 2 +- content/docs/concepts/metadata-lifecycle.mdx | 2 +- content/docs/permissions/authentication.mdx | 2 +- .../docs/protocol/kernel/http-protocol.mdx | 2 +- lychee.toml | 16 + package.json | 2 + pnpm-lock.yaml | 3 + scripts/check-doc-anchors.mjs | 526 ++++++++++++++++++ 10 files changed, 576 insertions(+), 4 deletions(-) create mode 100644 scripts/check-doc-anchors.mjs diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml index a3ae9b253f..947321bc9f 100644 --- a/.github/workflows/check-links.yml +++ b/.github/workflows/check-links.yml @@ -13,6 +13,13 @@ name: Check Links # advisory gate does not get to consume merge-queue capacity. If it is ever # promoted into the required set, `merge_group` MUST be added in the same # change or the queue stalls on a required check that never reports (#6121). +# +# ⚠️ This job checks a link's FILE, never its `#anchor` — `lychee.toml` sets +# `include_fragments = "none"`, and a link to a heading that does not exist is +# reported `[200] OK` here (measured, #7484). Do NOT read a green run as +# "the anchors are fine". The fragment half is owned by `pnpm check:doc-anchors` +# in `lint.yml`, which is REQUIRED where this lane is advisory; `lychee.toml`'s +# `include_fragments` comment records why the enum is not the fix. on: workflow_dispatch: pull_request: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a4b463ad9b..c8b8641db7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -169,6 +169,24 @@ jobs: - name: Doc/skill authoring guard run: pnpm check:doc-authoring + # #7484 docs anchors: `lychee.toml` sets `include_fragments = "none"`, so + # the `Check Documentation Links` job resolves a link's FILE and never its + # `#anchor` — measured with the pinned lychee 0.24.2 under the CI argv, a + # link to a heading that does not exist is reported `[200] OK`. So a + # cross-file anchor was a two-sided invariant with no owner: rename the + # heading, leave the inbound link, ship green. This resolves every internal + # `#fragment` against the destination page's real heading ids, computed + # with `github-slugger` — the same package fumadocs-core renders the site + # with, so the gate and the renderer cannot disagree about a slug. + # + # It lives in this job rather than in check-links.yml for two reasons: it + # needs a workspace install (that job is deliberately a checkout plus two + # dependency-free `node` checks), and it is REQUIRED where that lane is + # advisory. The four anchors that were already dead were fixed in the same + # PR, so this ships with no baseline and no allowlist. + - name: Docs anchors resolve to real headings + run: pnpm check:doc-anchors + # #4851: the docs-accuracy-audit workflow carries its default scope inline # (a workflow script runs in a vm with no filesystem, so it cannot enumerate # content/docs/ itself). Hand-kept, that list rotted in BOTH directions — diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 52237c46ae..6f8f4835f5 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -237,7 +237,7 @@ Replace them per branch — they are different mechanisms, not one rename: | Retired shape | Use instead | | --- | --- | -| `actionType: 'email'` (+ `template` / `recipients` / `variables`) | a [`notify` node](#notify) — it delivers through the messaging service: the in-app inbox by default, real email once `@objectstack/plugin-email` is installed | +| `actionType: 'email'` (+ `template` / `recipients` / `variables`) | a [`notify` node](#node-types) — it delivers through the messaging service: the in-app inbox by default, real email once `@objectstack/plugin-email` is installed | | `actionType: 'slack'` | a `connector_action` node with the Slack connector, or an `http` node posting to an incoming webhook | | `actionType: 'my_fn'` (shorthand) | `function: 'my_fn'` — the conversion moves it for you | | inline `config.script` | move the logic into a registered function and call it via `config.function` | diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index 00fb883650..3712594057 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -74,7 +74,7 @@ any production path: every `new LayeredRepository(...)` call site is a test When a user edits a view in Studio: 1. Studio calls `PUT /api/v1/meta/view/case_grid` (REST). -2. `protocol.ts:saveMetaItem()` runs the two-tier gate (see [overlay whitelist](#overlay-whitelist)): an item that already exists as a packaged artifact requires `MetadataTypeRegistryEntry.allowOrgOverride`; a brand-new item requires `allowRuntimeCreate` **or** `allowOrgOverride`. +2. `protocol.ts:saveMetaItem()` runs the two-tier gate (see [overlay whitelist](#overlay-whitelist-shared-db-tenancy-invariant)): an item that already exists as a packaged artifact requires `MetadataTypeRegistryEntry.allowOrgOverride`; a brand-new item requires `allowRuntimeCreate` **or** `allowOrgOverride`. 3. If allowed, the call lands on `MetadataRepository.put(ref, body, { parentVersion, actor })`. 4. The repository: - Verifies `parentVersion` matches the current head (`ConflictError` on mismatch). diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index 95097c70c0..5ae59a1b57 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -72,7 +72,7 @@ Under `--json` this command is the CLI's **one declared NDJSON exception**: it emits the verification-URL record before you authorize and the result record afterwards, one compact JSON document per line, so stdout must be parsed line by line rather than with a single `JSON.parse`. See -[the CLI reference](/docs/deployment/cli#os-login--json-is-ndjson--the-one-exception). +[the CLI reference](/docs/deployment/cli#os-login---json-is-ndjson--the-one-exception). The email/password path is still supported for CI and non-interactive shells: diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx index 2b6c12104a..09496397ed 100644 --- a/content/docs/protocol/kernel/http-protocol.mdx +++ b/content/docs/protocol/kernel/http-protocol.mdx @@ -761,7 +761,7 @@ POST /api/v1/batch ``` The typed SDK surface for this route is -[`client.data.batchTransaction(operations)`](/docs/api/client-sdk#clientdata--crud-operations). +[`client.data.batchTransaction(operations)`](/docs/api/client-sdk#clientdata--crud--batch). Each operation specifies an `action` (`create`, `update`, or `delete`), the target `object`, and the relevant `data` / `id`. A field value of diff --git a/lychee.toml b/lychee.toml index aa2e296254..f57377a576 100644 --- a/lychee.toml +++ b/lychee.toml @@ -34,6 +34,22 @@ max_retries = 2 # ⛔ Not a boolean on the pinned lychee (0.24.2) -- it takes one of # none | anchor-only | text-only | full, and a bare `false` is a HARD parse # error. "none" preserves the original `false` semantics: fragments unchecked. +# +# ⚠️ "none" means a link to a heading that DOES NOT EXIST is reported +# `[200] ✅ OK` -- measured with 0.24.2 under this repo's CI argv (#7484). Read +# that as a scope statement, not a gap: since #7484 the fragment half of an +# internal link is owned by `pnpm check:doc-anchors` +# (scripts/check-doc-anchors.mjs, a REQUIRED step in Lint & Type Check), which +# resolves every `#fragment` in content/**, README.md and ARCHITECTURE.md +# against the destination page's real heading ids. +# +# ⛔ Do not "fix" this by flipping the enum. `include_fragments` is global: it +# cannot be scoped to internal links, so it would pull in external-anchor +# checking -- which needs the remote page fetched and parsed, the one thing the +# `--offline` lane exists to never do. It also cannot know how Fumadocs slugs a +# heading (github-slugger + a `[#custom-id]` suffix), so it would disagree with +# the rendered site. Both reasons are recorded in check-doc-anchors.mjs's +# header, with the measurement behind them. include_fragments = "none" # Verbose output diff --git a/package.json b/package.json index cede8bfd0f..99e8b3a25f 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "check:nul-bytes": "node scripts/check-nul-bytes.mjs --self-test && node scripts/check-nul-bytes.mjs", "check:stack-collection-maps": "node scripts/check-stack-collection-maps.mjs --self-test && node scripts/check-stack-collection-maps.mjs", "check:doc-authoring": "node scripts/check-doc-authoring.mjs --self-test && node scripts/check-doc-authoring.mjs", + "check:doc-anchors": "node scripts/check-doc-anchors.mjs --self-test && node scripts/check-doc-anchors.mjs", "check:docs-audit-scope": "node scripts/docs-audit/affected-docs.mjs --self-test && node scripts/docs-audit/check-audit-scope.mjs --self-test && node scripts/docs-audit/check-audit-scope.mjs", "check:role-word": "node scripts/check-role-word.mjs", "check:quick-reference-counts": "node scripts/check-quick-reference-counts.mjs --self-test && node scripts/check-quick-reference-counts.mjs", @@ -102,6 +103,7 @@ "@types/node": "^26.1.2", "@typescript-eslint/parser": "^8.65.0", "eslint": "^10.8.0", + "github-slugger": "2.0.0", "semver": "^7.8.5", "svelte": "^5.56.8", "tsup": "^8.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10b9bb20d8..732cab666b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,6 +52,9 @@ importers: eslint: specifier: ^10.8.0 version: 10.8.0(jiti@2.7.0) + github-slugger: + specifier: 2.0.0 + version: 2.0.0 semver: specifier: ^7.8.5 version: 7.8.5 diff --git a/scripts/check-doc-anchors.mjs b/scripts/check-doc-anchors.mjs new file mode 100644 index 0000000000..32be68aac8 --- /dev/null +++ b/scripts/check-doc-anchors.mjs @@ -0,0 +1,526 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// check-doc-anchors — resolve every `#fragment` an internal docs link writes +// against the heading ids the destination page actually renders. +// +// ## The failure this exists for +// +// framework#7484. `Check Documentation Links` runs lychee, and `lychee.toml` +// sets `include_fragments = "none"` — so lychee resolves the FILE and never +// looks at the `#anchor`. Measured with the pinned binary (0.24.2) under the +// exact CI argv: a link to `/docs/deployment/validating-metadata#this-anchor- +// does-not-exist-at-all` is reported `[200] ✅ OK`. +// +// That makes a cross-file anchor a two-sided invariant with no owner. The +// heading lives in one file, the link in another, and until this script nothing +// in CI related them: a rename that updates the heading and not the inbound +// `#anchor` shipped green. #7465 exists because exactly that pairing had already +// drifted once — `cli.mdx:458` pointed at a heading whose text had been correct +// in 2024 — and PR #7483 could only catch its own rename by hand, with a slug +// computed in a REPL and a repo-wide grep. Neither runs on anyone else's PR. +// +// Four inbound anchors were already dead when this landed (see "The fallout" +// below); all four are fixed in the same change, so this gate lands with an +// empty baseline and no allowlist to grandfather anything. +// +// ## Why a script and not `include_fragments = "anchor-only"` +// +// The card left the direction open and warned that the answer had to be +// measured. Three reasons the flip is the worse trade, in descending order: +// +// 1. **It cannot be scoped to internal links.** `include_fragments` is a +// global enum in `lychee.toml`; it does not distinguish "an anchor on a +// page in this repo" from "an anchor on someone else's website". The +// second class is the noisy one — it needs the page fetched and parsed, +// which the `--offline` lane deliberately never does — and that asymmetry +// is almost certainly why the key reads `"none"` today. Turning it on to +// buy the internal check imports the external noise with it. +// 2. **lychee does not know how Fumadocs slugs a heading.** It reads the +// rendered document's anchors; our pages are MDX compiled by +// `fumadocs-mdx`, whose `remarkHeading` assigns ids with `github-slugger` +// and honours a `[#custom-id]` suffix. A checker that computes ids any +// other way can disagree with the site — which is the same class of defect +// as not checking at all, only louder. +// 3. **It is not runnable locally.** `lychee.toml`'s own header asks authors +// to run lychee before pushing; that costs a from-source Rust build in an +// agent container. This is `node` + one dependency-free package. +// +// So `lychee.toml` keeps `include_fragments = "none"` and now says WHY, and the +// fragment half of the invariant is owned here. +// +// ## github-slugger is the single slug authority (the point of the exercise) +// +// `github-slugger@2.0.0` is not a lookalike of what the site does — it is +// literally the package `fumadocs-core@16.14.0` imports in +// `dist/mdx-plugins/remark-heading.js`, and the lockfile pins one copy of it +// for the whole workspace. This script constructs one `Slugger` per file and +// feeds it heading text in document order, exactly as `remarkHeading` does, so +// the duplicate-heading `-1` / `-2` suffixes line up too. The gate and the +// renderer cannot disagree about a slug, because they call the same function. +// +// What this script DOES have to reproduce is the text handed to the slugger: +// fumadocs passes `flattenNode(heading)`, the concatenation of every descendant +// mdast node's `value`. That is an inline-Markdown parse, and this file is a +// regex over lines. So the equivalence is MEASURED rather than asserted: +// `flattenHeadingText` was diffed against the real pipeline (`remark-parse` + +// `remark-mdx` + `remark-gfm` + `remark-frontmatter` + fumadocs-core's own +// `remarkHeading`) over the whole corpus — 397 files, 6955 headings, and after +// the two mismatches that sweep found were fixed, **zero** disagreements. +// +// The two it found are pinned in `--self-test`, because both were the same bug +// and it is the bug this shape invites: markup inside an inline code span is +// literal text, not markup. `### 8. \`\` axes bound to the wrong +// result column` and `### 3. Settings env overrides: canonical +// \`OS__\` only` each lost their angle-bracketed run to a +// JSX-stripping pass that ran too early. Code spans are therefore masked FIRST +// and restored LAST, and everything else operates on the masked string. +// +// ## The fallout this landed with (measured, then fixed in the same PR) +// +// 200 internal fragment links across `content/**`, of which 4 pointed at +// headings that do not exist: +// +// content/docs/automation/flows.mdx:240 #notify +// content/docs/concepts/metadata-lifecycle.mdx:77 #overlay-whitelist +// content/docs/permissions/authentication.mdx:75 /docs/deployment/cli#os-login--json-is-ndjson--the-one-exception +// content/docs/protocol/kernel/http-protocol.mdx:764 /docs/api/client-sdk#clientdata--crud-operations +// +// Three of the four are the exact shape the card predicted — an anchor written +// from the heading a reader SEES, one dash off from the heading the slugger +// produces. They are fixed in the PR that adds this file, which is why there is +// no `KNOWN_*` baseline here: a gate that ships with an allowlist teaches that +// the allowlist is where new failures go. +// +// ## Scope, stated so the next reader does not have to infer it +// +// - **Sources**: every `.mdx`/`.md` under `content/`, plus `README.md` and +// `ARCHITECTURE.md` — the same corpus the lychee lane globs, so the two +// gates cover one surface between them rather than two overlapping ones. +// Those two render on GitHub rather than through Fumadocs, which is not a +// second slug rule to model: `github-slugger` IS GitHub's algorithm, and +// that is the package's entire reason to exist. The one thing GitHub does +// NOT honour is the `[#custom-id]` suffix — neither file uses it, and a +// `content/**` page that does is unaffected because it renders in Fumadocs. +// - **Destinations**: internal links only. `http(s):`, `mailto:` and every +// other scheme are out of scope BY DESIGN, for the reason in (1) above. +// - **A link with no `#`** is lychee's, not this gate's: file existence is +// already checked there and duplicating it would mean two gates with two +// resolvers disagreeing about the same link. +// - **A link WITH a `#` whose page does not resolve** is reported here, as +// its own class. Skipping it would silently drop the anchor from coverage, +// and "checked nothing, reported green" is the disease this card is about. +// - Fenced code blocks and inline code spans are stripped before extraction, +// so an illustrative link in an example is not a finding. +// +// node scripts/check-doc-anchors.mjs +// node scripts/check-doc-anchors.mjs --self-test # verify the checker itself + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Slugger from 'github-slugger'; + +import { stripCodeSpans, stripFencedBlocks } from './check-adr-links.mjs'; + +/** The Fumadocs content root — what `/` means in a site route, and what the + * lychee lane passes as `--root-dir`. */ +const CONTENT_ROOT = 'content'; + +/** Link sources outside `content/`, matching the lychee globs. */ +const EXTRA_SOURCES = ['README.md', 'ARCHITECTURE.md']; + +const PAGE_EXTENSIONS = ['.mdx', '.md']; + +/* --------------------------------------------------------------- heading ids */ + +/** ATX heading, including the optional closing `##` run CommonMark allows. */ +const ATX_HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*$/; + +/** Fumadocs' custom-id suffix — `## Heading [#custom-id]`, remark-heading.ts. */ +const CUSTOM_ID = /\s*\[#([^\]]+?)]\s*$/; + +/** Sentinel for a masked inline code span. Private-use, so no source can contain it. */ +const MASK = '\uE000'; + +/** + * Replace every inline code span with a sentinel, returning the literals. + * + * This runs FIRST and is undone LAST. Everything between the two operates on + * the masked string, so `` `` `` and `` `OS__` `` + * survive the JSX and emphasis passes intact — the two corpus-wide parity + * failures this file was born with. + */ +function maskCodeSpans(raw) { + const codes = []; + let out = ''; + let i = 0; + while (i < raw.length) { + if (raw[i] !== '`') { + out += raw[i]; + i += 1; + continue; + } + let open = 0; + while (raw[i + open] === '`') open += 1; + let j = i + open; + let close = -1; + while (j < raw.length) { + if (raw[j] === '`') { + let run = 0; + while (raw[j + run] === '`') run += 1; + if (run === open) { + close = j; + break; + } + j += run; + continue; + } + j += 1; + } + if (close === -1) { + out += raw.slice(i, i + open); + i += open; + continue; + } + let value = raw.slice(i + open, close); + // CommonMark: one space is stripped from each end when both are present. + if (value.length > 2 && value.startsWith(' ') && value.endsWith(' ') && value.trim() !== '') { + value = value.slice(1, -1); + } + out += `${MASK}${codes.length}${MASK}`; + codes.push(value); + i = close + open; + } + return { masked: out, codes }; +} + +function unmask(text, codes) { + return text.replace(new RegExp(`${MASK}(\\d+)${MASK}`, 'g'), (_, n) => codes[Number(n)] ?? ''); +} + +/** + * Approximate fumadocs' `flattenNode(heading)` — the concatenation of every + * descendant node's `value` — from one heading's raw Markdown. + * + * Verified equal to the real pipeline over the whole corpus; see the header. + */ +export function flattenHeadingText(raw) { + const { masked, codes } = maskCodeSpans(raw); + let s = masked; + s = s.replace(/<\/?[A-Za-z][^>]*\/?>/g, ''); // mdxJsxTextElement carries no `value` + s = s.replace(/!\[[^\]]*]\([^)]*\)/g, ''); // `image` has neither children nor `value` + s = s.replace(/\[([^\]]*)]\([^)]*\)/g, '$1'); // inline link -> its text + s = s.replace(/\[([^\]]*)]\[[^\]]*]/g, '$1'); // reference link -> its text + // Emphasis/strong/strikethrough delimiters are syntax. An INTRAWORD `_` or + // `*` is literal text under CommonMark's flanking rules (`snake_case` keeps + // both underscores, and `github-slugger` keeps them in the slug), so only + // runs on a word boundary are removed. + s = s.replace(/(^|[^\p{L}\p{N}])([*_~]+)/gu, '$1'); + s = s.replace(/([*_~]+)($|[^\p{L}\p{N}])/gu, '$2'); + s = s.replace(/\\([\\`*_{}[\]()#+\-.!~<>|])/g, '$1'); // backslash escapes + return unmask(s, codes).trim(); +} + +/** Blank out a leading YAML frontmatter block, preserving line count. */ +export function stripFrontmatter(text) { + const m = /^---\r?\n[\s\S]*?\r?\n---[ \t]*(\r?\n|$)/.exec(text); + if (!m) return text; + return m[0].replace(/[^\n]/g, '') + text.slice(m[0].length); +} + +/** + * Every heading id one page renders, in document order. + * + * One `Slugger` per file, fed in document order — the duplicate-heading `-1` / + * `-2` counters are part of the contract, not an accident. + */ +export function headingIds(markdown) { + const slugger = new Slugger(); + const ids = []; + for (const line of stripFencedBlocks(stripFrontmatter(markdown)).split('\n')) { + const m = ATX_HEADING.exec(line); + if (!m) continue; + const raw = (m[2] ?? '').replace(/\s+#+\s*$/, ''); + // The custom-id probe runs on the MASKED text so a `[#x]` written inside a + // code span is not mistaken for one — fumadocs only honours the suffix when + // the heading's last child is a text node. + const { masked, codes } = maskCodeSpans(raw); + const custom = CUSTOM_ID.exec(masked); + if (custom) { + ids.push(unmask(custom[1], codes)); + continue; + } + ids.push(slugger.slug(flattenHeadingText(raw))); + } + return ids; +} + +/* --------------------------------------------------------------------- links */ + +const INLINE_LINK = /!?\[[^\]]*]\(\s*([^)\s]*?)(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/g; +const REFERENCE_DEFINITION = /^ {0,3}\[[^\]]+]:\s*(\S+)/; + +/** A destination that names something in this repository, not a URL. */ +function isInternal(dest) { + if (!dest) return false; + return !/^[a-z][a-z0-9+.\-]*:/i.test(dest) && !dest.startsWith('//'); +} + +/** + * Every internal link destination carrying a `#fragment`, with its line. + * Fenced blocks and code spans go first — that is the discrimination mechanism, + * shared with `check-adr-links.mjs` so both gates read a document the same way. + */ +export function extractFragmentLinks(markdown) { + const prose = stripCodeSpans(stripFencedBlocks(markdown)); + const found = []; + prose.split('\n').forEach((line, idx) => { + const dests = []; + INLINE_LINK.lastIndex = 0; + let m; + while ((m = INLINE_LINK.exec(line)) !== null) dests.push(m[1]); + const ref = REFERENCE_DEFINITION.exec(line); + if (ref) dests.push(ref[1]); + for (const dest of dests) { + if (!isInternal(dest) || !dest.includes('#')) continue; + const hash = dest.indexOf('#'); + const fragment = dest.slice(hash + 1); + if (fragment === '') continue; // `foo#` names the page, not an anchor + found.push({ line: idx + 1, dest, path: dest.slice(0, hash), fragment }); + } + }); + return found; +} + +/* ---------------------------------------------------------------- resolution */ + +/** + * Map a site route (`/docs/permissions`) or a relative path onto the file that + * renders it — the same rule the lychee lane asserts on its invocation + * (`--root-dir /content --fallback-extensions mdx,md`). + */ +export function resolvePage(root, fromFile, linkPath) { + if (!linkPath) return fromFile; // a bare `#anchor` is same-document + let p = linkPath.split('?')[0]; + try { + p = decodeURIComponent(p); + } catch { + /* leave as written */ + } + p = p.replace(/\/+$/, ''); + if (p === '') return fromFile; + const base = p.startsWith('/') ? join(CONTENT_ROOT, p.slice(1)) : join(dirname(fromFile), p); + const candidates = [ + base, + ...PAGE_EXTENSIONS.map((e) => base + e), + ...PAGE_EXTENSIONS.map((e) => join(base, `index${e}`)), + ]; + for (const candidate of candidates) { + const abs = join(root, candidate); + if (existsSync(abs) && statSync(abs).isFile()) return relative(root, resolve(abs)); + } + return null; +} + +function listPages(root, dir) { + const abs = join(root, dir); + if (!existsSync(abs)) return []; + return readdirSync(abs, { withFileTypes: true, recursive: true }) + .filter((entry) => entry.isFile() && PAGE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) + .map((entry) => relative(root, join(entry.parentPath ?? abs, entry.name))) + .sort(); +} + +function listSources(root) { + return [...listPages(root, CONTENT_ROOT), ...EXTRA_SOURCES.filter((f) => existsSync(join(root, f)))]; +} + +/** + * Sweep the corpus. Returns `{ checked, broken, unresolved, sources }` — + * `checked` is the census of fragment links examined, so "clean corpus" can be + * told apart from "the extractor stopped matching". + */ +export function sweep(root = process.cwd()) { + const idCache = new Map(); + const idsOf = (file) => { + if (!idCache.has(file)) idCache.set(file, new Set(headingIds(readFileSync(join(root, file), 'utf8')))); + return idCache.get(file); + }; + + const broken = []; + const unresolved = []; + let checked = 0; + const sources = listSources(root); + + for (const file of sources) { + const text = readFileSync(join(root, file), 'utf8'); + for (const link of extractFragmentLinks(text)) { + const target = resolvePage(root, file, link.path); + if (target === null) { + unresolved.push({ file, ...link }); + continue; + } + checked += 1; + if (!idsOf(target).has(link.fragment)) broken.push({ file, ...link, target }); + } + } + return { checked, broken, unresolved, sources: sources.length }; +} + +/* ---------------------------------------------------------------- reporting */ + +function describe(finding) { + return ( + ` ${finding.file}:${finding.line} ${finding.dest}\n` + + ` ${finding.target} renders no heading with id "${finding.fragment}"` + ); +} + +function runCheck() { + const { checked, broken, unresolved, sources } = sweep(); + const problems = []; + + if (checked === 0) { + problems.push( + 'No internal fragment links found in the docs corpus at all.\n' + + ' A green run over nothing is not a green run: either the corpus moved or the\n' + + ' extractor stopped matching. Fix the sweep, do not delete this guard.', + ); + } + + if (broken.length > 0) { + problems.push( + `${broken.length} link(s) point at a heading that does not exist:\n` + + broken.map(describe).join('\n') + + '\n\n The heading id is computed with `github-slugger`, the same package\n' + + ' fumadocs-core uses to render the page, so what this gate says the anchor is\n' + + ' is what the site says it is. Print a page\'s real ids with:\n' + + " node -e \"import('./scripts/check-doc-anchors.mjs').then(m=>console.log(m.headingIds(require('node:fs').readFileSync('','utf8')).join('\\n')))\"\n" + + ' Fix the link, or fix the heading. Do not add an exemption — an anchor that\n' + + ' nobody may check is the defect this gate exists to remove (#7484).', + ); + } + + if (unresolved.length > 0) { + problems.push( + `${unresolved.length} link(s) carry a #fragment but name a page that does not resolve:\n` + + unresolved.map((u) => ` ${u.file}:${u.line} ${u.dest}`).join('\n') + + '\n\n These are not skipped on purpose: an unresolvable page means the anchor was\n' + + ' never checked, and silently uncovered is how this gap started.', + ); + } + + if (problems.length > 0) { + console.error(`❌ check-doc-anchors\n\n${problems.join('\n\n')}\n`); + process.exit(1); + } + console.log(`✅ check-doc-anchors: ${checked} internal #fragment link(s) across ${sources} source file(s) all resolve to a real heading`); +} + +/* ---------------------------------------------------------------- self-test */ + +function assert(condition, message) { + if (!condition) { + console.error(`❌ check-doc-anchors --self-test: ${message}`); + process.exit(1); + } +} + +/** + * The two corpus-wide parity failures this file was born with. Both are the + * same bug — markup inside an inline code span read as markup — and both are + * live headings, so a regression fails here rather than on someone's PR. + */ +const PARITY_PINS = [ + ['### 8. `` axes bound to the wrong result column', '8-objectchart-axes-bound-to-the-wrong-result-column'], + ['### 3. Settings env overrides: canonical `OS__` only', '3-settings-env-overrides-canonical-os_namespace_key-only'], +]; + +function idOf(headingLine) { + return headingIds(headingLine)[0]; +} + +function selfTest() { + // 1. Slug parity with the renderer, on the two shapes that actually broke. + for (const [heading, expected] of PARITY_PINS) { + assert(idOf(heading) === expected, `parity pin failed: ${heading}\n expected ${expected}\n got ${idOf(heading)}`); + } + + // 2. The inline shapes the flattener has to get right. + assert(idOf('## `os login --json` is NDJSON — the one exception') === 'os-login---json-is-ndjson--the-one-exception', 'code span + em dash'); + assert(idOf('## `client.data` — CRUD & Batch') === 'clientdata--crud--batch', 'code span + ampersand'); + assert(idOf('## **Bold** and *emphasis*') === 'bold-and-emphasis', 'emphasis delimiters are syntax'); + assert(idOf('## The `snake_case` rule') === 'the-snake_case-rule', 'intraword underscore is literal text'); + assert(idOf('## See [the CLI](/docs/deployment/cli) first') === 'see-the-cli-first', 'a link contributes its text, not its href'); + assert(idOf('## Closing hashes ##') === 'closing-hashes', 'ATX closing sequence'); + assert(idOf('## Dynamic approvers (#3447) [#dynamic-approvers-3447]') === 'dynamic-approvers-3447', 'fumadocs custom id'); + assert(idOf('## A `[#not-an-id]` code span') === 'a-not-an-id-code-span', 'a custom id inside a code span is not a custom id'); + + // 3. Duplicate headings get the renderer's counter suffixes. + const dupes = headingIds('## Overview\n## Overview\n## Overview\n'); + assert( + dupes.join(',') === 'overview,overview-1,overview-2', + `duplicate-heading counters drifted: ${dupes.join(',')}`, + ); + + // 4. Headings that are not headings. + assert(headingIds('```md\n## Not a heading\n```\n').length === 0, 'a heading inside a fence is not a heading'); + assert(headingIds('---\ntitle: Frontmatter\n---\n\n## Real\n').join(',') === 'real', 'frontmatter is not a heading'); + + // 5. Extraction discriminates prose from examples. + const extracted = extractFragmentLinks( + 'See [a](/docs/x#good) and `[b](/docs/x#in-a-code-span)`.\n' + + '```md\n[c](/docs/x#in-a-fence)\n```\n' + + 'External [d](https://example.com/y#skipped), page-only [e](/docs/x#).\n', + ); + assert( + extracted.map((l) => l.fragment).join(',') === 'good', + `extractor picked up the wrong links: ${extracted.map((l) => l.dest).join(', ')}`, + ); + + // 6. End to end, on a synthetic corpus: a good anchor is silent, a dead one + // is found, and an unresolvable page is its own class. A discrimination + // rule that only ever says "nothing found" looks exactly like a broken + // extractor, so both directions are provoked. + const tmp = mkdtempSync(join(tmpdir(), 'check-doc-anchors-')); + try { + const write = (rel, body) => { + mkdirSync(dirname(join(tmp, rel)), { recursive: true }); + writeFileSync(join(tmp, rel), body); + }; + write('content/docs/target.mdx', '---\ntitle: T\n---\n\n## Overlay whitelist (shared-DB tenancy invariant)\n'); + write( + 'content/docs/source.mdx', + '---\ntitle: S\n---\n\n' + + '[ok](/docs/target#overlay-whitelist-shared-db-tenancy-invariant)\n' + + '[relative ok](./target#overlay-whitelist-shared-db-tenancy-invariant)\n' + + '[dead](/docs/target#overlay-whitelist)\n' + + '[no page](/docs/gone#anywhere)\n', + ); + const result = sweep(tmp); + assert(result.checked === 3, `expected 3 checked fragment links, got ${result.checked}`); + assert(result.broken.length === 1 && result.broken[0].fragment === 'overlay-whitelist', 'the dead anchor was not reported'); + assert(result.unresolved.length === 1 && result.unresolved[0].path === '/docs/gone', 'the unresolvable page was not reported'); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + + // 7. The census guard is load-bearing: it is the only thing that tells a + // clean corpus apart from an extractor that matches nothing. + const live = sweep(); + assert(live.checked > 0, 'the real corpus yielded zero fragment links — the extractor is over-stripping'); + + console.log( + `✅ check-doc-anchors --self-test: slug parity, custom ids, duplicate counters, extraction discrimination and both finding classes verified (${live.checked} live fragment links)`, + ); +} + +/* Run only when invoked as a program — the extractor and the slug helpers are + * exported so a caller chasing a false positive can import them without the + * import itself sweeping the repo. */ +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + if (process.argv.includes('--self-test')) selfTest(); + else runCheck(); +}