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:
@@ -1154,7 +1154,7 @@ function renderLifecycle(): string {
|
||||
const maintenance = 'curated Mermaid sequence; exact event signatures live in the generated Cordis catalog'
|
||||
return [
|
||||
...generatedHeader('Agent Turn And Step Lifecycle'),
|
||||
'This sequence is the visual companion to [architecture.md](architecture.md#loop-lifecycle-session--turn--step). It keeps durable replay facts on `session/event` and live control/status on `agent/*`.',
|
||||
'This sequence is the visual companion to [architecture.md](architecture.md#default-loop-lifecycle). It keeps durable replay facts on `session/event` and live control/status on `agent/*`.',
|
||||
'',
|
||||
'```mermaid',
|
||||
'sequenceDiagram',
|
||||
|
||||
83
scripts/verify-md-links.spec.ts
Normal file
83
scripts/verify-md-links.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Acceptance-path coverage for fragment validation in `verify-md-links`: a
|
||||
* `#fragment` onto a Markdown target — same-file anchors included — must name
|
||||
* a real heading slug or explicit `<a id>`, while non-Markdown fragments and
|
||||
* external targets stay out of scope.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { documentAnchors, findViolations, githubSlug } from './verify-md-links.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function layout(files: Record<string, string>): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'md-links-'))
|
||||
roots.push(root)
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
mkdirSync(join(root, rel, '..'), { recursive: true })
|
||||
writeFileSync(join(root, rel), content)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
function violationsIn(root: string, rel: string): { url: string; reason: string }[] {
|
||||
const cache = new Map<string, Set<string>>()
|
||||
const anchorsOf = (abs: string): Set<string> => {
|
||||
const hit = cache.get(abs)
|
||||
if (hit) return hit
|
||||
const anchors = documentAnchors(readFileSync(abs, 'utf8'))
|
||||
cache.set(abs, anchors)
|
||||
return anchors
|
||||
}
|
||||
return findViolations(join(root, rel), anchorsOf, root).map(({ url, reason }) => ({ url, reason }))
|
||||
}
|
||||
|
||||
describe('documentAnchors', () => {
|
||||
it('slugs headings, suffixes repeats, and reads explicit <a id> anchors', () => {
|
||||
const anchors = documentAnchors([
|
||||
'# My Doc',
|
||||
'## Live `events` — mode!',
|
||||
'## Repeat',
|
||||
'## Repeat',
|
||||
'<a id="hand-anchor"></a>',
|
||||
'',
|
||||
].join('\n'))
|
||||
expect(anchors).toEqual(new Set(['my-doc', 'live-events--mode', 'repeat', 'repeat-1', 'hand-anchor']))
|
||||
expect(githubSlug('Security and authority are non-goals')).toBe('security-and-authority-are-non-goals')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findViolations fragments', () => {
|
||||
it('accepts resolving same-file and cross-file fragments, non-md fragments, and externals', () => {
|
||||
const root = layout({
|
||||
'a.md': '# A\n\n## Deferred work\n\n[self](#deferred-work) [b](b.md#part-two) [code](x.ts#L10) [ext](https://x.example/#frag)\n',
|
||||
'b.md': '# B\n\n## Part two\n',
|
||||
'x.ts': 'export {}\n',
|
||||
})
|
||||
expect(violationsIn(root, 'a.md')).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a same-file fragment that names no heading or <a id>', () => {
|
||||
const root = layout({ 'a.md': '# A\n\n[gone](#deferred-work)\n' })
|
||||
expect(violationsIn(root, 'a.md')).toEqual([{ url: '#deferred-work', reason: 'anchor' }])
|
||||
})
|
||||
|
||||
it('rejects a cross-file fragment missing from the target document', () => {
|
||||
const root = layout({
|
||||
'a.md': '# A\n\n[stale](b.md#old-heading)\n',
|
||||
'b.md': '# B\n\n## New heading\n',
|
||||
})
|
||||
expect(violationsIn(root, 'a.md')).toEqual([{ url: 'b.md#old-heading', reason: 'anchor' }])
|
||||
})
|
||||
|
||||
it('still rejects a missing target file, reported as target not anchor', () => {
|
||||
const root = layout({ 'a.md': '# A\n\n[ghost](missing.md#anything)\n' })
|
||||
expect(violationsIn(root, 'a.md')).toEqual([{ url: 'missing.md#anything', reason: 'target' }])
|
||||
})
|
||||
})
|
||||
@@ -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