mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into cross-family-fs-sandbox
# Conflicts: # docs/config-catalog.md # docs/module-graph.md # docs/rfc/INDEX.md # examples/acp-agent/composition.md # examples/acp-agent/cordis.yml # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/permission-switching/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/text-turn/session.jsonl # packages/bash/bash-sandbox/package.json # packages/bash/bash/package.json # packages/bash/tool-bash/src/index.ts # packages/fs/fs/package.json # packages/fs/tool-fs/package.json # packages/fs/tool-fs/src/index.ts # packages/fs/tool-fs/tests/tools.spec.ts # pnpm-lock.yaml
This commit is contained in:
@@ -17,7 +17,7 @@ const root = resolve(import.meta.dirname, '..')
|
||||
/** The closure manifest whose dependencies define the executable. */
|
||||
const DEPLOY_ROOT_PACKAGE = 'dsh-jsonrpc-agent-pkg'
|
||||
/** The app entry inside the deployed closure. */
|
||||
const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js'
|
||||
const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js'
|
||||
const OUTPUT_BASENAME = 'dsh-jsonrpc-agent-pkg'
|
||||
/** Default Node major; SEA mode requires at least Node 22. */
|
||||
const DEFAULT_NODE_RANGE = 'node24'
|
||||
|
||||
@@ -9,8 +9,8 @@ import { spawn } from 'node:child_process'
|
||||
// the overlay config (the stdio bin keeps --expose-internals for the cordis
|
||||
// Loader's HMR path).
|
||||
const UIS = new Map([
|
||||
['repl', ['--expose-internals', '--import', 'tsx', 'packages/ui/stdio-agent/src/bin.ts', 'examples/coding-agent/code-mode.cordis.yml']],
|
||||
['acp', ['--import', 'tsx', 'packages/ui/acp-agent/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']],
|
||||
['repl', ['--expose-internals', '--import', 'tsx', 'packages/examples/stdio-demo/src/bin.ts', 'examples/coding-agent/code-mode.cordis.yml']],
|
||||
['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']],
|
||||
])
|
||||
|
||||
const ui = process.argv[2] ?? 'repl'
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
"AGENTS.md": 1370,
|
||||
"docs/AGENTS.md": 1100,
|
||||
"docs/architecture.md": 1790,
|
||||
"docs/cordis-primer.md": 550,
|
||||
"docs/cordis-primer.md": 600,
|
||||
"docs/defensive-patterns.md": 550,
|
||||
"docs/testing.md": 800,
|
||||
"examples/AGENTS.md": 200,
|
||||
"packages/AGENTS.md": 290,
|
||||
"packages/README.md": 710
|
||||
"packages/README.md": 760
|
||||
}
|
||||
|
||||
@@ -125,7 +125,12 @@ try {
|
||||
})
|
||||
|
||||
try {
|
||||
execFileSync('node_modules/.bin/tsc', ['-b', join(tmp, 'tsconfig.json')], { cwd: root, stdio: 'pipe' })
|
||||
// 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() ?? ''}`
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { dirname, resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { LINK_MAP } from './gen-cordis-catalog.ts'
|
||||
import { parseJsDoc, pointer, rawJsDoc } from './jsdoc.ts'
|
||||
@@ -478,6 +478,15 @@ function walkSchemaExpr(
|
||||
}
|
||||
return
|
||||
}
|
||||
// A union of objects (discriminated union config): collect keys from all
|
||||
// variants. Each variant is visited the same way as an intersect element.
|
||||
if (method === 'union' && call.arguments[0] && ts.isArrayLiteralExpression(call.arguments[0])) {
|
||||
for (const el of call.arguments[0].elements) {
|
||||
const part = unwrapExpr(el)
|
||||
if (ts.isCallExpression(part)) { visit(part); continue }
|
||||
}
|
||||
return
|
||||
}
|
||||
// A chained refinement (`z.object({…}).default(…)` etc.): the keys live on
|
||||
// the call the chain hangs off — keep unwrapping toward it.
|
||||
const base = unwrapExpr(call.expression.expression)
|
||||
@@ -572,7 +581,7 @@ export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] {
|
||||
// workspace-package imports while individual packages are still being walked.
|
||||
const pkgDirByName = new Map<string, string>()
|
||||
const manifests: { dir: string; pkg: string }[] = []
|
||||
for (const manifestRel of globSync('packages/*/*/package.json', { cwd: scanRoot }).sort()) {
|
||||
for (const manifestRel of globSync('packages/*/*/package.json', { cwd: scanRoot }).map(path => path.split(sep).join('/')).sort()) {
|
||||
const dir = manifestRel.slice(0, -'/package.json'.length)
|
||||
const manifest = JSON.parse(readFileSync(resolve(scanRoot, manifestRel), 'utf8')) as { name?: string; os?: string[]; cpu?: string[] }
|
||||
const pkg = manifest.name
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts'
|
||||
|
||||
@@ -129,7 +129,7 @@ function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.Source
|
||||
export function collectEvents(scanRoot: string = root): EventEntry[] {
|
||||
const entries: EventEntry[] = []
|
||||
const violations: string[] = []
|
||||
for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).sort()) {
|
||||
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 (!text.includes('interface Events')) continue
|
||||
@@ -183,7 +183,7 @@ export function collectEvents(scanRoot: string = root): EventEntry[] {
|
||||
export function collectServices(scanRoot: string = root): ServiceEntry[] {
|
||||
const entries: ServiceEntry[] = []
|
||||
const violations: string[] = []
|
||||
for (const rel of globSync('packages/*/*/src/index.ts', { cwd: scanRoot }).sort()) {
|
||||
for (const rel of globSync('packages/*/*/src/index.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
|
||||
const abs = resolve(scanRoot, rel)
|
||||
const text = readFileSync(abs, 'utf8')
|
||||
if (!text.includes('interface Context')) continue
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* `--check` verifies the generated set.
|
||||
*/
|
||||
|
||||
import { existsSync, globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, relative, resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { collectEvents, collectServices } from './gen-cordis-catalog.ts'
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
graphNodeId as nodeId,
|
||||
type PackageGraphNode,
|
||||
} from './package-graph.ts'
|
||||
import { TypeScriptProject } from './ts-project.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
type Pkg = PackageGraphNode
|
||||
@@ -45,6 +46,14 @@ interface EventRelation {
|
||||
listeners: Set<string>
|
||||
}
|
||||
|
||||
interface PackageSource {
|
||||
rel: string
|
||||
pkg: string
|
||||
sourceFile: ts.SourceFile
|
||||
}
|
||||
|
||||
type EventReceiverKind = 'context' | 'agent-dispatch' | 'events-service'
|
||||
|
||||
const GROUP_ORDER = [
|
||||
'util',
|
||||
'llm',
|
||||
@@ -120,8 +129,8 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'user-interaction',
|
||||
title: 'Human question/answer seam',
|
||||
mode: 'seam',
|
||||
implementations: ['stdio-agent', 'acp'],
|
||||
consumers: ['tool-ask-user', 'stdio-agent', 'acp'],
|
||||
implementations: ['stdio-demo', 'acp'],
|
||||
consumers: ['tool-ask-user', 'stdio-demo', 'acp'],
|
||||
note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.',
|
||||
},
|
||||
{
|
||||
@@ -138,7 +147,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'agent',
|
||||
title: 'Agent registry',
|
||||
mode: 'core',
|
||||
consumers: ['agent-loop', 'acp', 'subagent-inprocess', 'stdio-agent', 'invariants'],
|
||||
consumers: ['agent-loop', 'acp', 'subagent-inprocess', 'stdio-demo', 'invariants'],
|
||||
note: 'Owns live Agent handles and the create/resume factory seam.',
|
||||
},
|
||||
{
|
||||
@@ -146,7 +155,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'agent-loop',
|
||||
title: 'Concrete loop driver',
|
||||
mode: 'bundle',
|
||||
consumers: ['agent-core'],
|
||||
consumers: ['agent-spine-demo'],
|
||||
note: 'The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package.',
|
||||
},
|
||||
{
|
||||
@@ -251,51 +260,6 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: string }> = [
|
||||
// Creation notifications preserve synchronous veto/rollback but observe
|
||||
// returned promises explicitly so async listener rejection is not unhandled.
|
||||
{ event: 'agent/created', pkg: 'agent', method: 'events.dispatch' },
|
||||
// Registry disposal reuses the stable carrier captured before entry commit
|
||||
// and contains each listener directly rather than rebuilding via agentEvents.
|
||||
{ event: 'agent/disposed', pkg: 'agent', method: 'events.dispatch' },
|
||||
{ event: 'session/created', pkg: 'session', method: 'events.dispatch' },
|
||||
// Session event callbacks are likewise resolved before the log push, then
|
||||
// invoked individually after commit so observer failures are contained.
|
||||
{ event: 'session/event', pkg: 'session', method: 'events.dispatch' },
|
||||
// Flush resolves the scoped callback set directly so internal instrumentation
|
||||
// cannot substitute the accepted session before parallel invocation.
|
||||
{ event: 'session/flush', pkg: 'session', method: 'events.dispatch' },
|
||||
// Session disposal uses direct callback resolution so teardown contains each
|
||||
// synchronous throw and returned-promise rejection independently.
|
||||
{ event: 'session/disposed', pkg: 'session', method: 'events.dispatch' },
|
||||
// tools/result uses ctx.events.dispatch directly so the registry can invoke
|
||||
// every synchronous observer while containing each callback independently.
|
||||
{ event: 'tools/result', pkg: 'tools', method: 'events.dispatch' },
|
||||
// Subagent lifecycle events intentionally bypass ctx.emit and call
|
||||
// ctx.events.dispatch directly so one throwing listener cannot starve later
|
||||
// listeners or strand an already-started child run.
|
||||
{ event: 'subagent/start', pkg: 'subagent', method: 'events.dispatch' },
|
||||
{ event: 'subagent/end', pkg: 'subagent', method: 'events.dispatch' },
|
||||
// provider-removed fires inside the provider registration's DISPOSER and
|
||||
// routes through the same contained dispatch (see emitLifecycle in
|
||||
// dsh-subagent), so the AST scan cannot attribute it either.
|
||||
{ event: 'subagent/provider-removed', pkg: 'subagent', method: 'events.dispatch' },
|
||||
// The workflow/* lifecycle events dispatch the same way, for the same
|
||||
// per-listener-containment reason (WorkflowService.emitWorkflowEvent).
|
||||
{ event: 'workflow/start', pkg: 'workflow', method: 'events.dispatch' },
|
||||
{ event: 'workflow/phase', pkg: 'workflow', method: 'events.dispatch' },
|
||||
{ event: 'workflow/log', pkg: 'workflow', method: 'events.dispatch' },
|
||||
{ event: 'workflow/agent-start', pkg: 'workflow', method: 'events.dispatch' },
|
||||
{ event: 'workflow/agent-end', pkg: 'workflow', method: 'events.dispatch' },
|
||||
{ event: 'workflow/end', pkg: 'workflow', method: 'events.dispatch' },
|
||||
]
|
||||
|
||||
const DYNAMIC_EVENT_LISTENERS: Array<{ event: string; pkg: string }> = [
|
||||
// The invariants oracle marks the session started from its global
|
||||
// internal/dispatch listener before product session-start callbacks run.
|
||||
{ event: 'agent/session-start', pkg: 'invariants' },
|
||||
]
|
||||
|
||||
function generatedHeader(title: string): string[] {
|
||||
return [
|
||||
'<!-- Generated by scripts/gen-doc-graphs.ts - do not edit by hand.',
|
||||
@@ -467,11 +431,11 @@ type AppExample = typeof APP_EXAMPLES[number]
|
||||
function renderAppExpansion(lines: string[], appNode: string, pluginName: string): void {
|
||||
const agentCore = nodeId('bundle', 'agent_core')
|
||||
const jsonl = nodeId('bundle', 'jsonl')
|
||||
lines.push(` ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-core"]`)
|
||||
lines.push(` ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-spine-demo"]`)
|
||||
lines.push(` ${appNode} --> ${jsonl}["@deepseek-ai/dsh-session-persistence-jsonl"]`)
|
||||
if (pluginName === '@deepseek-ai/dsh-stdio-agent') {
|
||||
if (pluginName === '@deepseek-ai/dsh-stdio-demo') {
|
||||
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'stdio')}["readline UI<br/>console logger<br/>pre-created main agent"]`)
|
||||
} else if (pluginName === '@deepseek-ai/dsh-acp-agent') {
|
||||
} else if (pluginName === '@deepseek-ai/dsh-acp-demo') {
|
||||
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'acp')}["@deepseek-ai/dsh-acp<br/>JSON-RPC stdio bridge<br/>sessions created by client"]`)
|
||||
}
|
||||
lines.push(
|
||||
@@ -497,7 +461,7 @@ function renderAppComposition(example: AppExample): string {
|
||||
const pluginNode = nodeId(`plugin_${example.id}`, plugin.id)
|
||||
lines.push(` ${pluginNode}["${escLabel(plugin.id)}<br/>${escLabel(plugin.name)}"]`)
|
||||
lines.push(` cfg --> ${pluginNode}`)
|
||||
if (plugin.name === '@deepseek-ai/dsh-stdio-agent' || plugin.name === '@deepseek-ai/dsh-acp-agent') {
|
||||
if (plugin.name === '@deepseek-ai/dsh-stdio-demo' || plugin.name === '@deepseek-ai/dsh-acp-demo') {
|
||||
renderAppExpansion(lines, pluginNode, plugin.name)
|
||||
}
|
||||
}
|
||||
@@ -514,81 +478,256 @@ function renderAppComposition(example: AppExample): string {
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function collectEventRelations(): Map<string, EventRelation> {
|
||||
const out = new Map<string, EventRelation>()
|
||||
const ensure = (event: string): EventRelation => {
|
||||
const existing = out.get(event)
|
||||
if (existing) return existing
|
||||
const next = { dispatchers: new Map<string, Set<string>>(), listeners: new Set<string>() }
|
||||
out.set(event, next)
|
||||
return next
|
||||
/** Collect event dispatch/listener relations from real cross-file receiver types. */
|
||||
class EventRelationCollector {
|
||||
private readonly relations = new Map<string, EventRelation>()
|
||||
private readonly callSites = new Map<ts.SignatureDeclaration | ts.JSDocSignature, ts.CallExpression[]>()
|
||||
private readonly contextType: ts.Type
|
||||
private readonly agentDispatchType: ts.Type
|
||||
private readonly eventsServiceType: ts.Type
|
||||
|
||||
constructor(
|
||||
private readonly project: TypeScriptProject,
|
||||
private readonly sources: readonly PackageSource[],
|
||||
) {
|
||||
this.contextType = this.declaredType('vendor/cordis/src/context.ts', 'Context')
|
||||
this.agentDispatchType = this.declaredType('packages/core/agent/src/dispatch.ts', 'AgentEventDispatch')
|
||||
this.eventsServiceType = this.declaredType('vendor/cordis/src/events.ts', 'EventsService')
|
||||
this.indexCallSites()
|
||||
}
|
||||
for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: root }).sort()) {
|
||||
const [, , leaf] = rel.split('/')
|
||||
if (leaf === undefined) continue
|
||||
const text = readFileSync(resolve(root, rel), 'utf8')
|
||||
const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true)
|
||||
|
||||
/** Return all event relations discovered from the Program. */
|
||||
collect(): Map<string, EventRelation> {
|
||||
for (const source of this.sources) this.visitSource(source)
|
||||
return this.relations
|
||||
}
|
||||
|
||||
/** Resolve one named class/interface declaration to its merged instance type. */
|
||||
private declaredType(relativePath: string, name: string): ts.Type {
|
||||
const sourceFile = this.project.sourceFile(relativePath)
|
||||
const declaration = sourceFile.statements.find((statement): statement is ts.ClassDeclaration | ts.InterfaceDeclaration => {
|
||||
return (ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement)) && statement.name?.text === name
|
||||
})
|
||||
const symbol = declaration?.name && this.project.checker.getSymbolAtLocation(declaration.name)
|
||||
if (!symbol) throw new Error(`cannot resolve TypeScript type ${name} from ${relativePath}`)
|
||||
return this.project.checker.getDeclaredTypeOfSymbol(symbol)
|
||||
}
|
||||
|
||||
/** Index resolved local function calls for narrow argument-flow recovery. */
|
||||
private indexCallSites(): void {
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const declaration = this.project.checker.getResolvedSignature(node)?.declaration
|
||||
if (declaration) {
|
||||
const calls = this.callSites.get(declaration) ?? []
|
||||
calls.push(node)
|
||||
this.callSites.set(declaration, calls)
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
for (const source of this.sources) visit(source.sourceFile)
|
||||
}
|
||||
|
||||
/** Walk one package source file and classify event API calls by receiver type. */
|
||||
private visitSource(source: PackageSource): void {
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
||||
const receiverKind = this.receiverKind(node.expression.expression)
|
||||
const method = node.expression.name.text
|
||||
if (!isCordisContextReceiver(node.expression, sf)) {
|
||||
ts.forEachChild(node, visit)
|
||||
return
|
||||
}
|
||||
if (method === 'on') {
|
||||
const event = eventArg(node.arguments, method)
|
||||
if (event) ensure(event).listeners.add(leaf)
|
||||
} else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'waterfall') {
|
||||
const event = eventArg(node.arguments, method)
|
||||
if (event) {
|
||||
const relation = ensure(event)
|
||||
const methods = relation.dispatchers.get(leaf) ?? new Set<string>()
|
||||
methods.add(method)
|
||||
relation.dispatchers.set(leaf, methods)
|
||||
if (receiverKind === 'events-service' && method === 'dispatch') {
|
||||
const argumentList = node.arguments[1]
|
||||
if (argumentList) {
|
||||
for (const event of this.eventNamesFromArgumentList(argumentList, new Set())) {
|
||||
this.addDispatcher(event, source.pkg, 'events.dispatch')
|
||||
}
|
||||
}
|
||||
} else if (receiverKind === 'context' || receiverKind === 'agent-dispatch') {
|
||||
const eventNames = this.eventNamesFromCall(node, receiverKind)
|
||||
if (method === 'on' || method === 'once') {
|
||||
for (const event of eventNames) this.ensure(event).listeners.add(source.pkg)
|
||||
} else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'waterfall') {
|
||||
for (const event of eventNames) this.addDispatcher(event, source.pkg, method)
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(sf)
|
||||
visit(source.sourceFile)
|
||||
}
|
||||
for (const entry of DYNAMIC_EVENT_DISPATCHERS) {
|
||||
const relation = ensure(entry.event)
|
||||
const methods = relation.dispatchers.get(entry.pkg) ?? new Set<string>()
|
||||
methods.add(entry.method)
|
||||
relation.dispatchers.set(entry.pkg, methods)
|
||||
|
||||
/** Classify a receiver using assignability to the repository's actual event API types. */
|
||||
private receiverKind(receiver: ts.Expression): EventReceiverKind | undefined {
|
||||
const type = this.project.checker.getTypeAtLocation(receiver)
|
||||
if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) return undefined
|
||||
if (this.project.checker.isTypeAssignableTo(type, this.eventsServiceType)) return 'events-service'
|
||||
if (this.project.checker.isTypeAssignableTo(type, this.contextType)) return 'context'
|
||||
if (this.project.checker.isTypeAssignableTo(type, this.agentDispatchType)) return 'agent-dispatch'
|
||||
return undefined
|
||||
}
|
||||
for (const entry of DYNAMIC_EVENT_LISTENERS) {
|
||||
ensure(entry.event).listeners.add(entry.pkg)
|
||||
|
||||
/** Resolve the event-name argument for Context and fused agent dispatch calls. */
|
||||
private eventNamesFromCall(call: ts.CallExpression, receiverKind: Exclude<EventReceiverKind, 'events-service'>): Set<string> {
|
||||
const candidates = receiverKind === 'context' ? call.arguments.slice(0, 2) : call.arguments.slice(0, 1)
|
||||
for (const candidate of candidates) {
|
||||
const values = this.finiteStringValues(candidate)
|
||||
if (values) return values
|
||||
}
|
||||
return new Set()
|
||||
}
|
||||
|
||||
/** Recover the event slot from the argument array handed to EventsService.dispatch(). */
|
||||
private eventNamesFromArgumentList(expression: ts.Expression, seen: Set<ts.Node>): Set<string> {
|
||||
const current = unwrapExpression(expression)
|
||||
if (seen.has(current)) return new Set()
|
||||
seen.add(current)
|
||||
|
||||
if (ts.isArrayLiteralExpression(current)) {
|
||||
for (const element of current.elements.slice(0, 2)) {
|
||||
if (ts.isOmittedExpression(element) || ts.isSpreadElement(element)) continue
|
||||
const values = this.finiteStringValues(element)
|
||||
if (values) return values
|
||||
}
|
||||
return new Set()
|
||||
}
|
||||
if (ts.isConditionalExpression(current)) {
|
||||
return unionSets(
|
||||
this.eventNamesFromArgumentList(current.whenTrue, new Set(seen)),
|
||||
this.eventNamesFromArgumentList(current.whenFalse, new Set(seen)),
|
||||
)
|
||||
}
|
||||
if (!ts.isIdentifier(current)) return new Set()
|
||||
|
||||
const symbol = this.project.checker.getSymbolAtLocation(current)
|
||||
if (!symbol) return new Set()
|
||||
const events = new Set<string>()
|
||||
for (const declaration of symbol.declarations ?? []) {
|
||||
if (ts.isVariableDeclaration(declaration) && declaration.initializer && isConstDeclaration(declaration)) {
|
||||
addAll(events, this.eventNamesFromArgumentList(declaration.initializer, new Set(seen)))
|
||||
} else if (ts.isParameter(declaration)) {
|
||||
addAll(events, this.eventNamesFromParameter(declaration, seen))
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/** Follow a non-exported local helper parameter back to every resolved call site. */
|
||||
private eventNamesFromParameter(parameter: ts.ParameterDeclaration, seen: Set<ts.Node>): Set<string> {
|
||||
const owner = parameter.parent
|
||||
if (!ts.isFunctionDeclaration(owner) || hasExportModifier(owner)) return new Set()
|
||||
const index = owner.parameters.indexOf(parameter)
|
||||
if (index < 0) return new Set()
|
||||
const events = new Set<string>()
|
||||
for (const call of this.callSites.get(owner) ?? []) {
|
||||
const argument = call.arguments[index]
|
||||
if (argument) addAll(events, this.eventNamesFromArgumentList(argument, new Set(seen)))
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/** Return a finite string-literal value set, rejecting widened and generic strings. */
|
||||
private finiteStringValues(expression: ts.Expression): Set<string> | undefined {
|
||||
const current = unwrapExpression(expression)
|
||||
if (ts.isStringLiteralLike(current)) return new Set([current.text])
|
||||
if (this.isForwardedAgentEventParameter(current)) return undefined
|
||||
return finiteStringTypeValues(this.project.checker.getTypeAtLocation(current))
|
||||
}
|
||||
|
||||
/** Reject the contextual parameter inside the AgentEventDispatch forwarding object. */
|
||||
private isForwardedAgentEventParameter(expression: ts.Expression): boolean {
|
||||
if (!ts.isIdentifier(expression)) return false
|
||||
const declarations = this.project.checker.getSymbolAtLocation(expression)?.declarations ?? []
|
||||
return declarations.some((declaration) => {
|
||||
if (!ts.isParameter(declaration)) return false
|
||||
const method = declaration.parent
|
||||
if (!ts.isMethodDeclaration(method) || !ts.isObjectLiteralExpression(method.parent)) return false
|
||||
const contextualType = this.project.checker.getContextualType(method.parent)
|
||||
return contextualType !== undefined
|
||||
&& this.project.checker.isTypeAssignableTo(contextualType, this.agentDispatchType)
|
||||
})
|
||||
}
|
||||
|
||||
/** Get or create one relation row. */
|
||||
private ensure(event: string): EventRelation {
|
||||
const existing = this.relations.get(event)
|
||||
if (existing) return existing
|
||||
const relation = { dispatchers: new Map<string, Set<string>>(), listeners: new Set<string>() }
|
||||
this.relations.set(event, relation)
|
||||
return relation
|
||||
}
|
||||
|
||||
/** Add one dispatcher method without duplicating package/method labels. */
|
||||
private addDispatcher(event: string, pkg: string, method: string): void {
|
||||
const relation = this.ensure(event)
|
||||
const methods = relation.dispatchers.get(pkg) ?? new Set<string>()
|
||||
methods.add(method)
|
||||
relation.dispatchers.set(pkg, methods)
|
||||
}
|
||||
}
|
||||
|
||||
/** Peel syntax-only wrappers that do not change an expression's runtime value. */
|
||||
function unwrapExpression(expression: ts.Expression): ts.Expression {
|
||||
let current = expression
|
||||
while (
|
||||
ts.isParenthesizedExpression(current)
|
||||
|| ts.isAsExpression(current)
|
||||
|| ts.isTypeAssertionExpression(current)
|
||||
|| ts.isNonNullExpression(current)
|
||||
|| ts.isSatisfiesExpression(current)
|
||||
) {
|
||||
current = current.expression
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
/** Return every value only when a type is a closed string-literal union. */
|
||||
function finiteStringTypeValues(type: ts.Type): Set<string> | undefined {
|
||||
if (type.flags & ts.TypeFlags.StringLiteral) {
|
||||
return new Set([(type as ts.StringLiteralType).value])
|
||||
}
|
||||
if (type.flags & ts.TypeFlags.Never) return new Set()
|
||||
if (!type.isUnion()) return undefined
|
||||
const values = new Set<string>()
|
||||
for (const member of type.types) {
|
||||
const memberValues = finiteStringTypeValues(member)
|
||||
if (!memberValues) return undefined
|
||||
addAll(values, memberValues)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
/** Return whether a variable declaration belongs to a const declaration list. */
|
||||
function isConstDeclaration(declaration: ts.VariableDeclaration): boolean {
|
||||
return (declaration.parent.flags & ts.NodeFlags.Const) !== 0
|
||||
}
|
||||
|
||||
/** Return whether a declaration is visible to callers outside its source module. */
|
||||
function hasExportModifier(node: ts.Node): boolean {
|
||||
return ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some((modifier) => {
|
||||
return modifier.kind === ts.SyntaxKind.ExportKeyword || modifier.kind === ts.SyntaxKind.DefaultKeyword
|
||||
}) ?? false)
|
||||
}
|
||||
|
||||
/** Add every member of source to target. */
|
||||
function addAll<T>(target: Set<T>, source: ReadonlySet<T>): void {
|
||||
for (const value of source) target.add(value)
|
||||
}
|
||||
|
||||
/** Return the union of two sets without mutating either input. */
|
||||
function unionSets<T>(left: ReadonlySet<T>, right: ReadonlySet<T>): Set<T> {
|
||||
const out = new Set(left)
|
||||
addAll(out, right)
|
||||
return out
|
||||
}
|
||||
|
||||
function isCordisContextReceiver(expr: ts.PropertyAccessExpression, sf: ts.SourceFile): boolean {
|
||||
// The chained fused-dispatch spelling: `agentEvents(ctx, agent).emit(…)` —
|
||||
// the receiver is a call expression, not an identifier.
|
||||
if (ts.isCallExpression(expr.expression) && expr.expression.expression.getText(sf) === 'agentEvents') {
|
||||
return true
|
||||
}
|
||||
const target = expr.expression.getText(sf)
|
||||
if (target === 'ctx' || target === 'this.ctx') return true
|
||||
// Scoped-dispatch spellings are conventional names. Keep this list in sync
|
||||
// with renames or the relationship matrix can silently lose an edge.
|
||||
return target === 'events' || target === 'childCtx' || target === 'this.loopCtx' || target === 'emitCtx'
|
||||
}
|
||||
|
||||
function eventArg(args: ts.NodeArray<ts.Expression>, method: string): string | undefined {
|
||||
if (method === 'waterfall') {
|
||||
const arg = args.find(ts.isStringLiteralLike)
|
||||
return arg?.text
|
||||
}
|
||||
const first = args[0]
|
||||
if (first && ts.isStringLiteralLike(first)) return first.text
|
||||
// Scope-carrier dispatch: `emit(carrier, 'event/name', …)` puts the event
|
||||
// name second. Accept a string literal in position 1 when position 0 is a
|
||||
// non-literal expression (the carrier).
|
||||
const second = args[1]
|
||||
return second && ts.isStringLiteralLike(second) ? second.text : undefined
|
||||
function collectEventRelations(): Map<string, EventRelation> {
|
||||
const project = new TypeScriptProject(root)
|
||||
const sources = project.sourceFiles().flatMap((sourceFile): PackageSource[] => {
|
||||
const rel = project.relativePath(sourceFile)
|
||||
const match = /^packages\/[^/]+\/([^/]+)\/src\/.+\.ts$/.exec(rel)
|
||||
return match?.[1] ? [{ rel, pkg: match[1], sourceFile }] : []
|
||||
}).sort((left, right) => left.rel.localeCompare(right.rel))
|
||||
return new EventRelationCollector(project, sources).collect()
|
||||
}
|
||||
|
||||
function relationPackages(map: Map<string, Set<string>>, pkgsByShort: Map<string, Pkg>): string {
|
||||
@@ -608,10 +747,10 @@ function renderEventRelations(pkgs: Pkg[]): string {
|
||||
const events = collectEvents()
|
||||
const relations = collectEventRelations()
|
||||
const pkgsByShort = new Map(pkgs.map(pkg => [pkg.short, pkg]))
|
||||
const maintenance = 'hybrid generated: Cordis event declarations and most producer/listener edges are AST-scanned; dynamic dispatch sites are classified in `scripts/gen-doc-graphs.ts`'
|
||||
const maintenance = 'generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program'
|
||||
const lines = generatedHeader('Event Producer And Consumer Matrix')
|
||||
lines.push(
|
||||
'This matrix shows which packages dispatch each harness-owned event and which packages listen to it. It is intentionally a table rather than one large graph: events are many-to-many, and dense relation data is easier to review in rows. Dynamic dispatch overrides cover sites that deliberately bypass `ctx.emit`, such as subagent lifecycle containment.',
|
||||
'This matrix shows which packages dispatch each harness-owned event and which packages listen to it. It is intentionally a table rather than one large graph: events are many-to-many, and dense relation data is easier to review in rows. Receiver and event-name types also cover contained dispatch sites that deliberately bypass `ctx.emit`, such as subagent lifecycle containment.',
|
||||
'',
|
||||
'| Event | Mode | Declared in | Dispatchers | Listeners |',
|
||||
'| --- | --- | --- | --- | --- |',
|
||||
@@ -621,7 +760,7 @@ function renderEventRelations(pkgs: Pkg[]): string {
|
||||
lines.push(`| \`${event.name}\` | \`${event.mode}\` | ${sourceLink(event.source)} | ${relationPackages(relation.dispatchers, pkgsByShort)} | ${listenerPackages(relation.listeners, pkgsByShort)} |`)
|
||||
}
|
||||
// Every declared event needs a dispatcher: zero means dead vocabulary or an
|
||||
// unrecognized dispatch spelling. Listener-free extension points remain valid.
|
||||
// unrecognized semantic dispatch shape. Listener-free extension points remain valid.
|
||||
const undispatched = [...events]
|
||||
.filter(event => (relations.get(event.name)?.dispatchers.size ?? 0) === 0)
|
||||
.map(event => event.name)
|
||||
@@ -629,8 +768,8 @@ function renderEventRelations(pkgs: Pkg[]): string {
|
||||
if (undispatched.length > 0) {
|
||||
throw new Error(
|
||||
`event-producer-consumer matrix: no dispatcher found for declared event${undispatched.length > 1 ? 's' : ''} `
|
||||
+ `${undispatched.map(name => `"${name}"`).join(', ')} — dead vocabulary, or a dispatch spelling the scan misses `
|
||||
+ '(teach scripts/gen-doc-graphs.ts the spelling or add a DYNAMIC_EVENT_DISPATCHERS override)',
|
||||
+ `${undispatched.map(name => `"${name}"`).join(', ')} — dead vocabulary, or a dispatch shape the semantic scan misses `
|
||||
+ '(teach scripts/gen-doc-graphs.ts the shape)',
|
||||
)
|
||||
}
|
||||
const declared = new Set(events.map(event => event.name))
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts'
|
||||
|
||||
@@ -117,7 +117,7 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
|
||||
const violations: string[] = []
|
||||
const seen = new Map<string, string>()
|
||||
let owningDecl: string | null = null
|
||||
for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).sort()) {
|
||||
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 (!text.includes('SessionEventMap')) continue
|
||||
@@ -194,7 +194,7 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
|
||||
*/
|
||||
export function collectSurfaceEventTypes(scanRoot: string = root): string[] {
|
||||
const found: { names: string[]; source: string }[] = []
|
||||
for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).sort()) {
|
||||
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 (!text.includes('SurfaceEventType')) continue
|
||||
|
||||
441
scripts/gen-scoped-events.ts
Normal file
441
scripts/gen-scoped-events.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* Generate the dev-invariants scoped-event resolver map from the
|
||||
* repository TypeScript Program.
|
||||
*
|
||||
* A scoped event declares `this: Scoped<Base>`. Real `scopeTarget(base, key)`
|
||||
* calls establish the routing-key type for that base. The generator searches
|
||||
* every event payload parameter and one property level for exactly one type
|
||||
* equivalent to that key. Each generated resolver compiles against the merged
|
||||
* `Events` parameter tuple. Zero matches require `@dshScopeScan unsupported`;
|
||||
* multiple matches are ambiguous and always fail loud.
|
||||
*
|
||||
* `tsx scripts/gen-scoped-events.ts` -> write the generated source
|
||||
* `tsx scripts/gen-scoped-events.ts --check` -> exit 1 when it is stale
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { pointer, rawJsDoc } from './jsdoc.ts'
|
||||
import { TypeScriptProject } from './ts-project.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'packages/support/invariants/src/scoped-events.generated.ts'
|
||||
const SCOPE_DOC_MARKER = 'Scope-filtered dispatch'
|
||||
|
||||
interface ScopeTargetContract {
|
||||
baseType: ts.Type
|
||||
keyType: ts.Type
|
||||
source: string
|
||||
}
|
||||
|
||||
interface SubjectCandidate {
|
||||
path: string
|
||||
parameter: number
|
||||
property?: string
|
||||
type: ts.Type
|
||||
}
|
||||
|
||||
interface ScopedEventResolver {
|
||||
event: string
|
||||
candidate: SubjectCandidate | null
|
||||
ownerPackage: string
|
||||
}
|
||||
|
||||
interface ScopeTag {
|
||||
present: boolean
|
||||
unsupported: boolean
|
||||
}
|
||||
|
||||
/** Program-backed analyzer and renderer for the generated scoped-event resolvers. */
|
||||
class ScopedEventGenerator {
|
||||
private readonly checker: ts.TypeChecker
|
||||
private readonly packageSources: ts.SourceFile[]
|
||||
private readonly scopeTargetDeclaration: ts.FunctionDeclaration
|
||||
private readonly scopedSymbol: ts.Symbol
|
||||
private readonly violations: string[] = []
|
||||
private readonly packageNames = new Map<string, string>()
|
||||
|
||||
constructor(private readonly project: TypeScriptProject) {
|
||||
this.checker = project.checker
|
||||
this.packageSources = project.sourceFiles().filter((sourceFile) => {
|
||||
return /^packages\/[^/]+\/[^/]+\/src\/.+\.ts$/.test(project.relativePath(sourceFile))
|
||||
})
|
||||
this.scopeTargetDeclaration = this.functionDeclaration(
|
||||
'packages/core/scope/src/index.ts',
|
||||
'scopeTarget',
|
||||
)
|
||||
this.scopedSymbol = this.typeAliasSymbol(
|
||||
'packages/core/scope/src/index.ts',
|
||||
'Scoped',
|
||||
)
|
||||
}
|
||||
|
||||
/** Render the complete generated TypeScript module or throw every contract violation. */
|
||||
render(): string {
|
||||
const contracts = this.collectScopeTargetContracts()
|
||||
const resolvers = this.collectScopedEventResolvers(contracts)
|
||||
if (this.violations.length > 0) {
|
||||
throw new Error(
|
||||
`gen-scoped-events: ${this.violations.length} scoped-event contract violation(s):\n`
|
||||
+ this.violations.map(violation => ` - ${violation}`).join('\n'),
|
||||
)
|
||||
}
|
||||
const ownerImports = [...new Set(resolvers.map(resolver => resolver.ownerPackage))]
|
||||
.sort()
|
||||
.map(packageName => `import type {} from ${quote(packageName)}`)
|
||||
return [
|
||||
'/**',
|
||||
' * Generated scoped-event routing-subject resolvers for dsh-invariants.',
|
||||
' * Do not edit by hand; run `pnpm run gen-scoped-events`.',
|
||||
' *',
|
||||
' * @module @deepseek-ai/dsh-invariants/scoped-events.generated',
|
||||
' */',
|
||||
'',
|
||||
"import type { Events } from 'cordis'",
|
||||
"import type { Scoped } from '@deepseek-ai/dsh-scope'",
|
||||
...ownerImports,
|
||||
'',
|
||||
'type ScopedEventName = {',
|
||||
' [K in keyof Events]: ThisParameterType<Events[K]> extends Scoped<object> ? K : never',
|
||||
'}[keyof Events]',
|
||||
'',
|
||||
'type ScopedSubjectResolver = (args: readonly unknown[]) => unknown',
|
||||
'',
|
||||
'function adapt<K extends ScopedEventName>(',
|
||||
' resolver: (args: Parameters<Events[K]>) => unknown,',
|
||||
'): ScopedSubjectResolver {',
|
||||
' return args => resolver(args as Parameters<Events[K]>)',
|
||||
'}',
|
||||
'',
|
||||
'const scopedSubjectResolvers = Object.freeze({',
|
||||
...resolvers.map(({ event, candidate }) => {
|
||||
if (candidate === null) return ` '${event}': null,`
|
||||
const subject = candidate.property === undefined
|
||||
? `args[${candidate.parameter}]`
|
||||
: `args[${candidate.parameter}].${candidate.property}`
|
||||
return ` '${event}': adapt<'${event}'>(args => ${subject}),`
|
||||
}),
|
||||
'} as const satisfies Readonly<Record<ScopedEventName, ScopedSubjectResolver | null>>)',
|
||||
'',
|
||||
'const scopedSubjectResolverIndex: Readonly<Record<string, ScopedSubjectResolver | null>> = scopedSubjectResolvers',
|
||||
'',
|
||||
'/**',
|
||||
' * Resolve the routing key named by one scoped event payload. A null',
|
||||
' * resolver means the payload cannot expose its external routing key, so the',
|
||||
' * invariant checks carrier presence only.',
|
||||
' * @param event - runtime Cordis event name.',
|
||||
' * @returns the generated subject resolver, null for presence-only,',
|
||||
' * or undefined when the event is not scope-filtered.',
|
||||
' */',
|
||||
'export function scopedSubjectResolverFor(event: string): ScopedSubjectResolver | null | undefined {',
|
||||
' return scopedSubjectResolverIndex[event]',
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Resolve one named function declaration from a known source file. */
|
||||
private functionDeclaration(relativePath: string, name: string): ts.FunctionDeclaration {
|
||||
const sourceFile = this.project.sourceFile(relativePath)
|
||||
const declaration = sourceFile.statements.find((statement): statement is ts.FunctionDeclaration => {
|
||||
return ts.isFunctionDeclaration(statement) && statement.name?.text === name
|
||||
})
|
||||
if (!declaration) throw new Error(`gen-scoped-events: cannot resolve function ${name} from ${relativePath}`)
|
||||
return declaration
|
||||
}
|
||||
|
||||
/** Resolve one named type-alias symbol from a known source file. */
|
||||
private typeAliasSymbol(relativePath: string, name: string): ts.Symbol {
|
||||
const sourceFile = this.project.sourceFile(relativePath)
|
||||
const declaration = sourceFile.statements.find((statement): statement is ts.TypeAliasDeclaration => {
|
||||
return ts.isTypeAliasDeclaration(statement) && statement.name.text === name
|
||||
})
|
||||
const symbol = declaration && this.checker.getSymbolAtLocation(declaration.name)
|
||||
if (!symbol) throw new Error(`gen-scoped-events: cannot resolve type ${name} from ${relativePath}`)
|
||||
return symbol
|
||||
}
|
||||
|
||||
/** Collect every real scopeTarget(base, key) base/key type contract. */
|
||||
private collectScopeTargetContracts(): ScopeTargetContract[] {
|
||||
const contracts: ScopeTargetContract[] = []
|
||||
const visit = (sourceFile: ts.SourceFile, node: ts.Node): void => {
|
||||
if (ts.isCallExpression(node)
|
||||
&& this.checker.getResolvedSignature(node)?.declaration === this.scopeTargetDeclaration) {
|
||||
const base = node.arguments[0]
|
||||
const key = node.arguments[1]
|
||||
if (!base || !key) {
|
||||
const source = pointer(this.project.relativePath(sourceFile), sourceFile, node)
|
||||
this.violations.push(`${source} calls scopeTarget without base and key arguments`)
|
||||
} else {
|
||||
contracts.push({
|
||||
baseType: this.checker.getTypeAtLocation(base),
|
||||
keyType: this.checker.getTypeAtLocation(key),
|
||||
source: pointer(this.project.relativePath(sourceFile), sourceFile, node),
|
||||
})
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, (child) => { visit(sourceFile, child) })
|
||||
}
|
||||
for (const sourceFile of this.packageSources) visit(sourceFile, sourceFile)
|
||||
return contracts
|
||||
}
|
||||
|
||||
/** Collect every Events member and derive its generated resolver. */
|
||||
private collectScopedEventResolvers(contracts: readonly ScopeTargetContract[]): ScopedEventResolver[] {
|
||||
const resolvers: ScopedEventResolver[] = []
|
||||
for (const sourceFile of this.packageSources) {
|
||||
const rel = this.project.relativePath(sourceFile)
|
||||
const ownerPackage = this.packageName(packageRootFor(rel))
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isInterfaceDeclaration(node) && node.name.text === 'Events' && isCordisModuleInterface(node)) {
|
||||
for (const member of node.members) {
|
||||
if (!ts.isMethodSignature(member) || !ts.isStringLiteral(member.name)) continue
|
||||
const event = member.name.text
|
||||
const raw = rawJsDoc(sourceFile.text, member)
|
||||
const where = `event '${event}' (${pointer(rel, sourceFile, member)})`
|
||||
const tag = parseScopeTag(raw, where, this.violations)
|
||||
const thisParameter = member.parameters.find(isThisParameter)
|
||||
const scopedBase = thisParameter && this.scopedBaseType(thisParameter)
|
||||
if (!scopedBase) {
|
||||
if (raw.includes(SCOPE_DOC_MARKER)) {
|
||||
this.violations.push(
|
||||
`${where} documents scope-filtered dispatch but its signature has no this: Scoped<...> receiver`,
|
||||
)
|
||||
}
|
||||
if (tag.present) {
|
||||
this.violations.push(`${where} has @dshScopeScan metadata but is not a Scoped event`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!raw.includes(SCOPE_DOC_MARKER)) {
|
||||
this.violations.push(
|
||||
`${where} has this: Scoped<...> but its JSDoc does not explain "${SCOPE_DOC_MARKER}"`,
|
||||
)
|
||||
}
|
||||
const keyType = this.routingKeyType(where, scopedBase, contracts)
|
||||
if (!keyType) continue
|
||||
const candidates = this.subjectCandidates(member)
|
||||
.filter(candidate => this.typesEquivalent(candidate.type, keyType))
|
||||
if (candidates.length > 1) {
|
||||
this.violations.push(
|
||||
`${where} has multiple routing-key candidates for ${this.typeText(keyType)}: `
|
||||
+ candidates.map(candidate => `${candidate.path}: ${this.typeText(candidate.type)}`).join(', '),
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
if (!tag.unsupported) {
|
||||
const keyLabel = this.typeText(keyType)
|
||||
this.violations.push(
|
||||
`${where} exposes no parameter or one-level property equivalent to routing key type ${keyLabel}; `
|
||||
+ 'add @dshScopeScan unsupported only when the key is intentionally absent from the payload',
|
||||
)
|
||||
}
|
||||
resolvers.push({ event, candidate: null, ownerPackage })
|
||||
continue
|
||||
}
|
||||
if (tag.unsupported) {
|
||||
this.violations.push(
|
||||
`${where} has unnecessary @dshScopeScan unsupported; ${candidates[0]?.path} exposes the routing key`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
resolvers.push({ event, candidate: candidates[0] ?? null, ownerPackage })
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(sourceFile)
|
||||
}
|
||||
return resolvers.sort((left, right) => left.event.localeCompare(right.event))
|
||||
}
|
||||
|
||||
/** Extract the Base type from one exact this: Scoped<Base> parameter. */
|
||||
private scopedBaseType(parameter: ts.ParameterDeclaration): ts.Type | undefined {
|
||||
const type = this.checker.getTypeAtLocation(parameter)
|
||||
if (type.aliasSymbol !== this.scopedSymbol) return undefined
|
||||
return type.aliasTypeArguments?.[0]
|
||||
}
|
||||
|
||||
/** Resolve one unambiguous key type for a scoped carrier base. */
|
||||
private routingKeyType(
|
||||
where: string,
|
||||
scopedBase: ts.Type,
|
||||
contracts: readonly ScopeTargetContract[],
|
||||
): ts.Type | undefined {
|
||||
const matches = contracts.filter((contract) => {
|
||||
return this.checker.isTypeAssignableTo(this.normalizedType(contract.baseType), this.normalizedType(scopedBase))
|
||||
})
|
||||
if (matches.length === 0) {
|
||||
this.violations.push(
|
||||
`${where} has no matching scopeTarget(base, key) call for carrier base ${this.typeText(scopedBase)}`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
const keyTypes: ts.Type[] = []
|
||||
for (const match of matches) {
|
||||
if (!keyTypes.some(type => this.typesEquivalent(type, match.keyType))) keyTypes.push(match.keyType)
|
||||
}
|
||||
if (keyTypes.length > 1) {
|
||||
this.violations.push(
|
||||
`${where} carrier base ${this.typeText(scopedBase)} has inconsistent routing-key types: `
|
||||
+ matches.map(match => `${this.typeText(match.keyType)} at ${match.source}`).join(', '),
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
return keyTypes[0]
|
||||
}
|
||||
|
||||
/** Enumerate every payload parameter and every accessible one-level property. */
|
||||
private subjectCandidates(member: ts.MethodSignature): SubjectCandidate[] {
|
||||
const candidates: SubjectCandidate[] = []
|
||||
let runtimeIndex = 0
|
||||
for (const parameter of member.parameters) {
|
||||
if (isThisParameter(parameter)) continue
|
||||
const directPath = `args[${runtimeIndex}]`
|
||||
const parameterType = this.checker.getTypeAtLocation(parameter)
|
||||
candidates.push({ path: directPath, parameter: runtimeIndex, type: parameterType })
|
||||
for (const property of this.checker.getPropertiesOfType(this.normalizedType(parameterType))) {
|
||||
const name = property.getName()
|
||||
if (name.startsWith('__@') || hasNonPublicDeclaration(property)) continue
|
||||
candidates.push({
|
||||
path: `${directPath}.${name}`,
|
||||
parameter: runtimeIndex,
|
||||
property: name,
|
||||
type: this.checker.getTypeOfSymbolAtLocation(property, parameter),
|
||||
})
|
||||
}
|
||||
runtimeIndex += 1
|
||||
}
|
||||
return dedupeCandidates(candidates)
|
||||
}
|
||||
|
||||
/** Read and cache one workspace package name. */
|
||||
private packageName(packageRoot: string): string {
|
||||
const cached = this.packageNames.get(packageRoot)
|
||||
if (cached) return cached
|
||||
const manifest: unknown = JSON.parse(readFileSync(resolve(root, packageRoot, 'package.json'), 'utf8'))
|
||||
const name: unknown = typeof manifest === 'object' && manifest !== null
|
||||
? Reflect.get(manifest, 'name')
|
||||
: undefined
|
||||
if (typeof name !== 'string') throw new Error(`gen-scoped-events: ${packageRoot}/package.json has no name`)
|
||||
this.packageNames.set(packageRoot, name)
|
||||
return name
|
||||
}
|
||||
|
||||
/** Compare exact Program type identities after removing null and undefined. */
|
||||
private typesEquivalent(left: ts.Type, right: ts.Type): boolean {
|
||||
const normalizedLeft = this.normalizedType(left)
|
||||
const normalizedRight = this.normalizedType(right)
|
||||
if (normalizedLeft.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return false
|
||||
if (normalizedRight.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return false
|
||||
return normalizedLeft === normalizedRight
|
||||
}
|
||||
|
||||
/** Remove null and undefined from a routing or candidate type. */
|
||||
private normalizedType(type: ts.Type): ts.Type {
|
||||
return this.checker.getNonNullableType(type)
|
||||
}
|
||||
|
||||
/** Render a stable diagnostic type label. */
|
||||
private typeText(type: ts.Type): string {
|
||||
return this.checker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation)
|
||||
}
|
||||
}
|
||||
|
||||
/** Return whether an Events interface is inside declare module 'cordis'. */
|
||||
function isCordisModuleInterface(node: ts.InterfaceDeclaration): boolean {
|
||||
const block = node.parent
|
||||
const declaration = block.parent
|
||||
return ts.isModuleBlock(block)
|
||||
&& ts.isModuleDeclaration(declaration)
|
||||
&& ts.isStringLiteral(declaration.name)
|
||||
&& declaration.name.text === 'cordis'
|
||||
}
|
||||
|
||||
/** Return whether a parameter is the explicit TypeScript this receiver. */
|
||||
function isThisParameter(parameter: ts.ParameterDeclaration): boolean {
|
||||
return ts.isIdentifier(parameter.name) && parameter.name.text === 'this'
|
||||
}
|
||||
|
||||
/** Parse and validate the optional @dshScopeScan unsupported tag. */
|
||||
function parseScopeTag(raw: string, where: string, violations: string[]): ScopeTag {
|
||||
const tags = raw
|
||||
.replace(/^\/\*\*/, '')
|
||||
.replace(/\*\/$/, '')
|
||||
.split('\n')
|
||||
.map(line => line.replace(/^\s*\*?\s?/, '').trim())
|
||||
.filter(line => line.startsWith('@dshScopeScan'))
|
||||
if (tags.length > 1) violations.push(`${where} has multiple @dshScopeScan tags`)
|
||||
if (tags.length === 0) return { present: false, unsupported: false }
|
||||
const unsupported = tags[0] === '@dshScopeScan unsupported'
|
||||
if (!unsupported) {
|
||||
violations.push(
|
||||
`${where} has invalid scoped-event scan metadata '${tags[0]}'; expected '@dshScopeScan unsupported'`,
|
||||
)
|
||||
}
|
||||
return { present: true, unsupported }
|
||||
}
|
||||
|
||||
/** Return whether a property has a private or protected declaration. */
|
||||
function hasNonPublicDeclaration(symbol: ts.Symbol): boolean {
|
||||
return (symbol.declarations ?? []).some((declaration) => {
|
||||
if (!ts.canHaveModifiers(declaration)) return false
|
||||
return ts.getModifiers(declaration)?.some((modifier) => {
|
||||
return modifier.kind === ts.SyntaxKind.PrivateKeyword || modifier.kind === ts.SyntaxKind.ProtectedKeyword
|
||||
}) ?? false
|
||||
})
|
||||
}
|
||||
|
||||
/** Deduplicate candidate paths contributed by merged/intersection types. */
|
||||
function dedupeCandidates(candidates: readonly SubjectCandidate[]): SubjectCandidate[] {
|
||||
const seen = new Set<string>()
|
||||
return candidates.filter((candidate) => {
|
||||
if (seen.has(candidate.path)) return false
|
||||
seen.add(candidate.path)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/** Return the workspace package root owning one package source file. */
|
||||
function packageRootFor(relativePath: string): string {
|
||||
const match = /^(packages\/[^/]+\/[^/]+)\/src\//.exec(relativePath)
|
||||
if (!match?.[1]) throw new Error(`gen-scoped-events: cannot derive package root from ${relativePath}`)
|
||||
return match[1]
|
||||
}
|
||||
|
||||
/** Quote a generated property key as a single-quoted TypeScript string. */
|
||||
function quote(value: string): string {
|
||||
return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the generated scoped-event resolver module for one repository root.
|
||||
* @param projectRoot - repository root carrying tsconfig.json.
|
||||
* @returns complete generated TypeScript source.
|
||||
*/
|
||||
export function renderScopedEvents(projectRoot: string = root): string {
|
||||
return new ScopedEventGenerator(new TypeScriptProject(projectRoot)).render()
|
||||
}
|
||||
|
||||
/** Generate or freshness-check the fixed invariants source file. */
|
||||
function main(): void {
|
||||
const content = renderScopedEvents()
|
||||
const output = resolve(root, OUT)
|
||||
if (process.argv.includes('--check')) {
|
||||
const committed = existsSync(output) ? readFileSync(output, 'utf8') : null
|
||||
if (committed === content) {
|
||||
console.log(`gen-scoped-events: ${OUT} is up to date.`)
|
||||
return
|
||||
}
|
||||
console.error(`gen-scoped-events: ${OUT} is stale. Run \`pnpm run gen-scoped-events\` and commit it.`)
|
||||
process.exit(1)
|
||||
}
|
||||
writeFileSync(output, content)
|
||||
console.log(`gen-scoped-events: wrote ${OUT}.`)
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
|
||||
main()
|
||||
}
|
||||
@@ -6,8 +6,16 @@ import { join } from 'node:path'
|
||||
const git = spawnSync('git', ['rev-parse', '--git-dir'], { stdio: 'ignore' })
|
||||
if (git.status !== 0) process.exit(0)
|
||||
|
||||
const lefthook = join(process.cwd(), 'node_modules', '.bin', process.platform === 'win32' ? 'lefthook.cmd' : 'lefthook')
|
||||
const isWindows = process.platform === 'win32'
|
||||
const lefthook = join(process.cwd(), 'node_modules', '.bin', isWindows ? 'lefthook.cmd' : 'lefthook')
|
||||
if (!existsSync(lefthook)) process.exit(0)
|
||||
|
||||
const result = spawnSync(lefthook, ['install', '--force'], { stdio: 'inherit' })
|
||||
// On Windows the bin shim is a `.cmd` file, and recent Node (CVE-2024-27980)
|
||||
// refuses to launch `.cmd`/`.bat` via spawn without `shell: true` — it returns
|
||||
// `EINVAL` with a null status, which would otherwise fail postinstall. Quote
|
||||
// the path because a shell re-parses the command line and the path may contain
|
||||
// spaces. POSIX needs no shell: the extensionless shim is directly executable.
|
||||
const result = isWindows
|
||||
? spawnSync(`"${lefthook}"`, ['install', '--force'], { stdio: 'inherit', shell: true })
|
||||
: spawnSync(lefthook, ['install', '--force'], { stdio: 'inherit' })
|
||||
process.exit(result.status ?? 1)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { dirname, resolve, sep } from 'node:path'
|
||||
|
||||
const SCOPE = '@deepseek-ai/dsh-'
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface PackageGraphNode {
|
||||
*/
|
||||
export function collectPackageGraph(root: string, groupOrder: readonly string[], gate: string): PackageGraphNode[] {
|
||||
const packages: PackageGraphNode[] = []
|
||||
for (const rel of globSync('packages/*/*/package.json', { cwd: root }).sort()) {
|
||||
for (const rel of globSync('packages/*/*/package.json', { cwd: root }).map(path => path.split(sep).join('/')).sort()) {
|
||||
const json = JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as {
|
||||
name: string
|
||||
peerDependencies?: Record<string, string>
|
||||
|
||||
@@ -12,6 +12,13 @@ const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY'
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const packagesRoot = resolve(root, 'packages')
|
||||
|
||||
// Run publint's JS CLI through the current node, not the .bin shim: the
|
||||
// extensionless shim isn't spawnable on Windows (CVE-2024-27980) and the .cmd
|
||||
// variant needs shell:true, which space-joins args UNESCAPED (DEP0190) and
|
||||
// breaks when the repo path contains spaces. The JS entry is identical on every
|
||||
// platform (`bin` is `./src/cli.js` per publint's package.json).
|
||||
const publintCli = resolve(root, 'node_modules/publint/src/cli.js')
|
||||
|
||||
type PublintResult =
|
||||
| { path: string; status: 'passed'; stdout: string; stderr: string }
|
||||
| { path: string; status: 'failed'; stdout: string; stderr: string; message: string }
|
||||
@@ -50,7 +57,7 @@ function outputText(value: unknown): string {
|
||||
|
||||
async function runPublint(path: string): Promise<PublintResult> {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('node_modules/.bin/publint', [path], {
|
||||
const { stdout, stderr } = await execFileAsync(process.execPath, [publintCli, path], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Shared repository file discovery and line-oriented reference scanning. */
|
||||
|
||||
import { globSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { relative, resolve } from 'node:path'
|
||||
import { relative, resolve, sep } from 'node:path'
|
||||
|
||||
/** One authored path plus its canonical target for symlink deduplication. */
|
||||
export interface RepoFile {
|
||||
@@ -37,8 +37,9 @@ export function uniqueRepoFiles(
|
||||
const files: RepoFile[] = []
|
||||
for (const pattern of patterns) {
|
||||
for (const match of globSync(pattern, { cwd: root })) {
|
||||
if (isExcluded(match)) continue
|
||||
const abs = resolve(root, match)
|
||||
const repoPath = match.split(sep).join('/')
|
||||
if (isExcluded(repoPath)) continue
|
||||
const abs = resolve(root, repoPath)
|
||||
const real = realpathSync(abs)
|
||||
if (seen.has(real)) continue
|
||||
seen.add(real)
|
||||
@@ -65,7 +66,7 @@ export function findReferenceViolations(
|
||||
normalize: (raw: string) => string,
|
||||
isViolation: (ref: string) => boolean,
|
||||
): ReferenceViolation[] {
|
||||
const file = relative(root, absPath)
|
||||
const file = relative(root, absPath).split(sep).join('/')
|
||||
const out: ReferenceViolation[] = []
|
||||
const lines = readFileSync(absPath, 'utf8').split('\n')
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import { globSync } from 'node:fs'
|
||||
|
||||
export const rfcRoot = resolve(import.meta.dirname, '../docs/rfc')
|
||||
@@ -58,7 +58,7 @@ export function walkRfcTree(): { rfcs: Rfc[]; errors: string[] } {
|
||||
}
|
||||
}
|
||||
for (const lifecycle of LIFECYCLES) {
|
||||
for (const match of globSync(`${lifecycle}/**/*.md`, { cwd: rfcRoot }).sort()) {
|
||||
for (const match of globSync(`${lifecycle}/**/*.md`, { cwd: rfcRoot }).map(path => path.split(sep).join('/')).sort()) {
|
||||
const segs = match.split('/')
|
||||
// Allowlisted file directly at the lifecycle root (e.g. implemented/AGENTS.md).
|
||||
if (segs.length === 2 && ROOT_ALLOWLIST.has(segs[1] ?? '')) continue
|
||||
|
||||
@@ -96,8 +96,7 @@ function pnpmScript(id: string, script: string, options: Partial<Gate> = {}): Ga
|
||||
return {
|
||||
id,
|
||||
label: options.label ?? script,
|
||||
command: pnpmBin(),
|
||||
args: ['run', script],
|
||||
...pnpmInvocation(['run', script]),
|
||||
...options,
|
||||
}
|
||||
}
|
||||
@@ -106,14 +105,18 @@ function pnpmExec(id: string, args: string[], options: Partial<Gate> = {}): Gate
|
||||
return {
|
||||
id,
|
||||
label: options.label ?? `pnpm exec ${args.join(' ')}`,
|
||||
command: pnpmBin(),
|
||||
args: ['exec', ...args],
|
||||
...pnpmInvocation(['exec', ...args]),
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
function pnpmBin(): string {
|
||||
return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
function pnpmInvocation(args: string[]): Pick<Gate, 'command' | 'args'> {
|
||||
const entrypoint = process.env.npm_execpath
|
||||
if (entrypoint === undefined || entrypoint === '') {
|
||||
throw new Error('run-gates: npm_execpath is unavailable; invoke the runner through a pnpm package script.')
|
||||
}
|
||||
// Windows cannot spawn the pnpm.cmd shim directly; the JavaScript entrypoint keeps every host shell-free.
|
||||
return { command: process.execPath, args: [entrypoint, ...args] }
|
||||
}
|
||||
|
||||
function nodeOptions(...options: string[]): string {
|
||||
@@ -153,6 +156,7 @@ function gatesForMode(selected: Mode): Gate[] {
|
||||
case 'pre-push':
|
||||
return [
|
||||
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
||||
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
||||
pnpmScript('test', 'test'),
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
pnpmScript('snapshot', 'test:snapshot'),
|
||||
@@ -168,6 +172,7 @@ function ciPrimaryGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
||||
pnpmScript('typecheck', 'typecheck'),
|
||||
lintGate(),
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
@@ -191,13 +196,19 @@ function ciStaticGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
demoSmokeGate(),
|
||||
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
||||
...staticDemoSmokeGates(),
|
||||
...docSyncLeafGates(),
|
||||
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
||||
pnpmScript('knip', 'knip'),
|
||||
]
|
||||
}
|
||||
|
||||
function staticDemoSmokeGates(): Gate[] {
|
||||
// Native Windows session persistence is outside the gates-only support scope.
|
||||
return process.platform === 'win32' ? [] : [demoSmokeGate()]
|
||||
}
|
||||
|
||||
function ciArtifactGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('build', 'build'),
|
||||
@@ -273,7 +284,7 @@ function docSyncLeafGates(): Gate[] {
|
||||
pnpmScript('config-catalog', 'verify-config-catalog', { label: 'config catalog' }),
|
||||
pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }),
|
||||
pnpmScript('doc-graphs', 'verify-doc-graphs', { label: 'doc graphs' }),
|
||||
pnpmScript('scoped-dispatch', 'verify-scoped-dispatch', { label: 'scoped dispatch' }),
|
||||
pnpmScript('scoped-events', 'verify-scoped-events', { label: 'scoped events' }),
|
||||
pnpmScript('markdown-wrap', 'verify-md-wrap', { label: 'markdown wrap' }),
|
||||
pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }),
|
||||
pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }),
|
||||
@@ -283,6 +294,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('package-readme-limitations', 'verify-package-readme-limitations', { label: 'package README limitations' }),
|
||||
@@ -294,8 +306,7 @@ function demoSmokeGate(options: { needs?: string[] } = {}): Gate {
|
||||
return {
|
||||
id: 'demo-smoke',
|
||||
label: 'demo smoke',
|
||||
command: pnpmBin(),
|
||||
args: ['run', 'demo:echo'],
|
||||
...pnpmInvocation(['run', 'demo:echo']),
|
||||
input: 'echo ci smoke\n',
|
||||
...dependencyOptions,
|
||||
verify: async (result) => {
|
||||
@@ -332,8 +343,8 @@ function builtBinSmokeGate(): Gate {
|
||||
'run',
|
||||
'--config',
|
||||
'vitest.e2e.config.ts',
|
||||
'packages/ui/stdio-agent/tests/built-bin.e2e.ts',
|
||||
'packages/ui/acp-agent/tests/built-bin.e2e.ts',
|
||||
'packages/examples/stdio-demo/tests/built-bin.e2e.ts',
|
||||
'packages/examples/acp-demo/tests/built-bin.e2e.ts',
|
||||
// The worker-entry packages' built bundles: the only automated proof
|
||||
// that lib/index.js resolves its sibling lib/worker.cjs under plain node
|
||||
// (the e2e lane runs unbuilt, so these files self-skip there).
|
||||
|
||||
@@ -55,7 +55,7 @@ CUSTOM_CORDIS = """\
|
||||
- id: jsonrpc
|
||||
name: '@deepseek-ai/dsh-jsonrpc'
|
||||
- id: agent-core
|
||||
name: '@deepseek-ai/dsh-agent-core'
|
||||
name: '@deepseek-ai/dsh-agent-spine-demo'
|
||||
config:
|
||||
tools:
|
||||
mode: both
|
||||
|
||||
@@ -408,8 +408,7 @@
|
||||
],
|
||||
"isError": false,
|
||||
"meta": {
|
||||
"logs": [],
|
||||
"dispatches": 1
|
||||
"logs": []
|
||||
}
|
||||
},
|
||||
"sourceEventSeqs": [
|
||||
@@ -1606,8 +1605,7 @@
|
||||
],
|
||||
"isError": false,
|
||||
"meta": {
|
||||
"logs": [],
|
||||
"dispatches": 1
|
||||
"logs": []
|
||||
}
|
||||
},
|
||||
"sourceEventSeqs": [
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}}
|
||||
{"type":"tool/code-dispatch","seq":22,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"resultSummary":"42"}}
|
||||
{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false,"meta":{"logs":[],"dispatches":1}},"sourceEventSeqs":[21],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[21],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}}
|
||||
{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}
|
||||
{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
"requiredSince": "2026-07-14",
|
||||
"required": [
|
||||
"README.md",
|
||||
"docs/cookbook/adding-a-package.md",
|
||||
"docs/cookbook/adding-a-tool.md",
|
||||
"docs/cookbook/adding-a-vendored-package.md",
|
||||
"docs/cookbook/adding-an-llm-adapter.md",
|
||||
"docs/cookbook/extension-cookbook.md",
|
||||
"docs/cookbook/responding-to-pr-review-on-a-stack.md",
|
||||
"docs/development.md",
|
||||
"docs/i18n/README.md",
|
||||
"docs/i18n/translation-rules.md",
|
||||
|
||||
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
|
||||
}
|
||||
113
scripts/ts-project.ts
Normal file
113
scripts/ts-project.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Shared TypeScript Program construction for repository gates that need real
|
||||
* cross-file symbols and types instead of isolated syntax trees.
|
||||
*/
|
||||
|
||||
import { relative, resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
|
||||
interface ProjectGraph {
|
||||
rootNames: string[]
|
||||
options: ts.CompilerOptions
|
||||
}
|
||||
|
||||
const configHost: ts.ParseConfigFileHost = {
|
||||
useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames,
|
||||
readDirectory: (...args) => ts.sys.readDirectory(...args),
|
||||
fileExists: fileName => ts.sys.fileExists(fileName),
|
||||
readFile: fileName => ts.sys.readFile(fileName),
|
||||
getCurrentDirectory: () => ts.sys.getCurrentDirectory(),
|
||||
onUnRecoverableConfigFileDiagnostic(diagnostic) {
|
||||
throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
|
||||
},
|
||||
}
|
||||
|
||||
/** Parse a root tsconfig and flatten all referenced projects into one semantic graph. */
|
||||
function loadProjectGraph(projectRoot: string): ProjectGraph {
|
||||
const rootConfigPath = resolve(projectRoot, 'tsconfig.json')
|
||||
const rootConfig = parseConfig(rootConfigPath)
|
||||
const rootNames = new Set<string>()
|
||||
const visited = new Set<string>()
|
||||
|
||||
const collect = (configPath: string, parsed: ts.ParsedCommandLine): void => {
|
||||
if (visited.has(configPath)) return
|
||||
visited.add(configPath)
|
||||
for (const fileName of parsed.fileNames) rootNames.add(fileName)
|
||||
for (const reference of parsed.projectReferences ?? []) {
|
||||
const referencePath = ts.resolveProjectReferencePath(reference)
|
||||
collect(referencePath, parseConfig(referencePath))
|
||||
}
|
||||
}
|
||||
collect(rootConfigPath, rootConfig)
|
||||
|
||||
return {
|
||||
rootNames: [...rootNames],
|
||||
options: rootConfig.options,
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse one config file and fail loud on any config diagnostic. */
|
||||
function parseConfig(configPath: string): ts.ParsedCommandLine {
|
||||
const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, configHost)
|
||||
if (!parsed) throw new Error(`cannot parse TypeScript config ${configPath}`)
|
||||
if (parsed.errors.length > 0) {
|
||||
throw new Error(parsed.errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n'))
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
/** Disable emit-only options after loading the root solution config. */
|
||||
function semanticCompilerOptions(options: ts.CompilerOptions): ts.CompilerOptions {
|
||||
return {
|
||||
...options,
|
||||
noEmit: true,
|
||||
composite: false,
|
||||
declaration: false,
|
||||
declarationMap: false,
|
||||
sourceMap: false,
|
||||
incremental: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** A repository-scoped TypeScript Program and its shared TypeChecker. */
|
||||
export class TypeScriptProject {
|
||||
/** The bound cross-file TypeScript program. */
|
||||
readonly program: ts.Program
|
||||
/** The checker shared by every semantic query in this project. */
|
||||
readonly checker: ts.TypeChecker
|
||||
|
||||
constructor(private readonly projectRoot: string) {
|
||||
const graph = loadProjectGraph(projectRoot)
|
||||
this.program = ts.createProgram(graph.rootNames, semanticCompilerOptions(graph.options))
|
||||
this.checker = this.program.getTypeChecker()
|
||||
}
|
||||
|
||||
/**
|
||||
* Return every source file loaded into the flattened root project graph.
|
||||
* @returns program source files, including libraries and external dependencies.
|
||||
*/
|
||||
sourceFiles(): readonly ts.SourceFile[] {
|
||||
return this.program.getSourceFiles()
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a loaded source file relative to the project root.
|
||||
* @param sourceFile - a source file from this project.
|
||||
* @returns a slash-separated repository-relative path.
|
||||
*/
|
||||
relativePath(sourceFile: ts.SourceFile): string {
|
||||
return relative(this.projectRoot, sourceFile.fileName).replaceAll('\\', '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Return one program source file by repository-relative path.
|
||||
* @param relativePath - path relative to the project root.
|
||||
* @returns the source file bound into this project.
|
||||
* @throws if a requested root or imported source was not loaded.
|
||||
*/
|
||||
sourceFile(relativePath: string): ts.SourceFile {
|
||||
const sourceFile = this.program.getSourceFile(resolve(this.projectRoot, relativePath))
|
||||
if (!sourceFile) throw new Error(`TypeScript project did not load ${relativePath}`)
|
||||
return sourceFile
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,6 @@
|
||||
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunResult", "source": "packages/code-runtime/code-runtime/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeBindingNamespace", "source": "packages/code-runtime/code-runtime/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeBindingFunction", "source": "packages/code-runtime/code-runtime/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeLogEntry", "source": "packages/code-runtime/code-runtime/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunFailure", "source": "packages/code-runtime/code-runtime/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTarget", "source": "packages/fs/fs/src/types.ts" },
|
||||
@@ -142,7 +141,6 @@
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchRequest", "source": "packages/web/web/src/types.ts" },
|
||||
{ "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/web.md", "symbol": "WebProviderStatus", "source": "packages/web/web/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" },
|
||||
|
||||
111
scripts/verify-cordis-config.ts
Normal file
111
scripts/verify-cordis-config.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Reject JavaScript expressions in Cordis Loader entry metadata.
|
||||
*
|
||||
* The Loader interpolates only a plugin entry's `config`; expression objects in
|
||||
* fields such as `disabled` remain truthy data and silently change composition.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import * as yaml from 'js-yaml'
|
||||
|
||||
interface JsExpr {
|
||||
__jsExpr: 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', {
|
||||
kind: 'scalar',
|
||||
resolve: data => typeof data === 'string',
|
||||
construct: (data: unknown): JsExpr => {
|
||||
if (typeof data !== 'string') throw new TypeError('!!js requires a scalar string')
|
||||
return { __jsExpr: data }
|
||||
},
|
||||
})
|
||||
const schema = yaml.JSON_SCHEMA.extend(jsExprType)
|
||||
|
||||
const files = globSync(['**/*cordis*.yml', '**/*cordis*.yaml'], {
|
||||
cwd: root,
|
||||
exclude: ['.claude/**', 'node_modules/**', 'vendor/**'],
|
||||
}).sort()
|
||||
const errors: string[] = []
|
||||
|
||||
for (const file of files) {
|
||||
const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema })
|
||||
if (!isUnknownArray(document)) {
|
||||
errors.push(`${file}: root must be a Loader entry array`)
|
||||
continue
|
||||
}
|
||||
for (let index = 0; index < document.length; index++) {
|
||||
validateEntry(document[index], file, `[${index}]`)
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error('verify-cordis-config: Loader entry metadata is static; move !!js under plugin config or select an explicit overlay.')
|
||||
for (const error of errors) console.error(`- ${error}`)
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
console.log(`verify-cordis-config: ${files.length} config files passed.`)
|
||||
}
|
||||
|
||||
function validateEntry(value: unknown, file: string, path: string): void {
|
||||
if (!isRecord(value)) {
|
||||
errors.push(`${file}${path}: entry must be an object`)
|
||||
return
|
||||
}
|
||||
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++) {
|
||||
validateEntry(value.config[index], file, `${path}.config[${index}]`)
|
||||
}
|
||||
}
|
||||
if (value.name !== '@cordisjs/plugin-include') return
|
||||
const config = value.config
|
||||
if (!isRecord(config) || !isUnknownArray(config.patches)) return
|
||||
for (let index = 0; index < config.patches.length; index++) {
|
||||
const patch = config.patches[index]
|
||||
const patchPath = `${path}.config.patches[${index}]`
|
||||
if (!isRecord(patch)) continue
|
||||
validateMetadata(patch, file, patchPath)
|
||||
if (!isUnknownArray(patch.insert)) continue
|
||||
for (let insertIndex = 0; insertIndex < patch.insert.length; insertIndex++) {
|
||||
validateEntry(patch.insert[insertIndex], file, `${patchPath}.insert[${insertIndex}]`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateMetadata(entry: Record<string, unknown>, file: string, path: string): void {
|
||||
for (const field of metadataFields) {
|
||||
if (!(field in entry)) continue
|
||||
const expressionPaths: string[] = []
|
||||
collectExpressionPaths(entry[field], `${path}.${field}`, expressionPaths)
|
||||
for (const expressionPath of expressionPaths) errors.push(`${file}${expressionPath}: !!js is not interpolated here`)
|
||||
}
|
||||
}
|
||||
|
||||
function collectExpressionPaths(value: unknown, path: string, output: string[]): void {
|
||||
if (isJsExpr(value)) {
|
||||
output.push(path)
|
||||
return
|
||||
}
|
||||
if (isUnknownArray(value)) {
|
||||
for (let index = 0; index < value.length; index++) collectExpressionPaths(value[index], `${path}[${index}]`, output)
|
||||
return
|
||||
}
|
||||
if (!isRecord(value)) return
|
||||
for (const [key, child] of Object.entries(value)) collectExpressionPaths(child, `${path}.${key}`, output)
|
||||
}
|
||||
|
||||
function isJsExpr(value: unknown): value is JsExpr {
|
||||
return isRecord(value) && typeof value.__jsExpr === 'string'
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object'
|
||||
}
|
||||
|
||||
function isUnknownArray(value: unknown): value is unknown[] {
|
||||
return Array.isArray(value)
|
||||
}
|
||||
@@ -143,7 +143,11 @@ try {
|
||||
.join('\n')
|
||||
writeFileSync(resolve(tmp, 'index.ts'), `${imports}\n`)
|
||||
|
||||
execFileSync(resolve(root, 'node_modules/.bin/tsc'), ['-p', resolve(tmp, 'tsconfig.json'), '--pretty', 'false'], {
|
||||
// tsc's JS entry via the current node, not the .bin shim: the extensionless
|
||||
// shim isn't spawnable on Windows (CVE-2024-27980) and the .cmd variant needs
|
||||
// shell:true, which space-joins args UNESCAPED (DEP0190) — a hazard for the
|
||||
// temp tsconfig path. The JS entry behaves identically on every platform.
|
||||
execFileSync(process.execPath, ['node_modules/typescript/bin/tsc', '-p', resolve(tmp, 'tsconfig.json'), '--pretty', 'false'], {
|
||||
cwd: root,
|
||||
stdio: 'pipe',
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { existsSync, globSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import { markdownHeadingLines, markdownProseLines } from './markdown.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
@@ -30,7 +30,7 @@ function isLimitationsLike(headingText: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
const packageJsons = globSync('packages/*/*/package.json', { cwd: root }).sort()
|
||||
const packageJsons = globSync('packages/*/*/package.json', { cwd: root }).map(path => path.split(sep).join('/')).sort()
|
||||
const scannedPackages = new Set(packageJsons.map(path => path.slice(0, -'/package.json'.length)))
|
||||
const failures: string[] = []
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { existsSync, globSync, readFileSync } from 'node:fs'
|
||||
import { relative, resolve } from 'node:path'
|
||||
import { relative, resolve, sep } from 'node:path'
|
||||
import { markdownHeadingLines, markdownProseLines, type MarkdownProseLine } from './markdown.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
@@ -42,7 +42,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' },
|
||||
'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' },
|
||||
'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' },
|
||||
'packages/core/agent-core': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' },
|
||||
'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' },
|
||||
'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' },
|
||||
'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
|
||||
'packages/fs/fs-sandbox': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
|
||||
@@ -57,11 +57,12 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'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/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/ui/acp-agent': { kind: 'indirect', reason: 'The app bundle delegates request composition to dsh-agent-core and dsh-acp.' },
|
||||
'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/ui/jsonrpc-agent': { kind: 'indirect', reason: 'Only the externally configured 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/timeout': { kind: 'indirect', reason: 'Only timeout consumers render timeout outcomes.' },
|
||||
@@ -146,7 +147,7 @@ for (const line of readFileSync(resolve(root, 'docs/tool-catalog.md'), 'utf8').s
|
||||
}
|
||||
|
||||
const failures: Failure[] = []
|
||||
const packageJsons = globSync('packages/*/*/package.json', { cwd: root }).sort()
|
||||
const packageJsons = globSync('packages/*/*/package.json', { cwd: root }).map(path => path.split(sep).join('/')).sort()
|
||||
const scannedPackages = new Set(packageJsons.map(path => path.slice(0, -'/package.json'.length)))
|
||||
let structuredCount = 0
|
||||
let contextSurfaceCount = 0
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* Scoped-dispatch drift gate: the set of scope-filtered events is declared in TWO places that
|
||||
* must never diverge — the dev-invariants runtime table (the `scopedSubject` map in
|
||||
* `packages/support/invariants/src/index.ts`, which enforces carriers at dispatch time) and
|
||||
* the event declarations' JSDoc (the "Scope-filtered dispatch" sentence rendered into the
|
||||
* events catalog, which tells plugin authors what a scoped listener will and won't hear).
|
||||
* Registry-subject notifications are intentionally unfiltered and belong in neither set.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** The marker sentence every scope-filtered event's JSDoc carries. */
|
||||
const MARKER = 'Scope-filtered dispatch'
|
||||
|
||||
/** Events that are deliberately UNFILTERED registry-subject notifications. */
|
||||
const REGISTRY_SUBJECT = new Set(['tools/change', 'system-prompt/change', 'subagent/provider-added', 'subagent/provider-removed'])
|
||||
|
||||
function invariantTable(): Set<string> {
|
||||
const source = readFileSync(resolve(root, 'packages/support/invariants/src/index.ts'), 'utf8')
|
||||
const start = source.indexOf('const scopedSubject')
|
||||
if (start < 0) throw new Error('verify-scoped-dispatch: cannot find the scopedSubject table in dsh-invariants')
|
||||
const block = source.slice(start, source.indexOf('}', start))
|
||||
return new Set([...block.matchAll(/'([a-z-]+\/[a-z-]+)':/g)].flatMap(match => match[1] === undefined ? [] : [match[1]]))
|
||||
}
|
||||
|
||||
function documentedSet(): Set<string> {
|
||||
const documented = new Set<string>()
|
||||
for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: root })) {
|
||||
const source = readFileSync(resolve(root, rel), 'utf8')
|
||||
if (!source.includes(MARKER)) continue
|
||||
// Each event declaration: a JSDoc block followed by the quoted event name.
|
||||
// Tolerate `//` comment lines between the JSDoc and the declaration
|
||||
// (e.g. an inline TODO under the doc block).
|
||||
for (const match of source.matchAll(/\/\*\*([\s\S]*?)\*\/\s*\n(?:\s*\/\/[^\n]*\n)*\s*'([a-z-]+\/[a-z-]+)'\(/g)) {
|
||||
const [, doc, event] = match
|
||||
if (doc === undefined || event === undefined) continue
|
||||
if (doc.includes(MARKER)) documented.add(event)
|
||||
}
|
||||
}
|
||||
return documented
|
||||
}
|
||||
|
||||
const table = invariantTable()
|
||||
const documented = documentedSet()
|
||||
|
||||
const problems: string[] = []
|
||||
for (const event of table) {
|
||||
if (!documented.has(event)) {
|
||||
problems.push(`"${event}" is enforced by the dev-invariants carrier table but its declaration JSDoc carries no "${MARKER}" sentence — document the filtering plugin authors will observe.`)
|
||||
}
|
||||
if (REGISTRY_SUBJECT.has(event)) {
|
||||
problems.push(`"${event}" is a registry-subject notification (deliberately unfiltered) but appears in the dev-invariants carrier table.`)
|
||||
}
|
||||
}
|
||||
for (const event of documented) {
|
||||
if (!table.has(event)) {
|
||||
problems.push(`"${event}" documents scope-filtered dispatch but is missing from the dev-invariants carrier table (packages/support/invariants) — a bare dispatch of it would silently revert to global delivery.`)
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
console.error(`verify-scoped-dispatch: ${problems.length} drift(s) between the invariant table and the documented scoped-event set:`)
|
||||
for (const problem of problems) console.error(` - ${problem}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`verify-scoped-dispatch: ${table.size} scope-filtered event(s) consistent between the invariant table and the declaration docs.`)
|
||||
@@ -9,11 +9,16 @@
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { basename, join, resolve } 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 { basename, join, resolve, sep } from 'node:path'
|
||||
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,102 +79,10 @@ 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) {
|
||||
for (const match of globSync(pattern, { cwd: root })) files.add(match)
|
||||
for (const match of globSync(pattern, { cwd: root })) files.add(match.split(sep).join('/'))
|
||||
}
|
||||
const translations = [...files].filter(f => f.endsWith('.zh.md')).sort()
|
||||
const metas = [...files].filter(f => f.endsWith('.i18n.yaml')).sort()
|
||||
@@ -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)
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
@@ -121,7 +121,7 @@ const keyOf = (x: { doc: string; symbol: string }): string => `${x.doc}::${x.sym
|
||||
// as an orphan rather than silently skipped.
|
||||
const docSet = new Set<string>()
|
||||
for (const pattern of MARKDOWN_GLOBS) {
|
||||
for (const match of globSync(pattern, { cwd: root })) docSet.add(match)
|
||||
for (const match of globSync(pattern, { cwd: root })) docSet.add(match.split(sep).join('/'))
|
||||
}
|
||||
const blocks: EquivBlock[] = [...docSet].sort().flatMap(extractEquivBlocks)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user