docs,feat(doc-gates): fix 15 dead anchor fragments; verify-md-links now validates fragments

A corpus sweep under the doc/prose standards found 15 links whose #fragment
named no anchor in its target — reworded headings, one relocated contract
(tool-fs → the group README's no-timeout rule), and zh sides citing English
slugs their Chinese headings never produce. Fixed all 15 (zh sides get the
conventional explicit <a id> + English fragment), fixed the one generator-owned
instance at its source (gen-doc-graphs), and extended verify-md-links to
resolve fragments onto Markdown targets — same-file anchors included — against
heading slugs and explicit <a id>, so the class is gated instead of manually
grepped. Remaining probes (narrated history, duplication shingles, comment
transcripts, budgets) came back clean; sibling-adapter README symmetry and
implemented-note contrasts are deliberate keeps.
This commit is contained in:
Tianyi Cui
2026-08-09 03:05:29 +08:00
parent 80019566f2
commit 10ef3d4924
29 changed files with 304 additions and 53 deletions

View File

@@ -1,8 +1,10 @@
/**
* Verify that relative Markdown links, images, and definitions resolve. URL,
* root-absolute, and in-page targets are excluded; query strings and fragments
* do not affect resolution against the source file. The checker never rewrites,
* and symlinked instruction files are deduped.
* Verify that relative Markdown links, images, and definitions resolve — the
* target file must exist AND a `#fragment` onto a Markdown target (including
* a same-file `#anchor`) must name a real heading slug or explicit `<a id>`.
* URL and root-absolute targets are excluded; query strings do not affect
* resolution against the source file. The checker never rewrites, and
* symlinked instruction files are deduped.
*/
import { existsSync, readFileSync } from 'node:fs'
@@ -28,21 +30,22 @@ const PATTERNS = [
'skills/**/*.md',
]
/** A broken relative link: a target path that does not resolve to a file. */
/** A broken relative link: a missing target path or a missing anchor on it. */
interface Violation {
file: string
/** 1-based line where the link/image/definition node starts. */
line: number
url: string
/** What failed: the target file or the fragment onto it. */
reason: 'target' | 'anchor'
}
/**
* True for targets this gate must NOT check: scheme-qualified URLs (`https:`,
* `mailto:`, …), protocol-relative (`//host`), root-absolute (`/path`), and
* pure in-page anchors (`#frag`). Everything else is a relative path we own.
* `mailto:`, …), protocol-relative (`//host`), and root-absolute (`/path`).
* Pure in-page anchors (`#frag`) ARE checked, against the source file itself.
*/
function isExternalOrAnchor(url: string): boolean {
if (url.startsWith('#')) return true
function isExternal(url: string): boolean {
if (url.startsWith('//')) return true
if (url.startsWith('/')) return true
// A scheme like `https:` / `mailto:` — a colon before any slash, dot, or hash.
@@ -69,22 +72,105 @@ function pathPart(url: string): string {
}
}
/** Find every broken relative cross-link in one Markdown file via its AST. */
function findViolations(absPath: string): Violation[] {
const file = relative(root, absPath)
/** The percent-decoded `#fragment` of a link target, or null when it has none. */
function fragmentPart(url: string): string | null {
const hash = url.indexOf('#')
if (hash === -1) return null
const raw = url.slice(hash + 1).replace(/\?.*$/, '')
try {
return decodeURIComponent(raw)
} catch {
// Same stance as pathPart: a malformed escape names no anchor anyone
// meant, so the raw text flows into the lookup and is reported missing.
return raw
}
}
/**
* GitHub's heading-slug algorithm (lowercase; drop everything but letters,
* numbers, spaces, hyphens; spaces become hyphens) — the same rule
* `gen-cordis-catalog`'s region anchors are built from, kept in sync by the
* corpus passing this gate rather than by a shared import across the
* script/package boundary.
* @param heading - the rendered heading text.
* @returns the anchor GitHub assigns the first occurrence of the heading.
*/
export function githubSlug(heading: string): string {
return heading.toLowerCase().replace(/[^\p{L}\p{N} -]/gu, '').replaceAll(' ', '-')
}
/**
* Every anchor one Markdown document exposes: each heading's GitHub slug
* (repeated headings get the renderer's `-1`, `-2`, … suffixes) plus every
* explicit `<a id="…">`. Lowercased for case-insensitive fragment matching.
* @param source - the document's full Markdown text.
* @returns the set of valid fragments for links into this document.
*/
export function documentAnchors(source: string): Set<string> {
const anchors = new Set<string>()
const seen = new Map<string, number>()
const tree = parseMarkdown(source)
visitMarkdown(tree, (node: Nodes): void => {
if (node.type === 'heading') {
const text = source.slice(node.position?.start.offset ?? 0, node.position?.end.offset ?? 0)
.replace(/^#{1,6}\s+/, '')
.replace(/[`*_]/g, '')
const base = githubSlug(text)
const bump = seen.get(base) ?? 0
seen.set(base, bump + 1)
anchors.add(bump === 0 ? base : `${base}-${bump}`)
}
})
for (const match of source.matchAll(/<a id="([^"]+)"/g)) anchors.add((match[1] ?? '').toLowerCase())
return anchors
}
/** Lazily collect and cache the anchor set of any existing Markdown file. */
function anchorCache(): (absPath: string) => Set<string> {
const cache = new Map<string, Set<string>>()
return (absPath) => {
const hit = cache.get(absPath)
if (hit) return hit
const anchors = documentAnchors(readFileSync(absPath, 'utf8'))
cache.set(absPath, anchors)
return anchors
}
}
/**
* Find every broken relative cross-link in one Markdown file via its AST: a
* relative target that does not exist, or a fragment onto a Markdown file
* (same-file `#anchor` links included) that names no heading slug or explicit
* `<a id>` there. Fragments onto non-Markdown targets (`file.ts#L10`) carry
* renderer-owned semantics and are not judged.
* @param absPath - absolute path of the Markdown source to scan.
* @param anchorsOf - anchor lookup shared across files for cross-link checks.
* @param scanRoot - repository root violations are reported relative to.
* @returns one entry per broken link, in document order.
*/
export function findViolations(
absPath: string,
anchorsOf: (abs: string) => Set<string>,
scanRoot: string = root,
): Violation[] {
const file = relative(scanRoot, absPath)
const dir = dirname(absPath)
const source = readFileSync(absPath, 'utf8')
const tree = parseMarkdown(source)
const out: Violation[] = []
const check = (url: string, node: Nodes): void => {
if (isExternalOrAnchor(url)) return
if (isExternal(url)) return
const target = pathPart(url)
// A bare `#anchor` reduced to empty path is a same-file anchor — skip.
if (target === '') return
const resolved = resolve(dir, target)
const resolved = target === '' ? absPath : resolve(dir, target)
if (!existsSync(resolved)) {
out.push({ file, line: node.position?.start.line ?? 0, url })
out.push({ file, line: node.position?.start.line ?? 0, url, reason: 'target' })
return
}
const fragment = fragmentPart(url)
if (fragment === null || !resolved.endsWith('.md')) return
if (!anchorsOf(resolved).has(fragment.toLowerCase())) {
out.push({ file, line: node.position?.start.line ?? 0, url, reason: 'anchor' })
}
}
@@ -96,18 +182,22 @@ function findViolations(absPath: string): Violation[] {
return out
}
// Archived notes remain valid link targets, but their historical outbound links are frozen.
const files = uniqueRepoFiles(root, PATTERNS, isArchivedAgentNotePath)
const all = files.flatMap(file => findViolations(file.abs))
const checked = files.length
// Run only when invoked as a script, not when imported by the spec.
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
// Archived notes remain valid link targets, but their historical outbound links are frozen.
const files = uniqueRepoFiles(root, PATTERNS, isArchivedAgentNotePath)
const anchorsOf = anchorCache()
const all = files.flatMap(file => findViolations(file.abs, anchorsOf))
const checked = files.length
if (all.length === 0) {
console.log(`verify-md-links: ${checked} file(s) checked, all relative cross-links resolve.`)
process.exit(0)
}
if (all.length === 0) {
console.log(`verify-md-links: ${checked} file(s) checked, all relative cross-links and fragments resolve.`)
process.exit(0)
}
console.error('verify-md-links: broken relative cross-links found (target does not exist):')
for (const v of all) {
console.error(` ${v.file}:${v.line} ${v.url}`)
console.error('verify-md-links: broken relative cross-links found:')
for (const v of all) {
console.error(` ${v.file}:${v.line} ${v.url} (${v.reason === 'target' ? 'target does not exist' : 'no such anchor in target'})`)
}
process.exit(1)
}
process.exit(1)