Merge branch 'worktree/agent-execution-context-rfc' into worktree/explicit-turn-signal

This commit is contained in:
Yichen Jiang
2026-07-18 21:33:17 +08:00
683 changed files with 37770 additions and 8712 deletions
+99
View File
@@ -0,0 +1,99 @@
/**
* 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 declarations.
* One walk, two renderers — the catalog and the website page carry different
* prose but must agree on WHAT exists.
*/
import ts from 'typescript'
import { parseJsDoc, pointer, rawJsDoc } from './jsdoc.ts'
/** The body of the cordis module merge in `sf`: `declare module 'cordis'`
* (harness packages) or `declare module './context.ts'` (vendor core), or
* null when the file has neither. */
export function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
for (const stmt of sf.statements) {
if (!ts.isModuleDeclaration(stmt) || !ts.isStringLiteral(stmt.name)) continue
if (stmt.name.text !== 'cordis' && stmt.name.text !== './context.ts') continue
if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body
}
return null
}
/** Every `interface Events` method member of a cordis module merge, with the
* event name resolved from its (possibly string-literal) property name. */
export function eventMembers(body: ts.ModuleBlock, sf: ts.SourceFile): { name: string; member: ts.MethodSignature }[] {
const out: { name: string; member: ts.MethodSignature }[] = []
for (const stmt of body.statements) {
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue
for (const member of stmt.members) {
if (!ts.isMethodSignature(member)) continue
const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf)
out.push({ name, member })
}
}
return out
}
/** The `ctx.<key> → type name` map declared by a merge's `interface Context`. */
function contextKeyMap(body: ts.ModuleBlock, sf: ts.SourceFile): Map<string, string> {
const keyToType = new Map<string, string>()
for (const stmt of body.statements) {
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue
for (const member of stmt.members) {
if (!ts.isPropertySignature(member) || !member.type) continue
keyToType.set(member.name.getText(sf), member.type.getText(sf))
}
}
return keyToType
}
/** One `ctx.<key>` service declaration resolved from a Context merge. */
export interface ServiceDeclaration {
key: string
type: string
declaration: ts.ClassDeclaration | ts.InterfaceDeclaration
abstract: boolean
/** Declaration-level JSDoc prose (empty string when missing — also reported). */
doc: string
}
/**
* Resolve each `ctx.<key>` of a merge to the service class or interface declared in the
* same file. A key whose type is not a class here (a Pick-mixin member, e.g.
* timer helpers) is skipped. A declaration without JSDoc prose is reported into
* `violations` (named `where` by the caller's gate).
*
* @param body — the cordis module merge body.
* @param sf — the source file containing the merge.
* @param rel — repo-relative path of `sf`, for violation pointers.
* @param violations — sink for JSDoc-completeness violations.
* @returns the resolved service declarations, in Context-declaration order.
*/
export function serviceDeclarations(
body: ts.ModuleBlock,
sf: ts.SourceFile,
rel: string,
violations: string[],
): ServiceDeclaration[] {
const text = sf.getFullText()
const out: ServiceDeclaration[] = []
for (const [key, type] of contextKeyMap(body, sf)) {
const declaration = sf.statements.find(
(s): s is ts.ClassDeclaration | ts.InterfaceDeclaration =>
(ts.isClassDeclaration(s) || ts.isInterfaceDeclaration(s)) && s.name?.text === type,
)
if (!declaration) continue // a Pick-mixin member, not a service declaration here
const abstract = ts.isInterfaceDeclaration(declaration)
|| (declaration.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false)
const doc = parseJsDoc(rawJsDoc(text, declaration)).doc
if (!doc) {
const kind = ts.isInterfaceDeclaration(declaration) ? 'interface' : 'class'
violations.push(`service ctx.${key} (${pointer(rel, sf, declaration)}): ${kind} ${type} has no JSDoc.`)
}
out.push({ key, type, declaration, abstract, doc })
}
return out
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"AGENTS.md": 1370,
"AGENTS.md": 1500,
"docs/AGENTS.md": 1100,
"docs/architecture.md": 1790,
"docs/cordis-primer.md": 600,
+160 -84
View File
@@ -1,13 +1,14 @@
/**
* Typecheck Markdown `ts` fences against workspace sources. `ignore-check`
* fences are reported as opt-outs; generated catalog fragments and
* `type-equiv` blocks are skipped here because their owning gates verify them.
* Typecheck Markdown `ts` fences against the workspace API. `ignore-check` fences are reported as
* opt-outs; generated catalog fragments and `type-equiv` blocks are skipped here because their
* owning gates verify them. A build-coordinated mode consumes existing declarations without emit.
*/
import { execFileSync } from 'node:child_process'
import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join, relative, resolve } from 'node:path'
import ts from 'typescript'
import { extractFences } from './md-fences.ts'
const root = resolve(import.meta.dirname, '..')
@@ -27,60 +28,123 @@ interface Block {
code: string
}
/** The info-string → kind table this gate tracks. */
const KIND_BY_INFO: Record<string, BlockKind> = {
'ts': 'check',
'ts ignore-check': 'ignore',
'ts type-equiv': 'type-equiv',
'ts cordis-catalog': 'cordis-catalog',
'ts persistence-catalog': 'persistence-catalog',
'ts config-catalog': 'config-catalog',
}
/** Extract every recognized TypeScript fence from one Markdown file. */
function extractBlocks(absPath: string): Block[] {
const text = readFileSync(absPath, 'utf8')
const lines = text.split('\n')
const file = relative(root, absPath)
const blocks: Block[] = []
let open: { line: number; kind: BlockKind; body: string[] } | null = null
return extractFences(absPath, info => KIND_BY_INFO[info] ?? null)
.map(f => ({ file, line: f.line, kind: f.kind, code: f.code }))
}
lines.forEach((raw, i) => {
const fence = /^```(\s*)(\S.*)?$/.exec(raw)
if (!fence) {
if (open) open.body.push(raw)
return
}
if (open) {
// closing fence
blocks.push({ file, line: open.line, kind: open.kind, code: open.body.join('\n') })
open = null
return
}
// Ignore non-TypeScript fences.
const info = (fence[2] ?? '').trim()
const kind: BlockKind | null =
info === 'ts' ? 'check'
: info === 'ts ignore-check' ? 'ignore'
: info === 'ts type-equiv' ? 'type-equiv'
: info === 'ts cordis-catalog' ? 'cordis-catalog'
: info === 'ts persistence-catalog' ? 'persistence-catalog'
: info === 'ts config-catalog' ? 'config-catalog'
: null
if (kind) open = { line: i + 1, kind, body: [] }
const configHost: ts.ParseConfigFileHost = {
...ts.sys,
getCurrentDirectory: () => root,
onUnRecoverableConfigFileDiagnostic(diagnostic) {
throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
},
}
/** Load root settings and redirect workspace aliases to declarations from the coordinated build. */
function builtTypeCompilerOptions(): ts.CompilerOptions {
const configPath = join(root, 'tsconfig.json')
const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, configHost)
if (!parsed) throw new Error(`doc-typecheck: cannot parse ${configPath}`)
if (parsed.errors.length > 0) {
throw new Error(parsed.errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n'))
}
if (parsed.options.paths === undefined) throw new Error('doc-typecheck: root tsconfig has no workspace paths')
const paths = Object.fromEntries(Object.entries(parsed.options.paths).map(([specifier, candidates]) => [
specifier,
candidates.map((candidate) => {
if (!candidate.endsWith('/src')) {
throw new Error(`doc-typecheck: cannot map workspace source path to built declarations: ${candidate}`)
}
return `${candidate.slice(0, -'/src'.length)}/lib/types`
}),
]))
const options: ts.CompilerOptions = {
...parsed.options,
paths,
noEmit: true,
composite: false,
incremental: false,
declaration: false,
declarationMap: false,
sourceMap: false,
noUnusedLocals: false,
noUnusedParameters: false,
}
delete options.tsBuildInfoFile
return options
}
/** Compile Markdown blocks as virtual files against declarations from the coordinated build. */
function compileBlocksAgainstBuiltTypes(blocks: Block[]): readonly ts.Diagnostic[] {
const options = builtTypeCompilerOptions()
const sources = new Map<string, string>()
for (const [index, block] of blocks.entries()) {
const fileName = resolve(root, '.doc-typecheck', `block-${index}.ts`)
sources.set(fileName, block.code.endsWith('\n') ? block.code : `${block.code}\n`)
}
const baseHost = ts.createCompilerHost(options, true)
const host: ts.CompilerHost = {
...baseHost,
fileExists(fileName) {
return sources.has(resolve(fileName)) || baseHost.fileExists(fileName)
},
readFile(fileName) {
return sources.get(resolve(fileName)) ?? baseHost.readFile(fileName)
},
getSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile) {
const source = sources.get(resolve(fileName))
if (source !== undefined) return ts.createSourceFile(fileName, source, languageVersion, true)
return baseHost.getSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile)
},
writeFile() {
throw new Error('doc-typecheck: noEmit compilation attempted to write output')
},
}
const program = ts.createProgram([...sources.keys()], options, host)
return ts.getPreEmitDiagnostics(program)
}
/** Render compiler diagnostics with virtual block paths mapped back to Markdown. */
function formatDiagnostics(diagnostics: readonly ts.Diagnostic[], blocks: Block[]): string {
const formatted = ts.formatDiagnostics(diagnostics, {
getCanonicalFileName: fileName => fileName,
getCurrentDirectory: () => root,
getNewLine: () => ts.sys.newLine,
})
return blocks
return remapBlockPaths(formatted, blocks)
}
/** Reuse the repo typecheck graph references from a temp project one directory below root. */
function workspaceReferences(): { path: string }[] {
const file = join(root, 'tsconfig.json')
// Parse with TypeScript's own JSONC reader, not a hand-rolled comment strip:
// a regex strip mistakes the `/*/` in a wildcard path candidate
// (`./packages/core/*/src`) for a block comment and corrupts the map.
const result = ts.readConfigFile(file, p => readFileSync(p, 'utf8'))
// Parse with TypeScript's own JSONC reader: a regex comment stripper corrupts the `/*/` path
// candidate in the workspace wildcard.
const result = ts.readConfigFile(file, path => readFileSync(path, 'utf8'))
if (result.error) {
throw new Error(`doc-typecheck: cannot read ${file}: ${ts.flattenDiagnosticMessageText(result.error.messageText, '\n')}`)
}
// `config` is typed `any` by the TS API; narrow it to the one field we read.
const { references } = result.config as { compilerOptions: { paths: Record<string, string[]> }; references: { path: string }[] }
return references.map(({ path }) => {
const relativeToTemp = path.startsWith('./') ? `../${path.slice(2)}` : `../${path}`
return { path: relativeToTemp }
})
// `config` is typed `any` by the TS API; narrow it to the one field read here.
const { references } = result.config as { references: { path: string }[] }
return references.map(({ path }) => ({
path: path.startsWith('./') ? `../${path.slice(2)}` : `../${path}`,
}))
}
/** The standalone tsconfig for the temp typecheck project. */
/** The standalone temp project used when no coordinated build owns declaration freshness. */
function tempTsconfig(): string {
return JSON.stringify({
extends: '../tsconfig.json',
@@ -94,7 +158,40 @@ function tempTsconfig(): string {
})
}
const markdownGlobs = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md']
/** Compile blocks through project references for the standalone command. */
function compileBlocksStandalone(blocks: Block[]): string | undefined {
const tmp = mkdtempSync(join(root, '.doc-typecheck-'))
try {
writeFileSync(join(tmp, 'tsconfig.json'), tempTsconfig())
for (const [index, block] of blocks.entries()) {
writeFileSync(join(tmp, `block-${index}.ts`), block.code.endsWith('\n') ? block.code : `${block.code}\n`)
}
try {
// Invoke tsc's JS entry through Node instead of a platform-specific shell shim.
execFileSync(process.execPath, ['node_modules/typescript/bin/tsc', '-b', join(tmp, 'tsconfig.json')], {
cwd: root,
stdio: 'pipe',
})
return undefined
} catch (error: unknown) {
const failed = error as { stdout?: Buffer; stderr?: Buffer }
return remapBlockPaths(`${failed.stdout?.toString() ?? ''}${failed.stderr?.toString() ?? ''}`, blocks)
}
} finally {
rmSync(tmp, { recursive: true, force: true })
}
}
/** Map virtual or temporary block paths back to their owning Markdown fences. */
function remapBlockPaths(output: string, blocks: Block[]): string {
return output.replace(/(?:[^\s:()]*[/\\])?block-(\d+)\.ts\((\d+),(\d+)\)/g, (_match, index: string, line: string, column: string) => {
const block = blocks[Number(index)]
if (!block) return `block-${index}.ts(${line},${column})`
return `${block.file} (block at line ${block.line}, +${line}:${column})`
})
}
const markdownGlobs = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'website/zh-CN/**/*.md']
const files: string[] = []
for (const pattern of markdownGlobs) {
@@ -114,45 +211,24 @@ if (checked.length === 0) {
process.exit(0)
}
const tmp = mkdtempSync(join(root, '.doc-typecheck-'))
try {
writeFileSync(join(tmp, 'tsconfig.json'), tempTsconfig())
const fileForBlock = new Map<string, Block>()
checked.forEach((block, i) => {
const name = `block-${i}.ts`
writeFileSync(join(tmp, name), block.code.endsWith('\n') ? block.code : `${block.code}\n`)
fileForBlock.set(name, block)
})
try {
// tsc's JS entry via the current node, not the .bin shim: the extensionless
// shim is not spawnable on Windows (the CVE-2024-27980 class the sibling
// scripts hit), and the .cmd variant would need shell:true, which
// concatenates args UNESCAPED — a hazard for the temp project path. The JS
// entry behaves identically on every platform.
execFileSync(process.execPath, ['node_modules/typescript/bin/tsc', '-b', join(tmp, 'tsconfig.json')], { cwd: root, stdio: 'pipe' })
} catch (error: unknown) {
const failed = error as { stdout?: Buffer; stderr?: Buffer }
const out = `${failed.stdout?.toString() ?? ''}${failed.stderr?.toString() ?? ''}`
// Rewrite "block-N.ts(line,col)" to the real "file:fenceLine" for triage.
const remapped = out.replace(/(?:[^\s:()]*[/\\])?block-(\d+)\.ts\((\d+),(\d+)\)/g, (_m, idx: string, ln: string, col: string) => {
const block = fileForBlock.get(`block-${idx}.ts`)
if (!block) return `block-${idx}.ts(${ln},${col})`
return `${block.file} (block at line ${block.line}, +${ln}:${col})`
})
console.error('doc-typecheck: documentation code blocks failed to compile.\n')
console.error(remapped)
process.exit(1)
}
const ratio = ignored.length / ratioDenominator
const skipped = all.length - ratioDenominator
console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${skipped} type-equiv/catalog (checked elsewhere).`)
// Guard against the escape hatch becoming the norm.
if (ratioDenominator >= 4 && ratio > 0.5) {
console.error(`doc-typecheck: too many blocks opt out of checking (${ignored.length}/${ratioDenominator}). Make them compile or delete them.`)
process.exit(1)
}
} finally {
rmSync(tmp, { recursive: true, force: true })
const useBuiltTypes = process.env.DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT === '1'
const compilationError = useBuiltTypes
? (() => {
const diagnostics = compileBlocksAgainstBuiltTypes(checked)
return diagnostics.length === 0 ? undefined : formatDiagnostics(diagnostics, checked)
})()
: compileBlocksStandalone(checked)
if (compilationError !== undefined) {
console.error('doc-typecheck: documentation code blocks failed to compile.\n')
console.error(compilationError)
process.exit(1)
}
const ratio = ignored.length / ratioDenominator
const skipped = all.length - ratioDenominator
console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${skipped} type-equiv/catalog (checked elsewhere).`)
// Guard against the escape hatch becoming the norm.
if (ratioDenominator >= 4 && ratio > 0.5) {
console.error(`doc-typecheck: too many blocks opt out of checking (${ignored.length}/${ratioDenominator}). Make them compile or delete them.`)
process.exit(1)
}
+45 -79
View File
@@ -9,6 +9,7 @@ import { globSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve, sep } from 'node:path'
import ts from 'typescript'
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts'
import { cordisModuleBody, eventMembers, serviceDeclarations } from './cordis-walk.ts'
const root = resolve(import.meta.dirname, '..')
const OUT_EVENTS = 'docs/cordis-catalog/events.md'
@@ -104,15 +105,8 @@ interface InheritedEntry {
source: string
}
/** Find the `declare module 'cordis'` body in a source file, or null. */
function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
for (const stmt of sf.statements) {
if (ts.isModuleDeclaration(stmt) && ts.isStringLiteral(stmt.name) && stmt.name.text === 'cordis') {
if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body
}
}
return null
}
// cordisModuleBody / eventMembers / serviceClasses live in cordis-walk.ts,
// shared with gen-website-api.ts — one walk, two renderers.
/** The signature text of a method-signature member (everything but a body). */
function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.SourceFile): string {
@@ -136,46 +130,41 @@ export function collectEvents(scanRoot: string = root): EventEntry[] {
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
const body = cordisModuleBody(sf)
if (!body) continue
for (const stmt of body.statements) {
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue
for (const member of stmt.members) {
if (!ts.isMethodSignature(member)) continue
const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf)
const signature = memberSignature(member, sf)
const raw = rawJsDoc(text, member)
const { doc, mode } = parseJsDoc(raw)
const src = pointer(rel, sf, member)
const where = `event '${name}' (${src})`
if (!mode) {
violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
}
// Conclusive structural check: a trailing `next: () => …` parameter is a
// waterfall. (emit vs parallel vs serial is not structurally
// distinguishable, so it is trusted from the tag.)
const last = member.parameters.at(-1)
const hasNext = !!last && last.name.getText(sf) === 'next'
if (mode && hasNext && mode !== 'waterfall') {
violations.push(`${where} has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`)
}
if (mode && !hasNext && mode === 'waterfall') {
violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
}
if (!doc) violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`)
// Payload parameters need a non-empty @param. The `this` receiver is not
// payload, and a waterfall's trailing `next` is covered by its mode.
const { params } = parseTags(raw)
checkParams(where, 'event', member.parameters, params, sf,
p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations)
if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src })
for (const { name, member } of eventMembers(body, sf)) {
const signature = memberSignature(member, sf)
const raw = rawJsDoc(text, member)
const { doc, mode } = parseJsDoc(raw)
const src = pointer(rel, sf, member)
const where = `event '${name}' (${src})`
if (!mode) {
violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
}
// Conclusive structural check: a trailing `next: () => …` parameter is a
// waterfall. (emit vs parallel vs serial is not structurally
// distinguishable, so it is trusted from the tag.)
const last = member.parameters.at(-1)
const hasNext = !!last && last.name.getText(sf) === 'next'
if (mode && hasNext && mode !== 'waterfall') {
violations.push(`${where} has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`)
}
if (mode && !hasNext && mode === 'waterfall') {
violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
}
if (!doc) violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`)
// Payload parameters need a non-empty @param. The `this` receiver is not
// payload, and a waterfall's trailing `next` is covered by its mode.
const { params } = parseTags(raw)
checkParams(where, 'event', member.parameters, params, sf,
p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations)
if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src })
}
}
reportViolations('gen-cordis-catalog', violations)
return entries
}
/** Walk every harness `interface Context` block + its service declaration,
* hard-erroring (aggregated) on any JSDoc-completeness violation: a service or public
/** Walk every harness `interface Context` block + its service declaration, hard-
* erroring (aggregated) on any JSDoc-completeness violation: a declaration or public
* method without JSDoc prose, an undocumented parameter, a stale `@param`, a
* missing `@returns` on a non-void method, or an inferred (unannotated) return
* type the pure-AST walk cannot classify.
@@ -190,31 +179,8 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
const body = cordisModuleBody(sf)
if (!body) continue
// The ctx key → type mapping(s) declared in this file's interface Context.
const keyToType = new Map<string, string>()
for (const stmt of body.statements) {
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue
for (const member of stmt.members) {
if (!ts.isPropertySignature(member) || !member.type) continue
const key = member.name.getText(sf)
keyToType.set(key, member.type.getText(sf))
}
}
if (keyToType.size === 0) continue
// Find each service declaration in the same file and emit an entry.
for (const [key, type] of keyToType) {
const declaration = sf.statements.find(
(s): s is ts.ClassDeclaration | ts.InterfaceDeclaration =>
(ts.isClassDeclaration(s) || ts.isInterfaceDeclaration(s)) && s.name?.text === type,
)
if (!declaration) continue // a Pick-mixin member (e.g. timer helpers), not a declaration here
const abstract = ts.isInterfaceDeclaration(declaration)
|| (declaration.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false)
const declarationDoc = parseJsDoc(rawJsDoc(text, declaration)).doc
if (!declarationDoc) {
const kind = ts.isInterfaceDeclaration(declaration) ? 'interface' : 'class'
violations.push(`service ctx.${key} (${pointer(rel, sf, declaration)}): ${kind} ${type} has no JSDoc.`)
}
// Resolve each ctx key to its service declaration (shared walk) and emit an entry.
for (const { key, type, declaration, abstract, doc: declarationDoc } of serviceDeclarations(body, sf, rel, violations)) {
const methods: string[] = []
for (const member of declaration.members) {
if (!ts.isMethodDeclaration(member) && !ts.isMethodSignature(member)) continue
@@ -265,14 +231,14 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
* sibling check is N/A; keep them current on a vendor bump.
*/
const INHERITED_EVENTS: InheritedEntry[] = [
{ name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:197' },
{ name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:198' },
{ name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:199' },
{ name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:200' },
{ name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:201' },
{ name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:202' },
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:203' },
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:204' },
{ name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:328' },
{ name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:330' },
{ name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:332' },
{ name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:334' },
{ name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:336' },
{ name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:338' },
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:340' },
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:342' },
{ name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' },
{ name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' },
{ name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' },
@@ -283,12 +249,12 @@ const INHERITED_EVENTS: InheritedEntry[] = [
]
export const INHERITED_SERVICES: InheritedEntry[] = [
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:29' },
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:29' },
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:144' },
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:34' },
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:34' },
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:164' },
{ name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.', source: 'vendor/cordis/src/fiber.ts:9' },
{ name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.', source: 'vendor/cordis/src/reflect.ts:7' },
{ name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).', source: 'vendor/cordis/src/context.ts:35' },
{ name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).', source: 'vendor/cordis/src/context.ts:42' },
{ name: 'ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger', summary: 'Ambient handles onto the running context graph.', source: 'vendor/cordis/src/context.ts:16' },
{ name: 'ctx.timer (+ interval / timeout / throttle / debounce / setTimeout / setInterval)', summary: 'Disposable timer helpers. The `timer` key is provided at runtime; the six helpers are mixed onto ctx directly (declared via Pick).', source: 'vendor/timer/src/index.ts:4' },
{ name: 'ctx.loader', summary: 'The config Loader that booted the app (present under the loader).', source: 'vendor/loader/src/index.ts:30' },
+32 -5
View File
@@ -67,6 +67,7 @@ const GROUP_ORDER = [
'tasks',
'workflow',
'web',
'spill',
'todo',
'cordis',
'hooks',
@@ -86,6 +87,14 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['agent-loop', 'compact-basic'],
note: 'Adapters register provider implementations; the loop and compaction call the provider-neutral stream service.',
},
{
key: 'tokenMeter',
pkg: 'token-meter',
title: 'Replay token measurement',
mode: 'core',
consumers: ['compact-basic'],
note: 'Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements.',
},
{
key: 'sessions',
pkg: 'session',
@@ -100,15 +109,15 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'Durable session persistence seam',
mode: 'seam',
implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'],
consumers: ['agent-loop', 'acp', 'session-query'],
consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'acp', 'session-query'],
note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.',
},
{
key: 'sessionQuery',
pkg: 'session-query',
title: 'Exact session-history reads',
title: 'Exact session-history reads and traces',
mode: 'seam',
note: 'Resolves live and optional persisted logs into one logical corpus for exact reads.',
note: 'Resolves live and optional persisted logs into one logical corpus for exact reads and relationship traces.',
},
{
key: 'systemPrompt',
@@ -177,6 +186,13 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['tool-bash', 'hooks-claude', 'hooks-codex'],
note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them.',
},
{
key: 'bashEnv',
pkg: 'tool-bash',
title: 'Managed bash environment registry',
mode: 'core',
note: 'Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace.',
},
{
key: 'sandbox',
pkg: 'sandbox',
@@ -258,6 +274,15 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['tool-web'],
note: 'Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names.',
},
{
key: 'spillStore',
pkg: 'spill',
title: 'Spill storage seam',
mode: 'seam',
implementations: ['spill-local'],
consumers: ['spill-policy'],
note: 'The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill.',
},
{
key: 'workflows',
pkg: 'workflow',
@@ -841,6 +866,8 @@ function renderLifecycle(): string {
` Driver-->>SDK: ${mermaidCode('agent/status')} idle`,
'```',
'',
'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.',
'',
'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.',
'',
...maintenanceFooter(maintenance),
@@ -868,7 +895,7 @@ function renderToolPipeline(): string {
` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`,
` post["${mermaidCode('tools/post-execute')} waterfall<br/>accept, block, replace, add context"]`,
` final["${mermaidCode('tools/result')} synchronous notification<br/>frozen authoritative outcome"]`,
' context["Buffered additionalContext<br/>context/message after all tool results"]',
' context["Buffered additionalContexts<br/>context/message after all tool results"]',
` toolResult["Session event: ${mermaidCode('tool/result')}<br/>single model-facing outcome"]`,
' allResults["All calls in the step settled<br/>and tool/result events recorded"]',
' presentResult["UI completed card<br/>presentResult(args, result)"]',
@@ -896,7 +923,7 @@ function renderToolPipeline(): string {
' allResults --> context',
'```',
'',
'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`, while `tools/result` observes the immutable outcome after transforms, lossless-JSON validation, and outer error normalization. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContext` to preserve call/result adjacency.',
'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`, while `tools/result` observes the immutable outcome after transforms, lossless-JSON validation, and outer error normalization. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency.',
'',
...maintenanceFooter(maintenance),
].join('\n')
+1
View File
@@ -27,6 +27,7 @@ const GROUP_ORDER = [
'compact',
'subagent',
'web',
'spill',
'timeout',
'todo',
'cordis',
+19 -1
View File
@@ -27,6 +27,7 @@ import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
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 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'
@@ -132,7 +133,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
await ctx.plugin(ToolCordis)
},
note:
'Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes.',
'Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes.',
},
{
pkg: '@deepseek-ai/dsh-tool-fs',
@@ -149,6 +150,23 @@ const TOOL_PACKAGES: ToolPackage[] = [
note:
'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin.',
},
{
pkg: '@deepseek-ai/dsh-tool-fs-search',
dir: 'tool-fs-search',
source: 'packages/fs/tool-fs-search/src/index.ts',
requires: ['ctx.tools', 'ctx.bash', 'ctx.systemPrompt'],
writes: ['tool/call', 'tool/result'],
async mount(ctx) {
// The tools inject `bash` (search executes fixed `rg` commands through
// the executor seam, not ctx.fs); boot the local executor to satisfy it.
// `ctx.spillStore` is optional (read via ctx.get) and does not affect the
// schemas, so no spill backend is mounted.
await ctx.plugin(LocalBashExecutor)
await ctx.plugin(ToolFsSearch)
},
note:
'glob and grep are bash-backed discovery tools: they 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-skill',
dir: 'tool-skill',
+734
View File
@@ -0,0 +1,734 @@
/**
* 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: doc-typecheck
* only processes its known info strings, so these bare (non-compilable)
* signature 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, serviceDeclarations } 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[]
/** 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.
/** 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('_')
}
type HarnessServiceMember = ts.MethodDeclaration | ts.MethodSignature | ts.PropertyDeclaration
| ts.PropertySignature | ts.GetAccessorDeclaration
/** Whether a class/interface service member is renderable public API. */
function isPublicServiceMember(member: HarnessServiceMember): boolean {
if (ts.isMethodSignature(member) || ts.isPropertySignature(member)) {
if (ts.isComputedPropertyName(member.name)) return false
return !member.name.getText().startsWith('_')
}
return isPublicInstance(member)
}
/** 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 => rawJsDoc(text, 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)),
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 doc = parseJsDoc(rawJsDoc(text, first)).doc
const code = matches.map(s => stripBodies(s, sf).replace(/^export\s+(default\s+)?/, '')).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, declaration, abstract, doc: declarationDoc } of serviceDeclarations(body, sf, rel, violations)) {
const groups = new Map<string, HarnessServiceMember[]>()
for (const member of declaration.members) {
// Public properties are API too: ctx.codeRuntime.language/isolation
// are readonly descriptors consumers key presentation off.
const renderable = ts.isMethodDeclaration(member) || ts.isMethodSignature(member)
|| ts.isPropertyDeclaration(member) || ts.isPropertySignature(member) || ts.isGetAccessorDeclaration(member)
if (!renderable) continue
if (!isPublicServiceMember(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: declarationDoc, members, source: pointer(rel, sf, declaration), 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
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 = rawJsDoc(text, 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), 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)
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.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))
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()
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Shared fenced-code-block extractor for the Markdown doc gates
* (`doc-typecheck.ts`, `verify-website-yaml.ts`). 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.
*/
import { readFileSync } from 'node:fs'
/** One extracted fenced block, classified by the caller's `classify`. */
export interface Fence<K> {
/** 1-based line of the opening fence. */
line: number
kind: K
code: string
}
/**
* Extract every fenced block of `absPath` whose info string `classify` maps
* to a kind. Blocks classified `null` are skipped (their bodies are still
* consumed, so an unrelated fence can never leak into a tracked one).
*
* @param absPath — absolute path of the Markdown file.
* @param classify — info string (trimmed, e.g. `ts ignore-check`) → kind, or
* null for fences this gate does not track.
* @returns the classified blocks in document order.
*/
export function extractFences<K>(absPath: string, classify: (info: string) => K | null): Fence<K>[] {
const lines = readFileSync(absPath, 'utf8').split('\n')
const blocks: Fence<K>[] = []
let open: { line: number; kind: K; body: string[] } | null = null
let skipping = false
lines.forEach((raw, i) => {
const fence = /^```(\s*)(\S.*)?$/.exec(raw)
if (!fence) {
if (open) open.body.push(raw)
return
}
if (open) {
blocks.push({ line: open.line, kind: open.kind, code: open.body.join('\n') })
open = null
return
}
if (skipping) {
skipping = false
return
}
const kind = classify((fence[2] ?? '').trim())
if (kind !== null) open = { line: i + 1, kind, body: [] }
else skipping = true
})
return blocks
}
+96 -17
View File
@@ -24,6 +24,7 @@ type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped'
interface Gate {
id: string
label: string
displayCommand: string
command: string
args: string[]
needs?: string[]
@@ -38,22 +39,39 @@ interface GateResult {
durationMs: number
stdout: string
stderr: string
output: GateOutputChunk[]
exitCode: number | null
error?: string
}
interface GateOutputChunk {
stream: 'stdout' | 'stderr'
text: string
}
interface RunningGate {
gate: Gate
promise: Promise<GateResult>
}
interface ConcurrencyDefault {
workers: number
source: string
}
const root = resolve(import.meta.dirname, '..')
const mode = parseMode(process.argv[2])
const gates = gatesForMode(mode)
const maxConcurrency = concurrencyFromEnv('DSH_GATE_CONCURRENCY', defaultConcurrency(gates.length))
const concurrencyDefault = defaultConcurrency(mode, gates.length)
const concurrencyOverride = process.env.DSH_GATE_CONCURRENCY
const maxConcurrency = concurrencyFromEnv('DSH_GATE_CONCURRENCY', concurrencyDefault.workers)
const verbose = process.env.DSH_GATE_VERBOSE === '1'
const startedAt = performance.now()
console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s).`)
const concurrencySource = concurrencyOverride === undefined || concurrencyOverride === ''
? concurrencyDefault.source
: '$DSH_GATE_CONCURRENCY'
console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`)
const results = await runGates(gates, maxConcurrency)
printSummary(results, performance.now() - startedAt)
@@ -78,8 +96,15 @@ function parseMode(raw: string | undefined): Mode {
}
}
function defaultConcurrency(total: number): number {
return Math.min(total, Math.max(4, availableParallelism()))
function defaultConcurrency(selectedMode: Mode, total: number): ConcurrencyDefault {
const available = availableParallelism()
const modeLimit = selectedMode === 'pre-push' ? Math.min(4, available) : available
return {
workers: Math.min(total, modeLimit),
source: selectedMode === 'pre-push'
? `${available} available CPU(s), pre-push cap 4`
: `${available} available CPU(s)`,
}
}
function concurrencyFromEnv(name: string, fallback: number): number {
@@ -96,6 +121,7 @@ function pnpmScript(id: string, script: string, options: Partial<Gate> = {}): Ga
return {
id,
label: options.label ?? script,
displayCommand: `pnpm run ${script}`,
...pnpmInvocation(['run', script]),
...options,
}
@@ -105,6 +131,7 @@ function pnpmExec(id: string, args: string[], options: Partial<Gate> = {}): Gate
return {
id,
label: options.label ?? `pnpm exec ${args.join(' ')}`,
displayCommand: `pnpm exec ${args.join(' ')}`,
...pnpmInvocation(['exec', ...args]),
...options,
}
@@ -162,7 +189,10 @@ function gatesForMode(selected: Mode): Gate[] {
pnpmScript('snapshot', 'test:snapshot'),
pnpmScript('build', 'build'),
...hygieneLeafGates({ artifactNeeds: ['build'] }),
...docSyncLeafGates(),
...docSyncLeafGates({
docTypecheckNeeds: ['build'],
docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' },
}),
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
]
}
@@ -182,6 +212,7 @@ function ciPrimaryGates(): Gate[] {
...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', {
@@ -201,6 +232,7 @@ function ciStaticGates(): Gate[] {
...docSyncLeafGates(),
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
pnpmScript('knip', 'knip'),
pnpmScript('website-build', 'website:build', { label: 'website build' }),
]
}
@@ -275,9 +307,15 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
]
}
function docSyncLeafGates(): Gate[] {
function docSyncLeafGates(options: {
docTypecheckNeeds?: string[]
docTypecheckEnv?: Record<string, string | undefined>
} = {}): Gate[] {
const docTypecheckOptions: Partial<Gate> = {}
if (options.docTypecheckNeeds !== undefined) docTypecheckOptions.needs = options.docTypecheckNeeds
if (options.docTypecheckEnv !== undefined) docTypecheckOptions.env = options.docTypecheckEnv
return [
pnpmScript('doc-typecheck', 'doc-typecheck'),
pnpmScript('doc-typecheck', 'doc-typecheck', docTypecheckOptions),
pnpmScript('cordis-catalog', 'verify-cordis-catalog', { label: 'cordis catalog' }),
pnpmScript('export-jsdoc', 'verify-export-jsdoc', { label: 'export jsdoc' }),
pnpmScript('tool-catalog', 'verify-tool-catalog', { label: 'tool catalog' }),
@@ -285,6 +323,7 @@ function docSyncLeafGates(): Gate[] {
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' }),
@@ -298,6 +337,7 @@ function docSyncLeafGates(): Gate[] {
pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }),
pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }),
pnpmScript('package-readme-limitations', 'verify-package-readme-limitations', { label: 'package README limitations' }),
pnpmScript('website-yaml', 'verify-website-yaml', { label: 'website yaml' }),
]
}
@@ -306,6 +346,7 @@ function demoSmokeGate(options: { needs?: string[] } = {}): Gate {
return {
id: 'demo-smoke',
label: 'demo smoke',
displayCommand: 'pnpm run demo:echo',
...pnpmInvocation(['run', 'demo:echo']),
input: 'echo ci smoke\n',
...dependencyOptions,
@@ -382,6 +423,7 @@ async function runGates(allGates: Gate[], maxActive: number): Promise<GateResult
durationMs: 0,
stdout: '',
stderr: '',
output: [],
exitCode: null,
error: `dependency failed or skipped: ${failedDeps.join(', ')}`,
}
@@ -416,8 +458,10 @@ async function runGate(gate: Gate): Promise<GateResult> {
const started = performance.now()
let stdout = ''
let stderr = ''
const output: GateOutputChunk[] = []
let spawnError: string | undefined
const exitCode = await new Promise<number | null>((resolveExit, reject) => {
const exitCode = await new Promise<number | null>((resolveExit) => {
const child = spawn(gate.command, gate.args, {
cwd: root,
env: { ...process.env, ...gate.env },
@@ -425,19 +469,28 @@ async function runGate(gate: Gate): Promise<GateResult> {
})
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (chunk: string) => { stdout += chunk })
child.stderr.on('data', (chunk: string) => { stderr += chunk })
child.on('error', reject)
child.stdout.on('data', (chunk: string) => {
stdout += chunk
output.push({ stream: 'stdout', text: chunk })
})
child.stderr.on('data', (chunk: string) => {
stderr += chunk
output.push({ stream: 'stderr', text: chunk })
})
child.on('error', (error) => {
spawnError = `failed to start command: ${error.message}`
resolveExit(null)
})
child.on('close', resolveExit)
if (gate.input !== undefined) child.stdin.end(gate.input)
else child.stdin.end()
})
let status: GateStatus = exitCode === 0 ? 'passed' : 'failed'
let error: string | undefined
let status: GateStatus = exitCode === 0 && spawnError === undefined ? 'passed' : 'failed'
let error = spawnError
if (status === 'passed' && gate.verify !== undefined) {
try {
await gate.verify({ gate, status, durationMs: performance.now() - started, stdout, stderr, exitCode })
await gate.verify({ gate, status, durationMs: performance.now() - started, stdout, stderr, output, exitCode })
} catch (verifyError: unknown) {
status = 'failed'
error = verifyError instanceof Error ? verifyError.message : String(verifyError)
@@ -450,6 +503,7 @@ async function runGate(gate: Gate): Promise<GateResult> {
durationMs: performance.now() - started,
stdout,
stderr,
output,
exitCode,
}
if (error !== undefined) result.error = error
@@ -458,9 +512,16 @@ async function runGate(gate: Gate): Promise<GateResult> {
function printResult(result: GateResult): void {
const seconds = (result.durationMs / 1000).toFixed(2)
console.log(`\n== ${result.status.toUpperCase()} ${result.gate.label} (${seconds}s) ==`)
process.stdout.write(result.stdout)
process.stderr.write(result.stderr)
if (result.status === 'passed' && !verbose) {
console.log(`run-gates: PASS ${result.gate.label} (${seconds}s)`)
return
}
const heading = `${result.status.toUpperCase()} ${result.gate.label} (${seconds}s)`
const writeHeading = result.status === 'passed' ? console.log : console.error
writeHeading(`\n== ${heading} ==`)
if (result.status !== 'passed') console.error(`command: ${result.gate.displayCommand}`)
printOutput(result.output)
if (result.error !== undefined) console.error(result.error)
}
@@ -470,4 +531,22 @@ function printSummary(results: GateResult[], durationMs: number): void {
const skipped = results.filter(result => result.status === 'skipped').length
const seconds = (durationMs / 1000).toFixed(2)
console.log(`\nrun-gates: ${passed} passed, ${failed} failed, ${skipped} skipped in ${seconds}s.`)
const unsuccessful = results.filter(result => result.status === 'failed' || result.status === 'skipped')
if (unsuccessful.length === 0) return
console.error('run-gates: unsuccessful gates:')
for (const result of unsuccessful) {
const duration = (result.durationMs / 1000).toFixed(2)
const reason = result.error ?? (result.exitCode === null ? 'no exit code' : `exit ${result.exitCode}`)
console.error(` - ${result.status.toUpperCase()} ${result.gate.label} (${duration}s, ${reason})`)
console.error(` ${result.gate.displayCommand}`)
}
}
function printOutput(output: GateOutputChunk[]): void {
for (const chunk of output) {
if (chunk.stream === 'stdout') process.stdout.write(chunk.text)
else process.stderr.write(chunk.text)
}
}
+6 -25
View File
@@ -57,6 +57,7 @@ CUSTOM_CORDIS = """\
- id: agent-core
name: '@deepseek-ai/dsh-agent-spine-demo'
config:
workspaceContext: false
tools:
mode: both
- id: sessions
@@ -379,6 +380,7 @@ def smoke_sdk_default(base_url: str) -> None:
root = Path(temporary).resolve()
sessions = root / "sessions"
with DeepSeekHarness(
provider="deepseek",
model="smoke-model",
cwd=str(root),
session_root=str(sessions),
@@ -401,6 +403,7 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None:
cordis = root / "cordis.yml"
cordis.write_text(CUSTOM_CORDIS)
with DeepSeekHarness(
provider="deepseek",
model="smoke-model",
cwd=str(root),
session_root=str(sessions),
@@ -432,6 +435,7 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool)
cordis = root / "cordis.yml"
cordis.write_text(CUSTOM_CORDIS)
with DeepSeekHarness(
provider="deepseek",
model="smoke-model",
cwd=str(root),
session_root=str(sessions),
@@ -481,7 +485,7 @@ def smoke_direct(base_url: str, executable: Path) -> None:
}
peer = RuntimePeer([str(executable)], root, environment)
try:
peer.send({"jsonrpc": "2.0", "id": "initialize", "method": "initialize", "params": {"cwd": str(root), "model": "smoke-model"}})
peer.send({"jsonrpc": "2.0", "id": "initialize", "method": "initialize", "params": {"cwd": str(root), "provider": "deepseek", "model": "smoke-model"}})
peer.read_until(lambda message: message.get("id") == "initialize")
peer.send({
"jsonrpc": "2.0",
@@ -703,7 +707,7 @@ def normalize_snapshot_value(
def scrub_snapshot_header(value: dict[object, object]) -> None:
"""Tokenize request-header bulk while retaining delta tool names."""
"""Tokenize full request-header bulk while retaining tool names."""
data = value.get("data")
if not isinstance(data, dict):
return
@@ -721,29 +725,6 @@ def scrub_snapshot_header(value: dict[object, object]) -> None:
]
if isinstance(header.get("messagePrefix"), list):
header["messagePrefix"] = ["{{messagePrefix}}" for _ in header["messagePrefix"]]
return
if value.get("type") != "request/header-delta":
return
system = data.get("system")
if isinstance(system, dict) and isinstance(system.get("insert"), list):
system["insert"] = ["{{system}}" for _ in system["insert"]]
tools = data.get("tools")
if isinstance(tools, dict):
for key in ("added", "changed"):
if isinstance(tools.get(key), list):
tools[key] = [scrub_snapshot_tool_schema(tool) for tool in tools[key]]
if isinstance(data.get("messagePrefix"), list):
data["messagePrefix"] = ["{{messagePrefix}}" for _ in data["messagePrefix"]]
def scrub_snapshot_tool_schema(value: object) -> object:
"""Keep a changed tool's name while tokenizing its schema bulk."""
if not isinstance(value, dict):
return value
return {
key: item if key == "name" else "{{tools}}"
for key, item in value.items()
}
def render_jsonl(records: list[object]) -> str:
@@ -253,7 +253,7 @@
"workflow"
]
},
"reason": "fallback"
"reason": "change"
}
},
{
@@ -915,22 +915,29 @@
}
},
{
"type": "request/header-delta",
"type": "request/header",
"seq": 56,
"time": 0,
"data": {
"system": {
"keepStart": 62,
"keepEnd": 34,
"insert": []
"header": {
"config": {
"model": "smoke-model"
},
"system": "{{system}}",
"tools": [
"bash",
"bash_kill",
"bash_output",
"cordis_inspect",
"cordis_mount",
"cordis_unmount",
"run_code",
"skill",
"subagent",
"workflow"
]
},
"tools": {
"added": [],
"removed": [
"snapshot_double"
],
"changed": []
}
"reason": "change"
}
},
{
@@ -1396,7 +1403,7 @@
"workflow"
]
},
"reason": "fallback"
"reason": "change"
}
}
}
@@ -2358,22 +2365,29 @@
"payload": {
"sessionId": "{{parent}}",
"event": {
"type": "request/header-delta",
"type": "request/header",
"seq": 56,
"time": 0,
"data": {
"system": {
"keepStart": 62,
"keepEnd": 34,
"insert": []
"header": {
"config": {
"model": "smoke-model"
},
"system": "{{system}}",
"tools": [
"bash",
"bash_kill",
"bash_output",
"cordis_inspect",
"cordis_mount",
"cordis_unmount",
"run_code",
"skill",
"subagent",
"workflow"
]
},
"tools": {
"added": [],
"removed": [
"snapshot_double"
],
"changed": []
}
"reason": "change"
}
}
}
@@ -13,7 +13,7 @@
{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"<anonymous>\", state: active)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}
{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}}
{"type":"request/header","seq":14,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"fallback"}}
{"type":"request/header","seq":14,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"change"}}
{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}}}
{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}}}}
@@ -55,7 +55,7 @@
{"type":"tool/result","seq":53,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"<anonymous>\")"}],"isError":false},"sourceEventSeqs":[52],"surfaceOp":"append"}
{"type":"step/end","seq":54,"time":0,"data":{"turn":1,"step":5}}
{"type":"step/start","seq":55,"time":0,"data":{"turn":1,"step":6}}
{"type":"request/header-delta","seq":56,"time":0,"data":{"system":{"keepStart":62,"keepEnd":34,"insert":[]},"tools":{"added":[],"removed":["snapshot_double"],"changed":[]}}}
{"type":"request/header","seq":56,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","workflow"]},"reason":"change"}}
{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}}
{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}}
+23 -1
View File
@@ -3,14 +3,18 @@
"entries": [
{ "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/util/brand/src/index.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "AssistantProvenance", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "Message", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "MessageSourceMap", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "LlmProviderInfo", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "LlmModelInfo", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "GenerateOptions", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "ToolSchema", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "LlmCallConfig", "source": "packages/llm/llm/src/call-config.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "AgentCancelCause", "source": "packages/core/agent/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "InjectOptions", "source": "packages/core/agent/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/core/agent/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "AgentExecution", "source": "packages/core/agent-execution/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "AgentExecutionService", "source": "packages/core/agent-execution/src/index.ts" },
@@ -33,6 +37,10 @@
{ "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" },
{ "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" },
@@ -42,19 +50,23 @@
{ "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceEventType", "source": "packages/core/session/src/types.ts" },
{ "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceOp", "source": "packages/core/session/src/types.ts" },
{ "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceIntent", "source": "packages/core/session/src/types.ts" },
{ "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceNode", "source": "packages/core/session/src/surface.ts" },
{ "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldReplacement", "source": "packages/core/session/src/surface.ts" },
{ "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldResult", "source": "packages/core/session/src/surface.ts" },
{ "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" },
{ "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" },
{ "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionLocation", "source": "packages/session-persistence/session-persistence/src/index.ts" },
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSurface", "source": "packages/session-query/session-query/src/types.ts" },
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionRecord", "source": "packages/session-query/session-query/src/types.ts" },
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventRecord", "source": "packages/session-query/session-query/src/types.ts" },
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionLineageNode", "source": "packages/session-query/session-query/src/types.ts" },
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionLineageTrace", "source": "packages/session-query/session-query/src/types.ts" },
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionQueryErrorCode", "source": "packages/session-query/session-query/src/config.ts" },
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventReadRequest", "source": "packages/session-query/session-query/src/types.ts" },
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventWindow", "source": "packages/session-query/session-query/src/types.ts" },
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventTraceRequest", "source": "packages/session-query/session-query/src/types.ts" },
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventTrace", "source": "packages/session-query/session-query/src/types.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" },
@@ -63,6 +75,7 @@
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionToken", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionInput", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolRunContext", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolGuard", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolRestriction", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" },
@@ -86,6 +99,8 @@
{ "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalPolicy", "source": "packages/ui/user-approval/src/index.ts" },
{ "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalRequest", "source": "packages/ui/user-approval/src/index.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "DshEnvironmentKey", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "DshEnvironment", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecSpec", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/bash/src/types.ts" },
@@ -117,6 +132,7 @@
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTargetKey", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsVersion", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsInfo", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsPathInfo", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsDirEntry", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteIntent", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteOutcome", "source": "packages/fs/fs/src/types.ts" },
@@ -152,6 +168,12 @@
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchResult", "source": "packages/web/web/src/types.ts" },
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchBody", "source": "packages/web/web/src/types.ts" },
{ "doc": "docs/core-data-structures/spill.md", "symbol": "SaveTextSpill", "source": "packages/spill/spill/src/types.ts" },
{ "doc": "docs/core-data-structures/spill.md", "symbol": "SpillOwner", "source": "packages/spill/spill/src/types.ts" },
{ "doc": "docs/core-data-structures/spill.md", "symbol": "SpillSource", "source": "packages/spill/spill/src/types.ts" },
{ "doc": "docs/core-data-structures/spill.md", "symbol": "SpillRef", "source": "packages/spill/spill/src/types.ts" },
{ "doc": "docs/core-data-structures/spill.md", "symbol": "SpillLocator", "source": "packages/spill/spill/src/types.ts" },
{ "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowStartRequest", "source": "packages/workflow/workflow/src/types.ts" },
{ "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowMeta", "source": "packages/workflow/workflow/src/types.ts" },
{ "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowResult", "source": "packages/workflow/workflow/src/types.ts" },
@@ -31,6 +31,7 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly<Record<string, string>> = {
'packages/core/agent-execution': 'The package is model-agnostic ambient control infrastructure; model-facing consumers own any resulting request surface.',
'packages/core/scope': 'The package is a model-agnostic registration and lifecycle primitive; model-facing consumers own any context selection.',
'packages/util/brand': 'The package is a type-only primitive erased at compile time.',
'packages/util/paths': 'The package only resolves harness-owned host paths; model-facing consumers own any rendered use.',
}
/**
@@ -48,6 +49,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/fs/fs-local': { 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/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.' },
@@ -55,9 +57,12 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' },
'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' },
'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' },
'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' },
'packages/spill/spill-local': { kind: 'indirect', reason: 'The storage backend delegates model rendering to spill consumers.' },
'packages/subagent/subagent': { kind: 'indirect', reason: 'The provider registry delegates parent-model rendering to dsh-tool-subagent.' },
'packages/subagent/subagent-subprocess': { kind: 'indirect', reason: 'Only process-based subagent backends compose a child model request.' },
'packages/support/acp-snapshot': { kind: 'none', reason: 'The test harness observes and normalizes transcripts without changing live requests.' },
'packages/support/agent-loop-testkit': { kind: 'none', reason: 'The test helper mounts services but neither drives nor modifies model requests.' },
'packages/support/invariants': { kind: 'none', reason: 'The observer validates requests but never rewrites their context.' },
'packages/support/loader-smoke': { kind: 'none', reason: 'The test harness observes child-process streams without changing live requests.' },
'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' },
@@ -68,7 +73,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/examples/jsonrpc-demo': { kind: 'indirect', reason: 'Only the externally configured plugin tree contributes model context.' },
'packages/ui/permission': { kind: 'indirect', reason: 'The service writes mechanism events rendered by dsh-user-approval and dsh-tool-bash.' },
'packages/ui/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' },
'packages/util/home': { kind: 'indirect', reason: 'Only dsh-tool-bash exposes the resolved home to model commands.' },
'packages/util/timeout': { kind: 'indirect', reason: 'Only timeout consumers render timeout outcomes.' },
'packages/util/retention': { kind: 'indirect', reason: 'Only retention consumers render retained content and omission metadata.' },
'packages/web/web': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-web.' },
'packages/web/web-fetch-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' },
'packages/web/web-search-exa': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' },
+1 -1
View File
@@ -11,7 +11,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', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md']
const MARKDOWN_GLOBS = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'website/zh-CN/**/*.md']
/** One manifest entry: a documented type-equiv block and its source symbol. */
interface ManifestEntry {
+269
View File
@@ -0,0 +1,269 @@
/**
* 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)