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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user