mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into worktree/drop-create-by-name
# Conflicts: # .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml # .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml # .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md # .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml # .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md # .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md # apps/cli/reference/README.i18n.yaml # docs/config-catalog.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/api-proxy-approval.spec.ts # packages/host/apiproxy/tests/api-proxy-blank.spec.ts # packages/host/apiproxy/tests/api-proxy-cold.spec.ts # packages/host/apiproxy/tests/api-proxy-commands.spec.ts # packages/host/apiproxy/tests/api-proxy-config.spec.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/host/apiproxy/tests/api-proxy-projections.spec.ts # packages/host/apiproxy/tests/api-proxy-question.spec.ts # packages/host/apiproxy/tests/api-proxy-rename.spec.ts # packages/host/apiproxy/tests/api-proxy-search.spec.ts # packages/host/apiproxy/tests/api-proxy-subagents.spec.ts # packages/host/apiproxy/tests/api-proxy-view.spec.ts # packages/host/apiproxy/tests/api-proxy-workspace.spec.ts # packages/todo/tool-todo/tests/projection.spec.ts # scripts/hero-composer-dom-continuity.mjs
This commit is contained in:
@@ -326,7 +326,7 @@ class SingleExeBuild {
|
||||
if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${stagedBuild}`)
|
||||
else await rm(stagedBuild, { recursive: true, force: true })
|
||||
if (target.platform !== 'linux') return
|
||||
const source = join(root, 'packages', 'pty', 'pty-local', 'node_modules', 'node-pty', 'build', 'Release', 'pty.node')
|
||||
const source = join(root, 'packages', 'subprocess', 'subprocess-local', 'node_modules', 'node-pty', 'build', 'Release', 'pty.node')
|
||||
const destination = join(stagedBuild, 'Release', 'pty.node')
|
||||
if (this.cli.dryRun) {
|
||||
console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`)
|
||||
|
||||
@@ -97,14 +97,14 @@ function repositoryState(root: string): Record<string, string> {
|
||||
}
|
||||
|
||||
describe('change-scope', () => {
|
||||
it('uses an explicit base on a fresh branch without a same-name remote and after its first push', () => {
|
||||
it('uses an explicit base on a fresh branch without a same-name remote and after its first push', { timeout: 20_000 }, () => {
|
||||
const { root } = fixture()
|
||||
git(root, ['switch', '-c', 'feature'])
|
||||
git(root, ['branch', '--set-upstream-to=origin/master'])
|
||||
const headSha = commit(root, 'feature.txt', 'feature\n')
|
||||
|
||||
const fresh = jsonReport(root, 'origin/master')
|
||||
expect(fresh.repositoryRoot).toBe(realpathSync(root))
|
||||
expect(realpathSync.native(fresh.repositoryRoot)).toBe(realpathSync.native(root))
|
||||
expect(fresh.resolved).toEqual({
|
||||
baseSha: git(root, ['rev-parse', 'origin/master']),
|
||||
headSha,
|
||||
@@ -122,7 +122,7 @@ describe('change-scope', () => {
|
||||
const { root } = fixture('worktree ')
|
||||
const report = jsonReport(root, 'HEAD')
|
||||
|
||||
expect(report.repositoryRoot).toBe(realpathSync(root))
|
||||
expect(realpathSync.native(report.repositoryRoot)).toBe(realpathSync.native(root))
|
||||
expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] })
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
||||
import { join, relative, resolve } from 'node:path'
|
||||
import { isForbiddenPublicationFile } from './publication-payload.ts'
|
||||
import { hasTypeRTRemoteNavigation, isForbiddenPublicationFile } from './publication-payload.ts'
|
||||
import { collectProjectReferenceFaceViolations } from './project-reference-faces.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
// vendor/* is single-level; packages/<group>/<pkg> nests one level deeper
|
||||
@@ -15,6 +16,8 @@ const root = resolve(import.meta.dirname, '..')
|
||||
const workspaceGlobs = [
|
||||
{ dir: 'vendor', depth: 1 },
|
||||
{ dir: 'packages', depth: 2 },
|
||||
{ dir: 'native', depth: 1 },
|
||||
{ dir: 'native/landlock-run/packages', depth: 1 },
|
||||
{ dir: 'apps', depth: 1 },
|
||||
] as const
|
||||
const vendoredPackages = new Set([
|
||||
@@ -28,6 +31,16 @@ const vendoredPackages = new Set([
|
||||
'@cordisjs/plugin-hmr',
|
||||
'@cordisjs/plugin-logger-console',
|
||||
])
|
||||
const publicLandlockPackages = new Set([
|
||||
'@deepseek-ai/node-addon-landlock-run',
|
||||
'@deepseek-ai/node-addon-landlock-run-linux-arm64',
|
||||
'@deepseek-ai/node-addon-landlock-run-linux-x64',
|
||||
])
|
||||
/** Deliberate source payloads whose exact bytes are part of the package's audit surface. */
|
||||
const publicationSourceAllowlist: Readonly<Record<string, readonly string[]>> = {
|
||||
'@deepseek-ai/node-addon-landlock-run': ['src/main.c'],
|
||||
}
|
||||
const repositoryUrl = 'git+https://github.com/deepseek-harness/deepseek-harness.git'
|
||||
|
||||
const localArtifactDirs = new Set(['node_modules'])
|
||||
const appPackageFiles: Readonly<Record<string, readonly string[]>> = {
|
||||
@@ -55,6 +68,8 @@ interface PackageManifest {
|
||||
| undefined
|
||||
>
|
||||
files?: string[]
|
||||
publishConfig?: { access?: string }
|
||||
repository?: { type?: string; url?: string; directory?: string }
|
||||
peerDependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
}
|
||||
@@ -71,6 +86,8 @@ function readJson(path: string): PackageManifest {
|
||||
|
||||
const rootManifest = readJson(join(root, 'package.json'))
|
||||
const repositoryVersion = rootManifest.version
|
||||
const landlockWorkspaceManifest = readJson(join(root, 'native/landlock-run/package.json'))
|
||||
const landlockVersion = landlockWorkspaceManifest.version
|
||||
|
||||
/** Repo-relative dirs holding a package.json, walked to the configured depth. */
|
||||
function packageDirs(base: string, depth: number): string[] {
|
||||
@@ -79,12 +96,12 @@ function packageDirs(base: string, depth: number): string[] {
|
||||
.filter(entry => entry.isDirectory())
|
||||
.filter(entry => !localArtifactDirs.has(entry.name))
|
||||
.filter(entry => existsSync(join(root, base, entry.name, 'package.json')))
|
||||
.map(entry => join(base, entry.name))
|
||||
.map(entry => `${base}/${entry.name}`)
|
||||
}
|
||||
return readdirSync(join(root, base), { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory())
|
||||
.filter(entry => !localArtifactDirs.has(entry.name))
|
||||
.flatMap(group => packageDirs(join(base, group.name), depth - 1))
|
||||
.flatMap(group => packageDirs(`${base}/${group.name}`, depth - 1))
|
||||
}
|
||||
|
||||
function workspaceManifests(): WorkspaceManifest[] {
|
||||
@@ -102,13 +119,18 @@ function workspaceManifests(): WorkspaceManifest[] {
|
||||
}
|
||||
|
||||
const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
|
||||
// Profile bundles publish their dsh.bundle.patch layer beside the lib.
|
||||
'@deepseek-ai/dsh-base': ['cordis.patch.yml'],
|
||||
// Profile bundles publish their dsh.bundle.patch layer beside the lib;
|
||||
// dsh-base also ships the win32 shell platform layer the launcher reads.
|
||||
'@deepseek-ai/dsh-base': ['cordis.patch.yml', 'windows.cordis.patch.yml'],
|
||||
'@deepseek-ai/dsh-web-app': ['cordis.patch.yml'],
|
||||
'@deepseek-ai/dsh-headless': ['cordis.patch.yml'],
|
||||
'@deepseek-ai/dsh-client-ui-theme': ['lib/styles'],
|
||||
'@deepseek-ai/dsh-helper': ['lib/assets'],
|
||||
'@deepseek-ai/dsh-pty-local': ['scripts/ensure-spawn-helper.mjs'],
|
||||
// The argv-prefix runner entry ships beside the lib as its own bundle;
|
||||
// sandbox-local resolves it through the package's ./runner export.
|
||||
'@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js'],
|
||||
'@deepseek-ai/dsh-skill-badge': ['assets'],
|
||||
'@deepseek-ai/dsh-subprocess-local': ['scripts/ensure-spawn-helper.mjs'],
|
||||
'@deepseek-ai/dsh-scripts': [
|
||||
'lib/dev/tsdown-config.js',
|
||||
'lib/local-plugin-loader-hooks.js',
|
||||
@@ -122,6 +144,7 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl
|
||||
|
||||
function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] {
|
||||
const extras = manifest.name ? packageFileExtras[manifest.name] ?? [] : []
|
||||
const typeRTRemoteNavigation = hasTypeRTRemoteNavigation(manifest)
|
||||
return [
|
||||
'lib/index.js',
|
||||
// Every package publishes its invariant ownership companion as a separate
|
||||
@@ -145,9 +168,37 @@ function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] {
|
||||
// declarations.
|
||||
...usesEmittedTreeDefaults(manifest) ? ['lib/types/**/*.js'] : [],
|
||||
'lib/types/**/*.d.ts',
|
||||
...hasExportPair(manifest, './typert', './lib/typert.host.d.ts', './lib/typert.host.js')
|
||||
? ['lib/typert.host.js', 'lib/typert.host.d.ts']
|
||||
: [],
|
||||
...hasExportPair(manifest, './client/typert', './lib/typert.client.d.ts', './lib/typert.client.js')
|
||||
? ['lib/typert.client.js', 'lib/typert.client.d.ts']
|
||||
: [],
|
||||
...typeRTRemoteNavigation
|
||||
? [
|
||||
'lib/typert.remote-client.js',
|
||||
'lib/typert.remote-client.d.ts',
|
||||
'lib/typert.remote-client.d.ts.map',
|
||||
'src',
|
||||
]
|
||||
: [],
|
||||
]
|
||||
}
|
||||
|
||||
/** Whether one conditional export exactly names the generated runtime and declaration pair. */
|
||||
function hasExportPair(
|
||||
manifest: PackageManifest,
|
||||
subpath: string,
|
||||
types: string,
|
||||
runtime: string,
|
||||
): boolean {
|
||||
const entry = manifest.exports?.[subpath]
|
||||
return typeof entry === 'object'
|
||||
&& entry !== null
|
||||
&& entry.types === types
|
||||
&& entry.default === runtime
|
||||
}
|
||||
|
||||
/** Runtime target of an export entry: conditional `default`, or the bare-string shorthand. */
|
||||
function exportDefault(manifest: PackageManifest, subpath: string): string | undefined {
|
||||
const entry = manifest.exports?.[subpath]
|
||||
@@ -165,8 +216,25 @@ function usesEmittedTreeDefaults(manifest: PackageManifest): boolean {
|
||||
function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
|
||||
const errors: string[] = []
|
||||
const label = manifest.name ?? dir
|
||||
const isLandlockPackageDir = dir.startsWith('native/landlock-run/packages/')
|
||||
const isPublicLandlockPackage = isLandlockPackageDir
|
||||
&& manifest.name !== undefined
|
||||
&& publicLandlockPackages.has(manifest.name)
|
||||
|
||||
if (manifest.private !== true) {
|
||||
if (isPublicLandlockPackage) {
|
||||
if (manifest.private === true) {
|
||||
errors.push(`${label}: published Landlock package must not set "private": true`)
|
||||
}
|
||||
if (manifest.publishConfig?.access !== 'public') {
|
||||
errors.push(`${label}: published Landlock package must set publishConfig.access to "public"`)
|
||||
}
|
||||
const expectedDirectory = dir
|
||||
if (manifest.repository?.type !== 'git'
|
||||
|| manifest.repository.url !== repositoryUrl
|
||||
|| manifest.repository.directory !== expectedDirectory) {
|
||||
errors.push(`${label}: published Landlock package repository must use ${repositoryUrl} with directory ${expectedDirectory} for trusted publishing`)
|
||||
}
|
||||
} else if (manifest.private !== true) {
|
||||
errors.push(`${label}: package.json must set "private": true`)
|
||||
}
|
||||
|
||||
@@ -175,8 +243,10 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
|
||||
}
|
||||
|
||||
if (manifest.name?.startsWith('@deepseek-ai/')) {
|
||||
const allowedSources = publicationSourceAllowlist[manifest.name] ?? []
|
||||
const publicationPolicy = { typeRTRemoteNavigation: hasTypeRTRemoteNavigation(manifest) }
|
||||
for (const file of manifest.files ?? []) {
|
||||
if (isForbiddenPublicationFile(file)) {
|
||||
if (isForbiddenPublicationFile(file, publicationPolicy) && !allowedSources.includes(file)) {
|
||||
errors.push(`${label}: package.json files must not publish ${JSON.stringify(file)}`)
|
||||
}
|
||||
}
|
||||
@@ -191,6 +261,15 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
if (isLandlockPackageDir) {
|
||||
if (!isPublicLandlockPackage) {
|
||||
errors.push(`${label}: unexpected package in the public Landlock package family`)
|
||||
}
|
||||
if (manifest.version !== landlockVersion) {
|
||||
errors.push(`${label}: package.json version must match Landlock workspace version ${landlockVersion ?? '(missing)'}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (dir.startsWith('packages/') && manifest.name?.startsWith('@deepseek-ai/dsh-')) {
|
||||
const peer = manifest.peerDependencies?.cordis
|
||||
const dev = manifest.devDependencies?.cordis
|
||||
@@ -275,6 +354,7 @@ const errors = [
|
||||
...checkRepositoryVersion(),
|
||||
...workspaceManifests().flatMap(checkWorkspace),
|
||||
...checkHierarchyShape(),
|
||||
...collectProjectReferenceFaceViolations(root),
|
||||
]
|
||||
if (errors.length > 0) {
|
||||
console.error(errors.join('\n'))
|
||||
|
||||
@@ -26,8 +26,120 @@ describe('CI workflow', () => {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps Wine blocking while native Windows reports independently', () => {
|
||||
const workflow = loadWorkflow('.github/workflows/ci.yml')
|
||||
if (!isRecord(workflow.jobs)
|
||||
|| !isRecord(workflow.jobs.windows)
|
||||
|| !isRecord(workflow.jobs['windows-native'])
|
||||
|| !isRecord(workflow.jobs['all-checks-passed'])) {
|
||||
throw new TypeError('CI workflow must define Wine, native Windows, and aggregate jobs')
|
||||
}
|
||||
|
||||
const windows = workflow.jobs.windows
|
||||
const windowsNative = workflow.jobs['windows-native']
|
||||
const aggregate = workflow.jobs['all-checks-passed']
|
||||
if (!Array.isArray(windows.steps) || !Array.isArray(windowsNative.steps) || !Array.isArray(aggregate.needs)) {
|
||||
throw new TypeError('Windows jobs must define steps and the aggregate must define needs')
|
||||
}
|
||||
const nativeCommandSteps = windowsNative.steps.filter((step): step is Record<string, unknown> & { run: string } => (
|
||||
isRecord(step) && typeof step.run === 'string'
|
||||
))
|
||||
|
||||
expect(windows['runs-on']).toBe('ubuntu-latest')
|
||||
expect(windows.name).toBe('windows node 24 / wine blocking')
|
||||
expect(windows.if).toBe("github.event_name == 'pull_request'")
|
||||
expect(JSON.stringify(windows)).toContain('bash scripts/wine-windows-gates.sh')
|
||||
expect(workflow.jobs).toHaveProperty('wine-apt-cache')
|
||||
expect(windowsNative['runs-on']).toBe('dsh-windows-2025-16core')
|
||||
expect(windowsNative.name).toBe('windows node 24 / native complete')
|
||||
expect(windowsNative['timeout-minutes']).toBe(60)
|
||||
expect(windowsNative.if).toBe("github.event_name == 'pull_request'")
|
||||
expect(windowsNative.env).toMatchObject({
|
||||
DSH_COVERAGE_MAX_WORKERS: '2',
|
||||
DSH_GATE_CONCURRENCY: '2',
|
||||
DSH_PUBLINT_CONCURRENCY: '8',
|
||||
})
|
||||
expect(windowsNative).not.toHaveProperty('continue-on-error')
|
||||
expect(nativeCommandSteps).toHaveLength(3)
|
||||
expect(nativeCommandSteps.every(step => step.shell === 'pwsh')).toBe(true)
|
||||
expect(nativeCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete')
|
||||
expect(JSON.stringify(windowsNative)).not.toMatch(/wine/i)
|
||||
expect(aggregate.needs).toContain('windows')
|
||||
expect(aggregate.needs).not.toContain('windows-native')
|
||||
})
|
||||
|
||||
it('keeps supported LSP source under native Windows coverage', () => {
|
||||
const config = readFileSync(resolve(root, 'vitest.config.ts'), 'utf8')
|
||||
|
||||
expect(config).not.toContain('packages/lsp/lsp-local/src/connection.ts')
|
||||
expect(config).not.toContain('packages/lsp/lsp-local/src/index.ts')
|
||||
expect(config).not.toContain('packages/lsp/lsp-local/src/instance.ts')
|
||||
})
|
||||
|
||||
it('keeps every Vitest project process-isolated on native Windows', () => {
|
||||
const config = readFileSync(resolve(root, 'vitest.config.ts'), 'utf8')
|
||||
|
||||
expect(config).not.toContain("pool: process.platform === 'win32' ? 'threads' : 'forks'")
|
||||
expect(config.match(/pool: 'forks'/g)).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('E2B e2e workflow', () => {
|
||||
it('is manual-only and fails loud before running the focused live suite', () => {
|
||||
const workflow = loadWorkflow('.github/workflows/e2b-e2e.yml')
|
||||
expect(workflow.on).toEqual({ workflow_dispatch: null })
|
||||
if (!isRecord(workflow.jobs) || !isRecord(workflow.jobs.e2b) || !Array.isArray(workflow.jobs.e2b.steps)) {
|
||||
throw new TypeError('E2B e2e workflow must define the e2b job steps')
|
||||
}
|
||||
|
||||
const steps = workflow.jobs.e2b.steps.filter(isRecord)
|
||||
const preflight = steps.find(step => step.name === 'Preflight (require E2B API key)')
|
||||
const e2b = steps.find(step => step.name === 'E2B tests (live sandbox)')
|
||||
|
||||
expect(preflight).toMatchObject({
|
||||
env: { E2B_API_KEY: '${{ secrets.E2B_API_KEY_EXTERNAL }}' },
|
||||
})
|
||||
expect(preflight?.run).toContain('E2B_API_KEY_EXTERNAL repository secret')
|
||||
expect(e2b).toMatchObject({
|
||||
env: {
|
||||
E2B_API_KEY: '${{ secrets.E2B_API_KEY_EXTERNAL }}',
|
||||
DSH_E2E_MAX_WORKERS: '1',
|
||||
DSH_EXAMPLE_MODE: 'lib',
|
||||
},
|
||||
})
|
||||
expect(e2b?.run).toContain('packages/e2b/e2b/tests/composition.e2e.ts')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Issue lifecycle workflow', () => {
|
||||
it('uses review signals instead of rerunning when a draft becomes ready', () => {
|
||||
const lifecycle = loadWorkflow('.github/workflows/issue-lifecycle.yml')
|
||||
const lifecyclePullRequest = workflowEvent(lifecycle, 'pull_request')
|
||||
const lifecycleReview = workflowEvent(lifecycle, 'pull_request_review')
|
||||
const policy = loadWorkflow('.github/workflows/issue-policy.yml')
|
||||
const policyPullRequest = workflowEvent(policy, 'pull_request')
|
||||
|
||||
expect(lifecyclePullRequest.types).not.toContain('ready_for_review')
|
||||
expect(lifecyclePullRequest.types).toContain('review_requested')
|
||||
expect(lifecycleReview.types).toContain('submitted')
|
||||
expect(policyPullRequest.types).toContain('ready_for_review')
|
||||
})
|
||||
})
|
||||
|
||||
function loadWorkflow(path: string): Record<string, unknown> {
|
||||
const workflow: unknown = yaml.load(readFileSync(resolve(root, path), 'utf8'))
|
||||
if (!isRecord(workflow)) throw new TypeError(`${path} must define a workflow`)
|
||||
return workflow
|
||||
}
|
||||
|
||||
function workflowEvent(workflow: Record<string, unknown>, event: string): Record<string, unknown> {
|
||||
if (!isRecord(workflow.on) || !isRecord(workflow.on[event])) {
|
||||
throw new TypeError(`workflow must define the ${event} event`)
|
||||
}
|
||||
return workflow.on[event]
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
@@ -18,10 +18,10 @@ function write(path: string, content = ''): void {
|
||||
writeFileSync(path, content)
|
||||
}
|
||||
|
||||
function addProject(root: string, path: string): void {
|
||||
function addProject(root: string, path: string, outDir = 'lib/types'): void {
|
||||
write(join(root, 'tsconfig.json'), JSON.stringify({ files: [], references: [{ path }] }))
|
||||
write(join(root, path, 'tsconfig.json'), JSON.stringify({
|
||||
compilerOptions: { composite: true, outDir: 'lib/types' },
|
||||
compilerOptions: { composite: true, outDir },
|
||||
include: ['src'],
|
||||
}))
|
||||
write(join(root, path, 'src/index.ts'), 'export {}\n')
|
||||
@@ -60,6 +60,20 @@ describe('RepositoryCleaner', () => {
|
||||
expect(existsSync(join(root, 'products/shell/lib'))).toBe(true)
|
||||
})
|
||||
|
||||
it('removes the native Landlock entry output and solution build info', async () => {
|
||||
const root = fixture()
|
||||
const entry = 'native/landlock-run/packages/entry'
|
||||
addProject(root, entry, 'lib')
|
||||
write(join(root, entry, 'lib/index.js'))
|
||||
write(join(root, 'native/landlock-run/tsconfig.tsbuildinfo'))
|
||||
|
||||
await new RepositoryCleaner(root).clean()
|
||||
|
||||
expect(existsSync(join(root, entry, 'lib'))).toBe(false)
|
||||
expect(existsSync(join(root, entry, 'src/index.ts'))).toBe(true)
|
||||
expect(existsSync(join(root, 'native/landlock-run/tsconfig.tsbuildinfo'))).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses project outputs reached through a symlink outside the repository', async () => {
|
||||
const root = fixture()
|
||||
const externalProject = fixture()
|
||||
|
||||
@@ -72,6 +72,11 @@ export class RepositoryCleaner {
|
||||
for (const entry of await readdir(this.root, { withFileTypes: true })) {
|
||||
if (entry.isFile() && entry.name.endsWith('.tsbuildinfo')) targets.add(join(this.root, entry.name))
|
||||
}
|
||||
await this.addIfPresent(
|
||||
targets,
|
||||
join(this.root, 'native/landlock-run/tsconfig.tsbuildinfo'),
|
||||
canonicalRoot,
|
||||
)
|
||||
|
||||
// The root project-reference graph is the source of truth for live build targets.
|
||||
// Each emitting project declares lib/types as outDir; its parent lib also owns
|
||||
@@ -114,6 +119,7 @@ export class RepositoryCleaner {
|
||||
const outputs = new Set<string>()
|
||||
const pending = [join(this.root, 'tsconfig.json')]
|
||||
const visited = new Set<string>()
|
||||
const nativeEntryOutput = join(this.root, 'native/landlock-run/packages/entry/lib')
|
||||
|
||||
while (pending.length > 0) {
|
||||
const nextConfigPath = pending.pop()
|
||||
@@ -125,10 +131,14 @@ export class RepositoryCleaner {
|
||||
const parsed = parseConfig(configPath)
|
||||
if (parsed.options.outDir !== undefined) {
|
||||
const typesDirectory = resolve(parsed.options.outDir)
|
||||
if (basename(typesDirectory) !== 'types') {
|
||||
const outputDirectory = basename(typesDirectory) === 'types'
|
||||
? dirname(typesDirectory)
|
||||
: typesDirectory === nativeEntryOutput
|
||||
? typesDirectory
|
||||
: undefined
|
||||
if (outputDirectory === undefined) {
|
||||
throw new Error(`clean: expected TypeScript outDir to end in /types: ${repositoryPath(this.root, typesDirectory)}`)
|
||||
}
|
||||
const outputDirectory = dirname(typesDirectory)
|
||||
this.assertRepositoryTarget(outputDirectory)
|
||||
outputs.add(outputDirectory)
|
||||
}
|
||||
|
||||
@@ -15,8 +15,13 @@ interface CssPlugin {
|
||||
}
|
||||
|
||||
function cssPlugin(): CssPlugin {
|
||||
const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
const plugins = (configs[1] as { plugins: CssPlugin[] }).plugins
|
||||
const configs = clientBundle(
|
||||
'@deepseek-ai/dsh-client-test',
|
||||
['lib/types/index.js', 'lib/types/invariant.js'],
|
||||
)({ env: { DSH_BUILD_FACE: 'client' } })
|
||||
const client = configs.find(config => config.platform === 'browser')
|
||||
if (client === undefined) throw new Error('client config missing')
|
||||
const plugins = (client as { plugins: CssPlugin[] }).plugins
|
||||
const plugin = plugins.find(candidate => candidate.name === 'dsh-css-modules-inline')
|
||||
if (plugin === undefined) throw new Error('CSS Modules plugin missing from client config')
|
||||
return plugin
|
||||
|
||||
@@ -14,6 +14,24 @@ interface CssModulePlugin {
|
||||
load?: (this: { addWatchFile: (id: string) => void }, id: string) => Promise<unknown>
|
||||
}
|
||||
|
||||
function clientConfigs(id = '@deepseek-ai/dsh-client-test') {
|
||||
return clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])(
|
||||
{ env: { DSH_BUILD_FACE: 'client' } },
|
||||
).filter(config => config.platform === 'browser')
|
||||
}
|
||||
|
||||
describe('client bundle build faces', () => {
|
||||
it('watches source in development and consumes emitted JavaScript in the Client build', () => {
|
||||
const bundle = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js'])
|
||||
const development = bundle({ env: {} }).find(config => config.platform === 'browser')
|
||||
const artifact = bundle({ env: { DSH_BUILD_FACE: 'client' } })
|
||||
.find(config => config.platform === 'browser')
|
||||
|
||||
expect(development?.entry).toEqual({ client: 'src/client/index.ts' })
|
||||
expect(artifact?.entry).toEqual({ client: 'lib/types/client/index.js' })
|
||||
})
|
||||
})
|
||||
|
||||
function clientSourceMapPath(packagePath: string): string {
|
||||
return fileURLToPath(new URL(`../packages/${packagePath}/lib/client.js.map`, import.meta.url))
|
||||
}
|
||||
@@ -21,16 +39,16 @@ function clientSourceMapPath(packagePath: string): string {
|
||||
function purityResolveId(): ResolveId {
|
||||
// libEntry is spelled at every call site (no default) so the
|
||||
// package-invariants text check can see the invariant entry per package.
|
||||
const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
const plugins = (configs[1] as { plugins: { name: string; resolveId?: unknown }[] }).plugins
|
||||
const configs = clientConfigs()
|
||||
const plugins = (configs[0] as { plugins: { name: string; resolveId?: unknown }[] }).plugins
|
||||
const gate = plugins.find(p => p.name === 'dsh-client-bundle-purity')
|
||||
if (gate?.resolveId === undefined) throw new Error('purity plugin missing from client config')
|
||||
return gate.resolveId as ResolveId
|
||||
}
|
||||
|
||||
function cssModulePlugin(): CssModulePlugin {
|
||||
const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
const plugins = (configs[1] as { plugins: CssModulePlugin[] }).plugins
|
||||
const configs = clientConfigs()
|
||||
const plugins = (configs[0] as { plugins: CssModulePlugin[] }).plugins
|
||||
const plugin = plugins.find(candidate => candidate.name === 'dsh-css-modules-inline')
|
||||
if (plugin?.resolveId === undefined || plugin.load === undefined) {
|
||||
throw new Error('CSS Modules plugin missing from client config')
|
||||
@@ -59,6 +77,13 @@ describe('client bundle purity gate', () => {
|
||||
expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull()
|
||||
})
|
||||
|
||||
it('lets exact generated Remote contributions inline without admitting their package implementation', () => {
|
||||
expect(resolveId('@deepseek-ai/dsh-goal/remote')).toBeNull()
|
||||
expect(() => resolveId('@deepseek-ai/dsh-goal')).toThrow(/purity/)
|
||||
expect(() => resolveId('@deepseek-ai/dsh-goal/client')).toThrow(/purity/)
|
||||
expect(() => resolveId('@deepseek-ai/dsh-goal/remote/nested')).toThrow(/purity/)
|
||||
})
|
||||
|
||||
it('throws on any other @deepseek-ai leak', () => {
|
||||
expect(() => resolveId('@deepseek-ai/dsh-agent')).toThrow(/purity/)
|
||||
expect(() => resolveId('@deepseek-ai/dsh-client-web')).toThrow(/purity/)
|
||||
@@ -80,13 +105,13 @@ describe('client bundle purity gate', () => {
|
||||
|
||||
describe('client bundle debug artifacts', () => {
|
||||
it('emits source maps for plugin TS and TSX outside the Vite module graph', () => {
|
||||
const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
expect(configs[1]?.sourcemap).toBe(true)
|
||||
const configs = clientConfigs()
|
||||
expect(configs[0]?.sourcemap).toBe(true)
|
||||
})
|
||||
|
||||
it('maps first-party sources to their repository package paths', () => {
|
||||
const configs = clientBundle('@deepseek-ai/dsh-client-ui-goal', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
const outputOptions = configs[1]?.outputOptions
|
||||
const configs = clientConfigs('@deepseek-ai/dsh-client-ui-goal')
|
||||
const outputOptions = configs[0]?.outputOptions
|
||||
if (typeof outputOptions !== 'object' || outputOptions === null) throw new Error('client output options missing')
|
||||
const transform = outputOptions.sourcemapPathTransform
|
||||
if (transform === undefined) throw new Error('client sourcemap path transform missing')
|
||||
@@ -98,8 +123,8 @@ describe('client bundle debug artifacts', () => {
|
||||
})
|
||||
|
||||
it('maps dual-face host sources to the host package group', () => {
|
||||
const configs = clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js'])
|
||||
const outputOptions = configs[1]?.outputOptions
|
||||
const configs = clientConfigs('@deepseek-ai/dsh-host-directory-picker-native')
|
||||
const outputOptions = configs[0]?.outputOptions
|
||||
if (typeof outputOptions !== 'object' || outputOptions === null) throw new Error('client output options missing')
|
||||
const transform = outputOptions.sourcemapPathTransform
|
||||
if (transform === undefined) throw new Error('client sourcemap path transform missing')
|
||||
@@ -109,8 +134,8 @@ describe('client bundle debug artifacts', () => {
|
||||
})
|
||||
|
||||
it('maps inlined workspace sources to packages and leaves dependencies outside it unchanged', () => {
|
||||
const configs = clientBundle('@deepseek-ai/dsh-client-connection', ['lib/types/index.js'])
|
||||
const outputOptions = configs[1]?.outputOptions
|
||||
const configs = clientConfigs('@deepseek-ai/dsh-client-connection')
|
||||
const outputOptions = configs[0]?.outputOptions
|
||||
if (typeof outputOptions !== 'object' || outputOptions === null) throw new Error('client output options missing')
|
||||
const transform = outputOptions.sourcemapPathTransform
|
||||
if (transform === undefined) throw new Error('client sourcemap path transform missing')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Regression coverage for source declarations owned by the client test aggregate. */
|
||||
|
||||
import { existsSync, readdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import ts from 'typescript'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -14,6 +14,7 @@ function clientCssDeclarations(): string[] {
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => resolve(clientRoot, entry.name, 'src/css-modules.d.ts'))
|
||||
.filter(existsSync)
|
||||
.map(file => file.replaceAll(sep, '/'))
|
||||
.sort()
|
||||
}
|
||||
|
||||
@@ -26,6 +27,7 @@ describe('client TypeScript aggregate', () => {
|
||||
}
|
||||
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, root)
|
||||
const loaded = parsed.fileNames
|
||||
.map(file => file.replaceAll(sep, '/'))
|
||||
.filter(file => file.endsWith('/src/css-modules.d.ts'))
|
||||
.sort()
|
||||
expect(loaded).toEqual(clientCssDeclarations())
|
||||
|
||||
@@ -21,13 +21,13 @@ describe('Cordis core API generation', () => {
|
||||
it('renders the five detailed pages from pinned vendor declarations', () => {
|
||||
const pages = renderCordisCoreApiPages()
|
||||
expect([...pages.keys()]).toEqual(CORDIS_CORE_API_PAGES.map(page => page.out))
|
||||
expect(pages.get('docs/cordis-catalog/core/context.md')).toContain('### ctx.extend(meta?)')
|
||||
expect(pages.get('docs/cordis-catalog/core/events.md')).toContain('## DispatchMode')
|
||||
expect(pages.get('docs/cordis-catalog/core/fiber.md')).toContain('## EffectMeta')
|
||||
expect(pages.get('docs/cordis-catalog/core/registry.md')).toContain('## Plugin')
|
||||
expect(pages.get('docs/cordis-catalog/core/service.md')).toContain('### Service.resolveConfig')
|
||||
expect(pages.get('docs/cordis-api/context.md')).toContain('### ctx.extend(meta?)')
|
||||
expect(pages.get('docs/cordis-api/events.md')).toContain('## DispatchMode')
|
||||
expect(pages.get('docs/cordis-api/fiber.md')).toContain('## EffectMeta')
|
||||
expect(pages.get('docs/cordis-api/registry.md')).toContain('## Plugin')
|
||||
expect(pages.get('docs/cordis-api/service.md')).toContain('### Service.resolveConfig')
|
||||
|
||||
const fiber = pages.get('docs/cordis-catalog/core/fiber.md') ?? ''
|
||||
const fiber = pages.get('docs/cordis-api/fiber.md') ?? ''
|
||||
expect(fiber).toContain('```\n\nRegister a cleanup-aware effect on this fiber.')
|
||||
expect(fiber).toContain('- `execute` — the effect body; see `Effect` for accepted shapes.')
|
||||
expect(fiber).toContain('**Returns** a disposer that tears the effect down and settles once done.')
|
||||
@@ -39,7 +39,7 @@ describe('Cordis core API generation', () => {
|
||||
mkdirSync(join(root, 'vendor/cordis/src'), { recursive: true })
|
||||
writeFileSync(join(root, 'vendor/cordis/src/service.ts'), 'export class Service {\n run(): string { return "ok" }\n}\n')
|
||||
const page: CordisCoreApiPage = {
|
||||
out: 'docs/cordis-catalog/core/service.md',
|
||||
out: 'docs/cordis-api/service.md',
|
||||
title: 'Service',
|
||||
intro: 'Service API.',
|
||||
sections: [{ kind: 'class', file: 'vendor/cordis/src/service.ts', symbol: 'Service' }],
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface CordisCoreApiPage {
|
||||
/** Explicit editorial grouping for the pinned Cordis core surface. */
|
||||
export const CORDIS_CORE_API_PAGES: CordisCoreApiPage[] = [
|
||||
{
|
||||
out: 'docs/cordis-catalog/core/context.md',
|
||||
out: 'docs/cordis-api/context.md',
|
||||
title: 'Context',
|
||||
intro: 'The context is the core Cordis object: every service, event, and lifecycle API is reached through `ctx`. Event methods are documented on [Events](events.md), effects and the current fiber on [Fiber](fiber.md), and plugin loading on [Registry](registry.md).',
|
||||
sections: [
|
||||
@@ -35,9 +35,9 @@ export const CORDIS_CORE_API_PAGES: CordisCoreApiPage[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
out: 'docs/cordis-catalog/core/events.md',
|
||||
out: 'docs/cordis-api/events.md',
|
||||
title: 'Events',
|
||||
intro: 'The event-dispatch API mixed into every context. Harness event declarations and their dispatch modes are generated separately in the [Cordis events catalog](../events.md).',
|
||||
intro: 'The event-dispatch API mixed into every context. Harness event declarations and their dispatch modes are generated into each owning [subsystem page](../subsystems/core.md).',
|
||||
sections: [
|
||||
{ kind: 'context-merge', file: 'vendor/cordis/src/events.ts' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/events.ts', symbol: 'EventOptions' },
|
||||
@@ -45,7 +45,7 @@ export const CORDIS_CORE_API_PAGES: CordisCoreApiPage[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
out: 'docs/cordis-catalog/core/fiber.md',
|
||||
out: 'docs/cordis-api/fiber.md',
|
||||
title: 'Fiber',
|
||||
intro: 'A fiber is one loaded plugin instance: its lifecycle state, validated config, and registered effects. `ctx.fiber` is the current fiber, and `ctx.effect()` delegates to it.',
|
||||
sections: [
|
||||
@@ -59,7 +59,7 @@ export const CORDIS_CORE_API_PAGES: CordisCoreApiPage[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
out: 'docs/cordis-catalog/core/registry.md',
|
||||
out: 'docs/cordis-api/registry.md',
|
||||
title: 'Registry',
|
||||
intro: 'Plugin loading and dependency injection.',
|
||||
sections: [
|
||||
@@ -69,7 +69,7 @@ export const CORDIS_CORE_API_PAGES: CordisCoreApiPage[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
out: 'docs/cordis-catalog/core/service.md',
|
||||
out: 'docs/cordis-api/service.md',
|
||||
title: 'Service',
|
||||
intro: 'The base class for context services. A subclass loaded as a plugin registers itself as `ctx.<name>`.',
|
||||
sections: [
|
||||
@@ -357,7 +357,7 @@ function declarationPaste(ctx: RenderContext, rel: string, symbol: string): { do
|
||||
|
||||
function sourceLink(source: string): string {
|
||||
const [file, line] = source.split(':')
|
||||
return `[Source](../../../${file}${line === undefined ? '' : `#L${line}`})`
|
||||
return `[Source](../../${file}${line === undefined ? '' : `#L${line}`})`
|
||||
}
|
||||
|
||||
function unlink(text: string): string {
|
||||
|
||||
@@ -1,15 +1,102 @@
|
||||
/** Locate the Cordis module merge used by the vendored core API projector. */
|
||||
/**
|
||||
* AST helpers shared by the Cordis generators: locate the Cordis module merge
|
||||
* in a source file and enumerate the `interface Context` keys it declares.
|
||||
* The vendored core API projector consumes the merge body; the per-subsystem
|
||||
* region generator's exhaustiveness backstop consumes the key scan.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
|
||||
/** The body of the cordis module merge in `sf`: `declare module 'cordis'`
|
||||
* (harness packages) or `declare module './context.ts'` (vendor core), or
|
||||
* null when the file has neither. */
|
||||
export function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
|
||||
/** Cheap textual prefilter for a cordis module merge, quote-style agnostic
|
||||
* (the AST match below reads `stmt.name.text` and never sees the quotes). */
|
||||
const MERGE_HEAD = /declare module ['"](?:cordis|\.\/context\.ts)['"]/
|
||||
|
||||
/**
|
||||
* Parse every file matching `patterns` (repo-relative, sorted, `/`-normalized)
|
||||
* that textually contains a cordis module merge, yielding one entry per merge
|
||||
* BLOCK — a file may legally hold several `declare module 'cordis'` blocks
|
||||
* (the Typert analyzer reads them all), so the exhaustiveness scan must too.
|
||||
* Files without a merge are skipped.
|
||||
* @param scanRoot - Repository root the patterns are resolved against.
|
||||
* @param patterns - Glob(s) selecting the TypeScript files to scan.
|
||||
* @returns One entry per cordis module block, in path then source order.
|
||||
*/
|
||||
export function contextMergeFiles(
|
||||
scanRoot: string,
|
||||
patterns: string | readonly string[],
|
||||
): { rel: string; sf: ts.SourceFile; text: string; body: ts.ModuleBlock }[] {
|
||||
const out: { rel: string; sf: ts.SourceFile; text: string; body: ts.ModuleBlock }[] = []
|
||||
const rels = [...new Set(globSync(patterns as string | string[], { cwd: scanRoot }).map(s => s.split(sep).join('/')))].sort()
|
||||
for (const rel of rels) {
|
||||
const abs = resolve(scanRoot, rel)
|
||||
const text = readFileSync(abs, 'utf8')
|
||||
if (!MERGE_HEAD.test(text)) continue
|
||||
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
|
||||
for (const body of cordisModuleBodies(sf)) out.push({ rel, sf, text, body })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Every cordis module-merge body in `sf`: `declare module 'cordis'` (harness
|
||||
* packages) or `declare module './context.ts'` (vendor core), in source order.
|
||||
* Module-local: consumers walk blocks through {@link contextMergeFiles}. */
|
||||
function cordisModuleBodies(sf: ts.SourceFile): ts.ModuleBlock[] {
|
||||
const bodies: ts.ModuleBlock[] = []
|
||||
for (const stmt of sf.statements) {
|
||||
if (!ts.isModuleDeclaration(stmt) || !ts.isStringLiteral(stmt.name)) continue
|
||||
if (stmt.name.text !== 'cordis' && stmt.name.text !== './context.ts') continue
|
||||
if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body
|
||||
if (stmt.body && ts.isModuleBlock(stmt.body)) bodies.push(stmt.body)
|
||||
}
|
||||
return null
|
||||
return bodies
|
||||
}
|
||||
|
||||
/** The FIRST cordis module-merge body in `sf`, or null without one — for the
|
||||
* vendor core-API renderer whose input files carry exactly one merge; the
|
||||
* exhaustiveness scan uses {@link cordisModuleBodies} to read them all. */
|
||||
export function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
|
||||
return cordisModuleBodies(sf)[0] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `key: Type` property a `declare module 'cordis'` Context merge
|
||||
* declares in one module body.
|
||||
* @param body - The cordis module augmentation block.
|
||||
* @param sf - Owning source file (for text extraction).
|
||||
* @returns key → declared type-name text, in declaration order.
|
||||
*/
|
||||
export function contextKeyMap(body: ts.ModuleBlock, sf: ts.SourceFile): Map<string, string> {
|
||||
const keyToType = new Map<string, string>()
|
||||
for (const stmt of body.statements) {
|
||||
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue
|
||||
for (const member of stmt.members) {
|
||||
if (!ts.isPropertySignature(member) || !member.type) continue
|
||||
keyToType.set(member.name.getText(sf), member.type.getText(sf))
|
||||
}
|
||||
}
|
||||
return keyToType
|
||||
}
|
||||
|
||||
/**
|
||||
* Every event name a `declare module 'cordis'` Events merge declares in one
|
||||
* module body. Names are the literal member keys (`'agent/created'`), read
|
||||
* from method and property members alike so a declaration shape the projector
|
||||
* would reject still enters the exhaustiveness scan.
|
||||
* @param body - The cordis module augmentation block.
|
||||
* @param sf - Owning source file (for computed-name text extraction).
|
||||
* @returns Declared event names, in declaration order.
|
||||
*/
|
||||
export function eventNameList(body: ts.ModuleBlock, sf: ts.SourceFile): string[] {
|
||||
const names: string[] = []
|
||||
for (const stmt of body.statements) {
|
||||
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue
|
||||
for (const member of stmt.members) {
|
||||
if (!member.name) continue
|
||||
names.push(ts.isStringLiteral(member.name) || ts.isIdentifier(member.name)
|
||||
? member.name.text
|
||||
: member.name.getText(sf))
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
@@ -22,13 +22,7 @@ export default defineConfig({
|
||||
const bundlePath = join(root, 'lib/client.js')
|
||||
await writeFile(sourcePath, 'export const version = "watch-v1"\n')
|
||||
bundles = await watchClientPlugins(root, ['.'], 50)
|
||||
await expect.poll(async () => {
|
||||
try {
|
||||
return (await readFile(bundlePath, 'utf8')).includes('watch-v1')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, { timeout: 10_000 }).toBe(true)
|
||||
expect(await readFile(bundlePath, 'utf8')).toContain('watch-v1')
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1_000))
|
||||
await writeFile(sourcePath, `export const version = "watch-v2-${'x'.repeat(100)}"\n`)
|
||||
|
||||
@@ -47,21 +47,40 @@ export function discoverPluginDirs(root = repoRoot): string[] {
|
||||
* @param root - repository or fixture root passed to tsdown.
|
||||
* @param pluginDirs - workspace-relative package directories to watch.
|
||||
* @param pollInterval - optional source-watcher polling interval in milliseconds.
|
||||
* @returns live bundles whose async disposers stop every watcher.
|
||||
* @returns live bundles after every watcher has completed its initial build.
|
||||
*/
|
||||
export async function watchClientPlugins(
|
||||
root: string,
|
||||
pluginDirs: readonly string[],
|
||||
pollInterval?: number,
|
||||
): Promise<TsdownBundle[]> {
|
||||
return build({
|
||||
let resolveInitialBuilds: (() => void) | undefined
|
||||
const initialBuilds = new Promise<void>((resolve) => { resolveInitialBuilds = resolve })
|
||||
const initialized = new WeakSet<object>()
|
||||
const readiness: { expectedBuilds?: number; initializedBuilds: number } = { initializedBuilds: 0 }
|
||||
const bundles = await build({
|
||||
cwd: root,
|
||||
workspace: [...pluginDirs],
|
||||
watch: true,
|
||||
hooks: {
|
||||
'build:done': ({ options }) => {
|
||||
if (initialized.has(options)) return
|
||||
initialized.add(options)
|
||||
readiness.initializedBuilds += 1
|
||||
if (
|
||||
readiness.expectedBuilds !== undefined
|
||||
&& readiness.initializedBuilds >= readiness.expectedBuilds
|
||||
) resolveInitialBuilds?.()
|
||||
},
|
||||
},
|
||||
...pollInterval !== undefined
|
||||
? { inputOptions: { watch: { watcher: { usePolling: true, pollInterval } } } }
|
||||
: {},
|
||||
})
|
||||
readiness.expectedBuilds = bundles.length
|
||||
if (readiness.initializedBuilds >= readiness.expectedBuilds) resolveInitialBuilds?.()
|
||||
await initialBuilds
|
||||
return bundles
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1]
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"AGENTS.md": 1775,
|
||||
"AGENTS.md": 1900,
|
||||
"docs/AGENTS.md": 1320,
|
||||
"docs/architecture.md": 2160,
|
||||
"docs/architecture.md": 2400,
|
||||
"docs/cordis-primer.md": 600,
|
||||
"docs/defensive-patterns.md": 550,
|
||||
"docs/testing.md": 1150,
|
||||
"examples/AGENTS.md": 310,
|
||||
"packages/AGENTS.md": 675,
|
||||
"packages/README.md": 920
|
||||
"packages/README.md": 980
|
||||
}
|
||||
|
||||
@@ -136,10 +136,9 @@ function formatDiagnostics(diagnostics: readonly ts.Diagnostic[], blocks: Block[
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuse the host-aggregate references from a temp project one directory below
|
||||
* root. Doc fragments speak the host vocabulary, so the standalone project
|
||||
* seeds tsconfig.host.json (never the root solution: flattening host+client
|
||||
* into one program collides the cordis Context merges).
|
||||
* Reuse the Host aggregate references from a temp project one directory below
|
||||
* root. Generated Client API examples opt out because their declarations do
|
||||
* not exist until Host tsdown has run.
|
||||
*/
|
||||
function workspaceReferences(): { path: string }[] {
|
||||
const file = join(root, 'tsconfig.host.json')
|
||||
|
||||
@@ -126,12 +126,13 @@ function loadFile(abs: string, rel: string, cache: Map<string, FileCtx>): FileCt
|
||||
}
|
||||
|
||||
/** A type declaration a paste can contain. */
|
||||
type TypeDecl = ts.InterfaceDeclaration | ts.TypeAliasDeclaration
|
||||
type TypeDecl = ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.EnumDeclaration
|
||||
|
||||
/** Find an interface/type-alias declaration by name in a file, or null. */
|
||||
/** Find a pasteable type declaration by name in a file, or null. */
|
||||
function findTypeDecl(ctx: FileCtx, name: string): TypeDecl | null {
|
||||
for (const stmt of ctx.sf.statements) {
|
||||
if ((ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)) && stmt.name.text === name) return stmt
|
||||
if ((ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt) || ts.isEnumDeclaration(stmt))
|
||||
&& stmt.name.text === name) return stmt
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -207,7 +208,7 @@ function checkMemberDocs(ctx: FileCtx, decl: TypeDecl, violations: string[]): vo
|
||||
else ts.forEachChild(type, (n) => { walkNested(n, path) })
|
||||
}
|
||||
if (ts.isInterfaceDeclaration(decl)) walkMembers(decl.members, decl.name.text)
|
||||
else walkNested(decl.type, decl.name.text)
|
||||
else if (ts.isTypeAliasDeclaration(decl)) walkNested(decl.type, decl.name.text)
|
||||
}
|
||||
|
||||
/** Cross-file resolution context for the schema-path check. */
|
||||
@@ -776,7 +777,7 @@ function requiresLine(inject: string[]): string {
|
||||
}
|
||||
|
||||
/** Render one reference as a link: another plugin's config type → its section,
|
||||
* a curated core-data-structures name → its page, any other workspace type →
|
||||
* a curated subsystems name → its page, any other workspace type →
|
||||
* its source file, an external type → named with its module, unlinked. */
|
||||
function refLink(ref: TypeRef, byName: Map<string, CatalogEntry>): string {
|
||||
const target = byName.get(ref.specifier)
|
||||
@@ -784,7 +785,7 @@ function refLink(ref: TypeRef, byName: Map<string, CatalogEntry>): string {
|
||||
return `[\`${ref.alias}\`](#${slug(target.pkg)})`
|
||||
}
|
||||
const page = LINK_MAP[ref.imported]
|
||||
if (page) return `[\`${ref.alias}\`](core-data-structures/${page})`
|
||||
if (page) return `[\`${ref.alias}\`](subsystems/${page})`
|
||||
if (target) return `[\`${ref.alias}\`](../${target.entry})`
|
||||
return `\`${ref.alias}\` (\`${ref.specifier}\`)`
|
||||
}
|
||||
@@ -818,7 +819,7 @@ export function render(entries: CatalogEntry[]): string {
|
||||
'',
|
||||
'# Plugin Config Catalog',
|
||||
'',
|
||||
'Every `config:` block a `cordis.yml` entry can set: for each loadable harness package, the verbatim config declaration (JSDoc included) its `apply` function or service constructor receives, with every referenced type pasted alongside (package-local types) or linked (everything else). The paste is the plugin\'s full declared config type — a field the runtime schema deliberately excludes is a runtime-only seam (its own JSDoc says so) and is not settable from `cordis.yml`. This is the **deployment**-axis reference — the wiring a plugin author works against is the cordis [events](cordis-catalog/events.md) + [services](cordis-catalog/services.md) catalogs, the model-facing tool schemas are the [tool catalog](tool-catalog.md), and [core-data-structures/](core-data-structures/core.md) documents the types these declarations reference.',
|
||||
'Every `config:` block a `cordis.yml` entry can set: for each loadable harness package, the verbatim config declaration (JSDoc included) its `apply` function or service constructor receives, with every referenced type pasted alongside (package-local types) or linked (everything else). The paste is the plugin\'s full declared config type — a field the runtime schema deliberately excludes is a runtime-only seam (its own JSDoc says so) and is not settable from `cordis.yml`. This is the **deployment**-axis reference — the wiring a plugin author works against is the generated `cordis-surface` region on each [subsystem page](subsystems/core.md), the model-facing tool schemas are the [tool catalog](tool-catalog.md), and [subsystems/](subsystems/core.md) documents the types these declarations reference.',
|
||||
'',
|
||||
'This file is GENERATED from source (`scripts/gen-config-catalog.ts`) and verified fresh by `pnpm run verify-config-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks use a `ts config-catalog` fence (skipped by doc-typecheck, since a lone declaration referencing imports is not standalone-compilable). The generator also cross-checks the runtime schemastery schema against the pasted declaration — every schema-validated key, nested keys included, must be locatable on the declared config type — so the paste cannot hide a loader-accepted field.',
|
||||
'',
|
||||
|
||||
211
scripts/gen-cordis-catalog-partition.spec.ts
Normal file
211
scripts/gen-cordis-catalog-partition.spec.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Acceptance-path coverage for the cordis-surface partition backstops
|
||||
* (`walkPartitionProblems` + the AST scan helpers): a declared Context key or
|
||||
* Events member the rendering projection cannot see must carry a named walk
|
||||
* exemption, an exemption must stay live in both directions, and the scan
|
||||
* itself must reach nested (`src/**`) and Events-only merge files.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import ts from 'typescript'
|
||||
import { contextKeyMap, contextMergeFiles, eventNameList } from './cordis-walk.ts'
|
||||
import { walkPartitionProblems } from './gen-cordis-catalog.ts'
|
||||
import type { WalkPartitionInput, WalkPartitionMaps } from './gen-cordis-catalog.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** A consistent baseline the red cases mutate one facet at a time. */
|
||||
function baseline(): { input: WalkPartitionInput; maps: WalkPartitionMaps } {
|
||||
return {
|
||||
input: {
|
||||
renderedKeys: new Map([['llm', 'packages/llm/llm/src/index.ts:10']]),
|
||||
renderedScopes: new Set(['llm']),
|
||||
renderedEventNames: new Set(['llm/request']),
|
||||
declaredKeys: new Map([
|
||||
['llm', 'packages/llm/llm/src/index.ts'],
|
||||
['theme', 'packages/client/ui-theme/src/client/index.ts'],
|
||||
]),
|
||||
declaredEvents: new Map([
|
||||
['llm/request', 'packages/llm/llm/src/index.ts'],
|
||||
['theme/change', 'packages/client/ui-theme/src/client/index.ts'],
|
||||
]),
|
||||
},
|
||||
maps: {
|
||||
servicePage: { llm: 'llm-streaming.md' },
|
||||
serviceWalkExemptions: { theme: 'client-side — packages/client/ui-theme/README.md owns the surface' },
|
||||
eventScopePage: { llm: 'llm-streaming.md' },
|
||||
eventWalkExemptions: { 'theme/change': 'client-face — packages/client/ui-theme/README.md owns the surface' },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('walkPartitionProblems', () => {
|
||||
it('accepts a partition where every declared key and event is rendered or exempted', () => {
|
||||
const { input, maps } = baseline()
|
||||
expect(walkPartitionProblems(input, maps)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a declared event that is neither rendered nor exempted, naming its file', () => {
|
||||
const { input, maps } = baseline()
|
||||
const problems = walkPartitionProblems(input, { ...maps, eventWalkExemptions: {} })
|
||||
expect(problems).toEqual([
|
||||
expect.stringContaining("event 'theme/change' (packages/client/ui-theme/src/client/index.ts) is declared in an Events merge but invisible"),
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects an event exemption whose event the projection renders', () => {
|
||||
const { input, maps } = baseline()
|
||||
// A projection that renders theme/change necessarily renders the theme
|
||||
// scope too; the fixture models that and maps the scope so the only
|
||||
// violation is the stale exemption.
|
||||
const rendered = {
|
||||
...input,
|
||||
renderedScopes: new Set(['llm', 'theme']),
|
||||
renderedEventNames: new Set(['llm/request', 'theme/change']),
|
||||
}
|
||||
const mapped = { ...maps, eventScopePage: { llm: 'llm-streaming.md', theme: 'client-modules.md' } }
|
||||
expect(walkPartitionProblems(rendered, mapped)).toEqual([
|
||||
expect.stringContaining("event 'theme/change' is rendered by the projection but still listed in EVENT_WALK_EXEMPTIONS"),
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects rendered surface the independent scan cannot see, naming the scan as the defect', () => {
|
||||
const { input, maps } = baseline()
|
||||
const blind = {
|
||||
...input,
|
||||
declaredKeys: new Map([['theme', 'packages/client/ui-theme/src/client/index.ts']]),
|
||||
declaredEvents: new Map([['theme/change', 'packages/client/ui-theme/src/client/index.ts']]),
|
||||
}
|
||||
expect(walkPartitionProblems(blind, maps)).toEqual([
|
||||
expect.stringContaining('ctx.llm is rendered by the projection but the independent scan finds no Context merge declaring it'),
|
||||
expect.stringContaining("event 'llm/request' is rendered by the projection but the independent scan finds no Events merge declaring it"),
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects an event exemption no Events merge declares', () => {
|
||||
const { input, maps } = baseline()
|
||||
const stale = { ...maps, eventWalkExemptions: { ...maps.eventWalkExemptions, 'gone/away': 'nothing owns this' } }
|
||||
expect(walkPartitionProblems(input, stale)).toEqual([
|
||||
expect.stringContaining("EVENT_WALK_EXEMPTIONS names 'gone/away' but no Events merge declares it"),
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a declared Context key that is neither rendered nor exempted', () => {
|
||||
const { input, maps } = baseline()
|
||||
const problems = walkPartitionProblems(input, { ...maps, serviceWalkExemptions: {} })
|
||||
expect(problems).toEqual([
|
||||
expect.stringContaining('ctx.theme (packages/client/ui-theme/src/client/index.ts) is declared in a Context merge but invisible'),
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects an unmapped rendered service with its source pointer, and stale page maps both ways', () => {
|
||||
const { input, maps } = baseline()
|
||||
const problems = walkPartitionProblems(input, {
|
||||
...maps,
|
||||
servicePage: { ghost: 'core.md' },
|
||||
eventScopePage: { specter: 'core.md' },
|
||||
})
|
||||
expect(problems).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('service ctx.llm (packages/llm/llm/src/index.ts:10) has no SERVICE_PAGE entry'),
|
||||
expect.stringContaining("event scope 'llm/*' has no EVENT_SCOPE_PAGE entry"),
|
||||
expect.stringContaining("SERVICE_PAGE maps 'ctx.ghost' but the projection discovers no such service"),
|
||||
expect.stringContaining("EVENT_SCOPE_PAGE maps 'specter/*' but the projection discovers no such scope"),
|
||||
]))
|
||||
expect(problems).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cordis-walk scan reach', () => {
|
||||
it('finds Context keys and Events names in nested Events-only merge files', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cordis-walk-'))
|
||||
roots.push(root)
|
||||
const dir = join(root, 'packages/client/ui-x/src/client')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, 'index.ts'), [
|
||||
"declare module 'cordis' {",
|
||||
' interface Events {',
|
||||
" 'x/changed'(): void",
|
||||
' }',
|
||||
'}',
|
||||
'export {}',
|
||||
'',
|
||||
].join('\n'))
|
||||
const merges = contextMergeFiles(root, 'packages/*/*/src/**/*.ts')
|
||||
expect(merges.map(m => m.rel)).toEqual(['packages/client/ui-x/src/client/index.ts'])
|
||||
const only = merges[0]
|
||||
if (!only) throw new Error('scan returned no merge')
|
||||
expect(eventNameList(only.body, only.sf)).toEqual(['x/changed'])
|
||||
expect([...contextKeyMap(only.body, only.sf).keys()]).toEqual([])
|
||||
})
|
||||
|
||||
it('yields every merge block of a multi-block file, double-quoted heads, and .tsx sources', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cordis-walk-'))
|
||||
roots.push(root)
|
||||
const dir = join(root, 'packages/client/ui-x/src')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
// The Typert analyzer reads every cordis module block in a file; the
|
||||
// backstop must not stop at the first one, skip the double-quoted legal
|
||||
// form, or ignore .tsx sources.
|
||||
writeFileSync(join(dir, 'split.ts'), [
|
||||
"declare module 'cordis' {",
|
||||
' interface Context {',
|
||||
' first: FirstService',
|
||||
' }',
|
||||
'}',
|
||||
'declare module "cordis" {',
|
||||
' interface Events {',
|
||||
" 'second/changed'(): void",
|
||||
' }',
|
||||
'}',
|
||||
'export {}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(dir, 'view.tsx'), [
|
||||
"declare module 'cordis' {",
|
||||
' interface Context {',
|
||||
' fromTsx: TsxService',
|
||||
' }',
|
||||
'}',
|
||||
'export {}',
|
||||
'',
|
||||
].join('\n'))
|
||||
const merges = contextMergeFiles(root, ['packages/*/*/src/**/*.ts', 'packages/*/*/src/**/*.tsx'])
|
||||
expect(merges.map(m => m.rel)).toEqual([
|
||||
'packages/client/ui-x/src/split.ts',
|
||||
'packages/client/ui-x/src/split.ts',
|
||||
'packages/client/ui-x/src/view.tsx',
|
||||
])
|
||||
const keys = merges.flatMap(m => [...contextKeyMap(m.body, m.sf).keys()])
|
||||
const events = merges.flatMap(m => eventNameList(m.body, m.sf))
|
||||
expect(keys).toEqual(['first', 'fromTsx'])
|
||||
expect(events).toEqual(['second/changed'])
|
||||
})
|
||||
|
||||
it('reads string-literal and identifier member names from an Events merge', () => {
|
||||
const sf = ts.createSourceFile('x.ts', [
|
||||
"declare module 'cordis' {",
|
||||
' interface Events {',
|
||||
" 'scope/list'(items: string[]): void",
|
||||
' plain(): void',
|
||||
' }',
|
||||
' interface Context {',
|
||||
' thing: ThingService',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'), ts.ScriptTarget.Latest, true)
|
||||
const body = sf.statements[0] && ts.isModuleDeclaration(sf.statements[0]) && sf.statements[0].body
|
||||
&& ts.isModuleBlock(sf.statements[0].body)
|
||||
? sf.statements[0].body
|
||||
: null
|
||||
if (!body) throw new Error('fixture did not parse to a module block')
|
||||
expect(eventNameList(body, sf)).toEqual(['scope/list', 'plain'])
|
||||
expect([...contextKeyMap(body, sf)]).toEqual([['thing', 'ThingService']])
|
||||
})
|
||||
})
|
||||
144
scripts/gen-cordis-catalog-record.spec.ts
Normal file
144
scripts/gen-cordis-catalog-record.spec.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Negative-path coverage for the guarded pair auto-record
|
||||
* (`maybeRecordPair`): the safety property is that regeneration re-records a
|
||||
* pair's `.i18n.yaml` ONLY for a region-confined write over a well-formed,
|
||||
* previously-consistent record — every other state is left for the pairing
|
||||
* gate to report.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { maybeRecordPair, REGION_BEGIN, REGION_END, spliceRegion } from './gen-cordis-catalog.ts'
|
||||
import { blobHash, renderPairMeta } from './translation-pairing.ts'
|
||||
|
||||
const PAGE = 'docs/subsystems/fix.md'
|
||||
const ZH = 'docs/subsystems/fix.zh.md'
|
||||
const META = 'docs/subsystems/fix.i18n.yaml'
|
||||
|
||||
function page(prose: string, region: string): string {
|
||||
return `# Fix\n\n${prose}\n\n${REGION_BEGIN}\n${region}\n${REGION_END}\n`
|
||||
}
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Lay out a pair on disk and return { root, before } for a regeneration that already wrote `current`. */
|
||||
function setup(options: {
|
||||
beforeEn: string
|
||||
beforeZh: string
|
||||
currentEn: string
|
||||
currentZh: string
|
||||
meta?: string | null
|
||||
omitZhSnapshot?: boolean
|
||||
}): { root: string; before: Map<string, Buffer> } {
|
||||
const root = mkdtempSync(join(tmpdir(), 'record-guard-'))
|
||||
roots.push(root)
|
||||
mkdirSync(join(root, 'docs/subsystems'), { recursive: true })
|
||||
writeFileSync(join(root, PAGE), options.currentEn)
|
||||
writeFileSync(join(root, ZH), options.currentZh)
|
||||
const meta = options.meta === undefined
|
||||
? renderPairMeta(PAGE, blobHash(Buffer.from(options.beforeEn)), ZH, blobHash(Buffer.from(options.beforeZh)))
|
||||
: options.meta
|
||||
if (meta !== null) writeFileSync(join(root, META), meta)
|
||||
const before = new Map<string, Buffer>([[PAGE, Buffer.from(options.beforeEn)]])
|
||||
if (!options.omitZhSnapshot) before.set(ZH, Buffer.from(options.beforeZh))
|
||||
return { root, before }
|
||||
}
|
||||
|
||||
describe('maybeRecordPair', () => {
|
||||
const beforeEn = page('prose.', 'old region')
|
||||
const beforeZh = page('散文。', 'old region')
|
||||
const currentEn = page('prose.', 'new region')
|
||||
const currentZh = page('散文。', 'new region')
|
||||
|
||||
it('re-records a region-confined write over a consistent record', () => {
|
||||
const { root, before } = setup({ beforeEn, beforeZh, currentEn, currentZh })
|
||||
expect(maybeRecordPair(PAGE, before, root)).toBe(true)
|
||||
expect(readFileSync(join(root, META), 'utf8'))
|
||||
.toBe(renderPairMeta(PAGE, blobHash(Buffer.from(currentEn)), ZH, blobHash(Buffer.from(currentZh))))
|
||||
})
|
||||
|
||||
it('refuses when the pair was already out of sync before the run', () => {
|
||||
const stale = renderPairMeta(PAGE, blobHash(Buffer.from('drifted long ago\n')), ZH, blobHash(Buffer.from(beforeZh)))
|
||||
const { root, before } = setup({ beforeEn, beforeZh, currentEn, currentZh, meta: stale })
|
||||
expect(maybeRecordPair(PAGE, before, root)).toBe(false)
|
||||
expect(readFileSync(join(root, META), 'utf8')).toBe(stale)
|
||||
})
|
||||
|
||||
it('refuses a malformed record even when its hashes are current', () => {
|
||||
// A renamed key with preserved hashes must stay the pairing gate's error,
|
||||
// never become valid through regeneration.
|
||||
const renamedKeys = [
|
||||
'# comment',
|
||||
`fixXmd: ${blobHash(Buffer.from(beforeEn))}`,
|
||||
`fix.zh.md: ${blobHash(Buffer.from(beforeZh))}`,
|
||||
'',
|
||||
].join('\n')
|
||||
const { root, before } = setup({ beforeEn, beforeZh, currentEn, currentZh, meta: renamedKeys })
|
||||
expect(maybeRecordPair(PAGE, before, root)).toBe(false)
|
||||
expect(readFileSync(join(root, META), 'utf8')).toBe(renamedKeys)
|
||||
})
|
||||
|
||||
it('refuses a record with extra entries', () => {
|
||||
const extra = renderPairMeta(PAGE, blobHash(Buffer.from(beforeEn)), ZH, blobHash(Buffer.from(beforeZh)))
|
||||
+ `other.md: ${blobHash(Buffer.from(beforeEn))}\n`
|
||||
const { root, before } = setup({ beforeEn, beforeZh, currentEn, currentZh, meta: extra })
|
||||
expect(maybeRecordPair(PAGE, before, root)).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a record with a duplicated expected key', () => {
|
||||
// Map#set would collapse the duplicate back to size 2; the parser must
|
||||
// reject the repeat instead of letting the guard accept the record.
|
||||
const duplicated = [
|
||||
`fix.md: ${blobHash(Buffer.from(beforeEn))}`,
|
||||
`fix.md: ${blobHash(Buffer.from(beforeEn))}`,
|
||||
`fix.zh.md: ${blobHash(Buffer.from(beforeZh))}`,
|
||||
'',
|
||||
].join('\n')
|
||||
const { root, before } = setup({ beforeEn, beforeZh, currentEn, currentZh, meta: duplicated })
|
||||
expect(maybeRecordPair(PAGE, before, root)).toBe(false)
|
||||
expect(readFileSync(join(root, META), 'utf8')).toBe(duplicated)
|
||||
})
|
||||
|
||||
it('refuses when prose drifted alongside the region write', () => {
|
||||
const proseDrift = page('prose, edited by a human.', 'new region')
|
||||
const { root, before } = setup({ beforeEn, beforeZh, currentEn: proseDrift, currentZh })
|
||||
expect(maybeRecordPair(PAGE, before, root)).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a brand-new pair with no record', () => {
|
||||
const { root, before } = setup({ beforeEn, beforeZh, currentEn, currentZh, meta: null })
|
||||
expect(maybeRecordPair(PAGE, before, root)).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses when a side has no pre-write snapshot', () => {
|
||||
const { root, before } = setup({ beforeEn, beforeZh, currentEn, currentZh, omitZhSnapshot: true })
|
||||
expect(maybeRecordPair(PAGE, before, root)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('spliceRegion', () => {
|
||||
it('replaces exactly the cordis-surface region', () => {
|
||||
const doc = `# T\n\nprose\n\n${REGION_BEGIN}\nold\n${REGION_END}\ntail\n`
|
||||
expect(spliceRegion(doc, `${REGION_BEGIN}\nnew\n${REGION_END}`))
|
||||
.toBe(`# T\n\nprose\n\n${REGION_BEGIN}\nnew\n${REGION_END}\ntail\n`)
|
||||
})
|
||||
|
||||
it('fails loud on a page carrying only some other generator\'s region', () => {
|
||||
// Another generator's markers satisfy the generic region grammar but must
|
||||
// never be overwritten by THIS generator's splice.
|
||||
const foreign = '# T\n\n<!-- BEGIN GENERATED other-surface (other-gen.ts) — do not edit between markers -->\ntheirs\n<!-- END GENERATED other-surface -->\n'
|
||||
expect(() => spliceRegion(foreign, `${REGION_BEGIN}\nnew\n${REGION_END}`))
|
||||
.toThrow('expected exactly 1 cordis-surface region, found 0 BEGIN/0 END')
|
||||
})
|
||||
|
||||
it('fails loud on duplicate cordis-surface markers', () => {
|
||||
const doubled = `${REGION_BEGIN}\na\n${REGION_END}\n${REGION_BEGIN}\nb\n${REGION_END}\n`
|
||||
expect(() => spliceRegion(doubled, `${REGION_BEGIN}\nnew\n${REGION_END}`))
|
||||
.toThrow('found 2 BEGIN/2 END')
|
||||
})
|
||||
})
|
||||
@@ -1,51 +1,232 @@
|
||||
/**
|
||||
* Generate committed Cordis artifacts from the Typert catalog projector and
|
||||
* the independent vendored-core projector.
|
||||
* Generate the per-subsystem Cordis service/event reference regions from the
|
||||
* Typert catalog projection. Every harness `ctx.<key>` service and event scope
|
||||
* maps to exactly one `docs/subsystems/` page through the curated tables below;
|
||||
* the generator injects each page's surface between its GENERATED markers —
|
||||
* byte-identically into both language sides of the pair — and re-records a
|
||||
* pair's `.i18n.yaml` only when nothing outside the region changed. The
|
||||
* projection enforces event modes, JSDoc parameter/return completeness, and
|
||||
* signature type-link coverage; the inherited (vendor) tier renders to
|
||||
* `docs/cordis-api/inherited.md`. `--check` verifies every generated artifact.
|
||||
*/
|
||||
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import {
|
||||
projectCordisCatalog,
|
||||
renderEvents,
|
||||
renderServices,
|
||||
renderInheritedPage,
|
||||
renderPageRegion,
|
||||
REGION_BEGIN,
|
||||
REGION_END,
|
||||
} from '@deepseek-ai/dsh-typert-generator'
|
||||
import type { CordisCatalogPolicy } from '@deepseek-ai/dsh-typert-generator'
|
||||
import { renderCordisCoreApiPages } from './cordis-core-api.ts'
|
||||
import { contextKeyMap, contextMergeFiles, eventNameList } from './cordis-walk.ts'
|
||||
import {
|
||||
blobHash,
|
||||
parsePairMeta,
|
||||
partitionGeneratedRegions,
|
||||
renderPairMeta,
|
||||
} from './translation-pairing.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT_EVENTS = 'docs/cordis-catalog/events.md'
|
||||
const OUT_SERVICES = 'docs/cordis-catalog/services.md'
|
||||
const OUT_RUNTIME_API = 'packages/cordis/tool-cordis/src/api-catalog.ts'
|
||||
const SUBSYSTEMS_DIR = 'docs/subsystems'
|
||||
const OUT_INHERITED = 'docs/cordis-api/inherited.md'
|
||||
const OUT_RUNTIME_API = 'packages/self-modification/tool-cordis/src/api-catalog.ts'
|
||||
|
||||
/** One primary core-data-structures page per project type used by a generated signature. */
|
||||
export { REGION_BEGIN, REGION_END }
|
||||
|
||||
/**
|
||||
* The owning subsystems page for every harness `ctx.<key>` service the
|
||||
* projection discovers. Fail-closed both ways: a discovered key absent here
|
||||
* and an entry whose key the projection no longer discovers are both hard
|
||||
* errors, so the partition can never silently drift from the service surface.
|
||||
*/
|
||||
export const SERVICE_PAGE: Record<string, string> = {
|
||||
agentLoop: 'core.md',
|
||||
agentDefaultModel: 'core.md',
|
||||
agentPresets: 'core.md',
|
||||
agents: 'core.md',
|
||||
approval: 'approval.md',
|
||||
bash: 'bash.md',
|
||||
bashEnv: 'bash.md',
|
||||
clientModuleHost: 'client-modules.md',
|
||||
codeRuntime: 'code-runtime.md',
|
||||
commands: 'commands.md',
|
||||
compact: 'compaction.md',
|
||||
credentials: 'credentials.md',
|
||||
directoryPicker: 'workspace.md',
|
||||
e2b: 'subprocess.md',
|
||||
fs: 'filesystem.md',
|
||||
goals: 'goal.md',
|
||||
httpServer: 'http-server.md',
|
||||
invariants: 'invariants.md',
|
||||
llm: 'llm-streaming.md',
|
||||
permission: 'permission.md',
|
||||
planMode: 'plan.md',
|
||||
pty: 'pty.md',
|
||||
sandbox: 'sandbox.md',
|
||||
sandboxPolicy: 'sandbox.md',
|
||||
sessionPersistence: 'persistence.md',
|
||||
sessionQuery: 'session-query.md',
|
||||
sessionReferences: 'session-reference.md',
|
||||
sessionProjectionCache: 'session-projection.md',
|
||||
sessionProjections: 'session-projection.md',
|
||||
sessions: 'session.md',
|
||||
settings: 'settings.md',
|
||||
sessionTitle: 'session-title.md',
|
||||
skills: 'skills.md',
|
||||
spillStore: 'spill.md',
|
||||
storage: 'storage.md',
|
||||
storageDomain: 'storage.md',
|
||||
subagents: 'subagent.md',
|
||||
subprocess: 'subprocess.md',
|
||||
systemPrompt: 'system-prompt.md',
|
||||
tasks: 'tasks.md',
|
||||
telemetry: 'telemetry.md',
|
||||
tokenMeter: 'token-meter.md',
|
||||
toolResultPrune: 'compaction.md',
|
||||
tools: 'tools.md',
|
||||
typert: 'typert.md',
|
||||
typertGateway: 'typert.md',
|
||||
userInteraction: 'user-interaction.md',
|
||||
web: 'web.md',
|
||||
workflows: 'workflow.md',
|
||||
workspace: 'workspace.md',
|
||||
}
|
||||
|
||||
/**
|
||||
* Context keys declared in `interface Context` merges that the rendering
|
||||
* projection cannot see, each with the reason and its documentation owner.
|
||||
* The scan that enforces this list reads EVERY `declare module 'cordis'`
|
||||
* Context merge under `packages/x/x/src/**` — any depth, not only root
|
||||
* `index.ts` files with a same-named service class — so a new service can
|
||||
* never silently join this blind spot: it either enters {@link SERVICE_PAGE}
|
||||
* or names itself here. Client-face keys (the projection analyzes the host
|
||||
* face only) name the package README that owns their surface.
|
||||
* TODO(cordis-catalog-interface-services): the interface-typed and
|
||||
* non-index-declared entries would all render once the projection resolves a
|
||||
* Context key through its declaring file's imports to the class declaration.
|
||||
*/
|
||||
export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
agent: 'not a service: the DX accessor field on Agent.ctx (root accessor defaulting to undefined) — docs/subsystems/core.md owns the Agent handle',
|
||||
configuredAgentIdentities: 'not a service: launcher-provided boot-context value (ConfiguredAgentIdentities | undefined) — packages/core/agent-loop/README.md owns the launcher contract',
|
||||
launcherSessionQueryPath: 'not a service: launcher-provided boot-context value (string | undefined) — packages/session-query/session-query-sqlite/README.md owns the launcher contract',
|
||||
dshHomePath: 'not a service: boot-provided root accessor function (typeof dshHomePath | undefined) for Loader !!js config expressions — packages/boot/app-boot/README.md owns the boot contract',
|
||||
headlessIo: 'not a service: launcher-provided root accessor value (HeadlessIo | undefined) for the headless bundle runner — packages/bundle/headless/README.md owns the launcher contract',
|
||||
launcherEnvironment: 'not a service: launcher-provided root accessor value (EnvironmentSnapshot | undefined) — packages/util/environment/README.md owns the launcher contract',
|
||||
lsp: 'interface-typed (LspService); implementing class Lsp is not the declared type name — packages/lsp/lsp/README.md owns the surface',
|
||||
apiProxy: 'interface-typed (ApiProxy) with the class in api-proxy.ts, not index.ts — packages/host/apiproxy/README.md owns the surface',
|
||||
appShell: 'client-side interface-typed browser service — packages/client/web/README.md owns the surface',
|
||||
connection: 'client-side interface-typed browser service — packages/client/connection/README.md owns the surface',
|
||||
chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-conversation/README.md owns the surface',
|
||||
command: 'client-side interface-typed browser service — packages/client/ui-command/README.md owns the surface',
|
||||
conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the surface',
|
||||
conversationEvents: 'client-side interface-typed registry — packages/client/runtime/README.md owns the surface',
|
||||
conversationViews: 'client-side interface-typed registry — packages/client/runtime/README.md owns the surface',
|
||||
layout: 'client-side interface-typed browser service — packages/client/ui-layout/README.md owns the surface',
|
||||
locale: 'client-side interface-typed browser service — packages/client/locale/README.md owns the surface',
|
||||
models: 'client-side interface-typed browser service — packages/client/ui-model/README.md owns the surface',
|
||||
modules: 'client-side interface-typed browser service — packages/client/modules/README.md owns the surface',
|
||||
remote: 'client-side interface-typed gateway accessor (ClientRemote) — packages/api/gateway/README.md owns the surface',
|
||||
sessionHistory: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface',
|
||||
slash: 'client-side interface-typed browser service — packages/client/ui-slash/README.md owns the surface',
|
||||
slots: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface',
|
||||
theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the surface',
|
||||
workspaces: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface',
|
||||
}
|
||||
|
||||
/**
|
||||
* The owning subsystems page for every harness event scope (the segment
|
||||
* before the first `/`) the projection renders. Fail-closed exactly like
|
||||
* {@link SERVICE_PAGE}. Client-face events (`slash/*`, `theme/change`, …) are
|
||||
* invisible to the host-face projection and therefore never reach this map;
|
||||
* {@link EVENT_WALK_EXEMPTIONS} names each one with its documentation owner.
|
||||
*/
|
||||
export const EVENT_SCOPE_PAGE: Record<string, string> = {
|
||||
'agent': 'core.md',
|
||||
'agent-loop': 'core.md',
|
||||
'approval': 'approval.md',
|
||||
'commands': 'commands.md',
|
||||
'credentials': 'credentials.md',
|
||||
'domain': 'storage.md',
|
||||
'fs': 'filesystem.md',
|
||||
'goal': 'goal.md',
|
||||
'llm': 'llm-streaming.md',
|
||||
'session': 'session.md',
|
||||
'settings': 'settings.md',
|
||||
'skills': 'skills.md',
|
||||
'subagent': 'subagent.md',
|
||||
'system-prompt': 'system-prompt.md',
|
||||
'telemetry': 'telemetry.md',
|
||||
'tools': 'tools.md',
|
||||
'workflow': 'workflow.md',
|
||||
}
|
||||
|
||||
/**
|
||||
* Event names declared in `interface Events` merges that the rendering
|
||||
* projection cannot see, each with the reason and its documentation owner.
|
||||
* The mirror of {@link SERVICE_WALK_EXEMPTIONS} for events: an independent
|
||||
* scan reads EVERY `declare module 'cordis'` Events merge under
|
||||
* `packages/x/x/src/**`, so a declared event either renders onto a subsystems
|
||||
* page (via {@link EVENT_SCOPE_PAGE}) or names itself here — never vanishes
|
||||
* silently. Keys are full event names, not scopes: client-face events share
|
||||
* scopes with rendered host events (`commands/changed` beside `commands/*`),
|
||||
* so a scope-level exemption would mask a host-face regression.
|
||||
*/
|
||||
export const EVENT_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
'commands/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'connection/reset': 'client-face transport signal — packages/client/runtime/README.md owns the surface',
|
||||
'credentials/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'locale/change': 'client-face locale switch signal — packages/client/locale/README.md owns the surface',
|
||||
'models/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'session/preset-changed': 'client-face per-session catalog invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'settings/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'slash/input-begin-command': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',
|
||||
'slash/input-consume-token': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',
|
||||
'slash/input-insert-reference': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',
|
||||
'slash/input-insert-text': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',
|
||||
'slots/changed': 'client-face slot invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'theme/change': 'client-face theme switch signal — packages/client/ui-theme/README.md owns the surface',
|
||||
}
|
||||
|
||||
/**
|
||||
* One primary subsystems page per project type used by a generated
|
||||
* signature. This stays curated because union names intentionally do not
|
||||
* reuse the type-equivalence manifest's map-symbol entries and some symbols
|
||||
* appear on more than one page.
|
||||
*/
|
||||
export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
Agent: 'core.md',
|
||||
AgentCancelCause: 'core.md',
|
||||
AgentFactory: 'core.md',
|
||||
AgentHandle: 'core.md',
|
||||
ModelSelection: 'core.md',
|
||||
AgentOptions: 'core.md',
|
||||
AgentStatus: 'core.md',
|
||||
ContentBlock: 'core.md',
|
||||
ContinuationDecision: 'core.md',
|
||||
ContinuationStop: 'core.md',
|
||||
GenerateOptions: 'core.md',
|
||||
MessageId: 'core.md',
|
||||
HookContext: 'core.md',
|
||||
ContentBlock: 'llm-streaming.md',
|
||||
CreateAgentOptions: 'core.md',
|
||||
GenerateOptions: 'llm-streaming.md',
|
||||
InboxItem: 'core.md',
|
||||
InboxPlacement: 'core.md',
|
||||
MessageId: 'llm-streaming.md',
|
||||
ResumeAgentOptions: 'core.md',
|
||||
SettleReason: 'core.md',
|
||||
AdapterRegistrationHandle: 'core.md',
|
||||
DirectoryRegistrationHandle: 'core.md',
|
||||
LlmCallConfig: 'core.md',
|
||||
LlmModelContext: 'core.md',
|
||||
LlmModelReasoningInfo: 'core.md',
|
||||
LlmResolvedModelInfo: 'core.md',
|
||||
AdapterRegistrationHandle: 'llm-streaming.md',
|
||||
DirectoryRegistrationHandle: 'llm-streaming.md',
|
||||
LlmCallConfig: 'llm-streaming.md',
|
||||
LlmModelContext: 'llm-streaming.md',
|
||||
LlmModelReasoningInfo: 'llm-streaming.md',
|
||||
LlmResolvedModelInfo: 'llm-streaming.md',
|
||||
LlmFailure: 'llm-streaming.md',
|
||||
LlmModelInfo: 'core.md',
|
||||
LlmProviderInfo: 'core.md',
|
||||
LlmConfigurableProvider: 'core.md',
|
||||
LlmModelDiscoveryRequest: 'core.md',
|
||||
LlmDiscoveredModel: 'core.md',
|
||||
LlmModelInfo: 'llm-streaming.md',
|
||||
LlmProviderInfo: 'llm-streaming.md',
|
||||
LlmConfigurableProvider: 'llm-streaming.md',
|
||||
LlmModelDiscoveryRequest: 'llm-streaming.md',
|
||||
LlmDiscoveredModel: 'llm-streaming.md',
|
||||
ResolvedRetryPolicy: 'llm-streaming.md',
|
||||
Message: 'core.md',
|
||||
MessageSource: 'core.md',
|
||||
Message: 'llm-streaming.md',
|
||||
MessageSource: 'llm-streaming.md',
|
||||
UserMessage: 'session.md',
|
||||
PreStepDecision: 'core.md',
|
||||
PreStepContext: 'core.md',
|
||||
@@ -54,7 +235,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
PreparedReferencedMessage: 'session-reference.md',
|
||||
SessionReferenceCandidate: 'session-reference.md',
|
||||
SessionReferenceInput: 'session-reference.md',
|
||||
SessionEvent: 'core.md',
|
||||
SessionEvent: 'session.md',
|
||||
SessionId: 'core.md',
|
||||
SessionStartSource: 'core.md',
|
||||
SessionLogSnapshot: 'session-query.md',
|
||||
@@ -73,6 +254,8 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
SubprocessOutputRead: 'subprocess.md',
|
||||
SubprocessOutputReader: 'subprocess.md',
|
||||
SubprocessSpawnSpec: 'subprocess.md',
|
||||
SubprocessTerminalHandle: 'subprocess.md',
|
||||
SubprocessTerminalSpawnSpec: 'subprocess.md',
|
||||
CodeRunRequest: 'code-runtime.md',
|
||||
CodeRunResult: 'code-runtime.md',
|
||||
CompactionResult: 'compaction.md',
|
||||
@@ -83,6 +266,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
FsEditOutcome: 'filesystem.md',
|
||||
FsEditRequest: 'filesystem.md',
|
||||
FsInfo: 'filesystem.md',
|
||||
FsObservation: 'filesystem.md',
|
||||
FsPathInfo: 'filesystem.md',
|
||||
FsPolicyExec: 'filesystem.md',
|
||||
FsTarget: 'filesystem.md',
|
||||
@@ -95,8 +279,10 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
GoalChanged: 'goal.md',
|
||||
GoalRef: 'goal.md',
|
||||
GoalView: 'goal.md',
|
||||
CreateGoalResult: 'goal.md',
|
||||
CommandDefinition: 'commands.md',
|
||||
CommandDescriptor: 'commands.md',
|
||||
CommandId: 'commands.md',
|
||||
CommandResult: 'commands.md',
|
||||
CommandSurface: 'commands.md',
|
||||
LlmAdapter: 'llm-streaming.md',
|
||||
@@ -162,6 +348,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
SkillProvider: 'skills.md',
|
||||
SkillProviderObservation: 'skills.md',
|
||||
SkillRegistration: 'skills.md',
|
||||
SkillViewOptions: 'skills.md',
|
||||
SkillSummary: 'skills.md',
|
||||
SaveTextSpill: 'spill.md',
|
||||
SpillRef: 'spill.md',
|
||||
@@ -171,7 +358,9 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
ContinuableStart: 'subagent.md',
|
||||
ContinuableStartSpec: 'subagent.md',
|
||||
CoordinatorMessageSource: 'subagent.md',
|
||||
SubagentDescendantListEntry: 'subagent.md',
|
||||
SubagentFollowupOptions: 'subagent.md',
|
||||
SubagentInterruptAuthority: 'subagent.md',
|
||||
SubagentListEntry: 'subagent.md',
|
||||
SubagentProvider: 'subagent.md',
|
||||
SubagentReportDelivery: 'subagent.md',
|
||||
@@ -202,6 +391,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
ToolExecutionResult: 'tools.md',
|
||||
ToolExecutionToken: 'tools.md',
|
||||
ToolGuard: 'tools.md',
|
||||
ToolPresentationMode: 'tools.md',
|
||||
ToolRegistry: 'tools.md',
|
||||
ToolRestriction: 'tools.md',
|
||||
ToolSchema: 'tools.md',
|
||||
@@ -225,8 +415,34 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
WebSearchRequest: 'web.md',
|
||||
WebSearchResult: 'web.md',
|
||||
WorkflowRun: 'workflow.md',
|
||||
PresetOption: 'permission.md',
|
||||
PresetSpec: 'permission.md',
|
||||
InvariantInstaller: 'invariants.md',
|
||||
WebRoute: 'http-server.md',
|
||||
StorageBackend: 'storage.md',
|
||||
StorageForms: 'storage.md',
|
||||
Domain: 'storage.md',
|
||||
DomainSpec: 'storage.md',
|
||||
DomainChanged: 'storage.md',
|
||||
DomainFacility: 'storage.md',
|
||||
Workspace: 'workspace.md',
|
||||
WorkspaceId: 'workspace.md',
|
||||
WebBootGraph: 'client-modules.md',
|
||||
TelemetryRecord: 'telemetry.md',
|
||||
WorkflowRunInfo: 'workflow.md',
|
||||
WorkflowStartRequest: 'workflow.md',
|
||||
ProjectionDefinition: 'session-projection.md',
|
||||
SessionProjectionMap: 'session-projection.md',
|
||||
ProjectionChangeListener: 'session-projection.md',
|
||||
ProjectionSnapshot: 'session-projection.md',
|
||||
ProjectionCheckpoint: 'session-projection.md',
|
||||
DirectoryPickerCapability: 'workspace.md',
|
||||
TypertContribution: 'invariants.md',
|
||||
TypertFace: 'invariants.md',
|
||||
TypertPackageFilter: 'invariants.md',
|
||||
TypertPackageRecord: 'invariants.md',
|
||||
TypertSchemaFilter: 'invariants.md',
|
||||
TypertSchemaRecord: 'invariants.md',
|
||||
}
|
||||
|
||||
/** TypeScript lib and pinned framework types with no repository-owned data page. */
|
||||
@@ -239,69 +455,46 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
|
||||
'Partial',
|
||||
'Pick',
|
||||
'Promise',
|
||||
'Record',
|
||||
'Readonly',
|
||||
])
|
||||
|
||||
/** Project types deliberately documented outside the core-data catalog. */
|
||||
/** Project types deliberately documented outside the subsystems catalog. */
|
||||
export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
AgentFactory: 'agent creation seam is owned by packages/core/agent/README.md',
|
||||
z: 'schemastery schema constructor is owned by vendor/schemastery (vendored upstream)',
|
||||
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',
|
||||
AgentPreset: 'discovered preset record is owned by packages/preset/agent-presets/README.md',
|
||||
PresetMetadata: 'preset display text is owned by packages/preset/agent-presets/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',
|
||||
CompactAgentContext: 'compaction service input is owned by packages/compact/compact/src/index.ts',
|
||||
ManualCompactAgentContext: 'manual compaction service input is owned by packages/compact/compact/src/index.ts',
|
||||
DirectoryPickerCapability: 'picker interaction contract is owned by packages/host/directory-picker/README.md',
|
||||
CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md',
|
||||
Domain: 'domain interface is owned by packages/storage/storage-domain/README.md',
|
||||
DomainChanged: 'event-local snapshot is owned by packages/storage/storage-domain/src/events.ts',
|
||||
DomainFacility: 'domain form facility is owned by packages/storage/storage-domain/README.md',
|
||||
DomainImpl: 'domain implementation contract is owned by packages/storage/storage-domain/README.md',
|
||||
DomainSpec: 'domain declaration contract is owned by packages/storage/storage-domain/README.md',
|
||||
StorageBackend: 'backend contract is owned by packages/storage/storage/src/backend.ts',
|
||||
StorageForms: 'merge-extensible form map is owned by packages/storage/storage/src/index.ts',
|
||||
ProjectionDefinition: 'projection unit contract is owned by packages/session-projection/session-projection/README.md',
|
||||
SessionProjectionMap: 'merge-extensible projection key map is owned by packages/session-projection/session-projection/src/types.ts',
|
||||
ProjectionChangeListener: 'change-feed listener contract is owned by packages/session-projection/session-projection/src/index.ts',
|
||||
ProjectionSnapshot: 'watermark snapshot shape is owned by packages/session-projection/session-projection/src/index.ts',
|
||||
ProjectionCheckpoint: 'persisted checkpoint row map is owned by packages/session-projection/session-projection/src/index.ts',
|
||||
CommandExecution: 'executor return contract is owned by packages/ui/commands/src/index.ts',
|
||||
TypertContribution: 'registry contribution contract is owned by packages/typert/registry/README.md',
|
||||
TypertFace: 'registry face identity is owned by packages/typert/registry/README.md',
|
||||
TypertPackageFilter: 'registry package query filter is owned by packages/typert/registry/README.md',
|
||||
TypertPackageRecord: 'registry package record is owned by packages/typert/registry/README.md',
|
||||
TypertSchemaFilter: 'registry schema query filter is owned by packages/typert/registry/README.md',
|
||||
TypertSchemaRecord: 'registry schema record is owned by packages/typert/registry/README.md',
|
||||
CommandExecution: 'executor return contract is owned by packages/interaction/commands/src/index.ts',
|
||||
'z.core.JSONSchema.BaseSchema': 'zod projection output is owned by the zod v4 API',
|
||||
'z.core.ToJSONSchemaParams': 'zod projection parameters are owned by the zod v4 API',
|
||||
InvariantInstaller: 'service-local contribution contract is owned by packages/support/invariants/README.md',
|
||||
TypeRTDisposer: 'TypeRT lifecycle contract is owned by packages/typert/type-meta/README.md',
|
||||
InvokeRemoteRequest: 'gateway invocation contract is owned by packages/api/gateway/README.md',
|
||||
LocaleDict: 'service-local dictionary shape is owned by packages/client/i18n/src/index.ts',
|
||||
WebBootGraph: 'web boot graph wire shape is owned by packages/client/modules/src/client/index.ts',
|
||||
WebRoute: 'route registration contract is owned by packages/host/webserver/src/index.ts',
|
||||
WebUpgradeRoute:
|
||||
'upgrade route registration contract is owned by packages/host/webserver/src/index.ts',
|
||||
ThemeTokens: 'service-local token dictionary is owned by packages/client/ui-theme/src/index.ts',
|
||||
Translate: 'service-local bound translator is owned by packages/client/i18n/src/index.ts',
|
||||
WebUpgradeRoute:
|
||||
'upgrade route registration contract is owned by packages/host/webserver/src/index.ts',
|
||||
InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md',
|
||||
PresetOption: 'deployment menu metadata is owned by packages/ui/permission/README.md',
|
||||
PresetSpec: 'deployment preset composition is owned by packages/ui/permission/README.md',
|
||||
KnobState: 'projection unit state shape is owned by packages/ui/permission/README.md',
|
||||
PermissionSelect: 'permissions projection payload is owned by packages/ui/permission/src/types.ts',
|
||||
KnobState: 'projection unit state shape is owned by packages/interaction/permission/README.md',
|
||||
PermissionSelect: 'permissions projection payload is owned by packages/interaction/permission/src/types.ts',
|
||||
PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md',
|
||||
ResumeAgentOptions: 'agent resume contract is owned by packages/core/agent/README.md',
|
||||
Sandbox: 'external E2B SDK handle is owned by packages/e2b/e2b/README.md',
|
||||
SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts',
|
||||
SubagentRunEndInfo: 'event payload contract is owned by packages/subagent/subagent/src/types.ts',
|
||||
SubagentRunInfo: 'event payload contract is owned by packages/subagent/subagent/src/types.ts',
|
||||
TelemetryRecord: 'seam-local record contract is owned by packages/telemetry/session-telemetry/src/index.ts',
|
||||
WorkflowAgentEndInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
||||
WorkflowAgentInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
||||
WorkflowResultInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
||||
Workspace: 'workspace entity contract is owned by packages/workspace/workspace/README.md',
|
||||
WorkspaceId: 'branded id is owned by packages/workspace/workspace/README.md',
|
||||
}
|
||||
|
||||
/** Repository data policy consumed by the Cordis catalog projector. */
|
||||
@@ -319,8 +512,7 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = {
|
||||
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:340' },
|
||||
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:342' },
|
||||
{ name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' },
|
||||
{ name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:22' },
|
||||
{ name: 'hmr/config-update-failed', summary: 'A watched config-file refresh failed.', source: 'vendor/hmr/src/index.ts:29' },
|
||||
{ name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' },
|
||||
{ name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' },
|
||||
{ name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:24' },
|
||||
{ name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:25' },
|
||||
@@ -329,7 +521,7 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = {
|
||||
],
|
||||
inheritedServices: [
|
||||
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:34' },
|
||||
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:34' },
|
||||
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / short-circuit chain).', source: 'vendor/cordis/src/events.ts:34' },
|
||||
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:164' },
|
||||
{ name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.', source: 'vendor/cordis/src/fiber.ts:9' },
|
||||
{ name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.', source: 'vendor/cordis/src/reflect.ts:7' },
|
||||
@@ -341,15 +533,250 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = {
|
||||
],
|
||||
}
|
||||
|
||||
/** CLI entry: default writes every artifact; `--check` reports stale files.
|
||||
|
||||
/**
|
||||
* Splice a page's generated cordis-surface region into its Markdown content.
|
||||
* The page must contain exactly one cordis-surface region (the markers are
|
||||
* part of the hand-owned page skeleton once, then owned by the generator);
|
||||
* zero or several is a partition error the caller reports with the page path.
|
||||
* The match is on THIS generator's exact markers, not the generic region
|
||||
* grammar, so a page carrying only some other generator's region fails loud
|
||||
* instead of having that region overwritten.
|
||||
* @param content - the page's current full Markdown text.
|
||||
* @param region - the freshly rendered marker-delimited region.
|
||||
* @returns the page text with the region replaced.
|
||||
*/
|
||||
export function spliceRegion(content: string, region: string): string {
|
||||
const lines = content.split('\n')
|
||||
const begins = lines.flatMap((line, index) => (line === REGION_BEGIN ? [index] : []))
|
||||
const ends = lines.flatMap((line, index) => (line === REGION_END ? [index] : []))
|
||||
if (begins.length !== 1 || ends.length !== 1) {
|
||||
throw new Error(`expected exactly 1 cordis-surface region, found ${begins.length} BEGIN/${ends.length} END; add the BEGIN/END cordis-surface markers once`)
|
||||
}
|
||||
const begin = begins[0] ?? -1
|
||||
const end = ends[0] ?? -1
|
||||
if (end < begin) throw new Error('cordis-surface END marker precedes its BEGIN')
|
||||
return [...lines.slice(0, begin), ...region.split('\n'), ...lines.slice(end + 1)].join('\n')
|
||||
}
|
||||
|
||||
/** The declared-vs-rendered inputs {@link walkPartitionProblems} judges. */
|
||||
export interface WalkPartitionInput {
|
||||
/** Service key → source pointer, as the rendering projection produced them. */
|
||||
readonly renderedKeys: ReadonlyMap<string, string>
|
||||
/** Event scopes the rendering projection produced. */
|
||||
readonly renderedScopes: ReadonlySet<string>
|
||||
/** Event names the rendering projection produced. */
|
||||
readonly renderedEventNames: ReadonlySet<string>
|
||||
/** Context key → first declaring file, from the independent AST scan. */
|
||||
readonly declaredKeys: ReadonlyMap<string, string>
|
||||
/** Event name → first declaring file, from the independent AST scan. */
|
||||
readonly declaredEvents: ReadonlyMap<string, string>
|
||||
}
|
||||
|
||||
/** The curated partition maps {@link walkPartitionProblems} enforces. */
|
||||
export interface WalkPartitionMaps {
|
||||
readonly servicePage: Readonly<Record<string, string>>
|
||||
readonly serviceWalkExemptions: Readonly<Record<string, string>>
|
||||
readonly eventScopePage: Readonly<Record<string, string>>
|
||||
readonly eventWalkExemptions: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Judge the rendered surface and the independent AST scan against the curated
|
||||
* partition maps, fail-closed in both directions for services AND events: a
|
||||
* rendered key/scope must be mapped to a page, a mapped key/scope must still
|
||||
* render, and — the backstop — a DECLARED key/event the projection cannot see
|
||||
* must carry a named walk exemption (a rendered one must not). A third
|
||||
* direction guards the scan itself: everything rendered must also be declared
|
||||
* to the scan, so a scan blind spot cannot decay silently. Pure so the
|
||||
* acceptance paths are provable without running the projection.
|
||||
* @param input - rendered surface plus the declared-key/event scans.
|
||||
* @param maps - the curated page maps and walk exemptions.
|
||||
* @returns one message per violation, empty when the partition holds.
|
||||
*/
|
||||
export function walkPartitionProblems(input: WalkPartitionInput, maps: WalkPartitionMaps): string[] {
|
||||
const problems: string[] = []
|
||||
for (const [key, source] of input.renderedKeys) {
|
||||
if (!Object.hasOwn(maps.servicePage, key)) problems.push(`service ctx.${key} (${source}) has no SERVICE_PAGE entry; every service maps to exactly one subsystems page.`)
|
||||
}
|
||||
for (const scope of [...input.renderedScopes].sort()) {
|
||||
if (!Object.hasOwn(maps.eventScopePage, scope)) problems.push(`event scope '${scope}/*' has no EVENT_SCOPE_PAGE entry; every event scope maps to exactly one subsystems page.`)
|
||||
}
|
||||
for (const key of Object.keys(maps.servicePage)) {
|
||||
if (!input.renderedKeys.has(key)) problems.push(`SERVICE_PAGE maps 'ctx.${key}' but the projection discovers no such service; remove the stale entry.`)
|
||||
}
|
||||
for (const scope of Object.keys(maps.eventScopePage)) {
|
||||
if (!input.renderedScopes.has(scope)) problems.push(`EVENT_SCOPE_PAGE maps '${scope}/*' but the projection discovers no such scope; remove the stale entry.`)
|
||||
}
|
||||
// The rendering projection only sees a Context key it can resolve to a
|
||||
// documented service class. The independent scan reads EVERY Context merge
|
||||
// so a key the projection cannot render must either be rendered (mapped) or
|
||||
// carry a named SERVICE_WALK_EXEMPTIONS reason — never vanish silently.
|
||||
for (const [key, rel] of input.declaredKeys) {
|
||||
const rendered = input.renderedKeys.has(key)
|
||||
const exempt = Object.hasOwn(maps.serviceWalkExemptions, key)
|
||||
if (!rendered && !exempt) {
|
||||
problems.push(`ctx.${key} (${rel}) is declared in a Context merge but invisible to the rendering projection; map it in SERVICE_PAGE (after making it renderable) or name it in SERVICE_WALK_EXEMPTIONS with its documentation owner.`)
|
||||
}
|
||||
if (rendered && exempt) problems.push(`ctx.${key} is rendered by the projection but still listed in SERVICE_WALK_EXEMPTIONS; remove the stale exemption.`)
|
||||
}
|
||||
for (const key of Object.keys(maps.serviceWalkExemptions)) {
|
||||
if (!input.declaredKeys.has(key)) problems.push(`SERVICE_WALK_EXEMPTIONS names 'ctx.${key}' but no Context merge declares it; remove the stale exemption.`)
|
||||
}
|
||||
// The event mirror of the service backstop: the projection walks only files
|
||||
// reachable from host-face package exports, so a client-face or unreachable
|
||||
// Events merge would otherwise vanish without a trace.
|
||||
for (const [name, rel] of input.declaredEvents) {
|
||||
const rendered = input.renderedEventNames.has(name)
|
||||
const exempt = Object.hasOwn(maps.eventWalkExemptions, name)
|
||||
if (!rendered && !exempt) {
|
||||
problems.push(`event '${name}' (${rel}) is declared in an Events merge but invisible to the rendering projection; make it renderable (mapped via EVENT_SCOPE_PAGE) or name it in EVENT_WALK_EXEMPTIONS with its documentation owner.`)
|
||||
}
|
||||
if (rendered && exempt) problems.push(`event '${name}' is rendered by the projection but still listed in EVENT_WALK_EXEMPTIONS; remove the stale exemption.`)
|
||||
}
|
||||
for (const name of Object.keys(maps.eventWalkExemptions)) {
|
||||
if (!input.declaredEvents.has(name)) problems.push(`EVENT_WALK_EXEMPTIONS names '${name}' but no Events merge declares it; remove the stale exemption.`)
|
||||
}
|
||||
// Self-check the scan itself: everything the projection renders is declared
|
||||
// in a Context/Events merge the scan must also reach, so a rendered key or
|
||||
// event the scan cannot see means the SCAN regressed (glob, prefilter, or
|
||||
// block walk) — a partial blind spot that exemption staleness alone would
|
||||
// never surface.
|
||||
for (const key of input.renderedKeys.keys()) {
|
||||
if (!input.declaredKeys.has(key)) problems.push(`ctx.${key} is rendered by the projection but the independent scan finds no Context merge declaring it; the scan has a blind spot (glob, prefilter, or module-block walk) — fix the scan, not the maps.`)
|
||||
}
|
||||
for (const name of input.renderedEventNames) {
|
||||
if (!input.declaredEvents.has(name)) problems.push(`event '${name}' is rendered by the projection but the independent scan finds no Events merge declaring it; the scan has a blind spot (glob, prefilter, or module-block walk) — fix the scan, not the maps.`)
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute every generated artifact: the inherited-tier page, the model-facing
|
||||
* runtime API module, plus, per mapped subsystems page, the pair's two updated
|
||||
* documents with the injected region. Fail-loud partition checks live here: an
|
||||
* unmapped service/event scope, a mapping whose page file does not exist, a
|
||||
* curated entry whose key/scope the projection no longer discovers, a declared
|
||||
* Context key or Events member the projection cannot see without a named walk
|
||||
* exemption, and a mapped page missing its markers are all aggregated errors.
|
||||
* @returns `[repo-relative path, exact content]` for every generated artifact.
|
||||
*/
|
||||
export function computeOutputs(): [string, string][] {
|
||||
const { projector, model } = projectCordisCatalog(root, CORDIS_CATALOG_POLICY)
|
||||
const services = [...model.services]
|
||||
const events = [...model.events]
|
||||
|
||||
const declaredKeys = new Map<string, string>()
|
||||
const declaredEvents = new Map<string, string>()
|
||||
for (const { rel, sf, body } of contextMergeFiles(root, ['packages/*/*/src/**/*.ts', 'packages/*/*/src/**/*.tsx'])) {
|
||||
for (const key of contextKeyMap(body, sf).keys()) {
|
||||
if (!declaredKeys.has(key)) declaredKeys.set(key, rel)
|
||||
}
|
||||
for (const name of eventNameList(body, sf)) {
|
||||
if (!declaredEvents.has(name)) declaredEvents.set(name, rel)
|
||||
}
|
||||
}
|
||||
const problems = walkPartitionProblems({
|
||||
renderedKeys: new Map(services.map(s => [s.key, s.source])),
|
||||
renderedScopes: new Set(events.map(e => e.scope)),
|
||||
renderedEventNames: new Set(events.map(e => e.name)),
|
||||
declaredKeys,
|
||||
declaredEvents,
|
||||
}, {
|
||||
servicePage: SERVICE_PAGE,
|
||||
serviceWalkExemptions: SERVICE_WALK_EXEMPTIONS,
|
||||
eventScopePage: EVENT_SCOPE_PAGE,
|
||||
eventWalkExemptions: EVENT_WALK_EXEMPTIONS,
|
||||
})
|
||||
if (problems.length > 0) throw new Error(`gen-cordis-catalog: ${problems.length} partition violation(s):\n${problems.map(p => ` ${p}`).join('\n')}`)
|
||||
|
||||
const pages = [...new Set([...Object.values(SERVICE_PAGE), ...Object.values(EVENT_SCOPE_PAGE)])].sort()
|
||||
const outputs: [string, string][] = [
|
||||
[OUT_INHERITED, renderInheritedPage(CORDIS_CATALOG_POLICY)],
|
||||
[OUT_RUNTIME_API, projector.renderRuntimeApi(model)],
|
||||
]
|
||||
for (const page of pages) {
|
||||
const region = renderPageRegion(
|
||||
page,
|
||||
services.filter(s => SERVICE_PAGE[s.key] === page),
|
||||
events.filter(e => EVENT_SCOPE_PAGE[e.scope] === page),
|
||||
CORDIS_CATALOG_POLICY,
|
||||
)
|
||||
for (const side of [page, page.replace(/\.md$/, '.zh.md')]) {
|
||||
const rel = `${SUBSYSTEMS_DIR}/${side}`
|
||||
let current: string
|
||||
try {
|
||||
current = readFileSync(resolve(root, rel), 'utf8')
|
||||
} catch {
|
||||
// Both pair sides must exist before a region can be injected; the
|
||||
// pairing gate owns pair completeness, this generator names the miss.
|
||||
problems.push(`${rel}: mapped subsystems page does not exist.`)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
outputs.push([rel, spliceRegion(current, region)])
|
||||
} catch (error) {
|
||||
problems.push(`${rel}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (problems.length > 0) throw new Error(`gen-cordis-catalog: ${problems.length} page violation(s):\n${problems.map(p => ` ${p}`).join('\n')}`)
|
||||
return outputs
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-record a pair's `.i18n.yaml` after a region write ONLY when the write is
|
||||
* region-confined: both sides' region-stripped content must be byte-equal to
|
||||
* the region-stripped previous content whose hashes the record holds. The
|
||||
* caller supplies the previous bytes (read before writing); human-content
|
||||
* drift leaves the record untouched so the pairing gate still demands the
|
||||
* normal translation flow.
|
||||
* @param pageRel - repo-relative English page path (`docs/subsystems/x.md`).
|
||||
* @param before - pre-write bytes per repo-relative path.
|
||||
* @param scanRoot - repository root override for tests.
|
||||
* @returns true when the record was refreshed.
|
||||
*/
|
||||
export function maybeRecordPair(pageRel: string, before: Map<string, Buffer>, scanRoot: string = root): boolean {
|
||||
const zhRel = pageRel.replace(/\.md$/, '.zh.md')
|
||||
const metaRel = pageRel.replace(/\.md$/, '.i18n.yaml')
|
||||
const metaAbs = resolve(scanRoot, metaRel)
|
||||
let meta: string
|
||||
try {
|
||||
meta = readFileSync(metaAbs, 'utf8')
|
||||
} catch {
|
||||
// No record yet: a brand-new pair is recorded by the author's --write
|
||||
// after review, never silently by regeneration.
|
||||
return false
|
||||
}
|
||||
// The record must be exactly the well-formed two-entry shape for THIS pair;
|
||||
// a malformed or renamed-key sidecar is the pairing gate's problem to
|
||||
// report, never something regeneration silently repairs into validity.
|
||||
const recorded = parsePairMeta(meta)
|
||||
const names = [pageRel, zhRel].map(rel => rel.split('/').at(-1) ?? rel)
|
||||
if (!recorded || recorded.size !== 2 || !names.every(name => recorded.has(name))) return false
|
||||
for (const rel of [pageRel, zhRel]) {
|
||||
const previous = before.get(rel)
|
||||
if (!previous) return false
|
||||
if (recorded.get(rel.split('/').at(-1) ?? rel) !== blobHash(previous)) return false
|
||||
const current = readFileSync(resolve(scanRoot, rel))
|
||||
const strippedBefore = partitionGeneratedRegions(previous.toString('utf8')).stripped
|
||||
const strippedAfter = partitionGeneratedRegions(current.toString('utf8')).stripped
|
||||
if (strippedBefore !== strippedAfter) return false
|
||||
}
|
||||
const source = readFileSync(resolve(scanRoot, pageRel))
|
||||
const zh = readFileSync(resolve(scanRoot, zhRel))
|
||||
writeFileSync(metaAbs, renderPairMeta(pageRel, blobHash(source), zhRel, blobHash(zh)))
|
||||
return true
|
||||
}
|
||||
|
||||
/** CLI entry: default regenerates every artifact, `--check` fails if any is
|
||||
* stale. Guarded behind an entry-point check so importing this module for
|
||||
* tests neither regenerates the committed files nor calls process.exit.
|
||||
* @returns nothing; writes files or reports freshness through the process.
|
||||
*/
|
||||
export function main(): void {
|
||||
const { projector, model } = projectCordisCatalog(root, CORDIS_CATALOG_POLICY)
|
||||
const outputs: [string, string][] = [
|
||||
[OUT_EVENTS, renderEvents([...model.events], CORDIS_CATALOG_POLICY)],
|
||||
[OUT_SERVICES, renderServices([...model.services], CORDIS_CATALOG_POLICY)],
|
||||
[OUT_RUNTIME_API, projector.renderRuntimeApi(model)],
|
||||
...computeOutputs(),
|
||||
...renderCordisCoreApiPages(),
|
||||
]
|
||||
if (process.argv.includes('--check')) {
|
||||
@@ -359,25 +786,51 @@ export function main(): void {
|
||||
try {
|
||||
committed = readFileSync(resolve(root, out), 'utf8')
|
||||
} catch {
|
||||
// Only ENOENT is expected; either read failure has the same remedy.
|
||||
// Only ENOENT (not yet generated) is expected; a present-but-unreadable
|
||||
// file is not a state this repo produces. Either way the remedy is the
|
||||
// same — regenerate — so treat a read failure as "stale".
|
||||
committed = null
|
||||
}
|
||||
if (committed !== content) stale.push(out)
|
||||
}
|
||||
if (stale.length === 0) {
|
||||
console.log(`gen-cordis-catalog: ${outputs.length} generated file(s) are up to date.`)
|
||||
console.log(`gen-cordis-catalog: ${outputs.length} generated file(s)/region(s) are up to date.`)
|
||||
process.exit(0)
|
||||
}
|
||||
console.error(`gen-cordis-catalog: ${stale.join(' and ')} ${stale.length === 1 ? 'is' : 'are'} stale. Run \`pnpm run gen-cordis-catalog\` and commit the result.`)
|
||||
console.error(`gen-cordis-catalog: stale — ${stale.join(', ')}. Run \`pnpm run gen-cordis-catalog\` and commit the result.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const before = new Map<string, Buffer>()
|
||||
for (const [out] of outputs) {
|
||||
try {
|
||||
before.set(out, readFileSync(resolve(root, out)))
|
||||
} catch {
|
||||
// First generation of this artifact; nothing to guard, nothing to record.
|
||||
}
|
||||
}
|
||||
let changedPages = 0
|
||||
let recorded = 0
|
||||
for (const [out, content] of outputs) {
|
||||
const destination = resolve(root, out)
|
||||
if (before.get(out)?.toString('utf8') === content) continue
|
||||
mkdirSync(dirname(destination), { recursive: true })
|
||||
writeFileSync(destination, content)
|
||||
changedPages++
|
||||
}
|
||||
console.log(`gen-cordis-catalog: wrote ${outputs.length} generated file(s).`)
|
||||
for (const page of [...new Set([...Object.values(SERVICE_PAGE), ...Object.values(EVENT_SCOPE_PAGE)])]) {
|
||||
const rel = `${SUBSYSTEMS_DIR}/${page}`
|
||||
const zhRel = rel.replace(/\.md$/, '.zh.md')
|
||||
const wroteEither = [rel, zhRel].some((side) => {
|
||||
const previous = before.get(side)
|
||||
return previous !== undefined && previous.toString('utf8') !== readFileSync(resolve(root, side), 'utf8')
|
||||
})
|
||||
if (wroteEither && maybeRecordPair(rel, before)) recorded++
|
||||
}
|
||||
console.log(`gen-cordis-catalog: ${outputs.length} artifact(s) computed, ${changedPages} written, ${recorded} pair record(s) refreshed.`)
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) main()
|
||||
// Run only when invoked as a script, not when imported by a test.
|
||||
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
|
||||
main()
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ const GROUP_ORDER = [
|
||||
'bash',
|
||||
'pty',
|
||||
'sandbox',
|
||||
'e2b',
|
||||
'fs',
|
||||
'skill',
|
||||
'compact',
|
||||
@@ -124,7 +125,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'session',
|
||||
title: 'In-memory session store',
|
||||
mode: 'core',
|
||||
consumers: ['agent-loop', 'agent', 'cli-demo', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants'],
|
||||
consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants'],
|
||||
note: 'Owns append-only Session instances and emits the durable session event feed.',
|
||||
},
|
||||
{
|
||||
@@ -140,8 +141,15 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'typert-registry',
|
||||
title: 'Runtime type registry',
|
||||
mode: 'core',
|
||||
consumers: ['typert-loader'],
|
||||
note: 'Plugins register live zod contributions directly or through dsh-typert-loader; runtime consumers query schemas and reflection metadata at their own edges.',
|
||||
consumers: ['typert-loader', 'api-gateway'],
|
||||
note: 'Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges.',
|
||||
},
|
||||
{
|
||||
key: 'typertGateway',
|
||||
pkg: 'api-gateway',
|
||||
title: 'TypeRT Host invocation gateway',
|
||||
mode: 'core',
|
||||
note: 'Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier.',
|
||||
},
|
||||
{
|
||||
key: 'sessionPersistence',
|
||||
@@ -250,7 +258,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
title: 'Human question/answer seam',
|
||||
mode: 'seam',
|
||||
consumers: ['tool-ask-user'],
|
||||
note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.',
|
||||
note: 'UI front ends provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.',
|
||||
},
|
||||
{
|
||||
key: 'planMode',
|
||||
@@ -259,6 +267,13 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
mode: 'core',
|
||||
note: 'Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions.',
|
||||
},
|
||||
{
|
||||
key: 'agentPresets',
|
||||
pkg: 'agent-presets',
|
||||
title: 'Per-session agent composition',
|
||||
mode: 'core',
|
||||
note: 'Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm.',
|
||||
},
|
||||
{
|
||||
key: 'commands',
|
||||
pkg: 'commands',
|
||||
@@ -287,7 +302,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'skill',
|
||||
title: 'Skill provider registry',
|
||||
mode: 'seam',
|
||||
implementations: ['skill-local'],
|
||||
implementations: ['skill-badge', 'skill-local'],
|
||||
consumers: ['tool-skill'],
|
||||
note: 'Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies.',
|
||||
},
|
||||
@@ -296,9 +311,17 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'agent',
|
||||
title: 'Agent service',
|
||||
mode: 'core',
|
||||
consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess'],
|
||||
consumers: ['agent-loop', 'acp', 'subagent-inprocess'],
|
||||
note: 'Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation.',
|
||||
},
|
||||
{
|
||||
key: 'agentDefaultModel',
|
||||
pkg: 'agent-default-model',
|
||||
title: 'Default Agent model selection',
|
||||
mode: 'core',
|
||||
consumers: ['headless', 'host-apiproxy'],
|
||||
note: 'Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner.',
|
||||
},
|
||||
{
|
||||
key: 'agentLoop',
|
||||
pkg: 'agent-loop',
|
||||
@@ -314,14 +337,22 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
mode: 'core',
|
||||
note: 'Folds revisioned objective state from the session log and keeps live continuation activation process-local.',
|
||||
},
|
||||
{
|
||||
key: 'e2b',
|
||||
pkg: 'e2b',
|
||||
title: 'E2B sandbox lifecycle owner',
|
||||
mode: 'core',
|
||||
consumers: ['fs-e2b', 'subprocess-e2b'],
|
||||
note: 'Owns one shared E2B SDK handle, remote working directory, and final sandbox disposition so both fundamental E2B providers inhabit the same Linux runtime.',
|
||||
},
|
||||
{
|
||||
key: 'subprocess',
|
||||
pkg: 'subprocess',
|
||||
title: 'Subprocess seam',
|
||||
mode: 'seam',
|
||||
implementations: ['subprocess-local'],
|
||||
consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp', 'subagent-codex', 'subagent-claude-code'],
|
||||
note: 'The bash executors, the LSP host, and the out-of-process ACP, Codex, and Claude Code subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.',
|
||||
implementations: ['subprocess-local', 'subprocess-e2b'],
|
||||
consumers: ['bash-local', 'bash-sandbox', 'pty-local', 'lsp-local', 'subagent-acp', 'subagent-codex', 'subagent-claude-code'],
|
||||
note: 'The bash executors, the PTY shell backend, the LSP host, and the out-of-process ACP, Codex, and Claude Code subagent backends spawn through ctx.subprocess; the service owns process coordinates, tree/session lifetime, stdio dispositions, terminal mechanics, and kill escalation.',
|
||||
},
|
||||
{
|
||||
key: 'bash',
|
||||
@@ -398,7 +429,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'fs',
|
||||
title: 'Filesystem provider seam',
|
||||
mode: 'seam',
|
||||
implementations: ['fs-local', 'fs-sandbox'],
|
||||
implementations: ['fs-local', 'fs-sandbox', 'fs-e2b'],
|
||||
consumers: ['tool-fs'],
|
||||
companions: ['fs-policy'],
|
||||
note: 'tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate.',
|
||||
@@ -410,7 +441,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
mode: 'seam',
|
||||
implementations: ['compact-basic'],
|
||||
consumers: ['compact-basic'],
|
||||
note: 'The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred.',
|
||||
note: 'The basic backend consumes post-step pressure and request-error recovery events; there is no model-facing compact tool.',
|
||||
},
|
||||
{
|
||||
key: 'subagents',
|
||||
@@ -628,10 +659,10 @@ const APP_EXAMPLES = [
|
||||
{
|
||||
id: 'headless',
|
||||
rel: 'examples/headless-agent/composition.md',
|
||||
title: 'Headless Agent App Composition',
|
||||
title: 'Headless Agent Snapshot Composition',
|
||||
label: 'examples/headless-agent',
|
||||
config: 'examples/headless-agent/cordis.yml',
|
||||
summary: 'The headless demo combines the real DeepSeek adapter and coding capabilities with the one-shot app package, format-pure stdout, and one fresh persisted top-level session.',
|
||||
summary: 'The headless snapshot composition combines the real DeepSeek adapter and coding capabilities with one explicitly configured persisted top-level agent; its JSONL driver is test-only.',
|
||||
},
|
||||
{
|
||||
id: 'acp',
|
||||
@@ -650,10 +681,8 @@ function renderAppExpansion(lines: string[], appNode: string, pluginName: string
|
||||
const jsonl = nodeId('bundle', 'jsonl')
|
||||
lines.push(` ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-spine-demo"]`)
|
||||
lines.push(` ${appNode} --> ${jsonl}["@deepseek-ai/dsh-session-persistence-jsonl"]`)
|
||||
if (pluginName === '@deepseek-ai/dsh-cli-demo') {
|
||||
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'cli')}["one-shot driver<br/>format-pure stdout<br/>fresh top-level agent"]`)
|
||||
} else if (pluginName === '@deepseek-ai/dsh-acp-demo') {
|
||||
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'acp')}["@deepseek-ai/dsh-acp<br/>automation-only JSON-RPC stdio<br/>fresh sessions created by client"]`)
|
||||
if (pluginName === '@deepseek-ai/dsh-acp-demo') {
|
||||
lines.push(` ${appNode} --> ${nodeId('entrypoint', 'acp')}["@deepseek-ai/dsh-acp<br/>automation-only JSON-RPC stdio<br/>fresh sessions created by client"]`)
|
||||
}
|
||||
lines.push(
|
||||
` ${agentCore} --> ${nodeId('spine', 'llm')}["ctx.llm"]`,
|
||||
@@ -678,7 +707,7 @@ function renderAppComposition(example: AppExample): string {
|
||||
const pluginNode = nodeId(`plugin_${example.id}`, plugin.id)
|
||||
lines.push(` ${pluginNode}["${escLabel(plugin.id)}<br/>${escLabel(plugin.name)}"]`)
|
||||
lines.push(` cfg --> ${pluginNode}`)
|
||||
if (plugin.name === '@deepseek-ai/dsh-cli-demo' || plugin.name === '@deepseek-ai/dsh-acp-demo') {
|
||||
if (plugin.name === '@deepseek-ai/dsh-acp-demo') {
|
||||
renderAppExpansion(lines, pluginNode, plugin.name)
|
||||
}
|
||||
}
|
||||
@@ -705,7 +734,18 @@ type CallSiteIndex = Map<ts.SignatureDeclaration | ts.JSDocSignature, ts.CallExp
|
||||
*/
|
||||
const EVENT_API_METHODS = new Set(['on', 'once', 'emit', 'parallel', 'serial', 'waterfall', 'dispatch'])
|
||||
|
||||
/** Collect event dispatch/listener relations from real cross-file receiver types. */
|
||||
/**
|
||||
* Collect event dispatch/listener relations from real cross-file receiver types.
|
||||
*
|
||||
* TODO: the program is seeded from the host aggregate alone (ts-project.ts
|
||||
* documents why: one program cannot hold both faces' Context merges), so a
|
||||
* Client package enters only when a host file imports it. Client-face
|
||||
* listeners on client-face events are therefore under-reported —
|
||||
* `connection/reset` omits `ui-skill`/`ui-agent-preset`, `models/changed`
|
||||
* omits `ui-model`, `session/preset-changed` omits `ui-skill`. Closing it
|
||||
* needs a second Client program whose relations merge into these, not a
|
||||
* wider seed.
|
||||
*/
|
||||
export class EventRelationCollector {
|
||||
private readonly relations = new Map<string, EventRelation>()
|
||||
private readonly fileCallSites = new Map<ts.SourceFile, CallSiteIndex>()
|
||||
@@ -1095,7 +1135,7 @@ function renderEventRelations(pkgs: Pkg[], events: readonly EventEntry[]): strin
|
||||
const maintenance = 'generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program'
|
||||
const lines = generatedHeader('Event Producer And Consumer Matrix')
|
||||
lines.push(
|
||||
'This matrix shows which packages dispatch each harness-owned event and which packages listen to it. It is intentionally a table rather than one large graph: events are many-to-many, and dense relation data is easier to review in rows. Receiver and event-name types also cover contained dispatch sites that deliberately bypass `ctx.emit`, such as subagent lifecycle containment.',
|
||||
'This matrix shows which packages dispatch each harness-owned event and which packages listen to it. Events are many-to-many, so the dense relation data is presented as a table rather than one large graph. Receiver and event-name types also cover contained dispatch sites that deliberately bypass `ctx.emit`, such as subagent lifecycle containment.',
|
||||
'',
|
||||
'| Event | Mode | Declared in | Dispatchers | Listeners |',
|
||||
'| --- | --- | --- | --- | --- |',
|
||||
@@ -1140,7 +1180,7 @@ function renderLifecycle(): string {
|
||||
const maintenance = 'curated Mermaid sequence; exact event signatures live in the generated Cordis catalog'
|
||||
return [
|
||||
...generatedHeader('Agent Turn And Step Lifecycle'),
|
||||
'This sequence is the visual companion to [architecture.md](architecture.md#loop-lifecycle-session--turn--step). It keeps durable replay facts on `session/event` and live control/status on `agent/*`.',
|
||||
'This sequence is the visual companion to [architecture.md](architecture.md#default-loop-lifecycle). It keeps durable replay facts on `session/event` and live control/status on `agent/*`.',
|
||||
'',
|
||||
'```mermaid',
|
||||
'sequenceDiagram',
|
||||
@@ -1208,7 +1248,7 @@ function renderLifecycle(): string {
|
||||
` Driver-->>SDK: ${mermaidCode('agent/status')} idle`,
|
||||
'```',
|
||||
'',
|
||||
'The `assistant/message` edge records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history while the durable anchor retains usage and exact chunk provenance, including an explicit empty source set.',
|
||||
'The `assistant/message` event records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history, while the durable event keeps usage and `sourceEventSeqs` listing the exact `assistant/chunk` events, including an explicit empty list.',
|
||||
'',
|
||||
'`dsh-compact-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.',
|
||||
'',
|
||||
@@ -1330,7 +1370,7 @@ function renderIndex(docs: GraphDoc[]): string {
|
||||
const maintenance = 'mixed: each linked page declares generated, hybrid, or curated mode'
|
||||
return [
|
||||
...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).',
|
||||
'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 [subsystem pages](subsystems/core.md) (types + the generated `cordis-surface` regions) and [tool-catalog.md](tool-catalog.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).',
|
||||
'',
|
||||
|
||||
@@ -18,8 +18,11 @@ const OUT = 'docs/persistence-catalog.md'
|
||||
* doc-typecheck, since their imported types are not standalone-compilable). */
|
||||
const FENCE = 'ts persistence-catalog'
|
||||
|
||||
/** The package whose module id plugin merges augment (`declare module '…'`). */
|
||||
const SESSION_MODULE = '@deepseek-ai/dsh-session'
|
||||
/** The package that owns the durable event vocabulary. */
|
||||
const SESSION_PACKAGE = '@deepseek-ai/dsh-session'
|
||||
|
||||
/** The type-only module that plugin declaration merges augment. */
|
||||
const SESSION_TYPES_MODULE = '@deepseek-ai/dsh-session/types'
|
||||
|
||||
/** Event-envelope declarations rendered before the per-event vocabulary. */
|
||||
const EVENT_ENVELOPE_TYPE_NAMES = [
|
||||
@@ -31,7 +34,7 @@ const EVENT_ENVELOPE_TYPE_NAMES = [
|
||||
|
||||
type EventEnvelopeTypeName = typeof EVENT_ENVELOPE_TYPE_NAMES[number]
|
||||
|
||||
/** Primary core-data-structures page for linked payload types. */
|
||||
/** Primary subsystems page for linked payload types. */
|
||||
const LINK_MAP: Record<string, string> = {
|
||||
CallId: 'core.md',
|
||||
ContentBlock: 'core.md',
|
||||
@@ -115,7 +118,7 @@ function declarationText(text: string, sf: ts.SourceFile, node: ts.Node): string
|
||||
/**
|
||||
* Every `interface SessionEventMap` declaration in a source file: the owning
|
||||
* top-level declaration (in `@deepseek-ai/dsh-session`) and any declaration
|
||||
* merge inside a `declare module '@deepseek-ai/dsh-session'` block. Both forms
|
||||
* merge inside a `declare module '@deepseek-ai/dsh-session/types'` block. Both forms
|
||||
* declare members of the SAME merged interface, so both are catalogued
|
||||
* uniformly. `topLevel` distinguishes the owning form so the caller can verify
|
||||
* it actually lives in the owning package — an unrelated local interface that
|
||||
@@ -125,7 +128,7 @@ function sessionEventMapDecls(sf: ts.SourceFile): { decl: ts.InterfaceDeclaratio
|
||||
const decls: { decl: ts.InterfaceDeclaration; topLevel: boolean }[] = []
|
||||
for (const stmt of sf.statements) {
|
||||
if (ts.isInterfaceDeclaration(stmt) && stmt.name.text === 'SessionEventMap') decls.push({ decl: stmt, topLevel: true })
|
||||
if (ts.isModuleDeclaration(stmt) && ts.isStringLiteral(stmt.name) && stmt.name.text === SESSION_MODULE
|
||||
if (ts.isModuleDeclaration(stmt) && ts.isStringLiteral(stmt.name) && stmt.name.text === SESSION_TYPES_MODULE
|
||||
&& stmt.body && ts.isModuleBlock(stmt.body)) {
|
||||
for (const inner of stmt.body.statements) {
|
||||
if (ts.isInterfaceDeclaration(inner) && inner.name.text === 'SessionEventMap') decls.push({ decl: inner, topLevel: false })
|
||||
@@ -174,8 +177,8 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
|
||||
// the owning package. Same-named interfaces elsewhere are different
|
||||
// types and must not enter the on-disk catalog.
|
||||
const pkg = packageNameFor(rel, scanRoot)
|
||||
if (pkg !== SESSION_MODULE) {
|
||||
violations.push(`top-level interface SessionEventMap (${declSrc}) is outside ${SESSION_MODULE} (package ${pkg ?? 'unknown'}). Rename the interface, or contribute events via declare module '${SESSION_MODULE}'.`)
|
||||
if (pkg !== SESSION_PACKAGE) {
|
||||
violations.push(`top-level interface SessionEventMap (${declSrc}) is outside ${SESSION_PACKAGE} (package ${pkg ?? 'unknown'}). Rename the interface, or contribute events via declare module '${SESSION_TYPES_MODULE}'.`)
|
||||
continue
|
||||
}
|
||||
const exported = decl.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false
|
||||
@@ -243,7 +246,7 @@ export function collectEventEnvelopeTypes(scanRoot: string = root): EventEnvelop
|
||||
const abs = resolve(scanRoot, rel)
|
||||
const text = readFileSync(abs, 'utf8')
|
||||
if (!EVENT_ENVELOPE_TYPE_NAMES.some(name => text.includes(name))) continue
|
||||
if (packageNameFor(rel, scanRoot) !== SESSION_MODULE) continue
|
||||
if (packageNameFor(rel, scanRoot) !== SESSION_PACKAGE) continue
|
||||
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
|
||||
for (const stmt of sf.statements) {
|
||||
if (!ts.isTypeAliasDeclaration(stmt) || !wanted.has(stmt.name.text)) continue
|
||||
@@ -330,7 +333,7 @@ function typeLinks(payload: string): string {
|
||||
if (new RegExp(`\\b${name}\\b`).test(payload)) seen.add(name)
|
||||
}
|
||||
if (seen.size === 0) return ''
|
||||
const links = [...seen].sort().map(n => `[${n}](core-data-structures/${LINK_MAP[n]})`)
|
||||
const links = [...seen].sort().map(n => `[${n}](subsystems/${LINK_MAP[n]})`)
|
||||
return `Types: ${links.join(' · ')}`
|
||||
}
|
||||
|
||||
@@ -352,11 +355,11 @@ export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnv
|
||||
'',
|
||||
'# Session Persistence Event Catalog',
|
||||
'',
|
||||
'Every event type that can appear in a session\'s durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](core-data-structures/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).',
|
||||
'Every event type that can appear in a session\'s durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge into `@deepseek-ai/dsh-session/types` in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](subsystems/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](subsystems/persistence.md) (how the log is made durable), and the generated region of [session.md](subsystems/session.md#cordis-surface) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).',
|
||||
'',
|
||||
'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md).',
|
||||
'',
|
||||
'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.',
|
||||
'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.',
|
||||
'',
|
||||
'## Event envelope',
|
||||
'',
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('tierExternalDeps', () => {
|
||||
it('keeps a package runtime when any shipping area declares it, and excludes workspace links', () => {
|
||||
const { manifests, names } = workspace({
|
||||
'package.json': { devDependencies: { shared: '^1' } },
|
||||
'packages/ui/tui/package.json': { name: '@deepseek-ai/dsh-tui', dependencies: { shared: '^1', '@deepseek-ai/dsh-cli': 'workspace:^' } },
|
||||
'packages/interaction/tui/package.json': { name: '@deepseek-ai/dsh-tui', dependencies: { shared: '^1', '@deepseek-ai/dsh-cli': 'workspace:^' } },
|
||||
'apps/cli/package.json': { name: '@deepseek-ai/dsh-cli' },
|
||||
})
|
||||
|
||||
@@ -329,13 +329,13 @@ describe('official Claude distribution authorization', () => {
|
||||
|
||||
describe('manifestPatterns', () => {
|
||||
it('derives globs from the declared members, so a new member area is read', () => {
|
||||
expect(manifestPatterns(['packages/*/*', 'tools/*'], ['packages/*'])).toEqual([
|
||||
expect(manifestPatterns(['packages/*/*', 'tools/*', 'native/landlock-run', 'native/landlock-run/packages/*'])).toEqual([
|
||||
'package.json',
|
||||
'packages/*/*/package.json',
|
||||
'tools/*/package.json',
|
||||
'examples/*/package.json',
|
||||
'native/landlock-run/package.json',
|
||||
'native/landlock-run/packages/*/package.json',
|
||||
'examples/*/package.json',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,14 +39,11 @@ const DEV_ONLY_AREAS = [
|
||||
'native/',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* First-party packages released from sibling repositories under the project's
|
||||
* own license: reachable from workspace manifests but not third-party.
|
||||
*/
|
||||
/** First-party public native packages: reachable at runtime but not third-party. */
|
||||
const FIRST_PARTY = new Set([
|
||||
'node-addon-landlock-run',
|
||||
'node-addon-landlock-run-linux-arm64',
|
||||
'node-addon-landlock-run-linux-x64',
|
||||
'@deepseek-ai/node-addon-landlock-run',
|
||||
'@deepseek-ai/node-addon-landlock-run-linux-arm64',
|
||||
'@deepseek-ai/node-addon-landlock-run-linux-x64',
|
||||
])
|
||||
|
||||
/** Official SDK identity covered by the project's narrow owner authorization. */
|
||||
@@ -135,16 +132,13 @@ function readManifest(rel: string): Manifest {
|
||||
* here, so a new member area (`tools/*`) is read the day it is declared.
|
||||
* @returns one glob per manifest-bearing location, repository-relative.
|
||||
*/
|
||||
export function manifestPatterns(rootMembers: readonly string[], nativeMembers: readonly string[]): string[] {
|
||||
export function manifestPatterns(rootMembers: readonly string[]): string[] {
|
||||
return [
|
||||
'package.json',
|
||||
...rootMembers.map(member => `${member}/package.json`),
|
||||
// The demo leaves join the workspace through `examples/package.json`, so
|
||||
// their own manifests are members of nothing and no glob above reaches them.
|
||||
'examples/*/package.json',
|
||||
// `native/landlock-run` is a nested workspace with its own lock file.
|
||||
'native/landlock-run/package.json',
|
||||
...nativeMembers.map(member => `native/landlock-run/${member}/package.json`),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -165,7 +159,7 @@ function workspaceMembers(rel: string): string[] {
|
||||
* would silently push dev-area manifests into the runtime tier.
|
||||
*/
|
||||
function loadWorkspaceManifests(): { manifests: Map<string, Manifest>; names: Set<string> } {
|
||||
const patterns = manifestPatterns(workspaceMembers('pnpm-workspace.yaml'), workspaceMembers('native/landlock-run/pnpm-workspace.yaml'))
|
||||
const patterns = manifestPatterns(workspaceMembers('pnpm-workspace.yaml'))
|
||||
const manifests = new Map<string, Manifest>()
|
||||
const names = new Set<string>()
|
||||
for (const pattern of patterns) {
|
||||
@@ -279,8 +273,8 @@ export function virtualManifest(virtual: string, name: string): VirtualManifest
|
||||
/** Resolve one installed external package manifest from either pnpm store. */
|
||||
function installedManifest(name: string): VirtualManifest | undefined {
|
||||
let manifest: (Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string }) | undefined
|
||||
// The nested Landlock workspace installs into its own store, so a package
|
||||
// only that workspace depends on is unreachable from the root one.
|
||||
// Workspace-local link farms can expose a dependency that is not linked at
|
||||
// the repository root; both are backed by the root workspace's lockfile.
|
||||
for (const store of ['node_modules', 'native/landlock-run/node_modules']) {
|
||||
const direct = resolve(root, store, name, 'package.json')
|
||||
if (existsSync(direct)) {
|
||||
@@ -303,7 +297,7 @@ function installedMetadata(name: string): { license: string; repo: string } {
|
||||
const rawRepo = typeof manifest?.repository === 'string' ? manifest.repository : manifest?.repository?.url ?? manifest?.homepage
|
||||
const repo = override?.repo ?? normalizeRepo(rawRepo)
|
||||
if (license === undefined || repo === undefined) {
|
||||
throw new Error(`gen-third-party-notices: cannot resolve ${license === undefined ? 'license' : 'repository'} for ${name}; run \`pnpm install\` (or, for a Landlock-only dependency, \`pnpm --dir native/landlock-run install\`), or add an OVERRIDES entry.`)
|
||||
throw new Error(`gen-third-party-notices: cannot resolve ${license === undefined ? 'license' : 'repository'} for ${name}; run \`pnpm install\`, or add an OVERRIDES entry.`)
|
||||
}
|
||||
return { license, repo }
|
||||
}
|
||||
@@ -698,7 +692,7 @@ DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the th
|
||||
|
||||
This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude platform payload closure. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check.
|
||||
|
||||
The complete npm transitive closure, with exact pinned versions, is recorded in [\`pnpm-lock.yaml\`](pnpm-lock.yaml) — inspect it with \`pnpm licenses list\`. The Python closure is recorded in [\`python/sdk/uv.lock\`](python/sdk/uv.lock), and the Landlock launcher workspace keeps its own in [\`native/landlock-run/pnpm-lock.yaml\`](native/landlock-run/pnpm-lock.yaml).
|
||||
The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [\`pnpm-lock.yaml\`](pnpm-lock.yaml) — inspect it with \`pnpm licenses list\`. The Python closure is recorded separately in [\`python/sdk/uv.lock\`](python/sdk/uv.lock).
|
||||
|
||||
## Vendored source (\`vendor/\`)
|
||||
|
||||
@@ -740,9 +734,9 @@ ${python.map(dep => `| [\`${dep.name}\`](${dep.repo}) | ${dep.license} | ${dep.r
|
||||
| --- | --- | --- |
|
||||
${BUILD_TIME_TOOLS.map(tool => `| [\`${tool.name}\`](${tool.repo}) | ${tool.license} | ${tool.role} |`).join('\n')}
|
||||
|
||||
## First-party sibling releases
|
||||
## First-party native packages
|
||||
|
||||
\`node-addon-landlock-run\` (and its platform packages) is released from a DeepSeek Harness sibling repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party.
|
||||
\`@deepseek-ai/node-addon-landlock-run\` (and its platform packages) is built and released from this repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party.
|
||||
`
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-ask-user',
|
||||
dir: 'tool-ask-user',
|
||||
source: 'packages/ui/tool-ask-user/src/index.ts',
|
||||
source: 'packages/interaction/tool-ask-user/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.userInteraction'],
|
||||
writes: ['tool/call', 'tool/result after a UI/provider answers the question'],
|
||||
async mount(ctx) {
|
||||
@@ -221,12 +221,12 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
await ctx.plugin(ToolPwsh)
|
||||
},
|
||||
note:
|
||||
'The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus the sandbox surface — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session; ConPTY is roadmap work), with native `C:\\...` paths and `$env:NAME` variables.',
|
||||
'The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus the sandbox surface — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session), with native `C:\\...` paths and `$env:NAME` variables.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-cordis',
|
||||
dir: 'tool-cordis',
|
||||
source: 'packages/cordis/tool-cordis/src/index.ts',
|
||||
source: 'packages/self-modification/tool-cordis/src/index.ts',
|
||||
requires: ['ctx.tools'],
|
||||
writes: ['tool/call', 'tool/result', 'process-local temporary Plugin lifecycle'],
|
||||
async mount(ctx) {
|
||||
@@ -253,7 +253,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
dir: 'tool-str-replace-editor',
|
||||
source: 'packages/fs/tool-str-replace-editor/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.fs'],
|
||||
writes: ['tool/call', 'fs/observed after successful file operations', 'tool/result'],
|
||||
writes: ['tool/call', 'fs/observed after view presence/absence, edit absence, or successful mutation', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(LocalFileSystem)
|
||||
await ctx.plugin(ToolStrReplaceEditor)
|
||||
@@ -266,7 +266,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
dir: 'tool-fs',
|
||||
source: 'packages/fs/tool-fs/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.fs', 'ctx.systemPrompt'],
|
||||
writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after successful file operations', 'tool/result'],
|
||||
writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after read presence/absence or successful mutation', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
// The tool needs `fs`; the bare provider is sufficient because policy
|
||||
// changes behavior, not schema shape.
|
||||
@@ -399,10 +399,11 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
pkg: '@deepseek-ai/dsh-tool-subagent-control',
|
||||
dir: 'tool-subagent-control',
|
||||
source: {
|
||||
interrupt_agent: 'packages/subagent/tool-subagent-control/src/index.ts',
|
||||
list_agents: 'packages/subagent/tool-subagent-control/src/list-agents.ts',
|
||||
send_message: 'packages/subagent/tool-subagent-control/src/index.ts',
|
||||
},
|
||||
requires: ['ctx.tools', 'ctx.subagents', 'ctx.sessionProjections (list_agents catalog rows)'],
|
||||
requires: ['ctx.tools', 'ctx.subagents', 'ctx.agents and ctx.sessionProjections (list_agents only)'],
|
||||
writes: ['tool/call', 'tool/result', 'child session events through ctx.subagents'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(SubagentService)
|
||||
@@ -414,7 +415,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
await ctx.plugin(ToolSubagentListAgents)
|
||||
},
|
||||
note:
|
||||
'The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows are served through the sessionProjections registry).',
|
||||
'The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries).',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-subagent-report',
|
||||
@@ -607,7 +608,7 @@ export function render(catalog: ToolCatalog): string {
|
||||
'',
|
||||
'# Tool Schema Catalog',
|
||||
'',
|
||||
'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the cordis [events](cordis-catalog/events.md) & [services](cordis-catalog/services.md) catalogs (the wiring a plugin listens to and calls) and [core-data-structures/](core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered.',
|
||||
'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the [subsystem pages](subsystems/core.md) (the types plus each page\'s generated `cordis-surface` wiring region) — this page is the *tools* the agent is offered.',
|
||||
'',
|
||||
'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator\'s boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog Agent Note](../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md).',
|
||||
'',
|
||||
@@ -654,6 +655,16 @@ async function main(): Promise<void> {
|
||||
process.exit(0)
|
||||
}
|
||||
console.error(`gen-tool-catalog: ${OUT} is stale. Run \`pnpm run gen-tool-catalog\` and commit ${OUT}.`)
|
||||
const committedLines = committed?.split('\n') ?? []
|
||||
const generatedLines = content.split('\n')
|
||||
const lineCount = Math.max(committedLines.length, generatedLines.length)
|
||||
for (let index = 0; index < lineCount; index += 1) {
|
||||
if (committedLines[index] === generatedLines[index]) continue
|
||||
console.error(`gen-tool-catalog: first difference at line ${index + 1}`)
|
||||
console.error(` committed: ${JSON.stringify(committedLines[index])}`)
|
||||
console.error(` generated: ${JSON.stringify(generatedLines[index])}`)
|
||||
break
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,19 @@ const INSTALL_LOCK_INITIALIZATION_TIMEOUT_MS = 1_000
|
||||
const INSTALL_LOCK_POLL_MS = 50
|
||||
const ALLOW_HOOKS_PATH_OVERRIDE = 'DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE'
|
||||
const REPOSITORY_EXTENSION_PATTERN = '^extensions\\.'
|
||||
const PAIRING_MERGE_DRIVER_CONFIG = [
|
||||
['merge.dsh-translation-pairing.name', 'DeepSeek Harness bilingual pairing records'],
|
||||
[
|
||||
'merge.dsh-translation-pairing.driver',
|
||||
'scripts/merge-translation-pairing-driver.sh %O %A %B %P',
|
||||
],
|
||||
]
|
||||
const PAIRING_MERGE_DRIVER_PROBE = [
|
||||
'--import',
|
||||
'tsx/esm',
|
||||
'scripts/merge-translation-pairing.ts',
|
||||
'--probe',
|
||||
]
|
||||
|
||||
function errorCode(error) {
|
||||
return typeof error === 'object' && error !== null && 'code' in error
|
||||
@@ -595,6 +608,86 @@ function refuseScopedHooksPath(entry) {
|
||||
)
|
||||
}
|
||||
|
||||
function installPairingMergeDriver(root, worktreeConfigPath) {
|
||||
const added = []
|
||||
try {
|
||||
for (const [key, expected] of PAIRING_MERGE_DRIVER_CONFIG) {
|
||||
const entries = includedFileConfigEntries(root, worktreeConfigPath, key)
|
||||
const includedEntry = entries.find(entry => !originIsFile(entry.origin, root, worktreeConfigPath))
|
||||
if (includedEntry !== undefined) {
|
||||
throw new Error(
|
||||
`refusing pairing merge-driver config from an included worktree file (${configSource(includedEntry)})`,
|
||||
)
|
||||
}
|
||||
const existing = assertSingle(entries.map(entry => entry.value), `worktree ${key}`)
|
||||
const effectiveBefore = effectiveConfigEntry(root, key)
|
||||
if (effectiveBefore?.scope === 'command') {
|
||||
throw new Error(
|
||||
`refusing command-scoped ${key} (${configSource(effectiveBefore)}); `
|
||||
+ 'transient configuration cannot be replaced by the worktree installer',
|
||||
)
|
||||
}
|
||||
if (existing === undefined && effectiveBefore !== undefined && effectiveBefore.value !== expected) {
|
||||
throw new Error(
|
||||
`refusing to mask inherited ${key} (${configSource(effectiveBefore)}); `
|
||||
+ 'remove or integrate the custom pairing merge driver explicitly',
|
||||
)
|
||||
}
|
||||
if (existing !== undefined && existing !== expected) {
|
||||
throw new Error(
|
||||
`refusing to replace worktree ${key} value ${JSON.stringify(existing)}; `
|
||||
+ 'remove or integrate the custom pairing merge driver explicitly',
|
||||
)
|
||||
}
|
||||
if (existing === undefined) {
|
||||
git(['config', '--worktree', key, expected], root)
|
||||
added.push(key)
|
||||
}
|
||||
const installed = includedFileConfigEntries(root, worktreeConfigPath, key)
|
||||
if (
|
||||
installed.length !== 1
|
||||
|| installed[0]?.value !== expected
|
||||
|| !originIsFile(installed[0].origin, root, worktreeConfigPath)
|
||||
) {
|
||||
throw new Error(`new worktree-local ${key} did not become the direct worktree value`)
|
||||
}
|
||||
const effectiveAfter = effectiveConfigEntry(root, key)
|
||||
if (
|
||||
effectiveAfter === undefined
|
||||
|| effectiveAfter.scope !== 'worktree'
|
||||
|| effectiveAfter.value !== expected
|
||||
|| !originIsFile(effectiveAfter.origin, root, worktreeConfigPath)
|
||||
) {
|
||||
throw new Error(`new worktree-local ${key} did not become the effective direct worktree value`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const rollbackErrors = []
|
||||
for (const key of added.reverse()) {
|
||||
try {
|
||||
git(['config', '--worktree', '--unset-all', key], root)
|
||||
} catch (rollbackError) {
|
||||
rollbackErrors.push(rollbackError)
|
||||
}
|
||||
}
|
||||
if (rollbackErrors.length > 0) {
|
||||
throw new AggregateError(
|
||||
[error, ...rollbackErrors],
|
||||
`Pairing merge-driver configuration failed: ${String(error)}; `
|
||||
+ `rollback also failed: ${rollbackErrors.map(String).join('; ')}`,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return () => {
|
||||
for (const key of added.reverse()) git(['config', '--worktree', '--unset-all', key], root)
|
||||
}
|
||||
}
|
||||
|
||||
function probePairingMergeDriver(root) {
|
||||
capture(process.execPath, PAIRING_MERGE_DRIVER_PROBE, { cwd: root })
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') return
|
||||
if (typeof lefthookPackage.bin?.lefthook !== 'string') return
|
||||
@@ -682,7 +775,10 @@ async function main() {
|
||||
applyWorktreeConfigMigration(root, commonConfigPath, migration)
|
||||
|
||||
let pathChanged = false
|
||||
let rollbackPairingMergeDriver = () => {}
|
||||
try {
|
||||
probePairingMergeDriver(root)
|
||||
rollbackPairingMergeDriver = installPairingMergeDriver(root, worktreeConfigPath)
|
||||
git(['config', '--worktree', 'core.hooksPath', hooksPath], root)
|
||||
pathChanged = worktreePath !== hooksPath
|
||||
const installedEntry = effectiveConfigEntry(root, 'core.hooksPath')
|
||||
@@ -697,6 +793,7 @@ async function main() {
|
||||
runLefthook(root, lefthook)
|
||||
updateOwnershipMarker(ownedHooksDirectory.markerPath, hooksPath)
|
||||
} catch (error) {
|
||||
const rollbackErrors = []
|
||||
if (pathChanged) {
|
||||
try {
|
||||
if (worktreePath === undefined) {
|
||||
@@ -705,13 +802,21 @@ async function main() {
|
||||
git(['config', '--worktree', 'core.hooksPath', worktreePath], root)
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError(
|
||||
[error, rollbackError],
|
||||
`Lefthook installation failed: ${String(error)}; `
|
||||
+ `worktree hook rollback also failed: ${String(rollbackError)}`,
|
||||
)
|
||||
rollbackErrors.push(rollbackError)
|
||||
}
|
||||
}
|
||||
try {
|
||||
rollbackPairingMergeDriver()
|
||||
} catch (rollbackError) {
|
||||
rollbackErrors.push(rollbackError)
|
||||
}
|
||||
if (rollbackErrors.length > 0) {
|
||||
throw new AggregateError(
|
||||
[error, ...rollbackErrors],
|
||||
`Lefthook installation failed: ${String(error)}; `
|
||||
+ `worktree integration rollback also failed: ${rollbackErrors.map(String).join('; ')}`,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -18,6 +18,9 @@ import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const installer = fileURLToPath(new URL('./install-lefthook.mjs', import.meta.url))
|
||||
const pairingMergeDriver = 'scripts/merge-translation-pairing-driver.sh %O %A %B %P'
|
||||
const scriptsDirectory = fileURLToPath(new URL('.', import.meta.url))
|
||||
const tsxPackageDirectory = dirname(fileURLToPath(import.meta.resolve('tsx/package.json')))
|
||||
const fixtures: string[] = []
|
||||
// Multi-worktree cases spawn several Git and Node subprocesses; coverage concurrency can
|
||||
// legitimately exceed Vitest's default deadline without changing the installer behavior.
|
||||
@@ -95,7 +98,7 @@ if (!shouldFail) {
|
||||
const binary = join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'lefthook.cmd' : 'lefthook')
|
||||
const config = readFileSync(join(root, 'lefthook.yml'), 'utf8').trim()
|
||||
const hook = \`#!/bin/sh\\n# root=\${root}\\n# binary=\${binary}\\n# config=\${config}\\nexit 0\\n\`
|
||||
for (const name of ['pre-commit', 'pre-push']) writeFileSync(join(hooksPath, name), hook, { mode: 0o755 })
|
||||
for (const name of ['pre-commit', 'pre-merge-commit', 'pre-push']) writeFileSync(join(hooksPath, name), hook, { mode: 0o755 })
|
||||
}
|
||||
if (existsSync(running)) unlinkSync(running)
|
||||
if (process.env.DSH_TEST_LEFTHOOK_BREAK_WORKTREE_CONFIG === '1') {
|
||||
@@ -122,6 +125,12 @@ function installFakeLefthook(root: string): void {
|
||||
chmodSync(shim, 0o755)
|
||||
}
|
||||
|
||||
function installPairingProbeFixture(root: string): void {
|
||||
const linkType = process.platform === 'win32' ? 'junction' : 'dir'
|
||||
symlinkSync(scriptsDirectory, join(root, 'scripts'), linkType)
|
||||
symlinkSync(tsxPackageDirectory, join(root, 'node_modules/tsx'), linkType)
|
||||
}
|
||||
|
||||
function createFixture(names: { main?: string; linked?: string } = {}): Fixture {
|
||||
const container = mkdtempSync(join(tmpdir(), 'dsh-lefthook-'))
|
||||
fixtures.push(container)
|
||||
@@ -151,6 +160,8 @@ function createFixture(names: { main?: string; linked?: string } = {}): Fixture
|
||||
write(join(linked, 'lefthook.yml'), 'linked-worktree-config\n')
|
||||
installFakeLefthook(main)
|
||||
installFakeLefthook(linked)
|
||||
installPairingProbeFixture(main)
|
||||
installPairingProbeFixture(linked)
|
||||
return fixture
|
||||
}
|
||||
|
||||
@@ -222,6 +233,9 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
|
||||
expect(git(fixture, fixture.main, ['config', '--get', 'core.repositoryFormatVersion'])).toBe('0')
|
||||
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
|
||||
expect(existsSync(join(common, 'config.worktree'))).toBe(false)
|
||||
expect(gitResult(fixture, fixture.main, [
|
||||
'config', '--get', 'merge.dsh-translation-pairing.driver',
|
||||
]).status).toBe(1)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -241,6 +255,12 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
|
||||
expect(mainHooks).not.toBe(linkedHooks)
|
||||
expect(git(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(mainHooks)
|
||||
expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(linkedHooks)
|
||||
expect(git(fixture, fixture.main, [
|
||||
'config', '--worktree', '--get', 'merge.dsh-translation-pairing.driver',
|
||||
])).toBe(pairingMergeDriver)
|
||||
expect(git(fixture, fixture.linked, [
|
||||
'config', '--worktree', '--get', 'merge.dsh-translation-pairing.driver',
|
||||
])).toBe(pairingMergeDriver)
|
||||
|
||||
const mainHook = readFileSync(join(mainHooks, 'pre-commit'), 'utf8')
|
||||
const linkedHook = readFileSync(join(linkedHooks, 'pre-commit'), 'utf8')
|
||||
@@ -252,6 +272,8 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
|
||||
expect(linkedHook).toContain(`# root=${canonicalLinked}`)
|
||||
expect(linkedHook).toContain('# config=linked-worktree-config')
|
||||
expect(linkedHook).not.toContain(canonicalMain)
|
||||
expect(existsSync(join(mainHooks, 'pre-merge-commit'))).toBe(true)
|
||||
expect(existsSync(join(linkedHooks, 'pre-merge-commit'))).toBe(true)
|
||||
expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n')
|
||||
|
||||
const commonConfig = join(common, 'config')
|
||||
@@ -275,6 +297,7 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
|
||||
git(fixture, fixture.main, ['worktree', 'add', '-b', 'late-linked', lateLinked])
|
||||
write(join(lateLinked, 'lefthook.yml'), 'late-linked-worktree-config\n')
|
||||
installFakeLefthook(lateLinked)
|
||||
installPairingProbeFixture(lateLinked)
|
||||
expect(git(fixture, lateLinked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(mainHooks)
|
||||
|
||||
const linkedInstall = await runInstaller(fixture, lateLinked)
|
||||
@@ -587,7 +610,7 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('sibling dormant worktree config')
|
||||
expect(result.stderr).toContain(linkedConfig)
|
||||
expect(result.stderr).toContain(JSON.stringify(linkedConfig))
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
|
||||
expect(gitResult(fixture, fixture.linked, ['config', '--get', 'core.hooksPath']).status).toBe(1)
|
||||
expect(git(fixture, fixture.main, ['config', '--file', linkedConfig, '--get', 'core.hooksPath'])).toBe(linkedHooks)
|
||||
@@ -677,9 +700,50 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
|
||||
expect(result.stderr).toContain('command-scoped core.hooksPath')
|
||||
expect(readFileSync(sentinel, 'utf8')).toBe('#!/bin/sh\n# command-scope sentinel\n')
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
|
||||
expect(gitResult(fixture, fixture.main, [
|
||||
'config', '--get', 'merge.dsh-translation-pairing.driver',
|
||||
]).status).toBe(1)
|
||||
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
|
||||
})
|
||||
|
||||
it('never replaces a custom worktree pairing merge driver', async () => {
|
||||
const fixture = createFixture()
|
||||
const commonConfig = join(commonDirectory(fixture), 'config')
|
||||
git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1'])
|
||||
git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true'])
|
||||
git(fixture, fixture.main, [
|
||||
'config', '--worktree', 'merge.dsh-translation-pairing.driver', 'custom-driver %A',
|
||||
])
|
||||
|
||||
const result = await runInstaller(fixture, fixture.main)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('refusing to replace worktree merge.dsh-translation-pairing.driver')
|
||||
expect(git(fixture, fixture.main, [
|
||||
'config', '--worktree', '--get', 'merge.dsh-translation-pairing.driver',
|
||||
])).toBe('custom-driver %A')
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
|
||||
})
|
||||
|
||||
it('never masks an inherited custom pairing merge driver', async () => {
|
||||
const fixture = createFixture()
|
||||
git(fixture, fixture.main, [
|
||||
'config', '--local', 'merge.dsh-translation-pairing.driver', 'inherited-driver %A',
|
||||
])
|
||||
|
||||
const result = await runInstaller(fixture, fixture.main)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('refusing to mask inherited merge.dsh-translation-pairing.driver')
|
||||
expect(git(fixture, fixture.main, [
|
||||
'config', '--local', '--get', 'merge.dsh-translation-pairing.driver',
|
||||
])).toBe('inherited-driver %A')
|
||||
expect(gitResult(fixture, fixture.main, [
|
||||
'config', '--worktree', '--get', 'merge.dsh-translation-pairing.driver',
|
||||
]).status).toBe(1)
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
|
||||
})
|
||||
|
||||
it('does not pass unrelated command-scoped Git config to Lefthook', async () => {
|
||||
const fixture = createFixture()
|
||||
|
||||
@@ -729,9 +793,29 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
|
||||
expect(result.stderr).toContain('exit status 77')
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1)
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
|
||||
expect(gitResult(fixture, fixture.main, [
|
||||
'config', '--worktree', '--get', 'merge.dsh-translation-pairing.name',
|
||||
]).status).toBe(1)
|
||||
expect(gitResult(fixture, fixture.main, [
|
||||
'config', '--worktree', '--get', 'merge.dsh-translation-pairing.driver',
|
||||
]).status).toBe(1)
|
||||
expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy pre-push\n')
|
||||
})
|
||||
|
||||
it('does not publish worktree integration when the pairing driver probe fails', async () => {
|
||||
const fixture = createFixture()
|
||||
rmSync(join(fixture.main, 'node_modules/tsx'), { recursive: true, force: true })
|
||||
|
||||
const result = await runInstaller(fixture, fixture.main)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('merge-translation-pairing.ts --probe failed')
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
|
||||
expect(gitResult(fixture, fixture.main, [
|
||||
'config', '--get', 'merge.dsh-translation-pairing.driver',
|
||||
]).status).toBe(1)
|
||||
})
|
||||
|
||||
it('reports installation and hook-path rollback failures together', async () => {
|
||||
const fixture = createFixture()
|
||||
|
||||
@@ -743,8 +827,9 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('Lefthook installation failed')
|
||||
expect(result.stderr).toContain('exit status 77')
|
||||
expect(result.stderr).toContain('worktree hook rollback also failed')
|
||||
expect(result.stderr).toContain('worktree integration rollback also failed')
|
||||
expect(result.stderr).toContain('git config --worktree --unset-all core.hooksPath failed')
|
||||
expect(result.stderr).toContain('git config --worktree --unset-all merge.dsh-translation-pairing.driver failed')
|
||||
})
|
||||
|
||||
it('refuses an unowned directory at the reserved worktree hook path', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
# dsh one-line installer.
|
||||
#
|
||||
# curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh
|
||||
# curl -fsSL https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/master/scripts/install.sh | sh
|
||||
#
|
||||
# It clones the harness under ~/.dsh/source (the master clone at
|
||||
# ~/.dsh/source/master), adds a per-install staging worktree at
|
||||
@@ -47,12 +47,10 @@
|
||||
# DSH_CURRENT stable symlink to the active worktree (default: $DSH_SOURCE/current)
|
||||
# DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin)
|
||||
# DSH_HOME Harness home holding profiles and user patches (default: ~/.dsh)
|
||||
# FIXME(install-ts): Move the post-checkout workflow into a tested TypeScript
|
||||
# entrypoint; keep this POSIX shell file as the curl/source bootstrap.
|
||||
set -eu
|
||||
|
||||
DSH_REF=${DSH_REF:-master}
|
||||
DSH_REPO=${DSH_REPO:-https://github.com/deepseek-harness/deepseek-harness.git}
|
||||
DSH_REPO=${DSH_REPO:-https://github.com/deepseek-ai/deepseek-harness-sdk.git}
|
||||
# DSH_SOURCE is the staging-worktree container and the default home of `current`.
|
||||
# DSH_MASTER names the main clone: clone mode defaults it inside DSH_SOURCE,
|
||||
# while adoption discovers an existing clone anywhere on disk. Remember whether
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('Oxlint repository rule fingerprint', () => {
|
||||
const overrides: readonly unknown[] = parsed.overrides
|
||||
|
||||
it('pins the complete override shape', () => {
|
||||
expect(overrides).toHaveLength(6)
|
||||
expect(overrides).toHaveLength(8)
|
||||
})
|
||||
|
||||
it.each(Object.entries(profiles))('pins the %s rule profile', (_name, profile) => {
|
||||
|
||||
35
scripts/merge-translation-pairing-driver.sh
Executable file
35
scripts/merge-translation-pairing-driver.sh
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
|
||||
if [ "$#" -ne 4 ]; then
|
||||
echo 'merge-translation-pairing: expected <ancestor> <current> <other> <repository-path>' >&2
|
||||
exit 129
|
||||
fi
|
||||
|
||||
ancestor_path=$1
|
||||
current_path=$2
|
||||
other_path=$3
|
||||
meta_path=$4
|
||||
driver_directory=$(CDPATH= cd -P "$(dirname "$0")" && pwd) || exit 129
|
||||
driver_path=$driver_directory/merge-translation-pairing.ts
|
||||
|
||||
if command -v node >/dev/null 2>&1 \
|
||||
&& node --import tsx/esm "$driver_path" --probe >/dev/null 2>&1; then
|
||||
exec node --import tsx/esm "$driver_path" \
|
||||
"$ancestor_path" "$current_path" "$other_path" "$meta_path"
|
||||
fi
|
||||
|
||||
echo "merge-translation-pairing: runtime is unavailable; leaving an ordinary text conflict in $meta_path" >&2
|
||||
git merge-file \
|
||||
-L "$meta_path:current" \
|
||||
-L "$meta_path:ancestor" \
|
||||
-L "$meta_path:other" \
|
||||
-- "$current_path" "$ancestor_path" "$other_path"
|
||||
fallback_status=$?
|
||||
echo 'merge-translation-pairing: restore Node dependencies, then rerun the merge or `pnpm run resolve-translation-pairing-conflicts`; use `git merge --abort` to cancel' >&2
|
||||
|
||||
# A clean text merge is still unverified pairing metadata, so the driver must
|
||||
# leave Git's index stages unresolved until the repository-aware resolver runs.
|
||||
if [ "$fallback_status" -gt 127 ]; then
|
||||
exit "$fallback_status"
|
||||
fi
|
||||
exit 1
|
||||
51
scripts/merge-translation-pairing.ts
Normal file
51
scripts/merge-translation-pairing.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/** Git merge-driver and explicit conflict-resolver entrypoint for pairing records. */
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
mergeTranslationPairingRecords,
|
||||
resolveTranslationPairingConflicts,
|
||||
} from './translation-pairing-merge.ts'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
|
||||
try {
|
||||
if (args[0] === '--probe') {
|
||||
if (args.length !== 1) throw new Error('--probe takes no other arguments')
|
||||
} else {
|
||||
const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim()
|
||||
if (args[0] === '--resolve') {
|
||||
if (args.length !== 1) throw new Error('--resolve takes no paths; it inspects the unmerged index')
|
||||
const resolved = resolveTranslationPairingConflicts(root)
|
||||
if (resolved.length === 0) {
|
||||
console.log('merge-translation-pairing: no unresolved pairing records')
|
||||
} else {
|
||||
for (const path of resolved) console.log(`merge-translation-pairing: resolved ${path}`)
|
||||
}
|
||||
} else {
|
||||
if (args.length !== 4) {
|
||||
throw new Error('merge-driver mode requires <ancestor> <current> <other> <repository-path>')
|
||||
}
|
||||
const [ancestorPath, currentPath, otherPath, metaPath] = args
|
||||
if (ancestorPath === undefined || currentPath === undefined || otherPath === undefined || metaPath === undefined) {
|
||||
throw new Error('merge-driver arguments are incomplete')
|
||||
}
|
||||
const result = mergeTranslationPairingRecords(
|
||||
root,
|
||||
metaPath,
|
||||
readFileSync(ancestorPath, 'utf8'),
|
||||
readFileSync(currentPath, 'utf8'),
|
||||
readFileSync(otherPath, 'utf8'),
|
||||
)
|
||||
writeFileSync(currentPath, result.record)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`merge-translation-pairing: ${error instanceof Error ? error.message : String(error)}`)
|
||||
console.error(
|
||||
'merge-translation-pairing: resolve owner conflicts, then confirm the pair with '
|
||||
+ '`pnpm run verify-translation-pairing --write <pair>`; rerun '
|
||||
+ '`pnpm run resolve-translation-pairing-conflicts` for other safe records',
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { join, relative } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { flattenDiagnosticMessageText, parseConfigFileTextToJson } from 'typescript'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const repositoryRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
const eslintCli = fileURLToPath(new URL('../node_modules/eslint/bin/eslint.js', import.meta.url))
|
||||
const oxlintCli = fileURLToPath(new URL('../node_modules/oxlint/bin/oxlint', import.meta.url))
|
||||
const tsxCli = fileURLToPath(new URL('../node_modules/tsx/dist/cli.mjs', import.meta.url))
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
@@ -18,11 +19,11 @@ function isUnknownArray(value: unknown): value is unknown[] {
|
||||
return Array.isArray(value)
|
||||
}
|
||||
|
||||
function runStagedFormatter(paths: readonly string[]) {
|
||||
return spawnSync(process.execPath, [eslintCli, '--config', 'eslint.format.config.mjs', '--fix', '--no-warn-ignored', ...paths], {
|
||||
function runRepositoryOxlint(args: readonly string[], env: NodeJS.ProcessEnv = {}) {
|
||||
return spawnSync(process.execPath, [tsxCli, 'scripts/run-oxlint.ts', ...args], {
|
||||
cwd: repositoryRoot,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, NO_COLOR: '1' },
|
||||
env: { ...process.env, NO_COLOR: '1', ...env },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -150,7 +151,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 +
|
||||
}
|
||||
}, 20_000)
|
||||
|
||||
it('keeps formatter rules aligned with Oxlint validation', async () => {
|
||||
it('keeps the complete stylistic contract in Oxlint', async () => {
|
||||
const oxlintPath = join(repositoryRoot, '.oxlintrc.json')
|
||||
const result = parseConfigFileTextToJson(oxlintPath, await readFile(oxlintPath, 'utf8'))
|
||||
if (result.error !== undefined) {
|
||||
@@ -160,27 +161,67 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 +
|
||||
if (!isRecord(parsed) || !isUnknownArray(parsed.overrides)) {
|
||||
throw new Error('.oxlintrc.json must contain an overrides array')
|
||||
}
|
||||
expect(parsed.ignorePatterns).toEqual(expect.arrayContaining([
|
||||
'packages/typert/generator/tests/fixtures/type-model/**',
|
||||
]))
|
||||
const stylisticOverride = parsed.overrides.find((value: unknown) =>
|
||||
isRecord(value) && isRecord(value.rules) && '@stylistic/max-len' in value.rules)
|
||||
if (!isRecord(stylisticOverride) || !isRecord(stylisticOverride.rules)) {
|
||||
throw new Error('.oxlintrc.json must contain the @stylistic validator override')
|
||||
}
|
||||
const validatorRules = { ...stylisticOverride.rules }
|
||||
const maxLen = validatorRules['@stylistic/max-len']
|
||||
delete validatorRules['@stylistic/max-len']
|
||||
expect(stylisticOverride.rules).toMatchObject({
|
||||
'@stylistic/indent': ['error', 2],
|
||||
'@stylistic/semi': ['error', 'never'],
|
||||
'@stylistic/quotes': ['error', 'single', { avoidEscape: true }],
|
||||
'@stylistic/comma-dangle': ['error', 'always-multiline'],
|
||||
'@stylistic/eol-last': ['error', 'always'],
|
||||
'@stylistic/no-trailing-spaces': 'error',
|
||||
'@stylistic/object-curly-spacing': ['error', 'always'],
|
||||
'@stylistic/arrow-parens': ['error', 'as-needed', { requireForBlockBody: true }],
|
||||
'@stylistic/member-delimiter-style': ['error', {
|
||||
multiline: { delimiter: 'none' },
|
||||
singleline: { delimiter: 'semi', requireLast: false },
|
||||
}],
|
||||
'@stylistic/max-len': ['error', { code: 140, ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true }],
|
||||
})
|
||||
const typeGraphOverride = parsed.overrides.find((value: unknown) =>
|
||||
isRecord(value)
|
||||
&& isUnknownArray(value.files)
|
||||
&& value.files.includes('packages/typert/generator/tests/fixtures/type-model/packages/host/src/models.ts'))
|
||||
expect(typeGraphOverride).toMatchObject({
|
||||
rules: { '@stylistic/quotes': 'off' },
|
||||
})
|
||||
})
|
||||
|
||||
const formatterUrl = pathToFileURL(join(repositoryRoot, 'eslint.format.config.mjs')).href
|
||||
const formatterModule = await import(formatterUrl) as unknown
|
||||
if (!isRecord(formatterModule) || !isUnknownArray(formatterModule.default)) {
|
||||
throw new Error('eslint.format.config.mjs must default-export a config array')
|
||||
}
|
||||
const formatterOverride = formatterModule.default.find((value: unknown) => isRecord(value) && isRecord(value.rules))
|
||||
if (!isRecord(formatterOverride) || !isRecord(formatterOverride.rules)) {
|
||||
throw new Error('eslint.format.config.mjs must contain a rules object')
|
||||
it('checks preserved TypeGraph syntax without type-aware analysis', () => {
|
||||
const result = runOxlint([
|
||||
'--config',
|
||||
'.oxlintrc.staged.json',
|
||||
'packages/typert/generator/tests/fixtures/type-model',
|
||||
])
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.status, normalizedOutput(result)).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps repository lint workflows Oxlint-only', async () => {
|
||||
const packageJson = JSON.parse(await readFile(join(repositoryRoot, 'package.json'), 'utf8')) as unknown
|
||||
if (!isRecord(packageJson) || !isRecord(packageJson.scripts) || !isRecord(packageJson.devDependencies)) {
|
||||
throw new Error('package.json must contain scripts and devDependencies objects')
|
||||
}
|
||||
|
||||
expect(validatorRules).toStrictEqual(formatterOverride.rules)
|
||||
expect(maxLen).toStrictEqual(['error', { code: 140, ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true }])
|
||||
expect(packageJson.scripts['lint:contracts-ready']).toBe('tsx scripts/run-oxlint.ts .')
|
||||
expect(packageJson.scripts['lint:fix:contracts-ready']).toBe(
|
||||
'tsx scripts/run-oxlint.ts --config .oxlintrc.staged.json packages/typert/generator/tests/fixtures/type-model --fix && tsx scripts/run-oxlint.ts . --fix',
|
||||
)
|
||||
expect(packageJson.devDependencies).not.toHaveProperty('eslint')
|
||||
expect(packageJson.devDependencies).not.toHaveProperty('@typescript-eslint/parser')
|
||||
expect(existsSync(join(repositoryRoot, 'eslint.format.config.mjs'))).toBe(false)
|
||||
|
||||
const lefthook = await readFile(join(repositoryRoot, 'lefthook.yml'), 'utf8')
|
||||
expect(lefthook).toContain('scripts/run-oxlint.ts --config .oxlintrc.staged.json --fix')
|
||||
expect(lefthook).not.toContain('node_modules/.bin/eslint')
|
||||
expect(lefthook).not.toContain('eslint.format.config.mjs')
|
||||
})
|
||||
|
||||
it('reports an unused suppression', async () => {
|
||||
@@ -208,7 +249,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 +
|
||||
rm(configPath, { force: true }),
|
||||
])
|
||||
}
|
||||
})
|
||||
}, 20_000)
|
||||
|
||||
it('accepts an ignored-only staged selection', () => {
|
||||
const result = runOxlint([
|
||||
@@ -221,30 +262,112 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 +
|
||||
expect(result.status, normalizedOutput(result)).toBe(0)
|
||||
})
|
||||
|
||||
it('applies staged stylistic fixes before Oxlint validation', async () => {
|
||||
it('keeps staged validation project-free while preserving source rules', async () => {
|
||||
const configPath = join(repositoryRoot, '.oxlintrc.staged.json')
|
||||
const result = parseConfigFileTextToJson(configPath, await readFile(configPath, 'utf8'))
|
||||
if (result.error !== undefined) {
|
||||
throw new Error(flattenDiagnosticMessageText(result.error.messageText, '\n'))
|
||||
}
|
||||
const stagedConfig = result.config as unknown
|
||||
if (!isRecord(stagedConfig)) throw new Error('.oxlintrc.staged.json must contain a config object')
|
||||
expect(stagedConfig).toMatchObject({
|
||||
extends: ['./.oxlintrc.json'],
|
||||
options: { typeAware: false },
|
||||
})
|
||||
expect(stagedConfig.ignorePatterns).not.toContain('packages/typert/generator/tests/fixtures/type-model/**')
|
||||
|
||||
const suffix = randomUUID()
|
||||
const configPath = await writeContractConfig(suffix)
|
||||
const directory = join(repositoryRoot, 'scripts', `.oxlint-contract-${suffix}`)
|
||||
const path = join(directory, 'fix.ts')
|
||||
const path = join(repositoryRoot, 'scripts', `staged-lint-probe-${suffix}.ts`)
|
||||
try {
|
||||
await writeFile(path, 'export const value={answer:1};\n')
|
||||
const lint = runOxlint([
|
||||
'--config',
|
||||
relative(repositoryRoot, configPath),
|
||||
'--format',
|
||||
'unix',
|
||||
relative(repositoryRoot, path),
|
||||
])
|
||||
const output = normalizedOutput(lint)
|
||||
|
||||
expect(lint.error).toBeUndefined()
|
||||
expect(lint.status, output).toBe(1)
|
||||
expect(output).toContain('@stylistic')
|
||||
expect(output).not.toContain('typescript(')
|
||||
} finally {
|
||||
await rm(path, { force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves successful fix output channels', async () => {
|
||||
const suffix = randomUUID()
|
||||
const path = join(repositoryRoot, 'scripts', `staged-lint-probe-${suffix}.ts`)
|
||||
|
||||
try {
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(path, 'const value={answer:1}; \nconsole.log(value)\n')
|
||||
|
||||
const relativePath = relative(repositoryRoot, path)
|
||||
const formatResult = runStagedFormatter([relativePath])
|
||||
const lintResult = runOxlint(['--config', relative(repositoryRoot, configPath), '--fix', relativePath])
|
||||
|
||||
expect(formatResult.error).toBeUndefined()
|
||||
expect(formatResult.status, normalizedOutput(formatResult)).toBe(0)
|
||||
expect(lintResult.error).toBeUndefined()
|
||||
expect(lintResult.status, normalizedOutput(lintResult)).toBe(0)
|
||||
await expect(readFile(path, 'utf8')).resolves.toBe('const value={ answer:1 }\nconsole.log(value)\n')
|
||||
} finally {
|
||||
await Promise.all([
|
||||
rm(directory, { recursive: true, force: true }),
|
||||
rm(configPath, { force: true }),
|
||||
await writeFile(path, '// oxlint-disable-next-line no-console\nexport const value = 1\n')
|
||||
const result = runRepositoryOxlint([
|
||||
'--config',
|
||||
'.oxlintrc.staged.json',
|
||||
'--format',
|
||||
'unix',
|
||||
'--fix',
|
||||
relative(repositoryRoot, path),
|
||||
])
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.status, normalizedOutput(result)).toBe(0)
|
||||
expect(result.stdout).toContain('Unused oxlint-disable directive')
|
||||
expect(result.stderr).toBe('')
|
||||
} finally {
|
||||
await rm(path, { force: true })
|
||||
}
|
||||
}, 20_000)
|
||||
})
|
||||
|
||||
it('prints only the final diagnostics when a fix retry still fails', async () => {
|
||||
const suffix = randomUUID()
|
||||
const path = join(repositoryRoot, 'scripts', `staged-lint-probe-${suffix}.ts`)
|
||||
|
||||
try {
|
||||
await writeFile(path, `export const longProbe = ${'1 + '.repeat(80)}1\n`)
|
||||
const result = runRepositoryOxlint([
|
||||
'--config',
|
||||
'.oxlintrc.staged.json',
|
||||
'--format',
|
||||
'unix',
|
||||
'--fix',
|
||||
relative(repositoryRoot, path),
|
||||
])
|
||||
const output = normalizedOutput(result)
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.status, output).toBe(1)
|
||||
expect(output.match(/@stylistic\(max-len\)/g)).toHaveLength(1)
|
||||
} finally {
|
||||
await rm(path, { force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['--fix', '--fix-suggestions', '--fix-dangerously'])(
|
||||
'converges overlapping staged stylistic fixes through Oxlint under %s',
|
||||
async (fixFlag) => {
|
||||
const suffix = randomUUID()
|
||||
const directory = join(repositoryRoot, 'scripts', `.oxlint-contract-${suffix}`)
|
||||
const path = join(directory, 'fix.ts')
|
||||
|
||||
try {
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(path, 'const value={answer:1}; \nconsole.log(value)\n')
|
||||
|
||||
const relativePath = relative(repositoryRoot, path)
|
||||
const lintResult = runRepositoryOxlint(['--config', '.oxlintrc.staged.json', fixFlag, relativePath])
|
||||
|
||||
expect(lintResult.error).toBeUndefined()
|
||||
expect(lintResult.status, normalizedOutput(lintResult)).toBe(0)
|
||||
expect(normalizedOutput(lintResult)).not.toContain('@stylistic')
|
||||
await expect(readFile(path, 'utf8')).resolves.toBe('const value={ answer:1 }\nconsole.log(value)\n')
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
20_000,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -72,6 +72,20 @@ describe('package invariant gate', () => {
|
||||
expect(collectPackageInvariantViolations(fixture())).toEqual([])
|
||||
})
|
||||
|
||||
it('accepts an invariant reference owned by a package-local leaf project', () => {
|
||||
const root = fixture({ invariantReference: false })
|
||||
const dir = join(root, 'packages/core/probe')
|
||||
writeFileSync(join(dir, 'tsconfig.json'), `${JSON.stringify({
|
||||
files: [],
|
||||
references: [{ path: './tsconfig.host.json' }],
|
||||
}, null, 2)}\n`)
|
||||
writeFileSync(join(dir, 'tsconfig.host.json'), `${JSON.stringify({
|
||||
references: [{ path: '../../support/invariants' }],
|
||||
}, null, 2)}\n`)
|
||||
|
||||
expect(collectPackageInvariantViolations(root)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects missing publication metadata and build output', () => {
|
||||
const violations = collectPackageInvariantViolations(fixture({
|
||||
invariantExport: false,
|
||||
|
||||
@@ -118,11 +118,8 @@ function checkBuild(
|
||||
violations: PackageInvariantViolation[],
|
||||
): void {
|
||||
const tsconfigPath = `${owner.dir}/tsconfig.json`
|
||||
const tsconfig = JSON.parse(readFileSync(resolve(root, tsconfigPath), 'utf8')) as {
|
||||
references?: Array<{ path?: string }>
|
||||
}
|
||||
if (owner.packageName !== '@deepseek-ai/dsh-invariants'
|
||||
&& !tsconfig.references?.some(reference => reference.path === '../../support/invariants')) {
|
||||
&& !projectReferencesInvariants(root, owner.dir, tsconfigPath)) {
|
||||
addViolation(
|
||||
violations,
|
||||
tsconfigPath,
|
||||
@@ -138,6 +135,31 @@ function checkBuild(
|
||||
}
|
||||
}
|
||||
|
||||
function projectReferencesInvariants(root: string, ownerDir: string, entryPath: string): boolean {
|
||||
const ownerRoot = resolve(root, ownerDir)
|
||||
const target = resolve(root, 'packages/support/invariants')
|
||||
const pending = [resolve(root, entryPath)]
|
||||
const visited = new Set<string>()
|
||||
while (pending.length > 0) {
|
||||
const configPath = pending.pop()
|
||||
if (configPath === undefined) break
|
||||
if (visited.has(configPath)) continue
|
||||
visited.add(configPath)
|
||||
const config = JSON.parse(readFileSync(configPath, 'utf8')) as {
|
||||
references?: Array<{ path?: string }>
|
||||
}
|
||||
for (const reference of config.references ?? []) {
|
||||
if (reference.path === undefined) continue
|
||||
const referenced = resolve(dirname(configPath), reference.path)
|
||||
if (referenced === target) return true
|
||||
if (!referenced.startsWith(`${ownerRoot}${sep}`)) continue
|
||||
const childConfig = referenced.endsWith('.json') ? referenced : resolve(referenced, 'tsconfig.json')
|
||||
if (existsSync(childConfig)) pending.push(childConfig)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function checkSource(
|
||||
owner: PackageInvariantOwner,
|
||||
root: string,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/** Tests for the documentation website projection adapter. */
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { basename, join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { docsPages, type DocsPage } from '../website/docs.ts'
|
||||
import {
|
||||
@@ -104,7 +104,7 @@ describe('rewriteMarkdown', () => {
|
||||
repositoryRef: 'abc123',
|
||||
})).toBe(
|
||||
'[B](./reference/b.md#part) '
|
||||
+ '[source](https://github.com/deepseek-harness/deepseek-harness/blob/abc123/packages/tool.ts#L2) '
|
||||
+ '[source](https://github.com/deepseek-ai/deepseek-harness-sdk/blob/abc123/packages/tool.ts#L2) '
|
||||
+ '[web](https://example.com)\n',
|
||||
)
|
||||
})
|
||||
@@ -130,7 +130,7 @@ describe('rewriteMarkdown', () => {
|
||||
pages,
|
||||
repoRoot: root,
|
||||
repositoryRef: 'abc123',
|
||||
})).toBe('\n')
|
||||
})).toBe('\n')
|
||||
})
|
||||
|
||||
it('hands an image to the placer and uses the URL it returns', () => {
|
||||
@@ -148,7 +148,7 @@ describe('rewriteMarkdown', () => {
|
||||
repoRoot: root,
|
||||
repositoryRef: 'abc123',
|
||||
placeImage: (absPath) => {
|
||||
const name = absPath.split('/').pop() ?? ''
|
||||
const name = basename(absPath)
|
||||
placed.push(name)
|
||||
return `./${name}`
|
||||
},
|
||||
@@ -167,7 +167,7 @@ describe('rewriteMarkdown', () => {
|
||||
pages,
|
||||
repoRoot: root,
|
||||
repositoryRef: 'abc123',
|
||||
placeImage: absPath => `./${absPath.split('/').pop() ?? ''}`,
|
||||
placeImage: absPath => `./${basename(absPath)}`,
|
||||
})).toBe('\n')
|
||||
})
|
||||
|
||||
@@ -209,7 +209,7 @@ describe('rewriteMarkdown', () => {
|
||||
repositoryRef: 'abc123',
|
||||
})).toBe(
|
||||
'[title](./reference/b.md "b.md") '
|
||||
+ '[escaped](https://github.com/deepseek-harness/deepseek-harness/blob/abc123/docs/x(y).md)\n',
|
||||
+ '[escaped](https://github.com/deepseek-ai/deepseek-harness-sdk/blob/abc123/docs/x(y).md)\n',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -251,7 +251,7 @@ describe('rewriteMarkdown', () => {
|
||||
})
|
||||
|
||||
describe('docsPages locale routes', () => {
|
||||
it('publishes every route in both locales and selects paired sources', () => {
|
||||
it('publishes every route in both locales and uses every available Chinese counterpart', () => {
|
||||
const byRoute = new Map(docsPages.map(page => [page.route, page]))
|
||||
for (const page of docsPages.filter(page => page.locale === 'root')) {
|
||||
const counterpart = byRoute.get(`en/${page.route}`)
|
||||
@@ -265,24 +265,38 @@ describe('docsPages locale routes', () => {
|
||||
} else {
|
||||
expect(counterpart?.source).toBe(page.source)
|
||||
expect(counterpart?.contentLocale).toBe(page.contentLocale)
|
||||
const chineseSource = page.source.replace(/\.md$/, '.zh.md')
|
||||
expect(
|
||||
existsSync(resolve(repositoryRoot, chineseSource)),
|
||||
`${page.route} has a Chinese counterpart but projects English`,
|
||||
).toBe(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('projects translated core-data pages while retaining explicit English fallbacks', () => {
|
||||
it('indexes every subsystem page in both sides of the folder README', () => {
|
||||
const pages = globSync(join(repositoryRoot, 'docs/subsystems/*.md'))
|
||||
.map(page => basename(page))
|
||||
.filter(page => !page.endsWith('.zh.md') && page !== 'README.md')
|
||||
.sort()
|
||||
expect(pages.length).toBeGreaterThan(0)
|
||||
for (const readme of ['README.md', 'README.zh.md']) {
|
||||
const rows = readFileSync(join(repositoryRoot, 'docs/subsystems', readme), 'utf8')
|
||||
const missing = pages.filter(page => !rows.includes(`| [${page}](${page}) |`))
|
||||
expect(missing, `${readme} must carry one table row per subsystem page`).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('projects every published subsystem page in Chinese', () => {
|
||||
const rootPages = docsPages.filter(page => (
|
||||
page.locale === 'root' && page.route.startsWith('reference/core-data-structures/')
|
||||
page.locale === 'root' && page.route.startsWith('reference/subsystems/')
|
||||
))
|
||||
const translated = rootPages.filter(page => page.contentLocale === 'zh-CN')
|
||||
const fallbacks = rootPages.filter(page => page.contentLocale === 'en-US')
|
||||
|
||||
expect(translated).toHaveLength(20)
|
||||
expect(translated).toHaveLength(42)
|
||||
expect(translated.every(page => page.source.endsWith('.zh.md'))).toBe(true)
|
||||
expect(fallbacks.map(page => page.source).sort()).toEqual([
|
||||
'docs/core-data-structures/commands.md',
|
||||
'docs/core-data-structures/goal.md',
|
||||
'docs/core-data-structures/pty.md',
|
||||
])
|
||||
expect(fallbacks).toEqual([])
|
||||
})
|
||||
|
||||
it('publishes the Cordis core API under matching locale structures', () => {
|
||||
@@ -290,18 +304,51 @@ describe('docsPages locale routes', () => {
|
||||
for (const file of files) {
|
||||
const root = docsPages.find(page => page.route === `reference/cordis-api/${file}`)
|
||||
const english = docsPages.find(page => page.route === `en/reference/cordis-api/${file}`)
|
||||
expect(root?.source).toBe(`docs/cordis-catalog/core/${file}`)
|
||||
expect(root?.source).toBe(`docs/cordis-api/${file.replace(/\.md$/, '.zh.md')}`)
|
||||
expect(root?.contentLocale).toBe('zh-CN')
|
||||
expect(root?.section).toBe('Cordis API')
|
||||
expect(english?.source).toBe(root?.source)
|
||||
expect(english?.source).toBe(`docs/cordis-api/${file}`)
|
||||
expect(english?.contentLocale).toBe('en-US')
|
||||
expect(english?.section).toBe('Cordis Core API')
|
||||
}
|
||||
})
|
||||
|
||||
it('includes persistence event headings in both locale outlines', () => {
|
||||
const pages = docsPages.filter(page => page.source === 'docs/persistence-catalog.md')
|
||||
it('keeps Cordis inherited on the English fallback in both locales', () => {
|
||||
const pages = docsPages.filter(page => page.route.endsWith('reference/cordis-api/inherited.md'))
|
||||
expect(pages).toHaveLength(2)
|
||||
expect(pages.every(page => page.source === 'docs/cordis-api/inherited.md')).toBe(true)
|
||||
expect(pages.every(page => page.contentLocale === 'en-US')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes persistence event headings in both locale outlines', () => {
|
||||
const pages = docsPages.filter(page => page.route.endsWith('reference/persistence-catalog.md'))
|
||||
expect(pages).toHaveLength(2)
|
||||
expect(pages.map(page => page.source).sort()).toEqual([
|
||||
'docs/persistence-catalog.md',
|
||||
'docs/persistence-catalog.zh.md',
|
||||
])
|
||||
expect(pages.map(page => page.outline)).toEqual(['deep', 'deep'])
|
||||
})
|
||||
|
||||
it('projects reviewed generated counterparts into root locale routes', () => {
|
||||
// module-graph, event-producer-consumer, and graph-atlas are paired but intentionally unpublished.
|
||||
const routes = [
|
||||
'reference/capability-seams.md',
|
||||
'reference/agent-lifecycle.md',
|
||||
'reference/tool-execution-pipeline.md',
|
||||
'reference/config-catalog.md',
|
||||
'reference/tool-catalog.md',
|
||||
'reference/persistence-catalog.md',
|
||||
'reference/cordis-api/context.md',
|
||||
'reference/cordis-api/events.md',
|
||||
'reference/cordis-api/fiber.md',
|
||||
'reference/cordis-api/registry.md',
|
||||
'reference/cordis-api/service.md',
|
||||
]
|
||||
const pages = routes.map(route => docsPages.find(page => page.route === route))
|
||||
expect(pages.every(page => page?.contentLocale === 'zh-CN')).toBe(true)
|
||||
expect(pages.every(page => page?.source.endsWith('.zh.md'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addProjectionFrontmatter', () => {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { gfm } from 'micromark-extension-gfm'
|
||||
import type { Nodes } from 'mdast'
|
||||
import { docsPages, type DocsLocale, type DocsPage } from '../website/docs.ts'
|
||||
|
||||
const REPOSITORY_URL = 'https://github.com/deepseek-harness/deepseek-harness'
|
||||
const REPOSITORY_URL = 'https://github.com/deepseek-ai/deepseek-harness-sdk'
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const generatedRoot = resolve(root, 'website/.generated')
|
||||
|
||||
@@ -131,6 +131,12 @@ function destinationRange(rawNode: string, type: 'link' | 'image' | 'definition'
|
||||
return { start, end: rawNode.length }
|
||||
}
|
||||
|
||||
// `#fragment` suffixes pass through verbatim. Generated cordis-surface
|
||||
// headings carry explicit `<a id>` anchors with the GitHub slug, so those
|
||||
// fragments resolve on the published site too; hand-written headings rely on
|
||||
// VitePress's own slugger, which differs from GitHub's for punctuation-heavy
|
||||
// text — hand-authored cross-page fragments should prefer plain-text headings
|
||||
// or explicit anchors.
|
||||
function splitTarget(url: string): { path: string; suffix: string } {
|
||||
const boundary = url.search(/[?#]/)
|
||||
if (boundary === -1) return { path: url, suffix: '' }
|
||||
@@ -203,7 +209,7 @@ function githubTarget(
|
||||
image: boolean,
|
||||
): string {
|
||||
const path = repoPath(absPath, repoRoot)
|
||||
if (image) return `https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/${repositoryRef}/${path}${suffix}`
|
||||
if (image) return `https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/${repositoryRef}/${path}${suffix}`
|
||||
const kind = lstatSync(absPath).isDirectory() ? 'tree' : 'blob'
|
||||
const lineSuffix = line === undefined ? suffix : `#L${line}`
|
||||
return `${REPOSITORY_URL}/${kind}/${repositoryRef}/${path}${lineSuffix}`
|
||||
|
||||
100
scripts/project-reference-faces.spec.ts
Normal file
100
scripts/project-reference-faces.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { collectProjectReferenceFaceViolations } from './project-reference-faces.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function writeJson(path: string, value: unknown): void {
|
||||
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`)
|
||||
}
|
||||
|
||||
function workspaceFixture(options: {
|
||||
readonly host: readonly string[]
|
||||
readonly client: readonly string[]
|
||||
}): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-project-reference-faces-'))
|
||||
roots.push(root)
|
||||
const shared = join(root, 'packages/core/shared')
|
||||
const split = join(root, 'packages/api/split')
|
||||
mkdirSync(shared, { recursive: true })
|
||||
mkdirSync(split, { recursive: true })
|
||||
writeJson(join(root, 'tsconfig.base.json'), {})
|
||||
writeJson(join(root, 'tsconfig.base.client.json'), { extends: './tsconfig.base.json' })
|
||||
writeJson(join(shared, 'package.json'), { name: '@deepseek-ai/dsh-shared' })
|
||||
writeJson(join(shared, 'tsconfig.json'), {
|
||||
extends: '../../../tsconfig.base.json',
|
||||
references: [],
|
||||
})
|
||||
writeJson(join(split, 'package.json'), { name: '@deepseek-ai/dsh-split' })
|
||||
writeJson(join(split, 'tsconfig.json'), {
|
||||
files: [],
|
||||
references: [{ path: './tsconfig.host.json' }, { path: './tsconfig.client.json' }],
|
||||
})
|
||||
writeJson(join(split, 'tsconfig.host.json'), { references: [{ path: '../../core/shared' }] })
|
||||
writeJson(join(split, 'tsconfig.client.json'), { references: [{ path: '../../core/shared' }] })
|
||||
writeJson(join(root, 'tsconfig.host.json'), {
|
||||
references: options.host.map(path => ({ path })),
|
||||
})
|
||||
writeJson(join(root, 'tsconfig.client.json'), {
|
||||
references: options.client.map(path => ({ path })),
|
||||
})
|
||||
return root
|
||||
}
|
||||
|
||||
describe('Project Reference compiler faces', () => {
|
||||
it('allows neutral projects in either graph and matching split leaves', () => {
|
||||
const root = workspaceFixture({
|
||||
host: ['./packages/core/shared', './packages/api/split/tsconfig.host.json'],
|
||||
client: ['./packages/core/shared', './packages/api/split/tsconfig.client.json'],
|
||||
})
|
||||
|
||||
expect(collectProjectReferenceFaceViolations(root)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects the opposite leaf and the solution root of a split project', () => {
|
||||
const root = workspaceFixture({
|
||||
host: [
|
||||
'./packages/api/split/tsconfig.host.json',
|
||||
'./packages/api/split/tsconfig.client.json',
|
||||
],
|
||||
client: ['./packages/api/split'],
|
||||
})
|
||||
|
||||
expect(collectProjectReferenceFaceViolations(root)).toEqual([
|
||||
'tsconfig.client.json: Project Reference "./packages/api/split" enters split project packages/api/split from a Client config; reference "packages/api/split/tsconfig.client.json" instead',
|
||||
'tsconfig.host.json: Project Reference "./packages/api/split/tsconfig.client.json" enters split project packages/api/split from a Host config; reference "packages/api/split/tsconfig.host.json" instead',
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the referencing project face throughout the reachable graph', () => {
|
||||
const root = workspaceFixture({
|
||||
host: ['./packages/core/host-consumer'],
|
||||
client: ['./packages/core/client-consumer'],
|
||||
})
|
||||
const hostConsumer = join(root, 'packages/core/host-consumer')
|
||||
mkdirSync(hostConsumer, { recursive: true })
|
||||
writeJson(join(hostConsumer, 'package.json'), { name: '@deepseek-ai/dsh-host-consumer' })
|
||||
writeJson(join(hostConsumer, 'tsconfig.json'), {
|
||||
extends: '../../../tsconfig.base.json',
|
||||
references: [{ path: '../../api/split/tsconfig.client.json' }],
|
||||
})
|
||||
const clientConsumer = join(root, 'packages/core/client-consumer')
|
||||
mkdirSync(clientConsumer, { recursive: true })
|
||||
writeJson(join(clientConsumer, 'package.json'), { name: '@deepseek-ai/dsh-client-consumer' })
|
||||
writeJson(join(clientConsumer, 'tsconfig.json'), {
|
||||
extends: '../../../tsconfig.base.client.json',
|
||||
references: [{ path: '../../api/split/tsconfig.host.json' }],
|
||||
})
|
||||
|
||||
expect(collectProjectReferenceFaceViolations(root)).toEqual([
|
||||
'packages/core/client-consumer/tsconfig.json: Project Reference "../../api/split/tsconfig.host.json" enters split project packages/api/split from a Client config; reference "packages/api/split/tsconfig.client.json" instead',
|
||||
'packages/core/host-consumer/tsconfig.json: Project Reference "../../api/split/tsconfig.client.json" enters split project packages/api/split from a Host config; reference "packages/api/split/tsconfig.host.json" instead',
|
||||
])
|
||||
})
|
||||
})
|
||||
129
scripts/project-reference-faces.ts
Normal file
129
scripts/project-reference-faces.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/** Validate compiler-face isolation across workspace Project Reference graphs. */
|
||||
|
||||
import { existsSync, globSync } from 'node:fs'
|
||||
import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
|
||||
type ProjectFace = 'host' | 'client'
|
||||
|
||||
interface ProjectReferenceConfig {
|
||||
readonly extends?: unknown
|
||||
readonly references?: ReadonlyArray<{ readonly path?: unknown }>
|
||||
}
|
||||
|
||||
const WORKSPACE_MANIFESTS = [
|
||||
'packages/*/*/package.json',
|
||||
'apps/*/package.json',
|
||||
'vendor/*/package.json',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Find references that enter the wrong leaf of a split Host/Client project.
|
||||
*
|
||||
* A single-config project is neutral and may participate in either graph. Once
|
||||
* a package declares both face configs, every reachable reference must name
|
||||
* the leaf matching the aggregate from which traversal began.
|
||||
*
|
||||
* @param root - Repository root containing both aggregate tsconfigs.
|
||||
* @returns Repo-relative diagnostics for every mismatched reference edge.
|
||||
*/
|
||||
export function collectProjectReferenceFaceViolations(root: string): string[] {
|
||||
const splitRoots = splitProjectRoots(root)
|
||||
const violations: string[] = []
|
||||
const pending = [resolve(root, 'tsconfig.host.json'), resolve(root, 'tsconfig.client.json')]
|
||||
const visited = new Set<string>()
|
||||
for (let configPath = pending.pop(); configPath !== undefined; configPath = pending.pop()) {
|
||||
if (visited.has(configPath) || !existsSync(configPath)) continue
|
||||
visited.add(configPath)
|
||||
const config = projectConfig(root, configPath)
|
||||
const face = projectFace(root, configPath, config)
|
||||
for (const reference of projectReferences(config)) {
|
||||
const targetConfig = referenceConfigPath(configPath, reference)
|
||||
const splitRoot = containingSplitRoot(splitRoots, targetConfig)
|
||||
if (splitRoot !== undefined) {
|
||||
if (face === undefined) {
|
||||
violations.push(
|
||||
`${repoPath(root, configPath)}: Project Reference ${JSON.stringify(reference)} enters split project ${repoPath(root, splitRoot)} from a config with no Host/Client face`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
const expected = resolve(splitRoot, `tsconfig.${face}.json`)
|
||||
if (targetConfig !== expected) {
|
||||
violations.push(
|
||||
`${repoPath(root, configPath)}: Project Reference ${JSON.stringify(reference)} enters split project ${repoPath(root, splitRoot)} from a ${faceLabel(face)} config; reference ${JSON.stringify(repoPath(root, expected))} instead`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
pending.push(targetConfig)
|
||||
}
|
||||
}
|
||||
|
||||
return violations.sort()
|
||||
}
|
||||
|
||||
function splitProjectRoots(root: string): string[] {
|
||||
return globSync(WORKSPACE_MANIFESTS, { cwd: root })
|
||||
.map(manifest => resolve(root, dirname(manifest)))
|
||||
.filter(dir => existsSync(resolve(dir, 'tsconfig.host.json'))
|
||||
&& existsSync(resolve(dir, 'tsconfig.client.json')))
|
||||
.sort((left, right) => right.length - left.length)
|
||||
}
|
||||
|
||||
function projectConfig(root: string, configPath: string): ProjectReferenceConfig {
|
||||
const read = ts.readConfigFile(configPath, path => ts.sys.readFile(path))
|
||||
if (read.error !== undefined) {
|
||||
const message = ts.flattenDiagnosticMessageText(read.error.messageText, '\n')
|
||||
throw new Error(`${repoPath(root, configPath)}: ${message}`)
|
||||
}
|
||||
return read.config as ProjectReferenceConfig
|
||||
}
|
||||
|
||||
function projectReferences(config: ProjectReferenceConfig): string[] {
|
||||
return (config.references ?? [])
|
||||
.map(reference => reference.path)
|
||||
.filter((path): path is string => typeof path === 'string')
|
||||
}
|
||||
|
||||
function projectFace(
|
||||
root: string,
|
||||
configPath: string,
|
||||
config: ProjectReferenceConfig,
|
||||
seen = new Set<string>(),
|
||||
): ProjectFace | undefined {
|
||||
if (basename(configPath) === 'tsconfig.host.json') return 'host'
|
||||
if (basename(configPath) === 'tsconfig.client.json') return 'client'
|
||||
if (configPath === resolve(root, 'tsconfig.base.json')) return 'host'
|
||||
if (configPath === resolve(root, 'tsconfig.base.client.json')) return 'client'
|
||||
if (seen.has(configPath)) return undefined
|
||||
seen.add(configPath)
|
||||
const parent = localExtendsConfig(configPath, config.extends)
|
||||
if (parent === undefined || !existsSync(parent)) return undefined
|
||||
return projectFace(root, parent, projectConfig(root, parent), seen)
|
||||
}
|
||||
|
||||
function localExtendsConfig(configPath: string, value: unknown): string | undefined {
|
||||
if (typeof value !== 'string' || !value.startsWith('.')) return undefined
|
||||
const target = resolve(dirname(configPath), value)
|
||||
return target.endsWith('.json') ? target : `${target}.json`
|
||||
}
|
||||
|
||||
function referenceConfigPath(sourceConfig: string, reference: string): string {
|
||||
const target = resolve(dirname(sourceConfig), reference)
|
||||
return target.endsWith('.json') ? target : resolve(target, 'tsconfig.json')
|
||||
}
|
||||
|
||||
function containingSplitRoot(splitRoots: readonly string[], targetConfig: string): string | undefined {
|
||||
return splitRoots.find((root) => {
|
||||
const path = relative(root, targetConfig)
|
||||
return path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path)
|
||||
})
|
||||
}
|
||||
|
||||
function repoPath(root: string, path: string): string {
|
||||
return relative(root, path).split(sep).join('/')
|
||||
}
|
||||
|
||||
function faceLabel(face: ProjectFace): string {
|
||||
return face === 'host' ? 'Host' : 'Client'
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isForbiddenPublicationFile, validateTarballPayload } from './publication-payload.ts'
|
||||
import {
|
||||
hasTypeRTRemoteNavigation,
|
||||
isForbiddenPublicationFile,
|
||||
validateTarballPayload,
|
||||
} from './publication-payload.ts'
|
||||
|
||||
function validateFixtureTarball(files: readonly string[]): () => void {
|
||||
return () => {
|
||||
@@ -51,4 +55,29 @@ describe('publication payload policy', () => {
|
||||
'package/lib/styles/base.css',
|
||||
])).not.toThrow()
|
||||
})
|
||||
|
||||
it('allows only the TypeRT declaration map and its navigable source tree when requested', () => {
|
||||
const policy = { typeRTRemoteNavigation: true }
|
||||
expect(isForbiddenPublicationFile('src/index.ts', policy)).toBe(false)
|
||||
expect(isForbiddenPublicationFile('lib/typert.remote-client.d.ts.map', policy)).toBe(false)
|
||||
expect(isForbiddenPublicationFile('lib/types/index.d.ts.map', policy)).toBe(true)
|
||||
expect(() => {
|
||||
validateTarballPayload([
|
||||
'package/lib/typert.remote-client.d.ts.map',
|
||||
'package/src/index.ts',
|
||||
], 'fixture.tgz', policy)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('recognizes only the canonical Host-for-Client export pair', () => {
|
||||
expect(hasTypeRTRemoteNavigation({
|
||||
exports: {
|
||||
'./remote': {
|
||||
types: './lib/typert.remote-client.d.ts',
|
||||
default: './lib/typert.remote-client.js',
|
||||
},
|
||||
},
|
||||
})).toBe(true)
|
||||
expect(hasTypeRTRemoteNavigation({ exports: { './remote': './lib/remote.js' } })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
/** Publication payload policy shared by static manifests and packed tarballs. */
|
||||
|
||||
/** Publication exceptions required for TypeRT declaration-map navigation. */
|
||||
export interface PublicationPayloadPolicy {
|
||||
readonly typeRTRemoteNavigation?: boolean
|
||||
}
|
||||
|
||||
/** Whether a package manifest exports generated Host-for-Client metadata with source navigation. */
|
||||
export function hasTypeRTRemoteNavigation(manifest: unknown): boolean {
|
||||
if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) return false
|
||||
const exportsField = (manifest as Record<string, unknown>).exports
|
||||
if (exportsField === null || typeof exportsField !== 'object' || Array.isArray(exportsField)) return false
|
||||
const remote = (exportsField as Record<string, unknown>)['./remote']
|
||||
if (remote === null || typeof remote !== 'object' || Array.isArray(remote)) return false
|
||||
const entry = remote as Record<string, unknown>
|
||||
return entry.types === './lib/typert.remote-client.d.ts'
|
||||
&& entry.default === './lib/typert.remote-client.js'
|
||||
}
|
||||
|
||||
/** Normalize a package manifest path or npm tarball member to its payload-relative path. */
|
||||
function payloadPath(file: string): string {
|
||||
const normalized = file.replaceAll('\\', '/').replace(/^\.\/+/, '').replace(/\/+$/, '')
|
||||
@@ -7,17 +24,30 @@ function payloadPath(file: string): string {
|
||||
}
|
||||
|
||||
/** Whether a package payload path exposes source or declaration-map intermediates. */
|
||||
export function isForbiddenPublicationFile(file: string): boolean {
|
||||
export function isForbiddenPublicationFile(
|
||||
file: string,
|
||||
policy: PublicationPayloadPolicy = {},
|
||||
): boolean {
|
||||
const normalized = payloadPath(file)
|
||||
if (policy.typeRTRemoteNavigation === true
|
||||
&& (normalized === 'src'
|
||||
|| normalized.startsWith('src/')
|
||||
|| normalized === 'lib/typert.remote-client.d.ts.map')) {
|
||||
return false
|
||||
}
|
||||
return normalized === 'src'
|
||||
|| normalized.startsWith('src/')
|
||||
|| normalized.endsWith('.d.ts.map')
|
||||
}
|
||||
|
||||
/** Reject source and declaration-map members in a packed npm tarball. */
|
||||
export function validateTarballPayload(files: readonly string[], context: string): void {
|
||||
export function validateTarballPayload(
|
||||
files: readonly string[],
|
||||
context: string,
|
||||
policy: PublicationPayloadPolicy = {},
|
||||
): void {
|
||||
for (const file of files) {
|
||||
if (!isForbiddenPublicationFile(file)) continue
|
||||
if (!isForbiddenPublicationFile(file, policy)) continue
|
||||
const normalized = payloadPath(file)
|
||||
if (normalized === 'src' || normalized.startsWith('src/')) {
|
||||
throw new Error(`${context} publishes source file ${file}`)
|
||||
|
||||
@@ -18,7 +18,7 @@ import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep
|
||||
import { createInterface } from 'node:readline/promises'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { validateTarballPayload } from './publication-payload.ts'
|
||||
import { hasTypeRTRemoteNavigation, validateTarballPayload } from './publication-payload.ts'
|
||||
|
||||
const DEFAULT_REGISTRY = 'https://registry.npm.harnessment.com'
|
||||
const DEFAULT_OUTPUT_DIRECTORY = '.artifacts/npm-baseline'
|
||||
@@ -320,7 +320,11 @@ class ReleaseBundle {
|
||||
if (expected === undefined || !missingNames.delete(artifact.name)) {
|
||||
throw new Error(`unexpected or duplicate packed package: ${artifact.name}`)
|
||||
}
|
||||
if (expected.origin === 'harness') validateTarballPayload(artifact.files, tarball)
|
||||
if (expected.origin === 'harness') {
|
||||
validateTarballPayload(artifact.files, tarball, {
|
||||
typeRTRemoteNavigation: hasTypeRTRemoteNavigation(artifact.manifest),
|
||||
})
|
||||
}
|
||||
if (artifact.version !== version) {
|
||||
throw new Error(`${tarball} has version ${artifact.version}; expected ${version}`)
|
||||
}
|
||||
@@ -394,7 +398,11 @@ class ReleaseBundle {
|
||||
throw new Error(`tarball checksum mismatch: ${pkg.tarball}`)
|
||||
}
|
||||
const artifact = inspectTarball(path, runner)
|
||||
if (pkg.origin === 'harness') validateTarballPayload(artifact.files, pkg.tarball)
|
||||
if (pkg.origin === 'harness') {
|
||||
validateTarballPayload(artifact.files, pkg.tarball, {
|
||||
typeRTRemoteNavigation: hasTypeRTRemoteNavigation(artifact.manifest),
|
||||
})
|
||||
}
|
||||
if (artifact.name !== pkg.name || artifact.version !== this.manifest.version) {
|
||||
throw new Error(`tarball identity mismatch: ${pkg.tarball}`)
|
||||
}
|
||||
@@ -463,13 +471,6 @@ class InstalledBundleSmoke {
|
||||
+ `expected ${this.bundle.manifest.version}`,
|
||||
)
|
||||
}
|
||||
const config = this.runner.capture(
|
||||
process.execPath,
|
||||
[bin, '--dump-default-config'],
|
||||
consumerRoot,
|
||||
environment,
|
||||
)
|
||||
if (config === '') throw new Error('installed dsh --dump-default-config returned no output')
|
||||
this.probeWeb(bin, consumerRoot, environment)
|
||||
console.log('publish-npm-baseline: installed dsh entry and Web startup probes passed')
|
||||
} finally {
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('gate graph validation', () => {
|
||||
'ci-primary',
|
||||
'ci-linux-primary',
|
||||
'ci-static',
|
||||
'ci-lint',
|
||||
'ci-lint-contracts-ready',
|
||||
'ci-coverage',
|
||||
'ci-snapshot',
|
||||
'ci-artifacts',
|
||||
@@ -77,6 +77,21 @@ describe('gate graph validation', () => {
|
||||
await expect(runGates(subject, subject.length, execute)).resolves.toHaveLength(subject.length)
|
||||
})
|
||||
|
||||
it('keeps the public repository link policy in the documentation gate', () => {
|
||||
const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
|
||||
|
||||
expect(ids).toContain('public-repository-links')
|
||||
})
|
||||
|
||||
it('keeps native Windows coverage blocking while portability inventory remains observational', () => {
|
||||
const gates = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))
|
||||
const byId = new Map(gates.map(subject => [subject.id, subject]))
|
||||
|
||||
expect(byId.get('coverage')?.allowFailure).not.toBe(true)
|
||||
expect(byId.get('coverage-exempt-heavy')?.allowFailure).not.toBe(true)
|
||||
expect(byId.get('duplication')?.allowFailure).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['empty', [], /gate graph has no gates/],
|
||||
['duplicate ids', [gate('same'), gate('same')], /duplicate gate id "same"/],
|
||||
@@ -112,29 +127,77 @@ describe('gate graph validation', () => {
|
||||
describe('Oxlint gate', () => {
|
||||
it('uses the package script when no worker bound is configured', () => {
|
||||
const subject = withEnv('DSH_OXLINT_THREADS', undefined, () =>
|
||||
withPnpmEntrypoint(() => gatesForMode('ci-lint')[0]))
|
||||
withPnpmEntrypoint(() => gatesForMode('ci-lint-contracts-ready')[0]))
|
||||
|
||||
expect(subject).toMatchObject({
|
||||
id: 'lint',
|
||||
displayCommand: 'pnpm run lint',
|
||||
displayCommand: 'pnpm run lint:contracts-ready',
|
||||
command: process.execPath,
|
||||
args: ['/private/pnpm.cjs', 'run', 'lint'],
|
||||
args: ['/private/pnpm.cjs', 'run', 'lint:contracts-ready'],
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces the configured worker bound on the shared package script', () => {
|
||||
const subject = withEnv('DSH_OXLINT_THREADS', '4', () =>
|
||||
withPnpmEntrypoint(() => gatesForMode('ci-lint')[0]))
|
||||
withPnpmEntrypoint(() => gatesForMode('ci-lint-contracts-ready')[0]))
|
||||
|
||||
expect(subject).toMatchObject({
|
||||
id: 'lint',
|
||||
displayCommand: 'DSH_OXLINT_THREADS=4 pnpm run lint',
|
||||
displayCommand: 'DSH_OXLINT_THREADS=4 pnpm run lint:contracts-ready',
|
||||
command: process.execPath,
|
||||
args: ['/private/pnpm.cjs', 'run', 'lint'],
|
||||
args: ['/private/pnpm.cjs', 'run', 'lint:contracts-ready'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('TypeRT contract preparation', () => {
|
||||
it('prepares primary source consumers once before they run', () => {
|
||||
const subject = withEnv('DSH_OXLINT_THREADS', undefined, () =>
|
||||
withPnpmEntrypoint(() => gatesForMode('ci-primary')))
|
||||
|
||||
expect(subject.find(item => item.id === 'typert-contracts')).toMatchObject({
|
||||
displayCommand: 'pnpm run build:lib:host',
|
||||
args: ['/private/pnpm.cjs', 'run', 'build:lib:host'],
|
||||
})
|
||||
for (const [id, script] of [
|
||||
['typecheck', 'typecheck:contracts-ready'],
|
||||
['lint', 'lint:contracts-ready'],
|
||||
['doc-typecheck', 'doc-typecheck:contracts-ready'],
|
||||
] as const) {
|
||||
expect(subject.find(item => item.id === id)).toMatchObject({
|
||||
displayCommand: `pnpm run ${script}`,
|
||||
args: ['/private/pnpm.cjs', 'run', script],
|
||||
needs: ['typert-contracts'],
|
||||
})
|
||||
}
|
||||
expect(subject.find(item => item.id === 'build')?.needs).toEqual([
|
||||
'typecheck',
|
||||
'lint',
|
||||
'doc-typecheck',
|
||||
])
|
||||
})
|
||||
|
||||
it('reuses contracts from the validated consumer build', () => {
|
||||
const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers'))
|
||||
|
||||
expect(subject.find(item => item.id === 'lint-and-duplication')).toMatchObject({
|
||||
displayCommand: 'pnpm run check:ci:lint:contracts-ready',
|
||||
args: ['/private/pnpm.cjs', 'run', 'check:ci:lint:contracts-ready'],
|
||||
})
|
||||
expect(subject.find(item => item.id === 'doc-typecheck')).toMatchObject({
|
||||
displayCommand: 'pnpm run doc-typecheck:contracts-ready',
|
||||
args: ['/private/pnpm.cjs', 'run', 'doc-typecheck:contracts-ready'],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps standalone doc sync responsible for preparation', () => {
|
||||
const docTypecheck = withPnpmEntrypoint(() =>
|
||||
gatesForMode('doc-sync').find(item => item.id === 'doc-typecheck'))
|
||||
|
||||
expect(docTypecheck?.displayCommand).toBe('pnpm run doc-typecheck')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node compatibility graph', () => {
|
||||
it('runs the jsdom environment smoke on every advertised Node line', () => {
|
||||
const subject = withPnpmEntrypoint(() => gatesForMode('node-compat'))
|
||||
@@ -164,7 +227,7 @@ describe('Node 24 lane ownership', () => {
|
||||
const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers'))
|
||||
|
||||
expect(defaultConcurrency('ci-consumers', subject.length, 4)).toEqual({
|
||||
workers: 10,
|
||||
workers: 11,
|
||||
source: 'ci-consumers gate count',
|
||||
})
|
||||
expect(subject.map(item => item.id)).toEqual([
|
||||
@@ -178,11 +241,19 @@ describe('Node 24 lane ownership', () => {
|
||||
'doc-typecheck',
|
||||
'node-next-types',
|
||||
'built-bin-smoke',
|
||||
'github-repository-plugin-e2e',
|
||||
])
|
||||
expect(subject.find(item => item.id === 'publint')?.needs).toEqual(['build'])
|
||||
expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint'])
|
||||
expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants'])
|
||||
for (const id of ['snapshot', 'web-snapshot', 'doc-typecheck', 'node-next-types', 'built-bin-smoke']) {
|
||||
for (const id of [
|
||||
'snapshot',
|
||||
'web-snapshot',
|
||||
'doc-typecheck',
|
||||
'node-next-types',
|
||||
'built-bin-smoke',
|
||||
'github-repository-plugin-e2e',
|
||||
]) {
|
||||
expect(subject.find(item => item.id === id)?.needs).toEqual(['built-package-invariants'])
|
||||
}
|
||||
expect(subject.find(item => item.id === 'snapshot')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' })
|
||||
@@ -195,6 +266,16 @@ describe('Node 24 lane ownership', () => {
|
||||
'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts',
|
||||
]),
|
||||
)
|
||||
const githubRepositoryPlugin = subject.find(item => item.id === 'github-repository-plugin-e2e')
|
||||
expect(githubRepositoryPlugin).toMatchObject({
|
||||
label: 'GitHub repository Plugin dsh run',
|
||||
env: {
|
||||
DSH_REQUIRE_GITHUB_REPOSITORY_PLUGIN_E2E: '1',
|
||||
},
|
||||
})
|
||||
expect(githubRepositoryPlugin?.args).toEqual(
|
||||
expect.arrayContaining(['apps/cli/tests/github-repository-plugin.built.e2e.ts']),
|
||||
)
|
||||
expect(subject.find(item => item.id === 'web-snapshot')).toMatchObject({
|
||||
displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built',
|
||||
env: { DSH_SNAPSHOT: 'replay' },
|
||||
|
||||
@@ -16,7 +16,7 @@ export type Mode =
|
||||
| 'ci-primary'
|
||||
| 'ci-linux-primary'
|
||||
| 'ci-static'
|
||||
| 'ci-lint'
|
||||
| 'ci-lint-contracts-ready'
|
||||
| 'ci-coverage'
|
||||
| 'ci-snapshot'
|
||||
| 'ci-artifacts'
|
||||
@@ -101,7 +101,7 @@ function parseMode(raw: string | undefined): Mode {
|
||||
case 'ci-primary':
|
||||
case 'ci-linux-primary':
|
||||
case 'ci-static':
|
||||
case 'ci-lint':
|
||||
case 'ci-lint-contracts-ready':
|
||||
case 'ci-coverage':
|
||||
case 'ci-snapshot':
|
||||
case 'ci-artifacts':
|
||||
@@ -115,7 +115,7 @@ function parseMode(raw: string | undefined): Mode {
|
||||
return raw
|
||||
default:
|
||||
throw new Error(
|
||||
`run-gates: expected mode ci-primary | ci-linux-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-consumers | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | check-all | doc-sync, got ${JSON.stringify(raw)}.`,
|
||||
`run-gates: expected mode ci-primary | ci-linux-primary | ci-static | ci-lint-contracts-ready | ci-coverage | ci-snapshot | ci-artifacts | ci-consumers | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | check-all | doc-sync, got ${JSON.stringify(raw)}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -197,7 +197,7 @@ export function gatesForMode(selected: Mode): Gate[] {
|
||||
return [...ciPrimaryGates(), webSnapshotGate(['built-package-invariants'])]
|
||||
case 'ci-static':
|
||||
return ciStaticGates({ ownsBuild: false })
|
||||
case 'ci-lint':
|
||||
case 'ci-lint-contracts-ready':
|
||||
return [
|
||||
lintGate(),
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
@@ -224,6 +224,7 @@ export function gatesForMode(selected: Mode): Gate[] {
|
||||
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
||||
pnpmScript('client-domain-graph', 'verify-client-domain-graph', { label: 'client domain graph' }),
|
||||
pnpmScript('test', 'test'),
|
||||
pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
snapshotGate(),
|
||||
pnpmScript('build', 'build'),
|
||||
@@ -232,6 +233,7 @@ export function gatesForMode(selected: Mode): Gate[] {
|
||||
...docSyncLeafGates({
|
||||
docTypecheckNeeds: ['build'],
|
||||
docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' },
|
||||
docTypecheckScript: 'doc-typecheck:contracts-ready',
|
||||
}),
|
||||
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
||||
]
|
||||
@@ -240,25 +242,36 @@ export function gatesForMode(selected: Mode): Gate[] {
|
||||
}
|
||||
}
|
||||
|
||||
function ciPrimaryGates(): Gate[] {
|
||||
function ciSharedStaticGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
|
||||
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
||||
pnpmScript('typecheck', 'typecheck'),
|
||||
lintGate(),
|
||||
pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
|
||||
]
|
||||
}
|
||||
|
||||
function ciPrimaryGates(): Gate[] {
|
||||
return [
|
||||
...ciSharedStaticGates(),
|
||||
typertContractsGate(),
|
||||
pnpmScript('typecheck', 'typecheck:contracts-ready', { needs: ['typert-contracts'] }),
|
||||
lintGate({ needs: ['typert-contracts'] }),
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
...coverageGates(),
|
||||
...nodeCompatSmokeGates(),
|
||||
snapshotGate(),
|
||||
...docSyncLeafGates(),
|
||||
...docSyncLeafGates({
|
||||
docTypecheckNeeds: ['typert-contracts'],
|
||||
docTypecheckScript: 'doc-typecheck:contracts-ready',
|
||||
}),
|
||||
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
||||
pnpmScript('knip', 'knip'),
|
||||
// typecheck and build now drive the same root solution graph; without the
|
||||
// dependency two concurrent `tsc -b` runs race the same tsbuildinfo files.
|
||||
// The tsc step is an incremental no-op after typecheck.
|
||||
pnpmScript('build', 'build', { needs: ['typecheck'] }),
|
||||
// The prepared typecheck and build both drive Client tsc, while build also
|
||||
// repeats the Host contract pass. Wait for all three consumers so build
|
||||
// neither races tsbuildinfo nor replaces declarations while they are read.
|
||||
pnpmScript('build', 'build', { needs: ['typecheck', 'lint', 'doc-typecheck'] }),
|
||||
pnpmScript('publint', 'publint', { needs: ['build'] }),
|
||||
pnpmScript('node-next-types', 'verify-node-next-types', {
|
||||
label: 'node-next types',
|
||||
@@ -299,7 +312,7 @@ function nodeCompatSmokeGates(options: { cliSmoke?: boolean } = {}): Gate[] {
|
||||
pnpmExec('jsonl-zstd-smoke', [
|
||||
'vitest',
|
||||
'run',
|
||||
'packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts',
|
||||
'packages/session/session-persistence-jsonl/tests/zstd.compat.spec.ts',
|
||||
], { label: 'JSONL Zstandard smoke' }),
|
||||
pnpmExec('dsh-source-launch-smoke', [
|
||||
'vitest',
|
||||
@@ -339,10 +352,7 @@ function runningNodeMajor(): number {
|
||||
|
||||
function ciStaticGates(options: { ownsBuild: boolean }): Gate[] {
|
||||
return [
|
||||
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
|
||||
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
||||
...ciSharedStaticGates(),
|
||||
...options.ownsBuild ? [pnpmScript('build', 'build')] : [],
|
||||
...docSyncLeafGates({
|
||||
includeDocTypecheck: options.ownsBuild,
|
||||
@@ -350,6 +360,7 @@ function ciStaticGates(options: { ownsBuild: boolean }): Gate[] {
|
||||
? {
|
||||
docTypecheckNeeds: ['build'],
|
||||
docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' },
|
||||
docTypecheckScript: 'doc-typecheck:contracts-ready',
|
||||
}
|
||||
: {},
|
||||
docsBuildScript: 'docs:build:mpa',
|
||||
@@ -380,13 +391,13 @@ function ciConsumerGates(): Gate[] {
|
||||
pnpmScript('node-compat', 'check:node-compat', { label: 'Node compatibility' }),
|
||||
pnpmScript('publint', 'publint', { needs: builtTree }),
|
||||
builtPackageInvariantsGate(['publint']),
|
||||
pnpmScript('lint-and-duplication', 'check:ci:lint', {
|
||||
pnpmScript('lint-and-duplication', 'check:ci:lint:contracts-ready', {
|
||||
label: 'lint and duplication',
|
||||
needs: validatedBuild,
|
||||
}),
|
||||
snapshotGate(validatedBuild),
|
||||
webSnapshotGate(validatedBuild),
|
||||
pnpmScript('doc-typecheck', 'doc-typecheck', {
|
||||
pnpmScript('doc-typecheck', 'doc-typecheck:contracts-ready', {
|
||||
needs: validatedBuild,
|
||||
env: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' },
|
||||
}),
|
||||
@@ -395,6 +406,7 @@ function ciConsumerGates(): Gate[] {
|
||||
needs: validatedBuild,
|
||||
}),
|
||||
builtBinSmokeGate(validatedBuild),
|
||||
githubRepositoryPluginE2eGate(validatedBuild),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -423,6 +435,7 @@ function ciWindowsCompleteGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('build', 'build'),
|
||||
pnpmScript('windows-site', 'docs:build', { label: 'production site' }),
|
||||
...coverageGates(),
|
||||
...observational,
|
||||
]
|
||||
}
|
||||
@@ -430,7 +443,7 @@ function ciWindowsCompleteGates(): Gate[] {
|
||||
function ciWindowsObservationalGates(): Gate[] {
|
||||
return [
|
||||
...ciStaticGates({ ownsBuild: true }),
|
||||
// Linux owns required lint, coverage, and snapshots; Windows omits those duplicates.
|
||||
// Linux owns required lint and snapshots; Windows omits those duplicates.
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
pnpmScript('publint', 'publint', { needs: ['build'] }),
|
||||
pnpmScript('node-next-types', 'verify-node-next-types', {
|
||||
@@ -442,11 +455,19 @@ function ciWindowsObservationalGates(): Gate[] {
|
||||
]
|
||||
}
|
||||
|
||||
function lintGate(): Gate {
|
||||
function typertContractsGate(): Gate {
|
||||
return pnpmScript('typert-contracts', 'build:lib:host', { label: 'TypeRT contracts' })
|
||||
}
|
||||
|
||||
function lintGate(options: { needs?: string[] } = {}): Gate {
|
||||
const raw = process.env.DSH_OXLINT_THREADS
|
||||
return pnpmScript('lint', 'lint', raw === undefined || raw === ''
|
||||
? {}
|
||||
: { displayCommand: `DSH_OXLINT_THREADS=${raw} pnpm run lint` })
|
||||
const script = 'lint:contracts-ready'
|
||||
return pnpmScript('lint', script, {
|
||||
...raw === undefined || raw === ''
|
||||
? {}
|
||||
: { displayCommand: `DSH_OXLINT_THREADS=${raw} pnpm run ${script}` },
|
||||
...options.needs === undefined ? {} : { needs: options.needs },
|
||||
})
|
||||
}
|
||||
|
||||
// The heavy suites run uninstrumented beside the thresholded gate: their
|
||||
@@ -549,6 +570,7 @@ function docSyncLeafGates(options: {
|
||||
includeDocTypecheck?: boolean
|
||||
docTypecheckNeeds?: string[]
|
||||
docTypecheckEnv?: Record<string, string | undefined>
|
||||
docTypecheckScript?: 'doc-typecheck' | 'doc-typecheck:contracts-ready'
|
||||
docsBuildScript?: 'docs:build' | 'docs:build:mpa'
|
||||
} = {}): Gate[] {
|
||||
const docTypecheckOptions: Partial<Gate> = {}
|
||||
@@ -557,7 +579,7 @@ function docSyncLeafGates(options: {
|
||||
return [
|
||||
...options.includeDocTypecheck === false
|
||||
? []
|
||||
: [pnpmScript('doc-typecheck', 'doc-typecheck', docTypecheckOptions)],
|
||||
: [pnpmScript('doc-typecheck', options.docTypecheckScript ?? 'doc-typecheck', docTypecheckOptions)],
|
||||
pnpmScript('cordis-catalog', 'verify-cordis-catalog', { label: 'cordis catalog' }),
|
||||
pnpmScript('export-jsdoc', 'verify-export-jsdoc', { label: 'export jsdoc' }),
|
||||
pnpmScript('tool-catalog', 'verify-tool-catalog', { label: 'tool catalog' }),
|
||||
@@ -567,8 +589,10 @@ function docSyncLeafGates(options: {
|
||||
pnpmScript('scoped-events', 'verify-scoped-events', { label: 'scoped events' }),
|
||||
pnpmScript('markdown-wrap', 'verify-md-wrap', { label: 'markdown wrap' }),
|
||||
pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }),
|
||||
pnpmScript('public-repository-links', 'verify-public-repository-links', { label: 'public repository links' }),
|
||||
pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }),
|
||||
pnpmScript('package-paths', 'verify-package-paths', { label: 'package paths' }),
|
||||
pnpmScript('config-source-ownership', 'verify-config-source-ownership', { label: 'config source ownership' }),
|
||||
pnpmScript('package-readme-model-experience', 'verify-package-readme-model-experience', { label: 'package README model experience' }),
|
||||
pnpmScript('mermaid', 'verify-mermaid'),
|
||||
pnpmScript('agent-note-classification', 'verify-agent-note-classification', { label: 'agent note classification' }),
|
||||
@@ -595,17 +619,18 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate {
|
||||
'vitest.e2e.config.ts',
|
||||
'examples/headless-agent/tests/keyless-smoke.e2e.ts',
|
||||
'apps/cli/tests/built-bin.e2e.ts',
|
||||
'packages/examples/cli-demo/tests/built-bin.e2e.ts',
|
||||
'packages/examples/acp-demo/tests/built-bin.e2e.ts',
|
||||
'packages/host/directory-picker-native/tests/built-worker.e2e.ts',
|
||||
'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts',
|
||||
'packages/scaffold/server/tests/built-scope-carrier.e2e.ts',
|
||||
'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts',
|
||||
'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts',
|
||||
// The worker-entry packages' built bundles: the only automated proof
|
||||
// that lib/index.js resolves its sibling lib/worker.cjs under plain node
|
||||
// (the e2e lane runs unbuilt, so these files self-skip there).
|
||||
'packages/api/remotes/tests/built-lib.e2e.ts',
|
||||
// Built execution consumers: the only automated proof that package-name
|
||||
// imports reach their lib/ entrypoints under plain Node. The e2e lane runs
|
||||
// unbuilt, so these files self-skip there.
|
||||
'packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts',
|
||||
'packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts',
|
||||
'packages/lsp/lsp-local/tests/built-lib.e2e.ts',
|
||||
], {
|
||||
label: 'built-bin smoke',
|
||||
needs,
|
||||
@@ -613,6 +638,20 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate {
|
||||
})
|
||||
}
|
||||
|
||||
function githubRepositoryPluginE2eGate(needs: string[]): Gate {
|
||||
return pnpmExec('github-repository-plugin-e2e', [
|
||||
'vitest',
|
||||
'run',
|
||||
'--config',
|
||||
'vitest.e2e.config.ts',
|
||||
'apps/cli/tests/github-repository-plugin.built.e2e.ts',
|
||||
], {
|
||||
label: 'GitHub repository Plugin dsh run',
|
||||
needs,
|
||||
env: { DSH_REQUIRE_GITHUB_REPOSITORY_PLUGIN_E2E: '1' },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a gate list whose graph cannot be executed unambiguously.
|
||||
* @param gates - complete aggregate to validate.
|
||||
|
||||
@@ -3,6 +3,12 @@ import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const oxlintCli = fileURLToPath(new URL('../node_modules/oxlint/bin/oxlint', import.meta.url))
|
||||
const MAX_CAPTURED_OUTPUT_BYTES = 64 * 1024 * 1024
|
||||
const FIX_FLAGS = new Set(['--fix', '--fix-dangerously', '--fix-suggestions'])
|
||||
|
||||
function isFixInvocation(args: readonly string[]): boolean {
|
||||
return args.some(arg => FIX_FLAGS.has(arg))
|
||||
}
|
||||
|
||||
/** Complete Oxlint child-process arguments and environment. */
|
||||
export interface OxlintInvocation {
|
||||
@@ -32,14 +38,50 @@ export function resolveOxlintInvocation(args: readonly string[], env: NodeJS.Pro
|
||||
}
|
||||
}
|
||||
|
||||
function completeFrom(result: { readonly signal: NodeJS.Signals | null; readonly status: number | null }): void {
|
||||
if (result.signal !== null) {
|
||||
process.kill(process.pid, result.signal)
|
||||
return
|
||||
}
|
||||
process.exitCode = result.status ?? 1
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const invocation = resolveOxlintInvocation(process.argv.slice(2), process.env)
|
||||
const result = spawnSync(process.execPath, [oxlintCli, ...invocation.args], {
|
||||
if (!isFixInvocation(invocation.args)) {
|
||||
const result = spawnSync(process.execPath, [oxlintCli, ...invocation.args], {
|
||||
env: invocation.env,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
if (result.error !== undefined) throw result.error
|
||||
completeFrom(result)
|
||||
return
|
||||
}
|
||||
|
||||
const first = spawnSync(process.execPath, [oxlintCli, ...invocation.args], {
|
||||
encoding: 'utf8',
|
||||
env: invocation.env,
|
||||
maxBuffer: MAX_CAPTURED_OUTPUT_BYTES,
|
||||
})
|
||||
if (first.error !== undefined) throw first.error
|
||||
if (first.signal !== null) {
|
||||
completeFrom(first)
|
||||
return
|
||||
}
|
||||
if (first.status === 0) {
|
||||
process.stdout.write(first.stdout)
|
||||
process.stderr.write(first.stderr)
|
||||
process.exitCode = 0
|
||||
return
|
||||
}
|
||||
|
||||
// Overlapping JS-plugin fixes can expose one more fixable diagnostic after the first pass.
|
||||
const second = spawnSync(process.execPath, [oxlintCli, ...invocation.args], {
|
||||
env: invocation.env,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
if (result.error !== undefined) throw result.error
|
||||
process.exitCode = result.status ?? 1
|
||||
if (second.error !== undefined) throw second.error
|
||||
completeFrom(second)
|
||||
}
|
||||
|
||||
const entrypoint = process.argv[1]
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -5,10 +5,9 @@
|
||||
* 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`.
|
||||
* first-occurrence tracking follow the incremental-pipeline planner mechanics.
|
||||
* 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'
|
||||
|
||||
@@ -5,6 +5,9 @@ import { createHash } from 'node:crypto'
|
||||
|
||||
const SNAPSHOT_REF_PREFIX = 'refs/dsh/translation-pairing/snapshots'
|
||||
|
||||
/** Maximum buffered stdout or stderr for repository-owned Git subprocesses. */
|
||||
export const GIT_COMMAND_MAX_BUFFER = 1 << 26
|
||||
|
||||
/** Full SHA-1 Git blob hash (the 40-hex format used by pairing records). */
|
||||
export function gitBlobHash(content: Buffer): string {
|
||||
const hash = createHash('sha1')
|
||||
@@ -13,10 +16,20 @@ export function gitBlobHash(content: Buffer): string {
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
function runGit(root: string, args: string[], operation: string, input?: Buffer): Buffer {
|
||||
/**
|
||||
* Run one Git subprocess and return its exact stdout bytes.
|
||||
*
|
||||
* @param root - Repository root used as Git's working directory.
|
||||
* @param args - Arguments following the `git` executable.
|
||||
* @param operation - Human-readable operation for failure diagnostics.
|
||||
* @param input - Optional stdin bytes.
|
||||
* @returns Exact stdout bytes.
|
||||
* @throws Error when Git cannot start or exits unsuccessfully.
|
||||
*/
|
||||
export function runGit(root: string, args: string[], operation: string, input?: Buffer): Buffer {
|
||||
const result = spawnSync('git', ['-C', root, ...args], {
|
||||
input,
|
||||
maxBuffer: 1 << 26,
|
||||
maxBuffer: GIT_COMMAND_MAX_BUFFER,
|
||||
})
|
||||
if (result.error) {
|
||||
throw new Error(`${operation} failed: ${result.error.message}`, { cause: result.error })
|
||||
@@ -27,6 +40,39 @@ function runGit(root: string, args: string[], operation: string, input?: Buffer)
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
/** One regular stage-zero Git index entry and its exact blob bytes. */
|
||||
export interface GitIndexBlob {
|
||||
/** Object ID recorded in the index. */
|
||||
objectId: string
|
||||
/** Blob bytes stored under that object ID. */
|
||||
content: Buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one path from the Git index without consulting working-tree bytes.
|
||||
*
|
||||
* @param root - Repository root.
|
||||
* @param path - Repository-relative path.
|
||||
* @returns The stage-zero blob, or `undefined` when the path is absent.
|
||||
* @throws Error when the path is unmerged or has an invalid index shape.
|
||||
*/
|
||||
export function readGitIndexBlob(root: string, path: string): GitIndexBlob | undefined {
|
||||
const output = runGit(
|
||||
root,
|
||||
['ls-files', '--stage', '-z', '--', path],
|
||||
`git ls-files --stage for ${path}`,
|
||||
).toString('utf8')
|
||||
const entries = output.split('\0').filter(Boolean)
|
||||
if (entries.length === 0) return undefined
|
||||
if (entries.length !== 1) throw new Error(`${path} does not have exactly one resolved index entry`)
|
||||
const match = /^(?:\d+) ([0-9a-f]+) 0\t[\s\S]+$/.exec(entries[0] ?? '')
|
||||
if (!match?.[1]) throw new Error(`${path} remains unmerged or has an invalid index entry`)
|
||||
return {
|
||||
objectId: match[1],
|
||||
content: runGit(root, ['cat-file', 'blob', match[1]], `reading staged ${path}`),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist exact working-tree bytes so a pairing record can later recover them
|
||||
* with `git cat-file`, even when they have never appeared in the index or a
|
||||
|
||||
568
scripts/translation-pairing-merge.spec.ts
Normal file
568
scripts/translation-pairing-merge.spec.ts
Normal file
@@ -0,0 +1,568 @@
|
||||
/** Integration coverage for automatic and explicit pairing-record conflict resolution. */
|
||||
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { gitBlobHash, storeGitBlob } from './translation-pairing-git.ts'
|
||||
import {
|
||||
mergeTranslationPairingRecords,
|
||||
resolveTranslationPairingConflicts,
|
||||
} from './translation-pairing-merge.ts'
|
||||
import {
|
||||
renderTranslationPairingRecord,
|
||||
translationPairPaths,
|
||||
} from './translation-pairing-record.ts'
|
||||
|
||||
const driver = fileURLToPath(new URL('./merge-translation-pairing.ts', import.meta.url))
|
||||
const driverLauncher = fileURLToPath(new URL('./merge-translation-pairing-driver.sh', import.meta.url))
|
||||
const workspaceRoot = fileURLToPath(new URL('../', import.meta.url))
|
||||
const tsxLoader = import.meta.resolve('tsx/esm')
|
||||
const fixtures: string[] = []
|
||||
|
||||
interface Fixture {
|
||||
env: NodeJS.ProcessEnv
|
||||
root: string
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const fixture of fixtures.splice(0)) rmSync(fixture, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function git(fixture: Fixture, args: string[]): string {
|
||||
return execFileSync('git', ['-C', fixture.root, ...args], {
|
||||
encoding: 'utf8',
|
||||
env: fixture.env,
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function write(root: string, path: string, content: string): void {
|
||||
const absolute = join(root, path)
|
||||
mkdirSync(dirname(absolute), { recursive: true })
|
||||
writeFileSync(absolute, content)
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `"${value.replace(/["\\$`]/g, '\\$&')}"`
|
||||
}
|
||||
|
||||
function installFixtureRuntime(root: string): void {
|
||||
const linkType = process.platform === 'win32' ? 'junction' : 'dir'
|
||||
symlinkSync(
|
||||
join(workspaceRoot, 'node_modules'),
|
||||
join(root, 'node_modules'),
|
||||
linkType,
|
||||
)
|
||||
symlinkSync(join(workspaceRoot, 'scripts'), join(root, 'scripts'), linkType)
|
||||
}
|
||||
|
||||
function startMergeWithFakeNode(
|
||||
fixture: Fixture,
|
||||
nodeScript = '#!/bin/sh\nexit 72\n',
|
||||
) {
|
||||
const fakeBin = join(fixture.root, 'fake-bin')
|
||||
const fakeNode = join(fakeBin, 'node')
|
||||
write(fixture.root, 'fake-bin/node', nodeScript)
|
||||
chmodSync(fakeNode, 0o755)
|
||||
git(fixture, [
|
||||
'config',
|
||||
'merge.dsh-translation-pairing.driver',
|
||||
`${shellQuote(driverLauncher)} %O %A %B %P`,
|
||||
])
|
||||
return spawnSync('git', ['-C', fixture.root, 'merge', '--no-commit', 'master'], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...fixture.env,
|
||||
PATH: `${fakeBin}${delimiter}${fixture.env.PATH ?? ''}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createFixture(attributes = true): Fixture {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-merge-'))
|
||||
fixtures.push(root)
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
GIT_AUTHOR_EMAIL: 'pairing@example.test',
|
||||
GIT_AUTHOR_NAME: 'Pairing Test',
|
||||
GIT_COMMITTER_EMAIL: 'pairing@example.test',
|
||||
GIT_COMMITTER_NAME: 'Pairing Test',
|
||||
GIT_CONFIG_GLOBAL: join(root, 'global.gitconfig'),
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_DEFAULT_HASH: 'sha1',
|
||||
}
|
||||
const fixture = { env, root }
|
||||
execFileSync('git', ['init', '--quiet', '--initial-branch=master', root], { env })
|
||||
if (attributes) write(root, '.gitattributes', '*.i18n.yaml merge=dsh-translation-pairing\n')
|
||||
return fixture
|
||||
}
|
||||
|
||||
function record(root: string, path: string, source: string, zh: string): string {
|
||||
const paths = translationPairPaths(path)
|
||||
write(root, paths.source, source)
|
||||
write(root, paths.zh, zh)
|
||||
const content = renderTranslationPairingRecord(paths, {
|
||||
sourceHash: storeGitBlob(root, Buffer.from(source)),
|
||||
zhHash: storeGitBlob(root, Buffer.from(zh)),
|
||||
})
|
||||
write(root, paths.meta, content)
|
||||
return content
|
||||
}
|
||||
|
||||
const baseSource = '# Guide\n\nEnglish | [中文](guide.zh.md)\n\nAlpha base.\n\nBeta base.\n'
|
||||
const baseZh = '# 指南\n\n[English](guide.md) | 中文\n\n甲基础。\n\n乙基础。\n'
|
||||
const currentSource = baseSource.replace('Alpha base.', 'Alpha current.')
|
||||
const currentZh = baseZh.replace('甲基础。', '甲当前。')
|
||||
const otherSource = baseSource.replace('Beta base.', 'Beta other.')
|
||||
const otherZh = baseZh.replace('乙基础。', '乙对侧。')
|
||||
const mergedSource = currentSource.replace('Beta base.', 'Beta other.')
|
||||
const mergedZh = currentZh.replace('乙基础。', '乙对侧。')
|
||||
const generatedBaseSource = '# Module graph\n\nAlpha base.\n\nBeta base.\n'
|
||||
const generatedBaseZh = '# 模块图\n\n[English](module-graph.md) | 中文\n\n甲基础。\n\n乙基础。\n'
|
||||
const generatedCurrentSource = generatedBaseSource.replace('Alpha base.', 'Alpha current.')
|
||||
const generatedCurrentZh = generatedBaseZh.replace('甲基础。', '甲当前。')
|
||||
const generatedOtherSource = generatedBaseSource.replace('Beta base.', 'Beta other.')
|
||||
const generatedOtherZh = generatedBaseZh.replace('乙基础。', '乙对侧。')
|
||||
const manualBaseSource = baseSource.replace('guide.zh.md', 'manual.zh.md')
|
||||
const manualBaseZh = baseZh.replace('guide.md', 'manual.md')
|
||||
const manualCurrentSource = manualBaseSource.replace('Alpha base.', 'Alpha current.')
|
||||
const manualCurrentZh = manualBaseZh.replace('甲基础。', '甲当前。')
|
||||
const manualOtherSource = manualBaseSource.replace('Alpha base.', 'Alpha other.')
|
||||
const manualOtherZh = manualBaseZh.replace('甲基础。', '甲对侧。')
|
||||
|
||||
function commitPair(fixture: Fixture, source: string, zh: string, message: string): string {
|
||||
const sidecar = record(fixture.root, 'docs/guide.md', source, zh)
|
||||
git(fixture, ['add', '.'])
|
||||
git(fixture, ['commit', '-m', message])
|
||||
return sidecar
|
||||
}
|
||||
|
||||
function commitTextCleanPair(fixture: Fixture, source: string, zh: string, message: string): void {
|
||||
const sidecar = record(fixture.root, 'docs/guide.md', source, zh)
|
||||
write(
|
||||
fixture.root,
|
||||
'docs/guide.i18n.yaml',
|
||||
sidecar.replace('\nguide.zh.md:', '\n# Stable separator for independent line merges.\nguide.zh.md:'),
|
||||
)
|
||||
git(fixture, ['add', '.'])
|
||||
git(fixture, ['commit', '-m', message])
|
||||
}
|
||||
|
||||
function createDivergedPair(fixture: Fixture): { ancestor: string; current: string; other: string } {
|
||||
const ancestor = commitPair(fixture, baseSource, baseZh, 'base')
|
||||
git(fixture, ['switch', '-c', 'current'])
|
||||
const current = commitPair(fixture, currentSource, currentZh, 'current')
|
||||
git(fixture, ['switch', 'master'])
|
||||
const other = commitPair(fixture, otherSource, otherZh, 'other')
|
||||
git(fixture, ['switch', 'current'])
|
||||
return { ancestor, current, other }
|
||||
}
|
||||
|
||||
function createTextCleanDivergedPair(fixture: Fixture): void {
|
||||
commitTextCleanPair(fixture, baseSource, baseZh, 'base')
|
||||
git(fixture, ['switch', '-c', 'current'])
|
||||
commitTextCleanPair(fixture, currentSource, baseZh, 'current source')
|
||||
git(fixture, ['switch', 'master'])
|
||||
commitTextCleanPair(fixture, baseSource, otherZh, 'other translation')
|
||||
git(fixture, ['switch', 'current'])
|
||||
}
|
||||
|
||||
function startStoppedPairingMerge(fixture: Fixture): void {
|
||||
createDivergedPair(fixture)
|
||||
const merge = spawnSync('git', ['-C', fixture.root, 'merge', '--no-commit', 'master'], {
|
||||
encoding: 'utf8',
|
||||
env: fixture.env,
|
||||
})
|
||||
expect(merge.status).toBe(1)
|
||||
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
|
||||
}
|
||||
|
||||
function commitMixedPairs(
|
||||
fixture: Fixture,
|
||||
guide: { source: string; zh: string },
|
||||
manual: { source: string; zh: string },
|
||||
message: string,
|
||||
): void {
|
||||
record(fixture.root, 'docs/guide.md', guide.source, guide.zh)
|
||||
record(fixture.root, 'docs/manual.md', manual.source, manual.zh)
|
||||
git(fixture, ['add', '.'])
|
||||
git(fixture, ['commit', '-m', message])
|
||||
}
|
||||
|
||||
function startMixedPairingMerge(fixture: Fixture): void {
|
||||
commitMixedPairs(
|
||||
fixture,
|
||||
{ source: baseSource, zh: baseZh },
|
||||
{ source: manualBaseSource, zh: manualBaseZh },
|
||||
'base',
|
||||
)
|
||||
git(fixture, ['switch', '-c', 'current'])
|
||||
commitMixedPairs(
|
||||
fixture,
|
||||
{ source: currentSource, zh: currentZh },
|
||||
{ source: manualCurrentSource, zh: manualCurrentZh },
|
||||
'current',
|
||||
)
|
||||
git(fixture, ['switch', 'master'])
|
||||
commitMixedPairs(
|
||||
fixture,
|
||||
{ source: otherSource, zh: otherZh },
|
||||
{ source: manualOtherSource, zh: manualOtherZh },
|
||||
'other',
|
||||
)
|
||||
git(fixture, ['switch', 'current'])
|
||||
const merge = spawnSync('git', ['-C', fixture.root, 'merge', '--no-commit', 'master'], {
|
||||
encoding: 'utf8',
|
||||
env: fixture.env,
|
||||
})
|
||||
expect(merge.status).toBe(1)
|
||||
}
|
||||
|
||||
function expectMergedPair(fixture: Fixture): void {
|
||||
expect(readFileSync(join(fixture.root, 'docs/guide.md'), 'utf8')).toBe(mergedSource)
|
||||
expect(readFileSync(join(fixture.root, 'docs/guide.zh.md'), 'utf8')).toBe(mergedZh)
|
||||
expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toBe(
|
||||
renderTranslationPairingRecord(translationPairPaths('docs/guide.md'), {
|
||||
sourceHash: gitBlobHash(Buffer.from(mergedSource)),
|
||||
zhHash: gitBlobHash(Buffer.from(mergedZh)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe('translation pairing merge composition', { timeout: 15_000 }, () => {
|
||||
it('rejects a pairing-record path outside the repository', () => {
|
||||
const fixture = createFixture(false)
|
||||
|
||||
expect(() => mergeTranslationPairingRecords(
|
||||
fixture.root,
|
||||
'../guide.i18n.yaml',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
)).toThrow('pairing record escapes the repository')
|
||||
})
|
||||
|
||||
it('merges the owner blobs named by three valid records', () => {
|
||||
const fixture = createFixture(false)
|
||||
git(fixture, ['config', 'merge.default', 'text'])
|
||||
const records = createDivergedPair(fixture)
|
||||
|
||||
const result = mergeTranslationPairingRecords(
|
||||
fixture.root,
|
||||
'docs/guide.i18n.yaml',
|
||||
records.ancestor,
|
||||
records.current,
|
||||
records.other,
|
||||
)
|
||||
|
||||
expect(result.sourceContent.toString('utf8')).toBe(mergedSource)
|
||||
expect(result.zhContent.toString('utf8')).toBe(mergedZh)
|
||||
expect(result.sourceHash).toBe(gitBlobHash(Buffer.from(mergedSource)))
|
||||
expect(result.zhHash).toBe(gitBlobHash(Buffer.from(mergedZh)))
|
||||
})
|
||||
|
||||
it('merges a generated source without an English language switcher', () => {
|
||||
const fixture = createFixture(false)
|
||||
const ancestor = record(fixture.root, 'docs/module-graph.md', generatedBaseSource, generatedBaseZh)
|
||||
const current = record(fixture.root, 'docs/module-graph.md', generatedCurrentSource, generatedCurrentZh)
|
||||
const other = record(fixture.root, 'docs/module-graph.md', generatedOtherSource, generatedOtherZh)
|
||||
|
||||
const result = mergeTranslationPairingRecords(
|
||||
fixture.root,
|
||||
'docs/module-graph.i18n.yaml',
|
||||
ancestor,
|
||||
current,
|
||||
other,
|
||||
)
|
||||
|
||||
expect(result.sourceContent.toString('utf8')).toBe(
|
||||
generatedCurrentSource.replace('Beta base.', 'Beta other.'),
|
||||
)
|
||||
expect(result.zhContent.toString('utf8')).toBe(generatedCurrentZh.replace('乙基础。', '乙对侧。'))
|
||||
})
|
||||
|
||||
it('rejects an authored source without an English language switcher', () => {
|
||||
const fixture = createFixture(false)
|
||||
const source = baseSource.replace('English | [中文](guide.zh.md)\n\n', '')
|
||||
const ancestor = record(fixture.root, 'docs/guide.md', source, baseZh)
|
||||
const current = record(fixture.root, 'docs/guide.md', source, baseZh)
|
||||
const other = record(fixture.root, 'docs/guide.md', source, baseZh)
|
||||
|
||||
expect(() => mergeTranslationPairingRecords(
|
||||
fixture.root,
|
||||
'docs/guide.i18n.yaml',
|
||||
ancestor,
|
||||
current,
|
||||
other,
|
||||
)).toThrow('docs/guide.md clean merge lost its language-switcher link to guide.zh.md')
|
||||
})
|
||||
|
||||
it('rejects generated Chinese content without its English backlink', () => {
|
||||
const fixture = createFixture(false)
|
||||
const zh = generatedBaseZh.replace('[English](module-graph.md) | 中文\n\n', '')
|
||||
const ancestor = record(fixture.root, 'docs/module-graph.md', generatedBaseSource, zh)
|
||||
const current = record(fixture.root, 'docs/module-graph.md', generatedBaseSource, zh)
|
||||
const other = record(fixture.root, 'docs/module-graph.md', generatedBaseSource, zh)
|
||||
|
||||
expect(() => mergeTranslationPairingRecords(
|
||||
fixture.root,
|
||||
'docs/module-graph.i18n.yaml',
|
||||
ancestor,
|
||||
current,
|
||||
other,
|
||||
)).toThrow(
|
||||
'docs/module-graph.zh.md clean merge lost its language-switcher link to module-graph.md',
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves owner-content conflicts for a human', () => {
|
||||
const fixture = createFixture(false)
|
||||
const ancestor = record(fixture.root, 'docs/guide.md', baseSource, baseZh)
|
||||
const current = record(
|
||||
fixture.root,
|
||||
'docs/guide.md',
|
||||
baseSource.replace('Alpha base.', 'Alpha current.'),
|
||||
baseZh.replace('甲基础。', '甲当前。'),
|
||||
)
|
||||
const other = record(
|
||||
fixture.root,
|
||||
'docs/guide.md',
|
||||
baseSource.replace('Alpha base.', 'Alpha other.'),
|
||||
baseZh.replace('甲基础。', '甲对侧。'),
|
||||
)
|
||||
|
||||
expect(() => mergeTranslationPairingRecords(
|
||||
fixture.root,
|
||||
'docs/guide.i18n.yaml',
|
||||
ancestor,
|
||||
current,
|
||||
other,
|
||||
)).toThrow('docs/guide.md has content conflicts')
|
||||
})
|
||||
|
||||
it('rejects structurally divergent clean owner merges', () => {
|
||||
const fixture = createFixture(false)
|
||||
const ancestor = record(fixture.root, 'docs/guide.md', baseSource, baseZh)
|
||||
const current = record(fixture.root, 'docs/guide.md', currentSource, currentZh)
|
||||
const other = record(
|
||||
fixture.root,
|
||||
'docs/guide.md',
|
||||
`${otherSource}\n## Extra\n`,
|
||||
otherZh,
|
||||
)
|
||||
|
||||
expect(() => mergeTranslationPairingRecords(
|
||||
fixture.root,
|
||||
'docs/guide.i18n.yaml',
|
||||
ancestor,
|
||||
current,
|
||||
other,
|
||||
)).toThrow('clean merges diverge structurally')
|
||||
})
|
||||
|
||||
it('refuses owners assigned to another merge strategy', () => {
|
||||
const fixture = createFixture(false)
|
||||
write(fixture.root, '.gitattributes', 'docs/*.md merge=custom-owner\n')
|
||||
const records = createDivergedPair(fixture)
|
||||
|
||||
expect(() => mergeTranslationPairingRecords(
|
||||
fixture.root,
|
||||
'docs/guide.i18n.yaml',
|
||||
records.ancestor,
|
||||
records.current,
|
||||
records.other,
|
||||
)).toThrow('docs/guide.md uses merge=custom-owner')
|
||||
})
|
||||
|
||||
it('refuses unspecified owners affected by merge.default', () => {
|
||||
const fixture = createFixture(false)
|
||||
git(fixture, ['config', 'merge.default', 'custom-owner'])
|
||||
const records = createDivergedPair(fixture)
|
||||
|
||||
expect(() => mergeTranslationPairingRecords(
|
||||
fixture.root,
|
||||
'docs/guide.i18n.yaml',
|
||||
records.ancestor,
|
||||
records.current,
|
||||
records.other,
|
||||
)).toThrow('merge.default=custom-owner')
|
||||
})
|
||||
|
||||
it('runs as Git\'s custom driver and commits a clean composed record', () => {
|
||||
const fixture = createFixture()
|
||||
createDivergedPair(fixture)
|
||||
installFixtureRuntime(fixture.root)
|
||||
git(fixture, [
|
||||
'config',
|
||||
'merge.dsh-translation-pairing.driver',
|
||||
'scripts/merge-translation-pairing-driver.sh %O %A %B %P',
|
||||
])
|
||||
|
||||
git(fixture, ['merge', '--no-edit', 'master'])
|
||||
|
||||
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('')
|
||||
expectMergedPair(fixture)
|
||||
})
|
||||
|
||||
it('leaves an ordinary recoverable conflict when the configured runtime is unavailable', () => {
|
||||
const fixture = createFixture()
|
||||
const records = createDivergedPair(fixture)
|
||||
const headBefore = git(fixture, ['rev-parse', 'HEAD'])
|
||||
|
||||
const result = startMergeWithFakeNode(fixture)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('runtime is unavailable; leaving an ordinary text conflict')
|
||||
expect(git(fixture, ['rev-parse', 'HEAD'])).toBe(headBefore)
|
||||
expect(git(fixture, ['rev-parse', '--verify', 'MERGE_HEAD'])).not.toBe('')
|
||||
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
|
||||
expect(git(fixture, ['ls-files', '--unmerged', '--', 'docs/guide.i18n.yaml']).split('\n')).toHaveLength(3)
|
||||
const conflicted = readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')
|
||||
expect(conflicted).toContain('<<<<<<< docs/guide.i18n.yaml:current')
|
||||
for (const record of [records.current, records.other]) {
|
||||
const dataLines = record.split('\n').filter(line => line !== '' && !line.startsWith('#')).join('\n')
|
||||
expect(conflicted).toContain(dataLines)
|
||||
}
|
||||
|
||||
expect(resolveTranslationPairingConflicts(fixture.root)).toEqual(['docs/guide.i18n.yaml'])
|
||||
expectMergedPair(fixture)
|
||||
})
|
||||
|
||||
it('falls back before a broken driver entrypoint can replace the launcher', () => {
|
||||
const fixture = createFixture()
|
||||
createDivergedPair(fixture)
|
||||
const result = startMergeWithFakeNode(
|
||||
fixture,
|
||||
'#!/bin/sh\nif [ "$3" = "--eval" ]; then exit 0; fi\nexit 72\n',
|
||||
)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('runtime is unavailable; leaving an ordinary text conflict')
|
||||
expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toContain(
|
||||
'<<<<<<< docs/guide.i18n.yaml:current',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a clean text fallback unresolved until the explicit resolver confirms it', () => {
|
||||
const fixture = createFixture()
|
||||
createTextCleanDivergedPair(fixture)
|
||||
const result = startMergeWithFakeNode(fixture)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
|
||||
const canonicalRecord = renderTranslationPairingRecord(translationPairPaths('docs/guide.md'), {
|
||||
sourceHash: gitBlobHash(Buffer.from(currentSource)),
|
||||
zhHash: gitBlobHash(Buffer.from(otherZh)),
|
||||
})
|
||||
expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toBe(
|
||||
canonicalRecord.replace(
|
||||
'\nguide.zh.md:',
|
||||
'\n# Stable separator for independent line merges.\nguide.zh.md:',
|
||||
),
|
||||
)
|
||||
|
||||
expect(resolveTranslationPairingConflicts(fixture.root)).toEqual(['docs/guide.i18n.yaml'])
|
||||
expect(readFileSync(join(fixture.root, 'docs/guide.md'), 'utf8')).toBe(currentSource)
|
||||
expect(readFileSync(join(fixture.root, 'docs/guide.zh.md'), 'utf8')).toBe(otherZh)
|
||||
expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toBe(canonicalRecord)
|
||||
})
|
||||
|
||||
it('leaves a staged merge when the pre-merge-commit hook rejects it', () => {
|
||||
const fixture = createFixture()
|
||||
createDivergedPair(fixture)
|
||||
installFixtureRuntime(fixture.root)
|
||||
git(fixture, [
|
||||
'config',
|
||||
'merge.dsh-translation-pairing.driver',
|
||||
'scripts/merge-translation-pairing-driver.sh %O %A %B %P',
|
||||
])
|
||||
const hooks = join(fixture.root, 'hooks')
|
||||
write(
|
||||
fixture.root,
|
||||
'hooks/pre-merge-commit',
|
||||
'#!/bin/sh\necho "fixture pre-merge-commit rejection" >&2\nexit 77\n',
|
||||
)
|
||||
chmodSync(join(hooks, 'pre-merge-commit'), 0o755)
|
||||
git(fixture, ['config', 'core.hooksPath', hooks])
|
||||
const headBefore = git(fixture, ['rev-parse', 'HEAD'])
|
||||
|
||||
const result = spawnSync('git', ['-C', fixture.root, 'merge', '--no-edit', 'master'], {
|
||||
encoding: 'utf8',
|
||||
env: fixture.env,
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('fixture pre-merge-commit rejection')
|
||||
expect(git(fixture, ['rev-parse', 'HEAD'])).toBe(headBefore)
|
||||
expect(git(fixture, ['rev-parse', '--verify', 'MERGE_HEAD'])).not.toBe('')
|
||||
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('')
|
||||
expect(git(fixture, ['diff', '--cached', '--name-only']).split('\n')).toContain(
|
||||
'docs/guide.i18n.yaml',
|
||||
)
|
||||
expectMergedPair(fixture)
|
||||
})
|
||||
|
||||
it('prints the recovery path when driver input is not composable', () => {
|
||||
const fixture = createFixture(false)
|
||||
const result = spawnSync(process.execPath, ['--import', tsxLoader, driver], {
|
||||
cwd: fixture.root,
|
||||
encoding: 'utf8',
|
||||
env: fixture.env,
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('pnpm run verify-translation-pairing --write <pair>')
|
||||
expect(result.stderr).toContain('pnpm run resolve-translation-pairing-conflicts')
|
||||
})
|
||||
|
||||
it('resolves an already-stopped generated-only conflict from index stages', () => {
|
||||
const fixture = createFixture(false)
|
||||
startStoppedPairingMerge(fixture)
|
||||
|
||||
expect(resolveTranslationPairingConflicts(fixture.root)).toEqual(['docs/guide.i18n.yaml'])
|
||||
|
||||
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('')
|
||||
expectMergedPair(fixture)
|
||||
})
|
||||
|
||||
it('refuses to confirm unstaged owner bytes after a stopped merge', () => {
|
||||
const fixture = createFixture(false)
|
||||
startStoppedPairingMerge(fixture)
|
||||
write(fixture.root, 'docs/guide.md', `${mergedSource}\nunstaged\n`)
|
||||
|
||||
expect(() => resolveTranslationPairingConflicts(fixture.root)).toThrow(
|
||||
'docs/guide.md has unstaged content',
|
||||
)
|
||||
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
|
||||
})
|
||||
|
||||
it('refuses to overwrite an edited sidecar after a stopped merge', () => {
|
||||
const fixture = createFixture(false)
|
||||
startStoppedPairingMerge(fixture)
|
||||
write(fixture.root, 'docs/guide.i18n.yaml', 'manually resolved\n')
|
||||
|
||||
expect(() => resolveTranslationPairingConflicts(fixture.root)).toThrow(
|
||||
'docs/guide.i18n.yaml has edited conflict content',
|
||||
)
|
||||
expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toBe('manually resolved\n')
|
||||
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
|
||||
})
|
||||
|
||||
it('resolves safe records while leaving an owner-conflicted pair untouched', () => {
|
||||
const fixture = createFixture(false)
|
||||
startMixedPairingMerge(fixture)
|
||||
|
||||
expect(() => resolveTranslationPairingConflicts(fixture.root)).toThrow(
|
||||
'docs/manual.i18n.yaml: docs/manual.md has content conflicts',
|
||||
)
|
||||
|
||||
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U']).split('\n')).toEqual([
|
||||
'docs/manual.i18n.yaml',
|
||||
'docs/manual.md',
|
||||
'docs/manual.zh.md',
|
||||
])
|
||||
expectMergedPair(fixture)
|
||||
})
|
||||
})
|
||||
339
scripts/translation-pairing-merge.ts
Normal file
339
scripts/translation-pairing-merge.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
/** Fail-closed composition of bilingual pairing records during Git merges. */
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
||||
import {
|
||||
GIT_COMMAND_MAX_BUFFER,
|
||||
gitBlobHash,
|
||||
readGitIndexBlob,
|
||||
runGit,
|
||||
storeGitBlob,
|
||||
} from './translation-pairing-git.ts'
|
||||
import {
|
||||
linksTo,
|
||||
isTranslationScopeFile,
|
||||
parseTranslationMarkdown,
|
||||
requiresSourceLanguageSwitcher,
|
||||
translationStructureDiff,
|
||||
translationStructureSignature,
|
||||
} from './translation-pairing.ts'
|
||||
import {
|
||||
parseTranslationPairingRecord,
|
||||
renderTranslationPairingRecord,
|
||||
translationPairPathsFromMeta,
|
||||
type TranslationPairPaths,
|
||||
type TranslationPairingRecord,
|
||||
} from './translation-pairing-record.ts'
|
||||
|
||||
const UNMERGED_ENTRY = /^(\d+) ([0-9a-f]+) ([123])\t([\s\S]+)$/
|
||||
|
||||
/** A mechanically composed record and the exact merged owner contents it names. */
|
||||
export interface TranslationPairingMergeResult extends TranslationPairingRecord {
|
||||
/** Canonical generated sidecar text. */
|
||||
record: string
|
||||
/** Clean three-way merge of the English owner. */
|
||||
sourceContent: Buffer
|
||||
/** Clean three-way merge of the Simplified Chinese owner. */
|
||||
zhContent: Buffer
|
||||
}
|
||||
|
||||
interface UnmergedStages {
|
||||
ancestor?: string
|
||||
current?: string
|
||||
other?: string
|
||||
}
|
||||
|
||||
function readGitBlob(root: string, objectId: string, owner: string): Buffer {
|
||||
const content = runGit(root, ['cat-file', 'blob', objectId], `reading ${owner} blob ${objectId}`)
|
||||
if (gitBlobHash(content) !== objectId) {
|
||||
throw new Error(`${owner} record names ${objectId}, which is not its SHA-1 git blob hash`)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
function readMergeDefault(root: string): string | undefined {
|
||||
const result = spawnSync('git', ['-C', root, 'config', '--get', 'merge.default'], {
|
||||
maxBuffer: GIT_COMMAND_MAX_BUFFER,
|
||||
})
|
||||
if (result.error) {
|
||||
throw new Error(`reading merge.default failed: ${result.error.message}`, { cause: result.error })
|
||||
}
|
||||
if (result.status === 1) return undefined
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`reading merge.default failed with status ${String(result.status)}: ${result.stderr.toString('utf8').trim()}`,
|
||||
)
|
||||
}
|
||||
return result.stdout.toString('utf8').trim()
|
||||
}
|
||||
|
||||
function assertDefaultTextMerge(root: string, paths: TranslationPairPaths): void {
|
||||
const output = runGit(
|
||||
root,
|
||||
['check-attr', '-z', 'merge', '--', paths.source, paths.zh],
|
||||
'checking bilingual owner merge attributes',
|
||||
).toString('utf8')
|
||||
const fields = output.split('\0')
|
||||
fields.pop()
|
||||
let mergeDefault: string | undefined
|
||||
for (let index = 0; index < fields.length; index += 3) {
|
||||
const path = fields[index]
|
||||
const value = fields[index + 2]
|
||||
if (path === undefined || value === undefined) {
|
||||
throw new Error('git check-attr returned a malformed result')
|
||||
}
|
||||
if (!['unspecified', 'set', 'text'].includes(value)) {
|
||||
throw new Error(`${path} uses merge=${value}; the pairing driver only composes Git's default text merge`)
|
||||
}
|
||||
if (value === 'unspecified') {
|
||||
mergeDefault ??= readMergeDefault(root)
|
||||
if (mergeDefault !== undefined && mergeDefault !== 'text') {
|
||||
throw new Error(
|
||||
`${path} inherits merge.default=${mergeDefault}; the pairing driver only composes Git's default text merge`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runTextMerge(
|
||||
root: string,
|
||||
label: string,
|
||||
ancestor: Buffer | string,
|
||||
current: Buffer | string,
|
||||
other: Buffer | string,
|
||||
): { output: Buffer; status: number | null } {
|
||||
const temporary = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-merge-'))
|
||||
try {
|
||||
const ancestorPath = join(temporary, 'ancestor')
|
||||
const currentPath = join(temporary, 'current')
|
||||
const otherPath = join(temporary, 'other')
|
||||
writeFileSync(ancestorPath, ancestor)
|
||||
writeFileSync(currentPath, current)
|
||||
writeFileSync(otherPath, other)
|
||||
const result = spawnSync('git', [
|
||||
'-C', root,
|
||||
'merge-file', '-p',
|
||||
'-L', `${label}:current`,
|
||||
'-L', `${label}:ancestor`,
|
||||
'-L', `${label}:other`,
|
||||
currentPath, ancestorPath, otherPath,
|
||||
], { maxBuffer: GIT_COMMAND_MAX_BUFFER })
|
||||
if (result.error) {
|
||||
throw new Error(`merging ${label} failed: ${result.error.message}`, { cause: result.error })
|
||||
}
|
||||
return { output: result.stdout, status: result.status }
|
||||
} finally {
|
||||
rmSync(temporary, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function mergeBlobTriplet(
|
||||
root: string,
|
||||
owner: string,
|
||||
ancestor: Buffer,
|
||||
current: Buffer,
|
||||
other: Buffer,
|
||||
): Buffer {
|
||||
const result = runTextMerge(root, owner, ancestor, current, other)
|
||||
if (result.status !== 0) {
|
||||
const kind = result.status !== null && result.status > 0 && result.status <= 127
|
||||
? 'has content conflicts'
|
||||
: `failed with status ${String(result.status)}`
|
||||
throw new Error(`${owner} ${kind}`)
|
||||
}
|
||||
return result.output
|
||||
}
|
||||
|
||||
function loadRecordOwners(
|
||||
root: string,
|
||||
label: string,
|
||||
content: string,
|
||||
paths: TranslationPairPaths,
|
||||
): { source: Buffer; zh: Buffer } {
|
||||
const record = parseTranslationPairingRecord(content, paths)
|
||||
if (record === undefined) throw new Error(`${label} ${paths.meta} is not a valid two-hash pairing record`)
|
||||
return {
|
||||
source: readGitBlob(root, record.sourceHash, `${label} ${paths.source}`),
|
||||
zh: readGitBlob(root, record.zhHash, `${label} ${paths.zh}`),
|
||||
}
|
||||
}
|
||||
|
||||
function assertMergedPairStructure(paths: TranslationPairPaths, source: Buffer, zh: Buffer): void {
|
||||
const sourceTree = parseTranslationMarkdown(source.toString('utf8'))
|
||||
const zhTree = parseTranslationMarkdown(zh.toString('utf8'))
|
||||
if (requiresSourceLanguageSwitcher(paths.source) && !linksTo(sourceTree, basename(paths.zh))) {
|
||||
throw new Error(`${paths.source} clean merge lost its language-switcher link to ${basename(paths.zh)}`)
|
||||
}
|
||||
if (!linksTo(zhTree, basename(paths.source))) {
|
||||
throw new Error(`${paths.zh} clean merge lost its language-switcher link to ${basename(paths.source)}`)
|
||||
}
|
||||
const divergences = translationStructureDiff(
|
||||
translationStructureSignature(sourceTree, basename(paths.zh)),
|
||||
translationStructureSignature(zhTree, basename(paths.source)),
|
||||
)
|
||||
if (divergences.length > 0) {
|
||||
throw new Error(`${paths.source} and ${paths.zh} clean merges diverge structurally: ${divergences.join('; ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMetaPath(root: string, meta: string): string {
|
||||
if (isAbsolute(meta)) throw new Error(`pairing record must be repository-relative: ${JSON.stringify(meta)}`)
|
||||
const repositoryRelative = relative(resolve(root), resolve(root, meta))
|
||||
if (repositoryRelative === '' || repositoryRelative === '..' || repositoryRelative.startsWith(`..${sep}`)) {
|
||||
throw new Error(`pairing record escapes the repository: ${JSON.stringify(meta)}`)
|
||||
}
|
||||
return repositoryRelative.split(sep).join('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose one generated sidecar from the ancestor, current, and other records.
|
||||
*
|
||||
* Each input record is already a confirmation of its two owner blobs. The
|
||||
* result exists only when Git's default text merge succeeds independently for
|
||||
* both languages and the composed documents retain the pairing structure.
|
||||
*
|
||||
* @param root - Repository root containing the referenced Git objects.
|
||||
* @param metaPath - Repository-relative sidecar path.
|
||||
* @param ancestorRecord - Common-ancestor sidecar text.
|
||||
* @param currentRecord - Current-side sidecar text.
|
||||
* @param otherRecord - Other-side sidecar text.
|
||||
* @returns The canonical record and exact merged owner contents.
|
||||
* @throws Error when the input is not mechanically composable.
|
||||
*/
|
||||
export function mergeTranslationPairingRecords(
|
||||
root: string,
|
||||
metaPath: string,
|
||||
ancestorRecord: string,
|
||||
currentRecord: string,
|
||||
otherRecord: string,
|
||||
): TranslationPairingMergeResult {
|
||||
const normalizedMeta = normalizeMetaPath(root, metaPath)
|
||||
if (!isTranslationScopeFile(normalizedMeta)) {
|
||||
throw new Error(`${normalizedMeta} is outside the active bilingual documentation corpus`)
|
||||
}
|
||||
const paths = translationPairPathsFromMeta(normalizedMeta)
|
||||
assertDefaultTextMerge(root, paths)
|
||||
const ancestor = loadRecordOwners(root, 'ancestor', ancestorRecord, paths)
|
||||
const current = loadRecordOwners(root, 'current', currentRecord, paths)
|
||||
const other = loadRecordOwners(root, 'other', otherRecord, paths)
|
||||
const sourceContent = mergeBlobTriplet(root, paths.source, ancestor.source, current.source, other.source)
|
||||
const zhContent = mergeBlobTriplet(root, paths.zh, ancestor.zh, current.zh, other.zh)
|
||||
assertMergedPairStructure(paths, sourceContent, zhContent)
|
||||
const sourceHash = storeGitBlob(root, sourceContent)
|
||||
const zhHash = storeGitBlob(root, zhContent)
|
||||
return {
|
||||
record: renderTranslationPairingRecord(paths, { sourceHash, zhHash }),
|
||||
sourceContent,
|
||||
sourceHash,
|
||||
zhContent,
|
||||
zhHash,
|
||||
}
|
||||
}
|
||||
|
||||
function unmergedSidecars(root: string): Map<string, UnmergedStages> {
|
||||
const output = runGit(root, ['ls-files', '--unmerged', '-z'], 'listing unresolved merge entries').toString('utf8')
|
||||
const records = new Map<string, UnmergedStages>()
|
||||
for (const entry of output.split('\0')) {
|
||||
if (entry === '') continue
|
||||
const match = UNMERGED_ENTRY.exec(entry)
|
||||
if (!match?.[2] || !match[3] || match[4] === undefined) {
|
||||
throw new Error(`git ls-files returned a malformed unmerged entry: ${JSON.stringify(entry)}`)
|
||||
}
|
||||
const path = match[4]
|
||||
if (!path.endsWith('.i18n.yaml')) continue
|
||||
const stages = records.get(path) ?? {}
|
||||
const field = match[3] === '1' ? 'ancestor' : match[3] === '2' ? 'current' : 'other'
|
||||
stages[field] = match[2]
|
||||
records.set(path, stages)
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
function assertUneditedSidecar(
|
||||
root: string,
|
||||
metaPath: string,
|
||||
ancestorRecord: string,
|
||||
currentRecord: string,
|
||||
otherRecord: string,
|
||||
): void {
|
||||
const worktreeRecord = readFileSync(join(root, metaPath), 'utf8')
|
||||
if (worktreeRecord === currentRecord || worktreeRecord === otherRecord) return
|
||||
const textMerge = runTextMerge(root, metaPath, ancestorRecord, currentRecord, otherRecord)
|
||||
if (textMerge.status === 0 && textMerge.output.toString('utf8') === worktreeRecord) return
|
||||
const stageDataLines = [currentRecord, otherRecord]
|
||||
.flatMap(record => record.split(/\r?\n/))
|
||||
.filter(line => line !== '' && !line.startsWith('#'))
|
||||
const hasUneditedConflict = worktreeRecord.includes('<<<<<<<')
|
||||
&& worktreeRecord.includes('=======')
|
||||
&& worktreeRecord.includes('>>>>>>>')
|
||||
&& stageDataLines.every(line => worktreeRecord.includes(line))
|
||||
if (!hasUneditedConflict) {
|
||||
throw new Error(`${metaPath} has edited conflict content; refusing to overwrite manual work`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every mechanically composable `.i18n.yaml` conflict in the index.
|
||||
*
|
||||
* The command first proves that Git's already-staged owner merges match the
|
||||
* independently composed contents, then writes and stages all sidecars as one
|
||||
* batch. Other conflicts remain untouched; after staging the safe records, an
|
||||
* aggregate error reports any pairing conflicts that still need manual work.
|
||||
*
|
||||
* @param root - Repository root with an in-progress merge-like operation.
|
||||
* @returns Repository-relative sidecar paths resolved and staged.
|
||||
*/
|
||||
export function resolveTranslationPairingConflicts(root: string): string[] {
|
||||
const resolutions: { path: string; record: string }[] = []
|
||||
const failures: { path: string; reason: string }[] = []
|
||||
for (const [metaPath, stages] of [...unmergedSidecars(root)].sort(([left], [right]) => left.localeCompare(right))) {
|
||||
try {
|
||||
if (stages.ancestor === undefined || stages.current === undefined || stages.other === undefined) {
|
||||
throw new Error('is an add/delete or incomplete-stage conflict and requires manual resolution')
|
||||
}
|
||||
const ancestorRecord = readGitBlob(root, stages.ancestor, `ancestor ${metaPath}`).toString('utf8')
|
||||
const currentRecord = readGitBlob(root, stages.current, `current ${metaPath}`).toString('utf8')
|
||||
const otherRecord = readGitBlob(root, stages.other, `other ${metaPath}`).toString('utf8')
|
||||
assertUneditedSidecar(root, metaPath, ancestorRecord, currentRecord, otherRecord)
|
||||
const result = mergeTranslationPairingRecords(
|
||||
root,
|
||||
metaPath,
|
||||
ancestorRecord,
|
||||
currentRecord,
|
||||
otherRecord,
|
||||
)
|
||||
const paths = translationPairPathsFromMeta(metaPath)
|
||||
if (readGitIndexBlob(root, paths.source)?.objectId !== result.sourceHash) {
|
||||
throw new Error(`${paths.source} staged merge does not match the pairing driver's clean merge`)
|
||||
}
|
||||
if (readGitIndexBlob(root, paths.zh)?.objectId !== result.zhHash) {
|
||||
throw new Error(`${paths.zh} staged merge does not match the pairing driver's clean merge`)
|
||||
}
|
||||
for (const [path, expected] of [[paths.source, result.sourceHash], [paths.zh, result.zhHash]] as const) {
|
||||
if (gitBlobHash(readFileSync(join(root, path))) !== expected) {
|
||||
throw new Error(`${path} has unstaged content; refusing to confirm bytes outside the merge result`)
|
||||
}
|
||||
}
|
||||
resolutions.push({ path: metaPath, record: result.record })
|
||||
} catch (error) {
|
||||
failures.push({ path: metaPath, reason: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
for (const resolution of resolutions) writeFileSync(join(root, resolution.path), resolution.record)
|
||||
if (resolutions.length > 0) {
|
||||
runGit(root, ['add', '--', ...resolutions.map(resolution => resolution.path)], 'staging resolved pairing records')
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
const resolved = resolutions.length === 0
|
||||
? ''
|
||||
: `resolved and staged ${resolutions.map(resolution => resolution.path).join(', ')}; `
|
||||
throw new Error(
|
||||
`${resolved}left ${String(failures.length)} pairing conflict(s) unresolved:\n`
|
||||
+ failures.map(failure => `- ${failure.path}: ${failure.reason}`).join('\n'),
|
||||
)
|
||||
}
|
||||
return resolutions.map(resolution => resolution.path)
|
||||
}
|
||||
99
scripts/translation-pairing-record.ts
Normal file
99
scripts/translation-pairing-record.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/** Canonical paths, parsing, and rendering for bilingual pairing records. */
|
||||
|
||||
import { basename } from 'node:path'
|
||||
|
||||
/** The three repository-relative paths that form one bilingual pair. */
|
||||
export interface TranslationPairPaths {
|
||||
/** English document path. */
|
||||
source: string
|
||||
/** Simplified Chinese document path. */
|
||||
zh: string
|
||||
/** Generated consistency-record path. */
|
||||
meta: string
|
||||
}
|
||||
|
||||
/** The two content hashes recorded for a bilingual pair. */
|
||||
export interface TranslationPairingRecord {
|
||||
/** Git blob hash of the English document. */
|
||||
sourceHash: string
|
||||
/** Git blob hash of the Simplified Chinese document. */
|
||||
zhHash: string
|
||||
}
|
||||
|
||||
const META_LINE = /^([^:#]+\.md): ([0-9a-f]{40})$/
|
||||
|
||||
/**
|
||||
* Derive the counterpart and consistency-record paths from an English document.
|
||||
*
|
||||
* @param source - Repository-relative English Markdown path.
|
||||
* @returns The complete three-path pair.
|
||||
*/
|
||||
export function translationPairPaths(source: string): TranslationPairPaths {
|
||||
if (!source.endsWith('.md') || source.endsWith('.zh.md')) {
|
||||
throw new Error(`expected an English Markdown path, received ${JSON.stringify(source)}`)
|
||||
}
|
||||
return {
|
||||
source,
|
||||
zh: source.replace(/\.md$/, '.zh.md'),
|
||||
meta: source.replace(/\.md$/, '.i18n.yaml'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive one pair from its consistency-record path.
|
||||
*
|
||||
* @param meta - Repository-relative `foo.i18n.yaml` path.
|
||||
* @returns The complete three-path pair.
|
||||
*/
|
||||
export function translationPairPathsFromMeta(meta: string): TranslationPairPaths {
|
||||
if (!meta.endsWith('.i18n.yaml')) {
|
||||
throw new Error(`expected a bilingual consistency-record path, received ${JSON.stringify(meta)}`)
|
||||
}
|
||||
return translationPairPaths(meta.replace(/\.i18n\.yaml$/, '.md'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a consistency record for its expected sibling names.
|
||||
*
|
||||
* @param content - Complete sidecar text.
|
||||
* @param paths - Expected sibling paths.
|
||||
* @returns The two hashes, or `undefined` for malformed, duplicate, or unexpected keys.
|
||||
*/
|
||||
export function parseTranslationPairingRecord(
|
||||
content: string,
|
||||
paths: TranslationPairPaths,
|
||||
): TranslationPairingRecord | undefined {
|
||||
const hashes = new Map<string, string>()
|
||||
for (const line of content.split('\n')) {
|
||||
if (line === '' || line.startsWith('#')) continue
|
||||
const match = META_LINE.exec(line)
|
||||
if (!match?.[1] || !match[2] || hashes.has(match[1])) return undefined
|
||||
hashes.set(match[1], match[2])
|
||||
}
|
||||
const sourceHash = hashes.get(basename(paths.source))
|
||||
const zhHash = hashes.get(basename(paths.zh))
|
||||
if (hashes.size !== 2 || sourceHash === undefined || zhHash === undefined) return undefined
|
||||
return { sourceHash, zhHash }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the canonical consistency record for a pair.
|
||||
*
|
||||
* @param paths - Pair paths written into the record and its recovery command.
|
||||
* @param record - Confirmed content hashes.
|
||||
* @returns Canonical YAML text with exactly one trailing newline.
|
||||
*/
|
||||
export function renderTranslationPairingRecord(
|
||||
paths: TranslationPairPaths,
|
||||
record: TranslationPairingRecord,
|
||||
): string {
|
||||
return [
|
||||
'# 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 ${paths.source}`,
|
||||
`${basename(paths.source)}: ${record.sourceHash}`,
|
||||
`${basename(paths.zh)}: ${record.zhHash}`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
@@ -4,18 +4,9 @@
|
||||
".agents/notes/implemented/AGENTS.md",
|
||||
".agents/notes/implemented/CLAUDE.md",
|
||||
"docs/AGENTS.md",
|
||||
"docs/agent-lifecycle.md",
|
||||
"docs/capability-seams.md",
|
||||
"docs/config-catalog.md",
|
||||
"docs/cordis-catalog/",
|
||||
"docs/event-producer-consumer.md",
|
||||
"docs/graph-atlas.md",
|
||||
"docs/cordis-api/inherited.md",
|
||||
"docs/i18n/style-samples.md",
|
||||
"docs/i18n/terminology.md",
|
||||
"docs/i18n/translation-prompt.md",
|
||||
"docs/module-graph.md",
|
||||
"docs/persistence-catalog.md",
|
||||
"docs/tool-catalog.md",
|
||||
"docs/tool-execution-pipeline.md"
|
||||
"docs/i18n/translation-prompt.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
/** Regression tests for bilingual snapshots, corpus scope, and structure. */
|
||||
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { gitBlobHash, storeGitBlob } from './translation-pairing-git.ts'
|
||||
import { gitBlobHash, readGitIndexBlob, storeGitBlob } from './translation-pairing-git.ts'
|
||||
import {
|
||||
parseTranslationPairingRecord,
|
||||
renderTranslationPairingRecord,
|
||||
translationPairPaths,
|
||||
} from './translation-pairing-record.ts'
|
||||
import {
|
||||
blobHash,
|
||||
isTranslationScopeFile,
|
||||
pairAnchorOfArgument,
|
||||
parseTranslationMarkdown,
|
||||
parseTranslationPairingCliArgs,
|
||||
parseTranslationPairingManifest,
|
||||
partitionGeneratedRegions,
|
||||
requiresSourceLanguageSwitcher,
|
||||
translationStructureDiff,
|
||||
translationStructureSignature,
|
||||
} from './translation-pairing.ts'
|
||||
@@ -74,6 +82,28 @@ describe('translation pairing snapshots', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('reads staged bytes independently of the working tree', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-index-'))
|
||||
try {
|
||||
execFileSync('git', ['init', '--quiet', root], {
|
||||
env: { ...process.env, GIT_DEFAULT_HASH: 'sha1' },
|
||||
})
|
||||
execFileSync('git', ['-C', root, 'config', 'user.email', 'pairing@example.test'])
|
||||
execFileSync('git', ['-C', root, 'config', 'user.name', 'Pairing Test'])
|
||||
writeFileSync(join(root, 'owner.md'), 'staged')
|
||||
execFileSync('git', ['-C', root, 'add', 'owner.md'])
|
||||
writeFileSync(join(root, 'owner.md'), 'unstaged')
|
||||
|
||||
const indexed = readGitIndexBlob(root, 'owner.md')
|
||||
|
||||
expect(indexed?.content.toString('utf8')).toBe('staged')
|
||||
expect(indexed?.objectId).toBe(gitBlobHash(Buffer.from('staged')))
|
||||
expect(readGitIndexBlob(root, 'absent.md')).toBeUndefined()
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(!supportsSha256ObjectFormat)('rejects an object format that pairing records cannot represent', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-'))
|
||||
try {
|
||||
@@ -113,6 +143,42 @@ describe('translation pairing manifest', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('translation pairing switchers', () => {
|
||||
it('exempts only paired generated English sources from reciprocal switchers', () => {
|
||||
expect(requiresSourceLanguageSwitcher('docs/config-catalog.md')).toBe(false)
|
||||
expect(requiresSourceLanguageSwitcher('docs/cordis-api/context.md')).toBe(false)
|
||||
expect(requiresSourceLanguageSwitcher('docs/cordis-api/inherited.md')).toBe(false)
|
||||
expect(requiresSourceLanguageSwitcher('docs/architecture.md')).toBe(true)
|
||||
expect(requiresSourceLanguageSwitcher('packages/core/session/README.md')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('translation pairing records', () => {
|
||||
const paths = translationPairPaths('docs/foo.md')
|
||||
const record = {
|
||||
sourceHash: '1'.repeat(40),
|
||||
zhHash: '2'.repeat(40),
|
||||
}
|
||||
|
||||
it('round-trips the canonical two-hash record', () => {
|
||||
expect(parseTranslationPairingRecord(renderTranslationPairingRecord(paths, record), paths)).toEqual(record)
|
||||
})
|
||||
|
||||
it('rejects duplicate or unexpected keys', () => {
|
||||
expect(parseTranslationPairingRecord([
|
||||
`foo.md: ${'1'.repeat(40)}`,
|
||||
`foo.md: ${'3'.repeat(40)}`,
|
||||
`foo.zh.md: ${'2'.repeat(40)}`,
|
||||
'',
|
||||
].join('\n'), paths)).toBeUndefined()
|
||||
expect(parseTranslationPairingRecord([
|
||||
`foo.md: ${'1'.repeat(40)}`,
|
||||
`bar.zh.md: ${'2'.repeat(40)}`,
|
||||
'',
|
||||
].join('\n'), paths)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('translation scope discovery', () => {
|
||||
it.each([
|
||||
'README.md',
|
||||
@@ -186,28 +252,95 @@ describe('pair CLI arguments', () => {
|
||||
|
||||
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({
|
||||
input: 'worktree',
|
||||
mode: 'check',
|
||||
scope: 'pairs',
|
||||
anchors: ['docs/bar.md', 'docs/foo.md'],
|
||||
})
|
||||
expect(parseTranslationPairingCliArgs([])).toEqual({ mode: 'check', scope: 'corpus', anchors: [] })
|
||||
expect(parseTranslationPairingCliArgs([])).toEqual({
|
||||
input: 'worktree',
|
||||
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({
|
||||
input: 'worktree',
|
||||
mode: 'write',
|
||||
scope: 'pairs',
|
||||
anchors: ['docs/foo.md'],
|
||||
})
|
||||
expect(parseTranslationPairingCliArgs(['--write', '--all'])).toEqual({ mode: 'write', scope: 'corpus', anchors: [] })
|
||||
expect(parseTranslationPairingCliArgs(['--write', '--all'])).toEqual({
|
||||
input: 'worktree',
|
||||
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'])).toEqual({
|
||||
input: 'worktree',
|
||||
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')
|
||||
})
|
||||
|
||||
it('makes cached verification a named, read-only index check', () => {
|
||||
expect(parseTranslationPairingCliArgs(['--cached', 'docs/foo.i18n.yaml'])).toEqual({
|
||||
input: 'index',
|
||||
mode: 'check',
|
||||
scope: 'pairs',
|
||||
anchors: ['docs/foo.md'],
|
||||
})
|
||||
expect(() => parseTranslationPairingCliArgs(['--cached'])).toThrow('requires the staged pair paths')
|
||||
expect(() => parseTranslationPairingCliArgs(['--cached', '--write', 'docs/foo.md'])).toThrow('read-only')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generated regions', () => {
|
||||
const BEGIN = '<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->'
|
||||
const END = '<!-- END GENERATED cordis-surface -->'
|
||||
|
||||
it('partitions marker-delimited regions from the hand-owned remainder', () => {
|
||||
const doc = `# T\n\nprose\n\n${BEGIN}\ninjected\n${END}\ntail\n`
|
||||
const { regions, stripped } = partitionGeneratedRegions(doc)
|
||||
expect(regions).toEqual([`${BEGIN}\ninjected\n${END}`])
|
||||
expect(stripped).toBe('# T\n\nprose\n\ntail\n')
|
||||
})
|
||||
|
||||
it('treats a document without markers as one hand-owned remainder', () => {
|
||||
const { regions, stripped } = partitionGeneratedRegions('# T\n\nprose\n')
|
||||
expect(regions).toEqual([])
|
||||
expect(stripped).toBe('# T\n\nprose\n')
|
||||
})
|
||||
|
||||
it('rejects unbalanced or nested markers', () => {
|
||||
expect(() => partitionGeneratedRegions(`${END}\n`)).toThrow('without a BEGIN')
|
||||
expect(() => partitionGeneratedRegions(`${BEGIN}\n`)).toThrow('without an END')
|
||||
expect(() => partitionGeneratedRegions(`${BEGIN}\n${BEGIN}\n${END}\n`)).toThrow('nested')
|
||||
})
|
||||
|
||||
it('rejects mismatched slugs and malformed marker lines', () => {
|
||||
expect(() => partitionGeneratedRegions('<!-- BEGIN GENERATED a -->\nx\n<!-- END GENERATED b -->\n'))
|
||||
.toThrow("END slug 'b' does not match its BEGIN slug 'a'")
|
||||
expect(() => partitionGeneratedRegions('<!-- BEGIN GENERATED a --> trailing\nx\n<!-- END GENERATED a -->\n'))
|
||||
.toThrow('malformed generated region marker line')
|
||||
expect(() => partitionGeneratedRegions('x\n<!-- END GENERATED a --> tail\n'))
|
||||
.toThrow('malformed generated region marker line')
|
||||
})
|
||||
|
||||
it('computes the exact git blob hash', () => {
|
||||
// `git hash-object` of the empty file and of "x\n" — pinned upstream values.
|
||||
expect(blobHash(Buffer.from(''))).toBe('e69de29bb2d1d6434b8b29ae775ad8c2e48c5391')
|
||||
expect(blobHash(Buffer.from('x\n'))).toBe('587be6b4c3f93f93c489c0111bba5596147a26cb')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,13 +2,122 @@
|
||||
* Pure parsing and structural helpers for the bilingual-document pairing
|
||||
* gate. Kept separate from the CLI so corpus discovery and signature behavior
|
||||
* can be regression-tested without reading or mutating the repository tree.
|
||||
* Also the one home of the generated-region grammar and the pair-record
|
||||
* primitives, shared by the pairing gate and the region-injecting generators.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { basename } from 'node:path'
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||
import { gfmFromMarkdown } from 'mdast-util-gfm'
|
||||
import { gfm } from 'micromark-extension-gfm'
|
||||
import type { Nodes } from 'mdast'
|
||||
|
||||
/** Complete opening marker line: `<!-- BEGIN GENERATED <slug> … -->` (slug captured). */
|
||||
const GENERATED_REGION_BEGIN_LINE = /^<!-- BEGIN GENERATED (\S+)(?: [^>]*)? -->$/
|
||||
/** Complete closing marker line: `<!-- END GENERATED <slug> -->` (slug captured). */
|
||||
const GENERATED_REGION_END_LINE = /^<!-- END GENERATED (\S+) -->$/
|
||||
/** Loose marker detector: any line that LOOKS like a region marker must parse as one. */
|
||||
const GENERATED_REGION_MARKER_HINT = /^<!-- (?:BEGIN|END) GENERATED /
|
||||
|
||||
/**
|
||||
* Extract every generated region (markers included) and the document with
|
||||
* those regions removed. Regions are line-delimited: a marker occupies its
|
||||
* whole line, must be a complete well-formed marker, and the closing slug
|
||||
* must match the opener. The stripped form is what "human content" means for
|
||||
* the region-aware pair-record guard.
|
||||
*
|
||||
* @param content - Full Markdown document text.
|
||||
* @returns The regions in document order and the region-free remainder.
|
||||
* @throws Error on an unopened END, unclosed BEGIN, nested BEGIN, malformed
|
||||
* marker line, or a closing slug that does not match its opener.
|
||||
*/
|
||||
export function partitionGeneratedRegions(content: string): { regions: string[]; stripped: string } {
|
||||
const lines = content.split('\n')
|
||||
const regions: string[] = []
|
||||
const kept: string[] = []
|
||||
let open: { slug: string; lines: string[] } | null = null
|
||||
for (const line of lines) {
|
||||
const begin = GENERATED_REGION_BEGIN_LINE.exec(line)
|
||||
if (begin?.[1]) {
|
||||
if (open) throw new Error('generated region BEGIN marker nested inside an open region')
|
||||
open = { slug: begin[1], lines: [line] }
|
||||
continue
|
||||
}
|
||||
const end = GENERATED_REGION_END_LINE.exec(line)
|
||||
if (end?.[1]) {
|
||||
if (!open) throw new Error('generated region END marker without a BEGIN')
|
||||
if (end[1] !== open.slug) throw new Error(`generated region END slug '${end[1]}' does not match its BEGIN slug '${open.slug}'`)
|
||||
open.lines.push(line)
|
||||
regions.push(open.lines.join('\n'))
|
||||
open = null
|
||||
continue
|
||||
}
|
||||
if (GENERATED_REGION_MARKER_HINT.test(line)) {
|
||||
throw new Error(`malformed generated region marker line: ${JSON.stringify(line)}`)
|
||||
}
|
||||
if (open) open.lines.push(line)
|
||||
else kept.push(line)
|
||||
}
|
||||
if (open) throw new Error('generated region BEGIN marker without an END')
|
||||
return { regions, stripped: kept.join('\n') }
|
||||
}
|
||||
|
||||
/**
|
||||
* Full git blob hash of file content (what `git hash-object` prints).
|
||||
* @param content - Exact file bytes.
|
||||
* @returns The 40-hex-digit SHA-1 blob hash.
|
||||
*/
|
||||
export function blobHash(content: Buffer): string {
|
||||
const hash = createHash('sha1')
|
||||
hash.update(`blob ${content.byteLength}\0`)
|
||||
hash.update(content)
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
const PAIR_META_LINE = /^([^:#]+\.md): ([0-9a-f]{40})$/
|
||||
|
||||
/**
|
||||
* Parse a `foo.i18n.yaml` consistency record into basename → recorded blob
|
||||
* hash, or undefined when any non-comment line deviates from the exact
|
||||
* `<basename>.md: <40-hex>` shape or repeats a key. Consumers must
|
||||
* additionally require exactly the two expected basenames — a renamed key is
|
||||
* a malformed record, never a silently-missing entry.
|
||||
* @param content - Sidecar file text.
|
||||
* @returns The recorded map, or undefined for a malformed record.
|
||||
*/
|
||||
export function parsePairMeta(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 = PAIR_META_LINE.exec(line)
|
||||
if (!match?.[1] || !match[2]) return undefined
|
||||
if (out.has(match[1])) return undefined
|
||||
out.set(match[1], match[2])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a `foo.i18n.yaml` consistency record.
|
||||
* @param source - Repo-relative English path.
|
||||
* @param sourceHash - Blob hash of the English side.
|
||||
* @param zh - Repo-relative Chinese path.
|
||||
* @param zhHash - Blob hash of the Chinese side.
|
||||
* @returns The exact sidecar file content.
|
||||
*/
|
||||
export function renderPairMeta(source: string, sourceHash: string, zh: string, zhHash: string): string {
|
||||
return [
|
||||
'# 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 ${source}`,
|
||||
`${basename(source)}: ${sourceHash}`,
|
||||
`${basename(zh)}: ${zhHash}`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Validated shape of `scripts/translation-pairing.manifest.json`. */
|
||||
export interface TranslationPairingManifest {
|
||||
/** Source documents exempt from pairing because they are generated, instructional, or bilingual by construction. */
|
||||
@@ -120,6 +229,8 @@ export function pairAnchorOfArgument(argument: string): string {
|
||||
|
||||
/** A parsed `verify-translation-pairing` invocation. */
|
||||
export interface TranslationPairingCliRequest {
|
||||
/** Content plane read by the check. Writes and corpus checks use the working tree. */
|
||||
input: 'worktree' | 'index'
|
||||
mode: 'check' | 'list' | 'write'
|
||||
/** `corpus` runs discovery over the whole tree; `pairs` touches only the named anchors. */
|
||||
scope: 'corpus' | 'pairs'
|
||||
@@ -142,24 +253,32 @@ export interface TranslationPairingCliRequest {
|
||||
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))
|
||||
const unknown = flags.filter(flag => !['--list', '--write', '--all', '--cached'].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)) {
|
||||
const cachedMode = flags.includes('--cached')
|
||||
if (listMode && (writeMode || allMode || cachedMode || 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 (cachedMode && writeMode) throw new Error('--cached is a read-only index check and cannot be combined with --write')
|
||||
if (cachedMode && anchors.length === 0) throw new Error('--cached requires the staged pair paths to check')
|
||||
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 }
|
||||
return { input: 'worktree', mode: 'write', scope: allMode ? 'corpus' : 'pairs', anchors }
|
||||
}
|
||||
if (listMode) return { input: 'worktree', mode: 'list', scope: 'corpus', anchors: [] }
|
||||
return {
|
||||
input: cachedMode ? 'index' : 'worktree',
|
||||
mode: 'check',
|
||||
scope: anchors.length > 0 ? 'pairs' : 'corpus',
|
||||
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. */
|
||||
@@ -192,6 +311,28 @@ export function linksTo(tree: Nodes, target: string): boolean {
|
||||
return found
|
||||
}
|
||||
|
||||
/** Generated English sources cannot carry a switcher without making their generator stale. */
|
||||
export function requiresSourceLanguageSwitcher(source: string): boolean {
|
||||
return ![
|
||||
'docs/agent-lifecycle.md',
|
||||
'docs/capability-seams.md',
|
||||
'docs/config-catalog.md',
|
||||
'docs/cordis-api/context.md',
|
||||
'docs/cordis-api/events.md',
|
||||
'docs/cordis-api/fiber.md',
|
||||
// Excluded from pairing, but kept here for generated-category completeness and direct spec coverage.
|
||||
'docs/cordis-api/inherited.md',
|
||||
'docs/cordis-api/registry.md',
|
||||
'docs/cordis-api/service.md',
|
||||
'docs/event-producer-consumer.md',
|
||||
'docs/graph-atlas.md',
|
||||
'docs/module-graph.md',
|
||||
'docs/persistence-catalog.md',
|
||||
'docs/tool-catalog.md',
|
||||
'docs/tool-execution-pipeline.md',
|
||||
].includes(source)
|
||||
}
|
||||
|
||||
/** Collect the ordered structural signature, skipping one switcher target. */
|
||||
export function translationStructureSignature(tree: Nodes, switcherTarget: string): TranslationStructureSignature {
|
||||
const sig: TranslationStructureSignature = { headings: [], code: [], tables: [], lists: [], links: [] }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,16 @@
|
||||
/**
|
||||
* Enforce intra-package domain layering inside `packages/client/*\/src/client/`.
|
||||
* verify-module-graph covers package-level edges; this gate covers the
|
||||
* directory level the future package split will land on: domain directories
|
||||
* may import `contract/` and never each other, and only the assembly point
|
||||
* (`apply.ts` / `index.ts`) may import across domains.
|
||||
* directory level: domain directories may import `contract/` and never each
|
||||
* other, and only the assembly point (`apply.ts` / `index.ts`) may import
|
||||
* across domains.
|
||||
*
|
||||
* Layer model (lower may not import higher):
|
||||
* 0 contract/ shared contract surface (types + slot declarations)
|
||||
* 1 <domain>/ + service domain implementations (skeleton/, chat/, ...)
|
||||
* 2 apply.ts, index.ts assembly point and re-export shell
|
||||
*
|
||||
* Not yet wired into the gate sequence (loose-gate window); run directly:
|
||||
* Run directly:
|
||||
* pnpm exec tsx scripts/verify-client-domain-graph.ts
|
||||
*/
|
||||
|
||||
|
||||
30
scripts/verify-config-source-ownership.spec.ts
Normal file
30
scripts/verify-config-source-ownership.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { collectConfigSourceOwnershipViolations } from './verify-config-source-ownership.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('configuration source ownership gate', () => {
|
||||
it('rejects inline endpoints in shipped bundle patches', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-config-source-ownership-'))
|
||||
roots.push(root)
|
||||
const directory = join(root, 'packages/bundle/base')
|
||||
mkdirSync(directory, { recursive: true })
|
||||
writeFileSync(
|
||||
join(directory, 'cordis.patch.yml'),
|
||||
'config:\n baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL\n',
|
||||
)
|
||||
|
||||
expect(collectConfigSourceOwnershipViolations(root)).toEqual([
|
||||
'packages/bundle/base/cordis.patch.yml:2: inlines a credential or endpoint from the environment.'
|
||||
+ ' The adapter resolves apiKeyEnv through ctx.credentials and the endpoint through the'
|
||||
+ ' environment snapshot; inlining here bypasses both ladders.',
|
||||
])
|
||||
})
|
||||
})
|
||||
56
scripts/verify-config-source-ownership.ts
Normal file
56
scripts/verify-config-source-ownership.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Gate for forbidden credential or endpoint environment inlines in shipped
|
||||
* Cordis configuration.
|
||||
* @module scripts/verify-config-source-ownership
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { resolve, sep } from 'node:path'
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** Shipped Cordis configuration these rules apply to. */
|
||||
const SHIPPED_CONFIG_GLOBS = [
|
||||
'apps/*/config/*.yml',
|
||||
'examples/*/*.cordis.yml',
|
||||
'examples/*/cordis.yml',
|
||||
'packages/bundle/*/cordis.patch.yml',
|
||||
// The Python runtime ships its own default composition inside the wheel.
|
||||
'python/*/src/**/cordis.yml',
|
||||
]
|
||||
|
||||
/** Ordinary single-line forms this narrow source-shape check rejects; not full YAML analysis. */
|
||||
const INLINE_DENY = /^\s*(apiKey|baseURL|apiKeyEnv|authToken|headers)\s*:\s*!!js\b/
|
||||
|
||||
/** Return every forbidden inline environment form in shipped configuration. */
|
||||
export function collectConfigSourceOwnershipViolations(root: string): string[] {
|
||||
const failures: string[] = []
|
||||
for (const glob of SHIPPED_CONFIG_GLOBS) {
|
||||
for (const file of globSync(glob, { cwd: root })) {
|
||||
const rel = file.split(sep).join('/')
|
||||
readFileSync(resolve(root, rel), 'utf8').split('\n').forEach((line, index) => {
|
||||
if (!INLINE_DENY.test(line)) return
|
||||
failures.push(
|
||||
`${rel}:${String(index + 1)}: inlines a credential or endpoint from the environment.`
|
||||
+ ' The adapter resolves apiKeyEnv through ctx.credentials and the endpoint through the'
|
||||
+ ' environment snapshot; inlining here bypasses both ladders.',
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
|
||||
const failures = collectConfigSourceOwnershipViolations(ROOT)
|
||||
if (failures.length > 0) {
|
||||
process.stderr.write('verify-config-source-ownership: configuration source ownership violated:\n')
|
||||
for (const failure of failures) process.stderr.write(` ${failure}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
'verify-config-source-ownership: no credential or endpoint uses the ordinary inline environment form'
|
||||
+ ' in shipped configuration.\n',
|
||||
)
|
||||
}
|
||||
@@ -78,6 +78,7 @@ for (const file of files) {
|
||||
errors.push(...validateExampleResolution())
|
||||
errors.push(...validateAppResolution())
|
||||
errors.push(...validateSourcePlaneResolution())
|
||||
errors.push(...validatePresetPlaneSeparation())
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error('verify-cordis-config: invalid Loader metadata or plugin package resolution:')
|
||||
@@ -87,6 +88,76 @@ if (errors.length > 0) {
|
||||
console.log(`verify-cordis-config: ${files.length} config files passed.`)
|
||||
}
|
||||
|
||||
/**
|
||||
* No shipped agent preset may repeat a row the host composition still runs.
|
||||
*
|
||||
* A preset contributes what ONE session adds to the host's registries. A row
|
||||
* active on both planes is therefore mounted twice — once per process and once
|
||||
* per session — and what that costs depends on what the row does: a provider
|
||||
* behind an `isolate` realm shadows the host's for its own consumers, so a host
|
||||
* contributor to that service reaches nobody; a row that registers into a host
|
||||
* singleton registers once per live session, so the second one collides.
|
||||
*
|
||||
* Both have happened. `bash-env` in a preset realm left `DSH_WEB_URL` reaching
|
||||
* no shell, and `tool-subagent-report` handed every child `report` once per live
|
||||
* session until the second registration threw. Neither changes a tool catalog,
|
||||
* so no catalog assertion can see them — and the shipped presets are near-copies
|
||||
* of each other, so a fix applied to three of four is the normal failure.
|
||||
* @returns one diagnostic per preset row that is also active on the host plane.
|
||||
*/
|
||||
function validatePresetPlaneSeparation(): string[] {
|
||||
const problems: string[] = []
|
||||
// The shipped Web surface is two bundle patch layers over an empty root.
|
||||
const hostFile = 'packages/bundle/base/cordis.patch.yml'
|
||||
const overlayFile = 'packages/bundle/web-app/cordis.patch.yml'
|
||||
const hostRows = rowIds(hostFile)
|
||||
const overlay = loadEntries(overlayFile)
|
||||
const disabled = new Set<string>()
|
||||
for (const entry of overlay) {
|
||||
if (!isRecord(entry)) continue
|
||||
if (entry.disabled === true && typeof entry.id === 'string') disabled.add(entry.id)
|
||||
}
|
||||
// The overlay's own inserts are host-plane too; its disables take them back out.
|
||||
const active = new Set([...hostRows, ...rowIds(overlayFile)].filter(id => !disabled.has(id)))
|
||||
for (const file of globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root })) {
|
||||
for (const id of rowIds(file)) {
|
||||
if (!active.has(id)) continue
|
||||
problems.push(
|
||||
`${file}: row "${id}" is also active in the host composition; `
|
||||
+ 'a row belongs to exactly one plane',
|
||||
)
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
/** Every entry of one config file, or an empty list when it is not an entry array. */
|
||||
function loadEntries(file: string): unknown[] {
|
||||
const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema })
|
||||
return isUnknownArray(document) ? document : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Row ids declared anywhere in one config file, including inside group `config`
|
||||
* lists — a preset nests most of its rows in `isolate` groups.
|
||||
* @param file - repository-relative config path.
|
||||
* @returns the declared ids.
|
||||
*/
|
||||
function rowIds(file: string): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
const walk = (value: unknown): void => {
|
||||
if (isUnknownArray(value)) {
|
||||
for (const item of value) walk(item)
|
||||
return
|
||||
}
|
||||
if (!isRecord(value)) return
|
||||
if (typeof value.id === 'string' && typeof value.name === 'string') ids.add(value.id)
|
||||
for (const child of Object.values(value)) walk(child)
|
||||
}
|
||||
walk(loadEntries(file))
|
||||
return ids
|
||||
}
|
||||
|
||||
function validateEntry(value: unknown, file: string, path: string): void {
|
||||
if (!isRecord(value)) {
|
||||
errors.push(`${file}${path}: entry must be an object`)
|
||||
|
||||
108
scripts/verify-md-links.spec.ts
Normal file
108
scripts/verify-md-links.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Acceptance-path coverage for fragment validation in `verify-md-links`: a
|
||||
* `#fragment` onto a Markdown target — same-file anchors included — must name
|
||||
* a real heading slug or explicit `<a id>`, while non-Markdown fragments and
|
||||
* external targets stay out of scope.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { anchorCache, documentAnchors, findViolations, githubSlug } from './verify-md-links.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function layout(files: Record<string, string>): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'md-links-'))
|
||||
roots.push(root)
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
mkdirSync(join(root, rel, '..'), { recursive: true })
|
||||
writeFileSync(join(root, rel), content)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
function violationsIn(root: string, rel: string): { url: string; reason: string }[] {
|
||||
return findViolations(join(root, rel), anchorCache(), root).map(({ url, reason }) => ({ url, reason }))
|
||||
}
|
||||
|
||||
describe('documentAnchors', () => {
|
||||
it('slugs rendered heading text, suffixes repeats, and reads explicit <a id> anchors', () => {
|
||||
const anchors = documentAnchors([
|
||||
'# My Doc',
|
||||
'## Live `events` — mode!',
|
||||
'## Repeat',
|
||||
'## Repeat',
|
||||
'<a id="hand-anchor"></a>',
|
||||
'',
|
||||
].join('\n'))
|
||||
expect(anchors).toEqual(new Set(['my-doc', 'live-events--mode', 'repeat', 'repeat-1', 'hand-anchor']))
|
||||
expect(githubSlug('Security and authority are non-goals')).toBe('security-and-authority-are-non-goals')
|
||||
})
|
||||
|
||||
it('keeps underscores the way GitHub does', () => {
|
||||
expect(githubSlug('Showcase: web_fetch')).toBe('showcase-web_fetch')
|
||||
expect(documentAnchors('## Showcase: web_fetch\n')).toEqual(new Set(['showcase-web_fetch']))
|
||||
})
|
||||
|
||||
it('slugs a heading containing a link from its rendered text', () => {
|
||||
expect(documentAnchors('## [Install](setup.md)\n')).toEqual(new Set(['install']))
|
||||
})
|
||||
|
||||
it('bumps repeat suffixes past occupied slugs, matching GitHub', () => {
|
||||
const anchors = documentAnchors(['## Repeat', '## Repeat-1', '## Repeat', ''].join('\n'))
|
||||
expect(anchors).toEqual(new Set(['repeat', 'repeat-1', 'repeat-2']))
|
||||
})
|
||||
|
||||
it('ignores <a id> inside code fences, inline code, and HTML comments', () => {
|
||||
const anchors = documentAnchors([
|
||||
'# Doc',
|
||||
'```md',
|
||||
'<a id="fenced"></a>',
|
||||
'```',
|
||||
'Inline `<a id="inline"></a>` sample.',
|
||||
'<!-- <a id="commented"></a> -->',
|
||||
'<a id="real"></a>',
|
||||
'',
|
||||
].join('\n'))
|
||||
expect(anchors).toEqual(new Set(['doc', 'real']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('findViolations fragments', () => {
|
||||
it('accepts resolving same-file and cross-file fragments, non-md fragments, and externals', () => {
|
||||
const root = layout({
|
||||
'a.md': '# A\n\n## Deferred work\n\n[self](#deferred-work) [b](b.md#part-two) [code](x.ts#L10) [ext](https://x.example/#frag)\n',
|
||||
'b.md': '# B\n\n## Part two\n',
|
||||
'x.ts': 'export {}\n',
|
||||
})
|
||||
expect(violationsIn(root, 'a.md')).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a same-file fragment that names no heading or <a id>', () => {
|
||||
const root = layout({ 'a.md': '# A\n\n[gone](#deferred-work)\n' })
|
||||
expect(violationsIn(root, 'a.md')).toEqual([{ url: '#deferred-work', reason: 'anchor' }])
|
||||
})
|
||||
|
||||
it('rejects a case-variant fragment: element ids are case-sensitive', () => {
|
||||
const root = layout({ 'a.md': '# A\n\n## Default Loop\n\n[case](#Default-Loop)\n' })
|
||||
expect(violationsIn(root, 'a.md')).toEqual([{ url: '#Default-Loop', reason: 'anchor' }])
|
||||
})
|
||||
|
||||
it('rejects a cross-file fragment missing from the target document', () => {
|
||||
const root = layout({
|
||||
'a.md': '# A\n\n[stale](b.md#old-heading)\n',
|
||||
'b.md': '# B\n\n## New heading\n',
|
||||
})
|
||||
expect(violationsIn(root, 'a.md')).toEqual([{ url: 'b.md#old-heading', reason: 'anchor' }])
|
||||
})
|
||||
|
||||
it('still rejects a missing target file, reported as target not anchor', () => {
|
||||
const root = layout({ 'a.md': '# A\n\n[ghost](missing.md#anything)\n' })
|
||||
expect(violationsIn(root, 'a.md')).toEqual([{ url: 'missing.md#anything', reason: 'target' }])
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,16 @@
|
||||
/**
|
||||
* Verify that relative Markdown links, images, and definitions resolve. URL,
|
||||
* root-absolute, and in-page targets are excluded; query strings and fragments
|
||||
* do not affect resolution against the source file. The checker never rewrites,
|
||||
* and symlinked instruction files are deduped.
|
||||
* Verify that relative Markdown links, images, and definitions resolve — the
|
||||
* target file must exist AND a `#fragment` onto a Markdown target (including
|
||||
* a same-file `#anchor`) must name a real heading slug or explicit `<a id>`.
|
||||
* URL and root-absolute targets are excluded; query strings do not affect
|
||||
* resolution against the source file. The checker never rewrites, and
|
||||
* symlinked instruction files are deduped.
|
||||
*/
|
||||
|
||||
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 { markdownHeadingLines, parseMarkdown, visitMarkdown } from './markdown.ts'
|
||||
import { isArchivedAgentNotePath, uniqueRepoFiles } from './repo-files.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
@@ -28,21 +30,22 @@ const PATTERNS = [
|
||||
'skills/**/*.md',
|
||||
]
|
||||
|
||||
/** A broken relative link: a target path that does not resolve to a file. */
|
||||
/** A broken relative link: a missing target path or a missing anchor on it. */
|
||||
interface Violation {
|
||||
file: string
|
||||
/** 1-based line where the link/image/definition node starts. */
|
||||
line: number
|
||||
url: string
|
||||
/** What failed: the target file or the fragment onto it. */
|
||||
reason: 'target' | 'anchor'
|
||||
}
|
||||
|
||||
/**
|
||||
* True for targets this gate must NOT check: scheme-qualified URLs (`https:`,
|
||||
* `mailto:`, …), protocol-relative (`//host`), root-absolute (`/path`), and
|
||||
* pure in-page anchors (`#frag`). Everything else is a relative path we own.
|
||||
* `mailto:`, …), protocol-relative (`//host`), and root-absolute (`/path`).
|
||||
* Pure in-page anchors (`#frag`) ARE checked, against the source file itself.
|
||||
*/
|
||||
function isExternalOrAnchor(url: string): boolean {
|
||||
if (url.startsWith('#')) return true
|
||||
function isExternal(url: string): boolean {
|
||||
if (url.startsWith('//')) return true
|
||||
if (url.startsWith('/')) return true
|
||||
// A scheme like `https:` / `mailto:` — a colon before any slash, dot, or hash.
|
||||
@@ -69,22 +72,119 @@ function pathPart(url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Find every broken relative cross-link in one Markdown file via its AST. */
|
||||
function findViolations(absPath: string): Violation[] {
|
||||
const file = relative(root, absPath)
|
||||
/** The percent-decoded `#fragment` of a link target, or null when it has none. */
|
||||
function fragmentPart(url: string): string | null {
|
||||
const hash = url.indexOf('#')
|
||||
if (hash === -1) return null
|
||||
const raw = url.slice(hash + 1).replace(/\?.*$/, '')
|
||||
try {
|
||||
return decodeURIComponent(raw)
|
||||
} catch {
|
||||
// Same stance as pathPart: a malformed escape names no anchor anyone
|
||||
// meant, so the raw text flows into the lookup and is reported missing.
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub's heading-slug algorithm (lowercase; drop everything but letters,
|
||||
* numbers, underscores, spaces, hyphens; spaces become hyphens). Underscores
|
||||
* survive (`## Showcase: web_fetch` → `#showcase-web_fetch`), unlike
|
||||
* `gen-cordis-catalog`'s region-anchor slugs — the generator's headings are
|
||||
* always reachable through its explicit `<a id>` anchors, so the two need not
|
||||
* share one rule.
|
||||
* @param heading - the RENDERED heading text (Markdown syntax already gone).
|
||||
* @returns the anchor GitHub assigns the first occurrence of the heading.
|
||||
*/
|
||||
export function githubSlug(heading: string): string {
|
||||
return heading.toLowerCase().replace(/[^\p{L}\p{N}_ -]/gu, '').replaceAll(' ', '-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Every anchor one Markdown document exposes: each heading's GitHub slug —
|
||||
* computed from the RENDERED heading text, so links, images, inline code, and
|
||||
* emphasis inside a heading slug the way GitHub renders them — plus every
|
||||
* explicit `<a id="…">` that appears in real HTML flow (a fenced or inline
|
||||
* code sample and a commented-out anchor register nothing). Repeated slugs
|
||||
* get GitHub's occupied-set `-1`, `-2`, … suffixes: each collision bumps the
|
||||
* ORIGINAL slug's counter until a free name is found, so `Repeat`, `Repeat-1`,
|
||||
* `Repeat` yields `repeat`, `repeat-1`, `repeat-2`. Matching is exact —
|
||||
* element ids are case-sensitive.
|
||||
* @param source - the document's full Markdown text.
|
||||
* @returns the set of valid fragments for links into this document.
|
||||
*/
|
||||
export function documentAnchors(source: string): Set<string> {
|
||||
const anchors = new Set<string>()
|
||||
const occurrences = new Map<string, number>()
|
||||
for (const heading of markdownHeadingLines(source)) {
|
||||
const base = githubSlug(heading.text)
|
||||
let result = base
|
||||
let bump = occurrences.get(base) ?? 0
|
||||
while (anchors.has(result)) {
|
||||
bump += 1
|
||||
result = `${base}-${bump}`
|
||||
}
|
||||
occurrences.set(base, bump)
|
||||
anchors.add(result)
|
||||
}
|
||||
visitMarkdown(parseMarkdown(source), (node: Nodes): void => {
|
||||
if (node.type !== 'html') return
|
||||
const html = node.value.replace(/<!--[\s\S]*?-->/g, '')
|
||||
for (const match of html.matchAll(/<a id="([^"]+)"/g)) anchors.add(match[1] ?? '')
|
||||
})
|
||||
return anchors
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily collect and cache the anchor set of any existing Markdown file —
|
||||
* shared across all scanned sources so a target parses once.
|
||||
* @returns the memoized absolute-path → anchor-set lookup.
|
||||
*/
|
||||
export function anchorCache(): (absPath: string) => Set<string> {
|
||||
const cache = new Map<string, Set<string>>()
|
||||
return (absPath) => {
|
||||
const hit = cache.get(absPath)
|
||||
if (hit) return hit
|
||||
const anchors = documentAnchors(readFileSync(absPath, 'utf8'))
|
||||
cache.set(absPath, anchors)
|
||||
return anchors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every broken relative cross-link in one Markdown file via its AST: a
|
||||
* relative target that does not exist, or a fragment onto a Markdown file
|
||||
* (same-file `#anchor` links included) that names no heading slug or explicit
|
||||
* `<a id>` there. Fragments onto non-Markdown targets (`file.ts#L10`) carry
|
||||
* renderer-owned semantics and are not judged.
|
||||
* @param absPath - absolute path of the Markdown source to scan.
|
||||
* @param anchorsOf - anchor lookup shared across files for cross-link checks.
|
||||
* @param scanRoot - repository root violations are reported relative to.
|
||||
* @returns one entry per broken link, in document order.
|
||||
*/
|
||||
export function findViolations(
|
||||
absPath: string,
|
||||
anchorsOf: (abs: string) => Set<string>,
|
||||
scanRoot: string = root,
|
||||
): Violation[] {
|
||||
const file = relative(scanRoot, absPath)
|
||||
const dir = dirname(absPath)
|
||||
const source = readFileSync(absPath, 'utf8')
|
||||
const tree = parseMarkdown(source)
|
||||
const out: Violation[] = []
|
||||
|
||||
const check = (url: string, node: Nodes): void => {
|
||||
if (isExternalOrAnchor(url)) return
|
||||
if (isExternal(url)) return
|
||||
const target = pathPart(url)
|
||||
// A bare `#anchor` reduced to empty path is a same-file anchor — skip.
|
||||
if (target === '') return
|
||||
const resolved = resolve(dir, target)
|
||||
const resolved = target === '' ? absPath : resolve(dir, target)
|
||||
if (!existsSync(resolved)) {
|
||||
out.push({ file, line: node.position?.start.line ?? 0, url })
|
||||
out.push({ file, line: node.position?.start.line ?? 0, url, reason: 'target' })
|
||||
return
|
||||
}
|
||||
const fragment = fragmentPart(url)
|
||||
if (fragment === null || !resolved.endsWith('.md')) return
|
||||
if (!anchorsOf(resolved).has(fragment)) {
|
||||
out.push({ file, line: node.position?.start.line ?? 0, url, reason: 'anchor' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,18 +196,22 @@ function findViolations(absPath: string): Violation[] {
|
||||
return out
|
||||
}
|
||||
|
||||
// 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
|
||||
// Run only when invoked as a script, not when imported by the spec.
|
||||
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
|
||||
// Archived notes remain valid link targets, but their historical outbound links are frozen.
|
||||
const files = uniqueRepoFiles(root, PATTERNS, isArchivedAgentNotePath)
|
||||
const anchorsOf = anchorCache()
|
||||
const all = files.flatMap(file => findViolations(file.abs, anchorsOf))
|
||||
const checked = files.length
|
||||
|
||||
if (all.length === 0) {
|
||||
console.log(`verify-md-links: ${checked} file(s) checked, all relative cross-links resolve.`)
|
||||
process.exit(0)
|
||||
}
|
||||
if (all.length === 0) {
|
||||
console.log(`verify-md-links: ${checked} file(s) checked, all relative cross-links and fragments resolve.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.error('verify-md-links: broken relative cross-links found (target does not exist):')
|
||||
for (const v of all) {
|
||||
console.error(` ${v.file}:${v.line} ${v.url}`)
|
||||
console.error('verify-md-links: broken relative cross-links found:')
|
||||
for (const v of all) {
|
||||
console.error(` ${v.file}:${v.line} ${v.url} (${v.reason === 'target' ? 'target does not exist' : 'no such anchor in target'})`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
process.exit(1)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Find stale root-relative `packages/...` references in repo-authored prose and
|
||||
* TypeScript. A missing path is reported only when it names a real package leaf;
|
||||
* globs, placeholders, hypothetical packages, and unbuilt `lib/` output are
|
||||
* outside the check.
|
||||
* TypeScript. A missing path is reported only when it names a real package leaf
|
||||
* outside its own explaining group directory; globs, placeholders, hypothetical
|
||||
* packages, and unbuilt `lib/` output are outside the check.
|
||||
*/
|
||||
|
||||
import { existsSync, globSync } from 'node:fs'
|
||||
@@ -66,7 +66,15 @@ function isDriftedPackageReference(ref: string): boolean {
|
||||
const libAt = parts.indexOf('lib')
|
||||
if (libAt === 3 && existsSync(resolve(root, parts.slice(0, 3).join('/')))) return false
|
||||
// A missing reference is drift only when a path segment names a live package.
|
||||
return ref.split('/').slice(1).some(segment => packageNames.has(segment))
|
||||
// A leading segment that is itself an existing group directory is explained by
|
||||
// the group, not by a relocated leaf sharing its name (`client` is both the
|
||||
// client-modules group and the scaffold leaf), so only later segments count.
|
||||
const segments = ref.split('/').slice(1)
|
||||
const [group] = segments
|
||||
const scanned = group !== undefined && segments.length > 1 && existsSync(resolve(root, 'packages', group))
|
||||
? segments.slice(1)
|
||||
: segments
|
||||
return scanned.some(segment => packageNames.has(segment))
|
||||
}
|
||||
|
||||
/** Find missing package references whose path names a live package; bare paths, typos, and illustrative skeletons do not count. */
|
||||
|
||||
@@ -33,6 +33,7 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly<Record<string, string>> = {
|
||||
'packages/core/scope': 'The package is a model-agnostic registration and lifecycle primitive; model-facing consumers own any context selection.',
|
||||
'packages/util/brand': 'The package is a type-only primitive erased at compile time.',
|
||||
'packages/util/paths': 'The package only resolves harness-owned host paths; model-facing consumers own any rendered use.',
|
||||
'packages/util/environment': 'The package only resolves host environment values; model-facing consumers own any rendered use.',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,9 +47,14 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' },
|
||||
'packages/bash/pwsh-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-pwsh.' },
|
||||
'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' },
|
||||
'packages/core/agent-tool-mode': { kind: 'indirect', reason: 'The row only selects between the two projections dsh-tools owns; it registers no prompt, schema, or result of its own.' },
|
||||
'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' },
|
||||
'packages/client/ui-agent-preset': { kind: 'indirect', reason: 'Browser-side settings row; the preset it selects owns every model-facing effect.' },
|
||||
'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' },
|
||||
'packages/preset/agent-presets': { kind: 'indirect', reason: 'The mount installs a preset\'s own plugins, which own every model-facing registration it makes visible.' },
|
||||
'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' },
|
||||
'packages/typert/loader': { kind: 'none', reason: 'Loader integration only registers generated artifacts; consumers own any model-visible projection.' },
|
||||
'packages/e2b/e2b': { kind: 'none', reason: 'The shared remote-runtime owner registers no model context; provider adapters and consumers own rendered effects.' },
|
||||
'packages/client/hmr': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/modules': { kind: 'none', reason: 'Browser-side module-loading kernel machinery; registers no model surface.' },
|
||||
'packages/client/test-runtime': { kind: 'none', reason: 'Browser-side test infrastructure (jsdom bench); registers no model surface.' },
|
||||
@@ -57,13 +63,16 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/client/web-react': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/schema-form': { kind: 'none', reason: 'Browser-side form-rendering library; registers no model surface.' },
|
||||
'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/api/remotes': { kind: 'none', reason: 'The Remote BFF selects business methods and identity policy; selected services own any model-visible effect.' },
|
||||
'packages/client/runtime': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-tool': { kind: 'none', reason: 'Browser-side Tool presentation layer; renders logged calls without changing model context.' },
|
||||
'packages/client/ui-deliverables': { 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-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the host snapshots the target at the next prompt-assembly boundary and owns the model-visible effect.' },
|
||||
'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the Host snapshots the selection at the next prompt-assembly boundary and owns the model-visible effect.' },
|
||||
'packages/client/ui-goal': { kind: 'indirect', reason: 'The strip verbs route goal.* mutations; the host GoalService owns the model-visible goal/change context message.' },
|
||||
'packages/client/ui-permission': { kind: 'indirect', reason: 'The picker submits the host /permission command; the knob events it appends own the model-visible effect through the sandbox/approval consumers.' },
|
||||
'packages/client/ui-plan': { kind: 'indirect', reason: 'The chip dispatches /plan off; dsh-plan-mode owns the model-visible policy, exit tool, and logged state.' },
|
||||
@@ -78,6 +87,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'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.' },
|
||||
'packages/e2b/fs-e2b': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
|
||||
'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
|
||||
'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' },
|
||||
'packages/host/apiproxy': { kind: 'none', reason: 'The wire contract and fetch carriers move already-composed messages and register no model surface.' },
|
||||
@@ -88,22 +98,24 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' },
|
||||
'packages/host/frontend-static': { kind: 'none', reason: 'The SPA dist server answers browser asset requests and registers no model surface.' },
|
||||
'packages/bundle/base': { kind: 'indirect', reason: 'The bundle is a patch-list carrier; each inserted row\'s package owns its model surface.' },
|
||||
'packages/bundle/headless': { kind: 'none', reason: 'The one-shot runner submits the task as an ordinary user message; prompts and tools belong to the composed base/web bundles.' },
|
||||
'packages/bundle/headless': { kind: 'none', reason: 'The one-shot runner submits the task as an ordinary user message; prompts and tools belong to the composed base and headless bundles.' },
|
||||
'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' },
|
||||
'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' },
|
||||
'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' },
|
||||
'packages/lsp/lsp-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-lsp.' },
|
||||
'packages/subprocess/subprocess': { kind: 'indirect', reason: 'The seam delegates all model rendering to consumer seams such as the bash executor family.' },
|
||||
'packages/e2b/subprocess-e2b': { kind: 'indirect', reason: 'The remote spawn backend delegates model rendering to consumer seams such as the bash executor family.' },
|
||||
'packages/subprocess/subprocess-local': { kind: 'indirect', reason: 'The spawn backend delegates model rendering to consumer seams such as the bash executor family.' },
|
||||
'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' },
|
||||
'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' },
|
||||
'packages/sdk/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' },
|
||||
'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' },
|
||||
'packages/sdk/sdk-client': { kind: 'none', reason: 'Client-process library; the model surface lives in the spawned runtime\'s composed plugins.' },
|
||||
'packages/sdk/sdk-protocol': { kind: 'none', reason: 'Client-facing wire library; the runtime plugins behind the serving entry own the model surface.' },
|
||||
'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' },
|
||||
'packages/session-projection/session-projection': { kind: 'none', reason: 'The projection registry serves client-facing read models of already-logged session state and registers no model surface.' },
|
||||
'packages/session-projection/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection state and registers no model surface.' },
|
||||
'packages/sandbox/sandbox-windows-acl': { kind: 'indirect', reason: 'The provider backend delegates model rendering to the bash/pwsh sandbox executors and their tools.' },
|
||||
'packages/scaffold/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' },
|
||||
'packages/scaffold/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' },
|
||||
'packages/scaffold/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' },
|
||||
'packages/scaffold/client': { kind: 'none', reason: 'Client-process library; the model surface lives in the spawned runtime\'s composed plugins.' },
|
||||
'packages/scaffold/protocol': { kind: 'none', reason: 'Client-facing wire library; the runtime plugins behind the serving entry own the model surface.' },
|
||||
'packages/scaffold/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' },
|
||||
'packages/session/session-projection': { kind: 'none', reason: 'The projection registry serves client-facing read models of already-logged session state and registers no model surface.' },
|
||||
'packages/session/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection state and registers no model surface.' },
|
||||
'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' },
|
||||
'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' },
|
||||
'packages/settings/settings': { kind: 'indirect', reason: 'The seam stores and resolves user settings; consumer plugins own any model surface a value feeds.' },
|
||||
@@ -111,9 +123,10 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/credentials/credentials': { kind: 'indirect', reason: 'The seam resolves credential references; the consuming adapter owns every model surface a value authorizes.' },
|
||||
'packages/credentials/credentials-local': { kind: 'indirect', reason: 'The file/environment provider stores credential values; consumers of ctx.credentials own any model surface.' },
|
||||
'packages/util/atomic-write': { kind: 'none', reason: 'Pure filesystem write primitive; registers no model surface.' },
|
||||
'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' },
|
||||
'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' },
|
||||
'packages/session/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' },
|
||||
'packages/session/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' },
|
||||
'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' },
|
||||
'packages/skill/skill-badge': { kind: 'indirect', reason: 'The bundled provider delegates model rendering to dsh-tool-skill.' },
|
||||
'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' },
|
||||
'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' },
|
||||
'packages/spill/spill-local': { kind: 'indirect', reason: 'The storage backend delegates model rendering to spill consumers.' },
|
||||
@@ -121,17 +134,19 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/support/acp-snapshot': { kind: 'none', reason: 'The test harness observes and normalizes transcripts without changing live requests.' },
|
||||
'packages/support/agent-loop-testkit': { kind: 'none', reason: 'The test helper mounts services but neither drives nor modifies model requests.' },
|
||||
'packages/support/invariants': { kind: 'none', reason: 'The observer validates requests but never rewrites their context.' },
|
||||
'packages/support/loader-smoke': { kind: 'none', reason: 'The test harness observes child-process streams without changing live requests.' },
|
||||
'packages/support/loader-smoke': { kind: 'none', reason: 'The test harness submits an ordinary user task but delegates prompt and tool composition to the loaded tree.' },
|
||||
'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/api/gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' },
|
||||
'packages/typert/type-meta': { kind: 'none', reason: 'Compiler-independent Remote protocol declarations; registers no model surface.' },
|
||||
'packages/typert/generator': { kind: 'none', reason: 'The build-time generator runs outside any agent runtime and touches no model request.' },
|
||||
'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/boot/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.' },
|
||||
'packages/ui/permission': { kind: 'indirect', reason: 'The service writes mechanism events rendered by dsh-user-approval and dsh-tool-bash.' },
|
||||
'packages/ui/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' },
|
||||
'packages/interaction/permission': { kind: 'indirect', reason: 'The service writes mechanism events rendered by dsh-user-approval and dsh-tool-bash.' },
|
||||
'packages/interaction/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' },
|
||||
'packages/util/timeout': { kind: 'indirect', reason: 'Only timeout consumers render timeout outcomes.' },
|
||||
'packages/util/retention': { kind: 'indirect', reason: 'Only retention consumers render retained content and omission metadata.' },
|
||||
'packages/util/native-command': { kind: 'none', reason: 'The host-side subprocess runner registers no model surface.' },
|
||||
|
||||
60
scripts/verify-public-repository-links.spec.ts
Normal file
60
scripts/verify-public-repository-links.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { findInternalRepositoryReferences } from './verify-public-repository-links.ts'
|
||||
|
||||
describe('public repository link policy', () => {
|
||||
it('rejects encoded and case-varied internal identities without blocking public repositories', () => {
|
||||
const internalOwner = ['deepseek', 'harness'].join('-')
|
||||
const internalRepository = [internalOwner, internalOwner].join('/')
|
||||
const encodedRepository = internalRepository.replaceAll('-', '%2D').replace('/', '%2F')
|
||||
const htmlEncodedRepository = internalRepository.replace('/', '/')
|
||||
const jsonEscapedRepository = internalRepository.replace('/', '\\/')
|
||||
const unicodeEscapedRepository = internalRepository.replace('/', String.raw`\u002f`)
|
||||
const source = [
|
||||
'https://github.com/deepseek-ai/deepseek-harness-sdk',
|
||||
`https://github.com/${internalOwner}/cordis`,
|
||||
`https://github.com/${internalRepository.toUpperCase()}/issues/1`,
|
||||
`https://github.com/${encodedRepository}/issues/2`,
|
||||
`https://github.com/${htmlEncodedRepository}/issues/3`,
|
||||
`"https:\\/\\/github.com\\/${jsonEscapedRepository}\\/issues\\/4"`,
|
||||
`"https:\\/\\/github.com\\/${unicodeEscapedRepository}\\/issues\\/5"`,
|
||||
`${internalOwner.toUpperCase()}#6`,
|
||||
].join('\n')
|
||||
|
||||
expect(findInternalRepositoryReferences('subject.md', source)).toEqual([
|
||||
{ file: 'subject.md', line: 3 },
|
||||
{ file: 'subject.md', line: 4 },
|
||||
{ file: 'subject.md', line: 5 },
|
||||
{ file: 'subject.md', line: 6 },
|
||||
{ file: 'subject.md', line: 7 },
|
||||
{ file: 'subject.md', line: 8 },
|
||||
])
|
||||
})
|
||||
|
||||
it('allows only the exact audited trusted-publishing repository declarations', () => {
|
||||
const internalOwner = ['deepseek', 'harness'].join('-')
|
||||
const internalRepository = [internalOwner, internalOwner].join('/')
|
||||
const repositoryUrl = `git+https://github.com/${internalRepository}.git`
|
||||
const manifestLine = ` "url": "${repositoryUrl}",`
|
||||
const constraintLine = `const repositoryUrl = '${repositoryUrl}'`
|
||||
const allowedDeclarations = [
|
||||
['native/landlock-run/packages/entry/package.json', manifestLine],
|
||||
['native/landlock-run/packages/linux-arm64/package.json', manifestLine],
|
||||
['native/landlock-run/packages/linux-x64/package.json', manifestLine],
|
||||
['scripts/check-workspace-constraints.ts', constraintLine],
|
||||
] as const
|
||||
|
||||
for (const [file, source] of allowedDeclarations) {
|
||||
expect(findInternalRepositoryReferences(file, source)).toEqual([])
|
||||
}
|
||||
|
||||
const wrongFile = 'native/landlock-run/package.json'
|
||||
expect(findInternalRepositoryReferences(wrongFile, manifestLine)).toEqual([{ file: wrongFile, line: 1 }])
|
||||
|
||||
const manifestFile = 'native/landlock-run/packages/entry/package.json'
|
||||
const wrongField = ` "homepage": "${repositoryUrl}",`
|
||||
expect(findInternalRepositoryReferences(manifestFile, wrongField)).toEqual([{ file: manifestFile, line: 1 }])
|
||||
|
||||
const encodedLine = manifestLine.replace('github.com/', 'github.com\\/')
|
||||
expect(findInternalRepositoryReferences(manifestFile, encodedLine)).toEqual([{ file: manifestFile, line: 1 }])
|
||||
})
|
||||
})
|
||||
101
scripts/verify-public-repository-links.ts
Normal file
101
scripts/verify-public-repository-links.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/** Reject tracked files that expose the internal repository identity outside audited publishing declarations. */
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, lstatSync, readFileSync, readlinkSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const internalOwner = ['deepseek', 'harness'].join('-')
|
||||
const internalRepository = [internalOwner, internalOwner].join('/')
|
||||
const internalIssueShorthand = `${internalOwner}#`
|
||||
const trustedPublishingRepositoryUrl = `git+https://github.com/${internalRepository}.git`
|
||||
|
||||
/** Exact declarations that intentionally expose the source repository for trusted publishing. */
|
||||
const allowedInternalRepositoryLineByFile: Readonly<Record<string, string>> = {
|
||||
'native/landlock-run/packages/entry/package.json': `"url": "${trustedPublishingRepositoryUrl}",`,
|
||||
'native/landlock-run/packages/linux-arm64/package.json': `"url": "${trustedPublishingRepositoryUrl}",`,
|
||||
'native/landlock-run/packages/linux-x64/package.json': `"url": "${trustedPublishingRepositoryUrl}",`,
|
||||
'scripts/check-workspace-constraints.ts': `const repositoryUrl = '${trustedPublishingRepositoryUrl}'`,
|
||||
}
|
||||
|
||||
const namedReferenceCharacters: Readonly<Record<string, string>> = {
|
||||
hyphen: '-',
|
||||
num: '#',
|
||||
sol: '/',
|
||||
}
|
||||
|
||||
/** Normalize source spellings that render or decode to repository separators. */
|
||||
function canonicalReferenceText(source: string): string {
|
||||
return source
|
||||
.replaceAll('\\/', '/')
|
||||
.replace(/\\u(0023|002d|002f)/gi, (_match, code: string) => String.fromCodePoint(Number.parseInt(code, 16)))
|
||||
.replace(/%(23|2d|2f)/gi, (_match, code: string) => String.fromCodePoint(Number.parseInt(code, 16)))
|
||||
.replace(/&#(?:(\d+)|x([\da-f]+));/gi, (entity, decimal: string | undefined, hexadecimal: string | undefined) => {
|
||||
const code = Number.parseInt(decimal ?? hexadecimal ?? '', decimal === undefined ? 16 : 10)
|
||||
return code === 35 || code === 45 || code === 47 ? String.fromCodePoint(code) : entity
|
||||
})
|
||||
.replace(/&(hyphen|num|sol);/gi, (entity, name: string) => namedReferenceCharacters[name.toLowerCase()] ?? entity)
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
/** One tracked reference to the internal repository. */
|
||||
export interface InternalRepositoryReference {
|
||||
/** Repository-relative file path. */
|
||||
file: string
|
||||
/** One-based source line. */
|
||||
line: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate unaudited internal-repository references in one text file.
|
||||
* @param file - Repository-relative path used in diagnostics.
|
||||
* @param source - Text to inspect.
|
||||
* @returns every matching source line.
|
||||
*/
|
||||
export function findInternalRepositoryReferences(file: string, source: string): InternalRepositoryReference[] {
|
||||
const references: InternalRepositoryReference[] = []
|
||||
for (const [index, line] of source.split('\n').entries()) {
|
||||
const canonicalLine = canonicalReferenceText(line)
|
||||
const isAllowedPublishingDeclaration = line.trim() === allowedInternalRepositoryLineByFile[file]
|
||||
if (!isAllowedPublishingDeclaration
|
||||
&& (canonicalLine.includes(internalRepository) || canonicalLine.includes(internalIssueShorthand))) {
|
||||
references.push({ file, line: index + 1 })
|
||||
}
|
||||
}
|
||||
return references
|
||||
}
|
||||
|
||||
function trackedFiles(repoRoot: string): string[] {
|
||||
return execFileSync('git', ['ls-files', '-z'], { cwd: repoRoot, encoding: 'utf8' })
|
||||
.split('\0')
|
||||
.filter(file => file !== '')
|
||||
}
|
||||
|
||||
function scanRepository(repoRoot: string): InternalRepositoryReference[] {
|
||||
const references: InternalRepositoryReference[] = []
|
||||
for (const file of trackedFiles(repoRoot)) {
|
||||
const path = resolve(repoRoot, file)
|
||||
if (!existsSync(path)) continue
|
||||
const stat = lstatSync(path)
|
||||
if (!stat.isFile() && !stat.isSymbolicLink()) continue
|
||||
const source = stat.isSymbolicLink() ? readlinkSync(path) : readFileSync(path, 'utf8')
|
||||
if (source.includes('\0')) continue
|
||||
references.push(...findInternalRepositoryReferences(file, source))
|
||||
}
|
||||
return references
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1]
|
||||
const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href
|
||||
if (isMain) {
|
||||
const references = scanRepository(root)
|
||||
if (references.length === 0) {
|
||||
console.log('verify-public-repository-links: tracked files expose no unexpected internal repository identity.')
|
||||
} else {
|
||||
console.error('verify-public-repository-links: unexpected internal repository references found:')
|
||||
for (const reference of references) console.error(` ${reference.file}:${String(reference.line)}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,28 @@
|
||||
* 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.
|
||||
* pairs (`--write --all` records every complete pair); `--cached <pairs...>`
|
||||
* checks exact index bytes for hooks. 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.
|
||||
*/
|
||||
|
||||
import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { basename, join, resolve, sep } from 'node:path'
|
||||
import { gitBlobHash, storeGitBlob } from './translation-pairing-git.ts'
|
||||
import { gitBlobHash, readGitIndexBlob, storeGitBlob } from './translation-pairing-git.ts'
|
||||
import {
|
||||
parseTranslationPairingRecord,
|
||||
renderTranslationPairingRecord,
|
||||
translationPairPaths,
|
||||
} from './translation-pairing-record.ts'
|
||||
import {
|
||||
linksTo,
|
||||
parseTranslationMarkdown,
|
||||
parseTranslationPairingCliArgs,
|
||||
parseTranslationPairingManifest,
|
||||
partitionGeneratedRegions,
|
||||
requiresSourceLanguageSwitcher,
|
||||
isTranslationScopeFile,
|
||||
TRANSLATION_SCOPE_GLOB_EXCLUDES,
|
||||
translationStructureDiff,
|
||||
@@ -33,6 +41,24 @@ try {
|
||||
}
|
||||
const listMode = request.mode === 'list'
|
||||
const writeMode = request.mode === 'write'
|
||||
const indexMode = request.input === 'index'
|
||||
|
||||
const contentCache = new Map<string, Buffer | undefined>()
|
||||
|
||||
/** Read one repository path from the selected worktree or index plane. */
|
||||
function readRepositoryFile(file: string): Buffer | undefined {
|
||||
if (contentCache.has(file)) return contentCache.get(file)
|
||||
const content = indexMode
|
||||
? readGitIndexBlob(root, file)?.content
|
||||
: existsSync(join(root, file)) ? readFileSync(join(root, file)) : undefined
|
||||
contentCache.set(file, content)
|
||||
return content
|
||||
}
|
||||
|
||||
/** Whether one path exists in the selected content plane. */
|
||||
function repositoryFileExists(file: string): boolean {
|
||||
return readRepositoryFile(file) !== undefined
|
||||
}
|
||||
|
||||
/** Discover source Markdown and pairing sidecars before applying the corpus predicate. */
|
||||
const SCOPE_PATTERNS = [
|
||||
@@ -42,7 +68,11 @@ const SCOPE_PATTERNS = [
|
||||
'.agents/notes/**/*.i18n.yaml',
|
||||
]
|
||||
|
||||
const manifest = parseTranslationPairingManifest(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8'))
|
||||
const manifestContent = readRepositoryFile('scripts/translation-pairing.manifest.json')
|
||||
if (manifestContent === undefined) {
|
||||
throw new Error('scripts/translation-pairing.manifest.json is missing from the selected content plane')
|
||||
}
|
||||
const manifest = parseTranslationPairingManifest(manifestContent.toString('utf8'))
|
||||
|
||||
/**
|
||||
* An excluded entry ending in `/` excludes the whole directory. The trailing
|
||||
@@ -54,50 +84,20 @@ function isExcluded(file: string): boolean {
|
||||
return manifest.excluded.some(entry => (entry.endsWith('/') ? file.startsWith(entry) : file === entry))
|
||||
}
|
||||
|
||||
/** The three paths of a pair, derived from the English-file path. */
|
||||
function pairPaths(source: string): { zh: string; meta: string } {
|
||||
return { zh: source.replace(/\.md$/, '.zh.md'), meta: source.replace(/\.md$/, '.i18n.yaml') }
|
||||
}
|
||||
|
||||
const META_LINE = /^([^:#]+\.md): ([0-9a-f]{40})$/
|
||||
|
||||
/** Parse a `foo.i18n.yaml` consistency record: basename → recorded 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 = META_LINE.exec(line)
|
||||
if (!match?.[1] || !match[2]) return undefined
|
||||
out.set(match[1], match[2])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Render a `foo.i18n.yaml` consistency record. */
|
||||
function renderMeta(source: string, sourceHash: string, zh: string, zhHash: string): string {
|
||||
return [
|
||||
'# 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 ${source}`,
|
||||
`${basename(source)}: ${sourceHash}`,
|
||||
`${basename(zh)}: ${zhHash}`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
// 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>()
|
||||
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)
|
||||
const { source, zh, meta } = translationPairPaths(anchor)
|
||||
for (const file of [source, zh, meta]) {
|
||||
if (repositoryFileExists(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)
|
||||
// A named worktree anchor with no files still enters the source list so
|
||||
// an interactive check reports it. An index check accepts a complete
|
||||
// three-file deletion and still rejects every partial deletion below.
|
||||
if (!indexMode && !repositoryFileExists(anchor)) files.add(anchor)
|
||||
}
|
||||
} else {
|
||||
for (const pattern of SCOPE_PATTERNS) {
|
||||
@@ -113,8 +113,11 @@ const sources = [...files].filter(f => f.endsWith('.md') && !f.endsWith('.zh.md'
|
||||
|
||||
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) {
|
||||
const absent = request.anchors.filter((anchor) => {
|
||||
const { source, zh, meta } = translationPairPaths(anchor)
|
||||
return ![source, zh, meta].some(repositoryFileExists)
|
||||
})
|
||||
if (rejected.length > 0 || (!indexMode && 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)`)
|
||||
}
|
||||
@@ -132,20 +135,25 @@ if (writeMode) {
|
||||
let written = 0
|
||||
for (const source of sources) {
|
||||
if (isExcluded(source)) continue
|
||||
const { zh, meta } = pairPaths(source)
|
||||
if (!existsSync(join(root, source)) || !existsSync(join(root, zh))) {
|
||||
const paths = translationPairPaths(source)
|
||||
const { zh, meta } = paths
|
||||
if (!repositoryFileExists(source) || !repositoryFileExists(zh)) {
|
||||
if (request.scope === 'pairs') {
|
||||
console.error(`verify-translation-pairing: cannot record ${source}: missing ${existsSync(join(root, source)) ? zh : source}`)
|
||||
console.error(`verify-translation-pairing: cannot record ${source}: missing ${repositoryFileExists(source) ? zh : source}`)
|
||||
process.exit(2)
|
||||
}
|
||||
continue
|
||||
}
|
||||
const sourceContent = readFileSync(join(root, source))
|
||||
const zhContent = readFileSync(join(root, zh))
|
||||
const sourceContent = readRepositoryFile(source)
|
||||
const zhContent = readRepositoryFile(zh)
|
||||
if (sourceContent === undefined || zhContent === undefined) throw new Error(`${source}: complete pair became unreadable`)
|
||||
// A consistency record is also a recovery pointer for the briefing
|
||||
// generator. Persist both snapshots even when the sidecar text is already
|
||||
// current, because the bytes may exist only in this working tree.
|
||||
const record = renderMeta(source, storeGitBlob(root, sourceContent), zh, storeGitBlob(root, zhContent))
|
||||
const record = renderTranslationPairingRecord(paths, {
|
||||
sourceHash: storeGitBlob(root, sourceContent),
|
||||
zhHash: storeGitBlob(root, zhContent),
|
||||
})
|
||||
if (existsSync(join(root, meta)) && readFileSync(join(root, meta), 'utf8') === record) continue
|
||||
writeFileSync(join(root, meta), record)
|
||||
console.log(`verify-translation-pairing: recorded ${meta}`)
|
||||
@@ -161,8 +169,8 @@ const state = new Map<string, 'ok' | 'out-of-sync' | 'missing'>()
|
||||
// 1. Every discovered, non-excluded source merges bilingual.
|
||||
for (const source of sources) {
|
||||
if (isExcluded(source)) continue
|
||||
const { zh } = pairPaths(source)
|
||||
if (!existsSync(join(root, zh))) {
|
||||
const { zh } = translationPairPaths(source)
|
||||
if (!repositoryFileExists(zh)) {
|
||||
errors.push(`${source}: in-scope documentation must merge bilingual (docs/i18n/README.md); add the counterpart and record the pair`)
|
||||
state.set(source, 'missing')
|
||||
}
|
||||
@@ -176,8 +184,13 @@ for (const zh of translations) pairAnchors.add(zh.replace(/\.zh\.md$/, '.md'))
|
||||
for (const meta of metas) pairAnchors.add(meta.replace(/\.i18n\.yaml$/, '.md'))
|
||||
|
||||
for (const source of [...pairAnchors].sort()) {
|
||||
const { zh, meta } = pairPaths(source)
|
||||
const have = { source: existsSync(join(root, source)), zh: existsSync(join(root, zh)), meta: existsSync(join(root, meta)) }
|
||||
const paths = translationPairPaths(source)
|
||||
const { zh, meta } = paths
|
||||
const have = {
|
||||
source: repositoryFileExists(source),
|
||||
zh: repositoryFileExists(zh),
|
||||
meta: repositoryFileExists(meta),
|
||||
}
|
||||
|
||||
if (isExcluded(source)) {
|
||||
if (have.zh) errors.push(`${zh}: ${source} is excluded from pairing (generated or bilingual-by-construction); this translation must not exist`)
|
||||
@@ -190,10 +203,14 @@ for (const source of [...pairAnchors].sort()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const sourceContent = readFileSync(join(root, source))
|
||||
const zhContent = readFileSync(join(root, zh))
|
||||
const record = parseMeta(readFileSync(join(root, meta), 'utf8'))
|
||||
if (!record || record.size !== 2 || !record.has(basename(source)) || !record.has(basename(zh))) {
|
||||
const sourceContent = readRepositoryFile(source)
|
||||
const zhContent = readRepositoryFile(zh)
|
||||
const metaContent = readRepositoryFile(meta)
|
||||
if (sourceContent === undefined || zhContent === undefined || metaContent === undefined) {
|
||||
throw new Error(`${source}: complete pair became unreadable`)
|
||||
}
|
||||
const record = parseTranslationPairingRecord(metaContent.toString('utf8'), paths)
|
||||
if (record === undefined) {
|
||||
errors.push(`${meta}: malformed consistency record (expected exactly \`${basename(source)}: <40-hex>\` and \`${basename(zh)}: <40-hex>\`)`)
|
||||
continue
|
||||
}
|
||||
@@ -201,7 +218,8 @@ for (const source of [...pairAnchors].sort()) {
|
||||
let consistent = true
|
||||
for (const [file, content] of [[source, sourceContent], [zh, zhContent]] as const) {
|
||||
const current = gitBlobHash(content)
|
||||
if (record.get(basename(file)) !== current) {
|
||||
const recorded = file === source ? record.sourceHash : record.zhHash
|
||||
if (recorded !== current) {
|
||||
errors.push(`${file}: out of sync — content no longer matches the pair's last confirmed-consistent state in ${meta} (bring the other side along, then re-record with --write)`)
|
||||
consistent = false
|
||||
}
|
||||
@@ -211,12 +229,33 @@ for (const source of [...pairAnchors].sort()) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Generated regions are language-invariant: the exact same generator output
|
||||
// (markers included) must appear in both sides, in the same order. The
|
||||
// structural signature below compares the region content again as part of
|
||||
// the whole document; this dedicated check exists to name the divergence
|
||||
// precisely and to reject a region grammar violation on either side.
|
||||
let sourceRegions: { regions: string[]; stripped: string }
|
||||
let zhRegions: { regions: string[]; stripped: string }
|
||||
try {
|
||||
sourceRegions = partitionGeneratedRegions(sourceContent.toString('utf8'))
|
||||
zhRegions = partitionGeneratedRegions(zhContent.toString('utf8'))
|
||||
} catch (error) {
|
||||
errors.push(`${source} ↔ ${zh}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
state.set(source, 'out-of-sync')
|
||||
continue
|
||||
}
|
||||
if (sourceRegions.regions.length !== zhRegions.regions.length
|
||||
|| sourceRegions.regions.some((region, index) => region !== zhRegions.regions[index])) {
|
||||
errors.push(`${source} ↔ ${zh}: generated regions differ between the pair — regenerate (the generator writes both sides byte-identically)`)
|
||||
state.set(source, 'out-of-sync')
|
||||
}
|
||||
|
||||
const sourceTree = parseTranslationMarkdown(sourceContent.toString('utf8'))
|
||||
const zhTree = parseTranslationMarkdown(zhContent.toString('utf8'))
|
||||
if (!linksTo(zhTree, basename(source))) {
|
||||
errors.push(`${zh}: missing language switcher — no link to ${basename(source)}`)
|
||||
}
|
||||
if (!linksTo(sourceTree, basename(zh))) {
|
||||
if (requiresSourceLanguageSwitcher(source) && !linksTo(sourceTree, basename(zh))) {
|
||||
errors.push(`${source}: missing language switcher — no link back to ${basename(zh)}`)
|
||||
}
|
||||
for (const divergence of translationStructureDiff(
|
||||
@@ -247,7 +286,7 @@ if (listMode) {
|
||||
|
||||
if (errors.length === 0) {
|
||||
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} named ${indexMode ? 'staged ' : ''}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)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# Run the blocking Windows gates (workspace build, production site) with real
|
||||
# win-x64 Node.js under Wine — the same script the pull-request `windows` job
|
||||
# in ci.yml executes and the optional local gate `pnpm run check:windows-wine`
|
||||
# wraps. Owning rationale, fidelity limits, and measured timings:
|
||||
# .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
|
||||
# wraps. Owning rationale and fidelity limits:
|
||||
# .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
|
||||
#
|
||||
# The working tree is never mutated: tracked plus untracked-unignored files
|
||||
# are snapshotted into a scratch directory, the Wine-specific pnpm overrides
|
||||
@@ -74,19 +74,54 @@ trap cleanup EXIT
|
||||
mkdir -p "$cache_dir" "$scratch/logs"
|
||||
|
||||
# ---- provision Windows Node, boot Wine, snapshot + install concurrently ----
|
||||
curl_metadata_args=(
|
||||
--fail --silent --show-error --location
|
||||
--retry 3 --retry-all-errors --retry-delay 2
|
||||
--http1.1 --connect-timeout 10 --max-time 30 --retry-max-time 120
|
||||
)
|
||||
|
||||
download_node_archive() {
|
||||
local version="$1" output="$2" attempt status=0
|
||||
local archive="node-$version-win-x64.zip"
|
||||
local primary_url="https://nodejs.org/dist/$version/$archive"
|
||||
local mirror_url="https://npmmirror.com/mirrors/node/$version/$archive"
|
||||
|
||||
if curl --fail --silent --show-error --location --http1.1 \
|
||||
--connect-timeout 10 --max-time 300 --speed-limit 1024 --speed-time 30 \
|
||||
-o "$output" "$primary_url"; then
|
||||
return 0
|
||||
fi
|
||||
echo 'wine-windows-gates: nodejs.org archive transfer stalled; resuming from the checksum-untrusted transport mirror' >&2
|
||||
for attempt in 1 2 3; do
|
||||
if curl --fail --silent --show-error --location --http1.1 \
|
||||
--continue-at - --connect-timeout 10 --max-time 300 \
|
||||
--speed-limit 1024 --speed-time 30 \
|
||||
-o "$output" "$mirror_url"; then
|
||||
return 0
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
(( attempt < 3 )) || break
|
||||
echo "wine-windows-gates: mirror transfer failed (exit $status) on attempt $attempt; resuming partial download" >&2
|
||||
done
|
||||
return "$status"
|
||||
}
|
||||
|
||||
provision_node() {
|
||||
# Latest release of the primary line, checksum-verified against the same
|
||||
# dist directory. Offline runs fall back to the newest cached zip, loudly.
|
||||
# dist directory. Bound and retry every transfer so a stalled nodejs.org
|
||||
# response cannot consume the entire CI job. Offline runs fall back to the
|
||||
# newest cached zip, loudly.
|
||||
local version zip
|
||||
version="$(curl -fsSL --max-time 30 https://nodejs.org/dist/index.json 2> /dev/null \
|
||||
version="$(curl "${curl_metadata_args[@]}" https://nodejs.org/dist/index.json 2> /dev/null \
|
||||
| node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{const v=JSON.parse(d).find(r=>r.version.startsWith('v$node_major.'));if(v)console.log(v.version)})" \
|
||||
|| true)"
|
||||
if [ -n "$version" ]; then
|
||||
zip="$cache_dir/node-$version-win-x64.zip"
|
||||
if [ ! -f "$zip" ]; then
|
||||
curl -fsSL -o "$zip.tmp" "https://nodejs.org/dist/$version/node-$version-win-x64.zip"
|
||||
download_node_archive "$version" "$zip.tmp"
|
||||
local expected
|
||||
expected="$(curl -fsSL "https://nodejs.org/dist/$version/SHASUMS256.txt" \
|
||||
expected="$(curl "${curl_metadata_args[@]}" "https://nodejs.org/dist/$version/SHASUMS256.txt" \
|
||||
| awk -v a="node-$version-win-x64.zip" '$2 == a { print $1; exit }')"
|
||||
[ -n "$expected" ] || { echo "wine-windows-gates: no SHASUMS256 entry for node-$version-win-x64.zip" >&2; exit 1; }
|
||||
verify_sha256 "$expected" "$zip.tmp"
|
||||
@@ -204,12 +239,14 @@ cat "$scratch/logs/smoke.log"
|
||||
grep -q '^smoke: win32 x64' "$scratch/logs/smoke.log" || { echo 'wine-windows-gates: Windows Node smoke did not report win32 x64' >&2; exit 1; }
|
||||
|
||||
# ---- the two blocking surfaces, concurrently ------------------------------
|
||||
# The same shape run-gates gives ci-windows-blocking on native Windows:
|
||||
# `build` = tsc -b then tsdown, `production site` = the VitePress build. Both
|
||||
# statuses are captured so one failure cannot hide the other's result.
|
||||
# The build preserves the face order from package.json: compile and bundle the
|
||||
# Host face before compiling and bundling the Client face.
|
||||
# Both statuses are captured so one failure cannot hide the other's result.
|
||||
build_gate() {
|
||||
wine_node "$scratch/logs/tsc.log" "$tsc_js" -b --pretty false || return $?
|
||||
wine_node "$scratch/logs/tsdown.log" "$tsdown_js"
|
||||
wine_node "$scratch/logs/host-tsc.log" "$tsc_js" -b tsconfig.host.json --pretty false || return $?
|
||||
wine_node "$scratch/logs/host-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE host || return $?
|
||||
wine_node "$scratch/logs/client-tsc.log" "$tsc_js" -b tsconfig.client.json --pretty false || return $?
|
||||
wine_node "$scratch/logs/client-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE client
|
||||
}
|
||||
site_gate() {
|
||||
cd website
|
||||
@@ -235,7 +272,11 @@ report() {
|
||||
for log in "$@"; do tail -n 200 "$log" >&2 || true; done
|
||||
fi
|
||||
}
|
||||
report 'build (tsc -b, tsdown)' "$build_status" "$scratch/logs/tsc.log" "$scratch/logs/tsdown.log"
|
||||
report 'build (Host tsc/tsdown, Client tsc/tsdown)' "$build_status" \
|
||||
"$scratch/logs/host-tsc.log" \
|
||||
"$scratch/logs/host-tsdown.log" \
|
||||
"$scratch/logs/client-tsc.log" \
|
||||
"$scratch/logs/client-tsdown.log"
|
||||
report 'production site (vitepress build)' "$site_status" "$scratch/logs/site.log"
|
||||
if (( build_status != 0 )); then exit "$build_status"; fi
|
||||
exit "$site_status"
|
||||
|
||||
Reference in New Issue
Block a user