Merge remote-tracking branch 'origin/master' into worktree/routed-model-compaction-policy

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md
#	.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/event-producer-consumer.md
#	examples/headless-agent/tests/harness.ts
#	examples/repl-agent/cordis.yml
#	packages/compact/compact-basic/README.md
#	packages/compact/compact-basic/src/index.ts
#	packages/compact/compact-basic/tests/compact-basic.spec.ts
#	packages/compact/compact-basic/tests/loader-composition.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/llm/README.md
#	packages/llm/llm-deepseek/src/adapter.ts
#	packages/llm/llm-pi-ai/src/adapter.ts
#	packages/llm/llm/README.md
#	packages/llm/llm/src/index.ts
#	scripts/gen-cordis-catalog.ts
#	website/zh-CN/api/harness/events.md
#	website/zh-CN/api/harness/llm.md
#	website/zh-CN/api/harness/token-meter.md
#	website/zh-CN/guide/config.md
This commit is contained in:
Yichen Jiang
2026-07-21 10:17:55 +08:00
851 changed files with 32175 additions and 13361 deletions

View File

@@ -0,0 +1,49 @@
/** Tests for the generated Cordis core API reference. */
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 {
CORDIS_CORE_API_PAGES,
renderCordisCoreApiPage,
renderCordisCoreApiPages,
type CordisCoreApiPage,
} from './cordis-core-api.ts'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
describe('Cordis core API generation', () => {
it('renders the five detailed pages from pinned vendor declarations', () => {
const pages = renderCordisCoreApiPages()
expect([...pages.keys()]).toEqual(CORDIS_CORE_API_PAGES.map(page => page.out))
expect(pages.get('docs/cordis-catalog/core/context.md')).toContain('### ctx.extend(meta?)')
expect(pages.get('docs/cordis-catalog/core/events.md')).toContain('## DispatchMode')
expect(pages.get('docs/cordis-catalog/core/fiber.md')).toContain('## EffectMeta')
expect(pages.get('docs/cordis-catalog/core/registry.md')).toContain('## Plugin')
expect(pages.get('docs/cordis-catalog/core/service.md')).toContain('### Service.resolveConfig')
const fiber = pages.get('docs/cordis-catalog/core/fiber.md') ?? ''
expect(fiber).toContain('```\n\nRegister a cleanup-aware effect on this fiber.')
expect(fiber).toContain('- `execute` — the effect body; see `Effect` for accepted shapes.')
expect(fiber).toContain('**Returns** a disposer that tears the effect down and settles once done.')
})
it('rejects a public core class without source JSDoc', () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-cordis-core-api-'))
roots.push(root)
mkdirSync(join(root, 'vendor/cordis/src'), { recursive: true })
writeFileSync(join(root, 'vendor/cordis/src/service.ts'), 'export class Service {\n run(): string { return "ok" }\n}\n')
const page: CordisCoreApiPage = {
out: 'docs/cordis-catalog/core/service.md',
title: 'Service',
intro: 'Service API.',
sections: [{ kind: 'class', file: 'vendor/cordis/src/service.ts', symbol: 'Service' }],
}
expect(() => renderCordisCoreApiPage(page, root)).toThrow('class Service')
})
})

433
scripts/cordis-core-api.ts Normal file
View File

@@ -0,0 +1,433 @@
/** Generate detailed Cordis core API pages from pinned vendor declarations. */
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import ts from 'typescript'
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations } from './jsdoc.ts'
import { cordisModuleBody } from './cordis-walk.ts'
const root = resolve(import.meta.dirname, '..')
const FENCE = 'ts cordis-catalog'
/** One declaration group rendered on a Cordis core API page. */
type CordisCoreApiSection =
| { kind: 'class'; file: string; symbol: string; prefix?: string; heading?: string }
| { kind: 'context-merge'; file: string; heading?: string }
| { kind: 'decl'; file: string; symbol: string }
/** One generated Cordis core API page. */
export interface CordisCoreApiPage {
out: string
title: string
intro: string
sections: CordisCoreApiSection[]
}
/** Explicit editorial grouping for the pinned Cordis core surface. */
export const CORDIS_CORE_API_PAGES: CordisCoreApiPage[] = [
{
out: 'docs/cordis-catalog/core/context.md',
title: 'Context',
intro: 'The context is the core Cordis object: every service, event, and lifecycle API is reached through `ctx`. Event methods are documented on [Events](events.md), effects and the current fiber on [Fiber](fiber.md), and plugin loading on [Registry](registry.md).',
sections: [
{ kind: 'class', file: 'vendor/cordis/src/context.ts', symbol: 'Context', prefix: 'ctx.' },
{ kind: 'context-merge', file: 'vendor/cordis/src/reflect.ts', heading: 'Service store and mixins' },
],
},
{
out: 'docs/cordis-catalog/core/events.md',
title: 'Events',
intro: 'The event-dispatch API mixed into every context. Harness event declarations and their dispatch modes are generated separately in the [Cordis events catalog](../events.md).',
sections: [
{ kind: 'context-merge', file: 'vendor/cordis/src/events.ts' },
{ kind: 'decl', file: 'vendor/cordis/src/events.ts', symbol: 'EventOptions' },
{ kind: 'decl', file: 'vendor/cordis/src/events.ts', symbol: 'DispatchMode' },
],
},
{
out: 'docs/cordis-catalog/core/fiber.md',
title: 'Fiber',
intro: 'A fiber is one loaded plugin instance: its lifecycle state, validated config, and registered effects. `ctx.fiber` is the current fiber, and `ctx.effect()` delegates to it.',
sections: [
{ kind: 'context-merge', file: 'vendor/cordis/src/fiber.ts' },
{ kind: 'class', file: 'vendor/cordis/src/fiber.ts', symbol: 'Fiber', heading: 'The Fiber class' },
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'Effect' },
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'Disposable' },
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'EffectMeta' },
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'CordisError' },
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'ValidationError' },
],
},
{
out: 'docs/cordis-catalog/core/registry.md',
title: 'Registry',
intro: 'Plugin loading and dependency injection.',
sections: [
{ kind: 'context-merge', file: 'vendor/cordis/src/registry.ts' },
{ kind: 'decl', file: 'vendor/cordis/src/registry.ts', symbol: 'Plugin' },
{ kind: 'decl', file: 'vendor/cordis/src/registry.ts', symbol: 'Inject' },
],
},
{
out: 'docs/cordis-catalog/core/service.md',
title: 'Service',
intro: 'The base class for context services. A subclass loaded as a plugin registers itself as `ctx.<name>`.',
sections: [
{ kind: 'class', file: 'vendor/cordis/src/service.ts', symbol: 'Service' },
],
},
]
interface MemberDoc {
name: string
heading: string
signatures: string[]
jsDoc: string
doc: string
params: { name: string; text: string }[]
returns: string | null
source: string
}
interface RenderContext {
scanRoot: string
cache: Map<string, { sf: ts.SourceFile; text: string }>
violations: string[]
}
function load(ctx: RenderContext, rel: string): { sf: ts.SourceFile; text: string } {
const cached = ctx.cache.get(rel)
if (cached !== undefined) return cached
const text = readFileSync(resolve(ctx.scanRoot, rel), 'utf8')
const entry = { sf: ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true), text }
ctx.cache.set(rel, entry)
return entry
}
function sourceJsDoc(text: string, sf: ts.SourceFile, node: ts.Node): string {
const raw = rawJsDoc(text, node)
if (raw === '') return ''
const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf))
const lineStart = sf.getPositionOfLineAndCharacter(line, 0)
const indent = text.slice(lineStart, node.getStart(sf))
return raw.split('\n')
.map((sourceLine, index) => index > 0 && sourceLine.startsWith(indent)
? sourceLine.slice(indent.length)
: sourceLine)
.join('\n')
}
function signatureOf(member: ts.Node, sf: ts.SourceFile): string {
const full = member.getText(sf)
const tail = (member as { body?: ts.Node; initializer?: ts.Node }).body
?? (member as { initializer?: ts.Node }).initializer
const signature = tail
? full.slice(0, full.length - tail.getText(sf).length).replace(/[=\s]+$/, '')
: full
return signature.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim()
}
function headingParams(parameters: readonly ts.ParameterDeclaration[], sf: ts.SourceFile): string {
const names = parameters
.filter(parameter => !(ts.isIdentifier(parameter.name) && parameter.name.text === 'this'))
.map((parameter) => {
const rest = parameter.dotDotDotToken ? '...' : ''
const optional = parameter.questionToken || parameter.initializer ? '?' : ''
return `${rest}${parameter.name.getText(sf)}${optional}`
})
return `(${names.join(', ')})`
}
function isPublicInstance(member: ts.ClassElement): boolean {
const modifiers = ts.getCombinedModifierFlags(member)
if (modifiers & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected | ts.ModifierFlags.Static)) return false
if (!member.name || ts.isComputedPropertyName(member.name) || ts.isPrivateIdentifier(member.name)) return false
return !member.name.getText().startsWith('_')
}
function isPublicStatic(member: ts.ClassElement): boolean {
const modifiers = ts.getCombinedModifierFlags(member)
if (modifiers & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected)) return false
if (!(modifiers & ts.ModifierFlags.Static)) return false
if (!member.name || ts.isComputedPropertyName(member.name) || ts.isPrivateIdentifier(member.name)) return false
return !member.name.getText().startsWith('_')
}
type Member = ts.MethodDeclaration
| ts.MethodSignature
| ts.PropertyDeclaration
| ts.PropertySignature
| ts.GetAccessorDeclaration
function memberDoc(ctx: RenderContext, where: string, name: string, group: Member[], rel: string): MemberDoc {
const { sf, text } = load(ctx, rel)
const first = group[0]
if (first === undefined) throw new Error(`cordis-core-api: empty member group for ${name}.`)
const rawDocs = group.map(member => sourceJsDoc(text, sf, member))
const docIndex = rawDocs.findIndex(raw => parseJsDoc(raw).doc !== '')
const raw = docIndex === -1 ? '' : (rawDocs[docIndex] ?? '')
const doc = parseJsDoc(raw).doc
if (doc === '') ctx.violations.push(`${where} has no JSDoc prose.`)
const { params: tags, returns } = parseTags(raw)
const functionMembers = group.filter((member): member is ts.MethodDeclaration | ts.MethodSignature =>
ts.isMethodDeclaration(member) || ts.isMethodSignature(member))
const docCarrier = functionMembers[docIndex === -1 ? 0 : docIndex]
const params: { name: string; text: string }[] = []
if (docCarrier !== undefined) {
checkParams(where, 'cordis-core-api', docCarrier.parameters, tags, sf,
parameter => ts.isIdentifier(parameter.name) && parameter.name.text === 'this', ctx.violations)
if (docCarrier.type !== undefined) {
checkReturns(where, docCarrier.type, returns, sf, ctx.violations)
} else if (returns === null && ts.isMethodDeclaration(docCarrier)) {
ctx.violations.push(`${where} has no return type annotation; document the result with @returns.`)
}
for (const parameter of docCarrier.parameters) {
if (!ts.isIdentifier(parameter.name) || parameter.name.text === 'this') continue
const text = tags.get(parameter.name.text)
if (text !== undefined) params.push({ name: parameter.name.text, text })
}
}
const headingSource = docCarrier ?? functionMembers[0]
const signatures = ts.isMethodDeclaration(first) && functionMembers.length > 1
? functionMembers.filter(member => ts.isMethodDeclaration(member) && member.body === undefined)
: group
return {
name,
heading: headingSource === undefined ? '' : headingParams(headingSource.parameters, sf),
signatures: signatures.map(member => signatureOf(member, sf)),
jsDoc: raw,
doc,
params,
returns,
source: pointer(rel, sf, first),
}
}
function heritageMembers(
statement: ts.InterfaceDeclaration,
sf: ts.SourceFile,
groups: Map<string, (ts.MethodSignature | ts.PropertySignature | ts.MethodDeclaration)[]>,
): void {
for (const clause of statement.heritageClauses ?? []) {
for (const type of clause.types) {
if (!ts.isIdentifier(type.expression) || type.expression.text !== 'Pick') continue
const [target, keys] = type.typeArguments ?? []
if (target === undefined || keys === undefined || !ts.isTypeReferenceNode(target)) continue
const targetName = target.typeName.getText(sf)
const cls = sf.statements.find(
(entry): entry is ts.ClassDeclaration => ts.isClassDeclaration(entry) && entry.name?.text === targetName,
)
if (cls === undefined) continue
const picked = new Set<string>()
const collect = (node: ts.TypeNode): void => {
if (ts.isLiteralTypeNode(node) && ts.isStringLiteral(node.literal)) picked.add(node.literal.text)
if (ts.isUnionTypeNode(node)) node.types.forEach(collect)
}
collect(keys)
for (const member of cls.members) {
if (!ts.isMethodDeclaration(member)) continue
const name = member.name.getText(sf)
if (!picked.has(name)) continue
const group = groups.get(name) ?? []
group.push(member)
groups.set(name, group)
}
}
}
}
function contextMergeMembers(ctx: RenderContext, rel: string): MemberDoc[] {
const { sf } = load(ctx, rel)
const body = cordisModuleBody(sf)
if (body === null) throw new Error(`cordis-core-api: ${rel} has no Context module merge.`)
const groups = new Map<string, (ts.MethodSignature | ts.PropertySignature | ts.MethodDeclaration)[]>()
for (const statement of body.statements) {
if (!ts.isInterfaceDeclaration(statement) || statement.name.text !== 'Context') continue
heritageMembers(statement, sf, groups)
for (const member of statement.members) {
if (!ts.isMethodSignature(member) && !ts.isPropertySignature(member)) continue
if (ts.isComputedPropertyName(member.name)) continue
const name = member.name.getText(sf)
const group = groups.get(name) ?? []
group.push(member)
groups.set(name, group)
}
}
return [...groups.entries()].map(([name, group]) =>
memberDoc(ctx, `ctx.${name} (${rel})`, name, group, rel))
}
function classMembers(ctx: RenderContext, rel: string, className: string): {
doc: string
instance: MemberDoc[]
statics: MemberDoc[]
source: string
} {
const { sf, text } = load(ctx, rel)
const cls = sf.statements.find(
(statement): statement is ts.ClassDeclaration =>
ts.isClassDeclaration(statement) && statement.name?.text === className,
)
if (cls === undefined) throw new Error(`cordis-core-api: class ${className} not found in ${rel}.`)
const doc = parseJsDoc(rawJsDoc(text, cls)).doc
if (doc === '') ctx.violations.push(`class ${className} (${pointer(rel, sf, cls)}) has no JSDoc.`)
const instance = new Map<string, Member[]>()
const statics = new Map<string, Member[]>()
for (const member of cls.members) {
if (!ts.isMethodDeclaration(member) && !ts.isPropertyDeclaration(member) && !ts.isGetAccessorDeclaration(member)) continue
const name = member.name.getText(sf)
if (isPublicInstance(member)) {
const group = instance.get(name) ?? []
group.push(member)
instance.set(name, group)
} else if (isPublicStatic(member) && !ts.isGetAccessorDeclaration(member)) {
const group = statics.get(name) ?? []
group.push(member)
statics.set(name, group)
}
}
const declaration = sf.statements.find(
(statement): statement is ts.InterfaceDeclaration =>
ts.isInterfaceDeclaration(statement) && statement.name.text === className,
)
for (const member of declaration?.members ?? []) {
if (!ts.isPropertySignature(member) || ts.isComputedPropertyName(member.name)) continue
const name = member.name.getText(sf)
const group = instance.get(name) ?? []
group.push(member)
instance.set(name, group)
}
const render = (groups: Map<string, Member[]>, prefix: string): MemberDoc[] =>
[...groups.entries()].map(([name, group]) => memberDoc(ctx, `${prefix}${name} (${rel})`, name, group, rel))
return {
doc,
instance: render(instance, `${className}#`),
statics: render(statics, `${className}.`),
source: pointer(rel, sf, cls),
}
}
function stripBodies(node: ts.Node, sf: ts.SourceFile): string {
const cuts: { start: number; end: number }[] = []
const visit = (entry: ts.Node): void => {
const functionLike = ts.isMethodDeclaration(entry)
|| ts.isConstructorDeclaration(entry)
|| ts.isFunctionDeclaration(entry)
|| ts.isGetAccessorDeclaration(entry)
|| ts.isSetAccessorDeclaration(entry)
if (functionLike && entry.body !== undefined) {
const signatureEnd = (entry.type ?? entry.parameters.at(-1) ?? entry).getEnd()
cuts.push({ start: signatureEnd, end: entry.body.getEnd() })
return
}
entry.forEachChild(visit)
}
visit(node)
const base = node.getStart(sf)
let output = node.getText(sf)
for (const cut of cuts.sort((left, right) => right.start - left.start)) {
const head = output.slice(0, cut.start - base)
const between = output.slice(cut.start - base, cut.end - base)
const bodyBrace = between.indexOf('{')
output = head + between.slice(0, bodyBrace).trimEnd() + output.slice(cut.end - base)
}
return output
}
function declarationPaste(ctx: RenderContext, rel: string, symbol: string): { doc: string; code: string; source: string } {
const { sf, text } = load(ctx, rel)
const matches = sf.statements.filter((statement) => {
const named = ts.isInterfaceDeclaration(statement)
|| ts.isTypeAliasDeclaration(statement)
|| ts.isClassDeclaration(statement)
|| ts.isEnumDeclaration(statement)
|| ts.isModuleDeclaration(statement)
return named && statement.name?.getText(sf) === symbol
})
const first = matches[0]
if (first === undefined) throw new Error(`cordis-core-api: declaration ${symbol} not found in ${rel}.`)
const doc = parseJsDoc(sourceJsDoc(text, sf, first)).doc
const code = matches.map((statement) => {
const jsDoc = sourceJsDoc(text, sf, statement)
const declaration = stripBodies(statement, sf).replace(/^export\s+(default\s+)?/, '')
return jsDoc === '' ? declaration : `${jsDoc}\n${declaration}`
}).join('\n\n')
return { doc, code, source: pointer(rel, sf, first) }
}
function sourceLink(source: string): string {
const [file, line] = source.split(':')
return `[Source](../../../${file}${line === undefined ? '' : `#L${line}`})`
}
function unlink(text: string): string {
return text.replace(/\{@link\s+([^}|\s]+)\s*(?:[|\s]\s*([^}]*))?\}/g, (_match, target: string, label?: string) => {
const name = label?.trim()
return name && name !== '' ? name : `\`${target}\``
})
}
function prose(doc: string): string[] {
const paragraphs = unlink(doc)
.split(/\n\s*\n/)
.map(paragraph => paragraph.replace(/\s*\n\s*/g, ' ').trim())
.filter(paragraph => paragraph !== '')
return paragraphs.flatMap((paragraph, index) => index === 0 ? [paragraph] : ['', paragraph])
}
function renderMember(prefix: string, member: MemberDoc): string[] {
const lines = [`### ${prefix}${member.name}${member.heading}`, '', `\`\`\`${FENCE}`]
if (member.jsDoc !== '') lines.push(member.jsDoc)
lines.push(...member.signatures, '```', '')
if (member.doc !== '') lines.push(...prose(member.doc), '')
for (const parameter of member.params) lines.push(`- \`${parameter.name}\`${unlink(parameter.text)}`)
if (member.params.length > 0) lines.push('')
if (member.returns !== null && member.returns !== '') lines.push(`**Returns** ${unlink(member.returns)}`, '')
lines.push(sourceLink(member.source), '')
return lines
}
/** Render one detailed Cordis core API page and reject undocumented members. */
export function renderCordisCoreApiPage(
page: CordisCoreApiPage,
scanRoot: string = root,
): string {
const ctx: RenderContext = { scanRoot, cache: new Map(), violations: [] }
const lines = [
'<!-- Generated by scripts/gen-cordis-catalog.ts — do not edit by hand.',
' Run `pnpm run gen-cordis-catalog` to regenerate. -->',
'',
`# ${page.title}`,
'',
page.intro,
'',
]
for (const section of page.sections) {
if (section.kind !== 'decl' && section.heading !== undefined) lines.push(`## ${section.heading}`, '')
if (section.kind === 'context-merge') {
for (const member of contextMergeMembers(ctx, section.file)) lines.push(...renderMember('ctx.', member))
} else if (section.kind === 'class') {
const cls = classMembers(ctx, section.file, section.symbol)
if (cls.doc !== '') lines.push(...prose(cls.doc), '')
lines.push(sourceLink(cls.source), '')
const prefix = section.prefix ?? `${section.symbol.toLowerCase()}.`
for (const member of cls.instance) lines.push(...renderMember(prefix, member))
if (cls.statics.length > 0) {
lines.push('## Static members', '')
for (const member of cls.statics) lines.push(...renderMember(`${section.symbol}.`, member))
}
} else {
const declaration = declarationPaste(ctx, section.file, section.symbol)
lines.push(`## ${section.symbol}`, '')
if (declaration.doc !== '') lines.push(...prose(declaration.doc), '')
lines.push(`\`\`\`${FENCE}`, declaration.code, '```', '', sourceLink(declaration.source), '')
}
}
reportViolations('gen-cordis-catalog', ctx.violations)
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`
}
/** Render every detailed Cordis core API page. */
export function renderCordisCoreApiPages(scanRoot: string = root): Map<string, string> {
return new Map(CORDIS_CORE_API_PAGES.map(page => [page.out, renderCordisCoreApiPage(page, scanRoot)]))
}

View File

@@ -1,10 +1,7 @@
/**
* Shared AST walkers for the cordis documentation generators
* (`gen-cordis-catalog.ts`, `gen-website-api.ts`): locating the cordis module
* merge in a source file, enumerating its `interface Events` members, and
* resolving the `interface Context` service keys to their service classes.
* One walk, two renderers — the catalog and the website page carry different
* prose but must agree on WHAT exists.
* AST walkers for the Cordis catalog generator: locate the Cordis module merge
* in a source file, enumerate its `interface Events` members, and resolve the
* `interface Context` service keys to their service classes.
*/
import ts from 'typescript'

View File

@@ -1,23 +1,20 @@
/**
* Boot the REPL, TUI, or ACP Code Mode overlay, defaulting to REPL. Each overlay
* Boot the TUI or ACP Code Mode overlay, defaulting to TUI. Each overlay
* includes its base example, selects Code Mode, and adds the worker runtime.
* All require a DeepSeek API key; unsupported arguments fail with usage.
*/
import { spawn } from 'node:child_process'
// Each UI's node invocation, verbatim what its base demo script runs plus
// the overlay config (the stdio bin keeps --expose-internals for the cordis
// Loader's HMR path).
// Each UI's node invocation matches its base demo script plus the overlay config.
const UIS = new Map([
['repl', ['--expose-internals', '--import', 'tsx', 'packages/examples/stdio-demo/src/bin.ts', 'examples/repl-agent/code-mode.cordis.yml']],
['tui', ['--expose-internals', '--import', 'tsx', 'packages/examples/stdio-demo/src/bin.ts', 'examples/tui-agent/code-mode.cordis.yml']],
['tui', ['--expose-internals', '--import', 'tsx', 'packages/examples/tui-demo/src/bin.ts', 'examples/tui-agent/code-mode.cordis.yml']],
['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']],
])
const ui = process.argv[2] ?? 'repl'
const ui = process.argv[2] ?? 'tui'
const args = UIS.get(ui)
if (!args || process.argv.length > 3) {
console.error('usage: pnpm run demo:code-mode [repl|tui|acp]')
console.error('usage: pnpm run demo:code-mode [tui|acp]')
process.exit(2)
}

View File

@@ -1,7 +1,7 @@
{
"AGENTS.md": 1600,
"docs/AGENTS.md": 1150,
"docs/architecture.md": 1790,
"docs/architecture.md": 1800,
"docs/cordis-primer.md": 600,
"docs/defensive-patterns.md": 550,
"docs/testing.md": 960,

View File

@@ -192,7 +192,7 @@ function remapBlockPaths(output: string, blocks: Block[]): string {
})
}
const markdownGlobs = ['README.md', '.agents/notes/**/*.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'website/zh-CN/**/*.md']
const markdownGlobs = ['README.md', '.agents/notes/**/*.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md']
const files: string[] = []
for (const pattern of markdownGlobs) {

View File

@@ -5,9 +5,10 @@
* curated table below. `--check` verifies both committed artifacts.
*/
import { globSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve, sep } from 'node:path'
import { globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve, sep } from 'node:path'
import ts from 'typescript'
import { renderCordisCoreApiPages } from './cordis-core-api.ts'
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts'
import { cordisModuleBody, eventMembers, serviceClasses } from './cordis-walk.ts'
@@ -35,6 +36,7 @@ export const LINK_MAP: Record<string, string> = {
GenerateOptions: 'core.md',
LlmCallConfig: 'core.md',
LlmModelContext: 'core.md',
LlmFailure: 'llm-streaming.md',
LlmModelInfo: 'core.md',
LlmProviderInfo: 'core.md',
Message: 'core.md',
@@ -58,6 +60,7 @@ export const LINK_MAP: Record<string, string> = {
CodeRunResult: 'code-runtime.md',
CompactionResult: 'compaction.md',
CompactionTrigger: 'compaction.md',
PruneResult: 'compaction.md',
FileReadOutcome: 'filesystem.md',
FsDirEntry: 'filesystem.md',
FsEditOutcome: 'filesystem.md',
@@ -69,6 +72,16 @@ export const LINK_MAP: Record<string, string> = {
FsVersion: 'filesystem.md',
FsWriteIntent: 'filesystem.md',
FsWriteOutcome: 'filesystem.md',
CreateGoalRequest: 'goal.md',
EditGoalRequest: 'goal.md',
GoalBlockReason: 'goal.md',
GoalChanged: 'goal.md',
GoalRef: 'goal.md',
GoalView: 'goal.md',
CommandDefinition: 'commands.md',
CommandDescriptor: 'commands.md',
CommandResult: 'commands.md',
CommandSurface: 'commands.md',
LlmAdapter: 'llm-streaming.md',
LlmService: 'llm-streaming.md',
StreamChunk: 'llm-streaming.md',
@@ -266,8 +279,7 @@ interface InheritedEntry {
source: string
}
// cordisModuleBody / eventMembers / serviceClasses live in cordis-walk.ts,
// shared with gen-website-api.ts — one walk, two renderers.
// cordisModuleBody / eventMembers / serviceClasses live in cordis-walk.ts.
/** The signature text of a method-signature member (everything but a body). */
function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.SourceFile): string {
@@ -505,7 +517,7 @@ export function renderEvents(events: EventEntry[]): string {
'',
GATE_NOTICE,
'',
'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely.',
'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely. The event-dispatch methods themselves are generated in the [Cordis core Events API](core/events.md).',
'',
'Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`).',
'',
@@ -540,7 +552,7 @@ export function renderServices(services: ServiceEntry[]): string {
'',
GATE_NOTICE,
'',
'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely.',
'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely. Detailed Context, Fiber, Registry, and Service APIs are generated in the [Cordis core API](core/context.md).',
'',
]
for (const s of services) lines.push(...renderService(s))
@@ -564,6 +576,7 @@ function main(): void {
const outputs: [string, string][] = [
[OUT_EVENTS, renderEvents(collectEvents())],
[OUT_SERVICES, renderServices(collectServices())],
...renderCordisCoreApiPages(),
]
if (process.argv.includes('--check')) {
const stale: string[] = []
@@ -580,15 +593,19 @@ function main(): void {
if (committed !== content) stale.push(out)
}
if (stale.length === 0) {
console.log(`gen-cordis-catalog: ${OUT_EVENTS} and ${OUT_SERVICES} are up to date.`)
console.log(`gen-cordis-catalog: ${outputs.length} generated file(s) are up to date.`)
process.exit(0)
}
console.error(`gen-cordis-catalog: ${stale.join(' and ')} ${stale.length === 1 ? 'is' : 'are'} stale. Run \`pnpm run gen-cordis-catalog\` and commit the result.`)
process.exit(1)
}
for (const [out, content] of outputs) writeFileSync(resolve(root, out), content)
console.log(`gen-cordis-catalog: wrote ${OUT_EVENTS} and ${OUT_SERVICES}.`)
for (const [out, content] of outputs) {
const destination = resolve(root, out)
mkdirSync(dirname(destination), { recursive: true })
writeFileSync(destination, content)
}
console.log(`gen-cordis-catalog: wrote ${outputs.length} generated file(s).`)
}
// Run only when invoked as a script, not when imported by a test.

View File

@@ -58,6 +58,7 @@ const GROUP_ORDER = [
'util',
'llm',
'core',
'goal',
'bash',
'sandbox',
'fs',
@@ -95,6 +96,14 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['compact-basic'],
note: 'Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements.',
},
{
key: 'toolResultPrune',
pkg: 'compact-tool-result-prune',
title: 'Model-free tool-result pruning',
mode: 'core',
consumers: ['compact-basic'],
note: 'Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction.',
},
{
key: 'sessions',
pkg: 'session',
@@ -140,10 +149,18 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'user-interaction',
title: 'Human question/answer seam',
mode: 'seam',
implementations: ['stdio-demo', 'acp'],
consumers: ['tool-ask-user', 'stdio-demo', 'acp'],
implementations: ['tui', 'acp'],
consumers: ['tool-ask-user', 'tui', 'acp'],
note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.',
},
{
key: 'commands',
pkg: 'commands',
title: 'Human command registry',
mode: 'core',
consumers: ['tui', 'acp'],
note: 'Plugins register direct human commands; TUI and ACP consume the same effective per-agent catalog without sending invocations to the model.',
},
{
key: 'skills',
pkg: 'skill',
@@ -158,7 +175,7 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'agent',
title: 'Agent service',
mode: 'core',
consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess', 'stdio-demo', 'invariants'],
consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess', 'tui-demo', 'invariants'],
note: 'Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation.',
},
{
@@ -169,6 +186,13 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['agent-spine-demo'],
note: 'The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package.',
},
{
key: 'goals',
pkg: 'goal',
title: 'Same-session goal domain',
mode: 'core',
note: 'Folds revisioned objective state from the session log and keeps live continuation activation process-local.',
},
{
key: 'bash',
pkg: 'bash',
@@ -194,6 +218,15 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['bash-sandbox'],
note: 'Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement.',
},
{
key: 'sandboxPolicy',
pkg: 'sandbox-policy',
title: 'Sandbox policy home',
mode: 'core',
implementations: [],
consumers: ['bash-sandbox', 'fs-sandbox'],
note: 'The one home for the deployment default mode + workspace root; only the sandboxed executor and provider read the service (the tool layers use the pure `sandbox/mode` fold it also exports). Both enforcing families read it so bash and fs cannot confine to different roots.',
},
{
key: 'approval',
pkg: 'approval',
@@ -226,10 +259,10 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'fs',
title: 'Filesystem provider seam',
mode: 'seam',
implementations: ['fs-local'],
implementations: ['fs-local', 'fs-sandbox'],
consumers: ['tool-fs'],
companions: ['fs-policy'],
note: 'tool-fs executes read/write/edit through ctx.fs; fs-policy contributes observed-state checks through the fs/* event gate.',
note: 'tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate.',
},
{
key: 'compact',
@@ -246,8 +279,8 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'Subagent provider registry',
mode: 'seam',
implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp'],
consumers: ['tool-subagent'],
note: 'Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name.',
consumers: ['tool-subagent', 'tool-ralph'],
note: 'Providers implement transports; tool-subagent exposes configured delegation while tool-ralph requires one fresh structured-output route.',
},
{
key: 'tasks',
@@ -281,8 +314,8 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'Workflow script engine',
mode: 'seam',
implementations: ['workflow-workerthread'],
consumers: ['tool-workflow'],
note: 'One engine per context (bash shape, no named-provider registry); the worker-thread engine fans agent() calls out through ctx.subagents.',
consumers: ['tool-workflow', 'tool-ralph'],
note: 'One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents.',
},
]
@@ -418,29 +451,13 @@ function stripYamlScalar(value: string): string {
}
const APP_EXAMPLES = [
{
id: 'echo',
rel: 'examples/echo-agent/composition.md',
title: 'Echo Agent App Composition',
label: 'examples/echo-agent',
config: 'examples/echo-agent/cordis.yml',
summary: 'The echo demo swaps in a local mock LLM and teaching echo tool, then loads the stdio app package for the shared spine and terminal front door.',
},
{
id: 'repl',
rel: 'examples/repl-agent/composition.md',
title: 'REPL Agent App Composition',
label: 'examples/repl-agent',
config: 'examples/repl-agent/cordis.yml',
summary: 'The REPL agent demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package.',
},
{
id: 'tui',
rel: 'examples/tui-agent/composition.md',
title: 'TUI Agent App Composition',
label: 'examples/tui-agent',
config: 'examples/tui-agent/cordis.yml',
summary: 'The TUI agent reuses the repl-agent backend and tool composition while fixing the shared terminal app to the full-screen dsh-tui front door.',
summary: 'The TUI agent combines the real DeepSeek adapter, coding tools, compaction, subagents, and workflows with the full-screen terminal app package.',
},
{
id: 'headless',
@@ -470,18 +487,13 @@ const APP_EXAMPLES = [
type AppExample = typeof APP_EXAMPLES[number]
function renderAppExpansion(lines: string[], appNode: string, pluginName: string, exampleId: string): void {
function renderAppExpansion(lines: string[], appNode: string, pluginName: string): void {
const agentCore = nodeId('bundle', 'agent_core')
const jsonl = nodeId('bundle', 'jsonl')
lines.push(` ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-spine-demo"]`)
lines.push(` ${appNode} --> ${jsonl}["@deepseek-ai/dsh-session-persistence-jsonl"]`)
if (pluginName === '@deepseek-ai/dsh-stdio-demo') {
const frontDoor = exampleId === 'tui'
? '@deepseek-ai/dsh-tui<br/>pre-created main agent'
: exampleId === 'repl'
? '@deepseek-ai/dsh-stdio<br/>pre-created main agent'
: 'dsh-tui (TTY) / dsh-stdio (pipes)<br/>pre-created main agent'
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'stdio')}["${frontDoor}"]`)
if (pluginName === '@deepseek-ai/dsh-tui-demo') {
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'tui')}["@deepseek-ai/dsh-tui<br/>pre-created main agent"]`)
} else if (pluginName === '@deepseek-ai/dsh-cli-demo') {
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'cli')}["one-shot driver<br/>format-pure stdout<br/>fresh top-level agent"]`)
} else if (pluginName === '@deepseek-ai/dsh-acp-demo') {
@@ -510,8 +522,8 @@ function renderAppComposition(example: AppExample): string {
const pluginNode = nodeId(`plugin_${example.id}`, plugin.id)
lines.push(` ${pluginNode}["${escLabel(plugin.id)}<br/>${escLabel(plugin.name)}"]`)
lines.push(` cfg --> ${pluginNode}`)
if (plugin.name === '@deepseek-ai/dsh-stdio-demo' || plugin.name === '@deepseek-ai/dsh-cli-demo' || plugin.name === '@deepseek-ai/dsh-acp-demo') {
renderAppExpansion(lines, pluginNode, plugin.name, example.id)
if (plugin.name === '@deepseek-ai/dsh-tui-demo' || plugin.name === '@deepseek-ai/dsh-cli-demo' || plugin.name === '@deepseek-ai/dsh-acp-demo') {
renderAppExpansion(lines, pluginNode, plugin.name)
}
}
lines.push(
@@ -900,7 +912,7 @@ function renderLifecycle(): string {
'',
'The `assistant/message` edge records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history while the durable anchor retains usage and exact chunk provenance, including an explicit empty source set.',
'',
'`dsh-compact-basic` uses `agent/post-step` for pressure after those durable facts and `agent/request-error` only for canonical context overflow. Recovery compacts between the closed failed step and a fresh retry step, and returns retry only when the surface replacement generation advances; otherwise the original request error remains authoritative.',
'`dsh-compact-basic` uses `agent/post-step` for pressure after those durable facts and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and a fresh retry step, and returns retry only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.',
'',
'SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors.',
'',
@@ -1009,8 +1021,6 @@ function renderDocs(): GraphDoc[] {
function renderIndex(docs: GraphDoc[]): string {
const labels: Record<string, string> = {
'docs/capability-seams.md': 'capability seams and core services',
'examples/echo-agent/composition.md': 'echo-agent app composition',
'examples/repl-agent/composition.md': 'repl-agent app composition',
'examples/headless-agent/composition.md': 'headless-agent app composition',
'examples/tui-agent/composition.md': 'tui-agent app composition',
'examples/cordis-agent/composition.md': 'cordis-agent app composition',
@@ -1022,8 +1032,6 @@ function renderIndex(docs: GraphDoc[]): string {
}
const modes: Record<string, string> = {
'docs/capability-seams.md': 'hybrid generated',
'examples/echo-agent/composition.md': 'hybrid generated',
'examples/repl-agent/composition.md': 'hybrid generated',
'examples/headless-agent/composition.md': 'hybrid generated',
'examples/tui-agent/composition.md': 'hybrid generated',
'examples/cordis-agent/composition.md': 'hybrid generated',

View File

@@ -21,6 +21,7 @@ const GROUP_ORDER = [
'util',
'llm',
'core',
'goal',
'bash',
'fs',
'skill',

View File

@@ -10,6 +10,8 @@ import { globSync, readFileSync, writeFileSync } from 'node:fs'
import { basename, resolve } from 'node:path'
import { Context } from 'cordis'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import GoalService from '@deepseek-ai/dsh-goal'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
@@ -30,12 +32,14 @@ import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
import * as ToolGoal from '@deepseek-ai/dsh-tool-goal'
import * as ToolSkill from '@deepseek-ai/dsh-tool-skill'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread'
import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph'
import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
const root = resolve(import.meta.dirname, '..')
@@ -79,11 +83,16 @@ class CatalogSearchBashExecutor extends BashExecutor {
}
}
/** Register the descriptor needed to mount schema-producing consumers. */
/**
* Register the descriptor needed to mount schema-producing consumers. Declares
* the full capability set of the shipped in-process providers so consumers
* mount under their shipped defaults (tool-subagent's default numeric maxDepth
* requires `depthLimit`).
*/
function registerCatalogSubagentProvider(ctx: Context, name: string): void {
const provider: SubagentProvider = {
name,
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true },
inheritsParentContext: false,
start: () => Promise.reject(new Error('tool-catalog provider cannot start a child')),
}
@@ -219,6 +228,35 @@ const TOOL_PACKAGES: ToolPackage[] = [
note:
'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.',
},
{
pkg: '@deepseek-ai/dsh-tool-goal',
dir: 'tool-goal',
source: 'packages/goal/tool-goal/src/index.ts',
requires: ['ctx.tools', 'ctx.agents', 'ctx.goals', 'ctx.systemPrompt', 'a calling Agent in an authorized open turn'],
writes: ['tool/call', 'context/message goal snapshot for mutations', 'tool/result'],
async mount(ctx) {
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService)
await ctx.plugin(ToolGoal)
},
note:
'create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds.',
},
{
pkg: '@deepseek-ai/dsh-tool-ralph',
dir: 'tool-ralph',
source: 'packages/workflow/tool-ralph/src/index.ts',
requires: ['ctx.tools', 'ctx.workflows', 'ctx.subagents', 'ctx.systemPrompt', 'a calling Agent (exec.agent parents every fresh round)'],
writes: ['tool/call', 'tool/result', 'workflow and child session events during execution'],
async mount(ctx) {
await ctx.plugin(SubagentService)
registerCatalogSubagentProvider(ctx, 'mock')
await ctx.plugin(VmWorkflowEngine, { provider: 'mock' })
await ctx.plugin(ToolRalph, { subagentProvider: 'mock' })
},
note:
'A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap.',
},
{
pkg: '@deepseek-ai/dsh-tool-skill',
dir: 'tool-skill',
@@ -247,7 +285,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
await ctx.plugin(ToolSubagent, { provider: 'mock' })
},
note:
'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.',
'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.',
},
{
pkg: '@deepseek-ai/dsh-tool-tasks',

View File

@@ -1,757 +0,0 @@
/**
* Generate (and verify) the website API reference under `website/zh-CN/api/`.
*
* The website's API section is FULLY GENERATED from source — never hand-edit
* it. The hand-written hub `api/index.md` sits OUTSIDE the generated subdirs
* (`api/cordis/`, `api/harness/`), so the orphan sweep never touches it. Two tiers:
*
* - `api/cordis/*` — the vendored cordis framework surface (Context, Events,
* Fiber, Registry, Service), driven by the CORDIS_PAGES manifest below.
* Members come from the real class declarations and the `declare module
* './context.ts'` interface merges (the typed `ctx.*` surface a plugin
* author actually sees).
* - `api/harness/*` — one page per `ctx.<key>` harness service (walked from
* every `declare module 'cordis'` Context merge under `packages/<group>/<pkg>/src`),
* plus `events.md` listing every harness event grouped by scope.
*
* Prose comes from the JSDoc; the generator HARD-ERRORS (aggregated) when a
* rendered member lacks a summary, a parameter lacks `@param`, or a non-void
* annotated return lacks `@returns` — so a vendor sync or a new service method
* cannot land undocumented without CI going red. Pages are English (the
* planned zh translation flow arrives separately; see docs/i18n/README.md).
*
* Signature fences use the ` ```ts website-api ` info string and retain the
* declaration's original source JSDoc. doc-typecheck only processes its known
* info strings, so these bare (non-compilable) fragments are skipped there,
* while VitePress still highlights the `ts` token. The sidebar fragment
* `website/.vitepress/config/api-sidebar.json` is generated alongside so
* navigation can never drift from the page set.
*
* `tsx scripts/gen-website-api.ts` → write pages + sidebar
* `tsx scripts/gen-website-api.ts --check` → exit 1 if committed copies are
* stale (doc-sync / CI gate)
*/
import { globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import ts from 'typescript'
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts'
import { cordisModuleBody, eventMembers, serviceClasses } from './cordis-walk.ts'
const root = resolve(import.meta.dirname, '..')
/** Output roots: generated pages and the generated sidebar fragment. */
const PAGES_DIR = 'website/zh-CN/api'
const SIDEBAR_OUT = 'website/.vitepress/config/api-sidebar.json'
/** GitHub blob base for source links on the public site (repo-relative paths
* do not resolve on the built site, unlike the in-repo catalogs). */
const GITHUB = 'https://github.com/deepseek-harness/deepseek-harness/blob/master'
/** Signature-fence info string (skipped by doc-typecheck, highlighted as ts). */
const FENCE = 'ts website-api'
/** Return sorted repository-relative glob matches with stable URL separators. */
function repoGlob(pattern: string): string[] {
return globSync(pattern, { cwd: root }).map(rel => rel.replaceAll('\\', '/')).sort()
}
/** One rendered member: a method/property plus its parsed JSDoc. */
interface MemberDoc {
/** Display name, e.g. `on` or `agent/pre-step`. */
name: string
/** Heading suffix with parameter names, e.g. `(name, listener, options?)`;
* empty for properties. */
heading: string
/** All overload signature lines (bodies stripped). */
signatures: string[]
/** Original source JSDoc, dedented only from its containing declaration. */
jsDoc: string
/** Description prose, one paragraph per line. */
doc: string
/** Parameter name → `@param` text, in declaration order. */
params: { name: string; text: string }[]
/** `@returns` text, or null for void/undocumented. */
returns: string | null
/** Repo-relative `file:line` of the (first) declaration. */
source: string
}
/** A cordis-page section: which declarations it renders. */
type Section =
| { kind: 'class'; file: string; symbol: string; prefix?: string; heading?: string }
| { kind: 'context-merge'; file: string; heading?: string }
| { kind: 'decl'; file: string; symbol: string }
/** One generated cordis page. */
interface CordisPage {
out: string
title: string
intro: string
sections: Section[]
}
/**
* The cordis tier manifest. Deliberately explicit (not a blind walk): the
* vendor `Context` mixes true plugin-author surface with internals, and page
* grouping is an editorial choice — but every member listed here is still
* EXTRACTED, never transcribed, so signatures and docs cannot drift.
*/
const CORDIS_PAGES: CordisPage[] = [
{
out: 'cordis/context.md',
title: 'Context',
intro: 'The context is the core cordis object: every service, event, and lifecycle API is reached through `ctx`. Event methods (`ctx.on`, `ctx.emit`, …) are documented on [Events](./events.md); `ctx.effect` and `ctx.fiber` on [Fiber](./fiber.md); `ctx.plugin` and `ctx.inject` on [Registry](./registry.md).',
sections: [
{ kind: 'class', file: 'vendor/cordis/src/context.ts', symbol: 'Context', prefix: 'ctx.' },
{ kind: 'context-merge', file: 'vendor/cordis/src/reflect.ts', heading: 'Service store and mixins' },
],
},
{
out: 'cordis/events.md',
title: 'Events',
intro: 'The event system mixed into every context. Harness-defined events are cataloged on [Harness events](../harness/events.md).',
sections: [
{ kind: 'context-merge', file: 'vendor/cordis/src/events.ts' },
{ kind: 'decl', file: 'vendor/cordis/src/events.ts', symbol: 'EventOptions' },
{ kind: 'decl', file: 'vendor/cordis/src/events.ts', symbol: 'DispatchMode' },
],
},
{
out: 'cordis/fiber.md',
title: 'Fiber',
intro: 'A fiber is one loaded plugin instance: its lifecycle state, validated config, and registered effects. `ctx.fiber` is the current fiber; `ctx.effect()` delegates to it.',
sections: [
{ kind: 'context-merge', file: 'vendor/cordis/src/fiber.ts' },
{ kind: 'class', file: 'vendor/cordis/src/fiber.ts', symbol: 'Fiber', heading: 'The Fiber class' },
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'Effect' },
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'Disposable' },
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'EffectMeta' },
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'CordisError' },
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'ValidationError' },
],
},
{
out: 'cordis/registry.md',
title: 'Registry',
intro: 'Plugin loading and dependency injection.',
sections: [
{ kind: 'context-merge', file: 'vendor/cordis/src/registry.ts' },
{ kind: 'decl', file: 'vendor/cordis/src/registry.ts', symbol: 'Plugin' },
{ kind: 'decl', file: 'vendor/cordis/src/registry.ts', symbol: 'Inject' },
],
},
{
out: 'cordis/service.md',
title: 'Service',
intro: 'Base class for context services: subclass it and load the subclass as a plugin to register `ctx.<name>`.',
sections: [
{ kind: 'class', file: 'vendor/cordis/src/service.ts', symbol: 'Service' },
],
},
]
// ---------------------------------------------------------------------------
// Extraction
// ---------------------------------------------------------------------------
const sfCache = new Map<string, { sf: ts.SourceFile; text: string }>()
/** Parse (and cache) one repo-relative source file. */
function load(rel: string): { sf: ts.SourceFile; text: string } {
const cached = sfCache.get(rel)
if (cached) return cached
const text = readFileSync(resolve(root, rel), 'utf8')
const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true)
const entry = { sf, text }
sfCache.set(rel, entry)
return entry
}
// The module-merge walk (cordisModuleBody / eventMembers / serviceClasses) is
// shared with gen-cordis-catalog.ts via cordis-walk.ts.
/** Original JSDoc with only the source container's indentation removed. */
function sourceJSDoc(text: string, sf: ts.SourceFile, node: ts.Node): string {
const raw = rawJsDoc(text, node)
if (raw === '') return ''
const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf))
const lineStart = sf.getPositionOfLineAndCharacter(line, 0)
const indent = text.slice(lineStart, node.getStart(sf))
return raw.split('\n')
.map((sourceLine, index) => index > 0 && sourceLine.startsWith(indent)
? sourceLine.slice(indent.length)
: sourceLine)
.join('\n')
}
/** Signature text of a member: full text minus body/initializer, whitespace
* collapsed, trailing semicolon stripped. */
function signatureOf(member: ts.Node, sf: ts.SourceFile): string {
const full = member.getText(sf)
const tail = (member as { body?: ts.Node; initializer?: ts.Node }).body
?? (member as { initializer?: ts.Node }).initializer
const sig = tail ? full.slice(0, full.length - tail.getText(sf).length).replace(/[=\s]+$/, '') : full
return sig.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim()
}
/** `(a, b?, ...rest)` heading suffix from a parameter list, `this` dropped. */
function headingParams(parameters: readonly ts.ParameterDeclaration[], sf: ts.SourceFile): string {
const names = parameters
.filter(p => !(ts.isIdentifier(p.name) && p.name.text === 'this'))
.map((p) => {
const dots = p.dotDotDotToken ? '...' : ''
const opt = p.questionToken || p.initializer ? '?' : ''
return `${dots}${p.name.getText(sf)}${opt}`
})
return `(${names.join(', ')})`
}
/** Whether a class member is renderable public API (non-static half). */
function isPublicInstance(member: ts.ClassElement): boolean {
const mods = ts.getCombinedModifierFlags(member)
if (mods & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected | ts.ModifierFlags.Static)) return false
if (!member.name) return false
if (ts.isComputedPropertyName(member.name) || ts.isPrivateIdentifier(member.name)) return false
return !member.name.getText().startsWith('_')
}
/** Whether a class member is renderable public STATIC API. */
function isPublicStatic(member: ts.ClassElement): boolean {
const mods = ts.getCombinedModifierFlags(member)
if (mods & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected)) return false
if (!(mods & ts.ModifierFlags.Static)) return false
if (!member.name || ts.isComputedPropertyName(member.name) || ts.isPrivateIdentifier(member.name)) return false
return !member.name.getText().startsWith('_')
}
/** Build a MemberDoc from a declaration group (overloads share one entry),
* collecting completeness violations for everything rendered. */
function memberDoc(
where: string,
name: string,
group: (ts.MethodDeclaration | ts.MethodSignature | ts.PropertyDeclaration | ts.PropertySignature | ts.GetAccessorDeclaration)[],
rel: string,
violations: string[],
): MemberDoc {
const { sf, text } = load(rel)
const first = group[0]
if (!first) throw new Error(`gen-website-api: empty member group for ${name}`)
// Doc from the first overload that carries JSDoc prose.
const rawDocs = group.map(m => sourceJSDoc(text, sf, m))
const docIndex = rawDocs.findIndex(r => parseJsDoc(r).doc !== '')
const raw = docIndex === -1 ? '' : (rawDocs[docIndex] ?? '')
const doc = parseJsDoc(raw).doc
if (!doc) violations.push(`${where} has no JSDoc prose.`)
const { params: tags, returns } = parseTags(raw)
const params: { name: string; text: string }[] = []
let returnsText: string | null = null
const funcLike = group.filter((m): m is ts.MethodDeclaration | ts.MethodSignature => ts.isMethodDeclaration(m) || ts.isMethodSignature(m))
const docCarrier = funcLike[docIndex === -1 ? 0 : docIndex]
if (docCarrier) {
checkParams(where, 'website-api', docCarrier.parameters, tags, sf,
p => ts.isIdentifier(p.name) && p.name.text === 'this', violations)
if (docCarrier.type) {
checkReturns(where, docCarrier.type, returns, sf, violations)
} else if (!returns && ts.isMethodDeclaration(docCarrier)) {
// Comment-only vendor policy: we cannot add a return type annotation to
// pinned upstream source, so an unannotated rendered method must carry
// an explicit @returns describing the result instead.
violations.push(`${where} has no return type annotation; document the result with @returns.`)
}
for (const p of docCarrier.parameters) {
if (ts.isIdentifier(p.name) && p.name.text === 'this') continue
const pname = p.name.getText(sf)
const tag = tags.get(pname)
if (tag) params.push({ name: pname, text: tag })
}
returnsText = returns
}
const headingSource = docCarrier ?? funcLike[0]
return {
name,
heading: headingSource ? headingParams(headingSource.parameters, sf) : '',
signatures: (ts.isMethodDeclaration(first) && funcLike.length > 1
? funcLike.filter(m => ts.isMethodDeclaration(m) && !m.body)
: group).map(m => signatureOf(m, sf)),
jsDoc: raw,
doc,
params,
returns: returnsText,
source: pointer(rel, sf, first),
}
}
/** Resolve an `extends Pick<Class, 'a' | 'b'>` heritage clause on the Context
* merge to the named members of `Class` declared in the same file — the fiber
* merge (`interface Context extends Pick<Fiber, 'effect'>`) is the motivating
* case: without this, `ctx.effect` had no documented signature anywhere. */
function heritageMembers(
stmt: ts.InterfaceDeclaration,
sf: ts.SourceFile,
groups: Map<string, (ts.MethodSignature | ts.PropertySignature | ts.MethodDeclaration)[]>,
): void {
for (const clause of stmt.heritageClauses ?? []) {
for (const type of clause.types) {
if (!ts.isIdentifier(type.expression) || type.expression.text !== 'Pick') continue
const [target, keys] = type.typeArguments ?? []
if (!target || !keys || !ts.isTypeReferenceNode(target)) continue
const targetName = target.typeName.getText(sf)
const cls = sf.statements.find(
(s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === targetName,
)
if (!cls) continue
const picked = new Set<string>()
const collect = (node: ts.TypeNode): void => {
if (ts.isLiteralTypeNode(node) && ts.isStringLiteral(node.literal)) picked.add(node.literal.text)
if (ts.isUnionTypeNode(node)) node.types.forEach(collect)
}
collect(keys)
for (const member of cls.members) {
if (!ts.isMethodDeclaration(member)) continue
const name = member.name.getText(sf)
if (!picked.has(name)) continue
const group = groups.get(name) ?? []
group.push(member)
groups.set(name, group)
}
}
}
}
/** Members of the `interface Context` merge in `rel`, overloads grouped;
* `Pick<…>` heritage resolved to the picked class members. */
function contextMergeMembers(rel: string, violations: string[]): MemberDoc[] {
const { sf } = load(rel)
const body = cordisModuleBody(sf)
if (!body) throw new Error(`gen-website-api: ${rel} has no context module merge`)
const groups = new Map<string, (ts.MethodSignature | ts.PropertySignature | ts.MethodDeclaration)[]>()
for (const stmt of body.statements) {
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue
heritageMembers(stmt, sf, groups)
for (const member of stmt.members) {
if (!ts.isMethodSignature(member) && !ts.isPropertySignature(member)) continue
if (ts.isComputedPropertyName(member.name)) continue
const name = member.name.getText(sf)
const group = groups.get(name) ?? []
group.push(member)
groups.set(name, group)
}
}
return [...groups.entries()].map(([name, group]) =>
memberDoc(`ctx.${name} (${rel})`, name, group, rel, violations))
}
/** Instance + static members of one class, as two rendered lists. The class's
* same-named top-level interface half (declaration merging — vendor Context
* declares `root`/`events`/`logger`/… on the interface) is folded into the
* instance list, so neither half of a merged symbol goes undocumented. */
function classMembers(rel: string, className: string, violations: string[]): {
doc: string
instance: MemberDoc[]
statics: MemberDoc[]
source: string
} {
const { sf, text } = load(rel)
const cls = sf.statements.find(
(s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === className,
)
if (!cls) throw new Error(`gen-website-api: class ${className} not found in ${rel}`)
const clsDoc = parseJsDoc(rawJsDoc(text, cls)).doc
if (!clsDoc) violations.push(`class ${className} (${pointer(rel, sf, cls)}) has no JSDoc.`)
type Renderable = ts.MethodDeclaration | ts.PropertyDeclaration | ts.GetAccessorDeclaration | ts.PropertySignature
const instance = new Map<string, Renderable[]>()
const statics = new Map<string, (ts.MethodDeclaration | ts.PropertyDeclaration)[]>()
for (const member of cls.members) {
const renderable = ts.isMethodDeclaration(member) || ts.isPropertyDeclaration(member) || ts.isGetAccessorDeclaration(member)
if (!renderable) continue
const name = member.name.getText(sf)
if (isPublicInstance(member)) {
const group = instance.get(name) ?? []
group.push(member)
instance.set(name, group)
} else if (isPublicStatic(member) && !ts.isGetAccessorDeclaration(member)) {
const group = statics.get(name) ?? []
group.push(member)
statics.set(name, group)
}
}
const iface = sf.statements.find(
(s): s is ts.InterfaceDeclaration => ts.isInterfaceDeclaration(s) && s.name.text === className,
)
for (const member of iface?.members ?? []) {
if (!ts.isPropertySignature(member)) continue
if (ts.isComputedPropertyName(member.name)) continue
const name = member.name.getText(sf)
const group = instance.get(name) ?? []
group.push(member)
instance.set(name, group)
}
const toDocs = (groups: Map<string, Renderable[]>, prefix: string): MemberDoc[] =>
[...groups.entries()].map(([name, group]) =>
memberDoc(`${prefix}${name} (${rel})`, name, group, rel, violations))
return {
doc: clsDoc,
instance: toDocs(instance, `${className}#`),
statics: toDocs(statics, `${className}.`),
source: pointer(rel, sf, cls),
}
}
/** Splice every function-like BODY out of a declaration's text, leaving the
* signature (`) {` → `)`). A reference paste shows shapes, not implementation;
* property initializers (e.g. an `as const` code table) are data and stay. */
function stripBodies(node: ts.Node, sf: ts.SourceFile): string {
const cuts: { start: number; end: number }[] = []
const visit = (n: ts.Node): void => {
const funcLike = ts.isMethodDeclaration(n) || ts.isConstructorDeclaration(n)
|| ts.isFunctionDeclaration(n) || ts.isGetAccessorDeclaration(n) || ts.isSetAccessorDeclaration(n)
if (funcLike && n.body) {
// Cut from just after the parameter close (or return-type end) through
// the body, so `foo(a: string) { … }` renders as `foo(a: string)`.
const sigEnd = (n.type ?? n.parameters[n.parameters.length - 1] ?? n).getEnd()
// Find the `)` (and optional `: Type`) boundary: body start is exact.
cuts.push({ start: sigEnd, end: n.body.getEnd() })
return // nothing renderable inside the body
}
n.forEachChild(visit)
}
visit(node)
const base = node.getStart(sf)
let out = node.getText(sf)
for (const cut of cuts.sort((a, b) => b.start - a.start)) {
const head = out.slice(0, cut.start - base)
// Keep everything of the signature up to the closing paren / return type,
// drop ` { … }`. The head may end mid-signature (last param), so retain
// the source between sigEnd and the body's `{` MINUS trailing space.
const between = out.slice(cut.start - base, cut.end - base)
const bodyBrace = between.indexOf('{')
out = head + between.slice(0, bodyBrace).trimEnd() + out.slice(cut.end - base)
}
return out
}
/** Verbatim declaration paste: every top-level statement named `symbol`
* (class + merged namespace both), with leading JSDoc prose extracted and
* function bodies stripped (a reference shows shapes, not implementation). */
function declPaste(rel: string, symbol: string): { doc: string; code: string; source: string } {
const { sf, text } = load(rel)
const matches = sf.statements.filter((s) => {
const named = ts.isInterfaceDeclaration(s) || ts.isTypeAliasDeclaration(s)
|| ts.isClassDeclaration(s) || ts.isEnumDeclaration(s) || ts.isModuleDeclaration(s)
return named && s.name?.getText(sf) === symbol
})
if (matches.length === 0) throw new Error(`gen-website-api: declaration ${symbol} not found in ${rel}`)
const first = matches[0]
if (!first) throw new Error(`gen-website-api: declaration ${symbol} not found in ${rel}`)
const firstJSDoc = sourceJSDoc(text, sf, first)
const doc = parseJsDoc(firstJSDoc).doc
const code = matches.map((statement) => {
const jsDoc = sourceJSDoc(text, sf, statement)
const declaration = stripBodies(statement, sf).replace(/^export\s+(default\s+)?/, '')
return jsDoc === '' ? declaration : `${jsDoc}\n${declaration}`
}).join('\n\n')
return { doc, code, source: pointer(rel, sf, first) }
}
/** One harness service with member-level detail. */
interface HarnessService {
key: string
type: string
abstract: boolean
doc: string
members: MemberDoc[]
source: string
/** Owning npm package name (from the package.json beside the entry). */
pkg: string
}
/** Walk every harness `declare module 'cordis'` Context merge → services. */
function collectHarnessServices(violations: string[]): HarnessService[] {
const services: HarnessService[] = []
for (const rel of repoGlob('packages/*/*/src/index.ts')) {
const { sf, text } = load(rel)
if (!text.includes('interface Context')) continue
const body = cordisModuleBody(sf)
if (!body) continue
const pkgJson = resolve(root, dirname(dirname(rel)), 'package.json')
// Manifest shape is repo-owned; `name` is the one field read here.
const manifest = JSON.parse(readFileSync(pkgJson, 'utf8')) as { name: string }
const pkg = manifest.name
for (const { key, type, cls, abstract, doc: clsDoc } of serviceClasses(body, sf, rel, violations)) {
const groups = new Map<string, (ts.MethodDeclaration | ts.PropertyDeclaration | ts.GetAccessorDeclaration)[]>()
for (const member of cls.members) {
// Public properties are API too: ctx.codeRuntime.language/isolation
// are readonly descriptors consumers key presentation off.
const renderable = ts.isMethodDeclaration(member) || ts.isPropertyDeclaration(member) || ts.isGetAccessorDeclaration(member)
if (!renderable) continue
if (!isPublicInstance(member)) continue
const name = member.name.getText(sf)
const group = groups.get(name) ?? []
group.push(member)
groups.set(name, group)
}
const members = [...groups.entries()].map(([name, group]) =>
memberDoc(`ctx.${key}.${name} (${rel})`, name, group, rel, violations))
services.push({ key, type, abstract, doc: clsDoc, members, source: pointer(rel, sf, cls), pkg })
}
}
return services.sort((a, b) => a.key.localeCompare(b.key))
}
/** One harness event with member-level detail. */
interface HarnessEvent {
name: string
scope: string
mode: Mode | null
signature: string
/** Original source event JSDoc, dedented from its module/interface. */
jsDoc: string
doc: string
params: { name: string; text: string }[]
source: string
}
/** Walk every harness `interface Events` merge → events. */
function collectHarnessEvents(violations: string[]): HarnessEvent[] {
const events: HarnessEvent[] = []
for (const rel of repoGlob('packages/*/*/src/*.ts')) {
const { sf, text } = load(rel)
if (!text.includes('interface Events')) continue
const body = cordisModuleBody(sf)
if (!body) continue
for (const { name, member } of eventMembers(body, sf)) {
const raw = sourceJSDoc(text, sf, member)
const { doc, mode } = parseJsDoc(raw)
if (!mode) violations.push(`event '${name}' (${pointer(rel, sf, member)}) is missing @mode.`)
if (!doc) violations.push(`event '${name}' (${pointer(rel, sf, member)}) has no JSDoc prose.`)
const { params: tags } = parseTags(raw)
const last = member.parameters.at(-1)
const hasNext = !!last && last.name.getText(sf) === 'next'
checkParams(`event '${name}' (${pointer(rel, sf, member)})`, 'website-api', member.parameters, tags, sf,
p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations)
const params: { name: string; text: string }[] = []
for (const p of member.parameters) {
const pname = p.name.getText(sf)
const tag = tags.get(pname)
if (tag) params.push({ name: pname, text: tag })
}
events.push({ name, scope: name.split('/')[0] ?? name, mode, signature: signatureOf(member, sf), jsDoc: raw, doc, params, source: pointer(rel, sf, member) })
}
}
return events.sort((a, b) => a.name.localeCompare(b.name))
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
const BANNER = '<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->'
/** GitHub source link for a `file:line` pointer. */
function sourceLink(source: string): string {
const [file, line] = source.split(':')
return `[Source](${GITHUB}/${file}#L${line})`
}
/** Normalize JSDoc inline `{@link X}` / `{@link X|label}` / `{@link X label}`
* tags to plain Markdown code spans — left verbatim they leak into the built
* page as literal `{@link …}` text. */
function unlink(text: string): string {
return text.replace(/\{@link\s+([^}|\s]+)\s*(?:[|\s]\s*([^}]*))?\}/g, (_m, target: string, label?: string) => {
const name = label?.trim()
return name && name !== '' ? name : `\`${target}\``
})
}
/** Render prose paragraphs (one per line of `doc`), JSDoc links normalized. */
function prose(doc: string): string[] {
return unlink(doc).split('\n').filter(l => l.trim() !== '')
}
/** Render one member section at heading depth 3. */
function renderMember(prefix: string, m: MemberDoc): string[] {
const lines: string[] = []
const call = m.heading === '' ? '' : m.heading
lines.push(`### ${prefix}${m.name}${call}`, '')
lines.push('```' + FENCE)
lines.push(m.jsDoc)
for (const sig of m.signatures) lines.push(sig)
lines.push('```', '')
lines.push(...prose(m.doc), '')
if (m.params.length > 0) {
for (const p of m.params) lines.push(`- \`${p.name}\`${unlink(p.text)}`)
lines.push('')
}
if (m.returns) lines.push(`**Returns** ${unlink(m.returns)}`, '')
lines.push(sourceLink(m.source), '')
return lines
}
/** Render one cordis-tier page from its manifest entry. */
function renderCordisPage(page: CordisPage, violations: string[]): string {
const lines: string[] = [BANNER, '', `# ${page.title}`, '', page.intro, '']
for (const section of page.sections) {
if (section.kind !== 'decl' && section.heading) lines.push(`## ${section.heading}`, '')
if (section.kind === 'context-merge') {
for (const m of contextMergeMembers(section.file, violations)) {
lines.push(...renderMember('ctx.', m))
}
} else if (section.kind === 'class') {
const cls = classMembers(section.file, section.symbol, violations)
lines.push(...prose(cls.doc), '', sourceLink(cls.source), '')
const instancePrefix = section.prefix ?? `${section.symbol.toLowerCase()}.`
for (const m of cls.instance) lines.push(...renderMember(instancePrefix, m))
if (cls.statics.length > 0) {
lines.push('## Static members', '')
for (const m of cls.statics) lines.push(...renderMember(`${section.symbol}.`, m))
}
} else {
const decl = declPaste(section.file, section.symbol)
lines.push(`## ${section.symbol}`, '')
if (decl.doc) lines.push(...prose(decl.doc), '')
lines.push('```' + FENCE, decl.code, '```', '', sourceLink(decl.source), '')
}
}
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`
}
/** kebab-case a ctx key: `agentLoop` → `agent-loop`. */
function kebab(key: string): string {
return key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)
}
/** Render one harness service page. */
function renderServicePage(svc: HarnessService): string {
const seam = svc.abstract ? ' (abstract seam)' : ''
const lines: string[] = [
BANNER, '',
`# ctx.${svc.key}`, '',
`\`${svc.type}\`${seam} — provided by \`${svc.pkg}\`.`, '',
...prose(svc.doc), '',
sourceLink(svc.source), '',
]
for (const m of svc.members) lines.push(...renderMember(`ctx.${svc.key}.`, m))
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`
}
/** Render the harness events page, grouped by scope. */
function renderEventsPage(events: HarnessEvent[]): string {
const lines: string[] = [
BANNER, '',
'# Harness events', '',
`Every event the harness packages declare on the cordis event bus (${events.length} total), grouped by scope. The **mode** is the dispatch semantics (\`emit\` fire-and-forget, \`parallel\` awaited, \`serial\` first-bail, \`waterfall\` veto-chain — a waterfall listener MUST call \`next()\` to delegate).`, '',
]
const scopes = [...new Set(events.map(e => e.scope))].sort()
for (const scope of scopes) {
lines.push(`## ${scope}/*`, '')
for (const e of events.filter(ev => ev.scope === scope)) {
lines.push(`### ${e.name}`, '')
lines.push(`**Mode:** \`${e.mode ?? 'unknown'}\``, '')
lines.push('```' + FENCE, e.jsDoc, e.signature, '```', '')
lines.push(...prose(e.doc), '')
if (e.params.length > 0) {
for (const p of e.params) lines.push(`- \`${p.name}\`${unlink(p.text)}`)
lines.push('')
}
lines.push(sourceLink(e.source), '')
}
}
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`
}
// ---------------------------------------------------------------------------
// Assembly + CLI
// ---------------------------------------------------------------------------
/** Build every generated file as `relPath → content`. */
export function generate(): Map<string, string> {
const violations: string[] = []
const files = new Map<string, string>()
for (const page of CORDIS_PAGES) {
files.set(`${PAGES_DIR}/${page.out}`, renderCordisPage(page, violations))
}
const services = collectHarnessServices(violations)
for (const svc of services) {
files.set(`${PAGES_DIR}/harness/${kebab(svc.key)}.md`, renderServicePage(svc))
}
const events = collectHarnessEvents(violations)
files.set(`${PAGES_DIR}/harness/events.md`, renderEventsPage(events))
for (const [rel, content] of files) {
if (!rel.endsWith('.md')) continue
for (const match of content.matchAll(/^```ts website-api\n([\s\S]*?)\n```$/gm)) {
const body = match[1] ?? ''
if (!body.startsWith('/**')) {
violations.push(`${rel}: a ts website-api fence does not begin with original source JSDoc.`)
}
}
}
reportViolations('gen-website-api', violations)
const sidebar = {
cordis: CORDIS_PAGES.map(p => ({
text: p.title,
link: `/zh-CN/api/${p.out.replace(/\.md$/, '')}`,
})),
harness: [
...services.map(s => ({ text: `ctx.${s.key}`, link: `/zh-CN/api/harness/${kebab(s.key)}` })),
{ text: 'Events', link: '/zh-CN/api/harness/events' },
],
}
files.set(SIDEBAR_OUT, `${JSON.stringify(sidebar, null, 2)}\n`)
return files
}
/** CLI entry: default writes, `--check` fails on stale/orphan files. Guarded
* behind an entry-point check so tests can import `generate()`. */
function main(): void {
const check = process.argv.includes('--check')
const files = generate()
// Orphan detection: a generated-dir page that generate() no longer emits
// (e.g. a service was renamed) must be deleted, not left to rot.
const expected = new Set([...files.keys()])
// Orphans live in the generated subdirs only; the hand-written api/index.md
// is one level up and never matches this glob.
const onDisk = repoGlob(`${PAGES_DIR}/{cordis,harness}/*.md`)
const orphans = onDisk.filter(rel => !expected.has(rel))
if (check) {
const stale: string[] = []
for (const [rel, content] of files) {
let current: string | null = null
try {
current = readFileSync(resolve(root, rel), 'utf8')
} catch {
// Missing file: reported as stale below; readFileSync is the probe.
}
if (current !== content) stale.push(rel)
}
if (stale.length > 0 || orphans.length > 0) {
console.error('gen-website-api: website API reference is stale. Run `pnpm run gen-website-api` and commit the result.')
for (const rel of stale) console.error(` stale: ${rel}`)
for (const rel of orphans) console.error(` orphan (delete): ${rel}`)
process.exit(1)
}
console.log(`gen-website-api: ${files.size} generated file(s) fresh.`)
return
}
for (const [rel, content] of files) {
const abs = resolve(root, rel)
mkdirSync(dirname(abs), { recursive: true })
writeFileSync(abs, content)
}
for (const rel of orphans) {
console.log(`gen-website-api: orphan page ${rel} — delete it (no longer generated).`)
}
console.log(`gen-website-api: wrote ${files.size} file(s).`)
}
// Run only when invoked as a script, not when imported by a test.
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
main()
}

View File

@@ -1,6 +1,6 @@
/**
* Shared fenced-code-block extractor for the Markdown doc gates
* (`doc-typecheck.ts`, `verify-website-yaml.ts`). One scanner, per-gate
* (currently `doc-typecheck.ts`; future Markdown gates can share it). One scanner, per-gate
* classification: each gate maps a fence info string (` ```ts `,
* ` ```yaml ignore-check `, …) to its own kind tag and receives every
* classified block with its 1-based opening-fence line.

View File

@@ -0,0 +1,219 @@
/** Tests for the documentation website projection adapter. */
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 { docsPages, type DocsPage } from '../website/docs.ts'
import { addProjectionFrontmatter, projectedPageContent, rewriteMarkdown } from './project-doc-site.ts'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function fixture(): { root: string; pages: DocsPage[] } {
const root = mkdtempSync(join(tmpdir(), 'dsh-doc-site-'))
roots.push(root)
mkdirSync(join(root, 'docs'), { recursive: true })
mkdirSync(join(root, 'packages'), { recursive: true })
writeFileSync(join(root, 'docs/a.md'), '# A\n')
writeFileSync(join(root, 'docs/b.md'), '# B\n')
writeFileSync(join(root, 'docs/x(y).md'), '# Parentheses\n')
writeFileSync(join(root, 'packages/tool.ts'), 'one\ntwo\n')
writeFileSync(join(root, 'packages/logo.svg'), '<svg/>\n')
return {
root,
pages: [
{ locale: 'root', contentLocale: 'en-US', source: 'docs/a.md', route: 'a.md', label: 'A', sidebar: 'zh-reference', section: 'Test', order: 1 },
{ locale: 'root', contentLocale: 'en-US', source: 'docs/b.md', route: 'reference-root/b.md', label: 'B', sidebar: 'zh-reference', section: 'Test', order: 2 },
{ locale: 'en', contentLocale: 'en-US', source: 'docs/a.md', route: 'en/a.md', label: 'A', sidebar: 'en-reference', section: 'Test', order: 1 },
{ locale: 'en', contentLocale: 'en-US', source: 'docs/b.md', route: 'en/reference/b.md', label: 'B', sidebar: 'en-reference', section: 'Test', order: 2 },
],
}
}
describe('rewriteMarkdown', () => {
it('maps published pages and pins unpublished source links', () => {
const { root, pages } = fixture()
const source = '[B](b.md#part) [source](../packages/tool.ts:2) [web](https://example.com)\n'
expect(rewriteMarkdown(source, {
locale: 'en',
sourcePath: 'docs/a.md',
route: 'en/a.md',
pages,
repoRoot: root,
repositoryRef: 'abc123',
})).toBe(
'[B](./reference/b.md#part) '
+ '[source](https://github.com/deepseek-harness/deepseek-harness/blob/abc123/packages/tool.ts#L2) '
+ '[web](https://example.com)\n',
)
})
it('selects the published target in the current site locale', () => {
const { root, pages } = fixture()
expect(rewriteMarkdown('[B](b.md)\n', {
locale: 'root',
sourcePath: 'docs/a.md',
route: 'a.md',
pages,
repoRoot: root,
repositoryRef: 'abc123',
})).toBe('[B](./reference-root/b.md)\n')
})
it('uses raw GitHub content for unpublished images', () => {
const { root, pages } = fixture()
expect(rewriteMarkdown('![logo](../packages/logo.svg)\n', {
locale: 'en',
sourcePath: 'docs/a.md',
route: 'en/a.md',
pages,
repoRoot: root,
repositoryRef: 'abc123',
})).toBe('![logo](https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/abc123/packages/logo.svg)\n')
})
it('does not rewrite Markdown-looking text inside code fences', () => {
const { root, pages } = fixture()
const source = '```md\n[B](b.md)\n```\n'
expect(rewriteMarkdown(source, {
locale: 'en',
sourcePath: 'docs/a.md',
route: 'en/a.md',
pages,
repoRoot: root,
repositoryRef: 'abc123',
})).toBe(source)
})
it('replaces the destination token without changing repeated titles or escapes', () => {
const { root, pages } = fixture()
const source = '[title](b.md "b.md") [escaped](x\\(y\\).md)\n'
expect(rewriteMarkdown(source, {
locale: 'en',
sourcePath: 'docs/a.md',
route: 'en/a.md',
pages,
repoRoot: root,
repositoryRef: 'abc123',
})).toBe(
'[title](./reference/b.md "b.md") '
+ '[escaped](https://github.com/deepseek-harness/deepseek-harness/blob/abc123/docs/x(y).md)\n',
)
})
it('routes a pair switcher across locales while ordinary links stay in locale', () => {
const { root, pages } = fixture()
writeFileSync(join(root, 'docs/a.zh.md'), '# A\n')
const paired = pages.filter(page => page.source !== 'docs/a.md')
paired.push(
{
locale: 'root', contentLocale: 'zh-CN', source: 'docs/a.zh.md', sourceAliases: ['docs/a.md'],
route: 'guide/a.md', label: 'A', sidebar: 'zh-guide', section: 'Test', order: 1,
},
{
locale: 'en', contentLocale: 'en-US', source: 'docs/a.md', sourceAliases: ['docs/a.zh.md'],
route: 'en/guide/a.md', label: 'A', sidebar: 'en-guide', section: 'Test', order: 1,
},
)
expect(rewriteMarkdown('[English](a.md) [B](b.md)\n', {
locale: 'root',
sourcePath: 'docs/a.zh.md',
route: 'guide/a.md',
pages: paired,
repoRoot: root,
repositoryRef: 'abc123',
})).toBe('[English](../en/guide/a.md) [B](../reference-root/b.md)\n')
})
it('fails loud when a relative target is missing', () => {
const { root, pages } = fixture()
expect(() => rewriteMarkdown('[missing](missing.md)\n', {
locale: 'en',
sourcePath: 'docs/a.md',
route: 'en/a.md',
pages,
repoRoot: root,
repositoryRef: 'abc123',
})).toThrow('links to missing path "missing.md"')
})
})
describe('docsPages locale routes', () => {
it('publishes every route in both locales and selects paired user sources', () => {
const byRoute = new Map(docsPages.map(page => [page.route, page]))
for (const page of docsPages.filter(page => page.locale === 'root')) {
const counterpart = byRoute.get(`en/${page.route}`)
expect(counterpart, page.route).toBeDefined()
expect(counterpart?.locale).toBe('en')
if (page.source.startsWith('docs/user/')) {
expect(page.source).toMatch(/\.zh\.md$/)
expect(page.contentLocale).toBe('zh-CN')
expect(counterpart?.source).toBe(page.source.replace(/\.zh\.md$/, '.md'))
expect(counterpart?.contentLocale).toBe('en-US')
} else {
expect(counterpart?.source).toBe(page.source)
expect(counterpart?.contentLocale).toBe(page.contentLocale)
}
}
})
it('publishes the Cordis core API under matching locale structures', () => {
const files = ['context.md', 'events.md', 'fiber.md', 'registry.md', 'service.md']
for (const file of files) {
const root = docsPages.find(page => page.route === `reference/cordis-api/${file}`)
const english = docsPages.find(page => page.route === `en/reference/cordis-api/${file}`)
expect(root?.source).toBe(`docs/cordis-catalog/core/${file}`)
expect(root?.section).toBe('Cordis API')
expect(english?.source).toBe(root?.source)
expect(english?.section).toBe('Cordis Core API')
}
})
})
describe('addProjectionFrontmatter', () => {
it('adds frontmatter to an ordinary Markdown page', () => {
expect(addProjectionFrontmatter('# Guide\n', 'docs/guide.md')).toBe(
'---\neditSource: "docs/guide.md"\n---\n\n# Guide\n',
)
})
it('extends existing VitePress frontmatter', () => {
expect(addProjectionFrontmatter('---\nlayout: home\n---\n', 'docs/index.md')).toBe(
'---\neditSource: "docs/index.md"\nlayout: home\n---\n',
)
})
})
describe('projectedPageContent', () => {
const page = (sidebar: DocsPage['sidebar']): DocsPage => ({
locale: 'root',
contentLocale: 'zh-CN',
source: 'docs/index.zh.md',
route: 'index.md',
label: 'Home',
sidebar,
section: 'Home',
order: 0,
})
it('omits the source-only body from locale home pages', () => {
expect(projectedPageContent(
'---\nlayout: home\nhero:\n name: Harness\n---\n\n# Harness\n\n[English](index.md) | 中文\n',
page(null),
)).toBe('---\nlayout: home\nhero:\n name: Harness\n---\n')
})
it('keeps the full body for ordinary pages', () => {
const markdown = '---\ntitle: Guide\n---\n\n# Guide\n'
expect(projectedPageContent(markdown, page('zh-guide'))).toBe(markdown)
})
it('rejects a locale home source without frontmatter', () => {
expect(() => projectedPageContent('# Harness\n', page(null)))
.toThrow('locale home source "docs/index.zh.md" must start with YAML frontmatter')
})
})

322
scripts/project-doc-site.ts Normal file
View File

@@ -0,0 +1,322 @@
/**
* Build-time projection from canonical repository Markdown into VitePress.
*
* The generated tree is disposable: sources stay in their owning `docs/`
* tier, while this adapter rewrites cross-source links for the public site.
*/
import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, extname, posix, relative, resolve, sep } from 'node:path'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { gfmFromMarkdown } from 'mdast-util-gfm'
import { gfm } from 'micromark-extension-gfm'
import type { Nodes } from 'mdast'
import { docsPages, type DocsLocale, type DocsPage } from '../website/docs.ts'
const REPOSITORY_URL = 'https://github.com/deepseek-harness/deepseek-harness'
const root = resolve(import.meta.dirname, '..')
const generatedRoot = resolve(root, 'website/.generated')
interface Replacement {
start: number
end: number
value: string
}
interface DestinationRange {
start: number
end: number
}
type RewritableNode = Extract<Nodes, { type: 'link' | 'image' | 'definition' }>
/** Inputs for rewriting one canonical Markdown page. */
export interface RewriteMarkdownOptions {
locale: DocsLocale
sourcePath: string
route: string
pages: DocsPage[]
repoRoot: string
repositoryRef: string
}
function repoPath(absPath: string, repoRoot: string): string {
return relative(repoRoot, absPath).split(sep).join('/')
}
function isExternalOrSiteAbsolute(url: string): boolean {
return url.startsWith('#')
|| url.startsWith('//')
|| url.startsWith('/')
|| /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)
}
function skipWhitespace(source: string, start: number): number {
let index = start
while (/\s/.test(source[index] ?? '')) index += 1
return index
}
function labelEnd(source: string): number {
const first = source.indexOf('[')
if (first === -1) return -1
let depth = 0
for (let index = first; index < source.length; index += 1) {
const char = source[index]
if (char === '\\') {
index += 1
} else if (char === '[') {
depth += 1
} else if (char === ']') {
depth -= 1
if (depth === 0) return index
}
}
return -1
}
function destinationRange(rawNode: string, type: 'link' | 'image' | 'definition'): DestinationRange {
const endOfLabel = labelEnd(rawNode)
if (endOfLabel === -1) {
throw new Error(`project-doc-site: cannot locate label end in ${JSON.stringify(rawNode)}.`)
}
let start: number
if (type === 'definition') {
const colon = rawNode.indexOf(':', endOfLabel + 1)
if (colon === -1) {
throw new Error(`project-doc-site: cannot locate definition separator in ${JSON.stringify(rawNode)}.`)
}
start = skipWhitespace(rawNode, colon + 1)
} else {
if (rawNode[endOfLabel + 1] !== '(') {
throw new Error(`project-doc-site: cannot locate inline destination in ${JSON.stringify(rawNode)}.`)
}
start = skipWhitespace(rawNode, endOfLabel + 2)
}
if (rawNode[start] === '<') {
for (let index = start + 1; index < rawNode.length; index += 1) {
if (rawNode[index] === '\\') index += 1
else if (rawNode[index] === '>') return { start: start + 1, end: index }
}
throw new Error(`project-doc-site: cannot locate angle-bracket destination end in ${JSON.stringify(rawNode)}.`)
}
let depth = 0
for (let index = start; index < rawNode.length; index += 1) {
const char = rawNode[index]
if (char === '\\') {
index += 1
} else if (char === '(') {
depth += 1
} else if (char === ')') {
if (depth === 0) return { start, end: index }
depth -= 1
} else if (/\s/.test(char ?? '') && depth === 0) {
return { start, end: index }
}
}
return { start, end: rawNode.length }
}
function splitTarget(url: string): { path: string; suffix: string } {
const boundary = url.search(/[?#]/)
if (boundary === -1) return { path: url, suffix: '' }
return { path: url.slice(0, boundary), suffix: url.slice(boundary) }
}
function decodePath(path: string): string {
try {
return decodeURIComponent(path)
} catch {
throw new Error(`project-doc-site: malformed percent escape in ${JSON.stringify(path)}.`)
}
}
function routeTarget(fromRoute: string, toRoute: string, suffix: string): string {
const target = posix.relative(posix.dirname(fromRoute), toRoute)
return `${target.startsWith('.') ? target : `./${target}`}${suffix}`
}
function sourceMap(pages: DocsPage[]): Map<string, Map<DocsLocale, DocsPage>> {
const map = new Map<string, Map<DocsLocale, DocsPage>>()
for (const page of pages) {
for (const source of [page.source, ...(page.sourceAliases ?? [])]) {
const localized = map.get(source) ?? new Map<DocsLocale, DocsPage>()
if (localized.has(page.locale)) {
throw new Error(`project-doc-site: duplicate source or alias ${JSON.stringify(source)} for locale ${JSON.stringify(page.locale)}.`)
}
localized.set(page.locale, page)
map.set(source, localized)
}
}
return map
}
function counterpartSource(source: string): string {
return source.endsWith('.zh.md')
? source.replace(/\.zh\.md$/, '.md')
: source.replace(/\.md$/, '.zh.md')
}
function resolveRepositoryTarget(sourceAbs: string, rawPath: string, repoRoot: string): { absPath: string; line?: number } {
const decoded = decodePath(rawPath)
let absPath = resolve(dirname(sourceAbs), decoded)
if (existsSync(absPath)) return { absPath }
const lineMatch = decoded.match(/:(\d+)$/)
if (lineMatch !== null) {
const lineText = lineMatch[1]
if (lineText === undefined) throw new Error('project-doc-site: line suffix matched without a line number.')
absPath = resolve(dirname(sourceAbs), decoded.slice(0, -lineMatch[0].length))
if (existsSync(absPath)) return { absPath, line: Number.parseInt(lineText, 10) }
}
if (extname(decoded) === '') {
const markdown = resolve(dirname(sourceAbs), `${decoded}.md`)
if (existsSync(markdown)) return { absPath: markdown }
const index = resolve(dirname(sourceAbs), decoded, 'index.md')
if (existsSync(index)) return { absPath: index }
}
throw new Error(`project-doc-site: ${repoPath(sourceAbs, repoRoot)} links to missing path ${JSON.stringify(rawPath)}.`)
}
function githubTarget(
absPath: string,
line: number | undefined,
suffix: string,
repositoryRef: string,
repoRoot: string,
image: boolean,
): string {
const path = repoPath(absPath, repoRoot)
if (image) return `https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/${repositoryRef}/${path}${suffix}`
const kind = lstatSync(absPath).isDirectory() ? 'tree' : 'blob'
const lineSuffix = line === undefined ? suffix : `#L${line}`
return `${REPOSITORY_URL}/${kind}/${repositoryRef}/${path}${lineSuffix}`
}
/**
* Rewrite repository-relative links without reserializing Markdown.
*
* @param source Markdown text from the canonical file.
* @param options Source, route, manifest, and repository context.
* @returns Markdown whose published links resolve inside the site or to GitHub.
*/
export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions): string {
const sourceAbs = resolve(options.repoRoot, options.sourcePath)
const published = sourceMap(options.pages)
const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
const replacements: Replacement[] = []
const rewrite = (node: RewritableNode): void => {
if (isExternalOrSiteAbsolute(node.url)) return
const { path, suffix } = splitTarget(node.url)
if (path === '') return
const { absPath, line } = resolveRepositoryTarget(sourceAbs, path, options.repoRoot)
const targetPath = repoPath(absPath, options.repoRoot)
const isLanguageSwitcher = targetPath === counterpartSource(options.sourcePath)
const targetLocale: DocsLocale = isLanguageSwitcher
? options.locale === 'root' ? 'en' : 'root'
: options.locale
const page = published.get(targetPath)?.get(targetLocale)
const nextUrl = page === undefined
? githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image')
: routeTarget(options.route, page.route, suffix)
const start = node.position?.start.offset
const end = node.position?.end.offset
if (start === undefined || end === undefined) {
throw new Error(`project-doc-site: link ${JSON.stringify(node.url)} has no source offsets.`)
}
const rawNode = source.slice(start, end)
const rawDestination = destinationRange(rawNode, node.type)
replacements.push({
start: start + rawDestination.start,
end: start + rawDestination.end,
value: nextUrl,
})
}
const visit = (node: Nodes): void => {
if ((node.type === 'link' || node.type === 'image' || node.type === 'definition') && 'url' in node) rewrite(node)
if ('children' in node) {
for (const child of node.children) visit(child)
}
}
visit(tree)
let projected = source
for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
projected = projected.slice(0, replacement.start) + replacement.value + projected.slice(replacement.end)
}
return projected
}
/**
* Record the canonical edit target in VitePress frontmatter.
*
* @param markdown Projected Markdown content.
* @param sourcePath Repository-relative canonical source path.
* @returns Markdown with an `editSource` frontmatter field.
*/
export function addProjectionFrontmatter(markdown: string, sourcePath: string): string {
const field = `editSource: ${JSON.stringify(sourcePath)}`
if (markdown.startsWith('---\n')) return markdown.replace('---\n', `---\n${field}\n`)
return `---\n${field}\n---\n\n${markdown}`
}
/**
* Select the Markdown rendered for one published page.
*
* @param markdown Rewritten canonical Markdown content.
* @param page Publication manifest entry for the content.
* @returns Full Markdown for ordinary pages or frontmatter-only Markdown for a locale home page.
*/
export function projectedPageContent(markdown: string, page: DocsPage): string {
if (page.sidebar !== null) return markdown
if (!markdown.startsWith('---\n')) {
throw new Error(`project-doc-site: locale home source ${JSON.stringify(page.source)} must start with YAML frontmatter.`)
}
const closingDelimiter = '\n---\n'
const closing = markdown.indexOf(closingDelimiter, 4)
if (closing === -1) {
throw new Error(`project-doc-site: locale home source ${JSON.stringify(page.source)} has unclosed YAML frontmatter.`)
}
return markdown.slice(0, closing + closingDelimiter.length)
}
/** Canonical Markdown files watched by the local VitePress dev server. */
export function docsSourceFiles(): string[] {
return [...new Set(docsPages.map(page => resolve(root, page.source)))]
}
/** Rebuild the disposable VitePress source tree from the publication manifest. */
export function projectDocs(): void {
const routes = new Set<string>()
const repositoryRef = process.env.GITHUB_SHA ?? 'master'
rmSync(generatedRoot, { recursive: true, force: true })
for (const page of docsPages) {
if (routes.has(page.route)) throw new Error(`project-doc-site: duplicate route ${JSON.stringify(page.route)}.`)
routes.add(page.route)
const sourceAbs = resolve(root, page.source)
if (!existsSync(sourceAbs) || !lstatSync(sourceAbs).isFile()) {
throw new Error(`project-doc-site: source ${JSON.stringify(page.source)} does not exist or is not a file.`)
}
const output = resolve(generatedRoot, page.route)
mkdirSync(dirname(output), { recursive: true })
const markdown = readFileSync(sourceAbs, 'utf8')
const projected = rewriteMarkdown(markdown, {
sourcePath: page.source,
locale: page.locale,
route: page.route,
pages: docsPages,
repoRoot: root,
repositoryRef,
})
writeFileSync(output, addProjectionFrontmatter(projectedPageContent(projected, page), page.source))
}
}

View File

@@ -5,9 +5,8 @@
* independent commands can overlap and which commands wait for built artifacts.
*/
import { spawn } from 'node:child_process'
import { readdir, rm } from 'node:fs/promises'
import { availableParallelism } from 'node:os'
import { join, resolve } from 'node:path'
import { resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
type Mode =
@@ -181,6 +180,11 @@ function gatesForMode(selected: Mode): Gate[] {
'run',
'packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts',
], { label: 'source worker smoke' }),
pnpmExec('jsonl-zstd-smoke', [
'vitest',
'run',
'packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts',
], { label: 'JSONL Zstandard smoke' }),
]
case 'pre-push':
return [
@@ -210,11 +214,9 @@ function ciPrimaryGates(): Gate[] {
pnpmScript('duplication', 'duplication'),
coverageGate(),
snapshotGate(),
demoSmokeGate({ needs: ['lint'] }),
...docSyncLeafGates(),
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
pnpmScript('knip', 'knip'),
pnpmScript('website-build', 'website:build', { label: 'website build' }),
pnpmScript('build', 'build', { needs: ['typecheck'] }),
pnpmScript('publint', 'publint', { needs: ['build'] }),
pnpmScript('node-next-types', 'verify-node-next-types', {
@@ -230,19 +232,12 @@ function ciStaticGates(): Gate[] {
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
pnpmScript('constraints', 'constraints'),
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
...staticDemoSmokeGates(),
...docSyncLeafGates(),
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
pnpmScript('knip', 'knip'),
pnpmScript('website-build', 'website:build', { label: 'website build' }),
]
}
function staticDemoSmokeGates(): Gate[] {
// Native Windows session persistence is outside the gates-only support scope.
return process.platform === 'win32' ? [] : [demoSmokeGate()]
}
function ciArtifactGates(): Gate[] {
return [
pnpmScript('build', 'build'),
@@ -337,7 +332,6 @@ function docSyncLeafGates(options: {
pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }),
pnpmScript('doc-graphs', 'verify-doc-graphs', { label: 'doc graphs' }),
pnpmScript('scoped-events', 'verify-scoped-events', { label: 'scoped events' }),
pnpmScript('website-api', 'verify-website-api', { label: 'website api' }),
pnpmScript('markdown-wrap', 'verify-md-wrap', { label: 'markdown wrap' }),
pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }),
pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }),
@@ -350,55 +344,20 @@ function docSyncLeafGates(options: {
pnpmScript('translation-prompt', 'verify-translation-prompt', { label: 'translation prompt' }),
pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }),
pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }),
// Keep the VitePress build in this single gate because projection rewrites website/.generated.
pnpmScript('docs-site', 'docs:check', { label: 'documentation site' }),
pnpmScript('package-readme-limitations', 'verify-package-readme-limitations', { label: 'package README limitations' }),
pnpmScript('website-yaml', 'verify-website-yaml', { label: 'website yaml' }),
]
}
function demoSmokeGate(options: { needs?: string[] } = {}): Gate {
const dependencyOptions = options.needs === undefined ? {} : { needs: options.needs }
return {
id: 'demo-smoke',
label: 'demo smoke',
displayCommand: 'pnpm run demo:echo',
...pnpmInvocation(['run', 'demo:echo']),
input: 'echo ci smoke\n',
...dependencyOptions,
verify: async (result) => {
const output = result.stdout + result.stderr
const sessionsRoot = join(root, '.sessions')
try {
if (!output.includes('[tool call] echo({"text":"ci smoke"})')) {
throw new Error('demo smoke did not show the echo tool call.')
}
if (!output.includes('[tool result] ECHO: CI SMOKE')) {
throw new Error('demo smoke did not show the echo tool result.')
}
const buckets = await readdir(sessionsRoot, { withFileTypes: true })
let found = false
for (const bucket of buckets) {
if (!bucket.isDirectory() || !bucket.name.startsWith('cwd-')) continue
const entries = await readdir(join(sessionsRoot, bucket.name))
if (entries.some(entry => /^main-session-.+\.jsonl$/.test(entry))) {
found = true
break
}
}
if (!found) throw new Error('demo smoke did not create a main-session JSONL log in a cwd bucket.')
} finally {
await rm(sessionsRoot, { recursive: true, force: true })
}
},
}
}
function builtBinSmokeGate(): Gate {
return pnpmExec('built-bin-smoke', [
'vitest',
'run',
'--config',
'vitest.e2e.config.ts',
'packages/examples/stdio-demo/tests/built-bin.e2e.ts',
'examples/headless-agent/tests/keyless-smoke.e2e.ts',
'examples/tui-agent/tests/tui-keyless-smoke.e2e.ts',
'packages/examples/cli-demo/tests/built-bin.e2e.ts',
'packages/examples/acp-demo/tests/built-bin.e2e.ts',
'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts',
@@ -410,6 +369,7 @@ function builtBinSmokeGate(): Gate {
], {
label: 'built-bin smoke',
needs: ['build'],
env: { DSH_EXAMPLE_MODE: 'lib' },
})
}

View File

@@ -64,6 +64,7 @@ CUSTOM_CORDIS = """\
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
root: !!js process.env.DSH_SESSION_ROOT
compression: 'none'
- id: bash
name: '@deepseek-ai/dsh-bash-local'
config:
@@ -391,7 +392,7 @@ def smoke_sdk_default(base_url: str) -> None:
result = harness.run("reply with the smoke text", session_id="default-smoke")
assert result.status == "ok", result
assert result.final_response == EXPECTED_TEXT, result.final_response
assert_session_log(sessions, root, EXPECTED_TEXT)
assert_zstd_session_log(sessions)
def smoke_sdk_custom(base_url: str, executable: Path) -> None:
@@ -585,6 +586,14 @@ def assert_session_log(sessions: Path, cwd: Path, *expected_texts: str) -> None:
raise AssertionError(f"session log has no {expected!r} response: {logs[0]}")
def assert_zstd_session_log(sessions: Path) -> None:
logs = list(sessions.rglob("*.jsonl.zstd"))
if len(logs) != 1:
raise AssertionError(f"expected one Zstandard JSONL session log under {sessions}, found {logs}")
if not logs[0].read_bytes().startswith(bytes.fromhex("28b52ffd")):
raise AssertionError(f"session log has no Zstandard magic: {logs[0]}")
def read_session_logs(sessions: Path) -> dict[str, list[dict[str, object]]]:
"""Parse every persisted JSONL session into a map keyed by header id."""
logs: dict[str, list[dict[str, object]]] = {}

View File

@@ -11,6 +11,18 @@
"docs/development.md",
"docs/i18n/README.md",
"docs/i18n/translation-rules.md",
"docs/user/develop/basic/config.md",
"docs/user/develop/basic/index.md",
"docs/user/develop/basic/tool.md",
"docs/user/develop/framework/events.md",
"docs/user/develop/framework/index.md",
"docs/user/develop/framework/service.md",
"docs/user/develop/practice/index.md",
"docs/user/develop/practice/llm-adapter.md",
"docs/user/guide/config.md",
"docs/user/guide/index.md",
"docs/user/guide/quickstart.md",
"docs/user/index.md",
".agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md",
".agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md",
"python/README.md",

View File

@@ -28,11 +28,31 @@
{ "doc": "docs/core-data-structures/scope.md", "symbol": "Scoped", "source": "packages/core/scope/src/index.ts" },
{ "doc": "docs/core-data-structures/scope.md", "symbol": "Scope", "source": "packages/core/scope/src/index.ts" },
{ "doc": "docs/core-data-structures/goal.md", "symbol": "GoalRef", "source": "packages/goal/goal/src/types.ts" },
{ "doc": "docs/core-data-structures/goal.md", "symbol": "GoalPhase", "source": "packages/goal/goal/src/types.ts" },
{ "doc": "docs/core-data-structures/goal.md", "symbol": "GoalBlockReason", "source": "packages/goal/goal/src/types.ts" },
{ "doc": "docs/core-data-structures/goal.md", "symbol": "GoalSnapshot", "source": "packages/goal/goal/src/types.ts" },
{ "doc": "docs/core-data-structures/goal.md", "symbol": "GoalView", "source": "packages/goal/goal/src/types.ts" },
{ "doc": "docs/core-data-structures/goal.md", "symbol": "GoalSnapshotChangeMeta", "source": "packages/goal/goal/src/types.ts" },
{ "doc": "docs/core-data-structures/goal.md", "symbol": "GoalClearChangeMeta", "source": "packages/goal/goal/src/types.ts" },
{ "doc": "docs/core-data-structures/goal.md", "symbol": "GoalMessageSource", "source": "packages/goal/goal/src/types.ts" },
{ "doc": "docs/core-data-structures/goal.md", "symbol": "CreateGoalRequest", "source": "packages/goal/goal/src/types.ts" },
{ "doc": "docs/core-data-structures/goal.md", "symbol": "EditGoalRequest", "source": "packages/goal/goal/src/types.ts" },
{ "doc": "docs/core-data-structures/goal.md", "symbol": "GoalChanged", "source": "packages/goal/goal/src/types.ts" },
{ "doc": "docs/core-data-structures/commands.md", "symbol": "CommandInputDescriptor", "source": "packages/ui/commands/src/index.ts" },
{ "doc": "docs/core-data-structures/commands.md", "symbol": "CommandDefinition", "source": "packages/ui/commands/src/index.ts" },
{ "doc": "docs/core-data-structures/commands.md", "symbol": "CommandInvocation", "source": "packages/ui/commands/src/index.ts" },
{ "doc": "docs/core-data-structures/commands.md", "symbol": "CommandResult", "source": "packages/ui/commands/src/index.ts" },
{ "doc": "docs/core-data-structures/commands.md", "symbol": "CommandDescriptor", "source": "packages/ui/commands/src/index.ts" },
{ "doc": "docs/core-data-structures/commands.md", "symbol": "ParsedCommand", "source": "packages/ui/commands/src/index.ts" },
{ "doc": "docs/core-data-structures/system-prompt.md", "symbol": "AssembleContext", "source": "packages/core/system-prompt/src/index.ts" },
{ "doc": "docs/core-data-structures/system-prompt.md", "symbol": "PromptSection", "source": "packages/core/system-prompt/src/index.ts" },
{ "doc": "docs/core-data-structures/system-prompt.md", "symbol": "ToolProviderResult", "source": "packages/core/system-prompt/src/index.ts" },
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "LlmFailure", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "AppIdentity", "source": "packages/llm/llm/src/attribution.ts" },
@@ -42,7 +62,6 @@
{ "doc": "docs/core-data-structures/token-meter.md", "symbol": "TokenMeasurement", "source": "packages/llm/token-meter/src/types.ts" },
{ "doc": "docs/core-data-structures/token-meter.md", "symbol": "TokenSurfaceNode", "source": "packages/llm/token-meter/src/types.ts" },
{ "doc": "docs/core-data-structures/session.md", "symbol": "ContextEnvelope", "source": "packages/core/session/src/types.ts" },
{ "doc": "docs/core-data-structures/session.md", "symbol": "SessionEventMap", "source": "packages/core/session/src/types.ts" },
{ "doc": "docs/core-data-structures/session.md", "symbol": "EpochHeader", "source": "packages/core/session/src/types.ts" },
{ "doc": "docs/core-data-structures/session.md", "symbol": "TodoItem", "source": "packages/core/session/src/types.ts" },
@@ -159,6 +178,8 @@
{ "doc": "docs/core-data-structures/compaction.md", "symbol": "CompactionResult", "source": "packages/compact/compact/src/types.ts" },
{ "doc": "docs/core-data-structures/compaction.md", "symbol": "CompactionTrigger", "source": "packages/compact/compact/src/index.ts" },
{ "doc": "docs/core-data-structures/compaction.md", "symbol": "PrunedEntry", "source": "packages/compact/compact-tool-result-prune/src/types.ts" },
{ "doc": "docs/core-data-structures/compaction.md", "symbol": "PruneResult", "source": "packages/compact/compact-tool-result-prune/src/types.ts" },
{ "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentCapabilities", "source": "packages/subagent/subagent/src/types.ts" },
{ "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentStartRequest", "source": "packages/subagent/subagent/src/types.ts" },

View File

@@ -2,7 +2,8 @@
* Reject Markdown prose paragraphs spanning multiple physical lines. The GFM
* AST distinguishes paragraphs—including those in lists and blockquotes—from
* multiline structural nodes. The checker never rewrites; symlinked instruction
* files are deduped. The owning convention is in `docs/AGENTS.md`.
* files are deduped. VitePress frontmatter and custom-container delimiters are
* masked before parsing. The owning convention is in `docs/AGENTS.md`.
*/
import { readFileSync } from 'node:fs'
@@ -35,11 +36,23 @@ interface Violation {
text: string
}
function maskVitePressStructure(source: string): string {
const lines = source.split('\n')
if (lines[0] === '---') {
const closing = lines.indexOf('---', 1)
if (closing !== -1) {
for (let index = 0; index <= closing; index++) lines[index] = ''
}
}
return lines.map(line => line.trimStart().startsWith(':::') ? '' : line).join('\n')
}
/** Find every hard-wrapped prose paragraph in one Markdown file via its AST. */
function findViolations(absPath: string): Violation[] {
const file = relative(root, absPath)
const source = readFileSync(absPath, 'utf8')
const tree = parseMarkdown(source)
const parsedSource = maskVitePressStructure(source)
const tree = parseMarkdown(parsedSource)
const out: Violation[] = []
visitMarkdown(tree, (node: Nodes): boolean | void => {

View File

@@ -48,10 +48,12 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' },
'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' },
'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
'packages/fs/fs-sandbox': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' },
'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' },
'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' },
'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' },
'packages/sandbox/sandbox-policy': { kind: 'indirect', reason: 'The policy service holds the mode dsh-tool-bash and dsh-tool-fs render in their denial markers.' },
'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' },
'packages/sdk/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' },
'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' },

View File

@@ -14,7 +14,7 @@ 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', 'website/zh-CN/**/*.md']
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 {

View File

@@ -1,269 +0,0 @@
/**
* Doc-sync gate: verify the fenced ```yaml examples in the website against
* the loader and the workspace truth. A `cordis.yml` example that names a
* plugin that does not exist, or passes a config key the plugin never
* declared, is worse than no example — it fails silently for the reader.
*
* Scope: `website/zh-CN/**/*.md`, EXCLUDING `website/zh-CN/api/**` (the api
* pages are generator-owned — their yaml examples are verified at generation
* time by a later stream, not re-checked here). Blocks opt out with
* ` ```yaml ignore-check ` (same philosophy as doc-typecheck's opt-out: the
* count is reported, an unchecked block is a visible decision, not a silent
* hole — placeholder plugin names in tutorials are the legitimate case).
*
* Each checked block is parsed with the loader's REAL schema —
* `JSON_SCHEMA` extended with the `!!js` scalar type exactly as
* vendor/include/src/index.ts declares it — so `!!js process.env.X` parses
* here iff it parses at runtime. Then:
*
* - Root is an ARRAY → a cordis.yml entry list. Every item must be a mapping
* with a string `name` and only the keys `EntryOptions` declares
* (vendor/loader/src/config/entry.ts plus the isolate.ts merge:
* id, name, config, group, disabled, inject, intercept, isolate).
* - `./` / `../` names are illustrative local plugins — existence is not
* checkable, skip. `group:*` names are loader built-ins; their `config`
* is itself an entry list and is recursed into.
* - Any other name must be a real workspace package (`packages/*/*` and
* `vendor/*` package.json names).
* - For `@deepseek-ai/dsh-*` names the config-catalog generator is the
* truth: kind `config` → the yaml `config`'s top-level keys must be
* properties of the declared config type (member names of the first
* catalog paste top-level segments of the runtime schema keys);
* config-free kinds → a non-empty `config` mapping is a violation;
* seam/library kinds → name existence only (loading one directly is
* dubious, but that is a docs-prose concern, not this gate's).
* - Root is a MAPPING or scalar → a fragment (e.g. a bare `config:` excerpt):
* syntax check only.
*
* This is a checker, not a fixer: it reports `file:line message` and exits 1.
*
* Run: `tsx scripts/verify-website-yaml.ts`.
*/
import { globSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import * as yaml from 'js-yaml'
import ts from 'typescript'
import { collectConfigCatalog, type CatalogEntry } from './gen-config-catalog.ts'
import { extractFences } from './md-fences.ts'
const root = resolve(import.meta.dirname, '..')
/** Mirror of the loader's yaml schema (vendor/include/src/index.ts): the
* `!!js` tag parses to an expression wrapper, everything else is JSON. */
const JsExpr = new yaml.Type('tag:yaml.org,2002:js', {
kind: 'scalar',
resolve: data => typeof data === 'string',
construct: (data: string) => ({ __jsExpr: data }),
})
const schema = yaml.JSON_SCHEMA.extend(JsExpr)
/** The exact key set an entry mapping may carry: `EntryOptions` in
* vendor/loader/src/config/entry.ts plus the isolate.ts interface merge. */
const ENTRY_KEYS = ['id', 'name', 'config', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const
/** One `file:line message` finding. */
interface Violation {
file: string
/** 1-based line of the block's opening fence. */
line: number
message: string
}
/** One extracted ```yaml block. */
interface Block {
file: string
/** 1-based line of the opening fence. */
line: number
kind: 'check' | 'ignore'
code: string
}
/** Extract every ```yaml / ```yaml ignore-check block from one Markdown file. */
function extractBlocks(file: string): Block[] {
return extractFences(resolve(root, file), info =>
info === 'yaml' ? 'check' : info === 'yaml ignore-check' ? 'ignore' : null)
.map(f => ({ file, line: f.line, kind: f.kind, code: f.code }))
}
/** Every workspace package name: `packages/<group>/<pkg>` and `vendor/<pkg>`. */
function knownPackages(): Set<string> {
const names = new Set<string>()
for (const pattern of ['packages/*/*/package.json', 'vendor/*/package.json']) {
for (const match of globSync(pattern, { cwd: root })) {
const pkg: unknown = JSON.parse(readFileSync(resolve(root, match), 'utf8'))
if (typeof pkg === 'object' && pkg !== null && 'name' in pkg && typeof pkg.name === 'string') {
names.add(pkg.name)
}
}
}
return names
}
/** The catalog, built once on first `@deepseek-ai/dsh-*` name, keyed by pkg. */
let catalogByPkg: Map<string, CatalogEntry> | null = null
function catalogFor(pkg: string): CatalogEntry | undefined {
catalogByPkg ??= new Map(collectConfigCatalog().map(e => [e.pkg, e]))
return catalogByPkg.get(pkg)
}
/** Top-level property names of the first catalog paste (the verbatim config
* type declaration), parsed as source text. */
function pasteKeys(paste: string): Set<string> {
const sf = ts.createSourceFile('paste.ts', paste, ts.ScriptTarget.Latest, true)
const keys = new Set<string>()
const addMembers = (members: ts.NodeArray<ts.TypeElement>): void => {
for (const m of members) {
if (ts.isPropertySignature(m) || ts.isMethodSignature(m)) {
const name = m.name
keys.add(ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : name.getText(sf))
}
}
}
for (const stmt of sf.statements) {
if (ts.isInterfaceDeclaration(stmt)) addMembers(stmt.members)
else if (ts.isTypeAliasDeclaration(stmt) && ts.isTypeLiteralNode(stmt.type)) addMembers(stmt.type.members)
}
return keys
}
/** The allowed top-level config keys of a kind-`config` catalog entry: the
* first paste's member names the schema keys' top-level segments
* (`agents[].id` → `agents`). Cached per entry. */
const allowedKeysCache = new Map<string, Set<string>>()
function allowedConfigKeys(entry: CatalogEntry): Set<string> {
const cached = allowedKeysCache.get(entry.pkg)
if (cached) return cached
const keys = pasteKeys(entry.pastes?.[0]?.text ?? '')
for (const path of entry.schemaKeys ?? []) {
const top = path.split('.')[0]?.replace(/\[\]$/, '')
if (top) keys.add(top)
}
allowedKeysCache.set(entry.pkg, keys)
return keys
}
/** A parsed yaml mapping (arrays and `!!js` wrappers excluded). */
function asMapping(value: unknown): Record<string, unknown> | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
if ('__jsExpr' in value) return null
return value as Record<string, unknown>
}
/** Check one cordis.yml entry list (recursing into `group:` sub-lists). */
function checkEntryList(
items: unknown[],
known: Set<string>,
block: Block,
violations: Violation[],
): void {
const flag = (message: string): void => {
violations.push({ file: block.file, line: block.line, message })
}
items.forEach((item, index) => {
const at = `entry ${index + 1}`
const entry = asMapping(item)
if (!entry) {
flag(`${at}: not a mapping`)
return
}
const name = entry['name']
if (typeof name !== 'string') {
flag(`${at}: missing string \`name\``)
return
}
for (const key of Object.keys(entry)) {
if (!(ENTRY_KEYS as readonly string[]).includes(key)) {
flag(`${at} (${name}): unknown entry key \`${key}\` (EntryOptions allows: ${[...ENTRY_KEYS].join(', ')})`)
}
}
// Illustrative local plugin — nothing on disk to check against.
if (name.startsWith('./') || name.startsWith('../')) return
// A `group:`-style pseudo-name is NOT loadable: tree.import() only
// special-cases the `cordis:` prefix, and nothing in this repo registers
// loader builtins — reject it and point at the real group plugin.
if (name.startsWith('group:')) {
flag(`${at}: \`${name}\` is not loadable (no loader builtin is registered); use \`@cordisjs/plugin-group\` with \`group: true\``)
return
}
// The vendored group plugin: its config is a nested entry list.
if (name === '@cordisjs/plugin-group') {
if (Array.isArray(entry['config'])) checkEntryList(entry['config'], known, block, violations)
return
}
if (!known.has(name)) {
flag(`${at}: unknown plugin \`${name}\` (not a workspace package)`)
return
}
if (!name.startsWith('@deepseek-ai/dsh-')) return
const catalog = catalogFor(name)
if (!catalog) return
const config = asMapping(entry['config'])
if (catalog.kind === 'config') {
if (!config) return
const allowed = allowedConfigKeys(catalog)
for (const key of Object.keys(config)) {
if (!allowed.has(key)) {
flag(`${at}: \`${name}\` has no config key \`${key}\` (known keys: ${[...allowed].sort().join(', ')})`)
}
}
} else if (catalog.kind === 'no-config') {
if (config && Object.keys(config).length > 0) {
flag(`${at}: \`${name}\` declares no config, but the example passes one`)
}
}
// seam / library: loading one directly is dubious, but that is a prose
// concern — this gate only vouches for name existence.
})
}
const files = globSync('website/zh-CN/**/*.md', { cwd: root })
.filter(f => !f.startsWith('website/zh-CN/api/'))
.sort()
const violations: Violation[] = []
const known = knownPackages()
let entryLists = 0
let fragments = 0
let ignored = 0
let scanned = 0
for (const file of files) {
for (const block of extractBlocks(file)) {
scanned++
if (block.kind === 'ignore') {
ignored++
continue
}
let parsed: unknown
try {
parsed = yaml.load(block.code, { schema })
} catch (error) {
const message = error instanceof Error ? error.message.split('\n')[0] ?? 'parse error' : String(error)
violations.push({ file: block.file, line: block.line, message: `yaml parse error: ${message}` })
continue
}
if (Array.isArray(parsed)) {
entryLists++
checkEntryList(parsed, known, block, violations)
} else {
// Mapping or scalar root: a fragment (e.g. a bare `config:` excerpt) —
// syntax is all there is to check.
fragments++
}
}
}
if (violations.length === 0) {
console.log(
`verify-website-yaml: ${scanned} yaml block(s) in ${files.length} file(s): `
+ `${entryLists} entry list(s) + ${fragments} fragment(s) checked, ${ignored} ignore-check skipped.`,
)
process.exit(0)
}
console.error('verify-website-yaml: invalid yaml examples found:')
for (const v of violations) {
console.error(` ${v.file}:${v.line} ${v.message}`)
}
process.exit(1)