Merge remote-tracking branch 'origin/token-meter-service' into compact-post-step-overflow-recovery

# Conflicts:
#	docs/architecture.md
This commit is contained in:
Hypatia May
2026-07-16 10:14:33 +08:00
280 changed files with 17940 additions and 3212 deletions

View File

@@ -116,11 +116,31 @@ const dshWorkerPackageFiles = [
'src',
] as const
const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
'@deepseek-ai/dsh-helper': ['lib/assets'],
'@deepseek-ai/dsh-scripts': [
'lib/dev/tsdown-config.js',
'lib/local-plugin-loader-hooks.js',
'lib/assets',
],
}
function sameStringList(actual: readonly string[] | undefined, expected: readonly string[]): boolean {
return !!actual && actual.length === expected.length && actual.every((value, index) => value === expected[index])
}
function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] {
const extras = manifest.name ? packageFileExtras[manifest.name] ?? [] : []
if (extras.length > 0) {
return [
'lib/index.js',
...manifest.bin ? ['lib/bin.js'] : [],
...extras,
'lib/types/**/*.d.ts',
'lib/types/**/*.d.ts.map',
'src',
]
}
if (manifest.bin) return dshBinPackageFiles
// A declared "./worker" subpath export sanctions the one extra runtime
// bundle a worker-thread entry needs (and NodeNext/publint then validate

View File

@@ -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'
@@ -581,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

View File

@@ -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'
@@ -46,8 +46,6 @@ export const LINK_MAP: Record<string, string> = {
BashExecRequest: 'bash.md',
BashExecSpec: 'bash.md',
BashRunResult: 'bash.md',
BashTask: 'bash.md',
BashTaskRead: 'bash.md',
ConfinedArgv: 'sandbox.md',
SandboxMode: 'sandbox.md',
SandboxPolicy: 'sandbox.md',
@@ -129,7 +127,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 +181,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

View File

@@ -64,6 +64,8 @@ const GROUP_ORDER = [
'skill',
'compact',
'subagent',
'tasks',
'workflow',
'web',
'todo',
'cordis',
@@ -239,6 +241,14 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['tool-subagent'],
note: 'Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name.',
},
{
key: 'tasks',
pkg: 'tasks',
title: 'Background task registry',
mode: 'core',
consumers: ['tool-bash', 'tool-subagent', 'tool-tasks'],
note: 'Producers (tool-bash background commands, tool-subagent background delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it.',
},
{
key: 'web',
pkg: 'web',

View File

@@ -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

View File

@@ -22,11 +22,13 @@ import SubagentService from '@deepseek-ai/dsh-subagent'
import * as SubagentMock from '@deepseek-ai/dsh-subagent-mock'
import SkillService from '@deepseek-ai/dsh-skill'
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
import TaskService from '@deepseek-ai/dsh-tasks'
import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as ToolSkill from '@deepseek-ai/dsh-tool-skill'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
@@ -111,14 +113,14 @@ const TOOL_PACKAGES: ToolPackage[] = [
pkg: '@deepseek-ai/dsh-tool-bash',
dir: 'tool-bash',
source: 'packages/bash/tool-bash/src/index.ts',
requires: ['ctx.tools', 'ctx.bash'],
writes: ['tool/call', 'tool/result', 'context/message via agent.inject() for background completion notices'],
requires: ['ctx.tools', 'ctx.bash', 'ctx.tasks at call time for run_in_background'],
writes: ['tool/call', 'tool/result'],
async mount(ctx) {
await ctx.plugin(LocalBashExecutor)
await ctx.plugin(ToolBash)
},
note:
'The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam.',
'The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled.',
},
{
pkg: '@deepseek-ai/dsh-tool-cordis',
@@ -178,6 +180,19 @@ const TOOL_PACKAGES: ToolPackage[] = [
note:
'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.',
},
{
pkg: '@deepseek-ai/dsh-tool-tasks',
dir: 'tool-tasks',
source: 'packages/tasks/tool-tasks/src/index.ts',
requires: ['ctx.tools', 'ctx.tasks', 'ctx.systemPrompt'],
writes: ['tool/call', 'tool/result', 'context/message via agent.inject() for background completion notices'],
async mount(ctx) {
await ctx.plugin(TaskService)
await ctx.plugin(ToolTasks)
},
note:
'The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers\' `ctx.tasks.start()`.',
},
{
pkg: '@deepseek-ai/dsh-tool-todo',
dir: 'tool-todo',

View File

@@ -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>

View File

@@ -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++) {

View File

@@ -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

View File

@@ -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 {
@@ -194,13 +197,18 @@ function ciStaticGates(): Gate[] {
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
pnpmScript('constraints', 'constraints'),
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
demoSmokeGate(),
...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'),
@@ -286,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' }),
@@ -297,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) => {

View 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"',
])
})
})

View 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
}

View 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')
})
})

View 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
}

View File

@@ -94,8 +94,15 @@
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashSandboxInfo", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "CollectedOutput", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashProcess", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashProcessRead", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskKindMap", "source": "packages/tasks/tasks/src/types.ts" },
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskStart", "source": "packages/tasks/tasks/src/types.ts" },
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskHooks", "source": "packages/tasks/tasks/src/types.ts" },
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskOutcome", "source": "packages/tasks/tasks/src/types.ts" },
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskSnapshot", "source": "packages/tasks/tasks/src/types.ts" },
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskRead", "source": "packages/tasks/tasks/src/types.ts" },
{ "doc": "docs/core-data-structures/sandbox.md", "symbol": "SandboxMode", "source": "packages/sandbox/sandbox/src/index.ts" },
{ "doc": "docs/core-data-structures/sandbox.md", "symbol": "ConfinedSandboxMode", "source": "packages/sandbox/sandbox/src/index.ts" },

View File

@@ -7,8 +7,8 @@
* re-exports keep their docs at the declaring contract. Unknown forms fail closed.
*/
import { existsSync, globSync } from 'node:fs'
import { resolve } from 'node:path'
import { existsSync, globSync, readFileSync } from 'node:fs'
import { relative, resolve, sep } from 'node:path'
import ts from 'typescript'
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc } from './jsdoc.ts'
@@ -389,7 +389,13 @@ function checkDecl(
* @param w - the walk state violations append to.
* @param ambient - whether this scope is ambient (`declare` namespace or a declaration file), where members export implicitly.
*/
function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk, ambient: boolean): void {
function checkScope(
statements: readonly ts.Statement[],
prefix: string,
w: Walk,
ambient: boolean,
allowedNames?: ReadonlySet<string>,
): void {
const byName = new Map<string, ts.Statement[]>()
const overloadSigs = new Set<string>()
const add = (name: string, stmt: ts.Statement): void => {
@@ -455,7 +461,20 @@ function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk
}
continue
}
if (isExported(stmt) || (ambient && !ts.isImportDeclaration(stmt))) request(stmt, null)
if (isExported(stmt) || (ambient && !ts.isImportDeclaration(stmt))) {
if (allowedNames === undefined) {
request(stmt, null)
} else if (ts.isVariableStatement(stmt)) {
for (const declaration of stmt.declarationList.declarations) {
if (ts.isIdentifier(declaration.name) && allowedNames.has(declaration.name.text)) {
request(stmt, declaration.name.text)
}
}
} else {
const name = declarationName(stmt) ?? 'default'
if (allowedNames.has(name)) request(stmt, null)
}
}
}
for (const stmt of statements) {
const only = requested.get(stmt)
@@ -463,6 +482,67 @@ function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk
}
}
function exportedTargets(value: unknown): string[] {
if (typeof value === 'string') return [value]
if (!value || typeof value !== 'object') return []
return Object.values(value).flatMap(exportedTargets)
}
function sourceEntry(target: string): string | undefined {
if (target.startsWith('./lib/types/') && target.endsWith('.d.ts')) {
return `src/${target.slice('./lib/types/'.length, -'.d.ts'.length)}.ts`
}
if (target.startsWith('./lib/') && target.endsWith('.js')) {
return `src/${target.slice('./lib/'.length, -'.js'.length)}.ts`
}
return undefined
}
function declarationName(declaration: ts.Node): string | undefined {
const name = (declaration as ts.NamedDeclaration).name
if (name && ts.isIdentifier(name)) return name.text
return undefined
}
/** Resolve the declarations reachable through packages that do not export src/*. */
function restrictedPublicNames(
scanRoot: string,
rels: readonly string[],
program: ts.Program,
checker: ts.TypeChecker,
): { restrictedPackages: Set<string>; namesByFile: Map<string, Set<string>> } {
const restrictedPackages = new Set<string>()
const namesByFile = new Map<string, Set<string>>()
const packages = new Set(rels.map(rel => rel.split('/').slice(0, 3).join('/')))
for (const packageDir of packages) {
const manifestPath = resolve(scanRoot, packageDir, 'package.json')
if (!existsSync(manifestPath)) continue
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { exports?: Record<string, unknown> }
if (!manifest.exports || manifest.exports['./src/*'] !== undefined) continue
restrictedPackages.add(packageDir)
const entries = new Set(Object.values(manifest.exports).flatMap(exportedTargets).flatMap((target) => {
const entry = sourceEntry(target)
return entry ? [`${packageDir}/${entry}`] : []
}))
for (const entry of entries) {
const source = program.getSourceFile(resolve(scanRoot, entry))
const moduleSymbol = source && checker.getSymbolAtLocation(source)
if (!source || !moduleSymbol) continue
for (const exported of checker.getExportsOfModule(moduleSymbol)) {
const target = (exported.flags & ts.SymbolFlags.Alias) !== 0 ? checker.getAliasedSymbol(exported) : exported
for (const declaration of target.declarations ?? []) {
const name = declarationName(declaration)
const file = declaration.getSourceFile().fileName
const rel = relative(scanRoot, file).split(sep).join('/')
if (!name || !rel.startsWith(`${packageDir}/src/`)) continue
namesByFile.set(rel, new Set([...(namesByFile.get(rel) ?? []), name]))
}
}
}
}
return { restrictedPackages, namesByFile }
}
/**
* Compiler options for the walk's program.
*
@@ -495,15 +575,26 @@ function loadCompilerOptions(scanRoot: string): ts.CompilerOptions {
*/
export function collectExportJsdocViolations(scanRoot: string = root): string[] {
const violations: string[] = []
const rels = globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).sort()
const rels = globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot })
.map(path => path.split(sep).join('/'))
.sort()
const program = ts.createProgram(rels.map(rel => resolve(scanRoot, rel)), loadCompilerOptions(scanRoot))
const checker = program.getTypeChecker()
const { restrictedPackages, namesByFile } = restrictedPublicNames(scanRoot, rels, program, checker)
for (const rel of rels) {
const sf = program.getSourceFile(resolve(scanRoot, rel))
if (!sf) continue // program root files always resolve; guard for narrowing
// A script-style declaration file (no imports/exports) is one big ambient
// scope; a module-style .d.ts still honors explicit export modifiers.
checkScope(sf.statements, '', { rel, sf, text: sf.text, checker, violations }, sf.isDeclarationFile && !ts.isExternalModule(sf))
const packageDir = rel.split('/').slice(0, 3).join('/')
const allowedNames = restrictedPackages.has(packageDir) ? namesByFile.get(rel) ?? new Set<string>() : undefined
checkScope(
sf.statements,
'',
{ rel, sf, text: sf.text, checker, violations },
sf.isDeclarationFile && !ts.isExternalModule(sf),
allowedNames,
)
}
return violations
}

View File

@@ -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[] = []

View File

@@ -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, '..')
@@ -49,6 +49,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' },
'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' },
'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' },
'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' },
'packages/sdk/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' },
'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' },
'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' },
'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' },
'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' },
@@ -59,6 +62,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/support/loader-smoke': { kind: 'none', reason: 'The test harness observes child-process streams without changing live requests.' },
'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' },
'packages/support/subagent-mock': { kind: 'indirect', reason: 'Only dsh-tool-subagent renders its configured test outcome.' },
'packages/tasks/tasks': { kind: 'indirect', reason: 'Producer and control-surface plugins own all model rendering over the task registry.' },
'packages/examples/acp-demo': { kind: 'indirect', reason: 'The app bundle delegates request composition to dsh-agent-spine-demo and dsh-acp.' },
'packages/ui/app-boot': { kind: 'indirect', reason: 'Only the loaded plugin tree contributes model context.' },
'packages/examples/jsonrpc-demo': { kind: 'indirect', reason: 'Only the externally configured plugin tree contributes model context.' },
@@ -146,7 +150,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

View File

@@ -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 }

View 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)
}

View File

@@ -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)