Merge remote-tracking branch 'origin/master' into feat/telemetry-otel-plugin

Resolutions: regenerate the conflicted generated docs (cordis services
catalog, event-producer-consumer, module-graph); take master's
packages/README pair and re-insert the telemetry row on both sides;
re-record the README and session-doc translation pairs.
This commit is contained in:
kingwl
2026-07-27 20:26:21 +08:00
1287 changed files with 40863 additions and 13931 deletions

View File

@@ -8,15 +8,18 @@ import { resolve, sep } from 'node:path'
export const agentNoteRoot = resolve(import.meta.dirname, '../.agents/notes')
/** The closed set of Agent Note lifecycles (top-level folders under .agents/notes/). */
const LIFECYCLES = ['proposed', 'implemented', 'rejected'] as const
/** The closed set of active Agent Note lifecycles (top-level folders under .agents/notes/). */
const AGENT_NOTE_LIFECYCLES = ['proposed', 'implemented', 'rejected'] as const
/**
* The closed set of Agent Note classes (nested folder under each lifecycle). Adding a
* class is a deliberate act: extend this list AND the README's Classification
* section. The gate rejects any folder not listed here.
*/
const CLASSES = ['feature', 'bug-fix', 'simplification', 'architecture', 'process', 'testing'] as const
export const AGENT_NOTE_CLASSES = ['feature', 'bug-fix', 'simplification', 'architecture', 'process', 'testing'] as const
/** Historical implemented notes live outside the active lifecycle tree. */
const AGENT_NOTE_ARCHIVE = 'archived'
/** Non-Agent Note Markdown allowed to sit directly at a lifecycle root. */
const ROOT_ALLOWLIST = new Set(['AGENTS.md', 'CLAUDE.md'])
@@ -45,11 +48,13 @@ export function walkAgentNoteTree(): { notes: AgentNote[]; errors: string[] } {
errors.push('structure: INDEX.md — centralized Agent Note indexes are forbidden; browse the lifecycle/class tree or search the repository')
continue
}
if (entry.isDirectory() && !(LIFECYCLES as readonly string[]).includes(entry.name)) {
errors.push(`structure: ${entry.name}/ — unknown lifecycle folder (allowed: ${LIFECYCLES.join(', ')})`)
if (entry.isDirectory()
&& entry.name !== AGENT_NOTE_ARCHIVE
&& !(AGENT_NOTE_LIFECYCLES as readonly string[]).includes(entry.name)) {
errors.push(`structure: ${entry.name}/ — unknown lifecycle folder (allowed: ${AGENT_NOTE_LIFECYCLES.join(', ')}, plus ${AGENT_NOTE_ARCHIVE}/)`)
}
}
for (const lifecycle of LIFECYCLES) {
for (const lifecycle of AGENT_NOTE_LIFECYCLES) {
for (const match of globSync(`${lifecycle}/**/*.md`, { cwd: agentNoteRoot }).map(path => path.split(sep).join('/')).sort()) {
const segs = match.split('/')
// Allowlisted file directly at the lifecycle root (e.g. implemented/AGENTS.md).
@@ -63,8 +68,8 @@ export function walkAgentNoteTree(): { notes: AgentNote[]; errors: string[] } {
errors.push(`structure: ${match} — expected {lifecycle}/{class}/file.md (got depth ${segs.length})`)
continue
}
if (!(CLASSES as readonly string[]).includes(cls)) {
errors.push(`structure: ${match} — unknown class folder "${cls}" (allowed: ${CLASSES.join(', ')})`)
if (!(AGENT_NOTE_CLASSES as readonly string[]).includes(cls)) {
errors.push(`structure: ${match} — unknown class folder "${cls}" (allowed: ${AGENT_NOTE_CLASSES.join(', ')})`)
continue
}
if (!/^\d{4}-\d{2}-\d{2}-.+\.md$/.test(base)) {

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import {
extendArchiveManifest,
gitBlobHash,
parseArchiveManifest,
renderArchiveManifest,
validateArchiveArtifacts,
validateArchiveManifestExtension,
type ArchiveManifest,
} from './archived-agent-notes.ts'
import { isArchivedAgentNotePath } from './repo-files.ts'
function fixture(): Map<string, Buffer> {
const base = '2026-07-26-example'
const source = Buffer.from(`# Agent Note: Example\n\nStatus: implemented\nArchived: 2026-07-26\n\nEnglish | [中文](${base}.zh.md)\n\n## Problem\n\nExample.\n`)
const zh = Buffer.from(`# Agent Note: 示例\n\nStatus: implemented\nArchived: 2026-07-26\n\n[English](${base}.md) | 中文\n\n## 问题\n\n示例。\n`)
const meta = Buffer.from(`${base}.md: ${gitBlobHash(source)}\n${base}.zh.md: ${gitBlobHash(zh)}\n`)
return new Map([
[`process/${base}.md`, source],
[`process/${base}.zh.md`, zh],
[`process/${base}.i18n.yaml`, meta],
])
}
describe('archived Agent Notes', () => {
it('recognizes archived paths with POSIX and Windows separators', () => {
expect(isArchivedAgentNotePath('.agents/notes/archived/process/example.md')).toBe(true)
expect(isArchivedAgentNotePath('.agents\\notes\\archived\\process\\example.md')).toBe(true)
expect(isArchivedAgentNotePath('.agents/notes/implemented/process/example.md')).toBe(false)
})
it('accepts one complete implemented triplet with matching archive metadata', () => {
expect(validateArchiveArtifacts(fixture())).toEqual([])
})
it('rejects incomplete triplets and invalid archive headers', () => {
const artifacts = fixture()
artifacts.delete('process/2026-07-26-example.i18n.yaml')
artifacts.set(
'process/2026-07-26-example.md',
Buffer.from('# Agent Note: Example\n\nStatus: proposed\nArchived: yesterday\n'),
)
expect(validateArchiveArtifacts(artifacts).join('\n')).toMatch(/incomplete archived triplet/)
})
it('extends the manifest without permitting a sealed change or removal', () => {
const artifacts = fixture()
const empty: ArchiveManifest = { version: 1, files: {} }
const first = extendArchiveManifest(empty, artifacts)
expect(first.errors).toEqual([])
expect(first.added).toHaveLength(3)
const sealed: ArchiveManifest = { version: 1, files: first.files }
const changed = new Map(artifacts)
changed.set('process/2026-07-26-example.md', Buffer.from('changed'))
expect(extendArchiveManifest(sealed, changed).errors).toEqual([
'process/2026-07-26-example.md: sealed content hash changed',
])
changed.delete('process/2026-07-26-example.zh.md')
expect(extendArchiveManifest(sealed, changed).errors).toContain(
'process/2026-07-26-example.zh.md: sealed artifact is missing',
)
})
it('rejects replacing manifest seals alongside changed archive content', () => {
const artifacts = fixture()
const initial = extendArchiveManifest({ version: 1, files: {} }, artifacts)
const baseline: ArchiveManifest = { version: 1, files: initial.files }
const path = 'process/2026-07-26-example.md'
const changedArtifacts = new Map(artifacts)
changedArtifacts.set(path, Buffer.from('changed'))
const replacement = extendArchiveManifest({ version: 1, files: {} }, changedArtifacts)
const current: ArchiveManifest = { version: 1, files: replacement.files }
expect(extendArchiveManifest(current, changedArtifacts).errors).toEqual([])
expect(validateArchiveManifestExtension(baseline, current)).toEqual([
`${path}: sealed manifest hash changed`,
])
const removed: ArchiveManifest = {
version: 1,
files: Object.fromEntries(Object.entries(current.files).filter(([candidate]) => candidate !== path)),
}
expect(validateArchiveManifestExtension(baseline, removed)).toContain(
`${path}: sealed manifest entry is missing`,
)
})
it('round-trips the deterministic manifest schema', () => {
const content = renderArchiveManifest({ 'process/z.md': `sha256:${'a'.repeat(64)}` })
expect(parseArchiveManifest(content)).toEqual({
version: 1,
files: { 'process/z.md': `sha256:${'a'.repeat(64)}` },
})
})
})

View File

@@ -0,0 +1,190 @@
/** Pure archive-format, triplet, and immutable-manifest helpers. */
import { createHash } from 'node:crypto'
import { basename } from 'node:path'
import { AGENT_NOTE_CLASSES } from './agent-note-tree.ts'
/** Versioned shape of the frozen-content manifest. */
export interface ArchiveManifest {
version: 1
files: Readonly<Record<string, string>>
}
/** Hash one archived artifact independently of the repository's Git object format. */
function archiveContentHash(content: Buffer): string {
return `sha256:${createHash('sha256').update(content).digest('hex')}`
}
/** Compute the SHA-1 Git blob id used by bilingual consistency sidecars. */
export function gitBlobHash(content: Buffer): string {
const hash = createHash('sha1')
hash.update(`blob ${content.byteLength}\0`)
hash.update(content)
return hash.digest('hex')
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** Parse the archive manifest and reject fields or hashes outside its closed schema. */
export function parseArchiveManifest(content: string): ArchiveManifest {
const value: unknown = JSON.parse(content)
if (!isRecord(value)) throw new Error('expected a JSON object')
const fields = Object.keys(value).sort()
if (fields.join(',') !== 'files,version') throw new Error('expected exactly the fields `version` and `files`')
if (value.version !== 1) throw new Error('unsupported manifest version (expected 1)')
if (!isRecord(value.files)) throw new Error('`files` must be an object')
const files: Record<string, string> = {}
for (const [path, hash] of Object.entries(value.files)) {
if (typeof hash !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(hash)) {
throw new Error(`invalid content hash for ${path}`)
}
files[path] = hash
}
return { version: 1, files }
}
/** Render the archive manifest with deterministic path ordering. */
export function renderArchiveManifest(files: Readonly<Record<string, string>>): string {
return `${JSON.stringify({
version: 1,
files: Object.fromEntries(Object.entries(files).sort(([left], [right]) => left.localeCompare(right))),
}, null, 2)}\n`
}
/** Reject changes or removals of entries sealed by a prior manifest. */
export function validateArchiveManifestExtension(
baseline: ArchiveManifest,
current: ArchiveManifest,
): string[] {
const errors: string[] = []
for (const [path, expected] of Object.entries(baseline.files)) {
const actual = current.files[path]
if (actual === undefined) errors.push(`${path}: sealed manifest entry is missing`)
else if (actual !== expected) errors.push(`${path}: sealed manifest hash changed`)
}
return errors
}
function validDate(value: string): boolean {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
if (match === null) return false
const year = Number(match[1])
const month = Number(match[2])
const day = Number(match[3])
const date = new Date(Date.UTC(year, month - 1, day))
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
}
interface Triplet {
source?: Buffer
zh?: Buffer
meta?: Buffer
}
function pairMeta(content: string): Map<string, string> | undefined {
const entries = new Map<string, string>()
for (const line of content.split('\n')) {
if (line === '' || line.startsWith('#')) continue
const match = /^([^:#]+\.md): ([0-9a-f]{40})$/.exec(line)
if (match?.[1] === undefined || match[2] === undefined) return undefined
entries.set(match[1], match[2])
}
return entries
}
function validateHeader(path: string, content: Buffer, sourceBase: string, chinese: boolean): string[] {
const errors: string[] = []
const lines = content.toString('utf8').split('\n')
if (!/^# Agent Note: \S/.test(lines[0] ?? '')) errors.push(`${path}: line 1 must be \`# Agent Note: <title>\``)
if (lines[1] !== '') errors.push(`${path}: line 2 must be blank`)
if (lines[2] !== 'Status: implemented') errors.push(`${path}: line 3 must be \`Status: implemented\``)
const archived = /^Archived: (\d{4}-\d{2}-\d{2})$/.exec(lines[3] ?? '')?.[1]
if (archived === undefined || !validDate(archived)) {
errors.push(`${path}: line 4 must be \`Archived: YYYY-MM-DD\` with a valid date`)
} else if (archived < sourceBase.slice(0, 10)) {
errors.push(`${path}: archive date ${archived} predates the note filename`)
}
if (lines[4] !== '') errors.push(`${path}: line 5 must be blank`)
const switcher = chinese
? `[English](${sourceBase}.md) | 中文`
: `English | [中文](${sourceBase}.zh.md)`
if (lines[5] !== switcher) errors.push(`${path}: line 6 must be ${JSON.stringify(switcher)}`)
return errors
}
/** Validate the closed kind tree, implemented/archive headers, and complete bilingual triplets. */
export function validateArchiveArtifacts(artifacts: ReadonlyMap<string, Buffer>): string[] {
const errors: string[] = []
const triplets = new Map<string, Triplet>()
for (const [path, content] of artifacts) {
const match = /^([^/]+)\/(\d{4}-\d{2}-\d{2}-.+?)(\.zh\.md|\.i18n\.yaml|\.md)$/.exec(path)
if (match?.[1] === undefined || match[2] === undefined || match[3] === undefined) {
errors.push(`${path}: expected {kind}/yyyy-mm-dd-topic.{md,zh.md,i18n.yaml}`)
continue
}
if (!(AGENT_NOTE_CLASSES as readonly string[]).includes(match[1])) {
errors.push(`${path}: unknown Agent Note kind ${JSON.stringify(match[1])}`)
continue
}
const key = `${match[1]}/${match[2]}`
const triplet = triplets.get(key) ?? {}
if (match[3] === '.md') triplet.source = content
else if (match[3] === '.zh.md') triplet.zh = content
else triplet.meta = content
triplets.set(key, triplet)
}
for (const [key, triplet] of [...triplets].sort(([left], [right]) => left.localeCompare(right))) {
const sourcePath = `${key}.md`
const zhPath = `${key}.zh.md`
const metaPath = `${key}.i18n.yaml`
const { source, zh, meta } = triplet
const missing = [
source === undefined ? sourcePath : undefined,
zh === undefined ? zhPath : undefined,
meta === undefined ? metaPath : undefined,
].filter((path): path is string => path !== undefined)
if (source === undefined || zh === undefined || meta === undefined) {
errors.push(`${key}: incomplete archived triplet; missing ${missing.join(', ')}`)
continue
}
const sourceBase = basename(key)
errors.push(...validateHeader(sourcePath, source, sourceBase, false))
errors.push(...validateHeader(zhPath, zh, sourceBase, true))
const sourceDate = /^Archived: (\d{4}-\d{2}-\d{2})$/m.exec(source.toString('utf8'))?.[1]
const zhDate = /^Archived: (\d{4}-\d{2}-\d{2})$/m.exec(zh.toString('utf8'))?.[1]
if (sourceDate !== undefined && zhDate !== undefined && sourceDate !== zhDate) {
errors.push(`${key}: English and Chinese archive dates differ (${sourceDate} vs ${zhDate})`)
}
const pair = pairMeta(meta.toString('utf8'))
if (pair === undefined || pair.size !== 2
|| pair.get(`${sourceBase}.md`) !== gitBlobHash(source)
|| pair.get(`${sourceBase}.zh.md`) !== gitBlobHash(zh)) {
errors.push(`${metaPath}: consistency record must contain the current Git blob hashes of both archived sides`)
}
}
return errors
}
/** Preserve every sealed path/hash and append hashes for newly archived artifacts. */
export function extendArchiveManifest(
existing: ArchiveManifest,
artifacts: ReadonlyMap<string, Buffer>,
): { files: Record<string, string>; added: string[]; errors: string[] } {
const errors: string[] = []
const files: Record<string, string> = { ...existing.files }
for (const [path, expected] of Object.entries(existing.files)) {
const content = artifacts.get(path)
if (content === undefined) errors.push(`${path}: sealed artifact is missing`)
else if (archiveContentHash(content) !== expected) errors.push(`${path}: sealed content hash changed`)
}
const added: string[] = []
for (const [path, content] of [...artifacts].sort(([left], [right]) => left.localeCompare(right))) {
if (files[path] !== undefined) continue
files[path] = archiveContentHash(content)
added.push(path)
}
return { files, added, errors }
}

View File

@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
@@ -59,4 +59,21 @@ describe('RepositoryCleaner', () => {
await expect(new RepositoryCleaner(root).clean()).rejects.toThrow('packages/removed/ghost/notes.txt')
expect(existsSync(join(root, 'products/shell/lib'))).toBe(true)
})
it('refuses project outputs reached through a symlink outside the repository', async () => {
const root = fixture()
const externalProject = fixture()
write(join(root, 'tsconfig.json'), JSON.stringify({ files: [], references: [{ path: './linked' }] }))
write(join(externalProject, 'tsconfig.json'), JSON.stringify({
compilerOptions: { composite: true, outDir: 'lib/types' },
include: ['src'],
}))
write(join(externalProject, 'src/index.ts'), 'export {}\n')
write(join(externalProject, 'lib/types/index.js'))
symlinkSync(externalProject, join(root, 'linked'), process.platform === 'win32' ? 'junction' : 'dir')
await expect(new RepositoryCleaner(root).clean()).rejects.toThrow('outside repository')
expect(existsSync(join(externalProject, 'lib/types/index.js'))).toBe(true)
})
})

View File

@@ -1,4 +1,4 @@
import { lstat, readdir, rm } from 'node:fs/promises'
import { lstat, readdir, realpath, rm } from 'node:fs/promises'
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import ts from 'typescript'
@@ -45,7 +45,11 @@ function parseConfig(configPath: string): ts.ParsedCommandLine {
/** Plans and removes repository-owned build output without crossing the repository boundary. */
export class RepositoryCleaner {
constructor(private readonly root: string) {}
private readonly root: string
constructor(root: string) {
this.root = resolve(root)
}
/**
* Remove generated build state and package directories containing only known residue.
@@ -61,9 +65,10 @@ export class RepositoryCleaner {
private async plan(): Promise<string[]> {
const targets = new Set<string>()
const unsafeOrphans: string[] = []
const canonicalRoot = await realpath(this.root)
// These checks cover legacy root-level incremental state emitted by older configs.
await this.addIfPresent(targets, join(this.root, '.typecheck'))
await this.addIfPresent(targets, join(this.root, '.typecheck'), canonicalRoot)
for (const entry of await readdir(this.root, { withFileTypes: true })) {
if (entry.isFile() && entry.name.endsWith('.tsbuildinfo')) targets.add(join(this.root, entry.name))
}
@@ -72,7 +77,7 @@ export class RepositoryCleaner {
// Each emitting project declares lib/types as outDir; its parent lib also owns
// the sibling runtime bundles, so the complete build output root is removed.
for (const outputDirectory of this.buildOutputDirectories()) {
await this.addIfPresent(targets, outputDirectory)
await this.addIfPresent(targets, outputDirectory, canonicalRoot)
}
for (const groupDirectory of await childDirectories(join(this.root, 'packages'))) {
@@ -90,7 +95,7 @@ export class RepositoryCleaner {
if (unknown.length > 0) {
unsafeOrphans.push(...unknown.map(entry => repositoryPath(this.root, join(packageDirectory, entry))))
} else {
targets.add(packageDirectory)
await this.addIfPresent(targets, packageDirectory, canonicalRoot)
}
}
}
@@ -137,15 +142,24 @@ export class RepositoryCleaner {
}
private assertRepositoryTarget(path: string): void {
const repositoryRelative = relative(this.root, path)
this.assertDescendant(this.root, path, path)
}
private assertDescendant(root: string, path: string, displayPath: string): void {
const repositoryRelative = relative(root, path)
if (repositoryRelative === '' || repositoryRelative === '..' || repositoryRelative.startsWith(`..${sep}`) || isAbsolute(repositoryRelative)) {
throw new Error(`clean: refusing build output outside repository: ${path}`)
throw new Error(`clean: refusing deletion target outside repository: ${displayPath}`)
}
}
private async addIfPresent(targets: Set<string>, path: string): Promise<void> {
private async addIfPresent(targets: Set<string>, path: string, canonicalRoot: string): Promise<void> {
// Missing outputs are normal on a clean checkout; only existing paths become deletion targets.
if (await exists(path)) targets.add(path)
if (!await exists(path)) return
// Resolve the parent rather than the final entry: rm unlinks a final symlink,
// but a symlink in an ancestor would make deletion cross the repository boundary.
const canonicalParent = await realpath(dirname(path))
this.assertDescendant(canonicalRoot, join(canonicalParent, basename(path)), path)
targets.add(path)
}
}

View File

@@ -0,0 +1,33 @@
/** Regression coverage for source declarations owned by the client test aggregate. */
import { existsSync, readdirSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import ts from 'typescript'
import { describe, expect, it } from 'vitest'
const root = fileURLToPath(new URL('..', import.meta.url))
function clientCssDeclarations(): string[] {
const clientRoot = resolve(root, 'packages/client')
return readdirSync(clientRoot, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => resolve(clientRoot, entry.name, 'src/css-modules.d.ts'))
.filter(existsSync)
.sort()
}
describe('client TypeScript aggregate', () => {
it('loads package CSS declarations without relying on workspace-link realpaths', () => {
const configPath = resolve(root, 'tsconfig.client.json')
const read = ts.readConfigFile(configPath, file => ts.sys.readFile(file))
if (read.error !== undefined) {
throw new Error(ts.flattenDiagnosticMessageText(read.error.messageText, '\n'))
}
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, root)
const loaded = parsed.fileNames
.filter(file => file.endsWith('/src/css-modules.d.ts'))
.sort()
expect(loaded).toEqual(clientCssDeclarations())
})
})

View File

@@ -17,8 +17,8 @@
* `watch` through API-level inline config (tsdown workspace mode fills inline
* keys under each package's file config, and no package config defines it).
*/
import { readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { globSync, readFileSync } from 'node:fs'
import { dirname, join, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { build } from 'tsdown'
@@ -33,20 +33,9 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url))
*/
function discoverPluginDirs(): string[] {
const dirs: string[] = []
for (const group of readdirSync(join(repoRoot, 'packages'), { withFileTypes: true })) {
if (!group.isDirectory()) continue
for (const pkg of readdirSync(join(repoRoot, 'packages', group.name), { withFileTypes: true })) {
if (!pkg.isDirectory()) continue
let manifest: { dshClient?: { platform?: unknown } }
try {
manifest = JSON.parse(
readFileSync(join(repoRoot, 'packages', group.name, pkg.name, 'package.json'), 'utf8'),
) as { dshClient?: { platform?: unknown } }
} catch {
continue // no package.json (support dirs, scratch): not a workspace package
}
if (manifest.dshClient?.platform === 'web') dirs.push(`packages/${group.name}/${pkg.name}`)
}
for (const manifestPath of globSync('packages/*/*/package.json', { cwd: repoRoot }).sort()) {
const manifest = JSON.parse(readFileSync(join(repoRoot, manifestPath), 'utf8')) as { dshClient?: { platform?: unknown } }
if (manifest.dshClient?.platform === 'web') dirs.push(dirname(manifestPath).split(sep).join('/'))
}
return dirs
}

View File

@@ -1,11 +1,11 @@
{
"AGENTS.md": 1680,
"AGENTS.md": 1705,
"docs/AGENTS.md": 1150,
"docs/architecture.md": 1800,
"docs/cordis-primer.md": 600,
"docs/defensive-patterns.md": 550,
"docs/testing.md": 1100,
"examples/AGENTS.md": 310,
"packages/AGENTS.md": 660,
"packages/AGENTS.md": 675,
"packages/README.md": 835
}

View File

@@ -10,8 +10,9 @@ import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node
import { join, relative, resolve } from 'node:path'
import ts from 'typescript'
import { builtDeclarationPath } from './doc-typecheck-paths.ts'
import { extractFences } from './md-fences.ts'
import { markdownFences } from './markdown.ts'
import { partitionPairedMarkdownDerivatives } from './paired-markdown-derivatives.ts'
import { isArchivedAgentNotePath } from './repo-files.ts'
const root = resolve(import.meta.dirname, '..')
@@ -45,8 +46,10 @@ const KIND_BY_INFO: Record<string, BlockKind> = {
/** Extract every recognized TypeScript fence from one Markdown file. */
function extractBlocks(absPath: string): Block[] {
const file = relative(root, absPath)
return extractFences(absPath, info => KIND_BY_INFO[info] ?? null)
.map(f => ({ file, line: f.line, kind: f.kind, code: f.code }))
return markdownFences(readFileSync(absPath, 'utf8')).flatMap((fence) => {
const kind = KIND_BY_INFO[fence.info]
return kind === undefined ? [] : [{ file, line: fence.line, kind, code: fence.code }]
})
}
const configHost: ts.ParseConfigFileHost = {
@@ -204,7 +207,9 @@ const markdownGlobs = ['README.md', '.agents/notes/**/*.md', 'docs/**/*.md', 'pa
const files: string[] = []
for (const pattern of markdownGlobs) {
for (const match of globSync(pattern, { cwd: root })) files.push(resolve(root, match))
for (const match of globSync(pattern, { cwd: root })) {
if (!isArchivedAgentNotePath(match)) files.push(resolve(root, match))
}
}
files.sort()

View File

@@ -40,6 +40,8 @@ export const LINK_MAP: Record<string, string> = {
HookContext: 'core.md',
LlmCallConfig: 'core.md',
LlmModelContext: 'core.md',
LlmModelReasoningInfo: 'core.md',
LlmResolvedModelInfo: 'core.md',
LlmFailure: 'llm-streaming.md',
LlmModelInfo: 'core.md',
LlmProviderInfo: 'core.md',
@@ -64,7 +66,12 @@ export const LINK_MAP: Record<string, string> = {
BashExecSpec: 'bash.md',
BashProcess: 'bash.md',
BashRunResult: 'bash.md',
DshEnvironment: 'bash.md',
DshEnvironment: 'subprocess.md',
SubprocessHandle: 'subprocess.md',
SubprocessOutcome: 'subprocess.md',
SubprocessOutputRead: 'subprocess.md',
SubprocessOutputReader: 'subprocess.md',
SubprocessSpawnSpec: 'subprocess.md',
CodeRunRequest: 'code-runtime.md',
CodeRunResult: 'code-runtime.md',
CompactionResult: 'compaction.md',
@@ -92,6 +99,7 @@ export const LINK_MAP: Record<string, string> = {
CommandResult: 'commands.md',
CommandSurface: 'commands.md',
LlmAdapter: 'llm-streaming.md',
PreparedLlmCall: 'llm-streaming.md',
LlmService: 'llm-streaming.md',
StreamChunk: 'llm-streaming.md',
CreateSessionOptions: 'persistence.md',
@@ -165,6 +173,7 @@ export const LINK_MAP: Record<string, string> = {
TaskSnapshot: 'tasks.md',
TaskStart: 'tasks.md',
TokenMeasurement: 'token-meter.md',
CodeDispatchLog: 'tools.md',
PostToolDecision: 'tools.md',
PreToolDecision: 'tools.md',
ToolDefinition: 'tools.md',
@@ -206,6 +215,10 @@ const FOUNDATION_TYPE_NAMES = new Set([
/** Project types deliberately documented outside the core-data catalog. */
const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
AgentFactory: 'agent creation seam is owned by packages/core/agent/README.md',
BeginCommandRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
InsertReferenceRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
ConsumeTokenRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
InsertTextRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
AgentHandle: 'agent ownership handle is owned by packages/core/agent/README.md',
BashEnvContributor: 'service-local extension type is owned by packages/bash/tool-bash/src/index.ts',
BashEnvVariableInfo: 'service-local metadata type is owned by packages/bash/tool-bash/src/index.ts',
@@ -389,7 +402,7 @@ export function collectEvents(scanRoot: string = root): EventEntry[] {
const where = `event '${name}' (${src})`
checkTypeLinks(where, member, sf, typeLinkViolations)
if (!mode) {
violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial|bail' to its JSDoc (see AGENTS.md).`)
}
// Conclusive structural check: a trailing `next: () => …` parameter is a
// waterfall. (emit vs parallel vs serial is not structurally
@@ -580,7 +593,7 @@ export function renderEvents(events: EventEntry[]): string {
'',
'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely. The event-dispatch methods themselves are generated in the [Cordis core Events API](core/events.md).',
'',
'Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`).',
'Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`), **bail** (synchronous in-order dispatch until one listener returns a bail value; the scoped input-mutation events use it for an applied/not-applied answer).',
'',
]
const scopes = [...new Set(events.map(e => e.scope))].sort()

View File

@@ -59,6 +59,7 @@ const GROUP_ORDER = [
'llm',
'core',
'goal',
'process',
'bash',
'pty',
'sandbox',
@@ -274,6 +275,15 @@ const SERVICE_ROLES: ServiceRole[] = [
mode: 'core',
note: 'Folds revisioned objective state from the session log and keeps live continuation activation process-local.',
},
{
key: 'subprocess',
pkg: 'subprocess',
title: 'Subprocess seam',
mode: 'seam',
implementations: ['subprocess-local'],
consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp'],
note: 'The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.',
},
{
key: 'bash',
pkg: 'bash',
@@ -375,9 +385,10 @@ const SERVICE_ROLES: ServiceRole[] = [
key: 'tasks',
pkg: 'tasks',
title: 'Background task registry',
mode: 'core',
mode: 'seam',
implementations: ['tasks-local'],
consumers: ['tool-bash', 'tool-pty', 'tool-subagent', 'tool-tasks'],
note: 'Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it.',
note: 'Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it; tasks-local is the process-local registry.',
},
{
key: 'web',
@@ -926,8 +937,13 @@ function renderEventRelations(pkgs: Pkg[]): string {
lines.push(`| \`${event.name}\` | \`${event.mode}\` | ${sourceLink(event.source)} | ${relationPackages(relation.dispatchers, pkgsByShort)} | ${listenerPackages(relation.listeners, pkgsByShort)} |`)
}
// Every declared event needs a dispatcher: zero means dead vocabulary or an
// unrecognized semantic dispatch shape. Listener-free extension points remain valid.
// unrecognized semantic dispatch shape. Listener-free extension points remain
// valid. Client-declared events are exempt: the relation scan seeds the HOST
// aggregate program only (host+client cannot share one program — the cordis
// Context merges collide), so client dispatch sites are structurally
// invisible here; their rows stay in the table for the declarations' sake.
const undispatched = [...events]
.filter(event => !event.source.startsWith('packages/client/'))
.filter(event => (relations.get(event.name)?.dispatchers.size ?? 0) === 0)
.map(event => event.name)
.sort()
@@ -1138,7 +1154,7 @@ function renderIndex(docs: GraphDoc[]): string {
...generatedHeader('Documentation Graph Index'),
'These diagrams are the relationship layer above the generated catalogs. Use them to navigate package topology, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type shapes still live in the generated [events](cordis-catalog/events.md) / [services](cordis-catalog/services.md) catalogs, [tool-catalog.md](tool-catalog.md), and [core-data-structures/](core-data-structures/core.md).',
'',
'The process decision behind this index is recorded in [the documentation graph Agent Note](../.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.md).',
'The process decision behind this index is recorded in [the documentation graph Agent Note](../.agents/notes/archived/process/2026-07-03-documentation-graph-atlas.md).',
'',
'| Graph | Mode |',
'| --- | --- |',

View File

@@ -19,6 +19,7 @@ import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import PlanModeService from '@deepseek-ai/dsh-plan-mode'
@@ -29,7 +30,7 @@ import SubagentService from '@deepseek-ai/dsh-subagent'
import type { SubagentProvider } from '@deepseek-ai/dsh-subagent'
import SkillService from '@deepseek-ai/dsh-skill'
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
import TaskService from '@deepseek-ai/dsh-tasks'
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
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'
@@ -169,14 +170,14 @@ const TOOL_PACKAGES: ToolPackage[] = [
dir: 'tools',
source: 'packages/core/tools/src/code-mode.ts',
requires: ['ctx.tools', 'ctx.codeRuntime (execution time)', 'ctx.systemPrompt'],
writes: ['tool/call', 'one tool/code-dispatch per bridged sub-call', 'tool/result'],
writes: ['tool/call', 'one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call', 'tool/result'],
// The registry's OWN tool: run_code exists only under a non-native mode
// (the registry registers it in its constructor; the code runtime is read
// at assembly/execution time, so the schema harvest needs none mounted).
toolsConfig: { mode: 'code' },
async mount() {},
note:
'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry\'s only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.',
'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry\'s only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.',
},
{
pkg: '@deepseek-ai/dsh-plan-mode',
@@ -197,6 +198,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
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(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor)
await ctx.plugin(ToolBash)
},
@@ -355,7 +357,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
requires: ['ctx.tools', 'ctx.tasks', 'ctx.systemPrompt'],
writes: ['tool/call', 'tool/result', 'user/message via agent.inject() for background completion notices'],
async mount(ctx) {
await ctx.plugin(TaskService)
await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks)
},
note:

View File

@@ -0,0 +1,302 @@
/**
* Print the minimal-update briefing for out-of-sync translation pairs:
* `pnpm run gen-translation-brief [--apply] [pair paths...]`. With no
* arguments it discovers every out-of-sync pair; with arguments (any file
* of a pair) it briefs exactly those pairs and fails loud on in-sync,
* incomplete, or out-of-scope requests. Each briefing maps the change at
* the narrowest safe granularity — code-fence-only splice, changed
* Markdown units, heading sections, whole document — and `--apply` writes
* the computed counterpart for pairs whose change is code-fence-only.
* The briefing contract lives in `scripts/translation-brief.ts`; the
* consuming workflow is `.agents/skills/dsh-translate-docs/SKILL.md`.
*/
import { spawnSync } from 'node:child_process'
import { existsSync, globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, join, resolve, sep } from 'node:path'
import {
isTranslationScopeFile,
pairAnchorOfArgument,
parseTranslationMarkdown,
parseTranslationPairingManifest,
TRANSLATION_SCOPE_GLOB_EXCLUDES,
translationStructureDiff,
translationStructureSignature,
} from './translation-pairing.ts'
import {
changedSpanIndices,
computeMechanicalUpdate,
firstOccurrenceContext,
markdownUnits,
relevantTerminologyRows,
renderTranslationBrief,
sectionSpans,
spansAligned,
type BriefBundle,
type BriefDirection,
type BriefScope,
type MarkdownSpan,
} from './translation-brief.ts'
const root = resolve(import.meta.dirname, '..')
const manifest = parseTranslationPairingManifest(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8'))
const terminology = readFileSync(join(root, 'docs/i18n/terminology.md'), 'utf8')
function isExcluded(file: string): boolean {
return manifest.excluded.some(entry => (entry.endsWith('/') ? file.startsWith(entry) : file === entry))
}
/** Recorded hashes of one consistency record: basename → blob hash. */
function parseMeta(content: string): Map<string, string> | undefined {
const out = new Map<string, string>()
for (const line of content.split('\n')) {
if (line === '' || line.startsWith('#')) continue
const match = /^([^:#]+\.md): ([0-9a-f]{40})$/.exec(line)
if (!match?.[1] || !match[2]) return undefined
out.set(match[1], match[2])
}
return out
}
function git(args: string[], allowedExitCodes: number[] = [0]): string {
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', maxBuffer: 1 << 26 })
if (result.error) throw result.error
if (!allowedExitCodes.includes(result.status ?? -1)) {
throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`)
}
return result.stdout
}
function blobText(hash: string): string {
return git(['cat-file', '-p', hash])
}
/** Unified diff between two texts, headers stripped, via `git diff --no-index`. */
function diffTexts(before: string, after: string): string {
const dir = mkdtempSync(join(tmpdir(), 'translation-brief-'))
try {
writeFileSync(join(dir, 'last-confirmed.md'), before)
writeFileSync(join(dir, 'current.md'), after)
const raw = git(['diff', '--no-index', '--unified=2', join(dir, 'last-confirmed.md'), join(dir, 'current.md')], [0, 1])
return raw.split('\n')
.filter(line => !line.startsWith('diff --git') && !line.startsWith('index ') && !line.startsWith('--- ') && !line.startsWith('+++ '))
.join('\n')
.trim()
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
interface PairState {
anchor: string
zh: string
meta: string
enDrifted: boolean
zhDrifted: boolean
enLast: string
zhLast: string
}
/** Load one pair's recorded and current state, or explain why it cannot be briefed. */
function loadPair(anchor: string): PairState | string {
const zh = anchor.replace(/\.md$/, '.zh.md')
const meta = anchor.replace(/\.md$/, '.i18n.yaml')
if (!isTranslationScopeFile(anchor) || isExcluded(anchor)) {
return `${anchor}: not an in-scope documentation pair (docs/i18n/README.md)`
}
const missing = [anchor, zh, meta].filter(file => !existsSync(join(root, file)))
if (missing.length > 0) {
return `${anchor}: incomplete pair (missing ${missing.join(', ')}) — a new counterpart is whole-document translation work, not a minimal update`
}
const record = parseMeta(readFileSync(join(root, meta), 'utf8'))
const enRecorded = record?.get(basename(anchor))
const zhRecorded = record?.get(basename(zh))
if (record === undefined || enRecorded === undefined || zhRecorded === undefined) {
return `${meta}: malformed consistency record`
}
const enCurrent = readFileSync(join(root, anchor), 'utf8')
const zhCurrent = readFileSync(join(root, zh), 'utf8')
const enLast = blobText(enRecorded)
const zhLast = blobText(zhRecorded)
return {
anchor,
zh,
meta,
enDrifted: enCurrent !== enLast,
zhDrifted: zhCurrent !== zhLast,
enLast,
zhLast,
}
}
/** Assemble bundles for the given changed + first-occurrence span indices. */
function bundlesFor(
indices: number[],
extraIndices: number[],
confirmed: MarkdownSpan[],
current: MarkdownSpan[],
counterpart: MarkdownSpan[],
): BriefBundle[] {
const extras = new Set(extraIndices)
return [...new Set([...indices, ...extraIndices])].sort((left, right) => left - right).map((index) => {
const confirmedSpan = confirmed[index]
const currentSpan = current[index]
const counterpartSpan = counterpart[index]
if (confirmedSpan === undefined || currentSpan === undefined || counterpartSpan === undefined) {
throw new Error(`gen-translation-brief: span ${index} is unmapped despite alignment`)
}
return {
index,
label: currentSpan.label,
reason: extras.has(index) && confirmedSpan.text === currentSpan.text ? 'first-occurrence' as const : undefined,
confirmedSourceText: confirmedSpan.text,
currentSourceText: currentSpan.text,
counterpartText: counterpartSpan.text,
counterpartStartLine: counterpartSpan.startLine,
}
})
}
interface PlannedBrief {
scope: BriefScope
/** Old + new text of the changed spans, for terminology matching. */
changedText: string
/** Computed counterpart for a mechanical scope, for `--apply`. */
mechanicalResult?: string | undefined
}
/** Choose the narrowest safely mapped granularity for one drifted side. */
function planScope(
sourceLast: string,
sourceCurrent: string,
counterpartCurrent: string,
direction: BriefDirection,
bothDrifted: boolean,
): PlannedBrief {
const wholeChangedText = `${sourceLast}\n${sourceCurrent}`
if (bothDrifted) {
return {
scope: { kind: 'document', reason: 'BOTH sides changed since the pair was last confirmed consistent, so no side is a trustworthy mapping anchor; decide which side owns each divergence.' },
changedText: wholeChangedText,
}
}
const mechanical = computeMechanicalUpdate(sourceLast, sourceCurrent, counterpartCurrent)
if (mechanical !== undefined) {
return { scope: { kind: 'mechanical' }, changedText: wholeChangedText, mechanicalResult: mechanical }
}
for (const [kind, spansOf] of [['units', markdownUnits], ['sections', sectionSpans]] as const) {
const confirmed = spansOf(sourceLast)
const current = spansOf(sourceCurrent)
const counterpart = spansOf(counterpartCurrent)
if (!spansAligned(confirmed, current) || !spansAligned(confirmed, counterpart)) continue
const changed = changedSpanIndices(confirmed, current)
if (changed.length === 0) continue
const changedText = changed.map(index => `${confirmed[index]?.text ?? ''}\n${current[index]?.text ?? ''}`).join('\n')
const rows = relevantTerminologyRows(terminology, direction, changedText)
const occurrence = direction === 'en-to-zh'
? firstOccurrenceContext(sourceLast, sourceCurrent, confirmed, current, rows, new Set(changed))
: { notes: [], extraSpanIndices: [] }
return {
scope: {
kind,
bundles: bundlesFor(changed, occurrence.extraSpanIndices, confirmed, current, counterpart),
firstOccurrenceNotes: occurrence.notes,
},
changedText,
}
}
return {
scope: { kind: 'document', reason: 'Neither fine-grained units nor heading sections align one to one across the last-confirmed source, current source, and current counterpart.' },
changedText: wholeChangedText,
}
}
/** Validate a computed mechanical counterpart and write it. */
function applyMechanical(counterpartPath: string, sourceCurrent: string, result: string): void {
const counterpartBase = basename(counterpartPath)
const sourceBase = counterpartBase.endsWith('.zh.md')
? counterpartBase.replace(/\.zh\.md$/, '.md')
: counterpartBase.replace(/\.md$/, '.zh.md')
const errors = translationStructureDiff(
translationStructureSignature(parseTranslationMarkdown(sourceCurrent), counterpartBase),
translationStructureSignature(parseTranslationMarkdown(result), sourceBase),
)
if (errors.length > 0) {
throw new Error(`gen-translation-brief: computed mechanical update for ${counterpartPath} violates the pair structure: ${errors.join('; ')}`)
}
writeFileSync(join(root, counterpartPath), result)
console.error(`gen-translation-brief: applied code-fence splice to ${counterpartPath}; review the diff, then record the pair.`)
}
/** Render (and under `--apply`, apply) the briefing for one drifted side. */
function briefDirection(pair: PairState, direction: BriefDirection, apply: boolean): string {
const sourceIsEnglish = direction === 'en-to-zh'
const sourcePath = sourceIsEnglish ? pair.anchor : pair.zh
const counterpartPath = sourceIsEnglish ? pair.zh : pair.anchor
const sourceLast = sourceIsEnglish ? pair.enLast : pair.zhLast
const sourceCurrent = readFileSync(join(root, sourcePath), 'utf8')
const counterpartCurrent = readFileSync(join(root, counterpartPath), 'utf8')
const diff = diffTexts(sourceLast, sourceCurrent)
const planned = planScope(sourceLast, sourceCurrent, counterpartCurrent, direction, pair.enDrifted && pair.zhDrifted)
if (apply && planned.mechanicalResult !== undefined) {
applyMechanical(counterpartPath, sourceCurrent, planned.mechanicalResult)
}
return renderTranslationBrief({
sourcePath,
counterpartPath,
direction,
diff,
scope: planned.scope,
terminology: relevantTerminologyRows(terminology, direction, planned.changedText),
})
}
const argv = process.argv.slice(2)
const flags = argv.filter(argument => argument.startsWith('--'))
const unknownFlags = flags.filter(flag => flag !== '--apply')
if (unknownFlags.length > 0) {
console.error(`gen-translation-brief: unknown flag(s): ${unknownFlags.join(', ')} (only --apply is supported)`)
process.exit(2)
}
const applyMode = flags.includes('--apply')
const requested = argv.filter(argument => !argument.startsWith('--')).map(pairAnchorOfArgument)
let anchors: string[]
if (requested.length > 0) {
anchors = [...new Set(requested)].sort()
} else {
const discovered = new Set<string>()
for (const match of globSync('**/*.i18n.yaml', { cwd: root, exclude: TRANSLATION_SCOPE_GLOB_EXCLUDES })) {
const normalized = match.split(sep).join('/')
if (isTranslationScopeFile(normalized)) discovered.add(normalized.replace(/\.i18n\.yaml$/, '.md'))
}
anchors = [...discovered].sort()
}
const briefs: string[] = []
const problems: string[] = []
const skipped: string[] = []
for (const anchor of anchors) {
const pair = loadPair(anchor)
if (typeof pair === 'string') {
if (requested.length > 0) problems.push(pair)
continue
}
if (!pair.enDrifted && !pair.zhDrifted) {
if (requested.length > 0) skipped.push(`${anchor}: pair is consistent with its record — nothing to brief`)
continue
}
if (pair.enDrifted) briefs.push(briefDirection(pair, 'en-to-zh', applyMode))
if (pair.zhDrifted) briefs.push(briefDirection(pair, 'zh-to-en', applyMode))
}
if (problems.length > 0 || skipped.length > 0) {
for (const message of [...problems, ...skipped]) console.error(`gen-translation-brief: ${message}`)
process.exit(2)
}
if (briefs.length === 0) {
console.log('gen-translation-brief: every recorded pair matches its consistency record; nothing to brief.')
process.exit(0)
}
console.log(briefs.join('\n\n---\n\n'))

View File

@@ -19,7 +19,7 @@ export function rawJsDoc(text: string, node: ts.Node): string {
}
/** A dispatch mode, rendered as the badge after an event name in the catalog. */
export type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial'
export type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial' | 'bail'
/**
* Parse a raw JSDoc block into description prose and an optional `@mode`. Prose
@@ -59,7 +59,7 @@ export function parseJsDoc(raw: string): { doc: string; mode: Mode | null; hasMo
}
for (const line of inner) {
const tagLine = line.trimStart()
const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/.exec(tagLine)
const m = /^@mode\s+(emit|waterfall|parallel|serial|bail)\s*$/.exec(tagLine)
if (m) { mode = m[1] as Mode; hasMode = true; flushPara(); inTags = true; continue }
if (/^@mode\b/.test(tagLine)) { hasMode = true; flushPara(); inTags = true; continue }
if (tagLine.startsWith('@')) { flushPara(); inTags = true; continue }

View File

@@ -21,6 +21,24 @@ export interface MarkdownHeadingLine extends MarkdownProseLine {
text: string
}
/** One code block from a parsed Markdown source. */
export interface MarkdownFence {
/** 1-based source line of the opening fence. */
line: number
/** Info-string language (its first word), null on a bare or indented block. */
lang: string | null
/** Full info string (e.g. `ts ignore-check`), '' on a bare or indented block. */
info: string
/** Block body without the fence delimiters. */
code: string
/**
* Whether a closing fence delimiter terminates the block — mdast silently
* closes an unterminated fence at end of file. False on indented
* (non-fenced) blocks, whose end line is code.
*/
closed: boolean
}
/** Parse GitHub-flavored Markdown with the repository's standard extensions. */
export function parseMarkdown(source: string): Nodes {
return fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
@@ -38,6 +56,26 @@ export function visitMarkdown(node: Nodes, visitor: (node: Nodes) => boolean | v
}
}
/**
* Extract every parsed code block with its info string, in document order.
* @param source - Markdown source to scan.
* @returns each block's opening line, language, info string, and body.
*/
export function markdownFences(source: string): MarkdownFence[] {
const lines = source.split('\n')
const fences: MarkdownFence[] = []
visitMarkdown(parseMarkdown(source), (node) => {
if (node.type !== 'code' || node.position === undefined) return
const lang = node.lang ?? null
const meta = node.meta ?? ''
const info = lang === null ? '' : meta === '' ? lang : `${lang} ${meta}`
const endLine = lines[node.position.end.line - 1] ?? ''
const closed = /^ {0,3}(`{3,}|~{3,})\s*$/.test(endLine)
fences.push({ line: node.position.start.line, lang, info, code: node.value, closed })
})
return fences
}
/** Text a reader sees from one Markdown node; raw HTML itself contributes none. */
function renderedText(node: Nodes): string {
if (node.type === 'text' || node.type === 'inlineCode') return node.value
@@ -115,27 +153,22 @@ function hasRenderedTextOutsideComments(raw: string, ranges: readonly ColumnRang
}
/**
* Return source lines outside backtick or tilde fences and HTML comments.
* Return source lines outside code blocks and HTML comments.
* @param source - Markdown source whose prose should be retained verbatim.
* @returns unfenced lines with their original 1-based locations.
*/
export function markdownProseLines(source: string): MarkdownProseLine[] {
let fence: { marker: '`' | '~'; length: number } | undefined
const kept: MarkdownProseLine[] = []
const rawLines = source.split('\n')
const comments = htmlCommentRanges(source, rawLines)
const fenced = new Set<number>()
visitMarkdown(parseMarkdown(source), (node) => {
if (node.type !== 'code' || node.position === undefined) return
for (let line = node.position.start.line; line <= node.position.end.line; line += 1) fenced.add(line)
})
const kept: MarkdownProseLine[] = []
rawLines.forEach((raw, i) => {
const token = /^ {0,3}(`{3,}|~{3,})/.exec(raw)?.[1]
if (token !== undefined) {
const marker = token[0] as '`' | '~'
if (fence === undefined) {
fence = { marker, length: token.length }
} else if (marker === fence.marker && token.length >= fence.length) {
fence = undefined
}
return
}
if (fence === undefined && hasRenderedTextOutsideComments(raw, comments.get(i + 1))) {
if (fenced.has(i + 1)) return
if (hasRenderedTextOutsideComments(raw, comments.get(i + 1))) {
kept.push({ index: i + 1, raw })
}
})

View File

@@ -1,55 +0,0 @@
/**
* Shared fenced-code-block extractor for the Markdown doc gates
* (currently `doc-typecheck.ts`; future Markdown gates can share it). One scanner, per-gate
* classification: each gate maps a fence info string (` ```ts `,
* ` ```yaml ignore-check `, …) to its own kind tag and receives every
* classified block with its 1-based opening-fence line.
*/
import { readFileSync } from 'node:fs'
/** One extracted fenced block, classified by the caller's `classify`. */
export interface Fence<K> {
/** 1-based line of the opening fence. */
line: number
kind: K
code: string
}
/**
* Extract every fenced block of `absPath` whose info string `classify` maps
* to a kind. Blocks classified `null` are skipped (their bodies are still
* consumed, so an unrelated fence can never leak into a tracked one).
*
* @param absPath — absolute path of the Markdown file.
* @param classify — info string (trimmed, e.g. `ts ignore-check`) → kind, or
* null for fences this gate does not track.
* @returns the classified blocks in document order.
*/
export function extractFences<K>(absPath: string, classify: (info: string) => K | null): Fence<K>[] {
const lines = readFileSync(absPath, 'utf8').split('\n')
const blocks: Fence<K>[] = []
let open: { line: number; kind: K; body: string[] } | null = null
let skipping = false
lines.forEach((raw, i) => {
const fence = /^```(\s*)(\S.*)?$/.exec(raw)
if (!fence) {
if (open) open.body.push(raw)
return
}
if (open) {
blocks.push({ line: open.line, kind: open.kind, code: open.body.join('\n') })
open = null
return
}
if (skipping) {
skipping = false
return
}
const kind = classify((fence[2] ?? '').trim())
if (kind !== null) open = { line: i + 1, kind, body: [] }
else skipping = true
})
return blocks
}

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env node
/**
* Temporary branch-convergence command for canonical packed session fixtures.
*
* @see ../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md
*/
import { writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { inspectSessionFixtureLayouts } from './session-fixture-layout.ts'
if (process.argv.length > 2) throw new Error('migrate:packed-session-fixtures takes no arguments')
const root = resolve(import.meta.dirname, '..')
const fixtures = inspectSessionFixtureLayouts(root)
const changed = fixtures.filter(fixture => fixture.source !== fixture.canonical)
for (const fixture of changed) {
writeFileSync(resolve(root, fixture.path), fixture.canonical)
console.log(fixture.path)
}
console.log(`packed session fixtures: ${changed.length} rewritten, ${fixtures.length} inspected`)

View File

@@ -2,19 +2,23 @@
import {
globSync,
readFileSync,
readdirSync,
readFileSync,
statSync,
} from 'node:fs'
import { availableParallelism } from 'node:os'
import { dirname, relative, resolve, sep } from 'node:path'
import { parseArgs } from 'node:util'
import { publint, type Message, type PackFile } from 'publint'
import { formatMessage } from 'publint/utils'
const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY'
const repositoryRoot = resolve(import.meta.dirname, '..')
const options = parseOptions(process.argv.slice(2))
const packagesRoot = resolve(options.get('--packages-root') ?? repositoryRoot)
const { values: options } = parseArgs({
args: process.argv.slice(2),
options: { 'packages-root': { type: 'string' } },
})
const packagesRoot = resolve(options['packages-root'] ?? repositoryRoot)
interface PackageTarget {
path: string
@@ -88,7 +92,12 @@ function publicationFiles(target: PackageTarget): PackFile[] {
function addPath(path: string, paths: Set<string>): void {
const stat = statSync(path)
if (stat.isDirectory()) {
for (const entry of readdirSync(path)) addPath(resolve(path, entry), paths)
// readdirSync, not globSync: `**/*` skips dot-prefixed segments, but npm
// pack publishes dotfiles inside included directories, and this view must
// match what npm publishes.
for (const entry of readdirSync(path, { recursive: true, withFileTypes: true })) {
if (entry.isFile()) paths.add(resolve(entry.parentPath, entry.name))
}
} else if (stat.isFile()) {
paths.add(path)
}
@@ -144,20 +153,6 @@ function printResult(result: PublintResult): void {
if (result.status === 'passed' && result.messages.length === 0) console.log('All good!')
}
function parseOptions(args: string[]): Map<string, string> {
const parsed = new Map<string, string>()
for (let index = 0; index < args.length; index += 2) {
const name = args[index]
const value = args[index + 1]
if (name !== '--packages-root' || value === undefined || value.startsWith('--')) {
throw new Error(`publint-all: expected [--packages-root PATH], got ${JSON.stringify(args)}.`)
}
if (parsed.has(name)) throw new Error(`publint-all: duplicate option ${name}.`)
parsed.set(name, value)
}
return parsed
}
const packages = workspacePackages()
const concurrency = publintConcurrency(packages.length)
console.log(`publint-all: linting ${packages.length} package(s) with ${concurrency} worker(s).`)

View File

@@ -21,6 +21,11 @@ export interface ReferenceViolation {
ref: string
}
/** Whether a repository path is frozen Agent Note history, not evolving source prose. */
export function isArchivedAgentNotePath(path: string): boolean {
return path.replaceAll('\\', '/').startsWith('.agents/notes/archived/')
}
/**
* Expand repository-relative globs and deduplicate symlinked files.
* @param root - absolute repository root.

View File

@@ -454,6 +454,7 @@ function docSyncLeafGates(options: {
pnpmScript('mermaid', 'verify-mermaid'),
pnpmScript('agent-note-classification', 'verify-agent-note-classification', { label: 'agent note classification' }),
pnpmScript('agent-note-format', 'verify-agent-note-format', { label: 'agent note format' }),
pnpmScript('archived-agent-notes', 'verify-archived-agent-notes', { label: 'archived agent notes' }),
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' }),

View File

@@ -0,0 +1,17 @@
/** Repository-wide canonical-layout check for committed session fixtures. */
import { resolve } from 'node:path'
import { expect, it } from 'vitest'
import { inspectSessionFixtureLayouts } from './session-fixture-layout.ts'
const root = resolve(import.meta.dirname, '..')
it('keeps every session-format JSONL fixture in canonical packed layout', () => {
const nonCanonical = inspectSessionFixtureLayouts(root)
.filter(fixture => fixture.source !== fixture.canonical)
.map(fixture => fixture.path)
expect(
nonCanonical,
'Run `pnpm run migrate:packed-session-fixtures` and commit the mechanical fixture rewrite.',
).toEqual([])
})

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import { decodeStorageRecord, type SessionEvent } from '@deepseek-ai/dsh-session'
import { canonicalSessionFixture } from './session-fixture-layout.ts'
const HEADER = ' {"type":"session","version":0,"id":"fixture","createdAt":1,"delegationDepth":0} '
function chunkRun(): SessionEvent[] {
return Array.from({ length: 4 }, (_, index) => ({
type: 'assistant/chunk',
seq: index,
time: 10 + index,
data: {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: `part-${index}` },
},
}))
}
function unpackedFixture(): string {
return [HEADER, ...chunkRun().map(event => JSON.stringify(event)), ''].join('\n')
}
function decodedBody(content: string): SessionEvent[] {
return content.trimEnd().split('\n').slice(1)
.flatMap(line => decodeStorageRecord(JSON.parse(line) as unknown))
}
describe('canonicalSessionFixture', () => {
it('preserves the header line and packs an unpacked event run losslessly', () => {
const canonical = canonicalSessionFixture(unpackedFixture(), 'fixture.jsonl')
expect(canonical).toBeDefined()
expect(canonical?.split('\n')[0]).toBe(HEADER)
expect(JSON.parse(canonical?.split('\n')[1] ?? '{}')).toMatchObject({ type: 'text-chunks' })
expect(decodedBody(canonical ?? '')).toStrictEqual(chunkRun())
})
it('ignores JSONL whose first record is not a session header', () => {
expect(canonicalSessionFixture('{"type":"session_event"}\n{"value":1}\n')).toBeUndefined()
})
it('is idempotent for an already packed fixture', () => {
const packed = canonicalSessionFixture(unpackedFixture())
expect(packed).toBeDefined()
expect(canonicalSessionFixture(packed ?? '')).toBe(packed)
})
it('fails loud on malformed records after a session header', () => {
expect(() => canonicalSessionFixture(`${HEADER}\n{not-json}\n`, 'broken.jsonl'))
.toThrow(/broken\.jsonl:2: invalid JSON/)
})
it('labels malformed packed rows with the fixture path and line', () => {
expect(() => canonicalSessionFixture(`${HEADER}\n{"type":"text-chunks"}\n`, 'broken.jsonl'))
.toThrow(/broken\.jsonl:2: invalid session storage record: malformed text-chunks storage row/)
})
})

View File

@@ -0,0 +1,128 @@
/** Canonical packed-row layout helpers for repository session fixtures. */
import { deepStrictEqual } from 'node:assert'
import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { decodeStorageRecord, packChunkRuns, type SessionEvent } from '@deepseek-ai/dsh-session'
/** One repository session fixture and its canonical packed representation. */
export interface SessionFixtureLayout {
/** Repository-relative path with `/` separators. */
path: string
/** Current fixture bytes decoded as UTF-8. */
source: string
/** Canonical packed fixture bytes. */
canonical: string
}
interface RecordLine {
line: number
text: string
}
function recordLines(content: string): RecordLine[] {
return content.split(/\r?\n/).flatMap((text, index) => (
text.trim().length === 0 ? [] : [{ line: index + 1, text }]
))
}
function parseRecord(line: RecordLine, label: string): unknown {
try {
return JSON.parse(line.text) as unknown
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
throw new Error(`${label}:${line.line}: invalid JSON: ${detail}`, { cause: error })
}
}
function isSessionHeader(value: unknown): boolean {
return value !== null && typeof value === 'object' && (value as { type?: unknown }).type === 'session'
}
function decodeBody(lines: readonly RecordLine[], label: string): SessionEvent[] {
return lines.flatMap((line) => {
const record = parseRecord(line, label)
try {
return decodeStorageRecord(record)
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
throw new Error(`${label}:${line.line}: invalid session storage record: ${detail}`, { cause: error })
}
})
}
function renderFixture(headerLine: string, events: readonly SessionEvent[]): string {
return [
headerLine,
...packChunkRuns(events).map(record => JSON.stringify(record)),
'',
].join('\n')
}
/**
* Canonicalize one JSONL document when its first record is a session header.
* The header line remains byte-identical; body records decode to logical events
* and re-encode with {@link packChunkRuns}. Non-session JSONL returns undefined.
*
* @param content - JSONL source text.
* @param label - path-like diagnostic label.
* @returns Canonical text for a session fixture, otherwise undefined.
*/
export function canonicalSessionFixture(content: string, label = '<session-fixture>'): string | undefined {
const lines = recordLines(content)
const header = lines[0]
if (header === undefined) return undefined
let headerValue: unknown
try {
headerValue = JSON.parse(header.text) as unknown
} catch {
return undefined
}
if (!isSessionHeader(headerValue)) return undefined
const events = decodeBody(lines.slice(1), label)
const canonical = renderFixture(header.text, events)
const canonicalLines = recordLines(canonical)
const decoded = decodeBody(canonicalLines.slice(1), label)
try {
deepStrictEqual(decoded, events)
} catch (error) {
throw new Error(`${label}: packed rewrite changed the decoded event stream`, { cause: error })
}
if (renderFixture(header.text, decoded) !== canonical) {
throw new Error(`${label}: packed rewrite is not idempotent`)
}
return canonical
}
/**
* Discover tracked and unignored untracked JSONL files through Git.
*
* @param root - repository root.
* @returns Stable repository-relative paths.
*/
function discoverJsonlFiles(root: string): string[] {
return execFileSync(
'git',
['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '*.jsonl'],
{ cwd: root, encoding: 'utf8' },
).split('\0')
.filter(path => path.length > 0 && existsSync(resolve(root, path)))
.sort()
}
/**
* Inspect every repository JSONL whose first record is a session header.
*
* @param root - repository root.
* @returns Session fixtures with current and canonical text.
*/
export function inspectSessionFixtureLayouts(root: string): SessionFixtureLayout[] {
return discoverJsonlFiles(root).flatMap((path) => {
const source = readFileSync(resolve(root, path), 'utf8')
const canonical = canonicalSessionFixture(source, path)
return canonical === undefined ? [] : [{ path, source, canonical }]
})
}

View File

@@ -151,7 +151,11 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
)
if prompt == CODE_PROMPT:
assert_advertised_tool(body, "run_code")
return tool_call_chunks("call-code-worker", "run_code", {"code": "return 6 * 7"})
return tool_call_chunks(
"call-code-worker",
"run_code",
{"code": "return 6 * 7", "description": "Compute the smoke value"},
)
if prompt == WORKFLOW_PROMPT:
assert_advertised_tool(body, "workflow")
return tool_call_chunks(

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,283 @@
/** Regression tests for the minimal-update briefing assembly. */
import { describe, expect, it } from 'vitest'
import {
changedSpanIndices,
computeMechanicalUpdate,
firstOccurrenceContext,
markdownUnits,
parseTerminologyRows,
relevantTerminologyRows,
renderTranslationBrief,
sectionSpans,
spansAligned,
termOffsets,
} from './translation-brief.ts'
const DOC = [
'Preamble line.',
'',
'# Title',
'',
'Intro paragraph.',
'',
'## First',
'',
'First body.',
'',
'```ts',
'const value = 1',
'```',
'',
'## Second',
'',
'| A | B |',
'|---|---|',
'| 1 | 2 |',
'',
'- item one',
'- item two',
].join('\n')
describe('markdown spans', () => {
it('lists units with container-scoped kinds in document order', () => {
const kinds = markdownUnits(DOC).map(span => span.kind)
expect(kinds).toEqual([
'root.0:paragraph',
'root.1:heading:1',
'root.2:paragraph',
'root.3:heading:2',
'root.4:paragraph',
'root.5:code',
'root.6:heading:2',
'root.7.0:tableRow',
'root.7.1:tableRow',
'root.8.0:listItem',
'root.8.1:listItem',
])
})
it('lists heading sections with a preamble span and heading labels', () => {
const sections = sectionSpans(DOC)
expect(sections.map(span => span.label)).toEqual([
'(preamble before the first heading)',
'Title',
'First',
'Second',
])
expect(sections[0]).toMatchObject({ startLine: 1, endLine: 2 })
expect(sections[2]).toMatchObject({ startLine: 7, endLine: 14 })
})
it('labels units by their node type', () => {
const units = markdownUnits(DOC)
expect(units[0]!.label).toBe('paragraph')
expect(units[1]!.label).toBe('heading')
expect(units[7]!.label).toBe('tableRow')
})
it('aligns sections by depth only, so translated heading text still maps', () => {
const zh = DOC.replace('## First', '## 第一节').replace('## Second', '## 第二节').replace('# Title', '# 标题')
expect(spansAligned(sectionSpans(DOC), sectionSpans(zh))).toBe(true)
})
it('aligns span lists only on equal non-empty kind sequences', () => {
const zh = DOC.replace('First body.', '第一段。').replace('item one', '第一项').replace('Intro paragraph.', '导语。')
expect(spansAligned(markdownUnits(DOC), markdownUnits(zh))).toBe(true)
const reshaped = DOC.replace('- item one\n- item two', 'merged paragraph')
expect(spansAligned(markdownUnits(DOC), markdownUnits(reshaped))).toBe(false)
expect(spansAligned([], [])).toBe(false)
})
it('reports the indices whose text changed', () => {
const edited = DOC.replace('First body.', 'First body, revised.').replace('| 1 | 2 |', '| 1 | 3 |')
expect(changedSpanIndices(markdownUnits(DOC), markdownUnits(edited))).toEqual([4, 8])
})
})
describe('mechanical code updates', () => {
const en = '# T\n\nProse.\n\n```sh\nrun one\n```\n'
const zh = '# T\n\n中文。\n\n```sh\nrun one\n```\n'
it('splices a fence-only edit into the counterpart', () => {
const edited = en.replace('run one', 'run two')
expect(computeMechanicalUpdate(en, edited, zh)).toBe(zh.replace('run one', 'run two'))
})
it('refuses when prose changed too', () => {
const edited = en.replace('Prose.', 'Prose!').replace('run one', 'run two')
expect(computeMechanicalUpdate(en, edited, zh)).toBeUndefined()
})
it('refuses when the counterpart fences already diverge from last-confirmed', () => {
const edited = en.replace('run one', 'run two')
expect(computeMechanicalUpdate(en, edited, zh.replace('run one', 'run stale'))).toBeUndefined()
})
it('refuses when fence counts differ or nothing changed', () => {
expect(computeMechanicalUpdate(en, `${en}\n\`\`\`sh\nextra\n\`\`\`\n`, zh)).toBeUndefined()
expect(computeMechanicalUpdate(en, en, zh)).toBeUndefined()
})
})
const TERMINOLOGY = [
'| English | 中文 | 首次出现 | 不要译作 | 备注 |',
'|---|---|---|---|---|',
'| agent | agent | agent智能体 | 智能体 | |',
'| session log | 会话日志 | | 会话记录 | |',
'| gate | 门禁 | | | |',
'| registry | 注册表 | | | |',
].join('\n')
describe('terminology', () => {
it('parses data rows and skips the header and separator', () => {
const rows = parseTerminologyRows(TERMINOLOGY)
expect(rows.map(row => row.english)).toEqual(['agent', 'session log', 'gate', 'registry'])
expect(rows[0]).toMatchObject({ chinese: 'agent', first: 'agent智能体' })
})
it('matches English terms on word boundaries with plural inflections', () => {
expect(termOffsets('two agents met', 'agent', true)).toEqual([4])
expect(termOffsets('two registries', 'registry', true)).toEqual([4])
expect(termOffsets('reagents', 'agent', true)).toEqual([])
expect(termOffsets('', 'agent', true)).toEqual([])
})
it('selects rows for the changed text per direction', () => {
expect(relevantTerminologyRows(TERMINOLOGY, 'en-to-zh', 'All agents write a session log.').map(row => row.english))
.toEqual(['agent', 'session log'])
expect(relevantTerminologyRows(TERMINOLOGY, 'zh-to-en', '门禁在提交时运行。').map(row => row.english))
.toEqual(['gate'])
expect(relevantTerminologyRows(TERMINOLOGY, 'en-to-zh', 'delegate the work')).toEqual([])
})
})
describe('first-occurrence tracking', () => {
const before = '# T\n\nAlpha paragraph.\n\nThe agent runs.\n'
const after = '# T\n\nAlpha paragraph with an agent.\n\nThe agent runs.\n'
const rows = parseTerminologyRows(TERMINOLOGY).filter(row => row.english === 'agent')
it('flags a moved first occurrence and pulls the vacated span in', () => {
const context = firstOccurrenceContext(
before, after, markdownUnits(before), markdownUnits(after), rows, new Set([1]),
)
expect(context.notes).toHaveLength(1)
expect(context.notes[0]).toContain('moved from #2 to #1')
expect(context.extraSpanIndices).toEqual([2])
})
it('stays silent when the first occurrence does not move', () => {
const unmoved = before.replace('Alpha paragraph.', 'Alpha paragraph, revised.')
const context = firstOccurrenceContext(
before, unmoved, markdownUnits(before), markdownUnits(unmoved), rows, new Set([1]),
)
expect(context.notes).toEqual([])
expect(context.extraSpanIndices).toEqual([])
})
it('ignores rows without a first-occurrence rendering', () => {
const bare = parseTerminologyRows(TERMINOLOGY).filter(row => row.english === 'gate')
const withGate = after.replace('The agent runs.', 'The gate runs.')
const context = firstOccurrenceContext(
before, withGate, markdownUnits(before), markdownUnits(withGate), bare, new Set([2]),
)
expect(context.notes).toEqual([])
})
})
describe('brief rendering', () => {
const base = {
sourcePath: 'docs/foo.md',
counterpartPath: 'docs/foo.zh.md',
direction: 'en-to-zh' as const,
diff: '@@ -5 +5 @@\n-old text about the agent\n+new text about the agent',
terminology: relevantTerminologyRows(TERMINOLOGY, 'en-to-zh', 'the agent'),
}
const bundle = {
index: 4,
label: 'paragraph',
confirmedSourceText: 'old text about the agent\n',
currentSourceText: 'new text about the agent\n',
counterpartText: '关于 agent 的旧文本\n',
counterpartStartLine: 9,
}
it('renders unit bundles with three-way context and line anchors', () => {
const brief = renderTranslationBrief({
...base,
scope: { kind: 'units', bundles: [bundle], firstOccurrenceNotes: ['agent: the document-wide first occurrence moved from #2 to #1; the agent智能体 form moves with it (later occurrences drop the annotation).'] },
})
expect(brief).toContain('# Translation update briefing: docs/foo.md')
expect(brief).toContain('## Changed units')
expect(brief).toContain('### #4 paragraph — counterpart at docs/foo.zh.md:9')
expect(brief).toContain('Last-confirmed English:')
expect(brief).toContain('Current Chinese (bring this along):')
expect(brief).toContain('## First-occurrence notes')
expect(brief).toContain('agent智能体')
expect(brief).toContain('首次出现 annotations attach to the document-wide first occurrence only')
expect(brief).toContain('verify-translation-pairing --write docs/foo.md')
})
it('marks first-occurrence bundles and omits their unchanged confirmed text', () => {
const brief = renderTranslationBrief({
...base,
scope: {
kind: 'units',
bundles: [{ ...bundle, reason: 'first-occurrence', confirmedSourceText: bundle.currentSourceText }],
firstOccurrenceNotes: [],
},
})
expect(brief).toContain('unchanged; included for a first-occurrence move')
expect(brief).not.toContain('Last-confirmed English:')
})
it('renders the mechanical scope with the --apply command', () => {
const brief = renderTranslationBrief({ ...base, scope: { kind: 'mechanical' } })
expect(brief).toContain('## Mechanical update — no translation judgment involved')
expect(brief).toContain('gen-translation-brief --apply docs/foo.md')
expect(brief).not.toContain('## Changed units')
})
it('renders the section fallback under its own heading', () => {
const brief = renderTranslationBrief({
...base,
scope: { kind: 'sections', bundles: [bundle], firstOccurrenceNotes: [] },
})
expect(brief).toContain('## Changed sections')
expect(brief).toContain('fine-grained units do not align')
})
it('renders the document fallback with its reason and no bundles', () => {
const brief = renderTranslationBrief({
...base,
scope: { kind: 'document', reason: 'BOTH sides changed since the pair was last confirmed consistent, so no side is a trustworthy mapping anchor; decide which side owns each divergence.' },
})
expect(brief).toContain('## Whole-document update required')
expect(brief).toContain('BOTH sides changed')
expect(brief).toContain('locate the affected regions yourself')
})
it('renders the English-target digest for zh-to-en updates', () => {
const brief = renderTranslationBrief({
...base,
direction: 'zh-to-en',
sourcePath: 'docs/foo.zh.md',
counterpartPath: 'docs/foo.md',
scope: { kind: 'units', bundles: [bundle], firstOccurrenceNotes: [] },
})
expect(brief).toContain('exactly what the new Chinese states')
expect(brief).toContain('verify-translation-pairing --write docs/foo.md')
})
it('grows bundle fences past tilde runs in the text', () => {
const brief = renderTranslationBrief({
...base,
scope: {
kind: 'units',
bundles: [{ ...bundle, counterpartText: '~~~~\ninner\n~~~~\n' }],
firstOccurrenceNotes: [],
},
})
expect(brief).toContain('~~~~~markdown')
})
})

View File

@@ -0,0 +1,513 @@
/**
* Pure assembly of the minimal-update briefing for one out-of-sync
* translation pair: the authored side's changes since the last confirmed
* state at the narrowest safely mapped granularity (code-fence-only splice,
* changed Markdown units, heading sections, whole document), the terminology
* rows those changes touch, first-occurrence movement notes, and a digest of
* the binding update rules. The unit mapping, mechanical code splice, and
* first-occurrence tracking adopt the planner mechanics validated in the
* incremental-pipeline work (PR #684). The CLI wrapper is
* `scripts/gen-translation-brief.ts`; the workflow that consumes the
* briefing is `.agents/skills/dsh-translate-docs/SKILL.md`.
*/
import type { Nodes } from 'mdast'
import { parseTranslationMarkdown } from './translation-pairing.ts'
/** One block-level span of a Markdown document, in document order. */
export interface MarkdownSpan {
/** Position in the span list; briefing ids derive from it. */
index: number
/**
* Structural kind compared for alignment, language-neutral: container path
* plus node type for units (`root.3:tableRow`), depth for sections (`section:2`).
*/
kind: string
/** Reader-facing label: heading text for sections, node type for units. */
label: string
/** 1-based first source line. */
startLine: number
/** 1-based last source line. */
endLine: number
/** The span's text, trailing newline normalized to exactly one. */
text: string
}
function linesOf(markdown: string): string[] {
const lines = markdown.replaceAll('\r\n', '\n').split('\n')
if (lines.at(-1) === '') lines.pop()
return lines
}
function sliceLines(lines: string[], startLine: number, endLine: number): string {
return `${lines.slice(startLine - 1, endLine).join('\n')}\n`
}
/**
* List a document's translation units: the outermost block nodes a minimal
* update can replace independently. Headings, paragraphs, code fences, table
* rows, list items, block quotes, HTML blocks, thematic breaks, and link
* definitions are units; the container path is part of the kind so kind
* sequences only align when container membership also aligns.
*
* @param markdown - Document text.
* @returns Units in document order.
*/
export function markdownUnits(markdown: string): MarkdownSpan[] {
const positions: Array<{ kind: string; label: string; startLine: number; endLine: number }> = []
const visit = (node: Nodes, path: string): void => {
let kind: string | undefined
switch (node.type) {
case 'heading':
kind = `${path}:heading:${node.depth}`
break
case 'paragraph':
case 'code':
case 'tableRow':
case 'listItem':
case 'blockquote':
case 'html':
case 'thematicBreak':
case 'definition':
kind = `${path}:${node.type}`
break
default:
break
}
if (kind !== undefined && node.position !== undefined) {
positions.push({ kind, label: node.type, startLine: node.position.start.line, endLine: node.position.end.line })
return
}
if ('children' in node) for (const [index, child] of node.children.entries()) visit(child, `${path}.${index}`)
}
visit(parseTranslationMarkdown(markdown), 'root')
positions.sort((left, right) => left.startLine - right.startLine)
const lines = linesOf(markdown)
return positions.map((position, index) => ({
index,
...position,
text: sliceLines(lines, position.startLine, position.endLine),
}))
}
/**
* List a document's heading-delimited sections, including a leading
* `preamble` span when content precedes the first heading.
*
* @param markdown - Document text.
* @returns Sections in document order.
*/
export function sectionSpans(markdown: string): MarkdownSpan[] {
const headings: Array<{ depth: number; line: number; label: string }> = []
const visit = (node: Nodes): void => {
if (node.type === 'heading' && node.position !== undefined) {
let label = ''
const collect = (child: Nodes): void => {
if ('value' in child && typeof child.value === 'string') label += child.value
if ('children' in child) for (const grandchild of child.children) collect(grandchild)
}
for (const child of node.children) collect(child)
headings.push({ depth: node.depth, line: node.position.start.line, label })
}
if ('children' in node) for (const child of node.children) visit(child)
}
visit(parseTranslationMarkdown(markdown))
headings.sort((left, right) => left.line - right.line)
const lines = linesOf(markdown)
const spans: MarkdownSpan[] = []
const firstHeadingLine = headings[0]?.line ?? lines.length + 1
if (firstHeadingLine > 1) {
spans.push({ index: 0, kind: 'preamble', label: '(preamble before the first heading)', startLine: 1, endLine: firstHeadingLine - 1, text: sliceLines(lines, 1, firstHeadingLine - 1) })
}
for (const [order, heading] of headings.entries()) {
const endLine = (headings[order + 1]?.line ?? lines.length + 1) - 1
spans.push({
index: spans.length,
// Depth only: heading TEXT is translated across a pair, so it cannot
// participate in cross-language alignment.
kind: `section:${heading.depth}`,
label: heading.label === '' ? '(untitled section)' : heading.label,
startLine: heading.line,
endLine,
text: sliceLines(lines, heading.line, endLine),
})
}
return spans
}
/**
* Whether two span lists map one to one: same non-zero length and the same
* kind at every position.
*
* @param left - One document's spans.
* @param right - The other document's spans.
* @returns True when index-wise mapping is sound.
*/
export function spansAligned(left: MarkdownSpan[], right: MarkdownSpan[]): boolean {
return left.length > 0
&& left.length === right.length
&& left.every((span, index) => span.kind === right[index]?.kind)
}
/**
* Indices whose text differs between two aligned span lists.
*
* @param before - Spans of the earlier state.
* @param after - Spans of the later state, aligned with `before`.
* @returns Ascending changed indices.
*/
export function changedSpanIndices(before: MarkdownSpan[], after: MarkdownSpan[]): number[] {
return before.filter((span, index) => span.text !== after[index]?.text).map(span => span.index)
}
function codeSpansOf(markdown: string): MarkdownSpan[] {
return markdownUnits(markdown).filter(span => span.kind.endsWith(':code'))
.map((span, index) => ({ ...span, index }))
}
function replaceSpanTexts(markdown: string, spans: MarkdownSpan[], replacements: Map<number, string>): string {
const lines = linesOf(markdown)
for (const [index, replacement] of [...replacements.entries()].sort((left, right) => right[0] - left[0])) {
const span = spans[index]
if (span === undefined) throw new Error(`translation brief: unknown replacement span ${index}`)
lines.splice(span.startLine - 1, span.endLine - span.startLine + 1, ...linesOf(replacement))
}
return `${lines.join('\n')}\n`
}
function maskCodeSpans(markdown: string, spans: MarkdownSpan[]): string {
return replaceSpanTexts(markdown, spans, new Map(spans.map(span => [span.index, `DSH_TRANSLATION_CODE_${span.index}\n`])))
}
/**
* Compute the counterpart update for a change confined to fenced code
* blocks. Fences are byte-identical across a pair, so when the source's
* prose is untouched and the counterpart's fences match the last-confirmed
* source, splicing the edited fences into the counterpart is the complete
* update — no translation judgment is involved.
*
* @param confirmedSource - The changed side's last-confirmed text.
* @param currentSource - The changed side's current text.
* @param counterpart - The other side's current text.
* @returns The updated counterpart, or undefined when the change is not code-only.
*/
export function computeMechanicalUpdate(confirmedSource: string, currentSource: string, counterpart: string): string | undefined {
const confirmed = codeSpansOf(confirmedSource)
const current = codeSpansOf(currentSource)
const target = codeSpansOf(counterpart)
if (confirmed.length === 0 || confirmed.length !== current.length || confirmed.length !== target.length) return undefined
if (maskCodeSpans(confirmedSource, confirmed) !== maskCodeSpans(currentSource, current)) return undefined
if (confirmed.some((span, index) => span.text !== target[index]?.text)) return undefined
const changed = current.filter((span, index) => span.text !== confirmed[index]?.text)
if (changed.length === 0) return undefined
return replaceSpanTexts(counterpart, target, new Map(changed.map(span => [span.index, span.text])))
}
/** One parsed terminology-table data row. */
export interface TerminologyRow {
english: string
chinese: string
/** The 首次出现 cell (first-occurrence rendering), possibly empty. */
first: string
/** The verbatim table row. */
line: string
}
/** Strip Markdown emphasis and code markers from a terminology cell. */
function plainTerm(cell: string): string {
return cell.replaceAll('`', '').replaceAll('**', '').trim()
}
/**
* Parse the data rows of the terminology table.
*
* @param terminology - Full `docs/i18n/terminology.md` contents.
* @returns Rows in table order.
*/
export function parseTerminologyRows(terminology: string): TerminologyRow[] {
const rows: TerminologyRow[] = []
for (const line of terminology.split('\n')) {
if (!line.startsWith('|')) continue
if (/^\|[\s:|-]+\|$/.test(line)) continue
const cells = line.split('|').map(cell => cell.trim())
const english = plainTerm(cells[1] ?? '')
if (english === '' || english === 'English') continue
rows.push({ english, chinese: plainTerm(cells[2] ?? ''), first: plainTerm(cells[3] ?? ''), line })
}
return rows
}
/**
* Character offsets of a term's occurrences. English word-like terms match
* on word boundaries and accept plural inflections (`agents`, `registries`);
* other terms match as case-insensitive substrings.
*
* @param text - Text to search.
* @param term - The term to find.
* @param englishInflections - Whether to accept English plural forms.
* @returns Ascending match offsets.
*/
export function termOffsets(text: string, term: string, englishInflections = false): number[] {
if (term === '') return []
const escape = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const wordLike = /^[A-Za-z0-9][A-Za-z0-9 ._-]*[A-Za-z0-9]$/.test(term)
const inflected = englishInflections && wordLike
? /[^aeiou]y$/i.test(term)
? `${escape(term.slice(0, -1))}(?:y|ies)`
: `${escape(term)}(?:s|es)?`
: escape(term)
const expression = new RegExp(wordLike ? `(?<![A-Za-z0-9_])${inflected}(?![A-Za-z0-9_])` : inflected, 'gi')
return [...text.matchAll(expression)].map(match => match.index)
}
/** The two update directions a pair supports. */
export type BriefDirection = 'en-to-zh' | 'zh-to-en'
/** Whether a row's source-language term occurs in the given text. */
function rowOccurs(row: TerminologyRow, direction: BriefDirection, text: string): boolean {
const terms = direction === 'en-to-zh' ? [row.english] : [row.first, row.chinese].filter(term => /[一-鿿]/.test(term))
return terms.some(term => termOffsets(text, term, direction === 'en-to-zh').length > 0)
}
/**
* Select the terminology rows whose source-language term occurs in the
* changed text (old and new states combined).
*
* @param terminology - Full `docs/i18n/terminology.md` contents.
* @param direction - Update direction; decides which columns to match.
* @param changedText - Concatenated old and new text of the changed spans.
* @returns Matched rows in table order.
*/
export function relevantTerminologyRows(terminology: string, direction: BriefDirection, changedText: string): TerminologyRow[] {
return parseTerminologyRows(terminology).filter(row => rowOccurs(row, direction, changedText))
}
function lineAtOffset(text: string, offset: number): number {
return text.slice(0, offset).split('\n').length
}
function spanIndexAtOffset(text: string, spans: MarkdownSpan[], offset: number | undefined): number | undefined {
if (offset === undefined) return undefined
const line = lineAtOffset(text, offset)
return spans.find(span => line >= span.startLine && line <= span.endLine)?.index
}
/** First-occurrence guidance computed for a Chinese-target update. */
export interface FirstOccurrenceContext {
/** Human-readable notes for the briefing. */
notes: string[]
/** Unchanged span indices that must join the briefing because a first occurrence moved into or out of them. */
extraSpanIndices: number[]
}
/**
* Track document-wide first occurrences of the relevant English terms. The
* 首次出现 rendering attaches to a term's first occurrence, so when an edit
* moves that occurrence across spans, both the old and new spans need
* counterpart edits even when only one of them changed.
*
* @param confirmedSource - Last-confirmed English text.
* @param currentSource - Current English text.
* @param confirmedSpans - Spans of the last-confirmed English text.
* @param currentSpans - Spans of the current English text, aligned with `confirmedSpans`.
* @param rows - The relevant terminology rows.
* @param changed - Span indices already in the briefing.
* @returns Notes and extra span indices to include.
*/
export function firstOccurrenceContext(
confirmedSource: string,
currentSource: string,
confirmedSpans: MarkdownSpan[],
currentSpans: MarkdownSpan[],
rows: TerminologyRow[],
changed: Set<number>,
): FirstOccurrenceContext {
const notes: string[] = []
const extra = new Set<number>()
for (const row of rows) {
if (row.first === '') continue
const oldIndex = spanIndexAtOffset(confirmedSource, confirmedSpans, termOffsets(confirmedSource, row.english, true)[0])
const newIndex = spanIndexAtOffset(currentSource, currentSpans, termOffsets(currentSource, row.english, true)[0])
if (oldIndex === newIndex) continue
for (const index of [oldIndex, newIndex]) {
if (index !== undefined && !changed.has(index)) extra.add(index)
}
notes.push(`${row.english}: the document-wide first occurrence moved from ${oldIndex === undefined ? 'absent' : `#${oldIndex}`} to ${newIndex === undefined ? 'absent' : `#${newIndex}`}; the ${row.first} form moves with it (later occurrences drop the annotation).`)
}
return { notes, extraSpanIndices: [...extra].sort((left, right) => left - right) }
}
/** Smallest fence of `mark` characters that safely wraps `body`. */
function fenceFor(body: string, mark: '`' | '~'): string {
let longest = 2
for (const line of body.split('\n')) {
const run = new RegExp(`^\\s*(${mark === '`' ? '`' : '~'}{3,})`).exec(line)
if (run?.[1] !== undefined && run[1].length > longest) longest = run[1].length
}
return mark.repeat(longest + 1)
}
/** One changed (or first-occurrence) span with its three-way context. */
export interface BriefBundle {
/** Span index shared by the aligned documents. */
index: number
/** Human label: heading text or node type. */
label: string
/** Why the bundle is present when its source text did not change. */
reason?: 'first-occurrence' | undefined
confirmedSourceText: string
currentSourceText: string
counterpartText: string
/** 1-based line the counterpart span starts on. */
counterpartStartLine: number
}
/** The granularities a briefing can map the change at, narrowest first. */
export type BriefScope =
| { kind: 'mechanical' }
| { kind: 'units'; bundles: BriefBundle[]; firstOccurrenceNotes: string[] }
| { kind: 'sections'; bundles: BriefBundle[]; firstOccurrenceNotes: string[] }
| { kind: 'document'; reason: string }
/** Inputs for rendering one pair's briefing. */
export interface TranslationBriefInput {
/** Repo-relative path of the side that changed. */
sourcePath: string
/** Repo-relative path of the counterpart to update. */
counterpartPath: string
direction: BriefDirection
/** Unified diff of the changed side, last-confirmed to current. */
diff: string
scope: BriefScope
terminology: TerminologyRow[]
}
const ZH_TARGET_DIGEST = [
'- Edit ONLY what the change requires; preserve the reviewed phrasing of everything unchanged.',
'- Nothing added, nothing dropped: the Chinese must state exactly what the new English states.',
'- Write natural institutional technical Chinese, not word-by-word gloss; terse stays terse.',
'- Code fences byte-identical to the English side, comments included; inline code spans verbatim.',
'- Relative links keep the `.md` target; only the switcher line links `.zh.md`.',
'- Structure mirrors the counterpart: heading depths and order, list kinds and item counts, table rows and columns.',
'- 首次出现 annotations attach to the document-wide first occurrence only; later occurrences use the bare form, and an empty 首次出现 cell means never gloss.',
'- Typography: one half-width space between Chinese and Latin or digits; full-width punctuation in Chinese prose; 顿号 for enumerations; second person is 你.',
'- One physical line per paragraph; exactly one trailing newline.',
]
const EN_TARGET_DIGEST = [
'- Edit ONLY what the change requires; preserve the reviewed phrasing of everything unchanged.',
'- Nothing added, nothing dropped: the English must state exactly what the new Chinese states.',
'- Write concise professional developer prose, not word-by-word gloss; terse stays terse.',
'- Code fences byte-identical to the Chinese side, comments included; inline code spans verbatim.',
'- Relative links keep the `.md` target; only the switcher line links `.zh.md`.',
'- Structure mirrors the counterpart: heading depths and order, list kinds and item counts, table rows and columns.',
'- One physical line per paragraph; exactly one trailing newline.',
]
function renderBundles(out: string[], input: TranslationBriefInput, bundles: BriefBundle[], firstOccurrenceNotes: string[]): void {
const sourceLanguage = input.direction === 'en-to-zh' ? 'English' : 'Chinese'
const counterpartLanguage = input.direction === 'en-to-zh' ? 'Chinese' : 'English'
for (const bundle of bundles) {
out.push('')
out.push(`### #${bundle.index} ${bundle.label}${bundle.reason === 'first-occurrence' ? ' — unchanged; included for a first-occurrence move' : ''} — counterpart at ${input.counterpartPath}:${bundle.counterpartStartLine}`)
const fence = fenceFor([bundle.confirmedSourceText, bundle.currentSourceText, bundle.counterpartText].join('\n'), '~')
if (bundle.confirmedSourceText !== bundle.currentSourceText) {
out.push('')
out.push(`Last-confirmed ${sourceLanguage}:`)
out.push('')
out.push(`${fence}markdown`)
out.push(bundle.confirmedSourceText.trimEnd())
out.push(fence)
}
out.push('')
out.push(`Current ${sourceLanguage}:`)
out.push('')
out.push(`${fence}markdown`)
out.push(bundle.currentSourceText.trimEnd())
out.push(fence)
out.push('')
out.push(`Current ${counterpartLanguage} (bring this along):`)
out.push('')
out.push(`${fence}markdown`)
out.push(bundle.counterpartText.trimEnd())
out.push(fence)
}
if (firstOccurrenceNotes.length > 0) {
out.push('')
out.push('## First-occurrence notes')
out.push('')
for (const note of firstOccurrenceNotes) out.push(`- ${note}`)
}
}
/**
* Render the complete briefing for one out-of-sync pair.
*
* @param input - Diff, mapped scope, terminology, and pair identity.
* @returns Markdown briefing text.
*/
export function renderTranslationBrief(input: TranslationBriefInput): string {
const sourceLanguage = input.direction === 'en-to-zh' ? 'English' : 'Chinese'
const counterpartLanguage = input.direction === 'en-to-zh' ? 'Chinese' : 'English'
const out: string[] = []
out.push(`# Translation update briefing: ${input.sourcePath}`)
out.push('')
out.push(`The ${sourceLanguage} side changed; bring \`${input.counterpartPath}\` along with the smallest edit that covers the change.`)
if (input.scope.kind === 'mechanical') {
out.push('')
out.push('## Mechanical update — no translation judgment involved')
out.push('')
out.push(`Every change since the last confirmed state is inside fenced code blocks, which are byte-identical across the pair. Run \`pnpm run gen-translation-brief --apply ${input.sourcePath}\` to splice the updated fences into the counterpart (the result is structure-validated before writing), then record per the Finish steps.`)
}
out.push('')
out.push(`## ${sourceLanguage} diff (last-confirmed → current)`)
out.push('')
const diffFence = fenceFor(input.diff, '`')
out.push(`${diffFence}diff`)
out.push(input.diff.trimEnd())
out.push(diffFence)
switch (input.scope.kind) {
case 'mechanical':
break
case 'units':
out.push('')
out.push(`## Changed units (last-confirmed ${sourceLanguage} → current ${sourceLanguage}, with the current ${counterpartLanguage})`)
renderBundles(out, input, input.scope.bundles, input.scope.firstOccurrenceNotes)
break
case 'sections':
out.push('')
out.push('## Changed sections (fine-grained units do not align across the pair; whole heading sections shown)')
renderBundles(out, input, input.scope.bundles, input.scope.firstOccurrenceNotes)
break
case 'document':
out.push('')
out.push('## Whole-document update required')
out.push('')
out.push(`${input.scope.reason} Open \`${input.counterpartPath}\` directly, locate the affected regions yourself, and reconcile under docs/i18n/translation-rules.md.`)
break
default:
input.scope satisfies never
}
if (input.terminology.length > 0) {
out.push('')
out.push('## Binding terminology rows matching this change (docs/i18n/terminology.md)')
out.push('')
out.push('| English | 中文 | 首次出现 | 不要译作 | 备注 |')
out.push('|---|---|---|---|---|')
for (const row of input.terminology) out.push(row.line)
out.push('')
out.push('For any term you introduce that is not listed above, consult the full table before inventing a rendering.')
}
out.push('')
out.push('## Rules digest (full rules: docs/i18n/translation-rules.md)')
out.push('')
out.push(...(input.direction === 'en-to-zh' ? ZH_TARGET_DIGEST : EN_TARGET_DIGEST))
out.push('')
out.push('## Finish')
out.push('')
out.push('1. Apply the smallest counterpart edit that covers the change, then verify the changed spans clause by clause against the source.')
out.push(`2. \`pnpm run verify-translation-pairing --write ${input.sourcePath.replace(/\.zh\.md$/, '.md')}\``)
out.push(`3. \`pnpm run verify-translation-pairing ${input.sourcePath.replace(/\.zh\.md$/, '.md')}\``)
out.push('')
return out.join('\n')
}

View File

@@ -3,7 +3,9 @@
import { describe, expect, it } from 'vitest'
import {
isTranslationScopeFile,
pairAnchorOfArgument,
parseTranslationMarkdown,
parseTranslationPairingCliArgs,
parseTranslationPairingManifest,
translationStructureDiff,
translationStructureSignature,
@@ -102,3 +104,40 @@ describe('translation structural signature', () => {
])
})
})
describe('pair CLI arguments', () => {
it('normalizes any pair file or bare stem to the English anchor', () => {
expect(pairAnchorOfArgument('docs/foo.md')).toBe('docs/foo.md')
expect(pairAnchorOfArgument('docs/foo.zh.md')).toBe('docs/foo.md')
expect(pairAnchorOfArgument('docs/foo.i18n.yaml')).toBe('docs/foo.md')
expect(pairAnchorOfArgument('docs/foo')).toBe('docs/foo.md')
expect(pairAnchorOfArgument('.\\docs\\foo.zh.md')).toBe('docs/foo.md')
})
it('scopes a check to named pairs and dedupes the three spellings', () => {
expect(parseTranslationPairingCliArgs(['docs/foo.zh.md', 'docs/foo.i18n.yaml', 'docs/bar.md'])).toEqual({
mode: 'check',
scope: 'pairs',
anchors: ['docs/bar.md', 'docs/foo.md'],
})
expect(parseTranslationPairingCliArgs([])).toEqual({ mode: 'check', scope: 'corpus', anchors: [] })
})
it('requires --write to name confirmed pairs or opt into --all', () => {
expect(() => parseTranslationPairingCliArgs(['--write'])).toThrow('requires the pair(s) you confirmed')
expect(parseTranslationPairingCliArgs(['--write', 'docs/foo.md'])).toEqual({
mode: 'write',
scope: 'pairs',
anchors: ['docs/foo.md'],
})
expect(parseTranslationPairingCliArgs(['--write', '--all'])).toEqual({ mode: 'write', scope: 'corpus', anchors: [] })
expect(() => parseTranslationPairingCliArgs(['--write', '--all', 'docs/foo.md'])).toThrow('not both')
})
it('keeps --list corpus-only and rejects unknown flags', () => {
expect(parseTranslationPairingCliArgs(['--list'])).toEqual({ mode: 'list', scope: 'corpus', anchors: [] })
expect(() => parseTranslationPairingCliArgs(['--list', 'docs/foo.md'])).toThrow('takes no other flags or paths')
expect(() => parseTranslationPairingCliArgs(['--all'])).toThrow('--all only applies to --write')
expect(() => parseTranslationPairingCliArgs(['--frobnicate'])).toThrow('unknown flag(s): --frobnicate')
})
})

View File

@@ -34,6 +34,7 @@ const NON_SOURCE_DIRECTORIES = new Set([
/** Glob traversal exclusions corresponding to the non-source path predicate. */
export const TRANSLATION_SCOPE_GLOB_EXCLUDES = [
'.agents/notes/archived/**',
'**/node_modules/**',
'**/lib/**',
'**/.pnpm-store/**',
@@ -67,7 +68,8 @@ function isTranslationSourceExcluded(file: string): boolean {
/** 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)
return !file.startsWith('.agents/notes/archived/')
&& !isTranslationSourceExcluded(file) && (README_ARTIFACT.test(file)
|| file.startsWith('.agents/notes/')
|| file.startsWith('docs/')
|| file.startsWith('python/'))
@@ -100,6 +102,66 @@ export function parseTranslationPairingManifest(content: string): TranslationPai
return { excluded: excludedField(record) }
}
/**
* Normalize one CLI pair argument to its English anchor path: any of the
* pair's three files (`foo.md`, `foo.zh.md`, `foo.i18n.yaml`) or the bare
* `foo` stem names the same pair, and platform separators are accepted.
*
* @param argument - Repo-relative path as passed on a command line.
* @returns The pair's `foo.md` anchor path with `/` separators.
*/
export function pairAnchorOfArgument(argument: string): string {
const normalized = argument.split('\\').join('/').replace(/^\.\//, '')
if (normalized.endsWith('.zh.md')) return `${normalized.slice(0, -'.zh.md'.length)}.md`
if (normalized.endsWith('.i18n.yaml')) return `${normalized.slice(0, -'.i18n.yaml'.length)}.md`
if (normalized.endsWith('.md')) return normalized
return `${normalized}.md`
}
/** A parsed `verify-translation-pairing` invocation. */
export interface TranslationPairingCliRequest {
mode: 'check' | 'list' | 'write'
/** `corpus` runs discovery over the whole tree; `pairs` touches only the named anchors. */
scope: 'corpus' | 'pairs'
/** English anchor paths, empty for corpus scope. */
anchors: string[]
}
/**
* Parse and validate `verify-translation-pairing` CLI arguments.
*
* Check accepts optional pair paths; `--write` requires either pair paths or
* `--all` so a bulk re-record is always an explicit choice — a bare
* `--write` would silently bless every drifted pair in the tree, including
* ones the caller never confirmed. `--list` is corpus-only.
*
* @param argv - Arguments after the script name.
* @returns The validated request.
* @throws Error when flags or their combination are invalid.
*/
export function parseTranslationPairingCliArgs(argv: string[]): TranslationPairingCliRequest {
const flags = argv.filter(argument => argument.startsWith('--'))
const anchors = [...new Set(argv.filter(argument => !argument.startsWith('--')).map(pairAnchorOfArgument))].sort()
const unknown = flags.filter(flag => !['--list', '--write', '--all'].includes(flag))
if (unknown.length > 0) throw new Error(`unknown flag(s): ${unknown.join(', ')}`)
const listMode = flags.includes('--list')
const writeMode = flags.includes('--write')
const allMode = flags.includes('--all')
if (listMode && (writeMode || allMode || anchors.length > 0)) {
throw new Error('--list reports the whole corpus and takes no other flags or paths')
}
if (allMode && !writeMode) throw new Error('--all only applies to --write')
if (writeMode) {
if (anchors.length > 0 && allMode) throw new Error('--write takes either pair paths or --all, not both')
if (anchors.length === 0 && !allMode) {
throw new Error('--write requires the pair(s) you confirmed (any file of a pair), or --all to re-record every complete pair; recording pairs you did not review blesses unconfirmed content')
}
return { mode: 'write', scope: allMode ? 'corpus' : 'pairs', anchors }
}
if (listMode) return { mode: 'list', scope: 'corpus', anchors: [] }
return { mode: 'check', scope: anchors.length > 0 ? 'pairs' : 'corpus', anchors }
}
/** The structural surface compared between the two sides of a pair. */
export interface TranslationStructureSignature {
/** Heading depths in document order (h2 -> 2). */

View File

@@ -46,6 +46,26 @@
"symbol": "LlmModelContext",
"source": "packages/llm/llm/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "ReasoningEffortId",
"source": "packages/llm/llm/src/brand.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "LlmReasoningEffortInfo",
"source": "packages/llm/llm/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "LlmModelReasoningInfo",
"source": "packages/llm/llm/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "LlmResolvedModelInfo",
"source": "packages/llm/llm/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "GenerateOptions",
@@ -292,6 +312,11 @@
"source": "packages/llm/llm/src/assembler.ts",
"projection": "public-api"
},
{
"doc": "docs/core-data-structures/llm-streaming.md",
"symbol": "PreparedLlmCall",
"source": "packages/llm/llm/src/index.ts"
},
{
"doc": "docs/core-data-structures/llm-streaming.md",
"symbol": "LlmAdapter",
@@ -609,6 +634,11 @@
"symbol": "ToolExecutionMode",
"source": "packages/core/tools/src/index.ts"
},
{
"doc": "docs/core-data-structures/tools.md",
"symbol": "CodeDispatchLog",
"source": "packages/core/tools/src/index.ts"
},
{
"doc": "docs/core-data-structures/tools.md",
"symbol": "ToolRunContext",
@@ -729,16 +759,6 @@
"symbol": "ApprovalRequest",
"source": "packages/ui/user-approval/src/index.ts"
},
{
"doc": "docs/core-data-structures/bash.md",
"symbol": "DshEnvironmentKey",
"source": "packages/bash/bash/src/types.ts"
},
{
"doc": "docs/core-data-structures/bash.md",
"symbol": "DshEnvironment",
"source": "packages/bash/bash/src/types.ts"
},
{
"doc": "docs/core-data-structures/bash.md",
"symbol": "BashExecRequest",
@@ -759,11 +779,6 @@
"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": "BashProcess",
@@ -1248,6 +1263,71 @@
"doc": "docs/core-data-structures/session-query.md",
"symbol": "SessionSearchHit",
"source": "packages/session-query/session-query/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "SubprocessSpawnSpec",
"source": "packages/subprocess/subprocess/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "SubprocessHandle",
"source": "packages/subprocess/subprocess/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "SubprocessOutputReader",
"source": "packages/subprocess/subprocess/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "SubprocessOutputRead",
"source": "packages/subprocess/subprocess/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "SubprocessOutcome",
"source": "packages/subprocess/subprocess/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "DshEnvironmentKey",
"source": "packages/subprocess/subprocess/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "DshEnvironment",
"source": "packages/subprocess/subprocess/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "CollectedOutput",
"source": "packages/subprocess/subprocess/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "SubprocessStdinMode",
"source": "packages/subprocess/subprocess/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "SubprocessCollect",
"source": "packages/subprocess/subprocess/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "SubprocessOutputMode",
"source": "packages/subprocess/subprocess/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "SubprocessStdio",
"source": "packages/subprocess/subprocess/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "SubprocessCollectedOutputs",
"source": "packages/subprocess/subprocess/src/types.ts"
}
]
}

View File

@@ -0,0 +1,115 @@
/** Verify and append-seal the frozen Agent Note archive. */
import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { AGENT_NOTE_CLASSES, agentNoteRoot } from './agent-note-tree.ts'
import {
extendArchiveManifest,
parseArchiveManifest,
renderArchiveManifest,
validateArchiveArtifacts,
validateArchiveManifestExtension,
type ArchiveManifest,
} from './archived-agent-notes.ts'
const args = process.argv.slice(2)
const writeMode = args.length === 1 && args[0] === '--write'
if (args.length > 0 && !writeMode) {
console.error('verify-archived-agent-notes: usage: tsx scripts/verify-archived-agent-notes.ts [--write]')
process.exit(1)
}
const archiveRoot = resolve(agentNoteRoot, 'archived')
const manifestPath = resolve(archiveRoot, 'manifest.json')
const repoRoot = resolve(agentNoteRoot, '../..')
const manifestRepoPath = '.agents/notes/archived/manifest.json'
const errors: string[] = []
const allowedRootFiles = new Set(['AGENTS.md', 'manifest.json'])
const kinds = new Set<string>()
if (!existsSync(resolve(archiveRoot, 'AGENTS.md'))) errors.push('archived/AGENTS.md is required')
const artifacts = new Map<string, Buffer>()
for (const entry of readdirSync(archiveRoot, { withFileTypes: true })) {
if (entry.isFile()) {
if (!allowedRootFiles.has(entry.name)) errors.push(`archived/${entry.name}: unexpected root file`)
continue
}
if (!entry.isDirectory()) {
errors.push(`archived/${entry.name}: only regular files and kind directories are allowed`)
continue
}
if (!(AGENT_NOTE_CLASSES as readonly string[]).includes(entry.name)) {
errors.push(`archived/${entry.name}/: unknown Agent Note kind`)
continue
}
kinds.add(entry.name)
for (const child of readdirSync(resolve(archiveRoot, entry.name), { withFileTypes: true })) {
const rel = `${entry.name}/${child.name}`
if (!child.isFile()) {
errors.push(`${rel}: archived kind directories contain regular files only`)
continue
}
artifacts.set(rel, readFileSync(resolve(archiveRoot, rel)))
}
}
for (const kind of AGENT_NOTE_CLASSES) {
if (!kinds.has(kind)) errors.push(`archived/${kind}/: required kind directory is missing`)
}
errors.push(...validateArchiveArtifacts(artifacts))
function runGit(args: string[]): string {
const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' })
if (result.error !== undefined) throw result.error
if (result.status !== 0) throw new Error(result.stderr.trim() || `git exited with status ${result.status}`)
return result.stdout
}
function readBaselineManifest(ref: string): ArchiveManifest {
runGit(['cat-file', '-e', `${ref}^{commit}`])
const manifestEntry = runGit(['ls-tree', '--name-only', ref, '--', manifestRepoPath]).trim()
if (manifestEntry === '') return { version: 1, files: {} }
return parseArchiveManifest(runGit(['show', `${ref}:${manifestRepoPath}`]))
}
let manifest: ArchiveManifest = { version: 1, files: {} }
if (existsSync(manifestPath)) {
try {
manifest = parseArchiveManifest(readFileSync(manifestPath, 'utf8'))
} catch (error: unknown) {
errors.push(`archived/manifest.json: ${error instanceof Error ? error.message : String(error)}`)
}
} else if (!writeMode) {
errors.push('archived/manifest.json is required; seal new artifacts with `pnpm run verify-archived-agent-notes --write`')
}
// CI supplies its trusted pre-change commit; local writes compare with committed HEAD.
const baselineRef = process.env.DSH_ARCHIVE_BASE_REF ?? 'HEAD'
try {
const baseline = readBaselineManifest(baselineRef)
errors.push(...validateArchiveManifestExtension(baseline, manifest))
} catch (error: unknown) {
errors.push(`archived/manifest.json: cannot read baseline ${JSON.stringify(baselineRef)}: ${error instanceof Error ? error.message : String(error)}`)
}
const extended = extendArchiveManifest(manifest, artifacts)
errors.push(...extended.errors)
if (!writeMode) {
for (const path of extended.added) errors.push(`${path}: archived artifact is not sealed in manifest.json`)
}
if (errors.length > 0) {
console.error('verify-archived-agent-notes: archive contract violated:')
for (const error of errors) console.error(` ${error}`)
process.exit(1)
}
if (writeMode) {
const rendered = renderArchiveManifest(extended.files)
if (!existsSync(manifestPath) || readFileSync(manifestPath, 'utf8') !== rendered) {
writeFileSync(manifestPath, rendered)
}
console.log(`verify-archived-agent-notes: sealed ${extended.added.length} new artifact(s); existing seals unchanged.`)
} else {
console.log(`verify-archived-agent-notes: ${artifacts.size} frozen artifact(s) checked across ${kinds.size} kind(s).`)
}

View File

@@ -13,11 +13,15 @@ import {
} from 'node:fs'
import { dirname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { parseArgs } from 'node:util'
const repositoryRoot = resolve(import.meta.dirname, '..')
const options = parseOptions(process.argv.slice(2))
const packagesRoot = resolve(options.get('--packages-root') ?? repositoryRoot)
const loaderUrl = options.get('--loader-url')
const { values: options } = parseArgs({
args: process.argv.slice(2),
options: { 'packages-root': { type: 'string' }, 'loader-url': { type: 'string' } },
})
const packagesRoot = resolve(options['packages-root'] ?? repositoryRoot)
const loaderUrl = options['loader-url']
?? pathToFileURL(resolve(repositoryRoot, 'vendor/loader/lib/index.js')).href
const failures = []
const manifests = globSync('packages/*/*/package.json', { cwd: packagesRoot }).sort()
@@ -77,21 +81,6 @@ if (failures.length > 0) {
console.log(`verify-built-package-invariants: ${manifests.length} compiled companion(s) passed plain-Node Loader checks.`)
function parseOptions(args) {
const allowed = new Set(['--packages-root', '--loader-url'])
const parsed = new Map()
for (let index = 0; index < args.length; index += 2) {
const name = args[index]
const value = args[index + 1]
if (!allowed.has(name) || value === undefined || value.startsWith('--')) {
throw new Error(`verify-built-package-invariants: expected [--packages-root PATH] [--loader-url URL], got ${JSON.stringify(args)}.`)
}
if (parsed.has(name)) throw new Error(`verify-built-package-invariants: duplicate option ${name}.`)
parsed.set(name, value)
}
return parsed
}
function copyDeclaredLibFiles(packageDir, stagedPackageDir, files) {
for (const pattern of files) {
if (!pattern.startsWith('lib/')) continue

View File

@@ -14,8 +14,8 @@
* pnpm exec tsx scripts/verify-client-domain-graph.ts
*/
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { globSync, readdirSync, readFileSync, statSync } from 'node:fs'
import { join, resolve, sep } from 'node:path'
const root = resolve(import.meta.dirname, '..')
const CLIENT_DIR = join(root, 'packages/client')
@@ -28,15 +28,11 @@ const ASSEMBLY_FILES = new Set(['apply.ts', 'index.ts', 'index.tsx'])
interface Violation { file: string; imported: string; reason: string }
/** Recursively list .ts/.tsx files under dir (relative paths). */
function listSources(dir: string, prefix = ''): string[] {
const out: string[] = []
for (const name of readdirSync(dir)) {
const full = join(dir, name)
const rel = prefix ? `${prefix}/${name}` : name
if (statSync(full).isDirectory()) out.push(...listSources(full, rel))
else if (/\.tsx?$/.test(name) && !/\.legacy\./.test(name)) out.push(rel)
}
return out
function listSources(dir: string): string[] {
return globSync('**/*.{ts,tsx}', { cwd: dir })
.map(rel => rel.split(sep).join('/'))
.filter(rel => !/\.legacy\./.test(rel.slice(rel.lastIndexOf('/') + 1)))
.sort()
}
/** First path segment of a client-relative file, or '' for top-level files. */

View File

@@ -9,7 +9,7 @@ import { existsSync, readFileSync } from 'node:fs'
import { dirname, relative, resolve } from 'node:path'
import type { Nodes } from 'mdast'
import { parseMarkdown, visitMarkdown } from './markdown.ts'
import { uniqueRepoFiles } from './repo-files.ts'
import { isArchivedAgentNotePath, uniqueRepoFiles } from './repo-files.ts'
const root = resolve(import.meta.dirname, '..')
@@ -95,7 +95,8 @@ function findViolations(absPath: string): Violation[] {
return out
}
const files = uniqueRepoFiles(root, PATTERNS)
// Archived notes remain valid link targets, but their historical outbound links are frozen.
const files = uniqueRepoFiles(root, PATTERNS, isArchivedAgentNotePath)
const all = files.flatMap(file => findViolations(file.abs))
const checked = files.length

View File

@@ -10,7 +10,7 @@ import { readFileSync } from 'node:fs'
import { relative, resolve } from 'node:path'
import type { Nodes } from 'mdast'
import { parseMarkdown, visitMarkdown } from './markdown.ts'
import { uniqueRepoFiles } from './repo-files.ts'
import { isArchivedAgentNotePath, uniqueRepoFiles } from './repo-files.ts'
const root = resolve(import.meta.dirname, '..')
@@ -69,7 +69,7 @@ function findViolations(absPath: string): Violation[] {
return out
}
const files = uniqueRepoFiles(root, PATTERNS)
const files = uniqueRepoFiles(root, PATTERNS, isArchivedAgentNotePath)
const all = files.flatMap(file => findViolations(file.abs))
const checked = files.length

View File

@@ -11,6 +11,7 @@ import { gfmFromMarkdown } from 'mdast-util-gfm'
import { gfm } from 'micromark-extension-gfm'
import { JSDOM } from 'jsdom'
import type { Nodes } from 'mdast'
import { isArchivedAgentNotePath } from './repo-files.ts'
const root = resolve(import.meta.dirname, '..')
@@ -65,6 +66,7 @@ const seen = new Set<string>()
let checkedFiles = 0
for (const pattern of PATTERNS) {
for (const match of globSync(pattern, { cwd: root })) {
if (isArchivedAgentNotePath(match)) continue
const real = realpathSync(resolve(root, match))
if (seen.has(real)) continue
seen.add(real)

View File

@@ -5,9 +5,14 @@
* outside the check.
*/
import { existsSync, readdirSync } from 'node:fs'
import { existsSync, globSync } from 'node:fs'
import { resolve } from 'node:path'
import { findReferenceViolations, uniqueRepoFiles, type ReferenceViolation as Violation } from './repo-files.ts'
import {
findReferenceViolations,
isArchivedAgentNotePath,
uniqueRepoFiles,
type ReferenceViolation as Violation,
} from './repo-files.ts'
const root = resolve(import.meta.dirname, '..')
@@ -26,7 +31,7 @@ const PATTERNS = [
/** Paths excluded from the scan: built output and vendored upstream source. */
const isExcluded = (p: string): boolean =>
p.includes('/lib/') || p.endsWith('.d.ts') || p.startsWith('vendor/')
isArchivedAgentNotePath(p) || p.includes('/lib/') || p.endsWith('.d.ts') || p.startsWith('vendor/')
/**
* Directory names of every real package, `packages/<group>/<pkg>`. A broken
@@ -36,12 +41,8 @@ const isExcluded = (p: string): boolean =>
*/
function realPackageNames(): Set<string> {
const names = new Set<string>()
const pkgRoot = resolve(root, 'packages')
for (const group of readdirSync(pkgRoot, { withFileTypes: true })) {
if (!group.isDirectory()) continue
for (const pkg of readdirSync(resolve(pkgRoot, group.name), { withFileTypes: true })) {
if (pkg.isDirectory()) names.add(pkg.name)
}
for (const pkg of globSync('packages/*/*', { cwd: root, withFileTypes: true })) {
if (pkg.isDirectory()) names.add(pkg.name)
}
return names
}

View File

@@ -55,6 +55,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' },
'packages/client/ui-question': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' },
'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-workspace': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
@@ -75,6 +77,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' },
'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' },
'packages/lsp/lsp-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-lsp.' },
'packages/subprocess/subprocess': { kind: 'indirect', reason: 'The seam delegates all model rendering to consumer seams such as the bash executor family.' },
'packages/subprocess/subprocess-local': { kind: 'indirect', reason: 'The spawn backend delegates model rendering to consumer seams such as the bash executor family.' },
'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' },
'packages/sandbox/sandbox-policy': { kind: 'indirect', reason: 'The policy service holds the mode dsh-tool-bash and dsh-tool-fs render in their denial markers.' },
'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' },
@@ -90,7 +94,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' },
'packages/spill/spill-local': { kind: 'indirect', reason: 'The storage backend delegates model rendering to spill consumers.' },
'packages/subagent/subagent': { kind: 'indirect', reason: 'The provider registry delegates parent-model rendering to dsh-tool-subagent.' },
'packages/subagent/subagent-subprocess': { kind: 'indirect', reason: 'Only process-based subagent backends compose a child model request.' },
'packages/support/acp-snapshot': { kind: 'none', reason: 'The test harness observes and normalizes transcripts without changing live requests.' },
'packages/support/agent-loop-testkit': { kind: 'none', reason: 'The test helper mounts services but neither drives nor modifies model requests.' },
'packages/support/invariants': { kind: 'none', reason: 'The observer validates requests but never rewrites their context.' },
@@ -98,6 +101,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' },
'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' },
'packages/tasks/tasks': { kind: 'indirect', reason: 'Producer and control-surface plugins own all model rendering over the task registry.' },
'packages/tasks/tasks-local': { kind: 'indirect', reason: 'The registry backend delegates model rendering to producer plugins and dsh-tool-tasks.' },
'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.' },

View File

@@ -3,8 +3,9 @@
* peer in its dependency graph. With auto peer installation disabled, a missing
* root peer can otherwise fail only when Cordis loads the packaged plugin.
*/
import { readFile, readdir } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { globSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { parseArgs } from 'node:util'
interface PackageManifest {
@@ -72,15 +73,9 @@ if (failures.length > 0) {
console.log(`verify-runtime-closure: ${queue.length} workspace packages form a closed runtime dependency graph.`)
async function loadWorkspacePackages(): Promise<Map<string, WorkspacePackage>> {
const paths: string[] = []
for (const group of await childDirectories(join(root, 'packages'))) {
for (const packageDir of await childDirectories(join(root, 'packages', group))) {
paths.push(join(root, 'packages', group, packageDir, 'package.json'))
}
}
for (const packageDir of await childDirectories(join(root, 'vendor'))) {
paths.push(join(root, 'vendor', packageDir, 'package.json'))
}
const paths = globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root })
.sort()
.map(relative => resolve(root, relative))
const result = new Map<string, WorkspacePackage>()
for (const path of paths) {
const manifest = await loadManifest(path)
@@ -89,11 +84,6 @@ async function loadWorkspacePackages(): Promise<Map<string, WorkspacePackage>> {
return result
}
async function childDirectories(path: string): Promise<string[]> {
const entries = await readdir(path, { withFileTypes: true })
return entries.filter(entry => entry.isDirectory()).map(entry => entry.name).sort()
}
async function loadManifest(path: string): Promise<PackageManifest> {
return JSON.parse(await readFile(path, 'utf8')) as PackageManifest
}

View File

@@ -2,8 +2,10 @@
* Enforce complete English/Chinese pairs, matching structure, and recorded git
* blob hashes for every in-scope document. The manifest contains only explicit
* exclusions, which may have neither a counterpart nor a sidecar.
* `--list` reports state and `--write` records both sides after human review.
* Translation quality remains a review responsibility.
* `--list` reports state; `--write <pairs...>` records the named confirmed
* pairs (`--write --all` records every complete pair); a check or write named
* with pair paths touches only those pairs, so update iteration does not pay
* for a corpus scan. Translation quality remains a review responsibility.
* See `docs/i18n/README.md` for the owning contract.
*/
@@ -13,6 +15,7 @@ import { basename, join, resolve, sep } from 'node:path'
import {
linksTo,
parseTranslationMarkdown,
parseTranslationPairingCliArgs,
parseTranslationPairingManifest,
isTranslationScopeFile,
TRANSLATION_SCOPE_GLOB_EXCLUDES,
@@ -21,8 +24,15 @@ import {
} from './translation-pairing.ts'
const root = resolve(import.meta.dirname, '..')
const listMode = process.argv.includes('--list')
const writeMode = process.argv.includes('--write')
let request: ReturnType<typeof parseTranslationPairingCliArgs>
try {
request = parseTranslationPairingCliArgs(process.argv.slice(2))
} catch (error) {
console.error(`verify-translation-pairing: ${error instanceof Error ? error.message : String(error)}`)
process.exit(2)
}
const listMode = request.mode === 'list'
const writeMode = request.mode === 'write'
/** Discover source Markdown and pairing sidecars before applying the corpus predicate. */
const SCOPE_PATTERNS = [
@@ -77,32 +87,67 @@ function renderMeta(source: string, sourceHash: string, zh: string, zhHash: stri
'# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each',
'# side as of the last confirmed-consistent state. Both languages carry equal authority;',
'# after editing either side, bring the other along and re-record with:',
'# pnpm run verify-translation-pairing --write',
`# pnpm run verify-translation-pairing --write ${source}`,
`${basename(source)}: ${sourceHash}`,
`${basename(zh)}: ${zhHash}`,
'',
].join('\n')
}
// Enumerate the scope once.
// Enumerate the scope once: the whole corpus, or exactly the named pairs'
// three files (a named pair whose files are absent is caught by the same
// completeness rules that cover discovered remnants).
const files = new Set<string>()
for (const pattern of SCOPE_PATTERNS) {
for (const match of globSync(pattern, { cwd: root, exclude: TRANSLATION_SCOPE_GLOB_EXCLUDES })) {
const normalized = match.split(sep).join('/')
if (isTranslationScopeFile(normalized)) files.add(normalized)
if (request.scope === 'pairs') {
for (const anchor of request.anchors) {
for (const file of [anchor, ...Object.values(pairPaths(anchor))]) {
if (existsSync(join(root, file))) files.add(file)
}
// A named anchor with no files on disk still enters the source list so
// the check reports it instead of silently passing an empty scope.
if (!existsSync(join(root, anchor))) files.add(anchor)
}
} else {
for (const pattern of SCOPE_PATTERNS) {
for (const match of globSync(pattern, { cwd: root, exclude: TRANSLATION_SCOPE_GLOB_EXCLUDES })) {
const normalized = match.split(sep).join('/')
if (isTranslationScopeFile(normalized)) files.add(normalized)
}
}
}
const translations = [...files].filter(f => f.endsWith('.zh.md')).sort()
const metas = [...files].filter(f => f.endsWith('.i18n.yaml')).sort()
const sources = [...files].filter(f => f.endsWith('.md') && !f.endsWith('.zh.md')).sort()
// --write: (re)record both hashes for every complete pair, creating missing records.
if (request.scope === 'pairs') {
const rejected = request.anchors.filter(anchor => !isTranslationScopeFile(anchor) || isExcluded(anchor))
const absent = request.anchors.filter(anchor => ![anchor, ...Object.values(pairPaths(anchor))].some(file => existsSync(join(root, file))))
if (rejected.length > 0 || absent.length > 0) {
for (const anchor of rejected) {
console.error(`verify-translation-pairing: ${anchor} is not an in-scope pair (excluded or outside the documentation corpus; see docs/i18n/README.md)`)
}
for (const anchor of absent) {
console.error(`verify-translation-pairing: ${anchor} names no pair on disk (none of its three files exist)`)
}
process.exit(2)
}
}
// --write: (re)record both hashes for the requested complete pairs, creating
// missing records. A named pair that cannot be recorded (missing counterpart)
// fails loud; corpus scope (--all) skips pairless sources as before.
if (writeMode) {
let written = 0
for (const source of sources) {
if (isExcluded(source)) continue
const { zh, meta } = pairPaths(source)
if (!existsSync(join(root, zh))) continue
if (!existsSync(join(root, source)) || !existsSync(join(root, zh))) {
if (request.scope === 'pairs') {
console.error(`verify-translation-pairing: cannot record ${source}: missing ${existsSync(join(root, source)) ? zh : source}`)
process.exit(2)
}
continue
}
const record = renderMeta(source, blobHash(readFileSync(join(root, source))), zh, blobHash(readFileSync(join(root, zh))))
if (existsSync(join(root, meta)) && readFileSync(join(root, meta), 'utf8') === record) continue
writeFileSync(join(root, meta), record)
@@ -204,7 +249,9 @@ if (listMode) {
}
if (errors.length === 0) {
console.log(`verify-translation-pairing: ${pairAnchors.size} pair(s) checked across all in-scope documentation, all consistent.`)
console.log(request.scope === 'pairs'
? `verify-translation-pairing: ${pairAnchors.size} named pair(s) consistent; the corpus-wide check still runs in doc-sync.`
: `verify-translation-pairing: ${pairAnchors.size} pair(s) checked across all in-scope documentation, all consistent.`)
process.exit(0)
}

View File

@@ -11,7 +11,9 @@
import { globSync, readFileSync, existsSync } from 'node:fs'
import { resolve, sep } from 'node:path'
import ts from 'typescript'
import { markdownFences } from './markdown.ts'
import { partitionPairedMarkdownDerivatives } from './paired-markdown-derivatives.ts'
import { isArchivedAgentNotePath } from './repo-files.ts'
const root = resolve(import.meta.dirname, '..')
@@ -80,42 +82,27 @@ function blockSymbol(code: string): string | null {
/** Extract every source-equivalence block from one Markdown file. */
function extractEquivBlocks(docRel: string): EquivBlock[] {
const text = readFileSync(resolve(root, docRel), 'utf8')
const lines = text.split('\n')
const blocks: EquivBlock[] = []
let open: { line: number; body: string[]; projection?: 'public-api' } | null = null
for (let i = 0; i < lines.length; i++) {
const raw = lines[i] ?? ''
const fence = /^```(\s*)(\S.*)?$/.exec(raw)
if (!fence) {
if (open) open.body.push(raw)
continue
for (const fence of markdownFences(readFileSync(resolve(root, docRel), 'utf8'))) {
if (fence.info === 'ts type-equiv public-api') {
throw new Error(`verify-type-equiv: ${docRel}:${fence.line} — use the concise \`ts public-api\` fence`)
}
if (open) {
const code = open.body.join('\n')
const symbol = blockSymbol(code)
if (!symbol) {
throw new Error(`verify-type-equiv: ${docRel}:${open.line} — type-equiv block has no parseable interface/type/class declaration`)
}
blocks.push({
doc: docRel,
line: open.line,
symbol,
code,
...(open.projection === undefined ? {} : { projection: open.projection }),
})
open = null
continue
if (fence.info !== 'ts type-equiv' && fence.info !== 'ts public-api') continue
if (!fence.closed) {
throw new Error(`verify-type-equiv: ${docRel}:${fence.line} — unterminated type-equivalence fence (missing closing \`\`\`)`)
}
const info = (fence[2] ?? '').trim()
if (info === 'ts type-equiv public-api') {
throw new Error(`verify-type-equiv: ${docRel}:${i + 1} — use the concise \`ts public-api\` fence`)
const symbol = blockSymbol(fence.code)
if (symbol === null) {
throw new Error(`verify-type-equiv: ${docRel}:${fence.line} — type-equiv block has no parseable interface/type/class declaration`)
}
if (info === 'ts type-equiv') open = { line: i + 1, body: [] }
if (info === 'ts public-api') open = { line: i + 1, body: [], projection: 'public-api' }
blocks.push({
doc: docRel,
line: fence.line,
symbol,
code: fence.code,
...(fence.info === 'ts public-api' ? { projection: 'public-api' as const } : {}),
})
}
if (open) throw new Error(`verify-type-equiv: ${docRel}:${open.line} — unterminated type-equiv block`)
return blocks
}
@@ -223,7 +210,10 @@ const keyOf = (x: { doc: string; symbol: string; projection?: 'public-api' }): s
// 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.split(sep).join('/'))
for (const match of globSync(pattern, { cwd: root })) {
const normalized = match.split(sep).join('/')
if (!isArchivedAgentNotePath(normalized)) docSet.add(normalized)
}
}
const extractedBlocks: EquivBlock[] = [...docSet].sort().flatMap(extractEquivBlocks)
const { primary: blocks, derivatives } = partitionPairedMarkdownDerivatives(

223
scripts/wine-windows-gates.sh Executable file
View File

@@ -0,0 +1,223 @@
#!/usr/bin/env bash
# Run the blocking Windows gates (workspace build, production site) with real
# win-x64 Node.js under Wine — the same script the pull-request `windows` job
# in ci.yml executes and the optional local gate `pnpm run check:windows-wine`
# wraps. Owning rationale, fidelity limits, and measured timings:
# .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
#
# The working tree is never mutated: tracked plus untracked-unignored files
# are snapshotted into a scratch directory, the Wine-specific pnpm overrides
# (hoisted layout, win32-x64 platform packages) are appended to the SNAPSHOT's
# pnpm-workspace.yaml, and the install and gates run there against the shared
# pnpm store. The Wine prefix and the checksum-verified Windows Node zip
# persist in .cache/wine-windows/ so reruns skip provisioning.
#
# Environment: DSH_WINE_NODE_MAJOR (default $PRIMARY_NODE_VERSION, then 24)
# picks the Windows Node line; DSH_WINE_GATE_CACHE_DIR relocates the cache;
# DSH_WINE_GATE_KEEP=1 preserves the scratch tree for inspection.
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)"
node_major="${DSH_WINE_NODE_MAJOR:-${PRIMARY_NODE_VERSION:-24}}"
cache_dir="${DSH_WINE_GATE_CACHE_DIR:-$repo_root/.cache/wine-windows}"
export WINEDEBUG='-all'
export WINEARCH=win64
# Skip Wine Mono / Gecko installers: Node needs neither.
export WINEDLLOVERRIDES='mscoree,mshtml='
export WINEPREFIX="$cache_dir/prefix"
# ---- preflight: fail loud before any expensive work --------------------
wine_bin=''
for candidate in "$(command -v wine || true)" "$(command -v wine64 || true)" /usr/lib/wine/wine64; do
if [ -n "$candidate" ] && [ -x "$candidate" ]; then wine_bin="$candidate"; break; fi
done
# GNU coreutils sha256sum on Linux; perl shasum ships with macOS. Both
# accept the same "<hash> <file>" --check input.
checksum_tool=''
if command -v sha256sum > /dev/null; then
checksum_tool='sha256sum'
elif command -v shasum > /dev/null; then
checksum_tool='shasum'
fi
missing=()
[ -n "$wine_bin" ] || missing+=('wine (apt: wine | brew: wine-stable)')
command -v curl > /dev/null || missing+=('curl')
command -v unzip > /dev/null || missing+=('unzip')
[ -n "$checksum_tool" ] || missing+=('sha256sum or shasum (apt: coreutils | macOS ships shasum)')
if ! command -v pnpm > /dev/null; then corepack enable > /dev/null 2>&1 || true; fi
command -v pnpm > /dev/null || missing+=('pnpm (corepack enable)')
if (( ${#missing[@]} > 0 )); then
printf 'wine-windows-gates: missing required tool: %s\n' "${missing[@]}" >&2
exit 1
fi
# Verify file $2 against SHA-256 hex $1 with whichever tool preflight found.
verify_sha256() {
case "$checksum_tool" in
sha256sum) printf '%s %s\n' "$1" "$2" | sha256sum --check - > /dev/null ;;
shasum) printf '%s %s\n' "$1" "$2" | shasum -a 256 --check - > /dev/null ;;
esac
}
scratch="$(mktemp -d "${TMPDIR:-/tmp}/dsh-wine-gates.XXXXXX")"
cleanup() {
wineserver -k > /dev/null 2>&1 || true
if [ "${DSH_WINE_GATE_KEEP:-0}" = '1' ]; then
echo "wine-windows-gates: scratch tree kept at $scratch"
else
rm -rf "$scratch"
fi
}
trap cleanup EXIT
mkdir -p "$cache_dir" "$scratch/logs"
# ---- provision Windows Node, boot Wine, snapshot + install concurrently ----
provision_node() {
# Latest release of the primary line, checksum-verified against the same
# dist directory. Offline runs fall back to the newest cached zip, loudly.
local version zip
version="$(curl -fsSL --max-time 30 https://nodejs.org/dist/index.json 2> /dev/null \
| node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{const v=JSON.parse(d).find(r=>r.version.startsWith('v$node_major.'));if(v)console.log(v.version)})" \
|| true)"
if [ -n "$version" ]; then
zip="$cache_dir/node-$version-win-x64.zip"
if [ ! -f "$zip" ]; then
curl -fsSL -o "$zip.tmp" "https://nodejs.org/dist/$version/node-$version-win-x64.zip"
local expected
expected="$(curl -fsSL "https://nodejs.org/dist/$version/SHASUMS256.txt" \
| awk -v a="node-$version-win-x64.zip" '$2 == a { print $1; exit }')"
[ -n "$expected" ] || { echo "wine-windows-gates: no SHASUMS256 entry for node-$version-win-x64.zip" >&2; exit 1; }
verify_sha256 "$expected" "$zip.tmp"
mv "$zip.tmp" "$zip"
fi
else
zip="$(ls -t "$cache_dir"/node-v"$node_major".*-win-x64.zip 2> /dev/null | head -1 || true)"
[ -n "$zip" ] || { echo "wine-windows-gates: nodejs.org unreachable and no cached Windows Node v$node_major zip in $cache_dir" >&2; exit 1; }
echo "wine-windows-gates: nodejs.org unreachable; using cached $(basename "$zip")" >&2
fi
unzip -q -o "$zip" -d "$scratch/node-win"
echo "$scratch/node-win/$(basename "$zip" .zip)/node.exe" > "$scratch/node-win-path"
}
boot_wine() {
"$wine_bin" wineboot --init > /dev/null 2>&1 || true
wineserver -w || true
}
snapshot_and_install() {
# Tracked + untracked-unignored files, minus agent-session litter; the
# existence filter drops paths staged as deleted. Then the Wine-specific
# install-time overrides go on the SNAPSHOT only: hoisted because Windows
# Node under Wine does not realpath pnpm's isolated-layout symlinks, and
# win32-x64 so the Windows esbuild/rolldown/rollup binaries materialize.
# Neither is recorded in the lockfile, so --frozen-lockfile stays valid;
# --ignore-scripts skips host lifecycle scripts no gate loads.
git -C "$repo_root" ls-files -z --cached --others --exclude-standard -- . ':!:.claude' ':!:.codex' \
| while IFS= read -r -d '' file; do [ -e "$repo_root/$file" ] && printf '%s\0' "$file"; done \
| tar -C "$repo_root" --null --files-from=- -cf - \
| tar -C "$scratch/tree" -xf -
cat >> "$scratch/tree/pnpm-workspace.yaml" << 'EOF'
nodeLinker: hoisted
supportedArchitectures:
os: [current, win32]
cpu: [current, x64]
EOF
(cd "$scratch/tree" && pnpm install --frozen-lockfile --ignore-scripts > "$scratch/logs/install.log" 2>&1) \
|| { tail -40 "$scratch/logs/install.log" >&2; return 1; }
}
mkdir "$scratch/tree"
start=$SECONDS
provision_node & node_pid=$!
boot_wine & wine_pid=$!
snapshot_and_install & install_pid=$!
# Wait for EVERY child before judging any: a bare `wait` under set -e would
# exit on the first failure and let the EXIT trap delete $scratch while the
# other children still run inside it. Named statuses also make the report
# point at the root cause instead of a downstream symptom.
node_status=0; wait "$node_pid" || node_status=$?
wine_status=0; wait "$wine_pid" || wine_status=$?
install_status=0; wait "$install_pid" || install_status=$?
provision_failed=0
report_provision() {
if (( $2 != 0 )); then
echo "wine-windows-gates: FAILED $1 (exit $2)" >&2
provision_failed=$2
fi
}
report_provision 'Windows Node provisioning' "$node_status"
report_provision 'wineboot' "$wine_status"
report_provision 'workspace snapshot + pnpm install' "$install_status"
if (( provision_failed != 0 )); then exit "$provision_failed"; fi
node_win="$(cat "$scratch/node-win-path")"
echo "wine-windows-gates: provisioned in $((SECONDS - start))s (wine $("$wine_bin" --version 2> /dev/null), node $(basename "$(dirname "$node_win")"))"
# ---- resolve entrypoints, lay the vue link, smoke ------------------------
# Node under Wine cannot attach stdio to pipes the caller owns (Socket open
# EBADF at bootstrap), so every invocation routes stdio through a file.
wine_node() {
local log="$1"
shift
local status=0
"$wine_bin" "$node_win" "$@" < /dev/null > "$log" 2>&1 || status=$?
return "$status"
}
cd "$scratch/tree"
tsc_js='node_modules/typescript/bin/tsc'
tsdown_js='node_modules/tsdown/dist/run.mjs'
vitepress_js='node_modules/vitepress/bin/vitepress.js'
[ -f "$vitepress_js" ] || vitepress_js='website/node_modules/vitepress/bin/vitepress.js'
for entry in "$tsc_js" "$tsdown_js" "$vitepress_js"; do
[ -f "$entry" ] || { echo "wine-windows-gates: expected entrypoint missing after hoisted install: $entry" >&2; exit 1; }
done
# VitePress links vue into the site's node_modules at build time; Wine cannot
# CREATE Windows symlinks (ENOTSUP) but follows pre-existing Unix ones.
if [ -d node_modules/vue ] && [ ! -e website/node_modules/vue ]; then
mkdir -p website/node_modules
ln -s ../../node_modules/vue website/node_modules/vue
fi
wine_node "$scratch/logs/smoke.log" -p "'smoke: ' + process.platform + ' ' + process.arch + ' ' + process.version"
cat "$scratch/logs/smoke.log"
grep -q '^smoke: win32 x64' "$scratch/logs/smoke.log" || { echo 'wine-windows-gates: Windows Node smoke did not report win32 x64' >&2; exit 1; }
# ---- the two blocking surfaces, concurrently ------------------------------
# The same shape run-gates gives ci-windows-blocking on native Windows:
# `build` = tsc -b then tsdown, `production site` = the VitePress build. Both
# statuses are captured so one failure cannot hide the other's result.
build_gate() {
wine_node "$scratch/logs/tsc.log" "$tsc_js" -b --pretty false || return $?
wine_node "$scratch/logs/tsdown.log" "$tsdown_js"
}
site_gate() {
cd website
wine_node "$scratch/logs/site.log" "../$vitepress_js" build .
}
start=$SECONDS
build_gate & build_pid=$!
site_gate & site_pid=$!
build_status=0
wait "$build_pid" || build_status=$?
site_status=0
wait "$site_pid" || site_status=$?
elapsed=$((SECONDS - start))
report() {
local label="$1" status="$2"
shift 2
if (( status == 0 )); then
echo "wine-windows-gates: PASS $label (${elapsed}s window)"
else
echo "== FAILED $label (exit $status) ==" >&2
for log in "$@"; do tail -n 200 "$log" >&2 || true; done
fi
}
report 'build (tsc -b, tsdown)' "$build_status" "$scratch/logs/tsc.log" "$scratch/logs/tsdown.log"
report 'production site (vitepress build)' "$site_status" "$scratch/logs/site.log"
if (( build_status != 0 )); then exit "$build_status"; fi
exit "$site_status"