Merge master into unified subagent activations

Integrate master 78ae4fc2d4d8ecaebb4b674028f781a358b92cc9 after the four
independent review fixes. Keep this base update separate from those fixes
and from subsequent review changes so the integration is independently
reviewable and the observed-master checkpoint remains preserved.

Retain activation-owned execution, caller-independent local discovery,
and unconditional parent final-answer delivery. Preserve master's preset
scoped scheduling tools and all delegated-child schedule deny rules. Move
the new scheduling regression from the removed driver package into the
surviving subagent package and run it through a real Spawn activation.

Preserve master's removal of runtime invariant plugins, dependencies,
exports, and test mounts. Keep context disposal before fixture directory
cleanup. Combine Python workflow ordering with activation/catalog events
without inventing unsupported source-plane import paths.

Keep all frozen Agent Note archives byte-for-byte identical to master;
do not accept Git's automatic edits to renamed archived notes. Retain
current lifecycle and delivery facts in active documentation, repair its
links, and regenerate catalogs/persistence schemas from merged source.
All 62 master persistence roots remain; only subagent/catalog gains the
PR's external membership extension. Keep master's third-party lock graph
and update only workspace dependency changes.

Validation before this checkpoint: 683 focused tests passed, seven
Windows-only cases skipped; 2,772 archived artifacts verified. Generated
catalog and active-pair checks passed. Fresh artifact build and remaining
build-dependent documentation checks continue before publication.
This commit is contained in:
Tianyi Cui committed 2026-10-05 23:49:47 +08:00
commit 9dcc8fa985
4943 files changed
+102212 -113475

No files matched your search

+3 -1
View File
@@ -417,12 +417,14 @@ async function runNpm(
* @param index - Package metadata exposed through the local registry.
* @param dependencies - Root dependencies whose install layout npm computes.
* @param timeoutMs - Hard wall-clock limit for the npm child process.
* @param timeoutSubject - What the limit guards, named in the timeout diagnostic.
* @returns The package lock plus timing and registry-request observations.
*/
export async function resolveNpmPackageLock(
index: RegistryIndex,
dependencies: Readonly<Record<string, string>>,
timeoutMs: number,
timeoutSubject = 'npm resolution',
): Promise<NpmPackageLockResolution> {
let registryRequests = 0
let archiveRequests = 0
@@ -471,7 +473,7 @@ export async function resolveNpmPackageLock(
dependencies,
}, null, 2)}\n`)
const result = await runNpm(consumer, registry, timeoutMs)
if (result.timedOut) throw new Error(`npm resolution exceeded ${String(timeoutMs)} ms`)
if (result.timedOut) throw new Error(`${timeoutSubject} exceeded ${String(timeoutMs)} ms`)
return {
durationMs: result.durationMs,
registryRequests,
@@ -23,6 +23,7 @@ describe('Python runtime executable assets', () => {
expect(result.status).toBe(0)
expect(result.stdout).toContain('node_modules/@deepseek-ai/dsh-web-frontend/dist/**/*')
expect(result.stdout).toContain('node_modules/@deepseek-ai/dsh-skill-badge/assets/**/*')
expect(result.stdout).toContain('node_modules/@deepseek-ai/dsh-sandbox-windows-acl/assets/**/*')
expect(result.stdout).not.toContain('node_modules/**/*.py')
})
})
+73
View File
@@ -23,6 +23,78 @@ function run(env: NodeJS.ProcessEnv, ...args: string[]) {
}
describe('Python runtime executable builder CLI', () => {
it.each([
{ flags: [], command: 'run build' },
{ flags: ['--artifacts-only'], command: 'run build --artifacts-only' },
])('validates the closure before $command and deploys only after building', ({ flags, command }) => {
const result = run(
{ npm_execpath: 'C:\\tools\\pnpm.cjs' },
'--dry-run',
'--targets=node24-macos-arm64',
...flags,
)
expect(result.error).toBeUndefined()
expect(result.signal).toBeNull()
expect(result.status, result.stderr).toBe(0)
const commands = result.stdout.split('\n').filter(line => line.includes('C:\\tools\\pnpm.cjs '))
expect(commands[0]).toMatch(/ run verify-runtime-closure$/)
expect(commands[1]?.endsWith(' ' + command)).toBe(true)
expect(commands[2]).toContain(' --filter dsh-python-runtime-closure deploy ')
})
it('rejects simultaneous build modes before invoking pnpm', () => {
const setup = mkdtempSync(join(tmpdir(), 'dsh-python-build-mode-'))
temporaryDirectories.push(setup)
const entrypoint = join(setup, 'pnpm.cjs')
const commands = join(setup, 'commands.jsonl')
writeFileSync(entrypoint, [
"require('node:fs').appendFileSync(process.env.DSH_PYTHON_BUILD_COMMANDS, JSON.stringify(process.argv.slice(2)) + '\\n')",
'process.exit(31)',
'',
].join('\n'))
const result = run(
{ npm_execpath: entrypoint, DSH_PYTHON_BUILD_COMMANDS: commands },
'--skip-build',
'--artifacts-only',
'--targets=node24-macos-arm64',
)
expect(result.error).toBeUndefined()
expect(result.signal).toBeNull()
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('--skip-build and --artifacts-only cannot be combined')
expect(result.stdout).toBe('')
expect(existsSync(commands)).toBe(false)
})
it('stops before artifact building or deployment when the closure check fails', () => {
const setup = mkdtempSync(join(tmpdir(), 'dsh-python-closure-'))
temporaryDirectories.push(setup)
const entrypoint = join(setup, 'pnpm.cjs')
const commands = join(setup, 'commands.jsonl')
writeFileSync(entrypoint, [
"require('node:fs').appendFileSync(process.env.DSH_PYTHON_BUILD_COMMANDS, JSON.stringify(process.argv.slice(2)) + '\\n')",
"if (process.argv.slice(2).join(' ') === 'run verify-runtime-closure') process.exit(31)",
'process.exit(32)',
'',
].join('\n'))
const result = run(
{ npm_execpath: entrypoint, DSH_PYTHON_BUILD_COMMANDS: commands },
'--artifacts-only',
'--targets=node24-macos-arm64',
)
expect(result.error).toBeUndefined()
expect(result.signal).toBeNull()
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('runtime dependency closure failed (exit code 31)')
expect(readFileSync(commands, 'utf8').trim().split('\n'))
.toEqual([JSON.stringify(['run', 'verify-runtime-closure'])])
})
it('keeps the single-file dispatcher on the Python packaging surface', () => {
const bootstrapPath = resolve(root, 'python/sdk-runtime/runtime-bootstrap.mjs')
const bootstrap = readFileSync(bootstrapPath, 'utf8')
@@ -59,6 +131,7 @@ describe('Python runtime executable builder CLI', () => {
expect(result.status).toBe(0)
expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs run verify-runtime-closure`)
expect(result.stdout).not.toMatch(/\[dry-run\] .* run build(?: --artifacts-only)?$/m)
expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs --filter dsh-python-runtime-closure deploy`)
const deploy = result.stdout.split('\n').find(line => line.includes(' --filter dsh-python-runtime-closure deploy'))
expect(deploy).toContain('--prod --config.allow-unused-patches=true')
+13 -3
View File
@@ -62,6 +62,8 @@ const ASSET_GLOBS = [
'node_modules/@deepseek-ai/dsh-web-frontend/dist/**/*',
// skill-badge resolves both Markdown and image resources through import.meta.url.
'node_modules/@deepseek-ai/dsh-skill-badge/assets/**/*',
// The diagnosis provider extracts its PowerShell script for an external interpreter.
'node_modules/@deepseek-ai/dsh-sandbox-windows-acl/assets/**/*',
]
const PLATFORMS = ['linux', 'macos', 'win'] as const
@@ -154,8 +156,10 @@ class BuildCli {
private constructor(
/** Build targets; defaults to the host platform only. */
readonly targets: readonly Target[],
/** Skip step 1 (`pnpm run build`); lib/ artifacts must already exist. */
/** Skip the package build; lib/ artifacts must already exist. */
readonly skipBuild: boolean,
/** Emit package and Web artifacts without repository test and script typechecks. */
readonly artifactsOnly: boolean,
/** Print every command and config patch instead of executing. */
readonly dryRun: boolean,
) {}
@@ -179,6 +183,9 @@ class BuildCli {
console.log(BuildCli.usage())
process.exit(0)
}
if (values['skip-build'] && values['artifacts-only']) {
throw new Error('build-exe-for-python-sdk: --skip-build and --artifacts-only cannot be combined.')
}
const targets = values.targets === undefined
? [Target.host()]
: values.targets.split(',').map(part => part.trim()).filter(part => part !== '').map(spec => Target.parse(spec))
@@ -191,7 +198,7 @@ class BuildCli {
}
seen.add(key)
}
return new BuildCli(targets, values['skip-build'], values['dry-run'])
return new BuildCli(targets, values['skip-build'], values['artifacts-only'], values['dry-run'])
}
private static parseRaw(argv: string[]) {
@@ -200,6 +207,7 @@ class BuildCli {
options: {
'targets': { type: 'string' },
'skip-build': { type: 'boolean', default: false },
'artifacts-only': { type: 'boolean', default: false },
'dry-run': { type: 'boolean', default: false },
'help': { type: 'boolean', default: false },
},
@@ -213,6 +221,7 @@ class BuildCli {
' --targets=<t1,t2,...> pkg targets, e.g. node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-macos-x64,node24-win-x64.',
' Default: the host platform only (on node24).',
' --skip-build skip `pnpm run build` (lib/ artifacts must already exist).',
' --artifacts-only omit repository test and script typechecks from the artifact build.',
' --dry-run print every command and config patch without executing.',
' --help print this help.',
'',
@@ -279,7 +288,8 @@ class SingleExeBuild {
console.log('build-exe-for-python-sdk: skipping pnpm run build (--skip-build)')
return
}
await this.runPnpm('build', ['run', 'build'])
const args = this.cli.artifactsOnly ? ['run', 'build', '--artifacts-only'] : ['run', 'build']
await this.runPnpm('build', args)
}
/** Clear and deploy the runtime closure into the node carrier. */
+28
View File
@@ -0,0 +1,28 @@
import { spawnSync } from 'node:child_process'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const root = resolve(import.meta.dirname, '..')
describe('repository build CLI', () => {
// The sentinel makes parseArgs fail fast if the check ever stops running first.
it('rejects a Node process without TypeScript type stripping before any build step', () => {
const result = spawnSync(
process.execPath,
['--no-experimental-strip-types', '--import', 'tsx/esm', resolve(root, 'scripts/build.ts'), '--spec-sentinel'],
{ cwd: root, encoding: 'utf8' },
)
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('build: Node.js TypeScript type stripping is unavailable')
})
it('rejects a value for the artifacts-only flag before any build step', () => {
const result = spawnSync(
process.execPath,
['--import', 'tsx/esm', resolve(root, 'scripts/build.ts'), '--artifacts-only=yes'],
{ cwd: root, encoding: 'utf8' },
)
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('does not take an argument')
})
})
+24 -8
View File
@@ -1,4 +1,4 @@
/** Run the complete repository build and bind its client artifacts to their public environment. */
/** Build repository artifacts and bind client outputs to their public environment. */
import { spawnSync } from 'node:child_process'
import { rmSync } from 'node:fs'
@@ -14,9 +14,9 @@ import {
} from './client-build-environment.ts'
import { pnpmInvocation } from './pnpm-invocation.ts'
/** Run one package script through the package manager that invoked this build. */
function runScript(script: string, environment: NodeJS.ProcessEnv): void {
const invocation = pnpmInvocation(['run', script], environment)
/** Run one package-manager command with the selected public build environment. */
function runCommand(args: readonly string[], environment: NodeJS.ProcessEnv, label: string): void {
const invocation = pnpmInvocation(args, environment)
const result = spawnSync(invocation.command, invocation.args, {
cwd: resolve(import.meta.dirname, '..'),
env: environment,
@@ -24,14 +24,26 @@ function runScript(script: string, environment: NodeJS.ProcessEnv): void {
})
if (result.error !== undefined) throw result.error
if (result.status !== 0) {
throw new Error(`build: ${script} exited with ${String(result.status ?? result.signal)}`)
throw new Error(`build: ${label} exited with ${String(result.status ?? result.signal)}`)
}
}
/** Run the full build selected by `--profile` or `DSH_BUILD_CLIENT_PROFILE`. */
/** Run one package script through the package manager that invoked this build. */
function runScript(script: string, environment: NodeJS.ProcessEnv): void {
runCommand(['run', script], environment, script)
}
/** Build client artifacts and optionally omit the repository test and script typechecks. */
function main(): void {
// tsdown.config.ts loads only through Node type stripping (`--config-loader native`); this names the cause before tsdown fails.
if (!process.features.typescript) {
throw new Error('build: Node.js TypeScript type stripping is unavailable in this Node.js process; remove --no-experimental-strip-types from NODE_OPTIONS or use a Node.js build with TypeScript support')
}
const { values } = parseArgs({
options: { profile: { type: 'string' } },
options: {
profile: { type: 'string' },
'artifacts-only': { type: 'boolean' },
},
allowPositionals: false,
})
const root = resolve(import.meta.dirname, '..')
@@ -42,7 +54,11 @@ function main(): void {
rmSync(resolve(root, CLIENT_BUILD_RECORD_PATH), { force: true })
runScript('build:native-system', buildEnvironment)
runScript('build:lib', buildEnvironment)
if (values['artifacts-only']) {
runCommand(['exec', 'tsx', 'scripts/compile-referenced-projects.ts', 'libraries'], buildEnvironment, 'artifact libraries')
} else {
runScript('build:lib', buildEnvironment)
}
runScript('build:web', buildEnvironment)
const record = writeClientBuildRecord(root, clientEnvironment)
console.log(
+25 -3
View File
@@ -255,9 +255,21 @@ describe('dsh family version coherence', () => {
})
describe('package payload constraints', () => {
it.each(['./art/icon.svg', 'art/icon.svg'])('includes declared icon %s in the canonical payload', (icon) => {
expect(expectedDshPackageFiles({ icon, exports: { './locale/*.json': './locale/*.json' } })).toEqual([
'art/icon.svg', 'locale/*.json', 'lib/index.js', 'lib/types/**/*.d.ts',
it.each([
['./art/icon.svg', ['art/icon.svg']],
[{ import: './art/icon.svg', default: './art/fallback.svg' }, ['art/icon.svg', 'art/fallback.svg']],
[['./art/icon.svg', './art/icon.svg'], ['art/icon.svg']],
] as const)('includes exported icon targets in the canonical payload: %j', (icon, expected) => {
expect(expectedDshPackageFiles({ exports: { './icon': icon } })).toEqual([...expected, 'lib/index.js', 'lib/types/**/*.d.ts'])
})
it.each(['icon.svg', './icon.svg'])('includes and deduplicates manifest icon %s', (icon) => {
expect(expectedDshPackageFiles({ icon, exports: { './icon': './icon.svg' } })).toEqual(['icon.svg', 'lib/index.js', 'lib/types/**/*.d.ts'])
})
it('includes manifest, root, and subpath icon targets', () => {
expect(expectedDshPackageFiles({ icon: './legacy.svg', exports: { './icon': './fallback.svg', './search/icon': './search.svg' } })).toEqual([
'legacy.svg', 'fallback.svg', 'search.svg', 'lib/index.js', 'lib/types/**/*.d.ts',
])
})
@@ -376,6 +388,16 @@ it('requires the local speech worker and locked runtime in the published payload
}
})
it('requires the Inspector Worker, Client chunks, and mirrored DevTools resources in the published payload', () => {
const dir = 'packages/experimental/inspector'
const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest']
expect(checkWorkspaceManifest({ dir, manifest })).toEqual([])
for (const omitted of ['lib/client.*.js', 'lib/worker.js', 'lib/devtools/**']) {
expect(checkWorkspaceManifest({ dir, manifest: { ...manifest, files: manifest.files!.filter(file => file !== omitted) } }))
.toEqual([expect.stringContaining('package.json files must be')])
}
})
it('requires the standalone shortcut protocol and rejects unrelated runtime files', () => {
const dir = 'packages/client/shortcuts'
const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest']
+33 -39
View File
@@ -63,7 +63,7 @@ const localArtifactDirs = new Set(['node_modules'])
const appPackageFiles: Readonly<Record<string, readonly string[]>> = {
'@deepseek-ai/dsh': ['lib/*.js', 'lib/types/*.d.ts'],
'@deepseek-ai/dsh-desktop-host': [
'lib/index.js',
'lib/index.js', 'lib/cli.js',
],
// Sourcemaps stay out by payload policy; the worker-preview surface
// (dist/preview.html and dist/preview/) backs opt-in experimental
@@ -80,16 +80,7 @@ export interface PackageManifest {
main?: string
types?: string
bin?: string | Record<string, string>
exports?: Record<
string,
| string
| {
types?: string
default?: string
}
| null
| undefined
>
exports?: Record<string, ExportTarget | undefined>
files?: string[]
icon?: string
publishConfig?: { access?: string }
@@ -103,6 +94,9 @@ export interface PackageManifest {
}
}
/** Node package export target: a path, a fallback list, a conditional map, or an exclusion. */
export type ExportTarget = string | readonly ExportTarget[] | { readonly [condition: string]: ExportTarget | undefined } | null
/** One workspace manifest and its repo-relative path. */
export interface WorkspaceManifest {
dir: string
@@ -194,8 +188,8 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
'@deepseek-ai/dsh-experimental-speech-to-text-sensevoice': ['runtime/assets.json'],
// The isolated Node bootstrap is a separately launched bundle.
'@deepseek-ai/dsh-ptc-runtime-node': ['lib/process.js'],
// The Host entry starts its sibling Worker by URL rather than a package export.
'@deepseek-ai/dsh-experimental-inspector': ['lib/worker.js'],
// The Inspector owns a Worker and a mirrored frontend outside package export paths.
'@deepseek-ai/dsh-experimental-inspector': ['lib/client.*.js', 'lib/worker.js', 'lib/devtools/**'],
// Creator's composition guidance travels with the declaration package.
'@deepseek-ai/dsh-agent-preset': ['skills'],
// The Web Host mounts the default-off settings owner independently of each
@@ -207,7 +201,7 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
// The argv-prefix runner entry ships beside the lib as its own bundle;
// sandbox-local resolves it through the package's ./runner export. tsdown
// also shares its generated FFI code through a hashed runtime chunk.
'@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js', 'lib/types-*.js'],
'@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js', 'lib/types-*.js', 'assets'],
'@deepseek-ai/dsh-skill-badge': ['assets'],
'@deepseek-ai/dsh-skill-office': ['assets'],
'@deepseek-ai/dsh-subprocess': ['lib/control.js'],
@@ -229,6 +223,8 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
// through a hashed chunk. The committed bin.js is the link target pnpm can
// resolve at install time, before the build produces lib/bin.js.
'@deepseek-ai/dsh-experimental-webworker-packer': ['bin.js', 'lib/repository-*.js'],
// Startup and runtime share the advertised URL parser.
'@deepseek-ai/dsh-web-app': ['lib/public-url-*.js'],
// The headless entry and its startup row share the JSON projection code
// through a hashed tsdown chunk; both import it by relative path.
'@deepseek-ai/dsh-headless': ['lib/json-stream-*.js'],
@@ -239,7 +235,7 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl
}
/**
* Compute canonical publication patterns, including the declared icon and exported locale JSON resources.
* Compute canonical publication patterns, including declared and exported icon paths and exported locale JSON resources.
* @param manifest - workspace package manifest.
* @returns the icon and deduplicated locale targets followed by runtime and declaration payloads.
*/
@@ -256,13 +252,17 @@ export function expectedDshPackageFiles(manifest: PackageManifest): readonly str
...bundleFiles,
...(manifest.name ? packageFileExtras[manifest.name] ?? [] : []),
]
return [
const targets = (value: ExportTarget | undefined): string[] => typeof value === 'string' ? [value]
: typeof value === 'object' && value !== null ? Object.values(value).flatMap(targets) : []
const icons = [
...typeof manifest.icon === 'string' ? [manifest.icon.replace(/^\.\//u, '')] : [],
...Object.entries(manifest.exports ?? {}).filter(([key]) => /^\.\/(?:.+\/)?icon$/u.test(key))
.flatMap(([, target]) => targets(target)).filter(icon => icon.startsWith('./')).map(icon => icon.slice(2)),
]
return [
...new Set(icons),
...[...localeFiles].sort(),
'lib/index.js',
// Packages with an invariant export publish its runtime as a separate
// bundle; the package-invariant gate validates the source/export pairing.
...manifest.exports?.['./invariant'] ? ['lib/invariant.js'] : [],
...manifest.bin ? ['lib/bin.js'] : [],
// Worker-thread packages ship a CJS worker entry; the browser worker
// bundle is an ES module a page loads with `new Worker(type: 'module')`.
@@ -300,6 +300,15 @@ export function expectedDshPackageFiles(manifest: PackageManifest): readonly str
]
}
/** Fields of a conditional export; scalar and list targets have no named conditions. */
function exportFields(value: ExportTarget | undefined): { readonly [condition: string]: ExportTarget | undefined } | undefined {
return typeof value !== 'object' || value === null || isExportList(value) ? undefined : value
}
function isExportList(value: ExportTarget): value is readonly ExportTarget[] {
return Array.isArray(value)
}
/** Whether one conditional export exactly names the generated runtime and declaration pair. */
function hasExportPair(
manifest: PackageManifest,
@@ -307,19 +316,16 @@ function hasExportPair(
types: string,
runtime: string,
): boolean {
const entry = manifest.exports?.[subpath]
return typeof entry === 'object'
&& entry !== null
&& entry.types === types
&& entry.default === runtime
const entry = exportFields(manifest.exports?.[subpath])
return 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]
if (typeof entry === 'string') return entry
if (typeof entry === 'object' && entry !== null) return entry.default
return undefined
const target = exportFields(entry)?.default
return typeof target === 'string' ? target : undefined
}
/** Whether any export's runtime default points into the tsc-emitted lib/types tree. */
@@ -479,25 +485,13 @@ export function checkWorkspaceManifest({ dir, manifest }: WorkspaceManifest): st
if (manifest.types !== 'lib/types/index.d.ts') {
errors.push(`${label}: package.json must set "types": "lib/types/index.d.ts"`)
}
const rootExport = manifest.exports?.['.']
const rootEntry = typeof rootExport === 'object' && rootExport !== null ? rootExport : undefined
const rootEntry = exportFields(manifest.exports?.['.'])
if (rootEntry?.types !== './lib/types/index.d.ts') {
errors.push(`${label}: package.json exports["."].types must be "./lib/types/index.d.ts"`)
}
if (rootEntry?.default !== './lib/index.js') {
errors.push(`${label}: package.json exports["."].default must be "./lib/index.js"`)
}
const invariantRaw = manifest.exports?.['./invariant']
const invariantExport = typeof invariantRaw === 'object' && invariantRaw !== null ? invariantRaw : undefined
if (invariantExport?.types !== undefined && invariantExport.types !== './lib/types/invariant.d.ts') {
errors.push(`${label}: package.json exports["./invariant"].types must be "./lib/types/invariant.d.ts"`)
}
if (invariantExport?.default !== undefined && invariantExport.default !== './lib/invariant.js') {
errors.push(`${label}: package.json exports["./invariant"].default must be "./lib/invariant.js"`)
}
if (invariantExport && (invariantExport.types === undefined || invariantExport.default === undefined)) {
errors.push(`${label}: package.json exports["./invariant"] must declare both types and default targets`)
}
const expectedFiles = expectedDshPackageFiles(manifest)
if (!sameStringList(manifest.files, expectedFiles)) {
errors.push(`${label}: package.json files must be ${JSON.stringify(expectedFiles)}`)
+215 -3
View File
@@ -14,6 +14,131 @@ const runnerPrivatePnpmDestination = /^\$\{\{ runner\.temp \}\}\/setup-pnpm-\$\{
const nativeWindowsPnpmDestination = '${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}'
describe('CI workflow', () => {
it('keeps at most ten worker jobs active while runtime targets follow the SDK suite', () => {
const ci = loadWorkflow('.github/workflows/ci.yml')
const compatibility = workflowJob(ci, 'node-compat')
const runtime = workflowJob(ci, 'python-runtime')
if (!isRecord(ci.jobs) || !isRecord(compatibility.strategy) || !isRecord(runtime.with)) {
throw new TypeError('CI must define jobs, compatibility scheduling, and runtime inputs')
}
const compatibilityLimit = compatibility.strategy['max-parallel']
const targets = runtime.with.targets
if (typeof compatibilityLimit !== 'number' || typeof targets !== 'string') {
throw new TypeError('CI requires a numeric compatibility limit and explicit runtime targets')
}
expect(Number.isInteger(compatibilityLimit)).toBe(true)
expect(compatibilityLimit).toBeGreaterThan(0)
expect(compatibility.strategy).toMatchObject({
'fail-fast': false,
matrix: { include: [{ node: '22.19' }, { node: '24.9' }, { node: 26 }] },
})
expect(workflowJob(ci, 'all-checks-passed').needs).toContain('node-compat')
const ordinaryJobs = Object.entries(ci.jobs)
.filter(([name]) => !['node-compat', 'python-runtime', 'all-checks-passed'].includes(name))
for (const [name, job] of ordinaryJobs) {
if (!isRecord(job)) throw new TypeError(`${name} must define a job`)
expect(job).not.toHaveProperty('strategy')
expect(job).not.toHaveProperty('uses')
}
expect(ordinaryJobs.map(([name]) => name)).toContain('python-sdk')
expect(runtime.needs).toBe('python-sdk')
expect(runtime.strategy).toBeUndefined()
expect(runtime.with).toMatchObject({ targets: 'node24-linux-x64,node24-win-x64' })
expect(runtime.uses).toBe('./.github/workflows/build-exe-for-python-sdk.yml')
const builder = loadWorkflow('.github/workflows/build-exe-for-python-sdk.yml')
if (!isRecord(builder.jobs)) throw new TypeError('Python runtime builder must define jobs')
expect(Object.keys(builder.jobs).sort()).toEqual(['build', 'plan', 'sdk-wheel'])
const plan = workflowJob(builder, 'plan')
const wheel = workflowJob(builder, 'sdk-wheel')
const build = workflowJob(builder, 'build')
expect(plan.needs).toBeUndefined()
expect(plan.strategy).toBeUndefined()
expect(plan.uses).toBeUndefined()
expect(wheel.needs).toBe('plan')
expect(wheel.strategy).toBeUndefined()
expect(wheel.uses).toBeUndefined()
expect(build.needs).toEqual(['plan', 'sdk-wheel'])
expect(build.uses).toBeUndefined()
if (!isRecord(build.strategy)) throw new TypeError('Python runtime build must define its target matrix')
expect(build.strategy.matrix).toEqual({ include: '${{ fromJSON(needs.plan.outputs.matrix) }}' })
const beforeSdk = ordinaryJobs.length + compatibilityLimit
const afterSdk = ordinaryJobs.length - 1 + compatibilityLimit + Math.max(1, targets.split(',').length)
expect(beforeSdk).toBeLessThanOrEqual(10)
expect(afterSdk).toBeLessThanOrEqual(10)
})
it('retains aggregate diagnostics in required builds and protected publication', () => {
const ci = loadWorkflow('.github/workflows/ci.yml')
for (const [name, command] of [
['node-24-consumers', 'pnpm run check:ci:consumers'],
['windows-build', 'pnpm run check:ci:windows-blocking'],
] as const) {
const job = workflowJob(ci, name)
expect(job.steps).toContainEqual(expect.objectContaining({ run: command }))
}
for (const [file, command] of [
['release-publish.yml', 'pnpm run build:official'],
['release-vendor-publish.yml', 'pnpm run build:lib:host'],
] as const) {
expect(workflowJob(loadWorkflow('.github/workflows/' + file), 'pack').steps)
.toContainEqual(expect.objectContaining({ name: 'Build', run: command }))
}
const scripts = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { scripts: Record<string, string> }
expect(scripts.scripts['build:lib:host']).toContain('tsc -b tsconfig.host.json')
expect(scripts.scripts['build:lib:client']).toContain('tsc -b tsconfig.client.json')
expect(scripts.scripts.typecheck).toBe('npm run build:lib:host && npm run typecheck:contracts-ready')
})
it('selects artifact builds only for PR release and API rehearsals', () => {
const official = "${{ github.event_name == 'pull_request' && 'pnpm run build --artifacts-only --profile official' || 'pnpm run build:official' }}"
const vendor = "${{ github.event_name == 'pull_request' && 'pnpm exec tsx scripts/compile-referenced-projects.ts host-libraries' || 'pnpm run build:lib:host' }}"
for (const [file, jobName, expected] of [
['release.yml', 'pack', official],
['release-vendor.yml', 'pack', vendor],
['e2e.yml', 'e2e', official],
] as const) {
const job = workflowJob(loadWorkflow('.github/workflows/' + file), jobName)
if (!Array.isArray(job.steps)) throw new TypeError(`${jobName} must define steps`)
const step = job.steps.filter(isRecord).find(candidate => typeof candidate.run === 'string'
&& (candidate.run.includes('--artifacts-only') || candidate.run.includes('host-libraries')))
expect(step?.run).toBe(expected)
for (const event of ['pull_request', 'push', 'workflow_dispatch']) {
const expression = expected.slice('${{ '.length, -' }}'.length)
const selected: unknown = runInNewContext(expression, { github: { event_name: event } }, { timeout: 1000 })
expect(selected).toBe(event === 'pull_request'
? file === 'release-vendor.yml'
? 'pnpm exec tsx scripts/compile-referenced-projects.ts host-libraries'
: 'pnpm run build --artifacts-only --profile official'
: file === 'release-vendor.yml' ? 'pnpm run build:lib:host' : 'pnpm run build:official')
}
}
})
it('selects PR artifact compilation inside the Python executable builder', () => {
const job = workflowJob(loadWorkflow('.github/workflows/build-exe-for-python-sdk.yml'), 'build')
if (!Array.isArray(job.steps)) throw new TypeError('Python runtime builder must define steps')
const steps = job.steps.filter(isRecord)
const execute = steps.find(step => step.name === 'Build single-exe')
expect(steps.some(step => step.run === 'pnpm run build --artifacts-only')).toBe(false)
expect(execute).toMatchObject({
env: { DSH_BUILD_CLIENT_PROFILE: 'official' },
})
if (typeof execute?.run !== 'string') throw new TypeError('Python executable builder must define a command')
const expression = [...execute.run.matchAll(/\$\{\{ ([^{}]+) \}\}/g)]
.map(match => match[1]).find(value => value?.startsWith('inputs.ci'))
expect(expression).toBe("inputs.ci && github.event_name == 'pull_request' && '--artifacts-only' || ''")
if (expression === undefined) throw new TypeError('Python executable builder must select its build mode')
for (const ci of [false, true]) {
for (const event of ['pull_request', 'push', 'workflow_dispatch']) {
const context = { inputs: { ci }, github: { event_name: event } }
const selected: unknown = runInNewContext(expression, context, { timeout: 1000 })
expect(selected).toBe(ci && event === 'pull_request' ? '--artifacts-only' : '')
}
}
})
it('prepares confinement before Node compatibility smokes', () => {
const job = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'node-compat')
if (!Array.isArray(job.steps)) throw new TypeError('Node compatibility job must define steps')
@@ -62,6 +187,34 @@ describe('CI workflow', () => {
expect(wine.steps).toContainEqual(expect.objectContaining({ name: 'Shut down wineserver', if: 'always()' }))
})
it.each([
['node-24-coverage', 'Linux', 'LINUX', 'pnpm run check:ci:coverage'],
['windows-coverage', 'Windows', 'WINDOWS', 'pnpm run check:ci:coverage'],
])('reuses %s coverage timings only within its platform and runner pool', (jobName, platform, variable, command) => {
const job = workflowJob(loadWorkflow('.github/workflows/ci.yml'), jobName)
if (!Array.isArray(job.steps)) throw new TypeError(`${jobName} must define steps`)
const steps = job.steps.filter(isRecord)
const checkout = steps.findIndex(step => step.uses === 'actions/checkout@v6')
const restore = steps.findIndex(step => step.name === 'Restore coverage duration history')
const coverage = steps.findIndex(step => step.run === command)
const save = steps.findIndex(step => step.name === 'Save coverage duration history')
const prefix = `coverage-times-${platform}-\${{ runner.environment }}-\${{ vars.DSH_CI_FAILOVER_${variable} || 'enterprise' }}-`
const key = prefix + '${{ github.run_id }}-${{ github.run_attempt }}'
expect(checkout).toBeGreaterThanOrEqual(0)
expect(restore).toBeGreaterThan(checkout)
expect(coverage).toBeGreaterThan(restore)
expect(save).toBeGreaterThan(coverage)
expect(steps[restore]).toMatchObject({
uses: 'actions/cache/restore@v4',
with: { path: '.coverage-times.json', key, 'restore-keys': `${prefix}\n` },
})
expect(steps[save]).toMatchObject({
uses: 'actions/cache/save@v4',
if: '${{ !cancelled() }}',
with: { path: '.coverage-times.json', key },
})
})
it('isolates every pnpm action setup destination per runner', () => {
const files = ['.github/workflows/ci.yml', '.github/workflows/ci-master.yml']
const setups: Array<{ jobName: string; step: unknown }> = []
@@ -407,6 +560,24 @@ describe('CI workflow', () => {
expect(observational?.env).toMatchObject({ DSH_GATE_FAIL_FAST: '' })
})
it('runs the darwin unit parity inventory at the coverage lanes\' test budget', () => {
const coverage = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'node-24-coverage')
if (!isRecord(coverage.env) || typeof coverage.env.DSH_COVERAGE_TEST_TIMEOUT_MS !== 'string') {
throw new TypeError('node-24-coverage must grant DSH_COVERAGE_TEST_TIMEOUT_MS')
}
const unitDarwin = workflowJob(loadWorkflow('.github/workflows/sandbox.yml'), 'unit-darwin')
if (!Array.isArray(unitDarwin.steps)) throw new TypeError('unit-darwin job must define steps')
const unit = unitDarwin.steps.filter(isRecord).find(step => step.name === 'Unit tests (darwin parity)')
// The whole unit inventory on a shared macos-latest runner pays the same
// scheduling delay the coverage lanes absorb; the ci-unit aggregate is the
// `pnpm run test` path that consumes the variable, and the value is the
// coverage lane's so the two budget classes cannot drift apart.
expect(unit).toMatchObject({
run: 'pnpm run check:ci:unit',
env: { DSH_COVERAGE_TEST_TIMEOUT_MS: coverage.env.DSH_COVERAGE_TEST_TIMEOUT_MS },
})
})
it('gates standalone keyless blacksmith jobs and benchmark tiers on the failover variables', () => {
const expectedFilenames = workflowJob(loadWorkflow('.github/workflows/expected-filenames.yml'), 'expected-filenames')
const sandbox = workflowJob(loadWorkflow('.github/workflows/sandbox.yml'), 'sandbox-e2e')
@@ -464,10 +635,10 @@ describe('CI workflow', () => {
})
})
it('bounds the complete benchmark job to fifteen minutes', () => {
it('bounds the complete benchmark job to twenty minutes', () => {
const benchmark = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'node-24-bench')
expect(benchmark['timeout-minutes']).toBe(15)
expect(benchmark['timeout-minutes']).toBe(20)
expect(benchmark.steps).toContainEqual({
name: 'Run performance benchmarks',
env: { DSH_GATE_VERBOSE: '1' },
@@ -566,7 +737,7 @@ describe('CI workflow', () => {
// Removing this injection would send every pnpm call in the lane (setup,
// store-path probe, install, and the gate) back to the root partition's
// /tmp; rationale in
// .agents/notes/implemented/process/2026-08-28-ci-node-compile-cache-data-disk.md.
// .github/workflows/ci.yml.
expect(redirectStepIndex, `${jobKey} must inject NODE_COMPILE_CACHE into GITHUB_ENV`).toBeGreaterThan(-1)
const pnpmSetupIndex = job.steps.findIndex((step): step is Record<string, unknown> & { uses: string } => (
isRecord(step) && typeof step.uses === 'string' && step.uses.includes('pnpm/action-setup')
@@ -613,6 +784,16 @@ describe('CI workflow', () => {
expect(config).not.toContain("pool: process.platform === 'win32' ? 'threads' : 'forks'")
expect(config.match(/pool: 'forks'/g)).toHaveLength(2)
})
it('applies the lane test budget inside every Vitest project', () => {
// Each inline project spreads coverageTestTimeoutOptions, the only route
// for DSH_COVERAGE_TEST_TIMEOUT_MS into projects; the behavior itself is
// pinned by scripts/lane-test-budget.spec.ts.
const config = readFileSync(resolve(root, 'vitest.config.ts'), 'utf8')
expect(config).toContain('const laneTestBudget = coverageTestTimeoutOptions(process.env[COVERAGE_TEST_TIMEOUT_ENV])')
expect(config.match(/^ {10}\.\.\.laneTestBudget,$/gm)).toHaveLength(2)
})
})
describe('Runtime and LLM e2e Blacksmith routing', () => {
@@ -1104,6 +1285,37 @@ describe('Issue lifecycle workflow', () => {
})
describe('npm release workflows', () => {
it('passes the optional vendor channel as a quoted argument while preserving default publication', () => {
const workflow = loadWorkflow('.github/workflows/release-vendor-publish.yml')
expect(workflow.on).toMatchObject({
workflow_dispatch: {
inputs: {
'dist-tag': {
required: false,
type: 'string',
default: '',
},
},
},
})
const publish = workflowJob(workflow, 'publish')
expect(publish.steps).toContainEqual({
name: 'Publish tarballs',
env: {
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}',
RELEASE_DIST_TAG: '${{ inputs.dist-tag }}',
},
run: [
'args=()',
'if [[ -n "$RELEASE_DIST_TAG" ]]; then',
' args+=(--dist-tag "$RELEASE_DIST_TAG")',
'fi',
'pnpm run release:publish --family vendor --from dist/npm-vendor "${args[@]}"',
'',
].join('\n'),
})
})
it('keeps publication dispatch-only and pack in the PR workflow', () => {
// pack stays in the PR/master release workflows so a PR proves the set packs.
for (const file of ['release.yml', 'release-vendor.yml']) {
@@ -234,7 +234,6 @@ describe('client build environment', () => {
const configs = clientBundle('@deepseek-ai/dsh-client-ui-sidebar', [
'lib/types/index.js',
'lib/types/invariant.js',
])({ env: { DSH_BUILD_FACE: 'client' } })
if (!Array.isArray(configs)) throw new TypeError('client bundle config must be an array')
const dynamic = configs.find(config => config.name === '@deepseek-ai/dsh-client-ui-sidebar/client')
+1 -1
View File
@@ -17,7 +17,7 @@ interface CssPlugin {
function cssPlugin(name: 'dsh-css-modules-inline' | 'dsh-css-global-inline' | 'dsh-css-text-inline'): CssPlugin {
const configs = clientBundle(
'@deepseek-ai/dsh-client-test',
['lib/types/index.js', 'lib/types/invariant.js'],
['lib/types/index.js'],
)({ env: { DSH_BUILD_FACE: 'client' } })
const client = configs.find(config => config.platform === 'browser')
if (client === undefined) throw new Error('client config missing')
+1 -3
View File
@@ -39,7 +39,7 @@ interface InputIsolationPlugin {
const REQUESTING_PACKAGE = '@deepseek-ai/dsh-client-ui-conversation'
function clientConfigs(id = REQUESTING_PACKAGE) {
return clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])(
return clientBundle(id, ['lib/types/index.js'])(
{ env: { DSH_BUILD_FACE: 'client' } },
).filter(config => config.platform === 'browser')
}
@@ -89,8 +89,6 @@ function clientSourceMapPath(packagePath: string): string {
}
function purityResolveId(id = REQUESTING_PACKAGE): 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 = clientConfigs(id)
const plugins = (configs[0] as { plugins: { name: string; resolveId?: unknown }[] }).plugins
const gate = plugins.find(p => p.name === 'dsh-client-bundle-purity')
+310
View File
@@ -0,0 +1,310 @@
import { spawnSync } from 'node:child_process'
import { globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { buildLibraryArtifacts, compileReferencedProjects } from './compile-referenced-projects.ts'
import { runCiBench } from './run-ci-bench.ts'
import { removeFixtureSafely } from './test-fixture-cleanup.ts'
const compiler = fileURLToPath(import.meta.resolve('typescript/bin/tsc'))
function fixture(): { root: string; leaf: string } {
const root = mkdtempSync(join(tmpdir(), 'dsh-referenced-projects-'))
onTestFinished(() => { removeFixtureSafely(root) })
const leaf = join(root, 'leaf package')
mkdirSync(join(leaf, 'src'), { recursive: true })
writeFileSync(join(root, 'package.json'), JSON.stringify({ type: 'module' }))
writeFileSync(join(leaf, 'src/index.ts'), 'export const value: number = 42\n')
const compilerOptions = {
target: 'ES2024',
module: 'NodeNext',
moduleResolution: 'NodeNext',
lib: ['ES2024'],
types: [],
composite: true,
strict: true,
}
writeFileSync(join(leaf, 'tsconfig.json'), JSON.stringify({
compilerOptions: {
...compilerOptions,
rootDir: 'src',
outDir: 'lib/types',
declaration: true,
declarationMap: true,
sourceMap: true,
},
include: ['src'],
}))
writeFileSync(join(root, 'aggregate.ts'), 'import { value } from "./leaf package/src/index.js"\nexport const aggregate: number = value\n')
for (const face of ['host', 'client']) {
writeFileSync(join(root, `tsconfig.${face}.json`), JSON.stringify({
compilerOptions: { ...compilerOptions, noEmit: true },
include: ['aggregate.ts'],
references: [{ path: './leaf package' }],
}))
}
return { root, leaf }
}
function artifacts(leaf: string): Record<string, string> {
const output = join(leaf, 'lib')
return Object.fromEntries(globSync('**/*', { cwd: output })
.filter(path => statSync(join(output, path)).isFile())
.sort()
.map(path => [path.replaceAll('\\', '/'), readFileSync(join(output, path)).toString('base64')]))
}
function recordedCommands(root: string, failHostBundle = false): () => { args: string[]; title: string }[] {
const log = join(root, 'commands.jsonl')
const entrypoint = join(root, 'pnpm', 'bin', 'pnpm.cjs')
mkdirSync(join(root, 'pnpm', 'bin'), { recursive: true })
writeFileSync(entrypoint, [
'const { appendFileSync } = require("node:fs")',
'const args = process.argv.slice(2)',
`appendFileSync(${JSON.stringify(log)}, JSON.stringify({ args, title: process.env.DSH_CLIENT_TITLE }) + "\\n")`,
...failHostBundle ? ['if (args.includes("host")) process.exit(7)'] : [],
].join('\n') + '\n')
vi.stubEnv('npm_execpath', entrypoint)
vi.stubEnv('DSH_CLIENT_TITLE', 'Inherited Build Title')
onTestFinished(() => { vi.unstubAllEnvs() })
return () => readFileSync(log, 'utf8').trim().split('\n')
.map(line => JSON.parse(line) as { args: string[]; title: string })
}
describe('internal artifact build order', () => {
it('resolves pnpm for a direct CLI without npm_execpath and preserves its environment', () => {
const { root } = fixture()
const marker = join(root, 'direct-cli-title.txt')
const environment: NodeJS.ProcessEnv = { ...process.env, DSH_CLIENT_TITLE: 'Direct CLI Title' }
delete environment.npm_execpath
const helper = pathToFileURL(resolve(import.meta.dirname, 'compile-referenced-projects.ts')).href
const code = `import { runPnpmCommand } from ${JSON.stringify(helper)}; runPnpmCommand(${JSON.stringify(root)}, ${JSON.stringify([
'exec', process.execPath, '-e',
`require('node:fs').writeFileSync(${JSON.stringify(marker)}, process.env.DSH_CLIENT_TITLE)`,
])})`
const result = spawnSync(process.execPath, [
'--import', import.meta.resolve('tsx/esm'),
'--input-type=module', '-e', code,
], { cwd: root, env: environment, encoding: 'utf8' })
expect(result.status, result.stdout + result.stderr).toBe(0)
expect(readFileSync(marker, 'utf8')).toBe('Direct CLI Title')
})
it.each([true, false])('preserves bundler order and the public environment with hostOnly=%s', (hostOnly) => {
const { root } = fixture()
const commands = recordedCommands(root)
buildLibraryArtifacts(root, hostOnly)
const expected = [
['exec', 'tsdown', '--config-loader', 'native', '--env.DSH_BUILD_FACE', 'host'],
['--filter', '@deepseek-ai/dsh-desktop', 'run', 'bundle'],
...hostOnly ? [] : [['exec', 'tsdown', '--config-loader', 'native', '--env.DSH_BUILD_FACE', 'client']],
]
expect(commands()).toEqual(expected.map(args => ({ args, title: 'Inherited Build Title' })))
})
it('stops after a failed Host bundler before Desktop and Client builds', () => {
const { root } = fixture()
const commands = recordedCommands(root, true)
expect(() => { buildLibraryArtifacts(root, false) }).toThrow('exited with 7')
expect(commands()).toHaveLength(1)
})
it('defers benchmark diagnostics while normal artifact builds reject the same source', () => {
const { root, leaf } = fixture()
const commands = recordedCommands(root)
writeFileSync(join(leaf, 'src/index.ts'), 'export const value: number = "invalid"\n')
runCiBench(root)
expect(commands()).toEqual([
['run', 'build:native-system'],
['exec', 'tsdown', '--config-loader', 'native', '--env.DSH_BUILD_FACE', 'host'],
['--filter', '@deepseek-ai/dsh-desktop', 'run', 'bundle'],
['exec', 'tsdown', '--config-loader', 'native', '--env.DSH_BUILD_FACE', 'client'],
['exec', 'tsdown', '--config-loader', 'native', '--config', 'benchmarks/tsdown.config.ts'],
['run', 'build:web'],
['run', 'test:bench:built'],
].map(args => ({ args, title: 'Inherited Build Title' })))
expect(() => { buildLibraryArtifacts(root, false) }).toThrow('host compiler exited with')
expect(commands()).toHaveLength(7)
})
})
describe('package project compilation', () => {
it('records Windows-style glob keys without changing artifact bytes', () => {
const { leaf } = fixture()
const output = join(leaf, 'lib')
mkdirSync(join(output, 'types'), { recursive: true })
const bytes = Buffer.from([0, 255, 13, 10])
writeFileSync(join(output, 'types\\index.js'), bytes)
expect(artifacts(leaf)).toEqual({ 'types/index.js': bytes.toString('base64') })
})
it.each(['host', 'client'] as const)('preserves every %s package artifact byte', (face) => {
const { root, leaf } = fixture()
const complete = spawnSync(process.execPath, [compiler, '-b', `tsconfig.${face}.json`], {
cwd: root,
encoding: 'utf8',
})
expect(complete.status, complete.stdout + complete.stderr).toBe(0)
const expected = artifacts(leaf)
expect(Object.keys(expected)).toEqual(expect.arrayContaining([
'types/index.js', 'types/index.js.map', 'types/index.d.ts', 'types/index.d.ts.map',
]))
rmSync(join(leaf, 'lib'), { recursive: true })
compileReferencedProjects(root, face)
expect(artifacts(leaf)).toEqual(expected)
})
it.each(['host', 'client'] as const)('preserves %s runtime output during benchmark emission', (face) => {
const { root, leaf } = fixture()
writeFileSync(join(leaf, 'src/support.ts'), [
'export interface Payload { label: string }',
'export const enum Offset { Baseline = 40 }',
'',
].join('\n'))
writeFileSync(join(leaf, 'src/index.ts'), [
'import { Offset, type Payload } from "./support.js"',
'export const value: number = Offset.Baseline + 2',
'export function describe(payload: Payload): string { return `${payload.label}: ${value}` }',
'',
].join('\n'))
compileReferencedProjects(root, face)
const runtimeArtifacts = () => Object.fromEntries(Object.entries(artifacts(leaf))
.filter(([path]) => /\.js(?:\.map)?$/.test(path)))
const expected = runtimeArtifacts()
expect(Object.keys(expected)).toEqual([
'types/index.js', 'types/index.js.map', 'types/support.js', 'types/support.js.map',
])
rmSync(join(leaf, 'lib'), { recursive: true })
compileReferencedProjects(root, face, 'benchmark-emit')
expect(runtimeArtifacts()).toEqual(expected)
const entrypoint = pathToFileURL(join(leaf, 'lib/types/index.js')).href
const result = spawnSync(process.execPath, [
'--input-type=module', '-e',
`import { describe } from ${JSON.stringify(entrypoint)}; console.log(describe({ label: 'answer' }))`,
], { cwd: root, encoding: 'utf8' })
expect(result.error).toBeUndefined()
expect(result.signal).toBeNull()
expect(result.status, result.stderr).toBe(0)
expect(result.stdout.trim()).toBe('answer: 42')
})
it.each(['host', 'client'] as const)('rechecks %s semantic errors after benchmark emission without forcing a rebuild', (face) => {
const { root, leaf } = fixture()
writeFileSync(join(leaf, 'src/index.ts'), 'export const value: number = "invalid"\n')
compileReferencedProjects(root, face, 'benchmark-emit')
expect(Object.keys(artifacts(leaf)).some(path => path.endsWith('.tsbuildinfo'))).toBe(true)
const complete = spawnSync(process.execPath, [compiler, '-b', `tsconfig.${face}.json`], {
cwd: root,
encoding: 'utf8',
})
expect(complete.error).toBeUndefined()
expect(complete.signal).toBeNull()
expect(complete.status).not.toBe(0)
expect(complete.stdout).toContain('src/index.ts(1,14): error TS2322')
})
it('rejects package syntax errors during benchmark emission', () => {
const { root, leaf } = fixture()
writeFileSync(join(leaf, 'src/index.ts'), 'export const value = ;\n')
expect(() => { compileReferencedProjects(root, 'host', 'benchmark-emit') })
.toThrow('host compiler exited with')
})
it('keeps aggregate-only errors in the complete compiler check', () => {
const { root } = fixture()
writeFileSync(join(root, 'aggregate.ts'), 'export const aggregate: number = "invalid"\n')
expect(() => { compileReferencedProjects(root, 'host') }).not.toThrow()
const complete = spawnSync(process.execPath, [compiler, '-b', 'tsconfig.host.json'], {
cwd: root,
encoding: 'utf8',
})
expect(complete.status).not.toBe(0)
expect(complete.stdout).toContain('aggregate.ts(1,14): error TS2322')
})
it('rejects package source errors', () => {
const { root, leaf } = fixture()
writeFileSync(join(leaf, 'src/index.ts'), 'export const value: number = "invalid"\n')
expect(() => { compileReferencedProjects(root, 'host') }).toThrow('host compiler exited with')
})
it.each([undefined, []])('rejects an aggregate with missing or empty references: %j', (references) => {
const { root } = fixture()
writeFileSync(join(root, 'tsconfig.host.json'), JSON.stringify({ files: [], references }))
expect(() => { compileReferencedProjects(root, 'host') }).toThrow('requires non-empty project references')
})
it('rejects a missing referenced project', () => {
const { root } = fixture()
writeFileSync(join(root, 'tsconfig.host.json'), JSON.stringify({
compilerOptions: { noEmit: true },
files: [],
references: [{ path: './absent' }],
}))
expect(() => { compileReferencedProjects(root, 'host') }).toThrow('host compiler exited with')
})
it.each([undefined, false])('rejects an aggregate capable of emitting files: noEmit=%s', (noEmit) => {
const { root } = fixture()
writeFileSync(join(root, 'tsconfig.host.json'), JSON.stringify({
compilerOptions: { noEmit },
include: ['aggregate.ts'],
references: [{ path: './leaf package' }],
}))
expect(() => { compileReferencedProjects(root, 'host') }).toThrow(
`${join(root, 'tsconfig.host.json')} requires noEmit: true`,
)
})
it('rejects a missing aggregate config', () => {
const { root } = fixture()
rmSync(join(root, 'tsconfig.host.json'))
expect(() => { compileReferencedProjects(root, 'host') }).toThrow('Cannot read file')
})
it('rejects invalid compiler options', () => {
const { root } = fixture()
writeFileSync(join(root, 'tsconfig.host.json'), JSON.stringify({
compilerOptions: { target: 'invalid' },
files: [],
references: [{ path: './leaf package' }],
}))
expect(() => { compileReferencedProjects(root, 'host') }).toThrow("Argument for '--target' option must be")
})
it('rejects an unsupported build mode before building', () => {
const repository = resolve(import.meta.dirname, '..')
const result = spawnSync(process.execPath, [
'--import', 'tsx/esm',
resolve(repository, 'scripts/compile-referenced-projects.ts'),
'unsupported',
], { cwd: repository, encoding: 'utf8' })
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('expected exactly one mode: host, client, host-libraries, or libraries')
})
})
+111
View File
@@ -0,0 +1,111 @@
/** Compile and bundle package projects without repeating aggregate test and script typechecks. */
import { spawnSync } from 'node:child_process'
import { relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseArgs } from 'node:util'
import ts from 'typescript'
import { pnpmCommand } from './release/process.ts'
type CompilationMode = 'checked' | 'benchmark-emit'
/**
* Run one package-manager command serially with the inherited build environment.
* @param root - Repository root supplying the command's working directory.
* @param args - Package-manager arguments, without shell quoting.
* @throws If the command cannot start or exits unsuccessfully.
*/
export function runPnpmCommand(root: string, args: readonly string[]): void {
const [command, ...prefix] = pnpmCommand()
const result = spawnSync(command, [...prefix, ...args], { cwd: root, stdio: 'inherit' })
if (result.error !== undefined) throw result.error
if (result.status !== 0) {
throw new Error(`compile-referenced-projects: pnpm ${args.join(' ')} exited with ${String(result.status ?? result.signal)}`)
}
}
/**
* Compile one aggregate's project references with the serial TypeScript builder.
* @param root - Repository root containing the aggregate tsconfig.
* @param face - Independent compiler face whose package projects are emitted.
* @param mode - Check package types by default; benchmark emission defers diagnostics to required full builds.
* @throws If the aggregate emits files, its configuration is invalid, or package compilation fails.
*/
export function compileReferencedProjects(
root: string,
face: 'host' | 'client',
mode: CompilationMode = 'checked',
): void {
const configPath = resolve(root, `tsconfig.${face}.json`)
const config = ts.getParsedCommandLineOfConfigFile(configPath, {}, {
...ts.sys,
onUnRecoverableConfigFileDiagnostic(diagnostic) {
throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
},
})
if (config === undefined) throw new Error(`compile-referenced-projects: cannot read ${configPath}`)
const references = config.projectReferences
if (references === undefined || references.length === 0) {
throw new Error(`compile-referenced-projects: ${configPath} requires non-empty project references`)
}
if (config.errors.length > 0) {
throw new Error(config.errors
.map(diagnostic => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
.join('\n'))
}
if (config.options.noEmit !== true) {
throw new Error(`compile-referenced-projects: ${configPath} requires noEmit: true to exclude its aggregate program`)
}
const compiler = fileURLToPath(import.meta.resolve('typescript/bin/tsc'))
const result = spawnSync(process.execPath, [
...face === 'host' ? ['--max-old-space-size=4096'] : [],
compiler,
'-b',
...mode === 'benchmark-emit' ? ['--noCheck'] : [],
// Relative arguments keep the complete workspace graph below Windows' command-line limit.
...references.map(reference => relative(root, reference.path)),
], { cwd: root, stdio: 'inherit' })
if (result.error !== undefined) throw result.error
if (result.status !== 0) {
throw new Error(`compile-referenced-projects: ${face} compiler exited with ${String(result.status ?? result.signal)}`)
}
}
/**
* Emit package libraries in Host, Desktop, then Client dependency order.
* @param root - Repository root containing the compiler and bundle configurations.
* @param hostOnly - Omit the Client compiler and bundler after the Host artifacts are ready.
* @param mode - Check package types by default; benchmark emission defers diagnostics to required full builds.
* @throws If any compiler or bundler fails.
*/
export function buildLibraryArtifacts(root: string, hostOnly: boolean, mode: CompilationMode = 'checked'): void {
compileReferencedProjects(root, 'host', mode)
runPnpmCommand(root, ['exec', 'tsdown', '--config-loader', 'native', '--env.DSH_BUILD_FACE', 'host'])
runPnpmCommand(root, ['--filter', '@deepseek-ai/dsh-desktop', 'run', 'bundle'])
if (hostOnly) return
compileReferencedProjects(root, 'client', mode)
runPnpmCommand(root, ['exec', 'tsdown', '--config-loader', 'native', '--env.DSH_BUILD_FACE', 'client'])
}
if (import.meta.main) {
const { positionals } = parseArgs({ allowPositionals: true })
const [mode] = positionals
if (positionals.length !== 1) {
throw new Error('compile-referenced-projects: expected exactly one mode: host, client, host-libraries, or libraries')
}
const root = resolve(import.meta.dirname, '..')
switch (mode) {
case 'host':
case 'client':
compileReferencedProjects(root, mode)
break
case 'host-libraries':
buildLibraryArtifacts(root, true)
break
case 'libraries':
buildLibraryArtifacts(root, false)
break
default:
throw new Error('compile-referenced-projects: expected exactly one mode: host, client, host-libraries, or libraries')
}
}
+67
View File
@@ -0,0 +1,67 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { ModuleDiagnostic, ResolvedConfig } from 'vitest/node'
import { afterEach, describe, expect, it } from 'vitest'
import CoverageFileTimesReporter from './coverage-file-times.ts'
const roots: string[] = []
afterEach(async () => {
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
})
async function temporaryRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'dsh-coverage-file-times-'))
roots.push(root)
return root
}
describe('coverage file timing report', () => {
it('includes import and hook costs once without serializing coverage or test objects', async () => {
const root = await temporaryRoot()
const reporter = new CoverageFileTimesReporter()
reporter.onInit({ config: { root, outputFile: { fileTimes: 'times.json' } } })
const diagnostic: ModuleDiagnostic = {
environmentSetupDuration: 3,
prepareDuration: 5,
setupDuration: 7,
collectDuration: 100,
duration: 200,
heap: undefined,
// Per-dependency import times are already included in collectDuration.
importDurations: { dependency: { selfTime: 100, totalTime: 100 } },
}
const module = {
moduleId: join(root, 'packages', 'a', 'tests', 'x.spec.ts'),
diagnostic: () => diagnostic,
// Vitest modules retain tests and coverage through their project. The
// timing report must read only their paths and diagnostics.
toJSON: () => { throw new Error('test module was serialized') },
}
await reporter.onTestRunEnd([module])
const output = await readFile(join(root, 'times.json'), 'utf8')
expect(JSON.parse(output)).toEqual({ 'packages/a/tests/x.spec.ts': 315 })
expect(output.endsWith('\n')).toBe(true)
expect(output).not.toContain('coverageMap')
expect(output).not.toContain('assertionResults')
})
it('writes an empty report when no modules finished', async () => {
const root = await temporaryRoot()
const reporter = new CoverageFileTimesReporter()
reporter.onInit({ config: { root, outputFile: { fileTimes: 'times.json' } } })
await reporter.onTestRunEnd([])
expect(await readFile(join(root, 'times.json'), 'utf8')).toBe('{}\n')
})
const missingOutputs: ResolvedConfig['outputFile'][] = ['report.json', {}, { fileTimes: '' }]
it.each(missingOutputs)(
'rejects missing timing output %j',
(outputFile) => {
const reporter = new CoverageFileTimesReporter()
expect(() => { reporter.onInit({ config: { root: '/repo', outputFile } }) })
.toThrow('outputFile.fileTimes is required')
},
)
})
+43
View File
@@ -0,0 +1,43 @@
/** Record coverage partition file costs without retaining test results or coverage maps. */
import { writeFile } from 'node:fs/promises'
import { relative, resolve, sep } from 'node:path'
import type { Reporter, ResolvedConfig, TestModule } from 'vitest/node'
/** Vitest reporter that writes repository-relative file durations to `outputFile.fileTimes`. */
export default class CoverageFileTimesReporter implements Reporter {
private root = ''
private outputFile = ''
/**
* Resolve the timing output owned by the coverage coordinator.
* @param vitest - repository root and configured reporter output files.
* @throws when the file-times output is absent.
*/
public onInit(vitest: { config: Pick<ResolvedConfig, 'root' | 'outputFile'> }): void {
const output = vitest.config.outputFile
if (typeof output !== 'object' || typeof output.fileTimes !== 'string' || output.fileTimes === '') {
throw new Error('coverage-file-times: outputFile.fileTimes is required.')
}
this.root = vitest.config.root
this.outputFile = resolve(this.root, output.fileTimes)
}
/**
* Write each file's distinct environment, preparation, setup, collection, and execution costs.
* @param testModules - finished files, including failed and skipped files.
* @returns after the compact timing report has been written.
*/
public async onTestRunEnd(testModules: readonly Pick<TestModule, 'moduleId' | 'diagnostic'>[]): Promise<void> {
const durations: Record<string, number> = {}
for (const module of testModules) {
const diagnostic = module.diagnostic()
const file = relative(this.root, module.moduleId).split(sep).join('/')
durations[file] = diagnostic.environmentSetupDuration
+ diagnostic.prepareDuration
+ diagnostic.setupDuration
+ diagnostic.collectDuration
+ diagnostic.duration
}
await writeFile(this.outputFile, `${JSON.stringify(durations)}\n`, 'utf8')
}
}
+34 -20
View File
@@ -11,9 +11,10 @@ import {
CoveragePartitionCoordinator,
assignWeightedPartitions,
collectPartitionDurations,
coverageTestTimeoutArgs,
coverageTestTimeoutOptions,
forwardedCoverageArgs,
parseCoveragePartitionCount,
parseCoverageTestTimeout,
parseListOutput,
readFileDurations,
writeFileDurations,
@@ -102,21 +103,24 @@ describe('coverage partition count', () => {
})
})
describe('coverage partition timeout', () => {
it('applies one configured timeout to tests, polling, and hooks', () => {
expect(coverageTestTimeoutArgs('30000')).toEqual([
'--testTimeout=30000',
'--expect.poll.timeout=30000',
'--hookTimeout=30000',
])
describe('lane test budget', () => {
it('applies one configured budget to tests, hooks, and expect.poll', () => {
expect(coverageTestTimeoutOptions('30000')).toEqual({
testTimeout: 30000,
hookTimeout: 30000,
expect: { poll: { timeout: 30000 } },
})
})
it('keeps Vitest defaults when the timeout is absent', () => {
expect(coverageTestTimeoutArgs(undefined)).toEqual([])
it.each([undefined, ''])('keeps Vitest defaults when the budget is %j', (raw) => {
expect(parseCoverageTestTimeout(raw)).toBeUndefined()
expect(coverageTestTimeoutOptions(raw)).toEqual({})
})
it('rejects invalid timeout input', () => {
expect(() => coverageTestTimeoutArgs('0'))
it.each(['0', '-1', '2.5', '090', '90000ms', 'many'])('rejects %j', (raw) => {
expect(() => parseCoverageTestTimeout(raw))
.toThrow(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`)
expect(() => coverageTestTimeoutOptions(raw))
.toThrow(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer`)
})
})
@@ -265,17 +269,26 @@ describe('coverage file inventory', () => {
expect(durations.get('packages/a/tests/y.spec.ts')).toBe(7)
})
it('extracts per-file durations from partition json reports', async () => {
it('extracts per-file durations from compact partition timing reports', async () => {
const root = await temporaryRoot()
const report = join(root, 'partition-1.report.json')
const report = join(root, 'partition-1.times.json')
await writeFile(report, JSON.stringify({
testResults: [
{ name: join(root, 'packages/a/tests/x.spec.ts'), startTime: 1000, endTime: 1500 },
{ name: 'not-a-spec', startTime: 1, endTime: 2 },
],
'packages/a/tests/x.spec.ts': 500,
'packages/a/tests/y.spec.ts': 0,
'packages/a/tests/invalid.spec.ts': -1,
'packages/a/tests/malformed.spec.ts': 'unknown',
}))
const durations = collectPartitionDurations([report], root)
const durations = collectPartitionDurations([report])
expect(durations.get('packages/a/tests/x.spec.ts')).toBe(500)
expect(durations.get('packages/a/tests/y.spec.ts')).toBe(0)
expect(durations.size).toBe(2)
})
it.each(['null', '[]', '42', '"invalid"', '{'])('ignores corrupt timing metadata %s', async (contents) => {
const root = await temporaryRoot()
const report = join(root, 'partition.times.json')
await writeFile(report, contents)
expect(collectPartitionDurations([report])).toEqual(new Map())
})
})
@@ -507,9 +520,10 @@ describe('coverage partition coordinator', () => {
'--maxWorkers=1',
'--reporter=default',
'--reporter=blob',
'--reporter=json',
'--testTimeout=30000',
]))
expect(command.args).not.toContain('--reporter=json')
expect(command.args).toContain('--reporter=./scripts/coverage-file-times.ts')
expect(command.args).not.toContain('--shard=1/3')
expect(command.args.some(argument => argument.startsWith('--config='))).toBe(true)
expect(command.env).toEqual({
+55 -27
View File
@@ -1,8 +1,13 @@
/** Coordinate single-worker Vitest coverage partitions and one merged report. */
/**
* Coordinate single-worker Vitest coverage partitions and one merged report.
* Separate processes preserve worker isolation; only the complete merged
* inventory can enforce per-file coverage thresholds.
*/
import { spawn } from 'node:child_process'
import { globSync, readFileSync, writeFileSync } from 'node:fs'
import { lstat, mkdir, readdir, rm, unlink, writeFile } from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
import type { TestUserConfig } from 'vitest/config'
import { coverageExemptHeavySuites } from './coverage-exempt.ts'
import { pnpmInvocation } from './pnpm-invocation.ts'
@@ -12,7 +17,7 @@ export const COVERAGE_PARTITIONS_ENV = 'DSH_COVERAGE_PARTITIONS'
/** Internal marker that suppresses reports and thresholds inside a partition process. */
export const COVERAGE_PARTITION_MODE_ENV = 'DSH_COVERAGE_PARTITION_MODE'
/** Environment variable overriding instrumented test, polling, and hook timeouts. */
/** Environment variable carrying a lane's per-test, hook, and `expect.poll` budget in milliseconds. */
export const COVERAGE_TEST_TIMEOUT_ENV = 'DSH_COVERAGE_TEST_TIMEOUT_MS'
/**
@@ -22,6 +27,7 @@ export const COVERAGE_TEST_TIMEOUT_ENV = 'DSH_COVERAGE_TEST_TIMEOUT_MS'
* working directory on every platform.
*/
const CANONICAL_LOCATIONS_REPORTER = './scripts/coverage-canonical-locations.ts'
const FILE_TIMES_REPORTER = './scripts/coverage-file-times.ts'
/** One child command owned by the coverage coordinator. */
export interface CoverageCommand {
@@ -85,22 +91,44 @@ export function parseCoveragePartitionCount(raw: string | undefined): number | u
}
/**
* Resolve the paired Vitest timeout arguments used by coverage partitions.
* `--hookTimeout` travels with the test budget because setup and teardown pay
* the same host contention the raised test budget accounts for: fixtures that
* await child exit or retry Windows handle release spend that cost in
* `afterEach`, where Vitest's separate 10 s default would otherwise fail a
* suite whose cases all passed.
* Parse the lane test budget carried by `DSH_COVERAGE_TEST_TIMEOUT_MS`.
* @param raw - the configured millisecond budget, or undefined to keep Vitest's defaults.
* @returns the Vitest arguments applying that budget, empty when unset.
* @returns the budget in milliseconds, or undefined when unset.
* @throws when the value is set and is not a positive integer.
*/
export function coverageTestTimeoutArgs(raw: string | undefined): string[] {
if (raw === undefined || raw === '') return []
export function parseCoverageTestTimeout(raw: string | undefined): number | undefined {
if (raw === undefined || raw === '') return undefined
const parsed = Number.parseInt(raw, 10)
if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) {
throw new Error(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`)
}
return [`--testTimeout=${raw}`, `--expect.poll.timeout=${raw}`, `--hookTimeout=${raw}`]
return parsed
}
/** The Vitest `test` options one lane budget sets in each inline project. */
export type CoverageTestTimeoutOptions = Pick<TestUserConfig, 'testTimeout' | 'hookTimeout' | 'expect'>
/**
* Resolve the Vitest test options that apply one lane budget to the per-test,
* hook, and `expect.poll` defaults together. The hook budget travels with the
* test budget because setup and teardown pay the same host contention the
* raised test budget accounts for: fixtures that await child exit or retry
* Windows handle release spend that cost in `afterEach`, where Vitest's
* separate 10 s default would otherwise fail a suite whose cases all passed.
* The root `vitest.config.ts` spreads these into each inline project, so the
* budget governs every Vitest run of that config in an environment carrying
* the variable, whichever gate or script starts it. The config, not a CLI
* flag, is the only route into inline projects: Vitest forwards a fixed list
* of CLI overrides into projects, and `--hookTimeout` and
* `--expect.poll.timeout` are not on it (only `--testTimeout` is). Explicit
* `describe`, case, and fixture timeouts remain authoritative.
* @param raw - the configured millisecond budget, or undefined to keep Vitest's defaults.
* @returns the options to spread into a project's `test` block, empty when unset.
*/
export function coverageTestTimeoutOptions(raw: string | undefined): CoverageTestTimeoutOptions {
const timeout = parseCoverageTestTimeout(raw)
if (timeout === undefined) return {}
return { testTimeout: timeout, hookTimeout: timeout, expect: { poll: { timeout } } }
}
/** Remove pnpm's package-script separator before forwarding Vitest arguments. */
@@ -272,23 +300,23 @@ export function writeFileDurations(
}
/**
* Extract per-file durations from Vitest JSON reporter outputs (one per
* partition). Each `testResults` entry names an absolute spec path and carries
* `startTime`/`endTime`; the difference is the file's recorded duration.
* Extract per-file durations from compact timing reports, one per partition.
* @param reportFiles - reports mapping repository-relative file paths to milliseconds.
* @returns finite, non-negative file costs from readable reports.
*/
export function collectPartitionDurations(reportFiles: readonly string[], root: string): Map<string, number> {
export function collectPartitionDurations(reportFiles: readonly string[]): Map<string, number> {
const durations = new Map<string, number>()
for (const file of reportFiles) {
let report: { testResults?: Array<{ name?: unknown; startTime?: number; endTime?: number }> }
let report: unknown
try {
report = JSON.parse(readFileSync(file, 'utf8')) as { testResults?: Array<{ name?: unknown; startTime?: number; endTime?: number }> }
report = JSON.parse(readFileSync(file, 'utf8'))
} catch {
continue
}
for (const result of report.testResults ?? []) {
if (typeof result.name !== 'string' || typeof result.startTime !== 'number' || typeof result.endTime !== 'number') continue
const relativePath = relative(root, result.name).split(sep).join('/')
durations.set(relativePath, Math.max(0, result.endTime - result.startTime))
if (report === null || typeof report !== 'object' || Array.isArray(report)) continue
for (const [path, duration] of Object.entries(report)) {
if (typeof duration !== 'number' || !Number.isFinite(duration) || duration < 0) continue
durations.set(path, duration)
}
}
return durations
@@ -505,10 +533,10 @@ export class CoveragePartitionCoordinator {
private persistDurations(partitionCount: number): void {
const reportFiles = Array.from(
{ length: partitionCount },
(_, index) => join(this.temporaryRoot, `partition-${index + 1}.report.json`),
(_, index) => join(this.temporaryRoot, `partition-${index + 1}.times.json`),
)
const currentFiles = this.projectOf.size > 0 ? [...this.projectOf.keys()] : undefined
writeFileDurations(this.root, collectPartitionDurations(reportFiles, this.root), currentFiles)
writeFileDurations(this.root, collectPartitionDurations(reportFiles), currentFiles)
}
/**
@@ -557,7 +585,7 @@ export class CoveragePartitionCoordinator {
private partitionCommand(index: number, configPath: string): CoverageCommand {
const blobPath = join(this.blobsRoot, `partition-${index}.json`)
const reportsDirectory = join(this.temporaryRoot, `coverage-${index}`)
const jsonReportPath = join(this.temporaryRoot, `partition-${index}.report.json`)
const timingReportPath = join(this.temporaryRoot, `partition-${index}.times.json`)
const invocation = pnpmInvocation([
'exec',
'vitest',
@@ -568,10 +596,10 @@ export class CoveragePartitionCoordinator {
`--config=${this.relativePath(configPath)}`,
'--reporter=default',
'--reporter=blob',
'--reporter=json',
`--reporter=${FILE_TIMES_REPORTER}`,
`--reporter=${CANONICAL_LOCATIONS_REPORTER}`,
`--outputFile.blob=${this.relativePath(blobPath)}`,
`--outputFile.json=${this.relativePath(jsonReportPath)}`,
`--outputFile.fileTimes=${this.relativePath(timingReportPath)}`,
`--coverage.reportsDirectory=${this.relativePath(reportsDirectory)}`,
...this.vitestArgs,
], { npm_execpath: this.pnpmEntrypoint })
+5
View File
@@ -195,6 +195,10 @@ import { defineConfig } from 'tsdown'
export default defineConfig({
entry: { client: 'src.ts' }, outDir: 'lib', format: 'cjs', platform: 'browser', dts: false, clean: false,
outputOptions: { entryFileNames: 'client.js' },
hooks: { 'build:done': async () => {
const { writeFile } = await import('node:fs/promises')
await writeFile(new URL('./hook-complete', import.meta.url), 'ready')
} },
})
`)
const sourcePath = join(root, 'src.ts')
@@ -202,6 +206,7 @@ export default defineConfig({
await writeFile(sourcePath, 'export const version = "watch-v1"\n')
bundles = await watchClientPlugins(root, ['.'], 50)
expect(await readFile(bundlePath, 'utf8')).toContain('watch-v1')
expect(await readFile(join(root, 'hook-complete'), 'utf8')).toBe('ready')
await new Promise(resolve => setTimeout(resolve, 1_000))
await writeFile(sourcePath, `export const version = "watch-v2-${'x'.repeat(100)}"\n`)
+11 -11
View File
@@ -173,7 +173,7 @@ export function discoverLibraryDirs(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 after every watcher has completed its initial build.
* @returns live bundles after every watcher has completed its initial build and package-owned post-build hooks.
*/
export async function watchClientPlugins(
root: string,
@@ -187,17 +187,17 @@ export async function watchClientPlugins(
const bundles = await build({
cwd: root,
workspace: [...pluginDirs],
configLoader: 'native',
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?.()
},
// Workspace hooks override inline hooks; onSuccess runs after each package's build:done work.
onSuccess: (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 } } } }
+1 -1
View File
@@ -1,5 +1,5 @@
{
"AGENTS.md": 1950,
"AGENTS.md": 1960,
"docs/AGENTS.md": 1320,
"docs/architecture.md": 2410,
"docs/cordis-primer.md": 600,
+1
View File
@@ -45,6 +45,7 @@ const KIND_TEMPLATES: Readonly<Record<string, string>> = {
'persistence-change': '.agents/skills/dsh-doc/templates/persistence-change.md',
'persistence-release': '.agents/skills/dsh-doc/templates/persistence-release.md',
'persistence-format': '.agents/skills/dsh-doc/templates/persistence-format.md',
'upgrade-guide': '.agents/skills/dsh-doc/templates/upgrade-guide.md',
}
/**
+7 -5
View File
@@ -4,14 +4,16 @@ import { builtDeclarationPath } from './doc-typecheck-paths.ts'
describe('builtDeclarationPath', () => {
it('maps package source directories and exact entry files to built declarations', () => {
expect(builtDeclarationPath('./packages/*/*/src')).toBe('./packages/*/*/lib/types')
expect(builtDeclarationPath('./packages/runtime-diagnostics/invariants/src/index.ts'))
.toBe('./packages/runtime-diagnostics/invariants/lib/types/index.d.ts')
expect(builtDeclarationPath('./packages/core/session/src/invariant.ts'))
.toBe('./packages/core/session/lib/types/invariant.d.ts')
expect(builtDeclarationPath('./packages/core/session/src/index.ts'))
.toBe('./packages/core/session/lib/types/index.d.ts')
expect(builtDeclarationPath('./packages/core/session/src/types.ts'))
.toBe('./packages/core/session/lib/types/types.d.ts')
expect(builtDeclarationPath('./packages/experimental/claude-code-mods/examples/*'))
.toBe('./packages/experimental/claude-code-mods/examples/*')
})
it('rejects aliases without a supported source target', () => {
expect(() => builtDeclarationPath('./packages/runtime-diagnostics/invariants/source/index.ts'))
expect(() => builtDeclarationPath('./packages/core/session/source/index.ts'))
.toThrow('cannot map workspace source path')
})
})
+2
View File
@@ -8,6 +8,8 @@ export function builtDeclarationPath(candidate: string): string {
if (candidate.endsWith('/src/*')) {
return `${candidate.slice(0, -'/src/*'.length)}/lib/types/*`
}
// Example plugin entries ship no declarations; a documentation fence never imports them, so they stay as authored.
if (candidate.includes('/examples/')) return candidate
const sourceFile = /^(.*)\/src\/(.+)\.ts$/.exec(candidate)
if (sourceFile?.[1] && sourceFile[2]) {
return `${sourceFile[1]}/lib/types/${sourceFile[2]}.d.ts`
@@ -0,0 +1,32 @@
/**
* Waits that end only through the lane budget in force. Runs only inside the
* Vitest child that scripts/lane-test-budget.spec.ts spawns.
*/
import { beforeEach, describe, expect, it } from 'vitest'
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
describe('lane test budget fixture', () => {
it('per-test budget', async () => {
await sleep(600)
})
// The predicate counts attempts instead of reading a clock, so a host stall
// cannot turn a budget-ended poll into a pass: a 10 ms interval fits at most
// ~20 attempts into a 200 ms budget and ~100 into Vitest's 1000 ms default,
// both far below the target, and the case's own budget stays above both so
// the poll budget is what ends the assertion. The reported duration is then
// the poll budget (or longer under load), which the spawning spec reads.
it('expect.poll budget', { timeout: 5_000 }, async () => {
let attempts = 0
await expect.poll(() => { attempts += 1; return attempts }, { interval: 10 }).toBeGreaterThan(10_000)
})
describe('hook budget', () => {
beforeEach(async () => {
await sleep(600)
})
it('runs after the hook', () => {})
})
})
+19 -9
View File
@@ -50,6 +50,7 @@ export { REGION_BEGIN, REGION_END }
* errors, so the partition can never silently drift from the service API.
*/
export const SERVICE_PAGE: Record<string, string> = {
translator: 'translation.md',
speechToText: 'voice-input.md',
speechController: 'voice-input.md',
otel: 'otel.md',
@@ -93,7 +94,6 @@ export const SERVICE_PAGE: Record<string, string> = {
schedule: 'schedule.md',
inspector: 'extensions.md',
webServer: 'web-server.md',
invariants: 'invariants.md',
llm: 'llm-streaming.md',
lsp: 'lsp.md',
messageFeedback: 'feedback.md',
@@ -127,6 +127,7 @@ export const SERVICE_PAGE: Record<string, string> = {
jobController: 'jobs.md',
sessionTelemetry: 'session-telemetry.md',
agentTeams: 'agent-team.md',
claudeCodeMods: 'claude-code-mods.md',
tokenMeter: 'token-meter.md',
toolResultPruner: 'compaction.md',
tools: 'tools.md',
@@ -182,6 +183,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
configForms: 'client-side shared entry forms — packages/client/ui-settings/README.md owns the API',
chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-chat/README.md owns the API',
shortcuts: 'client-side interface-typed keyboard service — packages/client/shortcuts/README.md owns the API',
userQuestionPanels: 'client-side slot-contract accessor (UserQuestionPanels) — packages/client/ui-tool/README.md owns the API',
commandUi: 'client-side interface-typed browser service — packages/client/ui-commands/README.md owns the API',
feedbackUi: 'client-side feedback dialog service — packages/client/ui-message-feedback/README.md owns the API',
conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the API',
@@ -424,6 +426,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
FileUploadReceiptId: 'attachment.md',
FileUploadValue: 'attachment.md',
SessionStartSource: 'core.md',
ToolCallId: 'core.md',
SessionLogSnapshot: 'session-query.md',
SessionSurfaceSnapshot: 'session-query.md',
ApprovalOutcome: 'approval.md',
@@ -663,6 +666,11 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
SpeechProviderInfo: 'voice-input.md',
SpeechInput: 'voice-input.md',
SpeechRequest: 'voice-input.md',
TranslationProvider: 'translation.md',
TranslationRequest: 'translation.md',
TranslationSpec: 'translation.md',
TranslationError: 'translation.md',
TranslationErrorCode: 'translation.md',
SpeechSpec: 'voice-input.md',
Transcript: 'voice-input.md',
SpeechCatalog: 'voice-input.md',
@@ -681,6 +689,8 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
TeamMemberProjection: 'agent-team.md',
TeamWaitResult: 'agent-team.md',
UpdateTeamTaskRequest: 'agent-team.md',
ModDefinition: 'claude-code-mods.md',
SurfaceSnapshot: 'claude-code-mods.md',
TokenMeasurement: 'token-meter.md',
PtcDispatchLog: 'tools.md',
PostToolDecision: 'tools.md',
@@ -737,6 +747,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
ResolvedCredential: 'credentials.md',
AskUserQuestionAnswer: 'user-questions.md',
AskUserQuestionRequest: 'user-questions.md',
TimedUserQuestionResult: 'user-questions.md',
UserQuestionProvider: 'user-questions.md',
WebFetchProvider: 'web.md',
WebFetchRequest: 'web.md',
@@ -750,7 +761,6 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
PermissionCatalog: 'permission-presets.md',
PresetOption: 'permission-presets.md',
PresetSpec: 'permission-presets.md',
InvariantInstaller: 'invariants.md',
WebRoute: 'web-server.md',
IndexInjection: 'web-server.md',
StorageBackend: 'storage.md',
@@ -793,14 +803,14 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
ProjectionCheckpoint: 'session-projection.md',
DirectoryPickerCapability: 'workspace.md',
DirectoryListing: 'workspace.md',
TypertContribution: 'invariants.md',
TypertContribution: 'typert.md',
TypertRemoteEventSource: 'typert.md',
RemoteEventHostInfo: 'typert.md',
TypertFace: 'invariants.md',
TypertPackageFilter: 'invariants.md',
TypertPackageRecord: 'invariants.md',
TypertSchemaFilter: 'invariants.md',
TypertSchemaRecord: 'invariants.md',
TypertFace: 'typert.md',
TypertPackageFilter: 'typert.md',
TypertPackageRecord: 'typert.md',
TypertSchemaFilter: 'typert.md',
TypertSchemaRecord: 'typert.md',
}
/** TypeScript lib and pinned framework types with no repository-owned data page. */
@@ -860,6 +870,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
AsyncDisposable: 'TypeScript explicit resource management interface',
AgentPresetDocument: 'preset composition view is owned by packages/preset/agent-preset-registry/README.md',
AgentPresetComposition: 'flattened composition rows are owned by packages/preset/agent-preset-registry/README.md',
AgentPresetInspection: 'retained revision modules and isolation diagnostics are owned by packages/preset/agent-preset-registry/README.md',
PresetMetadata: 'preset display text is owned by packages/preset/agent-preset-registry/README.md',
BashEnvContributor: 'service-local extension type is owned by packages/shell/tool-bash/src/index.ts',
BashEnvVariableInfo: 'service-local metadata type is owned by packages/shell/tool-bash/src/index.ts',
@@ -912,7 +923,6 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
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/runtime-diagnostics/invariants/README.md',
JsonValue: 'JSON value union is owned by packages/core/session/src/json.ts',
KnobState: 'projection unit state fields are owned by packages/interaction/permission-presets/README.md',
PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md',
+16 -10
View File
@@ -107,6 +107,13 @@ const GROUP_ORDER = [
]
const SERVICE_ROLES: ServiceRole[] = [
{
key: 'translator',
pkg: 'experimental-translator',
title: 'Anonymous text translation',
mode: 'service',
note: 'Resolves Google or Bing requests and bounds provider calls independently of Sessions.',
},
{
key: 'hmr',
pkg: 'hmr',
@@ -235,7 +242,7 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'session',
title: 'In-memory session store',
mode: 'core',
consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent', 'invariants', 'message-feedback'],
consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent', 'message-feedback'],
note: 'Owns append-only Session instances and emits the durable session event feed.',
},
{
@@ -322,14 +329,6 @@ const SERVICE_ROLES: ServiceRole[] = [
mode: 'core',
note: 'Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on.',
},
{
key: 'invariants',
pkg: 'invariants',
title: 'Package-owned invariant registry',
mode: 'core',
consumers: ['session', 'agent', 'scope', 'agent-loop'],
note: 'Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures.',
},
{
key: 'typert',
pkg: 'typert-registry',
@@ -575,7 +574,7 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'skill',
title: 'Skill provider registry',
mode: 'seam',
implementations: ['skill-badge', 'skill-filesystem', 'skill-office'],
implementations: ['sandbox-windows-acl', 'skill-badge', 'skill-filesystem', 'skill-office'],
consumers: ['tool-skill'],
note: 'Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies.',
},
@@ -749,6 +748,13 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['experimental-tool-agent-team'],
note: 'Owns the implicit-root roster, durable peer mailbox, shared task DAG, and continuable-child lifecycle; tool-agent-team contributes model controls.',
},
{
key: 'claudeCodeMods',
pkg: 'experimental-claude-code-mods',
title: 'Claude Code mods bridge',
mode: 'core',
note: 'Loads mods that defineMod plugins add, raises their hook chains from harness extension points, and draws the band above the prompt over its Remote.',
},
{
key: 'inspector',
pkg: 'inspector',
+51 -4
View File
@@ -3,7 +3,8 @@
* the owning event-envelope types. This is the durable-record vocabulary, not
* the live Cordis bus. Event declarations must be unique, explicitly typed,
* documented, inheritance-free, and free of Cordis-only `@mode` tags; every
* surface-union member must resolve to one. `--check` verifies the artifact.
* surface-union member must resolve to one. Experimental plugin records have
* a separate current-source catalog. `--check` verifies every generated artifact.
*/
import { readFileSync, writeFileSync } from 'node:fs'
@@ -16,14 +17,16 @@ import {
import { extractPersistenceSchema } from './persistence-schema.ts'
import { persistenceCatalogText, type PersistenceCatalogLocale } from './persistence-catalog-text.ts'
import { renderPersistencePair, type PersistenceArtifact } from './persistence-artifacts.ts'
import { collectPluginRecords, type PluginRecordEntry } from './plugin-record-catalog.ts'
export { annotateSurface, collectEventEnvelopeTypes, collectLogEvents, collectSurfaceEventTypes } from './persistence-catalog-source.ts'
export type { AnnotatedLogEventEntry, EventEnvelopeTypeEntry, LogEventEntry } from './persistence-catalog-source.ts'
import type { PersistenceSchemaInventory } from './persistence-schema-model.ts'
import { persistenceSchemaSnapshot, type PersistenceSchemaInventory } from './persistence-schema-model.ts'
import { renderPersistenceSchemaDefinitions, renderPersistenceSchemaIndex } from './render-persistence-schema.ts'
const root = resolve(import.meta.dirname, '..')
const OUT = 'docs/persistence-catalog.md'
const OUT_PLUGIN_RECORDS = 'docs/experimental-persistence-catalog.md'
const OUT_RUNTIME_TYPES = 'packages/core/session/src/known-event-types.ts'
const OUT_SCHEMA = 'docs/persistence-schema.json'
@@ -81,6 +84,46 @@ function renderEvent(e: AnnotatedLogEventEntry, locale: PersistenceCatalogLocale
return out
}
/** Escape a source annotation as inline table code, including template literals and unions. */
function recordCode(value: string): string {
return `<code>${value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
.replaceAll('|', '&#124;').replaceAll('`', '&#96;').replace(/\r?\n/gu, '<br>')}</code>`
}
/**
* Render the standalone current-source plugin record catalog without expanding payload types.
* @param records - validated experimental package record declarations.
* @param locale - language of the page's headings and explanatory prose.
* @returns the complete Markdown page, including its language switcher and main catalog link.
*/
export function renderPluginRecordCatalog(records: readonly PluginRecordEntry[], locale: PersistenceCatalogLocale = 'en'): string {
const text = persistenceCatalogText[locale]
const lines = [
'<!-- Generated by scripts/gen-persistence-catalog.ts — do not edit by hand.',
' Run `pnpm run gen-persistence-catalog` to regenerate. -->',
'',
`# ${text.pluginRecords}`,
'',
locale === 'en'
? 'English | [中文](experimental-persistence-catalog.zh.md)'
: '[English](experimental-persistence-catalog.md) | 中文',
'',
text.pluginRecordsIntro,
'',
text.pluginRecordsGeneration,
'',
text.pluginRecordsMainLink,
'',
]
if (records.length === 0) return [...lines, text.pluginRecordsEmpty, ''].join('\n')
lines.push(text.pluginRecordColumns, '|---|---|---|---|')
for (const record of [...records].sort((left, right) => left.owner.localeCompare(right.owner) || left.name.localeCompare(right.name))) {
const doc = record.doc.replaceAll('|', '\\|').replace(/\s*\n\s*/gu, ' ')
lines.push(`| \`${record.name}\` | \`${record.owner}\` | ${doc} | ${recordCode(record.payload)} · [\`${record.source}\`](../${record.source}) |`)
}
return [...lines, ''].join('\n')
}
/** Render the full catalog (pure, deterministic given the collected inputs). */
export function render(
events: AnnotatedLogEventEntry[],
@@ -98,6 +141,8 @@ export function render(
...(locale === 'zh' ? ['[English](persistence-catalog.md) | 中文', ''] : []),
text.intro,
'',
text.pluginRecordsLink,
'',
text.generation,
'',
text.envelopeIntro,
@@ -170,15 +215,17 @@ export function renderKnownEventTypes(events: AnnotatedLogEventEntry[]): string
* Render the complete generated persistence reference from one extracted inventory.
* @param scanRoot - checkout whose source event declarations supply JSDoc.
* @param schema - already extracted source schemas; shared with record validation.
* @returns both catalog languages, pairing metadata, runtime names and machine schemas.
* @returns both catalog pairs and their metadata, runtime names and machine schemas.
*/
export function persistenceCatalogArtifacts(scanRoot: string, schema: PersistenceSchemaInventory): PersistenceArtifact[] {
const events = annotateSurface(collectLogEvents(scanRoot), collectSurfaceEventTypes(scanRoot))
const envelope = collectEventEnvelopeTypes(scanRoot)
const pluginRecords = collectPluginRecords(scanRoot)
return [
...renderPersistencePair(scanRoot, OUT, render(events, envelope, schema), render(events, envelope, schema, 'zh')),
...renderPersistencePair(scanRoot, OUT_PLUGIN_RECORDS, renderPluginRecordCatalog(pluginRecords), renderPluginRecordCatalog(pluginRecords, 'zh')),
{ path: OUT_RUNTIME_TYPES, content: renderKnownEventTypes(events) },
{ path: OUT_SCHEMA, content: `${JSON.stringify(schema, null, 2)}\n` },
{ path: OUT_SCHEMA, content: `${JSON.stringify(persistenceSchemaSnapshot(schema), null, 2)}\n` },
]
}
-399
View File
@@ -1,399 +0,0 @@
/**
* Generate dsh-scope's invariant resolver map from the repository TypeScript
* Program.
*
* A scoped event declares `this: Scoped<Base>`. Real `scopeTarget(base, key)`
* calls establish the routing-key type for that base. The generator searches
* every event payload parameter and one property level for exactly one type
* equivalent to that key. Each generated resolver compiles against the merged
* `Events` parameter tuple. Zero matches require `@dshScopeScan unsupported`;
* multiple matches are ambiguous and always fail loud.
*
* `pnpm run gen-scoped-events` -> write the generated source
* `pnpm run verify-scoped-events` -> exit 1 when it is stale
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import ts from 'typescript'
import { pointer, rawJsDoc } from './jsdoc.ts'
import { TypeScriptProject } from './ts-project.ts'
const root = resolve(import.meta.dirname, '..')
const OUT = 'packages/core/scope/src/scoped-events.generated.ts'
const SCOPE_DOC_MARKER = 'Scope-filtered dispatch'
interface ScopeTargetContract {
baseType: ts.Type
keyType: ts.Type
source: string
}
interface SubjectCandidate {
path: string
parameter: number
property?: string
type: ts.Type
}
interface ScopedEventResolver {
event: string
candidate: SubjectCandidate | null
}
interface ScopeTag {
present: boolean
unsupported: boolean
}
/** Program-backed analyzer and renderer for the generated scoped-event resolvers. */
class ScopedEventGenerator {
private readonly checker: ts.TypeChecker
private readonly packageSources: ts.SourceFile[]
private readonly scopeTargetDeclaration: ts.FunctionDeclaration
private readonly scopedSymbol: ts.Symbol
private readonly violations: string[] = []
constructor(private readonly project: TypeScriptProject) {
this.checker = project.checker
this.packageSources = project.sourceFiles().filter((sourceFile) => {
return /^packages\/[^/]+\/[^/]+\/src\/.+\.ts$/.test(project.relativePath(sourceFile))
})
this.scopeTargetDeclaration = this.functionDeclaration(
'packages/core/scope/src/index.ts',
'scopeTarget',
)
this.scopedSymbol = this.typeAliasSymbol(
'packages/core/scope/src/index.ts',
'Scoped',
)
}
/** Render the complete generated TypeScript module or throw every contract violation. */
render(): string {
const contracts = this.collectScopeTargetContracts()
const resolvers = this.collectScopedEventResolvers(contracts)
if (this.violations.length > 0) {
throw new Error(
`gen-scoped-events: ${this.violations.length} scoped-event contract violation(s):\n`
+ this.violations.map(violation => ` - ${violation}`).join('\n'),
)
}
return [
'/**',
' * Generated scoped-event routing-subject resolvers for dsh-scope invariants.',
' * Do not edit by hand; run `pnpm run gen-scoped-events`.',
' *',
' * @module @deepseek-ai/dsh-scope/scoped-events.generated',
' */',
'',
'type ScopedSubjectResolver = (args: readonly unknown[]) => unknown',
'',
'const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | null>> = Object.freeze({',
...resolvers.map(({ event, candidate }) => {
if (candidate === null) return ` '${event}': null,`
const subject = candidate.property === undefined
? `args[${candidate.parameter}]`
: `(args[${candidate.parameter}] as Record<string, unknown>)[${quote(candidate.property)}]`
return ` '${event}': args => ${subject},`
}),
'})',
'',
'/**',
' * Resolve the routing key named by one scoped event payload. A null',
' * resolver means the payload cannot expose its external routing key, so the',
' * invariant checks carrier presence only.',
' * @param event - runtime Cordis event name.',
' * @returns the generated subject resolver, null for presence-only,',
' * or undefined when the event is not scope-filtered.',
' */',
'export function scopedSubjectResolverFor(event: string): ScopedSubjectResolver | null | undefined {',
' return scopedSubjectResolvers[event]',
'}',
'',
].join('\n')
}
/** Resolve one named function declaration from a known source file. */
private functionDeclaration(relativePath: string, name: string): ts.FunctionDeclaration {
const sourceFile = this.project.sourceFile(relativePath)
const declaration = sourceFile.statements.find((statement): statement is ts.FunctionDeclaration => {
return ts.isFunctionDeclaration(statement) && statement.name?.text === name
})
if (!declaration) throw new Error(`gen-scoped-events: cannot resolve function ${name} from ${relativePath}`)
return declaration
}
/** Resolve one named type-alias symbol from a known source file. */
private typeAliasSymbol(relativePath: string, name: string): ts.Symbol {
const sourceFile = this.project.sourceFile(relativePath)
const declaration = sourceFile.statements.find((statement): statement is ts.TypeAliasDeclaration => {
return ts.isTypeAliasDeclaration(statement) && statement.name.text === name
})
const symbol = declaration && this.checker.getSymbolAtLocation(declaration.name)
if (!symbol) throw new Error(`gen-scoped-events: cannot resolve type ${name} from ${relativePath}`)
return symbol
}
/** Collect every real scopeTarget(base, key) base/key type contract. */
private collectScopeTargetContracts(): ScopeTargetContract[] {
const contracts: ScopeTargetContract[] = []
const visit = (sourceFile: ts.SourceFile, node: ts.Node): void => {
if (ts.isCallExpression(node)
&& this.checker.getResolvedSignature(node)?.declaration === this.scopeTargetDeclaration) {
const base = node.arguments[0]
const key = node.arguments[1]
if (!base || !key) {
const source = pointer(this.project.relativePath(sourceFile), sourceFile, node)
this.violations.push(`${source} calls scopeTarget without base and key arguments`)
} else {
contracts.push({
baseType: this.checker.getTypeAtLocation(base),
keyType: this.checker.getTypeAtLocation(key),
source: pointer(this.project.relativePath(sourceFile), sourceFile, node),
})
}
}
ts.forEachChild(node, (child) => { visit(sourceFile, child) })
}
for (const sourceFile of this.packageSources) visit(sourceFile, sourceFile)
return contracts
}
/** Collect every Events member and derive its generated resolver. */
private collectScopedEventResolvers(contracts: readonly ScopeTargetContract[]): ScopedEventResolver[] {
const resolvers: ScopedEventResolver[] = []
for (const sourceFile of this.packageSources) {
const rel = this.project.relativePath(sourceFile)
const visit = (node: ts.Node): void => {
if (ts.isInterfaceDeclaration(node) && node.name.text === 'Events' && isCordisModuleInterface(node)) {
for (const member of node.members) {
if (!ts.isMethodSignature(member) || !ts.isStringLiteral(member.name)) continue
const event = member.name.text
const raw = rawJsDoc(sourceFile.text, member)
const where = `event '${event}' (${pointer(rel, sourceFile, member)})`
const tag = parseScopeTag(raw, where, this.violations)
const thisParameter = member.parameters.find(isThisParameter)
const scopedBase = thisParameter && this.scopedBaseType(thisParameter)
if (!scopedBase) {
if (raw.includes(SCOPE_DOC_MARKER)) {
this.violations.push(
`${where} documents scope-filtered dispatch but its signature has no this: Scoped<...> receiver`,
)
}
if (tag.present) {
this.violations.push(`${where} has @dshScopeScan metadata but is not a Scoped event`)
}
continue
}
if (!raw.includes(SCOPE_DOC_MARKER)) {
this.violations.push(
`${where} has this: Scoped<...> but its JSDoc does not explain "${SCOPE_DOC_MARKER}"`,
)
}
const keyType = this.routingKeyType(where, scopedBase, contracts)
if (!keyType) continue
const candidates = this.subjectCandidates(member)
.filter(candidate => this.typesEquivalent(candidate.type, keyType))
if (candidates.length > 1) {
this.violations.push(
`${where} has multiple routing-key candidates for ${this.typeText(keyType)}: `
+ candidates.map(candidate => `${candidate.path}: ${this.typeText(candidate.type)}`).join(', '),
)
continue
}
if (candidates.length === 0) {
if (!tag.unsupported) {
const keyLabel = this.typeText(keyType)
this.violations.push(
`${where} exposes no parameter or one-level property equivalent to routing key type ${keyLabel}; `
+ 'add @dshScopeScan unsupported only when the key is intentionally absent from the payload',
)
}
resolvers.push({ event, candidate: null })
continue
}
if (tag.unsupported) {
this.violations.push(
`${where} has unnecessary @dshScopeScan unsupported; ${candidates[0]?.path} exposes the routing key`,
)
continue
}
resolvers.push({ event, candidate: candidates[0] ?? null })
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
}
return resolvers.sort((left, right) => left.event.localeCompare(right.event))
}
/** Extract the Base type from one exact this: Scoped<Base> parameter. */
private scopedBaseType(parameter: ts.ParameterDeclaration): ts.Type | undefined {
const type = this.checker.getTypeAtLocation(parameter)
if (type.aliasSymbol !== this.scopedSymbol) return undefined
return type.aliasTypeArguments?.[0]
}
/** Resolve one unambiguous key type for a scoped carrier base. */
private routingKeyType(
where: string,
scopedBase: ts.Type,
contracts: readonly ScopeTargetContract[],
): ts.Type | undefined {
const matches = contracts.filter((contract) => {
return this.checker.isTypeAssignableTo(this.normalizedType(contract.baseType), this.normalizedType(scopedBase))
})
if (matches.length === 0) {
this.violations.push(
`${where} has no matching scopeTarget(base, key) call for carrier base ${this.typeText(scopedBase)}`,
)
return undefined
}
const keyTypes: ts.Type[] = []
for (const match of matches) {
if (!keyTypes.some(type => this.typesEquivalent(type, match.keyType))) keyTypes.push(match.keyType)
}
if (keyTypes.length > 1) {
this.violations.push(
`${where} carrier base ${this.typeText(scopedBase)} has inconsistent routing-key types: `
+ matches.map(match => `${this.typeText(match.keyType)} at ${match.source}`).join(', '),
)
return undefined
}
return keyTypes[0]
}
/** Enumerate every payload parameter and every accessible one-level property. */
private subjectCandidates(member: ts.MethodSignature): SubjectCandidate[] {
const candidates: SubjectCandidate[] = []
let runtimeIndex = 0
for (const parameter of member.parameters) {
if (isThisParameter(parameter)) continue
const directPath = `args[${runtimeIndex}]`
const parameterType = this.checker.getTypeAtLocation(parameter)
candidates.push({ path: directPath, parameter: runtimeIndex, type: parameterType })
for (const property of this.checker.getPropertiesOfType(this.normalizedType(parameterType))) {
const name = property.getName()
if (name.startsWith('__@') || hasNonPublicDeclaration(property)) continue
candidates.push({
path: `${directPath}.${name}`,
parameter: runtimeIndex,
property: name,
type: this.checker.getTypeOfSymbolAtLocation(property, parameter),
})
}
runtimeIndex += 1
}
return dedupeCandidates(candidates)
}
/** Compare exact Program type identities after removing null and undefined. */
private typesEquivalent(left: ts.Type, right: ts.Type): boolean {
const normalizedLeft = this.normalizedType(left)
const normalizedRight = this.normalizedType(right)
if (normalizedLeft.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return false
if (normalizedRight.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return false
return normalizedLeft === normalizedRight
}
/** Remove null and undefined from a routing or candidate type. */
private normalizedType(type: ts.Type): ts.Type {
return this.checker.getNonNullableType(type)
}
/** Render a stable diagnostic type label. */
private typeText(type: ts.Type): string {
return this.checker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation)
}
}
/** Return whether an Events interface is inside declare module '@deepseek-ai/cordis'. */
function isCordisModuleInterface(node: ts.InterfaceDeclaration): boolean {
const block = node.parent
const declaration = block.parent
return ts.isModuleBlock(block)
&& ts.isModuleDeclaration(declaration)
&& ts.isStringLiteral(declaration.name)
&& declaration.name.text === '@deepseek-ai/cordis'
}
/** Return whether a parameter is the explicit TypeScript this receiver. */
function isThisParameter(parameter: ts.ParameterDeclaration): boolean {
return ts.isIdentifier(parameter.name) && parameter.name.text === 'this'
}
/** Parse and validate the optional @dshScopeScan unsupported tag. */
function parseScopeTag(raw: string, where: string, violations: string[]): ScopeTag {
const tags = raw
.replace(/^\/\*\*/, '')
.replace(/\*\/$/, '')
.split('\n')
.map(line => line.replace(/^\s*\*?\s?/, '').trim())
.filter(line => line.startsWith('@dshScopeScan'))
if (tags.length > 1) violations.push(`${where} has multiple @dshScopeScan tags`)
if (tags.length === 0) return { present: false, unsupported: false }
const unsupported = tags[0] === '@dshScopeScan unsupported'
if (!unsupported) {
violations.push(
`${where} has invalid scoped-event scan metadata '${tags[0]}'; expected '@dshScopeScan unsupported'`,
)
}
return { present: true, unsupported }
}
/** Return whether a property has a private or protected declaration. */
function hasNonPublicDeclaration(symbol: ts.Symbol): boolean {
return (symbol.declarations ?? []).some((declaration) => {
if (!ts.canHaveModifiers(declaration)) return false
return ts.getModifiers(declaration)?.some((modifier) => {
return modifier.kind === ts.SyntaxKind.PrivateKeyword || modifier.kind === ts.SyntaxKind.ProtectedKeyword
}) ?? false
})
}
/** Deduplicate candidate paths contributed by merged/intersection types. */
function dedupeCandidates(candidates: readonly SubjectCandidate[]): SubjectCandidate[] {
const seen = new Set<string>()
return candidates.filter((candidate) => {
if (seen.has(candidate.path)) return false
seen.add(candidate.path)
return true
})
}
/** Quote a generated property key as a single-quoted TypeScript string. */
function quote(value: string): string {
return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
}
/**
* Render the generated scoped-event resolver module for one repository root.
* @param projectRoot - repository root carrying tsconfig.host.json.
* @returns complete generated TypeScript source.
*/
export function renderScopedEvents(projectRoot: string = root): string {
return new ScopedEventGenerator(new TypeScriptProject(projectRoot)).render()
}
/** Generate or freshness-check the fixed dsh-scope source file. */
function main(): void {
const content = renderScopedEvents()
const output = resolve(root, OUT)
if (process.argv.includes('--check')) {
const committed = existsSync(output) ? readFileSync(output, 'utf8') : null
if (committed === content) {
console.log(`gen-scoped-events: ${OUT} is up to date.`)
return
}
console.error(`gen-scoped-events: ${OUT} is stale. Run \`pnpm run gen-scoped-events\` and commit it.`)
process.exit(1)
}
writeFileSync(output, content)
console.log(`gen-scoped-events: wrote ${OUT}.`)
}
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
main()
}
+2
View File
@@ -36,6 +36,8 @@ describe('THIRD_PARTY_NOTICES.md', () => {
expect(generated).toContain(`| [\`numpy\`](https://github.com/numpy/numpy) | ${primaryRuntimeLock.pythonPackages.numpy} | BSD-3-Clause |`)
expect(generated).toContain('## LibreOffice conversion kit')
expect(generated).toContain('Recipients must have access to those corresponding sources and notices.')
expect(generated.split('## Development-only npm dependencies')[0]).toContain('| [`chrome-devtools-frontend`]')
expect(generated).toContain('third-party license and notice files supplied by the npm source')
expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(generated)
})
})
+9 -1
View File
@@ -15,6 +15,7 @@ import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutB
import parseSpdx from 'spdx-expression-parse'
import primaryRuntimeLock from './primary-runtime/lock.json' with { type: 'json' }
import { browserBundledExternals } from './browser-bundled-externals.ts'
import { DEVTOOLS_NPM_VERSION, DEVTOOLS_SOURCE_REVISION } from '../packages/experimental/inspector/scripts/devtools/source.ts'
const root = resolve(import.meta.dirname, '..')
const OUT = 'THIRD_PARTY_NOTICES.md'
@@ -743,6 +744,8 @@ ${rows.join('\n')}
*/
export async function render(): Promise<string> {
const browser = await browserBundledExternals(root)
// The Inspector's Host build hook compiles this standalone frontend outside the Client bundle graph.
browser.add('chrome-devtools-frontend')
// The linked-manifest cache is keyed by name only, so it must not outlive
// the manifests map it was resolved from; render() owns that single load.
workspaceLinkedManifestCache.clear()
@@ -793,13 +796,18 @@ ${renderNpmTable(runtimeDeps)}
pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
${patchedLines.join('\n')}
## Chrome DevTools frontend
The optional experimental Inspector distributes a locally compiled copy of [chrome-devtools-frontend ${DEVTOOLS_NPM_VERSION}](https://www.npmjs.com/package/chrome-devtools-frontend/v/${DEVTOOLS_NPM_VERSION}), from upstream revision [${DEVTOOLS_SOURCE_REVISION}](https://chromium.googlesource.com/devtools/devtools-frontend/+/${DEVTOOLS_SOURCE_REVISION}). The build includes the Chromium [BSD-3-Clause license](packages/experimental/inspector/assets/devtools/LICENSE) and the third-party license and notice files supplied by the npm source. The Chromium root license does not replace those dependencies' licenses.
${renderClaudeDistribution(claudeDistribution)}
${kitRuntime ? `
## LibreOffice conversion kit
${[...LIBREOFFICE_PACKAGES].map(name => `\`${name}\``).join(', ')} declare MPL-2.0, which remains outside the permissive-license allowlist; the notices check accepts only these package identities at those terms. The [distribution decision](.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) records the source obligations.
The [kit repository](https://github.com/deepseek-harness/libreoffice-kit) supplies the corresponding LibreOffice source pin, modifications, build instructions, Node API, and artifact validation. Its engine packages retain their license and third-party notices; the Node API retains its MPL-2.0 declaration and NOTICE. Recipients must have access to those corresponding sources and notices.
The [kit repository](https://github.com/deepseek-ai/dsh-libreoffice-kit) supplies the corresponding LibreOffice source pin, modifications, build instructions, Node API, and artifact validation. Its engine packages retain their license and third-party notices; the Node API retains its MPL-2.0 declaration and NOTICE. Recipients must have access to those corresponding sources and notices.
` : ''}
## Development-only npm dependencies
+14 -35
View File
@@ -53,7 +53,8 @@ import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor'
import TerminalSessionService from '@deepseek-ai/dsh-terminal'
import * as ToolPty from '@deepseek-ai/dsh-tool-terminal'
import * as ToolGoal from '@deepseek-ai/dsh-tool-goal'
import * as ToolSchedule from '@deepseek-ai/dsh-schedule'
import * as ToolSchedule from '@deepseek-ai/dsh-tool-schedule'
import type ScheduleService from '@deepseek-ai/dsh-schedule'
import Lsp from '@deepseek-ai/dsh-lsp'
import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp'
import * as ToolSkill from '@deepseek-ai/dsh-tool-skill'
@@ -133,26 +134,6 @@ function registerCatalogSubagentProvider(ctx: Context, name: string): void {
/** Minted child-scope keys for packages whose tools are never global. */
const catalogChildScopes = new WeakMap<Context, Agent>()
/**
* Install one scope-local tool package into an agent-like child scope for
* schema harvest, without starting a model, Agent loop, or persistence backend.
* @param ctx - catalog context owning the scope.
* @param mountScoped - package installer for the scoped context.
* @param key - agent-like scope key exposed to the package's scope selector.
* @param inject - services the package installer must await before mounting.
*/
async function mountCatalogChildScope(
ctx: Context,
mountScoped: (childCtx: Context) => void,
key: Agent = { id: SessionId('tool-catalog-child') } as Agent,
inject: string[] = ['tools', 'systemPrompt', 'subagents'],
): Promise<void> {
await ctx.plugin(Object.assign((inner: Context) => {
mountScoped(createScope(inner, key).ctx)
}, { inject }))
catalogChildScopes.set(ctx, key)
}
/**
* Tool package plus its hand-maintained boot recipe. The caller mounts the
* prompt and registry; each recipe supplies only package-specific seams and
@@ -248,13 +229,13 @@ const TOOL_PACKAGES: ToolPackage[] = [
dir: 'tool-ask-user',
source: 'packages/interaction/tool-ask-user/src/index.ts',
requires: ['ctx.tools', 'ctx.userQuestions'],
writes: ['tool/call', 'tool/result after a UI/provider answers the question'],
writes: ['tool/call', 'tool/result after an answer or timeout', 'late user/message'],
async mount(ctx) {
await ctx.plugin(UserQuestionService)
await ctx.plugin(ToolAskUser)
},
note:
'ask_user_question pauses the tool call until the active UI provider returns a human answer.',
'ask_user_question keeps the original blocking behavior by default; set `mode: timed` to opt into a foreground timeout and pending result while the question remains answerable. In timed mode, `timeout: -1` keeps that call blocking indefinitely.',
},
{
pkg: '@deepseek-ai/dsh-tools',
@@ -447,22 +428,20 @@ const TOOL_PACKAGES: ToolPackage[] = [
'create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds.',
},
{
pkg: '@deepseek-ai/dsh-schedule',
dir: 'schedule',
source: 'packages/schedule/schedule/src/tools.ts',
requires: ['ctx.tools', 'ctx.schedule', 'a live root Agent'],
pkg: '@deepseek-ai/dsh-tool-schedule',
dir: 'tool-schedule',
source: 'packages/schedule/tool-schedule/src/index.ts',
requires: ['ctx.tools', 'ctx.schedule'],
writes: ['tool/call', 'Schedule storage domain create, update, or delete', 'tool/result'],
async mount(ctx) {
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('tool-catalog-schedule'))
const agent = { id: session.id, session } as Agent
await mountCatalogChildScope(ctx, (childCtx) => {
ToolSchedule.registerScheduleTools(ctx, childCtx, agent)
}, agent, ['tools', 'systemPrompt'])
// Schema harvest never executes a tool, so the Host service is a
// declaration-only stub.
ctx.provide('schedule', {} as ScheduleService)
await ctx.plugin(ToolSchedule)
},
scope: ctx => catalogChildScopes.get(ctx) as Agent,
note:
'Registered in live root Agent scopes while the Schedule service is loaded. '
'A preset or Agent scope mounts this package; the preset decides which agents receive the four '
+ 'management tools. Each call acts on the calling Agent\'s Session. '
+ 'Accepts after_seconds, explicit absolute at, bounded fixed-rate every_seconds, daily and weekly '
+ 'local times in an explicit IANA zone, and cron as a five-field expression. '
+ 'Management uses the Host storage domain; due messages resume the original Session.',
+7 -9
View File
@@ -20,7 +20,6 @@ describe('generated tsconfig package aliases', () => {
expect(session).toEqual({
specifier: '@deepseek-ai/dsh-session',
source: './packages/core/session/src',
hasInvariant: true,
})
// Sorted, so a package added anywhere lands in a stable spot in the diff.
expect([...aliases].sort((a, b) => a.specifier.localeCompare(b.specifier))).toEqual(aliases)
@@ -31,15 +30,15 @@ describe('generated tsconfig package aliases', () => {
it('yields to a hand-written alias and closes without a trailing comma', () => {
const aliases = [
{ specifier: '@deepseek-ai/dsh-a', source: './packages/g/a/src', hasInvariant: true },
{ specifier: '@deepseek-ai/dsh-b', source: './packages/g/b/src', hasInvariant: false },
{ specifier: '@deepseek-ai/dsh-a', source: './packages/g/a/src' },
{ specifier: '@deepseek-ai/dsh-b', source: './packages/g/b/src' },
{ specifier: '@deepseek-ai/dsh-c', source: './packages/g/c/src' },
]
const body = renderAliases(aliases, new Set(['@deepseek-ai/dsh-a']))
// The hand-written bare alias is skipped; its /invariant sibling is not.
expect(body).toBe([
' "@deepseek-ai/dsh-a/invariant": ["./packages/g/a/src/invariant.ts"]',
' "@deepseek-ai/dsh-b": ["./packages/g/b/src"]',
' "@deepseek-ai/dsh-c": ["./packages/g/c/src"]',
].join(',\n'))
expect(body.endsWith(',')).toBe(false)
})
@@ -72,7 +71,7 @@ describe('generated tsconfig package aliases', () => {
// resolve through the workspace symlink to built lib/types instead.
expect(uncoveredPackages(
['@deepseek-ai/dsh-a', '@deepseek-ai/dsh-b'],
new Set(['@deepseek-ai/dsh-a', '@deepseek-ai/dsh-a/invariant']),
new Set(['@deepseek-ai/dsh-a']),
)).toEqual(['@deepseek-ai/dsh-b'])
expect(uncoveredPackages(['@deepseek-ai/dsh-a'], new Set(['@deepseek-ai/dsh-a']))).toEqual([])
@@ -89,10 +88,9 @@ describe('generated tsconfig package aliases', () => {
it('leaves no wildcard that probes every package group', () => {
const config = readFileSync(resolve(root, 'tsconfig.base.json'), 'utf8')
// These two listed one candidate per group, so resolving a package late in
// the list cost a filesystem probe — and under tsx a decorated module
// A wildcard lists one candidate per group, so resolving a package late in
// the list costs a filesystem probe — and under tsx a decorated module
// error — for every group before it.
expect(config).not.toContain('"@deepseek-ai/dsh-*":')
expect(config).not.toContain('"@deepseek-ai/dsh-*/invariant":')
})
})
+6 -15
View File
@@ -2,10 +2,9 @@
* Expand the workspace path aliases that a wildcard would otherwise resolve by
* probing every package group in turn.
*
* `tsconfig.base.json` is the resolution facade for the whole repository, and
* two of its aliases used one key per *group* rather than per package:
* `@deepseek-ai/dsh-*` listed 49 candidate globs and `@deepseek-ai/dsh-*\/invariant`
* listed 45. TypeScript and tsx try those candidates in order, so a specifier
* `tsconfig.base.json` is the resolution facade for the whole repository. A
* `@deepseek-ai/dsh-*` wildcard would list one candidate glob per package
* group. TypeScript and tsx try those candidates in order, so a specifier
* whose package sits late in the list pays for every earlier miss. Under tsx's
* ESM hook each miss is an `ERR_MODULE_NOT_FOUND` that Node decorates with a
* full CommonJS resolution walk, which dominated source-launch boot.
@@ -37,8 +36,6 @@ interface PackageAlias {
readonly specifier: string
/** Repository-relative source directory, e.g. `./packages/session/session/src`. */
readonly source: string
/** Whether the package carries `src/invariant.ts`, which earns a second alias. */
readonly hasInvariant: boolean
}
/**
@@ -62,7 +59,6 @@ function packageName(manifest: string): string | undefined {
interface WorkspacePackage {
readonly group: string
readonly directory: string
readonly packageDir: string
readonly name: string
}
@@ -80,7 +76,7 @@ function workspacePackages(): WorkspacePackage[] {
const packageDir = join(groupDir, directory)
const name = packageName(join(packageDir, 'package.json'))
if (name === undefined || !name.startsWith(PREFIX)) continue
if (existsSync(join(packageDir, 'src'))) found.push({ group, directory, packageDir, name })
if (existsSync(join(packageDir, 'src'))) found.push({ group, directory, name })
}
}
return found
@@ -100,7 +96,7 @@ function workspacePackages(): WorkspacePackage[] {
*/
export function collectPackageAliases(): PackageAlias[] {
const bySpecifier = new Map<string, PackageAlias & { directory: string }>()
for (const { group, directory, packageDir, name } of workspacePackages()) {
for (const { group, directory, name } of workspacePackages()) {
if (name !== `${PREFIX}${directory}`) continue
const previous = bySpecifier.get(name)
if (previous !== undefined) {
@@ -112,12 +108,11 @@ export function collectPackageAliases(): PackageAlias[] {
bySpecifier.set(name, {
specifier: name,
source: `./packages/${group}/${directory}/src`,
hasInvariant: existsSync(join(packageDir, 'src', 'invariant.ts')),
directory: `${group}/${directory}`,
})
}
return [...bySpecifier.values()]
.map(({ specifier, source, hasInvariant }) => ({ specifier, source, hasInvariant }))
.map(({ specifier, source }) => ({ specifier, source }))
.sort((left, right) => left.specifier.localeCompare(right.specifier))
}
@@ -182,10 +177,6 @@ export function renderAliases(aliases: readonly PackageAlias[], handWritten: Rea
if (!handWritten.has(alias.specifier)) {
lines.push(` ${JSON.stringify(alias.specifier)}: [${JSON.stringify(alias.source)}]`)
}
const invariant = `${alias.specifier}/invariant`
if (alias.hasInvariant && !handWritten.has(invariant)) {
lines.push(` ${JSON.stringify(invariant)}: [${JSON.stringify(`${alias.source}/invariant.ts`)}]`)
}
}
// The region closes `paths`, so the last member carries no trailing comma.
return lines.join(',\n')
+122
View File
@@ -0,0 +1,122 @@
/**
* DSH_COVERAGE_TEST_TIMEOUT_MS reaches every inline Vitest project through the
* root config (coverageTestTimeoutOptions owns the rule and why a CLI flag
* cannot carry it). A budget below the fixture's waits must end all three
* inside both projects; unset must keep Vitest's defaults, under which the
* fixture's per-test and hook waits pass and its poll ends at the 1000 ms
* default; a malformed value must fail at config load.
*/
import { spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { COVERAGE_TEST_TIMEOUT_ENV } from './coverage-partitions.ts'
const root = resolve(import.meta.dirname, '..')
const vitestCli = fileURLToPath(new URL('../node_modules/vitest/vitest.mjs', import.meta.url))
const fixture = 'scripts/fixtures/lane-test-budget.fixture.ts'
const fixtureLine = /lane-test-budget\.fixture\.ts \((\d+) tests(?: \| (\d+) failed)?\)/g
/** One spawned Vitest run takes a few seconds; a hung child must not hold the worker. */
const CHILD_TIMEOUT_MS = 60_000
let temporaryRoot: string | undefined
let configPath = ''
// The root config with every project's inventory narrowed to the fixture, so
// the fixture never joins the ordinary inventory and still runs once per
// project. Absolute import in POSIX spelling because the temporary directory
// is outside the repository; the sibling package.json keeps Vite bundling the
// config as ESM, which the repository root's "type" otherwise supplies.
beforeAll(() => {
temporaryRoot = mkdtempSync(join(tmpdir(), 'dsh-lane-test-budget-'))
configPath = join(temporaryRoot, 'vitest.config.ts')
writeFileSync(join(temporaryRoot, 'package.json'), '{ "type": "module" }\n', 'utf8')
writeFileSync(configPath, [
`import base from ${JSON.stringify(resolve(root, 'vitest.config.ts').split('\\').join('/'))}`,
'export default {',
' ...base,',
' test: {',
' ...base.test,',
' projects: (base.test.projects ?? []).map(project => ({',
' ...project,',
` test: { ...project.test, include: [${JSON.stringify(fixture)}] },`,
' })),',
' },',
'}',
'',
].join('\n'), 'utf8')
})
afterAll(() => {
if (temporaryRoot !== undefined) rmSync(temporaryRoot, { recursive: true, force: true })
})
function runFixture(budget: string | undefined): { status: number | null; output: string } {
const env: NodeJS.ProcessEnv = { ...process.env, NO_COLOR: '1' }
for (const name of Object.keys(env)) {
// The parent worker's Vitest state and the coverage coordinator's own
// variables describe this process, not the child; the Actions reporter
// would otherwise annotate the parent job with the fixture's failures.
if (name.startsWith('VITEST') || name.startsWith('DSH_COVERAGE_') || name === 'GITHUB_ACTIONS') Reflect.deleteProperty(env, name)
}
if (budget !== undefined) env[COVERAGE_TEST_TIMEOUT_ENV] = budget
const child = spawnSync(process.execPath, [vitestCli, 'run', '--config', configPath], {
cwd: root,
env,
encoding: 'utf8',
maxBuffer: 16 * 1024 * 1024,
timeout: CHILD_TIMEOUT_MS,
killSignal: 'SIGKILL',
})
if (child.error !== undefined) throw child.error
expect(child.signal, 'the child Vitest run ended through a signal').toBeNull()
return { status: child.status, output: `${child.stdout}\n${child.stderr}` }
}
/** Per-project fixture summary lines, `[total, failed]` each, in output order. */
function fixtureSummaries(output: string): Array<[number, number]> {
return [...output.matchAll(fixtureLine)].map(([, total, failed]) => [Number(total), Number(failed ?? 0)])
}
describe('lane test budget', () => {
it('ends per-test, hook, and expect.poll waits in both inline projects', { timeout: 90_000 }, () => {
const { status, output } = runFixture('200')
expect(output).toMatch(/\|thread-safe\| scripts\/fixtures\/lane-test-budget\.fixture\.ts/)
expect(output).toMatch(/\|process-bound\| scripts\/fixtures\/lane-test-budget\.fixture\.ts/)
expect(fixtureSummaries(output)).toEqual([[3, 3], [3, 3]])
expect(output.match(/Test timed out in 200ms\./g)).toHaveLength(2)
expect(output.match(/Hook timed out in 200ms\./g)).toHaveLength(2)
expect(output.match(/Matcher did not succeed in time\./g)).toHaveLength(2)
// At most ~20 attempts fit into the 200 ms budget; the target is 10 000.
const polled = [...output.matchAll(/expected (\d+) to be greater than 10000/g)].map(([, count]) => Number(count))
expect(polled).toHaveLength(2)
for (const count of polled) expect(count).toBeLessThan(100)
expect(status).toBe(1)
})
it('keeps Vitest defaults when the budget is unset', { timeout: 90_000 }, () => {
const { status, output } = runFixture(undefined)
// 600 ms waits pass under the 5000 ms per-test and 10 000 ms hook defaults;
// the poll case ends at the 1000 ms default and can only take longer
// under load, never less.
expect(fixtureSummaries(output)).toEqual([[3, 1], [3, 1]])
expect(output.match(/Matcher did not succeed in time\./g)).toHaveLength(2)
expect(output).not.toMatch(/Test timed out in|Hook timed out in/)
const pollDurations = [...output.matchAll(/× expect\.poll budget (\d+)ms/g)].map(([, ms]) => Number(ms))
expect(pollDurations).toHaveLength(2)
for (const ms of pollDurations) expect(ms).toBeGreaterThanOrEqual(1000)
expect(status).toBe(1)
})
it('refuses a malformed budget at config load before any test runs', { timeout: 90_000 }, () => {
const { status, output } = runFixture('90000ms')
expect(output).toContain(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer, got "90000ms".`)
expect(fixtureSummaries(output)).toEqual([])
expect(status).toBe(1)
})
})
+1 -33
View File
@@ -502,9 +502,6 @@
"packages/client/ui-chat/tests/conversation.client.spec.ts": {
"299fcafb8671655372c99c1dbe14e2705536d569bf475ca09693f87b3b1182da": 1
},
"packages/client/ui-chat/tests/skill-name-projector.client.spec.ts": {
"cc5bd784736472e7293f8b0e2d889b0cf1c156a218457571390871164e35b5d5": 1
},
"packages/client/ui-chat/tests/tool-call-tree.client.spec.ts": {
"427f7efc431dec431f6707ea0e4431c54fafc97efbcc9c93865d29f2488300d9": 1
},
@@ -754,7 +751,6 @@
},
"packages/client/ui-tool/tests/image-card.client.spec.tsx": {
"1a5ae4c2528dae3d43a3decbe30410647b6e60104341e070fd4de2c6a668a607": 1,
"1d34efc5aadced56f018a7f1b453e5b2117ab13ab6e414a7bf32584a9b168af4": 1,
"7266b2c682605ac318dad266d9371b6061a73746beac20f2fd94e9391eca5d4a": 1,
"a4968fcd795d3444b0896dcd7e9688e84335d4fcc72e138c46f951d157a421c7": 1
},
@@ -787,20 +783,15 @@
"189b21b2a9360e0384602b7a81c935ca9a419f9d97d526fb1f6684343486c4fe": 1,
"24334994394c1d4336d192b2f38986beadcd0f6b235108370c3a736c34eebe98": 1,
"33e462c9f8d9ef55f1b54a408f047caaa9467facc4fc88887fa5979fdce6c09f": 1,
"3e385a12e8e6c592fbcc51a8e897d2ad4f21ab7a0c8ef00727e1b37b52af2c16": 1,
"455072902745ebd120bdeaaebf1b00b2d747a300409066715375263bcc4e8a63": 1,
"4df26af834cb4f8d55bb6908736ddeb6971b4c4c1cbd5a9ace2f9cfe9c0b94e4": 1,
"6078d7e1390a0c3c94892edd74526a76e6cbbda8360656bf9f97d367371c0c4c": 1,
"71b9c7a18658556ab36c9af394f29dac5885fab402304b0b6f4d5127d6acdeff": 1,
"8bd3bc4fed398e1420c3ff656c7f9847a9b5aba1cf8b653623907922004ff038": 1,
"9117562f15005ef8ec18dbad9c29cdc723c4af2c6a9c217af3cd7d8380de999b": 1,
"98169406d3c872458f70f3f8f70abdd6eeaca296816ce91cdcf8392cfa6a77bf": 1,
"afecf8092cd8de095ffbe00a1df00816897e8205854fd33a9d2beeaee88eca95": 1,
"b32e3f8c876615b2607309ee288de85817f0dd0480c4e16dfb3695fb84aa2674": 1,
"b389d62887b2c8991189e944ab6e6da49be6e907e3d85417926cd578cc212f73": 1,
"b8af8a60d1b9ee562bc77cc2f504fac357dd8b8ea37aea670d768d6a5b4ae285": 1,
"bc448993c30c4cc92c43cc077a2b00d033ef01a64427c609a8d89b0ba257dfcd": 1,
"c0d6774c76d86ae3c7881f2292fc41f3fb30e74ebd41a8130a3000c0466bc2b5": 1,
"e888d93b9171a1022992aecebca9c67f8b564ae541bbf0f9129eda3ea423b95c": 1,
"efed1d00d0f288a0c2716c88ee57ba53ace8f627abba4bd2c20de596755b5bad": 1,
"f37cbef85628f8df72f3d455868ac0715105f463a9bd858f10e2cb5f984baefb": 1
@@ -907,16 +898,9 @@
"336d4996145aa5694f6c8fd39fee7d248ba667330de106330224e92fd4511554": 1,
"6061de0a1e01547eb1abf9d4e1b5fa79fda112fa35dda980775491123413dcce": 1
},
"packages/core/agent/tests/invariant.spec.ts": {
"2ed6179a261a6cd1d9e84d8e8c5858eef36330853f2af62ea4222e00a9e578a3": 1
},
"packages/core/scope/src/index.ts": {
"e590ead7cd68f7ec90a646dc2622e3631bf63ee3e7a04cf4c39a4f1b07184865": 1
},
"packages/core/scope/tests/invariant.spec.ts": {
"001bfd2c7b05e2c76752d6895be67c0640b43aed6b8c9725c7321f02db6de745": 1,
"6cd200a3be79594ca783f345b6047758c19a4c2d744dbf4564ef78ccba0433ca": 1
},
"packages/core/scope/tests/store.spec.ts": {
"612bd4047fa63f957ecb33d7fc7544c489301c3c0592e9a42dafe2cb85311165": 1
},
@@ -987,9 +971,6 @@
"packages/core/tools/src/index.ts": {
"6061de0a1e01547eb1abf9d4e1b5fa79fda112fa35dda980775491123413dcce": 1
},
"packages/core/tools/src/ptc.ts": {
"dfa48d6930621bc7332e15ad2146cfd49542a64075f820bd3e1f48083679bfd1": 1
},
"packages/core/tools/src/schema.ts": {
"62870121f1e1644b67c62606e94a375bb40f880cc88edf7cd6baab0249e9da46": 1
},
@@ -1186,7 +1167,7 @@
},
"packages/experimental/inspector/tests/plugin.client.spec.ts": {
"148509a0193d8d3c16a606a15f5145770f454c2ebc70dd0ccaa51fc1a0f43e99": 1,
"2066ad746457f15b6737d62124c4ec81f2140fc3fa425187eac8e3b1c3b6ab0b": 9
"2066ad746457f15b6737d62124c4ec81f2140fc3fa425187eac8e3b1c3b6ab0b": 8
},
"packages/experimental/ptc-runtime-python/src/index.ts": {
"b121cee14635b00f7891217013e4682493cf96f676710be60722fdf8b862aea3": 1
@@ -1338,7 +1319,6 @@
"0c7b023d04a2d6744d9844f27de8438231e642fd058e2b75e113a1996711014a": 1
},
"packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts": {
"60655aea47d3d8667f90341b58e3faaf64d12670b9592af837aa40ecf7b83583": 1,
"d423bbcd1caff7aab2e2de52564efbc9061e775620a530157805a43d851c6fe9": 1
},
"packages/interaction/user-approval/tests/approval.spec.ts": {
@@ -1457,9 +1437,6 @@
"packages/plan/plan-mode/src/index.ts": {
"1422d291f0118635ac06f265a9018dd86dbbd4a97f76a7ed5abb5c2a42e81b53": 1
},
"packages/plan/plan-mode/tests/invariant.spec.ts": {
"6f54ffd70daee0f9ad7b6d42e14b6aae5af1ec5a39004d541ca224facc2ad576": 1
},
"packages/plan/plan-mode/tests/plan-mode.spec.ts": {
"036770a58292b891357228338d9876329b657defb6371ecb0b7133fc117771ce": 1,
"0ca57693819b64476ce22f00311089435afd085d4a38cfff61852dbf3cfa38c3": 1,
@@ -1682,9 +1659,6 @@
"packages/session/session-log-deepseek/tests/feedback-composition.spec.ts": {
"59c63f734e8bba862325f776e3fcbebd78a84cfb4161f8df41c216950d9bf351": 1
},
"packages/session/session-log-deepseek/tests/invariant.spec.ts": {
"7f99ec5e224c4475fc9ce0069040eeef36add5dd27d0941e74a814786ad90062": 1
},
"packages/session/session-log-deepseek/tests/upload.spec.ts": {
"0c25d15bf9489ae7483b0a31a64a047c12174602071e0aa29714e0f9196889c6": 1,
"119461c1a63f392b4db54f75a86f852f49b72b7f0342c04d1bc1c8d1677f37c1": 3,
@@ -1852,9 +1826,6 @@
"packages/storage/storage-domain/src/index.ts": {
"0fae329186de631f08edbf22f01cbfbf15d7c1ea8dce64e4cefa8ad0a8d43dea": 1
},
"packages/storage/storage-domain/tests/invariant.spec.ts": {
"9199dc978ec64150a1a706ca2e3e5a39925f3d6011c6c0b5453c4e41b89cd896": 1
},
"packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts": {
"daa15206e19d6a8fbae646d678889b2e6933ece824e48dd06c95d2096861d5c1": 1
},
@@ -2215,9 +2186,6 @@
"63a77f914e85aa8d63dd649be95c82f64756e06e8faa0a9aa3b53d14daebd2e5": 1,
"9cf0e19dc31ae2d244530fb4853d2877c0490271a5f930ad330e210051b41cd8": 1
},
"scripts/test-invariants.spec.ts": {
"1d25e54371f9bac32d21541bfe094eb3eb3bfc39e010982c9f25bb8b0f3bea14": 1
},
"snapshots/sdk/sdk.snapshot.ts": {
"782d52268c631008e0fec08f76f87751765690287d5acdfae45021a754900290": 1
},
+53 -9
View File
@@ -1,4 +1,4 @@
/** Every bundle the installation ships switched off composes over the shipped Web layers and carries display metadata. */
/** The delivered Web composition's Schedule rows, the optional bundles it ships switched off, and their display metadata. */
import { globSync, readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
@@ -64,16 +64,60 @@ describe('optional bundles', () => {
}
})
it('adds the three Schedule rows the shipped Web composition leaves out', () => {
const { patches } = bundle('@deepseek-ai/dsh-experimental-schedule-bundle')
const scheduleRows = (entries: ReturnType<typeof composeEntries>) =>
entries.filter(entry => ['time-context', 'schedule', 'ui-schedule'].includes(entry.id))
expect(scheduleRows(composeEntries(shipped))).toEqual([])
expect(scheduleRows(composeEntries([...shipped, patches]))).toEqual([
{ id: 'time-context', name: '@deepseek-ai/dsh-time-context' },
it('delivers the Schedule service and task page without a Host clock row', () => {
const composed = composeEntries(shipped)
// The delivered composition carries the Host Schedule service and its task
// page enabled; the clock stays preset-level, so no Host row declares it.
for (const row of [
{ id: 'schedule', name: '@deepseek-ai/dsh-schedule' },
{ id: 'ui-schedule', name: '@deepseek-ai/dsh-client-ui-schedule' },
])
]) {
expect(composed.filter(entry => entry.id === row.id && entry.name === row.name && entry.disabled !== true))
.toHaveLength(1)
}
expect(composed.some(entry => entry.id === 'time-context')).toBe(false)
})
it('keeps the clock and the reminder tools on the presets that declare them', () => {
const composed = composeEntries(shipped)
type PresetRow = { id?: string; name?: string; disabled?: boolean; config?: unknown }
const presetPlugins = (id: string): PresetRow[] => {
const row = composed.find(entry => entry.id === id)
if (row === undefined) throw new Error(`missing delivered preset row ${id}`)
const flat: PresetRow[] = []
// A `cordis:group` row nests its children in its own `config` array.
const walk = (rows: PresetRow[]): void => {
for (const entry of rows) {
flat.push(entry)
if (Array.isArray(entry.config)) walk(entry.config as PresetRow[])
}
}
walk((row.config as { plugins: PresetRow[] }).plugins)
return flat
}
for (const id of ['preset-standard', 'preset-cordis', 'preset-ptc']) {
const plugins = presetPlugins(id)
for (const plugin of [
{ id: 'time-context', name: '@deepseek-ai/dsh-time-context' },
{ id: 'tool-schedule', name: '@deepseek-ai/dsh-tool-schedule' },
]) {
const matches = plugins.filter(row => row.id === plugin.id && row.name === plugin.name)
expect(matches).toHaveLength(1)
expect(matches[0]?.disabled).not.toBe(true)
}
}
// A delegated child cannot arm reminders: both delegation rows deny the four
// tools, so a child's prompt never lists them.
for (const id of ['preset-standard', 'preset-cordis', 'preset-ptc']) {
for (const rowId of ['tool-subagent', 'tool-subagent-fork']) {
expect(presetPlugins(id).find(plugin => plugin.id === rowId)?.config).toMatchObject({
toolFilter: { deny: ['schedule_create', 'schedule_delete', 'schedule_list', 'schedule_update'] },
})
}
}
// `minimal` declares neither, so it composes no clock reading and no reminder tool.
expect(presetPlugins('preset-minimal').some(row => row.name === '@deepseek-ai/dsh-time-context'
|| row.name === '@deepseek-ai/dsh-tool-schedule')).toBe(false)
})
it.each(OPTIONAL_BUNDLES)('%s resolves a title, description, and icon in both shipped languages', (name) => {
+9 -5
View File
@@ -1,3 +1,7 @@
/**
* Process-spawning cases set Vitest's case timeout to 90 seconds, including
* plain local runs without a CI override. Configuration-only cases keep the default.
*/
import { spawnSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { existsSync } from 'node:fs'
@@ -205,7 +209,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 +
expect(result.error).toBeUndefined()
expect(result.status, normalizedOutput(result)).toBe(0)
})
}, 90_000)
it('keeps repository lint workflows Oxlint-only', async () => {
const packageJson: unknown = JSON.parse(await readFile(join(repositoryRoot, 'package.json'), 'utf8'))
@@ -356,7 +360,7 @@ export function unrelatedRead(): void {
expect(result.error).toBeUndefined()
expect(result.status, normalizedOutput(result)).toBe(0)
})
}, 90_000)
it('keeps staged validation project-free while preserving source rules', async () => {
const configPath = join(repositoryRoot, '.oxlintrc.staged.json')
@@ -392,7 +396,7 @@ export function unrelatedRead(): void {
} finally {
await rm(path, { force: true })
}
})
}, 90_000)
it('preserves successful fix output channels', async () => {
const suffix = randomUUID()
@@ -416,7 +420,7 @@ export function unrelatedRead(): void {
} finally {
await rm(path, { force: true })
}
})
}, 90_000)
it('prints only the final diagnostics when a fix retry still fails', async () => {
const suffix = randomUUID()
@@ -440,7 +444,7 @@ export function unrelatedRead(): void {
} finally {
await rm(path, { force: true })
}
})
}, 90_000)
it.each(['--fix', '--fix-suggestions', '--fix-dangerously'])(
'converges overlapping staged stylistic fixes through Oxlint under %s',
+7 -1
View File
@@ -15,6 +15,11 @@ const HOST_DEPENDENCY_PACKAGES: readonly string[] = [
'@deepseek-ai/dsh-session',
]
/** Required Cordis services whose Host imports are type-only. */
const REQUIRED_SERVICE_PEERS = {
'@deepseek-ai/dsh-api-terminal-controller': ['@deepseek-ai/dsh-subprocess'],
} as const satisfies Readonly<Record<string, readonly string[]>>
/** Development-only package relationships not represented by source imports. */
const CONFIGURATION_ONLY_DEV_DEPENDENCIES = {
'@deepseek-ai/dsh-client-locale': ['@deepseek-ai/dsh-api-remotes'],
@@ -57,7 +62,6 @@ const SAFE_HOST_DEPENDENCY_EXPORTS = {
/** Runtime exports that require every consumer to resolve the provider's shared peer instance. */
const PEER_REQUIRED_HOST_EXPORTS = {
'@deepseek-ai/dsh-client-connection': ['OperatorPeer'],
'@deepseek-ai/dsh-subprocess': ['SubprocessExecutableNotFoundError'],
'@deepseek-ai/dsh-scope': ['carrierKeyOf', 'createScope', 'scopeOf', 'scopeTarget'],
'@deepseek-ai/dsh-session': ['SESSION_FORMAT_VERSION'],
'@deepseek-ai/dsh-session-persistence': ['SessionPersistenceNotFoundError'],
@@ -71,6 +75,7 @@ export interface PackageDependencyPolicy {
readonly clientFaceInclude: readonly string[]
readonly clientFaceExclude: readonly string[]
readonly hostPackages: readonly string[]
readonly requiredServicePeers?: Readonly<Record<string, readonly string[]>>
readonly configurationOnlyDevDependencies: Readonly<Record<string, readonly string[]>>
readonly duplicateSafePackages?: readonly string[]
readonly safeHostDependencyExports: HostDependencyExports
@@ -82,6 +87,7 @@ export const PACKAGE_DEPENDENCY_POLICY: PackageDependencyPolicy = {
clientFaceInclude: CLIENT_FACE_INCLUDE,
clientFaceExclude: CLIENT_FACE_EXCLUDE,
hostPackages: HOST_DEPENDENCY_PACKAGES,
requiredServicePeers: REQUIRED_SERVICE_PEERS,
configurationOnlyDevDependencies: CONFIGURATION_ONLY_DEV_DEPENDENCIES,
duplicateSafePackages: DUPLICATE_SAFE_PACKAGES,
safeHostDependencyExports: SAFE_HOST_DEPENDENCY_EXPORTS,
-294
View File
@@ -1,294 +0,0 @@
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 {
collectPackageInvariantViolations,
packageInvariantOwners,
} from './package-invariants.ts'
import { usesFlattenedPackageDependencies } from './package-dependency-policy.ts'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function handwrittenInvariant(packageName: string): string {
return `
export const name = 'probe-invariant'
export const inject = ['invariants']
const install = (ctx: { on(name: string, listener: (value: number) => void): void }, fail: (message: string) => never) => {
ctx.on('probe/value', (value) => {
if (value < 0) fail('observed values must be non-negative')
})
}
export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) =>
Promise.resolve(ctx.invariants.register(${JSON.stringify(packageName)}, install))
`
}
function fixture(options: {
companion?: boolean
packageName?: string
packageDirectory?: string
source?: string
clientDeclaration?: boolean
clientExport?: boolean
invariantExport?: boolean
invariantFile?: boolean
invariantDependency?: boolean
invariantRange?: string
invariantReference?: boolean
buildEntry?: boolean
omissionReason?: boolean
} = {}): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-package-invariants-'))
roots.push(root)
const packageDirectory = options.packageDirectory ?? 'packages/core/probe'
const dir = join(root, packageDirectory)
mkdirSync(join(dir, 'src'), { recursive: true })
const packageName = options.packageName ?? '@deepseek-ai/dsh-probe'
const companion = options.companion ?? true
const invariantExport = options.invariantExport ?? companion
const invariantFile = options.invariantFile ?? companion
const invariantDependency = options.invariantDependency ?? companion
const invariantReference = options.invariantReference ?? companion
const buildEntry = options.buildEntry ?? companion
const exports = {
...(invariantExport ? { './invariant': {
types: './lib/types/invariant.d.ts',
default: './lib/invariant.js',
} } : {}),
...(options.clientExport === true ? {
'./client': {
types: './lib/types/client/index.d.ts',
default: './lib/client.js',
},
} : {}),
}
const dsh = options.clientDeclaration === true ? { client: {} } : undefined
const developmentOnlyInvariant = usesFlattenedPackageDependencies(
`${packageDirectory}/package.json`,
packageName,
dsh,
)
const manifest = {
name: packageName,
...(dsh === undefined ? {} : { dsh }),
exports,
files: ['lib/index.js', ...invariantFile ? ['lib/invariant.js'] : []],
peerDependencies: !invariantDependency || developmentOnlyInvariant ? {} : {
'@deepseek-ai/dsh-invariants': options.invariantRange ?? 'workspace:*',
},
devDependencies: !invariantDependency ? {} : {
'@deepseek-ai/dsh-invariants': options.invariantRange ?? 'workspace:*',
},
}
writeFileSync(join(dir, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`)
writeFileSync(join(dir, 'tsconfig.json'), `${JSON.stringify({
references: invariantReference ? [{ path: '../../runtime-diagnostics/invariants' }] : [],
}, null, 2)}\n`)
if (companion) {
writeFileSync(join(dir, 'src/invariant.ts'), options.source ?? handwrittenInvariant(packageName))
}
writeFileSync(
join(dir, 'tsdown.config.ts'),
buildEntry ? "export default { entry: ['lib/types/index.js', 'lib/types/invariant.js'] }\n" : "export default { entry: ['lib/types/index.js'] }\n",
)
writeFileSync(
join(dir, 'README.md'),
options.omissionReason === false ? '# Probe\n' : '# Probe\n\nNo runtime invariant companion is published because this fixture owns no diverging observations.\n',
)
return root
}
describe('package invariant gate', () => {
it('accepts a hand-owned checking companion with publication metadata', () => {
expect(collectPackageInvariantViolations(fixture())).toEqual([])
})
it('rejects caret ranges for the invariant peer and development dependency', () => {
expect(collectPackageInvariantViolations(fixture({ invariantRange: 'workspace:^' }))).toEqual([
{ path: 'packages/core/probe/package.json', message: '@deepseek-ai/dsh-invariants must be a workspace:* peerDependency' },
{ path: 'packages/core/probe/package.json', message: '@deepseek-ai/dsh-invariants must be a workspace:* devDependency' },
])
})
it('accepts a package that cleanly omits an invariant companion', () => {
const root = fixture({ companion: false })
expect(collectPackageInvariantViolations(root)).toEqual([])
expect(packageInvariantOwners(root)).toEqual([])
})
it('requires an omitted companion to have a README reason sentence', () => {
const violations = collectPackageInvariantViolations(fixture({
companion: false,
omissionReason: false,
}))
expect(violations).toContainEqual({
path: 'packages/core/probe/README.md',
message: 'omitted companion requires a README "No ... companion is published" reason sentence',
})
})
it('accepts development-only invariants for configured Host dependencies', () => {
expect(collectPackageInvariantViolations(fixture({ packageName: '@deepseek-ai/dsh-llm' }))).toEqual([])
})
it('accepts development-only invariants for client packages', () => {
expect(collectPackageInvariantViolations(fixture({
packageName: '@deepseek-ai/dsh-client-probe',
packageDirectory: 'packages/client/probe',
}))).toEqual([])
})
it('accepts development-only invariants for packages with a dsh.client entry', () => {
expect(collectPackageInvariantViolations(fixture({ clientDeclaration: true, clientExport: true }))).toEqual([])
})
it('keeps invariant peers for packages that only export a Client API', () => {
expect(collectPackageInvariantViolations(fixture({ clientExport: true }))).toEqual([])
})
it('keeps invariant peers for experimental packages with a dsh.client entry', () => {
expect(collectPackageInvariantViolations(fixture({
packageDirectory: 'packages/experimental/probe',
clientDeclaration: true,
clientExport: true,
}))).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: '../../runtime-diagnostics/invariants' }],
}, null, 2)}\n`)
expect(collectPackageInvariantViolations(root)).toEqual([])
})
it('rejects missing publication metadata and build output', () => {
const violations = collectPackageInvariantViolations(fixture({
invariantExport: false,
invariantFile: false,
invariantDependency: false,
invariantReference: false,
buildEntry: false,
}))
expect(violations.map(violation => violation.message)).toEqual(expect.arrayContaining([
expect.stringContaining('exports["./invariant"]'),
expect.stringContaining('peerDependency'),
expect.stringContaining('devDependency'),
expect.stringContaining('TypeScript project references'),
expect.stringContaining('must bundle lib/types/invariant.js'),
]))
})
it('rejects publication and build wiring left behind after omission', () => {
const violations = collectPackageInvariantViolations(fixture({
companion: false,
invariantExport: true,
invariantFile: true,
invariantReference: true,
buildEntry: true,
}))
expect(violations.map(violation => violation.message)).toEqual(expect.arrayContaining([
expect.stringContaining('exports["./invariant"] must be omitted'),
expect.stringContaining('files must omit lib/invariant.js'),
expect.stringContaining('TypeScript project references must omit ../../runtime-diagnostics/invariants'),
expect.stringContaining('build override must omit lib/types/invariant.js'),
]))
})
it('rejects foreign, duplicate, and unresolved registrations', () => {
const source = `
export const name = 'probe-invariant'
export const inject = ['invariants']
const selected = process.env.PACKAGE_NAME
const install = (_ctx: unknown, fail: (message: string) => never) => { fail('probe') }
export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) => {
ctx.invariants.register('@deepseek-ai/dsh-foreign', install)
return ctx.invariants.register(selected!, install)
}
`
const violations = collectPackageInvariantViolations(fixture({ source }))
expect(violations.map(violation => violation.message)).toEqual(expect.arrayContaining([
expect.stringContaining('must resolve to a local string constant'),
expect.stringContaining('must register exactly its own package name'),
]))
})
it('rejects generated markers and reporter-free executable installers', () => {
const generated = fixture({
source: `/** @generated */\n${handwrittenInvariant('@deepseek-ai/dsh-probe')}`,
})
expect(collectPackageInvariantViolations(generated).map(violation => violation.message))
.toContain('invariant companions must be hand-owned and may not carry @generated markers')
const reporterFree = fixture({
source: `
export const name = 'probe-invariant'
export const inject = ['invariants']
const install = () => { void 0 }
export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) =>
Promise.resolve(ctx.invariants.register('@deepseek-ai/dsh-probe', install))
`,
})
expect(collectPackageInvariantViolations(reporterFree).map(violation => violation.message))
.toContain('install function must accept the bound failure reporter as its second parameter')
const unused = fixture({
source: `
export const name = 'probe-invariant'
export const inject = ['invariants']
const install = (_ctx: unknown, _fail: (message: string) => never) => { void 0 }
export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) =>
Promise.resolve(ctx.invariants.register('@deepseek-ai/dsh-probe', install))
`,
})
expect(collectPackageInvariantViolations(unused).map(violation => violation.message))
.toContain('install function must use its bound failure reporter')
})
it('rejects registering a different installer than the checked local function', () => {
const decoy = fixture({
source: `
export const name = 'probe-invariant'
export const inject = ['invariants']
const install = (_ctx: unknown, fail: (message: string) => never) => { fail('checked decoy') }
export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) =>
ctx.invariants.register('@deepseek-ai/dsh-probe', () => {})
`,
})
expect(collectPackageInvariantViolations(decoy).map(violation => violation.message))
.toContain('line 6: ctx.invariants.register must use the checked local install function')
})
it.each([
'export default { name, inject, apply }',
"export * as default from './probe.ts'",
])('rejects a default export that would collapse the Loader namespace', (defaultExport) => {
const source = `${handwrittenInvariant('@deepseek-ai/dsh-probe')}\n${defaultExport}\n`
expect(collectPackageInvariantViolations(fixture({ source })).map(violation => violation.message))
.toContain('must not default-export; Loader must retain the companion namespace')
})
it('rejects empty installers because packages without a check omit the companion', () => {
const source = `
export const name = 'probe-invariant'
export const inject = ['invariants']
const PACKAGE_NAME = '@deepseek-ai/dsh-probe'
const install = () => {}
export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) =>
ctx.invariants.register(PACKAGE_NAME, install)
`
expect(collectPackageInvariantViolations(fixture({ source })).map(violation => violation.message))
.toContain('empty install function is unnecessary; omit the companion and its publication wiring')
})
})
-418
View File
@@ -1,418 +0,0 @@
/**
* Package-invariant companion discovery and structural checks.
* The runtime registry stays product-independent; this gate keeps each
* published companion complete without requiring synthetic empty companions.
*/
import { existsSync, globSync, readFileSync } from 'node:fs'
import { dirname, relative, resolve, sep } from 'node:path'
import ts from 'typescript'
import { usesFlattenedPackageDependencies } from './package-dependency-policy.ts'
/** Package README sentence that records why an invariant companion is omitted. */
const OMITTED_COMPANION_REASON = /No (?:(?:runtime )?invariant )?companion is published(?: because|[.:;—])\s+\S/i
interface PackageManifest {
name?: string
dsh?: unknown
exports?: Record<string, { types?: string; default?: string } | string | null | undefined>
files?: string[]
peerDependencies?: Record<string, string>
devDependencies?: Record<string, string>
}
/** One package and the files participating in its invariant publication rules. */
export interface PackageInvariantOwner {
readonly dir: string
readonly manifestPath: string
readonly sourcePath: string
readonly packageName: string
}
/** One gate violation with a repo-relative owner path. */
export interface PackageInvariantViolation {
readonly path: string
readonly message: string
}
/** Discover packages that own an invariant companion. */
export function packageInvariantOwners(root: string): PackageInvariantOwner[] {
return packageInvariantPackages(root)
.filter(owner => existsSync(resolve(root, owner.sourcePath)))
}
/** Discover every package under the repository package tree. */
function packageInvariantPackages(root: string): PackageInvariantOwner[] {
return globSync('packages/*/*/package.json', { cwd: root })
.map(path => path.split(sep).join('/'))
.sort()
.map((manifestPath) => {
const manifest = readManifest(resolve(root, manifestPath))
if (manifest.name === undefined || manifest.name === '') {
throw new Error(`${manifestPath}: package invariant owner must declare a package name`)
}
const dir = dirname(manifestPath)
return {
dir,
manifestPath,
sourcePath: `${dir}/src/invariant.ts`,
packageName: manifest.name,
}
})
}
/** Return all violations of the package-invariant companion rules. */
export function collectPackageInvariantViolations(root: string): PackageInvariantViolation[] {
const violations: PackageInvariantViolation[] = []
for (const owner of packageInvariantPackages(root)) {
const manifest = readManifest(resolve(root, owner.manifestPath))
const hasCompanion = existsSync(resolve(root, owner.sourcePath))
checkManifest(owner, manifest, hasCompanion, violations)
checkBuild(owner, root, hasCompanion, violations)
if (hasCompanion) {
checkSource(owner, root, violations)
} else {
checkOmissionReason(owner, root, violations)
}
}
return violations
}
function readManifest(path: string): PackageManifest {
return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest
}
function addViolation(
violations: PackageInvariantViolation[],
path: string,
message: string,
): void {
violations.push({ path, message })
}
function checkManifest(
owner: PackageInvariantOwner,
manifest: PackageManifest,
hasCompanion: boolean,
violations: PackageInvariantViolation[],
): void {
const invariantExport = manifest.exports?.['./invariant']
if (!hasCompanion) {
if (invariantExport !== undefined) {
addViolation(
violations,
owner.manifestPath,
'exports["./invariant"] must be omitted when src/invariant.ts is absent',
)
}
if (manifest.files?.includes('lib/invariant.js')) {
addViolation(
violations,
owner.manifestPath,
'files must omit lib/invariant.js when src/invariant.ts is absent',
)
}
return
}
if (typeof invariantExport !== 'object'
|| invariantExport === null
|| invariantExport.types !== './lib/types/invariant.d.ts'
|| invariantExport.default !== './lib/invariant.js') {
addViolation(
violations,
owner.manifestPath,
'exports["./invariant"] must target ./lib/types/invariant.d.ts and ./lib/invariant.js',
)
}
if (!manifest.files?.includes('lib/invariant.js')) {
addViolation(violations, owner.manifestPath, 'files must publish lib/invariant.js')
}
if (owner.packageName === '@deepseek-ai/dsh-invariants') return
const developmentOnlyInvariant = usesFlattenedPackageDependencies(
owner.manifestPath,
owner.packageName,
manifest.dsh,
)
const expectedRange = 'workspace:*'
const peerRange = manifest.peerDependencies?.['@deepseek-ai/dsh-invariants']
if (developmentOnlyInvariant ? peerRange !== undefined : peerRange !== expectedRange) {
addViolation(violations, owner.manifestPath, developmentOnlyInvariant
? '@deepseek-ai/dsh-invariants must not be a peerDependency under this package dependency policy'
: '@deepseek-ai/dsh-invariants must be a workspace:* peerDependency')
}
if (manifest.devDependencies?.['@deepseek-ai/dsh-invariants'] !== expectedRange) {
addViolation(
violations,
owner.manifestPath,
`@deepseek-ai/dsh-invariants must be a ${expectedRange} devDependency`,
)
}
}
function checkBuild(
owner: PackageInvariantOwner,
root: string,
hasCompanion: boolean,
violations: PackageInvariantViolation[],
): void {
const tsconfigPath = `${owner.dir}/tsconfig.json`
if (hasCompanion
&& owner.packageName !== '@deepseek-ai/dsh-invariants'
&& !projectReferencesInvariants(root, owner.dir, tsconfigPath)) {
addViolation(
violations,
tsconfigPath,
'TypeScript project references must include ../../runtime-diagnostics/invariants',
)
} else if (!hasCompanion && projectReferencesInvariants(root, owner.dir, tsconfigPath)) {
addViolation(
violations,
tsconfigPath,
'TypeScript project references must omit ../../runtime-diagnostics/invariants when src/invariant.ts is absent',
)
}
const configPath = `${owner.dir}/tsdown.config.ts`
if (!existsSync(resolve(root, configPath))) return
const source = readFileSync(resolve(root, configPath), 'utf8')
const bundlesCompanion = source.includes('lib/types/invariant.js')
if (hasCompanion && !bundlesCompanion) {
addViolation(violations, configPath, 'package build override must bundle lib/types/invariant.js')
} else if (!hasCompanion && bundlesCompanion) {
addViolation(
violations,
configPath,
'package build override must omit lib/types/invariant.js when src/invariant.ts is absent',
)
}
}
function checkOmissionReason(
owner: PackageInvariantOwner,
root: string,
violations: PackageInvariantViolation[],
): void {
const readmePath = `${owner.dir}/README.md`
const absolutePath = resolve(root, readmePath)
if (!existsSync(absolutePath) || !OMITTED_COMPANION_REASON.test(readFileSync(absolutePath, 'utf8'))) {
addViolation(
violations,
readmePath,
'omitted companion requires a README "No ... companion is published" reason sentence',
)
}
}
function projectReferencesInvariants(root: string, ownerDir: string, entryPath: string): boolean {
const ownerRoot = resolve(root, ownerDir)
const target = resolve(root, 'packages/runtime-diagnostics/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,
violations: PackageInvariantViolation[],
): void {
const absolutePath = resolve(root, owner.sourcePath)
const sourceText = readFileSync(absolutePath, 'utf8')
if (sourceText.includes('@generated')) {
addViolation(
violations,
owner.sourcePath,
'invariant companions must be hand-owned and may not carry @generated markers',
)
}
const sourceFile = ts.createSourceFile(
absolutePath,
sourceText,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
)
const constants = topLevelStringConstants(sourceFile)
const registrations: string[] = []
const unresolved: number[] = []
const mismatchedInstallers: number[] = []
const visit = (node: ts.Node): void => {
if (ts.isCallExpression(node) && isInvariantRegistration(node.expression)) {
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1
const argument = node.arguments[0]
const packageName = argument === undefined ? undefined : stringValue(argument, constants)
if (packageName === undefined) unresolved.push(line)
else registrations.push(packageName)
const installer = node.arguments[1]
if (installer === undefined || !ts.isIdentifier(installer) || installer.text !== 'install') {
mismatchedInstallers.push(line)
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
for (const line of unresolved) {
addViolation(
violations,
owner.sourcePath,
`line ${line}: ctx.invariants.register package name must resolve to a local string constant`,
)
}
for (const line of mismatchedInstallers) {
addViolation(
violations,
owner.sourcePath,
`line ${line}: ctx.invariants.register must use the checked local install function`,
)
}
if (registrations.length !== 1 || registrations[0] !== owner.packageName) {
addViolation(
violations,
owner.sourcePath,
`must register exactly its own package name ${JSON.stringify(owner.packageName)}; saw ${JSON.stringify(registrations)}`,
)
}
for (const exportedName of ['name', 'inject', 'apply']) {
if (!hasNamedExport(sourceFile, exportedName)) {
addViolation(violations, owner.sourcePath, `must named-export ${exportedName}`)
}
}
if (hasDefaultExport(sourceFile)) {
addViolation(violations, owner.sourcePath, 'must not default-export; Loader must retain the companion namespace')
}
checkInstaller(owner, sourceFile, violations)
}
function checkInstaller(
owner: PackageInvariantOwner,
sourceFile: ts.SourceFile,
violations: PackageInvariantViolation[],
): void {
let initializer: ts.Expression | undefined
for (const statement of sourceFile.statements) {
if (!ts.isVariableStatement(statement)) continue
for (const declaration of statement.declarationList.declarations) {
if (ts.isIdentifier(declaration.name)
&& declaration.name.text === 'install'
&& declaration.initializer !== undefined) {
initializer = declaration.initializer
}
}
}
const installer = initializer === undefined ? undefined : installerFunction(initializer)
if (installer === undefined) {
addViolation(violations, owner.sourcePath, 'must declare a local install function for package-owned checks')
return
}
if (ts.isBlock(installer.body) && installer.body.statements.length === 0) {
addViolation(
violations,
owner.sourcePath,
'empty install function is unnecessary; omit the companion and its publication wiring',
)
return
}
const reporter = installer.parameters[1]?.name
if (reporter === undefined || !ts.isIdentifier(reporter)) {
addViolation(violations, owner.sourcePath, 'install function must accept the bound failure reporter as its second parameter')
return
}
if (!usesIdentifier(installer.body, reporter.text)) {
addViolation(violations, owner.sourcePath, 'install function must use its bound failure reporter')
}
}
function usesIdentifier(node: ts.Node, name: string): boolean {
return ts.isIdentifier(node) && node.text === name
|| node.getChildren().some(child => usesIdentifier(child, name))
}
function installerFunction(
initializer: ts.Expression,
): ts.ArrowFunction | ts.FunctionExpression | undefined {
if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) return initializer
if (ts.isCallExpression(initializer)
&& ts.isPropertyAccessExpression(initializer.expression)
&& ts.isIdentifier(initializer.expression.expression)
&& initializer.expression.expression.text === 'Object'
&& initializer.expression.name.text === 'assign') {
const target = initializer.arguments[0]
if (target !== undefined && (ts.isArrowFunction(target) || ts.isFunctionExpression(target))) return target
}
return undefined
}
function topLevelStringConstants(sourceFile: ts.SourceFile): ReadonlyMap<string, string> {
const constants = new Map<string, string>()
for (const statement of sourceFile.statements) {
if (!ts.isVariableStatement(statement)) continue
for (const declaration of statement.declarationList.declarations) {
if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue
const value = stringValue(declaration.initializer, constants)
if (value !== undefined) constants.set(declaration.name.text, value)
}
}
return constants
}
function stringValue(node: ts.Expression, constants: ReadonlyMap<string, string>): string | undefined {
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text
if (ts.isIdentifier(node)) return constants.get(node.text)
return undefined
}
function isInvariantRegistration(expression: ts.LeftHandSideExpression): boolean {
return ts.isPropertyAccessExpression(expression)
&& expression.name.text === 'register'
&& ts.isPropertyAccessExpression(expression.expression)
&& expression.expression.name.text === 'invariants'
}
function hasNamedExport(sourceFile: ts.SourceFile, name: string): boolean {
return sourceFile.statements.some((statement) => {
if (!ts.isVariableStatement(statement)
|| !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) return false
return statement.declarationList.declarations.some(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === name)
})
}
function hasDefaultExport(sourceFile: ts.SourceFile): boolean {
return sourceFile.statements.some((statement) => {
if (ts.isExportAssignment(statement)) return true
const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined
if (modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword)) return true
if (!ts.isExportDeclaration(statement) || statement.exportClause === undefined) return false
if (ts.isNamespaceExport(statement.exportClause)) {
return statement.exportClause.name.text === 'default'
}
return statement.exportClause.elements.some(element => element.name.text === 'default')
})
}
/** Format violations for the command-line gate. */
export function formatPackageInvariantViolation(
root: string,
violation: PackageInvariantViolation,
): string {
const path = resolve(root, violation.path)
return `${relative(root, path)}: ${violation.message}`
}
+4
View File
@@ -185,6 +185,10 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
}
const name = member.name.text
const where = `log event '${name}' (${src})`
if (name.startsWith('plugin:')) {
violations.push(`${where} uses the plugin: namespace, which holds ignorable plugin records outside SessionEventMap; choose a scope/name event type.`)
continue
}
const prior = seen.get(name)
if (prior) {
violations.push(`${where} is already declared at ${prior}; an event type has exactly one declaration.`)
+16 -2
View File
@@ -5,10 +5,17 @@ export type PersistenceCatalogLocale = 'en' | 'zh'
const english = {
title: 'Session Persistence Event Catalog',
intro: 'Every repository-declared durable Session event appears here with its source declaration and resolved types. The catalog covers the logical and physical headers, event envelopes, and every plugin declaration merge. See [Session](subsystems/session.md) for replay and [persistence](subsystems/persistence.md) for storage.',
intro: 'Every repository-declared durable Session event appears here with its source declaration and resolved types. The catalog covers the logical and physical headers, event envelopes, and every SessionEventMap declaration merge. See [Session](subsystems/session.md) for replay and [persistence](subsystems/persistence.md) for storage.',
generation: 'Run `pnpm run gen-persistence-catalog` to regenerate both catalog languages, their pairing record, the known-event module, and the machine schema inventory. `pnpm run verify-persistence-catalog` checks all generated files. Declaration fences preserve source JSDoc and type references; resolved definitions expose their transitive structure.',
envelopeIntro: 'The envelope carries `type`, `seq`, `time`, `data`, optional `ignorable`, and conditional `surfaceOp` / `sourceEventSeqs`. A **surface** event produces model history; a **log-only** event does not. The inventory covers this repository; external plugin types require their own declarations and are outside this catalog.',
envelope: 'Event envelope', events: 'Events', sources: 'Sources: ', source: 'Source: ', types: 'Types: ',
pluginRecords: 'Experimental Plugin Record Catalog',
pluginRecordsLink: 'Experimental records appear in the separate [experimental plugin record catalog](experimental-persistence-catalog.md).',
pluginRecordsIntro: 'These current-source PluginRecordMap declarations describe ignorable, log-only records owned by experimental packages. Payload annotations link to their owning source without expanding referenced types. Records stay outside the known-event set, persistence schema fingerprints, and compatibility history; format migrations retain them on a best-effort basis.',
pluginRecordsGeneration: 'Run `pnpm run gen-persistence-catalog` to regenerate both persistence catalogs, their language pairs, and associated artifacts. `pnpm run verify-persistence-catalog` checks all generated files.',
pluginRecordsMainLink: 'Shared Session event declarations and resolved schemas appear in the [Session persistence event catalog](persistence-catalog.md).',
pluginRecordsEmpty: 'No production package currently declares a plugin record.',
pluginRecordColumns: '| Record | Owner | Purpose | Payload and source |',
fingerprints: 'Persistence type fingerprints',
fingerprintsIntro: 'The [machine inventory](persistence-schema.json) contains every reachable normalized type and its SHA-256 digest. Root digests include referenced types. Comments, source locations, alias names, erased brands, readonly markers, and harmless field, union, or intersection reordering do not affect these fingerprints. Tuple order, property names, value types, and optionality do. Catalog text and source locations can still produce a diff when digests stay unchanged.',
historyIntro: 'The [format references](persistence-changes/historical-formats/README.md) cover every historical Session format. The [change records](persistence-changes/README.md) acknowledge exact transitions using snapshots kept in this tree. Follow the [review workflow](cookbook/reviewing-persistence-type-changes.md) to classify a change and record it. These checks cover declared type structure; opaque payload contents and behavior without type changes are outside their scope.',
@@ -26,10 +33,17 @@ const english = {
const chinese: Record<keyof typeof english, string> = {
title: '会话持久化事件目录',
intro: '本目录列出仓库声明的每个持久化 Session 事件及其源码声明和解析类型,覆盖逻辑与物理 header、事件信封以及各插件的声明合并。回放规则参见 [Session](subsystems/session.zh.md),存储规则参见[持久化](subsystems/persistence.zh.md)。',
intro: '本目录列出仓库声明的每个持久化 Session 事件及其源码声明和解析类型,覆盖逻辑与物理 header、事件信封及所有 SessionEventMap 声明合并。回放规则参见 [Session](subsystems/session.zh.md),存储规则参见[持久化](subsystems/persistence.zh.md)。',
generation: '运行 `pnpm run gen-persistence-catalog` 可重新生成目录的两种语言、配对记录、已知事件模块和机器 schema 目录。`pnpm run verify-persistence-catalog` 检查所有生成文件。声明围栏保留源码 JSDoc 和类型引用;解析后的定义展开其传递引用结构。',
envelopeIntro: '信封包含 `type`、`seq`、`time`、`data`、可选的 `ignorable` 以及条件字段 `surfaceOp` / `sourceEventSeqs`。**surface** 事件产生模型历史,**log-only** 事件不产生模型历史。目录覆盖本仓库;外部插件类型需要独立声明,不属于本目录。',
envelope: '事件信封', events: '事件', sources: '来源:', source: '来源:', types: '类型:',
pluginRecords: '实验性插件记录目录',
pluginRecordsLink: '实验性记录单独列在[实验性插件记录目录](experimental-persistence-catalog.zh.md)中。',
pluginRecordsIntro: '这些当前源码中的 PluginRecordMap 声明描述实验性包拥有的 ignorable、仅日志记录。载荷类型注解链接到所属源码,不展开引用类型。记录不进入已知事件集合、持久化 schema 指纹或兼容性历史;格式迁移以尽力而为的方式保留它们。',
pluginRecordsGeneration: '运行 `pnpm run gen-persistence-catalog` 可重新生成两份持久化目录及其中英配对和关联制品。`pnpm run verify-persistence-catalog` 检查所有生成文件。',
pluginRecordsMainLink: '共享 Session 事件声明与解析后的 schema 见[会话持久化事件目录](persistence-catalog.zh.md)。',
pluginRecordsEmpty: '当前没有生产包声明插件记录。',
pluginRecordColumns: '| 记录 | 所属包 | 用途 | 载荷与源码 |',
fingerprints: '持久化类型指纹',
fingerprintsIntro: '[机器可读目录](persistence-schema.json)包含所有可达的规范化类型及其 SHA-256 摘要。根类型的摘要涵盖引用类型。注释、源码位置、别名、擦除的品牌标记、readonly 标记以及无语义变化的字段、联合类型或交叉类型重排不影响摘要;元组顺序、属性名称、值类型和可选性会影响摘要。摘要不变时,目录文本和源码位置仍可能产生 diff。',
historyIntro: '[格式参考](persistence-changes/historical-formats/README.zh.md)覆盖每个历史 Session 格式。[变更记录](persistence-changes/README.zh.md)通过保存在本源码树中的快照确认精确的类型转换。按照[评审流程](cookbook/reviewing-persistence-type-changes.zh.md)分类并记录变更。这些检查覆盖已声明的类型结构;不透明载荷的内部内容和未改变类型的行为变更不在检查范围内。',
+1
View File
@@ -811,6 +811,7 @@ describe('persistence changes current-tree commands', () => {
paths: { '@deepseek-ai/dsh-session/types': ['./packages/core/session/src/types.ts'] },
} }))
const source = [
'/** Experimental plugin records. */', 'export interface PluginRecordMap {}',
'/** Stored header. */', 'export interface SessionHeader { version: 3; id: string }',
'/** Stored event payloads. */', 'export interface SessionEventMap {', '/** Saved value. */',
"'example/value': { value: string }", '}', '/** Surface event names. */', "export type SurfaceEventType = 'example/value'",
+28 -13
View File
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from
import { basename, join, resolve } from 'node:path'
import { parseArgs } from 'node:util'
import { JSON_SCHEMA, load } from 'js-yaml'
import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
import { canonicalizeSchema, reachableSchemaTypes, schemaDigest } from './persistence-schema-model.ts'
import { matchingSourceCompatibility, sourceKindGroups, validSourceCompatibility } from './persistence-source-policy.ts'
import type { CanonicalSchema, PersistenceRoot, PersistenceSchemaInventory, SchemaNode, SchemaTupleElement } from './persistence-schema-model.ts'
import { extractPersistenceSchema } from './persistence-schema.ts'
@@ -265,7 +265,7 @@ function parseSchema(value: unknown, label: string, formatVersion: 1 | 2): Canon
return schema
}
/** Parse a persisted schema inventory, rejecting malformed graphs and digest drift.
/** Parse full or compact inventories, restoring type graphs and rejecting invalid digests.
* @param value - JSON read from the current inventory or an enforced acknowledgement snapshot.
* @returns the validated inventory.
*/
@@ -284,8 +284,10 @@ export function parseHistoricalPersistenceSnapshot(value: unknown): PersistenceS
function parseSnapshot(value: unknown, historical: boolean): PersistenceSchemaInventory {
const input = record(value, 'schema inventory')
keys(input, ['formatVersion', 'roots', 'types'], 'schema inventory')
if (input.formatVersion !== 1 && input.formatVersion !== 2) throw new Error('unsupported persistence schema normalization version')
const formatVersion = input.formatVersion
if (formatVersion !== 1 && formatVersion !== 2) throw new Error('unsupported persistence schema normalization version')
const names = new Set<string>()
const schemas: CanonicalSchema[] = []
for (const rawRoot of array(input.roots, 'schema roots')) {
const root = record(rawRoot, 'schema root')
keys(root, ['key', 'kind', 'digest', 'schema'], 'schema root', ['event', 'surface'])
@@ -298,24 +300,37 @@ function parseSnapshot(value: unknown, historical: boolean): PersistenceSchemaIn
} else if ((root.kind !== 'header' || !['SessionHeader', 'JsonlHeaderLine'].includes(key))
&& (root.kind !== 'envelope' || key !== 'SessionEventEnvelope')) throw new Error(`invalid schema root ${key}`)
if (root.kind !== 'event' && (root.event !== undefined || root.surface !== undefined)) throw new Error(`${key}: non-event metadata`)
const schema = parseSchema(root.schema, key, input.formatVersion)
const schema = parseSchema(root.schema, key, formatVersion)
if (root.kind === 'event') validateEventMetadata(schema, String(root.event), root.surface === true, historical)
if (digest(root.digest, `${key} digest`) !== schemaDigest(schema)) throw new Error(`${key}: schema digest mismatch`)
schemas.push(schema)
}
for (const rawType of array(input.types, 'schema types')) {
let reachable: Map<string, CanonicalSchema> | undefined
const types = array(input.types, 'schema types').map((rawType) => {
const type = record(rawType, 'schema type')
keys(type, ['digest', 'schema', 'names', 'sources'], 'schema type')
const schema = parseSchema(type.schema, 'shared schema', input.formatVersion)
if (digest(type.digest, 'shared digest') !== schemaDigest(schema)) throw new Error('shared schema digest mismatch')
for (const name of array(type.names, 'type names')) textValue(name, 'type name')
for (const source of array(type.sources, 'type sources')) {
keys(type, ['digest', 'names', 'sources'], 'schema type', ['schema'])
const typeDigest = digest(type.digest, 'shared digest')
let schema: CanonicalSchema
if ('schema' in type) {
schema = parseSchema(type.schema, 'shared schema', formatVersion)
if (typeDigest !== schemaDigest(schema)) throw new Error('shared schema digest mismatch')
} else {
reachable ??= reachableSchemaTypes(schemas)
const found = reachable.get(typeDigest)
if (found === undefined) throw new Error(`shared schema digest ${typeDigest} is not reachable from roots`)
schema = found
}
const typeNames = array(type.names, 'type names').map(name => textValue(name, 'type name'))
const sources = array(type.sources, 'type sources').map((source) => {
const location = textValue(source, 'type source')
if (historical && /:\d+(?::\d+)?$|#L\d+(?:-L\d+)?$/u.test(location)) {
throw new Error('historical schema sources must omit line numbers')
}
}
}
return input as unknown as PersistenceSchemaInventory
return location
})
return { digest: typeDigest, schema, names: typeNames, sources }
})
return { ...input as unknown as PersistenceSchemaInventory, types }
}
function validateEventMetadata(schema: CanonicalSchema, event: string, surface: boolean, historical: boolean): void {
+48
View File
@@ -4,6 +4,7 @@
*/
import { createHash } from 'node:crypto'
import { isDeepStrictEqual } from 'node:util'
/** Recorded reader promise for explicitly attributed source additions. */
export interface SourceCompatibility {
@@ -71,6 +72,53 @@ export interface PersistenceSchemaInventory {
readonly types: readonly PersistenceType[]
}
/** Type metadata with an explicit graph when roots cannot reconstruct it exactly. */
export interface PersistenceTypeIndex extends Omit<PersistenceType, 'schema'> {
readonly schema?: CanonicalSchema
}
/** Compact inventory representation; formatVersion still pins normalization. */
export interface PersistenceSchemaSnapshot {
readonly formatVersion: PersistenceSchemaInventory['formatVersion']
readonly roots: readonly PersistenceRoot[]
readonly types: readonly PersistenceTypeIndex[]
}
/**
* Index every normalized type reachable from the supplied graphs.
* @param schemas - complete canonical root graphs.
* @returns canonical subgraphs keyed by their structural digest.
*/
export function reachableSchemaTypes(schemas: readonly CanonicalSchema[]): Map<string, CanonicalSchema> {
const types = new Map<string, CanonicalSchema>()
for (const schema of schemas) {
for (const [index] of schema.nodes.entries()) {
const type = canonicalizeSchema(schema.nodes, index)
types.set(schemaDigest(type), type)
}
}
return types
}
/**
* Omit type graphs exactly reconstructable from roots, retaining all other graphs.
* @param inventory - expanded inventory, including types erased from roots by normalization.
* @returns a lossless snapshot preserving root graphs, type order, and declaration metadata.
*/
export function persistenceSchemaSnapshot(inventory: PersistenceSchemaInventory): PersistenceSchemaSnapshot {
const reachable = reachableSchemaTypes(inventory.roots.map(root => root.schema))
return {
formatVersion: inventory.formatVersion,
roots: inventory.roots,
types: inventory.types.map(({ digest, schema, names, sources }) => ({
digest,
...(isDeepStrictEqual(schema, reachable.get(digest)) ? {} : { schema }),
names,
sources,
})),
}
}
/**
* Visit direct graph edges in their normalized semantic order.
* @param node - resolved graph node.
@@ -0,0 +1,92 @@
/** Compact inventories retain root fingerprints and restore shared recursive type graphs. */
import { describe, expect, it } from 'vitest'
import { parseHistoricalPersistenceSnapshot, parsePersistenceSnapshot } from './persistence-changes.ts'
import { canonicalizeSchema, persistenceSchemaSnapshot, schemaDigest, type PersistenceSchemaInventory } from './persistence-schema-model.ts'
import { reviewPersistenceSchemas } from './persistence-review.ts'
import { renderPersistenceSchemaDefinitions, renderPersistenceSchemaIndex } from './render-persistence-schema.ts'
function inventory(): PersistenceSchemaInventory {
const schema = canonicalizeSchema([
{ kind: 'object', properties: [{ name: 'child', type: 1, optional: false }], indices: [] },
{ kind: 'object', properties: [{ name: 'next', type: 1, optional: true }, { name: 'value', type: 2, optional: false }], indices: [] },
{ kind: 'primitive', type: 'string' },
], 0)
return {
formatVersion: 1,
roots: ['SessionHeader', 'JsonlHeaderLine'].map(key => ({ key, kind: 'header', digest: schemaDigest(schema), schema })),
types: schema.nodes.map((_, index) => {
const type = canonicalizeSchema(schema.nodes, index)
return { digest: schemaDigest(type), schema: type, names: index === 1 ? ['Recursive'] : [], sources: ['packages/example/src/types.ts'] }
}).sort((left, right) => left.digest.localeCompare(right.digest)),
}
}
describe('compact persistence snapshots', () => {
it('stores only root graphs and restores shared recursive definitions without changing reports', () => {
const original = inventory()
const snapshot = persistenceSchemaSnapshot(original)
expect(snapshot.roots).toEqual(original.roots)
expect(snapshot.types).toEqual(original.types.map(({ digest, names, sources }) => ({ digest, names, sources })))
const restored = parsePersistenceSnapshot(JSON.parse(JSON.stringify(snapshot)))
expect(restored).toEqual(original)
expect(reviewPersistenceSchemas(original, restored)).toEqual(reviewPersistenceSchemas(original, original))
for (const rendering of ['current', 'historical'] as const) {
expect(renderPersistenceSchemaIndex(restored, 'en', undefined, 2, rendering))
.toBe(renderPersistenceSchemaIndex(original, 'en', undefined, 2, rendering))
expect(renderPersistenceSchemaDefinitions(restored, 'en', undefined, 2, rendering))
.toBe(renderPersistenceSchemaDefinitions(original, 'en', undefined, 2, rendering))
}
expect(parseHistoricalPersistenceSnapshot(snapshot)).toEqual(original)
})
it.each([true, false])('retains independent recursive types with root graphs=%s', (withRoots) => {
const base = inventory()
const schema = canonicalizeSchema([
{ kind: 'object', properties: [{ name: 'next', type: 0, optional: true }, { name: 'flag', type: 1, optional: false }], indices: [] },
{ kind: 'literal', value: false },
], 0)
const independent = { digest: schemaDigest(schema), schema, names: ['example.ts#Independent', 'example.ts#Alias'], sources: ['example.ts'] }
const original = { ...base, roots: withRoots ? base.roots : [], types: [independent, ...base.types] }
const snapshot = persistenceSchemaSnapshot(original)
expect(snapshot.types[0]).toEqual(independent)
if (withRoots) expect(snapshot.types.slice(1).every(type => type.schema === undefined)).toBe(true)
else expect(snapshot.types).toEqual(original.types)
const restored = parsePersistenceSnapshot(JSON.parse(JSON.stringify(snapshot)))
expect(restored).toEqual(original)
expect(parseHistoricalPersistenceSnapshot(snapshot)).toEqual(original)
expect(persistenceSchemaSnapshot(restored)).toEqual(snapshot)
expect(renderPersistenceSchemaDefinitions(restored)).toBe(renderPersistenceSchemaDefinitions(original))
})
it('reads legacy full graphs and keeps partial acknowledgement inventories empty', () => {
const original = inventory()
expect(parsePersistenceSnapshot(JSON.parse(JSON.stringify(original)))).toEqual(original)
const partial = { ...original, types: [] }
expect(parsePersistenceSnapshot(persistenceSchemaSnapshot(partial))).toEqual(partial)
})
it('rejects compact digests that do not identify a root subgraph', () => {
const snapshot = persistenceSchemaSnapshot(inventory())
const schema = canonicalizeSchema([{ kind: 'literal', value: 'unreachable' }], 0)
expect(() => parsePersistenceSnapshot({ ...snapshot, types: [{ digest: schemaDigest(schema), names: [], sources: [] }] }))
.toThrow('is not reachable from roots')
})
it('still validates explicit legacy graphs instead of replacing them with root graphs', () => {
const original = inventory()
const schema = canonicalizeSchema([{ kind: 'primitive', type: 'number' }], 0)
expect(() => parsePersistenceSnapshot({ ...original, types: original.types.map(type => ({ ...type, schema })) }))
.toThrow('shared schema digest mismatch')
})
it('rejects invalid metadata and malformed explicit schemas in compact inventories', () => {
const snapshot = persistenceSchemaSnapshot(inventory())
const type = snapshot.types[0]!
expect(() => parsePersistenceSnapshot({ ...snapshot, types: [{ ...type, names: [3] }] })).toThrow('type name')
expect(() => parsePersistenceSnapshot({ ...snapshot, types: [{ ...type, sources: [3] }] })).toThrow('type source')
expect(() => parsePersistenceSnapshot({ ...snapshot, types: [{ ...type, schema: null }] })).toThrow('shared schema')
expect(() => parseHistoricalPersistenceSnapshot({ ...snapshot, types: [{ ...type, sources: ['types.ts:3'] }] }))
.toThrow('must omit line numbers')
})
})
+215
View File
@@ -0,0 +1,215 @@
/** Plugin record inventory rejects incomplete declarations without creating compatibility roots. */
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { collectPluginRecords } from './plugin-record-catalog.ts'
import { persistenceCatalogArtifacts, render, renderPluginRecordCatalog } from './gen-persistence-catalog.ts'
import { extractPersistenceSchema } from './persistence-schema.ts'
import { removeFixtureSafely } from './test-fixture-cleanup.ts'
const roots: string[] = []
const OWNER = 'packages/core/session/src/types.ts'
const RECORDS = 'packages/experimental/bridge/src/records.ts'
const OWNER_SOURCE = '/** Experimental records declared by their owning packages. */\nexport interface PluginRecordMap {}\n'
afterEach(() => {
for (const root of roots.splice(0)) removeFixtureSafely(root)
})
function put(root: string, file: string, source: string): void {
const path = join(root, file)
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, source)
}
function fixture(files: Record<string, string> = {}): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-plugin-record-catalog-'))
roots.push(root)
for (const [file, source] of Object.entries({
'packages/core/session/package.json': '{"name":"@deepseek-ai/dsh-session"}',
'packages/experimental/bridge/package.json': '{"name":"@deepseek-ai/dsh-experimental-bridge"}',
[OWNER]: OWNER_SOURCE,
...files,
})) put(root, file, source)
return root
}
function augmentation(members: string, heritage = ''): string {
return `export {}\ndeclare module '@deepseek-ai/dsh-session/types' {\n interface PluginRecordMap ${heritage}{\n${members}\n }\n}\n`
}
const ENTRY = " /** Retains a bridge entry. */\n 'plugin:bridge/entry': SavedEntry"
describe('plugin record declarations', () => {
it('accepts an empty inventory only when the owning empty map exists', () => {
expect(collectPluginRecords(fixture())).toEqual([])
expect(() => collectPluginRecords(fixture({ [OWNER]: 'export {}\n' })))
.toThrow('missing exported empty PluginRecordMap')
})
it('collects owner, purpose, unexpanded payload annotation and a stable source file', () => {
const root = fixture({ [RECORDS]: augmentation(ENTRY) })
const records = collectPluginRecords(root)
expect(records).toEqual([{
name: 'plugin:bridge/entry', owner: '@deepseek-ai/dsh-experimental-bridge', payload: 'SavedEntry',
doc: 'Retains a bridge entry.', source: RECORDS,
}])
put(root, RECORDS, `\n\n${augmentation(ENTRY)}`)
expect(collectPluginRecords(root)).toEqual(records)
})
it.each([
['optional property', "'plugin:bridge/entry'?: SavedEntry", /required string-literal property/],
['index signature', '[name: string]: SavedEntry', /required string-literal property/],
['computed name', "['plugin:bridge/entry']: SavedEntry", /required string-literal property/],
['identifier name', 'entry: SavedEntry', /required string-literal property/],
['method', "'plugin:bridge/entry'(): SavedEntry", /required string-literal property/],
['missing type', "'plugin:bridge/entry'", /explicit payload type/],
['another owner', "'plugin:other/entry': SavedEntry", /must use 'plugin:bridge\/<name>'/],
['missing suffix', "'plugin:bridge': SavedEntry", /must use 'plugin:bridge\/<name>'/],
['invalid grammar', "'plugin:bridge/Entry': SavedEntry", /lowercase slash-separated segments/],
])('rejects a declaration with %s', (_name, member, error) => {
const root = fixture({ [RECORDS]: augmentation(`/** Entry. */\n${member}`) })
expect(() => collectPluginRecords(root)).toThrow(error)
})
it('rejects missing prose, Cordis mode tags, inheritance and duplicate names', () => {
for (const [source, error] of [
[augmentation("'plugin:bridge/entry': SavedEntry"), /has no description prose/],
[augmentation("/** Entry.\n * @mode emit\n */\n'plugin:bridge/entry': SavedEntry"), /must not carry a Cordis @mode/],
[augmentation(ENTRY, 'extends Other '), /without extends or type parameters/],
[augmentation(`${ENTRY}\n${ENTRY}`), /already declared/],
] as const) expect(() => collectPluginRecords(fixture({ [RECORDS]: source }))).toThrow(error)
})
it('requires one empty exported map in the session package', () => {
for (const [source, error] of [
['interface PluginRecordMap {}\n', /must export/],
[`${OWNER_SOURCE}${OWNER_SOURCE}`, /duplicates the owning declaration/],
[OWNER_SOURCE.replace('{}', "{ 'plugin:session/entry': string }"), /must remain empty/],
[OWNER_SOURCE.replace('Map {}', 'Map<T> {}'), /without extends or type parameters/],
] as const) expect(() => collectPluginRecords(fixture({ [OWNER]: source }))).toThrow(error)
expect(() => collectPluginRecords(fixture({ [RECORDS]: OWNER_SOURCE }))).toThrow('is outside @deepseek-ai/dsh-session')
})
it('finds and rejects record declarations outside experimental production source', () => {
const root = fixture({
'packages/core/bridge/package.json': '{"name":"@deepseek-ai/dsh-experimental-bridge"}',
'packages/core/bridge/src/records.ts': augmentation(ENTRY),
})
expect(() => collectPluginRecords(root)).toThrow('must belong to an experimental')
})
it('requires the types module augmentation and verifiable package ownership', () => {
expect(() => collectPluginRecords(fixture({
[RECORDS]: augmentation(ENTRY).replace("dsh-session/types'", "dsh-session'"),
}))).toThrow("must augment declare module '@deepseek-ai/dsh-session/types'")
for (const manifest of ['{}', 'broken json', '{"name":"@external/bridge"}']) {
expect(() => collectPluginRecords(fixture({
[RECORDS]: augmentation(ENTRY), 'packages/experimental/bridge/package.json': manifest,
}))).toThrow(/ownership|must belong/)
}
})
it('omits tests and handles escaped declaration names through syntax discovery', () => {
const root = fixture({
'packages/experimental/bridge/tests/fixture.ts': augmentation(ENTRY),
[RECORDS]: augmentation(ENTRY).replace('PluginRecordMap', '\\u0050luginRecordMap'),
})
expect(collectPluginRecords(root).map(record => record.name)).toEqual(['plugin:bridge/entry'])
})
})
describe('plugin record catalogue output', () => {
it('renders honest empty states in both languages', () => {
const english = renderPluginRecordCatalog([])
const chinese = renderPluginRecordCatalog([], 'zh')
expect(english).toContain('# Experimental Plugin Record Catalog')
expect(english).toContain('English | [中文](experimental-persistence-catalog.zh.md)')
expect(english).toContain('No production package currently declares a plugin record.')
expect(english).toContain('[Session persistence event catalog](persistence-catalog.md)')
expect(chinese).toContain('[English](experimental-persistence-catalog.md) | 中文')
expect(chinese).toContain('当前没有生产包声明插件记录。')
expect(chinese).toContain('[会话持久化事件目录](persistence-catalog.zh.md)')
})
it('links the main catalogue to the experimental page without embedding its inventory', () => {
const english = render([], [])
const chinese = render([], [], undefined, 'zh')
expect(english).toContain('[experimental plugin record catalog](experimental-persistence-catalog.md)')
expect(chinese).toContain('[实验性插件记录目录](experimental-persistence-catalog.zh.md)')
expect(english).not.toContain('No production package currently declares')
expect(english).not.toContain('| Record | Owner |')
expect(chinese).not.toContain('当前没有生产包声明')
expect(chinese).not.toContain('| 记录 | 所属包 |')
})
it('renders compact, sorted rows with escaped annotations and no line-number churn', () => {
const records = collectPluginRecords(fixture({ [RECORDS]: augmentation(`
/** Retains state | metadata. */
'plugin:bridge/state': { value: string | null; marker: 'a b' }
${ENTRY}
`) }))
const output = renderPluginRecordCatalog([...records].reverse())
expect(output).toContain('| `plugin:bridge/entry` | `@deepseek-ai/dsh-experimental-bridge` | Retains a bridge entry. | <code>SavedEntry</code>')
expect(output).toContain('Retains state \\| metadata.')
expect(output).toContain("<code>{ value: string &#124; null; marker: 'a b'; }</code>")
expect(output).toContain(`[\`${RECORDS}\`](../${RECORDS})`)
expect(output.indexOf('| `plugin:bridge/entry`')).toBeLessThan(output.indexOf('| `plugin:bridge/state`'))
expect(output).not.toMatch(/records\.ts:\d/u)
})
it('limits record declaration changes to the experimental artifact pair and its metadata', () => {
const root = fixture({
'tsconfig.host.json': JSON.stringify({ compilerOptions: {
target: 'es2024', module: 'esnext', moduleResolution: 'bundler', strict: true, types: [],
paths: { '@deepseek-ai/dsh-session/types': ['./packages/core/session/src/types.ts'] },
}, include: ['packages/**/src/**/*.ts'] }),
[OWNER]: `${OWNER_SOURCE}
export interface SessionHeader { version: 4; id: string; createdAt: number }
export interface SessionEventMap {
/** One ordinary log event. */
'test/record': { value: string }
}
/** Declared ordinary event names. */
export type SessionEventType = keyof SessionEventMap
/** Surface placement. */
export type SurfaceOp = 'append'
/** Events which can produce model history. */
export type SurfaceEventType = 'test/record'
/** One persisted event envelope. */
export type SessionEvent<T extends keyof SessionEventMap = keyof SessionEventMap> = {
[K in keyof SessionEventMap]: { type: K; seq: number; time: number; data: SessionEventMap[K]; surfaceOp: SurfaceOp }
}[T]
`,
'packages/session/session-persistence-jsonl/src/format.ts': "interface HeaderLine {type: 'session'; version: number; id: string; delegationDepth: number}\nexport {}\n",
})
const schema = extractPersistenceSchema(root)
const empty = persistenceCatalogArtifacts(root, schema)
const experimentalPaths = [
'docs/experimental-persistence-catalog.md',
'docs/experimental-persistence-catalog.zh.md',
'docs/experimental-persistence-catalog.i18n.yaml',
]
expect(empty.map(artifact => artifact.path)).toEqual([
'docs/persistence-catalog.md', 'docs/persistence-catalog.zh.md', 'docs/persistence-catalog.i18n.yaml',
...experimentalPaths,
'packages/core/session/src/known-event-types.ts', 'docs/persistence-schema.json',
])
put(root, RECORDS, `interface SavedEntry { value: string }\n${augmentation(ENTRY)}`)
const declared = persistenceCatalogArtifacts(root, schema)
expect(declared.filter((artifact, index) => artifact.content !== empty[index]?.content).map(artifact => artifact.path))
.toEqual(experimentalPaths)
expect(extractPersistenceSchema(root)).toEqual(schema)
const nullable = augmentation(ENTRY.replace(': SavedEntry', ': SavedEntry | null'))
put(root, RECORDS, `interface SavedEntry { value: string }\n${nullable}`)
const changedAnnotation = persistenceCatalogArtifacts(root, schema)
expect(changedAnnotation.filter((artifact, index) => artifact.content !== declared[index]?.content).map(artifact => artifact.path))
.toEqual(experimentalPaths)
put(root, RECORDS, `interface SavedEntry { nested: { count: number } }\n${nullable}`)
expect(persistenceCatalogArtifacts(root, schema)).toEqual(changedAnnotation)
expect(extractPersistenceSchema(root)).toEqual(schema)
})
})
+132
View File
@@ -0,0 +1,132 @@
/** Discover package-owned experimental Session records without adding persistence schema roots. */
import { globSync, readFileSync } from 'node:fs'
import { resolve, sep } from 'node:path'
import ts from 'typescript'
import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts'
const SESSION_PACKAGE = '@deepseek-ai/dsh-session'
const SESSION_TYPES_MODULE = `${SESSION_PACKAGE}/types`
const PACKAGE_PREFIX = '@deepseek-ai/dsh-experimental-'
const RECORD_NAME = /^plugin:[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)+$/u
const printer = ts.createPrinter({ removeComments: true })
/** One current-source plugin record declaration, independent of compatibility history. */
export interface PluginRecordEntry {
/** Literal stored record name. */
readonly name: string
/** Owning workspace package's npm name. */
readonly owner: string
/** Authored payload annotation, without resolving referenced types. */
readonly payload: string
/** Declaration JSDoc description. */
readonly doc: string
/** Repository-relative declaration file, without a line number. */
readonly source: string
}
/** Read package ownership from the source file's workspace manifest. */
function packageName(source: string, root: string): string | undefined {
const manifest: unknown = JSON.parse(readFileSync(resolve(root, source.split('/').slice(0, 3).join('/'), 'package.json'), 'utf8'))
return typeof manifest === 'object' && manifest !== null && 'name' in manifest && typeof manifest.name === 'string'
? manifest.name : undefined
}
/** Print compact type syntax while preserving whitespace within literal values. */
function payloadText(type: ts.TypeNode, source: ts.SourceFile): string {
const singleLine = (node: ts.Node): void => {
ts.setEmitFlags(node, ts.EmitFlags.SingleLine)
ts.forEachChild(node, singleLine)
}
singleLine(type)
return printer.printNode(ts.EmitHint.Unspecified, type, source).trim()
}
/**
* Collect documented `PluginRecordMap` augmentations from production package source.
* @param scanRoot - repository root containing the owning empty map and package manifests.
* @returns unique record declarations sorted by owner and name, with unexpanded payload annotations.
* @throws when ownership, namespace, declaration syntax, or description requirements fail.
*/
export function collectPluginRecords(scanRoot: string): PluginRecordEntry[] {
const entries: PluginRecordEntry[] = []
const violations: string[] = []
const seen = new Map<string, string>()
let owningDeclaration: string | undefined
const files = globSync('packages/*/*/src/**/*.{ts,tsx,mts,cts}', { cwd: scanRoot })
.map(file => file.split(sep).join('/')).sort()
for (const file of files) {
const text = readFileSync(resolve(scanRoot, file), 'utf8')
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true)
const visit = (node: ts.Node): void => {
if (!ts.isInterfaceDeclaration(node) || node.name.text !== 'PluginRecordMap') {
ts.forEachChild(node, visit)
return
}
const where = `PluginRecordMap (${pointer(file, source, node)})`
let owner: string | undefined
try {
owner = packageName(file, scanRoot)
} catch (error: unknown) {
violations.push(`${where} has no readable package ownership: ${String(error)}`)
return
}
if (node.heritageClauses?.length || node.typeParameters?.length) {
violations.push(`${where} must declare record properties directly, without extends or type parameters.`)
}
if (ts.isSourceFile(node.parent)) {
if (!file.startsWith('packages/core/session/src/') || owner !== SESSION_PACKAGE) {
violations.push(`${where} is outside ${SESSION_PACKAGE}; contribute records through declare module '${SESSION_TYPES_MODULE}'.`)
} else if (!node.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) {
violations.push(`${where} must export the owning empty interface.`)
} else if (owningDeclaration !== undefined) {
violations.push(`${where} duplicates the owning declaration at ${owningDeclaration}.`)
} else {
owningDeclaration = pointer(file, source, node)
}
if (node.members.length > 0) violations.push(`${where} must remain empty; experimental packages own record declarations.`)
return
}
const container = node.parent
const module = ts.isModuleBlock(container) ? container.parent : undefined
if (module === undefined || !ts.isModuleDeclaration(module) || !ts.isStringLiteral(module.name)
|| module.name.text !== SESSION_TYPES_MODULE || !ts.isSourceFile(module.parent)) {
violations.push(`${where} must augment declare module '${SESSION_TYPES_MODULE}'.`)
return
}
if (!file.startsWith('packages/experimental/') || owner === undefined || !owner.startsWith(PACKAGE_PREFIX)) {
violations.push(`${where} must belong to an experimental ${PACKAGE_PREFIX}<name> package.`)
return
}
const prefix = `plugin:${owner.slice(PACKAGE_PREFIX.length)}/`
for (const member of node.members) {
const location = pointer(file, source, member)
if (!ts.isPropertySignature(member) || member.type === undefined || member.questionToken !== undefined
|| !ts.isStringLiteral(member.name)) {
violations.push(`plugin record at ${location} must be a required string-literal property with an explicit payload type.`)
continue
}
const name = member.name.text
if (!RECORD_NAME.test(name) || !name.startsWith(prefix)) {
violations.push(`plugin record '${name}' (${location}) must use '${prefix}<name>' with lowercase slash-separated segments.`)
continue
}
const prior = seen.get(name)
if (prior !== undefined) {
violations.push(`plugin record '${name}' (${location}) is already declared at ${prior}.`)
continue
}
seen.set(name, location)
const { doc, hasMode } = parseJsDoc(rawJsDoc(text, member))
if (doc === '') violations.push(`plugin record '${name}' (${location}) has no description prose.`)
if (hasMode) violations.push(`plugin record '${name}' (${location}) must not carry a Cordis @mode tag.`)
const payload = payloadText(member.type, source)
entries.push({ name, owner, payload, doc, source: file })
}
}
visit(source)
}
if (owningDeclaration === undefined) violations.push(`missing exported empty PluginRecordMap in ${SESSION_PACKAGE}.`)
reportViolations('plugin-record-catalog', violations)
return entries.sort((left, right) => left.owner.localeCompare(right.owner) || left.name.localeCompare(right.name))
}
+3 -3
View File
@@ -24,14 +24,14 @@ describe('PR preview workflow', () => {
expect(preview.steps.find(step => step.uses === 'actions/checkout@v6')?.with).toEqual({ 'persist-credentials': false })
})
it('keeps the immutable full build and restore-only dependency cache', () => {
it('keeps the immutable complete artifact build and restore-only dependency cache', () => {
expect(workflow.env.PRIMARY_NODE_VERSION).toBe('24')
expect(workflow.env.DSH_TELEMETRY_DISABLED).toBe('1')
const commands = preview.steps.map(step => step.run)
expect(commands).toContain('pnpm install --frozen-lockfile')
expect(commands).toContain('pnpm run build')
expect(commands).toContain('pnpm run build --artifacts-only')
expect(commands).toContain('pnpm --filter @deepseek-ai/dsh-web-frontend run build:preview')
expect(commands.indexOf('pnpm run build')).toBeLessThan(commands.indexOf('pnpm --filter @deepseek-ai/dsh-web-frontend run build:preview'))
expect(commands.indexOf('pnpm run build --artifacts-only')).toBeLessThan(commands.indexOf('pnpm --filter @deepseek-ai/dsh-web-frontend run build:preview'))
expect(preview.steps.filter(step => step.uses?.startsWith('actions/cache'))).toHaveLength(1)
expect(preview.steps.find(step => step.uses === 'actions/cache/restore@v4')?.with).toMatchObject({
key: "${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}",
+2 -1
View File
@@ -86,7 +86,8 @@ it('fully extracts a large deflate-compressed wheel entry', async () => {
const archive = join(root, 'large.whl')
await writeFile(archive, zipSync({ 'large.bin': expected }, { level: 6 }))
await unpackPrimaryRuntimeWheel(archive, root)
expect(await readFile(join(root, 'large.bin'))).toEqual(expected)
// Native byte equality avoids a per-byte matcher traversal for this 1 MiB expected output.
expect((await readFile(join(root, 'large.bin'))).equals(expected)).toBe(true)
} finally {
await rm(root, { recursive: true, force: true })
}
+1 -1
View File
@@ -450,7 +450,7 @@ describe('docsPages locale routes', () => {
const translated = rootPages.filter(page => page.contentLocale === 'zh-CN')
const fallbacks = rootPages.filter(page => page.contentLocale === 'en-US')
expect(translated).toHaveLength(48)
expect(translated).toHaveLength(47)
expect(translated.every(page => page.source.endsWith('.zh.md'))).toBe(true)
expect(fallbacks).toEqual([])
})
-1
View File
@@ -561,7 +561,6 @@ class BaselinePackager {
)
this.runner.run('pnpm', ['run', 'build'], worktree.path)
this.runner.run('pnpm', ['run', 'publint'], worktree.path)
this.runner.run('pnpm', ['run', 'verify-built-package-invariants'], worktree.path)
this.runner.run('pnpm', [
'--filter', './vendor/**',
'--filter', './packages/**',
@@ -9,7 +9,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
import * as spawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
import { MockAdapter, textResponse } from '../packages/core/agent-loop/tests/mock-adapter.ts'
import type {} from '@deepseek-ai/dsh-tool-workflow'
import type {} from '@deepseek-ai/dsh-tool-workflow/types'
import type {} from '@deepseek-ai/dsh-session-log-deepseek'
import { afterEach, describe, expect, it, vi } from 'vitest'
// @ts-expect-error Scenario plugins are runtime JavaScript without declaration artifacts.
@@ -67,7 +67,7 @@ describe('advanced Python snapshot workflow ordering', () => {
return next()
})
inner.on('session/event', (_session, event) => {
if (event.type === 'tool-workflow/agent-start') order.push(event.type)
if (event.type === 'subagent/catalog' || event.type === 'tool-workflow/agent-start') order.push(event.type)
})
await inner.plugin(fixture, config)
},
@@ -90,7 +90,7 @@ describe('advanced Python snapshot workflow ordering', () => {
})
expect((await run.result).output).toEqual([{ type: 'text', text: 'child complete' }])
expect(adapter.requests).toHaveLength(1)
expect(order).toEqual(['tool-workflow/agent-start'])
expect(order).toEqual(['subagent/catalog', 'tool-workflow/agent-start'])
})
it('holds the child until the exact parent records the exact member', async () => {
+5 -1
View File
@@ -56,16 +56,20 @@ describe('release families', () => {
'@deepseek-ai/dsh-experimental-browser-use-playwright-mcp',
'@deepseek-ai/dsh-experimental-browser-use-runtime',
'@deepseek-ai/dsh-experimental-browser-use-stagehand-native',
'@deepseek-ai/dsh-experimental-claude-code-mods',
'@deepseek-ai/dsh-experimental-client-ui-agent-team',
'@deepseek-ai/dsh-experimental-client-ui-claude-code-mods',
'@deepseek-ai/dsh-experimental-client-ui-voice-input',
'@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp',
'@deepseek-ai/dsh-experimental-computer-use-cua-driver-native',
'@deepseek-ai/dsh-experimental-inspector-profile',
'@deepseek-ai/dsh-experimental-inspector',
'@deepseek-ai/dsh-experimental-ptc-runtime-python',
'@deepseek-ai/dsh-experimental-schedule-bundle',
'@deepseek-ai/dsh-experimental-session-inspector',
'@deepseek-ai/dsh-experimental-speech-to-text-sensevoice',
'@deepseek-ai/dsh-experimental-speech-to-text',
'@deepseek-ai/dsh-experimental-tool-agent-team',
'@deepseek-ai/dsh-experimental-translator',
'@deepseek-ai/dsh-experimental-voice-input-bundle',
'@deepseek-ai/dsh-experimental-webworker-packer',
'@deepseek-ai/dsh-experimental-webworker-runtime',
+362
View File
@@ -0,0 +1,362 @@
/** Real-pnpm regression coverage for release packing, selection, hooks, and payload checks. */
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { createServer, type ServerResponse } from 'node:http'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { officialClientBuildEnvironment, writeClientBuildRecord } from '../client-build-environment.ts'
import { runGate, type Gate } from '../run-gates.ts'
import { removeFixtureSafely } from '../test-fixture-cleanup.ts'
import { releaseFamily, tarballName } from './families.ts'
import { packedIdentity, PUBLISH_ORDER_FILE, readPublishOrder } from './tarball.ts'
const require = createRequire(import.meta.url)
const packScript = resolve(import.meta.dirname, 'pack.ts')
const tsxHook = pathToFileURL(require.resolve('tsx/esm')).href
const pnpmEntry = join(dirname(require.resolve('pnpm')), 'bin/pnpm.cjs')
const roots: string[] = []
const commit = 'abcdef0'
function write(path: string, content: string): void {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, content)
}
function fixture(): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-release-pack-'))
roots.push(root)
write(join(root, 'package.json'), JSON.stringify({ name: '@deepseek-ai/pack-root', private: true, version: '1.2.3' }))
write(join(root, 'pnpm-workspace.yaml'), 'packages:\n - vendor/*\n - native/*\n - packages/*/*\n - apps/*\n')
return root
}
function packageFixture(root: string, directory: string, name: string, additions: Record<string, unknown> = {}): void {
write(join(root, directory, 'package.json'), JSON.stringify({
name, version: '1.2.3', files: ['lib/index.js', 'hook.cjs'],
scripts: {
prepack: 'node hook.cjs prepack',
prepare: 'node hook.cjs prepare',
postpack: 'node hook.cjs postpack',
},
...additions,
}))
write(join(root, directory, 'lib/index.js'), 'export const value = 1;\n')
write(join(root, directory, 'hook.cjs'), [
"const fs = require('node:fs');",
"const path = require('node:path');",
"const manifest = require('./package.json');",
"fs.appendFileSync(path.join(process.env.DSH_PACK_FIXTURE, 'hooks.log'), `${manifest.name}:${process.argv[2]}\\n`);",
'',
].join('\n'))
}
function packGate(root: string, args: string[] = []): Gate {
return {
id: 'release-pack-fixture',
label: 'release pack fixture',
displayCommand: 'pnpm exec node release pack fixture',
command: process.execPath,
args: [pnpmEntry, '--dir', root, 'exec', process.execPath, '--import', tsxHook, packScript, '--family', 'vendor', '--out', 'packed', ...args],
env: { npm_execpath: pnpmEntry, DSH_PACK_FIXTURE: root, DSH_CLIENT_COMMIT_HASH: commit },
}
}
async function runPack(root: string, args: string[] = []) {
const controller = new AbortController()
const timer = setTimeout(() => { controller.abort() }, 90_000)
try {
const result = await runGate(packGate(root, args), controller.signal)
expect(result.error).toBeUndefined()
expect(result.aborted).toBe(false)
expect(result.signalCode).toBeNull()
return {
status: result.exitCode,
stderr: result.output.filter(chunk => chunk.stream === 'stderr').map(chunk => chunk.text).join(''),
}
} finally {
clearTimeout(timer)
}
}
function stopped(pid: number): boolean {
if (process.platform === 'linux') {
try {
return /\)\s+Z\s/.test(readFileSync(`/proc/${pid}/stat`, 'utf8'))
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true
throw error
}
}
try {
process.kill(pid, 0)
return false
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ESRCH') return true
throw error
}
}
function readyHookPids(root: string): number[] {
const ready: unknown = JSON.parse(readFileSync(join(root, 'hook-ready.json'), 'utf8'))
if (ready === null || typeof ready !== 'object' || !('hook' in ready) || !('child' in ready)
|| typeof ready.hook !== 'number' || !Number.isSafeInteger(ready.hook) || ready.hook < 1
|| typeof ready.child !== 'number' || !Number.isSafeInteger(ready.child) || ready.child < 1) {
throw new Error('pack fixture hook readiness requires positive process ids')
}
return [ready.hook, ready.child]
}
afterEach(() => {
for (const root of roots.splice(0)) removeFixtureSafely(root)
})
describe('release pack CLI', () => {
it.each([undefined, '1', '2'])('packs only public family members and records their publish order (concurrency=%s)', async (concurrency) => {
const root = fixture()
// Publication places zebra before alpha; pnpm's workspace dev edge points in the opposite direction.
packageFixture(root, 'vendor/alpha', '@deepseek-ai/alpha', { peerDependencies: { '@deepseek-ai/zebra': '*' } })
packageFixture(root, 'vendor/zebra', '@deepseek-ai/zebra', { devDependencies: { '@deepseek-ai/alpha': 'workspace:*' } })
write(join(root, 'vendor/zebra/node_modules/@deepseek-ai/alpha/package.json'), JSON.stringify({ name: '@deepseek-ai/alpha', version: '1.2.3' }))
packageFixture(root, 'vendor/private', '@deepseek-ai/private', { private: true })
packageFixture(root, 'native/unselected', '@deepseek-ai/unselected')
const family = releaseFamily('vendor')
const members = family.publishOrder(family.members(root)).order
const expectedOrder = members.map(tarballName)
const result = await runPack(root, concurrency === undefined ? [] : ['--concurrency', concurrency])
expect(result.status, result.stderr).toBe(0)
expect(readPublishOrder(join(root, 'packed'))).toEqual(expectedOrder)
expect(readdirSync(join(root, 'packed')).sort()).toEqual([...expectedOrder, PUBLISH_ORDER_FILE].sort())
for (const member of members) {
expect(packedIdentity(join(root, 'packed', tarballName(member))))
.toEqual({ name: member.name, version: member.version })
}
const hooks = readFileSync(join(root, 'hooks.log'), 'utf8').trim().split('\n')
const expectedHooks = members.flatMap(member => ['prepack', 'prepare', 'postpack'].map(phase => `${member.name}:${phase}`))
expect([...hooks].sort()).toEqual([...expectedHooks].sort())
if (concurrency !== '2') expect(hooks).toEqual(expectedHooks)
}, 120_000)
it('overlaps two independent packs and keeps later packages within the concurrency limit', async () => {
const root = fixture()
const names = ['alpha', 'bravo', 'charlie'].map(name => `@deepseek-ai/${name}`)
for (const name of names) {
const directory = `vendor/${name.slice('@deepseek-ai/'.length)}`
packageFixture(root, directory, name, {
scripts: { prepack: 'node hook.cjs start', postpack: 'node hook.cjs finish' },
})
write(join(root, directory, 'hook.cjs'), [
"const { get } = require('node:http');",
"const { name } = require('./package.json');",
'const url = `${process.env.DSH_PACK_COORDINATOR}/${process.argv[2]}?name=${encodeURIComponent(name)}&pid=${process.pid}`;',
'const request = get(url, { agent: false }, response => {',
' response.resume();',
" response.on('end', () => { if (response.statusCode !== 200) process.exitCode = 1; });",
'});',
"request.on('error', error => { console.error(error); process.exitCode = 1; });",
'',
].join('\n'))
}
const active = new Set<string>()
const waiting = new Map<string, ServerResponse>()
const started: string[] = []
const completed: string[] = []
const pids = new Set<number>()
const errors: string[] = []
let maximumActive = 0
const server = createServer((request, response) => {
const url = new URL(request.url ?? '/', 'http://localhost')
const name = url.searchParams.get('name') ?? ''
const pid = Number(url.searchParams.get('pid'))
if (!names.includes(name) || !Number.isSafeInteger(pid) || pid < 1
|| (url.pathname !== '/start' && url.pathname !== '/finish')) {
errors.push(`unexpected hook request ${request.url ?? ''}`)
response.writeHead(400).end()
return
}
pids.add(pid)
if (url.pathname === '/start') {
if (active.has(name)) errors.push(`${name} started twice`)
started.push(name)
active.add(name)
maximumActive = Math.max(maximumActive, active.size)
waiting.set(name, response)
} else {
if (!active.delete(name)) errors.push(`${name} finished without starting`)
completed.push(name)
response.end()
}
})
const release = (name: string | undefined): void => {
const response = name === undefined ? undefined : waiting.get(name)
if (name === undefined || response === undefined) throw new Error('pack hook has not reached its barrier')
waiting.delete(name)
response.end()
}
const controller = new AbortController()
const timer = setTimeout(() => { controller.abort() }, 90_000)
let pending: ReturnType<typeof runGate> | undefined
try {
await new Promise<void>((resolveListen, rejectListen) => {
server.once('error', rejectListen)
server.listen(0, '127.0.0.1', () => {
server.off('error', rejectListen)
resolveListen()
})
})
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('pack coordinator has no TCP address')
const gate = packGate(root, ['--concurrency', '2'])
gate.env = { ...gate.env, DSH_PACK_COORDINATOR: `http://127.0.0.1:${address.port}` }
pending = runGate(gate, controller.signal)
await vi.waitFor(() => { expect(started).toHaveLength(2) }, { timeout: 30_000, interval: 25 })
expect(active.size).toBe(2)
expect([...pids].every(pid => !stopped(pid))).toBe(true)
expect(completed).toEqual([])
release(started[0])
await vi.waitFor(() => { expect(started).toHaveLength(3) }, { timeout: 30_000, interval: 25 })
expect(completed).toEqual([started[0]])
expect(active.size).toBe(2)
expect(maximumActive).toBe(2)
release(started[1])
release(started[2])
const result = await pending
expect(result.error).toBeUndefined()
expect(result.aborted).toBe(false)
expect(result.signalCode).toBeNull()
expect(result.exitCode, result.output.map(chunk => chunk.text).join('')).toBe(0)
expect(errors).toEqual([])
expect([...started].sort()).toEqual(names)
expect([...completed].sort()).toEqual(names)
expect(active.size).toBe(0)
expect(maximumActive).toBe(2)
await vi.waitFor(() => { expect([...pids].every(stopped)).toBe(true) }, { timeout: 10_000, interval: 25 })
expect(readPublishOrder(join(root, 'packed'))).toHaveLength(3)
} finally {
clearTimeout(timer)
controller.abort()
try {
await pending
} finally {
if (server.listening) {
const closed = new Promise<void>((resolveClose, rejectClose) => {
server.close((error) => {
if (error === undefined) resolveClose()
else rejectClose(error)
})
})
server.closeAllConnections()
await closed
}
}
}
}, 120_000)
it('does not broaden an empty parallel family to the whole workspace', async () => {
const root = fixture()
packageFixture(root, 'vendor/private', '@deepseek-ai/private', { private: true })
packageFixture(root, 'native/unselected', '@deepseek-ai/unselected')
const result = await runPack(root, ['--concurrency', '8'])
expect(result.status, result.stderr).toBe(0)
expect(readFileSync(join(root, 'packed', PUBLISH_ORDER_FILE), 'utf8')).toBe('\n')
expect(readdirSync(join(root, 'packed'))).toEqual([PUBLISH_ORDER_FILE])
expect(existsSync(join(root, 'hooks.log'))).toBe(false)
})
it('leaves no publish order after a pack hook fails', async () => {
const root = fixture()
packageFixture(root, 'vendor/failing', '@deepseek-ai/failing', { scripts: { prepack: 'node failing.cjs' } })
write(join(root, 'vendor/failing/failing.cjs'), "throw new Error('pack fixture failure')\n")
const result = await runPack(root, ['--concurrency', '8'])
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('pack fixture failure')
expect(existsSync(join(root, 'packed', PUBLISH_ORDER_FILE))).toBe(false)
})
it('rejects a successful command that did not produce the expected member tarball', async () => {
const root = fixture()
packageFixture(root, 'vendor/expected', '@deepseek-ai/expected', { scripts: { prepack: 'node rename.cjs' } })
write(join(root, 'vendor/expected/rename.cjs'), [
"const fs = require('node:fs');",
"const manifest = require('./package.json');",
"manifest.name = '@deepseek-ai/renamed';",
"fs.writeFileSync('package.json', JSON.stringify(manifest));",
'',
].join('\n'))
const result = await runPack(root, ['--concurrency', '8'])
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('@deepseek-ai/expected produced no tarball')
expect(existsSync(join(root, 'packed', 'deepseek-ai-renamed-1.2.3.tgz'))).toBe(true)
expect(existsSync(join(root, 'packed', PUBLISH_ORDER_FILE))).toBe(false)
})
it('validates every parallel tarball before recording the publish order', async () => {
const root = fixture()
packageFixture(root, 'packages/util/alpha', '@deepseek-ai/alpha')
packageFixture(root, 'packages/util/zebra', '@deepseek-ai/zebra', { files: ['lib/index.js', 'src/index.ts'] })
write(join(root, 'packages/util/zebra/src/index.ts'), 'export const source = 1\n')
write(join(root, 'apps/web/dist/index.html'), '<main></main>')
writeClientBuildRecord(root, officialClientBuildEnvironment(root, { DSH_CLIENT_COMMIT_HASH: commit }))
const result = await runPack(root, ['--family', 'dsh', '--concurrency', '8'])
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('@deepseek-ai/zebra publishes source file package/src/index.ts')
expect(readdirSync(join(root, 'packed')).filter(name => name.endsWith('.tgz'))).toHaveLength(2)
expect(existsSync(join(root, 'packed', PUBLISH_ORDER_FILE))).toBe(false)
})
it('stops the ready pack hook and its stalled descendant before fixture cleanup', async () => {
const root = fixture()
packageFixture(root, 'vendor/stalled', '@deepseek-ai/stalled', { scripts: { prepack: 'node stalled.cjs' } })
write(join(root, 'vendor/stalled/stalled.cjs'), [
"const { spawn } = require('node:child_process');",
"spawn(process.execPath, ['descendant.cjs'], { stdio: 'inherit' });",
'',
].join('\n'))
write(join(root, 'vendor/stalled/descendant.cjs'), [
"const fs = require('node:fs');",
"const path = require('node:path');",
"process.on('SIGTERM', () => {});",
'setInterval(() => {}, 1000);',
"fs.writeFileSync(path.join(process.env.DSH_PACK_FIXTURE, 'hook-ready.json'), JSON.stringify({ hook: process.ppid, child: process.pid }));",
'',
].join('\n'))
const controller = new AbortController()
const timer = setTimeout(() => { controller.abort() }, 90_000)
const pending = runGate(packGate(root, ['--concurrency', '8']), controller.signal)
try {
let pids: number[] = []
await vi.waitFor(() => {
pids = readyHookPids(root)
expect(pids.every(pid => !stopped(pid))).toBe(true)
}, { timeout: 30_000, interval: 25 })
controller.abort()
const result = await pending
expect(result.aborted).toBe(true)
expect(result.status).toBe('failed')
await vi.waitFor(() => { expect(pids.every(stopped)).toBe(true) }, { timeout: 10_000, interval: 25 })
expect(existsSync(join(root, 'packed', PUBLISH_ORDER_FILE))).toBe(false)
} finally {
clearTimeout(timer)
controller.abort()
await pending
}
}, 120_000)
})
+38 -21
View File
@@ -1,6 +1,7 @@
/**
* Pack one release family's whole publish set into a single directory, in
* publish order, and record that order for the publish step.
* Pack one release family's whole publish set into a single directory and
* record its publish order for the publish step. Default serial packs execute
* in that order; explicitly parallel packs use pnpm's bounded scheduler.
*
* The pack step is the release boundary: it runs without credentials, produces
* every tarball from one commit, and hands the publish step exactly those bytes
@@ -19,16 +20,13 @@ import { PUBLISH_ORDER_FILE, tarballFiles } from './tarball.ts'
const DEFAULT_OUTPUT = 'dist/npm'
/**
* Pack one member and check what its tarball carries.
* Check one member's expected tarball and its published files.
* @param family - the release family being packed.
* @param member - the member to pack.
* @param destination - absolute output directory.
* @returns The tarball filename.
*/
async function packMember(family: ReleaseFamily, member: ReleaseMember, destination: string): Promise<string> {
const invocation = pnpmInvocation(['--dir', member.directory, 'pack', '--pack-destination', destination])
await runConcurrent(invocation.command, invocation.args)
function validatePackedMember(family: ReleaseFamily, member: ReleaseMember, destination: string): string {
const filename = tarballName(member)
const tarball = join(destination, filename)
if (!existsSync(tarball)) throw new Error(`${member.name} produced no tarball at ${tarball}`)
@@ -36,6 +34,38 @@ async function packMember(family: ReleaseFamily, member: ReleaseMember, destinat
return filename
}
/**
* Pack serial members in publish order, or batch an explicitly parallel family.
* @param family - Release family whose payload checks apply to every tarball.
* @param members - Exact public members in the recorded publish order.
* @param destination - Absolute output directory.
* @param concurrency - Maximum active package packs; one preserves serial publication order.
* @returns Validated tarball filenames in publish order.
*/
async function packMembers(
family: ReleaseFamily, members: readonly ReleaseMember[], destination: string, concurrency: number,
): Promise<string[]> {
if (concurrency === 1) {
const order: string[] = []
for (const member of members) {
const invocation = pnpmInvocation(['--dir', member.directory, 'pack', '--pack-destination', destination])
await runConcurrent(invocation.command, invocation.args)
order.push(validatePackedMember(family, member, destination))
}
return order
}
if (members.length === 0) return []
const invocation = pnpmInvocation([
'pack',
'--recursive',
`--workspace-concurrency=${String(concurrency)}`,
...members.map(member => `--filter=${member.name}`),
'--pack-destination', destination,
])
await runConcurrent(invocation.command, invocation.args)
return members.map(member => validatePackedMember(family, member, destination))
}
/**
* @returns The validated `--concurrency` value; 1 (the default) packs the
* members one at a time, exactly as the credentialed publish workflows run it.
@@ -68,20 +98,7 @@ async function main(): Promise<void> {
rmSync(destination, { recursive: true, force: true })
mkdirSync(destination, { recursive: true })
// Members pack in a bounded pool; the recorded publish order stays the
// members' order regardless of completion order, because each worker writes
// its result at the member's own position.
const order = new Array<string>(members.length)
let cursor = 0
await Promise.all(Array.from({ length: Math.min(concurrency, members.length) }, async () => {
while (cursor < members.length) {
const index = cursor
cursor += 1
const member = members[index]
if (member === undefined) break
order[index] = await packMember(family, member, destination)
}
}))
const order = await packMembers(family, members, destination, concurrency)
writeFileSync(join(destination, PUBLISH_ORDER_FILE), `${order.join('\n')}\n`)
console.log(`release pack: family ${family.id}, ${String(order.length)} tarball(s) in ${values.out ?? DEFAULT_OUTPUT}`)
+312
View File
@@ -0,0 +1,312 @@
/** npm channel selection, collision prevention, and immutable-artifact retries. */
import { createHash } from 'node:crypto'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { setTimeout as sleep } from 'node:timers/promises'
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { attempt, attemptEchoed, type CommandResult } from './process.ts'
import { publishRelease } from './publish.ts'
import { packedIdentity, readPublishOrder } from './tarball.ts'
vi.mock('./process.ts', async importOriginal => ({
...await importOriginal<typeof import('./process.ts')>(),
attempt: vi.fn(),
attemptEchoed: vi.fn(),
isEntry: () => false,
}))
vi.mock('./tarball.ts', async importOriginal => ({
...await importOriginal<typeof import('./tarball.ts')>(),
packedIdentity: vi.fn(),
readPublishOrder: vi.fn(),
}))
vi.mock('node:timers/promises', async importOriginal => ({
...await importOriginal<typeof import('node:timers/promises')>(),
setTimeout: vi.fn(),
}))
const CHANNEL = 'dsh-0-2-1-alpha-1'
const ABSENT: CommandResult = { status: 1, stdout: '', stderr: 'npm error code E404' }
const SUCCESS: CommandResult = { status: 0, stdout: '', stderr: '' }
const CONFLICT: CommandResult = { status: 1, stdout: '', stderr: 'npm error code E409 Failed to save packument' }
interface PackedFixture {
readonly name: string
readonly version: string
readonly path: string
readonly integrity: string
}
function packedRelease(versions: readonly string[]): { directory: string; packages: PackedFixture[] } {
const directory = mkdtempSync(join(tmpdir(), 'dsh-publish-channel-'))
onTestFinished(() => { rmSync(directory, { recursive: true, force: true }) })
const packages = versions.map((version, index) => {
const name = `@deepseek-ai/release-fixture-${String(index)}`
const path = join(directory, `${String(index)}.tgz`)
const bytes = Buffer.from(`${name}@${version}\n`)
writeFileSync(path, bytes)
return {
name,
version,
path,
integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}`,
}
})
vi.mocked(readPublishOrder).mockReturnValue(packages.map((_, index) => `${String(index)}.tgz`))
vi.mocked(packedIdentity).mockImplementation((path) => {
const entry = packages.find(entry => entry.path === path)
if (entry === undefined) throw new Error(`Unexpected tarball ${path}`)
return { name: entry.name, version: entry.version }
})
return { directory, packages }
}
function registry(
tags: ReadonlyMap<string, unknown> = new Map(),
integrities: ReadonlyMap<string, string> = new Map(),
): void {
vi.mocked(attempt).mockImplementation((command, args) => {
expect(command).toBe('npm')
if (args[0] === 'dist-tag') {
expect(args).toHaveLength(3)
expect(args[1]).toBe('ls')
const name = args[2]
if (name === undefined) throw new Error('Missing npm dist-tag package')
if (!tags.has(name)) return ABSENT
const payload = tags.get(name)
const stdout = payload !== null && typeof payload === 'object' && !Array.isArray(payload)
? Object.entries(payload).map(([tag, version]) => `${tag}: ${String(version)}`).join('\n')
: JSON.stringify(payload) ?? 'undefined'
return { ...SUCCESS, stdout }
}
expect(args).toHaveLength(4)
expect(args[0]).toBe('view')
expect(args[3]).toBe('--json')
const selector = args[1]
if (selector === undefined) throw new Error('Missing npm view selector')
if (args[2] === 'dist.integrity') {
const integrity = integrities.get(selector)
return integrity === undefined ? ABSENT : { ...SUCCESS, stdout: JSON.stringify(integrity) }
}
throw new Error(`Unexpected npm view field ${String(args[2])}`)
})
}
beforeEach(() => {
vi.resetAllMocks()
vi.mocked(attemptEchoed).mockReturnValue(SUCCESS)
vi.mocked(sleep).mockResolvedValue(undefined)
vi.spyOn(console, 'log').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
vi.resetAllMocks()
})
describe('release publication channels', () => {
it.each([
['vendor', '1.0.6', undefined],
['vendor', '1.0.6-alpha.1', 'next'],
['dsh', '0.2.1', undefined],
['dsh', '0.2.1-alpha.1', 'alpha'],
['dsh', '0.2.1-canary.1', 'canary'],
['dsh', '0.2.1-rc.1', 'next'],
] as const)('uses the default %s channel for %s when no override is supplied', async (family, version, tag) => {
const fixture = packedRelease([version])
const [entry] = fixture.packages
expect(entry).toBeDefined()
registry()
await publishRelease(family, fixture.directory)
expect(attempt).toHaveBeenCalledExactlyOnceWith('npm', ['view', `${entry!.name}@${version}`, 'dist.integrity', '--json'])
expect(attemptEchoed).toHaveBeenCalledExactlyOnceWith('npm', [
'publish', entry!.path, ...tag === undefined ? [] : ['--tag', tag],
])
})
it('checks every custom channel before publishing in the recorded order', async () => {
const fixture = packedRelease(['1.0.6-alpha.1', '4.0.5-alpha.1'])
const [first, second] = fixture.packages
expect(first).toBeDefined()
expect(second).toBeDefined()
registry(new Map([[first!.name, { latest: '1.0.5', next: '1.0.5-rc.1' }]]))
vi.mocked(attemptEchoed).mockImplementation(() => {
expect(vi.mocked(attempt).mock.calls.filter(([, args]) => args[0] === 'dist-tag')).toEqual([
['npm', ['dist-tag', 'ls', first!.name]],
['npm', ['dist-tag', 'ls', second!.name]],
])
return SUCCESS
})
await publishRelease('vendor', fixture.directory, CHANNEL)
expect(vi.mocked(attemptEchoed).mock.calls).toEqual(fixture.packages.map(entry => [
'npm', ['publish', entry.path, '--tag', CHANNEL],
]))
})
it('uses an explicit channel instead of the dsh prerelease default', async () => {
const fixture = packedRelease(['0.2.1-alpha.1'])
registry()
await publishRelease('dsh', fixture.directory, CHANNEL)
expect(attemptEchoed).toHaveBeenCalledExactlyOnceWith('npm', ['publish', fixture.packages[0]!.path, '--tag', CHANNEL])
})
it.each(['', ' ', ' next', '-release-preview', 'release/channel', '1.2.3', '^1.2', 'v1', 'x'])(
'rejects invalid channel %j before reading artifacts or querying npm',
async (tag) => {
await expect(publishRelease('vendor', '/unused-release-artifacts', tag)).rejects.toThrow()
expect(readPublishOrder).not.toHaveBeenCalled()
expect(packedIdentity).not.toHaveBeenCalled()
expect(attempt).not.toHaveBeenCalled()
expect(attemptEchoed).not.toHaveBeenCalled()
},
)
it('stops before publishing when a later package already assigns the custom channel elsewhere', async () => {
const fixture = packedRelease(['1.0.6-alpha.1', '4.0.5-alpha.1'])
const [first, second] = fixture.packages
registry(new Map([
[first!.name, { latest: '1.0.5' }],
[second!.name, { [CHANNEL]: '4.0.4' }],
]))
await expect(publishRelease('vendor', fixture.directory, CHANNEL)).rejects.toThrow()
expect(vi.mocked(attempt).mock.calls.filter(([, args]) => args[0] === 'dist-tag')).toEqual([
['npm', ['dist-tag', 'ls', first!.name]],
['npm', ['dist-tag', 'ls', second!.name]],
])
expect(attemptEchoed).not.toHaveBeenCalled()
})
it.each([null, [], {}, 'not an object', { [CHANNEL]: 42 }])('rejects malformed registry channels %j before publishing', async (payload) => {
const fixture = packedRelease(['1.0.6-alpha.1'])
registry(new Map([[fixture.packages[0]!.name, payload]]))
await expect(publishRelease('vendor', fixture.directory, CHANNEL)).rejects.toThrow()
expect(attemptEchoed).not.toHaveBeenCalled()
})
it('stops on a registry authorization failure before publishing', async () => {
const fixture = packedRelease(['1.0.6-alpha.1'])
vi.mocked(attempt).mockReturnValue({ status: 1, stdout: '', stderr: 'npm error code E403' })
await expect(publishRelease('vendor', fixture.directory, CHANNEL)).rejects.toThrow(/E403/u)
expect(attemptEchoed).not.toHaveBeenCalled()
})
it('rejects repeated channel bindings before publishing', async () => {
const fixture = packedRelease(['1.0.6-alpha.1'])
vi.mocked(attempt).mockReturnValue({
...SUCCESS,
stdout: `${CHANNEL}: 1.0.6-alpha.1\n${CHANNEL}: 1.0.6-alpha.1\n`,
})
await expect(publishRelease('vendor', fixture.directory, CHANNEL)).rejects.toThrow('invalid dist-tags')
expect(attemptEchoed).not.toHaveBeenCalled()
})
it('skips an identical artifact with its custom channel and no latest tag', async () => {
const fixture = packedRelease(['1.0.6-alpha.1'])
const entry = fixture.packages[0]!
registry(
new Map([[entry.name, { [CHANNEL]: entry.version }]]),
new Map([[`${entry.name}@${entry.version}`, entry.integrity]]),
)
await publishRelease('vendor', fixture.directory, CHANNEL)
expect(attempt).toHaveBeenCalledWith('npm', ['dist-tag', 'ls', entry.name])
expect(attemptEchoed).not.toHaveBeenCalled()
expect(sleep).not.toHaveBeenCalled()
})
it('rejects an already published version whose custom channel is absent', async () => {
const fixture = packedRelease(['1.0.6-alpha.1'])
const entry = fixture.packages[0]!
registry(new Map([[entry.name, { latest: '1.0.5' }]]), new Map([[`${entry.name}@${entry.version}`, entry.integrity]]))
await expect(publishRelease('vendor', fixture.directory, CHANNEL)).rejects.toThrow()
expect(attemptEchoed).not.toHaveBeenCalled()
expect(attempt).toHaveBeenCalledWith('npm', ['view', `${entry.name}@${entry.version}`, 'dist.integrity', '--json'])
})
it('stops before the first upload when a later published version has no custom channel', async () => {
const fixture = packedRelease(['1.0.6-alpha.1', '4.0.5-alpha.1'])
const [first, second] = fixture.packages
registry(
new Map([[first!.name, { latest: '1.0.5' }], [second!.name, { latest: '4.0.4' }]]),
new Map([[`${second!.name}@${second!.version}`, second!.integrity]]),
)
await expect(publishRelease('vendor', fixture.directory, CHANNEL)).rejects.toThrow()
expect(attempt).toHaveBeenCalledWith('npm', ['view', `${second!.name}@${second!.version}`, 'dist.integrity', '--json'])
expect(attemptEchoed).not.toHaveBeenCalled()
expect(sleep).not.toHaveBeenCalled()
})
it('preserves the explicit channel when retrying an E409 publication', async () => {
const fixture = packedRelease(['1.0.6-alpha.1'])
const entry = fixture.packages[0]!
registry(new Map([[entry.name, { latest: '1.0.5', next: '1.0.5-rc.1' }]]))
vi.mocked(attemptEchoed).mockReturnValueOnce(CONFLICT).mockReturnValueOnce(SUCCESS)
await publishRelease('vendor', fixture.directory, CHANNEL)
expect(vi.mocked(attemptEchoed).mock.calls).toEqual([
['npm', ['publish', entry.path, '--tag', CHANNEL]],
['npm', ['publish', entry.path, '--tag', CHANNEL]],
])
expect(sleep).toHaveBeenCalledExactlyOnceWith(2_000)
})
it('keeps the explicit channel when an E409 publication already landed', async () => {
const fixture = packedRelease(['1.0.6-alpha.1'])
const entry = fixture.packages[0]!
const tags = new Map<string, Record<string, string>>([[entry.name, { latest: '1.0.5', next: '1.0.5-rc.1' }]])
const integrities = new Map<string, string>()
registry(tags, integrities)
vi.mocked(attemptEchoed).mockImplementationOnce(() => {
integrities.set(`${entry.name}@${entry.version}`, entry.integrity)
tags.set(entry.name, { ...tags.get(entry.name)!, [CHANNEL]: entry.version })
return CONFLICT
})
await publishRelease('vendor', fixture.directory, CHANNEL)
expect(attemptEchoed).toHaveBeenCalledExactlyOnceWith('npm', ['publish', entry.path, '--tag', CHANNEL])
const lookupIndex = vi.mocked(attempt).mock.calls.findLastIndex(([, args]) => args[2] === 'dist.integrity')
expect(lookupIndex).toBeGreaterThanOrEqual(0)
expect(vi.mocked(attempt).mock.invocationCallOrder[lookupIndex]).toBeGreaterThan(
vi.mocked(attemptEchoed).mock.invocationCallOrder[0]!,
)
expect(sleep).not.toHaveBeenCalled()
})
it('rejects different bytes already published at the requested version', async () => {
const fixture = packedRelease(['1.0.6-alpha.1'])
const entry = fixture.packages[0]!
registry(
new Map([[entry.name, { [CHANNEL]: entry.version }]]),
new Map([[`${entry.name}@${entry.version}`, 'sha512-different']]),
)
await expect(publishRelease('vendor', fixture.directory, CHANNEL)).rejects.toThrow('already published with different content')
expect(attemptEchoed).not.toHaveBeenCalled()
})
})
+71 -18
View File
@@ -17,9 +17,10 @@ import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { setTimeout as sleep } from 'node:timers/promises'
import { parseArgs } from 'node:util'
import { valid, validRange } from 'semver'
import { releaseFamily } from './families.ts'
import { attempt, attemptEchoed, isEntry } from './process.ts'
import { packedIdentity, readPublishOrder } from './tarball.ts'
import { packedIdentity, readPublishOrder, type PackedIdentity } from './tarball.ts'
/**
* Registry codes that answer a write which did not settle, rather than a
@@ -84,6 +85,44 @@ function registryState(name: string, version: string): RegistryState {
return { kind: 'present', integrity: parsed }
}
/** Reject empty tags, npm option prefixes, characters requiring URI encoding, and version ranges. */
function validateDistTag(distTag: string): void {
if (distTag === '' || distTag.startsWith('-') || encodeURIComponent(distTag) !== distTag || validRange(distTag) !== null) {
throw new Error(`Invalid npm dist-tag ${JSON.stringify(distTag)}: use a non-empty tag without a leading hyphen, characters requiring URI encoding, or a version range.`)
}
}
/** An explicit channel may be new or already name the same version after a partial publication. */
function verifyDistTag(member: PackedIdentity, distTag: string): void {
const result = attempt('npm', ['dist-tag', 'ls', member.name])
if (result.status !== 0) {
const output = `${result.stdout}${result.stderr}`
if (output.includes('E404') || output.includes('404 Not Found')) return
throw new Error(`npm dist-tag ls ${member.name} failed:\n${output}`)
}
const tags = new Map<string, string>()
for (const line of result.stdout.split(/\r?\n/u).filter(line => line !== '')) {
const match = /^(\S+): (\S+)$/u.exec(line)
const tag = match?.[1]
const version = match?.[2]
if (tag === undefined || version === undefined || valid(version) === null || tags.has(tag)) {
throw new Error(`npm reported invalid dist-tags for ${member.name}: ${JSON.stringify(line)}`)
}
tags.set(tag, version)
}
if (tags.size === 0) throw new Error(`npm reported no dist-tags for ${member.name}`)
const version = tags.get(distTag)
if (version === undefined) {
if (registryState(member.name, member.version).kind === 'present') {
throw new Error(`${member.name}@${member.version} is already published without dist-tag ${distTag}; this run cannot bind an existing version to a new channel.`)
}
return
}
if (version !== member.version) {
throw new Error(`${member.name}@${distTag} already points to ${version}; choose an unused dist-tag instead of replacing it with ${member.version}.`)
}
}
/**
* Publish one tarball, retrying a registry write that did not settle.
*
@@ -127,30 +166,32 @@ async function publishTarball(
}
}
/** Publish the family named by `--family` from the directory named by `--from`. */
async function main(): Promise<void> {
const { values } = parseArgs({
options: { family: { type: 'string' }, from: { type: 'string' } },
allowPositionals: false,
})
if (values.family === undefined || values.from === undefined) {
throw new Error('usage: publish.ts --family <dsh|vendor> --from <packed directory>')
}
const family = releaseFamily(values.family)
const directory = resolve(process.cwd(), values.from)
/**
* Publish packed artifacts, preserving occupied explicit channels and existing version integrity.
* @param familyId - release family whose default channels apply when no override is supplied.
* @param directory - packed artifact directory containing the publication order.
* @param distTag - optional explicit npm channel, checked across the whole batch before uploading.
* @returns Resolves after every artifact has been published or verified as already present.
*/
export async function publishRelease(familyId: string, directory: string, distTag?: string): Promise<void> {
if (distTag !== undefined) validateDistTag(distTag)
const family = releaseFamily(familyId)
// Every entry in the order settles as either published or already present, so
// one counter answers "how far along is this run" for whoever is watching a
// release that takes minutes per family.
const order = readPublishOrder(directory)
const order = readPublishOrder(directory).map((filename) => {
const tarball = join(directory, filename)
return { tarball, ...packedIdentity(tarball) }
})
if (distTag !== undefined) {
for (const member of order) verifyDistTag(member, distTag)
}
const total = String(order.length)
let published = 0
let skipped = 0
for (const [index, filename] of order.entries()) {
for (const [index, { tarball, name, version }] of order.entries()) {
const progress = `[${String(index + 1)}/${total}]`
const tarball = join(directory, filename)
const { name, version } = packedIdentity(tarball)
const state = registryState(name, version)
if (state.kind === 'present') {
const local = integrityOf(tarball)
@@ -168,7 +209,7 @@ async function main(): Promise<void> {
// Space out the writes: the gap belongs between publishes, so a run that
// only skips does not wait at all.
if (published > 0) await sleep(PUBLISH_SPACING_MS)
await publishTarball(tarball, name, version, family.distTagForVersion(version))
await publishTarball(tarball, name, version, distTag ?? family.distTagForVersion(version))
console.log(`release publish: ${progress} ${name}@${version} published`)
published += 1
}
@@ -179,4 +220,16 @@ async function main(): Promise<void> {
)
}
/** Publish the family named by `--family` from the directory named by `--from`. */
async function main(): Promise<void> {
const { values } = parseArgs({
options: { family: { type: 'string' }, from: { type: 'string' }, 'dist-tag': { type: 'string' } },
allowPositionals: false,
})
if (values.family === undefined || values.from === undefined) {
throw new Error('usage: publish.ts --family <dsh|vendor> --from <packed directory> [--dist-tag <tag>]')
}
await publishRelease(values.family, resolve(process.cwd(), values.from), values['dist-tag'])
}
if (isEntry(import.meta.url)) await main()
@@ -0,0 +1,82 @@
/** Packed-install npm cache ownership through the real offline verifier entry. */
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
const root = resolve(import.meta.dirname, '../..')
const roots: string[] = []
afterEach(() => {
for (const directory of roots.splice(0)) rmSync(directory, { recursive: true, force: true })
})
// The release verifier invokes POSIX npm directly, not the Windows npm.cmd shim.
describe.skipIf(process.platform === 'win32')('packed-install npm cache', () => {
// The verifier starts npm, lifecycle scripts, and the installed entry serially.
it.each(['success', 'install failure', 'entry failure'] as const)(
'owns cache writes and removes them after %s',
(outcome) => {
const directory = mkdtempSync(join(tmpdir(), 'dsh-packed-cache-test-'))
roots.push(directory)
const source = join(directory, 'package')
const packed = join(directory, 'packed')
const ambient = join(directory, 'ambient-cache')
const configured = join(directory, 'configured-cache')
const temporary = join(directory, 'temporary')
for (const path of [join(source, 'lib'), packed, ambient, configured, temporary]) mkdirSync(path, { recursive: true })
writeFileSync(join(ambient, 'sentinel'), 'ambient cache must survive\n')
writeFileSync(join(configured, 'sentinel'), 'configured cache must survive\n')
const npmrc = join(directory, 'npmrc')
writeFileSync(npmrc, `cache=${configured}\n`)
const evidence = join(directory, 'evidence.json')
const probe = `
const fs = require('node:fs')
const path = require('node:path')
const consumer = process.env.INIT_CWD || process.cwd()
const cache = path.join(consumer, '.npm-cache', '_cacache')
const files = fs.existsSync(cache) ? fs.readdirSync(cache, { recursive: true }).filter(name => fs.statSync(path.join(cache, name)).isFile()) : []
fs.writeFileSync(process.env.CACHE_EVIDENCE, JSON.stringify({ consumer, files }))
`
writeFileSync(join(source, 'probe.cjs'), probe + (outcome === 'install failure' ? '\nprocess.exit(37)\n' : ''))
writeFileSync(join(source, 'lib/bin.js'), outcome === 'entry failure' ? 'process.exit(37)\n' : 'console.log("0.0.1")\n')
writeFileSync(join(source, 'package.json'), JSON.stringify({
name: '@deepseek-ai/dsh',
version: '0.0.1',
scripts: { postinstall: 'node probe.cjs' },
}))
const archive = spawnSync('tar', ['-czf', join(packed, 'dsh.tgz'), '-C', directory, 'package'], { encoding: 'utf8', timeout: 30_000 })
expect(archive.error).toBeUndefined()
expect(archive.signal).toBeNull()
expect(archive.status, archive.stderr).toBe(0)
const environment = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^npm_config_/i.test(key)))
Object.assign(environment, {
npm_config_cache: ambient,
npm_config_userconfig: npmrc,
npm_config_offline: 'true',
npm_config_ignore_scripts: 'false',
CACHE_EVIDENCE: evidence,
TMPDIR: temporary,
TMP: temporary,
TEMP: temporary,
})
const result = spawnSync(process.execPath, [
'--import', 'tsx', 'scripts/release/verify-packed-install.ts', '--family', 'dsh', '--from', packed,
], { cwd: root, env: environment, encoding: 'utf8', timeout: 120_000 })
expect(result.error).toBeUndefined()
expect(result.signal).toBeNull()
expect(result.status, result.stdout + result.stderr).toBe(outcome === 'success' ? 0 : 1)
const observation = JSON.parse(readFileSync(evidence, 'utf8')) as { consumer: string; files: string[] }
expect(observation.files.length, 'npm must write tarball content into the consumer-private cache').toBeGreaterThan(0)
expect(observation.consumer).toContain('dsh-packed-dsh-')
expect(existsSync(observation.consumer), 'the verifier finally must remove its consumer and cache').toBe(false)
expect(readdirSync(ambient)).toEqual(['sentinel'])
expect(readFileSync(join(ambient, 'sentinel'), 'utf8')).toBe('ambient cache must survive\n')
expect(readdirSync(configured)).toEqual(['sentinel'])
expect(readFileSync(join(configured, 'sentinel'), 'utf8')).toBe('configured cache must survive\n')
},
180_000,
)
})
+3 -2
View File
@@ -1,6 +1,7 @@
/**
* Install packed tarballs into a throwaway consumer outside the repository and
* drive the installed executable with plain Node.
* drive the installed executable with plain Node. Its npm cache belongs to that
* consumer and is removed with it on normal completion or failure.
*
* Every tarball the installed tree needs comes from `--from`, so the only
* registry traffic is for external dependencies. That matters beyond hermetic
@@ -105,7 +106,7 @@ function main(): void {
// that cannot install them must still start — which is what optional means
// here. Their entry package is a plain dependency of dsh-sandbox-local, so
// its tarball is supplied through --from.
capture('npm', ['install', '--no-audit', '--no-fund', '--package-lock=false', '--omit=optional', '--loglevel=http'],
capture('npm', ['install', '--cache', join(consumerRoot, '.npm-cache'), '--no-audit', '--no-fund', '--package-lock=false', '--omit=optional', '--loglevel=http'],
{ cwd: consumerRoot, env: environment })
const installedEntry = join(consumerRoot, 'node_modules', entry.packageName)
-1
View File
@@ -152,7 +152,6 @@ const POSTCONDITIONS: readonly PostCondition[] = [
{ file: 'vendor/hmr/package.json', text: '"name": "@deepseek-ai/cordis-plugin-hmr"', count: 1 },
{ file: 'scripts/cordis-walk.ts', text: '@deepseek-ai\\/cordis', count: 1 },
{ file: 'scripts/cordis-walk.ts', text: '!== \'@deepseek-ai/cordis\'', count: 1 },
{ file: 'scripts/gen-scoped-events.ts', text: '=== \'@deepseek-ai/cordis\'', count: 1 },
{ file: 'packages/typert/generator/src/analyzer.ts', text: '!== \'@deepseek-ai/cordis\'', count: 2 },
{ file: 'scripts/check-workspace-constraints.ts', text: '?.[\'@deepseek-ai/cordis\']', count: 2 },
{ file: 'packages/boot/app-boot/tsdown.config.ts', text: '[\'@deepseek-ai/cordis-plugin-include\']', count: 1 },
+19
View File
@@ -0,0 +1,19 @@
/** Build benchmark artifacts with TypeScript diagnostics owned by required full builds. */
import { resolve } from 'node:path'
import { buildLibraryArtifacts, runPnpmCommand } from './compile-referenced-projects.ts'
/**
* Build native support, libraries, benchmark workers, and Web assets before running the existing benchmark suite.
* @param root - Repository root containing all build configurations.
* @throws If any build or the benchmark suite fails.
*/
export function runCiBench(root: string): void {
runPnpmCommand(root, ['run', 'build:native-system'])
buildLibraryArtifacts(root, false, 'benchmark-emit')
runPnpmCommand(root, ['exec', 'tsdown', '--config-loader', 'native', '--config', 'benchmarks/tsdown.config.ts'])
runPnpmCommand(root, ['run', 'build:web'])
runPnpmCommand(root, ['run', 'test:bench:built'])
}
if (import.meta.main) runCiBench(resolve(import.meta.dirname, '..'))
+1 -6
View File
@@ -2,9 +2,7 @@
import { resolve } from 'node:path'
import {
COVERAGE_PARTITIONS_ENV,
COVERAGE_TEST_TIMEOUT_ENV,
CoveragePartitionCoordinator,
coverageTestTimeoutArgs,
forwardedCoverageArgs,
parseCoveragePartitionCount,
} from './coverage-partitions.ts'
@@ -22,9 +20,6 @@ const coordinator = new CoveragePartitionCoordinator({
root: resolve(import.meta.dirname, '..'),
partitions,
pnpmEntrypoint,
vitestArgs: [
...coverageTestTimeoutArgs(process.env[COVERAGE_TEST_TIMEOUT_ENV]),
...forwardedCoverageArgs(process.argv.slice(2)),
],
vitestArgs: forwardedCoverageArgs(process.argv.slice(2)),
})
process.exitCode = await coordinator.run()
+249 -59
View File
@@ -1,5 +1,5 @@
import { readFileSync } from 'node:fs'
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi, type MockInstance } from 'vitest'
import {
cliGateOptions,
ciWorkerEnvironment,
@@ -15,18 +15,27 @@ import {
type GateResult,
} from './run-gates.ts'
// Graph fixtures select their own browser pool instead of inheriting the CI host's pool.
beforeEach(() => vi.stubEnv('DSH_WEB_SNAPSHOT_WORKERS', undefined))
// Graph fixtures select their own browser pool instead of inheriting the CI
// host's pool, and coverage gates take their shape from the values each case
// sets, not from the lane that runs this file (a partitioned lane exports
// DSH_COVERAGE_PARTITIONS to every child).
beforeEach(() => {
vi.stubEnv('DSH_WEB_SNAPSHOT_WORKERS', undefined)
vi.stubEnv('DSH_COVERAGE_MAX_WORKERS', undefined)
vi.stubEnv('DSH_COVERAGE_PARTITIONS', undefined)
})
afterEach(() => vi.unstubAllEnvs())
/**
* Capture output a gate streams through runGate's streamOutput path.
* @param onOutput - optional observer called after each captured chunk.
* @returns the accumulated chunks and the stdout spy to restore in finally.
*/
function captureStreamedOutput(): { writes: string[]; write: MockInstance } {
function captureStreamedOutput(onOutput?: (chunks: readonly string[]) => void): { writes: string[]; write: MockInstance } {
const writes: string[] = []
const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
writes.push(String(chunk))
onOutput?.(writes)
return true
})
return { writes, write }
@@ -140,6 +149,20 @@ function withEnv<T>(name: string, value: string | undefined, action: () => T): T
}
}
// Mutates worker-global state: only use for synchronous, non-concurrent graph inspection.
function withPlatform<T>(platform: NodeJS.Platform, action: () => T): T {
const original = Object.getOwnPropertyDescriptor(process, 'platform')!
Object.defineProperty(process, 'platform', { ...original, value: platform })
try { return action() } finally { Object.defineProperty(process, 'platform', original) }
}
// Mutates worker-global state: only use for synchronous, non-concurrent graph inspection.
function withNodeVersion<T>(version: string, action: () => T): T {
const original = Object.getOwnPropertyDescriptor(process.versions, 'node')!
Object.defineProperty(process.versions, 'node', { ...original, value: version })
try { return action() } finally { Object.defineProperty(process.versions, 'node', original) }
}
describe('CI worker allocation', () => {
it.each([1, 2, 4, 8, 16, 64])('shares a %i CPU coverage budget without multiplying pools', (cpus) => {
const env = ciWorkerEnvironment('ci-coverage', {}, cpus)
@@ -183,6 +206,13 @@ describe('CI worker allocation', () => {
.not.toHaveProperty('DSH_COVERAGE_PARTITIONS')
})
it('leaves the plain unit inventory with the environment pnpm run test finds', () => {
// A 3-CPU host would otherwise receive DSH_COVERAGE_PARTITIONS=2, which
// the inventory's own coverage-gate tests read as a partitioned lane.
expect(ciWorkerEnvironment('ci-unit', {}, 3)).toEqual({})
expect(ciWorkerEnvironment('ci-unit', {}, 16)).toEqual({})
})
it.each(['0', '-1', 'NaN', '2.5'])('rejects invalid worker budget %s', (raw) => {
expect(() => ciWorkerEnvironment('ci-coverage', { DSH_COVERAGE_MAX_WORKERS: raw }, 16))
.toThrow('DSH_COVERAGE_MAX_WORKERS must be a positive integer')
@@ -200,6 +230,7 @@ describe('gate graph validation', () => {
'ci-static',
'ci-lint-contracts-ready',
'ci-coverage',
'ci-unit',
'ci-bench',
'ci-snapshot',
'ci-artifacts',
@@ -219,7 +250,7 @@ describe('gate graph validation', () => {
await expect(runGates(subject, subject.length, execute)).resolves.toHaveLength(subject.length)
})
it('builds the native addon before benchmarks through the ci-bench script chain', () => {
it('uses the internal CI benchmark runner without changing the public benchmark scripts', () => {
const subject = withPnpmEntrypoint(() => gatesForMode('ci-bench'))
const { scripts } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
scripts: Record<string, string>
@@ -229,12 +260,12 @@ describe('gate graph validation', () => {
expect(subject).toHaveLength(1)
expect(subject[0]).toMatchObject({
id: 'bench',
displayCommand: 'pnpm run test:bench',
args: ['/private/pnpm.cjs', 'run', 'test:bench'],
displayCommand: 'pnpm exec tsx scripts/run-ci-bench.ts',
args: ['/private/pnpm.cjs', 'exec', 'tsx', 'scripts/run-ci-bench.ts'],
})
expect(scripts['test:bench']).toBe('npm run build:bench && npm run build:web && npm run test:bench:built')
expect(scripts['build:bench']).toBe(
'npm run build:native-system && npm run build:lib && tsdown --config benchmarks/tsdown.config.ts',
'npm run build:native-system && npm run build:lib && tsdown --config-loader native --config benchmarks/tsdown.config.ts',
)
expect(scripts['build:native-system']).toBe('tsx native/system/scripts/build.ts --host-addon-only')
expect(scripts['test:bench:built']).toBe('vitest run --config vitest.bench.config.ts')
@@ -317,9 +348,9 @@ describe('gate graph validation', () => {
expect(ids).toEqual([
'rescope-vendor', 'publint', 'constraints', 'default-product-isolation', 'package-dependencies', 'application-entrypoints',
'dsh-package-licenses', 'package-invariants', 'built-package-invariants', 'node-next-types',
'dsh-package-licenses', 'node-next-types',
'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'client-route-resolution', 'no-bare-dispatcher',
'no-unknown-casts',
'plugin-record-callers', 'no-unknown-casts',
'cordis-config', 'runtime-closure',
])
})
@@ -336,9 +367,9 @@ describe('gate graph validation', () => {
it('schedules the longest documentation leaves before short checks', () => {
const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
expect(ids.slice(0, 11)).toEqual([
expect(ids.slice(0, 10)).toEqual([
'doc-typecheck', 'docs-site-build', 'doc-graphs', 'markdown-links', 'type-equivalence',
'cordis-catalog', 'cordis-inspect-catalog', 'workflow-guest', 'mermaid', 'scoped-events', 'translation-pairing',
'cordis-catalog', 'cordis-inspect-catalog', 'workflow-guest', 'mermaid', 'translation-pairing',
])
})
@@ -439,7 +470,7 @@ describe('gate graph validation', () => {
it('keeps native Windows coverage blocking and behind the complete build', () => {
const complete = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))
const observational = withPnpmEntrypoint(() => gatesForMode('ci-windows-observational-ready'))
.filter(gate => gate.id !== 'docs-site-build')
.filter(gate => gate.id !== 'docs-site-build' && gate.id !== 'electron-install')
const byId = new Map(complete.map(subject => [subject.id, subject]))
expect(byId.get('coverage')?.allowFailure).not.toBe(true)
@@ -518,34 +549,126 @@ describe('gate graph validation', () => {
expect(results.some(result => result.status === 'skipped')).toBe(false)
})
it('applies one configured test, polling, and hook timeout to both coverage gates', () => {
const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', '15000', () =>
it.each([undefined, '3'])('provisions Electron before Windows coverage with partition count %s', async (partitions) => {
const gates = withEnv('DSH_COVERAGE_PARTITIONS', partitions, () =>
withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')))
const installers = gates.filter(gate => gate.id === 'electron-install')
expect(installers).toHaveLength(1)
expect(installers[0]?.allowFailure).not.toBe(true)
expect(installers[0]?.needs ?? []).not.toContain('build')
expect(gates.find(gate => gate.id === 'coverage')?.needs).toContain('electron-install')
let installed = false
await runGates(gates, 8, async (subject) => {
if (subject.id === 'electron-install') installed = true
if (subject.id === 'coverage') expect(installed).toBe(true)
return resultFor(subject, 'passed')
})
})
for (const id of ['coverage', 'coverage-exempt-heavy']) {
expect(gates.find(subject => subject.id === id)?.args).toEqual(expect.arrayContaining([
'--testTimeout=15000',
'--expect.poll.timeout=15000',
'--hookTimeout=15000',
]))
it.each(['ci-windows-complete', 'ci-windows-observational-ready'] as const)(
'provisions the locked Electron binary before built smoke in %s', (mode) => {
const gates = withPnpmEntrypoint(() => gatesForMode(mode))
const install = gates.find(gate => gate.id === 'electron-install')
expect(install).toMatchObject({
command: process.execPath,
args: ['/private/pnpm.cjs', '--filter', '@deepseek-ai/dsh-desktop', 'exec', 'install-electron'],
env: { ELECTRON_GET_USE_PROXY: '1' },
})
expect(gates.find(gate => gate.id === 'built-bin-smoke')?.needs).toContain('electron-install')
expect(gates.filter(gate => gate.id !== 'electron-install').every(gate => gate.env?.ELECTRON_GET_USE_PROXY === undefined)).toBe(true)
},
)
it.each(['ci-windows-complete', 'ci-windows-observational-ready'] as const)(
'keeps failed Electron provisioning visible without stopping unrelated diagnostics in %s', async (mode) => {
const gates = withPnpmEntrypoint(() => gatesForMode(mode))
const attempted: string[] = []
const results = await runGates(gates, 8, async (subject) => {
attempted.push(subject.id)
return resultFor(subject, subject.id === 'electron-install' ? 'failed' : 'passed')
})
expect(results.filter(result => result.status === 'failed').map(result => result.gate.id)).toEqual(['electron-install'])
expect(attempted).not.toContain('built-bin-smoke')
expect(results.find(result => result.gate.id === 'built-bin-smoke')?.status).toBe('skipped')
expect(results.find(result => result.gate.id === 'doc-graphs')?.status).toBe('passed')
if (mode === 'ci-windows-complete') {
expect(results.filter(result => result.status === 'failed' && !result.gate.allowFailure).map(result => result.gate.id))
.toEqual(['electron-install'])
expect(attempted).not.toContain('coverage')
expect(results.find(result => result.gate.id === 'coverage')?.status).toBe('skipped')
expect(results.find(result => result.gate.id === 'coverage-exempt-heavy')?.status).toBe('passed')
}
},
)
it.each(['ci-coverage', 'ci-artifacts', 'ci-windows-blocking'] as const)(
'only provisions Electron for native Windows coverage in %s', (mode) => {
const original = Object.getOwnPropertyDescriptor(process, 'platform')
for (const platform of ['linux', 'darwin', 'win32'] as const) {
const gates = withPlatform(platform, () => withPnpmEntrypoint(() => gatesForMode(mode)))
const needed = platform === 'win32' && mode === 'ci-coverage'
expect(gates.filter(gate => gate.id === 'electron-install')).toHaveLength(needed ? 1 : 0)
if (needed) {
expect(gates.find(gate => gate.id === 'coverage')?.needs).toContain('electron-install')
expect(gates.find(gate => gate.id === 'electron-install')?.env).toEqual({ ELECTRON_GET_USE_PROXY: '1' })
expect(gates.filter(gate => gate.id !== 'electron-install').every(gate => gate.env?.ELECTRON_GET_USE_PROXY === undefined)).toBe(true)
}
}
expect(Object.getOwnPropertyDescriptor(process, 'platform')).toEqual(original)
},
)
it('restores the platform descriptor when coverage configuration is rejected', () => {
const original = Object.getOwnPropertyDescriptor(process, 'platform')
expect(() => withEnv('DSH_COVERAGE_PARTITIONS', '1', () =>
withPlatform('win32', () => withPnpmEntrypoint(() => gatesForMode('ci-coverage')))))
.toThrow('DSH_COVERAGE_PARTITIONS must be an integer greater than 1')
expect(Object.getOwnPropertyDescriptor(process, 'platform')).toEqual(original)
})
it('leaves the lane test budget to the inherited environment on both coverage gates', () => {
// vitest.config.ts reads DSH_COVERAGE_TEST_TIMEOUT_MS per inline project
// (coverageTestTimeoutOptions); a gate argument would add a second owner.
for (const budget of ['15000', undefined]) {
const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', budget, () =>
withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')))
for (const id of ['coverage', 'coverage-exempt-heavy']) {
const gate = gates.find(subject => subject.id === id)
if (gate === undefined) throw new Error(`ci-windows-complete must define the ${id} gate`)
expect(gate.args).not.toEqual(expect.arrayContaining([
expect.stringMatching(/^--(?:testTimeout|expect\.poll\.timeout|hookTimeout)=/),
]))
expect(gate.env ?? {}).not.toHaveProperty('DSH_COVERAGE_TEST_TIMEOUT_MS')
}
}
})
it('keeps Vitest timeout defaults when the coverage override is absent', () => {
const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', undefined, () =>
withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')))
for (const id of ['coverage', 'coverage-exempt-heavy']) {
expect(gates.find(subject => subject.id === id)?.args).not.toEqual(expect.arrayContaining([
expect.stringMatching(/^--(?:testTimeout|expect\.poll\.timeout|hookTimeout)=/),
]))
it('runs the plain unit inventory after the native build and leaves its budget to the inherited environment', () => {
const { scripts } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
scripts: Record<string, string>
}
})
expect(scripts['check:ci:unit']).toBe('tsx scripts/run-gates.ts ci-unit')
it('rejects an invalid coverage timeout before starting a gate', () => {
expect(() => withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', '0', () =>
withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))))
.toThrow('DSH_COVERAGE_TEST_TIMEOUT_MS must be a positive integer')
// The aggregate is the `test` package script's two segments, in order,
// so a step added to one cannot silently leave the other. The lane sets
// DSH_COVERAGE_TEST_TIMEOUT_MS; vitest.config.ts reads it per inline
// project (coverageTestTimeoutOptions), so the gate passes no budget flag.
const [nativeBuild, unitRun, ...rest] = (scripts.test ?? '').split(' && ')
expect(rest).toEqual([])
for (const budget of ['15000', undefined]) {
const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', budget, () =>
withPnpmEntrypoint(() => gatesForMode('ci-unit')))
expect(gates.map(gate => gate.id)).toEqual(['native-system', 'unit'])
expect(gates[0]).toMatchObject({ displayCommand: nativeBuild })
expect(gates[1]?.displayCommand).toBe(`pnpm exec ${unitRun}`)
expect(gates[1]).toMatchObject({
label: 'test',
needs: ['native-system'],
streamOutput: true,
args: ['/private/pnpm.cjs', 'exec', 'vitest', 'run'],
})
expect(gates[1]?.env ?? {}).not.toHaveProperty('DSH_COVERAGE_TEST_TIMEOUT_MS')
}
})
it('selects partitioned coverage only when explicitly configured', () => {
@@ -698,20 +821,76 @@ describe('Typert contract preparation', () => {
})
describe('Node compatibility graph', () => {
it('runs the jsdom environment smoke on every advertised Node line', () => {
const subject = withPnpmEntrypoint(() => gatesForMode('node-compat'))
it.each(['22.19.0', '24.9.0', '26.0.0'])('runs all source compatibility smokes serially on Node %s', (version) => {
const subject = withNodeVersion(version, () =>
withPnpmEntrypoint(() => gatesForMode('node-compat')))
expect(subject.find(item => item.id === 'vitest-jsdom-smoke')).toMatchObject({
label: 'Vitest jsdom smoke',
expect(subject.filter(item => item.id === 'source-compat-smokes')).toMatchObject([{
label: 'source compatibility smokes',
args: [
'/private/pnpm.cjs',
'exec',
'vitest',
'run',
'packages/workflow/workflow-ptc/tests/source-runtime.compat.spec.ts',
'packages/session/session-persistence-jsonl/tests/zstd.compat.spec.ts',
'apps/cli/tests/source-launch.compat.spec.ts',
'scripts/vitest-environment.compat.spec.ts',
'packages/boot/app-boot/tests/profile-resolution.spec.ts',
'packages/boot/app-boot/tests/profile-resolution-service.spec.ts',
'packages/boot/app-boot/tests/profile-resolution-worker-bootstrap.spec.ts',
'--no-file-parallelism',
'--maxWorkers=1',
],
env: { VITEST_MAX_WORKERS: '1' },
}])
})
it.each(['1', '8'])('keeps source compatibility smokes serial with inherited VITEST_MAX_WORKERS=%s', (workers) => {
const subject = withEnv('VITEST_MAX_WORKERS', workers, () =>
withPnpmEntrypoint(() => gatesForMode('node-compat')))
expect(subject.find(item => item.id === 'source-compat-smokes')?.env).toEqual({ VITEST_MAX_WORKERS: '1' })
})
it.each([undefined, '1'])('builds the complete Node 22 artifacts once with skip-typecheck=%s', (skipTypecheck) => {
const subject = withNodeVersion('22.19.0', () =>
withEnv('DSH_NODE_COMPAT_SKIP_TYPECHECK', skipTypecheck, () =>
withPnpmEntrypoint(() => gatesForMode('node-compat'))))
expect(subject.filter(item => item.displayCommand === 'pnpm run build --artifacts-only')).toHaveLength(1)
expect(subject.find(item => item.id === 'build')?.args).toEqual([
'/private/pnpm.cjs', 'run', 'build', '--artifacts-only',
])
expect(subject.map(item => item.id)).not.toContain('build:web')
expect(subject.find(item => item.id === 'cli-lazy-search-startup-smoke')).toMatchObject({
needs: ['build'],
env: { DSH_REQUIRE_BUILT_CLI_SMOKE: '1' },
})
})
it.each(['passed', 'failed'] as const)('runs the Node 22 lazy-search smoke only after a passed build (%s)', async (buildStatus) => {
const subject = withNodeVersion('22.19.0', () =>
withEnv('DSH_NODE_COMPAT_SKIP_TYPECHECK', '1', () =>
withPnpmEntrypoint(() => gatesForMode('node-compat'))))
let buildCompleted = false
let smokeStarted = false
const results = await runGates(subject, 2, async (item) => {
if (item.id === 'build') {
buildCompleted = true
return resultFor(item, buildStatus)
}
if (item.id === 'cli-lazy-search-startup-smoke') {
expect(buildCompleted).toBe(true)
smokeStarted = true
}
return resultFor(item)
})
expect(smokeStarted).toBe(buildStatus === 'passed')
expect(results.find(item => item.gate.id === 'cli-lazy-search-startup-smoke')?.status)
.toBe(buildStatus === 'passed' ? 'passed' : 'skipped')
})
})
describe('Node 24 lane ownership', () => {
@@ -733,7 +912,6 @@ describe('Node 24 lane ownership', () => {
'build',
'node-compat',
'publint',
'built-package-invariants',
'lint-and-duplication',
'snapshot',
'expected-output',
@@ -749,8 +927,7 @@ describe('Node 24 lane ownership', () => {
expect(subject.find(item => item.id === 'node-compat')?.env).toEqual({
DSH_BUILD_CLIENT_PROFILE: 'official',
})
expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['build'])
expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants'])
expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['build'])
for (const id of [
'snapshot',
'expected-output',
@@ -759,7 +936,7 @@ describe('Node 24 lane ownership', () => {
'node-next-types',
'built-bin-smoke',
]) {
expect(subject.find(item => item.id === id)?.needs).toEqual(['built-package-invariants'])
expect(subject.find(item => item.id === id)?.needs).toEqual(['build'])
}
expect(subject.find(item => item.id === 'snapshot')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' })
expect(subject.find(item => item.id === 'expected-output')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' })
@@ -770,6 +947,7 @@ describe('Node 24 lane ownership', () => {
expect(subject.find(item => item.id === 'built-bin-smoke')?.args).toEqual(
expect.arrayContaining([
'apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts',
'apps/desktop/tests/acl-skill.built.e2e.ts',
'packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts',
'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts',
'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts',
@@ -800,7 +978,7 @@ describe('Linux primary graph', () => {
expect(web).toMatchObject({
displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built',
env: { DSH_SNAPSHOT: 'replay' },
needs: ['built-package-invariants'],
needs: ['build'],
})
})
})
@@ -904,29 +1082,41 @@ describe('fail-fast scheduling', () => {
})
it.skipIf(process.platform === 'win32')('marks a zero-exit child as aborted when the signal fired', async () => {
const { writes, write } = captureStreamedOutput()
const marker = 'signal-trap-armed'
const armed = Promise.withResolvers<undefined>()
const controller = new AbortController()
let promise: Promise<GateResult> | undefined
const { write } = captureStreamedOutput((chunks) => {
if (chunks.join('').split('\n').slice(0, -1).includes(marker)) armed.resolve(undefined)
})
const cleanup = async (): Promise<void> => {
controller.abort()
try { await promise } finally { write.mockRestore() }
}
onTestFinished(cleanup)
try {
const controller = new AbortController()
const child = gate('traps-signal', {
args: ['-e', "process.stdout.write('ready\\n'); process.on('SIGTERM', () => process.exit(0)); setInterval(() => {}, 1000)"],
streamOutput: true,
})
const promise = runGate(child, controller.signal)
// Wait for the child to register its SIGTERM trap before aborting, so
// the signal is caught and the child really exits zero.
const deadline = Date.now() + 5000
while (!writes.join('').includes('ready') && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 10))
}
// Readiness belongs after native signal-handler registration.
const script = [
"process.on('SIGTERM', () => process.exit(0))",
`process.stdout.write(${JSON.stringify(`${marker}\n`)})`,
'setInterval(() => {}, 1000)',
].join(';')
const child = gate('traps-signal', { args: ['-e', script], streamOutput: true })
promise = runGate(child, controller.signal)
await Promise.race([
armed.promise,
promise.then((result) => { throw new Error(`signal fixture exited before readiness: ${formatGateResultReason(result)}`) }),
])
controller.abort()
const result = await promise
// The child trapped the signal and exited zero; the drain must not
// report this gate passed, so the raw outcome carries the abort mark.
expect(result.status).toBe('passed')
expect(result.aborted).toBe(true)
expect(result, formatGateResultReason(result)).toMatchObject({
status: 'passed', exitCode: 0, signalCode: null, aborted: true,
})
} finally {
write.mockRestore()
await cleanup()
}
})
+74 -71
View File
@@ -3,7 +3,6 @@
*
* Package scripts own public aggregate names; this runner owns their validated
* dependency graphs, scheduler environment, and process diagnostics.
* @see ../.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md
*/
import { spawn, spawnSync } from 'node:child_process'
import { readdirSync, readFileSync } from 'node:fs'
@@ -12,12 +11,7 @@ import { resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
import { CLIENT_BUILD_PROFILE_SELECTOR } from './client-build-environment.ts'
import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './coverage-exempt.ts'
import {
COVERAGE_PARTITIONS_ENV,
COVERAGE_TEST_TIMEOUT_ENV,
coverageTestTimeoutArgs,
parseCoveragePartitionCount,
} from './coverage-partitions.ts'
import { COVERAGE_PARTITIONS_ENV, parseCoveragePartitionCount } from './coverage-partitions.ts'
import { pnpmInvocation } from './pnpm-invocation.ts'
/** A named aggregate exposed by the gate runner. */
@@ -27,6 +21,7 @@ export type Mode =
| 'ci-static'
| 'ci-lint-contracts-ready'
| 'ci-coverage'
| 'ci-unit'
| 'ci-bench'
| 'ci-snapshot'
| 'ci-artifacts'
@@ -141,6 +136,7 @@ function parseMode(raw: string | undefined): Mode {
case 'ci-static':
case 'ci-lint-contracts-ready':
case 'ci-coverage':
case 'ci-unit':
case 'ci-bench':
case 'ci-snapshot':
case 'ci-artifacts':
@@ -156,7 +152,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-contracts-ready | ci-coverage | ci-bench | ci-snapshot | ci-artifacts | ci-consumers | ci-windows-blocking | ci-windows-complete | ci-windows-observational-ready | node-compat | check-all | hygiene | doc-sync | doc-quick, got ${JSON.stringify(raw)}.`,
`run-gates: expected mode ci-primary | ci-linux-primary | ci-static | ci-lint-contracts-ready | ci-coverage | ci-unit | ci-bench | ci-snapshot | ci-artifacts | ci-consumers | ci-windows-blocking | ci-windows-complete | ci-windows-observational-ready | node-compat | check-all | hygiene | doc-sync | doc-quick, got ${JSON.stringify(raw)}.`,
)
}
}
@@ -201,7 +197,12 @@ export function ciWorkerEnvironment(
env: NodeJS.ProcessEnv,
available = availableParallelism(),
): Record<string, string> {
if (!mode.startsWith('ci-')) return {}
// ci-unit's gates read none of these settings, while the inventory it runs
// reads the same variables (run-gates.spec.ts builds coverage gates from
// DSH_COVERAGE_PARTITIONS; the oxlint contract spawns run-oxlint, which
// reads DSH_OXLINT_THREADS), so the aggregate leaves the environment as
// `pnpm run test` finds it.
if (!mode.startsWith('ci-') || mode === 'ci-unit') return {}
const additions: Record<string, string> = {}
const setDefault = (name: string, value: number): void => {
if (env[name] === undefined || env[name] === '') additions[name] = String(value)
@@ -271,7 +272,7 @@ export function gatesForMode(selected: Mode): Gate[] {
case 'ci-primary':
return ciPrimaryGates()
case 'ci-linux-primary':
return [...ciPrimaryGates(), webSnapshotGate(['built-package-invariants'])]
return [...ciPrimaryGates(), webSnapshotGate(['build'])]
case 'ci-static':
return ciStaticGates({ ownsBuild: false })
case 'ci-lint-contracts-ready':
@@ -281,8 +282,10 @@ export function gatesForMode(selected: Mode): Gate[] {
]
case 'ci-coverage':
return coverageGates()
case 'ci-unit':
return ciUnitGates()
case 'ci-bench':
return [pnpmScript('bench', 'test:bench', { label: 'performance benchmarks' })]
return [pnpmExec('bench', ['tsx', 'scripts/run-ci-bench.ts'], { label: 'performance benchmarks' })]
case 'ci-snapshot':
return [ciBuildGate(), snapshotGate()]
case 'ci-artifacts':
@@ -346,7 +349,6 @@ function ciSharedStaticGates(): Gate[] {
pnpmScript('constraints', 'constraints'),
pnpmScript('package-dependencies', 'verify-package-dependencies', { label: 'package dependencies' }),
pnpmScript('dsh-package-licenses', 'verify-dsh-package-licenses', { label: 'DSH package licenses' }),
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
pnpmScript('package-meta', 'verify-package-meta', { label: 'package metadata' }),
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
...sharedHygieneGates(),
@@ -364,6 +366,7 @@ function sharedHygieneGates(): Gate[] {
pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }),
pnpmScript('client-route-resolution', 'verify-client-route-resolution', { label: 'client route resolution' }),
pnpmScript('no-bare-dispatcher', 'verify-no-bare-dispatcher', { label: 'proxy-aware dispatchers' }),
pnpmScript('plugin-record-callers', 'verify-plugin-record-callers', { label: 'plugin record callers' }),
pnpmScript('no-unknown-casts', 'verify-no-unknown-casts', { label: 'no new unknown casts' }),
]
}
@@ -392,7 +395,6 @@ function ciPrimaryGates(): Gate[] {
label: 'node-next types',
needs: ['build'],
}),
builtPackageInvariantsGate(['build']),
builtBinSmokeGate(),
]
}
@@ -407,45 +409,31 @@ function nodeCompatGates(): Gate[] {
return [
...typecheck,
pnpmScript('build', 'build', {
...pnpmInvocation(['run', 'build', '--artifacts-only']),
displayCommand: 'pnpm run build --artifacts-only',
...typecheck.length === 0 ? {} : { needs: ['typecheck'] },
}),
pnpmScript('build:web', 'build:web', {
label: 'Web frontend build',
needs: ['build'],
}),
...nodeCompatSmokeGates({ cliSmoke: true }),
]
}
function nodeCompatSmokeGates(options: { cliSmoke?: boolean } = {}): Gate[] {
const gates: Gate[] = [
pnpmExec('source-worker-smoke', [
// Serial files keep CLI and PTC children from overlapping. Vitest's
// environment worker override takes precedence over its CLI flags.
pnpmExec('source-compat-smokes', [
'vitest',
'run',
'packages/workflow/workflow-ptc/tests/source-runtime.compat.spec.ts',
], { label: 'source worker smoke' }),
pnpmExec('jsonl-zstd-smoke', [
'vitest',
'run',
'packages/session/session-persistence-jsonl/tests/zstd.compat.spec.ts',
], { label: 'JSONL Zstandard smoke' }),
pnpmExec('dsh-source-launch-smoke', [
'vitest',
'run',
'apps/cli/tests/source-launch.compat.spec.ts',
], { label: 'dsh source-launch smoke' }),
pnpmExec('vitest-jsdom-smoke', [
'vitest',
'run',
'scripts/vitest-environment.compat.spec.ts',
], { label: 'Vitest jsdom smoke' }),
pnpmExec('profile-resolution-smoke', [
'vitest',
'run',
'packages/boot/app-boot/tests/profile-resolution.spec.ts',
'packages/boot/app-boot/tests/profile-resolution-service.spec.ts',
'packages/boot/app-boot/tests/profile-resolution-worker-bootstrap.spec.ts',
], { label: 'profile resolution smoke' }),
'--no-file-parallelism',
'--maxWorkers=1',
], { label: 'source compatibility smokes', env: { VITEST_MAX_WORKERS: '1' } }),
]
if (options.cliSmoke) {
gates.push(
@@ -456,7 +444,7 @@ function nodeCompatSmokeGates(options: { cliSmoke?: boolean } = {}): Gate[] {
], {
label: 'CLI lazy-search startup smoke',
env: { DSH_REQUIRE_BUILT_CLI_SMOKE: '1' },
needs: ['build:web'],
needs: ['build'],
}),
)
}
@@ -499,14 +487,12 @@ function ciArtifactGates(): Gate[] {
label: 'node-next types',
needs: ['build'],
}),
builtPackageInvariantsGate(['build']),
builtBinSmokeGate(),
]
}
function ciConsumerGates(): Gate[] {
const builtTree = ['build']
const validatedBuild = ['built-package-invariants']
// The HMR web test starts `dev:web`, which rewrites the shared `lib/` and
// `apps/web/dist/` trees. Let every build-artifact reader settle before that
// writer starts; `after` preserves the web diagnostic even if a reader fails.
@@ -526,23 +512,22 @@ function ciConsumerGates(): Gate[] {
env: { [CLIENT_BUILD_PROFILE_SELECTOR]: 'official' },
}),
pnpmScript('publint', 'publint', { needs: builtTree }),
builtPackageInvariantsGate(builtTree),
pnpmScript('lint-and-duplication', 'check:ci:lint:contracts-ready', {
label: 'lint and duplication',
needs: validatedBuild,
needs: builtTree,
}),
snapshotGate(validatedBuild),
expectedOutputGate(validatedBuild),
webSnapshotGate(validatedBuild, buildArtifactReaders),
snapshotGate(builtTree),
expectedOutputGate(builtTree),
webSnapshotGate(builtTree, buildArtifactReaders),
pnpmScript('doc-typecheck', 'doc-typecheck:contracts-ready', {
needs: validatedBuild,
needs: builtTree,
env: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' },
}),
pnpmScript('node-next-types', 'verify-node-next-types', {
label: 'node-next types',
needs: validatedBuild,
needs: builtTree,
}),
builtBinSmokeGate(validatedBuild),
builtBinSmokeGate(builtTree),
]
}
@@ -578,15 +563,15 @@ function ciWindowsBlockingGates(): Gate[] {
}
function ciWindowsCompleteGates(): Gate[] {
const coverage = coverageGates().map(gate => ({
const coverage = coverageGates('win32').map(gate => gate.id === 'electron-install' ? gate : {
...gate,
needs: [...new Set(['build', ...(gate.needs ?? [])])],
}))
})
const coverageAfter = coverage.map(gate => gate.id)
const observational = ciWindowsObservationalGates()
// The required production site replaces the observational MPA build; both
// VitePress modes write the same output directory and cannot overlap.
.filter(gate => gate.id !== 'build' && gate.id !== 'docs-site-build')
// Coverage owns Electron preparation. The required production site replaces
// the MPA build, which writes to the same output directory.
.filter(gate => gate.id !== 'build' && gate.id !== 'docs-site-build' && gate.id !== 'electron-install')
.map(gate => ({
...gate,
allowFailure: true,
@@ -603,9 +588,21 @@ function ciWindowsCompleteGates(): Gate[] {
]
}
// Native Electron fixtures need the locked binary before Vitest removes ambient proxies.
function electronInstallGate(): Gate {
return {
id: 'electron-install',
label: 'Electron binary',
displayCommand: 'pnpm --filter @deepseek-ai/dsh-desktop exec install-electron',
...pnpmInvocation(['--filter', '@deepseek-ai/dsh-desktop', 'exec', 'install-electron']),
env: { ELECTRON_GET_USE_PROXY: '1' },
}
}
function ciWindowsObservationalGates(): Gate[] {
const predecessors = [
...ciStaticGates({ ownsBuild: true }),
electronInstallGate(),
// Linux owns required lint and snapshots; Windows omits those duplicates.
pnpmScript('duplication', 'duplication'),
pnpmScript('publint', 'publint', { needs: ['build'] }),
@@ -613,12 +610,11 @@ function ciWindowsObservationalGates(): Gate[] {
label: 'node-next types',
needs: ['build'],
}),
builtPackageInvariantsGate(['build']),
]
return [
...predecessors,
{
...builtBinSmokeGate(),
...builtBinSmokeGate(['build', 'electron-install']),
// This smoke starts real application children with bounded startup
// deadlines. Let other Windows processes settle before measuring startup.
after: predecessors.map(gate => gate.id),
@@ -653,9 +649,8 @@ function lintGate(options: { needs?: string[] } = {}): Gate {
// small share. A budget of 1 gives each gate 1 worker; lanes that need a strict
// total of one (the serial reference jobs) also set DSH_GATE_CONCURRENCY=1,
// which keeps the gates from overlapping at all.
// DSH_COVERAGE_TEST_TIMEOUT_MS raises Vitest's per-test, expect.poll, and hook
// defaults together for instrumented lanes whose scheduling overhead exceeds
// those defaults. Explicit fixture timeouts remain authoritative.
// DSH_COVERAGE_TEST_TIMEOUT_MS is not a gate argument: vitest.config.ts reads
// it from the environment every gate inherits (coverageTestTimeoutOptions).
function coverageWorkerArgs(): { instrumented: string[]; exempt: string[] } {
const [flag] = positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers')
if (flag === undefined) return { instrumented: [], exempt: [] }
@@ -668,9 +663,9 @@ function coverageWorkerArgs(): { instrumented: string[]; exempt: string[] } {
}
}
function coverageGates(): Gate[] {
function coverageGates(platform: NodeJS.Platform = process.platform): Gate[] {
const electron = platform === 'win32' ? [electronInstallGate()] : []
const workers = coverageWorkerArgs()
const timeouts = coverageTestTimeoutArgs(process.env[COVERAGE_TEST_TIMEOUT_ENV])
const partitions = parseCoveragePartitionCount(process.env[COVERAGE_PARTITIONS_ENV])
const instrumented = partitions === undefined
? pnpmExec('coverage', [
@@ -678,7 +673,6 @@ function coverageGates(): Gate[] {
'run',
'--coverage',
...workers.instrumented,
...timeouts,
], {
label: 'test:coverage',
env: { [COVERAGE_EXEMPT_ENV]: '1' },
@@ -691,13 +685,13 @@ function coverageGates(): Gate[] {
})
return [
pnpmScript('native-system', 'build:native-system'),
{ ...instrumented, needs: ['native-system'] },
...electron,
{ ...instrumented, needs: ['native-system', ...electron.map(gate => gate.id)] },
pnpmExec('coverage-exempt-heavy', [
'vitest',
'run',
...coverageExemptHeavySuites.map(suite => suite.filter),
...workers.exempt,
...timeouts,
], {
label: 'test:coverage-exempt-heavy',
needs: ['native-system'],
@@ -705,6 +699,23 @@ function coverageGates(): Gate[] {
]
}
// The uninstrumented unit inventory for a whole-inventory reference lane
// (the Sandbox workflow's darwin parity job). It is `pnpm run test`; the lane
// sets DSH_COVERAGE_TEST_TIMEOUT_MS, which vitest.config.ts reads from the
// inherited environment, because a shared hosted runner delays cases that
// inherit Vitest's defaults past them. Output streams so the job log keeps
// per-file timestamps for a 15–30 minute run.
function ciUnitGates(): Gate[] {
return [
pnpmScript('native-system', 'build:native-system'),
pnpmExec('unit', ['vitest', 'run'], {
label: 'test',
needs: ['native-system'],
streamOutput: true,
}),
]
}
// Recorded-session adapters boot process scenarios in `lib` mode. Callers wait
// either on `build` or on a validation gate that transitively owns that build.
function snapshotGate(needs: string[] = ['build']): Gate {
@@ -723,13 +734,6 @@ function expectedOutputGate(needs: string[] = ['build']): Gate {
})
}
function builtPackageInvariantsGate(needs?: string[]): Gate {
return pnpmScript('built-package-invariants', 'verify-built-package-invariants', {
label: 'built package invariants',
...needs === undefined ? {} : { needs },
})
}
function positiveIntArg(envName: string, flag: string): string[] {
const raw = process.env[envName]
if (raw === undefined || raw === '') return []
@@ -757,8 +761,6 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
pnpmScript('package-dependencies', 'verify-package-dependencies', { label: 'package dependencies' }),
pnpmScript('application-entrypoints', 'verify-application-entrypoints', { label: 'application entrypoints' }),
pnpmScript('dsh-package-licenses', 'verify-dsh-package-licenses', { label: 'DSH package licenses' }),
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
builtPackageInvariantsGate(options.artifactNeeds),
pnpmScript('node-next-types', 'verify-node-next-types', {
label: 'node-next types',
...artifactOptions,
@@ -790,7 +792,6 @@ function docSyncLeafGates(options: {
pnpmScript('cordis-inspect-catalog', 'verify-cordis-inspect-catalog', { label: 'Cordis inspect catalog' }),
pnpmScript('workflow-guest', 'verify-workflow-guest', { label: 'workflow guest source' }),
pnpmScript('mermaid', 'verify-mermaid'),
pnpmScript('scoped-events', 'verify-scoped-events', { label: 'scoped events' }),
pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing', quick: true }),
pnpmScript('markdown-wrap', 'verify-md-wrap', { label: 'markdown wrap', quick: true }),
pnpmScript('client-catalog', 'verify-client-catalog', { label: 'client catalog' }),
@@ -820,6 +821,7 @@ function docSyncLeafGates(options: {
pnpmScript('skill-invocation-metadata', 'verify-skill-invocation-metadata', { label: 'skill invocation metadata', quick: true }),
pnpmScript('translation-prompt', 'verify-translation-prompt', { label: 'translation prompt', quick: true }),
pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets', quick: true }),
pnpmScript('upgrade-guides', 'verify-upgrade-guides', { label: 'upgrade guides', quick: true }),
pnpmExec('doc-standard-tests', ['vitest', 'run', 'scripts/doc-standard.spec.ts'], {
label: 'documentation standard tests',
quick: true,
@@ -855,6 +857,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate {
'apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts',
'apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts',
'apps/cli/tests/built-bin.e2e.ts',
'apps/desktop/tests/acl-skill.built.e2e.ts',
'packages/host/directory-picker-native/tests/built-worker.e2e.ts',
'packages/sdk/server/tests/built-scope-carrier.e2e.ts',
'packages/deliverables/tool-present/tests/built-errors.e2e.ts',
+2 -3
View File
@@ -1128,7 +1128,7 @@ def smoke_sdk_live() -> None:
def assert_live_turn(label: str, result: RunResult) -> None:
"""Require completed model tool use and the smoke sentinel on the final answer line."""
"""Require completed model tool use and a standalone smoke acknowledgement."""
if result.finish_reason != "completed":
event_types = [event.get("type") for event in result.events]
turn_end_data = next(
@@ -1145,8 +1145,7 @@ def assert_live_turn(label: str, result: RunResult) -> None:
f"{label} turn made no model-requested tool call; "
f"final={result.final_response!r}"
)
answer_lines = result.final_response.strip().splitlines()
if not answer_lines or answer_lines[-1].strip() != LIVE_API_SENTINEL:
if not any(line.strip() == LIVE_API_SENTINEL for line in result.final_response.splitlines()):
raise AssertionError(f"{label} turn returned {result.final_response!r}")
def safe_turn_end(value: object) -> object:
-419
View File
@@ -1,419 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { Context, FiberState, Service, ValidationError } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import z from '@deepseek-ai/schemastery'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import { packageInvariantOwners } from './package-invariants.ts'
import {
TEST_INVARIANT_READY_SERVICE,
testInvariantCompanionPaths,
testInvariantCompanions,
type TestInvariantCompanion,
usesManualInvariantTree,
} from './test-invariants.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
testInvariantProbe: TestInvariantProbe
}
}
class TestInvariantProbe extends Service {
constructor(ctx: Context) {
super(ctx, 'testInvariantProbe')
}
}
function deferred(): { readonly promise: Promise<void>; readonly resolve: () => void } {
let resolve!: () => void
const promise = new Promise<void>((done) => {
resolve = done
})
return { promise, resolve }
}
function requiredConfig() {
return z.object({
requiredValue: z.string().required(),
})
}
function invalidConfigApply(): never {
throw new Error('invalid plugin apply executed')
}
async function rejectionOf(fiber: ReturnType<Context['plugin']>): Promise<unknown> {
return fiber.then(
() => undefined,
(error: unknown) => error,
)
}
function expectRequiredConfigValidation(error: unknown): void {
expect(error).toBeInstanceOf(ValidationError)
expect(error).toHaveProperty('message', expect.stringMatching(/requiredValue/))
}
async function withFakeCompanions(
create: (path: string, index: number) => () => Promise<TestInvariantCompanion>,
run: () => Promise<void>,
): Promise<void> {
const mutable = testInvariantCompanions as Record<string, () => Promise<TestInvariantCompanion>>
const originals = Object.entries(mutable)
for (const [index, [path]] of originals.entries()) {
mutable[path] = create(path, index)
}
try {
await run()
} finally {
for (const [path, load] of originals) {
mutable[path] = load
}
}
}
async function withDelayedFirstCompanion(
run: (control: { readonly started: Promise<void>; readonly release: () => void }) => Promise<void>,
): Promise<void> {
const started = deferred()
const release = deferred()
await withFakeCompanions(
(_path, index) => async () => ({
name: `test-invariant-${index}`,
inject: ['invariants'],
async apply() {
if (index === 0) {
started.resolve()
await release.promise
}
return () => {}
},
}),
() => run({ started: started.promise, release: release.resolve }),
)
}
describe('global test invariant host', () => {
it('uses one topology to reserve every companion owner with enabled checks', async () => {
const ctx = new Context()
await ctx.plugin(TestInvariantProbe)
const owners = packageInvariantOwners(process.cwd())
expect(Object.keys(testInvariantCompanions)).toHaveLength(owners.length)
const unreserved: string[] = []
for (const owner of owners) {
try {
const dispose = ctx.invariants.register(owner.packageName, () => {})
unreserved.push(owner.packageName)
dispose()
} catch (error) {
expect(error).toHaveProperty(
'message',
`invariants: package "${owner.packageName}" is already registered`,
)
}
}
expect(unreserved).toEqual([])
})
it('mounts an owning companion and leaves omitted or non-package roots service-only', () => {
expect(testInvariantCompanionPaths('/repo/packages/core/tools/tests/tools.spec.ts'))
.toEqual(['../packages/core/tools/src/invariant.ts'])
expect(testInvariantCompanionPaths('/repo/packages/util/brand/tests/brand.spec.ts')).toEqual([])
expect(testInvariantCompanionPaths('/repo/apps/cli/tests/profiles/headless/example.spec.ts')).toEqual([])
expect(testInvariantCompanionPaths('/repo/scripts/test-invariants.spec.ts'))
.toEqual(Object.keys(testInvariantCompanions).sort())
})
it('loads and executes every source companion through the real Loader setup', async () => {
const owners = new Map(packageInvariantOwners(process.cwd()).map(owner => [owner.sourcePath, owner.packageName]))
const registrations = new Map<string, string>()
const loader = Object.create(Loader.prototype) as Loader
const register = vi.fn((_packageName: string, installer: InvariantInstaller) => {
expect(typeof installer).toBe('function')
return () => {}
})
const fakeContext = { invariants: { register } } as unknown as Context
for (const [rawPath, load] of Object.entries(testInvariantCompanions)) {
const companion = await load()
const path = rawPath.replace(/^\.\.\//, '')
expect(companion.default, path).toBeUndefined()
const unwrapped = loader.unwrapExports(companion) as typeof companion
expect(unwrapped, path).toBe(companion)
expect(typeof unwrapped.name, path).toBe('string')
expect(unwrapped.inject, path).toContain('invariants')
expect(typeof unwrapped.apply, path).toBe('function')
await unwrapped.apply(fakeContext)
const call = register.mock.calls.at(-1)
if (call === undefined) throw new Error(`${path}: companion did not register`)
registrations.set(path, call[0])
}
expect(registrations).toEqual(owners)
})
it('recognizes focused invariant suites without a package inventory', () => {
expect(usesManualInvariantTree('/repo/packages/core/session/tests/invariant.spec.ts')).toBe(true)
expect(usesManualInvariantTree('/repo/packages/core/session/tests/request-invariant-hmr.spec.ts')).toBe(true)
expect(usesManualInvariantTree('C:\\repo\\packages\\runtime-diagnostics\\invariants\\tests\\service.spec.ts')).toBe(true)
expect(usesManualInvariantTree('/repo/packages/core/session/tests/session.spec.ts')).toBe(false)
})
it('preserves config validation failures without starting the rejected plugin', async () => {
const ctx = new Context()
const apply = vi.fn(invalidConfigApply)
const plugin = {
apply,
Config: requiredConfig(),
}
const fiber = ctx.plugin(plugin, {})
const firstError = await rejectionOf(fiber)
expectRequiredConfigValidation(firstError)
await ctx.plugin(TestInvariantProbe)
const secondError = await rejectionOf(fiber)
expect(secondError).toBe(firstError)
expect(fiber.state).toBe(FiberState.DISPOSED)
expect(apply).not.toHaveBeenCalled()
})
it('disposes invalid config after delayed invariant readiness', async () => {
await withDelayedFirstCompanion(
async ({ started, release }) => {
const ctx = new Context()
const apply = vi.fn(invalidConfigApply)
const plugin = {
apply,
Config: requiredConfig(),
}
const fiber = ctx.plugin(plugin, {})
const returnedError = rejectionOf(fiber)
await started
expect(fiber.state).toBe(FiberState.PENDING)
expect(apply).not.toHaveBeenCalled()
release()
expectRequiredConfigValidation(await returnedError)
expect(fiber.state).toBe(FiberState.DISPOSED)
expect(apply).not.toHaveBeenCalled()
},
)
})
it('retains a valid plugin failure after delayed invariant readiness', async () => {
await withDelayedFirstCompanion(
async ({ started, release }) => {
const ctx = new Context()
const failure = new Error('valid plugin apply failed')
const apply = vi.fn(function validConfigApply() {
throw failure
})
const plugin = {
apply,
Config: z.object({}),
}
const fiber = ctx.plugin(plugin, {})
const returnedError = rejectionOf(fiber)
await started
expect(fiber.state).toBe(FiberState.PENDING)
expect(apply).not.toHaveBeenCalled()
release()
expect(await returnedError).toBe(failure)
expect(fiber.state).toBe(FiberState.FAILED)
expect(apply).toHaveBeenCalledOnce()
expect(ctx.registry.has(plugin)).toBe(true)
expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1)
},
)
})
it('holds a root plugin until every lazy companion is active, then permits nested startup', async () => {
const delayedStarted = deferred()
const releaseDelayed = deferred()
const order: string[] = []
let delayedCompanion: TestInvariantCompanion | undefined
const companionNestedApply = vi.fn(function companionNestedApply() {})
await withFakeCompanions(
(path, index) => async () => {
const companion: TestInvariantCompanion = {
name: `test-invariant-${index}`,
inject: ['invariants'],
async apply(companionCtx) {
order.push(`companion-start:${path}`)
if (index === 0) {
delayedStarted.resolve()
await releaseDelayed.promise
}
if (index === 1) await companionCtx.plugin(companionNestedApply)
order.push(`companion-active:${path}`)
return () => {}
},
}
if (index === 0) delayedCompanion = companion
return companion
},
async () => {
const ctx = new Context()
ctx.provide('testInvariantTargetDependency', true)
let nestedFiber: ReturnType<Context['plugin']> | undefined
const nestedApply = vi.fn(function nestedApply() {
order.push('nested')
})
const targetApply = Object.assign(vi.fn(function targetApply(targetCtx: Context) {
order.push('target')
nestedFiber = targetCtx.plugin(nestedApply)
}), {
inject: ['testInvariantTargetDependency'],
})
const targetFiber = ctx.plugin(targetApply)
expect(ctx.registry.get(targetApply)?.callback).toBe(targetApply)
expect(targetFiber.inject).toEqual({
testInvariantTargetDependency: null,
[TEST_INVARIANT_READY_SERVICE]: null,
})
await delayedStarted.promise
await Promise.resolve()
await Promise.resolve()
expect(targetApply).not.toHaveBeenCalled()
releaseDelayed.resolve()
await targetFiber
if (nestedFiber === undefined) throw new Error('target did not register its nested plugin')
await nestedFiber
expect(targetFiber.state).toBe(FiberState.ACTIVE)
expect(targetApply).toHaveBeenCalledOnce()
expect(nestedApply).toHaveBeenCalledOnce()
expect(companionNestedApply).toHaveBeenCalledOnce()
const targetIndex = order.indexOf('target')
expect(targetIndex).toBeGreaterThan(-1)
expect(order.slice(0, targetIndex)).toHaveLength(Object.keys(testInvariantCompanions).length * 2)
expect(order.at(-1)).toBe('nested')
if (delayedCompanion === undefined) throw new Error('delayed companion did not load')
await ctx.plugin(InvariantRegistry, { enabled: true })
await ctx.plugin(delayedCompanion)
expect(ctx.registry.get(InvariantRegistry)?.fibers).toHaveLength(1)
expect(ctx.registry.get(delayedCompanion)?.fibers).toHaveLength(1)
},
)
})
it('holds plugins registered on a root-derived context until companion readiness', async () => {
await withDelayedFirstCompanion(
async ({ started, release }) => {
const ctx = new Context()
const rootApply = vi.fn(function rootApply() {})
const derivedApply = vi.fn(function derivedApply() {})
const derived = ctx.extend()
.isolate('testInvariantDerived')
.intercept('testInvariantDerived', {})
const rootFiber = ctx.plugin(rootApply)
const derivedFiber = derived.plugin(derivedApply)
await started
await Promise.resolve()
await Promise.resolve()
expect(rootApply).not.toHaveBeenCalled()
expect(derivedApply).not.toHaveBeenCalled()
expect(derivedFiber.inject).toEqual({
[TEST_INVARIANT_READY_SERVICE]: null,
})
release()
await Promise.all([rootFiber, derivedFiber])
expect(rootFiber.state).toBe(FiberState.ACTIVE)
expect(derivedFiber.state).toBe(FiberState.ACTIVE)
expect(rootApply).toHaveBeenCalledOnce()
expect(derivedApply).toHaveBeenCalledOnce()
},
)
})
it('holds a child registered externally on a pending target context', async () => {
await withDelayedFirstCompanion(
async ({ started, release }) => {
const ctx = new Context()
const targetApply = vi.fn(function targetApply() {})
const childApply = vi.fn(function childApply() {})
const targetFiber = ctx.plugin(targetApply)
const childFiber = targetFiber.ctx.plugin(childApply)
await started
await Promise.resolve()
await Promise.resolve()
expect(targetFiber.state).toBe(FiberState.PENDING)
expect(childFiber.state).toBe(FiberState.PENDING)
expect(targetApply).not.toHaveBeenCalled()
expect(childApply).not.toHaveBeenCalled()
expect(childFiber.inject).toEqual({
[TEST_INVARIANT_READY_SERVICE]: null,
})
release()
await Promise.all([targetFiber, childFiber])
expect(targetFiber.state).toBe(FiberState.ACTIVE)
expect(childFiber.state).toBe(FiberState.ACTIVE)
expect(targetApply).toHaveBeenCalledOnce()
expect(childApply).toHaveBeenCalledOnce()
},
)
})
it.each(['load', 'startup'] as const)(
'rejects a target when a lazy companion fails during %s without starting the target',
async (phase) => {
const failure = new Error(`test invariant companion ${phase} failed`)
await withFakeCompanions(
(_path, index) => phase === 'load' && index === 0
? async () => { throw failure }
: async () => ({
name: `test-invariant-${index}`,
inject: ['invariants'],
async apply() {
if (phase === 'startup' && index === 0) throw failure
return () => {}
},
}),
async () => {
const ctx = new Context()
const targetApply = vi.fn(function targetApply() {})
const targetFiber = ctx.plugin(targetApply)
await expect(targetFiber).rejects.toBe(failure)
expect(targetApply).not.toHaveBeenCalled()
expect(targetFiber.state).toBe(FiberState.PENDING)
await expect(targetFiber.dispose()).resolves.toBeUndefined()
expect(targetFiber.state).toBe(FiberState.DISPOSED)
},
)
},
)
it('disposes a pending target without waiting for companion readiness', async () => {
await withDelayedFirstCompanion(
async ({ started, release }) => {
const ctx = new Context()
const targetApply = vi.fn(function targetApply() {})
const targetFiber = ctx.plugin(targetApply)
await started
await expect(targetFiber.dispose()).resolves.toBeUndefined()
expect(targetFiber.state).toBe(FiberState.DISPOSED)
expect(targetApply).not.toHaveBeenCalled()
release()
await targetFiber
expect(targetApply).not.toHaveBeenCalled()
},
)
})
})
-232
View File
@@ -1,232 +0,0 @@
/**
* Vitest-wide invariant host. Ordinary Cordis roots receive the invariant
* service with global enablement plus the current test package's companion,
* when it publishes one.
* One topology test mounts every companion; focused invariant tests own their
* service topology explicitly.
*/
import { expect } from 'vitest'
import { FiberState, Inject, RegistryService, ValidationError } from '@deepseek-ai/cordis'
import type { Context, Plugin } from '@deepseek-ai/cordis'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
declare global {
interface ImportMeta {
/** Lazy Vite module-glob expansion used by the Vitest setup file. */
glob<TModule>(pattern: string): Record<string, () => Promise<TModule>>
}
}
/** Loader-safe exports shared by every package invariant companion. */
export interface TestInvariantCompanion {
readonly name: string
readonly inject: readonly string[]
readonly default?: unknown
apply(ctx: Context): Promise<() => void>
}
/** Private service dependency that holds ordinary root plugins until invariant startup completes. */
export const TEST_INVARIANT_READY_SERVICE = 'testInvariantReady'
/**
* Every published package companion as a lazy loader keyed by glob path. Ordinary tests
* load only their owner's module; the exhaustive topology test loads and
* executes all of them, so aggregated coverage still observes every
* registration while per-file setup avoids importing unrelated companions and their
* transitive package sources.
*/
export const testInvariantCompanions: Readonly<Record<string, () => Promise<TestInvariantCompanion>>> =
import.meta.glob<TestInvariantCompanion>('../packages/*/*/src/invariant.ts')
/** Manual-topology suites whose names cannot follow the focused invariant convention. */
const MANUAL_INVARIANT_TEST_EXCEPTIONS = [
'/packages/runtime-diagnostics/invariants/tests/service.spec.ts',
] as const
interface InvariantHost {
readonly byCallback: ReadonlyMap<unknown, PluginFiber>
readonly barrierOwners: WeakSet<Context['fiber']>
readonly ready: Promise<void>
}
type PluginFiber = ReturnType<RegistryService['plugin']>
type PluginCallback = Plugin.Function | Plugin.Constructor
const hosts = new WeakMap<Context, InvariantHost>()
// oxlint-disable-next-line typescript/unbound-method -- every call below supplies its RegistryService receiver explicitly.
const originalPlugin = RegistryService.prototype.plugin
RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, getOuterStack?: () => string[]) {
const testPath = expect.getState().testPath ?? ''
if (usesManualInvariantTree(testPath)) return originalPlugin.call(this, plugin, config, getOuterStack)
const root = this.ctx.root
const host = hosts.get(root) ?? startInvariantHost(root)
const callback = this.resolve(plugin)
const existing = callback === undefined ? undefined : host.byCallback.get(callback)
if (existing !== undefined) {
return hasBarrierOwner(host, this.ctx) ? existing : joinInvariantStartup(existing, host.ready)
}
// Causal descendants of a gated target have already crossed the barrier.
// Host service and companion descendants also bypass it so their own startup
// cannot depend on the readiness they are responsible for providing.
if (hasBarrierOwner(host, this.ctx)) {
return originalPlugin.call(this, plugin, config, getOuterStack)
}
if (callback === undefined) {
return originalPlugin.call(this, plugin, config, getOuterStack)
}
const fiber = originalPlugin.call(
this,
withInvariantReadiness(plugin, callback as PluginCallback),
config,
getOuterStack,
)
const initiallyPending = fiber.ctx.fiber.state === FiberState.PENDING
host.barrierOwners.add(fiber.ctx.fiber)
return joinInvariantStartup(fiber, host.ready, initiallyPending)
}
/**
* Detect focused suites that construct service selection or companion lifecycle explicitly.
* @param testPath - absolute or repo-relative Vitest file path.
* @returns whether the global invariant host must leave the root untouched.
*/
export function usesManualInvariantTree(testPath: string): boolean {
const normalized = testPath.replaceAll('\\', '/')
if (/\/packages\/[^/]+\/[^/]+\/tests\/[^/]*invariant[^/]*\.spec\.ts$/.test(normalized)) return true
return MANUAL_INVARIANT_TEST_EXCEPTIONS.some(path => normalized.endsWith(path))
}
const ALL_COMPANION_TESTS = ['/scripts/test-invariants.spec.ts'] as const
/**
* Select the package companions that an ordinary test root must register.
* Package tests receive their owner's checks; the dedicated topology test
* receives every companion owner so coverage and runtime registration remain
* independently enforced.
* @param testPath - absolute or repo-relative normalized Vitest file path.
* @returns sorted `import.meta.glob` keys for companions to mount.
*/
export function testInvariantCompanionPaths(testPath: string): string[] {
const normalized = testPath.replaceAll('\\', '/')
const allPaths = Object.keys(testInvariantCompanions).sort()
if (ALL_COMPANION_TESTS.some(path => normalized.endsWith(path))) return allPaths
const owner = normalized.match(/\/packages\/([^/]+)\/([^/]+)\/tests\//)
if (owner === null) return []
const companionPath = `../packages/${owner[1]}/${owner[2]}/src/invariant.ts`
return testInvariantCompanions[companionPath] === undefined ? [] : [companionPath]
}
function startInvariantHost(root: Context): InvariantHost {
const byCallback = new Map<unknown, PluginFiber>()
const barrierOwners = new WeakSet<Context['fiber']>()
const mount = (plugin: Plugin, config?: unknown): PluginFiber => {
const fiber = originalPlugin.call(root.registry, plugin, config)
const callback = root.registry.resolve(plugin)
if (callback === undefined) throw new Error('test invariants: companion is not a valid Cordis plugin')
byCallback.set(callback, fiber)
barrierOwners.add(fiber.ctx.fiber)
return fiber
}
// The service mounts synchronously so the intercepted registration that
// started this host immediately finds its own fiber in byCallback.
// Companions load and mount inside the ready chain (after the service is
// active, so their startup is directly joinable); every joined root plugin
// awaits ready, so none starts ahead of its package checks. Tests plugging
// a companion directly must await an earlier root plugin first — the
// duplicate-mount failure otherwise is loud (owner name already reserved).
const serviceFiber = mount(InvariantRegistry, { enabled: true })
const testPath = expect.getState().testPath ?? ''
const companionPaths = testInvariantCompanionPaths(testPath)
const ready = requireActive(serviceFiber, 'invariant service').then(async () => {
const companions = await Promise.all(companionPaths.map(async (path) => {
const load = testInvariantCompanions[path]
if (load === undefined) {
throw new Error(`test invariants: selected companion vanished at ${path}`)
}
const companion = await load()
if (!companion.inject.includes('invariants')) {
throw new Error(`test invariants: ${path} must inject the invariant service`)
}
return { companion, path }
}))
const companionFibers = companions.map(({ companion, path }) => ({
fiber: mount(companion),
path,
}))
await Promise.all(companionFibers.map(({ fiber, path }) => requireActive(fiber, path)))
root.provide(TEST_INVARIANT_READY_SERVICE, true)
})
const host = { byCallback, barrierOwners, ready }
hosts.set(root, host)
return host
}
function hasBarrierOwner(host: InvariantHost, ctx: Context): boolean {
let fiber = ctx.fiber
while (true) {
if (
host.barrierOwners.has(fiber)
&& (fiber.state === FiberState.LOADING || fiber.state === FiberState.ACTIVE)
) {
return true
}
const parent = fiber.parent.fiber
if (parent === fiber) return false
fiber = parent
}
}
async function requireActive(fiber: PluginFiber, label: string): Promise<void> {
await fiber.await()
if (fiber.state !== FiberState.ACTIVE) {
throw new Error(`test invariants: ${label} settled without becoming active`)
}
}
function withInvariantReadiness(plugin: Plugin, callback: PluginCallback): Plugin.Object {
return {
apply: callback as Plugin.Function,
inject: {
...Inject.resolve(plugin.inject),
[TEST_INVARIANT_READY_SERVICE]: null,
},
...(plugin.name === undefined ? {} : { name: plugin.name }),
...(plugin.Config === undefined ? {} : { Config: plugin.Config }),
...(plugin.provide === undefined ? {} : { provide: plugin.provide }),
...(plugin.intercept === undefined ? {} : { intercept: plugin.intercept }),
}
}
function joinInvariantStartup(
fiber: PluginFiber,
invariantReady: Promise<void>,
disposePendingValidationFailure = false,
): PluginFiber {
// RegistryService returns a thenable wrapper whose context still points to
// the raw Fiber. Calling inherited await() on the wrapper would return and
// assimilate that thenable, accidentally following later plugin startup.
const rawFiber = fiber.ctx.fiber
const readiness = invariantReady.then(async () => {
try {
return await rawFiber.await()
} catch (error) {
// Config resolves only after the readiness injection activates. Dispose
// validation failures owned by an initially pending target; ordinary
// callback failures remain inspectable.
if (disposePendingValidationFailure && error instanceof ValidationError) {
await rawFiber.dispose()
}
throw error
}
})
const joined = Object.create(fiber) as PluginFiber
joined.then = readiness.then.bind(readiness)
return joined
}
+1 -1
View File
@@ -25,7 +25,7 @@ describe('ambient proxy environment', () => {
// A runtime assertion that this process is clear would pass either way: importing the module
// above already ran it. What can actually regress is the wiring — a new Vitest project, or a
// config that lists only the invariant host — so that is what this pins.
// config that omits the proxy setup — so that is what this pins.
const declared = vitestConfigFiles()
.map(config => ({ config, slots: readFileSync(config, 'utf8').match(/setupFiles: \[[^\]]*\]/g) ?? [] }))
.filter(entry => entry.slots.length > 0)
+4 -2
View File
@@ -146,7 +146,9 @@ for (const [file, jobIds] of [['release.yml', ['dependencies', 'pack']], ['relea
const output = family === 'dsh' ? 'dist/npm' : 'dist/npm-vendor'
expect(job.steps[0]?.with?.['fetch-depth']).toBe(0)
expect(commands).toContain('pnpm run release:verify --family ' + family)
expect(commands).toContain('pnpm run ' + (family === 'dsh' ? 'build:official' : 'build:lib:host'))
expect(commands).toContain(family === 'dsh'
? "${{ github.event_name == 'pull_request' && 'pnpm run build --artifacts-only --profile official' || 'pnpm run build:official' }}"
: "${{ github.event_name == 'pull_request' && 'pnpm exec tsx scripts/compile-referenced-projects.ts host-libraries' || 'pnpm run build:lib:host' }}")
expect(commands).toContain('pnpm run release:pack --family ' + family + ' --out ' + output + ' --concurrency 8')
expect(commands).toContain('pnpm run release:verify-packed-install --family ' + family + ' --from ' + output
+ (family === 'dsh' ? ' --from dist/npm-vendor --from dist/npm-landlock' : ''))
@@ -161,6 +163,6 @@ for (const [file, jobIds] of [['release.yml', ['dependencies', 'pack']], ['relea
it.each(['release-publish.yml', 'release-vendor-publish.yml'])('keeps %s manual and entirely hosted', (file) => {
const publish = workflow(file)
expect(publish.on).toEqual({ workflow_dispatch: null })
expect(Object.keys(publish.on)).toEqual(['workflow_dispatch'])
for (const job of Object.values(publish.jobs)) expect(job['runs-on']).toBe(hosted)
})
+28
View File
@@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { parseMarkdown } from './markdown.ts'
import {
normalizeTranslationMarkdownLinks,
rewriteTranslationLinkLocales,
@@ -183,6 +184,33 @@ describe('translation link locale validation', () => {
})
describe('translation link rewriting and normalization', () => {
it('reuses an authored tree without changing its offsets, switcher exemption, or reference resolution', () => {
const root = fixture()
const input = '# 指南\n\n[English](guide.md) | 中文\n\n[行内](reference.md?view=full&amp;x=1#overview "标题")\n\n[引用][ref]\n\n[ref]: <reference.md#tail> "标注"\n[ref]: unpaired.md\n'
const context = linkContext(root, 'docs/guide.zh.md')
const tree = parseMarkdown(input)
const originalTree = structuredClone(tree)
expect(normalizeTranslationMarkdownLinks(input, context, ['guide.md'], tree)).toBe(
'# 指南\n\n[English](guide.md) | 中文\n\n[行内](dsh-translation-target:docs/reference.md?view=full&amp;x=1#overview "标题")\n\n[引用][ref]\n\n[ref]: <dsh-translation-target:docs/reference.md#tail> "标注"\n[ref]: unpaired.md\n',
)
expect(translationLinkLocaleViolations(input, context, ['guide.md'], tree)).toEqual([
{
sourcePath: 'docs/guide.zh.md',
line: 5,
url: 'reference.md?view=full&amp;x=1#overview',
expectedUrl: 'reference.zh.md?view=full&amp;x=1#overview',
},
{
sourcePath: 'docs/guide.zh.md',
line: 9,
url: 'reference.md#tail',
expectedUrl: 'reference.zh.md#tail',
},
])
expect(tree).toEqual(originalTree)
})
it('rewrites only the destination while preserving the suffix and title', () => {
const root = fixture()
const input = '[概览](reference.md?view=full&amp;mode=all#overview "reference.md title")\n'
+23 -6
View File
@@ -229,8 +229,8 @@ function visitDocumentLinkNodes(
markdown: string,
skipTargets: readonly string[],
visitor: (node: LinkNode) => void,
tree: Nodes = parseMarkdown(markdown),
): void {
const tree = parseMarkdown(markdown)
const switcherOffset = languageSwitcherLinkOffset(tree, markdown, skipTargets)
const referencedIdentifiers = new Set<string>()
const visitedDefinitions = new Set<string>()
@@ -255,20 +255,29 @@ function visitResolvedDocumentLinks(
context: TranslationLinkContext,
skipTargets: readonly string[],
visitor: (node: LinkNode, destination: MarkdownDestination, resolved: ResolvedTranslationLink) => void,
tree?: Nodes,
): void {
visitDocumentLinkNodes(markdown, skipTargets, (node) => {
if (isExternalOrAbsoluteMarkdownUrl(node.url)) return
const destination = markdownDestination(markdown, node)
const resolved = resolveTranslationLink(node.url, context, destination.url)
if (resolved !== undefined) visitor(node, destination, resolved)
})
}, tree)
}
/** Return one violation per wrong-locale link or link definition. */
/**
* Return one violation per wrong-locale link or link definition.
* @param markdown - Exact authored Markdown text used for link offsets.
* @param context - Repository and locale used to resolve relative links.
* @param skipTargets - Accepted language-switcher destinations.
* @param tree - Optional GFM parse of this exact Markdown text; never a normalized or edited document.
* @returns Wrong-locale links in document order.
*/
export function translationLinkLocaleViolations(
markdown: string,
context: TranslationLinkContext,
skipTargets: readonly string[] = [],
tree?: Nodes,
): TranslationLinkLocaleViolation[] {
const violations: TranslationLinkLocaleViolation[] = []
visitResolvedDocumentLinks(markdown, context, skipTargets, (node, destination, resolved) => {
@@ -279,7 +288,7 @@ export function translationLinkLocaleViolations(
url: destination.url,
expectedUrl: resolved.expectedUrl,
})
})
}, tree)
return violations
}
@@ -297,11 +306,19 @@ export function rewriteTranslationLinkLocales(
return { content: applyReplacements(markdown, replacements), rewritten: replacements.length }
}
/** Normalize only paired-document locale paths while retaining every other byte and URL suffix. */
/**
* Normalize only paired-document locale paths while retaining every other byte and URL suffix.
* @param markdown - Exact authored Markdown text used for link offsets.
* @param context - Repository and locale used to resolve relative links.
* @param skipTargets - Accepted language-switcher destinations.
* @param tree - Optional GFM parse of this exact Markdown text; never a normalized or edited document.
* @returns Markdown with paired-document destinations replaced by semantic targets.
*/
export function normalizeTranslationMarkdownLinks(
markdown: string,
context: TranslationLinkContext,
skipTargets: readonly string[] = [],
tree?: Nodes,
): string {
const replacements: Replacement[] = []
visitResolvedDocumentLinks(markdown, context, skipTargets, (_node, destination, resolved) => {
@@ -309,7 +326,7 @@ export function normalizeTranslationMarkdownLinks(
destination,
`dsh-translation-target:${resolved.pair.source}${resolved.suffix}`,
))
})
}, tree)
return applyReplacements(markdown, replacements)
}
+7 -4
View File
@@ -68,11 +68,10 @@ function headingText(node: Nodes): string {
* Fenced code blocks and generated regions, which the pairing gate requires to
* be identical on both sides, are left out of each section's blocks.
*/
function sections(markdown: string): Section[] {
function sections(markdown: string, tree: Nodes = parseTranslationMarkdown(markdown)): Section[] {
const frontmatter = /^---\n[\s\S]*?\n---(?:\n|$)/.exec(markdown)?.[0].length ?? 0
const result: Section[] = [{ blocks: frontmatter > 0 ? [markdown.slice(0, frontmatter)] : [] }]
const regionLines = generatedRegions(markdown).map(region => [region.begin + 1, region.end + 1] as const)
const tree = parseTranslationMarkdown(markdown)
if (tree.type !== 'root') throw new Error('Markdown parser returned a non-root tree')
for (const node of tree.children) {
if ((node.position?.start.offset ?? 0) < frontmatter) continue
@@ -106,6 +105,7 @@ function sectionHash(blocks: string[]): string {
* @param en - English document text.
* @param zh - Chinese document text.
* @param context - Link-resolution inputs for both sides.
* @param trees - Optional GFM parses of the exact English and Chinese inputs; normalized text is parsed separately when it changes.
* @returns Entries in document order.
* @throws Error when the two sides have different heading counts or malformed generated regions.
*/
@@ -114,9 +114,12 @@ export function computeTranslationPairingRecord(
en: string,
zh: string,
context: TranslationPairingRecordContext,
trees?: { readonly en: Nodes; readonly zh: Nodes },
): TranslationPairingRecord {
const enSections = sections(normalizeTranslationMarkdownLinks(en, { ...context, sourcePath: paths.source }))
const zhSections = sections(normalizeTranslationMarkdownLinks(zh, { ...context, sourcePath: paths.zh }))
const normalizedEn = normalizeTranslationMarkdownLinks(en, { ...context, sourcePath: paths.source }, [], trees?.en)
const normalizedZh = normalizeTranslationMarkdownLinks(zh, { ...context, sourcePath: paths.zh }, [], trees?.zh)
const enSections = sections(normalizedEn, normalizedEn === en ? trees?.en : undefined)
const zhSections = sections(normalizedZh, normalizedZh === zh ? trees?.zh : undefined)
if (enSections.length !== zhSections.length) {
throw new Error(`${paths.source} has ${enSections.length - 1} heading(s) but ${paths.zh} has ${zhSections.length - 1}`)
}
+25
View File
@@ -250,6 +250,31 @@ describe('translation pairing records', () => {
.toEqual(record(`${en}\nSee [bar](bar.zh.md).\n`, `${zh}\nSee [bar](bar.md).\n`))
})
it('retains section hashes after normalized links move later heading offsets', () => {
const english = '# Guide\n\n[Reference](bar.md?x=1&amp;y=2#tail)\n\n## Tail\n\nTail body.\n'
const chinese = '# 指南\n\n[参考](bar.zh.md?x=1&amp;y=2#tail)\n\n## 尾部\n\n尾部正文。\n'
const trees = { en: parseTranslationMarkdown(english), zh: parseTranslationMarkdown(chinese) }
const originalTrees = structuredClone(trees)
const computed = computeTranslationPairingRecord(paths, english, chinese, context, trees)
expect([...computed]).toEqual([
['/guide', { en: '3f43f06d1244d874', zh: '021cfe53b05ba084' }],
['/guide/tail', { en: '945d8537bf1f2b5f', zh: '0a851cb395705d76' }],
])
expect(computed).toEqual(record(english, chinese))
expect(trees).toEqual(originalTrees)
})
it('retains section hashes when the authored tree needs no link normalization', () => {
const english = '# Guide\n\nBody.\n'
const chinese = '# 指南\n\n正文。\n'
const trees = { en: parseTranslationMarkdown(english), zh: parseTranslationMarkdown(chinese) }
expect([...computeTranslationPairingRecord(paths, english, chinese, context, trees)]).toEqual([
['/guide', { en: '5e1bfa97a822af7a', zh: '3409c92bef7306bb' }],
])
})
it('refuses sides with different heading counts', () => {
expect(() => record(en, `${zh}\n## 额外\n`)).toThrow('docs/foo.md has 3 heading(s) but docs/foo.zh.md has 4')
})
+45 -15
View File
@@ -624,6 +624,21 @@
"symbol": "SessionEvent",
"source": "packages/core/session/src/types.ts"
},
{
"doc": "docs/subsystems/session.md",
"symbol": "PluginRecordType",
"source": "packages/core/session/src/types.ts"
},
{
"doc": "docs/subsystems/session.md",
"symbol": "PluginRecordMap",
"source": "packages/core/session/src/types.ts"
},
{
"doc": "docs/subsystems/session.md",
"symbol": "PluginRecord",
"source": "packages/core/session/src/types.ts"
},
{
"doc": "docs/subsystems/session.md",
"symbol": "TurnEndCancelCause",
@@ -1051,6 +1066,36 @@
"symbol": "AskUserQuestionAnswer",
"source": "packages/interaction/user-questions/src/types.ts"
},
{
"doc": "docs/subsystems/user-questions.md",
"symbol": "AskUserQuestionRequestEvent",
"source": "packages/interaction/user-questions/src/types.ts"
},
{
"doc": "docs/subsystems/user-questions.md",
"symbol": "TimedUserQuestionResult",
"source": "packages/interaction/user-questions/src/index.ts"
},
{
"doc": "docs/subsystems/user-questions.md",
"symbol": "UserQuestionState",
"source": "packages/interaction/user-questions/src/types.ts"
},
{
"doc": "docs/subsystems/user-questions.md",
"symbol": "PendingUserQuestion",
"source": "packages/interaction/user-questions/src/types.ts"
},
{
"doc": "docs/subsystems/user-questions.md",
"symbol": "SettledUserQuestion",
"source": "packages/interaction/user-questions/src/types.ts"
},
{
"doc": "docs/subsystems/user-questions.md",
"symbol": "UserQuestionProjectionView",
"source": "packages/interaction/user-questions/src/types.ts"
},
{
"doc": "docs/subsystems/user-questions.md",
"symbol": "UserQuestionError",
@@ -1841,21 +1886,6 @@
"symbol": "PlanModeConfig",
"source": "packages/plan/plan-mode/src/index.ts"
},
{
"doc": "docs/subsystems/invariants.md",
"symbol": "Config",
"source": "packages/runtime-diagnostics/invariants/src/index.ts"
},
{
"doc": "docs/subsystems/invariants.md",
"symbol": "InvariantFailure",
"source": "packages/runtime-diagnostics/invariants/src/index.ts"
},
{
"doc": "docs/subsystems/invariants.md",
"symbol": "InvariantInstaller",
"source": "packages/runtime-diagnostics/invariants/src/index.ts"
},
{
"doc": "docs/subsystems/web-server.md",
"symbol": "WebRouteKind",
@@ -1,99 +0,0 @@
/** Verify every compiled companion through its staged package self-reference under plain Node. */
import {
copyFileSync,
cpSync,
existsSync,
globSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { dirname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { parseArgs } from 'node:util'
const repositoryRoot = resolve(import.meta.dirname, '..')
const { values: options } = parseArgs({
args: process.argv.slice(2),
options: { 'packages-root': { type: 'string' }, 'loader-url': { type: 'string' } },
})
const packagesRoot = resolve(options['packages-root'] ?? repositoryRoot)
const loaderUrl = options['loader-url']
?? pathToFileURL(resolve(repositoryRoot, 'vendor/loader/lib/index.js')).href
const failures = []
const manifests = globSync('packages/*/*/package.json', { cwd: packagesRoot }).sort()
let companionCount = 0
const { default: Loader } = await import(loaderUrl)
const loader = Object.create(Loader.prototype)
for (const manifestPath of manifests) {
const packageDir = dirname(resolve(packagesRoot, manifestPath))
const manifest = JSON.parse(readFileSync(resolve(packagesRoot, manifestPath), 'utf8'))
const packageName = manifest.name
if (typeof packageName !== 'string' || packageName.length === 0) {
failures.push(`${manifestPath}: missing package name`)
continue
}
const invariantExport = manifest.exports?.['./invariant']
if (invariantExport === undefined) continue
companionCount += 1
if (typeof invariantExport !== 'object'
|| invariantExport === null
|| invariantExport.default !== './lib/invariant.js'
|| !manifest.files?.includes('lib/invariant.js')) {
failures.push(`${packageName}: manifest does not publish ./lib/invariant.js as ./invariant`)
continue
}
// Keep the staged view below its owning package so Node reaches the real
// pnpm dependency links. Junctioning node_modules elsewhere breaks pnpm's
// relative workspace links on Windows. Copy the manifest-declared lib view
// so a companion that imports an undeclared runtime chunk fails here.
const stagedPackageDir = mkdtempSync(resolve(packageDir, '.dsh-built-invariant-'))
try {
copyFileSync(resolve(packageDir, 'package.json'), resolve(stagedPackageDir, 'package.json'))
copyDeclaredLibFiles(packageDir, stagedPackageDir, manifest.files)
const probePath = resolve(stagedPackageDir, 'probe.mjs')
writeFileSync(
probePath,
`import * as companion from ${JSON.stringify(`${packageName}/invariant`)}\nexport default companion\n`,
)
const { default: companion } = await import(pathToFileURL(probePath).href)
if ('default' in companion) throw new Error('companion has a default export')
const unwrapped = loader.unwrapExports(companion)
if (unwrapped !== companion) throw new Error('Loader collapsed the companion namespace')
if (typeof unwrapped.name !== 'string') throw new Error('companion name is missing')
if (!Array.isArray(unwrapped.inject) || !unwrapped.inject.includes('invariants')) {
throw new Error('companion does not inject invariants')
}
if (typeof unwrapped.apply !== 'function') throw new Error('companion apply is missing')
} catch (error) {
failures.push(`${packageName}: ${error instanceof Error ? error.message : String(error)}`)
} finally {
rmSync(stagedPackageDir, { recursive: true, force: true })
}
}
if (failures.length > 0) {
console.error('verify-built-package-invariants: compiled companion failures:')
for (const failure of failures) console.error(` ${failure}`)
process.exit(1)
}
console.log(`verify-built-package-invariants: ${companionCount} compiled companion(s) passed plain-Node Loader checks.`)
function copyDeclaredLibFiles(packageDir, stagedPackageDir, files) {
for (const pattern of files) {
if (!pattern.startsWith('lib/')) continue
for (const relativePath of globSync(pattern, { cwd: packageDir })) {
const source = resolve(packageDir, relativePath)
if (!existsSync(source)) continue
const target = resolve(stagedPackageDir, relativePath)
mkdirSync(dirname(target), { recursive: true })
cpSync(source, target, { recursive: true })
}
}
}
@@ -1,103 +0,0 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { spawnSync } from 'node:child_process'
import { afterEach, describe, expect, it } from 'vitest'
const verifier = fileURLToPath(new URL('./verify-built-package-invariants.mjs', import.meta.url))
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function fixture(options: {
companion?: boolean
invariantSource?: string
invariantExport?: string
runtimeChunk?: string
} = {}): { root: string; loaderUrl: string } {
const root = mkdtempSync(join(tmpdir(), 'dsh-built-package-invariants-'))
roots.push(root)
const packageDir = join(root, 'packages/core/probe')
mkdirSync(join(packageDir, 'lib'), { recursive: true })
const companion = options.companion ?? true
writeFileSync(join(packageDir, 'package.json'), `${JSON.stringify({
name: '@deepseek-ai/dsh-probe',
type: 'module',
files: companion ? ['lib/invariant.js'] : [],
exports: companion ? {
'./invariant': {
default: options.invariantExport ?? './lib/invariant.js',
},
} : {},
}, null, 2)}\n`)
if (companion) {
writeFileSync(
join(packageDir, 'lib/invariant.js'),
options.invariantSource ?? "export const name = 'probe-invariant'\nexport const inject = ['invariants']\nexport const apply = () => {}\n",
)
}
if (options.runtimeChunk !== undefined) {
writeFileSync(join(packageDir, 'lib/chunk.js'), options.runtimeChunk)
}
const loaderPath = join(root, 'loader.mjs')
writeFileSync(loaderPath, 'export default class Loader { unwrapExports(value) { return value } }\n')
return { root, loaderUrl: pathToFileURL(loaderPath).href }
}
function verify(root: string, loaderUrl: string, timeout: number) {
const result = spawnSync(process.execPath, [
verifier,
'--packages-root', root,
'--loader-url', loaderUrl,
], {
encoding: 'utf8',
// Plain-Node startup and staged imports share the runner's platform budget.
timeout,
})
expect(result.error).toBeUndefined()
expect(result.signal, result.stderr).toBeNull()
return result
}
describe('built package invariant verifier', () => {
it('loads the staged compiled self-reference through plain Node and Loader normalization', ({ task }) => {
const { root, loaderUrl } = fixture()
const result = verify(root, loaderUrl, task.timeout)
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toContain('1 compiled companion(s) passed plain-Node Loader checks')
})
it('accepts packages that do not publish a companion', ({ task }) => {
const { root, loaderUrl } = fixture({ companion: false })
const result = verify(root, loaderUrl, task.timeout)
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toContain('0 compiled companion(s) passed plain-Node Loader checks')
})
it('rejects a default export and a broken invariant export map', ({ task }) => {
const withDefault = fixture({
invariantSource: "export default {}\nexport const name = 'probe-invariant'\nexport const inject = ['invariants']\nexport const apply = () => {}\n",
})
const defaultResult = verify(withDefault.root, withDefault.loaderUrl, task.timeout)
expect(defaultResult.status).toBe(1)
expect(defaultResult.stderr).toContain('companion has a default export')
const brokenExport = fixture({ invariantExport: './lib/missing.js' })
const exportResult = verify(brokenExport.root, brokenExport.loaderUrl, task.timeout)
expect(exportResult.status).toBe(1)
expect(exportResult.stderr).toContain('@deepseek-ai/dsh-probe')
})
it('rejects an invariant bundle that needs an unstaged runtime chunk', ({ task }) => {
const { root, loaderUrl } = fixture({
invariantSource: "export * from './chunk.js'\n",
runtimeChunk: "export const name = 'probe-invariant'\nexport const inject = ['invariants']\nexport const apply = () => {}\n",
})
const result = verify(root, loaderUrl, task.timeout)
expect(result.status).toBe(1)
expect(result.stderr).toContain('chunk.js')
})
})
+1 -1
View File
@@ -24,7 +24,7 @@
* `packages/client/modules/tests/node-half.client.spec.ts` owns the regression
* signal for composed graph rows, batch descriptors, and the source-map trailer.
*
* Rationale: .agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.md.
* Rationale: .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md.
*/
import { existsSync, globSync, readFileSync } from 'node:fs'
@@ -91,11 +91,12 @@ describe('default product isolation', () => {
expect(verifyDefaultProductIsolation(root).failures).toEqual([])
})
it('ships an optional bundle switched off: its graph is outside the product, its name stays out of imports and defaults', () => {
it.each([true, false])('ships an optional bundle switched off with legacy icon=%s', (legacy) => {
const root = fixture()
const layer = '@deepseek-ai/dsh-experimental-layer'
write(root, 'packages/experimental/layer/package.json', {
name: layer, icon: './icon.svg', exports: { './locale/*.json': './locale/*.json' },
name: layer, ...legacy ? { icon: './icon.svg' } : {},
exports: { ...legacy ? {} : { './icon': './icon.svg' }, './locale/*.json': './locale/*.json' },
dependencies: { [experimental]: 'workspace:^' }, dsh: { bundle: { patch: './cordis.patch.yml' } },
})
write(root, 'packages/experimental/layer/cordis.patch.yml', [{ insert: [{ name: experimental }] }])
@@ -123,7 +124,7 @@ describe('default product isolation', () => {
const failures = verifyDefaultProductIsolation(root).failures.join('\n')
expect(failures).toContain(`optional bundle ${experimental} must be a runtime dependency of apps/cli`)
expect(failures).toContain(`optional bundle ${experimental} must declare dsh.bundle.patch`)
expect(failures).toContain(`optional bundle ${experimental} must declare an icon`)
expect(failures).toContain(`optional bundle ${experimental} must declare icon or export ./icon`)
expect(failures).toContain(`optional bundle ${experimental} must export ./locale/*.json display metadata`)
// An experimental runtime dependency the list does not name is still a product requirement.
+2 -2
View File
@@ -94,8 +94,8 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes
if (manifest?.dsh?.bundle?.patch === undefined) {
failures.push(`${PROFILE_SOURCE}: optional bundle ${name} must declare dsh.bundle.patch`)
}
if (typeof manifest?.icon !== 'string') {
failures.push(`${PROFILE_SOURCE}: optional bundle ${name} must declare an icon`)
if (typeof manifest?.icon !== 'string' && manifest?.exports?.['./icon'] == null) {
failures.push(`${PROFILE_SOURCE}: optional bundle ${name} must declare icon or export ./icon`)
}
if (manifest?.exports?.['./locale/*.json'] === undefined) {
failures.push(`${PROFILE_SOURCE}: optional bundle ${name} must export ./locale/*.json display metadata`)
+29
View File
@@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'
import type { NpmPackageLock, RegistryIndex } from './benchmark-npm-resolution.ts'
import {
assertDualDshInstallLayout,
assertResolutionWorkBudget,
buildDualDshRegistry,
MAX_RESOLUTION_WORK_UNITS,
} from './verify-npm-install-layout.ts'
function validLayout(): NpmPackageLock {
@@ -113,3 +115,30 @@ describe('npm install layout verifier', () => {
)
})
})
describe('resolution work budget', () => {
it('accepts the measured dual-release graph', () => {
expect(assertResolutionWorkBudget({ dshPackagesPerVersion: 277, checkedDshEdges: 2524 }))
.toBe(699_148)
})
it('accepts a graph exactly at the budget and rejects one internal edge above it', () => {
const packages = 350
const edgesAtBudget = Math.floor(MAX_RESOLUTION_WORK_UNITS / packages)
expect(packages * edgesAtBudget).toBe(MAX_RESOLUTION_WORK_UNITS)
expect(assertResolutionWorkBudget({ dshPackagesPerVersion: packages, checkedDshEdges: edgesAtBudget }))
.toBe(MAX_RESOLUTION_WORK_UNITS)
expect(() => assertResolutionWorkBudget({
dshPackagesPerVersion: packages,
checkedDshEdges: edgesAtBudget + 1,
})).toThrow(`= ${String(MAX_RESOLUTION_WORK_UNITS + packages)} unit(s), budget ${String(MAX_RESOLUTION_WORK_UNITS)} unit(s)`)
})
it('accepts the headroom and rejects a runaway graph with its own measured counts', () => {
expect(assertResolutionWorkBudget({ dshPackagesPerVersion: 300, checkedDshEdges: 2800 }))
.toBe(840_000)
expect(() => assertResolutionWorkBudget({ dshPackagesPerVersion: 400, checkedDshEdges: 3000 }))
.toThrow('400 package(s) per release x 3000 internal edge(s) = 1200000 unit(s), budget 875000 unit(s)')
})
})
+57 -2
View File
@@ -15,7 +15,39 @@ const CORDIS_PACKAGE = '@deepseek-ai/cordis'
const NESTED_DSH_ALIAS = 'dsh-previous'
const NESTED_DSH_PATH = `node_modules/${NESTED_DSH_ALIAS}`
const DEPENDENCY_FIELDS = ['dependencies', 'optionalDependencies', 'peerDependencies'] as const
const TIMEOUT_MS = 300_000
/**
* Resolution work accepted from the dual-release graph, in
* `dshPackagesPerVersion * checkedDshEdges` units.
*
* npm's hoisted placement re-checks every internal edge against the incoming
* edges of its peer target (`canPlacePeers` calls `checkCanPlaceNoCurrent`,
* which walks `depValid` over that target's edges), so resolution time grows
* with this product and not with the package count alone. Measured on an idle
* M-series host: 277 package(s) x 2524 internal edge(s) = 699,148 unit(s) cost
* 180.7 s of npm time out of 185.1 s end to end. The budget adds 25% headroom
* for ordinary product growth, so a graph that grows here fails with its own
* measured counts instead of failing on whichever runner happens to be slow.
* Raising it requires re-measuring the resolution and recording the new counts.
*/
export const MAX_RESOLUTION_WORK_UNITS = 875_000
/**
* Measured npm seconds per resolution work unit on the reference host: 180.66 s
* for the 699,148 units of the graph the budget was derived from.
*/
const SECONDS_PER_WORK_UNIT = 2.6e-4
/**
* Wall-clock guard for the npm child process. This is a hang guard, not the
* gate's growth criterion: {@link MAX_RESOLUTION_WORK_UNITS} decides whether the
* graph is too large to verify, and a runner up to four times slower than the
* measured host cannot decide this gate's outcome. Derived from the budget so a
* raised budget cannot silently shrink the guard's headroom.
*/
const NPM_HANG_GUARD_MS = Math.round(
4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT * 1000,
)
/** Synthetic incompatible versions used to expose cross-release placement errors. */
export const SYNTHETIC_DSH_VERSIONS = ['0.1.0', '0.2.0'] as const
@@ -186,6 +218,27 @@ export function assertDualDshInstallLayout(packageLock: NpmPackageLock): DshInst
return { dshPackagesPerVersion: rootNames.size, checkedDshEdges }
}
/**
* Reject a verified dual-release graph that exceeds the resolution work budget.
* @param summary - Counts returned by {@link assertDualDshInstallLayout}.
* @returns The verified resolution work units.
*/
export function assertResolutionWorkBudget(summary: DshInstallLayoutSummary): number {
const units = summary.dshPackagesPerVersion * summary.checkedDshEdges
if (units > MAX_RESOLUTION_WORK_UNITS) {
throw new Error(
'dual-release graph exceeds the resolution work budget: '
+ `${String(summary.dshPackagesPerVersion)} package(s) per release x `
+ `${String(summary.checkedDshEdges)} internal edge(s) = ${String(units)} unit(s), `
+ `budget ${String(MAX_RESOLUTION_WORK_UNITS)} unit(s)\n`
+ ' npm re-checks every internal edge against the incoming edges of its peer target, so the resolution\n'
+ ' cost grows with this product. Measure the new cost and raise MAX_RESOLUTION_WORK_UNITS in\n'
+ ' scripts/verify-npm-install-layout.ts.',
)
}
return units
}
function workspaceVersion(root: string): string {
const manifest = JSON.parse(readFileSync(resolve(root, 'apps/cli/package.json'), 'utf8')) as { version?: unknown }
if (typeof manifest.version !== 'string') throw new Error('apps/cli/package.json has no string version')
@@ -199,12 +252,14 @@ async function main(): Promise<void> {
const result = await resolveNpmPackageLock(index, {
[DSH_PACKAGE]: rootVersion,
[NESTED_DSH_ALIAS]: `npm:${DSH_PACKAGE}@${nestedVersion}`,
}, TIMEOUT_MS)
}, NPM_HANG_GUARD_MS, 'npm resolution hang guard')
if (result.archiveRequests !== 0) throw new Error(`npm requested ${String(result.archiveRequests)} package archive(s)`)
const summary = assertDualDshInstallLayout(result.packageLock)
const units = assertResolutionWorkBudget(summary)
console.log(
`verify-npm-install-layout: ${String(summary.dshPackagesPerVersion)} DSH package(s) per release and `
+ `${String(summary.checkedDshEdges)} internal edge(s) verified in ${(result.durationMs / 1000).toFixed(2)} s; `
+ `${String(units)} of ${String(MAX_RESOLUTION_WORK_UNITS)} budgeted resolution work unit(s); `
+ `both releases share one Cordis installation; ${String(result.unknownPackages.length)} unavailable optional `
+ 'package name(s) ignored by npm.',
)
+64 -8
View File
@@ -190,6 +190,9 @@ describe('package dependency scope', () => {
'@deepseek-ai/dsh-llm',
'@deepseek-ai/dsh-session',
])
expect(PACKAGE_DEPENDENCY_POLICY.requiredServicePeers).toEqual({
'@deepseek-ai/dsh-api-terminal-controller': ['@deepseek-ai/dsh-subprocess'],
})
expect(PACKAGE_DEPENDENCY_POLICY.configurationOnlyDevDependencies).toEqual({
'@deepseek-ai/dsh-client-locale': ['@deepseek-ai/dsh-api-remotes'],
'@deepseek-ai/dsh-client-ui-conversation': [
@@ -471,11 +474,11 @@ describe('face-aware source classification', () => {
it('does not treat static browser library entries as Host modules', () => {
const subject = sourceFacts({
'src/index.tsx': "import 'static-input'; export const view = <div />",
'src/invariant.ts': "import 'browser-companion'",
'src/companion.ts': "import 'browser-companion'",
}, {
exports: {
'.': { types: './lib/types/index.d.ts', default: './lib/index.js' },
'./invariant': { types: './lib/types/invariant.d.ts', default: './lib/invariant.js' },
'./companion': { types: './lib/types/companion.d.ts', default: './lib/companion.js' },
},
}, 'client-only')
@@ -488,7 +491,7 @@ describe('face-aware source classification', () => {
it('scans published Node companions, conditional entries, and emitted-tree subpaths from source', () => {
const subject = sourceFacts({
'src/index.ts': 'export function apply() {}',
'src/invariant.ts': "import 'invariant-runtime'; import type { Kind } from 'invariant-types'",
'src/companion.ts': "import 'companion-runtime'; import type { Kind } from 'companion-types'",
'src/node/helper.ts': "export { helper } from 'node-helper'",
'src/node.mts': "import 'node-import'",
'src/node.cts': "require('node-require')",
@@ -500,7 +503,7 @@ describe('face-aware source classification', () => {
}, {
exports: {
'.': { types: './lib/types/index.d.ts', default: './lib/index.js' },
'./invariant': { types: './lib/types/invariant.d.ts', default: './lib/invariant.js' },
'./companion': { types: './lib/types/companion.d.ts', default: './lib/companion.js' },
'./renamed': { types: './lib/types/node/helper.d.ts', default: './lib/node-bundle.js' },
'./conditional': { browser: './lib/browser.js', node: { import: './lib/node.mjs', require: './lib/node.cjs' } },
'./emitted': { types: './lib/types/emitted.d.ts', default: './lib/types/emitted.js' },
@@ -515,11 +518,11 @@ describe('face-aware source classification', () => {
})
expect([...subject.hostRuntimeSourceUses.keys()].sort()).toEqual([
'invariant-runtime', 'node-helper', 'node-import', 'node-require', 'react', 'worker-one', 'worker-two',
'companion-runtime', 'node-helper', 'node-import', 'node-require', 'react', 'worker-one', 'worker-two',
])
const expected = expectedPackageDependencies(subject)
for (const name of subject.hostRuntimeSourceUses.keys()) expect(expected.get(name)?.section).toBe('dependencies')
for (const name of ['browser-only', 'invariant-types', 'type-export-only']) {
for (const name of ['browser-only', 'companion-types', 'type-export-only']) {
expect(expected.get(name)?.section).toBe('devDependencies')
}
})
@@ -550,7 +553,7 @@ describe('face-aware source classification', () => {
.toThrow('packages/g/host/package.json: Host runtime entry packages/g/host/src/index.ts does not exist')
})
it('counts Host values as dependencies and Client values as development inputs', () => {
it('classifies Host values, required services, and Client build inputs separately', () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-package-faces-'))
roots.push(root)
const subject = pkg('@f/dual', 'packages/g/dual/package.json', {
@@ -575,10 +578,22 @@ describe('face-aware source classification', () => {
CORDIS, '@f/runtime', '@f/types', '@f/nested', '@f/hidden', '@f/browser', '@f/injected',
]), policy({
configurationOnlyDevDependencies: { '@f/dual': ['@f/injected'] },
requiredServicePeers: { '@f/dual': ['@f/types'] },
}))
expect([...found.hostRuntimeSourceUses.keys()].sort()).toEqual(['@f/nested', '@f/runtime'])
expect([...found.configurationOnlyDevDependencies]).toEqual(['@f/injected'])
expect([...found.peerRequiredHostDependencies]).toEqual(['@f/types'])
expect(expectedPackageDependencies(found).get('@f/types')?.section).toBe('peer-dev')
const unrelated = readPackageDependencyFacts(root, subject, 'client-host', found.workspaceNames, policy({
requiredServicePeers: { '@f/other': ['@f/types'] },
}))
expect(expectedPackageDependencies(unrelated).get('@f/types')?.section).toBe('devDependencies')
for (const name of ['@f/missing', '@f/injected']) {
expect(() => readPackageDependencyFacts(root, subject, 'client-host', found.workspaceNames, policy({
requiredServicePeers: { '@f/dual': [name] },
}))).toThrow(`requiredServicePeers must name a referenced workspace package: ${name}`)
}
expect(found.hostRuntimeExportUses).toEqual([
{
packageName: '@f/nested',
@@ -916,11 +931,52 @@ describe('dependency sections', () => {
expect(formatPeerRequiredRuntimeDependencies({
facts: [subject], packages: [], policyViolations: [], workspaceNames: subject.workspaceNames,
})).toEqual([
'verify-package-dependencies: 1 Host runtime edge(s) remain in peerDependencies because their exports require shared identity across 1 package(s):',
'verify-package-dependencies: 1 Host runtime edge(s) remain in peerDependencies for shared exports or required services across 1 package(s):',
' @deepseek-ai/dsh-probe -> @deepseek-ai/dsh-runtime: @deepseek-ai/dsh-runtime#runtimeValue',
])
})
it('requires both peer and development declarations for a service without a value import', () => {
const dependency = '@deepseek-ai/dsh-runtime'
const manifest: PackageDependencyManifest = {
name: '@deepseek-ai/dsh-probe',
devDependencies: {
[CORDIS]: 'workspace:~',
[dependency]: 'workspace:*',
'@deepseek-ai/dsh-types': 'workspace:*',
},
peerDependencies: { [CORDIS]: 'workspace:~' },
}
const subject: PackageDependencyFacts = {
...facts(manifest),
hostRuntimeSourceUses: new Map(),
hostRuntimeExportUses: [],
peerRequiredHostDependencies: new Set([dependency]),
}
const state = { facts: [subject], packages: [], policyViolations: [], workspaceNames: subject.workspaceNames }
expect(collectPackageDependencyViolations(state)).toEqual([
expect.stringContaining(`${dependency} must be matching peerDependencies + devDependencies`),
])
repairPackageDependencyManifest(subject)
expect(collectPackageDependencyViolations(state)).toEqual([])
expect(manifest.peerDependencies?.[dependency]).toBe('workspace:*')
delete manifest.devDependencies?.[dependency]
expect(collectPackageDependencyViolations(state)).toHaveLength(1)
repairPackageDependencyManifest(subject)
expect(collectPackageDependencyViolations(state)).toEqual([])
expect(formatPeerRequiredRuntimeDependencies(state)).toEqual([
'verify-package-dependencies: 1 Host runtime edge(s) remain in peerDependencies for shared exports or required services across 1 package(s):',
` ${manifest.name} -> ${dependency}: required Cordis service`,
])
})
it('rejects required service declarations for unmanaged packages', () => {
const { root } = generatedHostFixture('schema')
expect(readPackageDependencyState(root, policy({
requiredServicePeers: { '@fixture/missing': [CORDIS] },
})).policyViolations).toContain('requiredServicePeers names unmanaged package @fixture/missing')
})
it('reports wrong sections, workspace ranges, and stale peer metadata', () => {
const manifest: PackageDependencyManifest = {
name: '@deepseek-ai/dsh-probe',
+25 -11
View File
@@ -453,7 +453,7 @@ function readAllSourceUses(root: string, pkg: WorkspacePackageManifest): Map<str
* @param pkg - Package manifest and directory.
* @param role - Selected dependency policy role.
* @param workspaceNames - Workspace package identities.
* @param policy - Reviewed Host export and configuration-only classifications.
* @param policy - Reviewed Host exports, required services, and development-only relationships.
* @param generatedHostSource - Host module already emitted in memory by a batched Typert pass.
* @returns Source-derived dependency facts without writing build artifacts.
*/
@@ -469,17 +469,27 @@ export function readPackageDependencyFacts(
const hostRuntime = role === 'client-only'
? { packageUses: new Map<string, string[]>(), exportUses: [] }
: readHostRuntimeUses(root, pkg, generatedHostSource)
const servicePeers = policy.requiredServicePeers?.[pkg.name] ?? []
const allSourceUses = readAllSourceUses(root, pkg)
for (const name of servicePeers) {
if (!workspaceNames.has(name) || !allSourceUses.has(name)) {
throw new Error(`${pkg.manifestPath}: requiredServicePeers must name a referenced workspace package: ${name}`)
}
}
return {
manifestPath: pkg.manifestPath,
role,
manifest: pkg.manifest,
workspaceNames,
allSourceUses: readAllSourceUses(root, pkg),
allSourceUses,
hostRuntimeSourceUses: hostRuntime.packageUses,
hostRuntimeExportUses: hostRuntime.exportUses,
peerRequiredHostDependencies: new Set(hostRuntime.exportUses
.filter(use => policy.peerRequiredHostExports[use.specifier]?.includes(use.exportName) === true)
.map(use => use.packageName)),
peerRequiredHostDependencies: new Set([
...hostRuntime.exportUses
.filter(use => policy.peerRequiredHostExports[use.specifier]?.includes(use.exportName) === true)
.map(use => use.packageName),
...servicePeers,
]),
configurationOnlyDevDependencies: new Set(
policy.configurationOnlyDevDependencies[pkg.manifest.name ?? ''] ?? [],
),
@@ -572,9 +582,10 @@ export function readPackageDependencyState(
policyViolations: [
...discovered.violations,
...collectHostDependencyExportPolicyViolations(facts, workspaceNames, policy),
...Object.keys(policy.configurationOnlyDevDependencies)
.filter(name => !selectedNames.has(name))
.map(name => `configurationOnlyDevDependencies names unmanaged package ${name}`),
...(['configurationOnlyDevDependencies', 'requiredServicePeers'] as const).flatMap(field =>
Object.keys(policy[field] ?? {})
.filter(name => !selectedNames.has(name))
.map(name => `${field} names unmanaged package ${name}`)),
].sort(),
workspaceNames,
}
@@ -622,6 +633,9 @@ export function expectedPackageDependencies(
: 'dependencies'
for (const path of paths) add(name, expectedSection, path)
}
for (const name of facts.peerRequiredHostDependencies) {
if (!facts.hostRuntimeSourceUses.has(name)) add(name, 'peer-dev', 'required Cordis service')
}
return new Map([...expected].map(([name, rule]) => [name, {
section: rule.section,
origins: [...rule.origins].sort(),
@@ -661,13 +675,13 @@ export function formatManagedRuntimeDependencies(state: PackageDependencyState):
]
}
/** Format Host runtime edges retained as peers by their imported export classification. */
/** Format Host runtime edges retained as peers for shared exports or required services. */
export function formatPeerRequiredRuntimeDependencies(state: PackageDependencyState): string[] {
const rows = managedRuntimeEdges(state, 'peer-dev')
const packages = new Set(rows.map(row => row.consumer)).size
return [
`${GATE}: ${String(rows.length)} Host runtime edge(s) remain in peerDependencies because their exports require shared identity across ${String(packages)} package(s):`,
...rows.map(row => ` ${row.consumer} -> ${row.dependency}: ${row.exports.join(', ')}`),
`${GATE}: ${String(rows.length)} Host runtime edge(s) remain in peerDependencies for shared exports or required services across ${String(packages)} package(s):`,
...rows.map(row => ` ${row.consumer} -> ${row.dependency}: ${row.exports.join(', ') || 'required Cordis service'}`),
]
}
-21
View File
@@ -1,21 +0,0 @@
/** Verify package-owned invariant source and publication rules. */
import { resolve } from 'node:path'
import {
collectPackageInvariantViolations,
formatPackageInvariantViolation,
packageInvariantOwners,
} from './package-invariants.ts'
const root = resolve(import.meta.dirname, '..')
const violations = collectPackageInvariantViolations(root)
if (violations.length > 0) {
console.error('verify-package-invariants: violations found:')
for (const violation of violations) {
console.error(` ${formatPackageInvariantViolation(root, violation)}`)
}
process.exit(1)
}
console.log(`verify-package-invariants: ${packageInvariantOwners(root).length} hand-owned package companion(s) conform.`)
+149 -32
View File
@@ -44,49 +44,89 @@ it('does not require locale resources for exported package name and description'
expect(packageMetaProblems(root)).toEqual([])
})
it.each([undefined, ['art'], ['art/*.svg'], ['./art/icon.svg']])('accepts a published package icon without locale resources: %j', (files) => {
manifest({ icon: './art/icon.svg', files })
file('art/icon.svg', '<svg/>')
it.each(['legacy.svg', './legacy.svg'])('accepts a manifest-only icon %s', (icon) => {
manifest({ icon, files: ['legacy.svg'] })
file('legacy.svg', 'legacy')
expect(packageMetaProblems(root)).toEqual([])
})
it.each([{ files: ['lib'] }, { files: ['art', '!art/icon.svg'] }])('rejects an icon omitted from package publication: %j', ({ files }) => {
manifest({ icon: './art/icon.svg', files })
file('art/icon.svg', '<svg/>')
expect(packageMetaProblems(root).join('\n')).toContain('files must include art/icon.svg')
it('reads the manifest image and still rejects an unresolvable lower-priority export', () => {
manifest({ icon: './legacy.svg', files: ['legacy.svg'],
exports: { './package.json': './package.json', './icon': '../invalid.svg' } })
file('legacy.svg', 'legacy')
expect(packageMetaProblems(root)).toEqual([`${join(dir, 'package.json')}: exports["./icon"] must resolve to a file`])
})
it.each([null, false, 1, '', './icon.gif', '/tmp/icon.svg', './missing.png', '../outside.svg'])('validates icon declarations without locale resources: %j', (icon) => {
manifest({ icon })
file('../outside.svg', '<svg/>')
it.each([['display'], ['display/manifest.json'], ['display/logo.svg']])
('checks publication of remapped legacy manifests and their images: %j', (...files) => {
manifest({ files,
exports: { './package.json': './display/manifest.json' } })
json('display/manifest.json', { icon: './logo.svg' })
file('display/logo.svg', 'legacy')
const problems = packageMetaProblems(root).join('\n')
if (files.includes('display')) expect(problems).toBe('')
else expect(problems).toContain(`files must include ${files.includes('display/manifest.json') ? 'display/logo.svg' : 'display/manifest.json'}`)
})
it('requires the root manifest icon declaration to remain accessible', () => {
manifest({ icon: './legacy.svg', files: ['legacy.svg'],
exports: { './icon': './legacy.svg' } })
file('legacy.svg', 'legacy')
expect(packageMetaProblems(root).join('\n')).toContain('exports must expose its icon declaration')
})
it.each([null, false, '', './missing.svg'])('diagnoses legacy icon %j despite a valid fallback export', (icon) => {
manifest({ icon, files: ['fallback.svg'],
exports: { './package.json': './package.json', './icon': './fallback.svg' } })
file('fallback.svg', 'fallback')
expect(packageMetaProblems(root).join('\n')).toContain('Plugin metadata for @test/plugin:')
})
it('requires the root icon declaration to be exported', () => {
manifest({ icon: './icon.svg', exports: { '.': './entry.js' }, files: ['icon.svg'] })
file('icon.svg', '<svg/>')
expect(packageMetaProblems(root).join('\n')).toContain('exports must expose its icon declaration through @test/plugin/package.json')
})
it.each([
{ './search/package.json': './resources/search/manifest.json' },
{ './*/package.json': './resources/*/manifest.json' },
{ './*': './resources/*' },
])('validates icons of independent exported manifests: %j', (exports) => {
manifest({ exports, files: ['resources'] })
const document = './*' in exports ? 'resources/search/package.json' : 'resources/search/manifest.json'
json(document, { icon: './icon.webp' })
file('resources/search/icon.webp', 'webp')
it.each([undefined, ['art'], ['art/*.webp'], ['./art/icon.webp']])('accepts an exported icon without other resources: %j', (files) => {
manifest({ exports: { './icon': './art/icon.webp' }, files })
file('art/icon.webp', 'image')
expect(packageMetaProblems(root)).toEqual([])
json(document, { icon: './missing.png' })
expect(packageMetaProblems(root).join('\n')).toContain('missing.png')
})
it('requires publication of both an exported icon manifest and its image', () => {
manifest({ exports: { './search/package.json': './resources/search.json' }, files: ['resources/icon.svg'] })
json('resources/search.json', { icon: './icon.svg' })
file('resources/icon.svg', '<svg/>')
expect(packageMetaProblems(root).join('\n')).toContain('files must include resources/search.json')
it.each([['lib'], ['art', '!art/icon.webp']])('rejects an exported icon omitted from publication: %j', (...files) => {
manifest({ exports: { './icon': './art/icon.webp' }, files })
file('art/icon.webp', 'image')
expect(packageMetaProblems(root).join('\n')).toContain('files must include art/icon.webp')
})
it.each([false, 1, '', './icon.gif', '/tmp/icon.svg', './missing.png', '../outside.svg'])('validates exported icon targets: %j', (icon) => {
manifest({ exports: { './icon': icon } })
expect(packageMetaProblems(root).join('\n')).toMatch(/Plugin metadata for @test\/plugin:|exports\["\.\/icon"\] must resolve to a file/u)
})
it.each([['assets'], ['lib']])('checks publication of the Node-selected conditional icon: %j', (...files) => {
manifest({ exports: { './icon': { import: './assets/logo.webp', default: './missing.gif' } }, files })
file('assets/logo.webp', 'image')
const problems = packageMetaProblems(root)
if (files.includes('assets')) expect(problems).toEqual([])
else expect(problems.join('\n')).toContain('files must include assets/logo.webp')
})
it('ignores package.json exported at a subpath plugin address', () => {
manifest({ exports: { './search': './search.js', './search/package.json': './broken.json' } })
file('broken.json', '{')
expect(packageMetaProblems(root)).toEqual([])
})
it.each([['search.svg'], ['lib']])('reads and publishes a subpath icon export: %j', (...files) => {
manifest({ icon: './root.svg', exports: { './package.json': './package.json', './search': './search.js', './search/icon': './search.svg' },
files: ['root.svg', ...files] })
file('root.svg', 'root')
file('search.svg', 'search')
const problems = packageMetaProblems(root)
if (files.includes('search.svg')) expect(problems).toEqual([])
else expect(problems).toEqual([`${join(dir, 'package.json')}: files must include search.svg`])
})
it('requires publication of a remapped package manifest', () => {
manifest({ exports: { './package.json': './resources/display.json' }, files: ['lib'] })
json('resources/display.json', { name: 'Display' })
expect(packageMetaProblems(root).join('\n')).toContain('files must include resources/display.json')
})
it('ignores icon-like fields in unrelated source JSON', () => {
@@ -103,6 +143,74 @@ it('excludes tests, installed dependencies, and build output from metadata disco
expect(packageMetaProblems(root)).toEqual([])
})
it.each([
['valid bundle', {}, 'Nested', undefined],
['manifest icon takes precedence', {
icon: './icon.svg', exports: { './package.json': './package.json', './locale/*.json': './locale/*.json', './icon': './fallback.svg' },
}, 'Nested', undefined],
['unpublished manifest icon', {
icon: './icon.svg', exports: { './package.json': './package.json', './locale/*.json': './locale/*.json', './icon': './fallback.svg' }, files: ['locale'],
}, 'Nested', 'files must include icon.svg'],
['missing locale export', { exports: { './icon': './icon.svg' } }, 'Nested', 'exports must expose locale/en.json'],
['unpublished locale', { files: ['icon.svg'] }, 'Nested', 'files must include locale/en.json'],
['unpublished icon', { files: ['locale'] }, 'Nested', 'files must include icon.svg'],
['missing icon target', { exports: { './locale/*.json': './locale/*.json', './icon': './missing.svg' } }, 'Nested', 'exports["./icon"] must resolve to a file'],
['invalid display text', {}, false, 'meta.title must be a non-empty string'],
['ordinary plugin with an exported icon', { dsh: undefined }, 'Nested', undefined],
] as const)('validates a named nested package independently: %s', (_label, fields, title, expected) => {
manifest({ exports: { '.': './entry.js' }, files: ['examples'] })
json('examples/nested/package.json', {
name: '@test/nested', type: 'module', dsh: { bundle: { patch: './cordis.patch.yml' } },
exports: { './locale/*.json': './locale/*.json', './icon': './icon.svg' },
files: ['locale', 'icon.svg'], ...fields,
})
json('examples/nested/locale/en.json', { meta: { title } })
file('examples/nested/icon.svg', '<svg/>')
file('examples/nested/fallback.svg', '<svg/>')
const problems = packageMetaProblems(root)
if (expected === undefined) expect(problems).toEqual([])
else expect(problems.join('\n')).toContain(expected)
})
it('checks grandchildren once and does not hide adjacent unowned locale directories', () => {
manifest({ exports: { '.': './entry.js' } })
json('examples/child/package.json', { name: '@test/child', exports: { './locale/*.json': './locale/*.json' } })
json('examples/child/locale/en.json', { meta: { title: 'Child' } })
json('examples/child/grandchild/package.json', { name: '@test/grandchild', exports: {} })
json('examples/child/grandchild/locale/en.json', { meta: { title: 'Grandchild' } })
json('examples/child-other/locale/en.json', { meta: { title: 'Unowned' } })
const problems = packageMetaProblems(root)
expect(problems.filter(problem => problem.includes(join('grandchild', 'package.json')))).toHaveLength(1)
expect(problems.join('\n')).toContain('exports must expose examples/child-other/locale/en.json')
})
it.each([undefined, '', 1])('does not treat an unnamed package manifest as a separate metadata owner: %j', (name) => {
manifest({ exports: { '.': './entry.js' } })
json('nested/package.json', { name, type: 'module' })
json('nested/locale/en.json', { meta: { title: 'Still owned by outer package' } })
expect(packageMetaProblems(root).join('\n')).toContain('exports must expose nested/locale/en.json')
})
it('still checks explicit outer exports of nested package resources', () => {
const exports = { './child/locale/*.json': './nested/locale/*.json' }
manifest({ exports, files: [] })
json('nested/package.json', { name: '@test/nested', exports: { './locale/*.json': './locale/*.json' }, files: ['locale'] })
json('nested/locale/en.json', { meta: { title: 'Shared resource' } })
expect(packageMetaProblems(root).join('\n')).toContain('files must include nested/locale/en.json')
manifest({ exports, files: ['nested'] })
expect(packageMetaProblems(root)).toEqual([])
})
it.each([
{ './child/locale/*.json': './nested/locale/*.json', './child/locale/en.json': null },
{ './child/locale/*.json': { require: './nested/locale/*.json' } },
])('rejects inaccessible outer exports even when the nested package exposes its locale: %j', (exports) => {
manifest({ exports, files: ['nested'] })
json('nested/package.json', { name: '@test/nested', exports: { './locale/*.json': './locale/*.json' } })
json('nested/locale/en.json', { meta: { title: 'Nested' } })
expect(packageMetaProblems(root).join('\n')).toContain('exports must expose nested/locale/en.json')
})
it('ignores unrelated locale content without requiring metadata exports', () => {
manifest({ exports: { '.': './lib/index.js' }, files: ['lib'] })
json('locale/zh.json', { buttons: { save: '保存' }, enabled: true, other: null })
@@ -489,3 +597,12 @@ it('reports metadata failures from more than one workspace package', () => {
expect(problems).toContain('@test/plugin')
expect(problems).toContain('@test/second')
})
it.each([[], ['locale']])('rejects a display manifest in build output without reading it: locales %j', (...locales) => {
manifest({ exports: { './package.json': './lib/display.json', ...locales.length > 0 ? { './locale/*.json': './locale/*.json' } : {} }, files: ['lib', 'locale'] })
if (locales.length > 0) json('locale/en.json', { meta: { title: 'Plugin' } })
file('lib/display.json', '{')
const problems = packageMetaProblems(root).join('\n')
expect(problems).toContain('@test/plugin/package.json: display manifest must resolve to source JSON')
expect(problems).not.toContain('Plugin metadata for')
})
+39 -23
View File
@@ -17,6 +17,7 @@ interface SourceJson {
metadata: boolean
invalid: boolean
icon?: unknown
packageName?: string
}
function sourceJsonFiles(dir: string): SourceJson[] {
@@ -31,7 +32,12 @@ function sourceJsonFiles(dir: string): SourceJson[] {
if (typeof contents !== 'object' || contents === null || Array.isArray(contents)) {
return { file, metadata: false, invalid: true }
}
return { file, metadata: Object.hasOwn(contents, 'meta'), invalid: false, icon: 'icon' in contents ? contents.icon : undefined }
const name = 'name' in contents ? contents.name : undefined
return {
file, metadata: Object.hasOwn(contents, 'meta'), invalid: false,
icon: 'icon' in contents ? contents.icon : undefined,
...basename(file) === 'package.json' && typeof name === 'string' && name.trim() !== '' ? { packageName: name } : {},
}
})
}
@@ -71,6 +77,9 @@ function packageProblems(manifestPath: string): string[] {
const dir = realpathSync(dirname(manifestPath))
const pkg = JSON.parse(readFileSync(manifestPath, 'utf8')) as Manifest
const documents = sourceJsonFiles(dir)
const nestedPackages = documents.filter(document => document.file !== 'package.json' && document.packageName !== undefined)
.map(document => ({ file: document.file, prefix: `${dirname(document.file).replaceAll('\\', '/')}/` }))
const children = nestedPackages.filter(child => !nestedPackages.some(parent => child !== parent && child.file.startsWith(parent.prefix)))
const byPath = new Map(documents.map(document => [join(dir, document.file), document]))
const parentURL = pathToFileURL(join(dir, 'package.json')).href
const specifierOf = (plugin: string): string => pkg.name + (plugin === '.' ? '' : plugin.slice(1))
@@ -81,7 +90,7 @@ function packageProblems(manifestPath: string): string[] {
if (!key.startsWith('.')) continue
if (!key.includes('*')) {
candidates.add(key)
if (key.endsWith('/package.json')) candidates.add(key.slice(0, -'/package.json'.length))
if (key.endsWith('/icon')) candidates.add(key.slice(0, -'/icon'.length))
}
const plugin = pluginOf(key)
if (plugin !== undefined && !plugin.includes('*')) candidates.add(plugin)
@@ -90,7 +99,6 @@ function packageProblems(manifestPath: string): string[] {
const value = substitution(pattern, `./${document.file}`)
if (value === undefined) continue
const request = key.replaceAll('*', value)
if (request.endsWith('/package.json')) candidates.add(request.slice(0, -'/package.json'.length))
const owner = pluginOf(request)
if (owner === undefined) continue
candidates.add(owner)
@@ -122,24 +130,36 @@ function packageProblems(manifestPath: string): string[] {
const file = lookup(resourceOf(filename))
return file === undefined ? [] : [{ filename, file }]
})
const iconManifest = lookup(`${specifier}/package.json`)
const iconDocument = iconManifest === undefined ? undefined : byPath.get(iconManifest)
let iconChecked = false
if (candidate === '.' && pkg.icon !== undefined && iconManifest !== join(dir, 'package.json')) {
const displayManifest = candidate === '.' ? lookup(`${specifier}/package.json`) : undefined
if (candidate === '.' && pkg.icon !== undefined && displayManifest !== join(dir, 'package.json')) {
problems.push(`${manifestPath}: exports must expose its icon declaration through ${pkg.name}/package.json`)
}
if (iconDocument?.icon !== undefined && resources.every(({ file }) => byPath.has(file))) {
iconChecked = true
const iconExport = `${candidate}/icon`
const exportsIcon = exported.some(([key]) => key === iconExport)
if (exportsIcon && lookup(`${specifier}/icon`) === undefined) {
problems.push(`${manifestPath}: exports["${iconExport}"] must resolve to a file`)
}
const iconDocument = displayManifest === undefined ? undefined : byPath.get(displayManifest)
// Build output is not validated source; reading it would make the result depend on stale artifacts.
const sourceManifest = displayManifest === undefined || iconDocument !== undefined
if (!sourceManifest) {
problems.push(`${specifier}/package.json: display manifest must resolve to source JSON, received ${displayManifest}`)
}
if (sourceManifest && resources.every(({ file }) => byPath.has(file))) {
const meta = readPluginMeta(specifier, parentURL)
if (meta?.error !== undefined) problems.push(meta.error)
if (meta?.icon !== undefined && typeof iconDocument.icon === 'string') {
const iconFile = relative(dir, resolve(dirname(join(dir, iconDocument.file)), iconDocument.icon)).replaceAll('\\', '/')
if (pkg.files !== undefined && !published(iconFile, pkg.files)) problems.push(`${manifestPath}: files must include ${iconFile}`)
if (iconDocument.file !== 'package.json' && pkg.files !== undefined && !published(iconDocument.file, pkg.files)) {
problems.push(`${manifestPath}: files must include ${iconDocument.file}`)
const iconTarget = meta?.icon === undefined ? undefined
: displayManifest !== undefined && typeof iconDocument?.icon === 'string'
? resolve(dirname(displayManifest), iconDocument.icon)
: lookup(`${specifier}/icon`)
for (const target of [displayManifest, iconTarget]) {
if (target === undefined) continue
const file = relative(dir, target).replaceAll('\\', '/')
if (file !== 'package.json' && pkg.files !== undefined && !published(file, pkg.files)) {
problems.push(`${manifestPath}: files must include ${file}`)
}
}
} else if (iconDocument?.icon !== undefined) {
} else if (sourceManifest && (exportsIcon || iconDocument?.icon !== undefined)) {
problems.push(`${specifier}: icon metadata requires locale resources to resolve to source JSON`)
}
if (!resources.some(({ file }) => byPath.get(file)?.metadata || byPath.get(file)?.invalid)) continue
@@ -154,20 +174,14 @@ function packageProblems(manifestPath: string): string[] {
continue
}
const directory = dirname(english)
let sourceResources = true
for (const { filename, file } of resources) {
if (!byPath.has(file)) {
problems.push(`${resourceOf(filename)}: locale metadata must resolve to source JSON, received ${file}`)
sourceResources = false
}
if (dirname(file) !== directory) {
problems.push(`${resourceOf(filename)}: ${file} must share the English locale directory ${directory}`)
}
}
if (sourceResources && !iconChecked) {
const meta = readPluginMeta(specifier, parentURL)
if (meta?.error !== undefined) problems.push(meta.error)
}
for (const document of documents.filter(document => dirname(join(dir, document.file)) === directory)) {
const resource = resourceOf(basename(document.file))
const file = lookup(resource)
@@ -184,6 +198,8 @@ function packageProblems(manifestPath: string): string[] {
for (const document of documents) {
const requests = alternatives.get(document.file)
// Named nested packages own unclaimed resources; explicit outer exports still require validation.
if (requests === undefined && children.some(child => document.file.startsWith(child.prefix))) continue
if (!document.file.split('/').includes('locale') && requests === undefined) continue
if (!document.metadata && !document.invalid) continue
if (claimed.has(join(dir, document.file))) continue
@@ -193,11 +209,11 @@ function packageProblems(manifestPath: string): string[] {
})) continue
problems.push(`${manifestPath}: exports must expose ${document.file} as a plugin locale resource with an en.json discovery baseline`)
}
return problems
return problems.concat(children.flatMap(child => packageProblems(join(dir, child.file))))
}
/**
* Check static plugin locale metadata and resource publication without evaluating plugin entries or reading built output.
* Check workspace and named nested package metadata without evaluating plugin entries or reading built output.
* @param root - repository or fixture root.
* @returns diagnostics for an empty package corpus, invalid metadata, inaccessible resources, or omitted publication files.
*/
@@ -2,7 +2,7 @@
* Doc-sync gate for package README Model Experience sections. It validates
* audited package classifications, model/token/KV-cache fields, package-owned
* text blocks, generated-catalog links, and final-section order. See the
* [Model Experience Agent Note](../.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.md).
* [Model Experience reference](../docs/cookbook/adding-a-package.md).
*/
import { existsSync, globSync, readFileSync } from 'node:fs'
@@ -45,6 +45,7 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly<Record<string, string>> = {
* blocks. A package moves on or off this list with its context behavior.
*/
const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/experimental/translator': { kind: 'none', reason: 'Translates consumer-owned text without contributing to model requests or Session logs.' },
'packages/client/product-analytics': { kind: 'none', reason: 'Desktop analytics observes selected interactions without contributing model context or Session events.' },
'packages/experimental/speech-to-text': { kind: 'none', reason: 'Routes transient recognition without adding model requests or Session events.' },
'packages/experimental/api-speech-to-text': { kind: 'none', reason: 'Transports audio and preparation state; ordinary user submission owns model-visible text.' },
@@ -100,6 +101,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/experimental/webworker-runtime': { kind: 'none', reason: 'Browser-side host runtime and Node-compatibility layer; the plugins it boots own every model-facing registration.' },
'packages/experimental/webworker-packer': { kind: 'none', reason: 'Build-time image writer; its output reaches a model only through the tree the worker then boots.' },
'packages/experimental/inspector': { kind: 'none', reason: 'Developer diagnostics transport; it observes runtime activity without changing model requests.' },
'packages/experimental/inspector-profile': { kind: 'none', reason: 'The bundle mounts developer inspection plugins without contributing model context.' },
'packages/experimental/session-inspector': { kind: 'none', reason: 'The conversation view entries add no model-facing input.' },
'packages/client/ui-slots': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
'packages/client/ui-attachment': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
'packages/client/ui-primitives': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
@@ -110,6 +113,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/client/file-upload': { kind: 'none', reason: 'Browser-side request-body transport; registers nothing model-facing.' },
'packages/api/remotes': { kind: 'none', reason: 'The Remote BFF selects business methods and forwarded events; selected services own any model-visible effect.' },
'packages/experimental/client-ui-agent-team': { kind: 'none', reason: 'The browser projection and task controls register no model-facing input.' },
'packages/experimental/client-ui-claude-code-mods': { kind: 'none', reason: 'The browser draws Host-held mod trees and relays clicks; the mods\' own hooks own any model-visible effect.' },
'packages/client/shortcuts': { kind: 'none', reason: 'Browser keyboard routing; registers nothing model-facing.' },
'packages/client/ui-shortcuts': { kind: 'none', reason: 'Read-only browser reference; registers nothing model-facing.' },
'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
@@ -122,7 +126,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/client/ui-chat': { kind: 'none', reason: 'Browser-side Chat presentation; registers nothing model-facing.' },
'packages/client/ui-message-feedback': { kind: 'none', reason: 'Browser-side controls over log-only message feedback; ratings and notes never enter model context or change model-input tokens.' },
'packages/client/ui-tool': { kind: 'none', reason: 'Browser-side Tool presentation layer; renders logged calls without changing model context.' },
'packages/client/ui-schedule': { kind: 'none', reason: 'Browser-side catalog, editing, and deletion of Host Schedule records; dsh-schedule owns the model-facing tools and delivery.' },
'packages/client/ui-schedule': { kind: 'none', reason: 'Browser-side catalog, editing, and deletion of Host Schedule records; dsh-schedule owns storage and delivery, and dsh-tool-schedule contributes the model-facing tools in a preset that mounts it.' },
'packages/client/ui-workflow-run': { kind: 'none', reason: 'Browser-side UI plugin layer; renders durable workflow records without changing model context.' },
'packages/client/ui-input-trigger': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
'packages/client/ui-reference': { kind: 'indirect', reason: 'Browser-side reference selection delegates file guidance and session snapshot preparation to Host-owned providers.' },
@@ -133,6 +137,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/client/ui-permission-presets': { 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-settings-plugins': { kind: 'none', reason: 'Browser-side settings surface; registers no model surface.' },
'packages/client/ui-settings-shell': { kind: 'none', reason: 'Browser-side settings surface; registers no model surface.' },
'packages/client/ui-settings-session-log': { kind: 'none', reason: 'Browser preference controls request metadata, not model input.' },
'packages/client/ui-settings-agent-loop': { kind: 'none', reason: 'Browser-side settings surface; registers no model surface.' },
'packages/client/ui-settings-subagent': { kind: 'none', reason: 'Browser-side settings surface; registers no model surface.' },
'packages/client/ui-settings-web-search': { kind: 'none', reason: 'Browser-side settings surface; registers no model surface.' },
@@ -203,7 +208,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/spill/spill-local': { kind: 'indirect', reason: 'The storage backend delegates model rendering to spill consumers.' },
'packages/test-support/session-snapshot': { kind: 'none', reason: 'The test harness observes and normalizes transcripts without changing live requests.' },
'packages/test-support/agent-loop-testkit': { kind: 'none', reason: 'The test helper mounts services but neither drives nor modifies model requests.' },
'packages/runtime-diagnostics/invariants': { kind: 'none', reason: 'The observer validates requests but never rewrites their context.' },
'packages/test-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/test-support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' },
'packages/test-support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' },
@@ -0,0 +1,324 @@
import { execFileSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
checkDeclaredPluginRecordWrites,
checkPluginRecordCallers,
findPluginRecordReferences,
isAdmittedCaller,
OWNER_FILE,
readRepositorySources,
unescaped,
} from './verify-plugin-record-callers.ts'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
/** A temporary git repository with the given files written and none of them staged. */
function repository(files: Record<string, string>): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-plugin-record-callers-'))
roots.push(root)
execFileSync('git', ['init', '--quiet'], { cwd: root, stdio: 'pipe' })
for (const [file, contents] of Object.entries(files)) {
mkdirSync(dirname(join(root, file)), { recursive: true })
writeFileSync(join(root, file), contents)
}
return root
}
const OWNER_SOURCE = 'export function appendPluginRecord(session, type, data) { return commit(session, type, data) }\n'
const EXPERIMENTAL = 'packages/experimental/bridge/src/index.ts'
const RELEASE = 'packages/core/agent/src/index.ts'
/** A source-only compiler fixture whose production map declares one bridge record. */
function declaredRepository(source: string, extra: Record<string, string> = {}): string {
return repository({
'tsconfig.host.json': JSON.stringify({
compilerOptions: { module: 'NodeNext', moduleResolution: 'NodeNext', strict: true, noEmit: true,
paths: { '@deepseek-ai/dsh-session': ['./packages/core/session/src/index.ts'],
'@deepseek-ai/dsh-session/types': ['./packages/core/session/src/types.ts'] } },
include: ['packages/**/*.ts'],
}),
'packages/core/session/package.json': JSON.stringify({ name: '@deepseek-ai/dsh-session' }),
'packages/experimental/bridge/package.json': JSON.stringify({ name: '@deepseek-ai/dsh-experimental-bridge' }),
'packages/core/session/src/types.ts': 'export interface PluginRecordMap {}\n',
[OWNER_FILE]: "import type { PluginRecordMap } from './types.ts'\nexport function appendPluginRecord<K extends keyof PluginRecordMap>(session: object, type: K, data: NoInfer<PluginRecordMap[K]>): number { return 0 }\n",
'packages/experimental/bridge/src/types.ts': [
'export interface Entry { value: number }',
"declare module '@deepseek-ai/dsh-session/types' {",
' interface PluginRecordMap {',
' /** Stores the bridge state. */',
" 'plugin:bridge/state': Entry",
' }',
'}',
].join('\n'),
[EXPERIMENTAL]: source,
...extra,
})
}
/** A minimal corpus: the owner, one experimental file, and one release file with the given source. */
function corpus(release: string, extra: Record<string, string> = {}): Map<string, string> {
return new Map(Object.entries({
[OWNER_FILE]: OWNER_SOURCE,
[EXPERIMENTAL]: "import { appendPluginRecord } from '@deepseek-ai/dsh-session'\nappendPluginRecord(session, 'plugin:a', {})\n",
[RELEASE]: release,
...extra,
}))
}
describe('verify-plugin-record-callers', () => {
it.each([
['a named import', "import { appendPluginRecord } from '@deepseek-ai/dsh-session'"],
['an aliased import', "import { appendPluginRecord as write } from '@deepseek-ai/dsh-session'"],
['a re-export', "export { appendPluginRecord } from '@deepseek-ai/dsh-session'"],
['a namespace member call', "S.appendPluginRecord(session, 'plugin:a', {})"],
['a destructured binding', 'const { appendPluginRecord: write } = S'],
['an element access', "S['appendPluginRecord'](session, 'plugin:a', {})"],
['a template element access', 'S[`appendPluginRecord`](session, `plugin:a`, {})'],
['an escaped identifier', 'S.\\u0061ppendPluginRecord(session, "plugin:a", {})'],
['a hexadecimal escape in an element access', "S['\\x61ppendPluginRecord'](session, 'plugin:a', {})"],
['a code point escape in an element access', "S['\\u{61}ppendPluginRecord'](session, 'plugin:a', {})"],
['needless character escapes in an element access', "S['\\a\\ppendPluginRecord'](session, 'plugin:a', {})"],
['a legacy octal escape in an element access', "S['\\141ppendPluginRecord'](session, 'plugin:a', {})"],
['a three-digit legacy octal escape for the last character, beside a two-digit one that spells no reference', "S['appendPluginRecor\\144'](session, 'plugin:a\\61', {})"],
['an escaped template element access', 'S[`\\x61ppendPluginRecord`](session, `plugin:a`, {})'],
])('rejects %s in release package source', (_form, source) => {
expect(checkPluginRecordCallers(corpus(`${source}\n`))).toEqual([{ file: RELEASE, line: 1, text: source }])
})
it('parses JSX and JavaScript sources by extension', () => {
const files = corpus('', {
'packages/client/ui/src/view.tsx': 'const view = <div>{S.appendPluginRecord(session, "plugin:a", {})}</div>\n',
'scripts/tool.mjs': "S['appendPluginRecord'](session, 'plugin:a', {})\n",
})
expect(checkPluginRecordCallers(files).map(caller => caller.file))
.toEqual(['packages/client/ui/src/view.tsx', 'scripts/tool.mjs'])
})
it('rejects a string that spells the name across a line continuation', () => {
expect(checkPluginRecordCallers(corpus("S['append\\\nPluginRecord'](session, 'plugin:a', {})\n"))).toEqual([
{ file: RELEASE, line: 1, text: "S['append\\" },
])
})
it('reports nothing for escapes that spell no reference, including one that names no code point', () => {
const source = "const path = 'C:\\\\temp\\\\appendPlugin'\nconst big = '\\u{110000}'\nconst newline = 'a\\nb'\n"
expect(findPluginRecordReferences(RELEASE, source)).toEqual([])
// A file the prefilter unescapes can still hold no reference: this escape spells no letter of the name.
expect(findPluginRecordReferences('packages/core/x/src/legacy.cjs', "S['\\4141ppendPluginRecord']\n")).toEqual([])
// The AST compares exact string values: " 0appendPluginRecord", as TypeScript decodes this, names nothing.
expect(findPluginRecordReferences('packages/core/x/src/legacy.cjs', "S['\\400appendPluginRecord']\n")).toEqual([])
})
it('unescapes legacy octal escapes as TypeScript decodes them, taking three digits only after 0 to 3', () => {
// \400 exceeds 0o377, so it is \40 then a literal 0; \141 and \61 stay whole.
expect([unescaped("'\\400'"), unescaped("'\\141\\61'"), unescaped("'\\0'"), unescaped("'\\777'")]).toEqual(["' 0'", "'a1'", "'\0'", "'?7'"])
})
it('ignores comments, JSDoc links, longer strings, other identifiers, and wildcard re-exports', () => {
const source = [
'// appendPluginRecord is restricted',
'/** Plugin state uses {@link appendPluginRecord} in experimental packages. */',
"const message = 'call appendPluginRecord only from experimental packages'",
'const appendPluginRecords = 1',
"export * from '@deepseek-ai/dsh-session'",
].join('\n')
expect(checkPluginRecordCallers(corpus(source))).toEqual([])
})
it('admits experimental source, the owner, tests, and this gate', () => {
for (const file of [
EXPERIMENTAL,
OWNER_FILE,
'packages/session/session-persistence/tests/live-write-contract.ts',
'apps/web/tests/session.e2e.ts',
'examples/demo/tests/demo.spec.ts',
'scripts/verify-plugin-record-callers.spec.ts',
'scripts/verify-plugin-record-callers.ts',
]) expect(isAdmittedCaller(file), file).toBe(true)
for (const file of [RELEASE, 'packages/core/session/src/surface.ts', 'scripts/tool.ts', 'apps/web/src/main.ts']) {
expect(isAdmittedCaller(file), file).toBe(false)
}
})
it('reports the line of each reference', () => {
expect(findPluginRecordReferences(RELEASE, 'const a = 1\n\nS.appendPluginRecord()\n'))
.toEqual([{ file: RELEASE, line: 3, text: 'S.appendPluginRecord()' }])
})
it('refuses a corpus whose owner no longer exports the function', () => {
for (const owner of [undefined, 'function appendPluginRecord() {}\n', 'export const appendPluginRecord = () => {}\n']) {
const files = corpus('')
if (owner === undefined) files.delete(OWNER_FILE)
else files.set(OWNER_FILE, owner)
expect(() => checkPluginRecordCallers(files)).toThrow(/no longer exports function appendPluginRecord/)
}
})
it('refuses a corpus without experimental or release package source', () => {
const withoutExperimental = corpus('')
withoutExperimental.delete(EXPERIMENTAL)
const withoutRelease = corpus('')
withoutRelease.delete(RELEASE)
for (const files of [withoutExperimental, withoutRelease]) {
expect(() => checkPluginRecordCallers(files)).toThrow(/lacks experimental or release package source/)
}
})
it('reads tracked and unignored sources outside vendor, skipping other files and deleted ones', () => {
const root = repository({
'.gitignore': 'ignored/\n',
[OWNER_FILE]: OWNER_SOURCE,
'scripts/tool.mjs': 'export {}\n',
'ignored/scratch.ts': 'export {}\n',
'vendor/cordis/src/index.ts': 'export {}\n',
'docs/page.md': '# Page\n',
'packages/core/gone/src/index.ts': 'export {}\n',
})
execFileSync('git', ['add', '.'], { cwd: root, stdio: 'pipe' })
rmSync(join(root, 'packages/core/gone/src/index.ts'))
mkdirSync(join(root, 'packages/core/agent/src'), { recursive: true })
writeFileSync(join(root, 'packages/core/agent/src/new.ts'), 'export {}\n')
expect([...readRepositorySources(root).keys()].sort()).toEqual([
'packages/core/agent/src/new.ts',
OWNER_FILE,
'scripts/tool.mjs',
])
})
it('accepts declared names through renamed imports, inferred aliases, and namespace calls', () => {
const root = declaredRepository([
"import { appendPluginRecord as write } from '@deepseek-ai/dsh-session'",
"import * as session from '@deepseek-ai/dsh-session'",
'const alias = write',
"write({}, 'plugin:bridge/state', { value: 1 })",
"alias({}, 'plugin:bridge/state', { value: 2 })",
"session.appendPluginRecord({}, 'plugin:bridge/state', { value: 3 })",
].join('\n'))
expect(checkDeclaredPluginRecordWrites(root, readRepositorySources(root))).toEqual([])
})
it('rejects undeclared literal names, partially declared unions, and dynamic strings', () => {
const root = declaredRepository([
"import { appendPluginRecord as write } from '@deepseek-ai/dsh-session'",
"declare const union: 'plugin:bridge/state' | 'plugin:bridge/missing'",
'declare const dynamic: string',
"write({}, 'plugin:bridge/missing', {})",
'write({}, union, {})',
'write({}, dynamic, {})',
].join('\n'))
const errors = checkDeclaredPluginRecordWrites(root, readRepositorySources(root))
expect(errors.map(error => error.line)).toEqual([4, 5, 6])
expect(errors.map(error => error.reason)).toEqual([
expect.stringContaining('not assignable'),
expect.stringContaining('not assignable'),
expect.stringContaining('not assignable'),
])
})
it('does not authorize a production write with a test-only declaration', () => {
const root = declaredRepository([
"import { appendPluginRecord } from '@deepseek-ai/dsh-session'",
"import type { PluginRecordMap } from '@deepseek-ai/dsh-session/types'",
"const key = 'plugin:bridge/test-only' as const",
'appendPluginRecord({}, key, {})',
].join('\n'), {
'packages/experimental/bridge/tests/records.spec.ts': [
'export {}',
"declare module '@deepseek-ai/dsh-session/types' {",
" interface PluginRecordMap { 'plugin:bridge/test-only': object }",
'}',
].join('\n'),
})
expect(checkDeclaredPluginRecordWrites(root, readRepositorySources(root))).toHaveLength(1)
})
it('accepts finite generic keys and ignores test writes and unrelated function names', () => {
const root = declaredRepository([
"import { appendPluginRecord } from '@deepseek-ai/dsh-session'",
"function write<K extends 'plugin:bridge/state'>(key: K): void { appendPluginRecord({}, key, { value: 1 }) }",
'function other(type: string): void {}',
"other('plugin:bridge/missing')",
].join('\n'), {
'packages/experimental/bridge/tests/records.spec.ts': "import { appendPluginRecord } from '@deepseek-ai/dsh-session'\nappendPluginRecord({}, 'plugin:test/state', {})\n",
})
expect(checkDeclaredPluginRecordWrites(root, readRepositorySources(root))).toEqual([])
})
it('checks an empty production caller set without loading a compiler project', () => {
const root = declaredRepository('export {}\n')
rmSync(join(root, 'tsconfig.host.json'))
expect(checkDeclaredPluginRecordWrites(root, readRepositorySources(root))).toEqual([])
})
it('rejects a test declaration imported transitively by production', () => {
const root = declaredRepository([
"import { appendPluginRecord } from '@deepseek-ai/dsh-session'",
"import type { Fixture } from '../tests/fixture.ts'",
'declare const data: Fixture',
"appendPluginRecord({}, 'plugin:bridge/test-only', data)",
].join('\n'), {
'packages/experimental/bridge/tests/fixture.ts': [
'export interface Fixture { value: number }',
"declare module '@deepseek-ai/dsh-session/types' {",
" interface PluginRecordMap { 'plugin:bridge/test-only': Fixture }",
'}',
].join('\n'),
})
const errors = checkDeclaredPluginRecordWrites(root, readRepositorySources(root))
expect(errors).toHaveLength(1)
expect(errors[0]?.reason).toContain('visible to production but has no production catalogue declaration')
})
it('keeps compiler faces separate and rejects unconfigured writer source', () => {
const compilerOptions = { module: 'NodeNext', moduleResolution: 'NodeNext', strict: true, noEmit: true,
paths: { '@deepseek-ai/dsh-session': ['./packages/core/session/src/index.ts'],
'@deepseek-ai/dsh-session/types': ['./packages/core/session/src/types.ts'] } }
const root = declaredRepository('export {}\n', {
'tsconfig.host.json': JSON.stringify({ compilerOptions, include: ['packages/core/**/src/**/*.ts'] }),
'tsconfig.client.json': JSON.stringify({ compilerOptions, include: ['packages/**/src/**/*.ts'] }),
'packages/experimental/bridge/src/client.ts': "import { appendPluginRecord } from '@deepseek-ai/dsh-session'\nappendPluginRecord({}, 'plugin:bridge/state', { value: 1 })\n",
'packages/experimental/bridge/tsdown.config.ts': 'const broken: number = "config is not source"\n',
'packages/experimental/bridge/examples/hook.js': 'export const hook = value => value\n',
})
expect(checkDeclaredPluginRecordWrites(root, readRepositorySources(root))).toEqual([])
writeFileSync(join(root, 'packages/experimental/bridge/examples/write.ts'),
"import { appendPluginRecord } from '@deepseek-ai/dsh-session'\nappendPluginRecord({}, 'plugin:bridge/state', { value: 1 })\n")
const errors = checkDeclaredPluginRecordWrites(root, readRepositorySources(root))
expect(errors).toHaveLength(1)
expect(errors[0]?.reason).toContain('must be included in a Host or Client compiler face')
})
it('checks call, apply, and bound arguments without rejecting declared forwarding', () => {
const root = declaredRepository([
"import { appendPluginRecord as write } from '@deepseek-ai/dsh-session'",
"write.call(undefined, {}, 'plugin:bridge/missing', {})",
"write.apply(undefined, [{}, 'plugin:bridge/missing', {}])",
"const missing = [{}, 'plugin:bridge/missing', {}] as const",
'write.apply(undefined, [...missing])',
"const bound = write.bind(undefined, {}, 'plugin:bridge/missing', {})",
'bound()',
"write.call(undefined, {}, 'plugin:bridge/state', { value: 1 })",
"write.apply(undefined, [{}, 'plugin:bridge/state', { value: 1 }])",
"const valid = [{}, 'plugin:bridge/state', { value: 1 }] as const",
'write.apply(undefined, [...valid])',
"const validBound = write.bind(undefined, {}, 'plugin:bridge/state', { value: 1 })",
'validBound()',
'const partial = write.bind(undefined, {})',
"partial('plugin:bridge/state', { value: 1 })",
"partial('plugin:bridge/missing', {})",
'write(...valid)',
].join('\n'))
const errors = checkDeclaredPluginRecordWrites(root, readRepositorySources(root))
expect([...new Set(errors.map(error => error.line))]).toEqual([2, 3, 5, 6, 16])
expect([...new Set(errors.filter(error => error.reason?.includes('plugin:bridge/missing'))
.map(error => error.line))]).toEqual([2, 3, 5, 6, 16])
})
})
+283
View File
@@ -0,0 +1,283 @@
/**
* Verify that only experimental packages call `appendPluginRecord` from production source.
*
* `appendPluginRecord` in `@deepseek-ai/dsh-session` appends an ignorable `plugin:` record that the
* experimental persistence catalogue lists and that a Session format migration keeps only on a best-effort
* basis. Experimental packages may rely on that; a release package must declare its events instead.
* The owning module and test files, which exercise the operation, are exempt.
*
* Discovery is syntax-aware: every identifier spelled `appendPluginRecord` counts — an import, a
* named re-export, a call, a namespace member, a destructured binding, or an alias source — and so
* does a string literal of exactly that name, which reaches the function by element access.
* Comments, including JSDoc links, and longer strings do not count. A wildcard re-export
* (`export * from`) names no identifier and is not reported; code that calls the function through
* it still names the function, and that reference is. A file is parsed when its text, or its text
* with its escapes unescaped, contains the name, so a spelling that escapes part of it (`\u0061`,
* `\x61`, `\u{61}`, `\141`, `\a`, a line continuation) is still found.
* Production writes must name a record in the production-only `PluginRecordMap` inventory;
* test declarations cannot authorize a production write.
*/
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { relative, resolve } from 'node:path'
import ts from 'typescript'
import { collectPluginRecords } from './plugin-record-catalog.ts'
import { faceConfigs } from './ts-project.ts'
const root = resolve(import.meta.dirname, '..')
/** The restricted function. */
export const RESTRICTED_NAME = 'appendPluginRecord'
/** The module that declares the restricted function. */
export const OWNER_FILE = 'packages/core/session/src/index.ts'
/** Production source under this prefix may call the restricted function. */
export const EXPERIMENTAL_PREFIX = 'packages/experimental/'
/** This gate and its spec name the restricted function to find it. */
const GATE_FILES: ReadonlySet<string> = new Set([
'scripts/verify-plugin-record-callers.ts',
'scripts/verify-plugin-record-callers.spec.ts',
])
const SOURCE_EXTENSION = /\.(?:[cm]?[jt]s|[jt]sx)$/u
/** A line continuation, which a string or template literal drops from its value. */
const LINE_CONTINUATION = /\\(?:\r\n|[\n\r\u2028\u2029])/gu
/** A `\u{…}`, `\uXXXX`, or `\xXX` escape, capturing its hex digits. */
const HEX_ESCAPE = /\\(?:u\{([0-9a-fA-F]+)\}|u([0-9a-fA-F]{4})|x([0-9a-fA-F]{2}))/gu
/**
* A legacy octal escape, which a sloppy-mode script such as a `.cjs` file may use: up to three
* octal digits, three only when the first is 0–3, so the value stays within 0o377.
*/
const OCTAL_ESCAPE = /\\([0-3][0-7]{0,2}|[4-7][0-7]?)/gu
/**
* The source as its escapes could spell it: line continuations dropped, hex and legacy octal
* escapes decoded, and every other backslash removed. Text that the parser reads as the
* restricted name, in an identifier or a string, contains the name here too.
* @param source - file contents.
* @returns the unescaped text.
*/
export function unescaped(source: string): string {
return source
.replace(LINE_CONTINUATION, '')
.replace(HEX_ESCAPE, (match: string, braced?: string, unit?: string, byte?: string) => {
const code = Number.parseInt(`${braced ?? ''}${unit ?? ''}${byte ?? ''}`, 16)
return code <= 0x10ffff ? String.fromCodePoint(code) : match
})
.replace(OCTAL_ESCAPE, (_match: string, digits: string) => String.fromCharCode(Number.parseInt(digits, 8)))
.replaceAll('\\', '')
}
/** One production-source reference to the restricted function outside its admitted callers. */
export interface PluginRecordCaller {
/** Repository-relative path, in POSIX separators. */
readonly file: string
/** One-based line number. */
readonly line: number
/** The trimmed source line. */
readonly text: string
/** Why a production write lacks a catalogue declaration, when applicable. */
readonly reason?: string
}
/**
* Whether a repository path is a test file under the repository's lint-override globs.
* @param file - repository-relative path, in POSIX separators.
* @returns whether the file is a package, app, or example test, or a script spec.
*/
export function isTestFile(file: string): boolean {
return /^(?:packages\/[^/]+\/[^/]+|apps\/[^/]+|examples\/[^/]+)\/tests\//u.test(file)
|| /^scripts\/.+\.spec\.tsx?$/u.test(file)
}
/**
* Whether a file may reference the restricted function.
* @param file - repository-relative path, in POSIX separators.
* @returns whether the file is experimental source, the owner, a test, or this gate.
*/
export function isAdmittedCaller(file: string): boolean {
return file.startsWith(EXPERIMENTAL_PREFIX) || file === OWNER_FILE || isTestFile(file) || GATE_FILES.has(file)
}
function scriptKind(file: string): ts.ScriptKind {
if (file.endsWith('.tsx')) return ts.ScriptKind.TSX
if (file.endsWith('.jsx')) return ts.ScriptKind.JSX
if (/\.[cm]?js$/u.test(file)) return ts.ScriptKind.JS
return ts.ScriptKind.TS
}
/**
* Find references to the restricted function in one source file, admitted or not.
* @param file - repository-relative path; its extension selects the parser.
* @param source - file contents.
* @returns one entry per referencing identifier or exact-name string literal, in source order.
*/
export function findPluginRecordReferences(file: string, source: string): PluginRecordCaller[] {
if (!source.includes(RESTRICTED_NAME) && !unescaped(source).includes(RESTRICTED_NAME)) return []
const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, scriptKind(file))
const lines = source.split(/\r?\n/u)
const references: PluginRecordCaller[] = []
const visit = (node: ts.Node): void => {
const named = ts.isIdentifier(node)
|| ts.isStringLiteral(node)
|| ts.isNoSubstitutionTemplateLiteral(node)
if (named && node.text === RESTRICTED_NAME) {
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line
references.push({ file, line: line + 1, text: lines[line]?.trim() ?? '' })
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return references
}
/** Whether the owner still exports a function declaration under the restricted name. */
function declaresRestrictedFunction(source: string): boolean {
const sourceFile = ts.createSourceFile(OWNER_FILE, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
return sourceFile.statements.some(statement => ts.isFunctionDeclaration(statement)
&& statement.name?.text === RESTRICTED_NAME
&& ts.getModifiers(statement)?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword) === true)
}
/**
* Check a source corpus and return its unadmitted references.
* @param files - repository-relative path, in POSIX separators, to contents.
* @returns every reference outside experimental source, the owner, tests, and this gate.
* @throws when the corpus lacks the owner's exported declaration or any experimental or release
* package source, because the gate would then pass by checking the wrong files.
*/
export function checkPluginRecordCallers(files: ReadonlyMap<string, string>): PluginRecordCaller[] {
const owner = files.get(OWNER_FILE)
if (owner === undefined || !declaresRestrictedFunction(owner)) {
throw new Error(`verify-plugin-record-callers: ${OWNER_FILE} no longer exports function ${RESTRICTED_NAME}; update this gate with the move or rename.`)
}
const paths = [...files.keys()]
if (!paths.some(file => file.startsWith(EXPERIMENTAL_PREFIX))
|| !paths.some(file => file.startsWith('packages/') && !isAdmittedCaller(file))) {
throw new Error('verify-plugin-record-callers: the corpus lacks experimental or release package source; the file listing no longer matches the repository.')
}
return paths
.filter(file => !isAdmittedCaller(file))
.flatMap(file => findPluginRecordReferences(file, files.get(file) ?? ''))
}
/**
* Check production write keys against the generated catalogue's source inventory.
* The compiler reads production sources only, so test map augmentations cannot widen the writer.
* @param repoRoot - checkout holding the compiler face and record declarations.
* @param files - tracked and unignored repository sources.
* @returns compiler errors in experimental production consumers, including undeclared write keys.
*/
export function checkDeclaredPluginRecordWrites(
repoRoot: string,
files: ReadonlyMap<string, string>,
): PluginRecordCaller[] {
const declared = new Set(collectPluginRecords(repoRoot).map(record => record.name))
const candidates = [...files.keys()].filter(file => file.startsWith(EXPERIMENTAL_PREFIX) && !isTestFile(file)
&& findPluginRecordReferences(file, files.get(file) ?? '').length > 0)
if (candidates.length === 0) return []
const remaining = new Set(candidates)
const violations: PluginRecordCaller[] = []
for (const face of ['host', 'client'] as const) {
if (remaining.size === 0) break
const configs = faceConfigs(repoRoot, face)
const roots = new Set<string>()
for (const config of configs.byPath.values()) {
for (const file of config.fileNames) {
if (!isTestFile(relative(repoRoot, file).replaceAll('\\', '/'))) roots.add(resolve(file))
}
}
const admitted = candidates.filter(file => roots.has(resolve(repoRoot, file)))
if (admitted.length === 0) continue
for (const file of admitted) remaining.delete(file)
const consumers = new Set(admitted.map(file => file.split('/').slice(0, 3).join('/')))
const program = ts.createProgram([...roots], {
...configs.root.options,
noEmit: true,
composite: false,
incremental: false,
})
const owner = program.getSourceFile(resolve(repoRoot, 'packages/core/session/src/types.ts'))
const map = owner?.statements.find((node): node is ts.InterfaceDeclaration =>
ts.isInterfaceDeclaration(node) && node.name.text === 'PluginRecordMap')
if (map === undefined) throw new Error('production compiler did not load the owning PluginRecordMap')
for (const member of program.getTypeChecker().getTypeAtLocation(map).getProperties()) {
if (declared.has(member.name)) continue
const declaration = member.declarations?.[0]
if (declaration === undefined) throw new Error(`plugin record has no source declaration: ${member.name}`)
const source = declaration.getSourceFile()
violations.push({
file: relative(repoRoot, source.fileName).replaceAll('\\', '/'),
line: source.getLineAndCharacterOfPosition(declaration.getStart(source)).line + 1,
text: declaration.getText(source),
reason: `record ${member.name} is visible to production but has no production catalogue declaration`,
})
}
for (const file of roots) {
const local = relative(repoRoot, file).replaceAll('\\', '/')
if (!consumers.has(local.split('/').slice(0, 3).join('/'))) continue
const source = program.getSourceFile(file)
if (source === undefined) throw new Error(`plugin record caller source was not loaded: ${local}`)
for (const diagnostic of program.getSemanticDiagnostics(source)) {
if (diagnostic.category !== ts.DiagnosticCategory.Error) continue
const line = source.getLineAndCharacterOfPosition(diagnostic.start ?? 0).line
violations.push({
file: local,
line: line + 1,
text: source.text.split(/\r?\n/u)[line]?.trim() ?? '',
reason: ts.flattenDiagnosticMessageText(diagnostic.messageText, ' '),
})
}
}
}
for (const file of remaining) {
violations.push({ file, line: 1, text: '', reason: 'production record writers must be included in a Host or Client compiler face' })
}
return violations
}
/**
* Read every tracked or unignored source file outside `vendor/`.
* @param repoRoot - the repository root; defaults to this checkout.
* @returns repository-relative path, in POSIX separators, to contents.
*/
export function readRepositorySources(repoRoot: string = root): Map<string, string> {
const listing = execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], {
cwd: repoRoot,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
})
const files = new Map<string, string>()
for (const entry of listing.split('\0')) {
const file = entry.replaceAll('\\', '/')
if (file === '' || file.startsWith('vendor/') || !SOURCE_EXTENSION.test(file)) continue
try {
files.set(file, readFileSync(resolve(repoRoot, file), 'utf8'))
} catch (error: unknown) {
// A file deleted in the working tree is still listed until the deletion is staged.
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
}
return files
}
function main(): void {
const files = readRepositorySources()
const callers = [...checkPluginRecordCallers(files), ...checkDeclaredPluginRecordWrites(root, files)]
if (callers.length === 0) {
console.log('verify-plugin-record-callers: production callers are experimental and record keys are catalogued.')
return
}
console.error('verify-plugin-record-callers: invalid production record callers.\n')
for (const caller of callers) console.error(` ${caller.file}:${String(caller.line)} ${caller.text}${caller.reason === undefined ? '' : ` — ${caller.reason}`}`)
console.error('\nA release package declares its events in SessionEventMap instead of writing plugin records.')
process.exit(1)
}
if (import.meta.filename === resolve(process.argv[1] ?? '')) main()
+1
View File
@@ -19,6 +19,7 @@ export const GROUPS_WITHOUT_SUBSYSTEM_PAGE: Readonly<Record<string, string>> = {
bundle: 'Composition patch carriers whose mounted packages own all runtime contracts.',
hooks: 'External hook-protocol bridges over existing interception points, not a new Harness service.',
sdk: 'Out-of-process protocol and client packages whose package READMEs own the SDK contracts.',
'test-support': 'Repository test harnesses; docs/testing.md owns the testing policy they serve.',
util: 'Low-level primitives whose business semantics remain with their consuming subsystems.',
}
+7 -5
View File
@@ -239,8 +239,12 @@ for (const source of [...pairAnchors].sort()) {
const sourceText = sourceContent.toString('utf8')
const zhText = zhContent.toString('utf8')
let current: ReturnType<typeof computeTranslationPairingRecord>
let sourceTree: ReturnType<typeof parseTranslationMarkdown>
let zhTree: ReturnType<typeof parseTranslationMarkdown>
try {
current = computeTranslationPairingRecord(paths, sourceText, zhText, recordContext)
sourceTree = parseTranslationMarkdown(sourceText)
zhTree = parseTranslationMarkdown(zhText)
current = computeTranslationPairingRecord(paths, sourceText, zhText, recordContext, { en: sourceTree, zh: zhTree })
} catch (error) {
errors.push(`${source} ↔ ${zh}: ${error instanceof Error ? error.message : String(error)}`)
state.set(source, 'out-of-sync')
@@ -265,13 +269,13 @@ for (const source of [...pairAnchors].sort()) {
sourcePath: source,
isTranslationPairSource,
repositoryFileExists,
}, zhSwitcherTargets),
}, zhSwitcherTargets, sourceTree),
...translationLinkLocaleViolations(zhText, {
repoRoot: root,
sourcePath: zh,
isTranslationPairSource,
repositoryFileExists,
}, sourceSwitcherTargets),
}, sourceSwitcherTargets, zhTree),
]) {
errors.push(`${violation.sourcePath}:${violation.line}: link target ${JSON.stringify(violation.url)} uses the wrong locale; expected ${JSON.stringify(violation.expectedUrl)}`)
state.set(source, 'out-of-sync')
@@ -309,8 +313,6 @@ for (const source of [...pairAnchors].sort()) {
state.set(source, 'out-of-sync')
}
const sourceTree = parseTranslationMarkdown(sourceText)
const zhTree = parseTranslationMarkdown(zhText)
if (!hasLanguageSwitcher(zhTree, zhText, sourceSwitcherTargets)) {
errors.push(`${zh}: missing language switcher — no link to ${basename(source)}`)
}
+130
View File
@@ -0,0 +1,130 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { collectUpgradeGuideViolations } from './verify-upgrade-guides.ts'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
const VALID = [
'---',
'kind: upgrade-guide',
'description: "The `--preset` flag is removed."',
'---',
'',
'# `--profile` replaces `--preset`',
'',
'## Change',
'',
'Old and new behavior.',
'',
'### Detail',
'',
'```md',
'# not a heading',
'```',
'',
'## Migration',
'',
'1. Rename the flag.',
'',
].join('\n')
const VALID_ZH = VALID
.replace('description: "The `--preset` flag is removed."', 'description: "移除 `--preset` 参数。"')
.replace('## Change', '## 变更')
.replace('## Migration', '## 迁移')
function fixtureRoot(files: Record<string, string>): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-upgrade-guides-'))
roots.push(root)
for (const [file, source] of Object.entries(files)) {
const path = join(root, 'docs/upgrade-guide', file)
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, source)
}
return root
}
describe('upgrade guide gate', () => {
it('accepts an absent tree and conforming guide pairs, including prerelease versions and CRLF files', () => {
expect(collectUpgradeGuideViolations(fixtureRoot({}))).toEqual([])
expect(collectUpgradeGuideViolations(fixtureRoot({
'v0.1.7-rc.2/profile-flag/guide.md': VALID,
'v0.1.7-rc.2/profile-flag/guide.zh.md': VALID_ZH,
'v0.1.7-rc.2/profile-flag/guide.i18n.yaml': 'not parsed by this gate\n',
'v1.0.0/crlf-item/guide.md': VALID.replaceAll('\n', '\r\n'),
}))).toEqual([])
})
it('rejects files outside the version/item/guide pair layout', () => {
expect(collectUpgradeGuideViolations(fixtureRoot({
'README.md': VALID,
'0.1.7/item/guide.md': VALID,
'v0.1/item/guide.md': VALID,
'v0.1.7/Item_Name/guide.md': VALID,
'v0.1.7/item/notes.md': VALID,
'v0.1.7/item/extra/guide.md': VALID,
}))).toEqual([
'docs/upgrade-guide/0.1.7/item/guide.md',
'docs/upgrade-guide/README.md',
'docs/upgrade-guide/v0.1.7/Item_Name/guide.md',
'docs/upgrade-guide/v0.1.7/item/extra/guide.md',
'docs/upgrade-guide/v0.1.7/item/notes.md',
'docs/upgrade-guide/v0.1/item/guide.md',
].map(path => `${path}: only v<semver>/<kebab-case-item>/guide.{md,zh.md,i18n.yaml} files belong in docs/upgrade-guide`))
})
it('rejects missing, extra, or invalid frontmatter fields', () => {
const body = VALID.slice(VALID.indexOf('# '))
expect(collectUpgradeGuideViolations(fixtureRoot({
'v0.1.7/none/guide.md': body,
'v0.1.7/list/guide.md': `---\n- a\n---\n${body}`,
'v0.1.7/fields/guide.md': `---\nkind: persistence-change\ndescription: ""\nversion: v0.1.7\n---\n${body}`,
}))).toEqual([
'docs/upgrade-guide/v0.1.7/fields/guide.md: frontmatter keys must be exactly description, kind; got description, kind, version',
'docs/upgrade-guide/v0.1.7/fields/guide.md: kind must be upgrade-guide',
'docs/upgrade-guide/v0.1.7/fields/guide.md: description must be a non-empty string',
'docs/upgrade-guide/v0.1.7/list/guide.md: frontmatter must be a YAML object',
'docs/upgrade-guide/v0.1.7/none/guide.md: must start with YAML frontmatter',
])
})
it('rejects a missing title and missing, renamed, extra, or reordered sections', () => {
const cases = {
'no-title': VALID.replace('# `--profile` replaces `--preset`\n', ''),
'two-titles': VALID.replace('## Migration', '# Migration'),
'renamed': VALID.replace('## Change', '## Changes'),
'extra': `${VALID}\n## References\n`,
'reordered': VALID.replace('## Change', '## Placeholder').replace('## Migration', '## Change').replace('## Placeholder', '## Migration'),
}
const violations = collectUpgradeGuideViolations(fixtureRoot(Object.fromEntries(
Object.entries(cases).map(([item, source]) => [`v0.1.7/${item}/guide.md`, source]),
)))
expect(violations.map(violation => violation.split('/')[3])).toEqual(['extra', 'no-title', 'renamed', 'reordered', 'two-titles'])
expect(violations[0]).toBe('docs/upgrade-guide/v0.1.7/extra/guide.md: headings must be one "#" title followed by "## Change", "## Migration"; got "# `--profile` replaces `--preset`", "## Change", "## Migration", "## References"')
})
it('checks the Chinese sibling against its own section names and metadata', () => {
expect(collectUpgradeGuideViolations(fixtureRoot({
'v0.1.7/english-headings/guide.zh.md': VALID,
'v0.1.7/wrong-kind/guide.zh.md': VALID_ZH.replace('kind: upgrade-guide', 'kind: guide'),
}))).toEqual([
'docs/upgrade-guide/v0.1.7/english-headings/guide.zh.md: headings must be one "#" title followed by "## 变更", "## 迁移"; got "# `--profile` replaces `--preset`", "## Change", "## Migration"',
'docs/upgrade-guide/v0.1.7/wrong-kind/guide.zh.md: kind must be upgrade-guide',
])
})
it('rejects an English guide over 500 words, counting frontmatter, and leaves the Chinese sibling uncounted', () => {
const words = VALID.split(/\s+/u).filter(Boolean).length
expect(collectUpgradeGuideViolations(fixtureRoot({
'v0.1.7/at-limit/guide.md': `${VALID}${'word '.repeat(500 - words)}\n`,
'v0.1.7/over-limit/guide.md': `${VALID}${'word '.repeat(501 - words)}\n`,
'v0.1.7/over-limit/guide.zh.md': `${VALID_ZH}${'词 '.repeat(600)}\n`,
}))).toEqual(['docs/upgrade-guide/v0.1.7/over-limit/guide.md: 501 words exceeds the 500-word ceiling'])
})
})
+85
View File
@@ -0,0 +1,85 @@
/**
* Enforce the `docs/upgrade-guide/v<version>/<item>/guide.md` layout, metadata,
* sections, and word ceiling defined by the `dsh-create-upgrade-guide` skill.
* `verify-translation-pairing` owns the Chinese sibling's pairing record.
* @module scripts/verify-upgrade-guides
*/
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { JSON_SCHEMA, load } from 'js-yaml'
import { fromMarkdown } from 'mdast-util-from-markdown'
const ROOT = resolve(import.meta.dirname, '..')
const GUIDE_ROOT = 'docs/upgrade-guide'
const FRONTMATTER_KEYS = ['description', 'kind']
/** Required `##` sections for each guide language; the word ceiling applies to the English source. */
const LANGUAGES = {
'guide.md': { sections: ['Change', 'Migration'], maxWords: 500 },
'guide.zh.md': { sections: ['变更', '迁移'], maxWords: undefined },
} as const
const SEMVER = String.raw`(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?`
const GUIDE_PATH = new RegExp(`^v${SEMVER}/[a-z0-9]+(?:-[a-z0-9]+)*/(guide\\.md|guide\\.zh\\.md|guide\\.i18n\\.yaml)$`, 'u')
/** Return one guide's frontmatter and structure violations. */
function guideViolations(source: string, language: typeof LANGUAGES[keyof typeof LANGUAGES]): string[] {
const violations: string[] = []
const words = source.split(/\s+/u).filter(Boolean).length
if (language.maxWords !== undefined && words > language.maxWords) violations.push(`${String(words)} words exceeds the ${String(language.maxWords)}-word ceiling`)
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/u.exec(source)
if (frontmatter === null) return [...violations, 'must start with YAML frontmatter']
const metadata = load(frontmatter[1] as string, { schema: JSON_SCHEMA })
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) return [...violations, 'frontmatter must be a YAML object']
const fields = metadata as Record<string, unknown>
const keys = Object.keys(fields).sort()
if (keys.join() !== FRONTMATTER_KEYS.join()) violations.push(`frontmatter keys must be exactly ${FRONTMATTER_KEYS.join(', ')}; got ${keys.join(', ') || 'none'}`)
if (fields.kind !== 'upgrade-guide') violations.push('kind must be upgrade-guide')
if (typeof fields.description !== 'string' || fields.description.trim() === '') violations.push('description must be a non-empty string')
const body = source.slice(frontmatter[0].length)
const outline = fromMarkdown(body).children
.flatMap(node => node.type === 'heading' && node.depth <= 2 ? [body.slice(node.position?.start.offset, node.position?.end.offset)] : [])
const expected = language.sections.map(section => `## ${section}`)
if (outline.length !== expected.length + 1 || !outline[0]?.startsWith('# ') || outline.slice(1).join('\n') !== expected.join('\n')) {
violations.push(`headings must be one "#" title followed by ${expected.map(heading => `"${heading}"`).join(', ')}; got ${outline.map(heading => `"${heading}"`).join(', ') || 'none'}`)
}
return violations
}
/**
* Report upgrade-guide tree violations.
* @param root - Repository root containing `docs/upgrade-guide`.
* @returns diagnostics prefixed by the repository-relative file path; empty when the tree is absent.
*/
export function collectUpgradeGuideViolations(root: string): string[] {
const directory = join(root, GUIDE_ROOT)
if (!existsSync(directory)) return []
const violations: string[] = []
const files = readdirSync(directory, { recursive: true, withFileTypes: true })
.filter(entry => !entry.isDirectory())
.map(entry => join(entry.parentPath, entry.name).slice(directory.length + 1).replaceAll('\\', '/'))
.sort()
for (const file of files) {
const path = `${GUIDE_ROOT}/${file}`
const name = GUIDE_PATH.exec(file)?.[1]
if (name === undefined) {
violations.push(`${path}: only v<semver>/<kebab-case-item>/guide.{md,zh.md,i18n.yaml} files belong in ${GUIDE_ROOT}`)
continue
}
if (name === 'guide.md' || name === 'guide.zh.md') {
for (const violation of guideViolations(readFileSync(join(directory, file), 'utf8'), LANGUAGES[name])) violations.push(`${path}: ${violation}`)
}
}
return violations
}
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
const violations = collectUpgradeGuideViolations(ROOT)
if (violations.length > 0) {
process.stderr.write('verify-upgrade-guides: violations found (see .agents/skills/dsh-create-upgrade-guide/SKILL.md):\n')
for (const violation of violations) process.stderr.write(` ${violation}\n`)
process.exit(1)
}
process.stdout.write('verify-upgrade-guides: upgrade guides conform.\n')
}