Merge remote-tracking branch 'origin/master' into worktree/docs-website
# Conflicts: # docs/user/develop/basic/config.zh.md # docs/user/develop/basic/index.zh.md # docs/user/develop/basic/tool.zh.md # docs/user/develop/framework/index.zh.md # docs/user/develop/framework/service.zh.md # docs/user/develop/practice/index.zh.md # docs/user/guide/index.zh.md # package.json # pnpm-lock.yaml # pnpm-workspace.yaml # website/.vitepress/config/index.ts # website/.vitepress/config/zh-CN.ts # website/package.json # website/zh-CN/api/cordis/context.md # website/zh-CN/api/cordis/events.md # website/zh-CN/api/cordis/fiber.md # website/zh-CN/api/cordis/registry.md # website/zh-CN/api/cordis/service.md # website/zh-CN/api/harness/bash.md # website/zh-CN/api/harness/fs.md # website/zh-CN/api/harness/llm.md # website/zh-CN/api/harness/tools.md # website/zh-CN/api/index.md # website/zh-CN/design/composability.md # website/zh-CN/design/context-model.md # website/zh-CN/design/reactive-coeffects.md # website/zh-CN/design/revertible-effects.md # website/zh-CN/develop/framework/events.md # website/zh-CN/develop/practice/llm-adapter.md # website/zh-CN/guide/config.md
This commit is contained in:
@@ -116,11 +116,31 @@ const dshWorkerPackageFiles = [
|
||||
'src',
|
||||
] as const
|
||||
|
||||
const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
|
||||
'@deepseek-ai/dsh-helper': ['lib/assets'],
|
||||
'@deepseek-ai/dsh-scripts': [
|
||||
'lib/dev/tsdown-config.js',
|
||||
'lib/local-plugin-loader-hooks.js',
|
||||
'lib/assets',
|
||||
],
|
||||
}
|
||||
|
||||
function sameStringList(actual: readonly string[] | undefined, expected: readonly string[]): boolean {
|
||||
return !!actual && actual.length === expected.length && actual.every((value, index) => value === expected[index])
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
91
scripts/cordis-walk.ts
Normal file
91
scripts/cordis-walk.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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'
|
||||
import { parseJsDoc, pointer, rawJsDoc } from './jsdoc.ts'
|
||||
|
||||
/** The body of the cordis module merge in `sf`: `declare module 'cordis'`
|
||||
* (harness packages) or `declare module './context.ts'` (vendor core), or
|
||||
* null when the file has neither. */
|
||||
export function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
|
||||
for (const stmt of sf.statements) {
|
||||
if (!ts.isModuleDeclaration(stmt) || !ts.isStringLiteral(stmt.name)) continue
|
||||
if (stmt.name.text !== 'cordis' && stmt.name.text !== './context.ts') continue
|
||||
if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Every `interface Events` method member of a cordis module merge, with the
|
||||
* event name resolved from its (possibly string-literal) property name. */
|
||||
export function eventMembers(body: ts.ModuleBlock, sf: ts.SourceFile): { name: string; member: ts.MethodSignature }[] {
|
||||
const out: { name: string; member: ts.MethodSignature }[] = []
|
||||
for (const stmt of body.statements) {
|
||||
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue
|
||||
for (const member of stmt.members) {
|
||||
if (!ts.isMethodSignature(member)) continue
|
||||
const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf)
|
||||
out.push({ name, member })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The `ctx.<key> → type name` map declared by a merge's `interface Context`. */
|
||||
function contextKeyMap(body: ts.ModuleBlock, sf: ts.SourceFile): Map<string, string> {
|
||||
const keyToType = new Map<string, string>()
|
||||
for (const stmt of body.statements) {
|
||||
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue
|
||||
for (const member of stmt.members) {
|
||||
if (!ts.isPropertySignature(member) || !member.type) continue
|
||||
keyToType.set(member.name.getText(sf), member.type.getText(sf))
|
||||
}
|
||||
}
|
||||
return keyToType
|
||||
}
|
||||
|
||||
/** One `ctx.<key>` service class resolved from a Context merge. */
|
||||
export interface ServiceClass {
|
||||
key: string
|
||||
type: string
|
||||
cls: ts.ClassDeclaration
|
||||
abstract: boolean
|
||||
/** Class-level JSDoc prose (empty string when missing — also reported). */
|
||||
doc: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve each `ctx.<key>` of a merge to the service class declared in the
|
||||
* same file. A key whose type is not a class here (a Pick-mixin member, e.g.
|
||||
* timer helpers) is skipped. A class without JSDoc prose is reported into
|
||||
* `violations` (named `where` by the caller's gate).
|
||||
*
|
||||
* @param body — the cordis module merge body.
|
||||
* @param sf — the source file containing the merge.
|
||||
* @param rel — repo-relative path of `sf`, for violation pointers.
|
||||
* @param violations — sink for JSDoc-completeness violations.
|
||||
* @returns the resolved service classes, in Context-declaration order.
|
||||
*/
|
||||
export function serviceClasses(
|
||||
body: ts.ModuleBlock,
|
||||
sf: ts.SourceFile,
|
||||
rel: string,
|
||||
violations: string[],
|
||||
): ServiceClass[] {
|
||||
const text = sf.getFullText()
|
||||
const out: ServiceClass[] = []
|
||||
for (const [key, type] of contextKeyMap(body, sf)) {
|
||||
const cls = sf.statements.find(
|
||||
(s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === type,
|
||||
)
|
||||
if (!cls) continue // a Pick-mixin member, not a class here
|
||||
const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false
|
||||
const doc = parseJsDoc(rawJsDoc(text, cls)).doc
|
||||
if (!doc) violations.push(`service ctx.${key} (${pointer(rel, sf, cls)}): class ${type} has no JSDoc.`)
|
||||
out.push({ key, type, cls, abstract, doc })
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"AGENTS.md": 1370,
|
||||
"AGENTS.md": 1500,
|
||||
"docs/AGENTS.md": 1100,
|
||||
"docs/architecture.md": 1790,
|
||||
"docs/cordis-primer.md": 600,
|
||||
"docs/defensive-patterns.md": 550,
|
||||
"docs/testing.md": 800,
|
||||
"examples/AGENTS.md": 200,
|
||||
"docs/testing.md": 960,
|
||||
"examples/AGENTS.md": 310,
|
||||
"packages/AGENTS.md": 290,
|
||||
"packages/README.md": 760
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* Typecheck Markdown `ts` fences against workspace sources. `ignore-check`
|
||||
* fences are reported as opt-outs; generated catalog fragments and
|
||||
* `type-equiv` blocks are skipped here because their owning gates verify them.
|
||||
* Typecheck Markdown `ts` fences against the workspace API. `ignore-check` fences are reported as
|
||||
* opt-outs; generated catalog fragments and `type-equiv` blocks are skipped here because their
|
||||
* owning gates verify them. A build-coordinated mode consumes existing declarations without emit.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join, relative, resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { extractFences } from './md-fences.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
@@ -27,60 +28,123 @@ interface Block {
|
||||
code: string
|
||||
}
|
||||
|
||||
/** The info-string → kind table this gate tracks. */
|
||||
const KIND_BY_INFO: Record<string, BlockKind> = {
|
||||
'ts': 'check',
|
||||
'ts ignore-check': 'ignore',
|
||||
'ts type-equiv': 'type-equiv',
|
||||
'ts cordis-catalog': 'cordis-catalog',
|
||||
'ts persistence-catalog': 'persistence-catalog',
|
||||
'ts config-catalog': 'config-catalog',
|
||||
}
|
||||
|
||||
/** Extract every recognized TypeScript fence from one Markdown file. */
|
||||
function extractBlocks(absPath: string): Block[] {
|
||||
const text = readFileSync(absPath, 'utf8')
|
||||
const lines = text.split('\n')
|
||||
const file = relative(root, absPath)
|
||||
const blocks: Block[] = []
|
||||
let open: { line: number; kind: BlockKind; body: string[] } | null = null
|
||||
return extractFences(absPath, info => KIND_BY_INFO[info] ?? null)
|
||||
.map(f => ({ file, line: f.line, kind: f.kind, code: f.code }))
|
||||
}
|
||||
|
||||
lines.forEach((raw, i) => {
|
||||
const fence = /^```(\s*)(\S.*)?$/.exec(raw)
|
||||
if (!fence) {
|
||||
if (open) open.body.push(raw)
|
||||
return
|
||||
}
|
||||
if (open) {
|
||||
// closing fence
|
||||
blocks.push({ file, line: open.line, kind: open.kind, code: open.body.join('\n') })
|
||||
open = null
|
||||
return
|
||||
}
|
||||
// Ignore non-TypeScript fences.
|
||||
const info = (fence[2] ?? '').trim()
|
||||
const kind: BlockKind | null =
|
||||
info === 'ts' ? 'check'
|
||||
: info === 'ts ignore-check' ? 'ignore'
|
||||
: info === 'ts type-equiv' ? 'type-equiv'
|
||||
: info === 'ts cordis-catalog' ? 'cordis-catalog'
|
||||
: info === 'ts persistence-catalog' ? 'persistence-catalog'
|
||||
: info === 'ts config-catalog' ? 'config-catalog'
|
||||
: null
|
||||
if (kind) open = { line: i + 1, kind, body: [] }
|
||||
const configHost: ts.ParseConfigFileHost = {
|
||||
...ts.sys,
|
||||
getCurrentDirectory: () => root,
|
||||
onUnRecoverableConfigFileDiagnostic(diagnostic) {
|
||||
throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
|
||||
},
|
||||
}
|
||||
|
||||
/** Load root settings and redirect workspace aliases to declarations from the coordinated build. */
|
||||
function builtTypeCompilerOptions(): ts.CompilerOptions {
|
||||
const configPath = join(root, 'tsconfig.json')
|
||||
const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, configHost)
|
||||
if (!parsed) throw new Error(`doc-typecheck: cannot parse ${configPath}`)
|
||||
if (parsed.errors.length > 0) {
|
||||
throw new Error(parsed.errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n'))
|
||||
}
|
||||
if (parsed.options.paths === undefined) throw new Error('doc-typecheck: root tsconfig has no workspace paths')
|
||||
const paths = Object.fromEntries(Object.entries(parsed.options.paths).map(([specifier, candidates]) => [
|
||||
specifier,
|
||||
candidates.map((candidate) => {
|
||||
if (!candidate.endsWith('/src')) {
|
||||
throw new Error(`doc-typecheck: cannot map workspace source path to built declarations: ${candidate}`)
|
||||
}
|
||||
return `${candidate.slice(0, -'/src'.length)}/lib/types`
|
||||
}),
|
||||
]))
|
||||
const options: ts.CompilerOptions = {
|
||||
...parsed.options,
|
||||
paths,
|
||||
noEmit: true,
|
||||
composite: false,
|
||||
incremental: false,
|
||||
declaration: false,
|
||||
declarationMap: false,
|
||||
sourceMap: false,
|
||||
noUnusedLocals: false,
|
||||
noUnusedParameters: false,
|
||||
}
|
||||
delete options.tsBuildInfoFile
|
||||
return options
|
||||
}
|
||||
|
||||
/** Compile Markdown blocks as virtual files against declarations from the coordinated build. */
|
||||
function compileBlocksAgainstBuiltTypes(blocks: Block[]): readonly ts.Diagnostic[] {
|
||||
const options = builtTypeCompilerOptions()
|
||||
const sources = new Map<string, string>()
|
||||
for (const [index, block] of blocks.entries()) {
|
||||
const fileName = resolve(root, '.doc-typecheck', `block-${index}.ts`)
|
||||
sources.set(fileName, block.code.endsWith('\n') ? block.code : `${block.code}\n`)
|
||||
}
|
||||
|
||||
const baseHost = ts.createCompilerHost(options, true)
|
||||
const host: ts.CompilerHost = {
|
||||
...baseHost,
|
||||
fileExists(fileName) {
|
||||
return sources.has(resolve(fileName)) || baseHost.fileExists(fileName)
|
||||
},
|
||||
readFile(fileName) {
|
||||
return sources.get(resolve(fileName)) ?? baseHost.readFile(fileName)
|
||||
},
|
||||
getSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile) {
|
||||
const source = sources.get(resolve(fileName))
|
||||
if (source !== undefined) return ts.createSourceFile(fileName, source, languageVersion, true)
|
||||
return baseHost.getSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile)
|
||||
},
|
||||
writeFile() {
|
||||
throw new Error('doc-typecheck: noEmit compilation attempted to write output')
|
||||
},
|
||||
}
|
||||
const program = ts.createProgram([...sources.keys()], options, host)
|
||||
return ts.getPreEmitDiagnostics(program)
|
||||
}
|
||||
|
||||
/** Render compiler diagnostics with virtual block paths mapped back to Markdown. */
|
||||
function formatDiagnostics(diagnostics: readonly ts.Diagnostic[], blocks: Block[]): string {
|
||||
const formatted = ts.formatDiagnostics(diagnostics, {
|
||||
getCanonicalFileName: fileName => fileName,
|
||||
getCurrentDirectory: () => root,
|
||||
getNewLine: () => ts.sys.newLine,
|
||||
})
|
||||
return blocks
|
||||
return remapBlockPaths(formatted, blocks)
|
||||
}
|
||||
|
||||
/** Reuse the repo typecheck graph references from a temp project one directory below root. */
|
||||
function workspaceReferences(): { path: string }[] {
|
||||
const file = join(root, 'tsconfig.json')
|
||||
// Parse with TypeScript's own JSONC reader, not a hand-rolled comment strip:
|
||||
// a regex strip mistakes the `/*/` in a wildcard path candidate
|
||||
// (`./packages/core/*/src`) for a block comment and corrupts the map.
|
||||
const result = ts.readConfigFile(file, p => readFileSync(p, 'utf8'))
|
||||
// Parse with TypeScript's own JSONC reader: a regex comment stripper corrupts the `/*/` path
|
||||
// candidate in the workspace wildcard.
|
||||
const result = ts.readConfigFile(file, path => readFileSync(path, 'utf8'))
|
||||
if (result.error) {
|
||||
throw new Error(`doc-typecheck: cannot read ${file}: ${ts.flattenDiagnosticMessageText(result.error.messageText, '\n')}`)
|
||||
}
|
||||
// `config` is typed `any` by the TS API; narrow it to the one field we read.
|
||||
const { references } = result.config as { compilerOptions: { paths: Record<string, string[]> }; references: { path: string }[] }
|
||||
return references.map(({ path }) => {
|
||||
const relativeToTemp = path.startsWith('./') ? `../${path.slice(2)}` : `../${path}`
|
||||
return { path: relativeToTemp }
|
||||
})
|
||||
// `config` is typed `any` by the TS API; narrow it to the one field read here.
|
||||
const { references } = result.config as { references: { path: string }[] }
|
||||
return references.map(({ path }) => ({
|
||||
path: path.startsWith('./') ? `../${path.slice(2)}` : `../${path}`,
|
||||
}))
|
||||
}
|
||||
|
||||
/** The standalone tsconfig for the temp typecheck project. */
|
||||
/** The standalone temp project used when no coordinated build owns declaration freshness. */
|
||||
function tempTsconfig(): string {
|
||||
return JSON.stringify({
|
||||
extends: '../tsconfig.json',
|
||||
@@ -94,6 +158,39 @@ function tempTsconfig(): string {
|
||||
})
|
||||
}
|
||||
|
||||
/** Compile blocks through project references for the standalone command. */
|
||||
function compileBlocksStandalone(blocks: Block[]): string | undefined {
|
||||
const tmp = mkdtempSync(join(root, '.doc-typecheck-'))
|
||||
try {
|
||||
writeFileSync(join(tmp, 'tsconfig.json'), tempTsconfig())
|
||||
for (const [index, block] of blocks.entries()) {
|
||||
writeFileSync(join(tmp, `block-${index}.ts`), block.code.endsWith('\n') ? block.code : `${block.code}\n`)
|
||||
}
|
||||
try {
|
||||
// Invoke tsc's JS entry through Node instead of a platform-specific shell shim.
|
||||
execFileSync(process.execPath, ['node_modules/typescript/bin/tsc', '-b', join(tmp, 'tsconfig.json')], {
|
||||
cwd: root,
|
||||
stdio: 'pipe',
|
||||
})
|
||||
return undefined
|
||||
} catch (error: unknown) {
|
||||
const failed = error as { stdout?: Buffer; stderr?: Buffer }
|
||||
return remapBlockPaths(`${failed.stdout?.toString() ?? ''}${failed.stderr?.toString() ?? ''}`, blocks)
|
||||
}
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
/** Map virtual or temporary block paths back to their owning Markdown fences. */
|
||||
function remapBlockPaths(output: string, blocks: Block[]): string {
|
||||
return output.replace(/(?:[^\s:()]*[/\\])?block-(\d+)\.ts\((\d+),(\d+)\)/g, (_match, index: string, line: string, column: string) => {
|
||||
const block = blocks[Number(index)]
|
||||
if (!block) return `block-${index}.ts(${line},${column})`
|
||||
return `${block.file} (block at line ${block.line}, +${line}:${column})`
|
||||
})
|
||||
}
|
||||
|
||||
const markdownGlobs = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md']
|
||||
|
||||
const files: string[] = []
|
||||
@@ -114,45 +211,24 @@ if (checked.length === 0) {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const tmp = mkdtempSync(join(root, '.doc-typecheck-'))
|
||||
try {
|
||||
writeFileSync(join(tmp, 'tsconfig.json'), tempTsconfig())
|
||||
const fileForBlock = new Map<string, Block>()
|
||||
checked.forEach((block, i) => {
|
||||
const name = `block-${i}.ts`
|
||||
writeFileSync(join(tmp, name), block.code.endsWith('\n') ? block.code : `${block.code}\n`)
|
||||
fileForBlock.set(name, block)
|
||||
})
|
||||
|
||||
try {
|
||||
// tsc's JS entry via the current node, not the .bin shim: the extensionless
|
||||
// shim is not spawnable on Windows (the CVE-2024-27980 class the sibling
|
||||
// scripts hit), and the .cmd variant would need shell:true, which
|
||||
// concatenates args UNESCAPED — a hazard for the temp project path. The JS
|
||||
// entry behaves identically on every platform.
|
||||
execFileSync(process.execPath, ['node_modules/typescript/bin/tsc', '-b', join(tmp, 'tsconfig.json')], { cwd: root, stdio: 'pipe' })
|
||||
} catch (error: unknown) {
|
||||
const failed = error as { stdout?: Buffer; stderr?: Buffer }
|
||||
const out = `${failed.stdout?.toString() ?? ''}${failed.stderr?.toString() ?? ''}`
|
||||
// Rewrite "block-N.ts(line,col)" to the real "file:fenceLine" for triage.
|
||||
const remapped = out.replace(/(?:[^\s:()]*[/\\])?block-(\d+)\.ts\((\d+),(\d+)\)/g, (_m, idx: string, ln: string, col: string) => {
|
||||
const block = fileForBlock.get(`block-${idx}.ts`)
|
||||
if (!block) return `block-${idx}.ts(${ln},${col})`
|
||||
return `${block.file} (block at line ${block.line}, +${ln}:${col})`
|
||||
})
|
||||
console.error('doc-typecheck: documentation code blocks failed to compile.\n')
|
||||
console.error(remapped)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const ratio = ignored.length / ratioDenominator
|
||||
const skipped = all.length - ratioDenominator
|
||||
console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${skipped} type-equiv/catalog (checked elsewhere).`)
|
||||
// Guard against the escape hatch becoming the norm.
|
||||
if (ratioDenominator >= 4 && ratio > 0.5) {
|
||||
console.error(`doc-typecheck: too many blocks opt out of checking (${ignored.length}/${ratioDenominator}). Make them compile or delete them.`)
|
||||
process.exit(1)
|
||||
}
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
const useBuiltTypes = process.env.DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT === '1'
|
||||
const compilationError = useBuiltTypes
|
||||
? (() => {
|
||||
const diagnostics = compileBlocksAgainstBuiltTypes(checked)
|
||||
return diagnostics.length === 0 ? undefined : formatDiagnostics(diagnostics, checked)
|
||||
})()
|
||||
: compileBlocksStandalone(checked)
|
||||
if (compilationError !== undefined) {
|
||||
console.error('doc-typecheck: documentation code blocks failed to compile.\n')
|
||||
console.error(compilationError)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const ratio = ignored.length / ratioDenominator
|
||||
const skipped = all.length - ratioDenominator
|
||||
console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${skipped} type-equiv/catalog (checked elsewhere).`)
|
||||
// Guard against the escape hatch becoming the norm.
|
||||
if (ratioDenominator >= 4 && ratio > 0.5) {
|
||||
console.error(`doc-typecheck: too many blocks opt out of checking (${ignored.length}/${ratioDenominator}). Make them compile or delete them.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts'
|
||||
import { cordisModuleBody, eventMembers, serviceClasses } from './cordis-walk.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT_EVENTS = 'docs/cordis-catalog/events.md'
|
||||
@@ -37,6 +38,7 @@ export const LINK_MAP: Record<string, string> = {
|
||||
TurnEndReason: 'session.md',
|
||||
ToolDefinition: 'tools.md',
|
||||
ToolExecution: 'tools.md',
|
||||
ToolExecutionMode: 'tools.md',
|
||||
ToolExecutionInput: 'tools.md',
|
||||
ToolExecutionResult: 'tools.md',
|
||||
ToolExecutionToken: 'tools.md',
|
||||
@@ -46,8 +48,6 @@ export const LINK_MAP: Record<string, string> = {
|
||||
BashExecRequest: 'bash.md',
|
||||
BashExecSpec: 'bash.md',
|
||||
BashRunResult: 'bash.md',
|
||||
BashTask: 'bash.md',
|
||||
BashTaskRead: 'bash.md',
|
||||
ConfinedArgv: 'sandbox.md',
|
||||
SandboxMode: 'sandbox.md',
|
||||
SandboxPolicy: 'sandbox.md',
|
||||
@@ -104,15 +104,7 @@ interface InheritedEntry {
|
||||
source: string
|
||||
}
|
||||
|
||||
/** Find the `declare module 'cordis'` body in a source file, or null. */
|
||||
function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
|
||||
for (const stmt of sf.statements) {
|
||||
if (ts.isModuleDeclaration(stmt) && ts.isStringLiteral(stmt.name) && stmt.name.text === 'cordis') {
|
||||
if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
// cordisModuleBody / eventMembers / serviceClasses live in cordis-walk.ts.
|
||||
|
||||
/** The signature text of a method-signature member (everything but a body). */
|
||||
function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.SourceFile): string {
|
||||
@@ -136,38 +128,33 @@ export function collectEvents(scanRoot: string = root): EventEntry[] {
|
||||
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
|
||||
const body = cordisModuleBody(sf)
|
||||
if (!body) continue
|
||||
for (const stmt of body.statements) {
|
||||
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue
|
||||
for (const member of stmt.members) {
|
||||
if (!ts.isMethodSignature(member)) continue
|
||||
const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf)
|
||||
const signature = memberSignature(member, sf)
|
||||
const raw = rawJsDoc(text, member)
|
||||
const { doc, mode } = parseJsDoc(raw)
|
||||
const src = pointer(rel, sf, member)
|
||||
const where = `event '${name}' (${src})`
|
||||
if (!mode) {
|
||||
violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
|
||||
}
|
||||
// Conclusive structural check: a trailing `next: () => …` parameter is a
|
||||
// waterfall. (emit vs parallel vs serial is not structurally
|
||||
// distinguishable, so it is trusted from the tag.)
|
||||
const last = member.parameters.at(-1)
|
||||
const hasNext = !!last && last.name.getText(sf) === 'next'
|
||||
if (mode && hasNext && mode !== 'waterfall') {
|
||||
violations.push(`${where} has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`)
|
||||
}
|
||||
if (mode && !hasNext && mode === 'waterfall') {
|
||||
violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
|
||||
}
|
||||
if (!doc) violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`)
|
||||
// Payload parameters need a non-empty @param. The `this` receiver is not
|
||||
// payload, and a waterfall's trailing `next` is covered by its mode.
|
||||
const { params } = parseTags(raw)
|
||||
checkParams(where, 'event', member.parameters, params, sf,
|
||||
p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations)
|
||||
if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src })
|
||||
for (const { name, member } of eventMembers(body, sf)) {
|
||||
const signature = memberSignature(member, sf)
|
||||
const raw = rawJsDoc(text, member)
|
||||
const { doc, mode } = parseJsDoc(raw)
|
||||
const src = pointer(rel, sf, member)
|
||||
const where = `event '${name}' (${src})`
|
||||
if (!mode) {
|
||||
violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
|
||||
}
|
||||
// Conclusive structural check: a trailing `next: () => …` parameter is a
|
||||
// waterfall. (emit vs parallel vs serial is not structurally
|
||||
// distinguishable, so it is trusted from the tag.)
|
||||
const last = member.parameters.at(-1)
|
||||
const hasNext = !!last && last.name.getText(sf) === 'next'
|
||||
if (mode && hasNext && mode !== 'waterfall') {
|
||||
violations.push(`${where} has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`)
|
||||
}
|
||||
if (mode && !hasNext && mode === 'waterfall') {
|
||||
violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
|
||||
}
|
||||
if (!doc) violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`)
|
||||
// Payload parameters need a non-empty @param. The `this` receiver is not
|
||||
// payload, and a waterfall's trailing `next` is covered by its mode.
|
||||
const { params } = parseTags(raw)
|
||||
checkParams(where, 'event', member.parameters, params, sf,
|
||||
p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations)
|
||||
if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src })
|
||||
}
|
||||
}
|
||||
reportViolations('gen-cordis-catalog', violations)
|
||||
@@ -190,26 +177,8 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
|
||||
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
|
||||
const body = cordisModuleBody(sf)
|
||||
if (!body) continue
|
||||
// The ctx key → type mapping(s) declared in this file's interface Context.
|
||||
const keyToType = new Map<string, string>()
|
||||
for (const stmt of body.statements) {
|
||||
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue
|
||||
for (const member of stmt.members) {
|
||||
if (!ts.isPropertySignature(member) || !member.type) continue
|
||||
const key = member.name.getText(sf)
|
||||
keyToType.set(key, member.type.getText(sf))
|
||||
}
|
||||
}
|
||||
if (keyToType.size === 0) continue
|
||||
// Find each service class declared in the same file and emit an entry.
|
||||
for (const [key, type] of keyToType) {
|
||||
const cls = sf.statements.find(
|
||||
(s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === type,
|
||||
)
|
||||
if (!cls) continue // a Pick-mixin member (e.g. timer helpers), not a class here
|
||||
const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false
|
||||
const clsDoc = parseJsDoc(rawJsDoc(text, cls)).doc
|
||||
if (!clsDoc) violations.push(`service ctx.${key} (${pointer(rel, sf, cls)}): class ${type} has no JSDoc.`)
|
||||
// Resolve each ctx key to its service class (shared walk) and emit an entry.
|
||||
for (const { key, type, cls, abstract, doc: clsDoc } of serviceClasses(body, sf, rel, violations)) {
|
||||
const methods: string[] = []
|
||||
for (const member of cls.members) {
|
||||
if (!ts.isMethodDeclaration(member)) continue
|
||||
@@ -260,14 +229,14 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
|
||||
* sibling check is N/A; keep them current on a vendor bump.
|
||||
*/
|
||||
const INHERITED_EVENTS: InheritedEntry[] = [
|
||||
{ name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:197' },
|
||||
{ name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:198' },
|
||||
{ name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:199' },
|
||||
{ name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:200' },
|
||||
{ name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:201' },
|
||||
{ name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:202' },
|
||||
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:203' },
|
||||
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:204' },
|
||||
{ name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:328' },
|
||||
{ name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:330' },
|
||||
{ name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:332' },
|
||||
{ name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:334' },
|
||||
{ name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:336' },
|
||||
{ name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:338' },
|
||||
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:340' },
|
||||
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:342' },
|
||||
{ name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' },
|
||||
{ name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' },
|
||||
{ name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' },
|
||||
@@ -278,12 +247,12 @@ const INHERITED_EVENTS: InheritedEntry[] = [
|
||||
]
|
||||
|
||||
export const INHERITED_SERVICES: InheritedEntry[] = [
|
||||
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:29' },
|
||||
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:29' },
|
||||
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:144' },
|
||||
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:34' },
|
||||
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:34' },
|
||||
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:164' },
|
||||
{ name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.', source: 'vendor/cordis/src/fiber.ts:9' },
|
||||
{ name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.', source: 'vendor/cordis/src/reflect.ts:7' },
|
||||
{ name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).', source: 'vendor/cordis/src/context.ts:35' },
|
||||
{ name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).', source: 'vendor/cordis/src/context.ts:42' },
|
||||
{ name: 'ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger', summary: 'Ambient handles onto the running context graph.', source: 'vendor/cordis/src/context.ts:16' },
|
||||
{ name: 'ctx.timer (+ interval / timeout / throttle / debounce / setTimeout / setInterval)', summary: 'Disposable timer helpers. The `timer` key is provided at runtime; the six helpers are mixed onto ctx directly (declared via Pick).', source: 'vendor/timer/src/index.ts:4' },
|
||||
{ name: 'ctx.loader', summary: 'The config Loader that booted the app (present under the loader).', source: 'vendor/loader/src/index.ts:30' },
|
||||
|
||||
@@ -64,7 +64,10 @@ const GROUP_ORDER = [
|
||||
'skill',
|
||||
'compact',
|
||||
'subagent',
|
||||
'tasks',
|
||||
'workflow',
|
||||
'web',
|
||||
'spill',
|
||||
'todo',
|
||||
'cordis',
|
||||
'hooks',
|
||||
@@ -84,6 +87,14 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
consumers: ['agent-loop', 'compact-basic'],
|
||||
note: 'Adapters register provider implementations; the loop and compaction call the provider-neutral stream service.',
|
||||
},
|
||||
{
|
||||
key: 'tokenMeter',
|
||||
pkg: 'token-meter',
|
||||
title: 'Replay token measurement',
|
||||
mode: 'core',
|
||||
consumers: ['compact-basic'],
|
||||
note: 'Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements.',
|
||||
},
|
||||
{
|
||||
key: 'sessions',
|
||||
pkg: 'session',
|
||||
@@ -98,15 +109,15 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
title: 'Durable session persistence seam',
|
||||
mode: 'seam',
|
||||
implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'],
|
||||
consumers: ['agent-loop', 'acp', 'session-query'],
|
||||
consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'acp', 'session-query'],
|
||||
note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.',
|
||||
},
|
||||
{
|
||||
key: 'sessionQuery',
|
||||
pkg: 'session-query',
|
||||
title: 'Exact session-history reads',
|
||||
title: 'Exact session-history reads and traces',
|
||||
mode: 'seam',
|
||||
note: 'Resolves live and optional persisted logs into one logical corpus for exact reads.',
|
||||
note: 'Resolves live and optional persisted logs into one logical corpus for exact reads and relationship traces.',
|
||||
},
|
||||
{
|
||||
key: 'systemPrompt',
|
||||
@@ -167,6 +178,13 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
consumers: ['tool-bash', 'hooks-claude', 'hooks-codex'],
|
||||
note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them.',
|
||||
},
|
||||
{
|
||||
key: 'bashEnv',
|
||||
pkg: 'tool-bash',
|
||||
title: 'Managed bash environment registry',
|
||||
mode: 'core',
|
||||
note: 'Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace.',
|
||||
},
|
||||
{
|
||||
key: 'sandbox',
|
||||
pkg: 'sandbox',
|
||||
@@ -231,6 +249,14 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
consumers: ['tool-subagent'],
|
||||
note: 'Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name.',
|
||||
},
|
||||
{
|
||||
key: 'tasks',
|
||||
pkg: 'tasks',
|
||||
title: 'Background task registry',
|
||||
mode: 'core',
|
||||
consumers: ['tool-bash', 'tool-subagent', 'tool-tasks'],
|
||||
note: 'Producers (tool-bash background commands, tool-subagent background delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it.',
|
||||
},
|
||||
{
|
||||
key: 'web',
|
||||
pkg: 'web',
|
||||
@@ -240,6 +266,15 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
consumers: ['tool-web'],
|
||||
note: 'Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names.',
|
||||
},
|
||||
{
|
||||
key: 'spillStore',
|
||||
pkg: 'spill',
|
||||
title: 'Spill storage seam',
|
||||
mode: 'seam',
|
||||
implementations: ['spill-local'],
|
||||
consumers: ['spill-policy'],
|
||||
note: 'The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill.',
|
||||
},
|
||||
{
|
||||
key: 'workflows',
|
||||
pkg: 'workflow',
|
||||
@@ -812,10 +847,19 @@ function renderLifecycle(): string {
|
||||
` Session-->>SDK: ${mermaidCode('session/event')} ${mermaidCode('assistant/chunk')}*`,
|
||||
` Driver->>Hooks: ${mermaidCode('agent/step-result')} waterfall`,
|
||||
` Driver->>Session: ${mermaidCode('assistant/message')}`,
|
||||
` Driver->>Session: ${mermaidCode('tool/call')}`,
|
||||
' Driver->>Tools: execute through pre and post waterfalls',
|
||||
' Tools-->>Session: tool-owned events when applicable',
|
||||
` Driver->>Session: ${mermaidCode('tool/result')} and ${mermaidCode('step/end')}`,
|
||||
' Driver->>Tools: classify pending call by executionMode',
|
||||
' loop barriers and bounded rolling pool, reclassify before start',
|
||||
' opt call starts',
|
||||
` Driver->>Session: ${mermaidCode('tool/call')}`,
|
||||
' Driver->>Tools: ordered pre, concurrent execute',
|
||||
' Tools-->>Session: tool-owned events when applicable',
|
||||
' end',
|
||||
' opt next model-order result ready',
|
||||
' Driver->>Tools: ordered post',
|
||||
` Driver->>Session: ${mermaidCode('tool/result')}`,
|
||||
' end',
|
||||
' end',
|
||||
` Driver->>Session: ${mermaidCode('step/end')}`,
|
||||
` Driver->>Hooks: ${mermaidCode('agent/turn-continuation')} waterfall`,
|
||||
` Driver->>Hooks: ${mermaidCode('agent/turn-stop')} serial terminal checkpoint`,
|
||||
` Driver->>Session: ${mermaidCode('turn/end')}`,
|
||||
@@ -823,6 +867,8 @@ function renderLifecycle(): string {
|
||||
` Driver-->>SDK: ${mermaidCode('agent/status')} idle`,
|
||||
'```',
|
||||
'',
|
||||
'The `assistant/message` edge records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history while the durable anchor retains usage and exact chunk provenance, including an explicit empty source set.',
|
||||
'',
|
||||
'SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors.',
|
||||
'',
|
||||
...maintenanceFooter(maintenance),
|
||||
@@ -850,9 +896,9 @@ function renderToolPipeline(): string {
|
||||
` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`,
|
||||
` post["${mermaidCode('tools/post-execute')} waterfall<br/>accept, block, replace, add context"]`,
|
||||
` final["${mermaidCode('tools/result')} synchronous notification<br/>frozen authoritative outcome"]`,
|
||||
' context["Buffered additionalContext<br/>context/message after all tool results"]',
|
||||
' context["Active-batch additionalContexts FIFO<br/>context/message after recorded tool results"]',
|
||||
` toolResult["Session event: ${mermaidCode('tool/result')}<br/>single model-facing outcome"]`,
|
||||
' allResults["All calls in the step settled<br/>and tool/result events recorded"]',
|
||||
' allResults["Tool batch settled<br/>recorded tool/result events complete"]',
|
||||
' presentResult["UI completed card<br/>presentResult(args, result)"]',
|
||||
' model --> toolCall',
|
||||
' toolCall --> presentCall',
|
||||
@@ -878,7 +924,7 @@ function renderToolPipeline(): string {
|
||||
' allResults --> context',
|
||||
'```',
|
||||
'',
|
||||
'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`, while `tools/result` observes the immutable outcome after transforms, lossless-JSON validation, and outer error normalization. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContext` to preserve call/result adjacency.',
|
||||
'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`, while `tools/result` observes the immutable outcome after transforms, lossless-JSON validation, and outer error normalization. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency.',
|
||||
'',
|
||||
...maintenanceFooter(maintenance),
|
||||
].join('\n')
|
||||
|
||||
@@ -27,6 +27,7 @@ const GROUP_ORDER = [
|
||||
'compact',
|
||||
'subagent',
|
||||
'web',
|
||||
'spill',
|
||||
'timeout',
|
||||
'todo',
|
||||
'cordis',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Generate `docs/persistence-catalog.md` from every `SessionEventMap` merge and
|
||||
* the owning `SurfaceEventType` union. This is the durable-record vocabulary,
|
||||
* not the live Cordis bus. Event declarations must be unique, explicitly typed,
|
||||
* the owning event-envelope types. This is the durable-record vocabulary, not
|
||||
* the live Cordis bus. Event declarations must be unique, explicitly typed,
|
||||
* documented, inheritance-free, and free of Cordis-only `@mode` tags; every
|
||||
* surface-union member must resolve to one. `--check` verifies the artifact.
|
||||
*/
|
||||
@@ -14,13 +14,23 @@ import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts'
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'docs/persistence-catalog.md'
|
||||
|
||||
/** The fenced-block info string for generated payload blocks (skipped by
|
||||
* doc-typecheck, since a bare payload fragment is not standalone-compilable). */
|
||||
/** The fenced-block info string for generated declaration blocks (skipped by
|
||||
* doc-typecheck, since their imported types are not standalone-compilable). */
|
||||
const FENCE = 'ts persistence-catalog'
|
||||
|
||||
/** The package whose module id plugin merges augment (`declare module '…'`). */
|
||||
const SESSION_MODULE = '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Event-envelope declarations rendered before the per-event vocabulary. */
|
||||
const EVENT_ENVELOPE_TYPE_NAMES = [
|
||||
'SessionEventType',
|
||||
'SurfaceEventType',
|
||||
'SurfaceOp',
|
||||
'SessionEvent',
|
||||
] as const
|
||||
|
||||
type EventEnvelopeTypeName = typeof EVENT_ENVELOPE_TYPE_NAMES[number]
|
||||
|
||||
/** Primary core-data-structures page for linked payload types. */
|
||||
const LINK_MAP: Record<string, string> = {
|
||||
CallId: 'core.md',
|
||||
@@ -41,6 +51,8 @@ export interface LogEventEntry {
|
||||
scope: string
|
||||
/** Payload type text (the member's type annotation, whitespace-collapsed). */
|
||||
payload: string
|
||||
/** Source member declaration and complete JSDoc, dedented from its container. */
|
||||
declaration: string
|
||||
/** Description prose (the member's JSDoc), one line per paragraph. */
|
||||
doc: string
|
||||
/** Source pointer `packages/…/file.ts:line` of the declaration. */
|
||||
@@ -53,6 +65,16 @@ export interface AnnotatedLogEventEntry extends LogEventEntry {
|
||||
surface: boolean
|
||||
}
|
||||
|
||||
/** One owning event-envelope declaration pasted into the generated catalog. */
|
||||
export interface EventEnvelopeTypeEntry {
|
||||
/** Exported declaration name. */
|
||||
name: EventEnvelopeTypeName
|
||||
/** Verbatim type declaration, including its complete leading JSDoc. */
|
||||
declaration: string
|
||||
/** Source pointer `packages/…/file.ts:line` of the declaration. */
|
||||
source: string
|
||||
}
|
||||
|
||||
const printer = ts.createPrinter({ removeComments: true })
|
||||
|
||||
/**
|
||||
@@ -67,6 +89,24 @@ function payloadText(type: ts.TypeNode, sf: ts.SourceFile): string {
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a declaration from its leading JSDoc through its closing token while
|
||||
* removing only the indentation imposed by its containing interface/module.
|
||||
*/
|
||||
function declarationText(text: string, sf: ts.SourceFile, node: ts.Node): string {
|
||||
const raw = rawJsDoc(text, node)
|
||||
const nodeStart = node.getStart(sf)
|
||||
const start = raw ? text.lastIndexOf(raw, nodeStart) : nodeStart
|
||||
const { line } = sf.getLineAndCharacterOfPosition(start)
|
||||
const lineStart = sf.getPositionOfLineAndCharacter(line, 0)
|
||||
const indent = text.slice(lineStart, start)
|
||||
return text.slice(lineStart, node.end)
|
||||
.split('\n')
|
||||
.map(lineText => lineText.startsWith(indent) ? lineText.slice(indent.length) : lineText)
|
||||
.join('\n')
|
||||
.trimEnd()
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `interface SessionEventMap` declaration in a source file: the owning
|
||||
* top-level declaration (in `@deepseek-ai/dsh-session`) and any declaration
|
||||
@@ -177,7 +217,8 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
|
||||
if (!doc) {
|
||||
violations.push(`${where} has no description prose. Say what the event records and what its payload means — the JSDoc becomes the catalog entry.`)
|
||||
}
|
||||
entries.push({ name, scope: name.split('/')[0] ?? name, payload, doc, source: src })
|
||||
const declaration = declarationText(text, sf, member)
|
||||
entries.push({ name, scope: name.split('/')[0] ?? name, payload, declaration, doc, source: src })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,6 +226,51 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the exported declarations that compose the persisted event envelope,
|
||||
* preserving their source JSDoc and declaration text.
|
||||
*/
|
||||
export function collectEventEnvelopeTypes(scanRoot: string = root): EventEnvelopeTypeEntry[] {
|
||||
const found = new Map<EventEnvelopeTypeName, EventEnvelopeTypeEntry>()
|
||||
const violations: string[] = []
|
||||
const wanted = new Set<string>(EVENT_ENVELOPE_TYPE_NAMES)
|
||||
for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
|
||||
const abs = resolve(scanRoot, rel)
|
||||
const text = readFileSync(abs, 'utf8')
|
||||
if (!EVENT_ENVELOPE_TYPE_NAMES.some(name => text.includes(name))) continue
|
||||
if (packageNameFor(rel, scanRoot) !== SESSION_MODULE) continue
|
||||
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
|
||||
for (const stmt of sf.statements) {
|
||||
if (!ts.isTypeAliasDeclaration(stmt) || !wanted.has(stmt.name.text)) continue
|
||||
const name = stmt.name.text as EventEnvelopeTypeName
|
||||
const src = pointer(rel, sf, stmt)
|
||||
const where = `event-envelope type '${name}' (${src})`
|
||||
const prior = found.get(name)
|
||||
if (prior) {
|
||||
violations.push(`${where} is already declared at ${prior.source}; the persisted envelope type has exactly one owner.`)
|
||||
continue
|
||||
}
|
||||
if (!(stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)) {
|
||||
violations.push(`${where} is not exported.`)
|
||||
}
|
||||
const { doc, hasMode } = parseJsDoc(rawJsDoc(text, stmt))
|
||||
if (hasMode) violations.push(`${where} carries an @mode tag, but a persisted type has no dispatch mode.`)
|
||||
if (!doc) violations.push(`${where} has no description prose. The full JSDoc is part of the generated catalog.`)
|
||||
found.set(name, { name, declaration: declarationText(text, sf, stmt), source: src })
|
||||
}
|
||||
}
|
||||
const missing = EVENT_ENVELOPE_TYPE_NAMES.filter(name => !found.has(name))
|
||||
if (missing.length > 0) {
|
||||
violations.push(`missing event-envelope declaration(s): ${missing.join(', ')}.`)
|
||||
}
|
||||
reportViolations('gen-persistence-catalog', violations)
|
||||
return EVENT_ENVELOPE_TYPE_NAMES.map((name) => {
|
||||
const entry = found.get(name)
|
||||
if (!entry) throw new Error(`gen-persistence-catalog: missing checked event-envelope declaration '${name}'.`)
|
||||
return entry
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `SurfaceEventType` union — the surface-eligible subset of event
|
||||
* types — from source. Hard-errors when the alias is missing, declared more
|
||||
@@ -246,8 +332,7 @@ function typeLinks(payload: string): string {
|
||||
/** Render one log event entry. */
|
||||
function renderEvent(e: AnnotatedLogEventEntry): string[] {
|
||||
const out = [`#### \`${e.name}\` — ${e.surface ? 'surface' : 'log-only'}`, '']
|
||||
if (e.doc) out.push(e.doc, '')
|
||||
out.push('```' + FENCE, `'${e.name}': ${e.payload}`, '```', '')
|
||||
out.push('```' + FENCE, e.declaration, '```', '')
|
||||
const links = typeLinks(e.payload)
|
||||
if (links) out.push(links, '')
|
||||
out.push(`Source: [\`${e.source}\`](../${e.source.split(':')[0]})`, '')
|
||||
@@ -255,18 +340,26 @@ function renderEvent(e: AnnotatedLogEventEntry): string[] {
|
||||
}
|
||||
|
||||
/** Render the full catalog (pure, deterministic given the collected inputs). */
|
||||
export function render(events: AnnotatedLogEventEntry[]): string {
|
||||
export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnvelopeTypeEntry[]): string {
|
||||
const lines: string[] = [
|
||||
'<!-- Generated by scripts/gen-persistence-catalog.ts — do not edit by hand.',
|
||||
' Run `pnpm run gen-persistence-catalog` to regenerate. -->',
|
||||
'',
|
||||
'# Persistence Log Event Catalog',
|
||||
'# Session Persistence Event Catalog',
|
||||
'',
|
||||
'Every event type that can appear in a session\'s durable event log: each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with the payload it carries, its surface badge, and the declaration it comes from. It complements [session.md](core-data-structures/session.md) (the `SessionEvent` envelope, surface list, and `deriveMessages()` projection), [persistence.md](core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).',
|
||||
'Every event type that can appear in a session\'s durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](core-data-structures/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).',
|
||||
'',
|
||||
'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Payload blocks use a `ts persistence-catalog` fence (skipped by doc-typecheck, since a bare payload fragment is not standalone-compilable). Type names in a payload link to the page that documents them. See [the persistence-log-catalog RFC](rfc/implemented/process/2026-07-04-persistence-log-catalog.md).',
|
||||
'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog RFC](rfc/implemented/process/2026-07-04-persistence-log-catalog.md).',
|
||||
'',
|
||||
'The on-disk envelope around every payload is `SessionEvent` — `type`, monotonic `seq`, epoch-ms `time`, the `data` documented here, plus `surfaceOp`/`sourceEventSeqs` on **surface** events only ([envelope](core-data-structures/session.md#sessioneventt--one-log-entry)). **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.',
|
||||
'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.',
|
||||
'',
|
||||
'## Event envelope',
|
||||
'',
|
||||
'```' + FENCE,
|
||||
envelopeTypes.map(entry => entry.declaration).join('\n\n'),
|
||||
'```',
|
||||
'',
|
||||
`Sources: ${envelopeTypes.map(entry => `[\`${entry.source}\`](../${entry.source.split(':')[0]})`).join(' · ')}`,
|
||||
'',
|
||||
'## Events',
|
||||
'',
|
||||
@@ -285,7 +378,7 @@ export function render(events: AnnotatedLogEventEntry[]): string {
|
||||
* is stale. Guarded behind an entry-point check so importing this module for
|
||||
* tests neither regenerates the committed file nor calls process.exit. */
|
||||
function main(): void {
|
||||
const content = render(annotateSurface(collectLogEvents(), collectSurfaceEventTypes()))
|
||||
const content = render(annotateSurface(collectLogEvents(), collectSurfaceEventTypes()), collectEventEnvelopeTypes())
|
||||
if (process.argv.includes('--check')) {
|
||||
let committed: string | null = null
|
||||
try {
|
||||
|
||||
@@ -22,11 +22,14 @@ import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import * as SubagentMock from '@deepseek-ai/dsh-subagent-mock'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||
import TaskService from '@deepseek-ai/dsh-tasks'
|
||||
import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
|
||||
import * as ToolSkill from '@deepseek-ai/dsh-tool-skill'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
|
||||
import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
|
||||
import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
|
||||
@@ -111,14 +114,14 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
pkg: '@deepseek-ai/dsh-tool-bash',
|
||||
dir: 'tool-bash',
|
||||
source: 'packages/bash/tool-bash/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.bash'],
|
||||
writes: ['tool/call', 'tool/result', 'context/message via agent.inject() for background completion notices'],
|
||||
requires: ['ctx.tools', 'ctx.bash', 'ctx.tasks at call time for run_in_background'],
|
||||
writes: ['tool/call', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(LocalBashExecutor)
|
||||
await ctx.plugin(ToolBash)
|
||||
},
|
||||
note:
|
||||
'The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam.',
|
||||
'The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-cordis',
|
||||
@@ -130,7 +133,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
await ctx.plugin(ToolCordis)
|
||||
},
|
||||
note:
|
||||
'Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes.',
|
||||
'Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-fs',
|
||||
@@ -147,6 +150,23 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
note:
|
||||
'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-fs-search',
|
||||
dir: 'tool-fs-search',
|
||||
source: 'packages/fs/tool-fs-search/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.bash', 'ctx.systemPrompt'],
|
||||
writes: ['tool/call', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
// The tools inject `bash` (search executes fixed `rg` commands through
|
||||
// the executor seam, not ctx.fs); boot the local executor to satisfy it.
|
||||
// `ctx.spillStore` is optional (read via ctx.get) and does not affect the
|
||||
// schemas, so no spill backend is mounted.
|
||||
await ctx.plugin(LocalBashExecutor)
|
||||
await ctx.plugin(ToolFsSearch)
|
||||
},
|
||||
note:
|
||||
'glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-skill',
|
||||
dir: 'tool-skill',
|
||||
@@ -178,6 +198,19 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
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/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-tasks',
|
||||
dir: 'tool-tasks',
|
||||
source: 'packages/tasks/tool-tasks/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.tasks', 'ctx.systemPrompt'],
|
||||
writes: ['tool/call', 'tool/result', 'context/message via agent.inject() for background completion notices'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(TaskService)
|
||||
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()`.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-todo',
|
||||
dir: 'tool-todo',
|
||||
|
||||
55
scripts/md-fences.ts
Normal file
55
scripts/md-fences.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Shared fenced-code-block extractor for the Markdown doc gates
|
||||
* (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.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
/** One extracted fenced block, classified by the caller's `classify`. */
|
||||
export interface Fence<K> {
|
||||
/** 1-based line of the opening fence. */
|
||||
line: number
|
||||
kind: K
|
||||
code: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract every fenced block of `absPath` whose info string `classify` maps
|
||||
* to a kind. Blocks classified `null` are skipped (their bodies are still
|
||||
* consumed, so an unrelated fence can never leak into a tracked one).
|
||||
*
|
||||
* @param absPath — absolute path of the Markdown file.
|
||||
* @param classify — info string (trimmed, e.g. `ts ignore-check`) → kind, or
|
||||
* null for fences this gate does not track.
|
||||
* @returns the classified blocks in document order.
|
||||
*/
|
||||
export function extractFences<K>(absPath: string, classify: (info: string) => K | null): Fence<K>[] {
|
||||
const lines = readFileSync(absPath, 'utf8').split('\n')
|
||||
const blocks: Fence<K>[] = []
|
||||
let open: { line: number; kind: K; body: string[] } | null = null
|
||||
let skipping = false
|
||||
|
||||
lines.forEach((raw, i) => {
|
||||
const fence = /^```(\s*)(\S.*)?$/.exec(raw)
|
||||
if (!fence) {
|
||||
if (open) open.body.push(raw)
|
||||
return
|
||||
}
|
||||
if (open) {
|
||||
blocks.push({ line: open.line, kind: open.kind, code: open.body.join('\n') })
|
||||
open = null
|
||||
return
|
||||
}
|
||||
if (skipping) {
|
||||
skipping = false
|
||||
return
|
||||
}
|
||||
const kind = classify((fence[2] ?? '').trim())
|
||||
if (kind !== null) open = { line: i + 1, kind, body: [] }
|
||||
else skipping = true
|
||||
})
|
||||
return blocks
|
||||
}
|
||||
@@ -24,6 +24,7 @@ type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped'
|
||||
interface Gate {
|
||||
id: string
|
||||
label: string
|
||||
displayCommand: string
|
||||
command: string
|
||||
args: string[]
|
||||
needs?: string[]
|
||||
@@ -38,22 +39,39 @@ interface GateResult {
|
||||
durationMs: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
output: GateOutputChunk[]
|
||||
exitCode: number | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface GateOutputChunk {
|
||||
stream: 'stdout' | 'stderr'
|
||||
text: string
|
||||
}
|
||||
|
||||
interface RunningGate {
|
||||
gate: Gate
|
||||
promise: Promise<GateResult>
|
||||
}
|
||||
|
||||
interface ConcurrencyDefault {
|
||||
workers: number
|
||||
source: string
|
||||
}
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const mode = parseMode(process.argv[2])
|
||||
const gates = gatesForMode(mode)
|
||||
const maxConcurrency = concurrencyFromEnv('DSH_GATE_CONCURRENCY', defaultConcurrency(gates.length))
|
||||
const concurrencyDefault = defaultConcurrency(mode, gates.length)
|
||||
const concurrencyOverride = process.env.DSH_GATE_CONCURRENCY
|
||||
const maxConcurrency = concurrencyFromEnv('DSH_GATE_CONCURRENCY', concurrencyDefault.workers)
|
||||
const verbose = process.env.DSH_GATE_VERBOSE === '1'
|
||||
const startedAt = performance.now()
|
||||
|
||||
console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s).`)
|
||||
const concurrencySource = concurrencyOverride === undefined || concurrencyOverride === ''
|
||||
? concurrencyDefault.source
|
||||
: '$DSH_GATE_CONCURRENCY'
|
||||
console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`)
|
||||
|
||||
const results = await runGates(gates, maxConcurrency)
|
||||
printSummary(results, performance.now() - startedAt)
|
||||
@@ -78,8 +96,15 @@ function parseMode(raw: string | undefined): Mode {
|
||||
}
|
||||
}
|
||||
|
||||
function defaultConcurrency(total: number): number {
|
||||
return Math.min(total, Math.max(4, availableParallelism()))
|
||||
function defaultConcurrency(selectedMode: Mode, total: number): ConcurrencyDefault {
|
||||
const available = availableParallelism()
|
||||
const modeLimit = selectedMode === 'pre-push' ? Math.min(4, available) : available
|
||||
return {
|
||||
workers: Math.min(total, modeLimit),
|
||||
source: selectedMode === 'pre-push'
|
||||
? `${available} available CPU(s), pre-push cap 4`
|
||||
: `${available} available CPU(s)`,
|
||||
}
|
||||
}
|
||||
|
||||
function concurrencyFromEnv(name: string, fallback: number): number {
|
||||
@@ -96,6 +121,7 @@ function pnpmScript(id: string, script: string, options: Partial<Gate> = {}): Ga
|
||||
return {
|
||||
id,
|
||||
label: options.label ?? script,
|
||||
displayCommand: `pnpm run ${script}`,
|
||||
...pnpmInvocation(['run', script]),
|
||||
...options,
|
||||
}
|
||||
@@ -105,6 +131,7 @@ function pnpmExec(id: string, args: string[], options: Partial<Gate> = {}): Gate
|
||||
return {
|
||||
id,
|
||||
label: options.label ?? `pnpm exec ${args.join(' ')}`,
|
||||
displayCommand: `pnpm exec ${args.join(' ')}`,
|
||||
...pnpmInvocation(['exec', ...args]),
|
||||
...options,
|
||||
}
|
||||
@@ -136,11 +163,13 @@ function gatesForMode(selected: Mode): Gate[] {
|
||||
]
|
||||
case 'ci-coverage':
|
||||
return [
|
||||
pnpmScript('build', 'build'),
|
||||
coverageGate(),
|
||||
]
|
||||
case 'ci-snapshot':
|
||||
return [
|
||||
pnpmScript('snapshot', 'test:snapshot'),
|
||||
pnpmScript('build', 'build'),
|
||||
snapshotGate(),
|
||||
]
|
||||
case 'ci-artifacts':
|
||||
return ciArtifactGates()
|
||||
@@ -159,10 +188,13 @@ function gatesForMode(selected: Mode): Gate[] {
|
||||
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
||||
pnpmScript('test', 'test'),
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
pnpmScript('snapshot', 'test:snapshot'),
|
||||
snapshotGate(),
|
||||
pnpmScript('build', 'build'),
|
||||
...hygieneLeafGates({ artifactNeeds: ['build'] }),
|
||||
...docSyncLeafGates(),
|
||||
...docSyncLeafGates({
|
||||
docTypecheckNeeds: ['build'],
|
||||
docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' },
|
||||
}),
|
||||
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
||||
]
|
||||
}
|
||||
@@ -177,11 +209,12 @@ function ciPrimaryGates(): Gate[] {
|
||||
lintGate(),
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
coverageGate(),
|
||||
pnpmScript('snapshot', 'test:snapshot'),
|
||||
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', {
|
||||
@@ -201,6 +234,7 @@ function ciStaticGates(): Gate[] {
|
||||
...docSyncLeafGates(),
|
||||
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
||||
pnpmScript('knip', 'knip'),
|
||||
pnpmScript('website-build', 'website:build', { label: 'website build' }),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -249,6 +283,18 @@ function coverageGate(): Gate {
|
||||
...positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers'),
|
||||
], {
|
||||
label: 'test:coverage',
|
||||
env: { DSH_EXAMPLE_MODE: 'lib' },
|
||||
needs: ['build'],
|
||||
})
|
||||
}
|
||||
|
||||
// The snapshot suite boots the example bins in `lib` mode (built artifact under plain Node,
|
||||
// plugins via real exports) — CI and pre-push already build, so they exercise what ships rather
|
||||
// than the tsx/source path dev uses. It therefore waits on `build`.
|
||||
function snapshotGate(): Gate {
|
||||
return pnpmScript('snapshot', 'test:snapshot', {
|
||||
env: { DSH_EXAMPLE_MODE: 'lib' },
|
||||
needs: ['build'],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -275,9 +321,15 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
|
||||
]
|
||||
}
|
||||
|
||||
function docSyncLeafGates(): Gate[] {
|
||||
function docSyncLeafGates(options: {
|
||||
docTypecheckNeeds?: string[]
|
||||
docTypecheckEnv?: Record<string, string | undefined>
|
||||
} = {}): Gate[] {
|
||||
const docTypecheckOptions: Partial<Gate> = {}
|
||||
if (options.docTypecheckNeeds !== undefined) docTypecheckOptions.needs = options.docTypecheckNeeds
|
||||
if (options.docTypecheckEnv !== undefined) docTypecheckOptions.env = options.docTypecheckEnv
|
||||
return [
|
||||
pnpmScript('doc-typecheck', 'doc-typecheck'),
|
||||
pnpmScript('doc-typecheck', 'doc-typecheck', docTypecheckOptions),
|
||||
pnpmScript('cordis-catalog', 'verify-cordis-catalog', { label: 'cordis catalog' }),
|
||||
pnpmScript('export-jsdoc', 'verify-export-jsdoc', { label: 'export jsdoc' }),
|
||||
pnpmScript('tool-catalog', 'verify-tool-catalog', { label: 'tool catalog' }),
|
||||
@@ -294,6 +346,7 @@ function docSyncLeafGates(): Gate[] {
|
||||
pnpmScript('rfc-classification', 'verify-rfc-classification', { label: 'rfc classification' }),
|
||||
pnpmScript('rfc-format', 'verify-rfc-format', { label: 'rfc format' }),
|
||||
pnpmScript('type-equivalence', 'verify-type-equiv', { label: 'type equivalence' }),
|
||||
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' }),
|
||||
pnpmScript('docs-site', 'docs:check', { label: 'documentation site' }),
|
||||
@@ -306,6 +359,7 @@ function demoSmokeGate(options: { needs?: string[] } = {}): Gate {
|
||||
return {
|
||||
id: 'demo-smoke',
|
||||
label: 'demo smoke',
|
||||
displayCommand: 'pnpm run demo:echo',
|
||||
...pnpmInvocation(['run', 'demo:echo']),
|
||||
input: 'echo ci smoke\n',
|
||||
...dependencyOptions,
|
||||
@@ -382,6 +436,7 @@ async function runGates(allGates: Gate[], maxActive: number): Promise<GateResult
|
||||
durationMs: 0,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
output: [],
|
||||
exitCode: null,
|
||||
error: `dependency failed or skipped: ${failedDeps.join(', ')}`,
|
||||
}
|
||||
@@ -416,8 +471,10 @@ async function runGate(gate: Gate): Promise<GateResult> {
|
||||
const started = performance.now()
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
const output: GateOutputChunk[] = []
|
||||
let spawnError: string | undefined
|
||||
|
||||
const exitCode = await new Promise<number | null>((resolveExit, reject) => {
|
||||
const exitCode = await new Promise<number | null>((resolveExit) => {
|
||||
const child = spawn(gate.command, gate.args, {
|
||||
cwd: root,
|
||||
env: { ...process.env, ...gate.env },
|
||||
@@ -425,19 +482,28 @@ async function runGate(gate: Gate): Promise<GateResult> {
|
||||
})
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stdout.on('data', (chunk: string) => { stdout += chunk })
|
||||
child.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||
child.on('error', reject)
|
||||
child.stdout.on('data', (chunk: string) => {
|
||||
stdout += chunk
|
||||
output.push({ stream: 'stdout', text: chunk })
|
||||
})
|
||||
child.stderr.on('data', (chunk: string) => {
|
||||
stderr += chunk
|
||||
output.push({ stream: 'stderr', text: chunk })
|
||||
})
|
||||
child.on('error', (error) => {
|
||||
spawnError = `failed to start command: ${error.message}`
|
||||
resolveExit(null)
|
||||
})
|
||||
child.on('close', resolveExit)
|
||||
if (gate.input !== undefined) child.stdin.end(gate.input)
|
||||
else child.stdin.end()
|
||||
})
|
||||
|
||||
let status: GateStatus = exitCode === 0 ? 'passed' : 'failed'
|
||||
let error: string | undefined
|
||||
let status: GateStatus = exitCode === 0 && spawnError === undefined ? 'passed' : 'failed'
|
||||
let error = spawnError
|
||||
if (status === 'passed' && gate.verify !== undefined) {
|
||||
try {
|
||||
await gate.verify({ gate, status, durationMs: performance.now() - started, stdout, stderr, exitCode })
|
||||
await gate.verify({ gate, status, durationMs: performance.now() - started, stdout, stderr, output, exitCode })
|
||||
} catch (verifyError: unknown) {
|
||||
status = 'failed'
|
||||
error = verifyError instanceof Error ? verifyError.message : String(verifyError)
|
||||
@@ -450,6 +516,7 @@ async function runGate(gate: Gate): Promise<GateResult> {
|
||||
durationMs: performance.now() - started,
|
||||
stdout,
|
||||
stderr,
|
||||
output,
|
||||
exitCode,
|
||||
}
|
||||
if (error !== undefined) result.error = error
|
||||
@@ -458,9 +525,16 @@ async function runGate(gate: Gate): Promise<GateResult> {
|
||||
|
||||
function printResult(result: GateResult): void {
|
||||
const seconds = (result.durationMs / 1000).toFixed(2)
|
||||
console.log(`\n== ${result.status.toUpperCase()} ${result.gate.label} (${seconds}s) ==`)
|
||||
process.stdout.write(result.stdout)
|
||||
process.stderr.write(result.stderr)
|
||||
if (result.status === 'passed' && !verbose) {
|
||||
console.log(`run-gates: PASS ${result.gate.label} (${seconds}s)`)
|
||||
return
|
||||
}
|
||||
|
||||
const heading = `${result.status.toUpperCase()} ${result.gate.label} (${seconds}s)`
|
||||
const writeHeading = result.status === 'passed' ? console.log : console.error
|
||||
writeHeading(`\n== ${heading} ==`)
|
||||
if (result.status !== 'passed') console.error(`command: ${result.gate.displayCommand}`)
|
||||
printOutput(result.output)
|
||||
if (result.error !== undefined) console.error(result.error)
|
||||
}
|
||||
|
||||
@@ -470,4 +544,22 @@ function printSummary(results: GateResult[], durationMs: number): void {
|
||||
const skipped = results.filter(result => result.status === 'skipped').length
|
||||
const seconds = (durationMs / 1000).toFixed(2)
|
||||
console.log(`\nrun-gates: ${passed} passed, ${failed} failed, ${skipped} skipped in ${seconds}s.`)
|
||||
|
||||
const unsuccessful = results.filter(result => result.status === 'failed' || result.status === 'skipped')
|
||||
if (unsuccessful.length === 0) return
|
||||
|
||||
console.error('run-gates: unsuccessful gates:')
|
||||
for (const result of unsuccessful) {
|
||||
const duration = (result.durationMs / 1000).toFixed(2)
|
||||
const reason = result.error ?? (result.exitCode === null ? 'no exit code' : `exit ${result.exitCode}`)
|
||||
console.error(` - ${result.status.toUpperCase()} ${result.gate.label} (${duration}s, ${reason})`)
|
||||
console.error(` ${result.gate.displayCommand}`)
|
||||
}
|
||||
}
|
||||
|
||||
function printOutput(output: GateOutputChunk[]): void {
|
||||
for (const chunk of output) {
|
||||
if (chunk.stream === 'stdout') process.stdout.write(chunk.text)
|
||||
else process.stderr.write(chunk.text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ CUSTOM_CORDIS = """\
|
||||
- id: agent-core
|
||||
name: '@deepseek-ai/dsh-agent-spine-demo'
|
||||
config:
|
||||
workspaceContext: false
|
||||
tools:
|
||||
mode: both
|
||||
- id: sessions
|
||||
@@ -379,6 +380,7 @@ def smoke_sdk_default(base_url: str) -> None:
|
||||
root = Path(temporary).resolve()
|
||||
sessions = root / "sessions"
|
||||
with DeepSeekHarness(
|
||||
provider="deepseek",
|
||||
model="smoke-model",
|
||||
cwd=str(root),
|
||||
session_root=str(sessions),
|
||||
@@ -401,6 +403,7 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None:
|
||||
cordis = root / "cordis.yml"
|
||||
cordis.write_text(CUSTOM_CORDIS)
|
||||
with DeepSeekHarness(
|
||||
provider="deepseek",
|
||||
model="smoke-model",
|
||||
cwd=str(root),
|
||||
session_root=str(sessions),
|
||||
@@ -432,6 +435,7 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool)
|
||||
cordis = root / "cordis.yml"
|
||||
cordis.write_text(CUSTOM_CORDIS)
|
||||
with DeepSeekHarness(
|
||||
provider="deepseek",
|
||||
model="smoke-model",
|
||||
cwd=str(root),
|
||||
session_root=str(sessions),
|
||||
@@ -481,7 +485,7 @@ def smoke_direct(base_url: str, executable: Path) -> None:
|
||||
}
|
||||
peer = RuntimePeer([str(executable)], root, environment)
|
||||
try:
|
||||
peer.send({"jsonrpc": "2.0", "id": "initialize", "method": "initialize", "params": {"cwd": str(root), "model": "smoke-model"}})
|
||||
peer.send({"jsonrpc": "2.0", "id": "initialize", "method": "initialize", "params": {"cwd": str(root), "provider": "deepseek", "model": "smoke-model"}})
|
||||
peer.read_until(lambda message: message.get("id") == "initialize")
|
||||
peer.send({
|
||||
"jsonrpc": "2.0",
|
||||
@@ -703,7 +707,7 @@ def normalize_snapshot_value(
|
||||
|
||||
|
||||
def scrub_snapshot_header(value: dict[object, object]) -> None:
|
||||
"""Tokenize request-header bulk while retaining delta tool names."""
|
||||
"""Tokenize full request-header bulk while retaining tool names."""
|
||||
data = value.get("data")
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
@@ -721,29 +725,6 @@ def scrub_snapshot_header(value: dict[object, object]) -> None:
|
||||
]
|
||||
if isinstance(header.get("messagePrefix"), list):
|
||||
header["messagePrefix"] = ["{{messagePrefix}}" for _ in header["messagePrefix"]]
|
||||
return
|
||||
if value.get("type") != "request/header-delta":
|
||||
return
|
||||
system = data.get("system")
|
||||
if isinstance(system, dict) and isinstance(system.get("insert"), list):
|
||||
system["insert"] = ["{{system}}" for _ in system["insert"]]
|
||||
tools = data.get("tools")
|
||||
if isinstance(tools, dict):
|
||||
for key in ("added", "changed"):
|
||||
if isinstance(tools.get(key), list):
|
||||
tools[key] = [scrub_snapshot_tool_schema(tool) for tool in tools[key]]
|
||||
if isinstance(data.get("messagePrefix"), list):
|
||||
data["messagePrefix"] = ["{{messagePrefix}}" for _ in data["messagePrefix"]]
|
||||
|
||||
|
||||
def scrub_snapshot_tool_schema(value: object) -> object:
|
||||
"""Keep a changed tool's name while tokenizing its schema bulk."""
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
return {
|
||||
key: item if key == "name" else "{{tools}}"
|
||||
for key, item in value.items()
|
||||
}
|
||||
|
||||
|
||||
def render_jsonl(records: list[object]) -> str:
|
||||
|
||||
@@ -253,7 +253,7 @@
|
||||
"workflow"
|
||||
]
|
||||
},
|
||||
"reason": "fallback"
|
||||
"reason": "change"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -915,22 +915,29 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "request/header-delta",
|
||||
"type": "request/header",
|
||||
"seq": 56,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"system": {
|
||||
"keepStart": 62,
|
||||
"keepEnd": 34,
|
||||
"insert": []
|
||||
"header": {
|
||||
"config": {
|
||||
"model": "smoke-model"
|
||||
},
|
||||
"system": "{{system}}",
|
||||
"tools": [
|
||||
"bash",
|
||||
"bash_kill",
|
||||
"bash_output",
|
||||
"cordis_inspect",
|
||||
"cordis_mount",
|
||||
"cordis_unmount",
|
||||
"run_code",
|
||||
"skill",
|
||||
"subagent",
|
||||
"workflow"
|
||||
]
|
||||
},
|
||||
"tools": {
|
||||
"added": [],
|
||||
"removed": [
|
||||
"snapshot_double"
|
||||
],
|
||||
"changed": []
|
||||
}
|
||||
"reason": "change"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1396,7 +1403,7 @@
|
||||
"workflow"
|
||||
]
|
||||
},
|
||||
"reason": "fallback"
|
||||
"reason": "change"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2358,22 +2365,29 @@
|
||||
"payload": {
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "request/header-delta",
|
||||
"type": "request/header",
|
||||
"seq": 56,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"system": {
|
||||
"keepStart": 62,
|
||||
"keepEnd": 34,
|
||||
"insert": []
|
||||
"header": {
|
||||
"config": {
|
||||
"model": "smoke-model"
|
||||
},
|
||||
"system": "{{system}}",
|
||||
"tools": [
|
||||
"bash",
|
||||
"bash_kill",
|
||||
"bash_output",
|
||||
"cordis_inspect",
|
||||
"cordis_mount",
|
||||
"cordis_unmount",
|
||||
"run_code",
|
||||
"skill",
|
||||
"subagent",
|
||||
"workflow"
|
||||
]
|
||||
},
|
||||
"tools": {
|
||||
"added": [],
|
||||
"removed": [
|
||||
"snapshot_double"
|
||||
],
|
||||
"changed": []
|
||||
}
|
||||
"reason": "change"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"<anonymous>\", state: active)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}}
|
||||
{"type":"request/header","seq":14,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"fallback"}}
|
||||
{"type":"request/header","seq":14,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"change"}}
|
||||
{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}}}
|
||||
{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}}}}
|
||||
@@ -55,7 +55,7 @@
|
||||
{"type":"tool/result","seq":53,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"<anonymous>\")"}],"isError":false},"sourceEventSeqs":[52],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":54,"time":0,"data":{"turn":1,"step":5}}
|
||||
{"type":"step/start","seq":55,"time":0,"data":{"turn":1,"step":6}}
|
||||
{"type":"request/header-delta","seq":56,"time":0,"data":{"system":{"keepStart":62,"keepEnd":34,"insert":[]},"tools":{"added":[],"removed":["snapshot_double"],"changed":[]}}}
|
||||
{"type":"request/header","seq":56,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","workflow"]},"reason":"change"}}
|
||||
{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}}
|
||||
{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}}
|
||||
|
||||
95
scripts/translation-pairing.spec.ts
Normal file
95
scripts/translation-pairing.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/** Regression tests for the bilingual cutoff and structural signature. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
datedDocumentDate,
|
||||
isIsoDate,
|
||||
parseTranslationMarkdown,
|
||||
parseTranslationPairingManifest,
|
||||
requiresPairByDate,
|
||||
translationStructureDiff,
|
||||
translationStructureSignature,
|
||||
} from './translation-pairing.ts'
|
||||
|
||||
function signature(markdown: string) {
|
||||
return translationStructureSignature(parseTranslationMarkdown(markdown), 'counterpart.zh.md')
|
||||
}
|
||||
|
||||
describe('translation pairing manifest', () => {
|
||||
it('accepts a real ISO cutoff and string-array fields', () => {
|
||||
expect(parseTranslationPairingManifest(JSON.stringify({
|
||||
requiredSince: '2026-07-14',
|
||||
required: ['README.md'],
|
||||
excluded: ['docs/generated/'],
|
||||
}))).toEqual({
|
||||
requiredSince: '2026-07-14',
|
||||
required: ['README.md'],
|
||||
excluded: ['docs/generated/'],
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['2026-7-14', '2026-02-29', '2026-13-01', 'not-a-date'])('rejects invalid cutoff %s', (cutoff) => {
|
||||
expect(isIsoDate(cutoff)).toBe(false)
|
||||
expect(() => parseTranslationPairingManifest(JSON.stringify({
|
||||
requiredSince: cutoff,
|
||||
required: [],
|
||||
excluded: [],
|
||||
}))).toThrow('requiredSince must be a valid YYYY-MM-DD date')
|
||||
})
|
||||
|
||||
it('rejects non-string manifest arrays', () => {
|
||||
expect(() => parseTranslationPairingManifest(JSON.stringify({
|
||||
requiredSince: '2026-07-14',
|
||||
required: [42],
|
||||
excluded: [],
|
||||
}))).toThrow('required must be an array of strings')
|
||||
})
|
||||
})
|
||||
|
||||
describe('date-based pairing frontier', () => {
|
||||
const cutoff = '2026-07-14'
|
||||
|
||||
it('enforces the cutoff day and every later day, but not the preceding day', () => {
|
||||
expect(requiresPairByDate('docs/rfc/2026-07-13-before.md', cutoff)).toBe(false)
|
||||
expect(requiresPairByDate('docs/rfc/2026-07-14-at-cutoff.md', cutoff)).toBe(true)
|
||||
expect(requiresPairByDate('docs/rfc/2026-07-15-after.md', cutoff)).toBe(true)
|
||||
})
|
||||
|
||||
it('matches only a date at the start of the basename', () => {
|
||||
expect(datedDocumentDate('docs/rfc/2026-07-14-proposal.md')).toBe('2026-07-14')
|
||||
expect(datedDocumentDate('docs/release-notes-2026-07-14-alpha.md')).toBeUndefined()
|
||||
expect(requiresPairByDate('docs/release-notes-2026-07-14-alpha.md', cutoff)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('translation structural signature', () => {
|
||||
it('accepts matching list kinds, starts, and item counts', () => {
|
||||
const source = signature('3. One\n4. Two\n\n- A\n- B\n')
|
||||
const counterpart = signature('3. 一\n4. 二\n\n- 甲\n- 乙\n')
|
||||
expect(translationStructureDiff(source, counterpart)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects an altered ordered-list start', () => {
|
||||
const source = signature('3. One\n4. Two\n\n- A\n- B\n')
|
||||
const counterpart = signature('1. 一\n2. 二\n\n- 甲\n- 乙\n')
|
||||
expect(translationStructureDiff(source, counterpart)).toEqual([
|
||||
'list (kind, start, item count) #1 diverges between the pair: "ordered:start=3:items=2" vs "ordered:start=1:items=2"',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a missing list item', () => {
|
||||
const source = signature('- A\n- B\n')
|
||||
const counterpart = signature('- 甲\n')
|
||||
expect(translationStructureDiff(source, counterpart)).toEqual([
|
||||
'list (kind, start, item count) #1 diverges between the pair: "bullet:items=2" vs "bullet:items=1"',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects altered table row or column counts', () => {
|
||||
const source = signature('| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n')
|
||||
const counterpart = signature('| 甲 | 乙 |\n|---|---|\n| 一 | 二 |\n')
|
||||
expect(translationStructureDiff(source, counterpart)).toEqual([
|
||||
'table (row x column count) #1 diverges between the pair: "3x2" vs "2x2"',
|
||||
])
|
||||
})
|
||||
})
|
||||
164
scripts/translation-pairing.ts
Normal file
164
scripts/translation-pairing.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Pure parsing and structural helpers for the bilingual-document pairing
|
||||
* gate. Kept separate from the CLI so cutoff and signature behavior can be
|
||||
* regression-tested without reading or mutating the repository tree.
|
||||
*/
|
||||
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||
import { gfmFromMarkdown } from 'mdast-util-gfm'
|
||||
import { gfm } from 'micromark-extension-gfm'
|
||||
import type { Nodes } from 'mdast'
|
||||
|
||||
/** Validated shape of `scripts/translation-pairing.manifest.json`. */
|
||||
export interface TranslationPairingManifest {
|
||||
required: string[]
|
||||
excluded: string[]
|
||||
/** Date-named documents on or after this day must merge bilingual. */
|
||||
requiredSince: string
|
||||
}
|
||||
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
|
||||
const DATED_DOCUMENT = /(?:^|\/)(\d{4}-\d{2}-\d{2})-[^/]*\.md$/
|
||||
|
||||
/** Whether a string names one real calendar day in canonical ISO form. */
|
||||
export function isIsoDate(value: string): boolean {
|
||||
if (!ISO_DATE.test(value)) return false
|
||||
const date = new Date(`${value}T00:00:00.000Z`)
|
||||
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
|
||||
}
|
||||
|
||||
/** Read one manifest string-array field or fail before enforcement starts. */
|
||||
function stringArrayField(record: Record<string, unknown>, field: 'required' | 'excluded'): string[] {
|
||||
const value = record[field]
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`translation-pairing.manifest.json: ${field} must be an array of strings`)
|
||||
}
|
||||
const entries: unknown[] = value
|
||||
if (!entries.every((entry): entry is string => typeof entry === 'string')) {
|
||||
throw new Error(`translation-pairing.manifest.json: ${field} must be an array of strings`)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/** Parse and validate the checked-in bilingual manifest. */
|
||||
export function parseTranslationPairingManifest(content: string): TranslationPairingManifest {
|
||||
const value: unknown = JSON.parse(content)
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error('translation-pairing.manifest.json: expected an object')
|
||||
}
|
||||
const record = value as Record<string, unknown>
|
||||
const requiredSince = record.requiredSince
|
||||
if (typeof requiredSince !== 'string' || !isIsoDate(requiredSince)) {
|
||||
throw new Error(`translation-pairing.manifest.json: requiredSince must be a valid YYYY-MM-DD date; got ${JSON.stringify(requiredSince)}`)
|
||||
}
|
||||
return {
|
||||
required: stringArrayField(record, 'required'),
|
||||
excluded: stringArrayField(record, 'excluded'),
|
||||
requiredSince,
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the leading date of a `yyyy-mm-dd-*.md` basename, if present. */
|
||||
export function datedDocumentDate(file: string): string | undefined {
|
||||
return DATED_DOCUMENT.exec(file)?.[1]
|
||||
}
|
||||
|
||||
/** Whether a date-named document falls on or after the pairing cutoff. */
|
||||
export function requiresPairByDate(file: string, requiredSince: string): boolean {
|
||||
const date = datedDocumentDate(file)
|
||||
return date !== undefined && date >= requiredSince
|
||||
}
|
||||
|
||||
/** The structural surface compared between the two sides of a pair. */
|
||||
export interface TranslationStructureSignature {
|
||||
/** Heading depths in document order (h2 -> 2). */
|
||||
headings: number[]
|
||||
/** Fenced code blocks verbatim: info string plus content, in order. */
|
||||
code: string[]
|
||||
/** Row and column count of each table, in order. */
|
||||
tables: string[]
|
||||
/** Kind, ordered-list start, and direct item count of each list, in order. */
|
||||
lists: string[]
|
||||
/** Every link target in order; the language switcher is excluded. */
|
||||
links: string[]
|
||||
}
|
||||
|
||||
/** Parse Markdown with the same GFM extensions used by the pairing gate. */
|
||||
export function parseTranslationMarkdown(content: string): Nodes {
|
||||
return fromMarkdown(content, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
|
||||
}
|
||||
|
||||
/** Whether the tree contains a link to exactly `target`. */
|
||||
export function linksTo(tree: Nodes, target: string): boolean {
|
||||
let found = false
|
||||
const visit = (node: Nodes): void => {
|
||||
if (node.type === 'link' && node.url === target) found = true
|
||||
if ('children' in node) for (const child of node.children) visit(child)
|
||||
}
|
||||
visit(tree)
|
||||
return found
|
||||
}
|
||||
|
||||
/** Collect the ordered structural signature, skipping one switcher target. */
|
||||
export function translationStructureSignature(tree: Nodes, switcherTarget: string): TranslationStructureSignature {
|
||||
const sig: TranslationStructureSignature = { headings: [], code: [], tables: [], lists: [], links: [] }
|
||||
const visit = (node: Nodes): void => {
|
||||
switch (node.type) {
|
||||
case 'heading':
|
||||
sig.headings.push(node.depth)
|
||||
break
|
||||
case 'code':
|
||||
sig.code.push(`\`\`\`${node.lang ?? ''}${node.meta ? ` ${node.meta}` : ''}\n${node.value}`)
|
||||
break
|
||||
case 'table':
|
||||
sig.tables.push(`${node.children.length}x${node.children[0]?.children.length ?? 0}`)
|
||||
break
|
||||
case 'list':
|
||||
sig.lists.push(node.ordered
|
||||
? `ordered:start=${node.start ?? 1}:items=${node.children.length}`
|
||||
: `bullet:items=${node.children.length}`)
|
||||
break
|
||||
case 'link':
|
||||
if (node.url !== switcherTarget) sig.links.push(node.url)
|
||||
break
|
||||
default:
|
||||
// Every other node kind is prose or a container, not part of the signature.
|
||||
break
|
||||
}
|
||||
if ('children' in node) for (const child of node.children) visit(child)
|
||||
}
|
||||
visit(tree)
|
||||
return sig
|
||||
}
|
||||
|
||||
/** Render a signature element for an error message, truncated for readability. */
|
||||
function show(value: string | number | undefined): string {
|
||||
if (value === undefined) return 'nothing'
|
||||
const text = JSON.stringify(value)
|
||||
return text.length > 72 ? `${text.slice(0, 72)}…` : text
|
||||
}
|
||||
|
||||
/** Return the first divergence for each structural field; empty means equal. */
|
||||
export function translationStructureDiff(
|
||||
source: TranslationStructureSignature,
|
||||
zh: TranslationStructureSignature,
|
||||
): string[] {
|
||||
const out: string[] = []
|
||||
const fields: [string, (string | number)[], (string | number)[]][] = [
|
||||
['heading (depth)', source.headings, zh.headings],
|
||||
['code block', source.code, zh.code],
|
||||
['table (row x column count)', source.tables, zh.tables],
|
||||
['list (kind, start, item count)', source.lists, zh.lists],
|
||||
['link target', source.links, zh.links],
|
||||
]
|
||||
for (const [field, sourceValues, zhValues] of fields) {
|
||||
const length = Math.max(sourceValues.length, zhValues.length)
|
||||
for (let index = 0; index < length; index++) {
|
||||
if (sourceValues[index] !== zhValues[index]) {
|
||||
out.push(`${field} #${index + 1} diverges between the pair: ${show(sourceValues[index])} vs ${show(zhValues[index])}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
76
scripts/translation-prompt.spec.ts
Normal file
76
scripts/translation-prompt.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/** Regression tests for the executable translation prompt contract. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
parseTranslationResponse,
|
||||
renderTranslationPrompt,
|
||||
renderTranslationResponse,
|
||||
} from './translation-prompt.ts'
|
||||
|
||||
const document = `# Wrapper
|
||||
|
||||
## 模板正文
|
||||
|
||||
\`\`\`\`text
|
||||
{{source_lang}} to {{target_lang}}
|
||||
{{translation_rules}}
|
||||
{{terminology}}
|
||||
[English]({{source_filename}}) | [中文]({{source_filename_zh}})
|
||||
\`\`\`\`
|
||||
`
|
||||
|
||||
describe('translation prompt rendering', () => {
|
||||
it('renders every supported placeholder without recursively rewriting injected rules', () => {
|
||||
const rendered = renderTranslationPrompt(document, {
|
||||
sourceLanguage: 'English',
|
||||
sourceFilename: 'guide.md',
|
||||
translationRules: 'A literal {{source_lang}} in injected rules.',
|
||||
terminology: '| English | 中文 |',
|
||||
})
|
||||
expect(rendered).toContain('English to Chinese')
|
||||
expect(rendered).toContain('A literal {{source_lang}} in injected rules.')
|
||||
expect(rendered).toContain('[English](guide.md) | [中文](guide.zh.md)')
|
||||
})
|
||||
|
||||
it('rejects a filename whose suffix contradicts the source language', () => {
|
||||
expect(() => renderTranslationPrompt(document, {
|
||||
sourceLanguage: 'Chinese',
|
||||
sourceFilename: 'guide.md',
|
||||
translationRules: 'rules',
|
||||
terminology: 'terms',
|
||||
})).toThrow('does not match source language Chinese')
|
||||
})
|
||||
|
||||
it('rejects malformed template placeholders before injecting rule contents', () => {
|
||||
expect(() => renderTranslationPrompt(document.replace('{{source_lang}}', '{{source-lang}}'), {
|
||||
sourceLanguage: 'English',
|
||||
sourceFilename: 'guide.md',
|
||||
translationRules: 'A literal {{source_lang}} in injected rules.',
|
||||
terminology: '| English | 中文 |',
|
||||
})).toThrow('template contains malformed placeholder syntax')
|
||||
})
|
||||
})
|
||||
|
||||
describe('translation response XML', () => {
|
||||
it('round-trips Markdown and the CDATA terminator', () => {
|
||||
const response = {
|
||||
translation: '# Draft\n\nA ]]> marker.',
|
||||
review: '- [Tone] Fixed.',
|
||||
final: '# Final\n\nA ]]> marker.',
|
||||
}
|
||||
expect(parseTranslationResponse(renderTranslationResponse(response))).toEqual(response)
|
||||
})
|
||||
|
||||
it('rejects missing, reordered, nested, attributed, or non-CDATA children', () => {
|
||||
expect(() => parseTranslationResponse('<dsh-translation-response version="1"/>')).toThrow('translation, review, and final')
|
||||
expect(() => parseTranslationResponse('<dsh-translation-response version="1"><review><![CDATA[x]]></review></dsh-translation-response>'))
|
||||
.toThrow('expected translation, got review')
|
||||
expect(() => parseTranslationResponse(renderTranslationResponse({ translation: 'x', review: 'y', final: 'z' })
|
||||
.replace('<translation><![CDATA[x]]></translation>', '<translation><b><![CDATA[x]]></b></translation>')))
|
||||
.toThrow('nested element b is not allowed')
|
||||
expect(() => parseTranslationResponse(renderTranslationResponse({ translation: 'x', review: 'y', final: 'z' }).replace('<review>', '<review lang="en">')))
|
||||
.toThrow('review must not have attributes')
|
||||
expect(() => parseTranslationResponse(renderTranslationResponse({ translation: 'x', review: 'y', final: 'z' }).replace('<![CDATA[x]]>', 'x')))
|
||||
.toThrow('all response field content must be inside CDATA')
|
||||
})
|
||||
})
|
||||
171
scripts/translation-prompt.ts
Normal file
171
scripts/translation-prompt.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Executable renderer and strict response parser for the committed
|
||||
* documentation-translation prompt contract.
|
||||
*/
|
||||
|
||||
import { basename } from 'node:path'
|
||||
import { SaxesParser } from 'saxes'
|
||||
|
||||
/** Placeholder names supported by the committed translation prompt. */
|
||||
export const TRANSLATION_PROMPT_PLACEHOLDERS = [
|
||||
'source_lang',
|
||||
'target_lang',
|
||||
'translation_rules',
|
||||
'terminology',
|
||||
'source_filename',
|
||||
'source_filename_zh',
|
||||
] as const
|
||||
|
||||
type TranslationPromptPlaceholder = (typeof TRANSLATION_PROMPT_PLACEHOLDERS)[number]
|
||||
|
||||
/** Languages accepted by the bidirectional prompt. */
|
||||
type TranslationLanguage = 'English' | 'Chinese'
|
||||
|
||||
/** Inputs that vary for one rendered translation request. */
|
||||
export interface TranslationPromptInput {
|
||||
sourceLanguage: TranslationLanguage
|
||||
/** Source basename, including `.md` or `.zh.md`. */
|
||||
sourceFilename: string
|
||||
/** Complete current `translation-rules.md` contents. */
|
||||
translationRules: string
|
||||
/** Complete current `terminology.md` contents. */
|
||||
terminology: string
|
||||
}
|
||||
|
||||
/** Parsed contents of the three-element XML response. */
|
||||
export interface TranslationResponse {
|
||||
translation: string
|
||||
review: string
|
||||
final: string
|
||||
}
|
||||
|
||||
const PLACEHOLDER = /{{([a-z_]+)}}/g
|
||||
const TEMPLATE_OPEN = '## 模板正文\n\n````text\n'
|
||||
const TEMPLATE_CLOSE = '\n````'
|
||||
const RESPONSE_CHILDREN = ['translation', 'review', 'final'] as const
|
||||
|
||||
/** Extract the machine-consumed text fence from `translation-prompt.md`. */
|
||||
function extractTranslationPrompt(document: string): string {
|
||||
const start = document.indexOf(TEMPLATE_OPEN)
|
||||
if (start === -1) throw new Error('translation prompt: missing `## 模板正文` text fence')
|
||||
const contentStart = start + TEMPLATE_OPEN.length
|
||||
const end = document.indexOf(TEMPLATE_CLOSE, contentStart)
|
||||
if (end === -1) throw new Error('translation prompt: missing closing four-backtick fence')
|
||||
return document.slice(contentStart, end)
|
||||
}
|
||||
|
||||
/** Read the placeholder names documented in the prompt's contract table. */
|
||||
export function documentedTranslationPromptPlaceholders(document: string): string[] {
|
||||
const preambleEnd = document.indexOf(TEMPLATE_OPEN)
|
||||
if (preambleEnd === -1) throw new Error('translation prompt: missing template body')
|
||||
return [...document.slice(0, preambleEnd).matchAll(/^\| `{{([a-z_]+)}}` \|/gm)].map(match => match[1] ?? '')
|
||||
}
|
||||
|
||||
/** Render one system prompt from the checked-in template and canonical rules. */
|
||||
export function renderTranslationPrompt(document: string, input: TranslationPromptInput): string {
|
||||
if (basename(input.sourceFilename) !== input.sourceFilename) {
|
||||
throw new Error(`translation prompt: sourceFilename must be a basename; got ${JSON.stringify(input.sourceFilename)}`)
|
||||
}
|
||||
const sourceIsChinese = input.sourceFilename.endsWith('.zh.md')
|
||||
if (input.sourceLanguage === 'Chinese' ? !sourceIsChinese : sourceIsChinese || !input.sourceFilename.endsWith('.md')) {
|
||||
throw new Error(`translation prompt: ${input.sourceFilename} does not match source language ${input.sourceLanguage}`)
|
||||
}
|
||||
|
||||
const targetLanguage: TranslationLanguage = input.sourceLanguage === 'English' ? 'Chinese' : 'English'
|
||||
const sourceFilenameZh = sourceIsChinese ? input.sourceFilename : input.sourceFilename.replace(/\.md$/, '.zh.md')
|
||||
const values: Record<TranslationPromptPlaceholder, string> = {
|
||||
source_lang: input.sourceLanguage,
|
||||
target_lang: targetLanguage,
|
||||
translation_rules: input.translationRules,
|
||||
terminology: input.terminology,
|
||||
source_filename: input.sourceFilename,
|
||||
source_filename_zh: sourceFilenameZh,
|
||||
}
|
||||
const template = extractTranslationPrompt(document)
|
||||
const placeholderFreeTemplate = template.replace(PLACEHOLDER, '')
|
||||
if (placeholderFreeTemplate.includes('{{') || placeholderFreeTemplate.includes('}}')) {
|
||||
throw new Error('translation prompt: template contains malformed placeholder syntax')
|
||||
}
|
||||
const names = [...template.matchAll(PLACEHOLDER)].map(match => match[1] ?? '')
|
||||
const unknown = names.filter(name => !TRANSLATION_PROMPT_PLACEHOLDERS.includes(name as TranslationPromptPlaceholder))
|
||||
if (unknown.length > 0) throw new Error(`translation prompt: unsupported placeholder(s): ${[...new Set(unknown)].join(', ')}`)
|
||||
const missing = TRANSLATION_PROMPT_PLACEHOLDERS.filter(name => !names.includes(name))
|
||||
if (missing.length > 0) throw new Error(`translation prompt: template does not use required placeholder(s): ${missing.join(', ')}`)
|
||||
|
||||
return template.replace(PLACEHOLDER, (_token, name: string) => values[name as TranslationPromptPlaceholder])
|
||||
}
|
||||
|
||||
/** Escape one value so it remains byte-identical inside an XML CDATA field. */
|
||||
function escapeTranslationCdata(value: string): string {
|
||||
return value.replaceAll(']]>', ']]]]><![CDATA[>')
|
||||
}
|
||||
|
||||
/** Serialize a response using the exact XML wire contract in the prompt. */
|
||||
export function renderTranslationResponse(response: TranslationResponse): string {
|
||||
return [
|
||||
'<dsh-translation-response version="1">',
|
||||
`<translation><![CDATA[${escapeTranslationCdata(response.translation)}]]></translation>`,
|
||||
`<review><![CDATA[${escapeTranslationCdata(response.review)}]]></review>`,
|
||||
`<final><![CDATA[${escapeTranslationCdata(response.final)}]]></final>`,
|
||||
'</dsh-translation-response>',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Parse and validate the exact XML response shape emitted by the model. */
|
||||
export function parseTranslationResponse(xml: string): TranslationResponse {
|
||||
const values: TranslationResponse = { translation: '', review: '', final: '' }
|
||||
const stack: string[] = []
|
||||
const cdataFields = new Set<string>()
|
||||
let rootSeen = false
|
||||
let childIndex = 0
|
||||
const fail = (message: string): never => {
|
||||
throw new Error(`translation response: ${message}`)
|
||||
}
|
||||
const parser = new SaxesParser({ xmlns: false })
|
||||
|
||||
parser.on('opentag', (tag) => {
|
||||
if (stack.length === 0) {
|
||||
if (rootSeen) fail('contains more than one root element')
|
||||
if (tag.name !== 'dsh-translation-response') fail(`expected dsh-translation-response root, got ${tag.name}`)
|
||||
const attributes = Object.keys(tag.attributes)
|
||||
if (attributes.length !== 1 || tag.attributes.version !== '1') fail('root must have only version="1"')
|
||||
rootSeen = true
|
||||
} else if (stack.length === 1) {
|
||||
const expected = RESPONSE_CHILDREN[childIndex]
|
||||
if (tag.name !== expected) fail(`expected ${expected ?? 'no more children'}, got ${tag.name}`)
|
||||
if (Object.keys(tag.attributes).length !== 0) fail(`${tag.name} must not have attributes`)
|
||||
childIndex++
|
||||
} else {
|
||||
fail(`nested element ${tag.name} is not allowed`)
|
||||
}
|
||||
stack.push(tag.name)
|
||||
})
|
||||
parser.on('text', (value) => {
|
||||
if (stack.length <= 1 && value.trim() === '') return
|
||||
fail('all response field content must be inside CDATA')
|
||||
})
|
||||
parser.on('cdata', (value) => {
|
||||
const field = stack.at(-1)
|
||||
if (field === undefined || !RESPONSE_CHILDREN.includes(field as (typeof RESPONSE_CHILDREN)[number])) {
|
||||
fail('CDATA is allowed only inside translation, review, or final')
|
||||
}
|
||||
const key = field as (typeof RESPONSE_CHILDREN)[number]
|
||||
values[key] += value
|
||||
cdataFields.add(key)
|
||||
})
|
||||
parser.on('closetag', (tag) => {
|
||||
const expected = stack.pop()
|
||||
if (expected !== tag.name) fail(`closing ${tag.name} does not match ${expected ?? 'nothing'}`)
|
||||
})
|
||||
parser.on('comment', () => fail('comments are not allowed'))
|
||||
parser.on('doctype', () => fail('doctypes are not allowed'))
|
||||
parser.on('processinginstruction', () => fail('processing instructions are not allowed'))
|
||||
parser.on('error', error => fail(`invalid XML: ${error.message}`))
|
||||
parser.write(xml).close()
|
||||
|
||||
if (childIndex !== RESPONSE_CHILDREN.length) fail('translation, review, and final must each appear exactly once and in order')
|
||||
for (const field of RESPONSE_CHILDREN) {
|
||||
if (!cdataFields.has(field)) fail(`${field} must contain a CDATA section`)
|
||||
}
|
||||
return values
|
||||
}
|
||||
@@ -3,13 +3,17 @@
|
||||
"entries": [
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/util/brand/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "AssistantProvenance", "source": "packages/llm/llm/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "Message", "source": "packages/llm/llm/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "MessageSourceMap", "source": "packages/llm/llm/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", "source": "packages/llm/llm/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "LlmProviderInfo", "source": "packages/llm/llm/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "LlmModelInfo", "source": "packages/llm/llm/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "GenerateOptions", "source": "packages/llm/llm/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "ToolSchema", "source": "packages/llm/llm/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "LlmCallConfig", "source": "packages/llm/llm/src/call-config.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "InjectOptions", "source": "packages/core/agent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/core/agent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" },
|
||||
@@ -30,6 +34,10 @@
|
||||
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "AppIdentity", "source": "packages/llm/llm/src/attribution.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/token-meter.md", "symbol": "TokenMeasurement", "source": "packages/llm/token-meter/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/token-meter.md", "symbol": "TokenSurfaceNode", "source": "packages/llm/token-meter/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "ContextEnvelope", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "SessionEventMap", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "EpochHeader", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "TodoItem", "source": "packages/core/session/src/types.ts" },
|
||||
@@ -39,19 +47,23 @@
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceEventType", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceOp", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceIntent", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceNode", "source": "packages/core/session/src/surface.ts" },
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldReplacement", "source": "packages/core/session/src/surface.ts" },
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldResult", "source": "packages/core/session/src/surface.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionLocation", "source": "packages/session-persistence/session-persistence/src/index.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSurface", "source": "packages/session-query/session-query/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionRecord", "source": "packages/session-query/session-query/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventRecord", "source": "packages/session-query/session-query/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionLineageNode", "source": "packages/session-query/session-query/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionLineageTrace", "source": "packages/session-query/session-query/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionQueryErrorCode", "source": "packages/session-query/session-query/src/config.ts" },
|
||||
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventReadRequest", "source": "packages/session-query/session-query/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventWindow", "source": "packages/session-query/session-query/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventTraceRequest", "source": "packages/session-query/session-query/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventTrace", "source": "packages/session-query/session-query/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" },
|
||||
@@ -60,6 +72,8 @@
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionToken", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionInput", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionMode", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolRunContext", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolGuard", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolRestriction", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" },
|
||||
@@ -83,13 +97,22 @@
|
||||
{ "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalPolicy", "source": "packages/ui/user-approval/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalRequest", "source": "packages/ui/user-approval/src/index.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "DshEnvironmentKey", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "DshEnvironment", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecSpec", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashSandboxInfo", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "CollectedOutput", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashProcess", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashProcessRead", "source": "packages/bash/bash/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskKindMap", "source": "packages/tasks/tasks/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskStart", "source": "packages/tasks/tasks/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskHooks", "source": "packages/tasks/tasks/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskOutcome", "source": "packages/tasks/tasks/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskSnapshot", "source": "packages/tasks/tasks/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskRead", "source": "packages/tasks/tasks/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/sandbox.md", "symbol": "SandboxMode", "source": "packages/sandbox/sandbox/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/sandbox.md", "symbol": "ConfinedSandboxMode", "source": "packages/sandbox/sandbox/src/index.ts" },
|
||||
@@ -107,6 +130,7 @@
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTargetKey", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsVersion", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsInfo", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsPathInfo", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsDirEntry", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteIntent", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteOutcome", "source": "packages/fs/fs/src/types.ts" },
|
||||
@@ -142,6 +166,12 @@
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchResult", "source": "packages/web/web/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchBody", "source": "packages/web/web/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/spill.md", "symbol": "SaveTextSpill", "source": "packages/spill/spill/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/spill.md", "symbol": "SpillOwner", "source": "packages/spill/spill/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/spill.md", "symbol": "SpillSource", "source": "packages/spill/spill/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/spill.md", "symbol": "SpillRef", "source": "packages/spill/spill/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/spill.md", "symbol": "SpillLocator", "source": "packages/spill/spill/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowStartRequest", "source": "packages/workflow/workflow/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowMeta", "source": "packages/workflow/workflow/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowResult", "source": "packages/workflow/workflow/src/types.ts" },
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
/**
|
||||
* Reject JavaScript expressions in Cordis Loader entry metadata.
|
||||
* Validate Cordis Loader entry metadata and example package resolution.
|
||||
*
|
||||
* The Loader interpolates only a plugin entry's `config`; expression objects in
|
||||
* fields such as `disabled` remain truthy data and silently change composition.
|
||||
* Example configs run from built packages, so every named package must resolve
|
||||
* from the examples workspace and every local package must be in the root
|
||||
* TypeScript project graph.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { dirname, relative, resolve } from 'node:path'
|
||||
import * as yaml from 'js-yaml'
|
||||
import ts from 'typescript'
|
||||
|
||||
interface JsExpr {
|
||||
__jsExpr: string
|
||||
}
|
||||
|
||||
interface PackageManifest {
|
||||
name?: string
|
||||
dependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
interface PluginReference {
|
||||
file: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const
|
||||
const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
|
||||
@@ -30,6 +44,7 @@ const files = globSync(['**/*cordis*.yml', '**/*cordis*.yaml'], {
|
||||
exclude: ['.claude/**', 'node_modules/**', 'vendor/**'],
|
||||
}).sort()
|
||||
const errors: string[] = []
|
||||
const examplePluginReferences: PluginReference[] = []
|
||||
|
||||
for (const file of files) {
|
||||
const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema })
|
||||
@@ -42,8 +57,10 @@ for (const file of files) {
|
||||
}
|
||||
}
|
||||
|
||||
errors.push(...validateExampleResolution())
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error('verify-cordis-config: Loader entry metadata is static; move !!js under plugin config or select an explicit overlay.')
|
||||
console.error('verify-cordis-config: invalid Loader metadata or example package resolution:')
|
||||
for (const error of errors) console.error(`- ${error}`)
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
@@ -55,6 +72,7 @@ function validateEntry(value: unknown, file: string, path: string): void {
|
||||
errors.push(`${file}${path}: entry must be an object`)
|
||||
return
|
||||
}
|
||||
recordExamplePlugin(value, file)
|
||||
validateMetadata(value, file, path)
|
||||
if ((value.group === true || value.name === '@cordisjs/plugin-group') && isUnknownArray(value.config)) {
|
||||
for (let index = 0; index < value.config.length; index++) {
|
||||
@@ -68,6 +86,7 @@ function validateEntry(value: unknown, file: string, path: string): void {
|
||||
const patch = config.patches[index]
|
||||
const patchPath = `${path}.config.patches[${index}]`
|
||||
if (!isRecord(patch)) continue
|
||||
recordExamplePlugin(patch, file)
|
||||
validateMetadata(patch, file, patchPath)
|
||||
if (!isUnknownArray(patch.insert)) continue
|
||||
for (let insertIndex = 0; insertIndex < patch.insert.length; insertIndex++) {
|
||||
@@ -76,6 +95,83 @@ function validateEntry(value: unknown, file: string, path: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function recordExamplePlugin(entry: Record<string, unknown>, file: string): void {
|
||||
if (file.startsWith('examples/') && typeof entry.name === 'string') {
|
||||
examplePluginReferences.push({ file, name: entry.name })
|
||||
}
|
||||
}
|
||||
|
||||
function validateExampleResolution(): string[] {
|
||||
const violations: string[] = []
|
||||
const exampleManifest = readManifest('examples/package.json')
|
||||
const dependencies = exampleManifest.dependencies ?? {}
|
||||
const localPackages = localPackageDirectories()
|
||||
const rootReferences = rootProjectReferences()
|
||||
const requiredPackages = new Map<string, Set<string>>()
|
||||
|
||||
for (const reference of examplePluginReferences) {
|
||||
const packageName = packageNameFromSpecifier(reference.name)
|
||||
if (packageName === undefined) continue
|
||||
const locations = requiredPackages.get(packageName) ?? new Set<string>()
|
||||
locations.add(reference.file)
|
||||
requiredPackages.set(packageName, locations)
|
||||
}
|
||||
|
||||
for (const [packageName, locations] of requiredPackages) {
|
||||
if (!(packageName in dependencies)) {
|
||||
violations.push(`${[...locations].join(', ')}: ${packageName} must be declared in examples/package.json dependencies`)
|
||||
}
|
||||
}
|
||||
|
||||
const localExamplePackages = new Set([
|
||||
...Object.keys(dependencies),
|
||||
...requiredPackages.keys(),
|
||||
])
|
||||
for (const packageName of localExamplePackages) {
|
||||
const packageDirectory = localPackages.get(packageName)
|
||||
if (packageDirectory === undefined || rootReferences.has(packageDirectory)) continue
|
||||
const repoPath = relative(root, packageDirectory).replaceAll('\\', '/')
|
||||
violations.push(`tsconfig.json: missing project reference for ${packageName} (${repoPath})`)
|
||||
}
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
function readManifest(path: string): PackageManifest {
|
||||
return JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest
|
||||
}
|
||||
|
||||
function localPackageDirectories(): Map<string, string> {
|
||||
const manifests = globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root })
|
||||
const packages = new Map<string, string>()
|
||||
for (const manifestPath of manifests) {
|
||||
const manifest = readManifest(manifestPath)
|
||||
if (manifest.name !== undefined) packages.set(manifest.name, resolve(root, dirname(manifestPath)))
|
||||
}
|
||||
return packages
|
||||
}
|
||||
|
||||
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'))
|
||||
}
|
||||
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)]
|
||||
}))
|
||||
}
|
||||
|
||||
function packageNameFromSpecifier(specifier: string): string | undefined {
|
||||
if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('file:')) return undefined
|
||||
const segments = specifier.split('/')
|
||||
if (specifier.startsWith('@')) {
|
||||
return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined
|
||||
}
|
||||
return segments[0] || undefined
|
||||
}
|
||||
|
||||
function validateMetadata(entry: Record<string, unknown>, file: string, path: string): void {
|
||||
for (const field of metadataFields) {
|
||||
if (!(field in entry)) continue
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
* re-exports keep their docs at the declaring contract. Unknown forms fail closed.
|
||||
*/
|
||||
|
||||
import { existsSync, globSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { existsSync, globSync, readFileSync } from 'node:fs'
|
||||
import { relative, resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc } from './jsdoc.ts'
|
||||
|
||||
@@ -389,7 +389,13 @@ function checkDecl(
|
||||
* @param w - the walk state violations append to.
|
||||
* @param ambient - whether this scope is ambient (`declare` namespace or a declaration file), where members export implicitly.
|
||||
*/
|
||||
function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk, ambient: boolean): void {
|
||||
function checkScope(
|
||||
statements: readonly ts.Statement[],
|
||||
prefix: string,
|
||||
w: Walk,
|
||||
ambient: boolean,
|
||||
allowedNames?: ReadonlySet<string>,
|
||||
): void {
|
||||
const byName = new Map<string, ts.Statement[]>()
|
||||
const overloadSigs = new Set<string>()
|
||||
const add = (name: string, stmt: ts.Statement): void => {
|
||||
@@ -455,7 +461,20 @@ function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (isExported(stmt) || (ambient && !ts.isImportDeclaration(stmt))) request(stmt, null)
|
||||
if (isExported(stmt) || (ambient && !ts.isImportDeclaration(stmt))) {
|
||||
if (allowedNames === undefined) {
|
||||
request(stmt, null)
|
||||
} else if (ts.isVariableStatement(stmt)) {
|
||||
for (const declaration of stmt.declarationList.declarations) {
|
||||
if (ts.isIdentifier(declaration.name) && allowedNames.has(declaration.name.text)) {
|
||||
request(stmt, declaration.name.text)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const name = declarationName(stmt) ?? 'default'
|
||||
if (allowedNames.has(name)) request(stmt, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const stmt of statements) {
|
||||
const only = requested.get(stmt)
|
||||
@@ -463,6 +482,67 @@ function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk
|
||||
}
|
||||
}
|
||||
|
||||
function exportedTargets(value: unknown): string[] {
|
||||
if (typeof value === 'string') return [value]
|
||||
if (!value || typeof value !== 'object') return []
|
||||
return Object.values(value).flatMap(exportedTargets)
|
||||
}
|
||||
|
||||
function sourceEntry(target: string): string | undefined {
|
||||
if (target.startsWith('./lib/types/') && target.endsWith('.d.ts')) {
|
||||
return `src/${target.slice('./lib/types/'.length, -'.d.ts'.length)}.ts`
|
||||
}
|
||||
if (target.startsWith('./lib/') && target.endsWith('.js')) {
|
||||
return `src/${target.slice('./lib/'.length, -'.js'.length)}.ts`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function declarationName(declaration: ts.Node): string | undefined {
|
||||
const name = (declaration as ts.NamedDeclaration).name
|
||||
if (name && ts.isIdentifier(name)) return name.text
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Resolve the declarations reachable through packages that do not export src/*. */
|
||||
function restrictedPublicNames(
|
||||
scanRoot: string,
|
||||
rels: readonly string[],
|
||||
program: ts.Program,
|
||||
checker: ts.TypeChecker,
|
||||
): { restrictedPackages: Set<string>; namesByFile: Map<string, Set<string>> } {
|
||||
const restrictedPackages = new Set<string>()
|
||||
const namesByFile = new Map<string, Set<string>>()
|
||||
const packages = new Set(rels.map(rel => rel.split('/').slice(0, 3).join('/')))
|
||||
for (const packageDir of packages) {
|
||||
const manifestPath = resolve(scanRoot, packageDir, 'package.json')
|
||||
if (!existsSync(manifestPath)) continue
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { exports?: Record<string, unknown> }
|
||||
if (!manifest.exports || manifest.exports['./src/*'] !== undefined) continue
|
||||
restrictedPackages.add(packageDir)
|
||||
const entries = new Set(Object.values(manifest.exports).flatMap(exportedTargets).flatMap((target) => {
|
||||
const entry = sourceEntry(target)
|
||||
return entry ? [`${packageDir}/${entry}`] : []
|
||||
}))
|
||||
for (const entry of entries) {
|
||||
const source = program.getSourceFile(resolve(scanRoot, entry))
|
||||
const moduleSymbol = source && checker.getSymbolAtLocation(source)
|
||||
if (!source || !moduleSymbol) continue
|
||||
for (const exported of checker.getExportsOfModule(moduleSymbol)) {
|
||||
const target = (exported.flags & ts.SymbolFlags.Alias) !== 0 ? checker.getAliasedSymbol(exported) : exported
|
||||
for (const declaration of target.declarations ?? []) {
|
||||
const name = declarationName(declaration)
|
||||
const file = declaration.getSourceFile().fileName
|
||||
const rel = relative(scanRoot, file).split(sep).join('/')
|
||||
if (!name || !rel.startsWith(`${packageDir}/src/`)) continue
|
||||
namesByFile.set(rel, new Set([...(namesByFile.get(rel) ?? []), name]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { restrictedPackages, namesByFile }
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiler options for the walk's program.
|
||||
*
|
||||
@@ -495,15 +575,26 @@ function loadCompilerOptions(scanRoot: string): ts.CompilerOptions {
|
||||
*/
|
||||
export function collectExportJsdocViolations(scanRoot: string = root): string[] {
|
||||
const violations: string[] = []
|
||||
const rels = globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).sort()
|
||||
const rels = globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot })
|
||||
.map(path => path.split(sep).join('/'))
|
||||
.sort()
|
||||
const program = ts.createProgram(rels.map(rel => resolve(scanRoot, rel)), loadCompilerOptions(scanRoot))
|
||||
const checker = program.getTypeChecker()
|
||||
const { restrictedPackages, namesByFile } = restrictedPublicNames(scanRoot, rels, program, checker)
|
||||
for (const rel of rels) {
|
||||
const sf = program.getSourceFile(resolve(scanRoot, rel))
|
||||
if (!sf) continue // program root files always resolve; guard for narrowing
|
||||
// A script-style declaration file (no imports/exports) is one big ambient
|
||||
// scope; a module-style .d.ts still honors explicit export modifiers.
|
||||
checkScope(sf.statements, '', { rel, sf, text: sf.text, checker, violations }, sf.isDeclarationFile && !ts.isExternalModule(sf))
|
||||
const packageDir = rel.split('/').slice(0, 3).join('/')
|
||||
const allowedNames = restrictedPackages.has(packageDir) ? namesByFile.get(rel) ?? new Set<string>() : undefined
|
||||
checkScope(
|
||||
sf.statements,
|
||||
'',
|
||||
{ rel, sf, text: sf.text, checker, violations },
|
||||
sf.isDeclarationFile && !ts.isExternalModule(sf),
|
||||
allowedNames,
|
||||
)
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ interface SentenceContract {
|
||||
const NO_MODEL_EXPERIENCE_SECTION: Readonly<Record<string, string>> = {
|
||||
'packages/core/scope': 'The package is a model-agnostic registration and lifecycle primitive; model-facing consumers own any context selection.',
|
||||
'packages/util/brand': 'The package is a type-only primitive erased at compile time.',
|
||||
'packages/util/paths': 'The package only resolves harness-owned host paths; model-facing consumers own any rendered use.',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,23 +48,33 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
|
||||
'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' },
|
||||
'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' },
|
||||
'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' },
|
||||
'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' },
|
||||
'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' },
|
||||
'packages/sdk/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' },
|
||||
'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' },
|
||||
'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' },
|
||||
'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' },
|
||||
'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' },
|
||||
'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' },
|
||||
'packages/spill/spill-local': { kind: 'indirect', reason: 'The storage backend delegates model rendering to spill consumers.' },
|
||||
'packages/subagent/subagent': { kind: 'indirect', reason: 'The provider registry delegates parent-model rendering to dsh-tool-subagent.' },
|
||||
'packages/subagent/subagent-subprocess': { kind: 'indirect', reason: 'Only process-based subagent backends compose a child model request.' },
|
||||
'packages/support/acp-snapshot': { kind: 'none', reason: 'The test harness observes and normalizes transcripts without changing live requests.' },
|
||||
'packages/support/agent-loop-testkit': { kind: 'none', reason: 'The test helper mounts services but neither drives nor modifies model requests.' },
|
||||
'packages/support/invariants': { kind: 'none', reason: 'The observer validates requests but never rewrites their context.' },
|
||||
'packages/support/loader-smoke': { kind: 'none', reason: 'The test harness observes child-process streams without changing live requests.' },
|
||||
'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' },
|
||||
'packages/support/subagent-mock': { kind: 'indirect', reason: 'Only dsh-tool-subagent renders its configured test outcome.' },
|
||||
'packages/tasks/tasks': { kind: 'indirect', reason: 'Producer and control-surface plugins own all model rendering over the task registry.' },
|
||||
'packages/examples/acp-demo': { kind: 'indirect', reason: 'The app bundle delegates request composition to dsh-agent-spine-demo and dsh-acp.' },
|
||||
'packages/ui/app-boot': { kind: 'indirect', reason: 'Only the loaded plugin tree contributes model context.' },
|
||||
'packages/examples/jsonrpc-demo': { kind: 'indirect', reason: 'Only the externally configured plugin tree contributes model context.' },
|
||||
'packages/ui/permission': { kind: 'indirect', reason: 'The service writes mechanism events rendered by dsh-user-approval and dsh-tool-bash.' },
|
||||
'packages/ui/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' },
|
||||
'packages/util/home': { kind: 'indirect', reason: 'Only dsh-tool-bash exposes the resolved home to model commands.' },
|
||||
'packages/util/timeout': { kind: 'indirect', reason: 'Only timeout consumers render timeout outcomes.' },
|
||||
'packages/util/retention': { kind: 'indirect', reason: 'Only retention consumers render retained content and omission metadata.' },
|
||||
'packages/web/web': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-web.' },
|
||||
'packages/web/web-fetch-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' },
|
||||
'packages/web/web-search-exa': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' },
|
||||
|
||||
@@ -10,10 +10,15 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { basename, join, 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 {
|
||||
datedDocumentDate,
|
||||
linksTo,
|
||||
parseTranslationMarkdown,
|
||||
parseTranslationPairingManifest,
|
||||
requiresPairByDate,
|
||||
translationStructureDiff,
|
||||
translationStructureSignature,
|
||||
} from './translation-pairing.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const listMode = process.argv.includes('--list')
|
||||
@@ -22,14 +27,7 @@ const writeMode = process.argv.includes('--write')
|
||||
/** Scope of the bilingual contract: the root README, the docs tree, and the Python SDK tree. */
|
||||
const SCOPE_PATTERNS = ['README.md', 'README.zh.md', 'README.i18n.yaml', 'docs/**/*.md', 'docs/**/*.i18n.yaml', 'python/**/*.md', 'python/**/*.i18n.yaml']
|
||||
|
||||
/** The enforcement frontier and the never-paired set (docs/i18n/README.md § Scope). */
|
||||
interface Manifest {
|
||||
required: string[]
|
||||
excluded: string[]
|
||||
/** Date-named documents (yyyy-mm-dd-*.md, i.e. RFCs) dated on/after this day must merge bilingual. */
|
||||
requiredSince: string
|
||||
}
|
||||
const manifest = JSON.parse(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8')) as Manifest
|
||||
const manifest = parseTranslationPairingManifest(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8'))
|
||||
|
||||
/**
|
||||
* An excluded entry ending in `/` excludes the whole directory. The trailing
|
||||
@@ -81,98 +79,6 @@ function renderMeta(source: string, sourceHash: string, zh: string, zhHash: stri
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* The structural signature the two sides must share, as ordered sequences so
|
||||
* a swap or a level change is caught, not just a count change. Prose is
|
||||
* deliberately absent: the gate checks shape, never wording.
|
||||
*/
|
||||
interface Signature {
|
||||
/** Heading depths in document order (h2 → 2). */
|
||||
headings: number[]
|
||||
/** Fenced code blocks verbatim: info string + content, in order. */
|
||||
code: string[]
|
||||
/** Column count of each table, in order. */
|
||||
tables: number[]
|
||||
/** Each list's kind (ordered vs bullet), in order. */
|
||||
lists: string[]
|
||||
/** Every link target in order, the language switcher's excluded. */
|
||||
links: string[]
|
||||
}
|
||||
|
||||
/** Whether the tree contains a link to exactly `target` (the switcher check). */
|
||||
function linksTo(tree: Nodes, target: string): boolean {
|
||||
let found = false
|
||||
const visit = (node: Nodes): void => {
|
||||
if (node.type === 'link' && node.url === target) found = true
|
||||
if ('children' in node) for (const child of node.children) visit(child)
|
||||
}
|
||||
visit(tree)
|
||||
return found
|
||||
}
|
||||
|
||||
/** Collect the structural signature, skipping links to `switcherTarget`. */
|
||||
function signatureOf(tree: Nodes, switcherTarget: string): Signature {
|
||||
const sig: Signature = { headings: [], code: [], tables: [], lists: [], links: [] }
|
||||
const visit = (node: Nodes): void => {
|
||||
switch (node.type) {
|
||||
case 'heading':
|
||||
sig.headings.push(node.depth)
|
||||
break
|
||||
case 'code':
|
||||
sig.code.push(`\`\`\`${node.lang ?? ''}${node.meta ? ` ${node.meta}` : ''}\n${node.value}`)
|
||||
break
|
||||
case 'table':
|
||||
sig.tables.push(node.children[0]?.children.length ?? 0)
|
||||
break
|
||||
case 'list':
|
||||
sig.lists.push(node.ordered ? 'ordered' : 'bullet')
|
||||
break
|
||||
case 'link':
|
||||
if (node.url !== switcherTarget) sig.links.push(node.url)
|
||||
break
|
||||
default:
|
||||
// Every other node kind is prose or container — not part of the signature.
|
||||
break
|
||||
}
|
||||
if ('children' in node) for (const child of node.children) visit(child)
|
||||
}
|
||||
visit(tree)
|
||||
return sig
|
||||
}
|
||||
|
||||
/** Render a signature element for an error message, truncated for readability. */
|
||||
function show(value: string | number | undefined): string {
|
||||
if (value === undefined) return 'nothing'
|
||||
const text = JSON.stringify(value)
|
||||
return text.length > 72 ? `${text.slice(0, 72)}…` : text
|
||||
}
|
||||
|
||||
/** First divergence between two signatures, as messages; empty when identical. */
|
||||
function signatureDiff(source: Signature, zh: Signature): string[] {
|
||||
const out: string[] = []
|
||||
const fields: [string, (string | number)[], (string | number)[]][] = [
|
||||
['heading (depth)', source.headings, zh.headings],
|
||||
['code block', source.code, zh.code],
|
||||
['table (column count)', source.tables, zh.tables],
|
||||
['list (kind)', source.lists, zh.lists],
|
||||
['link target', source.links, zh.links],
|
||||
]
|
||||
for (const [field, s, z] of fields) {
|
||||
const length = Math.max(s.length, z.length)
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (s[i] !== z[i]) {
|
||||
out.push(`${field} #${i + 1} diverges between the pair: ${show(s[i])} vs ${show(z[i])}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function parse(content: string): Nodes {
|
||||
return fromMarkdown(content, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
|
||||
}
|
||||
|
||||
// Enumerate the scope once.
|
||||
const files = new Set<string>()
|
||||
for (const pattern of SCOPE_PATTERNS) {
|
||||
@@ -218,14 +124,13 @@ for (const req of manifest.required) {
|
||||
// 2. Date-named documents (RFCs) dated on/after the requiredSince cutoff merge
|
||||
// bilingual: a new RFC lands with its pair or not at all. Deterministic from
|
||||
// the filename alone — no git history, so it holds on shallow CI checkouts.
|
||||
const DATED = /(?:^|\/)(\d{4}-\d{2}-\d{2})-[^/]*\.md$/
|
||||
for (const source of sources) {
|
||||
if (isExcluded(source)) continue
|
||||
const dated = DATED.exec(source)
|
||||
if (!dated?.[1] || dated[1] < manifest.requiredSince) continue
|
||||
const date = datedDocumentDate(source)
|
||||
if (!requiresPairByDate(source, manifest.requiredSince) || date === undefined) continue
|
||||
const { zh } = pairPaths(source)
|
||||
if (!existsSync(join(root, zh))) {
|
||||
errors.push(`${source}: dated ${dated[1]} — documents dated on/after ${manifest.requiredSince} merge bilingual (docs/i18n/README.md); add the counterpart and record the pair`)
|
||||
errors.push(`${source}: dated ${date} — documents dated on/after ${manifest.requiredSince} merge bilingual (docs/i18n/README.md); add the counterpart and record the pair`)
|
||||
state.set(source, 'missing')
|
||||
}
|
||||
}
|
||||
@@ -273,15 +178,18 @@ for (const source of [...pairAnchors].sort()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const sourceTree = parse(sourceContent.toString('utf8'))
|
||||
const zhTree = parse(zhContent.toString('utf8'))
|
||||
const sourceTree = parseTranslationMarkdown(sourceContent.toString('utf8'))
|
||||
const zhTree = parseTranslationMarkdown(zhContent.toString('utf8'))
|
||||
if (!linksTo(zhTree, basename(source))) {
|
||||
errors.push(`${zh}: missing language switcher — no link to ${basename(source)}`)
|
||||
}
|
||||
if (!linksTo(sourceTree, basename(zh))) {
|
||||
errors.push(`${source}: missing language switcher — no link back to ${basename(zh)}`)
|
||||
}
|
||||
for (const divergence of signatureDiff(signatureOf(sourceTree, basename(zh)), signatureOf(zhTree, basename(source)))) {
|
||||
for (const divergence of translationStructureDiff(
|
||||
translationStructureSignature(sourceTree, basename(zh)),
|
||||
translationStructureSignature(zhTree, basename(source)),
|
||||
)) {
|
||||
errors.push(`${source} ↔ ${zh}: ${divergence}`)
|
||||
}
|
||||
if (!state.has(source)) state.set(source, 'ok')
|
||||
@@ -297,8 +205,7 @@ if (listMode) {
|
||||
const rows = [...state.entries()].sort((a, b) => order[a[1]] - order[b[1]] || a[0].localeCompare(b[0]))
|
||||
for (const [file, status] of rows) {
|
||||
const required = manifest.required.includes(file)
|
||||
const date = DATED.exec(file)?.[1]
|
||||
const tag = required ? ' (required)' : date && date >= manifest.requiredSince ? ' (required by date)' : ' (backlog)'
|
||||
const tag = required ? ' (required)' : requiresPairByDate(file, manifest.requiredSince) ? ' (required by date)' : ' (backlog)'
|
||||
console.log(`${status.padEnd(11)} ${file}${status === 'missing' ? tag : ''}`)
|
||||
}
|
||||
const counts = { 'ok': 0, 'out-of-sync': 0, 'missing': 0 }
|
||||
|
||||
56
scripts/verify-translation-prompt.ts
Normal file
56
scripts/verify-translation-prompt.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/** Verify that the committed translation prompt renders and parses as documented. */
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import {
|
||||
documentedTranslationPromptPlaceholders,
|
||||
parseTranslationResponse,
|
||||
renderTranslationPrompt,
|
||||
renderTranslationResponse,
|
||||
TRANSLATION_PROMPT_PLACEHOLDERS,
|
||||
} from './translation-prompt.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
function read(path: string): string {
|
||||
return readFileSync(join(root, path), 'utf8')
|
||||
}
|
||||
|
||||
try {
|
||||
const document = read('docs/i18n/translation-prompt.md')
|
||||
const translationRules = read('docs/i18n/translation-rules.md')
|
||||
const terminology = read('docs/i18n/terminology.md')
|
||||
const documented = documentedTranslationPromptPlaceholders(document)
|
||||
if (documented.join('\n') !== TRANSLATION_PROMPT_PLACEHOLDERS.join('\n')) {
|
||||
throw new Error(`placeholder table must list exactly: ${TRANSLATION_PROMPT_PLACEHOLDERS.join(', ')}`)
|
||||
}
|
||||
|
||||
const englishSource = renderTranslationPrompt(document, {
|
||||
sourceLanguage: 'English',
|
||||
sourceFilename: 'example.md',
|
||||
translationRules,
|
||||
terminology,
|
||||
})
|
||||
const chineseSource = renderTranslationPrompt(document, {
|
||||
sourceLanguage: 'Chinese',
|
||||
sourceFilename: 'example.zh.md',
|
||||
translationRules,
|
||||
terminology,
|
||||
})
|
||||
if (!englishSource.includes('[English](example.md) | 中文')) throw new Error('English-source render does not carry the Chinese switcher instruction')
|
||||
if (!chineseSource.includes('English | [中文](example.zh.md)')) throw new Error('Chinese-source render does not carry the English switcher instruction')
|
||||
|
||||
const example = /```xml\n([\s\S]*?)\n```/.exec(englishSource)?.[1]
|
||||
if (example === undefined) throw new Error('rendered prompt has no XML response example')
|
||||
parseTranslationResponse(example)
|
||||
|
||||
const roundTrip = { translation: 'first ]]> pass', review: '- [None] No corrections.', final: 'final ]]> text' }
|
||||
const parsed = parseTranslationResponse(renderTranslationResponse(roundTrip))
|
||||
if (JSON.stringify(parsed) !== JSON.stringify(roundTrip)) throw new Error('CDATA split rule does not round-trip response content')
|
||||
|
||||
console.log('verify-translation-prompt: both directions render and the XML response contract parses.')
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`verify-translation-prompt: ${message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user