perf(docs): parse each Markdown document once in documentation gates

markdown links parsed anchor targets twice more and probed existence
without a cache; translation pairing reparsed every document after link
normalization and globbed four times; the README model-experience and
limitations checks parsed each README three times; type equivalence
parsed all 1,835 documents; Session format references reparsed each pair
four times. Each gate now parses a document once: links and anchors come
from one tree, pairing reuses the original tree when every rewritten
destination keeps the block structure and falls back to a full reparse
otherwise, the README checks share one tree, a text prefilter skips
documents that cannot hold a source-equivalence fence, and the
persistence renderer passes its trees on. Outputs are byte-identical on
the repository, and seeded faults are reported identically.

Refs #5852
This commit is contained in:
imccyu committed 2026-10-12 11:06:53 +08:00
1 parent 6fbfbb044e
commit 1e24617aad
14 files changed
+405 -95

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
/** Regression coverage for reusing one parse across the Markdown gate helpers. */
import { describe, expect, it } from 'vitest'
import { markdownHeadingLines, markdownProseLines, parseMarkdown } from './markdown.ts'
const SOURCE = [
'# Title',
'',
'Prose <!-- hidden --> kept.',
'<!-- comment only -->',
'',
'```ts',
'# not a heading',
'```',
'',
'Setext heading',
'--------------',
'',
].join('\n')
describe('Markdown parse reuse', () => {
it('derives prose lines and headings from a supplied parse exactly as from its own parse', () => {
const tree = parseMarkdown(SOURCE)
const unchanged = structuredClone(tree)
const prose = markdownProseLines(SOURCE, tree)
const headings = markdownHeadingLines(SOURCE, tree)
expect(prose).toEqual(markdownProseLines(SOURCE))
expect(headings).toEqual(markdownHeadingLines(SOURCE))
expect(prose.map(line => line.index)).toEqual([1, 2, 3, 5, 9, 10, 11, 12])
expect(headings).toEqual([
{ depth: 1, index: 1, raw: '# Title', text: 'Title' },
{ depth: 2, index: 10, raw: 'Setext heading', text: 'Setext heading' },
])
expect(tree).toEqual(unchanged)
})
})
+14 -8
View File
@@ -180,11 +180,16 @@ function renderedText(node: Nodes): string {
return ''
}
/** Return every parsed Markdown heading with its rendered text and source line. */
export function markdownHeadingLines(source: string): MarkdownHeadingLine[] {
/**
* Return every parsed Markdown heading with its rendered text and source line.
* @param source - Markdown source whose headings are returned.
* @param tree - the {@link parseMarkdown} result for exactly `source`, when the caller already has it.
* @returns headings in document order.
*/
export function markdownHeadingLines(source: string, tree: Nodes = parseMarkdown(source)): MarkdownHeadingLine[] {
const rawLines = source.split('\n')
const headings: MarkdownHeadingLine[] = []
visitMarkdown(parseMarkdown(source), (node) => {
visitMarkdown(tree, (node) => {
if (node.type !== 'heading' || node.position === undefined) return
headings.push({
depth: node.depth,
@@ -200,9 +205,9 @@ type ColumnRange = readonly [start: number, end: number]
type OffsetRange = readonly [start: number, end: number]
/** Source-column ranges occupied by parsed HTML comments, keyed by source line. */
function htmlCommentRanges(source: string, rawLines: readonly string[]): Map<number, ColumnRange[]> {
function htmlCommentRanges(tree: Nodes, rawLines: readonly string[]): Map<number, ColumnRange[]> {
const comments: OffsetRange[] = []
visitMarkdown(parseMarkdown(source), (node) => {
visitMarkdown(tree, (node) => {
if (node.type !== 'html' || node.position?.start.offset === undefined) return
let cursor = 0
while (true) {
@@ -250,13 +255,14 @@ function hasRenderedTextOutsideComments(raw: string, ranges: readonly ColumnRang
/**
* Return source lines outside code blocks and HTML comments.
* @param source - Markdown source whose prose should be retained verbatim.
* @param tree - the {@link parseMarkdown} result for exactly `source`, when the caller already has it.
* @returns unfenced lines with their original 1-based locations.
*/
export function markdownProseLines(source: string): MarkdownProseLine[] {
export function markdownProseLines(source: string, tree: Nodes = parseMarkdown(source)): MarkdownProseLine[] {
const rawLines = source.split('\n')
const comments = htmlCommentRanges(source, rawLines)
const comments = htmlCommentRanges(tree, rawLines)
const fenced = new Set<number>()
visitMarkdown(parseMarkdown(source), (node) => {
visitMarkdown(tree, (node) => {
if (node.type !== 'code' || node.position === undefined) return
for (let line = node.position.start.line; line <= node.position.end.line; line += 1) fenced.add(line)
})
+2 -1
View File
@@ -46,9 +46,10 @@ export function renderPersistencePair(root: string, source: string, en: string,
translationStructureSignature(zhTree, sourceTargets, { ...context, sourcePath: paths.zh, markdown: zh }),
)
if (errors.length > 0) throw new Error(`${source}: bilingual structure mismatch: ${errors.join('; ')}`)
const record = computeTranslationPairingRecord(paths, en, zh, context, { en: sourceTree, zh: zhTree })
return [
{ path: paths.source, content: en },
{ path: paths.zh, content: zh },
{ path: paths.meta, content: renderTranslationPairingRecord(paths, computeTranslationPairingRecord(paths, en, zh, context)) },
{ path: paths.meta, content: renderTranslationPairingRecord(paths, record) },
]
}
+48 -9
View File
@@ -58,7 +58,8 @@ interface Replacement {
value: string
}
type LinkNode = Extract<Nodes, { type: 'link' | 'definition' }>
/** Markdown nodes whose destinations translation links resolve. */
export type LinkNode = Extract<Nodes, { type: 'link' | 'definition' }>
/** Offset of the one top-level switcher link immediately following the H1. */
export function languageSwitcherLinkOffset(
@@ -306,6 +307,51 @@ export function rewriteTranslationLinkLocales(
return { content: applyReplacements(markdown, replacements), rewritten: replacements.length }
}
/** One authored destination that link normalization replaced. */
export interface TranslationLinkDestinationEdit {
/** Inline link or referenced definition whose destination was replaced. */
node: LinkNode
/** Absolute source offset where the authored destination text starts. */
start: number
/** Absolute source offset where the authored destination text ends. */
end: number
/** Semantic target text that replaces `markdown.slice(start, end)`. */
value: string
}
/** Normalized Markdown text and the destination edits that produced it. */
export interface TranslationLinkNormalization {
/** Markdown with paired-document destinations replaced by semantic targets. */
content: string
/** Applied edits in ascending source order; empty when `content` equals the input. */
edits: TranslationLinkDestinationEdit[]
}
/**
* Normalize only paired-document locale paths and report each replaced destination.
* @param markdown - Exact authored Markdown text used for link offsets.
* @param context - Repository and locale used to resolve relative links.
* @param skipTargets - Accepted language-switcher destinations.
* @param tree - Optional GFM parse of this exact Markdown text; never a normalized or edited document.
* @returns The normalized text plus the authored destination ranges it replaced.
*/
export function normalizeTranslationMarkdownLinkEdits(
markdown: string,
context: TranslationLinkContext,
skipTargets: readonly string[] = [],
tree?: Nodes,
): TranslationLinkNormalization {
const edits: TranslationLinkDestinationEdit[] = []
visitResolvedDocumentLinks(markdown, context, skipTargets, (node, destination, resolved) => {
edits.push({
node,
...replacementFor(destination, `dsh-translation-target:${resolved.pair.source}${resolved.suffix}`),
})
}, tree)
const content = applyReplacements(markdown, [...edits])
return { content, edits: edits.sort((left, right) => left.start - right.start) }
}
/**
* Normalize only paired-document locale paths while retaining every other byte and URL suffix.
* @param markdown - Exact authored Markdown text used for link offsets.
@@ -320,14 +366,7 @@ export function normalizeTranslationMarkdownLinks(
skipTargets: readonly string[] = [],
tree?: Nodes,
): string {
const replacements: Replacement[] = []
visitResolvedDocumentLinks(markdown, context, skipTargets, (_node, destination, resolved) => {
replacements.push(replacementFor(
destination,
`dsh-translation-target:${resolved.pair.source}${resolved.suffix}`,
))
}, tree)
return applyReplacements(markdown, replacements)
return normalizeTranslationMarkdownLinkEdits(markdown, context, skipTargets, tree).content
}
/** Semantic target of one authored inline link or referenced definition. */
+101 -14
View File
@@ -7,8 +7,13 @@
import { createHash } from 'node:crypto'
import { basename } from 'node:path'
import type { Nodes } from 'mdast'
import { normalizeTranslationMarkdownLinks, type TranslationLinkContext } from './translation-links.ts'
import type { Nodes, RootContent } from 'mdast'
import {
normalizeTranslationMarkdownLinkEdits,
type TranslationLinkContext,
type TranslationLinkDestinationEdit,
type TranslationLinkNormalization,
} from './translation-links.ts'
import { generatedRegions, parseTranslationMarkdown } from './translation-pairing.ts'
/** The three repository-relative paths that form one bilingual pair. */
@@ -58,31 +63,112 @@ interface Section {
blocks: string[]
}
/** One top-level Markdown node, located in the text that {@link sections} slices. */
export interface TopLevelBlock {
node: RootContent
start: number | undefined
end: number | undefined
line: number
}
function headingText(node: Nodes): string {
if ('value' in node && (node.type === 'text' || node.type === 'inlineCode')) return node.value
return 'children' in node ? node.children.map(headingText).join('') : ''
}
function topLevelBlocks(tree: Nodes): TopLevelBlock[] {
if (tree.type !== 'root') throw new Error('Markdown parser returned a non-root tree')
return tree.children.map(node => ({
node,
start: node.position?.start.offset,
end: node.position?.end.offset,
line: node.position?.start.line ?? 0,
}))
}
/** Characters that read only as destination text in every raw or angle-bracket destination slot. */
const PARSE_NEUTRAL_DESTINATION = /^[A-Za-z0-9._/:#%-]+$/
/**
* Whether one edit replaces a complete, title-free destination that starts on its opener's line,
* where both the authored and the replacement text match {@link PARSE_NEUTRAL_DESTINATION}.
*/
function isParseNeutralEdit(markdown: string, edit: TranslationLinkDestinationEdit): boolean {
const authored = markdown.slice(edit.start, edit.end)
const nodeStart = edit.node.position?.start.offset
const nodeEnd = edit.node.position?.end.offset
if (nodeStart === undefined || nodeEnd === undefined || edit.node.url !== authored) return false
if (!PARSE_NEUTRAL_DESTINATION.test(authored) || !PARSE_NEUTRAL_DESTINATION.test(edit.value)) return false
const before = markdown.slice(nodeStart, edit.start)
const after = markdown.slice(edit.end, nodeEnd)
const angle = before.endsWith('<')
return edit.node.type === 'link'
? /\]\([ \t]*<?$/.test(before) && (angle ? /^>[ \t]*\)$/ : /^[ \t]*\)$/).test(after)
: /\]:[ \t]*<?$/.test(before) && (angle ? /^>[ \t]*$/ : /^[ \t]*$/).test(after)
}
/**
* Locate the authored tree's top-level nodes in normalized text without reparsing it.
*
* An accepted edit only exchanges letters, digits, and `._/:#%-` for others of
* the same set inside one destination that neither starts a line nor carries a
* title. Those characters cannot end the destination, start or end a code span,
* HTML, autolink, autolink literal, emphasis, table cell, title, escape, or
* character reference, or change how a line starts, so the normalized text
* parses to the same nodes with offsets moved by the preceding edits.
*
* @param markdown - Exact authored text that `tree` parses.
* @param tree - GFM parse of `markdown`.
* @param edits - Normalization edits of `markdown` in ascending source order.
* @returns The moved blocks, or `undefined` when an edit is not provably parse-neutral or lies outside every top-level node.
*/
export function normalizedTopLevelBlocks(
markdown: string,
tree: Nodes,
edits: readonly TranslationLinkDestinationEdit[],
): TopLevelBlock[] | undefined {
if (!edits.every(edit => isParseNeutralEdit(markdown, edit))) return undefined
let next = 0
let shift = 0
const moved: TopLevelBlock[] = []
for (const block of topLevelBlocks(tree)) {
if (block.start === undefined || block.end === undefined) return undefined
const start = block.start + shift
for (let edit = edits[next]; edit !== undefined && edit.start < block.end; edit = edits[next]) {
if (edit.start < block.start || edit.end > block.end) return undefined
shift += edit.value.length - (edit.end - edit.start)
next += 1
}
moved.push({ ...block, start, end: block.end + shift })
}
return next === edits.length ? moved : undefined
}
/**
* Split normalized Markdown into the preamble plus one section per heading.
* Fenced code blocks and generated regions, which the pairing gate requires to
* be identical on both sides, are left out of each section's blocks.
*/
function sections(markdown: string, tree: Nodes = parseTranslationMarkdown(markdown)): Section[] {
function sections(markdown: string, blocks: readonly TopLevelBlock[]): Section[] {
const frontmatter = /^---\n[\s\S]*?\n---(?:\n|$)/.exec(markdown)?.[0].length ?? 0
const result: Section[] = [{ blocks: frontmatter > 0 ? [markdown.slice(0, frontmatter)] : [] }]
const regionLines = generatedRegions(markdown).map(region => [region.begin + 1, region.end + 1] as const)
if (tree.type !== 'root') throw new Error('Markdown parser returned a non-root tree')
for (const node of tree.children) {
if ((node.position?.start.offset ?? 0) < frontmatter) continue
for (const { node, start, end, line } of blocks) {
if ((start ?? 0) < frontmatter) continue
if (node.type === 'heading') result.push({ heading: node, blocks: [] })
const line = node.position?.start.line ?? 0
if (node.type === 'code' || regionLines.some(([begin, end]) => line >= begin && line <= end)) continue
result.at(-1)?.blocks.push(markdown.slice(node.position?.start.offset, node.position?.end.offset))
if (node.type === 'code' || regionLines.some(([begin, regionEnd]) => line >= begin && line <= regionEnd)) continue
result.at(-1)?.blocks.push(markdown.slice(start, end))
}
return result
}
/** Sections of one normalized side, reusing its authored parse whenever normalization provably keeps the node layout. */
function sideSections(markdown: string, normalized: TranslationLinkNormalization, tree: Nodes | undefined): Section[] {
if (tree !== undefined && normalized.content === markdown) return sections(markdown, topLevelBlocks(tree))
const moved = tree === undefined ? undefined : normalizedTopLevelBlocks(markdown, tree, normalized.edits)
return sections(normalized.content, moved ?? topLevelBlocks(parseTranslationMarkdown(normalized.content)))
}
function slug(text: string): string {
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'section'
}
@@ -105,7 +191,8 @@ function sectionHash(blocks: string[]): string {
* @param en - English document text.
* @param zh - Chinese document text.
* @param context - Link-resolution inputs for both sides.
* @param trees - Optional GFM parses of the exact English and Chinese inputs; normalized text is parsed separately when it changes.
* @param trees - Optional GFM parses of the exact English and Chinese inputs; changed normalized text
* is parsed again only when {@link normalizedTopLevelBlocks} cannot place its nodes.
* @returns Entries in document order.
* @throws Error when the two sides have different heading counts or malformed generated regions.
*/
@@ -116,10 +203,10 @@ export function computeTranslationPairingRecord(
context: TranslationPairingRecordContext,
trees?: { readonly en: Nodes; readonly zh: Nodes },
): TranslationPairingRecord {
const normalizedEn = normalizeTranslationMarkdownLinks(en, { ...context, sourcePath: paths.source }, [], trees?.en)
const normalizedZh = normalizeTranslationMarkdownLinks(zh, { ...context, sourcePath: paths.zh }, [], trees?.zh)
const enSections = sections(normalizedEn, normalizedEn === en ? trees?.en : undefined)
const zhSections = sections(normalizedZh, normalizedZh === zh ? trees?.zh : undefined)
const normalizedEn = normalizeTranslationMarkdownLinkEdits(en, { ...context, sourcePath: paths.source }, [], trees?.en)
const normalizedZh = normalizeTranslationMarkdownLinkEdits(zh, { ...context, sourcePath: paths.zh }, [], trees?.zh)
const enSections = sideSections(en, normalizedEn, trees?.en)
const zhSections = sideSections(zh, normalizedZh, trees?.zh)
if (enSections.length !== zhSections.length) {
throw new Error(`${paths.source} has ${enSections.length - 1} heading(s) but ${paths.zh} has ${zhSections.length - 1}`)
}
+41
View File
@@ -6,8 +6,10 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { gitIndexPaths, readGitIndexBlob } from './translation-pairing-git.ts'
import { normalizeTranslationMarkdownLinkEdits } from './translation-links.ts'
import {
computeTranslationPairingRecord,
normalizedTopLevelBlocks,
parseTranslationPairingRecord,
renderTranslationPairingRecord,
translationPairingRecordDiff,
@@ -276,6 +278,45 @@ describe('translation pairing records', () => {
])
})
it('places normalized nodes from the authored parse when every rewritten destination is parse-neutral', () => {
const english = [
'# Guide', '', 'See [bar](<bar.md>) and [baz][b], [q](./bar.md#x_y).', '', '[b]: baz.md', '',
'## Tail', '', '| [a](bar.md) | b |', '| --- | --- |', '',
].join('\n')
const chinese = [
'# 指南', '', '参见[bar](<bar.zh.md>)和[baz][b]、[q](./bar.zh.md#x_y)。', '', '[b]: baz.zh.md', '',
'## 尾部', '', '| [a](bar.zh.md) | b |', '| --- | --- |', '',
].join('\n')
const tree = parseTranslationMarkdown(english)
const normalized = normalizeTranslationMarkdownLinkEdits(english, { ...context, sourcePath: paths.source }, [], tree)
const reparsed = parseTranslationMarkdown(normalized.content)
expect(normalized.edits).toHaveLength(4)
expect(normalizedTopLevelBlocks(english, tree, normalized.edits)?.map(({ node, start, end, line }) => [node.type, start, end, line]))
.toEqual('children' in reparsed && reparsed.children.map(node => [
node.type, node.position?.start.offset, node.position?.end.offset, node.position?.start.line,
]))
expect(computeTranslationPairingRecord(paths, english, chinese, context, { en: tree, zh: parseTranslationMarkdown(chinese) }))
.toEqual(record(english, chinese))
})
it.each([
['a space that unlinks a heading', '# [Guide](a%20b.md)\n\nBody.\n', '# [指南](a%20b.zh.md)\n\n正文。\n', ['/guide-dsh-translation-target-docs-a-b-md']],
['a pipe that turns a paragraph into a table', '# Guide\n\nSee [a](a%7Cb.md)\n| --- | --- |\n', '# 指南\n\n参见 [a](a%7Cb.zh.md)\n| --- | --- |\n', ['/guide']],
['a title', '# Guide\n\nSee [bar](bar.md "Bar").\n', '# 指南\n\n参见[bar](bar.zh.md "Bar")。\n', ['/guide']],
['a destination on the next line', '# Guide\n\nSee [bar](\nbar.md).\n', '# 指南\n\n参见[bar](\nbar.zh.md)。\n', ['/guide']],
['an entity in the query', '# Guide\n\n[bar](bar.md?x=1&amp;y=2)\n', '# 指南\n\n[bar](bar.zh.md?x=1&amp;y=2)\n', ['/guide']],
])('reparses normalized text for %s', (_case, english, chinese, keys) => {
const tree = parseTranslationMarkdown(english)
const normalized = normalizeTranslationMarkdownLinkEdits(english, { ...context, sourcePath: paths.source }, [], tree)
const computed = computeTranslationPairingRecord(paths, english, chinese, context, { en: tree, zh: parseTranslationMarkdown(chinese) })
expect(normalized.edits).toHaveLength(1)
expect(normalizedTopLevelBlocks(english, tree, normalized.edits)).toBeUndefined()
expect([...computed.keys()]).toEqual(keys)
expect(computed).toEqual(record(english, chinese))
})
it('refuses sides with different heading counts', () => {
expect(() => record(en, `${zh}\n## 额外\n`)).toThrow('docs/foo.md has 3 heading(s) but docs/foo.zh.md has 4')
})
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { markdownFences } from './markdown.ts'
import { mayContainEquivFence } from './type-equiv-prefilter.ts'
const EQUIV_INFOS = new Set(['ts type-equiv', 'ts public-api', 'ts type-equiv public-api'])
function parsesEquivFence(source: string): boolean {
return markdownFences(source).some(fence => EQUIV_INFOS.has(fence.info))
}
describe('type-equivalence prefilter', () => {
it.each([
'```ts type-equiv\ninterface A {}\n```\n',
'~~~ts public-api\nclass A {}\n~~~\n',
'> ```ts type-equiv\n> interface A {}\n> ```\n',
'```ts type&#45;equiv\ninterface A {}\n```\n',
'```ts type\\-equiv\ninterface A {}\n```\n',
'```&#116;s type-equiv\ninterface A {}\n```\n',
'~~~ts public&#x2D;api\nclass A {}\n~~~\n',
])('keeps a document whose fence parses as a source-equivalence form: %j', (source) => {
expect(parsesEquivFence(source)).toBe(true)
expect(mayContainEquivFence(source)).toBe(true)
})
it.each([
'# Title\n\nPlain prose with a `type` word and an & sign.\n',
'```ts\ninterface A {}\n```\n',
'```sh\npnpm run build\n```\n',
])('skips a document without such a fence: %j', (source) => {
expect(parsesEquivFence(source)).toBe(false)
expect(mayContainEquivFence(source)).toBe(false)
})
})
+19
View File
@@ -0,0 +1,19 @@
/** Text prefilter that lets `verify-type-equiv` skip Markdown parsing for documents without a source-equivalence fence. */
/**
* Whether `source` can contain a fence whose parsed info string is a
* source-equivalence form; other documents skip Markdown parsing. Each such
* info string contains `type-equiv` or `public-api`, and it is the parsed rest
* of one opening fence line, which contains three backticks or tildes. Parsing
* changes that text only by decoding backslash escapes and character
* references, so an opening line without `\` or `&` contains the word
* literally, and a fence line with either character keeps the document:
* `ts type&#45;equiv` and `ts type\-equiv` both parse as `ts type-equiv`.
* @param source - Markdown document text.
* @returns `false` only when no fence in `source` can parse to a `type-equiv` or `public-api` info string.
*/
export function mayContainEquivFence(source: string): boolean {
if (source.includes('type-equiv') || source.includes('public-api')) return true
return source.split('\n').some(line =>
(line.includes('```') || line.includes('~~~')) && (line.includes('&') || line.includes('\\')))
}
+4 -4
View File
@@ -9,7 +9,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { anchorCache, documentAnchors, findViolations, githubSlug, markdownLinkSourcePaths } from './verify-md-links.ts'
import { documentAnchors, findViolations, githubSlug, markdownLinkCache, markdownLinkSourcePaths } from './verify-md-links.ts'
const roots: string[] = []
afterEach(() => {
@@ -27,7 +27,7 @@ function layout(files: Record<string, string>): string {
}
function violationsIn(root: string, rel: string): { url: string; reason: string }[] {
return findViolations(join(root, rel), anchorCache(), root).map(({ url, reason }) => ({ url, reason }))
return findViolations(join(root, rel), markdownLinkCache(), root).map(({ url, reason }) => ({ url, reason }))
}
describe('Markdown source discovery', () => {
@@ -37,7 +37,7 @@ describe('Markdown source discovery', () => {
'packages/README.zh.md': '[removed group](removed/README.zh.md)\n',
})
const sources = markdownLinkSourcePaths(root)
const violations = sources.flatMap(file => findViolations(join(root, file), anchorCache(), root))
const violations = sources.flatMap(file => findViolations(join(root, file), markdownLinkCache(), root))
expect(violations.map(({ file, reason }) => ({ file, reason })).sort((a, b) => a.file.localeCompare(b.file)))
.toEqual([
{ file: join('packages', 'README.md'), reason: 'target' },
@@ -53,7 +53,7 @@ describe('Markdown source discovery', () => {
'.agents/notes/archived/process/frozen.md': '[historical](missing.md)\n',
})
const sources = markdownLinkSourcePaths(root)
const violations = sources.flatMap(file => findViolations(join(root, file), anchorCache(), root))
const violations = sources.flatMap(file => findViolations(join(root, file), markdownLinkCache(), root))
expect(violations.map(({ file }) => file).sort()).toEqual([
join('packages', 'AGENTS.md'),
join('packages', 'core', 'README.md'),
+65 -35
View File
@@ -120,12 +120,13 @@ export function githubSlug(heading: string): string {
* `Repeat` yields `repeat`, `repeat-1`, `repeat-2`. Matching is exact —
* element ids are case-sensitive.
* @param source - the document's full Markdown text.
* @param tree - the GFM parse of exactly `source`, when the caller already has it.
* @returns the set of valid fragments for links into this document.
*/
export function documentAnchors(source: string): Set<string> {
export function documentAnchors(source: string, tree: Nodes = parseMarkdown(source)): Set<string> {
const anchors = new Set<string>()
const occurrences = new Map<string, number>()
for (const heading of markdownHeadingLines(source)) {
for (const heading of markdownHeadingLines(source, tree)) {
const base = githubSlug(heading.text)
let result = base
let bump = occurrences.get(base) ?? 0
@@ -136,7 +137,7 @@ export function documentAnchors(source: string): Set<string> {
occurrences.set(base, bump)
anchors.add(result)
}
visitMarkdown(parseMarkdown(source), (node: Nodes): void => {
visitMarkdown(tree, (node: Nodes): void => {
if (node.type !== 'html') return
const html = node.value.replace(/<!--[\s\S]*?-->/g, '')
for (const match of html.matchAll(/<a id="([^"]+)"/g)) anchors.add(match[1] ?? '')
@@ -144,19 +145,57 @@ export function documentAnchors(source: string): Set<string> {
return anchors
}
/** One Markdown file's link inputs and exposed anchors, collected from a single parse. */
export interface MarkdownLinkDocument {
/** Link, image, and definition destinations with their 1-based start lines, in document order. */
destinations: { url: string; line: number }[]
/** Fragments that links into this document may name. */
anchors: Set<string>
}
/** Per-run reads, parses, and target probes shared by every scanned source and link target. */
export interface MarkdownLinkCache {
/** Read and parse one Markdown file at most once per run. */
document: (absPath: string) => MarkdownLinkDocument
/** Whether a resolved link target exists, probed at most once per path. */
exists: (absPath: string) => boolean
}
function markdownLinkDocument(source: string): MarkdownLinkDocument {
const tree = parseMarkdown(source)
const destinations: MarkdownLinkDocument['destinations'] = []
visitMarkdown(tree, (node: Nodes): void => {
if ((node.type === 'link' || node.type === 'image' || node.type === 'definition') && 'url' in node) {
destinations.push({ url: node.url, line: node.position?.start.line ?? 0 })
}
})
return { destinations, anchors: documentAnchors(source, tree) }
}
/**
* Lazily collect and cache the anchor set of any existing Markdown file —
* shared across all scanned sources so a target parses once.
* @returns the memoized absolute-path → anchor-set lookup.
* Create the memoized file reads and existence probes for one checker run.
* A file that is both a scanned source and a fragment target parses once; the
* repository is assumed not to change while the run lasts.
* @returns the cache to pass to every {@link findViolations} call of the run.
*/
export 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
export function markdownLinkCache(): MarkdownLinkCache {
const documents = new Map<string, MarkdownLinkDocument>()
const existence = new Map<string, boolean>()
return {
document: (absPath) => {
const hit = documents.get(absPath)
if (hit) return hit
const document = markdownLinkDocument(readFileSync(absPath, 'utf8'))
documents.set(absPath, document)
return document
},
exists: (absPath) => {
const hit = existence.get(absPath)
if (hit !== undefined) return hit
const exists = existsSync(absPath)
existence.set(absPath, exists)
return exists
},
}
}
@@ -167,48 +206,39 @@ export function anchorCache(): (absPath: string) => Set<string> {
* `<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 cache - parses and target probes 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>,
cache: MarkdownLinkCache = markdownLinkCache(),
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 (isExternal(url)) return
for (const { url, line } of cache.document(absPath).destinations) {
if (isExternal(url)) continue
const target = pathPart(url)
const resolved = target === '' ? absPath : resolve(dir, target)
if (!existsSync(resolved)) {
out.push({ file, line: node.position?.start.line ?? 0, url, reason: 'target' })
return
if (!cache.exists(resolved)) {
out.push({ file, line, url, reason: 'target' })
continue
}
const fragment = fragmentPart(url)
if (fragment === null || !resolved.endsWith('.md')) return
if (!anchorsOf(resolved).has(fragment)) {
out.push({ file, line: node.position?.start.line ?? 0, url, reason: 'anchor' })
if (fragment === null || !resolved.endsWith('.md')) continue
if (!cache.document(resolved).anchors.has(fragment)) {
out.push({ file, line, url, reason: 'anchor' })
}
}
visitMarkdown(tree, (node: Nodes): void => {
if ((node.type === 'link' || node.type === 'image' || node.type === 'definition') && 'url' in node) {
check(node.url, node)
}
})
return out
}
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
const files = markdownLinkSourcePaths(root)
const anchorsOf = anchorCache()
const all = files.flatMap(file => findViolations(resolve(root, file), anchorsOf))
const cache = markdownLinkCache()
const all = files.flatMap(file => findViolations(resolve(root, file), cache))
const checked = files.length
if (all.length === 0) {
+4 -3
View File
@@ -8,7 +8,7 @@
import { existsSync, globSync, readFileSync } from 'node:fs'
import { resolve, sep } from 'node:path'
import { markdownHeadingLines, markdownProseLines } from './markdown.ts'
import { markdownHeadingLines, markdownProseLines, parseMarkdown } from './markdown.ts'
const root = resolve(import.meta.dirname, '..')
@@ -51,8 +51,9 @@ for (const pkg of scannedPackages) {
continue
}
const source = readFileSync(resolve(root, readme), 'utf8')
const lines = markdownProseLines(source)
const headings = markdownHeadingLines(source)
const tree = parseMarkdown(source)
const lines = markdownProseLines(source, tree)
const headings = markdownHeadingLines(source, tree)
const limitations = headings.filter(heading => isLimitationsLike(heading.text))
if (Object.hasOwn(NO_LIMITATIONS, pkg)) {
@@ -7,7 +7,7 @@
import { existsSync, globSync, readFileSync } from 'node:fs'
import { relative, resolve, sep } from 'node:path'
import { markdownHeadingLines, markdownProseLines, type MarkdownProseLine } from './markdown.ts'
import { markdownHeadingLines, markdownProseLines, parseMarkdown, type MarkdownProseLine } from './markdown.ts'
const root = resolve(import.meta.dirname, '..')
const HEADING = '## Model Experience'
@@ -369,8 +369,9 @@ for (const packageJson of packageJsons) {
const text = readFileSync(abs, 'utf8')
const rawLines = text.split('\n')
const lines = markdownProseLines(text)
const headings = markdownHeadingLines(text)
const tree = parseMarkdown(text)
const lines = markdownProseLines(text, tree)
const headings = markdownHeadingLines(text, tree)
const h2Headings = headings.filter(heading => heading.depth === 2)
const modelExperienceHeadings = headings.filter(heading => heading.text
.trim().replaceAll(/\s+/g, ' ').toLowerCase() === 'model experience')
+4 -5
View File
@@ -115,11 +115,10 @@ if (request.scope === 'pairs') {
if (!indexMode && !repositoryFileExists(anchor)) files.add(anchor)
}
} else {
for (const pattern of SCOPE_PATTERNS) {
for (const match of globSync(pattern, { cwd: root, exclude: TRANSLATION_SCOPE_GLOB_EXCLUDES })) {
const normalized = match.split(sep).join('/')
if (isTranslationScopeFile(normalized)) files.add(normalized)
}
// One call shares a single directory walk across every pattern.
for (const match of globSync(SCOPE_PATTERNS, { cwd: root, exclude: TRANSLATION_SCOPE_GLOB_EXCLUDES })) {
const normalized = match.split(sep).join('/')
if (isTranslationScopeFile(normalized)) files.add(normalized)
}
}
const translations = [...files].filter(f => f.endsWith('.zh.md')).sort()
+28 -13
View File
@@ -14,6 +14,7 @@ import ts from 'typescript'
import { markdownFences } from './markdown.ts'
import { partitionPairedMarkdownDerivatives } from './paired-markdown-derivatives.ts'
import { isArchivedAgentNotePath } from './repo-files.ts'
import { mayContainEquivFence } from './type-equiv-prefilter.ts'
const root = resolve(import.meta.dirname, '..')
@@ -90,7 +91,9 @@ function blockSymbol(code: string): string | null {
/** Extract every source-equivalence block from one Markdown file. */
function extractEquivBlocks(docRel: string): EquivBlock[] {
const blocks: EquivBlock[] = []
for (const fence of markdownFences(readFileSync(resolve(root, docRel), 'utf8'))) {
const source = readFileSync(resolve(root, docRel), 'utf8')
if (!mayContainEquivFence(source)) return blocks
for (const fence of markdownFences(source)) {
if (fence.info === 'ts type-equiv public-api') {
throw new Error(`verify-type-equiv: ${docRel}:${fence.line} — use the concise \`ts public-api\` fence`)
}
@@ -113,6 +116,26 @@ function extractEquivBlocks(docRel: string): EquivBlock[] {
return blocks
}
/** One manifest source file's text and its parse. */
interface ParsedSource {
text: string
sourceFile: ts.SourceFile
}
/** Parsed manifest sources by absolute path; declaration extraction only reads them. */
const parsedSources = new Map<string, ParsedSource>()
/** Read and parse one manifest source file at most once per run. */
function parsedSource(sourceRel: string): ParsedSource {
const abs = resolve(root, sourceRel)
const cached = parsedSources.get(abs)
if (cached !== undefined) return cached
const text = readFileSync(abs, 'utf8')
const parsed = { text, sourceFile: ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, /* setParentNodes */ true) }
parsedSources.set(abs, parsed)
return parsed
}
/**
* The declaration text of `symbol` in `sourceRel`, with `export` stripped, or
* null when the symbol is not declared there. Uses the TS parser so it spans
@@ -120,9 +143,7 @@ function extractEquivBlocks(docRel: string): EquivBlock[] {
* uniformly while including declaration and member JSDoc.
*/
function sourceDeclaration(sourceRel: string, symbol: string): string | null {
const abs = resolve(root, sourceRel)
const text = readFileSync(abs, 'utf8')
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, /* setParentNodes */ true)
const { text, sourceFile: sf } = parsedSource(sourceRel)
for (const stmt of sf.statements) {
const named =
ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)
@@ -148,9 +169,7 @@ interface InterfacePart {
/** Find one top-level interface declaration in a source file. */
function sourceInterface(sourceRel: string, symbol: string): InterfacePart | null {
const abs = resolve(root, sourceRel)
const text = readFileSync(abs, 'utf8')
const sourceFile = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, /* setParentNodes */ true)
const { text, sourceFile } = parsedSource(sourceRel)
const declaration = sourceFile.statements.find((statement): statement is ts.InterfaceDeclaration =>
ts.isInterfaceDeclaration(statement) && statement.name.text === symbol,
)
@@ -159,9 +178,7 @@ function sourceInterface(sourceRel: string, symbol: string): InterfacePart | nul
/** Find one interface declaration inside an explicit string-literal module augmentation. */
function augmentedInterface(sourceRel: string, moduleName: string, symbol: string): InterfacePart | null {
const abs = resolve(root, sourceRel)
const text = readFileSync(abs, 'utf8')
const sourceFile = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, /* setParentNodes */ true)
const { text, sourceFile } = parsedSource(sourceRel)
for (const statement of sourceFile.statements) {
if (!ts.isModuleDeclaration(statement) || !ts.isStringLiteral(statement.name)
|| statement.name.text !== moduleName || !statement.body || !ts.isModuleBlock(statement.body)) continue
@@ -241,9 +258,7 @@ function bodylessMember(text: string, sf: ts.SourceFile, member: ts.ClassElement
* retained so the projection is the source-owned public contract.
*/
function sourcePublicApi(sourceRel: string, symbol: string): string | null {
const abs = resolve(root, sourceRel)
const text = readFileSync(abs, 'utf8')
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, /* setParentNodes */ true)
const { text, sourceFile: sf } = parsedSource(sourceRel)
for (const stmt of sf.statements) {
if (!ts.isClassDeclaration(stmt) || stmt.name?.text !== symbol) continue
const classDoc = sourceJSDoc(text, stmt)