mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/token-meter-service' into compact-post-step-overflow-recovery
# Conflicts: # docs/architecture.md
This commit is contained in:
@@ -116,11 +116,31 @@ const dshWorkerPackageFiles = [
|
||||
'src',
|
||||
] as const
|
||||
|
||||
const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
|
||||
'@deepseek-ai/dsh-helper': ['lib/assets'],
|
||||
'@deepseek-ai/dsh-scripts': [
|
||||
'lib/dev/tsdown-config.js',
|
||||
'lib/local-plugin-loader-hooks.js',
|
||||
'lib/assets',
|
||||
],
|
||||
}
|
||||
|
||||
function sameStringList(actual: readonly string[] | undefined, expected: readonly string[]): boolean {
|
||||
return !!actual && actual.length === expected.length && actual.every((value, index) => value === expected[index])
|
||||
}
|
||||
|
||||
function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] {
|
||||
const extras = manifest.name ? packageFileExtras[manifest.name] ?? [] : []
|
||||
if (extras.length > 0) {
|
||||
return [
|
||||
'lib/index.js',
|
||||
...manifest.bin ? ['lib/bin.js'] : [],
|
||||
...extras,
|
||||
'lib/types/**/*.d.ts',
|
||||
'lib/types/**/*.d.ts.map',
|
||||
'src',
|
||||
]
|
||||
}
|
||||
if (manifest.bin) return dshBinPackageFiles
|
||||
// A declared "./worker" subpath export sanctions the one extra runtime
|
||||
// bundle a worker-thread entry needs (and NodeNext/publint then validate
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
@@ -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) => {
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, '..')
|
||||
@@ -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
|
||||
|
||||
@@ -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