Merge fix/web-agent-runtime-context into fix/web-gui-feedback-loop

This commit is contained in:
Tianyi Cui
2026-08-01 22:15:36 +08:00
2919 changed files with 226216 additions and 29022 deletions

View File

@@ -7,9 +7,9 @@
*/
import { spawn } from 'node:child_process'
import { existsSync, mkdirSync, statSync } from 'node:fs'
import { copyFile, readFile, rm, writeFile } from 'node:fs/promises'
import { basename, join, resolve, sep } from 'node:path'
import { existsSync, statSync } from 'node:fs'
import { chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { basename, dirname, join, resolve, sep } from 'node:path'
import { parseArgs } from 'node:util'
const root = resolve(import.meta.dirname, '..')
@@ -254,6 +254,10 @@ class SingleExeBuild {
'--config.node-linker=hoisted',
'--config.auto-install-peers=false',
'--config.link-workspace-packages=true',
// The production closure intentionally omits the patched dev-only
// @earendil-works/pi-tui package. The root frozen install still validates
// every patch; this exception is scoped only to the production deploy.
'--config.allow-unused-patches=true',
this.staging,
])
if (this.cli.dryRun) {
@@ -285,11 +289,12 @@ class SingleExeBuild {
/**
* Package one target; SEA mode accepts one target per invocation.
* @param target - the pkg target triple to build.
* @returns the canonical product path `<out>/dsh-jsonrpc-agent-pkg-<platform>-<arch>`.
* @returns the executable path and, on macOS, its helper path.
*/
async pack(target: Target): Promise<string> {
async pack(target: Target): Promise<string[]> {
const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`)
if (!this.cli.dryRun) mkdirSync(this.outDir, { recursive: true })
await this.prepareNativePty(target)
if (!this.cli.dryRun) await mkdir(this.outDir, { recursive: true })
await this.run(`pkg ${target.spec}`, pnpmBin(), [
'dlx',
PKG_SPEC,
@@ -303,7 +308,43 @@ class SingleExeBuild {
if (!this.cli.dryRun && !existsSync(product)) {
throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`)
}
return product
if (target.platform !== 'macos') return [product]
const spawnHelper = `${product}-spawn-helper`
const source = join(this.staging, 'node_modules', 'node-pty', 'prebuilds', `darwin-${target.arch}`, 'spawn-helper')
if (this.cli.dryRun) {
console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${spawnHelper}`)
} else {
await copyFile(source, spawnHelper)
await chmod(spawnHelper, 0o755)
}
return [product, spawnHelper]
}
/**
* Put the target node-pty addon in the staged closure. Linux npm installs
* build it from source, but legacy deploy omits that side-effect directory.
* @param target - the pkg target whose native addon is being staged.
*/
private async prepareNativePty(target: Target): Promise<void> {
const stagedBuild = join(this.staging, 'node_modules', 'node-pty', 'build')
if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${stagedBuild}`)
else await rm(stagedBuild, { recursive: true, force: true })
if (target.platform !== 'linux') return
const source = join(root, 'packages', 'pty', 'pty-local', 'node_modules', 'node-pty', 'build', 'Release', 'pty.node')
const destination = join(stagedBuild, 'Release', 'pty.node')
if (this.cli.dryRun) {
console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`)
return
}
const host = Target.host()
if (target.platform !== host.platform || target.arch !== host.arch) {
throw new Error(
'build-exe-for-python-sdk: build the Linux runtime on its target architecture; '
+ `target ${target.platform}-${target.arch} does not match host ${host.platform}-${host.arch}.`,
)
}
await mkdir(dirname(destination), { recursive: true })
await copyFile(source, destination)
}
/**
@@ -312,33 +353,34 @@ class SingleExeBuild {
*/
printProducts(products: string[]): void {
console.log(this.cli.dryRun ? 'build-exe-for-python-sdk: [dry-run] would produce:' : 'build-exe-for-python-sdk: products:')
for (const product of products) {
for (const path of products) {
if (this.cli.dryRun) {
console.log(` ${product}`)
console.log(` ${path}`)
continue
}
const megabytes = statSync(product).size / (1024 * 1024)
console.log(` ${product} (${megabytes.toFixed(1)} MB)`)
const megabytes = statSync(path).size / (1024 * 1024)
console.log(` ${path} (${megabytes.toFixed(1)} MB)`)
}
}
/**
* Copy each executable into the Python runtime package. The deployed node
* Copy each product into the Python runtime package. The deployed node
* carrier is already in place, and `dist-exe/` retains upload copies.
* @param products - the product paths returned by {@link pack}.
*/
async syncToPythonRuntime(products: string[]): Promise<void> {
const destDir = resolve(root, PYTHON_RUNTIME_DIR)
if (this.cli.dryRun) {
for (const product of products) {
console.log(`build-exe-for-python-sdk: [dry-run] cp ${product} ${join(destDir, basename(product))}`)
for (const path of products) {
console.log(`build-exe-for-python-sdk: [dry-run] cp ${path} ${join(destDir, basename(path))}`)
}
return
}
mkdirSync(destDir, { recursive: true })
for (const product of products) {
const destination = join(destDir, basename(product))
await copyFile(product, destination)
await mkdir(destDir, { recursive: true })
for (const path of products) {
const destination = join(destDir, basename(path))
await copyFile(path, destination)
await chmod(destination, statSync(path).mode & 0o777)
console.log(`build-exe-for-python-sdk: synced ${destination}`)
}
}
@@ -358,7 +400,12 @@ class SingleExeBuild {
}
console.log(`build-exe-for-python-sdk: ${label}: ${printable}`)
await new Promise<void>((resolvePromise, reject) => {
const child = spawn(command, args, { cwd: root, stdio: 'inherit' })
const child = spawn(command, args, {
cwd: root,
stdio: 'inherit',
// Artifact builds must not mutate or validate a developer's Git hooks.
env: { ...process.env, CI: 'true' },
})
child.once('error', (error) => {
reject(new Error(`build-exe-for-python-sdk: ${label} failed to spawn: ${error.message} (${printable})`))
})
@@ -384,7 +431,7 @@ async function main(): Promise<void> {
await pipeline.deployStaging()
await pipeline.injectPkgConfig()
const products: string[] = []
for (const target of cli.targets) products.push(await pipeline.pack(target))
for (const target of cli.targets) products.push(...await pipeline.pack(target))
pipeline.printProducts(products)
await pipeline.syncToPythonRuntime(products)
}

View File

@@ -24,6 +24,10 @@ PLATFORMS = {
}
def runtime_suffixes(executable_name: str) -> tuple[str, ...]:
return ("", "-spawn-helper") if "-macos-" in executable_name else ("",)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--package", choices=("sdk", "runtime"), required=True)
@@ -132,17 +136,12 @@ def stage_sdk(destination: Path, version: str) -> None:
def stage_runtime(destination: Path, version: str, executable: Path, executable_name: str) -> None:
if not executable.is_file():
raise FileNotFoundError(f"runtime executable does not exist: {executable}")
if executable.stat().st_mode & stat.S_IXUSR == 0:
raise PermissionError(f"runtime executable is not executable: {executable}")
copy_package(ROOT / "python" / "sdk-runtime", destination)
rewrite_version(destination / "pyproject.toml", version)
runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime"
runtime_dir.mkdir(parents=True, exist_ok=True)
destination_executable = runtime_dir / executable_name
shutil.copyfile(executable, destination_executable)
destination_executable.chmod(executable.stat().st_mode & 0o777)
for suffix in runtime_suffixes(executable_name):
shutil.copy2(Path(f"{executable}{suffix}"), runtime_dir / f"{executable_name}{suffix}")
def verify_wheel(
@@ -161,16 +160,21 @@ def verify_wheel(
raise RuntimeError(f"{wheel} has wrong WHEEL tags: {wheel_metadata.get_all('Tag')}")
if metadata.get("Version") != version:
raise RuntimeError(f"{wheel} has version {metadata.get('Version')}, expected {version}")
executables = [name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name]
runtime_files = [
name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name
]
if package == "runtime":
assert platform is not None
if len(executables) != 1 or not executables[0].endswith(f"/runtime/{platform[1]}"):
raise RuntimeError(f"{wheel} must contain exactly {platform[1]}, found {executables}")
mode = archive.getinfo(executables[0]).external_attr >> 16
if mode & stat.S_IXUSR == 0:
raise RuntimeError(f"{wheel} runtime executable lost its executable bit")
elif executables:
raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {executables}")
expected_files = [f"{platform[1]}{suffix}" for suffix in runtime_suffixes(platform[1])]
found_files = sorted(Path(name).name for name in runtime_files)
if found_files != expected_files:
raise RuntimeError(f"{wheel} runtime payload must be {expected_files}, found {found_files}")
for runtime_file in runtime_files:
mode = archive.getinfo(runtime_file).external_attr >> 16
if mode & stat.S_IXUSR == 0:
raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {runtime_file}")
elif runtime_files:
raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}")
if package == "sdk":
requirements = metadata.get_all("Requires-Dist") or []
expected_requirement = f"deepseek-harness-runtime-bin=={version}"

View File

@@ -0,0 +1,33 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import * as yaml from 'js-yaml'
import { describe, expect, it } from 'vitest'
const root = resolve(import.meta.dirname, '..')
const runnerPrivatePnpmDestination = '${{ runner.temp }}/setup-pnpm'
describe('CI workflow', () => {
it('isolates every pnpm action setup destination per runner', () => {
const workflow: unknown = yaml.load(readFileSync(resolve(root, '.github/workflows/ci.yml'), 'utf8'))
if (!isRecord(workflow) || !isRecord(workflow.jobs)) throw new TypeError('CI workflow must define jobs')
const setups = Object.entries(workflow.jobs).flatMap(([jobName, job]) => {
if (!isRecord(job) || !Array.isArray(job.steps)) return []
return job.steps.flatMap((step) => {
if (!isRecord(step) || typeof step.uses !== 'string' || !step.uses.startsWith('pnpm/action-setup@')) return []
return [{ jobName, step }]
})
})
expect(setups.length).toBeGreaterThan(0)
for (const { jobName, step } of setups) {
expect(step, `${jobName} must not share pnpm/action-setup's default destination`).toMatchObject({
with: { dest: runnerPrivatePnpmDestination },
})
}
})
})
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

View File

@@ -0,0 +1,47 @@
/**
* CSS Modules enter client bundles through virtual modules, so the loader must
* explicitly register the underlying stylesheet as a watch dependency.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { clientBundle } from '../packages/client/tsdown.client.ts'
interface CssPlugin {
name: string
resolveId?: (source: string, importer?: string) => string | null
load?: (this: { addWatchFile(id: string): void }, id: string) => Promise<string | null>
}
function cssPlugin(): CssPlugin {
const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js'])
const plugins = (configs[1] as { plugins: CssPlugin[] }).plugins
const plugin = plugins.find(candidate => candidate.name === 'dsh-css-modules-inline')
if (plugin === undefined) throw new Error('CSS Modules plugin missing from client config')
return plugin
}
describe('client bundle CSS Modules', () => {
it('registers the source stylesheet as a watch dependency', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-client-css-watch-'))
try {
const stylesheet = join(root, 'Fixture.module.css')
const importer = join(root, 'index.ts')
await writeFile(stylesheet, '.root { color: red; }\n')
const plugin = cssPlugin()
const virtualId = plugin.resolveId?.('./Fixture.module.css', importer)
if (typeof virtualId !== 'string' || plugin.load === undefined) {
throw new Error('CSS Modules plugin hooks are incomplete')
}
const watched: string[] = []
const output = await plugin.load.call({ addWatchFile: id => watched.push(id) }, virtualId)
expect(watched).toEqual([stylesheet])
expect(output).toContain('data-plugin-css')
} finally {
await rm(root, { recursive: true, force: true })
}
})
})

View File

@@ -1,16 +1,19 @@
/**
* Pins the client-bundle purity gate (tsdown preset resolveId classifier),
* the build-time mirror of the module-edge rules: platform module-table
* entries stay external, inline-safe wire layers inline, and every other
* @deepseek-ai value import — including a bare plugin-package name and a
* cross-plugin /client subpath — must fail the build loudly (cross-plugin
* collaboration goes through cordis services, never module imports).
* Pins shared client-bundle preset contracts: the module-edge purity gate and
* the physical watch dependencies hidden behind virtual CSS Modules.
*/
import { describe, expect, it } from 'vitest'
import { fileURLToPath } from 'node:url'
import { describe, expect, it, vi } from 'vitest'
import { CLIENT_EXTERNALS, clientBundle } from '../packages/client/tsdown.client.ts'
type ResolveId = (source: string) => null | { id: string; external: boolean }
interface CssModulePlugin {
name: string
resolveId?: (source: string, importer: string | undefined) => null | string
load?: (this: { addWatchFile: (id: string) => void }, id: string) => Promise<unknown>
}
function purityResolveId(): ResolveId {
// libEntry is spelled at every call site (no default) so the
// package-invariants text check can see the invariant entry per package.
@@ -21,6 +24,16 @@ function purityResolveId(): ResolveId {
return gate.resolveId as ResolveId
}
function cssModulePlugin(): CssModulePlugin {
const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js'])
const plugins = (configs[1] as { plugins: CssModulePlugin[] }).plugins
const plugin = plugins.find(candidate => candidate.name === 'dsh-css-modules-inline')
if (plugin?.resolveId === undefined || plugin.load === undefined) {
throw new Error('CSS Modules plugin missing from client config')
}
return plugin
}
describe('client bundle purity gate', () => {
const resolveId = purityResolveId()
@@ -60,3 +73,24 @@ describe('client bundle purity gate', () => {
expect(dshClientChannels).toEqual(['@deepseek-ai/dsh-client-runtime/client'])
})
})
describe('client bundle CSS Modules watch graph', () => {
it('registers the physical stylesheet read behind a virtual module', async () => {
const plugin = cssModulePlugin()
const importer = fileURLToPath(new URL(
'../packages/client/ui-conversation/src/client/queue/QueueDock.tsx',
import.meta.url,
))
const stylesheet = fileURLToPath(new URL(
'../packages/client/ui-conversation/src/client/queue/QueueDock.module.css',
import.meta.url,
))
const virtualId = plugin.resolveId?.('./QueueDock.module.css', importer)
if (virtualId === null || virtualId === undefined) throw new Error('CSS Modules import was not resolved')
const addWatchFile = vi.fn()
await plugin.load?.call({ addWatchFile }, virtualId)
expect(addWatchFile).toHaveBeenCalledExactlyOnceWith(stylesheet)
})
})

View File

@@ -1,11 +1,6 @@
/**
* AST walkers for the Cordis catalog generator: locate the Cordis module merge
* in a source file, enumerate its `interface Events` members, and resolve the
* `interface Context` service keys to their service classes.
*/
/** Locate the Cordis module merge used by the vendored core API projector. */
import ts from 'typescript'
import { parseJsDoc, pointer, rawJsDoc } from './jsdoc.ts'
/** The body of the cordis module merge in `sf`: `declare module 'cordis'`
* (harness packages) or `declare module './context.ts'` (vendor core), or
@@ -18,74 +13,3 @@ export function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
}
return null
}
/** Every `interface Events` method member of a cordis module merge, with the
* event name resolved from its (possibly string-literal) property name. */
export function eventMembers(body: ts.ModuleBlock, sf: ts.SourceFile): { name: string; member: ts.MethodSignature }[] {
const out: { name: string; member: ts.MethodSignature }[] = []
for (const stmt of body.statements) {
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue
for (const member of stmt.members) {
if (!ts.isMethodSignature(member)) continue
const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf)
out.push({ name, member })
}
}
return out
}
/** The `ctx.<key> → type name` map declared by a merge's `interface Context`. */
function contextKeyMap(body: ts.ModuleBlock, sf: ts.SourceFile): Map<string, string> {
const keyToType = new Map<string, string>()
for (const stmt of body.statements) {
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue
for (const member of stmt.members) {
if (!ts.isPropertySignature(member) || !member.type) continue
keyToType.set(member.name.getText(sf), member.type.getText(sf))
}
}
return keyToType
}
/** One `ctx.<key>` service class resolved from a Context merge. */
export interface ServiceClass {
key: string
type: string
cls: ts.ClassDeclaration
abstract: boolean
/** Class-level JSDoc prose (empty string when missing — also reported). */
doc: string
}
/**
* Resolve each `ctx.<key>` of a merge to the service class declared in the
* same file. A key whose type is not a class here (a Pick-mixin member, e.g.
* timer helpers) is skipped. A class without JSDoc prose is reported into
* `violations` (named `where` by the caller's gate).
*
* @param body — the cordis module merge body.
* @param sf — the source file containing the merge.
* @param rel — repo-relative path of `sf`, for violation pointers.
* @param violations — sink for JSDoc-completeness violations.
* @returns the resolved service classes, in Context-declaration order.
*/
export function serviceClasses(
body: ts.ModuleBlock,
sf: ts.SourceFile,
rel: string,
violations: string[],
): ServiceClass[] {
const text = sf.getFullText()
const out: ServiceClass[] = []
for (const [key, type] of contextKeyMap(body, sf)) {
const cls = sf.statements.find(
(s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === type,
)
if (!cls) continue // a Pick-mixin member, not a class here
const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false
const doc = parseJsDoc(rawJsDoc(text, cls)).doc
if (!doc) violations.push(`service ctx.${key} (${pointer(rel, sf, cls)}): class ${type} has no JSDoc.`)
out.push({ key, type, cls, abstract, doc })
}
return out
}

View File

@@ -0,0 +1,55 @@
/**
* Mechanical guard for the coverage-exempt roster: each entry's positional
* filter and exclude glob must select the same non-empty file set out of the
* repository's spec inventory, so a renamed suite cannot silently fall out of
* the uninstrumented gate while its exclude goes stale.
*/
import { globSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { coverageExemptHeavySuites } from './coverage-exempt.ts'
const root = resolve(import.meta.dirname, '..')
/** The spec inventory mirrored from vitest.config.ts testIncludes. */
const allSpecs = new Set([
...globSync('packages/*/*/tests/**/*.spec.ts', { cwd: root }),
...globSync('packages/*/*/tests/**/*.spec.tsx', { cwd: root }),
...globSync('apps/*/tests/**/*.spec.ts', { cwd: root }),
...globSync('examples/*/tests/**/*.spec.ts', { cwd: root }),
...globSync('scripts/**/*.spec.ts', { cwd: root }),
].map(path => path.replaceAll('\\', '/')))
function excludeMatches(exclude: string): string[] {
return globSync(exclude, { cwd: root })
.map(path => path.replaceAll('\\', '/'))
.filter(path => allSpecs.has(path))
.sort()
}
function filterMatches(filter: string): string[] {
return [...allSpecs].filter(spec => spec.startsWith(filter)).sort()
}
describe('coverage-exempt roster', () => {
it.each(coverageExemptHeavySuites.map(suite => [suite.filter, suite] as const))(
'filter and exclude select the same non-empty spec set for %s',
(_filter, suite) => {
const fromExclude = excludeMatches(suite.exclude)
const fromFilter = filterMatches(suite.filter)
expect(fromExclude.length).toBeGreaterThan(0)
expect(fromFilter).toEqual(fromExclude)
},
)
it('entries never overlap, so no suite is double-run or double-excluded', () => {
const seen = new Map<string, string>()
for (const suite of coverageExemptHeavySuites) {
for (const spec of excludeMatches(suite.exclude)) {
expect(seen.get(spec), `${spec} matched by ${seen.get(spec) ?? ''} and ${suite.exclude}`).toBeUndefined()
seen.set(spec, suite.exclude)
}
}
})
})

View File

@@ -0,0 +1,41 @@
/**
* Heavy suites the coverage aggregate runs uninstrumented in a parallel gate.
* Membership contract: a suite qualifies only when every coverage-measured
* file it executes in-process (`coverage.include` spans package src trees;
* typert generator src is threshold-excluded in vitest.config.ts) is already
* fully covered by other suites, so removing it from the instrumented run
* changes no threshold outcome. The aggregate still runs every listed suite
* plain beside the instrumented gate, so correctness signal is unchanged —
* only the v8 instrumentation tax on compiler- and subprocess-heavy fixtures
* is dropped.
*/
/** One coverage-exempt suite: a Vitest CLI filter and its exclude glob. */
export interface CoverageExemptSuite {
/** Positional file filter selecting the suite in the uninstrumented gate. */
readonly filter: string
/** Exclude glob removing the suite from the instrumented gate. */
readonly exclude: string
}
/**
* Set to `1` by the instrumented coverage gate; vitest.config.ts then drops
* the exempt suites from every project. CLI `--exclude` cannot express this:
* it does not reach per-project include resolution.
*/
export const COVERAGE_EXEMPT_ENV = 'DSH_COVERAGE_EXEMPT_HEAVY'
/** Coverage-exempt heavy suites; keep filter and exclude selecting the same files. */
export const coverageExemptHeavySuites: readonly CoverageExemptSuite[] = [
// Whole-workspace compiler analysis per case — the lane's longest tail.
// Generator src is threshold-excluded; tools-catalog's registry and
// tool-cordis imports are fully covered by those packages' own tests.
{
filter: 'packages/typert/generator/tests/',
exclude: 'packages/typert/generator/tests/**',
},
// Real child-process fixtures over scripts/ sources, which coverage never measures.
{ filter: 'scripts/install-lefthook.spec.ts', exclude: 'scripts/install-lefthook.spec.ts' },
{ filter: 'scripts/oxlint-contract.spec.ts', exclude: 'scripts/oxlint-contract.spec.ts' },
{ filter: 'scripts/change-scope.spec.ts', exclude: 'scripts/change-scope.spec.ts' },
]

View File

@@ -1,22 +1,16 @@
/**
* Boot the TUI or ACP Code Mode overlay, defaulting to TUI. Each overlay
* includes its base example, selects Code Mode, and adds the worker runtime.
* All require a DeepSeek API key; unsupported arguments fail with usage.
*/
/** Boot the ACP Code Mode overlay. Requires a DeepSeek API key. */
import { spawn } from 'node:child_process'
// Each UI's node invocation matches its base demo script plus the overlay config.
const UIS = new Map([
['tui', ['--import', 'tsx', 'apps/cli/src/bin.ts', '--config', 'examples/tui-agent/code-mode.cordis.yml']],
['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']],
])
const ui = process.argv[2] ?? 'tui'
const args = UIS.get(ui)
if (!args || process.argv.length > 3) {
console.error('usage: pnpm run demo:code-mode [tui|acp]')
if (process.argv.length > 2) {
console.error('usage: pnpm run demo:code-mode')
process.exit(2)
}
const child = spawn(process.execPath, args, { stdio: 'inherit' })
const child = spawn(process.execPath, [
'--import',
'tsx',
'packages/examples/acp-demo/src/bin.ts',
'--config',
'examples/acp-agent/code-mode.cordis.yml',
], { stdio: 'inherit' })
child.on('exit', (code, signal) => { process.exit(signal !== null ? 1 : code ?? 1) })

View File

@@ -1,21 +1,19 @@
/**
* Boot the self-referential Cordis tools under TUI, Web, or ACP, defaulting
* to TUI. This is a repository demo wrapper, not a product CLI feature.
* Boot the self-referential Cordis tools under Web or ACP, defaulting to Web. This is a repository demo wrapper, not a product CLI feature.
*/
import { spawn } from 'node:child_process'
const SURFACES = new Map([
['tui', ['--import', 'tsx', 'apps/cli/src/bin.ts', '--config', 'examples/cordis-agent/cordis.yml']],
// `dsh web` does not accept alternate configs yet. The TUI config escape
// hatch still boots this browser-only tree; the config owns port 3081.
['web', ['--import', 'tsx', 'apps/cli/src/bin.ts', '--config', 'examples/web-cordis/cordis.yml']],
// The browser surface with the cordis toolset layered on: `dsh web --config`
// applies this overlay over the shipped web composition; it owns port 3081.
['web', ['--import', 'tsx', 'apps/cli/src/bin.ts', 'web', '--config', 'examples/web-cordis/cordis.yml']],
['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/cordis-tools.cordis.yml']],
])
const surface = process.argv[2] ?? 'tui'
const surface = process.argv[2] ?? 'web'
const args = SURFACES.get(surface)
if (args === undefined || process.argv.length > 3) {
console.error('usage: pnpm run demo:cordis [tui|web|acp]')
console.error('usage: pnpm run demo:cordis [web|acp]')
process.exit(2)
}

View File

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

View File

@@ -1,282 +1,9 @@
/**
* Generate the model-facing Cordis API data module from the same event/service
* collector as the documentation catalogs. It emits original declaration
* JSDoc, first-sentence summaries, raw signatures, transitive public type
* shapes, and inherited context entries, without source pointers; output is
* deterministic and `--check` verifies it.
* Compatibility entry point for the unified Typert-backed Cordis catalog
* projection. The generated API module retains this command in its banner,
* while all extraction, validation, and rendering live in one implementation.
*/
import { globSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import ts from 'typescript'
import { collectEvents, collectServices, INHERITED_SERVICES } from './gen-cordis-catalog.ts'
import { main } from './gen-cordis-catalog.ts'
const root = resolve(import.meta.dirname, '..')
const OUT = 'packages/cordis/tool-cordis/src/api-catalog.ts'
/** Declarations longer than this render as a truncated stub — a shape the model cannot skim teaches nothing. */
const MAX_DECL_CHARS = 1500
/** The first sentence of a (possibly multi-line) JSDoc prose block. */
function firstSentence(doc: string): string {
const line = doc.split('\n', 1)[0] ?? ''
const match = /^(.*?[.!?])(?:\s|$)/.exec(line)
return (match?.[1] ?? line).trim()
}
/** Render a string as a single-quoted, lint-clean TS literal. */
function quote(value: string): string {
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, '\\\'').replace(/\n/g, '\\n')}'`
}
/**
* Reduce an exported class to its type shape: drop method/constructor bodies
* and property initializers so the catalog serves member signatures, not
* implementation. An abstract class (e.g. `Agent`) is a public type consumers
* program against, so it belongs in the type closure alongside interfaces.
*/
function classShape(node: ts.ClassDeclaration): ts.ClassDeclaration {
const isNonPublic = (member: ts.ClassElement): boolean =>
(ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined)?.some(m =>
m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword) ?? false
const members = node.members.flatMap((member): ts.ClassElement[] => {
// A model-facing type shape carries only the public surface — drop private,
// protected, and #private members, and strip every kept member's body.
if (isNonPublic(member) || (ts.isPropertyDeclaration(member) && ts.isPrivateIdentifier(member.name))) return []
if (ts.isMethodDeclaration(member)) {
return [ts.factory.updateMethodDeclaration(
member, member.modifiers, member.asteriskToken, member.name, member.questionToken,
member.typeParameters, member.parameters, member.type, undefined)]
}
if (ts.isConstructorDeclaration(member)) {
return [ts.factory.updateConstructorDeclaration(member, member.modifiers, member.parameters, undefined)]
}
if (ts.isGetAccessorDeclaration(member)) {
return [ts.factory.updateGetAccessorDeclaration(
member, member.modifiers, member.name, member.parameters, member.type, undefined)]
}
if (ts.isSetAccessorDeclaration(member)) {
return [ts.factory.updateSetAccessorDeclaration(
member, member.modifiers, member.name, member.parameters, undefined)]
}
if (ts.isPropertyDeclaration(member)) {
return [ts.factory.updatePropertyDeclaration(
member, member.modifiers, member.name, member.questionToken ?? member.exclamationToken, member.type, undefined)]
}
return [member]
})
return ts.factory.updateClassDeclaration(
node, node.modifiers, node.name, node.typeParameters, node.heritageClauses, members)
}
/**
* Collect exported interface, type-alias, and (body-stripped) class shapes;
* omit names declared in multiple packages rather than risk serving the wrong
* package's shape.
*/
function collectTypeDecls(scanRoot: string = root): Map<string, string> {
const printer = ts.createPrinter({ removeComments: true })
const decls = new Map<string, string>()
const ambiguous = new Set<string>()
for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).sort()) {
const abs = resolve(scanRoot, rel)
const sf = ts.createSourceFile(abs, readFileSync(abs, 'utf8'), ts.ScriptTarget.Latest, true)
for (const stmt of sf.statements) {
const named = ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt) || ts.isClassDeclaration(stmt)
if (!named || stmt.name === undefined) continue
if (!(stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)) continue
const name = stmt.name.text
if (decls.has(name)) {
ambiguous.add(name)
continue
}
const emit = ts.isClassDeclaration(stmt) ? classShape(stmt) : stmt
const printed = printer.printNode(ts.EmitHint.Unspecified, emit, sf).replace(/\r/g, '')
decls.set(name, printed.length > MAX_DECL_CHARS
? `${printed.slice(0, MAX_DECL_CHARS)} /* …truncated — full shape in source */`
: printed)
}
}
for (const name of ambiguous) decls.delete(name)
return decls
}
/** Resolve and sort the word-bounded transitive type closure referenced by seed text. */
function referencedTypes(seeds: string[], decls: Map<string, string>): { name: string; declaration: string }[] {
const included = new Map<string, string>()
let frontier = seeds
while (frontier.length > 0) {
const next: string[] = []
for (const [name, declaration] of decls) {
if (included.has(name)) continue
const pattern = new RegExp(`\\b${name}\\b`)
if (frontier.some(text => pattern.test(text))) {
included.set(name, declaration)
next.push(declaration)
}
}
frontier = next
}
return [...included].map(([name, declaration]) => ({ name, declaration })).sort((a, b) => a.name.localeCompare(b.name))
}
/** Render the whole generated module (pure, deterministic given sorted collector output). */
function render(): string {
const services = collectServices()
const events = collectEvents().sort((a, b) => a.name.localeCompare(b.name))
const types = referencedTypes(services.flatMap(service => service.methods.map(method => method.signature)), collectTypeDecls())
const lines: string[] = [
'/**',
' * Generated by scripts/gen-cordis-api.ts — do not edit by hand; run',
' * `pnpm run gen-cordis-api` to regenerate (freshness-gated by',
' * `pnpm run verify-cordis-api` in doc-sync).',
' *',
' * The machine-readable cordis API catalog `cordis_inspect` serves to the',
' * model: harness services (summary + public method signatures/JSDoc),',
' * harness events (mode + signature/JSDoc), and the inherited `ctx` surface. Produced by',
' * the same AST walk as docs/cordis-catalog, so this data and the rendered',
' * docs cannot diverge.',
' *',
' * @module @deepseek-ai/dsh-tool-cordis/api-catalog',
' */',
'',
'/** One public service method and its source-owned contract. */',
'export interface ServiceApiMethod {',
' /** Public method signature with its body stripped. */',
' signature: string',
' /** Original method JSDoc, with only container indentation removed. */',
' jsDoc: string',
'}',
'',
'/** One harness `ctx.<key>` service: its one-line summary and public methods. */',
'export interface ServiceApiEntry {',
' /** The `ctx.<key>` name, e.g. `tools`. */',
' key: string',
' /** First sentence of the service class JSDoc. */',
' summary: string',
' /** Public methods, bodies stripped, in source order. */',
' methods: readonly ServiceApiMethod[]',
'}',
'',
'/** One harness event: its dispatch mode, exact signature, and one-line summary. */',
'export interface EventApiEntry {',
' /** The scoped event name, e.g. `agent/status`. */',
' name: string',
' /** The dispatch mode from the declaration\'s `@mode` tag. */',
' mode: string',
' /** The exact listener signature, whitespace-normalized. */',
' signature: string',
' /** Original event JSDoc, with only container indentation removed. */',
' jsDoc: string',
' /** First sentence of the event JSDoc. */',
' summary: string',
'}',
'',
'/** One inherited (cordis core + loader/hmr/timer) `ctx` member group with its summary. */',
'export interface InheritedApiEntry {',
' /** The `ctx` member name(s), e.g. `ctx.on / ctx.once`. */',
' name: string',
' /** One-line summary of what the member does. */',
' summary: string',
'}',
'',
'/** One named type shape the service signatures reference. */',
'export interface TypeApiEntry {',
' /** The exported type/interface name, e.g. `BashRunResult`. */',
' name: string',
' /** The full declaration text, comments stripped. */',
' declaration: string',
'}',
'',
'/** Every harness `ctx.<key>` service, sorted by key. */',
'export const SERVICE_API: readonly ServiceApiEntry[] = [',
]
for (const service of services) {
lines.push(' {')
lines.push(` key: ${quote(service.key)},`)
lines.push(` summary: ${quote(firstSentence(service.doc))},`)
if (service.methods.length === 0) {
lines.push(' methods: [],')
} else {
lines.push(' methods: [')
for (const method of service.methods) {
lines.push(' {')
lines.push(` signature: ${quote(method.signature)},`)
lines.push(` jsDoc: ${quote(method.jsDoc)},`)
lines.push(' },')
}
lines.push(' ],')
}
lines.push(' },')
}
lines.push(
']',
'',
'/** Every harness event, sorted by name. */',
'export const EVENT_API: readonly EventApiEntry[] = [',
)
for (const event of events) {
lines.push(' {')
lines.push(` name: ${quote(event.name)},`)
lines.push(` mode: ${quote(event.mode)},`)
lines.push(` signature: ${quote(event.signature)},`)
lines.push(` jsDoc: ${quote(event.jsDoc)},`)
lines.push(` summary: ${quote(firstSentence(event.doc))},`)
lines.push(' },')
}
lines.push(
']',
'',
'/** Shapes of every exported type the SERVICE_API signatures reference (transitively), sorted by name. */',
'export const TYPE_API: readonly TypeApiEntry[] = [',
)
for (const type of types) {
lines.push(' {')
lines.push(` name: ${quote(type.name)},`)
lines.push(` declaration: ${quote(type.declaration)},`)
lines.push(' },')
}
lines.push(
']',
'',
'/** The inherited `ctx` surface (cordis core + loader/hmr/timer), in curated order. */',
'export const INHERITED_CTX_API: readonly InheritedApiEntry[] = [',
)
for (const inherited of INHERITED_SERVICES) {
lines.push(` { name: ${quote(inherited.name)}, summary: ${quote(inherited.summary)} },`)
}
lines.push(']', '')
return lines.join('\n')
}
/** CLI entry: default writes the artifact, `--check` fails if the committed
* copy is stale. Guarded behind an entry-point check so importing this module
* for tests neither regenerates the committed file nor calls process.exit. */
function main(): void {
const content = render()
if (process.argv.includes('--check')) {
let committed: string | null = null
try {
committed = readFileSync(resolve(root, OUT), 'utf8')
} catch {
// Only ENOENT (not yet generated) is expected; a present-but-unreadable
// file is not a state this repo produces. Either way the remedy is the
// same — regenerate — so treat a read failure as "stale".
committed = null
}
if (committed === content) {
console.log(`gen-cordis-api: ${OUT} is up to date.`)
process.exit(0)
}
console.error(`gen-cordis-api: ${OUT} is stale. Run \`pnpm run gen-cordis-api\` and commit ${OUT}.`)
process.exit(1)
}
writeFileSync(resolve(root, OUT), content)
console.log(`gen-cordis-api: wrote ${OUT}.`)
}
// Run only when invoked as a script, not when imported by a test.
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
main()
}
main()

View File

@@ -1,32 +1,25 @@
/**
* Generate the Cordis event and service catalogs from static declarations.
* The walk enforces event modes, JSDoc parameter/return completeness, and
* signature type-link coverage; inherited Cordis services come from the
* curated table below. `--check` verifies both committed artifacts.
* Generate committed Cordis artifacts from the Typert catalog projector and
* the independent vendored-core projector.
*/
import { globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve, sep } from 'node:path'
import ts from 'typescript'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import {
projectCordisCatalog,
renderEvents,
renderServices,
} from '@deepseek-ai/dsh-typert-generator'
import type { CordisCatalogPolicy } from '@deepseek-ai/dsh-typert-generator'
import { renderCordisCoreApiPages } from './cordis-core-api.ts'
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts'
import { cordisModuleBody, eventMembers, serviceClasses } from './cordis-walk.ts'
const root = resolve(import.meta.dirname, '..')
const OUT_EVENTS = 'docs/cordis-catalog/events.md'
const OUT_SERVICES = 'docs/cordis-catalog/services.md'
const OUT_RUNTIME_API = 'packages/cordis/tool-cordis/src/api-catalog.ts'
/** The fenced-block info string for generated signature blocks (skipped by
* doc-typecheck, since a bare signature fragment is not standalone-compilable). */
const FENCE = 'ts cordis-catalog'
/**
* One primary core-data-structures page per project type used by a generated
* signature. This stays curated because union names intentionally do not
* reuse the type-equivalence manifest's map-symbol entries and some symbols
* appear on more than one page.
*/
export const LINK_MAP: Record<string, string> = {
/** One primary core-data-structures page per project type used by a generated signature. */
export const LINK_MAP: Readonly<Record<string, string>> = {
Agent: 'core.md',
AgentCancelCause: 'core.md',
AgentOptions: 'core.md',
@@ -35,11 +28,12 @@ export const LINK_MAP: Record<string, string> = {
ContinuationDecision: 'core.md',
ContinuationStop: 'core.md',
GenerateOptions: 'core.md',
InboxItem: 'core.md',
InboxPlacement: 'core.md',
AgentMessage: 'core.md',
AgentMessageId: 'core.md',
MessageId: 'core.md',
HookContext: 'core.md',
SettleReason: 'core.md',
AdapterRegistrationHandle: 'core.md',
LlmCallConfig: 'core.md',
LlmModelContext: 'core.md',
LlmModelReasoningInfo: 'core.md',
@@ -47,12 +41,14 @@ export const LINK_MAP: Record<string, string> = {
LlmFailure: 'llm-streaming.md',
LlmModelInfo: 'core.md',
LlmProviderInfo: 'core.md',
LlmConfigurableProvider: 'core.md',
ResolvedRetryPolicy: 'llm-streaming.md',
Message: 'core.md',
MessageSource: 'core.md',
UserMessage: 'session.md',
PromptDecision: 'core.md',
RequestErrorAction: 'core.md',
RequestError: 'core.md',
RequestErrorAction: 'core.md',
PreparedReferencedMessage: 'session-reference.md',
SessionReferenceCandidate: 'session-reference.md',
SessionReferenceInput: 'session-reference.md',
@@ -105,6 +101,7 @@ export const LINK_MAP: Record<string, string> = {
PreparedLlmCall: 'llm-streaming.md',
LlmService: 'llm-streaming.md',
StreamChunk: 'llm-streaming.md',
SkillProviderControl: 'skills.md',
CreateSessionOptions: 'persistence.md',
SessionHeader: 'persistence.md',
SessionLocation: 'persistence.md',
@@ -154,9 +151,11 @@ export const LINK_MAP: Record<string, string> = {
SessionTitleObservationResult: 'session-query.md',
SessionTitleProvider: 'session-title.md',
SessionTitleSnapshot: 'session-title.md',
SkillCatalogSnapshot: 'skills.md',
SkillDefinition: 'skills.md',
SkillLookupOptions: 'skills.md',
SkillProvider: 'skills.md',
SkillProviderObservation: 'skills.md',
SkillRegistration: 'skills.md',
SkillSummary: 'skills.md',
SaveTextSpill: 'spill.md',
@@ -166,6 +165,7 @@ export const LINK_MAP: Record<string, string> = {
SubagentService: 'subagent.md',
SubagentStartRequest: 'subagent.md',
AssembleContext: 'system-prompt.md',
PromptContext: 'system-prompt.md',
PromptSection: 'system-prompt.md',
SystemPrompt: 'system-prompt.md',
ToolProviderResult: 'system-prompt.md',
@@ -189,6 +189,16 @@ export const LINK_MAP: Record<string, string> = {
ToolRegistry: 'tools.md',
ToolRestriction: 'tools.md',
ToolSchema: 'tools.md',
SettingsNamespace: 'settings.md',
SettingsRegisterOptions: 'settings.md',
SettingsScope: 'settings.md',
SettingsDescriptor: 'settings.md',
SettingsPathOp: 'settings.md',
SettingsDescribeOptions: 'settings.md',
SettingsUpdateSource: 'settings.md',
CredentialRef: 'credentials.md',
CredentialInfo: 'credentials.md',
ResolvedCredential: 'credentials.md',
AskUserQuestionAnswer: 'user-interaction.md',
AskUserQuestionRequest: 'user-interaction.md',
UserInteractionProvider: 'user-interaction.md',
@@ -203,20 +213,23 @@ export const LINK_MAP: Record<string, string> = {
WorkflowStartRequest: 'workflow.md',
}
/** TypeScript lib and pinned framework types that have no repository-owned data page. */
const FOUNDATION_TYPE_NAMES = new Set([
/** TypeScript lib and pinned framework types with no repository-owned data page. */
export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
'AbortSignal',
'AsyncIterable',
'Context',
'Error',
'Map',
'Partial',
'Pick',
'Promise',
'Readonly',
])
/** Project types deliberately documented outside the core-data catalog. */
const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
AgentFactory: 'agent creation seam is owned by packages/core/agent/README.md',
z: 'schemastery schema constructor is owned by vendor/schemastery (vendored upstream)',
BeginCommandRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
InsertReferenceRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
ConsumeTokenRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
@@ -225,6 +238,8 @@ const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
BashEnvContributor: 'service-local extension type is owned by packages/bash/tool-bash/src/index.ts',
BashEnvVariableInfo: 'service-local metadata type is owned by packages/bash/tool-bash/src/index.ts',
CompactAgentContext: 'compaction service input is owned by packages/compact/compact/src/index.ts',
ManualCompactAgentContext: 'manual compaction service input is owned by packages/compact/compact/src/index.ts',
DirectoryPickerCapability: 'picker interaction contract is owned by packages/host/directory-picker/README.md',
CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md',
Domain: 'domain interface is owned by packages/storage/storage-domain/README.md',
DomainChanged: 'event-local snapshot is owned by packages/storage/storage-domain/src/events.ts',
@@ -233,6 +248,20 @@ const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
DomainSpec: 'domain declaration contract is owned by packages/storage/storage-domain/README.md',
StorageBackend: 'backend contract is owned by packages/storage/storage/src/backend.ts',
StorageForms: 'merge-extensible form map is owned by packages/storage/storage/src/index.ts',
ProjectionDefinition: 'projection unit contract is owned by packages/session-projection/session-projection/README.md',
SessionProjectionMap: 'merge-extensible projection key map is owned by packages/session-projection/session-projection/src/types.ts',
ProjectionChangeListener: 'change-feed listener contract is owned by packages/session-projection/session-projection/src/index.ts',
ProjectionSnapshot: 'watermark snapshot shape is owned by packages/session-projection/session-projection/src/index.ts',
ProjectionCheckpoint: 'persisted checkpoint row map is owned by packages/session-projection/session-projection/src/index.ts',
CommandExecution: 'executor return contract is owned by packages/ui/commands/src/index.ts',
TypertContribution: 'registry contribution contract is owned by packages/typert/registry/README.md',
TypertFace: 'registry face identity is owned by packages/typert/registry/README.md',
TypertPackageFilter: 'registry package query filter is owned by packages/typert/registry/README.md',
TypertPackageRecord: 'registry package record is owned by packages/typert/registry/README.md',
TypertSchemaFilter: 'registry schema query filter is owned by packages/typert/registry/README.md',
TypertSchemaRecord: 'registry schema record is owned by packages/typert/registry/README.md',
'z.core.JSONSchema.BaseSchema': 'zod projection output is owned by the zod v4 API',
'z.core.ToJSONSchemaParams': 'zod projection parameters are owned by the zod v4 API',
InvariantInstaller: 'service-local contribution contract is owned by packages/support/invariants/README.md',
LocaleDict: 'service-local dictionary shape is owned by packages/client/i18n/src/index.ts',
WebBootGraph: 'web boot graph wire shape is owned by packages/client/modules/src/client/index.ts',
@@ -244,6 +273,8 @@ const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md',
PresetOption: 'deployment menu metadata is owned by packages/ui/permission/README.md',
PresetSpec: 'deployment preset composition is owned by packages/ui/permission/README.md',
KnobState: 'projection unit state shape is owned by packages/ui/permission/README.md',
PermissionSelect: 'permissions projection payload is owned by packages/ui/permission/src/types.ts',
PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md',
ResumeAgentOptions: 'agent resume contract is owned by packages/core/agent/README.md',
SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts',
@@ -257,401 +288,51 @@ const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
WorkspaceId: 'branded id is owned by packages/workspace/workspace/README.md',
}
/** Collect named references from parameter, generic-constraint/default, and return types. */
function signatureTypeNames(member: ts.MethodSignature | ts.MethodDeclaration, sf: ts.SourceFile): string[] {
const declared = new Set(member.typeParameters?.map(parameter => parameter.name.text) ?? [])
const referenced = new Set<string>()
const visit = (node: ts.Node): void => {
if (ts.isTypeReferenceNode(node)) referenced.add(node.typeName.getText(sf))
if (ts.isTypeQueryNode(node)) referenced.add(node.exprName.getText(sf))
ts.forEachChild(node, visit)
}
for (const parameter of member.typeParameters ?? []) {
if (parameter.constraint) visit(parameter.constraint)
if (parameter.default) visit(parameter.default)
}
for (const parameter of member.parameters) {
if (parameter.type) visit(parameter.type)
}
if (member.type) visit(member.type)
return [...referenced].filter(name => !declared.has(name)).sort()
/** Repository data policy consumed by the Cordis catalog projector. */
export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = {
linkedTypePages: LINK_MAP,
foundationTypeNames: FOUNDATION_TYPE_NAMES,
typeLinkExemptions: TYPE_LINK_EXEMPTIONS,
inheritedEvents: [
{ name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:328' },
{ name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:330' },
{ name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:332' },
{ name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:334' },
{ name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:336' },
{ name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:338' },
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:340' },
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:342' },
{ name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' },
{ name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' },
{ name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' },
{ name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:24' },
{ name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:25' },
{ name: 'loader/partial-dispose', summary: 'An entry is being partially disposed on reload.', source: 'vendor/loader/src/index.ts:26' },
{ name: 'loader/patch-context', summary: 'A context is being patched during a reload.', source: 'vendor/loader/src/index.ts:27' },
],
inheritedServices: [
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:34' },
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:34' },
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:164' },
{ name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.', source: 'vendor/cordis/src/fiber.ts:9' },
{ name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.', source: 'vendor/cordis/src/reflect.ts:7' },
{ name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).', source: 'vendor/cordis/src/context.ts:42' },
{ name: 'ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger', summary: 'Ambient handles onto the running context graph.', source: 'vendor/cordis/src/context.ts:16' },
{ name: 'ctx.timer (+ interval / timeout / throttle / debounce / setTimeout / setInterval)', summary: 'Disposable timer helpers. The `timer` key is provided at runtime; the six helpers are mixed onto ctx directly (declared via Pick).', source: 'vendor/timer/src/index.ts:4' },
{ name: 'ctx.loader', summary: 'The config Loader that booted the app (present under the loader).', source: 'vendor/loader/src/index.ts:30' },
{ name: 'ctx.hmr', summary: 'The hot-module-reload watcher (present under the hmr plugin).', source: 'vendor/hmr/src/index.ts:15' },
],
}
/** Append fail-closed signature type-link violations with actionable ownership choices. */
function checkTypeLinks(
where: string,
member: ts.MethodSignature | ts.MethodDeclaration,
sf: ts.SourceFile,
violations: string[],
): void {
for (const name of signatureTypeNames(member, sf)) {
if (Object.hasOwn(LINK_MAP, name)
|| FOUNDATION_TYPE_NAMES.has(name)
|| Object.hasOwn(TYPE_LINK_EXEMPTIONS, name)) continue
violations.push(
`${where} references unclassified type '${name}'. Add it to LINK_MAP with its core-data-structures page, `
+ 'to FOUNDATION_TYPE_NAMES if TypeScript or Cordis owns it, or to TYPE_LINK_EXEMPTIONS with '
+ 'the non-catalog documentation owner.',
)
}
}
/** Throw one aggregated diagnostic for every unclassified signature type. */
function reportTypeLinkViolations(gate: string, violations: string[]): void {
if (violations.length === 0) return
throw new Error(
`${gate}: ${violations.length} signature type-link coverage violation(s):\n`
+ violations.map(violation => ` ${violation}`).join('\n'),
)
}
/** One harness event, extracted from an `interface Events` block. */
interface EventEntry {
/** Scoped name, e.g. `agent/request`. */
name: string
/** The scope prefix, e.g. `agent` (everything before the first `/`). */
scope: string
/** Full signature text (the method-signature member, JSDoc stripped). */
signature: string
/** Original declaration JSDoc, dedented from its containing interface. */
jsDoc: string
/** Dispatch mode from the `@mode` tag. */
mode: Mode
/** Description prose (JSDoc minus the `@mode` tag), one line per paragraph. */
doc: string
/** Source pointer `packages/…/file.ts:line` of the declaration. */
source: string
}
/** One public service method and the source contract attached to it. */
interface ServiceMethodEntry {
/** Public method signature (body stripped). */
signature: string
/** Original method JSDoc, dedented from its containing class. */
jsDoc: string
}
/** One harness service, extracted from an `interface Context` block. */
interface ServiceEntry {
/** The `ctx.<key>` name, e.g. `llm`. */
key: string
/** The service class/interface name, e.g. `LlmService`. */
type: string
/** Whether the service class is abstract (a seam interface). */
abstract: boolean
/** Class-level JSDoc prose, one line per paragraph. */
doc: string
/** Public methods (bodies stripped), in source order. */
methods: ServiceMethodEntry[]
/** Source pointer of the class declaration. */
source: string
}
/** A terse inherited-tier entry (pinned vendor surface). */
interface InheritedEntry {
name: string
summary: string
/** Source pointer `vendor/…:line`. */
source: string
}
// cordisModuleBody / eventMembers / serviceClasses live in cordis-walk.ts.
/** The signature text of a method-signature member (everything but a body). */
function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.SourceFile): string {
const full = member.getText(sf)
const body = (member as { body?: ts.Node }).body
const sig = body ? full.slice(0, full.length - body.getText(sf).length) : full
return sig.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim()
}
/**
* Copy a node's original JSDoc while removing only the indentation imposed by
* its containing interface or class.
/** CLI entry: default writes every artifact; `--check` reports stale files.
* @returns nothing; writes files or reports freshness through the process.
*/
function jsDocText(text: string, sf: ts.SourceFile, node: ts.Node): string {
const raw = rawJsDoc(text, node)
if (!raw) return ''
const start = text.lastIndexOf(raw, node.getStart(sf))
const { line } = sf.getLineAndCharacterOfPosition(start)
const lineStart = sf.getPositionOfLineAndCharacter(line, 0)
const indent = text.slice(lineStart, start)
return raw.split('\n')
.map((lineText, index) => index > 0 && lineText.startsWith(indent) ? lineText.slice(indent.length) : lineText)
.join('\n')
}
/** Walk every harness `interface Events` block and extract its events, hard-
* erroring (aggregated) on any JSDoc-completeness violation: a missing/
* contradicted `@mode`, missing description prose, or an undocumented payload
* parameter. `scanRoot` defaults to the repo root; tests pass a fixture dir. */
export function collectEvents(scanRoot: string = root): EventEntry[] {
const entries: EventEntry[] = []
const violations: string[] = []
const typeLinkViolations: string[] = []
for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
const abs = resolve(scanRoot, rel)
const text = readFileSync(abs, 'utf8')
if (!text.includes('interface Events')) continue
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
const body = cordisModuleBody(sf)
if (!body) continue
for (const { name, member } of eventMembers(body, sf)) {
const signature = memberSignature(member, sf)
const raw = rawJsDoc(text, member)
const { doc, mode } = parseJsDoc(raw)
const src = pointer(rel, sf, member)
const where = `event '${name}' (${src})`
checkTypeLinks(where, member, sf, typeLinkViolations)
if (!mode) {
violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial|bail' to its JSDoc (see AGENTS.md).`)
}
// Conclusive structural check: a trailing `next: () => …` parameter is a
// waterfall. (emit vs parallel vs serial is not structurally
// distinguishable, so it is trusted from the tag.)
const last = member.parameters.at(-1)
const hasNext = !!last && last.name.getText(sf) === 'next'
if (mode && hasNext && mode !== 'waterfall') {
violations.push(`${where} has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`)
}
if (mode && !hasNext && mode === 'waterfall') {
violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
}
if (!doc) violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`)
// Payload parameters need a non-empty @param. The `this` receiver is not
// payload, and a waterfall's trailing `next` is covered by its mode.
const { params } = parseTags(raw)
checkParams(where, 'event', member.parameters, params, sf,
p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations)
if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, jsDoc: jsDocText(text, sf, member), mode, doc, source: src })
}
}
reportViolations('gen-cordis-catalog', violations)
reportTypeLinkViolations('gen-cordis-catalog', typeLinkViolations)
return entries
}
/** Walk every harness `interface Context` block + its service class, hard-
* erroring (aggregated) on any JSDoc-completeness violation: a class or public
* method without JSDoc prose, an undocumented parameter, a stale `@param`, a
* missing `@returns` on a non-void method, or an inferred (unannotated) return
* type the pure-AST walk cannot classify.
* `scanRoot` defaults to the repo root; tests pass a fixture dir. */
export function collectServices(scanRoot: string = root): ServiceEntry[] {
const entries: ServiceEntry[] = []
const violations: string[] = []
const typeLinkViolations: string[] = []
for (const rel of globSync('packages/*/*/src/index.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
const abs = resolve(scanRoot, rel)
const text = readFileSync(abs, 'utf8')
if (!text.includes('interface Context')) continue
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
const body = cordisModuleBody(sf)
if (!body) continue
// Resolve each ctx key to its service class (shared walk) and emit an entry.
for (const { key, type, cls, abstract, doc: clsDoc } of serviceClasses(body, sf, rel, violations)) {
const methods: ServiceMethodEntry[] = []
for (const member of cls.members) {
if (!ts.isMethodDeclaration(member)) continue
// Only instance methods callable through `ctx.<key>` are surface;
// private, protected, and static methods are not.
const nonPublic = member.modifiers?.some(m =>
m.kind === ts.SyntaxKind.PrivateKeyword
|| m.kind === ts.SyntaxKind.ProtectedKeyword
|| m.kind === ts.SyntaxKind.StaticKeyword)
|| ts.isPrivateIdentifier(member.name)
if (nonPublic) continue
const memberName = member.name.getText(sf)
if (memberName.startsWith('[')) continue // computed/symbol members
const where = `service method ctx.${key}.${memberName} (${pointer(rel, sf, member)})`
checkTypeLinks(where, member, sf, typeLinkViolations)
const raw = rawJsDoc(text, member)
methods.push({ signature: memberSignature(member, sf), jsDoc: jsDocText(text, sf, member) })
if (!raw) { violations.push(`${where} has no JSDoc.`); continue }
if (!parseJsDoc(raw).doc) violations.push(`${where} has no description prose above its block tags.`)
const { params, returns } = parseTags(raw)
// Every parameter needs a non-empty @param (`this` receiver exempt),
// and a non-void ANNOTATED result needs a non-empty @returns — the
// shared checkers carry the exact contract.
checkParams(where, 'service', member.parameters, params, sf,
p => ts.isIdentifier(p.name) && p.name.text === 'this', violations)
checkReturns(where, member.type, returns, sf, violations)
}
entries.push({
key,
type,
abstract,
doc: clsDoc,
methods,
source: pointer(rel, sf, cls),
})
}
}
reportViolations('gen-cordis-catalog', violations)
reportTypeLinkViolations('gen-cordis-catalog', typeLinkViolations)
return entries.sort((a, b) => a.key.localeCompare(b.key))
}
/**
* The inherited tier — cordis core + loader/hmr/timer. Curated, terse, and
* hand-summarized because (a) it is pinned vendor source that changes only on a
* deliberate vendor sync, (b) the cordis-core `Context` mixes true ctx members
* with non-service fields (`root`, `baseUrl`, `logger`) that a blind walk would
* wrongly surface as services, and (c) the internal/* events carry no JSDoc to
* render. Source pointers are verified against vendor by `verify-md-links`'
* sibling check is N/A; keep them current on a vendor bump.
*/
const INHERITED_EVENTS: InheritedEntry[] = [
{ name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:328' },
{ name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:330' },
{ name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:332' },
{ name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:334' },
{ name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:336' },
{ name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:338' },
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:340' },
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:342' },
{ name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' },
{ name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' },
{ name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' },
{ name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:24' },
{ name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:25' },
{ name: 'loader/partial-dispose', summary: 'An entry is being partially disposed on reload.', source: 'vendor/loader/src/index.ts:26' },
{ name: 'loader/patch-context', summary: 'A context is being patched during a reload.', source: 'vendor/loader/src/index.ts:27' },
]
export const INHERITED_SERVICES: InheritedEntry[] = [
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:34' },
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:34' },
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:164' },
{ name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.', source: 'vendor/cordis/src/fiber.ts:9' },
{ name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.', source: 'vendor/cordis/src/reflect.ts:7' },
{ name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).', source: 'vendor/cordis/src/context.ts:42' },
{ name: 'ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger', summary: 'Ambient handles onto the running context graph.', source: 'vendor/cordis/src/context.ts:16' },
{ name: 'ctx.timer (+ interval / timeout / throttle / debounce / setTimeout / setInterval)', summary: 'Disposable timer helpers. The `timer` key is provided at runtime; the six helpers are mixed onto ctx directly (declared via Pick).', source: 'vendor/timer/src/index.ts:4' },
{ name: 'ctx.loader', summary: 'The config Loader that booted the app (present under the loader).', source: 'vendor/loader/src/index.ts:30' },
{ name: 'ctx.hmr', summary: 'The hot-module-reload watcher (present under the hmr plugin).', source: 'vendor/hmr/src/index.ts:15' },
]
/** Render the cross-link "Types:" line for a signature, or '' if none apply. */
function typeLinks(signature: string): string {
const seen = new Set<string>()
for (const name of Object.keys(LINK_MAP)) {
if (new RegExp(`\\b${name}\\b`).test(signature)) seen.add(name)
}
if (seen.size === 0) return ''
const links = [...seen].sort().map(n => `[${n}](../core-data-structures/${LINK_MAP[n]})`)
return `Types: ${links.join(' · ')}`
}
/** Render one harness event entry. */
function renderEvent(e: EventEntry): string[] {
const out = [`### \`${e.name}\`${e.mode}`, '']
if (e.doc) out.push(e.doc, '')
out.push('```' + FENCE, e.jsDoc, e.signature, '```', '')
const links = typeLinks(e.signature)
if (links) out.push(links, '')
out.push(`Source: [\`${e.source}\`](../../${e.source.split(':')[0]})`, '')
return out
}
/** Render one harness service entry. */
function renderService(s: ServiceEntry): string[] {
const kind = s.abstract ? ' (abstract seam)' : ''
const out = [`## \`ctx.${s.key}\`\`${s.type}\`${kind}`, '']
if (s.doc) out.push(s.doc, '')
if (s.methods.length) {
const declarations = s.methods.flatMap((method, index) => [
...(index > 0 ? [''] : []),
method.jsDoc,
method.signature,
])
out.push('```' + FENCE, ...declarations, '```', '')
const links = typeLinks(s.methods.map(method => method.signature).join('\n'))
if (links) out.push(links, '')
}
out.push(`Source: [\`${s.source}\`](../../${s.source.split(':')[0]})`, '')
return out
}
/** The shared generated-file banner comment. */
const BANNER = [
'<!-- Generated by scripts/gen-cordis-catalog.ts — do not edit by hand.',
' Run `pnpm run gen-cordis-catalog` to regenerate. -->',
'',
]
/** The shared GENERATED + freshness-gate + fence notice paragraph. */
const GATE_NOTICE = 'This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence and include the original source JSDoc immediately before each event or service method. doc-typecheck skips these bare declaration fragments; type names in a signature link to the page that documents them.'
/** Render the events catalog (pure, deterministic given sorted inputs). */
export function renderEvents(events: EventEntry[]): string {
const lines: string[] = [
...BANNER,
'# Cordis Events Catalog',
'',
'Every cordis event a plugin can listen to: exact signature, dispatch mode, and original declaration JSDoc. This is one axis of the **wiring** reference a plugin author works against — the callable `ctx.<key>` surface is the sibling [services catalog](services.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around.',
'',
GATE_NOTICE,
'',
'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely. The event-dispatch methods themselves are generated in the [Cordis core Events API](core/events.md).',
'',
'Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`), **bail** (synchronous in-order dispatch until one listener returns a bail value; the scoped input-mutation events use it for an applied/not-applied answer).',
'',
]
const scopes = [...new Set(events.map(e => e.scope))].sort()
for (const scope of scopes) {
lines.push(`## \`${scope}/*\``, '')
for (const e of events.filter(x => x.scope === scope).sort((a, b) => a.name.localeCompare(b.name))) {
lines.push(...renderEvent(e))
}
}
lines.push(
'## Inherited events (cordis core + loader/hmr/timer)',
'',
'The framework events every plugin also sees, beyond the harness vocabulary above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of the event bus, without elevating framework internals to the harness tier\'s prominence.',
'',
)
for (const e of INHERITED_EVENTS) {
lines.push(`- \`${e.name}\`${e.summary} ([\`${e.source}\`](../../${e.source.split(':')[0]}))`)
}
lines.push('')
return lines.join('\n')
}
/** Render the services catalog (pure, deterministic given sorted inputs). */
export function renderServices(services: ServiceEntry[]): string {
const lines: string[] = [
...BANNER,
'# Cordis Services Catalog',
'',
'Every `ctx.<key>` service a plugin can call: the exact public interface with original method JSDoc, plus the class JSDoc. This is one axis of the **wiring** reference a plugin author works against — the events a plugin listens to are the sibling [events catalog](events.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.',
'',
GATE_NOTICE,
'',
'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely. Detailed Context, Fiber, Registry, and Service APIs are generated in the [Cordis core API](core/context.md).',
'',
]
for (const s of services) lines.push(...renderService(s))
lines.push(
'## Inherited `ctx` members (cordis core + loader/hmr/timer)',
'',
'The framework `ctx` surface every plugin also sees, beyond the harness services above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of what `ctx` offers, without elevating framework internals to the harness tier\'s prominence.',
'',
)
for (const s of INHERITED_SERVICES) {
lines.push(`- \`${s.name}\`${s.summary} ([\`${s.source}\`](../../${s.source.split(':')[0]}))`)
}
lines.push('')
return lines.join('\n')
}
/** CLI entry: `--write` (default) writes both catalogs, `--check` fails if
* either is stale. Guarded behind an entry-point check so importing this module
* for tests neither regenerates the committed files nor calls process.exit. */
function main(): void {
export function main(): void {
const { projector, model } = projectCordisCatalog(root, CORDIS_CATALOG_POLICY)
const outputs: [string, string][] = [
[OUT_EVENTS, renderEvents(collectEvents())],
[OUT_SERVICES, renderServices(collectServices())],
[OUT_EVENTS, renderEvents([...model.events], CORDIS_CATALOG_POLICY)],
[OUT_SERVICES, renderServices([...model.services], CORDIS_CATALOG_POLICY)],
[OUT_RUNTIME_API, projector.renderRuntimeApi(model)],
...renderCordisCoreApiPages(),
]
if (process.argv.includes('--check')) {
@@ -661,9 +342,7 @@ function main(): void {
try {
committed = readFileSync(resolve(root, out), 'utf8')
} catch {
// Only ENOENT (not yet generated) is expected; a present-but-unreadable
// file is not a state this repo produces. Either way the remedy is the
// same — regenerate — so treat a read failure as "stale".
// Only ENOENT is expected; either read failure has the same remedy.
committed = null
}
if (committed !== content) stale.push(out)
@@ -684,7 +363,4 @@ function main(): void {
console.log(`gen-cordis-catalog: wrote ${outputs.length} generated file(s).`)
}
// Run only when invoked as a script, not when imported by a test.
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
main()
}
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) main()

View File

@@ -0,0 +1,98 @@
/**
* Tests for the event-relation collector's demand-driven call-site indexing:
* the single-file fast path and the global fallback must recover the same
* helper-parameter event names, including shapes that defeat the locality
* proof (alias escapes and global script files).
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { collectPackageSources, EventRelationCollector } from './gen-doc-graphs.ts'
import { TypeScriptProject } from './ts-project.ts'
const FIXTURE: Record<string, string> = {
'tsconfig.host.json': JSON.stringify({
compilerOptions: {
target: 'es2022',
module: 'esnext',
moduleResolution: 'bundler',
allowImportingTsExtensions: true,
noEmit: true,
skipLibCheck: true,
types: [],
},
include: ['vendor/**/*.ts', 'packages/**/*.ts'],
}),
'vendor/cordis/src/context.ts': 'export class Context { private brand!: void }\n',
'vendor/cordis/src/events.ts': [
'export class EventsService {',
' dispatch(type: string, args: unknown[]): unknown[] { return [type, args] }',
'}',
'',
].join('\n'),
'packages/core/agent/src/dispatch.ts':
'export interface AgentEventDispatch { emit(...args: unknown[]): void }\n',
// fireLocal: every same-file reference is a direct callee, so the locality
// proof holds and only this file is indexed. fireAliased: the exported
// const is a value-position reference, so the proof fails and the global
// fallback must find the cross-file call in pkgb.
'packages/fix/pkga/src/index.ts': [
"import { EventsService } from '../../../../vendor/cordis/src/events.ts'",
'declare const events: EventsService',
"function fireLocal(args: [string]): void { void events.dispatch('emit', args) }",
"fireLocal(['pkga/local-event'])",
"function fireAliased(args: [string]): void { void events.dispatch('emit', args) }",
'export const aliased = fireAliased',
'',
].join('\n'),
'packages/fix/pkgb/src/index.ts': [
"import { aliased } from '../../pkga/src/index.ts'",
"aliased(['pkgb/aliased-event'])",
'',
].join('\n'),
// Global script files (no import/export): scriptFire is program-visible, so
// the cross-file call in caller.ts leaves no same-file reference. Only the
// module-ness premise check routes this helper to the global index; without
// it the proof would pass and the event would silently drop.
'packages/fix/pkgc/src/globals.ts':
"declare var gEvents: import('../../../../vendor/cordis/src/events.ts').EventsService\n",
'packages/fix/pkgc/src/helper.ts':
"function scriptFire(args: [string]): void { void gEvents.dispatch('emit', args) }\n",
'packages/fix/pkgc/src/caller.ts': "scriptFire(['pkgc/script-event'])\n",
}
const root = mkdtempSync(join(tmpdir(), 'gen-doc-graphs-'))
for (const [rel, content] of Object.entries(FIXTURE)) {
mkdirSync(dirname(join(root, rel)), { recursive: true })
writeFileSync(join(root, rel), content)
}
const project = new TypeScriptProject(root)
const sources = collectPackageSources(project)
afterAll(() => {
rmSync(root, { recursive: true, force: true })
})
function dispatchersOf(pkgs: readonly string[], event: string): string[] {
const subset = sources.filter(source => pkgs.includes(source.pkg))
const relations = new EventRelationCollector(project, subset).collect()
return [...(relations.get(event)?.dispatchers.keys() ?? [])]
}
describe('event relation call-site indexing', () => {
it('recovers a proven-local helper through the single-file fast path', () => {
expect(dispatchersOf(['pkga', 'pkgb'], 'pkga/local-event')).toEqual(['pkga'])
})
it('recovers an alias-escaped helper through the global fallback', () => {
expect(dispatchersOf(['pkga', 'pkgb'], 'pkgb/aliased-event')).toEqual(['pkga'])
})
it('rejects the locality proof for global script files', () => {
// pkgc alone: the script helper is the first demand, so a wrongly passing
// proof would index helper.ts only and lose the caller.ts call site.
expect(dispatchersOf(['pkgc'], 'pkgc/script-event')).toEqual(['pkgc'])
})
})

View File

@@ -8,7 +8,9 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, relative, resolve } from 'node:path'
import ts from 'typescript'
import { collectEvents, collectServices } from './gen-cordis-catalog.ts'
import { projectCordisCatalog } from '@deepseek-ai/dsh-typert-generator'
import { CORDIS_CATALOG_POLICY } from './gen-cordis-catalog.ts'
import type { EventEntry, ServiceEntry } from '@deepseek-ai/dsh-typert-generator'
import {
collectPackageGraph,
escapeMermaidLabel as escLabel,
@@ -46,9 +48,13 @@ interface EventRelation {
listeners: Set<string>
}
interface PackageSource {
/** One scanned package source file and its owning package short name. */
export interface PackageSource {
/** Repository-relative path. */
rel: string
/** Package short name from the `packages/<group>/<pkg>/src` path. */
pkg: string
/** The bound program source file. */
sourceFile: ts.SourceFile
}
@@ -58,6 +64,7 @@ const GROUP_ORDER = [
'util',
'llm',
'core',
'typert',
'goal',
'process',
'bash',
@@ -128,6 +135,14 @@ const SERVICE_ROLES: ServiceRole[] = [
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',
title: 'Runtime type registry',
mode: 'core',
consumers: ['typert-loader'],
note: 'Plugins register live zod contributions directly or through dsh-typert-loader; runtime consumers query schemas and reflection metadata at their own edges.',
},
{
key: 'sessionPersistence',
pkg: 'session-persistence',
@@ -137,6 +152,24 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'session-query', 'session-query-sqlite'],
note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.',
},
{
key: 'settings',
pkg: 'settings',
title: 'User-settings seam',
mode: 'seam',
implementations: ['settings-local'],
consumers: ['llm-deepseek', 'llm-pi-ai', 'apiproxy'],
note: 'Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer.',
},
{
key: 'credentials',
pkg: 'credentials',
title: 'Credential seam',
mode: 'seam',
implementations: ['credentials-local'],
consumers: ['llm-deepseek', 'llm-pi-ai', 'apiproxy'],
note: 'Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage.',
},
{
key: 'telemetry',
pkg: 'session-telemetry',
@@ -236,6 +269,22 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['tui'],
note: 'Plugins register direct human commands; TUI consumes the effective per-agent catalog without sending invocations to the model.',
},
{
key: 'sessionProjections',
pkg: 'session-projection',
title: 'Session projection units',
mode: 'core',
consumers: ['tool-todo', 'session-title', 'host-apiproxy'],
note: 'Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values.',
},
{
key: 'sessionProjectionCache',
pkg: 'session-projection-cache',
title: 'Persisted projection cache',
mode: 'core',
consumers: ['host-apiproxy'],
note: 'Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs.',
},
{
key: 'tui',
pkg: 'tui',
@@ -408,6 +457,15 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['spill-policy'],
note: 'The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill.',
},
{
key: 'directoryPicker',
pkg: 'directory-picker',
title: 'Workspace-directory picking seam',
mode: 'seam',
implementations: ['directory-picker-native', 'directory-picker-browse'],
consumers: ['apiproxy'],
note: 'Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement).',
},
{
key: 'httpServer',
pkg: 'webserver',
@@ -482,8 +540,8 @@ function tableCell(value: string): string {
return value.replace(/\|/g, '\\|').replace(/\n/g, '<br>')
}
function assertServiceRolesComplete(): void {
const discovered = new Set(collectServices().map(service => service.key))
function assertServiceRolesComplete(services: readonly ServiceEntry[]): void {
const discovered = new Set(services.map(service => service.key))
const classified = new Set(SERVICE_ROLES.map(role => role.key))
const missing = [...discovered].filter(key => !classified.has(key)).sort()
const stale = [...classified].filter(key => !discovered.has(key)).sort()
@@ -495,8 +553,8 @@ function assertServiceRolesComplete(): void {
}
}
function renderCapabilitySeams(pkgs: Pkg[]): string {
assertServiceRolesComplete()
function renderCapabilitySeams(pkgs: Pkg[], services: readonly ServiceEntry[]): string {
assertServiceRolesComplete(services)
const pkgsByShort = new Map(pkgs.map(pkg => [pkg.short, pkg]))
const maintenance = 'hybrid: services are discovered from Cordis declarations; interface/implementation/consumer roles are classified in `scripts/gen-doc-graphs.ts` with a completeness guard'
const nodes = new Map<string, string>()
@@ -569,11 +627,11 @@ function stripYamlScalar(value: string): string {
const APP_EXAMPLES = [
{
id: 'tui',
rel: 'examples/tui-agent/composition.md',
rel: 'apps/cli/composition.md',
title: 'TUI Agent App Composition',
label: 'examples/tui-agent',
config: 'examples/tui-agent/cordis.yml',
summary: 'The TUI agent combines the real DeepSeek adapter, coding tools, compaction, subagents, and workflows with the full-screen terminal app package.',
label: 'apps/cli/config',
config: 'apps/cli/config/base.cordis.yml',
summary: 'The TUI surface combines the shared CLI base with its surface overlay and full-screen terminal package.',
},
{
id: 'headless',
@@ -583,14 +641,6 @@ const APP_EXAMPLES = [
config: 'examples/headless-agent/cordis.yml',
summary: 'The headless demo combines the real DeepSeek adapter and coding capabilities with the one-shot app package, format-pure stdout, and one fresh persisted top-level session.',
},
{
id: 'cordis',
rel: 'examples/cordis-agent/composition.md',
title: 'Cordis Agent App Composition',
label: 'examples/cordis-agent',
config: 'examples/cordis-agent/cordis.yml',
summary: 'The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its current-process runtime and mount or unmount in-memory temporary Plugins.',
},
{
id: 'acp',
rel: 'examples/acp-agent/composition.md',
@@ -655,13 +705,26 @@ function renderAppComposition(example: AppExample): string {
return lines.join('\n')
}
type CallSiteIndex = Map<ts.SignatureDeclaration | ts.JSDocSignature, ts.CallExpression[]>
/**
* The only method names visitSource classifies; receiver typing runs on these
* alone. Obligation: every method name matched by a branch inside visitSource
* must appear here — the prefilter drops non-members before any branch runs,
* so a branch for an unlisted name is silently dead.
*/
const EVENT_API_METHODS = new Set(['on', 'once', 'emit', 'parallel', 'serial', 'waterfall', 'dispatch'])
/** Collect event dispatch/listener relations from real cross-file receiver types. */
class EventRelationCollector {
export class EventRelationCollector {
private readonly relations = new Map<string, EventRelation>()
private readonly callSites = new Map<ts.SignatureDeclaration | ts.JSDocSignature, ts.CallExpression[]>()
private readonly fileCallSites = new Map<ts.SourceFile, CallSiteIndex>()
private readonly localCalleeProofs = new Map<ts.FunctionDeclaration, boolean>()
private globalCallSites: CallSiteIndex | null = null
private readonly contextType: ts.Type
private readonly agentDispatchType: ts.Type
private readonly eventsServiceType: ts.Type
private readonly packageSourceFiles: ReadonlySet<ts.SourceFile>
constructor(
private readonly project: TypeScriptProject,
@@ -670,7 +733,7 @@ class EventRelationCollector {
this.contextType = this.declaredType('vendor/cordis/src/context.ts', 'Context')
this.agentDispatchType = this.declaredType('packages/core/agent/src/dispatch.ts', 'AgentEventDispatch')
this.eventsServiceType = this.declaredType('vendor/cordis/src/events.ts', 'EventsService')
this.indexCallSites()
this.packageSourceFiles = new Set(sources.map(source => source.sourceFile))
}
/** Return all event relations discovered from the Program. */
@@ -690,20 +753,88 @@ class EventRelationCollector {
return this.project.checker.getDeclaredTypeOfSymbol(symbol)
}
/** Index resolved local function calls for narrow argument-flow recovery. */
private indexCallSites(): void {
/** Index resolved function calls in the given files for narrow argument-flow recovery. */
private buildCallSiteIndex(files: Iterable<ts.SourceFile>): CallSiteIndex {
const index: CallSiteIndex = new Map()
const visit = (node: ts.Node): void => {
if (ts.isCallExpression(node)) {
const declaration = this.project.checker.getResolvedSignature(node)?.declaration
if (declaration) {
const calls = this.callSites.get(declaration) ?? []
const calls = index.get(declaration) ?? []
calls.push(node)
this.callSites.set(declaration, calls)
index.set(declaration, calls)
}
}
ts.forEachChild(node, visit)
}
for (const source of this.sources) visit(source.sourceFile)
for (const file of files) visit(file)
return index
}
/**
* Return every indexed call resolving to one local helper declaration.
* Fast path: when every same-file reference to the non-exported helper is
* provably a direct callee, module scoping confines all of its calls to that
* file, so only that file is indexed. Any other reference shape may alias
* the function value outward, so the original full package-source index
* decides instead.
*/
private callSitesFor(owner: ts.FunctionDeclaration): ts.CallExpression[] {
if (!this.globalCallSites && !this.provenLocalCallee(owner)) {
this.globalCallSites = this.buildCallSiteIndex(this.packageSourceFiles)
}
if (this.globalCallSites) return this.globalCallSites.get(owner) ?? []
const file = owner.getSourceFile()
let index = this.fileCallSites.get(file)
if (!index) {
index = this.buildCallSiteIndex([file])
this.fileCallSites.set(file, index)
}
return index.get(owner) ?? []
}
/**
* Prove every same-file reference to one helper is a direct callee. The
* proof owns its premises: an exported helper or a helper in a global
* script file (no import/export means program-wide scope, callable from
* another file with no same-file reference at all) fails immediately.
* Alias escapes (re-export statements, default exports, value reads)
* resolve back to the owner symbol at a non-callee position and fail the
* proof, as does anything the scan cannot positively classify.
*/
private provenLocalCallee(owner: ts.FunctionDeclaration): boolean {
const cached = this.localCalleeProofs.get(owner)
if (cached !== undefined) return cached
if (hasExportModifier(owner) || !ts.isExternalModule(owner.getSourceFile())) {
this.localCalleeProofs.set(owner, false)
return false
}
const name = owner.name
const ownerSymbol = name && this.project.checker.getSymbolAtLocation(name)
let proven = !!ownerSymbol
const refersToOwner = (identifier: ts.Identifier): boolean => {
// Shorthand properties resolve to the property symbol; ask for the value side.
const local = ts.isShorthandPropertyAssignment(identifier.parent)
? this.project.checker.getShorthandAssignmentValueSymbol(identifier.parent)
: this.project.checker.getSymbolAtLocation(identifier)
if (!local) return false
const symbol = local.flags & ts.SymbolFlags.Alias
? this.project.checker.getAliasedSymbol(local)
: local
return symbol === ownerSymbol
}
const visit = (node: ts.Node): void => {
if (!proven) return
if (ts.isIdentifier(node) && node !== name && node.text === name?.text
&& !isDirectCallee(node) && refersToOwner(node)) {
proven = false
return
}
ts.forEachChild(node, visit)
}
visit(owner.getSourceFile())
this.localCalleeProofs.set(owner, proven)
return proven
}
/** Walk one package source file and classify event API calls by receiver type. */
@@ -717,7 +848,7 @@ class EventRelationCollector {
this.addDispatcher(name, source.pkg, 'emitAgentEvent')
}
}
} else if (ts.isPropertyAccessExpression(node.expression)) {
} else if (ts.isPropertyAccessExpression(node.expression) && EVENT_API_METHODS.has(node.expression.name.text)) {
const receiverKind = this.receiverKind(node.expression.expression)
const method = node.expression.name.text
if (receiverKind === 'events-service' && method === 'dispatch') {
@@ -820,7 +951,7 @@ class EventRelationCollector {
const index = owner.parameters.indexOf(parameter)
if (index < 0) return new Set()
const events = new Set<string>()
for (const call of this.callSites.get(owner) ?? []) {
for (const call of this.callSitesFor(owner)) {
const argument = call.arguments[index]
if (argument) addAll(events, this.eventNamesFromArgumentList(argument, new Set(seen)))
}
@@ -867,6 +998,21 @@ class EventRelationCollector {
}
}
/** Return whether an identifier is the callee of a call, seen through value-preserving wrappers. */
function isDirectCallee(identifier: ts.Identifier): boolean {
let current: ts.Node = identifier
while (
ts.isParenthesizedExpression(current.parent)
|| ts.isAsExpression(current.parent)
|| ts.isTypeAssertionExpression(current.parent)
|| ts.isNonNullExpression(current.parent)
|| ts.isSatisfiesExpression(current.parent)
) {
current = current.parent
}
return ts.isCallExpression(current.parent) && current.parent.expression === current
}
/** Peel syntax-only wrappers that do not change an expression's runtime value. */
function unwrapExpression(expression: ts.Expression): ts.Expression {
let current = expression
@@ -922,14 +1068,22 @@ function unionSets<T>(left: ReadonlySet<T>, right: ReadonlySet<T>): Set<T> {
return out
}
function collectEventRelations(): Map<string, EventRelation> {
const project = new TypeScriptProject(root)
const sources = project.sourceFiles().flatMap((sourceFile): PackageSource[] => {
/**
* Select the package source files of one project in deterministic order.
* @param project - the loaded repository TypeScript project.
* @returns `packages/<group>/<pkg>/src` files tagged with their package name.
*/
export function collectPackageSources(project: TypeScriptProject): PackageSource[] {
return project.sourceFiles().flatMap((sourceFile): PackageSource[] => {
const rel = project.relativePath(sourceFile)
const match = /^packages\/[^/]+\/([^/]+)\/src\/.+\.ts$/.exec(rel)
return match?.[1] ? [{ rel, pkg: match[1], sourceFile }] : []
}).sort((left, right) => left.rel.localeCompare(right.rel))
return new EventRelationCollector(project, sources).collect()
}
function collectEventRelations(): Map<string, EventRelation> {
const project = new TypeScriptProject(root)
return new EventRelationCollector(project, collectPackageSources(project)).collect()
}
function relationPackages(map: Map<string, Set<string>>, pkgsByShort: Map<string, Pkg>): string {
@@ -945,8 +1099,7 @@ function listenerPackages(listeners: Set<string>, pkgsByShort: Map<string, Pkg>)
return [...listeners].sort().map(pkg => pkgLink(pkgsByShort.get(pkg), pkg)).join(', ')
}
function renderEventRelations(pkgs: Pkg[]): string {
const events = collectEvents()
function renderEventRelations(pkgs: Pkg[], events: readonly EventEntry[]): string {
const relations = collectEventRelations()
const pkgsByShort = new Map(pkgs.map(pkg => [pkg.short, pkg]))
const maintenance = 'generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program'
@@ -1135,10 +1288,11 @@ function renderToolPipeline(): string {
function renderDocs(): GraphDoc[] {
const pkgs = collectPackageGraph(root, GROUP_ORDER, 'gen-doc-graphs')
const { model } = projectCordisCatalog(root, CORDIS_CATALOG_POLICY)
const docs: GraphDoc[] = [
{ rel: 'docs/capability-seams.md', content: renderCapabilitySeams(pkgs) },
{ rel: 'docs/capability-seams.md', content: renderCapabilitySeams(pkgs, model.services) },
...APP_EXAMPLES.map(example => ({ rel: example.rel, content: renderAppComposition(example) })),
{ rel: 'docs/event-producer-consumer.md', content: renderEventRelations(pkgs) },
{ rel: 'docs/event-producer-consumer.md', content: renderEventRelations(pkgs, model.events) },
{ rel: 'docs/agent-lifecycle.md', content: renderLifecycle() },
{ rel: 'docs/tool-execution-pipeline.md', content: renderToolPipeline() },
]

View File

@@ -0,0 +1,208 @@
import { readdirSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { collectPythonDependencies, isPermissive, type Manifest, manifestPatterns, parsePyprojectRequirements, parseVendoredRows, render, tierExternalDeps } from './gen-third-party-notices.ts'
const root = resolve(import.meta.dirname, '..')
describe('THIRD_PARTY_NOTICES.md', () => {
// Freshness lives here rather than in its own doc-sync gate: this spec file
// already runs in the test lane, so the check costs no extra CI process.
// Pre-commit regenerates the file whenever a manifest is staged, so reaching
// this assertion means the notices were committed without that hook.
it('matches what the generator produces from the current manifests', () => {
expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(render())
})
})
/** Build the (manifests, names) pair `tierExternalDeps` consumes. */
function workspace(entries: Record<string, Manifest>): { manifests: Map<string, Manifest>; names: Set<string> } {
const manifests = new Map(Object.entries(entries))
const names = new Set<string>()
for (const manifest of manifests.values()) {
if (manifest.name !== undefined) names.add(manifest.name)
}
return { manifests, names }
}
describe('tierExternalDeps', () => {
it('tiers by declaring area, not by the declaring section name', () => {
const { manifests, names } = workspace({
// Root tooling and test infrastructure never ship, whichever section declares them.
'package.json': { dependencies: { 'root-runtime-looking': '^1' }, devDependencies: { 'lint-tool': '^1' } },
'packages/support/loader-smoke/package.json': { name: '@deepseek-ai/dsh-loader-smoke', dependencies: { 'smoke-helper': '^1' } },
'packages/client/test-runtime/package.json': { name: '@deepseek-ai/dsh-client-test-runtime', dependencies: { 'test-lib': '^1' } },
'website/package.json': { devDependencies: { 'site-tool': '^1' } },
// A plugin package's runtime dependency ships even when no app mounts it by default.
'packages/mcp/mcp-client/package.json': { name: '@deepseek-ai/dsh-mcp-client', dependencies: { 'protocol-sdk': '^1' }, devDependencies: { 'protocol-fixture-server': '^1' } },
'apps/cli/package.json': { name: '@deepseek-ai/dsh-cli', dependencies: { 'cli-lib': '^1', '@deepseek-ai/dsh-mcp-client': 'workspace:^' } },
})
expect(tierExternalDeps(manifests, names)).toEqual(new Map([
['tsx', true],
['root-runtime-looking', false],
['lint-tool', false],
['smoke-helper', false],
['test-lib', false],
['site-tool', false],
['protocol-sdk', true],
['protocol-fixture-server', false],
['cli-lib', true],
]))
})
it('keeps a package runtime when any shipping area declares it, and excludes workspace links', () => {
const { manifests, names } = workspace({
'package.json': { devDependencies: { shared: '^1' } },
'packages/ui/tui/package.json': { name: '@deepseek-ai/dsh-tui', dependencies: { shared: '^1', '@deepseek-ai/dsh-cli': 'workspace:^' } },
'apps/cli/package.json': { name: '@deepseek-ai/dsh-cli' },
})
expect(tierExternalDeps(manifests, names).get('shared')).toBe(true)
expect(tierExternalDeps(manifests, names).has('@deepseek-ai/dsh-cli')).toBe(false)
})
})
describe('parseVendoredRows', () => {
it('reads the committed vendor manifest table', () => {
const rows = parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8'))
expect(rows.length).toBeGreaterThan(0)
expect(rows).toContainEqual({ npmName: 'cordis', upstream: 'https://github.com/cordiverse/cordis' })
// The upstream column carries a trailing package path for some rows; it is not part of the URL.
expect(rows.every(row => /^https:\/\/\S+$/.test(row.upstream))).toBe(true)
})
it('yields nothing when the table shape changes, so the generator fails loud', () => {
expect(parseVendoredRows('| `cordis/` | cordis | 4.0.0 | https://example.com | `abc123` |\n')).toEqual([])
})
it('covers every vendored directory, so no package can drop out of the notices', () => {
const parsed = new Set(parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8')).map(row => row.npmName))
const onDisk = readdirSync(resolve(root, 'vendor'), { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => (JSON.parse(readFileSync(resolve(root, 'vendor', entry.name, 'package.json'), 'utf8')) as Manifest).name)
expect([...onDisk].sort()).toEqual([...parsed].sort())
})
})
describe('parsePyprojectRequirements', () => {
it('reads the committed manifests', () => {
expect(parsePyprojectRequirements(readFileSync(resolve(root, 'python/sdk/pyproject.toml'), 'utf8'))).toContain('pydantic')
})
it('locates requirement arrays by TOML table, so author-named groups are not missed', () => {
expect(parsePyprojectRequirements([
'[build-system]',
'requires = ["hatchling>=1.24.0"]',
'',
'[project]',
'name = "not-a-requirement"',
'dependencies = ["pydantic>=2.12"]',
'',
'[project.optional-dependencies]',
'cli = ["click"]',
'',
'[dependency-groups]',
'docs = ["sphinx>=7"]',
'',
'[tool.hatch.build.targets.wheel]',
'packages = ["src/deepseek_harness"]',
'',
'[tool.pytest.ini_options]',
'testpaths = ["tests"]',
].join('\n'))).toEqual(['hatchling', 'pydantic', 'click', 'sphinx'])
})
it('does not truncate an array at a bracket inside extras', () => {
expect(parsePyprojectRequirements('[project]\ndependencies = ["httpx[http2]", "requests"]\n'))
.toEqual(['httpx', 'requests'])
})
it('reads names whether or not requirements carry versions, extras, or markers', () => {
expect(parsePyprojectRequirements("[project]\ndependencies = [\"pydantic>=2.12\", \"requests\", \"httpx[http2]\", \"tomli ; python_version < '3.11'\", \"hatchling >= 1.24.0\"]\n"))
.toEqual(['pydantic', 'requests', 'httpx', 'tomli', 'hatchling'])
})
it('reads single-quoted TOML literals and rejects an unreadable requirement', () => {
expect(parsePyprojectRequirements("[project]\ndependencies = ['requests', \"pydantic>=2\"]\n")).toEqual(['requests', 'pydantic'])
expect(() => parsePyprojectRequirements('[project]\ndependencies = ["!!broken"]\n')).toThrow(/cannot read a distribution name/)
})
it('reads a multi-line array', () => {
expect(parsePyprojectRequirements('[project]\ndependencies = [\n "pydantic>=2.12",\n "typing-extensions",\n]\n'))
.toEqual(['pydantic', 'typing-extensions'])
})
it('obeys TOML comments, quoted keys, and escaped strings', () => {
expect(parsePyprojectRequirements([
'[project] # a legal header comment',
'dependencies = [',
' "pydantic", # ] does not close the array',
' # "old-package" is not a dependency',
' "tomli; python_version < \'3.11\'",',
']',
'',
'[dependency-groups]',
'"test.docs" = ["pytest"]',
].join('\n'))).toEqual(['pydantic', 'tomli', 'pytest'])
})
it('accepts dependency-group includes and rejects unsupported requirement shapes', () => {
expect(parsePyprojectRequirements('[dependency-groups]\nbase = ["pytest"]\nall = [{ include-group = "base" }]\n'))
.toEqual(['pytest'])
expect(() => parsePyprojectRequirements('[project]\ndependencies = "pytest"\n')).toThrow(/must be an array/)
expect(() => parsePyprojectRequirements('[dependency-groups]\ntest = [{ unknown = "pytest" }]\n')).toThrow(/unsupported requirement entry/)
})
})
describe('collectPythonDependencies', () => {
it('excludes normalized local project names without exempting a third-party prefix', () => {
const pyprojects = [
'[project]\nname = "deepseek-harness-runtime-bin"\ndependencies = ["pydantic"]\n',
'[project]\nname = "deepseek-harness"\ndependencies = ["DeepSeek.Harness_Runtime-Bin", "deepseek-unrelated"]\n',
]
expect(() => collectPythonDependencies(pyprojects)).toThrow(
'python dependency deepseek-unrelated is missing from PYTHON_METADATA',
)
})
})
describe('isPermissive', () => {
it('accepts the licenses this project ships and rejects copyleft or unknown ones', () => {
expect(['MIT', 'ISC', 'BSD-3-Clause', 'Apache-2.0', 'MIT / Apache-2.0', '(MIT OR CC0-1.0)'].every(isPermissive)).toBe(true)
expect(['LGPL-3.0-only', 'MPL-2.0', 'GPL-3.0-or-later', 'SEE LICENSE IN LICENSE'].some(isPermissive)).toBe(false)
})
it('requires every operand of an AND, so a copyleft conjunct cannot ride along', () => {
expect(isPermissive('(MIT OR Apache-2.0) AND GPL-3.0-only')).toBe(false)
expect(isPermissive('MIT AND ISC')).toBe(true)
// An exception clause is not a recognized identifier, so it fails closed.
expect(isPermissive('GPL-2.0-only WITH Classpath-exception-2.0')).toBe(false)
})
it('honors grouping and SPDX precedence', () => {
expect(isPermissive('MIT OR (GPL-3.0-only AND GPL-2.0-only)')).toBe(true)
expect(isPermissive('(MIT OR Apache-2.0) AND ISC')).toBe(true)
})
it('fails closed for malformed expressions, additions, and exceptions', () => {
expect(['MIT)', '((MIT', '(MIT OR GPL-3.0-only', 'MIT OR OR GPL-3.0-only'].some(isPermissive)).toBe(false)
expect(isPermissive('MIT+')).toBe(false)
expect(isPermissive('GPL-2.0-only WITH Classpath-exception-2.0')).toBe(false)
})
})
describe('manifestPatterns', () => {
it('derives globs from the declared members, so a new member area is read', () => {
expect(manifestPatterns(['packages/*/*', 'tools/*'], ['packages/*'])).toEqual([
'package.json',
'packages/*/*/package.json',
'tools/*/package.json',
'examples/*/package.json',
'native/landlock-run/package.json',
'native/landlock-run/packages/*/package.json',
])
})
})

View File

@@ -0,0 +1,596 @@
/**
* Generate `THIRD_PARTY_NOTICES.md` from the workspace manifests: every
* external dependency named by a workspace `package.json`, the vendored-package
* manifest in `vendor/README.md`, the Python `pyproject.toml` files, and the
* pnpm patch list. License and repository metadata come from the installed
* store, so the tree must be installed. `--check` verifies the committed
* artifact. Tier policy and ownership live in
* `.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md`.
*/
import { existsSync, globSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import * as yaml from 'js-yaml'
import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml'
import parseSpdx from 'spdx-expression-parse'
const root = resolve(import.meta.dirname, '..')
const OUT = 'THIRD_PARTY_NOTICES.md'
/** Dependency-declaration kinds a consumer resolves at runtime. */
const RUNTIME_KINDS = ['dependencies', 'optionalDependencies'] as const
/** All manifest sections that name an external package this file must disclose. */
const ALL_KINDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const
/**
* Workspace areas that never reach a user: repository tooling and gates (the
* root manifest), test infrastructure, the documentation site, the runnable
* demo leaves, and the native launcher's build workspace. A runtime
* declaration by anything outside these areas is a disclosure-relevant
* runtime dependency, because `scripts/install.sh` installs the repository
* itself and any plugin package can be mounted from a user's `cordis.yml`.
*/
const DEV_ONLY_AREAS = [
'package.json',
'packages/support/',
'packages/client/test-runtime/',
'website/',
'examples/',
'native/',
] as const
/**
* First-party packages released from sibling repositories under the project's
* own license: reachable from workspace manifests but not third-party.
*/
const FIRST_PARTY = new Set([
'node-addon-landlock-run',
'node-addon-landlock-run-linux-arm64',
'node-addon-landlock-run-linux-x64',
])
/**
* Metadata overrides where the installed manifest is wrong or unreachable.
* Each entry documents why the store cannot answer.
*/
const OVERRIDES: Record<string, { license?: string; repo?: string }> = {
// Rust workspaces publishing npm bins without `license` in package.json.
'oxlint': { license: 'MIT', repo: 'https://github.com/oxc-project/oxc' },
'oxlint-tsgolint': { license: 'MIT', repo: 'https://github.com/oxc-project/tsgolint' },
// `license: SEE LICENSE IN LICENSE`: the servers repo is mid MIT→Apache-2.0
// relicensing, so the effective terms are per-contribution.
'@modelcontextprotocol/server-everything': { license: 'MIT / Apache-2.0', repo: 'https://github.com/modelcontextprotocol/servers' },
'@modelcontextprotocol/server-filesystem': { license: 'MIT / Apache-2.0', repo: 'https://github.com/modelcontextprotocol/servers' },
// No repository field in the published manifest.
'node-addon-require-builtin': { repo: 'https://www.npmjs.com/package/node-addon-require-builtin' },
}
/**
* Python dependencies are few and named directly in `pyproject.toml` files
* without installed metadata to harvest, so license/repo are recorded here and
* the generator fails when a manifest names a package this map misses.
*/
const PYTHON_METADATA: Record<string, { license: string; repo: string; role: string }> = {
pydantic: { license: 'MIT', repo: 'https://github.com/pydantic/pydantic', role: 'runtime dependency of `deepseek-harness`' },
hatchling: { license: 'MIT', repo: 'https://github.com/pypa/hatch', role: 'build backend' },
pytest: { license: 'MIT', repo: 'https://github.com/pytest-dev/pytest', role: 'test-only' },
}
type PythonMetadata = typeof PYTHON_METADATA
/** Tools fetched by scripts at build time, keyed by the pin the script owns. */
const BUILD_TIME_TOOLS = [
{
name: '@yao-pkg/pkg',
license: 'MIT',
repo: 'https://github.com/yao-pkg/pkg',
role: 'invoked by `scripts/build-exe-for-python-sdk.ts` to assemble the single-file SDK runtime executable',
pinSource: 'scripts/build-exe-for-python-sdk.ts',
},
]
/** The `package.json` fields this generator reads. */
export interface Manifest {
name?: string
private?: boolean
license?: string
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
optionalDependencies?: Record<string, string>
peerDependencies?: Record<string, string>
}
/** One disclosed external npm dependency. */
interface ExternalDep {
name: string
license: string
repo: string
/** True when some shipped workspace consumer reaches it through runtime dependency edges. */
runtime: boolean
}
/** Read and parse a workspace-relative `package.json`. */
function readManifest(rel: string): Manifest {
return JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as Manifest
}
/**
* Manifest globs, derived from the workspace declarations rather than listed
* here, so a new member area (`tools/*`) is read the day it is declared.
* @returns one glob per manifest-bearing location, repository-relative.
*/
export function manifestPatterns(rootMembers: readonly string[], nativeMembers: readonly string[]): string[] {
return [
'package.json',
...rootMembers.map(member => `${member}/package.json`),
// The demo leaves join the workspace through `examples/package.json`, so
// their own manifests are members of nothing and no glob above reaches them.
'examples/*/package.json',
// `native/landlock-run` is a nested workspace with its own lock file.
'native/landlock-run/package.json',
...nativeMembers.map(member => `native/landlock-run/${member}/package.json`),
]
}
/** The `packages:` member globs declared by one pnpm workspace file. */
function workspaceMembers(rel: string): string[] {
const declared = (yaml.load(readFileSync(resolve(root, rel), 'utf8')) as { packages?: unknown }).packages
if (!Array.isArray(declared) || declared.length === 0) {
throw new Error(`gen-third-party-notices: ${rel} declares no workspace members; the manifest set cannot be derived.`)
}
return declared.map(member => String(member))
}
/** Every workspace manifest, keyed by path, plus the set of workspace package names. */
function loadWorkspaceManifests(): { manifests: Map<string, Manifest>; names: Set<string> } {
const patterns = manifestPatterns(workspaceMembers('pnpm-workspace.yaml'), workspaceMembers('native/landlock-run/pnpm-workspace.yaml'))
const manifests = new Map<string, Manifest>()
const names = new Set<string>()
for (const pattern of patterns) {
for (const path of globSync(pattern, { cwd: root })) {
const manifest = readManifest(path)
manifests.set(path, manifest)
if (manifest.name !== undefined) names.add(manifest.name)
}
}
if (manifests.size < 100) throw new Error(`gen-third-party-notices: only ${manifests.size} workspace manifests found; the glob set is stale.`)
return { manifests, names }
}
/** License and repository URL for an installed external package, from the pnpm store. */
function installedMetadata(name: string): { license: string; repo: string } {
const override = OVERRIDES[name]
let manifest: (Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string }) | undefined
// The nested Landlock workspace installs into its own store, so a package
// only that workspace depends on is unreachable from the root one.
for (const store of ['node_modules', 'native/landlock-run/node_modules']) {
const direct = resolve(root, store, name, 'package.json')
if (existsSync(direct)) {
manifest = JSON.parse(readFileSync(direct, 'utf8')) as typeof manifest
break
}
const virtual = resolve(root, store, '.pnpm')
if (!existsSync(virtual)) continue
const prefix = `${name.replace('/', '+')}@`
const entry = readdirSync(virtual).find(dir => dir.startsWith(prefix))
if (entry === undefined) continue
manifest = JSON.parse(readFileSync(resolve(virtual, entry, 'node_modules', name, 'package.json'), 'utf8')) as typeof manifest
break
}
const license = override?.license ?? manifest?.license
const rawRepo = typeof manifest?.repository === 'string' ? manifest.repository : manifest?.repository?.url ?? manifest?.homepage
const repo = override?.repo ?? normalizeRepo(rawRepo)
if (license === undefined || repo === undefined) {
throw new Error(`gen-third-party-notices: cannot resolve ${license === undefined ? 'license' : 'repository'} for ${name}; run \`pnpm install\` (or, for a Landlock-only dependency, \`pnpm --dir native/landlock-run install\`), or add an OVERRIDES entry.`)
}
return { license, repo }
}
/** Normalize a manifest repository/homepage value to a browsable https URL. */
function normalizeRepo(raw: string | undefined): string | undefined {
if (raw === undefined || raw === '') return undefined
let url = raw
.replace(/^git\+ssh:\/\/git@/, 'https://')
.replace(/^git\+/, '')
.replace(/^git:\/\//, 'https://')
.replace(/^github:/, 'https://github.com/')
.replace(/\.git$/, '')
if (!url.startsWith('http')) url = `https://github.com/${url}`
return url
}
/**
* External npm dependencies, tiered by which workspace area declares them at
* runtime: a package is runtime when any manifest outside `DEV_ONLY_AREAS`
* names it in `dependencies`/`optionalDependencies`. A package declared only
* by tooling, test infrastructure, the website, or the demo leaves — whatever
* the declaring section is called — is development-only.
*/
function collectNpmDeps(): ExternalDep[] {
const { manifests, names } = loadWorkspaceManifests()
return [...tierExternalDeps(manifests, names)]
.filter(([name]) => !FIRST_PARTY.has(name))
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, runtime]) => ({ name, ...installedMetadata(name), runtime }))
}
/**
* Tier every external dependency the workspace declares.
* @param manifests - workspace manifests keyed by repository-relative path.
* @param names - every workspace package name, which never counts as external.
* @returns each external package mapped to whether it is a runtime dependency.
*/
export function tierExternalDeps(manifests: Map<string, Manifest>, names: Set<string>): Map<string, boolean> {
const tiers = new Map<string, boolean>()
// `tsx` is runtime by fiat: `bin/dsh` execs the CLI through its ESM hook.
tiers.set('tsx', true)
for (const [path, manifest] of manifests) {
const devOnly = DEV_ONLY_AREAS.some(area => (area.endsWith('/') ? path.startsWith(area) : path === area))
for (const kind of ALL_KINDS) {
for (const [dep, range] of Object.entries(manifest[kind] ?? {})) {
if (names.has(dep) || range.startsWith('workspace:')) continue
const runtime = !devOnly && (RUNTIME_KINDS as readonly string[]).includes(kind)
tiers.set(dep, (tiers.get(dep) ?? false) || runtime)
}
}
}
return tiers
}
/** A vendored package row parsed out of the `vendor/README.md` manifest table. */
export interface VendoredRow {
npmName: string
upstream: string
}
/**
* Parse the vendored-package manifest table out of `vendor/README.md`.
* @param text - the complete `vendor/README.md` contents.
* @returns one row per manifest-table entry, in table order.
*/
export function parseVendoredRows(text: string): VendoredRow[] {
const rows: VendoredRow[] = []
for (const line of text.split('\n')) {
const match = /^\| \x60\S+\/\x60 \| \x60([^\x60]+)\x60 \| \S+ \| (https:\/\/\S+?)(?: \([^)]*\))? \| \x60[0-9a-f]+\x60 \|$/.exec(line)
if (match === null) continue
const [, npmName, upstream] = match
if (npmName === undefined || upstream === undefined) continue
rows.push({ npmName, upstream })
}
return rows
}
/**
* Parse the vendored manifest table and confirm it accounts for every vendored
* directory. The `vendor/` tree — not the table — is the set that must be
* disclosed, so a row that stops matching the table format is a hard error
* rather than a package that quietly vanishes from the notices.
*/
function collectVendored(): VendoredRow[] {
const rows = parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8'))
const onDisk = new Map<string, string>()
for (const entry of readdirSync(resolve(root, 'vendor'), { withFileTypes: true })) {
if (!entry.isDirectory()) continue
const manifest = readManifest(`vendor/${entry.name}/package.json`)
if (manifest.name !== undefined) onDisk.set(manifest.name, entry.name)
}
const parsed = new Set(rows.map(row => row.npmName))
const missing = [...onDisk.keys()].filter(name => !parsed.has(name))
if (missing.length > 0) {
throw new Error(`gen-third-party-notices: vendor/README.md has no manifest-table row for ${missing.join(', ')}; its table format changed or the sync is incomplete.`)
}
for (const row of rows) {
const dir = onDisk.get(row.npmName)
if (dir === undefined) throw new Error(`gen-third-party-notices: vendored package ${row.npmName} from vendor/README.md has no vendor/ directory.`)
const license = readManifest(`vendor/${dir}/package.json`).license
if (license !== 'MIT') {
throw new Error(`gen-third-party-notices: vendored ${row.npmName} declares license ${JSON.stringify(license)}; the vendored section assumes MIT throughout.`)
}
}
return rows
}
/** Whether a parsed TOML value is a table rather than an array or scalar. */
function isTomlTable(value: TomlValueWithoutBigInt | undefined): value is TomlTableWithoutBigInt {
return value !== undefined && typeof value === 'object' && !Array.isArray(value)
}
/** Parse one PEP 508 requirement string into its distribution name. */
function parsePythonRequirement(requirement: string): string {
const name = /^\s*([a-zA-Z][a-zA-Z0-9._-]*)\s*(?:\[[^\]]*\])?\s*(?:[<>=!~;@].*)?$/.exec(requirement)?.[1]
if (name === undefined) {
throw new Error(`gen-third-party-notices: cannot read a distribution name from the requirement ${JSON.stringify(requirement)}.`)
}
return name
}
/** Add the string requirements from one parsed TOML array. */
function collectPythonRequirementArray(
names: string[],
value: TomlValueWithoutBigInt | undefined,
location: string,
allowGroupIncludes = false,
): void {
if (value === undefined) return
if (!Array.isArray(value)) {
throw new Error(`gen-third-party-notices: ${location} must be an array.`)
}
for (const item of value) {
if (typeof item === 'string') {
names.push(parsePythonRequirement(item))
continue
}
if (allowGroupIncludes && isTomlTable(item) && typeof item['include-group'] === 'string' && Object.keys(item).length === 1) {
continue
}
throw new Error(`gen-third-party-notices: ${location} contains an unsupported requirement entry.`)
}
}
/** Read an optional TOML table and reject a present value of another shape. */
function optionalTomlTable(value: TomlValueWithoutBigInt | undefined, location: string): TomlTableWithoutBigInt | undefined {
if (value === undefined || isTomlTable(value)) return value
throw new Error(`gen-third-party-notices: ${location} must be a table.`)
}
/**
* Parse a `pyproject.toml` project identity and every requirement it declares:
* `requires` under
* `[build-system]`, `dependencies` under `[project]`, and every key under
* `[project.optional-dependencies]` and `[dependency-groups]`. A TOML parser
* owns comments, quoted keys, escapes, and array boundaries; unsupported
* requirement shapes fail instead of disappearing from the notices.
* @param text - the complete `pyproject.toml` contents.
* @returns the local project name and declared requirement names.
*/
function parsePyproject(text: string): { projectName?: string; requirements: string[] } {
const names: string[] = []
const document = parseToml(text, { integersAsBigInt: false })
const buildSystem = optionalTomlTable(document['build-system'], '[build-system]')
const project = optionalTomlTable(document.project, '[project]')
const projectName = project?.name
if (projectName !== undefined && typeof projectName !== 'string') {
throw new Error('gen-third-party-notices: [project].name must be a string.')
}
collectPythonRequirementArray(names, buildSystem?.requires, '[build-system].requires')
collectPythonRequirementArray(names, project?.dependencies, '[project].dependencies')
const optional = optionalTomlTable(project?.['optional-dependencies'], '[project.optional-dependencies]')
for (const [group, requirements] of Object.entries(optional ?? {})) {
collectPythonRequirementArray(names, requirements, `[project.optional-dependencies].${group}`)
}
const groups = optionalTomlTable(document['dependency-groups'], '[dependency-groups]')
for (const [group, requirements] of Object.entries(groups ?? {})) {
collectPythonRequirementArray(names, requirements, `[dependency-groups].${group}`, true)
}
return projectName === undefined
? { requirements: names }
: { projectName, requirements: names }
}
/**
* Read every requirement name declared by one `pyproject.toml`.
* @param text - the complete `pyproject.toml` contents.
* @returns each declared requirement's distribution name, in file order.
*/
export function parsePyprojectRequirements(text: string): string[] {
return parsePyproject(text).requirements
}
/** Normalize a Python distribution name according to the packaging name rule. */
function normalizePythonDistributionName(name: string): string {
return name.toLowerCase().replace(/[-_.]+/g, '-')
}
/**
* Resolve external Python dependencies after excluding local project names.
* @param pyprojects - complete local `pyproject.toml` contents.
* @param metadata - disclosure metadata for every external dependency.
* @returns disclosed dependencies in normalized name order.
*/
export function collectPythonDependencies(
pyprojects: string[],
metadata: PythonMetadata = PYTHON_METADATA,
): { name: string; license: string; repo: string; role: string }[] {
const parsed = pyprojects.map(parsePyproject)
const firstParty = new Set(parsed.flatMap(({ projectName }) => (
projectName === undefined ? [] : [normalizePythonDistributionName(projectName)]
)))
const found = new Set(parsed
.flatMap(({ requirements }) => requirements.map(normalizePythonDistributionName))
.filter(name => !firstParty.has(name)))
return [...found].sort((a, b) => a.localeCompare(b)).map((name) => {
const entry = metadata[name]
if (entry === undefined) throw new Error(`gen-third-party-notices: python dependency ${name} is missing from PYTHON_METADATA.`)
return { name, ...entry }
})
}
/** Direct Python dependencies named by the `pyproject.toml` manifests under `python/`. */
function collectPython(): { name: string; license: string; repo: string; role: string }[] {
const manifests = globSync('python/*/pyproject.toml', { cwd: root })
if (manifests.length === 0) throw new Error('gen-third-party-notices: no python/*/pyproject.toml found; the Python tree moved.')
return collectPythonDependencies(manifests.map(path => readFileSync(resolve(root, path), 'utf8')))
}
/** pnpm-patched external packages, from `pnpm-workspace.yaml`. */
function collectPatched(): { spec: string; patch: string }[] {
const workspace = yaml.load(readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8')) as { patchedDependencies?: Record<string, string> }
return Object.entries(workspace.patchedDependencies ?? {}).map(([spec, patch]) => ({ spec, patch }))
}
/** Verify each build-time tool pin still appears in its owning script. */
function verifyBuildTimePins(): void {
for (const tool of BUILD_TIME_TOOLS) {
const text = readFileSync(resolve(root, tool.pinSource), 'utf8')
if (!text.includes(tool.name)) {
throw new Error(`gen-third-party-notices: ${tool.pinSource} no longer references ${tool.name}; update BUILD_TIME_TOOLS.`)
}
}
}
/** SPDX identifiers this project may ship without further review. */
const PERMISSIVE_LICENSES = new Set(['MIT', 'ISC', 'BSD-2-Clause', 'BSD-3-Clause', 'Apache-2.0', '0BSD', 'Unlicense', 'CC0-1.0', 'BlueOak-1.0.0', 'Python-2.0'])
/** Evaluate a parsed SPDX expression under the repository's license policy. */
function isPermissiveSpdx(expression: ReturnType<typeof parseSpdx>): boolean {
if ('conjunction' in expression) {
return expression.conjunction === 'and'
? isPermissiveSpdx(expression.left) && isPermissiveSpdx(expression.right)
: isPermissiveSpdx(expression.left) || isPermissiveSpdx(expression.right)
}
return expression.plus !== true
&& expression.exception === undefined
&& PERMISSIVE_LICENSES.has(expression.license)
}
/**
* Whether an SPDX expression grants terms this project may ship under.
* `OR` needs one permissive alternative, because the consumer chooses; `AND`
* needs all of them, because every obligation applies. Anything that is not a
* recognized permissive identifier — copyleft, an exception clause, or a
* license this list has never seen — evaluates to false, so an unfamiliar
* expression fails closed rather than passing on a partial match.
* @param license - the SPDX expression from the package manifest.
* @returns true when the expression's obligations are all permissive.
*/
export function isPermissive(license: string): boolean {
// Some npm manifests use a slash for a choice despite SPDX requiring `OR`.
const normalized = license.replace(/\s*\/\s*/g, ' OR ').trim()
try {
return isPermissiveSpdx(parseSpdx(normalized))
} catch {
return false
}
}
/**
* Render the sentence that isolates non-permissive development tooling, or
* nothing at all when every development dependency is permissive.
* @param deps - development dependencies whose license is not permissive.
* @returns the paragraph to place after the development table.
*/
function renderNonPermissiveNote(deps: ExternalDep[]): string {
if (deps.length === 0) return ''
const named = deps.map(dep => `\`${dep.name}\` (${dep.license})`)
const subject = named.length === 1 ? named[0] : `${named.slice(0, -1).join(', ')} and ${named.at(-1)}`
return `\n${subject} ${named.length === 1 ? 'runs' : 'run'} only as development tooling; their code is not linked into or distributed with any DeepSeek Harness artifact.\n`
}
/** Render one npm dependency table. */
function renderNpmTable(deps: ExternalDep[]): string {
const lines = ['| Package | License |', '| --- | --- |']
for (const dep of deps) lines.push(`| [\`${dep.name}\`](${dep.repo}) | ${dep.license} |`)
return lines.join('\n')
}
/**
* Render the complete notices document.
* @returns the exact bytes `THIRD_PARTY_NOTICES.md` must hold.
*/
export function render(): string {
verifyBuildTimePins()
const npm = collectNpmDeps()
const runtimeDeps = npm.filter(dep => dep.runtime)
const devDeps = npm.filter(dep => !dep.runtime)
const vendored = collectVendored()
const python = collectPython()
const patched = collectPatched()
const nonPermissiveDev = devDeps.filter(dep => !isPermissive(dep.license))
// A copyleft license reaching a shipped surface is a distribution decision,
// not a rendering detail; the notices cannot quietly absorb it.
const nonPermissiveRuntime = runtimeDeps.filter(dep => !isPermissive(dep.license))
if (nonPermissiveRuntime.length > 0) {
throw new Error(`gen-third-party-notices: runtime ${nonPermissiveRuntime.map(dep => `${dep.name} (${dep.license})`).join(', ')} is not a permissive license; review the distribution terms and record the decision before regenerating.`)
}
const patchedLines = patched.map(({ spec, patch }) => `- \`${spec}\` — [\`${patch}\`](${patch})`)
return `<!-- Generated by scripts/gen-third-party-notices.ts — do not edit by hand.
Run \`pnpm run gen-third-party-notices\` to regenerate. -->
# Third-Party Notices
DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party open-source software listed below. Each project remains under its own license; nothing in this file changes those terms.
This file lists **direct** dependencies declared by the workspace. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check.
The complete npm transitive closure, with exact pinned versions, is recorded in [\`pnpm-lock.yaml\`](pnpm-lock.yaml) — inspect it with \`pnpm licenses list\`. The Python closure is recorded in [\`python/sdk/uv.lock\`](python/sdk/uv.lock), and the Landlock launcher workspace keeps its own in [\`native/landlock-run/pnpm-lock.yaml\`](native/landlock-run/pnpm-lock.yaml).
## Vendored source (\`vendor/\`)
The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm. All are MIT-licensed; each directory preserves its upstream \`LICENSE\` file. Exact upstream commits and local modifications are recorded in [\`vendor/README.md\`](vendor/README.md).
| Package | Upstream | License |
| --- | --- | --- |
${vendored.map(row => `| \`${row.npmName}\` | [${row.upstream.replace('https://', '')}](${row.upstream}) | MIT |`).join('\n')}
## Runtime npm dependencies
External packages that a workspace package resolves at runtime. \`scripts/install.sh\` installs this repository itself, so the tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI/TUI, the Web UI, and the Python SDK runtime load by default.
${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')}
## Development-only npm dependencies
External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — \`pnpm-lock.yaml\` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles.
${renderNpmTable(devDeps)}
${renderNonPermissiveNote(nonPermissiveDev)}
## Python SDK dependencies (\`python/\`)
Direct dependencies of the \`pyproject.toml\` manifests, plus \`uv\` as the development workflow tool.
| Package | License | Role |
| --- | --- | --- |
${python.map(dep => `| [\`${dep.name}\`](${dep.repo}) | ${dep.license} | ${dep.role} |`).join('\n')}
| [\`uv\`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool |
## Fetched at build time
| Package | License | Role |
| --- | --- | --- |
${BUILD_TIME_TOOLS.map(tool => `| [\`${tool.name}\`](${tool.repo}) | ${tool.license} | ${tool.role} |`).join('\n')}
## First-party sibling releases
\`node-addon-landlock-run\` (and its platform packages) is released from a DeepSeek Harness sibling repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party.
`
}
/** CLI entry: default writes the notices, `--check` fails if the committed copy
* is stale. Guarded behind an entry-point check so importing this module for
* tests neither regenerates the committed file nor calls process.exit. */
function main(): void {
const content = render()
if (process.argv.includes('--check')) {
let committed: string | null = null
try {
committed = readFileSync(resolve(root, OUT), 'utf8')
} catch {
// Only ENOENT (not yet generated) is expected; a present-but-unreadable
// file is not a state this repo produces, and the remedy is the same.
committed = null
}
if (committed === content) {
console.log(`gen-third-party-notices: ${OUT} is up to date.`)
process.exit(0)
}
console.error(`gen-third-party-notices: ${OUT} is stale. Run \`pnpm run gen-third-party-notices\` and commit ${OUT}.`)
process.exit(1)
}
writeFileSync(resolve(root, OUT), content)
console.log(`gen-third-party-notices: wrote ${OUT}.`)
}
// Run only when invoked as a script, not when imported by a test.
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
main()
}

View File

@@ -33,9 +33,11 @@ import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent'
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor'
import PtyService from '@deepseek-ai/dsh-pty'
import * as ToolPty from '@deepseek-ai/dsh-tool-pty'
import * as ToolGoal from '@deepseek-ai/dsh-tool-goal'
@@ -215,7 +217,33 @@ const TOOL_PACKAGES: ToolPackage[] = [
await ctx.plugin(ToolCordis)
},
note:
'Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes.',
'Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes.',
},
{
pkg: '@deepseek-ai/dsh-tool-bash-persistent',
dir: 'tool-bash-persistent',
source: 'packages/pty/tool-bash-persistent/src/index.ts',
requires: ['ctx.tools', 'ctx.pty', 'an owning Agent at execution time'],
writes: ['tool/call', 'PTY shell state', 'tool/result'],
async mount(ctx) {
await ctx.plugin(PtyService)
await ctx.plugin(ToolBashPersistent)
},
note:
'One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description.',
},
{
pkg: '@deepseek-ai/dsh-tool-str-replace-editor',
dir: 'tool-str-replace-editor',
source: 'packages/fs/tool-str-replace-editor/src/index.ts',
requires: ['ctx.tools', 'ctx.fs'],
writes: ['tool/call', 'fs/observed after successful file operations', 'tool/result'],
async mount(ctx) {
await ctx.plugin(LocalFileSystem)
await ctx.plugin(ToolStrReplaceEditor)
},
note:
'Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface.',
},
{
pkg: '@deepseek-ai/dsh-tool-fs',
@@ -245,10 +273,10 @@ const TOOL_PACKAGES: ToolPackage[] = [
// never depends on the host PATH. `ctx.spillStore` is optional (read via
// ctx.get) and does not affect the schemas, so no spill backend is mounted.
await ctx.plugin(CatalogSearchBashExecutor)
await ctx.plugin(ToolFsSearch)
await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: true })
},
note:
'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.',
'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.',
},
{
pkg: '@deepseek-ai/dsh-tool-pty',
@@ -310,9 +338,10 @@ const TOOL_PACKAGES: ToolPackage[] = [
pkg: '@deepseek-ai/dsh-tool-skill',
dir: 'tool-skill',
source: 'packages/skill/tool-skill/src/index.ts',
requires: ['ctx.tools', 'ctx.skills'],
writes: ['tool/call', 'tool/result'],
requires: ['ctx.tools', 'ctx.agents', 'ctx.skills'],
writes: ['tool/call', 'tool/result', 'user/message replacement catalogs via agent.inject()'],
async mount(ctx) {
await ctx.plugin(AgentRegistry)
await ctx.plugin(SkillService)
await ctx.plugin(SkillLocal, {
dshHome: resolve(root, '.tmp/tool-catalog/.dsh'),
@@ -348,7 +377,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
await ctx.plugin(ToolSubagent, { provider: 'mock' })
},
note:
'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.',
'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `apps/cli/config/base.cordis.yml` and `examples/acp-agent/cordis.yml`.',
},
{
pkg: '@deepseek-ai/dsh-tool-tasks',

View File

@@ -0,0 +1,78 @@
// Regression drive for the unified hero composer (0729-0357-hero-unify):
// cold start with zero workspaces -> create a workspace -> type. Asserts the
// composer textarea is the SAME DOM node across the disabled->live flip (a
// remount drops the __heroMark marker property) — the session-maybe
// composer.bar contract.
//
// Prereqs: `pnpm run build`, then a fresh server against empty state:
// rm -rf .storages && DSH_HOME=$(mktemp -d) node --experimental-transform-types \
// --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts web --port 44285 \
// --workspace-root $(mktemp -d)
// Run: node scripts/hero-composer-dom-continuity.mjs
// (BASE_URL overrides the target; screenshots land in .artifacts/.)
import { createRequire } from 'node:module'
// playwright is a devDependency of apps/web only — resolve through its tree.
const require = createRequire(new URL('../apps/web/package.json', import.meta.url))
const { chromium } = require('playwright')
const BASE = process.env.BASE_URL ?? 'http://127.0.0.1:44285'
const SHOTS = new URL('../.artifacts/screenshots/0729-0357-hero-unify/', import.meta.url).pathname
const browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } })
page.on('console', msg => { if (msg.type() === 'error') console.log('[console.error]', msg.text()) })
page.on('pageerror', err => { console.log('[pageerror]', err.message) })
await page.goto(BASE)
await page.waitForSelector('textarea', { timeout: 20000 })
await page.screenshot({ path: SHOTS + '01-cold-start.png' })
const initial = await page.evaluate(() => {
const boxes = [...document.querySelectorAll('textarea')]
boxes.forEach((b, i) => { b.__heroMark = 'alive-' + i })
return boxes.map(b => ({ disabled: b.disabled, placeholder: b.placeholder }))
})
console.log('cold-start textareas:', JSON.stringify(initial))
// Open the picker and create a workspace by name (typed-input flow). The name
// must be unique per registry; keystrokes go through pressSequentially so the
// dialog's React onChange enables the submit button.
await page.getByRole('button', { name: 'Choose workspace' }).click()
await page.getByText('Create a new workspace').click()
await page.screenshot({ path: SHOTS + '03-create-form.png' })
const nameBox = page.getByPlaceholder('Workspace name')
await nameBox.click()
const wsName = 'proj-' + Date.now().toString(36)
await nameBox.pressSequentially(wsName, { delay: 30 })
await page.locator('button:text-is("Create workspace")').click()
// Wait for the composer to go live (placeholder flips, textarea enabled).
await page.waitForFunction(() => {
const box = document.querySelector('textarea')
return box !== null && !box.disabled
}, { timeout: 20000 })
await page.screenshot({ path: SHOTS + '04-live.png' })
const after = await page.evaluate(() => {
const boxes = [...document.querySelectorAll('textarea')]
return boxes.map(b => ({
mark: b.__heroMark ?? 'REMOUNTED',
disabled: b.disabled,
placeholder: b.placeholder,
}))
})
console.log('post-pick textareas:', JSON.stringify(after))
// Type into the live composer.
await page.locator('textarea').first().fill('hello from acceptance run')
const typed = await page.evaluate(() => document.querySelector('textarea')?.value)
console.log('typed value:', JSON.stringify(typed))
await page.screenshot({ path: SHOTS + '05-typed.png' })
const survived = after.length === 1 && after[0].mark === 'alive-0'
console.log(survived
? 'DOM-CONTINUITY: PASS (same textarea node across cold-start -> live)'
: 'DOM-CONTINUITY: FAIL ' + JSON.stringify(after))
await browser.close()
process.exit(survived && typed === 'hello from acceptance run' ? 0 : 1)

View File

@@ -1,8 +1,19 @@
#!/usr/bin/env node
import { randomUUID } from 'node:crypto'
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import {
closeSync,
existsSync,
fstatSync,
lstatSync,
mkdirSync,
openSync,
readdirSync,
readFileSync,
unlinkSync,
writeFileSync,
} from 'node:fs'
import { spawnSync } from 'node:child_process'
import { isAbsolute, join, resolve } from 'node:path'
import { dirname, isAbsolute, join, resolve } from 'node:path'
const MINIMUM_GIT = [2, 26, 0]
const HOOKS_DIRECTORY = 'dsh-hooks'
@@ -11,6 +22,7 @@ const OWNERSHIP_MARKER_VERSION = 1
const OWNERSHIP_MARKER_OWNER = 'deepseek-harness worktree-local lefthook hooks'
const INSTALL_LOCK = 'dsh-lefthook-install.lock'
const INSTALL_LOCK_TIMEOUT_MS = 30_000
const INSTALL_LOCK_INITIALIZATION_TIMEOUT_MS = 1_000
const INSTALL_LOCK_POLL_MS = 50
const ALLOW_HOOKS_PATH_OVERRIDE = 'DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE'
const REPOSITORY_EXTENSION_PATTERN = '^extensions\\.'
@@ -303,6 +315,11 @@ function parseInstallLock(record) {
return Number.isSafeInteger(owner) ? owner : undefined
}
function installLockRecordMayBeIncomplete(record) {
// Exclusive creation exposes the inode before its owner record is fully written.
return record === '' || (!record.endsWith('\n') && /^[1-9]\d*(?: [0-9a-f-]*)?$/i.test(record))
}
function lockOwnerIsAlive(owner) {
try {
process.kill(owner, 0)
@@ -351,11 +368,29 @@ async function acquireInstallLock(commonDirectory) {
const lockPath = join(commonDirectory, INSTALL_LOCK)
const deadline = Date.now() + INSTALL_LOCK_TIMEOUT_MS
const ownedRecord = `${String(process.pid)} ${randomUUID()}\n`
let initializingLock
while (true) {
try {
writeFileSync(lockPath, ownedRecord, { flag: 'wx', mode: 0o600 })
const ownedStat = installLockStat(lockPath)
if (ownedStat === undefined || !ownedStat.isFile() || ownedStat.isSymbolicLink()) {
const lockHandle = openSync(lockPath, 'wx', 0o600)
let ownedStat
try {
ownedStat = fstatSync(lockHandle)
const writeDelay = Number(process.env.DSH_TEST_LEFTHOOK_LOCK_WRITE_DELAY_MS ?? 0)
if (writeDelay > 0) {
await new Promise(resolveWait => setTimeout(resolveWait, writeDelay))
}
writeFileSync(lockHandle, ownedRecord)
} finally {
closeSync(lockHandle)
}
const publishedStat = installLockStat(lockPath)
if (
publishedStat === undefined
|| !publishedStat.isFile()
|| publishedStat.isSymbolicLink()
|| publishedStat.dev !== ownedStat.dev
|| publishedStat.ino !== ownedStat.ino
) {
throw lockOwnershipChangedError(lockPath)
}
return () => releaseInstallLock(lockPath, ownedRecord, ownedStat)
@@ -368,8 +403,36 @@ async function acquireInstallLock(commonDirectory) {
}
const existingRecord = readInstallLock(lockPath)
if (existingRecord === undefined) continue
const verifiedStat = installLockStat(lockPath)
if (verifiedStat === undefined) continue
if (!verifiedStat.isFile() || verifiedStat.isSymbolicLink()) {
throw manualLockRecoveryError(lockPath, 'invalid')
}
if (verifiedStat.dev !== existingStat.dev || verifiedStat.ino !== existingStat.ino) continue
const owner = parseInstallLock(existingRecord)
if (owner === undefined) throw manualLockRecoveryError(lockPath, 'invalid')
if (owner === undefined) {
if (!installLockRecordMayBeIncomplete(existingRecord)) {
throw manualLockRecoveryError(lockPath, 'invalid')
}
const now = Date.now()
if (
initializingLock === undefined
|| initializingLock.dev !== existingStat.dev
|| initializingLock.ino !== existingStat.ino
) {
initializingLock = {
deadline: now + INSTALL_LOCK_INITIALIZATION_TIMEOUT_MS,
dev: existingStat.dev,
ino: existingStat.ino,
}
}
if (now >= initializingLock.deadline) {
throw manualLockRecoveryError(lockPath, 'invalid')
}
await new Promise(resolveWait => setTimeout(resolveWait, INSTALL_LOCK_POLL_MS))
continue
}
initializingLock = undefined
if (!lockOwnerIsAlive(owner)) throw manualLockRecoveryError(lockPath, 'stale')
if (Date.now() >= deadline) {
throw new Error(`timed out waiting for Lefthook installer lock ${lockPath}`)
@@ -437,6 +500,15 @@ function inspectOwnedHooksDirectory(hooksPath) {
return { markerPath, ...marker }
}
function isRegisteredOwnedHooksPath(commonDirectory, hooksPath) {
const normalizedHooksPath = normalizedPath(hooksPath)
const isRegistered = registeredWorktreeConfigPaths(commonDirectory).some(
configPath => normalizedPath(join(dirname(configPath), HOOKS_DIRECTORY)) === normalizedHooksPath,
)
if (!isRegistered) return false
return inspectOwnedHooksDirectory(hooksPath)?.hooksPath === hooksPath
}
function ensureOwnedHooksDirectory(hooksPath) {
const inspected = inspectOwnedHooksDirectory(hooksPath)
if (inspected !== undefined) return inspected
@@ -561,14 +633,22 @@ async function main() {
'worktree core.hooksPath',
)
let ownedHooksDirectory
let copiedWorktreePathIsOwned = false
if (worktreePath !== undefined && worktreePath !== hooksPath) {
ownedHooksDirectory = inspectOwnedHooksDirectory(hooksPath)
if (ownedHooksDirectory === undefined || ownedHooksDirectory.hooksPath !== worktreePath) {
const worktreePathIsRelocated = ownedHooksDirectory?.hooksPath === worktreePath
copiedWorktreePathIsOwned = !worktreePathIsRelocated
&& isRegisteredOwnedHooksPath(commonDirectory, worktreePath)
if (!worktreePathIsRelocated && !copiedWorktreePathIsOwned) {
refuseScopedHooksPath({ origin: `file:${worktreeConfigPath}`, scope: 'worktree', value: worktreePath })
}
}
const directWorktreePathIsOwned = worktreePath !== undefined
&& (worktreePath === hooksPath || ownedHooksDirectory?.hooksPath === worktreePath)
&& (
worktreePath === hooksPath
|| ownedHooksDirectory?.hooksPath === worktreePath
|| copiedWorktreePathIsOwned
)
const effectiveEntry = effectiveConfigEntry(root, 'core.hooksPath')
if (effectiveEntry !== undefined) {
const effectivePathIsOwned = effectiveEntry.scope === 'worktree'
@@ -593,6 +673,7 @@ async function main() {
worktreePath !== undefined
&& worktreePath !== hooksPath
&& ownedHooksDirectory.hooksPath !== worktreePath
&& !copiedWorktreePathIsOwned
) {
throw new Error(`hooks directory ownership changed while relocating ${JSON.stringify(worktreePath)}`)
}

View File

@@ -196,7 +196,7 @@ function runInstaller(
})
}
describe('worktree-local Lefthook installer', () => {
describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
for (const [label, extraEnv] of [
['CI', { CI: 'true' }],
['GitHub Actions', { GITHUB_ACTIONS: 'true' }],
@@ -262,6 +262,30 @@ describe('worktree-local Lefthook installer', () => {
expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n')
})
it('replaces the owned hook path Git copies into a newly added worktree', async () => {
const fixture = createFixture()
const mainInstall = await runInstaller(fixture, fixture.main)
expect(mainInstall.status, mainInstall.stderr).toBe(0)
const mainHooks = hooksPath(fixture, fixture.main)
const mainHookBefore = readFileSync(join(mainHooks, 'pre-commit'), 'utf8')
const lateLinked = join(fixture.container, 'late-linked')
git(fixture, fixture.main, ['worktree', 'add', '-b', 'late-linked', lateLinked])
write(join(lateLinked, 'lefthook.yml'), 'late-linked-worktree-config\n')
installFakeLefthook(lateLinked)
expect(git(fixture, lateLinked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(mainHooks)
const linkedInstall = await runInstaller(fixture, lateLinked)
expect(linkedInstall.status, linkedInstall.stderr).toBe(0)
const linkedHooks = hooksPath(fixture, lateLinked)
expect(linkedHooks).not.toBe(mainHooks)
expect(git(fixture, lateLinked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(linkedHooks)
expect(readFileSync(join(linkedHooks, 'pre-commit'), 'utf8')).toContain(
'# config=late-linked-worktree-config',
)
expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBefore)
})
it('serializes concurrent installs and keeps repeated output stable', async () => {
const fixture = createFixture()
const delayed = { DSH_TEST_LEFTHOOK_DELAY_MS: '150' }
@@ -283,6 +307,22 @@ describe('worktree-local Lefthook installer', () => {
expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false)
})
it('waits for a concurrent installer to finish publishing its lock record', async () => {
const fixture = createFixture()
const lockPath = installLockPath(fixture)
const publishing = runInstaller(fixture, fixture.main, {
DSH_TEST_LEFTHOOK_LOCK_WRITE_DELAY_MS: '200',
})
await waitForPath(lockPath)
expect(readFileSync(lockPath, 'utf8')).toBe('')
const waiting = runInstaller(fixture, fixture.linked)
const results = await Promise.all([publishing, waiting])
for (const result of results) expect(result.status, result.stderr).toBe(0)
expect(existsSync(lockPath)).toBe(false)
})
it('repairs its owned absolute hook path after the checkout moves', async () => {
const fixture = createFixture()
const oldRoot = fixture.main
@@ -509,6 +549,30 @@ describe('worktree-local Lefthook installer', () => {
expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe('linked-custom-hooks')
})
it('does not trust an ownership marker outside a registered worktree hook path', async () => {
const fixture = createFixture()
const mainInstall = await runInstaller(fixture, fixture.main)
expect(mainInstall.status, mainInstall.stderr).toBe(0)
const externalHooks = join(fixture.container, 'external-owned-hooks')
write(
join(externalHooks, '.dsh-lefthook-owned'),
`${JSON.stringify({
version: 1,
owner: 'deepseek-harness worktree-local lefthook hooks',
hooksPath: externalHooks,
})}\n`,
0o600,
)
git(fixture, fixture.linked, ['config', '--worktree', 'core.hooksPath', externalHooks])
const result = await runInstaller(fixture, fixture.linked)
expect(result.status).toBe(1)
expect(result.stderr).toContain('worktree-scoped core.hooksPath')
expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(externalHooks)
expect(existsSync(hooksPath(fixture, fixture.linked))).toBe(false)
})
it('refuses to activate a sibling worktree dormant hook path', async () => {
const fixture = createFixture()
const linkedConfig = join(gitDirectory(fixture, fixture.linked), 'config.worktree')

View File

@@ -7,23 +7,34 @@
# ~/.dsh/source/master), adds a per-install staging worktree at
# ~/.dsh/source/staging-<timestamp> on branch dsh-staging/<timestamp>, checks
# host dependencies (git, Node, pnpm) and offers to install a missing pnpm, runs
# `pnpm install` (no build — the `bin/dsh` launcher runs the TypeScript source
# through the repo's own tsx), points the stable `~/.dsh/source/current` symlink
# `pnpm install`, points the stable `~/.dsh/source/current` symlink
# at that staging worktree and symlinks `dsh` onto PATH at `current/bin/dsh`,
# records your API credentials in the Harness home (`~/.dsh`) dsh reads at boot,
# and drops you into `dsh`. Keeping every checkout under ~/.dsh/source keeps
# successive upgrades in one place instead of scattered sibling clones, and lets
# staging worktrees share the master clone's object store. The PATH symlink
# resolves through `current`, so an upgrade repoints one stable symlink instead
# of relinking PATH: the `dsh` on PATH never moves and can never dangle.
# and lets you launch the Web UI or TUI. The Web choice builds the repository
# artifacts first; the TUI runs directly from TypeScript source through the
# repo's own tsx. Keeping every checkout under ~/.dsh/source keeps successive
# upgrades in one place instead of scattered sibling clones, and lets staging
# worktrees share the master clone's object store. The PATH symlink resolves through
# `current`, so an upgrade repoints one stable symlink instead of relinking PATH:
# the `dsh` on PATH never moves and can never dangle.
#
# When run from inside an existing checkout (e.g. `sh scripts/install.sh` rather
# than `curl ... | sh`) it reuses that checkout in place and skips the
# clone/worktree setup, leaving the working tree untouched and linking `dsh`
# straight at that checkout's `bin/dsh` (no `current` indirection — the checkout
# is not a managed staging worktree under the source container); DSH_REF is
# ignored in that mode. Setting DSH_SOURCE to a different directory opts back
# into the normal clone/worktree path.
# than `curl ... | sh`) it never clones and never touches that working tree;
# DSH_REF is ignored. Instead it *adopts* the checkout: `git rev-parse
# --git-common-dir` resolves the repository behind it (for a linked worktree that
# is the real clone, not the worktree), and a fresh staging worktree branched
# from the checkout's HEAD lands in the source container beside `current`. The
# container owns staging worktrees and `current`; the clone is discovered, not
# owned, so an arbitrary clone (~/src/dsh) and a managed one converge on one
# layout and stay upgradable. Adoption carries committed work only: the staging
# worktree branches from HEAD, so uncommitted changes stay in the checkout.
# Setting DSH_SOURCE to a different directory opts back into the normal
# clone/worktree path.
#
# Adopting an arbitrary clone leaves the container not self-contained: its
# staging worktrees hold an absolute gitdir pointer into that clone, so deleting
# it breaks them. `git worktree list` in that clone is the record of which
# worktrees depend on it.
#
# When run through `curl | sh` the script text arrives on stdin, so every
# prompt and the final launch read the controlling terminal (/dev/tty) directly;
@@ -43,16 +54,16 @@ set -eu
DSH_REF=${DSH_REF:-master}
DSH_REPO=${DSH_REPO:-https://github.com/deepseek-harness/deepseek-harness.git}
# DSH_SOURCE is the container directory that holds the master clone and every
# staging worktree; DSH_MASTER is the one real clone inside it. Remember whether
# the caller pinned the source container before defaulting it, so in-repo
# detection only repoints an unset DSH_SOURCE.
# DSH_SOURCE is the staging-worktree container and the default home of `current`.
# DSH_MASTER names the main clone: clone mode defaults it inside DSH_SOURCE,
# while adoption discovers an existing clone anywhere on disk. Remember whether
# DSH_SOURCE was explicit so a different path selects clone mode.
if [ -n "${DSH_SOURCE:-}" ]; then DSH_SOURCE_EXPLICIT=1; else DSH_SOURCE_EXPLICIT=0; fi
DSH_SOURCE=${DSH_SOURCE:-$HOME/.dsh/source}
DSH_MASTER=${DSH_MASTER:-$DSH_SOURCE/master}
# The stable symlink the PATH launcher resolves through: PATH -> current/bin/dsh
# -> <staging>/bin/dsh. Fresh installs and upgrades repoint this one symlink; the
# PATH launcher itself is written once and never moves. In-repo reuse ignores it.
# The stable symlink the PATH launcher resolves through: PATH/dsh ->
# current/bin/dsh -> <staging>/bin/dsh. Installs and upgrades repoint `current`;
# the PATH target remains current/bin/dsh.
DSH_CURRENT=${DSH_CURRENT:-$DSH_SOURCE/current}
DSH_BIN_DIR=${DSH_BIN_DIR:-$HOME/.local/bin}
# One UTC basic timestamp names this install's staging branch and worktree.
@@ -60,26 +71,44 @@ DSH_STAMP=$(date -u +%Y%m%dT%H%M%SZ)
DSH_STAGING_BRANCH=dsh-staging/$DSH_STAMP
DSH_STAGING=$DSH_SOURCE/staging-$DSH_STAMP
# --- path helpers ---------------------------------------------------------------
# Every path comparison below runs on physical paths. Git always reports resolved
# paths, so comparing one against an unresolved path disagrees whenever a symlink
# sits anywhere above the checkout — a symlinked home directory is enough, and
# macOS reaches every mktemp path that way through /var -> private/var. The
# mismatch silently misclassifies an existing managed install as a foreign clone
# and builds a second container beside the real one.
# `git rev-parse --path-format=absolute` would do this, but it needs git 2.31+.
#
# A not-yet-created directory (the container on a fresh install) has no physical
# path. Falling back here rather than at each call site keeps every caller a
# plain assignment, so no site can compare against an empty path by forgetting
# its own fallback.
resolve_dir() { CDPATH= cd -- "$1" 2>/dev/null && pwd -P || printf '%s\n' "$1"; }
# --- in-repo detection ---------------------------------------------------------
# Under `curl ... | sh` the script text arrives on stdin, so $0 is the shell
# name and no file path resolves; running a checked-out copy (`sh
# scripts/install.sh`) makes $0 the script file. When $0 is a readable file whose
# parent is a scripts/ dir inside a real dsh checkout (bin/dsh launcher present),
# reuse that checkout in place — link `dsh` straight at it and skip the
# clone/worktree setup. An explicit DSH_SOURCE pointing elsewhere opts back into
# the clone/worktree path.
# this is in-repo mode: never clone, never touch that working tree. An explicit
# DSH_SOURCE pointing elsewhere opts back into the clone/worktree path.
IN_REPO=0
DSH_CHECKOUT=''
if [ -f "$0" ]; then
_self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P) || _self_dir=''
_self_dir=$(resolve_dir "$(dirname -- "$0")")
if [ -n "$_self_dir" ]; then
# Physical without its own resolve_dir: dirname is textual, so trimming a
# resolved path leaves one. The comparison below depends on that.
_repo_root=$(dirname -- "$_self_dir")
if [ "$(basename -- "$_self_dir")" = scripts ] \
&& [ -x "$_repo_root/bin/dsh" ] && [ -f "$_repo_root/scripts/install.sh" ]; then
if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$DSH_SOURCE" = "$_repo_root" ]; then
# Compare the explicit DSH_SOURCE physically: an unresolved but equivalent
# path must still count as "the caller meant this checkout".
_src_resolved=$(resolve_dir "$DSH_SOURCE")
if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$_src_resolved" = "$_repo_root" ]; then
IN_REPO=1
# In-repo reuse links `dsh` at this checkout as-is; the master/staging
# split applies only to fresh clone installs.
DSH_STAGING=$_repo_root
DSH_CHECKOUT=$_repo_root
fi
fi
fi
@@ -148,7 +177,7 @@ confirm() {
printf '%s\n' "${B}DeepSeek Harness — dsh installer${RST}"
if [ "$IN_REPO" = 1 ]; then
printf '%ssource %s (in-repo reuse) @ %s%s\n' "$DIM" "$DSH_STAGING" "$DSH_REF" "$RST"
printf '%scheckout %s%s\n' "$DIM" "$DSH_CHECKOUT" "$RST"
else
printf '%smaster %s @ %s%s\n' "$DIM" "$DSH_MASTER" "$DSH_REF" "$RST"
printf '%sstaging %s%s\n' "$DIM" "$DSH_STAGING" "$RST"
@@ -203,42 +232,84 @@ else
fi
fi
# --- 2. clone the master and lay out the staging worktree ---------------------
# Fresh installs keep one real clone at $DSH_MASTER and check the running code
# out as a git worktree at $DSH_STAGING, so every checkout lives under
# $DSH_SOURCE and shares one object store. In-repo reuse links `dsh` at the
# existing checkout untouched.
# --- 2. resolve the repository and lay out the staging worktree ---------------
# The source container owns staging worktrees and `current`; the repository is
# *discovered*, not owned. A curl install discovers it by cloning to $DSH_MASTER;
# in-repo adoption discovers it from the checkout. Both then run one shared
# worktree/exclude/lock path, so an arbitrary clone and a managed install
# converge on the same layout.
#
# REPO_COMMON is the shared git directory every worktree of the repository
# points at; REPO_ROOT is the working tree that owns it (the master clone).
REPO_COMMON=''
REPO_ROOT=''
if [ "$IN_REPO" = 1 ]; then
step "Using existing checkout at $DSH_STAGING"
info "running from inside the repo — skipping clone (DSH_REF ignored, working tree left untouched)"
step "Using existing checkout at $DSH_CHECKOUT"
info "running from inside the repo — never cloning, and DSH_REF is ignored"
# Resolve the repository behind the checkout. --git-common-dir returns the
# SHARED git dir, so a linked worktree resolves to the real clone rather than
# itself; it is relative for a plain clone, so anchor it before resolving.
# Require the resolved git dir to exist: resolve_dir echoes its argument back
# for a missing path, so test the directory rather than the returned string.
if _common=$(git -C "$DSH_CHECKOUT" rev-parse --git-common-dir 2>/dev/null) && [ -n "$_common" ]; then
case "$_common" in /*) ;; *) _common=$DSH_CHECKOUT/$_common ;; esac
[ -d "$_common" ] && REPO_COMMON=$(resolve_dir "$_common")
fi
[ -n "$REPO_COMMON" ] || die "$DSH_CHECKOUT is not a git repository — cannot adopt it."
REPO_ROOT=$(dirname -- "$REPO_COMMON")
# Reuse the container when the repository already lives inside it (the normal
# managed install re-running its own script); otherwise treat that clone as
# its own master and keep worktrees in the default container.
_src_resolved=$(resolve_dir "$DSH_SOURCE")
case "$REPO_ROOT/" in
"$_src_resolved"/*) info "repository $REPO_ROOT is already inside $DSH_SOURCE" ;;
*) info "adopting clone $REPO_ROOT as its own master" ;;
esac
DSH_MASTER=$REPO_ROOT
else
step "Fetching source into $DSH_MASTER"
if [ -d "$DSH_MASTER/.git" ]; then
info "existing master clone found — updating"
git -C "$DSH_MASTER" fetch origin "$DSH_REF"
# Reset the master checkout to the freshly fetched tip. FETCH_HEAD (not
# origin/<ref>) so this resolves for a tag as well as a branch, and -B makes
# the re-run idempotent whether or not DSH_REF changed since the last install.
git -C "$DSH_MASTER" checkout -q -B "$DSH_REF" FETCH_HEAD
else
mkdir -p "$DSH_SOURCE"
git clone --branch "$DSH_REF" "$DSH_REPO" "$DSH_MASTER"
step "Fetching source into $DSH_MASTER"
if [ -d "$DSH_MASTER/.git" ]; then
info "existing master clone found — updating"
git -C "$DSH_MASTER" fetch origin "$DSH_REF"
# Reset the master checkout to the freshly fetched tip. FETCH_HEAD (not
# origin/<ref>) so this resolves for a tag as well as a branch, and -B makes
# the re-run idempotent whether or not DSH_REF changed since the last install.
git -C "$DSH_MASTER" checkout -q -B "$DSH_REF" FETCH_HEAD
else
mkdir -p "$DSH_SOURCE"
git clone --branch "$DSH_REF" "$DSH_REPO" "$DSH_MASTER"
fi
# Physical on both branches: REPO_ROOT is compared against resolved paths
# below, and REPO_COMMON stays symmetric with it so neither can be read as
# carrying a different kind of path.
REPO_COMMON=$(resolve_dir "$DSH_MASTER/.git")
REPO_ROOT=$(resolve_dir "$DSH_MASTER")
fi
step "Adding staging worktree at $DSH_STAGING"
[ -e "$DSH_STAGING" ] && die "staging path $DSH_STAGING already exists — remove it or set DSH_SOURCE elsewhere, then re-run."
# The staging worktree owns the branch dsh runs from; the master clone stays on
# $DSH_REF as the fetch/upgrade base. Exclude the per-worktree merge lock in the
# master clone's info/exclude, which every linked worktree inherits.
git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" FETCH_HEAD 2>/dev/null \
|| git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD
_exclude="$DSH_MASTER/.git/info/exclude"
mkdir -p "$DSH_SOURCE"
# The staging worktree owns the branch dsh runs from; the repository stays as
# the fetch/upgrade base and is never a launcher target. A clone install
# branches from the ref it just fetched; adoption branches from the checkout's
# HEAD so the contributor's committed work is what runs.
if [ "$IN_REPO" = 1 ]; then
git -C "$DSH_CHECKOUT" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD
else
git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" FETCH_HEAD 2>/dev/null \
|| git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD
fi
# Exclude the per-worktree merge lock in the shared git dir's info/exclude,
# which every linked worktree inherits.
_exclude="$REPO_COMMON/info/exclude"
if [ -f "$_exclude" ] && ! grep -qxF '.agents/merge.lock' "$_exclude" 2>/dev/null; then
printf '.agents/merge.lock\n' >>"$_exclude"
fi
mkdir -p "$DSH_STAGING/.agents"
: >"$DSH_STAGING/.agents/merge.lock"
fi
# --- 3. install dependencies (no build; the launcher runs from source) --------
step "Installing dependencies with pnpm (this can take a while)"
@@ -247,29 +318,29 @@ step "Installing dependencies with pnpm (this can take a while)"
[ -x "$DSH_STAGING/bin/dsh" ] || die "launcher $DSH_STAGING/bin/dsh missing after install — is DSH_REF a branch that ships apps/cli?"
# --- 4. put `dsh` on PATH ------------------------------------------------------
# Clone installs go through a stable `current` symlink so an upgrade repoints
# Every install goes through a stable `current` symlink so an upgrade repoints
# one symlink (current -> new worktree) and the PATH launcher never moves:
# PATH/dsh -> current/bin/dsh -> <staging>/bin/dsh. In-repo reuse links PATH
# straight at the checkout, since that checkout is not a managed worktree.
# PATH/dsh -> current/bin/dsh -> <staging>/bin/dsh.
step "Linking dsh into $DSH_BIN_DIR"
mkdir -p "$DSH_BIN_DIR"
if [ "$IN_REPO" = 1 ]; then
DSH_LAUNCH_TARGET=$DSH_STAGING/bin/dsh
ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh"
info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET"
else
# Point `current` at this staging worktree with `ln -sfn`: -f replaces an
# existing `current` (re-run or upgrade) and -n stops `ln` from dereferencing
# an existing symlink-to-directory and dropping the new link *inside* the old
# worktree. `mv` is unusable here — BSD/macOS `mv` follows the existing dir
# symlink the same way. The swap is one unlink+symlink pair on a local fs; the
# installer holds no other process racing this path.
ln -sfn "$DSH_STAGING" "$DSH_CURRENT"
info "pointed $DSH_CURRENT -> $DSH_STAGING"
DSH_LAUNCH_TARGET=$DSH_CURRENT/bin/dsh
ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh"
info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET"
fi
# The launcher must resolve to a staging worktree, never to the repository
# itself: an upgrade repoints `current`, so aliasing it onto the master clone
# would make every upgrade rewrite the fetch/upgrade base. Compare physical
# paths — a symlinked or unresolved path would slip past a string compare.
_staging_resolved=$(resolve_dir "$DSH_STAGING")
[ "$_staging_resolved" = "$REPO_ROOT" ] \
&& die "refusing to point $DSH_CURRENT at the repository $REPO_ROOT — the launcher must resolve to a staging worktree."
# Point `current` at this staging worktree with `ln -sfn`: -f replaces an
# existing `current` (re-run or upgrade) and -n stops `ln` from dereferencing
# an existing symlink-to-directory and dropping the new link *inside* the old
# worktree. `mv` is unusable here — BSD/macOS `mv` follows the existing dir
# symlink the same way. The swap is one unlink+symlink pair on a local fs; the
# installer holds no other process racing this path.
ln -sfn "$DSH_STAGING" "$DSH_CURRENT"
info "pointed $DSH_CURRENT -> $DSH_STAGING"
DSH_LAUNCH_TARGET=$DSH_CURRENT/bin/dsh
ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh"
info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET"
case ":$PATH:" in
*":$DSH_BIN_DIR:"*) ON_PATH=1 ;;
@@ -343,12 +414,33 @@ if [ "${SKIP_CREDS:-0}" != 1 ]; then
fi
fi
# --- 6. launch -----------------------------------------------------------------
# --- 6. choose and launch an interface -----------------------------------------
step "Done"
if [ "$HAS_TTY" = 1 ]; then
info "launching dsh — run 'dsh' anytime to start again"
exec "$DSH_BIN_DIR/dsh" </dev/tty
printf ' 1) Web UI (recommended)\n'
printf ' 2) TUI\n'
while :; do
LAUNCH_INTERFACE=$(ask "Choose an interface [1/2]:" 1)
case "$LAUNCH_INTERFACE" in
1|web|Web|WEB)
step "Building DeepSeek Harness for Web UI"
( cd "$DSH_STAGING" && pnpm run build )
info "launching Web UI — run 'dsh web' anytime to start again"
exec "$DSH_BIN_DIR/dsh" web </dev/tty
;;
2|tui|Tui|TUI)
info "launching TUI — run 'dsh' anytime to start again"
exec "$DSH_BIN_DIR/dsh" </dev/tty
;;
*)
warn "choose 1 for Web UI or 2 for TUI"
;;
esac
done
else
info "install complete. Start it with:"
info "install complete. Build and start the Web UI with:"
printf ' (cd %s && pnpm run build)\n' "$DSH_STAGING"
printf ' %s web\n' "$DSH_BIN_DIR/dsh"
info "or start the TUI with:"
printf ' %s\n' "$DSH_BIN_DIR/dsh"
fi

View File

@@ -0,0 +1,98 @@
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { flattenDiagnosticMessageText, parseConfigFileTextToJson } from 'typescript'
import { describe, expect, it } from 'vitest'
type Rules = Record<string, unknown>
interface Profile {
readonly count: number
readonly indexes: readonly number[]
readonly sha256: string
}
// A one-time audit against eslint.config.mjs blob 696b08282885296830189fdafe7051a356806fc2
// mapped @typescript-eslint/* to typescript/* and four extension rules to their
// Oxlint core equivalents. These fingerprints pin the resulting repository
// contract; they do not re-evaluate that deleted baseline or track its preset.
const profiles = {
source: {
count: 88,
indexes: [0, 1, 4, 5],
sha256: 'da1dfd77cb6eb66be93d8d3820f9b9b68b7aa391c24680f8851c0910298f9e3b',
},
example: {
count: 87,
indexes: [0, 1, 2, 4, 5],
sha256: '6a2606053bc1ec1de3b02611de88ea51d201dac13a1f193e4934d33c08b95f08',
},
test: {
count: 83,
indexes: [0, 3, 4, 5],
sha256: '7995e14926a36c40bd65c474637735222a95fb030395681685f03060e50a7b78',
},
} as const satisfies Record<string, Profile>
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isUnknownArray(value: unknown): value is unknown[] {
return Array.isArray(value)
}
function severity(value: unknown): 0 | 1 | 2 {
const level = isUnknownArray(value) ? value[0] : value
if (level === 'off' || level === 0) return 0
if (level === 'warn' || level === 'warning' || level === 1) return 1
if (level === 'error' || level === 2) return 2
throw new Error(`unsupported lint severity: ${JSON.stringify(level)}`)
}
function normalizedRules(rules: Rules): Rules {
return Object.fromEntries(Object.entries(rules)
.filter(([, value]) => severity(value) > 0)
.sort(([left], [right]) => left.localeCompare(right))
.map(([name, value]) => {
const options = isUnknownArray(value) ? value.slice(1) : []
return [name, [severity(value), ...options]]
}))
}
function mergedRules(overrides: readonly unknown[], indexes: readonly number[]): Rules {
const merged: Rules = {}
for (const index of indexes) {
const override = overrides[index]
if (!isRecord(override) || !isRecord(override.rules)) {
throw new Error(`.oxlintrc.json override ${index} must contain a rules object`)
}
Object.assign(merged, override.rules)
}
return normalizedRules(merged)
}
describe('Oxlint repository rule fingerprint', () => {
const path = fileURLToPath(new URL('../.oxlintrc.json', import.meta.url))
const result = parseConfigFileTextToJson(path, readFileSync(path, 'utf8'))
if (result.error !== undefined) {
throw new Error(flattenDiagnosticMessageText(result.error.messageText, '\n'))
}
const parsed: unknown = result.config
if (!isRecord(parsed) || !Array.isArray(parsed.overrides)) {
throw new Error('.oxlintrc.json must contain an overrides array')
}
const overrides: readonly unknown[] = parsed.overrides
it('pins the complete override shape', () => {
expect(overrides).toHaveLength(6)
})
it.each(Object.entries(profiles))('pins the %s rule profile', (_name, profile) => {
const rules = mergedRules(overrides, profile.indexes)
const fingerprint = createHash('sha256').update(JSON.stringify(rules)).digest('hex')
expect(Object.keys(rules)).toHaveLength(profile.count)
expect(fingerprint).toBe(profile.sha256)
})
})

View File

@@ -0,0 +1,250 @@
import { spawnSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { join, relative } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { flattenDiagnosticMessageText, parseConfigFileTextToJson } from 'typescript'
import { describe, expect, it } from 'vitest'
const repositoryRoot = fileURLToPath(new URL('..', import.meta.url))
const eslintCli = fileURLToPath(new URL('../node_modules/eslint/bin/eslint.js', import.meta.url))
const oxlintCli = fileURLToPath(new URL('../node_modules/oxlint/bin/oxlint', import.meta.url))
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isUnknownArray(value: unknown): value is unknown[] {
return Array.isArray(value)
}
function runStagedFormatter(paths: readonly string[]) {
return spawnSync(process.execPath, [eslintCli, '--config', 'eslint.format.config.mjs', '--fix', '--no-warn-ignored', ...paths], {
cwd: repositoryRoot,
encoding: 'utf8',
env: { ...process.env, NO_COLOR: '1' },
})
}
function runOxlint(args: readonly string[], env: NodeJS.ProcessEnv = {}) {
return spawnSync(process.execPath, [oxlintCli, ...args], {
cwd: repositoryRoot,
encoding: 'utf8',
env: { ...process.env, NO_COLOR: '1', ...env },
})
}
function normalizedOutput(result: ReturnType<typeof runOxlint>): string {
return `${result.stdout}${result.stderr}`.replaceAll('\\', '/')
}
async function writeContractConfig(suffix: string): Promise<string> {
const path = join(repositoryRoot, `.oxlintrc.contract-${suffix}.json`)
await writeFile(path, JSON.stringify({ extends: ['./.oxlintrc.json'], ignorePatterns: [] }))
return path
}
describe('Oxlint executable contract', () => {
it('discovers the owning TypeScript project for every file class', async () => {
const suffix = randomUUID()
const configPath = await writeContractConfig(suffix)
const probes = [
['host package source', 'packages/fs/fs-policy/src', 'packages/fs/fs-policy/tsconfig.json'],
['host package test', 'packages/fs/fs-policy/tests', 'tsconfig.host.json'],
['client package source', 'packages/client/ui-primitives/src', 'packages/client/ui-primitives/tsconfig.json'],
['client package test', 'packages/client/ui-trajectory/tests', 'tsconfig.client.json'],
['example', 'examples/headless-agent/tests', 'tsconfig.host.json'],
['website', 'website', 'tsconfig.host.json'],
] as const
const source = `export function probePromise(): Promise<void> {
return Promise.resolve()
}
probePromise()
`
try {
const paths: Array<readonly [label: string, path: string, tsconfig: string]> = []
for (const [label, parent, tsconfig] of probes) {
const path = join(repositoryRoot, parent, `oxlint-contract-${suffix}.ts`)
await writeFile(path, source)
paths.push([label, relative(repositoryRoot, path), tsconfig])
}
const clientScript = 'scripts/client-bundle-purity.spec.ts'
const result = runOxlint([
'--config',
relative(repositoryRoot, configPath),
'--format',
'unix',
...paths.map(([, path]) => path),
clientScript,
], { OXC_LOG: 'debug' })
const output = normalizedOutput(result)
expect(result.error).toBeUndefined()
expect(result.status, output).toBe(1)
for (const [label, path, tsconfig] of paths) {
expect(output, label).toContain(`${path.replaceAll('\\', '/')}:5:1: Promises must be awaited`)
expect(output, `${label} project`).toContain(
`Got tsconfig for file ${join(repositoryRoot, path).replaceAll('\\', '/')}: ${join(repositoryRoot, tsconfig).replaceAll('\\', '/')}`,
)
}
expect(output.match(/typescript\(no-floating-promises\)/g)).toHaveLength(probes.length)
expect(output, 'client aggregate script project').toContain(
`Got tsconfig for file ${join(repositoryRoot, clientScript).replaceAll('\\', '/')}: ${join(repositoryRoot, 'tsconfig.client.json').replaceAll('\\', '/')}`,
)
expect(output).not.toContain('Unmatched file:')
} finally {
await Promise.all([
...probes.map(([, parent]) => rm(join(repositoryRoot, parent, `oxlint-contract-${suffix}.ts`), { force: true })),
rm(configPath, { force: true }),
])
}
}, 20_000)
it('runs JavaScript compatibility and nursery rules', async () => {
const suffix = randomUUID()
const configPath = await writeContractConfig(suffix)
const path = join(repositoryRoot, 'scripts', `oxlint-contract-${suffix}.ts`)
const source = `export function firstProbe(): number {
const first = 1
const second = 2
return first + second
}
export function secondProbe(): number {
const first = 1
const second = 2
return first + second
}
export function hasValue(value: string): boolean {
return value !== undefined
}
export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1
`
try {
await writeFile(path, source)
const result = runOxlint([
'--config',
relative(repositoryRoot, configPath),
'--format',
'unix',
relative(repositoryRoot, path),
])
const output = normalizedOutput(result)
expect(result.error).toBeUndefined()
expect(result.status, output).toBe(1)
expect(output).toContain('@stylistic(max-len)')
expect(output).toContain('sonarjs(no-identical-functions)')
expect(output).toContain('typescript(no-unnecessary-condition)')
} finally {
await Promise.all([
rm(path, { force: true }),
rm(configPath, { force: true }),
])
}
}, 20_000)
it('keeps formatter rules aligned with Oxlint validation', async () => {
const oxlintPath = join(repositoryRoot, '.oxlintrc.json')
const result = parseConfigFileTextToJson(oxlintPath, await readFile(oxlintPath, 'utf8'))
if (result.error !== undefined) {
throw new Error(flattenDiagnosticMessageText(result.error.messageText, '\n'))
}
const parsed = result.config as unknown
if (!isRecord(parsed) || !isUnknownArray(parsed.overrides)) {
throw new Error('.oxlintrc.json must contain an overrides array')
}
const stylisticOverride = parsed.overrides.find((value: unknown) =>
isRecord(value) && isRecord(value.rules) && '@stylistic/max-len' in value.rules)
if (!isRecord(stylisticOverride) || !isRecord(stylisticOverride.rules)) {
throw new Error('.oxlintrc.json must contain the @stylistic validator override')
}
const validatorRules = { ...stylisticOverride.rules }
const maxLen = validatorRules['@stylistic/max-len']
delete validatorRules['@stylistic/max-len']
const formatterUrl = pathToFileURL(join(repositoryRoot, 'eslint.format.config.mjs')).href
const formatterModule = await import(formatterUrl) as unknown
if (!isRecord(formatterModule) || !isUnknownArray(formatterModule.default)) {
throw new Error('eslint.format.config.mjs must default-export a config array')
}
const formatterOverride = formatterModule.default.find((value: unknown) => isRecord(value) && isRecord(value.rules))
if (!isRecord(formatterOverride) || !isRecord(formatterOverride.rules)) {
throw new Error('eslint.format.config.mjs must contain a rules object')
}
expect(validatorRules).toStrictEqual(formatterOverride.rules)
expect(maxLen).toStrictEqual(['error', { code: 140, ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true }])
})
it('reports an unused suppression', async () => {
const suffix = randomUUID()
const configPath = await writeContractConfig(suffix)
const path = join(repositoryRoot, 'scripts', `oxlint-contract-${suffix}.ts`)
try {
await writeFile(path, '// oxlint-disable-next-line no-console\nexport const value = 1\n')
const result = runOxlint([
'--config',
relative(repositoryRoot, configPath),
'--format',
'unix',
relative(repositoryRoot, path),
])
const output = normalizedOutput(result)
expect(result.error).toBeUndefined()
expect(result.status, output).toBe(0)
expect(output).toContain('Unused oxlint-disable directive')
} finally {
await Promise.all([
rm(path, { force: true }),
rm(configPath, { force: true }),
])
}
})
it('accepts an ignored-only staged selection', () => {
const result = runOxlint([
'--fix',
'--no-error-on-unmatched-pattern',
'scripts/install-lefthook.mjs',
])
expect(result.error).toBeUndefined()
expect(result.status, normalizedOutput(result)).toBe(0)
})
it('applies staged stylistic fixes before Oxlint validation', async () => {
const suffix = randomUUID()
const configPath = await writeContractConfig(suffix)
const directory = join(repositoryRoot, 'scripts', `.oxlint-contract-${suffix}`)
const path = join(directory, 'fix.ts')
try {
await mkdir(directory, { recursive: true })
await writeFile(path, 'const value={answer:1}; \nconsole.log(value)\n')
const relativePath = relative(repositoryRoot, path)
const formatResult = runStagedFormatter([relativePath])
const lintResult = runOxlint(['--config', relative(repositoryRoot, configPath), '--fix', relativePath])
expect(formatResult.error).toBeUndefined()
expect(formatResult.status, normalizedOutput(formatResult)).toBe(0)
expect(lintResult.error).toBeUndefined()
expect(lintResult.status, normalizedOutput(lintResult)).toBe(0)
await expect(readFile(path, 'utf8')).resolves.toBe('const value={ answer:1 }\nconsole.log(value)\n')
} finally {
await Promise.all([
rm(directory, { recursive: true, force: true }),
rm(configPath, { force: true }),
])
}
}, 20_000)
})

View File

@@ -197,7 +197,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(18)
expect(translated).toHaveLength(20)
expect(translated.every(page => page.source.endsWith('.zh.md'))).toBe(true)
expect(fallbacks.map(page => page.source).sort()).toEqual([
'docs/core-data-structures/commands.md',
@@ -217,20 +217,35 @@ describe('docsPages locale routes', () => {
expect(english?.section).toBe('Cordis Core API')
}
})
it('includes persistence event headings in both locale outlines', () => {
const pages = docsPages.filter(page => page.source === 'docs/persistence-catalog.md')
expect(pages).toHaveLength(2)
expect(pages.map(page => page.outline)).toEqual(['deep', 'deep'])
})
})
describe('addProjectionFrontmatter', () => {
it('adds frontmatter to an ordinary Markdown page', () => {
expect(addProjectionFrontmatter('# Guide\n', 'docs/guide.md')).toBe(
expect(addProjectionFrontmatter('# Guide\n', { source: 'docs/guide.md' })).toBe(
'---\neditSource: "docs/guide.md"\n---\n\n# Guide\n',
)
})
it('extends existing VitePress frontmatter', () => {
expect(addProjectionFrontmatter('---\nlayout: home\n---\n', 'docs/index.md')).toBe(
expect(addProjectionFrontmatter('---\nlayout: home\n---\n', { source: 'docs/index.md' })).toBe(
'---\neditSource: "docs/index.md"\nlayout: home\n---\n',
)
})
it('adds the page-specific outline depth from the publication manifest', () => {
expect(addProjectionFrontmatter('# Catalog\n', {
source: 'docs/catalog.md',
outline: [2, 4],
})).toBe(
'---\neditSource: "docs/catalog.md"\noutline: [2,4]\n---\n\n# Catalog\n',
)
})
})
describe('projectedPageContent', () => {

View File

@@ -259,13 +259,16 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions)
* Record the canonical edit target in VitePress frontmatter.
*
* @param markdown Projected Markdown content.
* @param sourcePath Repository-relative canonical source path.
* @returns Markdown with an `editSource` frontmatter field.
* @param page Publication manifest entry for the content.
* @returns Markdown with projection-owned frontmatter fields.
*/
export function addProjectionFrontmatter(markdown: string, sourcePath: string): string {
const field = `editSource: ${JSON.stringify(sourcePath)}`
if (markdown.startsWith('---\n')) return markdown.replace('---\n', `---\n${field}\n`)
return `---\n${field}\n---\n\n${markdown}`
export function addProjectionFrontmatter(markdown: string, page: Pick<DocsPage, 'source' | 'outline'>): string {
const fields = [
`editSource: ${JSON.stringify(page.source)}`,
...(page.outline === undefined ? [] : [`outline: ${JSON.stringify(page.outline)}`]),
].join('\n')
if (markdown.startsWith('---\n')) return markdown.replace('---\n', `---\n${fields}\n`)
return `---\n${fields}\n---\n\n${markdown}`
}
/**
@@ -317,6 +320,6 @@ export function projectDocs(): void {
repoRoot: root,
repositoryRef,
})
writeFileSync(output, addProjectionFrontmatter(projectedPageContent(projected, page), page.source))
writeFileSync(output, addProjectionFrontmatter(projectedPageContent(projected, page), page))
}
}

View File

@@ -42,9 +42,22 @@ function withPnpmEntrypoint<T>(action: () => T): T {
}
}
function withEnv<T>(name: string, value: string | undefined, action: () => T): T {
const previous = process.env[name]
if (value === undefined) Reflect.deleteProperty(process.env, name)
else process.env[name] = value
try {
return action()
} finally {
if (previous === undefined) Reflect.deleteProperty(process.env, name)
else process.env[name] = previous
}
}
describe('gate graph validation', () => {
it.each([
'ci-primary',
'ci-linux-primary',
'ci-static',
'ci-lint',
'ci-coverage',
@@ -96,30 +109,103 @@ describe('gate graph validation', () => {
})
})
describe('Node 24 consumer graph', () => {
it('owns the seven-command pool and orders restored-artifact consumers', () => {
describe('Oxlint gate', () => {
it('uses the package script when no worker bound is configured', () => {
const subject = withEnv('DSH_OXLINT_THREADS', undefined, () =>
withPnpmEntrypoint(() => gatesForMode('ci-lint')[0]))
expect(subject).toMatchObject({
id: 'lint',
displayCommand: 'pnpm run lint',
command: process.execPath,
args: ['/private/pnpm.cjs', 'run', 'lint'],
})
})
it('surfaces the configured worker bound on the shared package script', () => {
const subject = withEnv('DSH_OXLINT_THREADS', '4', () =>
withPnpmEntrypoint(() => gatesForMode('ci-lint')[0]))
expect(subject).toMatchObject({
id: 'lint',
displayCommand: 'DSH_OXLINT_THREADS=4 pnpm run lint',
command: process.execPath,
args: ['/private/pnpm.cjs', 'run', 'lint'],
})
})
})
describe('Node compatibility graph', () => {
it('runs the jsdom environment smoke on every advertised Node line', () => {
const subject = withPnpmEntrypoint(() => gatesForMode('node-compat'))
expect(subject.find(item => item.id === 'vitest-jsdom-smoke')).toMatchObject({
label: 'Vitest jsdom smoke',
args: [
'/private/pnpm.cjs',
'exec',
'vitest',
'run',
'scripts/vitest-environment.compat.spec.ts',
],
})
})
})
describe('Node 24 lane ownership', () => {
it('keeps the static lane source-only', () => {
const subject = withPnpmEntrypoint(() => gatesForMode('ci-static'))
expect(subject.map(item => item.id)).not.toContain('build')
expect(subject.map(item => item.id)).not.toContain('doc-typecheck')
})
it('owns the build and orders its artifact consumers', () => {
const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers'))
expect(defaultConcurrency('ci-consumers', subject.length, 4)).toEqual({
workers: 7,
workers: 10,
source: 'ci-consumers gate count',
})
expect(subject.map(item => item.id)).toEqual([
'lint-and-duplication',
'build',
'node-compat',
'snapshot',
'publint',
'node-next-types',
'built-package-invariants',
'lint-and-duplication',
'snapshot',
'web-snapshot',
'doc-typecheck',
'node-next-types',
'built-bin-smoke',
])
expect(subject.find(item => item.id === 'publint')?.needs).toBeUndefined()
expect(subject.find(item => item.id === 'publint')?.needs).toEqual(['build'])
expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint'])
expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants'])
for (const id of ['snapshot', 'node-next-types', 'built-bin-smoke']) {
for (const id of ['snapshot', 'web-snapshot', 'doc-typecheck', 'node-next-types', 'built-bin-smoke']) {
expect(subject.find(item => item.id === id)?.needs).toEqual(['built-package-invariants'])
}
expect(subject.find(item => item.id === 'snapshot')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' })
expect(subject.find(item => item.id === 'doc-typecheck')?.env).toEqual({
DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1',
})
expect(subject.find(item => item.id === 'web-snapshot')).toMatchObject({
displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built',
env: { DSH_SNAPSHOT: 'replay' },
})
})
})
describe('Linux primary graph', () => {
it('adds the same compare-only web gate after built client artifacts', () => {
const subject = withPnpmEntrypoint(() => gatesForMode('ci-linux-primary'))
const web = subject.find(item => item.id === 'web-snapshot')
expect(web).toMatchObject({
displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built',
env: { DSH_SNAPSHOT: 'replay' },
needs: ['built-package-invariants'],
})
})
})

View File

@@ -9,10 +9,12 @@ import { spawn } from 'node:child_process'
import { availableParallelism } from 'node:os'
import { resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './coverage-exempt.ts'
/** A named aggregate exposed by the gate runner. */
export type Mode =
| 'ci-primary'
| 'ci-linux-primary'
| 'ci-static'
| 'ci-lint'
| 'ci-coverage'
@@ -97,6 +99,7 @@ async function main(args: string[]): Promise<number> {
function parseMode(raw: string | undefined): Mode {
switch (raw) {
case 'ci-primary':
case 'ci-linux-primary':
case 'ci-static':
case 'ci-lint':
case 'ci-coverage':
@@ -112,7 +115,7 @@ function parseMode(raw: string | undefined): Mode {
return raw
default:
throw new Error(
`run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-consumers | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | check-all | doc-sync, got ${JSON.stringify(raw)}.`,
`run-gates: expected mode ci-primary | ci-linux-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-consumers | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | check-all | doc-sync, got ${JSON.stringify(raw)}.`,
)
}
}
@@ -181,10 +184,6 @@ function pnpmInvocation(args: string[]): Pick<Gate, 'command' | 'args'> {
return { command: process.execPath, args: [entrypoint, ...args] }
}
function nodeOptions(...options: string[]): string {
return [process.env.NODE_OPTIONS, ...options].filter(option => option !== undefined && option !== '').join(' ')
}
/**
* Construct the complete gate list for a named aggregate.
* @param selected - aggregate mode to construct.
@@ -194,15 +193,17 @@ export function gatesForMode(selected: Mode): Gate[] {
switch (selected) {
case 'ci-primary':
return ciPrimaryGates()
case 'ci-linux-primary':
return [...ciPrimaryGates(), webSnapshotGate(['built-package-invariants'])]
case 'ci-static':
return ciStaticGates()
return ciStaticGates({ ownsBuild: false })
case 'ci-lint':
return [
lintGate(),
pnpmScript('duplication', 'duplication'),
]
case 'ci-coverage':
return [coverageGate()]
return coverageGates()
case 'ci-snapshot':
return [pnpmScript('build', 'build'), snapshotGate()]
case 'ci-artifacts':
@@ -248,7 +249,7 @@ function ciPrimaryGates(): Gate[] {
pnpmScript('typecheck', 'typecheck'),
lintGate(),
pnpmScript('duplication', 'duplication'),
coverageGate(),
...coverageGates(),
...nodeCompatSmokeGates(),
snapshotGate(),
...docSyncLeafGates(),
@@ -269,14 +270,27 @@ function ciPrimaryGates(): Gate[] {
}
function nodeCompatGates(): Gate[] {
const typecheck = flagEnabled('DSH_NODE_COMPAT_SKIP_TYPECHECK')
? []
: [pnpmScript('typecheck', 'typecheck')]
if (runningNodeMajor() !== 22) {
return [...typecheck, ...nodeCompatSmokeGates()]
}
return [
...flagEnabled('DSH_NODE_COMPAT_SKIP_TYPECHECK') ? [] : [pnpmScript('typecheck', 'typecheck')],
...nodeCompatSmokeGates(),
...typecheck,
pnpmScript('build', 'build', {
...typecheck.length === 0 ? {} : { needs: ['typecheck'] },
}),
pnpmScript('build:web', 'build:web', {
label: 'Web frontend build',
needs: ['build'],
}),
...nodeCompatSmokeGates({ cliSmoke: true }),
]
}
function nodeCompatSmokeGates(): Gate[] {
return [
function nodeCompatSmokeGates(options: { cliSmoke?: boolean } = {}): Gate[] {
const gates: Gate[] = [
pnpmExec('source-worker-smoke', [
'vitest',
'run',
@@ -287,19 +301,57 @@ function nodeCompatSmokeGates(): Gate[] {
'run',
'packages/session-persistence/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' }),
]
if (options.cliSmoke) {
gates.push(
pnpmExec('cli-lazy-search-startup-smoke', [
'vitest',
'run',
'apps/cli/tests/lazy-search-startup.compat.spec.ts',
], {
label: 'CLI lazy-search startup smoke',
env: { DSH_REQUIRE_BUILT_CLI_SMOKE: '1' },
needs: ['build:web'],
}),
)
}
return gates
}
function ciStaticGates(): Gate[] {
/** Active Node major used to scope version-specific compatibility contracts. */
function runningNodeMajor(): number {
const major = Number.parseInt(process.versions.node.split('.')[0] ?? '', 10)
if (!Number.isSafeInteger(major)) {
throw new Error(`run-gates: cannot parse Node version ${JSON.stringify(process.versions.node)}.`)
}
return major
}
function ciStaticGates(options: { ownsBuild: boolean }): Gate[] {
return [
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
pnpmScript('constraints', 'constraints'),
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
pnpmScript('build', 'build'),
...options.ownsBuild ? [pnpmScript('build', 'build')] : [],
...docSyncLeafGates({
docTypecheckNeeds: ['build'],
docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' },
includeDocTypecheck: options.ownsBuild,
...options.ownsBuild
? {
docTypecheckNeeds: ['build'],
docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' },
}
: {},
docsBuildScript: 'docs:build:mpa',
}),
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
@@ -321,25 +373,40 @@ function ciArtifactGates(): Gate[] {
}
function ciConsumerGates(): Gate[] {
const publicArtifacts = ['publint']
const restoredBuild = ['built-package-invariants']
const builtTree = ['build']
const validatedBuild = ['built-package-invariants']
return [
pnpmScript('build', 'build'),
pnpmScript('node-compat', 'check:node-compat', { label: 'Node compatibility' }),
pnpmScript('publint', 'publint', { needs: builtTree }),
builtPackageInvariantsGate(['publint']),
pnpmScript('lint-and-duplication', 'check:ci:lint', {
label: 'lint and duplication',
needs: restoredBuild,
needs: validatedBuild,
}),
snapshotGate(validatedBuild),
webSnapshotGate(validatedBuild),
pnpmScript('doc-typecheck', 'doc-typecheck', {
needs: validatedBuild,
env: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' },
}),
pnpmScript('node-compat', 'check:node-compat', { label: 'Node compatibility' }),
snapshotGate(restoredBuild),
pnpmScript('publint', 'publint'),
pnpmScript('node-next-types', 'verify-node-next-types', {
label: 'node-next types',
needs: restoredBuild,
needs: validatedBuild,
}),
builtPackageInvariantsGate(publicArtifacts),
builtBinSmokeGate(restoredBuild),
builtBinSmokeGate(validatedBuild),
]
}
function webSnapshotGate(needs: string[]): Gate {
return pnpmScript('web-snapshot', 'test:web:built', {
label: 'web browser snapshot',
displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built',
env: { DSH_SNAPSHOT: 'replay' },
needs,
})
}
function ciWindowsBlockingGates(): Gate[] {
return [
pnpmScript('windows-build', 'build', { label: 'build' }),
@@ -362,7 +429,7 @@ function ciWindowsCompleteGates(): Gate[] {
function ciWindowsObservationalGates(): Gate[] {
return [
...ciStaticGates(),
...ciStaticGates({ ownsBuild: true }),
// Linux owns required lint, coverage, and snapshots; Windows omits those duplicates.
pnpmScript('duplication', 'duplication'),
pnpmScript('publint', 'publint', { needs: ['build'] }),
@@ -375,59 +442,63 @@ function ciWindowsObservationalGates(): Gate[] {
]
}
function lintGate(eslintTargets: readonly string[] = ['.']): Gate {
const concurrencyArgs = eslintConcurrencyArgs()
if (process.env.DSH_ESLINT_CACHE === '1') {
return pnpmExec('lint', [
'eslint',
...eslintTargets,
...concurrencyArgs,
'--cache',
'--cache-location',
'.cache/eslint/',
'--cache-strategy',
'content',
function lintGate(): Gate {
const raw = process.env.DSH_OXLINT_THREADS
return pnpmScript('lint', 'lint', raw === undefined || raw === ''
? {}
: { displayCommand: `DSH_OXLINT_THREADS=${raw} pnpm run lint` })
}
// The heavy suites run uninstrumented beside the thresholded gate: their
// compiler- and subprocess-bound fixtures pay a multiple of their runtime
// under v8 instrumentation while contributing nothing the thresholds need
// (membership contract in scripts/coverage-exempt.ts).
//
// DSH_COVERAGE_MAX_WORKERS is the lane's worker budget, so the two parallel
// gates split it instead of each claiming it whole (the failover pool's
// 8 x 6-instance bound assumes one lane never exceeds its value). The exempt
// gate's wall clock is dominated by its longest single file, so it takes the
// 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.
function coverageWorkerArgs(): { instrumented: string[]; exempt: string[] } {
const [flag] = positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers')
if (flag === undefined) return { instrumented: [], exempt: [] }
const total = Number.parseInt(flag.split('=')[1] ?? '', 10)
const exempt = Math.max(1, Math.floor(total / 3))
const instrumented = Math.max(1, total - exempt)
return {
instrumented: [`--maxWorkers=${String(instrumented)}`],
exempt: [`--maxWorkers=${String(exempt)}`],
}
}
function coverageGates(): Gate[] {
const workers = coverageWorkerArgs()
return [
pnpmExec('coverage', [
'vitest',
'run',
'--coverage',
...workers.instrumented,
], {
label: 'lint',
env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') },
})
}
if (concurrencyArgs.length > 0) {
return pnpmExec('lint', ['eslint', ...eslintTargets, ...concurrencyArgs], {
label: 'lint',
env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') },
})
}
return pnpmScript('lint', 'lint', {
env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') },
})
}
function eslintConcurrencyArgs(): string[] {
const raw = process.env.DSH_ESLINT_CONCURRENCY
if (raw === undefined || raw === '') return []
if (raw === 'auto') return ['--concurrency=auto']
const parsed = Number.parseInt(raw, 10)
if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) {
throw new Error(`run-gates: DSH_ESLINT_CONCURRENCY must be a positive integer or auto, got ${JSON.stringify(raw)}.`)
}
return [`--concurrency=${raw}`]
}
function coverageGate(): Gate {
return pnpmExec('coverage', [
'vitest',
'run',
'--coverage',
...positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers'),
], {
label: 'test:coverage',
})
label: 'test:coverage',
env: { [COVERAGE_EXEMPT_ENV]: '1' },
}),
pnpmExec('coverage-exempt-heavy', [
'vitest',
'run',
...coverageExemptHeavySuites.map(suite => suite.filter),
...workers.exempt,
], {
label: 'test:coverage-exempt-heavy',
}),
]
}
// Example and package snapshots boot their bins in `lib` mode (built artifacts under plain Node,
// plugins via real exports); repository-script snapshots execute their real source entry path.
// Build-owning modes wait on `build`; a restored-artifact mode passes its validation dependency.
// Callers wait either on `build` or on a validation gate that transitively owns that build.
function snapshotGate(needs: string[] = ['build']): Gate {
return pnpmScript('snapshot', 'test:snapshot', {
env: { DSH_EXAMPLE_MODE: 'lib' },
@@ -475,6 +546,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
}
function docSyncLeafGates(options: {
includeDocTypecheck?: boolean
docTypecheckNeeds?: string[]
docTypecheckEnv?: Record<string, string | undefined>
docsBuildScript?: 'docs:build' | 'docs:build:mpa'
@@ -483,9 +555,10 @@ function docSyncLeafGates(options: {
if (options.docTypecheckNeeds !== undefined) docTypecheckOptions.needs = options.docTypecheckNeeds
if (options.docTypecheckEnv !== undefined) docTypecheckOptions.env = options.docTypecheckEnv
return [
pnpmScript('doc-typecheck', 'doc-typecheck', docTypecheckOptions),
...options.includeDocTypecheck === false
? []
: [pnpmScript('doc-typecheck', 'doc-typecheck', docTypecheckOptions)],
pnpmScript('cordis-catalog', 'verify-cordis-catalog', { label: 'cordis catalog' }),
pnpmScript('cordis-api', 'verify-cordis-api', { label: 'cordis api' }),
pnpmScript('export-jsdoc', 'verify-export-jsdoc', { label: 'export jsdoc' }),
pnpmScript('tool-catalog', 'verify-tool-catalog', { label: 'tool catalog' }),
pnpmScript('config-catalog', 'verify-config-catalog', { label: 'config catalog' }),
@@ -521,7 +594,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate {
'--config',
'vitest.e2e.config.ts',
'examples/headless-agent/tests/keyless-smoke.e2e.ts',
'examples/tui-agent/tests/tui-keyless-smoke.e2e.ts',
'apps/cli/tests/tui-keyless-smoke.e2e.ts',
'packages/examples/cli-demo/tests/built-bin.e2e.ts',
'packages/examples/acp-demo/tests/built-bin.e2e.ts',
'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts',

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { resolveOxlintInvocation } from './run-oxlint.ts'
describe('Oxlint invocation', () => {
it('preserves the ordinary default invocation', () => {
expect(resolveOxlintInvocation(['.'], { PATH: '/bin' })).toEqual({
args: ['.'],
env: { PATH: '/bin' },
})
})
it('bounds both worker pools from one setting', () => {
expect(resolveOxlintInvocation(['.', '--fix'], { DSH_OXLINT_THREADS: '4', GOMAXPROCS: '12' })).toEqual({
args: ['.', '--fix', '--threads=4'],
env: { DSH_OXLINT_THREADS: '4', GOMAXPROCS: '4' },
})
})
it.each(['0', '-1', '1.5', 'auto'])('rejects invalid worker bound %s', (value) => {
expect(() => resolveOxlintInvocation(['.'], { DSH_OXLINT_THREADS: value }))
.toThrow('DSH_OXLINT_THREADS must be a positive integer')
})
it('rejects a competing direct worker bound', () => {
expect(() => resolveOxlintInvocation(['.', '--threads=2'], { DSH_OXLINT_THREADS: '4' }))
.toThrow('use DSH_OXLINT_THREADS instead')
})
})

46
scripts/run-oxlint.ts Normal file
View File

@@ -0,0 +1,46 @@
import { spawnSync } from 'node:child_process'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const oxlintCli = fileURLToPath(new URL('../node_modules/oxlint/bin/oxlint', import.meta.url))
/** Complete Oxlint child-process arguments and environment. */
export interface OxlintInvocation {
readonly args: readonly string[]
readonly env: NodeJS.ProcessEnv
}
/**
* Apply the repository worker bound to both Oxlint backends.
* @param args - Oxlint CLI arguments requested by the caller.
* @param env - Environment inherited by the Oxlint process.
* @returns the complete CLI arguments and child environment.
*/
export function resolveOxlintInvocation(args: readonly string[], env: NodeJS.ProcessEnv): OxlintInvocation {
const raw = env.DSH_OXLINT_THREADS
if (raw === undefined || raw === '') return { args: [...args], env: { ...env } }
const parsed = Number.parseInt(raw, 10)
if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) {
throw new Error(`run-oxlint: DSH_OXLINT_THREADS must be a positive integer, got ${JSON.stringify(raw)}.`)
}
if (args.some(arg => arg === '--threads' || arg.startsWith('--threads='))) {
throw new Error('run-oxlint: use DSH_OXLINT_THREADS instead of passing --threads directly.')
}
return {
args: [...args, `--threads=${raw}`],
env: { ...env, GOMAXPROCS: raw },
}
}
function main(): void {
const invocation = resolveOxlintInvocation(process.argv.slice(2), process.env)
const result = spawnSync(process.execPath, [oxlintCli, ...invocation.args], {
env: invocation.env,
stdio: 'inherit',
})
if (result.error !== undefined) throw result.error
process.exitCode = result.status ?? 1
}
const entrypoint = process.argv[1]
if (entrypoint !== undefined && resolve(entrypoint) === fileURLToPath(import.meta.url)) main()

View File

@@ -25,6 +25,14 @@ CODE_PROMPT = "Use run_code to compute the packaged worker smoke value."
CODE_WORKER_TEXT = "code worker smoke ok"
WORKFLOW_PROMPT = "Use workflow to compute the packaged worker smoke value without agents."
WORKFLOW_WORKER_TEXT = "workflow worker smoke ok"
PERSISTENT_TOOLS_PROMPT = "Exercise the packaged persistent Bash and string-replacement editor."
PERSISTENT_TOOLS_TEXT = "persistent tools smoke ok"
PERSISTENT_EDITOR_PATH_PREFIX = "Editor path: "
PERSISTENT_BASH_COMMAND = (
"counter=$(( ${counter:-0} + 1 )); export counter; "
"printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; "
"if [ \"$counter\" -eq 1 ]; then cd /tmp; fi"
)
SNAPSHOT_PROMPT = "Run the advanced packaged-runtime snapshot scenario."
SNAPSHOT_SESSION_ID = "advanced-executable"
SNAPSHOT_DIRECT_CHILD_PROMPT = "Reply with exactly DIRECT_CHILD_OK and nothing else."
@@ -64,6 +72,9 @@ CUSTOM_CORDIS = """\
name: '@deepseek-ai/dsh-agent-spine-demo'
config:
workspaceContext: false
skills:
enabled: false
toolBash: false
tools:
mode: both
- id: sessions
@@ -71,10 +82,6 @@ CUSTOM_CORDIS = """\
config:
root: !!js process.env.DSH_SESSION_ROOT
compression: 'none'
- id: bash
name: '@deepseek-ai/dsh-bash-local'
config:
cwd: !!js process.env.DSH_CWD
- id: code-runtime
name: '@deepseek-ai/dsh-code-runtime-worker'
- id: subagents
@@ -96,6 +103,49 @@ CUSTOM_CORDIS = """\
- id: cordis-tool
name: '@deepseek-ai/dsh-tool-cordis'
"""
PERSISTENT_TOOLS_CORDIS = """\
- id: jsonrpc
name: '@deepseek-ai/dsh-jsonrpc'
- id: llm
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local'
- id: sandbox-policy
name: '@deepseek-ai/dsh-sandbox-policy'
config:
mode: danger-full-access
workspaceRoot: !!js process.env.DSH_CWD
- id: pty
name: '@deepseek-ai/dsh-pty'
- id: pty-local
name: '@deepseek-ai/dsh-pty-local'
- id: fs
name: '@deepseek-ai/dsh-fs-local'
config:
cwd: !!js process.env.DSH_CWD
- id: agent-core
name: '@deepseek-ai/dsh-agent-spine-demo'
config:
includeHarnessIdentity: false
persona: 'You are a helpful software engineer assistant.'
workspaceContext: false
skills:
enabled: false
toolBash: false
toolTasks: false
- id: sessions
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
root: !!js process.env.DSH_SESSION_ROOT
compression: 'none'
- id: persistent-bash
name: '@deepseek-ai/dsh-tool-bash-persistent'
- id: str-replace-editor
name: '@deepseek-ai/dsh-tool-str-replace-editor'
"""
class MockModelHandler(BaseHTTPRequestHandler):
@@ -132,6 +182,9 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
if latest.get("role") == "tool":
call_id, tool_name = latest_tool_call(messages)
tool_text = message_text(latest.get("content"))
persistent = persistent_tool_followup(body, call_id, tool_name, tool_text)
if persistent is not None:
return persistent
advanced = advanced_tool_followup(body, call_id, tool_name, tool_text)
if advanced is not None:
return advanced
@@ -144,6 +197,15 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
raise AssertionError(f"unexpected tool follow-up: {tool_name}")
prompt = message_text(latest.get("content"))
if prompt.startswith(f"{PERSISTENT_TOOLS_PROMPT}\n{PERSISTENT_EDITOR_PATH_PREFIX}"):
names = advertised_tool_names(body)
if names != {"bash", "str_replace_editor"}:
raise AssertionError(f"persistent tools smoke advertised unexpected tools: {names}")
return tool_call_chunks(
"persistent-bash-1",
"bash",
{"command": PERSISTENT_BASH_COMMAND},
)
if prompt == SNAPSHOT_DIRECT_CHILD_PROMPT:
return text_chunks("DIRECT_CHILD_OK")
if prompt == SNAPSHOT_WORKFLOW_CHILD_PROMPT:
@@ -178,6 +240,57 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
return text_chunks(EXPECTED_TEXT)
def persistent_tool_followup(
body: dict[str, object],
call_id: str,
tool_name: str,
tool_text: str,
) -> list[dict[str, object]] | None:
"""Verify packaged PTY persistence, then invoke the packaged editor."""
if not call_id.startswith("persistent-"):
return None
if call_id == "persistent-bash-1" and tool_name == "bash":
if "COUNT=1" not in tool_text:
raise AssertionError(f"first persistent bash call lost its output: {tool_text}")
return tool_call_chunks(
"persistent-bash-2",
"bash",
{"command": PERSISTENT_BASH_COMMAND},
)
if call_id == "persistent-bash-2" and tool_name == "bash":
if "COUNT=2 CWD=/tmp" not in tool_text:
raise AssertionError(f"persistent bash did not retain state: {tool_text}")
messages = body.get("messages")
if not isinstance(messages, list):
raise AssertionError("persistent editor smoke request has no messages")
editor_path = next(
(
text.split(PERSISTENT_EDITOR_PATH_PREFIX, 1)[1].strip()
for message in messages
if isinstance(message, dict) and message.get("role") == "user"
for text in [message_text(message.get("content"))]
if PERSISTENT_EDITOR_PATH_PREFIX in text
),
None,
)
if editor_path is None:
raise AssertionError("persistent editor smoke prompt has no editor path")
return tool_call_chunks(
"persistent-editor",
"str_replace_editor",
{
"command": "create",
"path": editor_path,
"file_text": "created by packaged editor\n",
},
)
if call_id == "persistent-editor" and tool_name == "str_replace_editor":
if "New file created successfully" not in tool_text:
raise AssertionError(f"packaged editor did not create its file: {tool_text}")
return text_chunks(PERSISTENT_TOOLS_TEXT)
raise AssertionError(f"unexpected persistent-tools follow-up: {call_id} {tool_name}: {tool_text}")
def advanced_tool_followup(
body: dict[str, object],
call_id: str,
@@ -357,14 +470,14 @@ def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--scenario",
choices=("all", "sdk-default", "sdk-custom", "sdk-snapshot", "direct"),
choices=("all", "sdk-default", "sdk-custom", "sdk-persistent", "sdk-snapshot", "direct"),
default="all",
)
parser.add_argument("--exe", type=Path)
parser.add_argument("--update-snapshots", action="store_true")
args = parser.parse_args()
if args.scenario in {"all", "sdk-custom", "sdk-snapshot", "direct"} and args.exe is None:
parser.error("--exe is required for custom, snapshot, and direct scenarios")
if args.scenario in {"all", "sdk-custom", "sdk-persistent", "sdk-snapshot", "direct"} and args.exe is None:
parser.error("--exe is required for custom, persistent, snapshot, and direct scenarios")
if args.update_snapshots and args.scenario not in {"all", "sdk-snapshot"}:
parser.error("--update-snapshots requires --scenario sdk-snapshot or all")
if args.exe is not None and not args.exe.is_file():
@@ -376,6 +489,9 @@ def main() -> None:
if args.scenario in {"all", "sdk-custom"}:
assert args.exe is not None
smoke_sdk_custom(model.url, args.exe.resolve())
if args.scenario in {"all", "sdk-persistent"}:
assert args.exe is not None
smoke_sdk_persistent_tools(model.url, args.exe.resolve())
if args.scenario in {"all", "sdk-snapshot"}:
assert args.exe is not None
smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots)
@@ -394,7 +510,7 @@ def smoke_sdk_default(base_url: str) -> None:
root = Path(temporary).resolve()
sessions = root / "sessions"
with DeepSeekHarness(
provider="deepseek",
provider="deepseek-official",
model="smoke-model",
cwd=str(root),
session_root=str(sessions),
@@ -417,7 +533,7 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None:
cordis = root / "cordis.yml"
cordis.write_text(CUSTOM_CORDIS)
with DeepSeekHarness(
provider="deepseek",
provider="deepseek-official",
model="smoke-model",
cwd=str(root),
session_root=str(sessions),
@@ -439,6 +555,39 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None:
assert_session_log(sessions, root, EXPECTED_TEXT, CODE_WORKER_TEXT, WORKFLOW_WORKER_TEXT)
def smoke_sdk_persistent_tools(base_url: str, executable: Path) -> None:
"""Exercise native PTY state and the editor through the packaged executable."""
from deepseek_harness import DeepSeekHarness
with tempfile.TemporaryDirectory(prefix="dsh-sdk-persistent-tools-") as temporary:
root = Path(temporary).resolve()
editor_path = root / "created.txt"
prompt = f"{PERSISTENT_TOOLS_PROMPT}\n{PERSISTENT_EDITOR_PATH_PREFIX}{editor_path}"
sessions = root / "sessions"
cordis = root / "cordis.yml"
cordis.write_text(PERSISTENT_TOOLS_CORDIS)
with DeepSeekHarness(
provider="deepseek",
model="smoke-model",
cwd=str(root),
session_root=str(sessions),
cordis=str(cordis),
runtime_bin=str(executable),
api_key="sk-keyless-smoke",
base_url=base_url,
request_timeout_seconds=60,
) as harness:
result = harness.run(prompt, session_id="persistent-tools-smoke")
assert result.status == "ok", result
event_text = json.dumps(result.events)
if PERSISTENT_TOOLS_TEXT not in event_text:
raise AssertionError(f"packaged tools run emitted no final response: {result.events}")
if editor_path.read_text() != "created by packaged editor\n":
raise AssertionError(f"packaged editor wrote unexpected content: {editor_path.read_text()!r}")
assert_session_log(sessions, root, PERSISTENT_TOOLS_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp")
def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None:
"""Drive and compare the advanced SDK/executable behavioral snapshot."""
from deepseek_harness import DeepSeekHarness
@@ -449,7 +598,7 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool)
cordis = root / "cordis.yml"
cordis.write_text(CUSTOM_CORDIS)
with DeepSeekHarness(
provider="deepseek",
provider="deepseek-official",
model="smoke-model",
cwd=str(root),
session_root=str(sessions),
@@ -499,7 +648,7 @@ def smoke_direct(base_url: str, executable: Path) -> None:
}
peer = RuntimePeer([str(executable)], root, environment)
try:
peer.send({"jsonrpc": "2.0", "id": "initialize", "method": "initialize", "params": {"cwd": str(root), "provider": "deepseek", "model": "smoke-model"}})
peer.send({"jsonrpc": "2.0", "id": "initialize", "method": "initialize", "params": {"cwd": str(root), "provider": "deepseek-official", "model": "smoke-model"}})
peer.read_until(lambda message: message.get("id") == "initialize")
peer.send({
"jsonrpc": "2.0",
@@ -724,6 +873,8 @@ def normalize_snapshot_value(
normalized["createdAt"] = 0
if "seq" in normalized and "time" in normalized:
normalized["time"] = 0
if isinstance(normalized.get("id"), str) and normalized.get("role") in ("assistant", "user"):
normalized["id"] = "{{messageId}}"
scrub_snapshot_header(normalized)
return normalized

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,14 @@
{"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","delegationDepth":1}
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}}
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}}
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
{"type":"step/end","seq":11,"time":0,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":12,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -1,14 +1,14 @@
{"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","delegationDepth":1}
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}}
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}}
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
{"type":"step/end","seq":11,"time":0,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":12,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -1,30 +1,30 @@
{"type":"session","version":0,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0}
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":0,"data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}}
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}}}
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}
{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"<anonymous>\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"}
{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"<anonymous>\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[11],"surfaceOp":"append"}
{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}}
{"type":"request/header","seq":15,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"change"}}
{"type":"request/header","seq":15,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}}
{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}
{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}}
{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"}
{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"}
{"type":"tool/call","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}
{"type":"tool/code-dispatch-start","seq":23,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21}}}
{"type":"tool/code-dispatch","seq":24,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"content":[{"type":"text","text":"42"}]}}
{"type":"tool/result","seq":25,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false},"sourceEventSeqs":[22],"surfaceOp":"append"}
{"type":"tool/result","seq":25,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"}
{"type":"step/end","seq":26,"time":0,"data":{"turn":1,"step":2}}
{"type":"step/start","seq":27,"time":0,"data":{"turn":1,"step":3}}
{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
@@ -32,9 +32,9 @@
{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}}
{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":33,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"}
{"type":"assistant/message","seq":33,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"}
{"type":"tool/call","seq":34,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}
{"type":"tool/result","seq":35,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[34],"surfaceOp":"append"}
{"type":"tool/result","seq":35,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[34],"surfaceOp":"append"}
{"type":"step/end","seq":36,"time":0,"data":{"turn":1,"step":3}}
{"type":"step/start","seq":37,"time":0,"data":{"turn":1,"step":4}}
{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
@@ -42,9 +42,9 @@
{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}}
{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":43,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"}
{"type":"assistant/message","seq":43,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"}
{"type":"tool/call","seq":44,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}
{"type":"tool/result","seq":45,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[44],"surfaceOp":"append"}
{"type":"tool/result","seq":45,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[44],"surfaceOp":"append"}
{"type":"step/end","seq":46,"time":0,"data":{"turn":1,"step":4}}
{"type":"step/start","seq":47,"time":0,"data":{"turn":1,"step":5}}
{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
@@ -52,17 +52,17 @@
{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}}
{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":53,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"}
{"type":"assistant/message","seq":53,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"}
{"type":"tool/call","seq":54,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}
{"type":"tool/result","seq":55,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[54],"surfaceOp":"append"}
{"type":"tool/result","seq":55,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[54],"surfaceOp":"append"}
{"type":"step/end","seq":56,"time":0,"data":{"turn":1,"step":5}}
{"type":"step/start","seq":57,"time":0,"data":{"turn":1,"step":6}}
{"type":"request/header","seq":58,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"change"}}
{"type":"request/header","seq":58,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}}
{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}}
{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}}
{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":64,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"}
{"type":"assistant/message","seq":64,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"}
{"type":"step/end","seq":65,"time":0,"data":{"turn":1,"step":6}}
{"type":"turn/end","seq":66,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}

File diff suppressed because one or more lines are too long

View File

@@ -1,11 +1,15 @@
import { describe, expect, it, vi } from 'vitest'
import { Context, Service } from 'cordis'
import { Context, FiberState, Service, ValidationError } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import z from 'schemastery'
import InvariantService 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'
@@ -21,6 +25,87 @@ class TestInvariantProbe extends Service {
}
}
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 queuedReadinessConfig(
ctx: Context,
onPublished: (dispose: () => void) => void,
) {
return z.transform(z.any(), () => {
queueMicrotask(() => {
onPublished(ctx.provide(TEST_INVARIANT_READY_SERVICE, true))
})
return {}
}, true)
}
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 exhaustive topology to reserve every package name with enabled checks', async () => {
const ctx = new Context()
@@ -61,7 +146,8 @@ describe('global test invariant host', () => {
return () => {}
})
const fakeContext = { invariants: { register } } as unknown as Context
for (const [rawPath, companion] of Object.entries(testInvariantCompanions)) {
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
@@ -84,4 +170,291 @@ describe('global test invariant host', () => {
expect(usesManualInvariantTree('/repo/packages/examples/agent-spine-demo/tests/agent-core.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 when readiness refresh wins the rejection-handler race', async () => {
await withDelayedFirstCompanion(
async ({ started, release }) => {
const ctx = new Context()
const apply = vi.fn(invalidConfigApply)
let disposeQueuedReadiness: (() => void) | undefined
const plugin = {
apply,
Config: z.intersect([
queuedReadinessConfig(ctx, (dispose) => {
disposeQueuedReadiness = dispose
}),
requiredConfig(),
]),
}
const fiber = ctx.plugin(plugin, {})
const firstError = await rejectionOf(fiber)
expectRequiredConfigValidation(firstError)
expect(fiber.state).toBe(FiberState.DISPOSED)
expect(apply).not.toHaveBeenCalled()
await started
if (disposeQueuedReadiness === undefined) throw new Error('queued readiness was not published')
disposeQueuedReadiness()
release()
await ctx.plugin(TestInvariantProbe)
const secondError = await rejectionOf(fiber)
expect(secondError).toBe(firstError)
expect(fiber.state).toBe(FiberState.DISPOSED)
expect(apply).not.toHaveBeenCalled()
},
)
})
it('retains a valid plugin failure when readiness wins the initial-probe race', async () => {
await withDelayedFirstCompanion(
async ({ started, release }) => {
const ctx = new Context()
const failure = new Error('valid plugin apply failed')
const applied = deferred()
const apply = vi.fn(function validConfigApply() {
applied.resolve()
throw failure
})
let disposeQueuedReadiness: (() => void) | undefined
const plugin = {
apply,
Config: queuedReadinessConfig(ctx, (dispose) => {
disposeQueuedReadiness = dispose
}),
}
const fiber = ctx.plugin(plugin, {})
const returnedError = rejectionOf(fiber)
try {
await Promise.all([started, applied.promise])
expect(fiber.state).toBe(FiberState.FAILED)
expect(apply).toHaveBeenCalledOnce()
expect(ctx.registry.has(plugin)).toBe(true)
expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1)
if (disposeQueuedReadiness === undefined) throw new Error('queued readiness was not published')
Reflect.deleteProperty(fiber.inject, TEST_INVARIANT_READY_SERVICE)
disposeQueuedReadiness()
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)
} finally {
Reflect.deleteProperty(fiber.inject, TEST_INVARIANT_READY_SERVICE)
disposeQueuedReadiness?.()
release()
}
},
)
})
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(InvariantService, { enabled: true })
await ctx.plugin(delayedCompanion)
expect(ctx.registry.get(InvariantService)?.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()
},
)
})
})

View File

@@ -6,14 +6,14 @@
*/
import { expect } from 'vitest'
import { RegistryService } from 'cordis'
import { FiberState, Inject, RegistryService } from 'cordis'
import type { Context, Plugin } from 'cordis'
import InvariantService from '@deepseek-ai/dsh-invariants'
declare global {
interface ImportMeta {
/** Eager Vite module-glob expansion used by the Vitest setup file. */
glob<TModule>(pattern: string, options: { eager: true }): Record<string, TModule>
/** Lazy Vite module-glob expansion used by the Vitest setup file. */
glob<TModule>(pattern: string): Record<string, () => Promise<TModule>>
}
}
@@ -25,9 +25,18 @@ export interface TestInvariantCompanion {
apply(ctx: Context): Promise<() => void>
}
/** Every package companion, discovered eagerly so coverage observes each registration. */
export const testInvariantCompanions: Readonly<Record<string, TestInvariantCompanion>> =
import.meta.glob<TestInvariantCompanion>('../packages/*/*/src/invariant.ts', { eager: true })
/** Private service dependency that holds ordinary root plugins until invariant startup completes. */
export const TEST_INVARIANT_READY_SERVICE = 'testInvariantReady'
/**
* Every 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 stops importing 168 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 = [
@@ -36,15 +45,16 @@ const MANUAL_INVARIANT_TEST_EXCEPTIONS = [
] as const
interface InvariantHost {
readonly fibers: readonly PluginFiber[]
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>()
// eslint-disable-next-line @typescript-eslint/unbound-method -- every call below supplies its RegistryService receiver explicitly.
// 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[]) {
@@ -56,14 +66,28 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge
const callback = this.resolve(plugin)
const existing = callback === undefined ? undefined : host.byCallback.get(callback)
if (existing !== undefined) {
return this.ctx === root ? joinInvariantStartup(existing, host.ready) : existing
return hasBarrierOwner(host, this.ctx) ? existing : joinInvariantStartup(existing, host.ready)
}
const fiber = originalPlugin.call(this, plugin, config, getOuterStack)
// A root-level await is the test's composition boundary. Nested plugin
// fibers must not await their own companion parent through the global host.
if (this.ctx !== root) return fiber
return joinInvariantStartup(fiber, 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)
}
/**
@@ -102,48 +126,106 @@ export function testInvariantCompanionPaths(testPath: string): string[] {
}
function startInvariantHost(root: Context): InvariantHost {
const fibers: PluginFiber[] = []
const byCallback = new Map<unknown, PluginFiber>()
const mount = (plugin: Plugin, config?: unknown): void => {
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')
fibers.push(fiber)
byCallback.set(callback, fiber)
barrierOwners.add(fiber.ctx.fiber)
return fiber
}
mount(InvariantService, { enabled: true })
// 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(InvariantService, { enabled: true })
const testPath = expect.getState().testPath ?? ''
const companionPaths = testInvariantCompanionPaths(testPath)
for (const path of companionPaths) {
const companion = testInvariantCompanions[path]
if (companion === undefined) {
throw new Error(`test invariants: selected companion vanished at ${path}`)
}
if (!companion.inject.includes('invariants')) {
throw new Error(`test invariants: ${path} must inject the invariant service`)
}
mount(companion)
}
const [serviceFiber, ...companionFibers] = fibers
if (serviceFiber === undefined) throw new Error('test invariants: service fiber was not mounted')
// A companion is initially PENDING on the invariant service, and Cordis
// Fiber.await() only joins work already in flight. Wait for the service to
// activate its dependants before joining their startup and failures.
const ready = serviceFiber.await()
.then(() => Promise.all(companionFibers.map(fiber => fiber.await())))
.then(() => undefined)
const host = { fibers, byCallback, ready }
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 joinInvariantStartup(fiber: PluginFiber, invariantReady: Promise<void>): PluginFiber {
const readiness = fiber.await().then(async (loaded) => {
await invariantReady
return loaded
})
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>,
disposeInitialFailure = 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 initialized = disposeInitialFailure
? rawFiber.await().catch(async (error: unknown) => {
// Config validation is the only failure recorded while a gated fiber
// is initially PENDING. Dispose it even if queued readiness publication
// changes its state before this rejection handler runs.
await rawFiber.dispose()
throw error
})
: Promise.resolve()
const readiness = initialized.then(() => invariantReady).then(() => rawFiber.await())
const joined = Object.create(fiber) as PluginFiber
joined.then = readiness.then.bind(readiness)
return joined

View File

@@ -0,0 +1,50 @@
/** Git-blob operations owned by the bilingual pairing workflow. */
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
const SNAPSHOT_REF_PREFIX = 'refs/dsh/translation-pairing/snapshots'
/** Full SHA-1 Git blob hash (the 40-hex format used by pairing records). */
export function gitBlobHash(content: Buffer): string {
const hash = createHash('sha1')
hash.update(`blob ${content.byteLength}\0`)
hash.update(content)
return hash.digest('hex')
}
function runGit(root: string, args: string[], operation: string, input?: Buffer): Buffer {
const result = spawnSync('git', ['-C', root, ...args], {
input,
maxBuffer: 1 << 26,
})
if (result.error) {
throw new Error(`${operation} failed: ${result.error.message}`, { cause: result.error })
}
if (result.status !== 0) {
throw new Error(`${operation} failed with status ${String(result.status)}: ${result.stderr.toString('utf8').trim()}`)
}
return result.stdout
}
/**
* Persist exact working-tree bytes so a pairing record can later recover them
* with `git cat-file`, even when they have never appeared in the index or a
* commit. The returned object ID is checked against the pairing format's own
* content hash before the caller writes a sidecar.
*/
export function storeGitBlob(root: string, content: Buffer): string {
const expected = gitBlobHash(content)
const stored = runGit(root, ['hash-object', '-w', '--stdin'], 'git hash-object -w --stdin', content)
.toString('utf8')
.trim()
if (stored !== expected) {
throw new Error(`git hash-object -w --stdin returned unexpected object ID ${JSON.stringify(stored)}; expected ${expected}`)
}
runGit(
root,
['update-ref', `${SNAPSHOT_REF_PREFIX}/${stored}`, stored],
'git update-ref for translation snapshot',
)
return stored
}

View File

@@ -1,6 +1,11 @@
/** Regression tests for the bilingual corpus scope and structural signature. */
/** Regression tests for bilingual snapshots, corpus scope, and structure. */
import { execFileSync, spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { gitBlobHash, storeGitBlob } from './translation-pairing-git.ts'
import {
isTranslationScopeFile,
pairAnchorOfArgument,
@@ -15,6 +20,71 @@ function signature(markdown: string) {
return translationStructureSignature(parseTranslationMarkdown(markdown), 'counterpart.zh.md')
}
function gitSupportsObjectFormat(format: 'sha256'): boolean {
const root = mkdtempSync(join(tmpdir(), 'dsh-git-object-format-'))
try {
return spawnSync('git', ['init', '--quiet', `--object-format=${format}`, root], {
stdio: 'ignore',
}).status === 0
} finally {
rmSync(root, { recursive: true, force: true })
}
}
const supportsSha256ObjectFormat = gitSupportsObjectFormat('sha256')
describe('translation pairing snapshots', () => {
it('stores exact uncommitted bytes for later recovery by object ID', () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-'))
try {
execFileSync('git', ['init', '--quiet', root], {
env: { ...process.env, GIT_DEFAULT_HASH: 'sha1' },
})
const content = Buffer.from([0x75, 0x6e, 0x63, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, 0x0a, 0xff])
const objectId = storeGitBlob(root, content)
expect(objectId).toBe(gitBlobHash(content))
expect(execFileSync('git', [
'-C', root, 'rev-parse', `refs/dsh/translation-pairing/snapshots/${objectId}`,
], { encoding: 'utf8' }).trim()).toBe(objectId)
execFileSync('git', ['-C', root, 'gc', '--prune=now'])
expect(execFileSync('git', ['-C', root, 'cat-file', '-p', objectId])).toEqual(content)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('fails before a sidecar can reference an unavailable object', () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-'))
try {
expect(() => storeGitBlob(root, Buffer.from('snapshot'))).toThrow('git hash-object -w --stdin failed')
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('fails clearly when Git cannot be started', () => {
const previousPath = process.env.PATH
try {
process.env.PATH = ''
expect(() => storeGitBlob('.', Buffer.from('snapshot'))).toThrow('git hash-object -w --stdin failed')
} finally {
process.env.PATH = previousPath
}
})
it.skipIf(!supportsSha256ObjectFormat)('rejects an object format that pairing records cannot represent', () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-'))
try {
execFileSync('git', ['init', '--quiet', '--object-format=sha256', root])
expect(() => storeGitBlob(root, Buffer.from('snapshot'))).toThrow('returned unexpected object ID')
} finally {
rmSync(root, { recursive: true, force: true })
}
})
})
describe('translation pairing manifest', () => {
it('accepts an exclusions-only manifest', () => {
expect(parseTranslationPairingManifest(JSON.stringify({

View File

@@ -14,23 +14,28 @@
{
"doc": "docs/core-data-structures/core.md",
"symbol": "AssistantProvenance",
"source": "packages/llm/llm/src/types.ts"
"source": "packages/llm/llm/src/message.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "Message",
"source": "packages/llm/llm/src/types.ts"
"source": "packages/llm/llm/src/message.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "MessageSourceMap",
"source": "packages/llm/llm/src/types.ts"
"source": "packages/llm/llm/src/message.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "FinishReasonMap",
"source": "packages/llm/llm/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "AdapterRegistrationHandle",
"source": "packages/llm/llm/src/index.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "LlmProviderInfo",
@@ -81,6 +86,11 @@
"symbol": "LlmCallConfig",
"source": "packages/llm/llm/src/call-config.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "LlmCallConfigAdapterDefaults",
"source": "packages/llm/llm/src/call-config.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "SessionEvent",
@@ -96,21 +106,26 @@
"symbol": "InboxPlacement",
"source": "packages/core/agent/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "InboxItem",
"source": "packages/core/agent/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "InboxAction",
"source": "packages/core/agent/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "InboxActionResult",
"source": "packages/core/agent/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "SendOptions",
"source": "packages/core/agent/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "AgentMessageId",
"source": "packages/core/agent/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "AgentMessage",
"source": "packages/core/agent/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "CancelOptions",
@@ -189,37 +204,37 @@
{
"doc": "docs/core-data-structures/goal.md",
"symbol": "GoalView",
"source": "packages/goal/goal/src/types.ts"
"source": "packages/goal/goal/src/domain.ts"
},
{
"doc": "docs/core-data-structures/goal.md",
"symbol": "GoalSnapshotChangeMeta",
"source": "packages/goal/goal/src/types.ts"
"source": "packages/goal/goal/src/domain.ts"
},
{
"doc": "docs/core-data-structures/goal.md",
"symbol": "GoalClearChangeMeta",
"source": "packages/goal/goal/src/types.ts"
"source": "packages/goal/goal/src/domain.ts"
},
{
"doc": "docs/core-data-structures/goal.md",
"symbol": "GoalMessageSource",
"source": "packages/goal/goal/src/types.ts"
"source": "packages/goal/goal/src/domain.ts"
},
{
"doc": "docs/core-data-structures/goal.md",
"symbol": "CreateGoalRequest",
"source": "packages/goal/goal/src/types.ts"
"source": "packages/goal/goal/src/domain.ts"
},
{
"doc": "docs/core-data-structures/goal.md",
"symbol": "EditGoalRequest",
"source": "packages/goal/goal/src/types.ts"
"source": "packages/goal/goal/src/domain.ts"
},
{
"doc": "docs/core-data-structures/goal.md",
"symbol": "GoalChanged",
"source": "packages/goal/goal/src/types.ts"
"source": "packages/goal/goal/src/domain.ts"
},
{
"doc": "docs/core-data-structures/commands.md",
@@ -256,6 +271,11 @@
"symbol": "AssembleContext",
"source": "packages/core/system-prompt/src/index.ts"
},
{
"doc": "docs/core-data-structures/system-prompt.md",
"symbol": "PromptContext",
"source": "packages/core/system-prompt/src/index.ts"
},
{
"doc": "docs/core-data-structures/system-prompt.md",
"symbol": "PromptSection",
@@ -320,8 +340,8 @@
},
{
"doc": "docs/core-data-structures/session.md",
"symbol": "UserMessageData",
"source": "packages/core/session/src/types.ts"
"symbol": "UserMessage",
"source": "packages/llm/llm/src/message.ts"
},
{
"doc": "docs/core-data-structures/session.md",
@@ -333,6 +353,11 @@
"symbol": "EpochHeader",
"source": "packages/core/session/src/types.ts"
},
{
"doc": "docs/core-data-structures/session.md",
"symbol": "RequestContext",
"source": "packages/core/session/src/types.ts"
},
{
"doc": "docs/core-data-structures/session.md",
"symbol": "TodoItem",
@@ -689,6 +714,11 @@
"symbol": "AskUserQuestionOption",
"source": "packages/ui/user-interaction/src/types.ts"
},
{
"doc": "docs/core-data-structures/user-interaction.md",
"symbol": "AskUserQuestionIntent",
"source": "packages/ui/user-interaction/src/types.ts"
},
{
"doc": "docs/core-data-structures/user-interaction.md",
"symbol": "AskUserQuestionItem",
@@ -974,11 +1004,21 @@
"symbol": "SkillResourceBase",
"source": "packages/skill/skill/src/index.ts"
},
{
"doc": "docs/core-data-structures/skills.md",
"symbol": "SkillInvocationPolicy",
"source": "packages/skill/skill/src/index.ts"
},
{
"doc": "docs/core-data-structures/skills.md",
"symbol": "SkillSummary",
"source": "packages/skill/skill/src/index.ts"
},
{
"doc": "docs/core-data-structures/skills.md",
"symbol": "SkillCatalogSnapshot",
"source": "packages/skill/skill/src/index.ts"
},
{
"doc": "docs/core-data-structures/skills.md",
"symbol": "SkillCandidate",
@@ -999,11 +1039,21 @@
"symbol": "SkillLookupOptions",
"source": "packages/skill/skill/src/index.ts"
},
{
"doc": "docs/core-data-structures/skills.md",
"symbol": "SkillProviderObservation",
"source": "packages/skill/skill/src/index.ts"
},
{
"doc": "docs/core-data-structures/skills.md",
"symbol": "SkillProvider",
"source": "packages/skill/skill/src/index.ts"
},
{
"doc": "docs/core-data-structures/skills.md",
"symbol": "SkillProviderControl",
"source": "packages/skill/skill/src/index.ts"
},
{
"doc": "docs/core-data-structures/skills.md",
"symbol": "Config",
@@ -1019,6 +1069,11 @@
"symbol": "CompactionTrigger",
"source": "packages/compact/compact/src/index.ts"
},
{
"doc": "docs/core-data-structures/compaction.md",
"symbol": "ManualCompactionErrorCode",
"source": "packages/compact/compact/src/index.ts"
},
{
"doc": "docs/core-data-structures/compaction.md",
"symbol": "PrunedEntry",
@@ -1308,6 +1363,66 @@
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "SubprocessCollectedOutputs",
"source": "packages/subprocess/subprocess/src/types.ts"
},
{
"doc": "docs/core-data-structures/settings.md",
"symbol": "SettingsNamespace",
"source": "packages/settings/settings/src/index.ts"
},
{
"doc": "docs/core-data-structures/settings.md",
"symbol": "SettingsRegisterOptions",
"source": "packages/settings/settings/src/index.ts"
},
{
"doc": "docs/core-data-structures/settings.md",
"symbol": "SettingsApplies",
"source": "packages/settings/settings/src/index.ts"
},
{
"doc": "docs/core-data-structures/settings.md",
"symbol": "SettingsScope",
"source": "packages/settings/settings/src/index.ts"
},
{
"doc": "docs/core-data-structures/settings.md",
"symbol": "SettingsDescriptor",
"source": "packages/settings/settings/src/index.ts"
},
{
"doc": "docs/core-data-structures/settings.md",
"symbol": "SettingsUpdateSource",
"source": "packages/settings/settings/src/index.ts"
},
{
"doc": "docs/core-data-structures/credentials.md",
"symbol": "CredentialRef",
"source": "packages/credentials/credentials/src/index.ts"
},
{
"doc": "docs/core-data-structures/credentials.md",
"symbol": "ResolvedCredential",
"source": "packages/credentials/credentials/src/index.ts"
},
{
"doc": "docs/core-data-structures/credentials.md",
"symbol": "CredentialInfo",
"source": "packages/credentials/credentials/src/index.ts"
},
{
"doc": "docs/core-data-structures/settings.md",
"symbol": "SettingsDescribeOptions",
"source": "packages/settings/settings/src/index.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "LlmConfigurableProvider",
"source": "packages/llm/llm/src/types.ts"
},
{
"doc": "docs/core-data-structures/settings.md",
"symbol": "SettingsPathOp",
"source": "packages/settings/settings/src/index.ts"
}
]
}

View File

@@ -1,10 +1,10 @@
/**
* Validate Cordis Loader entry metadata and example package resolution.
* Validate Cordis Loader entry metadata and package resolution.
*
* The Loader interpolates only a plugin entry's `config`; expression objects in
* fields such as `disabled` remain truthy data and silently change composition.
* Example configs run from built packages, so every named package must resolve
* from the examples workspace and every local package must be in the root
* Example configs and the dsh Web composition resolve named plugins from their
* owning workspace manifests. Local example packages must also be in the root
* TypeScript project graph.
*/
@@ -29,7 +29,27 @@ interface PluginReference {
}
const root = resolve(import.meta.dirname, '..')
// These example files are overlays consumed by the built dsh app, so their bare
// specifiers resolve from apps/cli rather than the examples workspace.
const appOverlayFiles = new Set([
'examples/web-cordis/cordis.yml',
...globSync('examples/mcp-memory/*.cordis.yml', { cwd: root }),
])
const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const
/** The adaptive directory-picker chooser package (mounts a backend row at boot). */
const CHOOSER_PACKAGE = '@deepseek-ai/dsh-host-directory-picker-auto'
/**
* The backends the chooser mounts by runtime string (mirror of its exported
* `BACKEND_PACKAGES`), invisible to yml-row scanning: a composition mounting
* the chooser must resolve both, or keyless Linux CI (which only ever
* resolves `browse`) hides a dropped `-native` dependency until a macOS boot.
*/
const CHOOSER_BACKEND_PACKAGES = [
'@deepseek-ai/dsh-host-directory-picker-native',
'@deepseek-ai/dsh-host-directory-picker-browse',
]
const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
kind: 'scalar',
resolve: data => typeof data === 'string',
@@ -42,7 +62,7 @@ const schema = yaml.JSON_SCHEMA.extend(jsExprType)
const files = cordisConfigFiles(root)
const errors: string[] = []
const examplePluginReferences: PluginReference[] = []
const pluginReferences: PluginReference[] = []
for (const file of files) {
const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema })
@@ -56,9 +76,11 @@ for (const file of files) {
}
errors.push(...validateExampleResolution())
errors.push(...validateAppResolution())
errors.push(...validateSourcePlaneResolution())
if (errors.length > 0) {
console.error('verify-cordis-config: invalid Loader metadata or example package resolution:')
console.error('verify-cordis-config: invalid Loader metadata or plugin package resolution:')
for (const error of errors) console.error(`- ${error}`)
process.exitCode = 1
} else {
@@ -70,13 +92,18 @@ function validateEntry(value: unknown, file: string, path: string): void {
errors.push(`${file}${path}: entry must be an object`)
return
}
recordExamplePlugin(value, file)
recordPlugin(value, file)
validateMetadata(value, file, path)
if ((value.group === true || value.name === '@cordisjs/plugin-group') && isUnknownArray(value.config)) {
for (let index = 0; index < value.config.length; index++) {
validateEntry(value.config[index], file, `${path}.config[${index}]`)
}
}
if (isUnknownArray(value.insert)) {
for (let index = 0; index < value.insert.length; index++) {
validateEntry(value.insert[index], file, `${path}.insert[${index}]`)
}
}
if (value.name !== '@cordisjs/plugin-include') return
const config = value.config
if (!isRecord(config) || !isUnknownArray(config.patches)) return
@@ -84,7 +111,7 @@ function validateEntry(value: unknown, file: string, path: string): void {
const patch = config.patches[index]
const patchPath = `${path}.config.patches[${index}]`
if (!isRecord(patch)) continue
recordExamplePlugin(patch, file)
recordPlugin(patch, file)
validateMetadata(patch, file, patchPath)
if (!isUnknownArray(patch.insert)) continue
for (let insertIndex = 0; insertIndex < patch.insert.length; insertIndex++) {
@@ -93,10 +120,8 @@ function validateEntry(value: unknown, file: string, path: string): void {
}
}
function recordExamplePlugin(entry: Record<string, unknown>, file: string): void {
if (file.startsWith('examples/') && typeof entry.name === 'string') {
examplePluginReferences.push({ file, name: entry.name })
}
function recordPlugin(entry: Record<string, unknown>, file: string): void {
if (typeof entry.name === 'string') pluginReferences.push({ file, name: entry.name })
}
function validateExampleResolution(): string[] {
@@ -105,25 +130,13 @@ function validateExampleResolution(): string[] {
const dependencies = exampleManifest.dependencies ?? {}
const localPackages = localPackageDirectories()
const rootReferences = rootProjectReferences()
const requiredPackages = new Map<string, Set<string>>()
for (const reference of examplePluginReferences) {
const packageName = packageNameFromSpecifier(reference.name)
if (packageName === undefined) continue
const locations = requiredPackages.get(packageName) ?? new Set<string>()
locations.add(reference.file)
requiredPackages.set(packageName, locations)
}
for (const [packageName, locations] of requiredPackages) {
if (!(packageName in dependencies)) {
violations.push(`${[...locations].join(', ')}: ${packageName} must be declared in examples/package.json dependencies`)
}
}
const exampleReferences = pluginReferences.filter(reference => reference.file.startsWith('examples/') && !appOverlayFiles.has(reference.file))
violations.push(...missingPluginDependencies(exampleReferences, dependencies, 'examples/package.json'))
const requiredPackages = new Set(exampleReferences.map(reference => packageNameFromSpecifier(reference.name)))
const localExamplePackages = new Set([
...Object.keys(dependencies),
...requiredPackages.keys(),
...[...requiredPackages].filter(packageName => packageName !== undefined),
])
for (const packageName of localExamplePackages) {
const packageDirectory = localPackages.get(packageName)
@@ -135,6 +148,89 @@ function validateExampleResolution(): string[] {
return violations
}
function validateAppResolution(): string[] {
const dependencies = readManifest('apps/cli/package.json').dependencies ?? {}
const shipped = new Set(globSync('*.cordis.yml', { cwd: resolve(root, 'apps/cli/config') })
.map(file => `apps/cli/config/${file}`))
const references = pluginReferences.filter(reference => shipped.has(reference.file) || appOverlayFiles.has(reference.file))
return missingPluginDependencies(references, dependencies, 'apps/cli/package.json')
}
/**
* Every configured specifier of a local workspace package must resolve through
* the tsconfig `paths` facade to a `.ts`/`.tsx` source file. The `dsh` source
* launch (tsx) and vitest resolve in the source plane; without a `paths` match
* they fall back to package `exports`, which reach built `lib/` — present on a
* built dev tree, absent on a clean one — so a missing mapping boots locally
* yet breaks every clean checkout. Anything but a `.ts`/`.tsx` hit (a `.d.ts`
* or `.js` under built `lib/`) is that artifact-plane fallback, not source.
*/
function validateSourcePlaneResolution(): string[] {
const violations: string[] = []
const localPackages = localPackageDirectories()
const config = ts.readConfigFile(resolve(root, 'tsconfig.base.json'), path => ts.sys.readFile(path))
if (config.error !== undefined) {
throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'))
}
const { options, errors: optionErrors } = ts.convertCompilerOptionsFromJson(
(config.config as { compilerOptions?: unknown }).compilerOptions,
root,
'tsconfig.base.json',
)
if (optionErrors.length > 0) {
throw new Error(optionErrors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n'))
}
// convertCompilerOptionsFromJson leaves `pathsBasePath` unset, so relative
// `paths` targets resolve against the host's current directory; anchor it to
// the repository root to keep the gate cwd-independent.
const host: ts.ModuleResolutionHost = {
fileExists: path => ts.sys.fileExists(path),
readFile: path => ts.sys.readFile(path),
directoryExists: path => ts.sys.directoryExists(path),
getCurrentDirectory: () => root,
}
const sourceExtensions = new Set<string>([ts.Extension.Ts, ts.Extension.Tsx])
const containingFile = resolve(root, 'scripts/verify-cordis-config.ts')
const locationsBySpecifier = new Map<string, Set<string>>()
for (const reference of pluginReferences) {
const packageName = packageNameFromSpecifier(reference.name)
if (packageName === undefined || !localPackages.has(packageName)) continue
const locations = locationsBySpecifier.get(reference.name) ?? new Set<string>()
locations.add(reference.file)
locationsBySpecifier.set(reference.name, locations)
}
for (const [specifier, locations] of locationsBySpecifier) {
const resolved = ts.resolveModuleName(specifier, containingFile, options, host).resolvedModule
if (resolved !== undefined && sourceExtensions.has(resolved.extension)) continue
violations.push(`${[...locations].join(', ')}: ${specifier} does not resolve to workspace source through tsconfig.base.json paths (add a mapping so the tsx source launch does not depend on built lib/)`)
}
return violations
}
function missingPluginDependencies(
references: readonly PluginReference[],
dependencies: Readonly<Record<string, string>>,
manifestPath: string,
): string[] {
const requiredPackages = new Map<string, Set<string>>()
const require = (packageName: string, file: string): void => {
const locations = requiredPackages.get(packageName) ?? new Set<string>()
locations.add(file)
requiredPackages.set(packageName, locations)
}
for (const reference of references) {
const packageName = packageNameFromSpecifier(reference.name)
if (packageName === undefined) continue
require(packageName, reference.file)
if (packageName === CHOOSER_PACKAGE) {
for (const backend of CHOOSER_BACKEND_PACKAGES) require(backend, reference.file)
}
}
return [...requiredPackages].flatMap(([packageName, locations]) => packageName in dependencies
? []
: `${[...locations].join(', ')}: ${packageName} must be declared in ${manifestPath} dependencies`)
}
function readManifest(path: string): PackageManifest {
return JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest
}
@@ -177,7 +273,7 @@ function rootProjectReferences(): Set<string> {
}
function packageNameFromSpecifier(specifier: string): string | undefined {
if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('file:')) return undefined
if (specifier.startsWith('.') || specifier.startsWith('/') || /^[a-z][a-z+.-]*:/i.test(specifier)) return undefined
const segments = specifier.split('/')
if (specifier.startsWith('@')) {
return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined

View File

@@ -126,7 +126,7 @@ function heritageExemption(
returnType = d.type.type
} else continue
baseParams ??= new Set()
// Leading underscores are the deliberately-unused marker (eslint
// Leading underscores are the deliberately-unused marker (lint
// argsIgnorePattern), not a rename: `_cwd` overriding `cwd` is the
// same parameter, so compare underscore-stripped on both sides.
for (const p of params) if (ts.isIdentifier(p.name)) baseParams.add(p.name.text.replace(/^_+/, ''))

View File

@@ -45,11 +45,15 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' },
'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' },
'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' },
'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' },
'packages/typert/loader': { kind: 'none', reason: 'Loader integration only registers generated artifacts; consumers own any model-visible projection.' },
'packages/client/hmr': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/modules': { kind: 'none', reason: 'Browser-side module-loading kernel machinery; registers no model surface.' },
'packages/client/test-runtime': { kind: 'none', reason: 'Browser-side test infrastructure (jsdom bench); registers no model surface.' },
'packages/client/ui-slots': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-primitives': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/web-react': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/schema-form': { kind: 'none', reason: 'Browser-side form-rendering library; registers no model surface.' },
'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/runtime': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
@@ -58,6 +62,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' },
'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the host snapshots the target at the next prompt-assembly boundary and owns the model-visible effect.' },
'packages/client/ui-goal': { kind: 'indirect', reason: 'The strip verbs route goal.* mutations; the host GoalService owns the model-visible goal/change context message.' },
'packages/client/ui-permission': { kind: 'indirect', reason: 'The picker submits the host /permission command; the knob events it appends own the model-visible effect through the sandbox/approval consumers.' },
'packages/client/ui-plan': { kind: 'indirect', reason: 'The chip dispatches /plan off; dsh-plan-mode owns the model-visible policy, exit tool, and logged state.' },
'packages/client/ui-question': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' },
'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-workspace': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
@@ -70,9 +77,12 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' },
'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' },
'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
'packages/fs/fs-sandbox': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' },
'packages/host/apiproxy': { kind: 'none', reason: 'The wire contract and fetch carriers move already-composed messages and register no model surface.' },
'packages/host/directory-picker': { kind: 'none', reason: 'The GUI-host picking seam registers no model surface.' },
'packages/host/directory-picker-auto': { kind: 'none', reason: 'The GUI-host picking chooser only mounts a backend row; registers no model surface.' },
'packages/host/directory-picker-browse': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' },
'packages/host/directory-picker-native': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' },
'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' },
'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' },
'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' },
@@ -81,15 +91,21 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/subprocess/subprocess': { kind: 'indirect', reason: 'The seam delegates all model rendering to consumer seams such as the bash executor family.' },
'packages/subprocess/subprocess-local': { kind: 'indirect', reason: 'The spawn backend delegates model rendering to consumer seams such as the bash executor family.' },
'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' },
'packages/sandbox/sandbox-policy': { kind: 'indirect', reason: 'The policy service holds the mode dsh-tool-bash and dsh-tool-fs render in their denial markers.' },
'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' },
'packages/sdk/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' },
'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' },
'packages/sdk/sdk-client': { kind: 'none', reason: 'Client-process library; the model surface lives in the spawned runtime\'s composed plugins.' },
'packages/sdk/sdk-protocol': { kind: 'none', reason: 'Client-facing wire library; the runtime plugins behind the serving entry own the model surface.' },
'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' },
'packages/session-projection/session-projection': { kind: 'none', reason: 'The projection registry serves client-facing read models of already-logged session state and registers no model surface.' },
'packages/session-projection/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection state and registers no model surface.' },
'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' },
'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' },
'packages/settings/settings': { kind: 'indirect', reason: 'The seam stores and resolves user settings; consumer plugins own any model surface a value feeds.' },
'packages/settings/settings-local': { kind: 'indirect', reason: 'The file provider stores and publishes namespace sections; consumers of ctx.settings own any model surface.' },
'packages/credentials/credentials': { kind: 'indirect', reason: 'The seam resolves credential references; the consuming adapter owns every model surface a value authorizes.' },
'packages/credentials/credentials-local': { kind: 'indirect', reason: 'The file/environment provider stores credential values; consumers of ctx.credentials own any model surface.' },
'packages/util/atomic-write': { kind: 'none', reason: 'Pure filesystem write primitive; registers no model surface.' },
'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' },
'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' },
'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' },
@@ -103,6 +119,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/support/loader-smoke': { kind: 'none', reason: 'The test harness observes child-process streams without changing live requests.' },
'packages/support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' },
'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' },
'packages/typert/generator': { kind: 'none', reason: 'The build-time generator runs outside any agent runtime and touches no model request.' },
'packages/tasks/tasks': { kind: 'indirect', reason: 'Producer and control-surface plugins own all model rendering over the task registry.' },
'packages/tasks/tasks-local': { kind: 'indirect', reason: 'The registry backend delegates model rendering to producer plugins and dsh-tool-tasks.' },
'packages/examples/acp-demo': { kind: 'indirect', reason: 'The app bundle delegates request composition to dsh-agent-spine-demo and dsh-acp.' },
@@ -112,6 +129,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/ui/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' },
'packages/util/timeout': { kind: 'indirect', reason: 'Only timeout consumers render timeout outcomes.' },
'packages/util/retention': { kind: 'indirect', reason: 'Only retention consumers render retained content and omission metadata.' },
'packages/util/native-command': { kind: 'none', reason: 'The host-side subprocess runner registers no model surface.' },
'packages/web/web': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-web.' },
'packages/web/web-fetch-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' },
'packages/web/web-search-exa': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' },

View File

@@ -9,9 +9,9 @@
* See `docs/i18n/README.md` for the owning contract.
*/
import { createHash } from 'node:crypto'
import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
import { basename, join, resolve, sep } from 'node:path'
import { gitBlobHash, storeGitBlob } from './translation-pairing-git.ts'
import {
linksTo,
parseTranslationMarkdown,
@@ -54,14 +54,6 @@ function isExcluded(file: string): boolean {
return manifest.excluded.some(entry => (entry.endsWith('/') ? file.startsWith(entry) : file === entry))
}
/** Full git blob hash (what `git hash-object` prints). */
function blobHash(content: Buffer): string {
const hash = createHash('sha1')
hash.update(`blob ${content.byteLength}\0`)
hash.update(content)
return hash.digest('hex')
}
/** The three paths of a pair, derived from the English-file path. */
function pairPaths(source: string): { zh: string; meta: string } {
return { zh: source.replace(/\.md$/, '.zh.md'), meta: source.replace(/\.md$/, '.i18n.yaml') }
@@ -148,7 +140,12 @@ if (writeMode) {
}
continue
}
const record = renderMeta(source, blobHash(readFileSync(join(root, source))), zh, blobHash(readFileSync(join(root, zh))))
const sourceContent = readFileSync(join(root, source))
const zhContent = readFileSync(join(root, zh))
// A consistency record is also a recovery pointer for the briefing
// generator. Persist both snapshots even when the sidecar text is already
// current, because the bytes may exist only in this working tree.
const record = renderMeta(source, storeGitBlob(root, sourceContent), zh, storeGitBlob(root, zhContent))
if (existsSync(join(root, meta)) && readFileSync(join(root, meta), 'utf8') === record) continue
writeFileSync(join(root, meta), record)
console.log(`verify-translation-pairing: recorded ${meta}`)
@@ -203,7 +200,7 @@ for (const source of [...pairAnchors].sort()) {
let consistent = true
for (const [file, content] of [[source, sourceContent], [zh, zhContent]] as const) {
const current = blobHash(content)
const current = gitBlobHash(content)
if (record.get(basename(file)) !== current) {
errors.push(`${file}: out of sync — content no longer matches the pair's last confirmed-consistent state in ${meta} (bring the other side along, then re-record with --write)`)
consistent = false

View File

@@ -0,0 +1,14 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
describe('Vitest jsdom compatibility', () => {
it('provides isolated browser storage instead of Node process storage', () => {
if (process.allowedNodeEnvironmentFlags.has('--webstorage')) {
expect(process.execArgv.filter(argument => argument === '--no-webstorage')).toHaveLength(1)
}
localStorage.setItem('dsh-vitest-storage-probe', 'available')
expect(localStorage.getItem('dsh-vitest-storage-probe')).toBe('available')
localStorage.removeItem('dsh-vitest-storage-probe')
})
})