Merge remote-tracking branch 'origin/master' into stack/agent-profiles-1-seam
This commit is contained in:
@@ -9,41 +9,54 @@ import { globSync, readFileSync } from 'node:fs'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
|
||||
/** Cheap textual prefilter for a cordis module merge, quote-style agnostic
|
||||
* (the AST match below reads `stmt.name.text` and never sees the quotes). */
|
||||
const MERGE_HEAD = /declare module ['"](?:cordis|\.\/context\.ts)['"]/
|
||||
|
||||
/**
|
||||
* Parse every file matching `pattern` (repo-relative, sorted, `/`-normalized)
|
||||
* that textually mentions `interface Context`, yielding each file's cordis
|
||||
* module-merge body. Files without a merge are skipped.
|
||||
* @param scanRoot - Repository root the pattern is resolved against.
|
||||
* @param pattern - Glob selecting the TypeScript files to scan.
|
||||
* @returns One entry per file with a cordis module merge, in path order.
|
||||
* Parse every file matching `patterns` (repo-relative, sorted, `/`-normalized)
|
||||
* that textually contains a cordis module merge, yielding one entry per merge
|
||||
* BLOCK — a file may legally hold several `declare module 'cordis'` blocks
|
||||
* (the Typert analyzer reads them all), so the exhaustiveness scan must too.
|
||||
* Files without a merge are skipped.
|
||||
* @param scanRoot - Repository root the patterns are resolved against.
|
||||
* @param patterns - Glob(s) selecting the TypeScript files to scan.
|
||||
* @returns One entry per cordis module block, in path then source order.
|
||||
*/
|
||||
export function contextMergeFiles(
|
||||
scanRoot: string,
|
||||
pattern: string,
|
||||
patterns: string | readonly string[],
|
||||
): { rel: string; sf: ts.SourceFile; text: string; body: ts.ModuleBlock }[] {
|
||||
const out: { rel: string; sf: ts.SourceFile; text: string; body: ts.ModuleBlock }[] = []
|
||||
for (const rel of globSync(pattern, { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
|
||||
const rels = [...new Set(globSync(patterns as string | string[], { cwd: scanRoot }).map(s => s.split(sep).join('/')))].sort()
|
||||
for (const rel of rels) {
|
||||
const abs = resolve(scanRoot, rel)
|
||||
const text = readFileSync(abs, 'utf8')
|
||||
if (!text.includes('interface Context')) continue
|
||||
if (!MERGE_HEAD.test(text)) continue
|
||||
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
|
||||
const body = cordisModuleBody(sf)
|
||||
if (!body) continue
|
||||
out.push({ rel, sf, text, body })
|
||||
for (const body of cordisModuleBodies(sf)) out.push({ rel, sf, text, body })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The body of the cordis module merge in `sf`: `declare module 'cordis'`
|
||||
* (harness packages) or `declare module './context.ts'` (vendor core), or
|
||||
* null when the file has neither. */
|
||||
export function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
|
||||
/** Every cordis module-merge body in `sf`: `declare module 'cordis'` (harness
|
||||
* packages) or `declare module './context.ts'` (vendor core), in source order.
|
||||
* Module-local: consumers walk blocks through {@link contextMergeFiles}. */
|
||||
function cordisModuleBodies(sf: ts.SourceFile): ts.ModuleBlock[] {
|
||||
const bodies: ts.ModuleBlock[] = []
|
||||
for (const stmt of sf.statements) {
|
||||
if (!ts.isModuleDeclaration(stmt) || !ts.isStringLiteral(stmt.name)) continue
|
||||
if (stmt.name.text !== 'cordis' && stmt.name.text !== './context.ts') continue
|
||||
if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body
|
||||
if (stmt.body && ts.isModuleBlock(stmt.body)) bodies.push(stmt.body)
|
||||
}
|
||||
return null
|
||||
return bodies
|
||||
}
|
||||
|
||||
/** The FIRST cordis module-merge body in `sf`, or null without one — for the
|
||||
* vendor core-API renderer whose input files carry exactly one merge; the
|
||||
* exhaustiveness scan uses {@link cordisModuleBodies} to read them all. */
|
||||
export function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
|
||||
return cordisModuleBodies(sf)[0] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,3 +77,26 @@ export function contextKeyMap(body: ts.ModuleBlock, sf: ts.SourceFile): Map<stri
|
||||
}
|
||||
return keyToType
|
||||
}
|
||||
|
||||
/**
|
||||
* Every event name a `declare module 'cordis'` Events merge declares in one
|
||||
* module body. Names are the literal member keys (`'agent/created'`), read
|
||||
* from method and property members alike so a declaration shape the projector
|
||||
* would reject still enters the exhaustiveness scan.
|
||||
* @param body - The cordis module augmentation block.
|
||||
* @param sf - Owning source file (for computed-name text extraction).
|
||||
* @returns Declared event names, in declaration order.
|
||||
*/
|
||||
export function eventNameList(body: ts.ModuleBlock, sf: ts.SourceFile): string[] {
|
||||
const names: string[] = []
|
||||
for (const stmt of body.statements) {
|
||||
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue
|
||||
for (const member of stmt.members) {
|
||||
if (!member.name) continue
|
||||
names.push(ts.isStringLiteral(member.name) || ts.isIdentifier(member.name)
|
||||
? member.name.text
|
||||
: member.name.getText(sf))
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
211
scripts/gen-cordis-catalog-partition.spec.ts
Normal file
211
scripts/gen-cordis-catalog-partition.spec.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Acceptance-path coverage for the cordis-surface partition backstops
|
||||
* (`walkPartitionProblems` + the AST scan helpers): a declared Context key or
|
||||
* Events member the rendering projection cannot see must carry a named walk
|
||||
* exemption, an exemption must stay live in both directions, and the scan
|
||||
* itself must reach nested (`src/**`) and Events-only merge files.
|
||||
*/
|
||||
|
||||
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 ts from 'typescript'
|
||||
import { contextKeyMap, contextMergeFiles, eventNameList } from './cordis-walk.ts'
|
||||
import { walkPartitionProblems } from './gen-cordis-catalog.ts'
|
||||
import type { WalkPartitionInput, WalkPartitionMaps } from './gen-cordis-catalog.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** A consistent baseline the red cases mutate one facet at a time. */
|
||||
function baseline(): { input: WalkPartitionInput; maps: WalkPartitionMaps } {
|
||||
return {
|
||||
input: {
|
||||
renderedKeys: new Map([['llm', 'packages/llm/llm/src/index.ts:10']]),
|
||||
renderedScopes: new Set(['llm']),
|
||||
renderedEventNames: new Set(['llm/request']),
|
||||
declaredKeys: new Map([
|
||||
['llm', 'packages/llm/llm/src/index.ts'],
|
||||
['theme', 'packages/client/ui-theme/src/client/index.ts'],
|
||||
]),
|
||||
declaredEvents: new Map([
|
||||
['llm/request', 'packages/llm/llm/src/index.ts'],
|
||||
['theme/change', 'packages/client/ui-theme/src/client/index.ts'],
|
||||
]),
|
||||
},
|
||||
maps: {
|
||||
servicePage: { llm: 'llm-streaming.md' },
|
||||
serviceWalkExemptions: { theme: 'client-side — packages/client/ui-theme/README.md owns the surface' },
|
||||
eventScopePage: { llm: 'llm-streaming.md' },
|
||||
eventWalkExemptions: { 'theme/change': 'client-face — packages/client/ui-theme/README.md owns the surface' },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('walkPartitionProblems', () => {
|
||||
it('accepts a partition where every declared key and event is rendered or exempted', () => {
|
||||
const { input, maps } = baseline()
|
||||
expect(walkPartitionProblems(input, maps)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a declared event that is neither rendered nor exempted, naming its file', () => {
|
||||
const { input, maps } = baseline()
|
||||
const problems = walkPartitionProblems(input, { ...maps, eventWalkExemptions: {} })
|
||||
expect(problems).toEqual([
|
||||
expect.stringContaining("event 'theme/change' (packages/client/ui-theme/src/client/index.ts) is declared in an Events merge but invisible"),
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects an event exemption whose event the projection renders', () => {
|
||||
const { input, maps } = baseline()
|
||||
// A projection that renders theme/change necessarily renders the theme
|
||||
// scope too; the fixture models that and maps the scope so the only
|
||||
// violation is the stale exemption.
|
||||
const rendered = {
|
||||
...input,
|
||||
renderedScopes: new Set(['llm', 'theme']),
|
||||
renderedEventNames: new Set(['llm/request', 'theme/change']),
|
||||
}
|
||||
const mapped = { ...maps, eventScopePage: { llm: 'llm-streaming.md', theme: 'client-modules.md' } }
|
||||
expect(walkPartitionProblems(rendered, mapped)).toEqual([
|
||||
expect.stringContaining("event 'theme/change' is rendered by the projection but still listed in EVENT_WALK_EXEMPTIONS"),
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects rendered surface the independent scan cannot see, naming the scan as the defect', () => {
|
||||
const { input, maps } = baseline()
|
||||
const blind = {
|
||||
...input,
|
||||
declaredKeys: new Map([['theme', 'packages/client/ui-theme/src/client/index.ts']]),
|
||||
declaredEvents: new Map([['theme/change', 'packages/client/ui-theme/src/client/index.ts']]),
|
||||
}
|
||||
expect(walkPartitionProblems(blind, maps)).toEqual([
|
||||
expect.stringContaining('ctx.llm is rendered by the projection but the independent scan finds no Context merge declaring it'),
|
||||
expect.stringContaining("event 'llm/request' is rendered by the projection but the independent scan finds no Events merge declaring it"),
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects an event exemption no Events merge declares', () => {
|
||||
const { input, maps } = baseline()
|
||||
const stale = { ...maps, eventWalkExemptions: { ...maps.eventWalkExemptions, 'gone/away': 'nothing owns this' } }
|
||||
expect(walkPartitionProblems(input, stale)).toEqual([
|
||||
expect.stringContaining("EVENT_WALK_EXEMPTIONS names 'gone/away' but no Events merge declares it"),
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a declared Context key that is neither rendered nor exempted', () => {
|
||||
const { input, maps } = baseline()
|
||||
const problems = walkPartitionProblems(input, { ...maps, serviceWalkExemptions: {} })
|
||||
expect(problems).toEqual([
|
||||
expect.stringContaining('ctx.theme (packages/client/ui-theme/src/client/index.ts) is declared in a Context merge but invisible'),
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects an unmapped rendered service with its source pointer, and stale page maps both ways', () => {
|
||||
const { input, maps } = baseline()
|
||||
const problems = walkPartitionProblems(input, {
|
||||
...maps,
|
||||
servicePage: { ghost: 'core.md' },
|
||||
eventScopePage: { specter: 'core.md' },
|
||||
})
|
||||
expect(problems).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('service ctx.llm (packages/llm/llm/src/index.ts:10) has no SERVICE_PAGE entry'),
|
||||
expect.stringContaining("event scope 'llm/*' has no EVENT_SCOPE_PAGE entry"),
|
||||
expect.stringContaining("SERVICE_PAGE maps 'ctx.ghost' but the projection discovers no such service"),
|
||||
expect.stringContaining("EVENT_SCOPE_PAGE maps 'specter/*' but the projection discovers no such scope"),
|
||||
]))
|
||||
expect(problems).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cordis-walk scan reach', () => {
|
||||
it('finds Context keys and Events names in nested Events-only merge files', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cordis-walk-'))
|
||||
roots.push(root)
|
||||
const dir = join(root, 'packages/client/ui-x/src/client')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, 'index.ts'), [
|
||||
"declare module 'cordis' {",
|
||||
' interface Events {',
|
||||
" 'x/changed'(): void",
|
||||
' }',
|
||||
'}',
|
||||
'export {}',
|
||||
'',
|
||||
].join('\n'))
|
||||
const merges = contextMergeFiles(root, 'packages/*/*/src/**/*.ts')
|
||||
expect(merges.map(m => m.rel)).toEqual(['packages/client/ui-x/src/client/index.ts'])
|
||||
const only = merges[0]
|
||||
if (!only) throw new Error('scan returned no merge')
|
||||
expect(eventNameList(only.body, only.sf)).toEqual(['x/changed'])
|
||||
expect([...contextKeyMap(only.body, only.sf).keys()]).toEqual([])
|
||||
})
|
||||
|
||||
it('yields every merge block of a multi-block file, double-quoted heads, and .tsx sources', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cordis-walk-'))
|
||||
roots.push(root)
|
||||
const dir = join(root, 'packages/client/ui-x/src')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
// The Typert analyzer reads every cordis module block in a file; the
|
||||
// backstop must not stop at the first one, skip the double-quoted legal
|
||||
// form, or ignore .tsx sources.
|
||||
writeFileSync(join(dir, 'split.ts'), [
|
||||
"declare module 'cordis' {",
|
||||
' interface Context {',
|
||||
' first: FirstService',
|
||||
' }',
|
||||
'}',
|
||||
'declare module "cordis" {',
|
||||
' interface Events {',
|
||||
" 'second/changed'(): void",
|
||||
' }',
|
||||
'}',
|
||||
'export {}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(dir, 'view.tsx'), [
|
||||
"declare module 'cordis' {",
|
||||
' interface Context {',
|
||||
' fromTsx: TsxService',
|
||||
' }',
|
||||
'}',
|
||||
'export {}',
|
||||
'',
|
||||
].join('\n'))
|
||||
const merges = contextMergeFiles(root, ['packages/*/*/src/**/*.ts', 'packages/*/*/src/**/*.tsx'])
|
||||
expect(merges.map(m => m.rel)).toEqual([
|
||||
'packages/client/ui-x/src/split.ts',
|
||||
'packages/client/ui-x/src/split.ts',
|
||||
'packages/client/ui-x/src/view.tsx',
|
||||
])
|
||||
const keys = merges.flatMap(m => [...contextKeyMap(m.body, m.sf).keys()])
|
||||
const events = merges.flatMap(m => eventNameList(m.body, m.sf))
|
||||
expect(keys).toEqual(['first', 'fromTsx'])
|
||||
expect(events).toEqual(['second/changed'])
|
||||
})
|
||||
|
||||
it('reads string-literal and identifier member names from an Events merge', () => {
|
||||
const sf = ts.createSourceFile('x.ts', [
|
||||
"declare module 'cordis' {",
|
||||
' interface Events {',
|
||||
" 'scope/list'(items: string[]): void",
|
||||
' plain(): void',
|
||||
' }',
|
||||
' interface Context {',
|
||||
' thing: ThingService',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'), ts.ScriptTarget.Latest, true)
|
||||
const body = sf.statements[0] && ts.isModuleDeclaration(sf.statements[0]) && sf.statements[0].body
|
||||
&& ts.isModuleBlock(sf.statements[0].body)
|
||||
? sf.statements[0].body
|
||||
: null
|
||||
if (!body) throw new Error('fixture did not parse to a module block')
|
||||
expect(eventNameList(body, sf)).toEqual(['scope/list', 'plain'])
|
||||
expect([...contextKeyMap(body, sf)]).toEqual([['thing', 'ThingService']])
|
||||
})
|
||||
})
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-typert-generator'
|
||||
import type { CordisCatalogPolicy } from '@deepseek-ai/dsh-typert-generator'
|
||||
import { renderCordisCoreApiPages } from './cordis-core-api.ts'
|
||||
import { contextKeyMap, contextMergeFiles } from './cordis-walk.ts'
|
||||
import { contextKeyMap, contextMergeFiles, eventNameList } from './cordis-walk.ts'
|
||||
import {
|
||||
blobHash,
|
||||
parsePairMeta,
|
||||
@@ -98,10 +98,11 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
* Context keys declared in `interface Context` merges that the rendering
|
||||
* projection cannot see, each with the reason and its documentation owner.
|
||||
* The scan that enforces this list reads EVERY `declare module 'cordis'`
|
||||
* Context merge under `packages/x/x/src/*.ts` — not only root `index.ts`
|
||||
* files with a same-named service class — so a new service can never silently
|
||||
* join this blind spot: it either enters {@link SERVICE_PAGE} or names itself
|
||||
* here.
|
||||
* Context merge under `packages/x/x/src/**` — any depth, not only root
|
||||
* `index.ts` files with a same-named service class — so a new service can
|
||||
* never silently join this blind spot: it either enters {@link SERVICE_PAGE}
|
||||
* or names itself here. Client-face keys (the projection analyzes the host
|
||||
* face only) name the package README that owns their surface.
|
||||
* TODO(cordis-catalog-interface-services): the interface-typed and
|
||||
* non-index-declared entries would all render once the projection resolves a
|
||||
* Context key through its declaring file's imports to the class declaration.
|
||||
@@ -117,14 +118,27 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
apiProxy: 'interface-typed (ApiProxy) with the class in api-proxy.ts, not index.ts — packages/host/apiproxy/README.md owns the surface',
|
||||
appShell: 'client-side interface-typed browser service — packages/client/web/README.md owns the surface',
|
||||
connection: 'client-side interface-typed browser service — packages/client/connection/README.md owns the surface',
|
||||
chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-conversation/README.md owns the surface',
|
||||
command: 'client-side interface-typed browser service — packages/client/ui-command/README.md owns the surface',
|
||||
conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the surface',
|
||||
layout: 'client-side interface-typed browser service — packages/client/ui-layout/README.md owns the surface',
|
||||
locale: 'client-side interface-typed browser service — packages/client/locale/README.md owns the surface',
|
||||
models: 'client-side interface-typed browser service — packages/client/ui-model/README.md owns the surface',
|
||||
modules: 'client-side interface-typed browser service — packages/client/modules/README.md owns the surface',
|
||||
remote: 'client-side interface-typed gateway accessor (ClientRemote) — packages/api/gateway/README.md owns the surface',
|
||||
sessionHistory: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface',
|
||||
slash: 'client-side interface-typed browser service — packages/client/ui-slash/README.md owns the surface',
|
||||
slots: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface',
|
||||
theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the surface',
|
||||
workspaces: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface',
|
||||
}
|
||||
|
||||
/**
|
||||
* The owning subsystems page for every harness event scope (the segment
|
||||
* before the first `/`). Fail-closed exactly like {@link SERVICE_PAGE}.
|
||||
* `slash` lives with the human-command surface: the client slash-input
|
||||
* protocol parses toward command invocation and `dsh-ui-slash` owns the
|
||||
* declarations, but commands.md owns the cross-package command story.
|
||||
* before the first `/`) the projection renders. Fail-closed exactly like
|
||||
* {@link SERVICE_PAGE}. Client-face events (`slash/*`, `theme/change`, …) are
|
||||
* invisible to the host-face projection and therefore never reach this map;
|
||||
* {@link EVENT_WALK_EXEMPTIONS} names each one with its documentation owner.
|
||||
*/
|
||||
export const EVENT_SCOPE_PAGE: Record<string, string> = {
|
||||
'agent': 'core.md',
|
||||
@@ -146,6 +160,32 @@ export const EVENT_SCOPE_PAGE: Record<string, string> = {
|
||||
'workflow': 'workflow.md',
|
||||
}
|
||||
|
||||
/**
|
||||
* Event names declared in `interface Events` merges that the rendering
|
||||
* projection cannot see, each with the reason and its documentation owner.
|
||||
* The mirror of {@link SERVICE_WALK_EXEMPTIONS} for events: an independent
|
||||
* scan reads EVERY `declare module 'cordis'` Events merge under
|
||||
* `packages/x/x/src/**`, so a declared event either renders onto a subsystems
|
||||
* page (via {@link EVENT_SCOPE_PAGE}) or names itself here — never vanishes
|
||||
* silently. Keys are full event names, not scopes: client-face events share
|
||||
* scopes with rendered host events (`commands/changed` beside `commands/*`),
|
||||
* so a scope-level exemption would mask a host-face regression.
|
||||
*/
|
||||
export const EVENT_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
'commands/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'connection/reset': 'client-face transport signal — packages/client/runtime/README.md owns the surface',
|
||||
'credentials/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'locale/change': 'client-face locale switch signal — packages/client/locale/README.md owns the surface',
|
||||
'models/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'settings/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'slash/input-begin-command': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',
|
||||
'slash/input-consume-token': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',
|
||||
'slash/input-insert-reference': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',
|
||||
'slash/input-insert-text': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',
|
||||
'slots/changed': 'client-face slot invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'theme/change': 'client-face theme switch signal — packages/client/ui-theme/README.md owns the surface',
|
||||
}
|
||||
|
||||
/**
|
||||
* One primary subsystems page per project type used by a generated
|
||||
* signature. This stays curated because union names intentionally do not
|
||||
@@ -509,56 +549,135 @@ export function spliceRegion(content: string, region: string): string {
|
||||
return [...lines.slice(0, begin), ...region.split('\n'), ...lines.slice(end + 1)].join('\n')
|
||||
}
|
||||
|
||||
/** The declared-vs-rendered inputs {@link walkPartitionProblems} judges. */
|
||||
export interface WalkPartitionInput {
|
||||
/** Service key → source pointer, as the rendering projection produced them. */
|
||||
readonly renderedKeys: ReadonlyMap<string, string>
|
||||
/** Event scopes the rendering projection produced. */
|
||||
readonly renderedScopes: ReadonlySet<string>
|
||||
/** Event names the rendering projection produced. */
|
||||
readonly renderedEventNames: ReadonlySet<string>
|
||||
/** Context key → first declaring file, from the independent AST scan. */
|
||||
readonly declaredKeys: ReadonlyMap<string, string>
|
||||
/** Event name → first declaring file, from the independent AST scan. */
|
||||
readonly declaredEvents: ReadonlyMap<string, string>
|
||||
}
|
||||
|
||||
/** The curated partition maps {@link walkPartitionProblems} enforces. */
|
||||
export interface WalkPartitionMaps {
|
||||
readonly servicePage: Readonly<Record<string, string>>
|
||||
readonly serviceWalkExemptions: Readonly<Record<string, string>>
|
||||
readonly eventScopePage: Readonly<Record<string, string>>
|
||||
readonly eventWalkExemptions: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Judge the rendered surface and the independent AST scan against the curated
|
||||
* partition maps, fail-closed in both directions for services AND events: a
|
||||
* rendered key/scope must be mapped to a page, a mapped key/scope must still
|
||||
* render, and — the backstop — a DECLARED key/event the projection cannot see
|
||||
* must carry a named walk exemption (a rendered one must not). A third
|
||||
* direction guards the scan itself: everything rendered must also be declared
|
||||
* to the scan, so a scan blind spot cannot decay silently. Pure so the
|
||||
* acceptance paths are provable without running the projection.
|
||||
* @param input - rendered surface plus the declared-key/event scans.
|
||||
* @param maps - the curated page maps and walk exemptions.
|
||||
* @returns one message per violation, empty when the partition holds.
|
||||
*/
|
||||
export function walkPartitionProblems(input: WalkPartitionInput, maps: WalkPartitionMaps): string[] {
|
||||
const problems: string[] = []
|
||||
for (const [key, source] of input.renderedKeys) {
|
||||
if (!Object.hasOwn(maps.servicePage, key)) problems.push(`service ctx.${key} (${source}) has no SERVICE_PAGE entry; every service maps to exactly one subsystems page.`)
|
||||
}
|
||||
for (const scope of [...input.renderedScopes].sort()) {
|
||||
if (!Object.hasOwn(maps.eventScopePage, scope)) problems.push(`event scope '${scope}/*' has no EVENT_SCOPE_PAGE entry; every event scope maps to exactly one subsystems page.`)
|
||||
}
|
||||
for (const key of Object.keys(maps.servicePage)) {
|
||||
if (!input.renderedKeys.has(key)) problems.push(`SERVICE_PAGE maps 'ctx.${key}' but the projection discovers no such service; remove the stale entry.`)
|
||||
}
|
||||
for (const scope of Object.keys(maps.eventScopePage)) {
|
||||
if (!input.renderedScopes.has(scope)) problems.push(`EVENT_SCOPE_PAGE maps '${scope}/*' but the projection discovers no such scope; remove the stale entry.`)
|
||||
}
|
||||
// The rendering projection only sees a Context key it can resolve to a
|
||||
// documented service class. The independent scan reads EVERY Context merge
|
||||
// so a key the projection cannot render must either be rendered (mapped) or
|
||||
// carry a named SERVICE_WALK_EXEMPTIONS reason — never vanish silently.
|
||||
for (const [key, rel] of input.declaredKeys) {
|
||||
const rendered = input.renderedKeys.has(key)
|
||||
const exempt = Object.hasOwn(maps.serviceWalkExemptions, key)
|
||||
if (!rendered && !exempt) {
|
||||
problems.push(`ctx.${key} (${rel}) is declared in a Context merge but invisible to the rendering projection; map it in SERVICE_PAGE (after making it renderable) or name it in SERVICE_WALK_EXEMPTIONS with its documentation owner.`)
|
||||
}
|
||||
if (rendered && exempt) problems.push(`ctx.${key} is rendered by the projection but still listed in SERVICE_WALK_EXEMPTIONS; remove the stale exemption.`)
|
||||
}
|
||||
for (const key of Object.keys(maps.serviceWalkExemptions)) {
|
||||
if (!input.declaredKeys.has(key)) problems.push(`SERVICE_WALK_EXEMPTIONS names 'ctx.${key}' but no Context merge declares it; remove the stale exemption.`)
|
||||
}
|
||||
// The event mirror of the service backstop: the projection walks only files
|
||||
// reachable from host-face package exports, so a client-face or unreachable
|
||||
// Events merge would otherwise vanish without a trace.
|
||||
for (const [name, rel] of input.declaredEvents) {
|
||||
const rendered = input.renderedEventNames.has(name)
|
||||
const exempt = Object.hasOwn(maps.eventWalkExemptions, name)
|
||||
if (!rendered && !exempt) {
|
||||
problems.push(`event '${name}' (${rel}) is declared in an Events merge but invisible to the rendering projection; make it renderable (mapped via EVENT_SCOPE_PAGE) or name it in EVENT_WALK_EXEMPTIONS with its documentation owner.`)
|
||||
}
|
||||
if (rendered && exempt) problems.push(`event '${name}' is rendered by the projection but still listed in EVENT_WALK_EXEMPTIONS; remove the stale exemption.`)
|
||||
}
|
||||
for (const name of Object.keys(maps.eventWalkExemptions)) {
|
||||
if (!input.declaredEvents.has(name)) problems.push(`EVENT_WALK_EXEMPTIONS names '${name}' but no Events merge declares it; remove the stale exemption.`)
|
||||
}
|
||||
// Self-check the scan itself: everything the projection renders is declared
|
||||
// in a Context/Events merge the scan must also reach, so a rendered key or
|
||||
// event the scan cannot see means the SCAN regressed (glob, prefilter, or
|
||||
// block walk) — a partial blind spot that exemption staleness alone would
|
||||
// never surface.
|
||||
for (const key of input.renderedKeys.keys()) {
|
||||
if (!input.declaredKeys.has(key)) problems.push(`ctx.${key} is rendered by the projection but the independent scan finds no Context merge declaring it; the scan has a blind spot (glob, prefilter, or module-block walk) — fix the scan, not the maps.`)
|
||||
}
|
||||
for (const name of input.renderedEventNames) {
|
||||
if (!input.declaredEvents.has(name)) problems.push(`event '${name}' is rendered by the projection but the independent scan finds no Events merge declaring it; the scan has a blind spot (glob, prefilter, or module-block walk) — fix the scan, not the maps.`)
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute every generated artifact: the inherited-tier page, the model-facing
|
||||
* runtime API module, plus, per mapped subsystems page, the pair's two updated
|
||||
* documents with the injected region. Fail-loud partition checks live here: an
|
||||
* unmapped service/event scope, a mapping whose page file does not exist, a
|
||||
* curated entry whose key/scope the projection no longer discovers, and a
|
||||
* mapped page missing its markers are all aggregated errors.
|
||||
* curated entry whose key/scope the projection no longer discovers, a declared
|
||||
* Context key or Events member the projection cannot see without a named walk
|
||||
* exemption, and a mapped page missing its markers are all aggregated errors.
|
||||
* @returns `[repo-relative path, exact content]` for every generated artifact.
|
||||
*/
|
||||
export function computeOutputs(): [string, string][] {
|
||||
const { projector, model } = projectCordisCatalog(root, CORDIS_CATALOG_POLICY)
|
||||
const services = [...model.services]
|
||||
const events = [...model.events]
|
||||
const problems: string[] = []
|
||||
|
||||
const discoveredKeys = new Set(services.map(s => s.key))
|
||||
const discoveredScopes = new Set(events.map(e => e.scope))
|
||||
for (const s of services) {
|
||||
if (!Object.hasOwn(SERVICE_PAGE, s.key)) problems.push(`service ctx.${s.key} (${s.source}) has no SERVICE_PAGE entry; every service maps to exactly one subsystems page.`)
|
||||
}
|
||||
for (const scope of discoveredScopes) {
|
||||
if (!Object.hasOwn(EVENT_SCOPE_PAGE, scope)) problems.push(`event scope '${scope}/*' has no EVENT_SCOPE_PAGE entry; every event scope maps to exactly one subsystems page.`)
|
||||
}
|
||||
for (const key of Object.keys(SERVICE_PAGE)) {
|
||||
if (!discoveredKeys.has(key)) problems.push(`SERVICE_PAGE maps 'ctx.${key}' but the projection discovers no such service; remove the stale entry.`)
|
||||
}
|
||||
for (const scope of Object.keys(EVENT_SCOPE_PAGE)) {
|
||||
if (!discoveredScopes.has(scope)) problems.push(`EVENT_SCOPE_PAGE maps '${scope}/*' but the projection discovers no such scope; remove the stale entry.`)
|
||||
}
|
||||
// The rendering projection only sees a Context key it can resolve to a
|
||||
// documented service class. This independent scan reads EVERY Context merge
|
||||
// so a key the projection cannot render must either be rendered (mapped) or
|
||||
// carry a named SERVICE_WALK_EXEMPTIONS reason — never vanish silently.
|
||||
const declaredKeys = new Map<string, string>()
|
||||
for (const { rel, sf, body } of contextMergeFiles(root, 'packages/*/*/src/*.ts')) {
|
||||
const declaredEvents = new Map<string, string>()
|
||||
for (const { rel, sf, body } of contextMergeFiles(root, ['packages/*/*/src/**/*.ts', 'packages/*/*/src/**/*.tsx'])) {
|
||||
for (const key of contextKeyMap(body, sf).keys()) {
|
||||
if (!declaredKeys.has(key)) declaredKeys.set(key, rel)
|
||||
}
|
||||
}
|
||||
for (const [key, rel] of declaredKeys) {
|
||||
const rendered = discoveredKeys.has(key)
|
||||
const exempt = Object.hasOwn(SERVICE_WALK_EXEMPTIONS, key)
|
||||
if (!rendered && !exempt) {
|
||||
problems.push(`ctx.${key} (${rel}) is declared in a Context merge but invisible to the rendering projection; map it in SERVICE_PAGE (after making it renderable) or name it in SERVICE_WALK_EXEMPTIONS with its documentation owner.`)
|
||||
for (const name of eventNameList(body, sf)) {
|
||||
if (!declaredEvents.has(name)) declaredEvents.set(name, rel)
|
||||
}
|
||||
if (rendered && exempt) problems.push(`ctx.${key} is rendered by the projection but still listed in SERVICE_WALK_EXEMPTIONS; remove the stale exemption.`)
|
||||
}
|
||||
for (const key of Object.keys(SERVICE_WALK_EXEMPTIONS)) {
|
||||
if (!declaredKeys.has(key)) problems.push(`SERVICE_WALK_EXEMPTIONS names 'ctx.${key}' but no Context merge declares it; remove the stale exemption.`)
|
||||
}
|
||||
const problems = walkPartitionProblems({
|
||||
renderedKeys: new Map(services.map(s => [s.key, s.source])),
|
||||
renderedScopes: new Set(events.map(e => e.scope)),
|
||||
renderedEventNames: new Set(events.map(e => e.name)),
|
||||
declaredKeys,
|
||||
declaredEvents,
|
||||
}, {
|
||||
servicePage: SERVICE_PAGE,
|
||||
serviceWalkExemptions: SERVICE_WALK_EXEMPTIONS,
|
||||
eventScopePage: EVENT_SCOPE_PAGE,
|
||||
eventWalkExemptions: EVENT_WALK_EXEMPTIONS,
|
||||
})
|
||||
if (problems.length > 0) throw new Error(`gen-cordis-catalog: ${problems.length} partition violation(s):\n${problems.map(p => ` ${p}`).join('\n')}`)
|
||||
|
||||
const pages = [...new Set([...Object.values(SERVICE_PAGE), ...Object.values(EVENT_SCOPE_PAGE)])].sort()
|
||||
|
||||
@@ -1161,7 +1161,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',
|
||||
|
||||
108
scripts/verify-md-links.spec.ts
Normal file
108
scripts/verify-md-links.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 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, 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 } 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 }[] {
|
||||
return findViolations(join(root, rel), anchorCache(), root).map(({ url, reason }) => ({ url, reason }))
|
||||
}
|
||||
|
||||
describe('documentAnchors', () => {
|
||||
it('slugs rendered heading text, 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')
|
||||
})
|
||||
|
||||
it('keeps underscores the way GitHub does', () => {
|
||||
expect(githubSlug('Showcase: web_fetch')).toBe('showcase-web_fetch')
|
||||
expect(documentAnchors('## Showcase: web_fetch\n')).toEqual(new Set(['showcase-web_fetch']))
|
||||
})
|
||||
|
||||
it('slugs a heading containing a link from its rendered text', () => {
|
||||
expect(documentAnchors('## [Install](setup.md)\n')).toEqual(new Set(['install']))
|
||||
})
|
||||
|
||||
it('bumps repeat suffixes past occupied slugs, matching GitHub', () => {
|
||||
const anchors = documentAnchors(['## Repeat', '## Repeat-1', '## Repeat', ''].join('\n'))
|
||||
expect(anchors).toEqual(new Set(['repeat', 'repeat-1', 'repeat-2']))
|
||||
})
|
||||
|
||||
it('ignores <a id> inside code fences, inline code, and HTML comments', () => {
|
||||
const anchors = documentAnchors([
|
||||
'# Doc',
|
||||
'```md',
|
||||
'<a id="fenced"></a>',
|
||||
'```',
|
||||
'Inline `<a id="inline"></a>` sample.',
|
||||
'<!-- <a id="commented"></a> -->',
|
||||
'<a id="real"></a>',
|
||||
'',
|
||||
].join('\n'))
|
||||
expect(anchors).toEqual(new Set(['doc', 'real']))
|
||||
})
|
||||
})
|
||||
|
||||
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 case-variant fragment: element ids are case-sensitive', () => {
|
||||
const root = layout({ 'a.md': '# A\n\n## Default Loop\n\n[case](#Default-Loop)\n' })
|
||||
expect(violationsIn(root, 'a.md')).toEqual([{ url: '#Default-Loop', 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,14 +1,16 @@
|
||||
/**
|
||||
* 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'
|
||||
import { dirname, relative, resolve } from 'node:path'
|
||||
import type { Nodes } from 'mdast'
|
||||
import { parseMarkdown, visitMarkdown } from './markdown.ts'
|
||||
import { markdownHeadingLines, parseMarkdown, visitMarkdown } from './markdown.ts'
|
||||
import { isArchivedAgentNotePath, uniqueRepoFiles } from './repo-files.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
@@ -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,119 @@ 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, underscores, spaces, hyphens; spaces become hyphens). Underscores
|
||||
* survive (`## Showcase: web_fetch` → `#showcase-web_fetch`), unlike
|
||||
* `gen-cordis-catalog`'s region-anchor slugs — the generator's headings are
|
||||
* always reachable through its explicit `<a id>` anchors, so the two need not
|
||||
* share one rule.
|
||||
* @param heading - the RENDERED heading text (Markdown syntax already gone).
|
||||
* @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 —
|
||||
* computed from the RENDERED heading text, so links, images, inline code, and
|
||||
* emphasis inside a heading slug the way GitHub renders them — plus every
|
||||
* explicit `<a id="…">` that appears in real HTML flow (a fenced or inline
|
||||
* code sample and a commented-out anchor register nothing). Repeated slugs
|
||||
* get GitHub's occupied-set `-1`, `-2`, … suffixes: each collision bumps the
|
||||
* ORIGINAL slug's counter until a free name is found, so `Repeat`, `Repeat-1`,
|
||||
* `Repeat` yields `repeat`, `repeat-1`, `repeat-2`. Matching is exact —
|
||||
* element ids are case-sensitive.
|
||||
* @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 occurrences = new Map<string, number>()
|
||||
for (const heading of markdownHeadingLines(source)) {
|
||||
const base = githubSlug(heading.text)
|
||||
let result = base
|
||||
let bump = occurrences.get(base) ?? 0
|
||||
while (anchors.has(result)) {
|
||||
bump += 1
|
||||
result = `${base}-${bump}`
|
||||
}
|
||||
occurrences.set(base, bump)
|
||||
anchors.add(result)
|
||||
}
|
||||
visitMarkdown(parseMarkdown(source), (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] ?? '')
|
||||
})
|
||||
return anchors
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)) {
|
||||
out.push({ file, line: node.position?.start.line ?? 0, url, reason: 'anchor' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,18 +196,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