Merge remote-tracking branch 'origin/master' into worktree/web-session-model-selector
# Conflicts: # apps/cli/package.json # apps/web/tests/smoke-real.e2e.ts # apps/web/tests/snapshots/fresh-round-trip/ui.expected.md # apps/web/tests/snapshots/seeded-history/ui.expected.md # docs/config-catalog.md # packages/client/connection/tests/fake-api.ts # packages/client/runtime/src/client/index.ts # packages/client/runtime/src/client/sessions/conversation.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/runtime/tests/fake-api.ts # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx # packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx # packages/client/ui-conversation/tests/chat-view.spec.tsx # packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/host/apiproxy/README.md # packages/host/apiproxy/src/api-proxy.ts # pnpm-lock.yaml # scripts/verify-package-readme-model-experience.ts # tsconfig.base.json
This commit is contained in:
@@ -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)) {
|
||||
|
||||
95
scripts/archived-agent-notes.spec.ts
Normal file
95
scripts/archived-agent-notes.spec.ts
Normal 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)}` },
|
||||
})
|
||||
})
|
||||
})
|
||||
190
scripts/archived-agent-notes.ts
Normal file
190
scripts/archived-agent-notes.ts
Normal 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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"docs/defensive-patterns.md": 550,
|
||||
"docs/testing.md": 1100,
|
||||
"examples/AGENTS.md": 310,
|
||||
"packages/AGENTS.md": 660,
|
||||
"packages/README.md": 790
|
||||
"packages/AGENTS.md": 675,
|
||||
"packages/README.md": 835
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Typecheck Markdown `ts` fences against the workspace API. `ignore-check` fences are reported as
|
||||
* opt-outs; generated catalog fragments and source-equivalence blocks are skipped here because their
|
||||
* owning gates verify them. A build-coordinated mode consumes existing declarations without emit.
|
||||
* owning gates verify them. Byte-identical `.zh.md` copies reuse their unsuffixed sibling's check. A
|
||||
* build-coordinated mode consumes existing declarations without emit.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
@@ -10,6 +11,8 @@ 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 { partitionPairedMarkdownDerivatives } from './paired-markdown-derivatives.ts'
|
||||
import { isArchivedAgentNotePath } from './repo-files.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
@@ -202,11 +205,18 @@ 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()
|
||||
|
||||
const all = files.flatMap(extractBlocks)
|
||||
const extracted = files.flatMap(extractBlocks)
|
||||
const { primary: all, derivatives } = partitionPairedMarkdownDerivatives(
|
||||
extracted,
|
||||
block => block.file,
|
||||
block => `${block.kind}\0${block.code}`,
|
||||
)
|
||||
const checked = all.filter(b => b.kind === 'check')
|
||||
const ignored = all.filter(b => b.kind === 'ignore')
|
||||
// Only compile-eligible fences belong in the opt-out ratio; every other skipped
|
||||
@@ -233,7 +243,7 @@ if (compilationError !== undefined) {
|
||||
|
||||
const ratio = ignored.length / ratioDenominator
|
||||
const skipped = all.length - ratioDenominator
|
||||
console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${skipped} type-equiv/catalog (checked elsewhere).`)
|
||||
console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${skipped} type-equiv/catalog (checked elsewhere), ${derivatives.length} paired derivative(s).`)
|
||||
// Guard against the escape hatch becoming the norm.
|
||||
if (ratioDenominator >= 4 && ratio > 0.5) {
|
||||
console.error(`doc-typecheck: too many blocks opt out of checking (${ignored.length}/${ratioDenominator}). Make them compile or delete them.`)
|
||||
|
||||
@@ -165,6 +165,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 +207,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',
|
||||
@@ -388,7 +393,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
|
||||
@@ -579,7 +584,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()
|
||||
|
||||
@@ -365,9 +365,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',
|
||||
@@ -916,8 +917,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()
|
||||
@@ -1128,7 +1134,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 |',
|
||||
'| --- | --- |',
|
||||
|
||||
@@ -29,7 +29,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 +169,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',
|
||||
@@ -355,7 +355,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:
|
||||
|
||||
302
scripts/gen-translation-brief.ts
Normal file
302
scripts/gen-translation-brief.ts
Normal 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'))
|
||||
@@ -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 }
|
||||
|
||||
21
scripts/migrate-packed-session-fixtures.ts
Normal file
21
scripts/migrate-packed-session-fixtures.ts
Normal 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`)
|
||||
66
scripts/paired-markdown-derivatives.spec.ts
Normal file
66
scripts/paired-markdown-derivatives.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { partitionPairedMarkdownDerivatives } from './paired-markdown-derivatives.ts'
|
||||
|
||||
interface Block {
|
||||
doc: string
|
||||
kind: string
|
||||
code: string
|
||||
}
|
||||
|
||||
const partition = (blocks: Block[]) => partitionPairedMarkdownDerivatives(
|
||||
blocks,
|
||||
block => block.doc,
|
||||
block => `${block.kind}\0${block.code}`,
|
||||
)
|
||||
|
||||
describe('partitionPairedMarkdownDerivatives', () => {
|
||||
it('treats a complete byte-identical Chinese sequence as derivative', () => {
|
||||
const english = [
|
||||
{ doc: 'docs/example.md', kind: 'ts', code: 'const one = 1' },
|
||||
{ doc: 'docs/example.md', kind: 'type-equiv', code: 'interface Example {}' },
|
||||
]
|
||||
const chinese = english.map(block => ({ ...block, doc: 'docs/example.zh.md' }))
|
||||
const unrelated = { doc: 'docs/other.md', kind: 'ts', code: 'const other = 2' }
|
||||
|
||||
expect(partition([...english, ...chinese, unrelated])).toEqual({
|
||||
primary: [...english, unrelated],
|
||||
derivatives: chinese,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps reordered, changed, partial, and orphan Chinese sequences primary', () => {
|
||||
const sequence = (doc: string) => [
|
||||
{ doc, kind: 'ts', code: 'const one = 1' },
|
||||
{ doc, kind: 'ts', code: 'const two = 2' },
|
||||
]
|
||||
const english = sequence('docs/example.md')
|
||||
const changed = english.map((block, index) => ({
|
||||
...block,
|
||||
doc: 'docs/example.zh.md',
|
||||
code: index === 0 ? 'const one = 0' : block.code,
|
||||
}))
|
||||
const reorderedEnglish = sequence('docs/reordered.md')
|
||||
const reordered = [...reorderedEnglish].reverse().map(block => ({ ...block, doc: 'docs/reordered.zh.md' }))
|
||||
const partialEnglish = sequence('docs/partial.md')
|
||||
const partial = [{ ...partialEnglish[0]!, doc: 'docs/partial.zh.md' }]
|
||||
const orphan = [{ doc: 'docs/orphan.zh.md', kind: 'ts', code: 'const orphan = true' }]
|
||||
const blocks = [
|
||||
...english,
|
||||
...changed,
|
||||
...reorderedEnglish,
|
||||
...reordered,
|
||||
...partialEnglish,
|
||||
...partial,
|
||||
...orphan,
|
||||
]
|
||||
|
||||
expect(partition(blocks)).toEqual({ primary: blocks, derivatives: [] })
|
||||
})
|
||||
|
||||
it('requires the fence kind to match as well as the body', () => {
|
||||
const english = { doc: 'docs/example.md', kind: 'type-equiv', code: 'interface Example {}' }
|
||||
const chinese = { ...english, doc: 'docs/example.zh.md', kind: 'public-api' }
|
||||
|
||||
expect(partition([english, chinese])).toEqual({ primary: [english, chinese], derivatives: [] })
|
||||
})
|
||||
})
|
||||
63
scripts/paired-markdown-derivatives.ts
Normal file
63
scripts/paired-markdown-derivatives.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Separate byte-identical Chinese Markdown code blocks from the primary checks
|
||||
* performed on their unsuffixed English siblings. The bilingual pairing gate
|
||||
* owns cross-language identity; source-oriented gates consume one copy.
|
||||
*/
|
||||
|
||||
/** The result of separating canonical blocks from paired Chinese derivatives. */
|
||||
export interface MarkdownDerivativePartition<T> {
|
||||
/** Blocks that still require the caller's owning check. */
|
||||
primary: T[]
|
||||
/** Chinese blocks covered by the byte-identical unsuffixed sequence. */
|
||||
derivatives: T[]
|
||||
}
|
||||
|
||||
/** Return the unsuffixed sibling of a Chinese Markdown path. */
|
||||
function unsuffixedSibling(doc: string): string | null {
|
||||
return doc.endsWith('.zh.md') ? `${doc.slice(0, -'.zh.md'.length)}.md` : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Partition complete byte-identical `.zh.md` block sequences from primary
|
||||
* blocks. A partial or reordered match stays primary so the caller fails
|
||||
* closed; the translation-pairing gate reports the cross-language mismatch.
|
||||
*
|
||||
* @param blocks - Blocks in repository scan order.
|
||||
* @param docOf - Repository-relative Markdown path owning a block.
|
||||
* @param fingerprintOf - Block kind/info string plus byte-exact body.
|
||||
* @returns Primary blocks and paired Chinese derivatives, preserving order.
|
||||
*/
|
||||
export function partitionPairedMarkdownDerivatives<T>(
|
||||
blocks: readonly T[],
|
||||
docOf: (block: T) => string,
|
||||
fingerprintOf: (block: T) => string,
|
||||
): MarkdownDerivativePartition<T> {
|
||||
const byDoc = new Map<string, T[]>()
|
||||
for (const block of blocks) {
|
||||
const doc = docOf(block)
|
||||
const group = byDoc.get(doc)
|
||||
if (group) group.push(block)
|
||||
else byDoc.set(doc, [block])
|
||||
}
|
||||
|
||||
const derivativeDocs = new Set<string>()
|
||||
for (const [doc, candidates] of byDoc) {
|
||||
const sibling = unsuffixedSibling(doc)
|
||||
if (sibling === null) continue
|
||||
const originals = byDoc.get(sibling)
|
||||
if (originals === undefined || originals.length !== candidates.length) continue
|
||||
if (candidates.every((candidate, index) => {
|
||||
const original = originals[index]
|
||||
return original !== undefined && fingerprintOf(candidate) === fingerprintOf(original)
|
||||
})) {
|
||||
derivativeDocs.add(doc)
|
||||
}
|
||||
}
|
||||
|
||||
const primary: T[] = []
|
||||
const derivatives: T[] = []
|
||||
for (const block of blocks) {
|
||||
(derivativeDocs.has(docOf(block)) ? derivatives : primary).push(block)
|
||||
}
|
||||
return { primary, derivatives }
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
17
scripts/session-fixture-layout.snapshot.ts
Normal file
17
scripts/session-fixture-layout.snapshot.ts
Normal 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([])
|
||||
})
|
||||
57
scripts/session-fixture-layout.spec.ts
Normal file
57
scripts/session-fixture-layout.spec.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
128
scripts/session-fixture-layout.ts
Normal file
128
scripts/session-fixture-layout.ts
Normal 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 }]
|
||||
})
|
||||
}
|
||||
@@ -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
283
scripts/translation-brief.spec.ts
Normal file
283
scripts/translation-brief.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
513
scripts/translation-brief.ts
Normal file
513
scripts/translation-brief.ts
Normal 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')
|
||||
}
|
||||
@@ -1,215 +1,8 @@
|
||||
{
|
||||
"requiredSince": "2026-07-14",
|
||||
"required": [
|
||||
".agents/notes/README.md",
|
||||
".agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md",
|
||||
".agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md",
|
||||
".agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.md",
|
||||
".agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md",
|
||||
".agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md",
|
||||
".agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md",
|
||||
".agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md",
|
||||
".agents/notes/implemented/architecture/2026-06-13-capability-seams.md",
|
||||
".agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md",
|
||||
".agents/notes/implemented/architecture/2026-06-14-session-persistence.md",
|
||||
".agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md",
|
||||
".agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md",
|
||||
".agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md",
|
||||
".agents/notes/implemented/architecture/2026-06-18-session-surface.md",
|
||||
".agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md",
|
||||
".agents/notes/implemented/architecture/2026-06-20-branded-ids.md",
|
||||
".agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.md",
|
||||
".agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md",
|
||||
".agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md",
|
||||
".agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md",
|
||||
".agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md",
|
||||
".agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md",
|
||||
".agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md",
|
||||
".agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md",
|
||||
".agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md",
|
||||
".agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md",
|
||||
".agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md",
|
||||
".agents/notes/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md",
|
||||
".agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md",
|
||||
".agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md",
|
||||
".agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md",
|
||||
".agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md",
|
||||
".agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md",
|
||||
".agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md",
|
||||
".agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md",
|
||||
".agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md",
|
||||
".agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md",
|
||||
".agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md",
|
||||
".agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md",
|
||||
".agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md",
|
||||
".agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md",
|
||||
".agents/notes/implemented/feature/2026-06-14-acp-multi-session.md",
|
||||
".agents/notes/implemented/feature/2026-06-15-code-mode.md",
|
||||
".agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md",
|
||||
".agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md",
|
||||
".agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md",
|
||||
".agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md",
|
||||
".agents/notes/implemented/feature/2026-06-25-ask-user-question.md",
|
||||
".agents/notes/implemented/feature/2026-06-29-todo-write-tool.md",
|
||||
".agents/notes/implemented/feature/2026-06-30-hook-bridges.md",
|
||||
".agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md",
|
||||
".agents/notes/implemented/feature/2026-06-30-interception-seams.md",
|
||||
".agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md",
|
||||
".agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.md",
|
||||
".agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md",
|
||||
".agents/notes/implemented/feature/2026-07-05-skill-system.md",
|
||||
".agents/notes/implemented/feature/2026-07-06-approval-seam.md",
|
||||
".agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md",
|
||||
".agents/notes/implemented/feature/2026-07-06-sandbox.md",
|
||||
".agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md",
|
||||
".agents/notes/implemented/feature/2026-07-07-session-prefix.md",
|
||||
".agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.md",
|
||||
".agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md",
|
||||
".agents/notes/implemented/feature/2026-07-10-session-query-service.md",
|
||||
".agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md",
|
||||
".agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.md",
|
||||
".agents/notes/implemented/process/2026-06-11-quality-gates.md",
|
||||
".agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.md",
|
||||
".agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.md",
|
||||
".agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md",
|
||||
".agents/notes/implemented/process/2026-06-17-ts-build-config.md",
|
||||
".agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.md",
|
||||
".agents/notes/implemented/process/2026-06-20-agent-note-classification.md",
|
||||
".agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md",
|
||||
".agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.md",
|
||||
".agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md",
|
||||
".agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md",
|
||||
".agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.md",
|
||||
".agents/notes/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md",
|
||||
".agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md",
|
||||
".agents/notes/implemented/process/2026-07-04-persistence-log-catalog.md",
|
||||
".agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md",
|
||||
".agents/notes/implemented/process/2026-07-06-export-surface-jsdoc-gate.md",
|
||||
".agents/notes/implemented/process/2026-07-06-generated-config-catalog.md",
|
||||
".agents/notes/implemented/process/2026-07-06-node-engine-floor.md",
|
||||
".agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md",
|
||||
".agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.md",
|
||||
".agents/notes/implemented/process/2026-07-12-package-model-experience-contract.md",
|
||||
".agents/notes/implemented/process/2026-07-19-web-styling-system.md",
|
||||
".agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.md",
|
||||
".agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md",
|
||||
".agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md",
|
||||
".agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md",
|
||||
".agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.md",
|
||||
".agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md",
|
||||
".agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md",
|
||||
".agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.md",
|
||||
".agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md",
|
||||
".agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md",
|
||||
".agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.md",
|
||||
".agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.md",
|
||||
".agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md",
|
||||
".agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md",
|
||||
".agents/notes/implemented/simplification/2026-07-04-prune-write-only-fs-surface.md",
|
||||
".agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md",
|
||||
".agents/notes/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md",
|
||||
".agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md",
|
||||
".agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md",
|
||||
".agents/notes/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md",
|
||||
".agents/notes/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md",
|
||||
".agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.md",
|
||||
".agents/notes/implemented/testing/2026-06-11-property-based-testing.md",
|
||||
".agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md",
|
||||
".agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md",
|
||||
".agents/notes/implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.md",
|
||||
".agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md",
|
||||
".agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.md",
|
||||
".agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md",
|
||||
".agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.md",
|
||||
".agents/notes/implemented/testing/2026-07-04-single-source-acp-replay-config.md",
|
||||
".agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md",
|
||||
".agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.md",
|
||||
".agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.md",
|
||||
".agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md",
|
||||
".agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md",
|
||||
".agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.md",
|
||||
".agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md",
|
||||
".agents/notes/rejected/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md",
|
||||
".agents/notes/proposed/process/2026-06-11-api-extractor-reports.md",
|
||||
".agents/notes/proposed/process/2026-06-11-architectural-conformance.md",
|
||||
".agents/notes/proposed/process/2026-06-11-supply-chain-and-vendor-drift.md",
|
||||
".agents/notes/proposed/process/2026-06-20-discover-package-inventory.md",
|
||||
".agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md",
|
||||
".agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.md",
|
||||
".agents/notes/proposed/testing/2026-06-11-mutation-testing.md",
|
||||
".agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.md",
|
||||
".agents/notes/rejected/architecture/2026-06-20-providerless-example-base.md",
|
||||
".agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.md",
|
||||
".agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md",
|
||||
".agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.md",
|
||||
".agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md",
|
||||
".agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md",
|
||||
".agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md",
|
||||
".agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.md",
|
||||
".agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.md",
|
||||
".agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.md",
|
||||
".agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.md",
|
||||
".agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.md",
|
||||
".agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.md",
|
||||
".agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md",
|
||||
".agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md",
|
||||
".agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.md",
|
||||
"README.md",
|
||||
"docs/architecture.md",
|
||||
"docs/cookbook/adding-a-package.md",
|
||||
"docs/cookbook/adding-a-tool.md",
|
||||
"docs/cookbook/adding-a-vendored-package.md",
|
||||
"docs/cookbook/adding-an-llm-adapter.md",
|
||||
"docs/cookbook/extension-cookbook.md",
|
||||
"docs/cookbook/responding-to-pr-review-on-a-stack.md",
|
||||
"docs/cordis-primer.md",
|
||||
"docs/core-data-structures/approval.md",
|
||||
"docs/core-data-structures/bash.md",
|
||||
"docs/core-data-structures/code-runtime.md",
|
||||
"docs/core-data-structures/compaction.md",
|
||||
"docs/core-data-structures/core.md",
|
||||
"docs/core-data-structures/filesystem.md",
|
||||
"docs/core-data-structures/llm-streaming.md",
|
||||
"docs/core-data-structures/persistence.md",
|
||||
"docs/core-data-structures/sandbox.md",
|
||||
"docs/core-data-structures/scope.md",
|
||||
"docs/core-data-structures/session-query.md",
|
||||
"docs/core-data-structures/session.md",
|
||||
"docs/core-data-structures/skills.md",
|
||||
"docs/core-data-structures/subagent.md",
|
||||
"docs/core-data-structures/system-prompt.md",
|
||||
"docs/core-data-structures/tools.md",
|
||||
"docs/core-data-structures/user-interaction.md",
|
||||
"docs/core-data-structures/web.md",
|
||||
"docs/core-data-structures/workflow.md",
|
||||
"docs/defensive-patterns.md",
|
||||
"docs/development.md",
|
||||
"docs/glossary.md",
|
||||
"docs/i18n/README.md",
|
||||
"docs/i18n/translation-rules.md",
|
||||
"docs/postmortem/0001-acp-default-export-drops-inject.md",
|
||||
"docs/postmortem/0002-js-expression-disabled-filesystem-tools.md",
|
||||
"docs/postmortem/README.md",
|
||||
"docs/testing.md",
|
||||
"docs/user/develop/basic/config.md",
|
||||
"docs/user/develop/basic/index.md",
|
||||
"docs/user/develop/basic/tool.md",
|
||||
"docs/user/develop/framework/events.md",
|
||||
"docs/user/develop/framework/index.md",
|
||||
"docs/user/develop/framework/service.md",
|
||||
"docs/user/develop/practice/index.md",
|
||||
"docs/user/develop/practice/llm-adapter.md",
|
||||
"docs/user/guide/config.md",
|
||||
"docs/user/guide/index.md",
|
||||
"docs/user/guide/quickstart.md",
|
||||
"docs/user/index.md",
|
||||
"python/README.md",
|
||||
"python/sdk-runtime/README.md",
|
||||
"python/sdk/README.md"
|
||||
],
|
||||
"excluded": [
|
||||
".agents/notes/AGENTS.md",
|
||||
".agents/notes/implemented/AGENTS.md",
|
||||
".agents/notes/implemented/CLAUDE.md",
|
||||
"docs/AGENTS.md",
|
||||
"docs/agent-lifecycle.md",
|
||||
"docs/capability-seams.md",
|
||||
@@ -223,7 +16,6 @@
|
||||
"docs/module-graph.md",
|
||||
"docs/persistence-catalog.md",
|
||||
"docs/tool-catalog.md",
|
||||
"docs/tool-execution-pipeline.md",
|
||||
"python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/"
|
||||
"docs/tool-execution-pipeline.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/** Regression tests for the bilingual cutoff and structural signature. */
|
||||
/** Regression tests for the bilingual corpus scope and structural signature. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
datedDocumentDate,
|
||||
isIsoDate,
|
||||
isTranslationScopeFile,
|
||||
pairAnchorOfArgument,
|
||||
parseTranslationMarkdown,
|
||||
parseTranslationPairingCliArgs,
|
||||
parseTranslationPairingManifest,
|
||||
requiresPairByDate,
|
||||
translationStructureDiff,
|
||||
translationStructureSignature,
|
||||
} from './translation-pairing.ts'
|
||||
@@ -16,49 +16,60 @@ function signature(markdown: string) {
|
||||
}
|
||||
|
||||
describe('translation pairing manifest', () => {
|
||||
it('accepts a real ISO cutoff and string-array fields', () => {
|
||||
it('accepts an exclusions-only manifest', () => {
|
||||
expect(parseTranslationPairingManifest(JSON.stringify({
|
||||
requiredSince: '2026-07-14',
|
||||
required: ['README.md'],
|
||||
excluded: ['docs/generated/'],
|
||||
}))).toEqual({
|
||||
requiredSince: '2026-07-14',
|
||||
required: ['README.md'],
|
||||
excluded: ['docs/generated/'],
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['2026-7-14', '2026-02-29', '2026-13-01', 'not-a-date'])('rejects invalid cutoff %s', (cutoff) => {
|
||||
expect(isIsoDate(cutoff)).toBe(false)
|
||||
it.each([
|
||||
['required', ['packages/README.md']],
|
||||
['requiredClasses', ['readme']],
|
||||
['requiredSince', '2026-07-14'],
|
||||
] as const)('rejects obsolete policy field %s instead of accepting an inert requirement', (field, value) => {
|
||||
expect(() => parseTranslationPairingManifest(JSON.stringify({
|
||||
requiredSince: cutoff,
|
||||
required: [],
|
||||
excluded: [],
|
||||
}))).toThrow('requiredSince must be a valid YYYY-MM-DD date')
|
||||
[field]: value,
|
||||
}))).toThrow(`unsupported field(s): ${field}; every in-scope document is required`)
|
||||
})
|
||||
|
||||
it('rejects non-string manifest arrays', () => {
|
||||
it('rejects a missing or non-string exclusion list', () => {
|
||||
expect(() => parseTranslationPairingManifest('{}')).toThrow('excluded must be an array of strings')
|
||||
expect(() => parseTranslationPairingManifest(JSON.stringify({
|
||||
requiredSince: '2026-07-14',
|
||||
required: [42],
|
||||
excluded: [],
|
||||
}))).toThrow('required must be an array of strings')
|
||||
excluded: [42],
|
||||
}))).toThrow('excluded must be an array of strings')
|
||||
})
|
||||
})
|
||||
|
||||
describe('date-based pairing frontier', () => {
|
||||
const cutoff = '2026-07-14'
|
||||
|
||||
it('enforces the cutoff day and every later day, but not the preceding day', () => {
|
||||
expect(requiresPairByDate('.agents/notes/2026-07-13-before.md', cutoff)).toBe(false)
|
||||
expect(requiresPairByDate('.agents/notes/2026-07-14-at-cutoff.md', cutoff)).toBe(true)
|
||||
expect(requiresPairByDate('.agents/notes/2026-07-15-after.md', cutoff)).toBe(true)
|
||||
describe('translation scope discovery', () => {
|
||||
it.each([
|
||||
'README.md',
|
||||
'apps/cli/README.md',
|
||||
'future/subtree/readme.md',
|
||||
'packages/example/README.zh.md',
|
||||
'native/example/README.i18n.yaml',
|
||||
'.agents/notes/proposed/feature.md',
|
||||
'docs/guide.md',
|
||||
'python/guide.md',
|
||||
])('includes %s', (file) => {
|
||||
expect(isTranslationScopeFile(file)).toBe(true)
|
||||
})
|
||||
|
||||
it('matches only a date at the start of the basename', () => {
|
||||
expect(datedDocumentDate('.agents/notes/2026-07-14-proposal.md')).toBe('2026-07-14')
|
||||
expect(datedDocumentDate('docs/release-notes-2026-07-14-alpha.md')).toBeUndefined()
|
||||
expect(requiresPairByDate('docs/release-notes-2026-07-14-alpha.md', cutoff)).toBe(false)
|
||||
it.each([
|
||||
'packages/example/guide.md',
|
||||
'examples/tutorial.md',
|
||||
'website/reference.md',
|
||||
'packages/example/README.txt',
|
||||
'vendor/example/README.md',
|
||||
'packages/example/node_modules/dependency/README.md',
|
||||
'packages/example/lib/README.md',
|
||||
'coverage/report/README.md',
|
||||
'python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-macos-arm64/README.md',
|
||||
'python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/README.md',
|
||||
])('excludes non-source or non-README path %s', (file) => {
|
||||
expect(isTranslationScopeFile(file)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Pure parsing and structural helpers for the bilingual-document pairing
|
||||
* gate. Kept separate from the CLI so cutoff and signature behavior can be
|
||||
* regression-tested without reading or mutating the repository tree.
|
||||
* gate. Kept separate from the CLI so corpus discovery and signature behavior
|
||||
* can be regression-tested without reading or mutating the repository tree.
|
||||
*/
|
||||
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||
@@ -11,31 +11,79 @@ import type { Nodes } from 'mdast'
|
||||
|
||||
/** Validated shape of `scripts/translation-pairing.manifest.json`. */
|
||||
export interface TranslationPairingManifest {
|
||||
required: string[]
|
||||
/** Source documents exempt from pairing because they are generated, instructional, or bilingual by construction. */
|
||||
excluded: string[]
|
||||
/** Date-named documents on or after this day must merge bilingual. */
|
||||
requiredSince: string
|
||||
}
|
||||
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
|
||||
const DATED_DOCUMENT = /(?:^|\/)(\d{4}-\d{2}-\d{2})-[^/]*\.md$/
|
||||
const README_ARTIFACT = /(?:^|\/)readme(?:\.md|\.zh\.md|\.i18n\.yaml)$/i
|
||||
const NON_SOURCE_DIRECTORIES = new Set([
|
||||
'node_modules',
|
||||
'lib',
|
||||
'.pnpm-store',
|
||||
'.cache',
|
||||
'coverage',
|
||||
'.sessions',
|
||||
'.storages',
|
||||
'tmp',
|
||||
'dist-exe',
|
||||
'__pycache__',
|
||||
'.pytest_cache',
|
||||
'.artifacts',
|
||||
'vendor',
|
||||
])
|
||||
|
||||
/** Whether a string names one real calendar day in canonical ISO form. */
|
||||
export function isIsoDate(value: string): boolean {
|
||||
if (!ISO_DATE.test(value)) return false
|
||||
const date = new Date(`${value}T00:00:00.000Z`)
|
||||
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
|
||||
/** Glob traversal exclusions corresponding to the non-source path predicate. */
|
||||
export const TRANSLATION_SCOPE_GLOB_EXCLUDES = [
|
||||
'.agents/notes/archived/**',
|
||||
'**/node_modules/**',
|
||||
'**/lib/**',
|
||||
'**/.pnpm-store/**',
|
||||
'**/.cache/**',
|
||||
'**/coverage/**',
|
||||
'**/.doc-typecheck-*/**',
|
||||
'**/.node-next-types-*/**',
|
||||
'**/.sessions/**',
|
||||
'**/.storages/**',
|
||||
'**/tmp/**',
|
||||
'**/dist-exe/**',
|
||||
'**/__pycache__/**',
|
||||
'**/.pytest_cache/**',
|
||||
'apps/web/dist/**',
|
||||
'.artifacts/**',
|
||||
'python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-*/**',
|
||||
'python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/**',
|
||||
'vendor/**',
|
||||
]
|
||||
|
||||
/** Whether a repository-relative path belongs to a dependency or generated tree. */
|
||||
function isTranslationSourceExcluded(file: string): boolean {
|
||||
const segments = file.split('/')
|
||||
return segments.some(segment => NON_SOURCE_DIRECTORIES.has(segment)
|
||||
|| segment.startsWith('.doc-typecheck-')
|
||||
|| segment.startsWith('.node-next-types-'))
|
||||
|| file.startsWith('apps/web/dist/')
|
||||
|| file.startsWith('python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-')
|
||||
|| file.startsWith('python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/')
|
||||
}
|
||||
|
||||
/** Read one manifest string-array field or fail before enforcement starts. */
|
||||
function stringArrayField(record: Record<string, unknown>, field: 'required' | 'excluded'): string[] {
|
||||
const value = record[field]
|
||||
/** Whether one discovered Markdown or sidecar path belongs to the bilingual source corpus. */
|
||||
export function isTranslationScopeFile(file: string): boolean {
|
||||
return !file.startsWith('.agents/notes/archived/')
|
||||
&& !isTranslationSourceExcluded(file) && (README_ARTIFACT.test(file)
|
||||
|| file.startsWith('.agents/notes/')
|
||||
|| file.startsWith('docs/')
|
||||
|| file.startsWith('python/'))
|
||||
}
|
||||
|
||||
/** Read the manifest exclusion list or fail before enforcement starts. */
|
||||
function excludedField(record: Record<string, unknown>): string[] {
|
||||
const value = record.excluded
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`translation-pairing.manifest.json: ${field} must be an array of strings`)
|
||||
throw new Error('translation-pairing.manifest.json: excluded must be an array of strings')
|
||||
}
|
||||
const entries: unknown[] = value
|
||||
if (!entries.every((entry): entry is string => typeof entry === 'string')) {
|
||||
throw new Error(`translation-pairing.manifest.json: ${field} must be an array of strings`)
|
||||
throw new Error('translation-pairing.manifest.json: excluded must be an array of strings')
|
||||
}
|
||||
return entries
|
||||
}
|
||||
@@ -47,26 +95,71 @@ export function parseTranslationPairingManifest(content: string): TranslationPai
|
||||
throw new Error('translation-pairing.manifest.json: expected an object')
|
||||
}
|
||||
const record = value as Record<string, unknown>
|
||||
const requiredSince = record.requiredSince
|
||||
if (typeof requiredSince !== 'string' || !isIsoDate(requiredSince)) {
|
||||
throw new Error(`translation-pairing.manifest.json: requiredSince must be a valid YYYY-MM-DD date; got ${JSON.stringify(requiredSince)}`)
|
||||
}
|
||||
return {
|
||||
required: stringArrayField(record, 'required'),
|
||||
excluded: stringArrayField(record, 'excluded'),
|
||||
requiredSince,
|
||||
const unsupported = Object.keys(record).filter(field => field !== 'excluded')
|
||||
if (unsupported.length > 0) {
|
||||
throw new Error(`translation-pairing.manifest.json: unsupported field(s): ${unsupported.join(', ')}; every in-scope document is required`)
|
||||
}
|
||||
return { excluded: excludedField(record) }
|
||||
}
|
||||
|
||||
/** Return the leading date of a `yyyy-mm-dd-*.md` basename, if present. */
|
||||
export function datedDocumentDate(file: string): string | undefined {
|
||||
return DATED_DOCUMENT.exec(file)?.[1]
|
||||
/**
|
||||
* 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`
|
||||
}
|
||||
|
||||
/** Whether a date-named document falls on or after the pairing cutoff. */
|
||||
export function requiresPairByDate(file: string, requiredSince: string): boolean {
|
||||
const date = datedDocumentDate(file)
|
||||
return date !== undefined && date >= requiredSince
|
||||
/** 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. */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"comment": "Maps each ` ```ts type-equiv ` or ` ```ts public-api ` block (by doc + declared symbol + projection) to the source declaration and original JSDoc it must match. Omit projection for the complete declaration; use public-api with a ` ```ts public-api ` block for a body-stripped public class declaration. verify-type-equiv.ts enforces a 1:1 correspondence: every source-equivalence block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a source-equivalence block; remove it when you remove the block.",
|
||||
"comment": "Maps each primary ` ```ts type-equiv ` or ` ```ts public-api ` block (by doc + declared symbol + projection) to the source declaration and original JSDoc it must match. Paired `.zh.md` blocks are byte-identical derivatives checked through their unsuffixed sibling and have no duplicate entry. Omit projection for the complete declaration; use public-api with a ` ```ts public-api ` block for a body-stripped public class declaration. verify-type-equiv.ts enforces a 1:1 correspondence between primary blocks and entries. Add an entry when you add a primary source-equivalence block; remove it when you remove the block.",
|
||||
"entries": [
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
@@ -609,6 +609,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",
|
||||
@@ -1248,949 +1253,6 @@
|
||||
"doc": "docs/core-data-structures/session-query.md",
|
||||
"symbol": "SessionSearchHit",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "Branded",
|
||||
"source": "packages/util/brand/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "ContentBlockMap",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "AssistantProvenance",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "Message",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "MessageSourceMap",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "FinishReasonMap",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "LlmProviderInfo",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "LlmModelInfo",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "LlmModelContext",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "GenerateOptions",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "ToolSchema",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "LlmCallConfig",
|
||||
"source": "packages/llm/llm/src/call-config.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "SessionEvent",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "SendOptions",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "AgentCancelCause",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "InjectOptions",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "ResolvedAgentInput",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "AgentMessageId",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "AgentMessage",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "CancelOptions",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "Agent",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "HookContext",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "PromptDecision",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "ContinuationDecision",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "RequestError",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "RequestErrorDecision",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "ContinuationStop",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.zh.md",
|
||||
"symbol": "SessionStartSource",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/scope.zh.md",
|
||||
"symbol": "ScopeKey",
|
||||
"source": "packages/core/scope/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/scope.zh.md",
|
||||
"symbol": "Scoped",
|
||||
"source": "packages/core/scope/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/scope.zh.md",
|
||||
"symbol": "Scope",
|
||||
"source": "packages/core/scope/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/scope.zh.md",
|
||||
"symbol": "ScopeLayer",
|
||||
"source": "packages/core/scope/src/store.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/system-prompt.zh.md",
|
||||
"symbol": "AssembleContext",
|
||||
"source": "packages/core/system-prompt/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/system-prompt.zh.md",
|
||||
"symbol": "PromptSection",
|
||||
"source": "packages/core/system-prompt/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/system-prompt.zh.md",
|
||||
"symbol": "ToolProviderResult",
|
||||
"source": "packages/core/system-prompt/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/llm-streaming.zh.md",
|
||||
"symbol": "StreamChunk",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/llm-streaming.zh.md",
|
||||
"symbol": "LlmFailure",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/llm-streaming.zh.md",
|
||||
"symbol": "TokenUsage",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/llm-streaming.zh.md",
|
||||
"symbol": "ContentBlockMap",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/llm-streaming.zh.md",
|
||||
"symbol": "AppIdentity",
|
||||
"source": "packages/llm/llm/src/attribution.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/llm-streaming.zh.md",
|
||||
"symbol": "BlockAssembler",
|
||||
"source": "packages/llm/llm/src/assembler.ts",
|
||||
"projection": "public-api"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/llm-streaming.zh.md",
|
||||
"symbol": "LlmAdapter",
|
||||
"source": "packages/llm/llm/src/index.ts",
|
||||
"projection": "public-api"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.zh.md",
|
||||
"symbol": "PromptMessageData",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.zh.md",
|
||||
"symbol": "SessionEventMap",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.zh.md",
|
||||
"symbol": "OutOfBandSessionEventMap",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.zh.md",
|
||||
"symbol": "EpochHeader",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.zh.md",
|
||||
"symbol": "TodoItem",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.zh.md",
|
||||
"symbol": "SessionEvent",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.zh.md",
|
||||
"symbol": "TurnTriggerMap",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.zh.md",
|
||||
"symbol": "TurnEndReasonMap",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.zh.md",
|
||||
"symbol": "SurfaceEventType",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.zh.md",
|
||||
"symbol": "SurfaceOp",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.zh.md",
|
||||
"symbol": "SurfaceIntent",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.zh.md",
|
||||
"symbol": "SessionSurface",
|
||||
"source": "packages/core/session/src/surface.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.zh.md",
|
||||
"symbol": "SurfaceFoldReplacement",
|
||||
"source": "packages/core/session/src/surface.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.zh.md",
|
||||
"symbol": "SurfaceFoldResult",
|
||||
"source": "packages/core/session/src/surface.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.zh.md",
|
||||
"symbol": "Session",
|
||||
"source": "packages/core/session/src/index.ts",
|
||||
"projection": "public-api"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/persistence.zh.md",
|
||||
"symbol": "SessionHeader",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/persistence.zh.md",
|
||||
"symbol": "CreateSessionOptions",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/persistence.zh.md",
|
||||
"symbol": "SessionLocation",
|
||||
"source": "packages/session-persistence/session-persistence/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/persistence.zh.md",
|
||||
"symbol": "SessionPersistenceRevision",
|
||||
"source": "packages/session-persistence/session-persistence/src/revision.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/persistence.zh.md",
|
||||
"symbol": "SessionPersistenceSnapshot",
|
||||
"source": "packages/session-persistence/session-persistence/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionEventSurface",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionRecord",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionLogSnapshot",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionSurfaceSnapshot",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionTitleObservation",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionTitleObservationResult",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionEventRecord",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionResultFilter",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionEventResultFilter",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionEventSearchDocument",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionSearchCursor",
|
||||
"source": "packages/session-query/session-query/src/cursor.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionSearchRequest",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionEventSearchRequest",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionSearchPage",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionEventSearchPage",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionEventSearchHit",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionSearchHit",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionLineageNode",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionLineageTrace",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionQueryErrorCode",
|
||||
"source": "packages/session-query/session-query/src/config.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionEventReadRequest",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionEventWindow",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionEventTraceRequest",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionEventTrace",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionEventTraceObservation",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "ToolOutputDefinition",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "ToolDefinition",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "ValueSchemaSpec",
|
||||
"source": "packages/core/tools/src/schema.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "ParameterPropertySpec",
|
||||
"source": "packages/core/tools/src/schema.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "ParameterSchemaSpec",
|
||||
"source": "packages/core/tools/src/schema.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "InferValue",
|
||||
"source": "packages/core/tools/src/schema.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "InferArgs",
|
||||
"source": "packages/core/tools/src/schema.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "ToolExecutionToken",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "ToolExecutionInput",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "ToolExecution",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "ToolDispatchExecution",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "ToolExecutionMode",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "ToolRunContext",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "ToolGuard",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "ToolRestriction",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "ToolFailure",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "ToolExecutionSuccess",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "ToolExecutionFailure",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "ToolExecutionResult",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "PreToolDecision",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "PostToolDecision",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "JsonSchemaScalar",
|
||||
"source": "packages/core/tools/src/json-schema.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "JsonSchemaType",
|
||||
"source": "packages/core/tools/src/json-schema.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "JsonSchemaNode",
|
||||
"source": "packages/core/tools/src/json-schema.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "ObjectJsonSchema",
|
||||
"source": "packages/core/tools/src/json-schema.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/user-interaction.zh.md",
|
||||
"symbol": "AskUserQuestionOption",
|
||||
"source": "packages/ui/user-interaction/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/user-interaction.zh.md",
|
||||
"symbol": "AskUserQuestionItem",
|
||||
"source": "packages/ui/user-interaction/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/user-interaction.zh.md",
|
||||
"symbol": "AskUserQuestionRequest",
|
||||
"source": "packages/ui/user-interaction/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/user-interaction.zh.md",
|
||||
"symbol": "AskUserQuestionAnswerItem",
|
||||
"source": "packages/ui/user-interaction/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/user-interaction.zh.md",
|
||||
"symbol": "AskUserQuestionAnswer",
|
||||
"source": "packages/ui/user-interaction/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/user-interaction.zh.md",
|
||||
"symbol": "UserInteractionProvider",
|
||||
"source": "packages/ui/user-interaction/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/user-interaction.zh.md",
|
||||
"symbol": "UserInteractionError",
|
||||
"source": "packages/ui/user-interaction/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/approval.zh.md",
|
||||
"symbol": "ApprovalRequestId",
|
||||
"source": "packages/ui/user-approval/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/approval.zh.md",
|
||||
"symbol": "ApprovalOutcome",
|
||||
"source": "packages/ui/user-approval/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/approval.zh.md",
|
||||
"symbol": "ApprovalPolicy",
|
||||
"source": "packages/ui/user-approval/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/approval.zh.md",
|
||||
"symbol": "ApprovalRequest",
|
||||
"source": "packages/ui/user-approval/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/bash.zh.md",
|
||||
"symbol": "DshEnvironmentKey",
|
||||
"source": "packages/bash/bash/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/bash.zh.md",
|
||||
"symbol": "DshEnvironment",
|
||||
"source": "packages/bash/bash/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/bash.zh.md",
|
||||
"symbol": "BashExecRequest",
|
||||
"source": "packages/bash/bash/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/bash.zh.md",
|
||||
"symbol": "BashExecSpec",
|
||||
"source": "packages/bash/bash/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/bash.zh.md",
|
||||
"symbol": "BashRunResult",
|
||||
"source": "packages/bash/bash/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/bash.zh.md",
|
||||
"symbol": "BashSandboxInfo",
|
||||
"source": "packages/bash/bash/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/bash.zh.md",
|
||||
"symbol": "CollectedOutput",
|
||||
"source": "packages/bash/bash/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/bash.zh.md",
|
||||
"symbol": "BashProcess",
|
||||
"source": "packages/bash/bash/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/bash.zh.md",
|
||||
"symbol": "BashProcessRead",
|
||||
"source": "packages/bash/bash/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/sandbox.zh.md",
|
||||
"symbol": "SandboxMode",
|
||||
"source": "packages/sandbox/sandbox/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/sandbox.zh.md",
|
||||
"symbol": "ConfinedSandboxMode",
|
||||
"source": "packages/sandbox/sandbox/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/sandbox.zh.md",
|
||||
"symbol": "SandboxExecutionPolicy",
|
||||
"source": "packages/sandbox/sandbox/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/sandbox.zh.md",
|
||||
"symbol": "SandboxEnforcement",
|
||||
"source": "packages/sandbox/sandbox/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/sandbox.zh.md",
|
||||
"symbol": "SandboxPolicy",
|
||||
"source": "packages/sandbox/sandbox/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/sandbox.zh.md",
|
||||
"symbol": "SandboxPolicyRequest",
|
||||
"source": "packages/sandbox/sandbox-policy/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/sandbox.zh.md",
|
||||
"symbol": "ConfinedArgv",
|
||||
"source": "packages/sandbox/sandbox/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/code-runtime.zh.md",
|
||||
"symbol": "CodeJsonValue",
|
||||
"source": "packages/code-runtime/code-runtime/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/code-runtime.zh.md",
|
||||
"symbol": "CodeRunRequest",
|
||||
"source": "packages/code-runtime/code-runtime/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/code-runtime.zh.md",
|
||||
"symbol": "CodeRunResult",
|
||||
"source": "packages/code-runtime/code-runtime/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/code-runtime.zh.md",
|
||||
"symbol": "CodeBindingNamespace",
|
||||
"source": "packages/code-runtime/code-runtime/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/code-runtime.zh.md",
|
||||
"symbol": "CodeBindingErrorClass",
|
||||
"source": "packages/code-runtime/code-runtime/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/code-runtime.zh.md",
|
||||
"symbol": "CodeBindingFunction",
|
||||
"source": "packages/code-runtime/code-runtime/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/code-runtime.zh.md",
|
||||
"symbol": "CodeRunFailure",
|
||||
"source": "packages/code-runtime/code-runtime/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/filesystem.zh.md",
|
||||
"symbol": "FsTarget",
|
||||
"source": "packages/fs/fs/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/filesystem.zh.md",
|
||||
"symbol": "FsTargetKey",
|
||||
"source": "packages/fs/fs/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/filesystem.zh.md",
|
||||
"symbol": "FsVersion",
|
||||
"source": "packages/fs/fs/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/filesystem.zh.md",
|
||||
"symbol": "FsInfo",
|
||||
"source": "packages/fs/fs/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/filesystem.zh.md",
|
||||
"symbol": "FsPathInfo",
|
||||
"source": "packages/fs/fs/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/filesystem.zh.md",
|
||||
"symbol": "FsDirEntry",
|
||||
"source": "packages/fs/fs/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/filesystem.zh.md",
|
||||
"symbol": "FsWriteIntent",
|
||||
"source": "packages/fs/fs/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/filesystem.zh.md",
|
||||
"symbol": "FsWriteOutcome",
|
||||
"source": "packages/fs/fs/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/filesystem.zh.md",
|
||||
"symbol": "FsEditRequest",
|
||||
"source": "packages/fs/fs/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/filesystem.zh.md",
|
||||
"symbol": "FsEditOutcome",
|
||||
"source": "packages/fs/fs/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/filesystem.zh.md",
|
||||
"symbol": "FsErrorCode",
|
||||
"source": "packages/fs/fs/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/filesystem.zh.md",
|
||||
"symbol": "FsPolicyExec",
|
||||
"source": "packages/fs/fs-policy/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/filesystem.zh.md",
|
||||
"symbol": "FileReadOutcome",
|
||||
"source": "packages/fs/tool-fs/src/read-render.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/skills.zh.md",
|
||||
"symbol": "SkillSource",
|
||||
"source": "packages/skill/skill/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/skills.zh.md",
|
||||
"symbol": "SkillResourceBase",
|
||||
"source": "packages/skill/skill/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/skills.zh.md",
|
||||
"symbol": "SkillSummary",
|
||||
"source": "packages/skill/skill/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/skills.zh.md",
|
||||
"symbol": "SkillCandidate",
|
||||
"source": "packages/skill/skill/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/skills.zh.md",
|
||||
"symbol": "SkillDefinition",
|
||||
"source": "packages/skill/skill/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/skills.zh.md",
|
||||
"symbol": "SkillRegistration",
|
||||
"source": "packages/skill/skill/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/skills.zh.md",
|
||||
"symbol": "SkillLookupOptions",
|
||||
"source": "packages/skill/skill/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/skills.zh.md",
|
||||
"symbol": "SkillProvider",
|
||||
"source": "packages/skill/skill/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/skills.zh.md",
|
||||
"symbol": "Config",
|
||||
"source": "packages/skill/skill/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/compaction.zh.md",
|
||||
"symbol": "CompactionResult",
|
||||
"source": "packages/compact/compact/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/compaction.zh.md",
|
||||
"symbol": "CompactionTrigger",
|
||||
"source": "packages/compact/compact/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/compaction.zh.md",
|
||||
"symbol": "PrunedEntry",
|
||||
"source": "packages/compact/compact-tool-result-prune/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/compaction.zh.md",
|
||||
"symbol": "PruneResult",
|
||||
"source": "packages/compact/compact-tool-result-prune/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.zh.md",
|
||||
"symbol": "SubagentCapabilities",
|
||||
"source": "packages/subagent/subagent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.zh.md",
|
||||
"symbol": "SubagentStartRequest",
|
||||
"source": "packages/subagent/subagent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.zh.md",
|
||||
"symbol": "SubagentResult",
|
||||
"source": "packages/subagent/subagent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.zh.md",
|
||||
"symbol": "SubagentStopReasonMap",
|
||||
"source": "packages/subagent/subagent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.zh.md",
|
||||
"symbol": "SubagentRun",
|
||||
"source": "packages/subagent/subagent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.zh.md",
|
||||
"symbol": "SubagentProvider",
|
||||
"source": "packages/subagent/subagent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/web.zh.md",
|
||||
"symbol": "WebSearchRequest",
|
||||
"source": "packages/web/web/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/web.zh.md",
|
||||
"symbol": "WebSearchResult",
|
||||
"source": "packages/web/web/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/web.zh.md",
|
||||
"symbol": "WebSearchSource",
|
||||
"source": "packages/web/web/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/web.zh.md",
|
||||
"symbol": "WebFetchRequest",
|
||||
"source": "packages/web/web/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/web.zh.md",
|
||||
"symbol": "WebFetchResult",
|
||||
"source": "packages/web/web/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/web.zh.md",
|
||||
"symbol": "WebFetchBody",
|
||||
"source": "packages/web/web/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/workflow.zh.md",
|
||||
"symbol": "WorkflowStartRequest",
|
||||
"source": "packages/workflow/workflow/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/workflow.zh.md",
|
||||
"symbol": "WorkflowMeta",
|
||||
"source": "packages/workflow/workflow/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/workflow.zh.md",
|
||||
"symbol": "WorkflowResult",
|
||||
"source": "packages/workflow/workflow/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/workflow.zh.md",
|
||||
"symbol": "WorkflowRun",
|
||||
"source": "packages/workflow/workflow/src/types.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
115
scripts/verify-archived-agent-notes.ts
Normal file
115
scripts/verify-archived-agent-notes.ts
Normal 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).`)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
|
||||
import { existsSync, readdirSync } 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
|
||||
|
||||
@@ -56,11 +56,16 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'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-model-selector': { 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.' },
|
||||
'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/i18n': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-settings': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-settings-general': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-models': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' },
|
||||
'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' },
|
||||
@@ -94,6 +99,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.' },
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* Enforce complete English/Chinese pairs, matching structure, and recorded git
|
||||
* blob hashes under the bilingual manifest. Required files and date-named docs
|
||||
* at or after `requiredSince` must be paired; excluded docs may have neither a
|
||||
* counterpart nor sidecar. `--list` reports state and `--write` records both
|
||||
* sides after human review. Translation quality remains a review responsibility.
|
||||
* 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; `--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.
|
||||
*/
|
||||
|
||||
@@ -11,30 +13,33 @@ import { createHash } from 'node:crypto'
|
||||
import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { basename, join, resolve, sep } from 'node:path'
|
||||
import {
|
||||
datedDocumentDate,
|
||||
linksTo,
|
||||
parseTranslationMarkdown,
|
||||
parseTranslationPairingCliArgs,
|
||||
parseTranslationPairingManifest,
|
||||
requiresPairByDate,
|
||||
isTranslationScopeFile,
|
||||
TRANSLATION_SCOPE_GLOB_EXCLUDES,
|
||||
translationStructureDiff,
|
||||
translationStructureSignature,
|
||||
} 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'
|
||||
|
||||
/** Scope of the bilingual contract: root docs, Agent Notes, the docs tree, and the Python SDK tree. */
|
||||
/** Discover source Markdown and pairing sidecars before applying the corpus predicate. */
|
||||
const SCOPE_PATTERNS = [
|
||||
'README.md',
|
||||
'README.zh.md',
|
||||
'README.i18n.yaml',
|
||||
'**/*.md',
|
||||
'**/*.i18n.yaml',
|
||||
'.agents/notes/**/*.md',
|
||||
'.agents/notes/**/*.i18n.yaml',
|
||||
'docs/**/*.md',
|
||||
'docs/**/*.i18n.yaml',
|
||||
'python/**/*.md',
|
||||
'python/**/*.i18n.yaml',
|
||||
]
|
||||
|
||||
const manifest = parseTranslationPairingManifest(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8'))
|
||||
@@ -82,29 +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 })) files.add(match.split(sep).join('/'))
|
||||
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)
|
||||
@@ -118,34 +161,17 @@ if (writeMode) {
|
||||
const errors: string[] = []
|
||||
const state = new Map<string, 'ok' | 'out-of-sync' | 'missing'>()
|
||||
|
||||
// 1. Required pairs exist.
|
||||
for (const req of manifest.required) {
|
||||
if (!existsSync(join(root, req))) {
|
||||
errors.push(`${req}: listed in translation-pairing.manifest.json \`required\` but the file does not exist`)
|
||||
continue
|
||||
}
|
||||
const { zh } = pairPaths(req)
|
||||
if (!existsSync(join(root, zh))) {
|
||||
errors.push(`${req}: required to have a translation, but ${zh} does not exist`)
|
||||
state.set(req, 'missing')
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Date-named documents (Agent Notes) dated on/after the requiredSince cutoff merge
|
||||
// bilingual: a new Agent Note lands with its pair or not at all. Deterministic from
|
||||
// the filename alone — no git history, so it holds on shallow CI checkouts.
|
||||
// 1. Every discovered, non-excluded source merges bilingual.
|
||||
for (const source of sources) {
|
||||
if (isExcluded(source)) continue
|
||||
const date = datedDocumentDate(source)
|
||||
if (!requiresPairByDate(source, manifest.requiredSince) || date === undefined) continue
|
||||
const { zh } = pairPaths(source)
|
||||
if (!existsSync(join(root, zh))) {
|
||||
errors.push(`${source}: dated ${date} — documents dated on/after ${manifest.requiredSince} merge bilingual (docs/i18n/README.md); add the counterpart and record the pair`)
|
||||
errors.push(`${source}: in-scope documentation must merge bilingual (docs/i18n/README.md); add the counterpart and record the pair`)
|
||||
state.set(source, 'missing')
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Every pair that exists at all is complete and consistent. Anchor on the
|
||||
// 2. Every pair that exists at all is complete and consistent. Anchor on the
|
||||
// union of .zh.md files and .i18n.yaml records so a half-deleted pair is
|
||||
// caught from either remnant.
|
||||
const pairAnchors = new Set<string>()
|
||||
@@ -205,7 +231,7 @@ for (const source of [...pairAnchors].sort()) {
|
||||
if (!state.has(source)) state.set(source, 'ok')
|
||||
}
|
||||
|
||||
// Complete the state map for --list: any in-scope, non-excluded document with no pair yet is backlog.
|
||||
// Complete the state map for --list: any in-scope, non-excluded document with no pair is missing.
|
||||
for (const source of sources) {
|
||||
if (!isExcluded(source) && !state.has(source)) state.set(source, 'missing')
|
||||
}
|
||||
@@ -214,9 +240,7 @@ if (listMode) {
|
||||
const order = { 'out-of-sync': 0, missing: 1, ok: 2 } as const
|
||||
const rows = [...state.entries()].sort((a, b) => order[a[1]] - order[b[1]] || a[0].localeCompare(b[0]))
|
||||
for (const [file, status] of rows) {
|
||||
const required = manifest.required.includes(file)
|
||||
const tag = required ? ' (required)' : requiresPairByDate(file, manifest.requiredSince) ? ' (required by date)' : ' (backlog)'
|
||||
console.log(`${status.padEnd(11)} ${file}${status === 'missing' ? tag : ''}`)
|
||||
console.log(`${status.padEnd(11)} ${file}${status === 'missing' ? ' (required)' : ''}`)
|
||||
}
|
||||
const counts = { 'ok': 0, 'out-of-sync': 0, 'missing': 0 }
|
||||
for (const status of state.values()) counts[status]++
|
||||
@@ -225,7 +249,9 @@ if (listMode) {
|
||||
}
|
||||
|
||||
if (errors.length === 0) {
|
||||
console.log(`verify-translation-pairing: ${pairAnchors.size} pair(s) checked against ${manifest.required.length} required, 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
* declaration; `public-api` entries preserve a class's body-stripped public
|
||||
* declaration. Blocks and entries have a one-to-one relationship; comparison
|
||||
* ignores whitespace and non-JSDoc comments but preserves declaration
|
||||
* structure and every original JSDoc comment.
|
||||
* structure and every original JSDoc comment. Byte-identical `.zh.md` blocks
|
||||
* reuse the manifest-backed check of their unsuffixed sibling.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { partitionPairedMarkdownDerivatives } from './paired-markdown-derivatives.ts'
|
||||
import { isArchivedAgentNotePath } from './repo-files.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
@@ -221,9 +224,17 @@ 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 blocks: EquivBlock[] = [...docSet].sort().flatMap(extractEquivBlocks)
|
||||
const extractedBlocks: EquivBlock[] = [...docSet].sort().flatMap(extractEquivBlocks)
|
||||
const { primary: blocks, derivatives } = partitionPairedMarkdownDerivatives(
|
||||
extractedBlocks,
|
||||
block => block.doc,
|
||||
block => `${block.projection ?? 'declaration'}\0${block.code}`,
|
||||
)
|
||||
|
||||
const errors: string[] = []
|
||||
// A manifest entry naming a doc that does not exist (or is outside the scanned
|
||||
@@ -299,11 +310,11 @@ for (const e of entries) {
|
||||
}
|
||||
|
||||
if (errors.length === 0) {
|
||||
console.log(`verify-type-equiv: ${verified} type-equiv block(s) match source structure and JSDoc (1:1 with manifest).`)
|
||||
console.log(`verify-type-equiv: ${verified} type-equiv block(s) match source structure and JSDoc (1:1 with manifest); ${derivatives.length} paired derivative(s).`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.error('verify-type-equiv: type-equiv verification failed:')
|
||||
for (const e of errors) console.error(` ${e}`)
|
||||
console.error(`\n(checked ${blocks.length} block(s) across ${new Set(blocks.map(b => b.doc)).size} doc(s); manifest at scripts/type-equiv.manifest.json)`)
|
||||
console.error(`\n(checked ${blocks.length} primary block(s) across ${new Set(blocks.map(b => b.doc)).size} doc(s), ${derivatives.length} paired derivative(s); manifest at scripts/type-equiv.manifest.json)`)
|
||||
process.exit(1)
|
||||
|
||||
Reference in New Issue
Block a user