# Conflicts: # .agents/notes/implemented/process/2026-07-13-documentation-site-projection.md # docs/AGENTS.md # docs/rfc/INDEX.md # docs/user/develop/basic/index.zh.md # docs/user/develop/basic/tool.zh.md # docs/user/develop/framework/index.zh.md # docs/user/develop/framework/service.zh.md # docs/user/develop/practice/index.zh.md # docs/user/guide/index.zh.md # docs/user/guide/quickstart.zh.md # knip.json # package.json # pnpm-lock.yaml # scripts/doc-typecheck.ts # scripts/gen-website-api.ts # scripts/translation-pairing.manifest.json # scripts/verify-type-equiv.ts # website/zh-CN/api/cordis/context.md # website/zh-CN/api/cordis/events.md # website/zh-CN/api/cordis/fiber.md # website/zh-CN/api/cordis/registry.md # website/zh-CN/api/cordis/service.md # website/zh-CN/api/harness/agent-loop.md # website/zh-CN/api/harness/agents.md # website/zh-CN/api/harness/approval.md # website/zh-CN/api/harness/bash-env.md # website/zh-CN/api/harness/bash.md # website/zh-CN/api/harness/code-runtime.md # website/zh-CN/api/harness/compact.md # website/zh-CN/api/harness/events.md # website/zh-CN/api/harness/fs.md # website/zh-CN/api/harness/llm.md # website/zh-CN/api/harness/permission.md # website/zh-CN/api/harness/sandbox.md # website/zh-CN/api/harness/session-persistence.md # website/zh-CN/api/harness/session-query.md # website/zh-CN/api/harness/sessions.md # website/zh-CN/api/harness/skills.md # website/zh-CN/api/harness/spill-store.md # website/zh-CN/api/harness/subagents.md # website/zh-CN/api/harness/system-prompt.md # website/zh-CN/api/harness/tasks.md # website/zh-CN/api/harness/token-meter.md # website/zh-CN/api/harness/tools.md # website/zh-CN/api/harness/user-interaction.md # website/zh-CN/api/harness/web.md # website/zh-CN/api/harness/workflows.md # website/zh-CN/api/index.md # website/zh-CN/design/effects-coeffects.md # website/zh-CN/design/index.md # website/zh-CN/guide/config.md
310 lines
13 KiB
TypeScript
310 lines
13 KiB
TypeScript
/**
|
|
* Verify every `ts type-equiv` and `ts public-api` block against the source
|
|
* symbol named by the manifest. Ordinary entries preserve the complete
|
|
* declaration; `public-api` entries preserve a class's body-stripped public
|
|
* declaration. Blocks and entries have a one-to-one relationship; comparison
|
|
* ignores whitespace and non-JSDoc comments but preserves declaration
|
|
* structure and every original JSDoc comment.
|
|
*/
|
|
|
|
import { globSync, readFileSync, existsSync } from 'node:fs'
|
|
import { resolve, sep } from 'node:path'
|
|
import ts from 'typescript'
|
|
|
|
const root = resolve(import.meta.dirname, '..')
|
|
|
|
/** Scan doc-typecheck's full Markdown scope so unmanifested blocks also fail. */
|
|
const MARKDOWN_GLOBS = ['README.md', '.agents/notes/**/*.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md']
|
|
|
|
/** One manifest entry: a source-equivalence block and its source symbol. */
|
|
interface ManifestEntry {
|
|
/** Doc file (repo-relative) containing the source-equivalence block. */
|
|
doc: string
|
|
/** The declared symbol the block must match (e.g. `SessionEvent`). */
|
|
symbol: string
|
|
/** Source file (repo-relative) that exports the symbol. */
|
|
source: string
|
|
/** Complete declaration (default), or a body-stripped public class API. */
|
|
projection?: 'public-api'
|
|
}
|
|
|
|
/** One extracted ` ```ts type-equiv ` or ` ```ts public-api ` block. */
|
|
interface EquivBlock {
|
|
doc: string
|
|
/** 1-based line of the opening fence (for diagnostics). */
|
|
line: number
|
|
/** Symbol name parsed from the block's declaration. */
|
|
symbol: string
|
|
/** Complete declaration (default), or a body-stripped public class API. */
|
|
projection?: 'public-api'
|
|
/** Block body (the pasted declaration). */
|
|
code: string
|
|
}
|
|
|
|
/** Normalize declaration structure independently of comments and whitespace. */
|
|
function normalizeStructure(code: string): string {
|
|
return code
|
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
.replace(/(^|[^:])\/\/.*$/gm, '$1')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
}
|
|
|
|
/**
|
|
* Extract normalized JSDoc comments in source order. Type declarations in this
|
|
* repository do not contain comment delimiters inside string literals.
|
|
*/
|
|
function normalizeJSDoc(code: string): string[] {
|
|
return [...code.matchAll(/\/\*\*[\s\S]*?\*\//g)]
|
|
.map(match => match[0].replace(/\s+/g, ' ').trim())
|
|
}
|
|
|
|
/** Strip source-only export modifiers. */
|
|
function stripExport(code: string): string {
|
|
return code.replace(/^export\s+(default\s+)?/, '')
|
|
}
|
|
|
|
/** Parse the declared symbol name from a source-equivalence block body. */
|
|
function blockSymbol(code: string): string | null {
|
|
const sf = ts.createSourceFile('type-equiv.ts', code, ts.ScriptTarget.Latest, /* setParentNodes */ false, ts.ScriptKind.TS)
|
|
for (const stmt of sf.statements) {
|
|
const named =
|
|
ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)
|
|
|| ts.isClassDeclaration(stmt) || ts.isEnumDeclaration(stmt)
|
|
if (named && stmt.name) return stmt.name.text
|
|
}
|
|
return null
|
|
}
|
|
|
|
/** Extract every source-equivalence block from one Markdown file. */
|
|
function extractEquivBlocks(docRel: string): EquivBlock[] {
|
|
const text = readFileSync(resolve(root, docRel), 'utf8')
|
|
const lines = text.split('\n')
|
|
const blocks: EquivBlock[] = []
|
|
let open: { line: number; body: string[]; projection?: 'public-api' } | null = null
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const raw = lines[i] ?? ''
|
|
const fence = /^```(\s*)(\S.*)?$/.exec(raw)
|
|
if (!fence) {
|
|
if (open) open.body.push(raw)
|
|
continue
|
|
}
|
|
if (open) {
|
|
const code = open.body.join('\n')
|
|
const symbol = blockSymbol(code)
|
|
if (!symbol) {
|
|
throw new Error(`verify-type-equiv: ${docRel}:${open.line} — type-equiv block has no parseable interface/type/class declaration`)
|
|
}
|
|
blocks.push({
|
|
doc: docRel,
|
|
line: open.line,
|
|
symbol,
|
|
code,
|
|
...(open.projection === undefined ? {} : { projection: open.projection }),
|
|
})
|
|
open = null
|
|
continue
|
|
}
|
|
const info = (fence[2] ?? '').trim()
|
|
if (info === 'ts type-equiv public-api') {
|
|
throw new Error(`verify-type-equiv: ${docRel}:${i + 1} — use the concise \`ts public-api\` fence`)
|
|
}
|
|
if (info === 'ts type-equiv') open = { line: i + 1, body: [] }
|
|
if (info === 'ts public-api') open = { line: i + 1, body: [], projection: 'public-api' }
|
|
}
|
|
if (open) throw new Error(`verify-type-equiv: ${docRel}:${open.line} — unterminated type-equiv block`)
|
|
return blocks
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* interfaces, type aliases (including mapped/generic ones), classes, and enums
|
|
* 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)
|
|
for (const stmt of sf.statements) {
|
|
const named =
|
|
ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)
|
|
|| ts.isClassDeclaration(stmt) || ts.isEnumDeclaration(stmt)
|
|
if (named && stmt.name?.text === symbol) {
|
|
const declarationStart = stmt.getStart(sf)
|
|
const jsDoc = ts.getJSDocCommentsAndTags(stmt)
|
|
.filter(ts.isJSDoc)
|
|
.map(doc => text.slice(doc.pos, doc.end))
|
|
.join('\n')
|
|
const declaration = stripExport(text.slice(declarationStart, stmt.getEnd()))
|
|
return jsDoc === '' ? declaration : `${jsDoc}\n${declaration}`
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/** Leading source JSDoc attached to one declaration or member. */
|
|
function sourceJSDoc(text: string, node: ts.Node): string {
|
|
return ts.getJSDocCommentsAndTags(node)
|
|
.filter(ts.isJSDoc)
|
|
.map(doc => text.slice(doc.pos, doc.end))
|
|
.join('\n')
|
|
}
|
|
|
|
/** Whether a class member is part of its public declaration. */
|
|
function isPublicMember(member: ts.ClassElement): boolean {
|
|
if (ts.isClassStaticBlockDeclaration(member)) return false
|
|
const name = ts.getNameOfDeclaration(member)
|
|
if (name && ts.isPrivateIdentifier(name)) return false
|
|
const modifiers = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined
|
|
return !(modifiers?.some(modifier =>
|
|
modifier.kind === ts.SyntaxKind.PrivateKeyword
|
|
|| modifier.kind === ts.SyntaxKind.ProtectedKeyword,
|
|
) ?? false)
|
|
}
|
|
|
|
/** Remove an implementation body while retaining the source signature. */
|
|
function bodylessMember(text: string, sf: ts.SourceFile, member: ts.ClassElement): string {
|
|
const start = member.getStart(sf)
|
|
let end = member.end
|
|
if (ts.isConstructorDeclaration(member) || ts.isMethodDeclaration(member)
|
|
|| ts.isGetAccessorDeclaration(member) || ts.isSetAccessorDeclaration(member)) {
|
|
if (member.body) end = member.body.getStart(sf)
|
|
}
|
|
if (ts.isPropertyDeclaration(member) && member.initializer) end = member.initializer.getStart(sf)
|
|
const signature = text.slice(start, end).trimEnd().replace(/;$/, '').replace(/=\s*$/, '').trimEnd()
|
|
return `${signature};`
|
|
}
|
|
|
|
/**
|
|
* Render a class as an ambient declaration containing only its public fields,
|
|
* constructor, accessors, and methods. Implementation bodies and private or
|
|
* protected members are deliberately absent; original class/member JSDoc is
|
|
* 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)
|
|
for (const stmt of sf.statements) {
|
|
if (!ts.isClassDeclaration(stmt) || stmt.name?.text !== symbol) continue
|
|
const classDoc = sourceJSDoc(text, stmt)
|
|
const abstract = stmt.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AbstractKeyword) ? 'abstract ' : ''
|
|
const typeParameters = stmt.typeParameters?.map(parameter => parameter.getText(sf)).join(', ')
|
|
const heritage = stmt.heritageClauses?.map(clause => clause.getText(sf)).join(' ')
|
|
const header = `declare ${abstract}class ${symbol}${typeParameters ? `<${typeParameters}>` : ''}${heritage ? ` ${heritage}` : ''} {`
|
|
const members = stmt.members
|
|
.filter(isPublicMember)
|
|
.map((member) => {
|
|
const jsDoc = sourceJSDoc(text, member)
|
|
const declaration = bodylessMember(text, sf, member)
|
|
return jsDoc === '' ? declaration : `${jsDoc}\n${declaration}`
|
|
})
|
|
const declaration = [header, ...members.map(member => member.split('\n').map(line => ` ${line}`).join('\n')), '}'].join('\n')
|
|
return classDoc === '' ? declaration : `${classDoc}\n${declaration}`
|
|
}
|
|
return null
|
|
}
|
|
|
|
const manifestRaw = readFileSync(resolve(root, 'scripts/type-equiv.manifest.json'), 'utf8')
|
|
const manifest = JSON.parse(manifestRaw) as { entries: ManifestEntry[] }
|
|
const entries = manifest.entries
|
|
|
|
// Key a block/entry by doc + symbol + projection. A symbol may be documented in
|
|
// more than one doc, and a doc may carry both complete and projected forms.
|
|
const keyOf = (x: { doc: string; symbol: string; projection?: 'public-api' }): string =>
|
|
`${x.doc}::${x.symbol}::${x.projection ?? 'declaration'}`
|
|
|
|
// Collect every type-equiv block across ALL docs in scope — not only the docs
|
|
// the manifest names — so a block in an unmanifested doc is found and reported
|
|
// as an orphan rather than silently skipped.
|
|
const docSet = new Set<string>()
|
|
for (const pattern of MARKDOWN_GLOBS) {
|
|
for (const match of globSync(pattern, { cwd: root })) docSet.add(match.split(sep).join('/'))
|
|
}
|
|
const blocks: EquivBlock[] = [...docSet].sort().flatMap(extractEquivBlocks)
|
|
|
|
const errors: string[] = []
|
|
// A manifest entry naming a doc that does not exist (or is outside the scanned
|
|
// scope, so no block could ever match it) is an error in its own right.
|
|
for (const d of [...new Set(entries.map(e => e.doc))]) {
|
|
if (!existsSync(resolve(root, d))) errors.push(`manifest references ${d}, which does not exist`)
|
|
else if (!docSet.has(d)) errors.push(`manifest references ${d}, which is outside the scanned markdown scope (${MARKDOWN_GLOBS.join(', ')})`)
|
|
}
|
|
|
|
// Duplicate-block guard: the same projected symbol twice in one doc is ambiguous.
|
|
const blockByKey = new Map<string, EquivBlock>()
|
|
for (const b of blocks) {
|
|
const k = keyOf(b)
|
|
const prior = blockByKey.get(k)
|
|
if (prior) {
|
|
errors.push(`duplicate type-equiv block for ${b.symbol} in ${b.doc} (lines ${prior.line} and ${b.line})`)
|
|
continue
|
|
}
|
|
blockByKey.set(k, b)
|
|
}
|
|
|
|
// Duplicate-entry guard in the manifest.
|
|
const entryByKey = new Map<string, ManifestEntry>()
|
|
for (const e of entries) {
|
|
const k = keyOf(e)
|
|
if (entryByKey.has(k)) {
|
|
errors.push(`duplicate manifest entry for ${e.symbol} in ${e.doc}`)
|
|
continue
|
|
}
|
|
entryByKey.set(k, e)
|
|
}
|
|
|
|
// 1:1 correspondence: orphan blocks (no entry) and orphan entries (no block).
|
|
for (const b of blocks) {
|
|
if (!entryByKey.has(keyOf(b))) {
|
|
errors.push(`type-equiv block ${b.symbol} (${b.doc}:${b.line}) has no manifest entry — add one to scripts/type-equiv.manifest.json`)
|
|
}
|
|
}
|
|
for (const e of entries) {
|
|
if (!blockByKey.has(keyOf(e))) {
|
|
errors.push(`manifest entry ${e.symbol} (${e.doc}) has no matching type-equiv block — remove it or add the block`)
|
|
}
|
|
}
|
|
|
|
// Verbatim check: each matched block must equal its source declaration.
|
|
let verified = 0
|
|
for (const e of entries) {
|
|
const b = blockByKey.get(keyOf(e))
|
|
if (!b) continue // already reported as an orphan entry
|
|
const decl = e.projection === 'public-api'
|
|
? sourcePublicApi(e.source, e.symbol)
|
|
: sourceDeclaration(e.source, e.symbol)
|
|
if (decl === null) {
|
|
errors.push(`symbol ${e.symbol} not found in ${e.source} (manifest entry for ${e.doc})`)
|
|
continue
|
|
}
|
|
const doc = stripExport(b.code)
|
|
const sourceStructure = normalizeStructure(decl)
|
|
const docStructure = normalizeStructure(doc)
|
|
const sourceJSDoc = normalizeJSDoc(decl)
|
|
const docJSDoc = normalizeJSDoc(doc)
|
|
if (sourceStructure !== docStructure || JSON.stringify(sourceJSDoc) !== JSON.stringify(docJSDoc)) {
|
|
errors.push(
|
|
`DRIFT: ${e.doc}:${b.line} — type-equiv block for ${e.symbol} does not match ${e.source}.\n`
|
|
+ ` source structure: ${sourceStructure}\n`
|
|
+ ` doc structure: ${docStructure}\n`
|
|
+ ` source JSDoc: ${JSON.stringify(sourceJSDoc)}\n`
|
|
+ ` doc JSDoc: ${JSON.stringify(docJSDoc)}`,
|
|
)
|
|
continue
|
|
}
|
|
verified++
|
|
}
|
|
|
|
if (errors.length === 0) {
|
|
console.log(`verify-type-equiv: ${verified} type-equiv block(s) match source structure and JSDoc (1:1 with manifest).`)
|
|
process.exit(0)
|
|
}
|
|
|
|
console.error('verify-type-equiv: type-equiv verification failed:')
|
|
for (const e of errors) console.error(` ${e}`)
|
|
console.error(`\n(checked ${blocks.length} block(s) across ${new Set(blocks.map(b => b.doc)).size} doc(s); manifest at scripts/type-equiv.manifest.json)`)
|
|
process.exit(1)
|