Merge refreshed rfc/pty into feature/persistent-pty-sessions

# Conflicts:
#	docs/architecture.md
#	docs/capability-seams.md
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	docs/module-graph.md
#	docs/tool-catalog.md
#	examples/acp-agent/tests/acp.snapshot.ts
#	examples/headless-agent/tests/headless.snapshot.ts
#	examples/package.json
#	packages/README.md
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/tools/tests/gen-tool-catalog.spec.ts
#	pnpm-lock.yaml
#	pnpm-workspace.yaml
#	scripts/gen-tool-catalog.ts
#	scripts/type-equiv.manifest.json
#	website/.vitepress/config/api-sidebar.json
This commit is contained in:
Tianyi Cui
2026-07-22 21:12:16 +08:00
1856 changed files with 106649 additions and 23384 deletions

3
scripts/AGENTS.md Normal file
View File

@@ -0,0 +1,3 @@
# AGENTS.md — Repository scripts
Gate scripts invoke pnpm shell-free, normalize repository-relative glob paths to `/` at ingestion, and keep platform adaptation at the owning gate boundary instead of a shared platform layer.

View File

@@ -40,10 +40,12 @@ interface PackageManifest {
bin?: string | Record<string, string>
exports?: Record<
string,
| string
| {
types?: string
default?: string
}
| null
| undefined
>
files?: string[]
@@ -93,29 +95,6 @@ function workspaceManifests(): WorkspaceManifest[] {
return manifests
}
const dshPackageFiles = [
'lib/index.js',
'lib/types/**/*.d.ts',
'lib/types/**/*.d.ts.map',
'src',
] as const
const dshBinPackageFiles = [
'lib/index.js',
'lib/bin.js',
'lib/types/**/*.d.ts',
'lib/types/**/*.d.ts.map',
'src',
] as const
const dshWorkerPackageFiles = [
'lib/index.js',
'lib/worker.cjs',
'lib/types/**/*.d.ts',
'lib/types/**/*.d.ts.map',
'src',
] as const
const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
'@deepseek-ai/dsh-helper': ['lib/assets'],
'@deepseek-ai/dsh-scripts': [
@@ -131,22 +110,46 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl
function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] {
const extras = manifest.name ? packageFileExtras[manifest.name] ?? [] : []
if (extras.length > 0) {
return [
'lib/index.js',
...manifest.bin ? ['lib/bin.js'] : [],
...extras,
'lib/types/**/*.d.ts',
'lib/types/**/*.d.ts.map',
'src',
]
}
if (manifest.bin) return dshBinPackageFiles
// A declared "./worker" subpath export sanctions the one extra runtime
// bundle a worker-thread entry needs (and NodeNext/publint then validate
// that subpath's targets like any other export).
if (manifest.exports?.['./worker']) return dshWorkerPackageFiles
return dshPackageFiles
return [
'lib/index.js',
// Every package publishes its invariant ownership companion as a separate
// bundle; the package-invariant gate validates the companion itself.
'lib/invariant.js',
...manifest.bin ? ['lib/bin.js'] : [],
...manifest.exports?.['./worker'] ? ['lib/worker.cjs'] : [],
// UI plugin packages ship their browser bundle beside the node lib
// (single-artifact ruling: dist/ retired, ./client resolves lib/client.js).
// Keyed on the artifact path, not the subpath name: apiproxy's ./client is
// a browser-safe source channel, not a bundle.
...exportDefault(manifest, './client') === './lib/client.js' ? ['lib/client.js'] : [],
// runtime's shell-held loader subpath ships as its own bundle beside the client half.
...exportDefault(manifest, './loader') === './lib/loader.js' ? ['lib/loader.js'] : [],
// web-react's store subpath ships its own bundle (single-entry builds; no shared chunk).
...exportDefault(manifest, './store') === './lib/store/index.js' ? ['lib/store/index.js'] : [],
...extras,
// Subpaths whose runtime default is the tsc-emitted tree (lib/types/*.js —
// browser-safe source channels rehomed off src so plain Node can import
// them without type stripping) publish the emitted JS alongside the
// declarations.
...usesEmittedTreeDefaults(manifest) ? ['lib/types/**/*.js'] : [],
'lib/types/**/*.d.ts',
'lib/types/**/*.d.ts.map',
'src',
]
}
/** Runtime target of an export entry: conditional `default`, or the bare-string shorthand. */
function exportDefault(manifest: PackageManifest, subpath: string): string | undefined {
const entry = manifest.exports?.[subpath]
if (typeof entry === 'string') return entry
if (typeof entry === 'object' && entry !== null) return entry.default
return undefined
}
/** Whether any export's runtime default points into the tsc-emitted lib/types tree. */
function usesEmittedTreeDefaults(manifest: PackageManifest): boolean {
return Object.keys(manifest.exports ?? {}).some(subpath =>
exportDefault(manifest, subpath)?.startsWith('./lib/types/') === true)
}
function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
@@ -182,12 +185,25 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
if (manifest.types !== 'lib/types/index.d.ts') {
errors.push(`${label}: package.json must set "types": "lib/types/index.d.ts"`)
}
if (manifest.exports?.['.']?.types !== './lib/types/index.d.ts') {
const rootExport = manifest.exports?.['.']
const rootEntry = typeof rootExport === 'object' && rootExport !== null ? rootExport : undefined
if (rootEntry?.types !== './lib/types/index.d.ts') {
errors.push(`${label}: package.json exports["."].types must be "./lib/types/index.d.ts"`)
}
if (manifest.exports?.['.']?.default !== './lib/index.js') {
if (rootEntry?.default !== './lib/index.js') {
errors.push(`${label}: package.json exports["."].default must be "./lib/index.js"`)
}
const invariantRaw = manifest.exports?.['./invariant']
const invariantExport = typeof invariantRaw === 'object' && invariantRaw !== null ? invariantRaw : undefined
if (invariantExport?.types !== undefined && invariantExport.types !== './lib/types/invariant.d.ts') {
errors.push(`${label}: package.json exports["./invariant"].types must be "./lib/types/invariant.d.ts"`)
}
if (invariantExport?.default !== undefined && invariantExport.default !== './lib/invariant.js') {
errors.push(`${label}: package.json exports["./invariant"].default must be "./lib/invariant.js"`)
}
if (invariantExport && (invariantExport.types === undefined || invariantExport.default === undefined)) {
errors.push(`${label}: package.json exports["./invariant"] must declare both types and default targets`)
}
const expectedFiles = expectedDshPackageFiles(manifest)
if (!sameStringList(manifest.files, expectedFiles)) {
errors.push(`${label}: package.json files must be ${JSON.stringify(expectedFiles)}`)

View File

@@ -0,0 +1,60 @@
/**
* Pins the client-bundle purity gate (tsdown preset resolveId classifier):
* a bare-name import of a module-table package must rewrite to its /client
* external form (inlining it duplicates runtime identity — the P0
/* leak that is not an
* inline-safe wire layer must fail the build loudly.
*/
import { describe, expect, it } from 'vitest'
import { CLIENT_EXTERNALS, clientBundle } from '../packages/client/tsdown.client.ts'
type ResolveId = (source: string) => null | { id: string; external: boolean }
function purityResolveId(): ResolveId {
// libEntry is spelled at every call site (no default) so the
// package-invariants text check can see the invariant entry per package.
const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js'])
const plugins = (configs[1] as { plugins: { name: string; resolveId?: unknown }[] }).plugins
const gate = plugins.find(p => p.name === 'dsh-client-bundle-purity')
if (gate?.resolveId === undefined) throw new Error('purity plugin missing from client config')
return gate.resolveId as ResolveId
}
describe('client bundle purity gate', () => {
const resolveId = purityResolveId()
it('leaves table entries and non-scoped specifiers alone', () => {
expect(resolveId('@deepseek-ai/dsh-client-ui-slots')).toBeNull()
expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull()
expect(resolveId('react')).toBeNull()
expect(resolveId('zod')).toBeNull()
})
it('rewrites a bare table-package name to its external /client form (duplicate-instance prevention)', () => {
expect(resolveId('@deepseek-ai/dsh-client-connection')).toEqual({
id: '@deepseek-ai/dsh-client-connection/client',
external: true,
})
expect(resolveId('@deepseek-ai/dsh-client-ui-layout')).toEqual({
id: '@deepseek-ai/dsh-client-ui-layout/client',
external: true,
})
})
it('lets inline-safe wire layers inline', () => {
expect(resolveId('@deepseek-ai/dsh-host-apiproxy/api')).toBeNull()
expect(resolveId('@deepseek-ai/dsh-session/surface')).toBeNull()
expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull()
})
it('throws on any other @deepseek-ai leak', () => {
expect(() => resolveId('@deepseek-ai/dsh-agent')).toThrow(/purity/)
expect(() => resolveId('@deepseek-ai/dsh-client-web')).toThrow(/purity/)
})
it('every /client external has no bare-name twin in the table (the rewrite assumption)', () => {
for (const entry of CLIENT_EXTERNALS) {
if (entry.endsWith('/client')) expect(CLIENT_EXTERNALS).not.toContain(entry.slice(0, -'/client'.length))
}
})
})

View File

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { builtDeclarationPath } from './doc-typecheck-paths.ts'
describe('builtDeclarationPath', () => {
it('maps package source directories and exact entry files to built declarations', () => {
expect(builtDeclarationPath('./packages/*/*/src')).toBe('./packages/*/*/lib/types')
expect(builtDeclarationPath('./packages/support/invariants/src/index.ts'))
.toBe('./packages/support/invariants/lib/types/index.d.ts')
expect(builtDeclarationPath('./packages/core/session/src/invariant.ts'))
.toBe('./packages/core/session/lib/types/invariant.d.ts')
})
it('rejects aliases without a supported source target', () => {
expect(() => builtDeclarationPath('./packages/support/invariants/source/index.ts'))
.toThrow('cannot map workspace source path')
})
})

View File

@@ -0,0 +1,22 @@
/** Map one workspace source alias target to its declaration-build target. */
export function builtDeclarationPath(candidate: string): string {
// Two workspace shapes exist: whole-package entries end in /src, subpath
// wildcards (apiproxy's browser-safe /api and /client channels) in /src/*.
if (candidate.endsWith('/src')) {
return `${candidate.slice(0, -'/src'.length)}/lib/types`
}
if (candidate.endsWith('/src/*')) {
return `${candidate.slice(0, -'/src/*'.length)}/lib/types/*`
}
const sourceFile = /^(.*)\/src\/(.+)\.ts$/.exec(candidate)
if (sourceFile?.[1] && sourceFile[2]) {
return `${sourceFile[1]}/lib/types/${sourceFile[2]}.d.ts`
}
// Directory subpath entries (web-react's /store, runtime's /client): the
// source dir maps to the same dir under lib/types (index resolution applies).
const sourceDir = /^(.*)\/src\/(.+)$/.exec(candidate)
if (sourceDir?.[1] && sourceDir[2]) {
return `${sourceDir[1]}/lib/types/${sourceDir[2]}`
}
throw new Error(`doc-typecheck: cannot map workspace source path to built declarations: ${candidate}`)
}

View File

@@ -8,6 +8,7 @@ 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 { builtDeclarationPath } from './doc-typecheck-paths.ts'
import { extractFences } from './md-fences.ts'
const root = resolve(import.meta.dirname, '..')
@@ -65,12 +66,7 @@ function builtTypeCompilerOptions(): ts.CompilerOptions {
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`
}),
candidates.map(builtDeclarationPath),
]))
const options: ts.CompilerOptions = {
...parsed.options,
@@ -192,7 +188,7 @@ function remapBlockPaths(output: string, blocks: Block[]): string {
})
}
const markdownGlobs = ['README.md', '.agents/notes/**/*.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'website/zh-CN/**/*.md']
const markdownGlobs = ['README.md', '.agents/notes/**/*.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md']
const files: string[] = []
for (const pattern of markdownGlobs) {

View File

@@ -5,9 +5,10 @@
* curated table below. `--check` verifies both committed artifacts.
*/
import { globSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve, sep } from 'node:path'
import { globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve, sep } from 'node:path'
import ts from 'typescript'
import { renderCordisCoreApiPages } from './cordis-core-api.ts'
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts'
import { cordisModuleBody, eventMembers, serviceClasses } from './cordis-walk.ts'
@@ -27,6 +28,7 @@ const FENCE = 'ts cordis-catalog'
*/
export const LINK_MAP: Record<string, string> = {
Agent: 'core.md',
AgentCancelCause: 'core.md',
AgentOptions: 'core.md',
AgentStatus: 'core.md',
ContentBlock: 'core.md',
@@ -34,6 +36,8 @@ export const LINK_MAP: Record<string, string> = {
ContinuationStop: 'core.md',
GenerateOptions: 'core.md',
LlmCallConfig: 'core.md',
LlmModelContext: 'core.md',
LlmFailure: 'llm-streaming.md',
LlmModelInfo: 'core.md',
LlmProviderInfo: 'core.md',
Message: 'core.md',
@@ -57,6 +61,7 @@ export const LINK_MAP: Record<string, string> = {
CodeRunResult: 'code-runtime.md',
CompactionResult: 'compaction.md',
CompactionTrigger: 'compaction.md',
PruneResult: 'compaction.md',
FileReadOutcome: 'filesystem.md',
FsDirEntry: 'filesystem.md',
FsEditOutcome: 'filesystem.md',
@@ -68,6 +73,16 @@ export const LINK_MAP: Record<string, string> = {
FsVersion: 'filesystem.md',
FsWriteIntent: 'filesystem.md',
FsWriteOutcome: 'filesystem.md',
CreateGoalRequest: 'goal.md',
EditGoalRequest: 'goal.md',
GoalBlockReason: 'goal.md',
GoalChanged: 'goal.md',
GoalRef: 'goal.md',
GoalView: 'goal.md',
CommandDefinition: 'commands.md',
CommandDescriptor: 'commands.md',
CommandResult: 'commands.md',
CommandSurface: 'commands.md',
LlmAdapter: 'llm-streaming.md',
LlmService: 'llm-streaming.md',
StreamChunk: 'llm-streaming.md',
@@ -91,8 +106,11 @@ export const LINK_MAP: Record<string, string> = {
ScopeKey: 'scope.md',
Scoped: 'scope.md',
EpochHeader: 'session.md',
OutOfBandSessionEventType: 'session.md',
Session: 'session.md',
SessionEventMap: 'session.md',
TurnEndReason: 'session.md',
TurnTrigger: 'session.md',
SessionEventReadRequest: 'session-query.md',
SessionEventRecord: 'session-query.md',
SessionEventTrace: 'session-query.md',
@@ -100,6 +118,8 @@ export const LINK_MAP: Record<string, string> = {
SessionEventWindow: 'session-query.md',
SessionLineageTrace: 'session-query.md',
SessionRecord: 'session-query.md',
SessionTitleProvider: 'session-title.md',
SessionTitleSnapshot: 'session-title.md',
SkillDefinition: 'skills.md',
SkillLookupOptions: 'skills.md',
SkillProvider: 'skills.md',
@@ -125,6 +145,7 @@ export const LINK_MAP: Record<string, string> = {
PreToolDecision: 'tools.md',
ToolDefinition: 'tools.md',
ToolExecution: 'tools.md',
ToolDispatchExecution: 'tools.md',
ToolExecutionInput: 'tools.md',
ToolExecutionMode: 'tools.md',
ToolExecutionResult: 'tools.md',
@@ -166,6 +187,11 @@ const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
BashEnvVariableInfo: 'service-local metadata type is owned by packages/bash/tool-bash/src/index.ts',
CompactAgentContext: 'compaction service input is owned by packages/compact/compact/src/index.ts',
CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md',
InvariantInstaller: 'service-local contribution contract is owned by packages/support/invariants/README.md',
LocaleDict: 'service-local dictionary shape is owned by packages/client/i18n/src/index.ts',
ThemeTokens: 'service-local token dictionary is owned by packages/client/ui-theme/src/index.ts',
Translate: 'service-local bound translator is owned by packages/client/i18n/src/index.ts',
InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md',
PresetOption: 'deployment menu metadata is owned by packages/ui/permission/README.md',
PresetSpec: 'deployment preset composition is owned by packages/ui/permission/README.md',
PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md',
@@ -276,8 +302,7 @@ interface InheritedEntry {
source: string
}
// cordisModuleBody / eventMembers / serviceClasses live in cordis-walk.ts,
// shared with gen-website-api.ts — one walk, two renderers.
// cordisModuleBody / eventMembers / serviceClasses live in cordis-walk.ts.
/** The signature text of a method-signature member (everything but a body). */
function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.SourceFile): string {
@@ -515,7 +540,7 @@ export function renderEvents(events: EventEntry[]): string {
'',
GATE_NOTICE,
'',
'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely.',
'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely. The event-dispatch methods themselves are generated in the [Cordis core Events API](core/events.md).',
'',
'Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`).',
'',
@@ -550,7 +575,7 @@ export function renderServices(services: ServiceEntry[]): string {
'',
GATE_NOTICE,
'',
'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely.',
'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely. Detailed Context, Fiber, Registry, and Service APIs are generated in the [Cordis core API](core/context.md).',
'',
]
for (const s of services) lines.push(...renderService(s))
@@ -574,6 +599,7 @@ function main(): void {
const outputs: [string, string][] = [
[OUT_EVENTS, renderEvents(collectEvents())],
[OUT_SERVICES, renderServices(collectServices())],
...renderCordisCoreApiPages(),
]
if (process.argv.includes('--check')) {
const stale: string[] = []
@@ -590,15 +616,19 @@ function main(): void {
if (committed !== content) stale.push(out)
}
if (stale.length === 0) {
console.log(`gen-cordis-catalog: ${OUT_EVENTS} and ${OUT_SERVICES} are up to date.`)
console.log(`gen-cordis-catalog: ${outputs.length} generated file(s) are up to date.`)
process.exit(0)
}
console.error(`gen-cordis-catalog: ${stale.join(' and ')} ${stale.length === 1 ? 'is' : 'are'} stale. Run \`pnpm run gen-cordis-catalog\` and commit the result.`)
process.exit(1)
}
for (const [out, content] of outputs) writeFileSync(resolve(root, out), content)
console.log(`gen-cordis-catalog: wrote ${OUT_EVENTS} and ${OUT_SERVICES}.`)
for (const [out, content] of outputs) {
const destination = resolve(root, out)
mkdirSync(dirname(destination), { recursive: true })
writeFileSync(destination, content)
}
console.log(`gen-cordis-catalog: wrote ${outputs.length} generated file(s).`)
}
// Run only when invoked as a script, not when imported by a test.

View File

@@ -58,6 +58,7 @@ const GROUP_ORDER = [
'util',
'llm',
'core',
'goal',
'bash',
'pty',
'sandbox',
@@ -70,10 +71,12 @@ const GROUP_ORDER = [
'web',
'spill',
'todo',
'plan',
'cordis',
'hooks',
'session-persistence',
'session-query',
'session-title',
'support',
'ui',
]
@@ -96,14 +99,30 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['compact-basic'],
note: 'Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements.',
},
{
key: 'toolResultPrune',
pkg: 'compact-tool-result-prune',
title: 'Model-free tool-result pruning',
mode: 'core',
consumers: ['compact-basic'],
note: 'Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction.',
},
{
key: 'sessions',
pkg: 'session',
title: 'In-memory session store',
mode: 'core',
consumers: ['agent-loop', 'agent', 'cli-demo', 'session-persistence', 'session-query', 'subagent-inprocess', 'invariants'],
consumers: ['agent-loop', 'agent', 'cli-demo', 'session-persistence', 'session-query', 'subagent-inprocess'],
note: 'Owns append-only Session instances and emits the durable session event feed.',
},
{
key: 'invariants',
pkg: 'invariants',
title: 'Package-owned invariant registry',
mode: 'core',
consumers: ['session', 'agent', 'scope', 'agent-loop'],
note: 'Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures.',
},
{
key: 'sessionPersistence',
pkg: 'session-persistence',
@@ -120,6 +139,14 @@ const SERVICE_ROLES: ServiceRole[] = [
mode: 'seam',
note: 'Resolves live and optional persisted logs into one logical corpus for exact reads and relationship traces.',
},
{
key: 'sessionTitle',
pkg: 'session-title',
title: 'Log-backed session titles',
mode: 'seam',
implementations: ['session-title-first-message-llm', 'session-title-all-messages-llm'],
note: 'Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration.',
},
{
key: 'systemPrompt',
pkg: 'system-prompt',
@@ -141,10 +168,26 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'user-interaction',
title: 'Human question/answer seam',
mode: 'seam',
implementations: ['stdio-demo', 'acp'],
consumers: ['tool-ask-user', 'stdio-demo', 'acp'],
implementations: ['tui', 'acp'],
consumers: ['tool-ask-user', 'tui', 'acp'],
note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.',
},
{
key: 'planMode',
pkg: 'plan-mode',
title: 'Plan collaboration state',
mode: 'core',
consumers: ['acp'],
note: 'Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions.',
},
{
key: 'commands',
pkg: 'commands',
title: 'Human command registry',
mode: 'core',
consumers: ['tui', 'acp'],
note: 'Plugins register direct human commands; TUI and ACP consume the same effective per-agent catalog without sending invocations to the model.',
},
{
key: 'skills',
pkg: 'skill',
@@ -159,7 +202,7 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'agent',
title: 'Agent service',
mode: 'core',
consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess', 'stdio-demo', 'invariants'],
consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess', 'tui-demo'],
note: 'Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation.',
},
{
@@ -170,6 +213,13 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['agent-spine-demo'],
note: 'The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package.',
},
{
key: 'goals',
pkg: 'goal',
title: 'Same-session goal domain',
mode: 'core',
note: 'Folds revisioned objective state from the session log and keeps live continuation activation process-local.',
},
{
key: 'bash',
pkg: 'bash',
@@ -265,8 +315,8 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'Subagent provider registry',
mode: 'seam',
implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp'],
consumers: ['tool-subagent'],
note: 'Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name.',
consumers: ['tool-subagent', 'tool-ralph'],
note: 'Providers implement transports; tool-subagent exposes configured delegation while tool-ralph requires one fresh structured-output route.',
},
{
key: 'tasks',
@@ -300,8 +350,8 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'Workflow script engine',
mode: 'seam',
implementations: ['workflow-workerthread'],
consumers: ['tool-workflow'],
note: 'One engine per context (bash shape, no named-provider registry); the worker-thread engine fans agent() calls out through ctx.subagents.',
consumers: ['tool-workflow', 'tool-ralph'],
note: 'One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents.',
},
]
@@ -437,29 +487,13 @@ function stripYamlScalar(value: string): string {
}
const APP_EXAMPLES = [
{
id: 'echo',
rel: 'examples/echo-agent/composition.md',
title: 'Echo Agent App Composition',
label: 'examples/echo-agent',
config: 'examples/echo-agent/cordis.yml',
summary: 'The echo demo swaps in a local mock LLM and teaching echo tool, then loads the stdio app package for the shared spine and terminal front door.',
},
{
id: 'repl',
rel: 'examples/repl-agent/composition.md',
title: 'REPL Agent App Composition',
label: 'examples/repl-agent',
config: 'examples/repl-agent/cordis.yml',
summary: 'The REPL agent demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package.',
},
{
id: 'tui',
rel: 'examples/tui-agent/composition.md',
title: 'TUI Agent App Composition',
label: 'examples/tui-agent',
config: 'examples/tui-agent/cordis.yml',
summary: 'The TUI agent reuses the repl-agent backend and tool composition while fixing the shared terminal app to the full-screen dsh-tui front door.',
summary: 'The TUI agent combines the real DeepSeek adapter, coding tools, compaction, subagents, and workflows with the full-screen terminal app package.',
},
{
id: 'headless',
@@ -489,18 +523,13 @@ const APP_EXAMPLES = [
type AppExample = typeof APP_EXAMPLES[number]
function renderAppExpansion(lines: string[], appNode: string, pluginName: string, exampleId: string): void {
function renderAppExpansion(lines: string[], appNode: string, pluginName: string): void {
const agentCore = nodeId('bundle', 'agent_core')
const jsonl = nodeId('bundle', 'jsonl')
lines.push(` ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-spine-demo"]`)
lines.push(` ${appNode} --> ${jsonl}["@deepseek-ai/dsh-session-persistence-jsonl"]`)
if (pluginName === '@deepseek-ai/dsh-stdio-demo') {
const frontDoor = exampleId === 'tui'
? '@deepseek-ai/dsh-tui<br/>pre-created main agent'
: exampleId === 'repl'
? '@deepseek-ai/dsh-stdio<br/>pre-created main agent'
: 'dsh-tui (TTY) / dsh-stdio (pipes)<br/>pre-created main agent'
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'stdio')}["${frontDoor}"]`)
if (pluginName === '@deepseek-ai/dsh-tui-demo') {
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'tui')}["@deepseek-ai/dsh-tui<br/>pre-created main agent"]`)
} else if (pluginName === '@deepseek-ai/dsh-cli-demo') {
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'cli')}["one-shot driver<br/>format-pure stdout<br/>fresh top-level agent"]`)
} else if (pluginName === '@deepseek-ai/dsh-acp-demo') {
@@ -529,8 +558,8 @@ function renderAppComposition(example: AppExample): string {
const pluginNode = nodeId(`plugin_${example.id}`, plugin.id)
lines.push(` ${pluginNode}["${escLabel(plugin.id)}<br/>${escLabel(plugin.name)}"]`)
lines.push(` cfg --> ${pluginNode}`)
if (plugin.name === '@deepseek-ai/dsh-stdio-demo' || plugin.name === '@deepseek-ai/dsh-cli-demo' || plugin.name === '@deepseek-ai/dsh-acp-demo') {
renderAppExpansion(lines, pluginNode, plugin.name, example.id)
if (plugin.name === '@deepseek-ai/dsh-tui-demo' || plugin.name === '@deepseek-ai/dsh-cli-demo' || plugin.name === '@deepseek-ai/dsh-acp-demo') {
renderAppExpansion(lines, pluginNode, plugin.name)
}
}
lines.push(
@@ -919,7 +948,7 @@ function renderLifecycle(): string {
'',
'The `assistant/message` edge records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history while the durable anchor retains usage and exact chunk provenance, including an explicit empty source set.',
'',
'`dsh-compact-basic` uses `agent/post-step` for pressure after those durable facts and `agent/request-error` only for canonical context overflow. Recovery compacts between the closed failed step and a fresh retry step, and returns retry only when the surface replacement generation advances; otherwise the original request error remains authoritative.',
'`dsh-compact-basic` uses `agent/post-step` for pressure after those durable facts and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and a fresh retry step, and returns retry only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.',
'',
'SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors.',
'',
@@ -1028,8 +1057,6 @@ function renderDocs(): GraphDoc[] {
function renderIndex(docs: GraphDoc[]): string {
const labels: Record<string, string> = {
'docs/capability-seams.md': 'capability seams and core services',
'examples/echo-agent/composition.md': 'echo-agent app composition',
'examples/repl-agent/composition.md': 'repl-agent app composition',
'examples/headless-agent/composition.md': 'headless-agent app composition',
'examples/tui-agent/composition.md': 'tui-agent app composition',
'examples/cordis-agent/composition.md': 'cordis-agent app composition',
@@ -1041,8 +1068,6 @@ function renderIndex(docs: GraphDoc[]): string {
}
const modes: Record<string, string> = {
'docs/capability-seams.md': 'hybrid generated',
'examples/echo-agent/composition.md': 'hybrid generated',
'examples/repl-agent/composition.md': 'hybrid generated',
'examples/headless-agent/composition.md': 'hybrid generated',
'examples/tui-agent/composition.md': 'hybrid generated',
'examples/cordis-agent/composition.md': 'hybrid generated',

View File

@@ -21,6 +21,7 @@ const GROUP_ORDER = [
'util',
'llm',
'core',
'goal',
'bash',
'fs',
'skill',
@@ -30,10 +31,12 @@ const GROUP_ORDER = [
'spill',
'timeout',
'todo',
'plan',
'cordis',
'hooks',
'session-persistence',
'session-query',
'session-title',
'support',
'ui',
]

View File

@@ -41,6 +41,11 @@ const LINK_MAP: Record<string, string> = {
TodoItem: 'session.md',
TurnTrigger: 'session.md',
TurnEndReason: 'session.md',
SessionTitleEventData: 'session-title.md',
SessionTitleLlmRequestEventData: 'session-title.md',
SessionTitleModelProvenance: 'session-title.md',
SessionTitleProviderId: 'session-title.md',
SessionTitleSource: 'session-title.md',
}
/** One log event, extracted from a `SessionEventMap` declaration. */

View File

@@ -1,6 +1,6 @@
/**
* Generate the dev-invariants scoped-event resolver map from the
* repository TypeScript Program.
* Generate dsh-scope's invariant resolver map from the repository TypeScript
* Program.
*
* A scoped event declares `this: Scoped<Base>`. Real `scopeTarget(base, key)`
* calls establish the routing-key type for that base. The generator searches
@@ -20,7 +20,7 @@ import { pointer, rawJsDoc } from './jsdoc.ts'
import { TypeScriptProject } from './ts-project.ts'
const root = resolve(import.meta.dirname, '..')
const OUT = 'packages/support/invariants/src/scoped-events.generated.ts'
const OUT = 'packages/core/scope/src/scoped-events.generated.ts'
const SCOPE_DOC_MARKER = 'Scope-filtered dispatch'
interface ScopeTargetContract {
@@ -39,7 +39,6 @@ interface SubjectCandidate {
interface ScopedEventResolver {
event: string
candidate: SubjectCandidate | null
ownerPackage: string
}
interface ScopeTag {
@@ -54,7 +53,6 @@ class ScopedEventGenerator {
private readonly scopeTargetDeclaration: ts.FunctionDeclaration
private readonly scopedSymbol: ts.Symbol
private readonly violations: string[] = []
private readonly packageNames = new Map<string, string>()
constructor(private readonly project: TypeScriptProject) {
this.checker = project.checker
@@ -81,44 +79,25 @@ class ScopedEventGenerator {
+ this.violations.map(violation => ` - ${violation}`).join('\n'),
)
}
const ownerImports = [...new Set(resolvers.map(resolver => resolver.ownerPackage))]
.sort()
.map(packageName => `import type {} from ${quote(packageName)}`)
return [
'/**',
' * Generated scoped-event routing-subject resolvers for dsh-invariants.',
' * Generated scoped-event routing-subject resolvers for dsh-scope invariants.',
' * Do not edit by hand; run `pnpm run gen-scoped-events`.',
' *',
' * @module @deepseek-ai/dsh-invariants/scoped-events.generated',
' * @module @deepseek-ai/dsh-scope/scoped-events.generated',
' */',
'',
"import type { Events } from 'cordis'",
"import type { Scoped } from '@deepseek-ai/dsh-scope'",
...ownerImports,
'',
'type ScopedEventName = {',
' [K in keyof Events]: ThisParameterType<Events[K]> extends Scoped<object> ? K : never',
'}[keyof Events]',
'',
'type ScopedSubjectResolver = (args: readonly unknown[]) => unknown',
'',
'function adapt<K extends ScopedEventName>(',
' resolver: (args: Parameters<Events[K]>) => unknown,',
'): ScopedSubjectResolver {',
' return args => resolver(args as Parameters<Events[K]>)',
'}',
'',
'const scopedSubjectResolvers = Object.freeze({',
'const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | null>> = Object.freeze({',
...resolvers.map(({ event, candidate }) => {
if (candidate === null) return ` '${event}': null,`
const subject = candidate.property === undefined
? `args[${candidate.parameter}]`
: `args[${candidate.parameter}].${candidate.property}`
return ` '${event}': adapt<'${event}'>(args => ${subject}),`
: `(args[${candidate.parameter}] as Record<string, unknown>)[${quote(candidate.property)}]`
return ` '${event}': args => ${subject},`
}),
'} as const satisfies Readonly<Record<ScopedEventName, ScopedSubjectResolver | null>>)',
'',
'const scopedSubjectResolverIndex: Readonly<Record<string, ScopedSubjectResolver | null>> = scopedSubjectResolvers',
'})',
'',
'/**',
' * Resolve the routing key named by one scoped event payload. A null',
@@ -129,7 +108,7 @@ class ScopedEventGenerator {
' * or undefined when the event is not scope-filtered.',
' */',
'export function scopedSubjectResolverFor(event: string): ScopedSubjectResolver | null | undefined {',
' return scopedSubjectResolverIndex[event]',
' return scopedSubjectResolvers[event]',
'}',
'',
].join('\n')
@@ -186,7 +165,6 @@ class ScopedEventGenerator {
const resolvers: ScopedEventResolver[] = []
for (const sourceFile of this.packageSources) {
const rel = this.project.relativePath(sourceFile)
const ownerPackage = this.packageName(packageRootFor(rel))
const visit = (node: ts.Node): void => {
if (ts.isInterfaceDeclaration(node) && node.name.text === 'Events' && isCordisModuleInterface(node)) {
for (const member of node.members) {
@@ -232,7 +210,7 @@ class ScopedEventGenerator {
+ 'add @dshScopeScan unsupported only when the key is intentionally absent from the payload',
)
}
resolvers.push({ event, candidate: null, ownerPackage })
resolvers.push({ event, candidate: null })
continue
}
if (tag.unsupported) {
@@ -241,7 +219,7 @@ class ScopedEventGenerator {
)
continue
}
resolvers.push({ event, candidate: candidates[0] ?? null, ownerPackage })
resolvers.push({ event, candidate: candidates[0] ?? null })
}
}
ts.forEachChild(node, visit)
@@ -311,19 +289,6 @@ class ScopedEventGenerator {
return dedupeCandidates(candidates)
}
/** Read and cache one workspace package name. */
private packageName(packageRoot: string): string {
const cached = this.packageNames.get(packageRoot)
if (cached) return cached
const manifest: unknown = JSON.parse(readFileSync(resolve(root, packageRoot, 'package.json'), 'utf8'))
const name: unknown = typeof manifest === 'object' && manifest !== null
? Reflect.get(manifest, 'name')
: undefined
if (typeof name !== 'string') throw new Error(`gen-scoped-events: ${packageRoot}/package.json has no name`)
this.packageNames.set(packageRoot, name)
return name
}
/** Compare exact Program type identities after removing null and undefined. */
private typesEquivalent(left: ts.Type, right: ts.Type): boolean {
const normalizedLeft = this.normalizedType(left)
@@ -398,13 +363,6 @@ function dedupeCandidates(candidates: readonly SubjectCandidate[]): SubjectCandi
})
}
/** Return the workspace package root owning one package source file. */
function packageRootFor(relativePath: string): string {
const match = /^(packages\/[^/]+\/[^/]+)\/src\//.exec(relativePath)
if (!match?.[1]) throw new Error(`gen-scoped-events: cannot derive package root from ${relativePath}`)
return match[1]
}
/** Quote a generated property key as a single-quoted TypeScript string. */
function quote(value: string): string {
return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
@@ -419,7 +377,7 @@ export function renderScopedEvents(projectRoot: string = root): string {
return new ScopedEventGenerator(new TypeScriptProject(projectRoot)).render()
}
/** Generate or freshness-check the fixed invariants source file. */
/** Generate or freshness-check the fixed dsh-scope source file. */
function main(): void {
const content = renderScopedEvents()
const output = resolve(root, OUT)

View File

@@ -10,6 +10,8 @@ import { globSync, readFileSync, writeFileSync } from 'node:fs'
import { basename, resolve } from 'node:path'
import { Context } from 'cordis'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import GoalService from '@deepseek-ai/dsh-goal'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
@@ -17,6 +19,7 @@ import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import PlanModeService from '@deepseek-ai/dsh-plan-mode'
import WebService from '@deepseek-ai/dsh-web'
import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa'
import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local'
@@ -32,12 +35,16 @@ import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
import PtyService from '@deepseek-ai/dsh-pty'
import * as ToolPty from '@deepseek-ai/dsh-tool-pty'
import * as ToolGoal from '@deepseek-ai/dsh-tool-goal'
import Lsp from '@deepseek-ai/dsh-lsp'
import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp'
import * as ToolSkill from '@deepseek-ai/dsh-tool-skill'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread'
import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph'
import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
const root = resolve(import.meta.dirname, '..')
@@ -81,11 +88,16 @@ class CatalogSearchBashExecutor extends BashExecutor {
}
}
/** Register the descriptor needed to mount schema-producing consumers. */
/**
* Register the descriptor needed to mount schema-producing consumers. Declares
* the full capability set of the shipped in-process providers so consumers
* mount under their shipped defaults (tool-subagent's default numeric maxDepth
* requires `depthLimit`).
*/
function registerCatalogSubagentProvider(ctx: Context, name: string): void {
const provider: SubagentProvider = {
name,
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true },
inheritsParentContext: false,
start: () => Promise.reject(new Error('tool-catalog provider cannot start a child')),
}
@@ -163,6 +175,18 @@ const TOOL_PACKAGES: ToolPackage[] = [
note:
'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry\'s only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.',
},
{
pkg: '@deepseek-ai/dsh-plan-mode',
dir: 'plan-mode',
source: 'packages/plan/plan-mode/src/index.ts',
requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.userInteraction (execution time, opportunistic)'],
writes: ['tool/call', 'plan/mode inactive on an approved review', 'tool/result'],
async mount(ctx) {
await ctx.plugin(PlanModeService, { section: 'Tool catalog schema harvest.' })
},
note:
'exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary.',
},
{
pkg: '@deepseek-ai/dsh-tool-bash',
dir: 'tool-bash',
@@ -234,6 +258,49 @@ const TOOL_PACKAGES: ToolPackage[] = [
note:
'The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema.',
},
{
pkg: '@deepseek-ai/dsh-tool-goal',
dir: 'tool-goal',
source: 'packages/goal/tool-goal/src/index.ts',
requires: ['ctx.tools', 'ctx.agents', 'ctx.goals', 'ctx.systemPrompt', 'a calling Agent in an authorized open turn'],
writes: ['tool/call', 'context/message goal snapshot for mutations', 'tool/result'],
async mount(ctx) {
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService)
await ctx.plugin(ToolGoal)
},
note:
'create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds.',
},
{
pkg: '@deepseek-ai/dsh-tool-lsp',
dir: 'tool-lsp',
source: 'packages/lsp/tool-lsp/src/index.ts',
requires: ['ctx.tools', 'ctx.lsp', 'ctx.systemPrompt'],
writes: ['tool/call', 'tool/result'],
async mount(ctx) {
// The tool registers from the seam alone; the schema does not depend on any provider.
await ctx.plugin(Lsp)
await ctx.plugin(ToolLsp)
},
note:
'The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema.',
},
{
pkg: '@deepseek-ai/dsh-tool-ralph',
dir: 'tool-ralph',
source: 'packages/workflow/tool-ralph/src/index.ts',
requires: ['ctx.tools', 'ctx.workflows', 'ctx.subagents', 'ctx.systemPrompt', 'a calling Agent (exec.agent parents every fresh round)'],
writes: ['tool/call', 'tool/result', 'workflow and child session events during execution'],
async mount(ctx) {
await ctx.plugin(SubagentService)
registerCatalogSubagentProvider(ctx, 'mock')
await ctx.plugin(VmWorkflowEngine, { provider: 'mock' })
await ctx.plugin(ToolRalph, { subagentProvider: 'mock' })
},
note:
'A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap.',
},
{
pkg: '@deepseek-ai/dsh-tool-skill',
dir: 'tool-skill',
@@ -262,7 +329,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
await ctx.plugin(ToolSubagent, { provider: 'mock' })
},
note:
'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.',
'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.',
},
{
pkg: '@deepseek-ai/dsh-tool-tasks',
@@ -275,7 +342,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
await ctx.plugin(ToolTasks)
},
note:
'The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers\' `ctx.tasks.start()`.',
'The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers\' `ctx.tasks.start()`.',
},
{
pkg: '@deepseek-ai/dsh-tool-todo',

View File

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

View File

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

View File

@@ -0,0 +1,187 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
collectPackageInvariantViolations,
} from './package-invariants.ts'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function handwrittenInvariant(packageName: string): string {
return `
export const name = 'probe-invariant'
export const inject = ['invariants']
const install = (ctx: { on(name: string, listener: (value: number) => void): void }, fail: (message: string) => never) => {
ctx.on('probe/value', (value) => {
if (value < 0) fail('observed values must be non-negative')
})
}
export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) =>
Promise.resolve(ctx.invariants.register(${JSON.stringify(packageName)}, install))
`
}
function fixture(options: {
packageName?: string
source?: string
invariantExport?: boolean
invariantDependency?: boolean
invariantReference?: boolean
buildEntry?: boolean
} = {}): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-package-invariants-'))
roots.push(root)
const dir = join(root, 'packages/core/probe')
mkdirSync(join(dir, 'src'), { recursive: true })
const packageName = options.packageName ?? '@deepseek-ai/dsh-probe'
const manifest = {
name: packageName,
exports: options.invariantExport === false ? {} : {
'./invariant': {
types: './lib/types/invariant.d.ts',
default: './lib/invariant.js',
},
},
files: ['lib/index.js', 'lib/invariant.js', 'src'],
peerDependencies: options.invariantDependency === false ? {} : {
'@deepseek-ai/dsh-invariants': '^0.0.1',
},
devDependencies: options.invariantDependency === false ? {} : {
'@deepseek-ai/dsh-invariants': 'workspace:^',
},
}
writeFileSync(join(dir, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`)
writeFileSync(join(dir, 'tsconfig.json'), `${JSON.stringify({
references: options.invariantReference === false ? [] : [{ path: '../../support/invariants' }],
}, null, 2)}\n`)
writeFileSync(join(dir, 'src/invariant.ts'), options.source ?? handwrittenInvariant(packageName))
writeFileSync(
join(dir, 'tsdown.config.ts'),
options.buildEntry === false ? "export default { entry: ['lib/types/index.js'] }\n" : "export default { entry: ['lib/types/index.js', 'lib/types/invariant.js'] }\n",
)
return root
}
describe('package invariant gate', () => {
it('accepts a hand-owned checking companion with publication metadata', () => {
expect(collectPackageInvariantViolations(fixture())).toEqual([])
})
it('rejects missing publication metadata and build output', () => {
const violations = collectPackageInvariantViolations(fixture({
invariantExport: false,
invariantDependency: false,
invariantReference: false,
buildEntry: false,
}))
expect(violations.map(violation => violation.message)).toEqual(expect.arrayContaining([
expect.stringContaining('exports["./invariant"]'),
expect.stringContaining('peerDependency'),
expect.stringContaining('devDependency'),
expect.stringContaining('TypeScript project references'),
expect.stringContaining('must bundle lib/types/invariant.js'),
]))
})
it('rejects foreign, duplicate, and unresolved registrations', () => {
const source = `
export const name = 'probe-invariant'
export const inject = ['invariants']
const selected = process.env.PACKAGE_NAME
const install = (_ctx: unknown, fail: (message: string) => never) => { fail('probe') }
export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) => {
ctx.invariants.register('@deepseek-ai/dsh-foreign', install)
return ctx.invariants.register(selected!, install)
}
`
const violations = collectPackageInvariantViolations(fixture({ source }))
expect(violations.map(violation => violation.message)).toEqual(expect.arrayContaining([
expect.stringContaining('must resolve to a local string constant'),
expect.stringContaining('must register exactly its own package name'),
]))
})
it('rejects generated markers and reporter-free executable installers', () => {
const generated = fixture({
source: `/** @generated */\n${handwrittenInvariant('@deepseek-ai/dsh-probe')}`,
})
expect(collectPackageInvariantViolations(generated).map(violation => violation.message))
.toContain('invariant companions must be hand-owned and may not carry @generated markers')
const reporterFree = fixture({
source: `
export const name = 'probe-invariant'
export const inject = ['invariants']
const install = () => { void 0 }
export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) =>
Promise.resolve(ctx.invariants.register('@deepseek-ai/dsh-probe', install))
`,
})
expect(collectPackageInvariantViolations(reporterFree).map(violation => violation.message))
.toContain('install function must accept the bound failure reporter as its second parameter')
const unused = fixture({
source: `
export const name = 'probe-invariant'
export const inject = ['invariants']
const install = (_ctx: unknown, _fail: (message: string) => never) => { void 0 }
export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) =>
Promise.resolve(ctx.invariants.register('@deepseek-ai/dsh-probe', install))
`,
})
expect(collectPackageInvariantViolations(unused).map(violation => violation.message))
.toContain('install function must use its bound failure reporter')
})
it('rejects registering a different installer than the checked local function', () => {
const decoy = fixture({
source: `
export const name = 'probe-invariant'
export const inject = ['invariants']
const install = (_ctx: unknown, fail: (message: string) => never) => { fail('checked decoy') }
export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) =>
ctx.invariants.register('@deepseek-ai/dsh-probe', () => {})
`,
})
expect(collectPackageInvariantViolations(decoy).map(violation => violation.message))
.toContain('line 6: ctx.invariants.register must use the checked local install function')
})
it.each([
'export default { name, inject, apply }',
"export * as default from './probe.ts'",
])('rejects a default export that would collapse the Loader namespace', (defaultExport) => {
const source = `${handwrittenInvariant('@deepseek-ai/dsh-probe')}\n${defaultExport}\n`
expect(collectPackageInvariantViolations(fixture({ source })).map(violation => violation.message))
.toContain('must not default-export; Loader must retain the companion namespace')
})
it('accepts explained empty installers and rejects unexplained ones', () => {
const explained = `
export const name = 'probe-invariant'
export const inject = ['invariants']
const PACKAGE_NAME = '@deepseek-ai/dsh-probe'
/** No runtime invariant: this pure package owns no events or mutable data. */
const install = () => {}
export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) =>
ctx.invariants.register(PACKAGE_NAME, install)
`
expect(collectPackageInvariantViolations(fixture({ source: explained }))).toEqual([])
const unexplained = `
export const name = 'probe-invariant'
export const inject = ['invariants']
const PACKAGE_NAME = '@deepseek-ai/dsh-probe'
const install = () => {}
export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) =>
ctx.invariants.register(PACKAGE_NAME, install)
`
expect(collectPackageInvariantViolations(fixture({ source: unexplained })).map(violation => violation.message))
.toContain('empty install function must explain why with a "No runtime invariant:" comment')
})
})

View File

@@ -0,0 +1,340 @@
/**
* Package-invariant companion discovery and structural checks.
* The runtime registry stays product-independent; this gate makes ownership
* exhaustive across packages without centralizing package checks.
*/
import { existsSync, globSync, readFileSync } from 'node:fs'
import { dirname, relative, resolve, sep } from 'node:path'
import ts from 'typescript'
/** Required explanation marker for an intentionally empty installer. */
const NO_RUNTIME_INVARIANT_MARKER = 'No runtime invariant:'
interface PackageManifest {
name?: string
exports?: Record<string, { types?: string; default?: string } | string | undefined>
files?: string[]
peerDependencies?: Record<string, string>
devDependencies?: Record<string, string>
}
/** One package and the files participating in its invariant publication contract. */
export interface PackageInvariantOwner {
readonly dir: string
readonly manifestPath: string
readonly sourcePath: string
readonly packageName: string
}
/** One gate violation with a repo-relative owner path. */
export interface PackageInvariantViolation {
readonly path: string
readonly message: string
}
/** Discover every package under the repository package tree. */
export function packageInvariantOwners(root: string): PackageInvariantOwner[] {
return globSync('packages/*/*/package.json', { cwd: root })
.map(path => path.split(sep).join('/'))
.sort()
.map((manifestPath) => {
const manifest = readManifest(resolve(root, manifestPath))
if (manifest.name === undefined || manifest.name === '') {
throw new Error(`${manifestPath}: package invariant owner must declare a package name`)
}
const dir = dirname(manifestPath)
return {
dir,
manifestPath,
sourcePath: `${dir}/src/invariant.ts`,
packageName: manifest.name,
}
})
}
/** Return all violations of the package-invariant companion contract. */
export function collectPackageInvariantViolations(root: string): PackageInvariantViolation[] {
const violations: PackageInvariantViolation[] = []
for (const owner of packageInvariantOwners(root)) {
const manifest = readManifest(resolve(root, owner.manifestPath))
checkManifest(owner, manifest, violations)
checkBuild(owner, root, violations)
checkSource(owner, root, violations)
}
return violations
}
function readManifest(path: string): PackageManifest {
return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest
}
function addViolation(
violations: PackageInvariantViolation[],
path: string,
message: string,
): void {
violations.push({ path, message })
}
function checkManifest(
owner: PackageInvariantOwner,
manifest: PackageManifest,
violations: PackageInvariantViolation[],
): void {
const invariantExport = manifest.exports?.['./invariant']
if (typeof invariantExport !== 'object'
|| invariantExport.types !== './lib/types/invariant.d.ts'
|| invariantExport.default !== './lib/invariant.js') {
addViolation(
violations,
owner.manifestPath,
'exports["./invariant"] must target ./lib/types/invariant.d.ts and ./lib/invariant.js',
)
}
if (!manifest.files?.includes('lib/invariant.js')) {
addViolation(violations, owner.manifestPath, 'files must publish lib/invariant.js')
}
if (owner.packageName === '@deepseek-ai/dsh-invariants') return
if (manifest.peerDependencies?.['@deepseek-ai/dsh-invariants'] !== '^0.0.1') {
addViolation(
violations,
owner.manifestPath,
'@deepseek-ai/dsh-invariants must be a ^0.0.1 peerDependency',
)
}
if (manifest.devDependencies?.['@deepseek-ai/dsh-invariants'] !== 'workspace:^') {
addViolation(
violations,
owner.manifestPath,
'@deepseek-ai/dsh-invariants must also be a workspace:^ devDependency',
)
}
}
function checkBuild(
owner: PackageInvariantOwner,
root: string,
violations: PackageInvariantViolation[],
): void {
const tsconfigPath = `${owner.dir}/tsconfig.json`
const tsconfig = JSON.parse(readFileSync(resolve(root, tsconfigPath), 'utf8')) as {
references?: Array<{ path?: string }>
}
if (owner.packageName !== '@deepseek-ai/dsh-invariants'
&& !tsconfig.references?.some(reference => reference.path === '../../support/invariants')) {
addViolation(
violations,
tsconfigPath,
'TypeScript project references must include ../../support/invariants',
)
}
const configPath = `${owner.dir}/tsdown.config.ts`
if (!existsSync(resolve(root, configPath))) return
const source = readFileSync(resolve(root, configPath), 'utf8')
if (!source.includes('lib/types/invariant.js')) {
addViolation(violations, configPath, 'package build override must bundle lib/types/invariant.js')
}
}
function checkSource(
owner: PackageInvariantOwner,
root: string,
violations: PackageInvariantViolation[],
): void {
const absolutePath = resolve(root, owner.sourcePath)
if (!existsSync(absolutePath)) {
addViolation(violations, owner.sourcePath, 'missing package-owned invariant companion')
return
}
const sourceText = readFileSync(absolutePath, 'utf8')
if (sourceText.includes('@generated')) {
addViolation(
violations,
owner.sourcePath,
'invariant companions must be hand-owned and may not carry @generated markers',
)
}
const sourceFile = ts.createSourceFile(
absolutePath,
sourceText,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
)
const constants = topLevelStringConstants(sourceFile)
const registrations: string[] = []
const unresolved: number[] = []
const mismatchedInstallers: number[] = []
const visit = (node: ts.Node): void => {
if (ts.isCallExpression(node) && isInvariantRegistration(node.expression)) {
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1
const argument = node.arguments[0]
const packageName = argument === undefined ? undefined : stringValue(argument, constants)
if (packageName === undefined) unresolved.push(line)
else registrations.push(packageName)
const installer = node.arguments[1]
if (installer === undefined || !ts.isIdentifier(installer) || installer.text !== 'install') {
mismatchedInstallers.push(line)
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
for (const line of unresolved) {
addViolation(
violations,
owner.sourcePath,
`line ${line}: ctx.invariants.register package name must resolve to a local string constant`,
)
}
for (const line of mismatchedInstallers) {
addViolation(
violations,
owner.sourcePath,
`line ${line}: ctx.invariants.register must use the checked local install function`,
)
}
if (registrations.length !== 1 || registrations[0] !== owner.packageName) {
addViolation(
violations,
owner.sourcePath,
`must register exactly its own package name ${JSON.stringify(owner.packageName)}; saw ${JSON.stringify(registrations)}`,
)
}
for (const exportedName of ['name', 'inject', 'apply']) {
if (!hasNamedExport(sourceFile, exportedName)) {
addViolation(violations, owner.sourcePath, `must named-export ${exportedName}`)
}
}
if (hasDefaultExport(sourceFile)) {
addViolation(violations, owner.sourcePath, 'must not default-export; Loader must retain the companion namespace')
}
checkInstaller(owner, sourceFile, sourceText, violations)
}
function checkInstaller(
owner: PackageInvariantOwner,
sourceFile: ts.SourceFile,
sourceText: string,
violations: PackageInvariantViolation[],
): void {
let initializer: ts.Expression | undefined
let declarationStatement: ts.VariableStatement | undefined
for (const statement of sourceFile.statements) {
if (!ts.isVariableStatement(statement)) continue
for (const declaration of statement.declarationList.declarations) {
if (ts.isIdentifier(declaration.name)
&& declaration.name.text === 'install'
&& declaration.initializer !== undefined) {
initializer = declaration.initializer
declarationStatement = statement
}
}
}
const installer = initializer === undefined ? undefined : installerFunction(initializer)
if (installer === undefined) {
addViolation(violations, owner.sourcePath, 'must declare a local install function for package-owned checks')
return
}
if (ts.isBlock(installer.body) && installer.body.statements.length === 0) {
const declarationText = declarationStatement === undefined
? ''
: sourceText.slice(declarationStatement.getFullStart(), declarationStatement.getEnd())
if (!declarationText.includes(NO_RUNTIME_INVARIANT_MARKER)) {
addViolation(
violations,
owner.sourcePath,
`empty install function must explain why with a "${NO_RUNTIME_INVARIANT_MARKER}" comment`,
)
}
return
}
const reporter = installer.parameters[1]?.name
if (reporter === undefined || !ts.isIdentifier(reporter)) {
addViolation(violations, owner.sourcePath, 'install function must accept the bound failure reporter as its second parameter')
return
}
if (!usesIdentifier(installer.body, reporter.text)) {
addViolation(violations, owner.sourcePath, 'install function must use its bound failure reporter')
}
}
function usesIdentifier(node: ts.Node, name: string): boolean {
return ts.isIdentifier(node) && node.text === name
|| node.getChildren().some(child => usesIdentifier(child, name))
}
function installerFunction(
initializer: ts.Expression,
): ts.ArrowFunction | ts.FunctionExpression | undefined {
if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) return initializer
if (ts.isCallExpression(initializer)
&& ts.isPropertyAccessExpression(initializer.expression)
&& ts.isIdentifier(initializer.expression.expression)
&& initializer.expression.expression.text === 'Object'
&& initializer.expression.name.text === 'assign') {
const target = initializer.arguments[0]
if (target !== undefined && (ts.isArrowFunction(target) || ts.isFunctionExpression(target))) return target
}
return undefined
}
function topLevelStringConstants(sourceFile: ts.SourceFile): ReadonlyMap<string, string> {
const constants = new Map<string, string>()
for (const statement of sourceFile.statements) {
if (!ts.isVariableStatement(statement)) continue
for (const declaration of statement.declarationList.declarations) {
if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue
const value = stringValue(declaration.initializer, constants)
if (value !== undefined) constants.set(declaration.name.text, value)
}
}
return constants
}
function stringValue(node: ts.Expression, constants: ReadonlyMap<string, string>): string | undefined {
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text
if (ts.isIdentifier(node)) return constants.get(node.text)
return undefined
}
function isInvariantRegistration(expression: ts.LeftHandSideExpression): boolean {
return ts.isPropertyAccessExpression(expression)
&& expression.name.text === 'register'
&& ts.isPropertyAccessExpression(expression.expression)
&& expression.expression.name.text === 'invariants'
}
function hasNamedExport(sourceFile: ts.SourceFile, name: string): boolean {
return sourceFile.statements.some((statement) => {
if (!ts.isVariableStatement(statement)
|| !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) return false
return statement.declarationList.declarations.some(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === name)
})
}
function hasDefaultExport(sourceFile: ts.SourceFile): boolean {
return sourceFile.statements.some((statement) => {
if (ts.isExportAssignment(statement)) return true
const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined
if (modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword)) return true
if (!ts.isExportDeclaration(statement) || statement.exportClause === undefined) return false
if (ts.isNamespaceExport(statement.exportClause)) {
return statement.exportClause.name.text === 'default'
}
return statement.exportClause.elements.some(element => element.name.text === 'default')
})
}
/** Format violations for the command-line gate. */
export function formatPackageInvariantViolation(
root: string,
violation: PackageInvariantViolation,
): string {
const path = resolve(root, violation.path)
return `${relative(root, path)}: ${violation.message}`
}

View File

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

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

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

View File

@@ -5,9 +5,8 @@
* independent commands can overlap and which commands wait for built artifacts.
*/
import { spawn } from 'node:child_process'
import { readdir, rm } from 'node:fs/promises'
import { availableParallelism } from 'node:os'
import { join, resolve } from 'node:path'
import { resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
type Mode =
@@ -19,6 +18,8 @@ type Mode =
| 'ci-artifacts'
| 'node-compat'
| 'pre-push'
| 'manual-push'
| 'doc-sync'
type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped'
interface Gate {
@@ -88,21 +89,25 @@ function parseMode(raw: string | undefined): Mode {
case 'ci-artifacts':
case 'node-compat':
case 'pre-push':
case 'doc-sync':
return raw
default:
throw new Error(
`run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | node-compat | pre-push, got ${JSON.stringify(raw)}.`,
`run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | node-compat | pre-push | doc-sync, got ${JSON.stringify(raw)}.`,
)
}
}
function defaultConcurrency(selectedMode: Mode, total: number): ConcurrencyDefault {
const available = availableParallelism()
const modeLimit = selectedMode === 'pre-push' ? Math.min(4, available) : available
// Local modes cap workers: several doc gates each build a full ts.Program,
// so an uncapped default on a large host trades wall clock for memory blowups.
const localCap = selectedMode === 'pre-push' || selectedMode === 'doc-sync'
const modeLimit = localCap ? Math.min(4, available) : available
return {
workers: Math.min(total, modeLimit),
source: selectedMode === 'pre-push'
? `${available} available CPU(s), pre-push cap 4`
source: localCap
? `${available} available CPU(s), ${selectedMode} cap 4`
: `${available} available CPU(s)`,
}
}
@@ -181,15 +186,23 @@ function gatesForMode(selected: Mode): Gate[] {
'run',
'packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts',
], { label: 'source worker smoke' }),
pnpmExec('jsonl-zstd-smoke', [
'vitest',
'run',
'packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts',
], { label: 'JSONL Zstandard smoke' }),
]
case 'pre-push':
case 'pre-push': return []
case 'manual-push':
return [
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
pnpmScript('client-domain-graph', 'verify-client-domain-graph', { label: 'client domain graph' }),
pnpmScript('test', 'test'),
pnpmScript('duplication', 'duplication'),
snapshotGate(),
pnpmScript('build', 'build'),
pnpmScript('build:web', 'build:web'),
...hygieneLeafGates({ artifactNeeds: ['build'] }),
...docSyncLeafGates({
docTypecheckNeeds: ['build'],
@@ -197,6 +210,8 @@ function gatesForMode(selected: Mode): Gate[] {
}),
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
]
case 'doc-sync':
return docSyncLeafGates()
}
}
@@ -204,23 +219,23 @@ function ciPrimaryGates(): Gate[] {
return [
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
pnpmScript('constraints', 'constraints'),
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
pnpmScript('typecheck', 'typecheck'),
lintGate(),
pnpmScript('duplication', 'duplication'),
coverageGate(),
snapshotGate(),
demoSmokeGate({ needs: ['lint'] }),
...docSyncLeafGates(),
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
pnpmScript('knip', 'knip'),
pnpmScript('website-build', 'website:build', { label: 'website build' }),
pnpmScript('build', 'build', { needs: ['typecheck'] }),
pnpmScript('publint', 'publint', { needs: ['build'] }),
pnpmScript('node-next-types', 'verify-node-next-types', {
label: 'node-next types',
needs: ['build'],
}),
builtPackageInvariantsGate(['build']),
builtBinSmokeGate(),
]
}
@@ -229,20 +244,14 @@ function ciStaticGates(): Gate[] {
return [
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
pnpmScript('constraints', 'constraints'),
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
...staticDemoSmokeGates(),
...docSyncLeafGates(),
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
pnpmScript('knip', 'knip'),
pnpmScript('website-build', 'website:build', { label: 'website build' }),
]
}
function staticDemoSmokeGates(): Gate[] {
// Native Windows session persistence is outside the gates-only support scope.
return process.platform === 'win32' ? [] : [demoSmokeGate()]
}
function ciArtifactGates(): Gate[] {
return [
pnpmScript('build', 'build'),
@@ -251,6 +260,7 @@ function ciArtifactGates(): Gate[] {
label: 'node-next types',
needs: ['build'],
}),
builtPackageInvariantsGate(['build']),
builtBinSmokeGate(),
]
}
@@ -298,6 +308,13 @@ function snapshotGate(): Gate {
})
}
function builtPackageInvariantsGate(needs?: string[]): Gate {
return pnpmScript('built-package-invariants', 'verify-built-package-invariants', {
label: 'built package invariants',
...needs === undefined ? {} : { needs },
})
}
function positiveIntArg(envName: string, flag: string): string[] {
const raw = process.env[envName]
if (raw === undefined || raw === '') return []
@@ -314,6 +331,8 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
pnpmScript('knip', 'knip'),
pnpmScript('publint', 'publint', artifactOptions),
pnpmScript('constraints', 'constraints'),
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
builtPackageInvariantsGate(options.artifactNeeds),
pnpmScript('node-next-types', 'verify-node-next-types', {
label: 'node-next types',
...artifactOptions,
@@ -331,13 +350,13 @@ function docSyncLeafGates(options: {
return [
pnpmScript('doc-typecheck', 'doc-typecheck', docTypecheckOptions),
pnpmScript('cordis-catalog', 'verify-cordis-catalog', { label: 'cordis catalog' }),
pnpmScript('cordis-api', 'verify-cordis-api', { label: 'cordis api' }),
pnpmScript('export-jsdoc', 'verify-export-jsdoc', { label: 'export jsdoc' }),
pnpmScript('tool-catalog', 'verify-tool-catalog', { label: 'tool catalog' }),
pnpmScript('config-catalog', 'verify-config-catalog', { label: 'config catalog' }),
pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }),
pnpmScript('doc-graphs', 'verify-doc-graphs', { label: 'doc graphs' }),
pnpmScript('scoped-events', 'verify-scoped-events', { label: 'scoped events' }),
pnpmScript('website-api', 'verify-website-api', { label: 'website api' }),
pnpmScript('markdown-wrap', 'verify-md-wrap', { label: 'markdown wrap' }),
pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }),
pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }),
@@ -350,55 +369,20 @@ function docSyncLeafGates(options: {
pnpmScript('translation-prompt', 'verify-translation-prompt', { label: 'translation prompt' }),
pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }),
pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }),
// Keep the VitePress build in this single gate because projection rewrites website/.generated.
pnpmScript('docs-site', 'docs:check', { label: 'documentation site' }),
pnpmScript('package-readme-limitations', 'verify-package-readme-limitations', { label: 'package README limitations' }),
pnpmScript('website-yaml', 'verify-website-yaml', { label: 'website yaml' }),
]
}
function demoSmokeGate(options: { needs?: string[] } = {}): Gate {
const dependencyOptions = options.needs === undefined ? {} : { needs: options.needs }
return {
id: 'demo-smoke',
label: 'demo smoke',
displayCommand: 'pnpm run demo:echo',
...pnpmInvocation(['run', 'demo:echo']),
input: 'echo ci smoke\n',
...dependencyOptions,
verify: async (result) => {
const output = result.stdout + result.stderr
const sessionsRoot = join(root, '.sessions')
try {
if (!output.includes('[tool call] echo({"text":"ci smoke"})')) {
throw new Error('demo smoke did not show the echo tool call.')
}
if (!output.includes('[tool result] ECHO: CI SMOKE')) {
throw new Error('demo smoke did not show the echo tool result.')
}
const buckets = await readdir(sessionsRoot, { withFileTypes: true })
let found = false
for (const bucket of buckets) {
if (!bucket.isDirectory() || !bucket.name.startsWith('cwd-')) continue
const entries = await readdir(join(sessionsRoot, bucket.name))
if (entries.some(entry => /^main-session-.+\.jsonl$/.test(entry))) {
found = true
break
}
}
if (!found) throw new Error('demo smoke did not create a main-session JSONL log in a cwd bucket.')
} finally {
await rm(sessionsRoot, { recursive: true, force: true })
}
},
}
}
function builtBinSmokeGate(): Gate {
return pnpmExec('built-bin-smoke', [
'vitest',
'run',
'--config',
'vitest.e2e.config.ts',
'packages/examples/stdio-demo/tests/built-bin.e2e.ts',
'examples/headless-agent/tests/keyless-smoke.e2e.ts',
'examples/tui-agent/tests/tui-keyless-smoke.e2e.ts',
'packages/examples/cli-demo/tests/built-bin.e2e.ts',
'packages/examples/acp-demo/tests/built-bin.e2e.ts',
'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts',
@@ -410,6 +394,7 @@ function builtBinSmokeGate(): Gate {
], {
label: 'built-bin smoke',
needs: ['build'],
env: { DSH_EXAMPLE_MODE: 'lib' },
})
}

View File

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

View File

@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest'
import { Context, Service } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import { packageInvariantOwners } from './package-invariants.ts'
import {
testInvariantCompanionPaths,
testInvariantCompanions,
usesManualInvariantTree,
} from './test-invariants.ts'
declare module 'cordis' {
interface Context {
testInvariantProbe: TestInvariantProbe
}
}
class TestInvariantProbe extends Service {
constructor(ctx: Context) {
super(ctx, 'testInvariantProbe')
}
}
describe('global test invariant host', () => {
it('uses one exhaustive topology to reserve every package name with enabled checks', async () => {
const ctx = new Context()
await ctx.plugin(TestInvariantProbe)
const owners = packageInvariantOwners(process.cwd())
expect(Object.keys(testInvariantCompanions)).toHaveLength(owners.length)
const unreserved: string[] = []
for (const owner of owners) {
try {
const dispose = ctx.invariants.register(owner.packageName, () => {})
unreserved.push(owner.packageName)
dispose()
} catch (error) {
expect(error).toHaveProperty(
'message',
`invariants: package "${owner.packageName}" is already registered`,
)
}
}
expect(unreserved).toEqual([])
})
it('mounts the owning package companion while leaving non-package roots service-only', () => {
expect(testInvariantCompanionPaths('/repo/packages/core/tools/tests/tools.spec.ts'))
.toEqual(['../packages/core/tools/src/invariant.ts'])
expect(testInvariantCompanionPaths('/repo/examples/echo-agent/tests/echo.spec.ts')).toEqual([])
expect(testInvariantCompanionPaths('/repo/scripts/test-invariants.spec.ts'))
.toEqual(Object.keys(testInvariantCompanions).sort())
})
it('loads and executes every source companion through the real Loader shape', async () => {
const owners = new Map(packageInvariantOwners(process.cwd()).map(owner => [owner.sourcePath, owner.packageName]))
const registrations = new Map<string, string>()
const loader = Object.create(Loader.prototype) as Loader
const register = vi.fn((_packageName: string, installer: InvariantInstaller) => {
expect(typeof installer).toBe('function')
return () => {}
})
const fakeContext = { invariants: { register } } as unknown as Context
for (const [rawPath, companion] of Object.entries(testInvariantCompanions)) {
const path = rawPath.replace(/^\.\.\//, '')
expect(companion.default, path).toBeUndefined()
const unwrapped = loader.unwrapExports(companion) as typeof companion
expect(unwrapped, path).toBe(companion)
expect(typeof unwrapped.name, path).toBe('string')
expect(unwrapped.inject, path).toContain('invariants')
expect(typeof unwrapped.apply, path).toBe('function')
await unwrapped.apply(fakeContext)
const call = register.mock.calls.at(-1)
if (call === undefined) throw new Error(`${path}: companion did not register`)
registrations.set(path, call[0])
}
expect(registrations).toEqual(owners)
})
it('recognizes focused invariant suites without a package inventory', () => {
expect(usesManualInvariantTree('/repo/packages/core/session/tests/invariant.spec.ts')).toBe(true)
expect(usesManualInvariantTree('/repo/packages/core/session/tests/request-invariant-hmr.spec.ts')).toBe(true)
expect(usesManualInvariantTree('C:\\repo\\packages\\support\\invariants\\tests\\service.spec.ts')).toBe(true)
expect(usesManualInvariantTree('/repo/packages/examples/agent-spine-demo/tests/agent-core.spec.ts')).toBe(true)
expect(usesManualInvariantTree('/repo/packages/core/session/tests/session.spec.ts')).toBe(false)
})
})

150
scripts/test-invariants.ts Normal file
View File

@@ -0,0 +1,150 @@
/**
* Vitest-wide invariant host. Ordinary Cordis roots receive the invariant
* service with global enablement plus the current test package's companion.
* One topology test mounts every companion; focused invariant tests own their
* service topology explicitly.
*/
import { expect } from 'vitest'
import { RegistryService } from 'cordis'
import type { Context, Plugin } from 'cordis'
import InvariantService from '@deepseek-ai/dsh-invariants'
declare global {
interface ImportMeta {
/** Eager Vite module-glob expansion used by the Vitest setup file. */
glob<TModule>(pattern: string, options: { eager: true }): Record<string, TModule>
}
}
/** Loader-safe shape shared by every package invariant companion. */
export interface TestInvariantCompanion {
readonly name: string
readonly inject: readonly string[]
readonly default?: unknown
apply(ctx: Context): Promise<() => void>
}
/** Every package companion, discovered eagerly so coverage observes each registration. */
export const testInvariantCompanions: Readonly<Record<string, TestInvariantCompanion>> =
import.meta.glob<TestInvariantCompanion>('../packages/*/*/src/invariant.ts', { eager: true })
/** Manual-topology suites whose names cannot follow the focused invariant convention. */
const MANUAL_INVARIANT_TEST_EXCEPTIONS = [
'/packages/support/invariants/tests/service.spec.ts',
'/packages/examples/agent-spine-demo/tests/agent-core.spec.ts',
] as const
interface InvariantHost {
readonly fibers: readonly PluginFiber[]
readonly byCallback: ReadonlyMap<unknown, PluginFiber>
readonly ready: Promise<void>
}
type PluginFiber = ReturnType<RegistryService['plugin']>
const hosts = new WeakMap<Context, InvariantHost>()
// eslint-disable-next-line @typescript-eslint/unbound-method -- every call below supplies its RegistryService receiver explicitly.
const originalPlugin = RegistryService.prototype.plugin
RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, getOuterStack?: () => string[]) {
const testPath = expect.getState().testPath ?? ''
if (usesManualInvariantTree(testPath)) return originalPlugin.call(this, plugin, config, getOuterStack)
const root = this.ctx.root
const host = hosts.get(root) ?? startInvariantHost(root)
const callback = this.resolve(plugin)
const existing = callback === undefined ? undefined : host.byCallback.get(callback)
if (existing !== undefined) {
return this.ctx === root ? joinInvariantStartup(existing, host.ready) : existing
}
const fiber = originalPlugin.call(this, plugin, config, getOuterStack)
// A root-level await is the test's composition boundary. Nested plugin
// fibers must not await their own companion parent through the global host.
if (this.ctx !== root) return fiber
return joinInvariantStartup(fiber, host.ready)
}
/**
* Detect focused suites that construct service selection or companion lifecycle explicitly.
* @param testPath - absolute or repo-relative Vitest file path.
* @returns whether the global invariant host must leave the root untouched.
*/
export function usesManualInvariantTree(testPath: string): boolean {
const normalized = testPath.replaceAll('\\', '/')
if (/\/packages\/[^/]+\/[^/]+\/tests\/[^/]*invariant[^/]*\.spec\.ts$/.test(normalized)) return true
return MANUAL_INVARIANT_TEST_EXCEPTIONS.some(path => normalized.endsWith(path))
}
const ALL_COMPANION_TESTS = ['/scripts/test-invariants.spec.ts'] as const
/**
* Select the package companions that an ordinary test root must register.
* Package tests receive their owner's checks; the dedicated topology test
* receives every owner so coverage and exhaustive runtime registration remain
* independently enforced.
* @param testPath - absolute or repo-relative normalized Vitest file path.
* @returns sorted `import.meta.glob` keys for companions to mount.
*/
export function testInvariantCompanionPaths(testPath: string): string[] {
const normalized = testPath.replaceAll('\\', '/')
const allPaths = Object.keys(testInvariantCompanions).sort()
if (ALL_COMPANION_TESTS.some(path => normalized.endsWith(path))) return allPaths
const owner = normalized.match(/\/packages\/([^/]+)\/([^/]+)\/tests\//)
if (owner === null) return []
const companionPath = `../packages/${owner[1]}/${owner[2]}/src/invariant.ts`
if (testInvariantCompanions[companionPath] === undefined) {
throw new Error(`test invariants: package test has no companion at ${companionPath}`)
}
return [companionPath]
}
function startInvariantHost(root: Context): InvariantHost {
const fibers: PluginFiber[] = []
const byCallback = new Map<unknown, PluginFiber>()
const mount = (plugin: Plugin, config?: unknown): void => {
const fiber = originalPlugin.call(root.registry, plugin, config)
const callback = root.registry.resolve(plugin)
if (callback === undefined) throw new Error('test invariants: companion is not a valid Cordis plugin')
fibers.push(fiber)
byCallback.set(callback, fiber)
}
mount(InvariantService, { enabled: true })
const testPath = expect.getState().testPath ?? ''
const companionPaths = testInvariantCompanionPaths(testPath)
for (const path of companionPaths) {
const companion = testInvariantCompanions[path]
if (companion === undefined) {
throw new Error(`test invariants: selected companion vanished at ${path}`)
}
if (!companion.inject.includes('invariants')) {
throw new Error(`test invariants: ${path} must inject the invariant service`)
}
mount(companion)
}
const [serviceFiber, ...companionFibers] = fibers
if (serviceFiber === undefined) throw new Error('test invariants: service fiber was not mounted')
// A companion is initially PENDING on the invariant service, and Cordis
// Fiber.await() only joins work already in flight. Wait for the service to
// activate its dependants before joining their startup and failures.
const ready = serviceFiber.await()
.then(() => Promise.all(companionFibers.map(fiber => fiber.await())))
.then(() => undefined)
const host = { fibers, byCallback, ready }
hosts.set(root, host)
return host
}
function joinInvariantStartup(fiber: PluginFiber, invariantReady: Promise<void>): PluginFiber {
const readiness = fiber.await().then(async (loaded) => {
await invariantReady
return loaded
})
const joined = Object.create(fiber) as PluginFiber
joined.then = readiness.then.bind(readiness)
return joined
}

View File

@@ -1,6 +1,13 @@
{
"requiredSince": "2026-07-14",
"required": [
".agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md",
".agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md",
".agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md",
".agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md",
".agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md",
".agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md",
".agents/notes/implemented/process/2026-07-19-web-styling-system.md",
"README.md",
"docs/cookbook/adding-a-package.md",
"docs/cookbook/adding-a-tool.md",
@@ -11,8 +18,18 @@
"docs/development.md",
"docs/i18n/README.md",
"docs/i18n/translation-rules.md",
".agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md",
".agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md",
"docs/user/develop/basic/config.md",
"docs/user/develop/basic/index.md",
"docs/user/develop/basic/tool.md",
"docs/user/develop/framework/events.md",
"docs/user/develop/framework/index.md",
"docs/user/develop/framework/service.md",
"docs/user/develop/practice/index.md",
"docs/user/develop/practice/llm-adapter.md",
"docs/user/guide/config.md",
"docs/user/guide/index.md",
"docs/user/guide/quickstart.md",
"docs/user/index.md",
"python/README.md",
"python/sdk-runtime/README.md",
"python/sdk/README.md"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,102 @@
/** Verify every packed companion through its package self-reference under plain Node. */
import { spawnSync } from 'node:child_process'
import {
copyFileSync,
globSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
} from 'node:fs'
import { dirname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
const root = resolve(import.meta.dirname, '..')
const loaderUrl = pathToFileURL(resolve(root, 'vendor/loader/lib/index.js')).href
const failures = []
const manifests = globSync('packages/*/*/package.json', { cwd: root }).sort()
const packArgs = ['pack', '--dry-run', '--json', '--ignore-scripts']
// Windows cannot spawn npm's .cmd shim directly; setup-node installs this JS
// entrypoint beside node.exe, so the probe stays shell-free on every runner.
const npmInvocation = process.platform === 'win32'
? [process.execPath, [resolve(dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'), ...packArgs]]
: ['npm', packArgs]
for (const manifestPath of manifests) {
const packageDir = dirname(resolve(root, manifestPath))
const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8'))
const packageName = manifest.name
if (typeof packageName !== 'string' || packageName.length === 0) {
failures.push(`${manifestPath}: missing package name`)
continue
}
const pack = spawnSync(npmInvocation[0], npmInvocation[1], {
cwd: packageDir,
encoding: 'utf8',
})
if (pack.status !== 0) {
const detail = pack.error?.message
?? (pack.stderr.trim() || pack.stdout.trim() || `npm pack exited ${pack.status}`)
failures.push(`${packageName}: ${detail}`)
continue
}
let files
try {
const result = JSON.parse(pack.stdout)
files = result[0]?.files
if (!Array.isArray(files)) throw new Error('npm pack returned no file inventory')
} catch (error) {
failures.push(`${packageName}: cannot parse npm pack inventory: ${String(error)}`)
continue
}
// Keep the packed view below its owning package so Node reaches the real
// pnpm dependency links. Junctioning node_modules elsewhere breaks pnpm's
// relative workspace links on Windows.
const stagedPackageDir = mkdtempSync(resolve(packageDir, '.dsh-packed-invariant-'))
try {
for (const file of files) {
if (typeof file.path !== 'string'
|| (file.path !== 'package.json' && !file.path.startsWith('lib/'))) continue
const target = resolve(stagedPackageDir, file.path)
mkdirSync(dirname(target), { recursive: true })
copyFileSync(resolve(packageDir, file.path), target)
}
const probe = `
const companion = await import(${JSON.stringify(`${packageName}/invariant`)});
const { default: Loader } = await import(${JSON.stringify(loaderUrl)});
if ('default' in companion) throw new Error('companion has a default export');
const loader = Object.create(Loader.prototype);
const unwrapped = loader.unwrapExports(companion);
if (unwrapped !== companion) throw new Error('Loader collapsed the companion namespace');
if (typeof unwrapped.name !== 'string') throw new Error('companion name is missing');
if (!Array.isArray(unwrapped.inject) || !unwrapped.inject.includes('invariants')) {
throw new Error('companion does not inject invariants');
}
if (typeof unwrapped.apply !== 'function') throw new Error('companion apply is missing');
`
const result = spawnSync(process.execPath, ['--input-type=module', '--eval', probe], {
cwd: stagedPackageDir,
encoding: 'utf8',
})
if (result.status !== 0) {
const detail = result.error?.message
?? (result.stderr.trim() || result.stdout.trim() || `node exited ${result.status}`)
failures.push(`${packageName}: ${detail}`)
}
} finally {
rmSync(stagedPackageDir, { recursive: true, force: true })
}
}
if (failures.length > 0) {
console.error('verify-built-package-invariants: packed companion failures:')
for (const failure of failures) console.error(` ${failure}`)
process.exit(1)
}
console.log(`verify-built-package-invariants: ${manifests.length} packed companion(s) passed plain-Node Loader checks.`)

View File

@@ -0,0 +1,102 @@
/**
* Enforce intra-package domain layering inside `packages/client/*\/src/client/`.
* verify-module-graph covers package-level edges; this gate covers the
* directory level the future package split will land on: domain directories
* may import `contract/` and never each other, and only the assembly point
* (`apply.ts` / `index.ts`) may import across domains.
*
* Layer model (lower may not import higher):
* 0 contract/ shared contract surface (types + slot declarations)
* 1 <domain>/ + service domain implementations (skeleton/, chat/, ...)
* 2 apply.ts, index.ts assembly point and re-export shell
*
* Not yet wired into the gate sequence (loose-gate window); run directly:
* pnpm exec tsx scripts/verify-client-domain-graph.ts
*/
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join, resolve } from 'node:path'
const root = resolve(import.meta.dirname, '..')
const CLIENT_DIR = join(root, 'packages/client')
/** Directory names treated as the shared contract layer (importable by all). */
const CONTRACT_DIRS = new Set(['contract'])
/** Top-level client files allowed to import across domains (assembly layer). */
const ASSEMBLY_FILES = new Set(['apply.ts', 'index.ts', 'index.tsx'])
interface Violation { file: string; imported: string; reason: string }
/** Recursively list .ts/.tsx files under dir (relative paths). */
function listSources(dir: string, prefix = ''): string[] {
const out: string[] = []
for (const name of readdirSync(dir)) {
const full = join(dir, name)
const rel = prefix ? `${prefix}/${name}` : name
if (statSync(full).isDirectory()) out.push(...listSources(full, rel))
else if (/\.tsx?$/.test(name) && !/\.legacy\./.test(name)) out.push(rel)
}
return out
}
/** First path segment of a client-relative file, or '' for top-level files. */
function domainOf(rel: string): string {
const ix = rel.indexOf('/')
return ix === -1 ? '' : rel.slice(0, ix)
}
function checkPackage(pkgName: string, clientDir: string): Violation[] {
const violations: Violation[] = []
const files = listSources(clientDir)
for (const rel of files) {
const fromDomain = domainOf(rel)
const isAssembly = fromDomain === '' && ASSEMBLY_FILES.has(rel)
if (isAssembly) continue
const source = readFileSync(join(clientDir, rel), 'utf8')
for (const match of source.matchAll(/from\s+['"](\.[^'"]+)['"]/g)) {
const spec = match[1]
if (spec === undefined) continue
// Resolve the relative specifier against the importing file's directory
// to a client-dir-relative path.
const fromDir = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''
const parts = (fromDir ? fromDir.split('/') : [])
for (const seg of spec.split('/')) {
if (seg === '.') continue
if (seg === '..') parts.pop()
else parts.push(seg)
}
const target = parts.join('/')
if (target.startsWith('..')) continue // out of client dir (package root) — package-level rules govern
const toDomain = domainOf(target)
if (toDomain === '' || CONTRACT_DIRS.has(toDomain)) continue // top-level shared file or contract layer
if (fromDomain === toDomain) continue // inside one domain
violations.push({
file: `${pkgName}/src/client/${rel}`,
imported: spec,
reason: fromDomain === ''
? `top-level non-assembly file imports domain "${toDomain}" (only apply/index may assemble)`
: `domain "${fromDomain}" imports sibling domain "${toDomain}" (route shared surface through contract/)`,
})
}
}
return violations
}
const violations: Violation[] = []
for (const pkg of readdirSync(CLIENT_DIR)) {
const clientDir = join(CLIENT_DIR, pkg, 'src/client')
try {
if (!statSync(clientDir).isDirectory()) continue
} catch {
// No client half in this package — nothing to layer-check.
continue
}
violations.push(...checkPackage(pkg, clientDir))
}
if (violations.length > 0) {
console.error(`verify-client-domain-graph: ${violations.length} violation(s):`)
for (const v of violations) console.error(` ${v.file} -> ${v.imported}\n ${v.reason}`)
process.exit(1)
}
console.log('verify-client-domain-graph: client domain layering clean.')

View File

@@ -152,15 +152,29 @@ function localPackageDirectories(): Map<string, string> {
}
function rootProjectReferences(): Set<string> {
const config = ts.readConfigFile(resolve(root, 'tsconfig.json'), path => ts.sys.readFile(path))
if (config.error !== undefined) {
throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'))
// Typecheck runs two sibling aggregates (root = host program,
// tsconfig.client.json = client program; the two sides merge cordis Context
// under the same keys, so one program cannot see both). Seed both and follow
// any nested aggregate references to collect the covered leaf project set.
const collected = new Set<string>()
const queue = [resolve(root, 'tsconfig.json'), resolve(root, 'tsconfig.client.json')]
const seen = new Set<string>()
for (let file = queue.pop(); file !== undefined; file = queue.pop()) {
if (seen.has(file)) continue
seen.add(file)
const config = ts.readConfigFile(file, path => ts.sys.readFile(path))
if (config.error !== undefined) {
throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'))
}
const references = (config.config as { references?: Array<{ path?: unknown }> }).references ?? []
for (const reference of references) {
if (typeof reference.path !== 'string') continue
const target = resolve(dirname(file), reference.path)
if (target.endsWith('.json')) queue.push(target)
else collected.add(target)
}
}
const references = (config.config as { references?: Array<{ path?: unknown }> }).references ?? []
return new Set(references.flatMap((reference) => {
if (typeof reference.path !== 'string') return []
return [resolve(root, reference.path)]
}))
return collected
}
function packageNameFromSpecifier(specifier: string): string | undefined {

View File

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

View File

@@ -79,7 +79,10 @@ Object.defineProperty(globalThis, 'window', { value: window })
Object.defineProperty(globalThis, 'document', { value: window.document })
Object.defineProperty(globalThis, 'navigator', { value: window.navigator })
const mermaid = (await import('mermaid')).default
mermaid.initialize({ startOnLoad: false })
// maxEdges: mermaid's default 500-edge render guard; the module graph grows
// with every package edge and crossed it legitimately. Raise the guard here
// (a secure config settable only via initialize) rather than trimming edges.
mermaid.initialize({ startOnLoad: false, maxEdges: 1000 })
for (const block of blocks) {
try {
await mermaid.parse(block.source, { suppressErrors: false })

View File

@@ -0,0 +1,21 @@
/** Verify package-owned invariant source and publication contracts. */
import { resolve } from 'node:path'
import {
collectPackageInvariantViolations,
formatPackageInvariantViolation,
packageInvariantOwners,
} from './package-invariants.ts'
const root = resolve(import.meta.dirname, '..')
const violations = collectPackageInvariantViolations(root)
if (violations.length > 0) {
console.error('verify-package-invariants: violations found:')
for (const violation of violations) {
console.error(` ${formatPackageInvariantViolation(root, violation)}`)
}
process.exit(1)
}
console.log(`verify-package-invariants: ${packageInvariantOwners(root).length} hand-owned package companion(s) conform.`)

View File

@@ -45,13 +45,30 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' },
'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' },
'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' },
'packages/client/ui-slots': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-primitives': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/web-react': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/runtime': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/i18n': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' },
'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' },
'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
'packages/fs/fs-sandbox': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' },
'packages/host/apiproxy': { kind: 'none', reason: 'The wire contract and fetch carriers move already-composed messages and register no model surface.' },
'packages/host/runtime': { kind: 'indirect', reason: 'The assembly mounts model-facing plugins and injects provider/model defaults into agents.' },
'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' },
'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/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' },
'packages/lsp/lsp-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-lsp.' },
'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' },
'packages/sandbox/sandbox-policy': { kind: 'indirect', reason: 'The policy service holds the mode dsh-tool-bash and dsh-tool-fs render in their denial markers.' },
'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' },
@@ -76,7 +93,6 @@ 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.' },

View File

@@ -14,7 +14,7 @@ import ts from 'typescript'
const root = resolve(import.meta.dirname, '..')
/** Scan doc-typecheck's full Markdown scope so unmanifested blocks also fail. */
const MARKDOWN_GLOBS = ['README.md', '.agents/notes/**/*.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'website/zh-CN/**/*.md']
const MARKDOWN_GLOBS = ['README.md', '.agents/notes/**/*.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md']
/** One manifest entry: a source-equivalence block and its source symbol. */
interface ManifestEntry {

View File

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