Files
deepseek-harness/scripts/translation-pairing.ts
T

196 lines
7.3 KiB
TypeScript

/**
* Pure parsing and structural helpers for the bilingual-document pairing
* gate. Kept separate from the CLI so corpus discovery 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 {
/** Source documents exempt from pairing because they are generated, instructional, or bilingual by construction. */
excluded: string[]
}
const README_ARTIFACT = /(?:^|\/)readme(?:\.md|\.zh\.md|\.i18n\.yaml)$/i
const NON_SOURCE_DIRECTORIES = new Set([
'node_modules',
'lib',
'.pnpm-store',
'.cache',
'coverage',
'.sessions',
'.storages',
'tmp',
'dist-exe',
'__pycache__',
'.pytest_cache',
'.artifacts',
'vendor',
])
/** Glob traversal exclusions corresponding to the non-source path predicate. */
export const TRANSLATION_SCOPE_GLOB_EXCLUDES = [
'**/node_modules/**',
'**/lib/**',
'**/.pnpm-store/**',
'**/.cache/**',
'**/coverage/**',
'**/.doc-typecheck-*/**',
'**/.node-next-types-*/**',
'**/.sessions/**',
'**/.storages/**',
'**/tmp/**',
'**/dist-exe/**',
'**/__pycache__/**',
'**/.pytest_cache/**',
'apps/web/dist/**',
'.artifacts/**',
'python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-*/**',
'python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/**',
'vendor/**',
]
/** Whether a repository-relative path belongs to a dependency or generated tree. */
function isTranslationSourceExcluded(file: string): boolean {
const segments = file.split('/')
return segments.some(segment => NON_SOURCE_DIRECTORIES.has(segment)
|| segment.startsWith('.doc-typecheck-')
|| segment.startsWith('.node-next-types-'))
|| file.startsWith('apps/web/dist/')
|| file.startsWith('python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-')
|| file.startsWith('python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/')
}
/** Whether one discovered Markdown or sidecar path belongs to the bilingual source corpus. */
export function isTranslationScopeFile(file: string): boolean {
return !isTranslationSourceExcluded(file) && (README_ARTIFACT.test(file)
|| file.startsWith('.agents/notes/')
|| file.startsWith('docs/')
|| file.startsWith('python/'))
}
/** Read the manifest exclusion list or fail before enforcement starts. */
function excludedField(record: Record<string, unknown>): string[] {
const value = record.excluded
if (!Array.isArray(value)) {
throw new Error('translation-pairing.manifest.json: excluded 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: excluded 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 unsupported = Object.keys(record).filter(field => field !== 'excluded')
if (unsupported.length > 0) {
throw new Error(`translation-pairing.manifest.json: unsupported field(s): ${unsupported.join(', ')}; every in-scope document is required`)
}
return { excluded: excludedField(record) }
}
/** 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
}