Merge remote-tracking branch 'origin/master' into feat/mcp-manager-core
# Conflicts: # docs/capability-seams.i18n.yaml # docs/capability-seams.md # docs/capability-seams.zh.md # docs/config-catalog.i18n.yaml # docs/config-catalog.md # docs/config-catalog.zh.md # docs/event-producer-consumer.i18n.yaml # docs/event-producer-consumer.md # docs/event-producer-consumer.zh.md # docs/module-graph.i18n.yaml # docs/module-graph.md # docs/module-graph.zh.md # docs/subsystems/tools.i18n.yaml # packages/extensions/tool-cordis/src/api-catalog.ts # packages/mcp/README.i18n.yaml # packages/mcp/README.md # packages/mcp/README.zh.md # packages/mcp/mcp-client/README.i18n.yaml # packages/mcp/mcp-client/README.md # packages/mcp/mcp-client/README.zh.md # packages/mcp/mcp-client/src/connection.ts # packages/mcp/mcp-client/src/index.ts # packages/mcp/mcp-client/tests/reconnect.spec.ts # scripts/gen-cordis-catalog.ts # tsconfig.base.json # tsconfig.host.json
This commit is contained in:
commit
fac0bd08e8
5314 files changed
+864087
-59489
No files matched your search
@@ -16,8 +16,8 @@ const WORKSPACE_MANIFEST_GLOBS = [
|
||||
'apps/*/package.json',
|
||||
'packages/*/*/package.json',
|
||||
'vendor/*/package.json',
|
||||
'native/landlock-run/package.json',
|
||||
'native/landlock-run/packages/*/package.json',
|
||||
'native/system/package.json',
|
||||
'native/system/packages/*/package.json',
|
||||
]
|
||||
const INSTALLED_MANIFEST_GLOBS = [
|
||||
'node_modules/.pnpm/*/node_modules/*/package.json',
|
||||
@@ -131,7 +131,7 @@ export function parseBenchmarkOptions(args: readonly string[]): BenchmarkOptions
|
||||
}
|
||||
|
||||
function workspaceManifestPath(path: string): boolean {
|
||||
return /^(?:apps\/[^/]+|packages\/[^/]+\/[^/]+|vendor\/[^/]+|native\/landlock-run(?:\/packages\/[^/]+)?)\/package\.json$/.test(path)
|
||||
return /^(?:apps\/[^/]+|packages\/[^/]+\/[^/]+|vendor\/[^/]+|native\/system(?:\/packages\/[^/]+)?)\/package\.json$/.test(path)
|
||||
}
|
||||
|
||||
function workspaceManifestPaths(root: string, ref: string | undefined): string[] {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { browserBundledExternals, browserPackageOfFile, browserSourceAliases } from './browser-bundled-externals.ts'
|
||||
|
||||
const filesystem = vi.hoisted(() => ({ preservedAlias: undefined as string | undefined }))
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>()
|
||||
return {
|
||||
...actual,
|
||||
realpathSync: Object.assign((...args: Parameters<typeof actual.realpathSync>) => {
|
||||
// Windows' JavaScript realpath can retain an 8.3 alias that Vite's native resolution expands.
|
||||
if (typeof args[0] === 'string' && args[0] === filesystem.preservedAlias) return args[0]
|
||||
return actual.realpathSync(...args)
|
||||
}, { native: actual.realpathSync.native }),
|
||||
}
|
||||
})
|
||||
|
||||
const roots: string[] = []
|
||||
const dependencyLinks: string[] = []
|
||||
const repositoryRoot = resolve(import.meta.dirname, '..')
|
||||
|
||||
afterEach(() => {
|
||||
filesystem.preservedAlias = undefined
|
||||
for (const link of dependencyLinks.splice(0)) unlinkSync(link)
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function fixture(): string {
|
||||
const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-browser-notices-')))
|
||||
roots.push(root)
|
||||
write(root, 'package.json', '{"type":"module"}')
|
||||
write(root, 'tsconfig.base.json', JSON.stringify({
|
||||
compilerOptions: {
|
||||
module: 'esnext', target: 'es2022', jsx: 'react-jsx',
|
||||
paths: { '@fixture/static': ['./packages/client/static/src/index.ts'] },
|
||||
},
|
||||
}))
|
||||
write(root, 'tsconfig.base.client.json', '{"extends":"./tsconfig.base.json"}')
|
||||
return root
|
||||
}
|
||||
|
||||
function write(root: string, path: string, text: string): void {
|
||||
const target = join(root, path)
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
writeFileSync(target, text)
|
||||
}
|
||||
|
||||
function library(root: string, name: string, source = 'export const value = 1'): void {
|
||||
write(root, `node_modules/${name}/package.json`, JSON.stringify({ name, type: 'module', exports: './index.js' }))
|
||||
write(root, `node_modules/${name}/index.js`, source)
|
||||
}
|
||||
|
||||
function dynamicPlugin(root: string, source: string): void {
|
||||
write(root, 'packages/client/dynamic/package.json', JSON.stringify({
|
||||
name: '@fixture/dynamic', dsh: { client: { platform: 'web' } },
|
||||
}))
|
||||
write(root, 'packages/client/dynamic/tsdown.config.ts', [
|
||||
'export default {',
|
||||
' name: "@fixture/dynamic/client",',
|
||||
' entry: { client: "src/client/index.ts" },',
|
||||
' inputOptions: { resolve: { conditionNames: ["browser", "import", "default"] } },',
|
||||
'}',
|
||||
].join('\n'))
|
||||
write(root, 'packages/client/dynamic/src/client/index.ts', source)
|
||||
}
|
||||
|
||||
describe('browser dependency discovery', () => {
|
||||
it('records runtime imports through the client config without following upstream dependencies or erased types', async () => {
|
||||
const root = fixture()
|
||||
library(root, 'browser-lib', 'export { value } from "transitive-lib"')
|
||||
library(root, 'transitive-lib')
|
||||
dynamicPlugin(root, [
|
||||
'import type { MissingType } from "type-only-lib"',
|
||||
'import { value } from "browser-lib"',
|
||||
'export const output: MissingType = value',
|
||||
].join('\n'))
|
||||
|
||||
expect(await browserBundledExternals(root)).toEqual(new Set(['browser-lib']))
|
||||
expect(existsSync(join(root, 'packages/client/dynamic/lib'))).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an unresolved runtime library', async () => {
|
||||
const root = fixture()
|
||||
dynamicPlugin(root, 'export { value } from "missing-browser-lib"')
|
||||
await expect(browserBundledExternals(root)).rejects.toThrow('cannot resolve missing-browser-lib')
|
||||
})
|
||||
|
||||
it('rejects a declared client without its browser config', async () => {
|
||||
const root = fixture()
|
||||
dynamicPlugin(root, 'export const value = 1')
|
||||
write(root, 'packages/client/dynamic/tsdown.config.ts', 'export default { name: "@fixture/dynamic", entry: "src/index.ts" }')
|
||||
await expect(browserBundledExternals(root)).rejects.toThrow('has no browser build config')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Rollup', false, false], ['Rollup', true, false], ['Rollup', true, true],
|
||||
['Rolldown', false, false], ['Rolldown', true, false], ['Rolldown', true, true],
|
||||
] as const)('follows shell aliases, CSS and lazy imports without writing output (%s, symlinked root: %s, retained alias: %s)', async (bundler, linked, preserveAlias) => {
|
||||
const root = fixture()
|
||||
library(root, 'shell-lib')
|
||||
library(root, 'lazy-lib')
|
||||
library(root, 'asset-lib')
|
||||
write(root, 'node_modules/asset-lib/package.json', JSON.stringify({
|
||||
name: 'asset-lib', exports: { './theme.css': './theme.css' },
|
||||
}))
|
||||
write(root, 'node_modules/asset-lib/theme.css', '.fixture { color: red }')
|
||||
write(root, 'packages/client/static/package.json', '{"name":"@fixture/static"}')
|
||||
write(root, 'packages/client/static/src/index.ts', [
|
||||
'import { value } from "shell-lib"',
|
||||
'import "asset-lib/theme.css"',
|
||||
'export const output = value',
|
||||
'export const lazy = () => import("lazy-lib")',
|
||||
].join('\n'))
|
||||
const app = join(root, 'apps/web')
|
||||
write(root, 'apps/web/package.json', '{"name":"@fixture/web","type":"module","exports":{"./dist/*":"./dist/*"}}')
|
||||
const owner = bundler === 'Rollup' ? resolve(repositoryRoot, 'apps/web/package.json')
|
||||
: createRequire(resolve(repositoryRoot, 'package.json')).resolve('vitest/package.json')
|
||||
const viteDirectory = dirname(createRequire(owner).resolve('vite/package.json'))
|
||||
const viteLink = join(app, 'node_modules/vite')
|
||||
mkdirSync(dirname(viteLink), { recursive: true })
|
||||
symlinkSync(viteDirectory, viteLink, 'junction')
|
||||
dependencyLinks.push(viteLink)
|
||||
write(root, 'apps/web/index.html', '<script type="module" src="./main.ts"></script>')
|
||||
write(root, 'apps/web/main.ts', 'import { output, lazy } from "@fixture/static"; console.log(output); void lazy()')
|
||||
write(root, 'apps/web/vite.config.ts', `
|
||||
import { productWebBundleIsolation } from ${JSON.stringify(resolve(repositoryRoot, 'apps/web/product-isolation.ts').replaceAll('\\', '/'))}
|
||||
export default ({ mode }) => {
|
||||
if (mode !== 'production') throw new Error('notice discovery must use the production build mode')
|
||||
return {
|
||||
plugins: [...productWebBundleIsolation(${JSON.stringify(root)}, ${JSON.stringify(app)}), {
|
||||
name: 'fixture-bundler-engine',
|
||||
generateBundle() {
|
||||
const actual = typeof this.meta.rolldownVersion === 'string' ? 'Rolldown' : 'Rollup'
|
||||
if (actual !== ${JSON.stringify(bundler)}) throw new Error('unexpected bundler: ' + actual)
|
||||
},
|
||||
}],
|
||||
build: { rollupOptions: { input: { index: ${JSON.stringify(join(app, 'index.html'))}, preview: "missing-preview.ts" } } }
|
||||
}
|
||||
}
|
||||
`)
|
||||
write(root, 'apps/web/dist/sentinel.txt', 'untouched')
|
||||
|
||||
const scanRoot = linked ? join(fixture(), 'linked') : root
|
||||
if (linked) symlinkSync(root, scanRoot, 'junction')
|
||||
if (preserveAlias) filesystem.preservedAlias = scanRoot
|
||||
try {
|
||||
expect(await browserBundledExternals(scanRoot)).toEqual(new Set(['shell-lib', 'lazy-lib', 'asset-lib']))
|
||||
} finally {
|
||||
filesystem.preservedAlias = undefined
|
||||
if (linked) unlinkSync(scanRoot)
|
||||
}
|
||||
expect(readFileSync(join(app, 'dist/sentinel.txt'), 'utf8')).toBe('untouched')
|
||||
expect(existsSync(join(app, 'dist/index.html'))).toBe(false)
|
||||
expect(existsSync(join(root, 'packages/client/static/lib'))).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes installed module ids and excludes virtual/workspace modules', () => {
|
||||
expect(browserPackageOfFile('/repo/node_modules/.pnpm/pkg@1/node_modules/pkg/a.js')).toBe('pkg')
|
||||
expect(browserPackageOfFile('C:\\repo\\node_modules\\@scope\\pkg\\a.css?url')).toBe('@scope/pkg')
|
||||
expect(browserPackageOfFile('/repo/packages/client/a.ts')).toBeUndefined()
|
||||
expect(browserPackageOfFile('\0vite/modulepreload-polyfill')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps exact names and subpaths from the source facade', () => {
|
||||
const root = fixture()
|
||||
const aliases = browserSourceAliases(root)
|
||||
expect('@fixture/static'.replace(aliases[0]!.find, aliases[0]!.replacement))
|
||||
.toBe(join(root, 'packages/client/static/src/index.ts'))
|
||||
expect(aliases[0]!.find.test('@fixture/static-extra')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
/** Resolve direct third-party browser inputs through the shipping build configurations, without emitting files. */
|
||||
|
||||
import { globSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Rolldown, type UserConfigExport } from 'tsdown'
|
||||
import ts from 'typescript'
|
||||
import { browserDependencyAnalysis } from '../apps/web/product-isolation.ts'
|
||||
|
||||
interface Manifest {
|
||||
name: string
|
||||
private?: boolean
|
||||
dsh?: { client?: unknown }
|
||||
exports?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ResolveContext {
|
||||
resolve(source: string, importer: string, options: { skipSelf: boolean }): Promise<{ id: string } | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Name of the installed package owning a bundler-resolved file.
|
||||
* @param file - Resolved module or asset id, including any loader query.
|
||||
* @returns Package name, or undefined for workspace and virtual modules.
|
||||
*/
|
||||
export function browserPackageOfFile(file: string): string | undefined {
|
||||
const normalized = file.replaceAll('\\', '/')
|
||||
const marker = normalized.lastIndexOf('/node_modules/')
|
||||
if (marker < 0) return undefined
|
||||
const parts = normalized.slice(marker + '/node_modules/'.length).split('/')
|
||||
return parts[0]?.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
|
||||
}
|
||||
|
||||
function recorder(seen: Set<string>, workspaceNames: ReadonlySet<string>, followWorkspace = false) {
|
||||
return {
|
||||
name: 'dsh-browser-direct-dependencies',
|
||||
enforce: 'pre' as const,
|
||||
resolveId: {
|
||||
order: 'pre' as const,
|
||||
async handler(this: ResolveContext, source: string, importer: string | undefined) {
|
||||
if (importer === undefined || source.startsWith('.') || source.startsWith('/')
|
||||
|| source.startsWith('\0') || source.startsWith('node:')) return null
|
||||
const parts = source.split('/')
|
||||
const name = source.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
|
||||
if (name !== undefined && workspaceNames.has(name)) {
|
||||
return followWorkspace ? null : { id: source, external: true }
|
||||
}
|
||||
const resolved = await this.resolve(source, importer, { skipSelf: true })
|
||||
if (resolved === null) throw new Error(`browser notices: cannot resolve ${source} from ${importer}`)
|
||||
const owner = browserPackageOfFile(resolved.id)
|
||||
if (owner === undefined) return resolved
|
||||
if (browserPackageOfFile(importer) === undefined) seen.add(owner)
|
||||
// Notices disclose direct dependencies; upstream implementation imports stay in the lockfile.
|
||||
return { id: source, external: true }
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function readManifest(path: string): Manifest {
|
||||
return JSON.parse(readFileSync(path, 'utf8')) as Manifest
|
||||
}
|
||||
|
||||
/**
|
||||
* Source aliases shared with the repository's source-plane TypeScript programs.
|
||||
* @param root - Repository root containing tsconfig.base.json.
|
||||
* @returns Exact and wildcard aliases for the Vite dependency walk.
|
||||
*/
|
||||
export function browserSourceAliases(root: string): { find: RegExp; replacement: string }[] {
|
||||
const path = resolve(root, 'tsconfig.base.json')
|
||||
const config = ts.readConfigFile(path, file => ts.sys.readFile(file))
|
||||
if (config.error !== undefined) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'))
|
||||
const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, root)
|
||||
return Object.entries(parsed.options.paths ?? {}).map(([name, targets]) => {
|
||||
const target = targets[0]
|
||||
if (target === undefined) throw new Error(`browser notices: ${name} has no source target in ${path}`)
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace('\\*', '(.*)')
|
||||
return { find: new RegExp(`^${escaped}$`), replacement: resolve(root, target).replace('*', '$1') }
|
||||
})
|
||||
}
|
||||
|
||||
async function collectClientBundles(
|
||||
root: string,
|
||||
manifests: ReadonlyMap<string, Manifest>,
|
||||
workspaceNames: ReadonlySet<string>,
|
||||
seen: Set<string>,
|
||||
): Promise<void> {
|
||||
for (const [manifestPath, manifest] of manifests) {
|
||||
if (manifest.private === true || manifest.dsh?.client === undefined) continue
|
||||
const dir = dirname(manifestPath)
|
||||
const loaded = await import(pathToFileURL(resolve(dir, 'tsdown.config.ts')).href) as { default: UserConfigExport }
|
||||
const factory = await loaded.default
|
||||
const configured = typeof factory === 'function' ? await factory({ env: {} }, { ci: false }) : factory
|
||||
const configs = Array.isArray(configured) ? configured : [configured]
|
||||
const client = configs.find(config => config.name === `${manifest.name}/client`)
|
||||
if (client === undefined) throw new Error(`browser notices: ${manifest.name} has no browser build config`)
|
||||
if (typeof client.inputOptions === 'function') throw new Error(`browser notices: ${manifest.name} needs resolved input options`)
|
||||
const bundle = await Rolldown.rolldown({
|
||||
...client.inputOptions,
|
||||
cwd: dir,
|
||||
input: client.entry as Rolldown.InputOption,
|
||||
platform: 'browser',
|
||||
transform: client.define === undefined ? {} : { define: client.define },
|
||||
plugins: [recorder(seen, workspaceNames), client.plugins ?? []] as NonNullable<Rolldown.InputOptions['plugins']>,
|
||||
tsconfig: resolve(root, 'tsconfig.base.client.json'),
|
||||
})
|
||||
try {
|
||||
await bundle.generate({ format: 'cjs', sourcemap: false })
|
||||
} finally {
|
||||
await bundle.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ShellConfig {
|
||||
build: { rollupOptions?: { input?: string | string[] | Record<string, string> } }
|
||||
}
|
||||
|
||||
interface ViteApi {
|
||||
resolveConfig(
|
||||
config: Record<string, unknown>, command: 'build', defaultMode: string, defaultNodeEnv: string,
|
||||
): Promise<ShellConfig>
|
||||
build(config: Record<string, unknown>): Promise<unknown>
|
||||
}
|
||||
|
||||
async function collectShell(
|
||||
root: string,
|
||||
workspaceNames: ReadonlySet<string>,
|
||||
seen: Set<string>,
|
||||
): Promise<void> {
|
||||
for (const path of globSync('apps/*/vite.config.ts', { cwd: root }).sort()) {
|
||||
const dir = dirname(resolve(root, path))
|
||||
const manifest = readManifest(resolve(dir, 'package.json'))
|
||||
if (manifest.private === true || manifest.exports?.['./dist/*'] === undefined) continue
|
||||
const vitePath = createRequire(resolve(dir, 'package.json')).resolve('vite')
|
||||
const vite = await import(pathToFileURL(vitePath).href) as ViteApi
|
||||
const config = await vite.resolveConfig({ root: dir, logLevel: 'error' }, 'build', 'production', 'production')
|
||||
const input = config.build.rollupOptions?.input
|
||||
const entries = typeof input === 'string' ? [input] : Object.values(input ?? {})
|
||||
const pages = entries.filter(entry => entry.endsWith('.html'))
|
||||
if (pages.length === 0) throw new Error(`browser notices: ${manifest.name} has no HTML build entry`)
|
||||
await vite.build({
|
||||
root: dir,
|
||||
logLevel: 'error',
|
||||
plugins: [browserDependencyAnalysis(), recorder(seen, workspaceNames, true)],
|
||||
resolve: { alias: browserSourceAliases(root) },
|
||||
build: {
|
||||
write: false,
|
||||
minify: false,
|
||||
sourcemap: false,
|
||||
reportCompressedSize: false,
|
||||
rollupOptions: {
|
||||
input: pages.length === 1 ? pages[0] : pages,
|
||||
// Chunk coloring expects full third-party bodies; the disclosure walk stops at their imports.
|
||||
output: { manualChunks: () => undefined },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct third-party packages resolved by published browser builds.
|
||||
* @param root - Repository root, possibly symlinked, with installed build dependencies; lib/ is not required.
|
||||
* @returns Names of distributed browser inputs, excluding workspace packages and erased types.
|
||||
*/
|
||||
export async function browserBundledExternals(root: string): Promise<Set<string>> {
|
||||
// Vite resolves HTML through native realpath, including Windows 8.3 alias expansion.
|
||||
root = realpathSync.native(root)
|
||||
const manifests = new Map<string, Manifest>()
|
||||
for (const glob of ['packages/*/*/package.json', 'vendor/*/package.json']) {
|
||||
for (const path of globSync(glob, { cwd: root }).sort()) {
|
||||
const absolute = resolve(root, path)
|
||||
manifests.set(absolute, readManifest(absolute))
|
||||
}
|
||||
}
|
||||
const names = new Set([...manifests.values()].map(manifest => manifest.name))
|
||||
const seen = new Set<string>()
|
||||
await collectClientBundles(root, manifests, names, seen)
|
||||
await collectShell(root, names, seen)
|
||||
return seen
|
||||
}
|
||||
@@ -60,6 +60,9 @@ describe('Python runtime executable builder CLI', () => {
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs run verify-runtime-closure`)
|
||||
expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs --filter dsh-python-runtime-closure deploy`)
|
||||
const deploy = result.stdout.split('\n').find(line => line.includes(' --filter dsh-python-runtime-closure deploy'))
|
||||
expect(deploy).toContain('--prod --config.allow-unused-patches=true')
|
||||
expect(result.stdout.split('--config.allow-unused-patches=true')).toHaveLength(2)
|
||||
expect(result.stdout).not.toContain(resolve(root, 'python/sdk-runtime/runtime-bootstrap.mjs'))
|
||||
expect(result.stdout).toContain('"bin":"runtime-bootstrap.mjs"')
|
||||
expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs exec pkg`)
|
||||
|
||||
@@ -292,6 +292,8 @@ class SingleExeBuild {
|
||||
'deploy',
|
||||
'--legacy',
|
||||
'--prod',
|
||||
// Production deployment omits workspace tooling such as Electron's patched signer.
|
||||
'--config.allow-unused-patches=true',
|
||||
'--config.node-linker=hoisted',
|
||||
'--config.auto-install-peers=false',
|
||||
'--config.link-workspace-packages=true',
|
||||
|
||||
@@ -41,6 +41,7 @@ function main(): void {
|
||||
const buildEnvironment = clientBuildProcessEnvironment(process.env, clientEnvironment)
|
||||
|
||||
rmSync(resolve(root, CLIENT_BUILD_RECORD_PATH), { force: true })
|
||||
runScript('build:native-system', buildEnvironment)
|
||||
runScript('build:lib', buildEnvironment)
|
||||
runScript('build:web', buildEnvironment)
|
||||
const record = writeClientBuildRecord(root, clientEnvironment)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/** Experimental ownership of the actual filesystem and package inputs supplied to a bundler. */
|
||||
|
||||
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/** Package identities are cached only within one build. */
|
||||
export class BundleInputIsolation {
|
||||
private readonly packages = new Map<string, string | undefined>()
|
||||
private readonly repository: string
|
||||
private readonly label: string
|
||||
|
||||
constructor(repository: string, label: string) {
|
||||
this.repository = repository
|
||||
this.label = label
|
||||
}
|
||||
|
||||
/** Discard package metadata before a new build or watch rebuild. */
|
||||
reset(): void { this.packages.clear() }
|
||||
|
||||
/**
|
||||
* Require a recorded input to have non-experimental physical and package ownership.
|
||||
* @param id - bundler module id, filesystem path, or filesystem URL.
|
||||
*/
|
||||
assertInput(id: string): void {
|
||||
this.checkInput(id, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check declared source-map ownership; dependency tarballs may omit their upstream sources.
|
||||
* @param id - source-map source resolved relative to its map.
|
||||
*/
|
||||
assertSourceMapInput(id: string): void {
|
||||
this.checkInput(id, true)
|
||||
}
|
||||
|
||||
private checkInput(id: string, allowMissing: boolean): void {
|
||||
if (/(?:^|[/:\u0000])@deepseek-ai\/dsh-experimental-[^/?#]+/.test(id)) {
|
||||
throw new Error(`${this.label}: experimental input ${id}`)
|
||||
}
|
||||
const file = physicalBundleInput(id)
|
||||
if (file === undefined) return
|
||||
const lexical = relative(resolve(this.repository, 'packages/experimental'), file)
|
||||
if (lexical === '' || lexical !== '..' && !lexical.startsWith(`..${sep}`) && !isAbsolute(lexical)) {
|
||||
throw new Error(`${this.label}: experimental input ${id}`)
|
||||
}
|
||||
let existing = file
|
||||
if (!allowMissing && !existsSync(file)) throw new Error(`${this.label}: input ${id} is missing`)
|
||||
while (!existsSync(existing)) {
|
||||
const parent = dirname(existing)
|
||||
if (parent === existing) throw new Error(`${this.label}: input ${id} has no existing filesystem root`)
|
||||
existing = parent
|
||||
}
|
||||
const canonical = resolve(realpathSync(existing), relative(existing, file))
|
||||
if (canonical !== file) this.checkInput(canonical, allowMissing)
|
||||
const name = this.packageName(dirname(canonical))
|
||||
if (name?.startsWith('@deepseek-ai/dsh-experimental-')) {
|
||||
throw new Error(`${this.label}: ${id} belongs to experimental package ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
private packageName(directory: string): string | undefined {
|
||||
if (this.packages.has(directory)) return this.packages.get(directory)
|
||||
const manifest = resolve(directory, 'package.json')
|
||||
let name: string | undefined
|
||||
if (existsSync(manifest)) {
|
||||
const value: unknown = JSON.parse(readFileSync(manifest, 'utf8'))
|
||||
if (value !== null && typeof value === 'object' && 'name' in value && typeof value.name === 'string') name = value.name
|
||||
}
|
||||
if (name === undefined && dirname(directory) !== directory) name = this.packageName(dirname(directory))
|
||||
this.packages.set(directory, name)
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover physical ownership from queries, filesystem URLs, and virtual path wrappers.
|
||||
* @param id - bundler module id or recorded filesystem input.
|
||||
* @returns absolute file path, or undefined for a virtual id without a physical path.
|
||||
*/
|
||||
export function physicalBundleInput(id: string): string | undefined {
|
||||
let clean = id.replaceAll('\\', '/').split(/[?#]/, 1)[0] ?? ''
|
||||
clean = clean.replace(/^\u0000/, '')
|
||||
if (clean.startsWith('file://')) return fileURLToPath(clean)
|
||||
if (clean.startsWith('/@fs/')) {
|
||||
clean = clean.slice('/@fs/'.length)
|
||||
if (!isAbsolute(clean) && !/^[a-zA-Z]:\//.test(clean)) clean = `/${clean}`
|
||||
}
|
||||
if (!isAbsolute(clean) && !/^[a-zA-Z]:\//.test(clean)) {
|
||||
const wrapped = /:(\/.*|[a-zA-Z]:\/.*)$/.exec(clean)?.[1]
|
||||
if (wrapped === undefined) return undefined
|
||||
clean = wrapped
|
||||
}
|
||||
return resolve(clean)
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
/** Experimental-package publication and dependency constraints. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isPublicExperimentalPackageDirectory,
|
||||
PRIVATE_EXPERIMENTAL_PACKAGE_DIRECTORIES,
|
||||
} from './experimental-package-policy.ts'
|
||||
import {
|
||||
checkDshFamilyVersion,
|
||||
checkExperimentalDependencyIsolation,
|
||||
@@ -9,10 +13,13 @@ import {
|
||||
type WorkspaceManifest,
|
||||
} from './check-workspace-constraints.ts'
|
||||
|
||||
const experimental: WorkspaceManifest = {
|
||||
const experimental = {
|
||||
dir: 'packages/experimental/prototype',
|
||||
manifest: { name: '@deepseek-ai/dsh-experimental-prototype', private: true },
|
||||
}
|
||||
manifest: {
|
||||
name: '@deepseek-ai/dsh-experimental-prototype',
|
||||
publishConfig: { access: 'public' },
|
||||
},
|
||||
} satisfies WorkspaceManifest
|
||||
|
||||
describe('experimental workspace constraints', () => {
|
||||
it('requires the experimental package-name prefix', () => {
|
||||
@@ -24,17 +31,46 @@ describe('experimental workspace constraints', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('requires private manifests without publication metadata', () => {
|
||||
it('requires public metadata for unlisted experimental packages', () => {
|
||||
expect(checkExperimentalManifest(experimental)).toEqual([])
|
||||
expect(checkExperimentalManifest({
|
||||
...experimental,
|
||||
manifest: { ...experimental.manifest, private: false, publishConfig: { access: 'public' } },
|
||||
manifest: { name: experimental.manifest.name, private: true },
|
||||
})).toEqual([
|
||||
'@deepseek-ai/dsh-experimental-prototype: experimental package must set "private": true',
|
||||
'@deepseek-ai/dsh-experimental-prototype: experimental package must omit publishConfig',
|
||||
'@deepseek-ai/dsh-experimental-prototype: public experimental package must not set "private": true',
|
||||
'@deepseek-ai/dsh-experimental-prototype: public experimental package must set publishConfig.access to "public"',
|
||||
])
|
||||
})
|
||||
|
||||
it('requires private metadata for an explicitly excluded prototype', () => {
|
||||
const { dir, manifest: { name } } = experimental
|
||||
const privateDirectories = [dir]
|
||||
expect(isPublicExperimentalPackageDirectory(dir, privateDirectories)).toBe(false)
|
||||
expect(checkExperimentalManifest({ dir, manifest: { name, private: true } }, privateDirectories)).toEqual([])
|
||||
expect(checkExperimentalManifest(experimental, privateDirectories)).toEqual([
|
||||
`${name}: experimental package must set "private": true`,
|
||||
`${name}: experimental package must omit publishConfig`,
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the current experimental publication set unrestricted', () => {
|
||||
expect(PRIVATE_EXPERIMENTAL_PACKAGE_DIRECTORIES).toEqual([])
|
||||
})
|
||||
|
||||
it('limits the public default to experimental package directories', () => {
|
||||
expect(isPublicExperimentalPackageDirectory(experimental.dir)).toBe(true)
|
||||
for (const dir of [
|
||||
'packages/core/session',
|
||||
'apps/cli',
|
||||
'vendor/cordis',
|
||||
'packages/experimental',
|
||||
'packages/experimental/prototype/src',
|
||||
...PRIVATE_EXPERIMENTAL_PACKAGE_DIRECTORIES,
|
||||
]) {
|
||||
expect(isPublicExperimentalPackageDirectory(dir)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['dependencies', 'optionalDependencies', 'peerDependencies'] as const)(
|
||||
'rejects release %s on an experimental package',
|
||||
(section) => {
|
||||
@@ -102,7 +138,7 @@ describe('dsh family version coherence', () => {
|
||||
it('leaves other sequences to their own version lines', () => {
|
||||
expect(checkDshFamilyVersion({ name: '@deepseek-ai/cordis', version: '4.0.1' }, '0.1.2-rc.1')).toBeUndefined()
|
||||
expect(checkDshFamilyVersion(
|
||||
{ name: '@deepseek-ai/node-addon-landlock-run', version: '0.1.1' },
|
||||
{ name: '@deepseek-ai/node-addon-system', version: '0.1.1' },
|
||||
'0.1.2-rc.1',
|
||||
)).toBeUndefined()
|
||||
expect(checkDshFamilyVersion({ version: '0.1.2-alpha.5' }, '0.1.2-rc.1')).toBeUndefined()
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
||||
import { join, relative, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import {
|
||||
isPublicExperimentalPackageDirectory,
|
||||
PRIVATE_EXPERIMENTAL_PACKAGE_DIRECTORIES,
|
||||
} from './experimental-package-policy.ts'
|
||||
import { hasTypertRemoteNavigation, isForbiddenPublicationFile } from './publication-payload.ts'
|
||||
import { collectProjectReferenceFaceViolations } from './project-reference-faces.ts'
|
||||
|
||||
@@ -18,7 +22,7 @@ const workspaceGlobs = [
|
||||
{ dir: 'vendor', depth: 1 },
|
||||
{ dir: 'packages', depth: 2 },
|
||||
{ dir: 'native', depth: 1 },
|
||||
{ dir: 'native/landlock-run/packages', depth: 1 },
|
||||
{ dir: 'native/system/packages', depth: 1 },
|
||||
{ dir: 'apps', depth: 1 },
|
||||
] as const
|
||||
const vendoredPackages = new Set([
|
||||
@@ -32,28 +36,25 @@ const vendoredPackages = new Set([
|
||||
'@deepseek-ai/cordis-plugin-hmr',
|
||||
'@deepseek-ai/cordis-plugin-logger-console',
|
||||
])
|
||||
const publicLandlockPackages = new Set([
|
||||
'@deepseek-ai/node-addon-landlock-run',
|
||||
'@deepseek-ai/node-addon-landlock-run-linux-arm64',
|
||||
'@deepseek-ai/node-addon-landlock-run-linux-x64',
|
||||
const publicNativePackages = new Set([
|
||||
'@deepseek-ai/node-addon-system',
|
||||
'@deepseek-ai/node-addon-system-darwin-arm64',
|
||||
'@deepseek-ai/node-addon-system-darwin-x64',
|
||||
'@deepseek-ai/node-addon-system-linux-arm64',
|
||||
'@deepseek-ai/node-addon-system-linux-x64',
|
||||
])
|
||||
/** Deliberate source payloads whose exact bytes are part of the package's audit surface. */
|
||||
const publicationSourceAllowlist: Readonly<Record<string, readonly string[]>> = {
|
||||
'@deepseek-ai/node-addon-landlock-run': ['src/main.c'],
|
||||
'@deepseek-ai/node-addon-system': ['src/main.c', 'src/flock.c'],
|
||||
}
|
||||
const repositoryUrl = 'git+https://github.com/deepseek-harness/deepseek-harness.git'
|
||||
/**
|
||||
* Source home the published packages point consumers at. It differs from
|
||||
* {@link repositoryUrl}, which the Landlock packages keep because npm resolves
|
||||
* their trusted publishing against the repository that runs the workflow.
|
||||
*/
|
||||
/** Public source home recorded in maintained package manifests. */
|
||||
const publishedRepositoryUrl = 'git+https://github.com/deepseek-ai/deepseek-harness.git'
|
||||
/** Private packages that participate in workspace checks but not releases. */
|
||||
/** Packages that participate in the experimental policy. */
|
||||
const experimentalPackageDirectory = /^packages\/experimental\/[^/]+$/
|
||||
/** npm namespace reserved for private experimental packages. */
|
||||
/** npm namespace reserved for experimental packages. */
|
||||
const experimentalPackageNamePrefix = '@deepseek-ai/dsh-experimental-'
|
||||
/** Directories whose packages this repository publishes: one release member each. */
|
||||
const releaseMemberDirectory = /^(?:packages\/(?!experimental\/)[^/]+\/[^/]+|apps\/(?!desktop(?:-host)?$)[^/]+|vendor\/[^/]+)$/
|
||||
/** Ordinary directories whose packages this repository publishes: one release member each. */
|
||||
const standardReleaseMemberDirectory = /^(?:packages\/(?!experimental\/)[^/]+\/[^/]+|apps\/(?!desktop(?:-host)?$)[^/]+|vendor\/[^/]+)$/
|
||||
/** Installable application assembled by electron-builder rather than published to npm. */
|
||||
const desktopApplicationDirectory = 'apps/desktop'
|
||||
const localArtifactDirs = new Set(['node_modules'])
|
||||
@@ -64,7 +65,7 @@ const appPackageFiles: Readonly<Record<string, readonly string[]>> = {
|
||||
'config/desktop.cordis.patch.yml',
|
||||
],
|
||||
// Sourcemaps stay out by payload policy; the worker-preview surface
|
||||
// (dist/preview.html and dist/preview/) backs private experimental
|
||||
// (dist/preview.html and dist/preview/) backs opt-in experimental
|
||||
// packages and is not published.
|
||||
'@deepseek-ai/dsh-web-frontend': ['dist', '!dist/**/*.map', '!dist/preview.html', '!dist/preview'],
|
||||
}
|
||||
@@ -114,8 +115,8 @@ function readJson(path: string): PackageManifest {
|
||||
|
||||
const rootManifest = readJson(join(root, 'package.json'))
|
||||
const repositoryVersion = rootManifest.version
|
||||
const landlockWorkspaceManifest = readJson(join(root, 'native/landlock-run/package.json'))
|
||||
const landlockVersion = landlockWorkspaceManifest.version
|
||||
const nativeWorkspaceManifest = readJson(join(root, 'native/system/package.json'))
|
||||
const nativeVersion = nativeWorkspaceManifest.version
|
||||
|
||||
/** Repo-relative dirs holding a package.json, walked to the configured depth. */
|
||||
function packageDirs(base: string, depth: number): string[] {
|
||||
@@ -153,10 +154,15 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
|
||||
// The glob covers whichever sheets a package emits; sourcemaps stay
|
||||
// unpublished, as everywhere else in the repository.
|
||||
'@deepseek-ai/dsh-client-ui-primitives': ['lib/**/*.css'],
|
||||
'@deepseek-ai/dsh-client-ui-dockkit': ['lib/**/*.css'],
|
||||
'@deepseek-ai/dsh-client-web': ['lib/**/*.css'],
|
||||
'@deepseek-ai/dsh-client-ui-theme': ['lib/styles'],
|
||||
// The CPython side ships as source .py files, published as-is rather than built.
|
||||
'@deepseek-ai/dsh-experimental-code-runtime-python': ['py/**/*.py'],
|
||||
'@deepseek-ai/dsh-experimental-ptc-runtime-python': ['py/**/*.py'],
|
||||
// The isolated Node bootstrap is a separately launched bundle.
|
||||
'@deepseek-ai/dsh-ptc-runtime-node': ['lib/process.js'],
|
||||
// The Host entry starts its sibling Worker by URL rather than a package export.
|
||||
'@deepseek-ai/dsh-experimental-inspector': ['lib/worker.js'],
|
||||
// The shipped preset compositions travel inside the roster package.
|
||||
'@deepseek-ai/dsh-agent-presets': ['presets'],
|
||||
// The Web Host mounts the default-off settings owner independently of each
|
||||
@@ -170,17 +176,28 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
|
||||
// also shares its generated FFI code through a hashed runtime chunk.
|
||||
'@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js', 'lib/types-*.js'],
|
||||
'@deepseek-ai/dsh-skill-badge': ['assets'],
|
||||
'@deepseek-ai/dsh-subprocess': ['lib/control.js'],
|
||||
// SSH launches a private helper and shares wire definitions and TLS setup
|
||||
// between that helper and the connection owner.
|
||||
'@deepseek-ai/dsh-ssh': [
|
||||
'lib/helper.js', 'lib/protocol.js', 'lib/schemas.js',
|
||||
'lib/protocol-*.js', 'lib/schemas-*.js', 'lib/stream-security-*.js',
|
||||
],
|
||||
// Ordinary native containment ships a path-loaded runner and its shared
|
||||
// runner chunk beside the existing node-pty permission repair.
|
||||
'@deepseek-ai/dsh-subprocess-local': [
|
||||
'lib/runner.js',
|
||||
'lib/runner-*.js',
|
||||
'lib/output.js',
|
||||
'scripts/ensure-spawn-helper.mjs',
|
||||
],
|
||||
// tsdown shares the repository/pack code between the lib entry and the bin
|
||||
// through a hashed chunk. The committed bin.js is the link target pnpm can
|
||||
// resolve at install time, before the build produces lib/bin.js.
|
||||
'@deepseek-ai/dsh-experimental-webworker-packer': ['bin.js', 'lib/repository-*.js'],
|
||||
// The headless entry and its startup row share the JSON projection code
|
||||
// through a hashed tsdown chunk; both import it by relative path.
|
||||
'@deepseek-ai/dsh-headless': ['lib/json-stream-*.js'],
|
||||
}
|
||||
|
||||
function sameStringList(actual: readonly string[] | undefined, expected: readonly string[]): boolean {
|
||||
@@ -264,19 +281,33 @@ function usesEmittedTreeDefaults(manifest: PackageManifest): boolean {
|
||||
exportDefault(manifest, subpath)?.startsWith('./lib/types/') === true)
|
||||
}
|
||||
|
||||
/** Experimental manifest requirements enforced independently from release metadata. */
|
||||
export function checkExperimentalManifest({ dir, manifest }: WorkspaceManifest): string[] {
|
||||
/** Experimental manifest requirements, including explicit private exceptions. */
|
||||
export function checkExperimentalManifest(
|
||||
{ dir, manifest }: WorkspaceManifest,
|
||||
privateDirectories: readonly string[] = PRIVATE_EXPERIMENTAL_PACKAGE_DIRECTORIES,
|
||||
): string[] {
|
||||
if (!experimentalPackageDirectory.test(dir)) return []
|
||||
const label = manifest.name ?? dir
|
||||
const errors: string[] = []
|
||||
if (manifest.name?.startsWith(experimentalPackageNamePrefix) !== true) {
|
||||
errors.push(`${label}: experimental package name must start with ${JSON.stringify(experimentalPackageNamePrefix)}`)
|
||||
}
|
||||
if (manifest.private !== true) errors.push(`${label}: experimental package must set "private": true`)
|
||||
if (manifest.publishConfig !== undefined) errors.push(`${label}: experimental package must omit publishConfig`)
|
||||
if (isPublicExperimentalPackageDirectory(dir, privateDirectories)) {
|
||||
if (manifest.private === true) errors.push(`${label}: public experimental package must not set "private": true`)
|
||||
if (manifest.publishConfig?.access !== 'public') {
|
||||
errors.push(`${label}: public experimental package must set publishConfig.access to "public"`)
|
||||
}
|
||||
} else {
|
||||
if (manifest.private !== true) errors.push(`${label}: experimental package must set "private": true`)
|
||||
if (manifest.publishConfig !== undefined) errors.push(`${label}: experimental package must omit publishConfig`)
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
function isReleaseMemberDirectory(dir: string): boolean {
|
||||
return standardReleaseMemberDirectory.test(dir) || isPublicExperimentalPackageDirectory(dir)
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a dsh-family manifest to carry the workspace version.
|
||||
*
|
||||
@@ -309,12 +340,12 @@ export function checkWorkspaceManifest({ dir, manifest }: WorkspaceManifest): st
|
||||
const label = manifest.name ?? dir
|
||||
const familyVersionError = checkDshFamilyVersion(manifest, repositoryVersion)
|
||||
if (familyVersionError !== undefined) errors.push(familyVersionError)
|
||||
const isLandlockPackageDir = dir.startsWith('native/landlock-run/packages/')
|
||||
const isPublicLandlockPackage = isLandlockPackageDir
|
||||
const isNativePackageDir = dir.startsWith('native/system/packages/')
|
||||
const isPublicNativePackage = isNativePackageDir
|
||||
&& manifest.name !== undefined
|
||||
&& publicLandlockPackages.has(manifest.name)
|
||||
&& publicNativePackages.has(manifest.name)
|
||||
|
||||
if (isPublicLandlockPackage) {
|
||||
if (isPublicNativePackage) {
|
||||
if (manifest.private === true) {
|
||||
errors.push(`${label}: published Landlock package must not set "private": true`)
|
||||
}
|
||||
@@ -323,11 +354,11 @@ export function checkWorkspaceManifest({ dir, manifest }: WorkspaceManifest): st
|
||||
}
|
||||
const expectedDirectory = dir
|
||||
if (manifest.repository?.type !== 'git'
|
||||
|| manifest.repository.url !== repositoryUrl
|
||||
|| manifest.repository.url !== publishedRepositoryUrl
|
||||
|| manifest.repository.directory !== expectedDirectory) {
|
||||
errors.push(`${label}: published Landlock package repository must use ${repositoryUrl} with directory ${expectedDirectory} for trusted publishing`)
|
||||
errors.push(`${label}: published Landlock package repository must use ${publishedRepositoryUrl} with directory ${expectedDirectory}`)
|
||||
}
|
||||
} else if (releaseMemberDirectory.test(dir)) {
|
||||
} else if (isReleaseMemberDirectory(dir)) {
|
||||
// Release members state that they are publishable: npm refuses a private
|
||||
// package, and the repository field is how a consumer finds the source of
|
||||
// the package it installed.
|
||||
@@ -374,12 +405,12 @@ export function checkWorkspaceManifest({ dir, manifest }: WorkspaceManifest): st
|
||||
}
|
||||
}
|
||||
|
||||
if (isLandlockPackageDir) {
|
||||
if (!isPublicLandlockPackage) {
|
||||
if (isNativePackageDir) {
|
||||
if (!isPublicNativePackage) {
|
||||
errors.push(`${label}: unexpected package in the public Landlock package family`)
|
||||
}
|
||||
if (manifest.version !== landlockVersion) {
|
||||
errors.push(`${label}: package.json version must match Landlock workspace version ${landlockVersion ?? '(missing)'}`)
|
||||
if (manifest.version !== nativeVersion) {
|
||||
errors.push(`${label}: package.json version must match native workspace version ${nativeVersion ?? '(missing)'}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,7 +510,7 @@ export function checkExperimentalDependencyIsolation(manifests: readonly Workspa
|
||||
.filter(name => name !== undefined))
|
||||
const errors: string[] = []
|
||||
for (const { dir, manifest } of manifests) {
|
||||
if (!releaseMemberDirectory.test(dir) && dir !== 'python/sdk-runtime') continue
|
||||
if (!standardReleaseMemberDirectory.test(dir) && dir !== 'python/sdk-runtime') continue
|
||||
for (const section of runtimeDependencySections) {
|
||||
for (const name of Object.keys(manifest[section] ?? {})) {
|
||||
if (!experimentalNames.has(name)) continue
|
||||
|
||||
@@ -57,6 +57,9 @@ describe('Node compatibility self-hosted routing', () => {
|
||||
it('uses the Linux pool only for opted-in repository-owned PRs', () => {
|
||||
expect(route()).toEqual(labels)
|
||||
for (const mode of ['', 'hosted', 'unexpected']) expect(route({ mode })).toBe('ubuntu-latest')
|
||||
// The blacksmith value routes the compatibility legs onto Blacksmith's
|
||||
// standard Linux runner regardless of PR ownership (ephemeral runners).
|
||||
expect(route({ mode: 'blacksmith' })).toBe('blacksmith-4vcpu-ubuntu-2404')
|
||||
expect(route({ author: 'dependabot[bot]', actor: 'maintainer' })).toBe('ubuntu-latest')
|
||||
expect(route({ repository: 'outsider/fork', fork: true })).toBe('ubuntu-latest')
|
||||
expect(route({ repository: 'outsider/fork', fork: false })).toBe('ubuntu-latest')
|
||||
|
||||
+311
-70
@@ -1,13 +1,67 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
function evaluateRunsOn(selector: unknown, context: Record<string, unknown>): unknown {
|
||||
if (typeof selector !== 'string') throw new TypeError('Runner selector must be a string')
|
||||
return runInNewContext(selector.trim().slice(3, -2), context, { timeout: 1000 })
|
||||
}
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const runnerPrivatePnpmDestination = /^\$\{\{ runner\.temp \}\}\/setup-pnpm-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}$/
|
||||
const nativeWindowsPnpmDestination = '${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}'
|
||||
|
||||
describe('CI workflow', () => {
|
||||
it('prepares confinement before Node compatibility smokes', () => {
|
||||
const job = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'node-compat')
|
||||
if (!Array.isArray(job.steps)) throw new TypeError('Node compatibility job must define steps')
|
||||
const steps = job.steps.filter(isRecord)
|
||||
const preparation = steps.findIndex(step => step.run === 'bash scripts/prepare-ci-bubblewrap.sh')
|
||||
const smoke = steps.findIndex(step => step.run === 'pnpm run check:node-compat')
|
||||
expect(preparation).toBeGreaterThanOrEqual(0)
|
||||
expect(smoke).toBeGreaterThan(preparation)
|
||||
expect(steps[preparation]).not.toHaveProperty('continue-on-error', true)
|
||||
})
|
||||
|
||||
it.each(['ci.yml', 'ci-master.yml', 'e2e.yml', 'release.yml', 'release-vendor.yml'])(
|
||||
'%s cancels superseded validation runs without crossing workflow or ref boundaries', (name) => {
|
||||
const workflow = loadWorkflow('.github/workflows/' + name)
|
||||
expect(workflow.concurrency).toEqual({
|
||||
group: '${{ github.workflow }}-${{ github.ref }}',
|
||||
'cancel-in-progress': true,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it('cancels reusable CI builds without cancelling release-owned builds', () => {
|
||||
const workflow = loadWorkflow('.github/workflows/build-exe-for-python-sdk.yml')
|
||||
expect(workflow.concurrency).toEqual({
|
||||
group: 'build-single-exe-${{ github.workflow }}-${{ github.ref }}',
|
||||
'cancel-in-progress': '${{ !inputs.release }}',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not cancel protected publication or deployment transactions', () => {
|
||||
for (const name of ['release-publish.yml', 'release-vendor-publish.yml']) {
|
||||
const publish = workflowJob(loadWorkflow('.github/workflows/' + name), 'publish')
|
||||
expect(publish.concurrency).toMatchObject({ 'cancel-in-progress': false })
|
||||
}
|
||||
for (const name of ['python-release.yml', 'node-addon-system-release.yml', 'docs-pages.yml']) {
|
||||
expect(loadWorkflow('.github/workflows/' + name).concurrency).toMatchObject({ 'cancel-in-progress': false })
|
||||
}
|
||||
})
|
||||
|
||||
it('skips coverage-history uploads on cancellation but retains Wine cleanup', () => {
|
||||
const coverage = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'windows-coverage')
|
||||
const wine = workflowJob(loadWorkflow('.github/workflows/ci-master.yml'), 'windows')
|
||||
expect(coverage.steps).toContainEqual(expect.objectContaining({
|
||||
name: 'Save coverage duration history', if: '${{ !cancelled() }}',
|
||||
}))
|
||||
expect(wine.steps).toContainEqual(expect.objectContaining({ name: 'Shut down wineserver', if: 'always()' }))
|
||||
})
|
||||
|
||||
it('isolates every pnpm action setup destination per runner', () => {
|
||||
const files = ['.github/workflows/ci.yml', '.github/workflows/ci-master.yml']
|
||||
const setups: Array<{ jobName: string; step: unknown }> = []
|
||||
@@ -36,6 +90,45 @@ describe('CI workflow', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['node-24', 'node-24-coverage', 'node-24-consumers'])(
|
||||
'%s keeps tool and fixture temporary files under runner cleanup',
|
||||
(jobName) => {
|
||||
const job = workflowJob(loadWorkflow('.github/workflows/ci.yml'), jobName)
|
||||
if (!Array.isArray(job.steps)) throw new TypeError(`${jobName} must define steps`)
|
||||
expect(job.steps[0]).toEqual({
|
||||
name: 'Use runner-owned temporary storage',
|
||||
run: [
|
||||
'echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV"',
|
||||
...(jobName === 'node-24-consumers'
|
||||
? ['echo "PLAYWRIGHT_BROWSERS_PATH=${RUNNER_TEMP%/*}/ms-playwright" >> "$GITHUB_ENV"']
|
||||
: []),
|
||||
'',
|
||||
].join('\n'),
|
||||
})
|
||||
if (jobName === 'node-24-consumers') {
|
||||
const browserCache: unknown = job.steps.find(step => isRecord(step) && isRecord(step.with)
|
||||
&& step.with.path === '${{ env.PLAYWRIGHT_BROWSERS_PATH }}')
|
||||
expect(browserCache).toMatchObject({ uses: 'actions/cache/restore@v4' })
|
||||
}
|
||||
const store: unknown = job.steps.find(step => isRecord(step) && step.name === 'Configure pnpm store path')
|
||||
expect(store).toMatchObject({
|
||||
run: [
|
||||
'store_root="$HOME/.local/share/pnpm/store"',
|
||||
'echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"',
|
||||
'store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)',
|
||||
'echo "path=$store_path" >> "$GITHUB_OUTPUT"',
|
||||
'',
|
||||
].join('\n'),
|
||||
})
|
||||
for (const step of job.steps) {
|
||||
if (isRecord(step) && isRecord(step.env)) {
|
||||
expect(step.env.TMPDIR).toBeUndefined()
|
||||
expect(step.env.npm_config_cache).toBeUndefined()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it('isolates the python SDK exe pnpm setup destination per job', () => {
|
||||
const workflow: unknown = yaml.load(readFileSync(resolve(root, '.github/workflows/build-exe-for-python-sdk.yml'), 'utf8'))
|
||||
if (!isRecord(workflow) || !isRecord(workflow.jobs)) throw new TypeError('build-exe-for-python-sdk.yml must define jobs')
|
||||
@@ -96,6 +189,7 @@ describe('CI workflow', () => {
|
||||
expect(job['runs-on']).toContain('self-hosted')
|
||||
expect(job['runs-on']).toContain('dsh-win-ci')
|
||||
expect(job['runs-on']).toContain('dsh-windows-2025-16core')
|
||||
expect(job['runs-on']).toContain('blacksmith-16vcpu-windows-2025')
|
||||
expect(job.if).toBe("github.event_name == 'pull_request'")
|
||||
}
|
||||
|
||||
@@ -163,7 +257,7 @@ describe('CI workflow', () => {
|
||||
expect(nativeTestCommand).toContain('--no-file-parallelism')
|
||||
expect(nativeTestCommand).toContain('--testTimeout 90000')
|
||||
expect(nativeTestCommand).toContain('tool-pwsh/tests/loader.spec.ts')
|
||||
expect(nativeTestCommand).toContain('workflow-worker-thread.spec.ts')
|
||||
expect(nativeTestCommand).toContain('workflow-ptc.spec.ts')
|
||||
|
||||
// windows-observational is non-blocking.
|
||||
expect(windowsObservational.name).toBe('windows node 24 / observational')
|
||||
@@ -180,7 +274,8 @@ describe('CI workflow', () => {
|
||||
isRecord(step) && step.name === 'Configure persistent pnpm store' && typeof step.run === 'string'
|
||||
))
|
||||
expect(serialStore).toBeDefined()
|
||||
expect(serialStore!.run).toContain('F:\\.pnpm-store')
|
||||
expect(serialStore!.run).toContain('[IO.Path]::GetPathRoot($env:GITHUB_WORKSPACE)')
|
||||
expect(serialStore!.run).toContain("'.pnpm-store'")
|
||||
const serialInstall = serialSteps.find((step): step is Record<string, unknown> & { run: string } => (
|
||||
isRecord(step) && step.name === 'Install (immutable)' && typeof step.run === 'string'
|
||||
))
|
||||
@@ -234,10 +329,42 @@ describe('CI workflow', () => {
|
||||
expect(job['runs-on'], `${jobName} runs-on must use the Linux failover switch`).toContain('DSH_CI_FAILOVER_LINUX')
|
||||
expect(job['runs-on'], `${jobName} runs-on must not use the Windows failover switch`).not.toContain('DSH_CI_FAILOVER_WINDOWS')
|
||||
expect(job['runs-on']).toContain('vm-backup')
|
||||
expect(job['runs-on']).toContain('blacksmith-16vcpu-ubuntu-2404')
|
||||
}
|
||||
expect(aggregate['runs-on']).toContain('DSH_CI_FAILOVER_LINUX')
|
||||
expect(aggregate['runs-on']).not.toContain('DSH_CI_FAILOVER_WINDOWS')
|
||||
expect(aggregate['runs-on']).toContain('vm-backup')
|
||||
expect(aggregate['runs-on']).toContain('blacksmith-4vcpu-ubuntu-2404')
|
||||
|
||||
// Evaluating the full selector, not just substring containment, proves the
|
||||
// blacksmith branch is standalone: it must not fall through to the
|
||||
// self-hosted pool when the two values are mutually exclusive.
|
||||
const selectors = {
|
||||
linux: node24['runs-on'] as string,
|
||||
linuxAggregate: aggregate['runs-on'] as string,
|
||||
windows: windowsBuild['runs-on'] as string,
|
||||
}
|
||||
const evaluate = (expression: string, vars: Record<string, string>, login = 'maintainer'): unknown => {
|
||||
return evaluateRunsOn(expression, {
|
||||
vars,
|
||||
fromJSON: JSON.parse,
|
||||
github: { event: { pull_request: { user: { login } } } },
|
||||
})
|
||||
}
|
||||
for (const [name, selector, variable, pool, hosted] of [
|
||||
['linux gates', selectors.linux, 'DSH_CI_FAILOVER_LINUX', ['self-hosted', 'linux', 'x64', 'vm-backup'], 'dsh-ubuntu-24-04-16core'],
|
||||
['linux aggregate', selectors.linuxAggregate, 'DSH_CI_FAILOVER_LINUX', ['self-hosted', 'linux', 'x64', 'vm-backup'], 'ubuntu-latest'],
|
||||
['windows lanes', selectors.windows, 'DSH_CI_FAILOVER_WINDOWS', ['self-hosted', 'dsh-win-ci', 'windows'], 'dsh-windows-2025-16core'],
|
||||
] as const) {
|
||||
expect(evaluate(selector, { [variable]: 'blacksmith' }), `${name} blacksmith value`).toMatch(/^blacksmith-/)
|
||||
expect(evaluate(selector, { [variable]: 'selfhosted' }), `${name} selfhosted value`).toEqual(pool)
|
||||
// The blacksmith branch must not capture the selfhosted pool, and the
|
||||
// dependabot exclusion applies to the pool, not to the blacksmith tier.
|
||||
expect(evaluate(selector, { [variable]: 'selfhosted' }, 'dependabot[bot]'), `${name} dependabot on selfhosted`).toBe(hosted)
|
||||
for (const mode of ['', 'hosted', 'unexpected']) {
|
||||
expect(evaluate(selector, { [variable]: mode }), `${name} default on ${mode}`).toBe(hosted)
|
||||
}
|
||||
}
|
||||
|
||||
// The run-gates aggregate lanes stop at the first blocking gate failure so
|
||||
// a red aggregate does not keep burning runner time on the remaining
|
||||
@@ -260,6 +387,35 @@ describe('CI workflow', () => {
|
||||
expect(windowsObservational.env).not.toMatchObject({ DSH_GATE_FAIL_FAST: '1' })
|
||||
})
|
||||
|
||||
it('gates standalone keyless blacksmith jobs and benchmark tiers on the failover variables', () => {
|
||||
const expectedFilenames = workflowJob(loadWorkflow('.github/workflows/expected-filenames.yml'), 'expected-filenames')
|
||||
const sandbox = workflowJob(loadWorkflow('.github/workflows/sandbox.yml'), 'sandbox-e2e')
|
||||
expect(expectedFilenames['runs-on']).toContain('DSH_CI_FAILOVER_LINUX')
|
||||
expect(expectedFilenames['runs-on']).toContain("== 'blacksmith'")
|
||||
expect(expectedFilenames['runs-on']).toContain('blacksmith-4vcpu-ubuntu-2404')
|
||||
expect(expectedFilenames['runs-on']).toContain("'ubuntu-latest'")
|
||||
expect(sandbox['runs-on']).toContain("matrix.runner == 'bwrap'")
|
||||
expect(sandbox['runs-on']).toContain('DSH_CI_FAILOVER_LINUX')
|
||||
expect(sandbox['runs-on']).toContain('blacksmith-4vcpu-ubuntu-2404')
|
||||
for (const name of ['larger-runner-benchmark', 'consolidated-runner-benchmark'] as const) {
|
||||
const benchmark = workflowJob(loadWorkflow('.github/workflows/ci-master.yml'), name)
|
||||
if (!isRecord(benchmark.strategy) || !isRecord(benchmark.strategy.matrix) || !Array.isArray(benchmark.strategy.matrix.include)) {
|
||||
throw new TypeError(`${name} must define a matrix include list`)
|
||||
}
|
||||
expect(benchmark['runs-on']).toContain('matrix.blacksmith')
|
||||
expect(benchmark['runs-on']).toContain('DSH_CI_FAILOVER_LINUX')
|
||||
expect(benchmark['runs-on']).toContain('DSH_CI_FAILOVER_WINDOWS')
|
||||
for (const row of benchmark.strategy.matrix.include as Array<Record<string, string>>) {
|
||||
expect(typeof row.blacksmith, `${name} ${row.cores}-core row must declare a blacksmith label`).toBe('string')
|
||||
if (row.cores === '64' || row.cores === '96') {
|
||||
expect(row.blacksmith, `${name} ${row.cores}-core row has no Blacksmith tier`).toBe('')
|
||||
} else {
|
||||
expect(row.blacksmith).toContain(`blacksmith-${row.cores}vcpu`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('runs required benchmarks on standard hosted Linux independently of failover', () => {
|
||||
const workflow = loadWorkflow('.github/workflows/ci.yml')
|
||||
const benchmark = workflowJob(workflow, 'node-24-bench')
|
||||
@@ -307,7 +463,7 @@ describe('CI workflow', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('exempts push from cancellation in ci-master, so one master merge does not cancel the running drill', () => {
|
||||
it('cancels superseded master runs without changing the post-merge job inventory', () => {
|
||||
const workflow = loadWorkflow('.github/workflows/ci-master.yml')
|
||||
const prWorkflow = loadWorkflow('.github/workflows/ci.yml')
|
||||
if (!isRecord(workflow.jobs) || !isRecord(workflow.concurrency)) {
|
||||
@@ -317,21 +473,11 @@ describe('CI workflow', () => {
|
||||
throw new TypeError('ci workflow must define jobs')
|
||||
}
|
||||
|
||||
// Cancellation applies to the whole superseded RUN, so this has to be
|
||||
// decided at workflow level and gated on the event: a job-level group
|
||||
// cannot exempt its job from its run being cancelled. Only push is exempt —
|
||||
// a drill takes longer than the interval between master merges. The negated
|
||||
// form is load-bearing: `== 'pull_request'` would also stop cancelling
|
||||
// workflow_dispatch, and a re-dispatched runner benchmark holds up to 12
|
||||
// larger runners for 15 minutes in this same group on master.
|
||||
expect(workflow.concurrency['cancel-in-progress']).toBe("${{ github.event_name != 'push' }}")
|
||||
|
||||
// The PR-only ci.yml still cancels a superseded run on a new push, so a
|
||||
// fresh head does not stack a second full 9-job run behind a stale one.
|
||||
// Unlike ci-master it has no push carve-out: every PR event supersedes.
|
||||
expect(prWorkflow.concurrency).toMatchObject({
|
||||
expect(workflow.concurrency).toEqual({
|
||||
group: '${{ github.workflow }}-${{ github.ref }}',
|
||||
'cancel-in-progress': true,
|
||||
})
|
||||
expect(prWorkflow.concurrency).toEqual(workflow.concurrency)
|
||||
|
||||
// The exact event sets are what keep master-only jobs out of the PR check
|
||||
// panel: ci-master triggers only on push(master) + workflow_dispatch and
|
||||
@@ -343,13 +489,12 @@ describe('CI workflow', () => {
|
||||
expect(Object.keys(workflow.on).sort()).toEqual(['push', 'workflow_dispatch'])
|
||||
expect(Object.keys(prWorkflow.on)).toEqual(['pull_request'])
|
||||
|
||||
// Neither drill may carry a job-level group: it would not exempt the job
|
||||
// from run-scoped cancellation.
|
||||
// Drills share the parent run’s supersession policy.
|
||||
for (const name of ['serial-linux-selfhosted', 'serial-windows']) {
|
||||
const job = workflow.jobs[name]
|
||||
if (!isRecord(job)) throw new TypeError(`${name} must be defined`)
|
||||
expect(job.concurrency).toBeUndefined()
|
||||
// Both stay master-push-only; that is what makes the push carve-out safe.
|
||||
// Standby drills remain post-merge work, but share run cancellation.
|
||||
expect(job.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'")
|
||||
}
|
||||
|
||||
@@ -370,10 +515,7 @@ describe('CI workflow', () => {
|
||||
.sort()
|
||||
expect(pushReachable).toEqual(['python-runtime', 'serial-linux-selfhosted', 'serial-windows', 'windows'])
|
||||
|
||||
// Why workflow_dispatch must keep cancelling: each benchmark fans out to a
|
||||
// dozen larger runners at once, in this same group on master. If it stopped
|
||||
// cancelling, a re-dispatch would queue ahead of a drill instead of
|
||||
// replacing the stale measurement.
|
||||
// Manual benchmarks retain their bounded fan-out.
|
||||
for (const name of ['larger-runner-benchmark', 'consolidated-runner-benchmark']) {
|
||||
const job = workflow.jobs[name]
|
||||
if (!isRecord(job) || !isRecord(job.strategy)) {
|
||||
@@ -453,6 +595,50 @@ describe('CI workflow', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Runtime and LLM e2e Blacksmith routing', () => {
|
||||
it('routes DeepSeek e2e only through the Linux Blacksmith switch', () => {
|
||||
const job = workflowJob(loadWorkflow('.github/workflows/e2e.yml'), 'e2e')
|
||||
for (const mode of ['', 'selfhosted', 'unexpected', 'blacksmith']) {
|
||||
expect(evaluateRunsOn(job['runs-on'], { vars: { DSH_CI_FAILOVER_LINUX: mode, DSH_CI_FAILOVER_WINDOWS: 'blacksmith' } }))
|
||||
.toBe(mode === 'blacksmith' ? 'blacksmith-4vcpu-ubuntu-2404' : 'ubuntu-latest')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps native release and dispatch builders hosted while routing x64 CI by platform', () => {
|
||||
const workflow = loadWorkflow('.github/workflows/build-exe-for-python-sdk.yml')
|
||||
const build = workflowJob(workflow, 'build')
|
||||
for (const [target, runner, variable, blacksmith] of [
|
||||
['node24-linux-x64', 'ubuntu-latest', 'DSH_CI_FAILOVER_LINUX', 'blacksmith-16vcpu-ubuntu-2404'],
|
||||
['node24-win-x64', 'windows-2025', 'DSH_CI_FAILOVER_WINDOWS', 'blacksmith-16vcpu-windows-2025'],
|
||||
['node24-linux-arm64', 'ubuntu-24.04-arm', 'DSH_CI_FAILOVER_LINUX', 'ubuntu-24.04-arm'],
|
||||
['node24-macos-arm64', 'macos-latest', 'DSH_CI_FAILOVER_LINUX', 'macos-latest'],
|
||||
['node24-macos-x64', 'macos-15-intel', 'DSH_CI_FAILOVER_LINUX', 'macos-15-intel'],
|
||||
] as const) {
|
||||
for (const ci of [false, true]) {
|
||||
for (const release of [false, true]) {
|
||||
for (const mode of ['', 'selfhosted', 'unexpected', 'blacksmith']) {
|
||||
const vars = { DSH_CI_FAILOVER_LINUX: 'blacksmith', DSH_CI_FAILOVER_WINDOWS: 'blacksmith', [variable]: mode }
|
||||
expect(evaluateRunsOn(build['runs-on'], { inputs: { ci, release }, vars, matrix: { target, runner } }), `${target} ci=${ci} release=${release} mode=${mode}`)
|
||||
.toBe(ci && !release && mode === 'blacksmith' ? blacksmith : runner)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['plan', 'sdk-wheel'])('routes runtime %s only for non-release CI', (name) => {
|
||||
const job = workflowJob(loadWorkflow('.github/workflows/build-exe-for-python-sdk.yml'), name)
|
||||
for (const ci of [false, true]) {
|
||||
for (const release of [false, true]) {
|
||||
for (const mode of ['', 'selfhosted', 'unexpected', 'blacksmith']) {
|
||||
expect(evaluateRunsOn(job['runs-on'], { inputs: { ci, release }, vars: { DSH_CI_FAILOVER_LINUX: mode } }))
|
||||
.toBe(ci && !release && mode === 'blacksmith' ? 'blacksmith-4vcpu-ubuntu-2404' : 'ubuntu-latest')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('DeepSeek e2e workflow', () => {
|
||||
it('prepares bubblewrap from the pinned payload without a package transaction', () => {
|
||||
const workflow = loadWorkflow('.github/workflows/e2e.yml')
|
||||
@@ -476,33 +662,6 @@ describe('DeepSeek e2e workflow', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('E2B e2e workflow', () => {
|
||||
it('is manual-only and fails loud before running the focused live suite', () => {
|
||||
const workflow = loadWorkflow('.github/workflows/e2b-e2e.yml')
|
||||
expect(workflow.on).toEqual({ workflow_dispatch: null })
|
||||
if (!isRecord(workflow.jobs) || !isRecord(workflow.jobs.e2b) || !Array.isArray(workflow.jobs.e2b.steps)) {
|
||||
throw new TypeError('E2B e2e workflow must define the e2b job steps')
|
||||
}
|
||||
|
||||
const steps = workflow.jobs.e2b.steps.filter(isRecord)
|
||||
const preflight = steps.find(step => step.name === 'Preflight (require E2B API key)')
|
||||
const e2b = steps.find(step => step.name === 'E2B tests (live sandbox)')
|
||||
|
||||
expect(preflight).toMatchObject({
|
||||
env: { E2B_API_KEY: '${{ secrets.E2B_API_KEY_EXTERNAL }}' },
|
||||
})
|
||||
expect(preflight?.run).toContain('E2B_API_KEY_EXTERNAL repository secret')
|
||||
expect(e2b).toMatchObject({
|
||||
env: {
|
||||
E2B_API_KEY: '${{ secrets.E2B_API_KEY_EXTERNAL }}',
|
||||
DSH_E2E_MAX_WORKERS: '1',
|
||||
DSH_EXAMPLE_MODE: 'lib',
|
||||
},
|
||||
})
|
||||
expect(e2b?.run).toContain('packages/e2b/e2b/tests/composition.e2e.ts')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Python release workflows', () => {
|
||||
it('keeps complete wheel validation separate from protected public publication', () => {
|
||||
const workflow = loadWorkflow('.github/workflows/python-release.yml')
|
||||
@@ -669,12 +828,14 @@ describe('Python release workflows', () => {
|
||||
expect(String(realApiPreflightPosix.if)).toContain('head.repo.fork')
|
||||
expect(String(realApiPreflightPosix.if)).toContain('dependabot[bot]')
|
||||
expect(realApiPreflightWindows).toMatchObject({ shell: 'pwsh' })
|
||||
expect(installedRealApiPosix).toMatchObject({
|
||||
env: {
|
||||
DEEPSEEK_API_KEY: '${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}',
|
||||
DEEPSEEK_BASE_URL: 'https://api.deepseek.com',
|
||||
},
|
||||
})
|
||||
for (const step of [installedRealApiPosix, installedRealApiWindows]) {
|
||||
expect(step).toMatchObject({
|
||||
env: {
|
||||
DEEPSEEK_API_KEY: '${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}',
|
||||
DEEPSEEK_BASE_URL: 'https://api.deepseek.com/anthropic',
|
||||
},
|
||||
})
|
||||
}
|
||||
expect(JSON.stringify(installedRealApiPosix)).toContain('--scenario sdk-live')
|
||||
expect(JSON.stringify(installedRealApiPosix)).toContain('-u DSH_RUNTIME_MODE')
|
||||
expect(installedRealApiWindows).toMatchObject({ shell: 'pwsh' })
|
||||
@@ -735,20 +896,89 @@ describe('Python release workflows', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Weighted approval workflow', () => {
|
||||
it('publishes from the trusted default branch after pull request and review updates', () => {
|
||||
const publisher = loadWorkflow('.github/workflows/weighted-approval.yml')
|
||||
const reviewEvent = loadWorkflow('.github/workflows/weighted-approval-review-event.yml')
|
||||
const pullRequest = workflowEvent(publisher, 'pull_request_target')
|
||||
const workflowRun = workflowEvent(publisher, 'workflow_run')
|
||||
const review = workflowEvent(reviewEvent, 'pull_request_review')
|
||||
const job = workflowJob(publisher, 'publish-status')
|
||||
const recordJob = workflowJob(reviewEvent, 'record-review-event')
|
||||
if (!isRecord(publisher.on)) throw new TypeError('weighted-approval workflow must define events')
|
||||
if (!isRecord(reviewEvent.on)) throw new TypeError('weighted-approval review event workflow must define events')
|
||||
if (!Array.isArray(job.steps)) throw new TypeError('weighted-approval job must define steps')
|
||||
if (!Array.isArray(recordJob.steps)) throw new TypeError('weighted-approval review event job must define steps')
|
||||
const steps = job.steps.filter(isRecord)
|
||||
const checkout = steps.find(step => step.name === 'Check out trusted approval policy')
|
||||
const publish = steps.find(step => step.name === 'Publish weighted approval status')
|
||||
const recordSteps = recordJob.steps.filter(isRecord)
|
||||
const record = recordSteps.find(step => step.name === 'Record review event')
|
||||
|
||||
expect(publisher.name).toBe('weighted-approval')
|
||||
expect(Object.keys(publisher.on)).toEqual(['pull_request_target', 'workflow_run'])
|
||||
expect(pullRequest.types).toEqual(['opened', 'synchronize', 'reopened', 'ready_for_review', 'converted_to_draft'])
|
||||
expect(workflowRun).toEqual({ workflows: ['weighted-approval-review-event'], types: ['completed'] })
|
||||
expect(reviewEvent.name).toBe('weighted-approval-review-event')
|
||||
expect(reviewEvent['run-name']).toBe('weighted-approval-review-event:${{ github.event.pull_request.number }}')
|
||||
expect(Object.keys(reviewEvent.on)).toEqual(['pull_request_review'])
|
||||
expect(review.types).toEqual(['submitted', 'edited', 'dismissed'])
|
||||
expect(reviewEvent.permissions).toEqual({})
|
||||
expect(publisher.permissions).toEqual({
|
||||
contents: 'read',
|
||||
'pull-requests': 'read',
|
||||
statuses: 'write',
|
||||
})
|
||||
expect(publisher.concurrency).toEqual({
|
||||
group: 'weighted-approval-${{ github.event.pull_request.number || github.event.workflow_run.head_sha }}',
|
||||
'cancel-in-progress': false,
|
||||
})
|
||||
expect(job).toMatchObject({
|
||||
if: "github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'",
|
||||
name: 'weighted approval publisher',
|
||||
'runs-on': 'ubuntu-latest',
|
||||
'timeout-minutes': 5,
|
||||
})
|
||||
expect(checkout).toMatchObject({
|
||||
uses: 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1',
|
||||
with: {
|
||||
ref: '${{ github.event.repository.default_branch }}',
|
||||
'persist-credentials': false,
|
||||
},
|
||||
})
|
||||
expect(publish).toMatchObject({
|
||||
env: {
|
||||
GITHUB_TOKEN: '${{ github.token }}',
|
||||
GITHUB_RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}',
|
||||
},
|
||||
run: 'node .github/review-ownership/check-approval.mjs',
|
||||
})
|
||||
expect(recordJob).toMatchObject({
|
||||
name: 'record weighted approval review event',
|
||||
'runs-on': 'ubuntu-latest',
|
||||
'timeout-minutes': 2,
|
||||
})
|
||||
expect(record).toBeDefined()
|
||||
expect(record?.run).toBe("echo 'Recorded a weighted approval review event.'")
|
||||
expect(recordSteps).toHaveLength(1)
|
||||
expect(JSON.stringify(publisher)).not.toContain('github.event.pull_request.head')
|
||||
expect(JSON.stringify(publisher)).not.toContain('secrets.')
|
||||
expect(JSON.stringify(reviewEvent)).not.toContain('github.token')
|
||||
expect(JSON.stringify(reviewEvent)).not.toContain('secrets.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Issue lifecycle workflow', () => {
|
||||
it('runs the lifecycle job on every PR/review event but gates token and board steps', () => {
|
||||
it('allocates lifecycle runners only for events that can change the board', () => {
|
||||
const lifecycle = loadWorkflow('.github/workflows/issue-lifecycle.yml')
|
||||
const policy = loadWorkflow('.github/workflows/issue-policy.yml')
|
||||
const lifecycleJob = workflowJob(lifecycle, 'lifecycle')
|
||||
if (!Array.isArray(lifecycleJob.steps)) throw new TypeError('Issue lifecycle job must define steps')
|
||||
|
||||
// The job has no job-level `if`, so it is listed on every pull_request /
|
||||
// pull_request_review event and reports success instead of a gray skip. The
|
||||
// write-capable steps are gated at step level so approved/commented reviews
|
||||
// never mint a Project/Issue App token nor touch the board.
|
||||
expect(lifecycle.on).toHaveProperty('pull_request')
|
||||
expect(lifecycle.on).toHaveProperty('pull_request_review')
|
||||
expect(lifecycleJob.if).toBeUndefined()
|
||||
expect(lifecycleJob.if).toContain("github.event.review.state == 'changes_requested'")
|
||||
expect(lifecycleJob.if).toContain('github.event.changes.body != null')
|
||||
// Keep the subscription-type gates: issue-lifecycle does not re-subscribe
|
||||
// ready_for_review (issue-policy owns that) and only reacts to submitted
|
||||
// review events.
|
||||
@@ -758,31 +988,43 @@ describe('Issue lifecycle workflow', () => {
|
||||
expect(lifecyclePullRequest.types).not.toContain('ready_for_review')
|
||||
expect(lifecyclePullRequest.types).toContain('review_requested')
|
||||
expect(lifecycleReview.types).toEqual(['submitted'])
|
||||
const gated = "${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }}"
|
||||
expect(lifecyclePullRequest.types).not.toContain('synchronize')
|
||||
expect(lifecyclePullRequest.types).not.toContain('labeled')
|
||||
expect(lifecyclePullRequest.types).not.toContain('unlabeled')
|
||||
const issueEvents = workflowEvent(lifecycle, 'issues')
|
||||
expect(issueEvents.types).not.toContain('assigned')
|
||||
expect(issueEvents.types).not.toContain('unassigned')
|
||||
expect(issueEvents.types).toContain('typed')
|
||||
expect(issueEvents.types).toContain('untyped')
|
||||
const steps = lifecycleJob.steps.filter(isRecord)
|
||||
const tokenStep = steps.find(s => s.name === 'Create project token')
|
||||
const handleStep = steps.find(s => s.name === 'Handle repository event')
|
||||
expect(tokenStep).toMatchObject({ if: gated })
|
||||
expect(handleStep).toMatchObject({ if: gated })
|
||||
expect(tokenStep?.if).toBeUndefined()
|
||||
expect(handleStep?.if).toBeUndefined()
|
||||
|
||||
// issue-policy owns PR validation; it is read-only and a real gate.
|
||||
const policyPullRequest = workflowEvent(policy, 'pull_request')
|
||||
expect(policyPullRequest.types).toContain('ready_for_review')
|
||||
})
|
||||
|
||||
it('uses a read-only Project token only for human pull request policy metadata', () => {
|
||||
it('mints Project credentials only after preflight and always revalidates current metadata', () => {
|
||||
const policy = loadWorkflow('.github/workflows/issue-policy.yml')
|
||||
const policyJob = workflowJob(policy, 'policy')
|
||||
if (!Array.isArray(policyJob.steps)) throw new TypeError('Issue policy job must define steps')
|
||||
const steps = policyJob.steps.filter(isRecord)
|
||||
const tokenStep = steps.find(step => step.name === 'Create Project read token')
|
||||
const validateStep = steps.find(step => step.name === 'Validate pull request')
|
||||
const humanPullRequest =
|
||||
"${{ github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.type != 'App' }}"
|
||||
const preflightStep = steps.find(step => step.id === 'preflight')
|
||||
expect(preflightStep).toMatchObject({ shell: 'bash' })
|
||||
expect(preflightStep?.run).toContain('if [ -f .github/issue-management/selective-preflight.json ]; then')
|
||||
expect(preflightStep?.run).toContain('node .github/issue-management/policy.mjs pr-preflight')
|
||||
expect(preflightStep?.if).toBeUndefined()
|
||||
expect(policyJob.if).toBeUndefined()
|
||||
expect(validateStep?.if).toBe("${{ steps.preflight.outputs.legacy-automated != 'true' }}")
|
||||
|
||||
expect(tokenStep).toMatchObject({
|
||||
id: 'app-token',
|
||||
if: humanPullRequest,
|
||||
if: "${{ steps.preflight.outputs.needs-project == 'true' }}",
|
||||
uses: 'actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1',
|
||||
with: {
|
||||
'client-id': '${{ vars.DSH_ISSUE_APP_CLIENT_ID }}',
|
||||
@@ -794,7 +1036,6 @@ describe('Issue lifecycle workflow', () => {
|
||||
},
|
||||
})
|
||||
expect(validateStep).toMatchObject({
|
||||
if: humanPullRequest,
|
||||
env: {
|
||||
GITHUB_TOKEN: '${{ github.token }}',
|
||||
PROJECT_TOKEN: '${{ steps.app-token.outputs.token }}',
|
||||
|
||||
@@ -64,16 +64,16 @@ describe('RepositoryCleaner', () => {
|
||||
|
||||
it('removes the native Landlock entry output and solution build info', async () => {
|
||||
const root = fixture()
|
||||
const entry = 'native/landlock-run/packages/entry'
|
||||
const entry = 'native/system/packages/entry'
|
||||
addProject(root, entry, 'lib')
|
||||
write(join(root, entry, 'lib/index.js'))
|
||||
write(join(root, 'native/landlock-run/tsconfig.tsbuildinfo'))
|
||||
write(join(root, 'native/system/tsconfig.tsbuildinfo'))
|
||||
|
||||
await new RepositoryCleaner(root).clean()
|
||||
|
||||
expect(existsSync(join(root, entry, 'lib'))).toBe(false)
|
||||
expect(existsSync(join(root, entry, 'src/index.ts'))).toBe(true)
|
||||
expect(existsSync(join(root, 'native/landlock-run/tsconfig.tsbuildinfo'))).toBe(false)
|
||||
expect(existsSync(join(root, 'native/system/tsconfig.tsbuildinfo'))).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses project outputs reached through a symlink outside the repository', async () => {
|
||||
|
||||
+2
-2
@@ -77,7 +77,7 @@ export class RepositoryCleaner {
|
||||
}
|
||||
await this.addIfPresent(
|
||||
targets,
|
||||
join(this.root, 'native/landlock-run/tsconfig.tsbuildinfo'),
|
||||
join(this.root, 'native/system/tsconfig.tsbuildinfo'),
|
||||
canonicalRoot,
|
||||
)
|
||||
|
||||
@@ -122,7 +122,7 @@ export class RepositoryCleaner {
|
||||
const outputs = new Set<string>()
|
||||
const pending = [join(this.root, 'tsconfig.json')]
|
||||
const visited = new Set<string>()
|
||||
const nativeEntryOutput = join(this.root, 'native/landlock-run/packages/entry/lib')
|
||||
const nativeEntryOutput = join(this.root, 'native/system/packages/entry/lib')
|
||||
|
||||
while (pending.length > 0) {
|
||||
const nextConfigPath = pending.pop()
|
||||
|
||||
@@ -28,7 +28,6 @@ const roots: string[] = []
|
||||
const dshBuildWorkflows = [
|
||||
'build-exe-for-python-sdk.yml',
|
||||
'ci.yml',
|
||||
'e2b-e2e.yml',
|
||||
'e2e.yml',
|
||||
'release.yml',
|
||||
'release-publish.yml',
|
||||
@@ -72,6 +71,7 @@ function repositoryFixture(version = '1.2.3-rc.4'): string {
|
||||
git(fixtureRoot, ['init'])
|
||||
git(fixtureRoot, ['config', 'user.name', 'DSH test'])
|
||||
git(fixtureRoot, ['config', 'user.email', '[email protected]'])
|
||||
git(fixtureRoot, ['config', 'commit.gpgsign', 'false'])
|
||||
git(fixtureRoot, ['add', 'package.json', 'tracked.txt'])
|
||||
git(fixtureRoot, ['commit', '-m', 'fixture'])
|
||||
return fixtureRoot
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
* Pins shared client-bundle preset rules: module-edge purity, source-map
|
||||
* chaining, and physical watch dependencies hidden behind virtual CSS Modules.
|
||||
*/
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { dirname, join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { clientBundle, requestedExternals } from '../packages/client/tsdown.client.ts'
|
||||
import { build, type TsdownBundle, type UserConfig } from 'tsdown'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { clientBundle, requestedExternals, staticLinked } from '../packages/client/tsdown.client.ts'
|
||||
|
||||
type ResolveId = (source: string) => null | { id: string; external: boolean }
|
||||
|
||||
@@ -22,6 +23,18 @@ interface SourceMapPlugin {
|
||||
load?: (id: string) => Promise<unknown>
|
||||
}
|
||||
|
||||
interface InputIsolationPlugin {
|
||||
name: string
|
||||
generateBundle: (this: {
|
||||
getModuleInfo(id: string): { importedIds: string[]; dynamicallyImportedIds: string[] } | null
|
||||
}, options: unknown, bundle: Record<string, {
|
||||
type: 'chunk'
|
||||
modules: Record<string, object>
|
||||
imports: string[]
|
||||
dynamicImports: string[]
|
||||
}>) => void
|
||||
}
|
||||
|
||||
/** A representative dynamic bundle using the shared client baseline. */
|
||||
const REQUESTING_PACKAGE = '@deepseek-ai/dsh-client-ui-conversation'
|
||||
|
||||
@@ -147,6 +160,175 @@ describe('client bundle purity gate', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('client bundle experimental input isolation', () => {
|
||||
const experimental = '@deepseek-ai/dsh-experimental-client-ui-agent-team'
|
||||
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-client-inputs-'))
|
||||
onTestFinished(() => { rmSync(root, { recursive: true, force: true }) })
|
||||
const owner = join(root, 'client')
|
||||
const entry = join(owner, 'lib/types/client/index.js')
|
||||
const prototype = join(root, 'prototype/src/index.js')
|
||||
for (const file of [entry, prototype]) mkdirSync(dirname(file), { recursive: true })
|
||||
writeFileSync(join(owner, 'package.json'), JSON.stringify({ name: REQUESTING_PACKAGE, type: 'module' }))
|
||||
writeFileSync(join(root, 'prototype/package.json'), JSON.stringify({ name: experimental, type: 'module' }))
|
||||
writeFileSync(prototype, 'export const marker = "experimental sentinel"\n')
|
||||
return { root, owner, entry, prototype }
|
||||
}
|
||||
|
||||
function config(kind: 'static' | 'dynamic', id = REQUESTING_PACKAGE): UserConfig {
|
||||
const configs = kind === 'static'
|
||||
? staticLinked(id, ['lib/types/client/index.js'])({ env: { DSH_BUILD_FACE: 'client' } })
|
||||
: clientConfigs(id)
|
||||
const browser = configs.find(config => config.platform === 'browser')
|
||||
if (browser === undefined) throw new Error('client config missing')
|
||||
return browser
|
||||
}
|
||||
|
||||
async function bundle(owner: string, config: UserConfig): Promise<string> {
|
||||
let builds: TsdownBundle[] = []
|
||||
try {
|
||||
builds = await build({
|
||||
...config, cwd: owner, config: false, tsconfig: false,
|
||||
write: false, clean: false, exports: false, report: false, logLevel: 'silent',
|
||||
})
|
||||
return builds.flatMap(build => build.chunks.filter(chunk => chunk.type === 'chunk').map(chunk => chunk.code)).join('\n')
|
||||
} finally {
|
||||
for (const build of builds) await build[Symbol.asyncDispose]()
|
||||
}
|
||||
}
|
||||
|
||||
function importPath(from: string, to: string): string {
|
||||
return relative(dirname(from), to).replaceAll('\\', '/')
|
||||
}
|
||||
|
||||
function checkCompilerModule(module: string, imports?: string[]): void {
|
||||
const plugins = config('dynamic').plugins as InputIsolationPlugin[]
|
||||
const plugin = plugins.find(plugin => plugin.name === 'dsh-client-input-isolation')
|
||||
if (plugin === undefined) throw new Error('client input isolation plugin missing')
|
||||
plugin.generateBundle.call({
|
||||
getModuleInfo: () => imports === undefined ? null : { importedIds: imports, dynamicallyImportedIds: [] },
|
||||
}, {}, {
|
||||
'client.js': { type: 'chunk', modules: { [module]: {} }, imports: [], dynamicImports: [] },
|
||||
})
|
||||
}
|
||||
|
||||
it('accepts the exact compiler runtime helper without a source module record', () => {
|
||||
expect(() => { checkCompilerModule('\0rolldown/runtime.js') }).not.toThrow()
|
||||
})
|
||||
|
||||
it.each(['\0rolldown/other.js', '\0rolldown/runtime.js?user', '\0other/runtime.js'])(
|
||||
'rejects the unrecorded module %j',
|
||||
(module) => {
|
||||
expect(() => { checkCompilerModule(module) }).toThrow(/has no bundler module record/)
|
||||
},
|
||||
)
|
||||
|
||||
it('checks runtime helper dependencies when the compiler supplies a module record', () => {
|
||||
expect(() => { checkCompilerModule('\0rolldown/runtime.js', [experimental]) })
|
||||
.toThrow(/client bundle isolation.*experimental/)
|
||||
})
|
||||
|
||||
it.each(['static', 'dynamic'] as const)('rejects experimental files folded into a %s client artifact', async (kind) => {
|
||||
const { owner, entry, prototype } = fixture()
|
||||
writeFileSync(entry, `export { marker } from ${JSON.stringify(importPath(entry, prototype))}\n`)
|
||||
|
||||
await expect(bundle(owner, config(kind))).rejects.toThrow(/client bundle isolation.*experimental/)
|
||||
expect(existsSync(join(owner, 'lib/client.js'))).toBe(false)
|
||||
expect(existsSync(join(owner, 'lib/index.js'))).toBe(false)
|
||||
})
|
||||
|
||||
it('proves the static library otherwise hides the experimental input in its emitted JavaScript', async () => {
|
||||
const { owner, entry, prototype } = fixture()
|
||||
writeFileSync(entry, `export { marker } from ${JSON.stringify(importPath(entry, prototype))}\n`)
|
||||
const guarded = config('static')
|
||||
const plugins = guarded.plugins as Array<{ name: string }>
|
||||
const unguarded: UserConfig = {
|
||||
...guarded,
|
||||
plugins: plugins.filter(plugin => plugin.name !== 'dsh-client-input-isolation'),
|
||||
outputOptions: { sourcemapExcludeSources: false },
|
||||
}
|
||||
|
||||
await expect(bundle(owner, unguarded)).resolves.toContain('experimental sentinel')
|
||||
await expect(bundle(owner, guarded)).rejects.toThrow(/client bundle isolation.*experimental/)
|
||||
})
|
||||
|
||||
it.each(['static', 'dynamic'] as const)('allows an experimental %s artifact to keep its own inputs', async (kind) => {
|
||||
const { owner, entry, prototype } = fixture()
|
||||
writeFileSync(entry, `export { marker } from ${JSON.stringify(importPath(entry, prototype))}\n`)
|
||||
|
||||
await expect(bundle(owner, config(kind, experimental))).resolves.toContain('experimental sentinel')
|
||||
})
|
||||
|
||||
it('rejects experimental ownership preserved only by an emitted compiler source map', async () => {
|
||||
const { owner, entry, prototype } = fixture()
|
||||
writeFileSync(entry, 'export const marker = "experimental sentinel"\n//# sourceMappingURL=index.js.map\n')
|
||||
writeFileSync(`${entry}.map`, JSON.stringify({
|
||||
version: 3, names: [], mappings: 'AAAA', sources: [importPath(entry, prototype)],
|
||||
sourcesContent: ['export const marker = "experimental sentinel"\n'],
|
||||
}))
|
||||
|
||||
await expect(bundle(owner, config('static'))).rejects.toThrow(/client bundle isolation.*experimental/)
|
||||
})
|
||||
|
||||
it('checks the resolved module when an import alias conceals its experimental package', async () => {
|
||||
const { owner, entry, prototype } = fixture()
|
||||
writeFileSync(entry, 'export { marker } from "./ordinary.js"\n')
|
||||
|
||||
await expect(bundle(owner, { ...config('static'), alias: { './ordinary.js': prototype } }))
|
||||
.rejects.toThrow(/client bundle isolation.*experimental/)
|
||||
})
|
||||
|
||||
it('rejects an experimental runtime import left external by a static client library', async () => {
|
||||
const { owner, entry } = fixture()
|
||||
writeFileSync(entry, `export * from ${JSON.stringify(experimental)}\n`)
|
||||
|
||||
await expect(bundle(owner, config('static'))).rejects.toThrow(/client bundle isolation.*experimental/)
|
||||
})
|
||||
|
||||
it.each([false, true])('checks omitted source-map files against their package owner (experimental: %s)', async (experimentalSource) => {
|
||||
const { root, owner, entry } = fixture()
|
||||
const dependency = join(root, 'dependency')
|
||||
mkdirSync(dependency)
|
||||
writeFileSync(join(dependency, 'package.json'), JSON.stringify({ name: experimentalSource ? experimental : 'ordinary-library' }))
|
||||
writeFileSync(entry, 'export const marker = "mapped sentinel"\n//# sourceMappingURL=index.js.map\n')
|
||||
writeFileSync(`${entry}.map`, JSON.stringify({
|
||||
version: 3, names: [], mappings: 'AAAA', sourceRoot: importPath(entry, dependency),
|
||||
sources: ['unshipped.ts'], sourcesContent: ['export const marker = "mapped sentinel"\n'],
|
||||
}))
|
||||
|
||||
const result = bundle(owner, config('static'))
|
||||
if (experimentalSource) await expect(result).rejects.toThrow(/client bundle isolation.*experimental/)
|
||||
else await expect(result).resolves.toContain('mapped sentinel')
|
||||
})
|
||||
|
||||
it.each(['.css', '.module.css', '.css?inline'])('rejects experimental %s inputs behind client CSS virtual loaders', async (extension) => {
|
||||
const { owner, entry, prototype } = fixture()
|
||||
const css = join(dirname(prototype), `style${extension.replace('?inline', '')}`)
|
||||
writeFileSync(css, 'body { color: red; }\n')
|
||||
const specifier = `${importPath(entry, css)}${extension.endsWith('?inline') ? '?inline' : ''}`
|
||||
writeFileSync(entry, extension === '.css'
|
||||
? `import ${JSON.stringify(specifier)}\nexport const marker = true\n`
|
||||
: `export { default as style } from ${JSON.stringify(specifier)}\n`)
|
||||
|
||||
await expect(bundle(owner, config('dynamic'))).rejects.toThrow(/client bundle isolation.*experimental/)
|
||||
})
|
||||
|
||||
it.each(['static', 'dynamic'] as const)('keeps ordinary %s bundles and source maps buildable', async (kind) => {
|
||||
const { owner, entry } = fixture()
|
||||
const source = join(owner, 'src/client/index.ts')
|
||||
mkdirSync(dirname(source), { recursive: true })
|
||||
writeFileSync(source, 'export const marker = "stable sentinel"\n')
|
||||
writeFileSync(entry, 'export const marker = "stable sentinel"\n//# sourceMappingURL=index.js.map\n')
|
||||
writeFileSync(`${entry}.map`, JSON.stringify({
|
||||
version: 3, names: [], mappings: 'AAAA', sources: [importPath(entry, source)],
|
||||
sourcesContent: ['export const marker = "stable sentinel"\n'],
|
||||
}))
|
||||
|
||||
await expect(bundle(owner, config(kind))).resolves.toContain('stable sentinel')
|
||||
})
|
||||
})
|
||||
|
||||
describe('client bundle module requests', () => {
|
||||
it('requests what the declaration lists', () => {
|
||||
const requests = requestedExternals('@deepseek-ai/dsh-client-fixture', {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Canonicalize coverage locations inside a coverage partition, before its
|
||||
* blob is written.
|
||||
*
|
||||
* Vitest reaches every reporter's `onCoverage` with the finished run's coverage
|
||||
* map and lets the blob reporter serialize that same map later, in
|
||||
* `onTestRunEnd`, so this reporter sees the locations while they are numeric.
|
||||
* ast-v8-to-istanbul ends a whole-line statement at column `Infinity`, which the
|
||||
* blob's JSON hop turns into `null`; istanbul-lib-coverage reconciles the
|
||||
* per-environment spellings of one statement only through numeric columns, so a
|
||||
* `null` column leaves a client-only spelling behind as a phantom uncovered
|
||||
* statement. Replacing each non-finite end column with
|
||||
* {@link END_OF_LINE_COLUMN} keeps the line-end meaning and keys identically in
|
||||
* every blob, so the merge attributes the coverage one process would have.
|
||||
*
|
||||
* @see ../.agents/notes/implemented/bug-fix/2026-09-13-partitioned-coverage-location-canonicalization.md
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* End column of a location that spans to the end of its start line, and the
|
||||
* finite stand-in for ast-v8-to-istanbul's unrepresentable `Infinity`.
|
||||
*/
|
||||
export const END_OF_LINE_COLUMN = Number.MAX_SAFE_INTEGER
|
||||
|
||||
/** Mutable structural view of the istanbul coverage map handed to reporters. */
|
||||
type CoverageRecord = Record<string, unknown>
|
||||
|
||||
/** Whether a value is a non-null object usable as a mutable record. */
|
||||
function isRecord(value: unknown): value is CoverageRecord {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
/** Rewrite one location's non-finite end column in place. */
|
||||
function canonicalizeLocation(location: unknown): void {
|
||||
if (!isRecord(location)) return
|
||||
const end = location['end']
|
||||
if (!isRecord(end)) return
|
||||
const column = end['column']
|
||||
if (typeof column === 'number' && Number.isFinite(column)) return
|
||||
end['column'] = END_OF_LINE_COLUMN
|
||||
}
|
||||
|
||||
/** Rewrite every location a statement, function, or branch entry carries. */
|
||||
function canonicalizeEntry(entry: unknown): void {
|
||||
if (!isRecord(entry)) return
|
||||
// A statement is a location itself; a function carries `decl` plus `loc`, and
|
||||
// a branch carries `loc` plus one location per branch path.
|
||||
canonicalizeLocation(entry)
|
||||
canonicalizeLocation(entry['decl'])
|
||||
canonicalizeLocation(entry['loc'])
|
||||
const locations = entry['locations']
|
||||
if (Array.isArray(locations)) for (const location of locations) canonicalizeLocation(location)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace every non-finite end column of an istanbul coverage map in place.
|
||||
* @param coverageMap - istanbul `CoverageMap` whose `data` holds per-file coverage.
|
||||
* @throws Error when the payload carries no istanbul `data` record, so a Vitest
|
||||
* change that stops handing reporters that map fails the partition instead of
|
||||
* leaving every location uncanonicalized. Inner structure a `CoverageMap`
|
||||
* always carries is read without further checks.
|
||||
*/
|
||||
export function canonicalizeEndOfLineColumns(coverageMap: unknown): void {
|
||||
const files = isRecord(coverageMap) ? coverageMap['data'] : undefined
|
||||
if (!isRecord(files)) {
|
||||
throw new Error('coverage-canonical-locations: onCoverage payload is not an istanbul CoverageMap')
|
||||
}
|
||||
for (const file of Object.values(files)) {
|
||||
if (!isRecord(file)) continue
|
||||
// FileCoverage keeps its maps on `data` and mirrors them as getters.
|
||||
const coverage = isRecord(file['data']) ? file['data'] : file
|
||||
for (const name of ['statementMap', 'fnMap', 'branchMap']) {
|
||||
const entries = coverage[name]
|
||||
if (!isRecord(entries)) continue
|
||||
for (const entry of Object.values(entries)) canonicalizeEntry(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vitest reporter that canonicalizes a partition's coverage map in
|
||||
* {@link CanonicalCoverageLocationsReporter.onCoverage}, the hook Vitest runs
|
||||
* before the blob reporter serializes that map.
|
||||
*/
|
||||
export default class CanonicalCoverageLocationsReporter {
|
||||
/**
|
||||
* Canonicalize the finished run's coverage locations.
|
||||
* @param coverageMap - coverage map Vitest hands to every reporter.
|
||||
*/
|
||||
public onCoverage(coverageMap: unknown): void {
|
||||
canonicalizeEndOfLineColumns(coverageMap)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,14 @@ function filterMatches(filter: string): string[] {
|
||||
}
|
||||
|
||||
describe('coverage-exempt roster', () => {
|
||||
it('selects every Typert suite for the uninstrumented gate', () => {
|
||||
const typertSpecs = filterMatches('packages/typert/')
|
||||
const exemptSpecs = coverageExemptHeavySuites.flatMap(suite => excludeMatches(suite.exclude))
|
||||
.filter(spec => spec.startsWith('packages/typert/')).sort()
|
||||
expect(typertSpecs.length).toBeGreaterThan(0)
|
||||
expect(exemptSpecs).toEqual(typertSpecs)
|
||||
})
|
||||
|
||||
it.each(coverageExemptHeavySuites.map(suite => [suite.filter, suite] as const))(
|
||||
'filter and exclude select the same non-empty spec set for %s',
|
||||
(_filter, suite) => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Heavy suites the coverage aggregate runs uninstrumented in a parallel gate.
|
||||
* Membership rule: 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
|
||||
* Typert 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 —
|
||||
@@ -27,12 +27,12 @@ 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
|
||||
// Typert src is threshold-excluded; compiler analysis runs uninstrumented.
|
||||
// The generator's tools-catalog registry and
|
||||
// tool-cordis imports are fully covered by those packages' own tests.
|
||||
{
|
||||
filter: 'packages/typert/generator/tests/',
|
||||
exclude: 'packages/typert/generator/tests/**',
|
||||
filter: 'packages/typert/',
|
||||
exclude: 'packages/typert/*/tests/**',
|
||||
},
|
||||
// The webworker-runtime package is outside the coverage requirement by
|
||||
// decision: vitest.config.ts threshold-excludes its src, so every suite
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
COVERAGE_PARTITION_MODE_ENV,
|
||||
@@ -19,6 +21,10 @@ import {
|
||||
type CoverageCommandResult,
|
||||
type CoveragePartitionCoordinatorOptions,
|
||||
} from './coverage-partitions.ts'
|
||||
import {
|
||||
END_OF_LINE_COLUMN,
|
||||
canonicalizeEndOfLineColumns,
|
||||
} from './coverage-canonical-locations.ts'
|
||||
|
||||
const passed: CoverageCommandResult = { exitCode: 0, signalCode: null }
|
||||
|
||||
@@ -206,6 +212,28 @@ describe('coverage file inventory', () => {
|
||||
expect(inventory.projectOf.get('packages/b/tests/b.spec.ts')).toBe('process-bound')
|
||||
})
|
||||
|
||||
it('removes every Typert package from instrumented files and project assignments', async () => {
|
||||
const root = await temporaryRoot()
|
||||
const typertFiles = [
|
||||
'packages/typert/generator/tests/type-model.spec.ts',
|
||||
'packages/typert/loader/tests/loader.spec.ts',
|
||||
'packages/typert/protocol/tests/protocol.spec.ts',
|
||||
'packages/typert/registry/tests/typert.spec.ts',
|
||||
'packages/typert/future/tests/nested/client.spec.tsx',
|
||||
]
|
||||
for (const file of typertFiles) {
|
||||
await mkdir(dirname(join(root, file)), { recursive: true })
|
||||
await writeFile(join(root, file), '')
|
||||
}
|
||||
const retained = 'packages/api/gateway/tests/rpc.spec.ts'
|
||||
const inventory = parseListOutput(
|
||||
[...typertFiles, retained].map(file => `[thread-safe] ${file}`).join('\n'),
|
||||
root,
|
||||
)
|
||||
expect(inventory.files).toEqual([retained])
|
||||
expect([...inventory.projectOf]).toEqual([[retained, 'thread-safe']])
|
||||
})
|
||||
|
||||
it('averages recorded durations per file from the results cache', async () => {
|
||||
const root = await temporaryRoot()
|
||||
await writeVitestCache(root, [
|
||||
@@ -251,6 +279,154 @@ describe('coverage file inventory', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* One source statement as two Vite environments spell it: the ssr map ends the
|
||||
* declaration at its identifier, the jsdom/client map at the nested call, and
|
||||
* both stop at their own line's end.
|
||||
*/
|
||||
const CRASH_LOOP = { start: { line: 36, column: 2 }, end: { line: 54, column: Infinity } }
|
||||
|
||||
/** File path the canonicalization fixtures share. */
|
||||
const CANONICALIZED_FILE = 'packages/util/home-paths/src/index.ts'
|
||||
|
||||
/** Istanbul statement location in the fixture's own terms. */
|
||||
interface StatementLocation {
|
||||
start: { line: number; column: number }
|
||||
end: { line: number; column: number }
|
||||
}
|
||||
|
||||
/** Statement hits and locations read back from a merged istanbul map. */
|
||||
interface MergedStatements {
|
||||
s: Record<string, number>
|
||||
statementMap: Record<string, StatementLocation>
|
||||
}
|
||||
|
||||
/** The istanbul entry points the merge assertions use. */
|
||||
interface CoverageLibrary {
|
||||
createCoverageMap: (data: unknown) => {
|
||||
merge: (data: unknown) => void
|
||||
fileCoverageFor: (file: string) => MergedStatements
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* istanbul-lib-coverage is the merge implementation the partition blobs feed.
|
||||
* It arrives as a dependency of the declared istanbul-lib-report devDependency,
|
||||
* so this spec resolves it through that owner instead of declaring its own copy.
|
||||
*/
|
||||
const coverageLibrary = createRequire(
|
||||
createRequire(import.meta.url).resolve('istanbul-lib-report'),
|
||||
)('istanbul-lib-coverage') as CoverageLibrary
|
||||
|
||||
/** One file's statement coverage, shaped as a partition blob carries it. */
|
||||
function statementRecord(statements: StatementLocation[], hits: number[]): Record<string, unknown> {
|
||||
return {
|
||||
[CANONICALIZED_FILE]: {
|
||||
path: CANONICALIZED_FILE,
|
||||
statementMap: Object.fromEntries(statements.map((location, index) => [index, location])),
|
||||
s: Object.fromEntries(hits.map((hit, index) => [index, hit])),
|
||||
fnMap: {},
|
||||
f: {},
|
||||
branchMap: {},
|
||||
b: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge partition records the way the coordinator's merge command does. The
|
||||
* blob serializer's JSON hop turns a non-finite end column into `null`, which
|
||||
* is what strips istanbul of the range containment it reconciles records with.
|
||||
* Canonicalization runs on the `CoverageMap` the reporter hook receives, whose
|
||||
* `data` holds the raw per-file records merged here.
|
||||
*/
|
||||
function mergePartitionRecords(
|
||||
records: Array<Record<string, unknown>>,
|
||||
canonicalize: boolean,
|
||||
): MergedStatements {
|
||||
const map = coverageLibrary.createCoverageMap({})
|
||||
for (const record of records) {
|
||||
if (canonicalize) canonicalizeEndOfLineColumns({ data: record })
|
||||
map.merge(JSON.parse(JSON.stringify(record)) as unknown)
|
||||
}
|
||||
return map.fileCoverageFor(CANONICALIZED_FILE)
|
||||
}
|
||||
|
||||
/** Start positions of the statements the merged map still reports as unhit. */
|
||||
function uncoveredStarts(coverage: MergedStatements): Array<{ line: number; column: number }> {
|
||||
return Object.entries(coverage.s)
|
||||
.filter(([, hits]) => hits === 0)
|
||||
.map(([index]) => coverage.statementMap[index])
|
||||
.filter((location): location is StatementLocation => location !== undefined)
|
||||
.map(location => location.start)
|
||||
}
|
||||
|
||||
describe('coverage location canonicalization', () => {
|
||||
// index.ts:48 as the ssr environment spells it (at `parent`) and as the
|
||||
// client environment spells it (at `dirname(current)`); the loop holding
|
||||
// both spellings ran in each, and only the client record never took the
|
||||
// catch branch that owns the statement.
|
||||
const ssrRecord = statementRecord(
|
||||
[CRASH_LOOP, { start: { line: 48, column: 12 }, end: { line: 48, column: Infinity } }],
|
||||
[3, 2],
|
||||
)
|
||||
const clientRecord = statementRecord(
|
||||
[CRASH_LOOP, { start: { line: 48, column: 21 }, end: { line: 48, column: Infinity } }],
|
||||
[5, 0],
|
||||
)
|
||||
|
||||
it('drops the phantom statement a client-only spelling leaves after the blob merge', () => {
|
||||
// 33 statements, 32 covered: the ssr spelling is hit while the client
|
||||
// spelling of the same source statement stays unhit, so a file whose every
|
||||
// statement ran fails the per-file 100% gate.
|
||||
expect(uncoveredStarts(mergePartitionRecords([ssrRecord, clientRecord], false)))
|
||||
.toEqual([{ line: 48, column: 21 }])
|
||||
|
||||
expect(uncoveredStarts(mergePartitionRecords([ssrRecord, clientRecord], true))).toEqual([])
|
||||
})
|
||||
|
||||
it('canonicalizes line-end columns in statement, function, and branch locations', () => {
|
||||
const lineEnd = (line: number): StatementLocation => ({
|
||||
start: { line, column: 4 },
|
||||
end: { line, column: Infinity },
|
||||
})
|
||||
const record = {
|
||||
[CANONICALIZED_FILE]: {
|
||||
path: CANONICALIZED_FILE,
|
||||
statementMap: { 0: lineEnd(10), 1: { start: { line: 11, column: 4 }, end: { line: 11, column: 9 } } },
|
||||
s: { 0: 1, 1: 1 },
|
||||
fnMap: { 0: { name: 'probe', decl: lineEnd(10), loc: lineEnd(10) } },
|
||||
f: { 0: 1 },
|
||||
branchMap: { 0: { type: 'if', loc: lineEnd(12), locations: [lineEnd(12), lineEnd(13)] } },
|
||||
b: { 0: [1, 1] },
|
||||
},
|
||||
}
|
||||
|
||||
canonicalizeEndOfLineColumns({ data: record })
|
||||
|
||||
const file = record[CANONICALIZED_FILE]
|
||||
expect(file.statementMap[0]?.end.column).toBe(END_OF_LINE_COLUMN)
|
||||
expect(file.statementMap[1]?.end.column).toBe(9)
|
||||
expect(file.fnMap[0]?.decl.end.column).toBe(END_OF_LINE_COLUMN)
|
||||
expect(file.fnMap[0]?.loc.end.column).toBe(END_OF_LINE_COLUMN)
|
||||
expect(file.branchMap[0]?.loc.end.column).toBe(END_OF_LINE_COLUMN)
|
||||
expect(file.branchMap[0]?.locations[0]?.end.column).toBe(END_OF_LINE_COLUMN)
|
||||
expect(file.branchMap[0]?.locations[1]?.end.column).toBe(END_OF_LINE_COLUMN)
|
||||
})
|
||||
|
||||
it('rejects a payload that carries no istanbul coverage data', () => {
|
||||
// Vitest types the reporter hook as `unknown`, so a payload without the
|
||||
// istanbul `data` record must fail the partition loudly rather than leave
|
||||
// every location uncanonicalized.
|
||||
expect(() => {
|
||||
canonicalizeEndOfLineColumns(undefined)
|
||||
}).toThrow(/not an istanbul CoverageMap/)
|
||||
expect(() => {
|
||||
canonicalizeEndOfLineColumns({})
|
||||
}).toThrow(/not an istanbul CoverageMap/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('coverage partition coordinator', () => {
|
||||
const weightedFiles = ['a.spec.ts', 'b.spec.ts', 'c.spec.ts']
|
||||
const weightedDurations = new Map([
|
||||
@@ -263,6 +439,34 @@ describe('coverage partition coordinator', () => {
|
||||
['b.spec.ts', 'process-bound'],
|
||||
['c.spec.ts', 'process-bound'],
|
||||
])
|
||||
it('passes the canonicalizing reporter to every partition', async () => {
|
||||
const root = await temporaryRoot()
|
||||
const commands: CoverageCommand[] = []
|
||||
const runCommand = successfulCommandRecorder(commands)
|
||||
const coordinator = new CoveragePartitionCoordinator({
|
||||
root,
|
||||
partitions: 2,
|
||||
pnpmEntrypoint: '/pnpm.cjs',
|
||||
files: ['a.spec.ts', 'b.spec.ts'],
|
||||
runCommand,
|
||||
})
|
||||
|
||||
await expect(coordinator.run()).resolves.toBe(0)
|
||||
|
||||
// The coordinator passes a root-relative argument and runs every child with
|
||||
// the repository root as its working directory, so the argument must name
|
||||
// the reporter that sits beside this spec. Whether that reporter runs
|
||||
// before the blob write is Vitest's reporter ordering, not a command the
|
||||
// coordinator builds.
|
||||
const specDirectory = dirname(fileURLToPath(import.meta.url))
|
||||
const reporterPath = join(specDirectory, 'coverage-canonical-locations.ts')
|
||||
for (const command of commands.slice(0, 2)) {
|
||||
const argument = command.args.find(candidate => candidate.startsWith('--reporter=') && candidate.endsWith('coverage-canonical-locations.ts'))
|
||||
if (argument === undefined) throw new Error(`${command.label} does not wire the coverage canonicalizer`)
|
||||
expect(resolve(specDirectory, '..', argument.slice('--reporter='.length))).toBe(reporterPath)
|
||||
}
|
||||
})
|
||||
|
||||
it('runs every single-worker partition before one merged threshold check', async () => {
|
||||
const root = await temporaryRoot()
|
||||
const commands: CoverageCommand[] = []
|
||||
|
||||
@@ -15,6 +15,14 @@ export const COVERAGE_PARTITION_MODE_ENV = 'DSH_COVERAGE_PARTITION_MODE'
|
||||
/** Environment variable overriding instrumented test, polling, and hook timeouts. */
|
||||
export const COVERAGE_TEST_TIMEOUT_ENV = 'DSH_COVERAGE_TEST_TIMEOUT_MS'
|
||||
|
||||
/**
|
||||
* Reporter that canonicalizes a partition's coverage locations before its blob
|
||||
* is serialized (see scripts/coverage-canonical-locations.ts). Root-relative in
|
||||
* POSIX spelling because every child runs with the repository root as its
|
||||
* working directory on every platform.
|
||||
*/
|
||||
const CANONICAL_LOCATIONS_REPORTER = './scripts/coverage-canonical-locations.ts'
|
||||
|
||||
/** One child command owned by the coverage coordinator. */
|
||||
export interface CoverageCommand {
|
||||
/** Diagnostic identity. */
|
||||
@@ -561,6 +569,7 @@ export class CoveragePartitionCoordinator {
|
||||
'--reporter=default',
|
||||
'--reporter=blob',
|
||||
'--reporter=json',
|
||||
`--reporter=${CANONICAL_LOCATIONS_REPORTER}`,
|
||||
`--outputFile.blob=${this.relativePath(blobPath)}`,
|
||||
`--outputFile.json=${this.relativePath(jsonReportPath)}`,
|
||||
`--coverage.reportsDirectory=${this.relativePath(reportsDirectory)}`,
|
||||
|
||||
@@ -17,6 +17,14 @@
|
||||
const path = require('node:path');
|
||||
const { ReportBase } = require('istanbul-lib-report');
|
||||
|
||||
/**
|
||||
* End column of a location that spans to the end of its start line. Whole-line
|
||||
* statements carry `Infinity` until a partition blob serializes it, which the
|
||||
* partition reporter replaces with this finite column
|
||||
* (scripts/coverage-canonical-locations.ts); both spellings read as line ends.
|
||||
*/
|
||||
const END_OF_LINE_COLUMN = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
/**
|
||||
* Editor-convention `line:column` of an istanbul location start (istanbul
|
||||
* columns are 0-based; editors and terminal link handlers expect 1-based).
|
||||
@@ -30,15 +38,20 @@ function usable(loc) {
|
||||
return Boolean(loc && loc.start && Number.isFinite(loc.start.line) && loc.start.line >= 1);
|
||||
}
|
||||
|
||||
/** Whether an end column marks its line's end instead of a measured position. */
|
||||
function atLineEnd(column) {
|
||||
return !Number.isFinite(column) || column >= END_OF_LINE_COLUMN;
|
||||
}
|
||||
|
||||
/**
|
||||
* ` (to line:col)` suffix when the range end adds information beyond the
|
||||
* start. v8-remapped whole-line statements carry end.column = Infinity; those
|
||||
* start. v8-remapped whole-line statements end at their line end; those
|
||||
* degrade to a line-only suffix, or to nothing on a single line.
|
||||
*/
|
||||
function endSuffix(loc) {
|
||||
const end = loc.end;
|
||||
if (!end || !Number.isFinite(end.line) || end.line < 1) return '';
|
||||
if (!Number.isFinite(end.column)) {
|
||||
if (atLineEnd(end.column)) {
|
||||
return end.line === loc.start.line ? '' : ` (to ${end.line})`;
|
||||
}
|
||||
if (end.line === loc.start.line && end.column === loc.start.column) return '';
|
||||
|
||||
+8539
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"capturedAt": "2026-09-12T08:56:20.434Z",
|
||||
"npm": "11.17.0",
|
||||
"node": "24.19.0",
|
||||
"platform": "darwin",
|
||||
"arch": "arm64",
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"installStrategy": "hoisted"
|
||||
}
|
||||
@@ -2,15 +2,17 @@
|
||||
* Quick comprehensive documentation-standard tests: the reference example
|
||||
* stays valid, the consolidated `dsh-doc` skill carries no stale copied
|
||||
* website values or prototype-era language, and the kind system maps each
|
||||
* label to exactly one skill template. These run in `pnpm run test` and
|
||||
* label to exactly one skill template. Session release records match the
|
||||
* writer bound, bilingual counterpart, and evidence links. These run in `pnpm run test` and
|
||||
* `pnpm run test:docs` to guard the standard between heavier corpus gates.
|
||||
* @module scripts/doc-standard.spec
|
||||
*/
|
||||
|
||||
import { existsSync, globSync, readFileSync } from 'node:fs'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import { load } from 'js-yaml'
|
||||
import { JSON_SCHEMA, load } from 'js-yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readCurrentSessionFormatVersion } from './gen-session-format-catalog.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const PACKAGE_README_GLOBS = [
|
||||
@@ -40,6 +42,9 @@ const KIND_TEMPLATES: Readonly<Record<string, string>> = {
|
||||
'package-reference': '.agents/skills/dsh-doc/templates/package-reference.md',
|
||||
'package-library': '.agents/skills/dsh-doc/templates/package-library.md',
|
||||
'package-bundle': '.agents/skills/dsh-doc/templates/package-bundle.md',
|
||||
'persistence-change': '.agents/skills/dsh-doc/templates/persistence-change.md',
|
||||
'persistence-release': '.agents/skills/dsh-doc/templates/persistence-release.md',
|
||||
'persistence-format': '.agents/skills/dsh-doc/templates/persistence-format.md',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,6 +54,7 @@ const KIND_TEMPLATES: Readonly<Record<string, string>> = {
|
||||
* library; the check re-derives the entry shape so a stale entry fails loud.
|
||||
*/
|
||||
const PACKAGE_LIBRARIES: Readonly<Record<string, string>> = {
|
||||
'packages/experimental/browser-use-runtime': 'Provider-owned browser resource management and MCP integration helpers; no plugin entry.',
|
||||
'packages/boot/app-boot': 'Boot library the app bins import; plain helper exports.',
|
||||
'packages/boot/cmdline': 'Command-line library the app bins import; plain module exports.',
|
||||
'packages/client/store': 'Browser-side state primitives; plain function/type exports.',
|
||||
@@ -66,6 +72,7 @@ const PACKAGE_LIBRARIES: Readonly<Record<string, string>> = {
|
||||
'packages/session/session-format': 'Pure Session format planning, codec dispatch, and lossless JSON library.',
|
||||
'packages/session/session-format-catalog': 'Generated build-static Session format inventory with no plugin registration.',
|
||||
'packages/session/session-format-v0-to-v1': 'Pure released-v0 codec and adjacent migration library.',
|
||||
'packages/session/session-format-v2-to-v3': 'Pure released-v2 codec and adjacent migration library.',
|
||||
'packages/session/session-telemetry': 'Telemetry Service Definition and capture library; providers mount the backend.',
|
||||
'packages/session/session-title-llm': 'Shared LLM title-provider registration and request policy.',
|
||||
'packages/subagent/subagent-in-process-driver': 'Shared one-shot child-agent driver used by provider plugins.',
|
||||
@@ -75,12 +82,14 @@ const PACKAGE_LIBRARIES: Readonly<Record<string, string>> = {
|
||||
'packages/test-support/client-runtime': 'Browser-side test infrastructure.',
|
||||
'packages/test-support/llm-mock-server': 'Test server library; substitutes provider wire behavior.',
|
||||
'packages/test-support/loader-smoke': 'Test harness library; mounts nothing into a product composition.',
|
||||
'packages/test-support/remote-mock': 'Browser-side test infrastructure; mounts nothing into a product composition.',
|
||||
'packages/typert/generator': 'Build-time generator run outside any agent runtime.',
|
||||
'packages/typert/protocol': 'Compiler-independent protocol declarations.',
|
||||
'packages/util/atomic-write': 'Zero-dependency filesystem write utility.',
|
||||
'packages/util/brand': 'Stateless nominal-string and canonical-key constructors.',
|
||||
'packages/util/crypto': 'Zero-dependency identifier minting utility.',
|
||||
'packages/util/deque': 'Zero-dependency circular deque utility.',
|
||||
'packages/util/chunked-list': 'Persistent collection operations and checkpoint validation without a plugin surface.',
|
||||
'packages/util/home-paths': 'Zero-dependency harness-home path resolver.',
|
||||
'packages/util/launch-environment': 'Zero-dependency environment resolver.',
|
||||
'packages/util/native-command': 'Host-side subprocess runner utility.',
|
||||
@@ -144,6 +153,146 @@ function packageReadmeStructureErrors(file: string, source: string): string[] {
|
||||
return required.flatMap(([pattern, label]) => pattern.test(source) ? [] : [`missing ${label}`])
|
||||
}
|
||||
|
||||
interface SessionFormatRelease {
|
||||
latestReleasedVersion: number
|
||||
evidenceTag: string
|
||||
}
|
||||
|
||||
/** Validate release metadata and tagged writer evidence; throw on malformed or inconsistent input. */
|
||||
function validateSessionFormatRelease(source: string, currentWriterVersion: number): SessionFormatRelease {
|
||||
const normalized = source.replaceAll('\r\n', '\n')
|
||||
const openings = [...normalized.matchAll(/^```yaml session-format-release[ \t]*$/gmu)]
|
||||
if (openings.length !== 1) throw new Error('Expected exactly one session-format-release record')
|
||||
const block = /^```yaml session-format-release[ \t]*\n([\s\S]*?)^```[ \t]*$/mu.exec(normalized)
|
||||
if (block === null) throw new Error('Expected a closed session-format-release record')
|
||||
const metadata: unknown = load(block[1]!, { schema: JSON_SCHEMA })
|
||||
if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) {
|
||||
throw new Error('Session format release record must be a mapping')
|
||||
}
|
||||
const fields = Object.keys(metadata).sort()
|
||||
if (fields.join(',') !== 'evidenceTag,latestReleasedVersion') {
|
||||
throw new Error('Session format release record requires exactly latestReleasedVersion and evidenceTag')
|
||||
}
|
||||
const { latestReleasedVersion, evidenceTag } = metadata as Record<string, unknown>
|
||||
if (typeof latestReleasedVersion !== 'number' || !Number.isSafeInteger(latestReleasedVersion)
|
||||
|| latestReleasedVersion < 0) {
|
||||
throw new Error('latestReleasedVersion must be a non-negative safe integer')
|
||||
}
|
||||
if (latestReleasedVersion > currentWriterVersion) {
|
||||
throw new Error('latestReleasedVersion must not exceed the current writer version')
|
||||
}
|
||||
if (typeof evidenceTag !== 'string'
|
||||
|| !/^dsh-v\d+\.\d+\.\d+(?:-[\dA-Za-z]+(?:[.-][\dA-Za-z]+)*)?(?:\+[\dA-Za-z]+(?:[.-][\dA-Za-z]+)*)?$/u.test(evidenceTag)) {
|
||||
throw new Error('evidenceTag must be a non-empty dsh-v version tag without URL delimiters')
|
||||
}
|
||||
const hasEvidence = normalized.split('\n').some(line =>
|
||||
line.includes(`\`${evidenceTag}\``) && line.includes('`packages/core/session/src/types.ts`'))
|
||||
if (!hasEvidence) throw new Error('Missing matching evidence tag and tagged writer path')
|
||||
return { latestReleasedVersion, evidenceTag }
|
||||
}
|
||||
|
||||
function sessionFormatReleaseFixture(): { record: SessionFormatRelease; body: string; evidence: string; source: string } {
|
||||
const record = validateSessionFormatRelease(
|
||||
readFileSync(resolve(root, 'docs/session-format-status.md'), 'utf8'),
|
||||
readCurrentSessionFormatVersion(root),
|
||||
)
|
||||
const body = `latestReleasedVersion: ${record.latestReleasedVersion}\nevidenceTag: ${record.evidenceTag}`
|
||||
const evidence = `Evidence: published product tag \`${record.evidenceTag}\`; tagged writer: \`packages/core/session/src/types.ts\`.`
|
||||
return { record, body, evidence, source: releaseDocument(body, evidence) }
|
||||
}
|
||||
|
||||
function releaseDocument(body: string, evidence: string): string {
|
||||
return `\`\`\`yaml session-format-release\n${body}\n\`\`\`\n\n${evidence}\n`
|
||||
}
|
||||
|
||||
describe('Session format release authority', () => {
|
||||
it('keeps bilingual release metadata consistent with the writer and tagged evidence', () => {
|
||||
const records = ['docs/session-format-status.md', 'docs/session-format-status.zh.md'].map(file =>
|
||||
validateSessionFormatRelease(readFileSync(resolve(root, file), 'utf8'), readCurrentSessionFormatVersion(root)),
|
||||
)
|
||||
expect(records[0]).toEqual(records[1])
|
||||
})
|
||||
|
||||
it('accepts a released writer and a newer development writer, including format zero', () => {
|
||||
const { record, body, evidence, source } = sessionFormatReleaseFixture()
|
||||
expect(validateSessionFormatRelease(source, record.latestReleasedVersion)).toEqual(record)
|
||||
expect(validateSessionFormatRelease(source, record.latestReleasedVersion + 1)).toEqual(record)
|
||||
const zero = releaseDocument(body.replace(`latestReleasedVersion: ${record.latestReleasedVersion}`, 'latestReleasedVersion: 0'), evidence)
|
||||
expect(validateSessionFormatRelease(zero, 0)).toEqual({ ...record, latestReleasedVersion: 0 })
|
||||
})
|
||||
|
||||
it('rejects missing, duplicated, unclosed, and malformed release records', () => {
|
||||
const { record, body, evidence, source } = sessionFormatReleaseFixture()
|
||||
for (const invalid of [
|
||||
evidence,
|
||||
source + source,
|
||||
source + '\n```yaml session-format-release\n',
|
||||
`\`\`\`yaml session-format-release\n${body}`,
|
||||
releaseDocument('[unterminated', evidence),
|
||||
releaseDocument('', evidence),
|
||||
releaseDocument('null', evidence),
|
||||
releaseDocument('scalar', evidence),
|
||||
releaseDocument(`- latestReleasedVersion: ${record.latestReleasedVersion}`, evidence),
|
||||
releaseDocument(`${body}\n---\n${body}`, evidence),
|
||||
]) {
|
||||
expect(() => validateSessionFormatRelease(invalid, record.latestReleasedVersion), invalid).toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects missing, duplicate, and extra record fields', () => {
|
||||
const { record, body, evidence } = sessionFormatReleaseFixture()
|
||||
for (const invalid of [
|
||||
'{}',
|
||||
`evidenceTag: ${record.evidenceTag}`,
|
||||
`latestReleasedVersion: ${record.latestReleasedVersion}`,
|
||||
`${body}\nlatestReleasedVersion: ${record.latestReleasedVersion}`,
|
||||
`${body}\nevidenceTag: ${record.evidenceTag}`,
|
||||
`${body}\nreleased: true`,
|
||||
]) {
|
||||
expect(() => validateSessionFormatRelease(releaseDocument(invalid, evidence), record.latestReleasedVersion), invalid).toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid released versions and releases beyond the current writer', () => {
|
||||
const { record, evidence } = sessionFormatReleaseFixture()
|
||||
for (const value of ['-1', '1.5', String(Number.MAX_SAFE_INTEGER + 1), '.inf', '.nan', 'null', 'true', '"0"']) {
|
||||
const source = releaseDocument(`latestReleasedVersion: ${value}\nevidenceTag: ${record.evidenceTag}`, evidence)
|
||||
expect(() => validateSessionFormatRelease(source, Number.MAX_SAFE_INTEGER), value).toThrow('non-negative safe integer')
|
||||
}
|
||||
const writer = readCurrentSessionFormatVersion(root)
|
||||
const future = releaseDocument(`latestReleasedVersion: ${writer + 1}\nevidenceTag: ${record.evidenceTag}`, evidence)
|
||||
expect(() => validateSessionFormatRelease(future, writer)).toThrow('must not exceed the current writer')
|
||||
})
|
||||
|
||||
it('rejects empty, malformed, and URL-injecting evidence tags', () => {
|
||||
const { record, evidence } = sessionFormatReleaseFixture()
|
||||
for (const tag of [
|
||||
null, true, 1, '', ' ', 'dsh-v', record.evidenceTag.replace('dsh-v', 'v'),
|
||||
`${record.evidenceTag}/other`, `${record.evidenceTag}?query`, `${record.evidenceTag}#fragment`,
|
||||
`${record.evidenceTag}%2Fother`, `${record.evidenceTag})`, `${record.evidenceTag}\n`,
|
||||
]) {
|
||||
const source = releaseDocument(`latestReleasedVersion: ${record.latestReleasedVersion}\nevidenceTag: ${JSON.stringify(tag)}`, evidence)
|
||||
expect(() => validateSessionFormatRelease(source, record.latestReleasedVersion), String(tag)).toThrow('dsh-v version tag')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects absent or mismatched release tags and tagged writer paths', () => {
|
||||
const { record, body, evidence } = sessionFormatReleaseFixture()
|
||||
for (const invalid of [
|
||||
'',
|
||||
evidence.replace(record.evidenceTag, `${record.evidenceTag}-other`),
|
||||
evidence.replace(record.evidenceTag, 'main'),
|
||||
evidence.replace('packages/core/session/src/types.ts', 'packages/core/session/src/other.ts'),
|
||||
evidence.replace(record.evidenceTag, `${record.evidenceTag}?query`),
|
||||
evidence.replace('types.ts', 'types.ts#fragment'),
|
||||
evidence.replace('; tagged writer:', ';\n tagged writer:'),
|
||||
]) {
|
||||
expect(() => validateSessionFormatRelease(releaseDocument(body, invalid), record.latestReleasedVersion), invalid)
|
||||
.toThrow('Missing matching evidence tag and tagged writer path')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('dsh-doc skill consolidation', () => {
|
||||
it('carries no prototype-era language', () => {
|
||||
const files = [
|
||||
@@ -200,6 +349,37 @@ describe('dsh-doc skill consolidation', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('maps persistence transition records to their dedicated document kind', () => {
|
||||
const files = globSync('docs/persistence-changes/*.md', { cwd: root })
|
||||
.filter(file => !/\/README(?:\.zh)?\.md$/u.test(file.replaceAll('\\', '/')))
|
||||
expect(files.length).toBeGreaterThan(0)
|
||||
for (const file of files) {
|
||||
const metadata = readFrontmatter(file)
|
||||
expect(metadata.kind, file).toBe('persistence-change')
|
||||
expect(typeof metadata.description === 'string' && metadata.description.trim().length > 0, file).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('maps retrospective release comparisons to their dedicated document kind', () => {
|
||||
const files = globSync('docs/persistence-changes/releases/dsh-*.md', { cwd: root })
|
||||
expect(files.length).toBeGreaterThan(0)
|
||||
for (const file of files) {
|
||||
const metadata = readFrontmatter(file)
|
||||
expect(metadata.kind, file).toBe('persistence-release')
|
||||
expect(typeof metadata.description === 'string' && metadata.description.trim().length > 0, file).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('maps historical Session format references to their dedicated document kind', () => {
|
||||
const files = globSync('docs/persistence-changes/historical-formats/v*.md', { cwd: root })
|
||||
expect(files.length).toBe(readCurrentSessionFormatVersion(root) * 2)
|
||||
for (const file of files) {
|
||||
const metadata = readFrontmatter(file)
|
||||
expect(metadata.kind, file).toBe('persistence-format')
|
||||
expect(typeof metadata.description === 'string' && metadata.description.trim().length > 0, file).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the audited library registry accurate: every entry has a plain module entry and no bundle declaration', () => {
|
||||
for (const [dir, reason] of Object.entries(PACKAGE_LIBRARIES)) {
|
||||
expect(reason.trim().length, `${dir}: library justification`).toBeGreaterThan(0)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Experimental packages excluded from public releases and npm baselines. */
|
||||
export const PRIVATE_EXPERIMENTAL_PACKAGE_DIRECTORIES: readonly string[] = []
|
||||
|
||||
/**
|
||||
* Whether an experimental package publishes under the default-public policy.
|
||||
* @param directory - repository-relative package directory.
|
||||
* @param privateDirectories - experimental directories excluded from publication.
|
||||
* @returns Whether the package publishes with the dsh family.
|
||||
*/
|
||||
export function isPublicExperimentalPackageDirectory(
|
||||
directory: string,
|
||||
privateDirectories: readonly string[] = PRIVATE_EXPERIMENTAL_PACKAGE_DIRECTORIES,
|
||||
): boolean {
|
||||
return /^packages\/experimental\/[^/]+$/.test(directory)
|
||||
&& !privateDirectories.includes(directory)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Change one ordinary section after a successful persistent shell call. */
|
||||
export const name = 'python-sdk-in-history-prompt'
|
||||
export const inject = ['systemPrompt']
|
||||
|
||||
/** @param {import('@deepseek-ai/cordis').Context} ctx - Composed runtime services. */
|
||||
export function apply(ctx) {
|
||||
let version = 1
|
||||
ctx.effect(() => ctx.systemPrompt.section({
|
||||
name: 'python-sdk:in-history',
|
||||
order: 400,
|
||||
text: () => `Python SDK prompt version ${version}.`,
|
||||
}))
|
||||
ctx.on('tools/post-execute', async (exec, result, next) => {
|
||||
const downstream = await next()
|
||||
if (!result.isError && (exec.name === 'bash' || exec.name === 'pwsh')) version = 2
|
||||
return downstream
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/** One admitted image and one authored failure for the Python SDK event snapshot. */
|
||||
|
||||
/** Cordis fixture identity. */
|
||||
export const name = 'python-snapshot-image-offload'
|
||||
|
||||
/** Admission and request hooks supplied by the shipped SDK profile. */
|
||||
export const inject = ['attachments', 'agents', 'llm']
|
||||
|
||||
/**
|
||||
* @param {import('@deepseek-ai/cordis').Context} ctx - Scenario-local host context.
|
||||
* @param {{ parentSessionId: string }} config - The one session receiving the authored failure.
|
||||
*/
|
||||
export function apply(ctx, config) {
|
||||
let injected = false
|
||||
let failed = false
|
||||
ctx.on('agent/pre-step', async ({ agent }, next) => {
|
||||
const decision = await next()
|
||||
if (agent.id !== config.parentSessionId || injected || decision.kind === 'reject') return decision
|
||||
const content = await ctx.attachments.admitPromptContent([{
|
||||
type: 'image',
|
||||
mediaType: 'image/png',
|
||||
data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC',
|
||||
}])
|
||||
injected = true
|
||||
return {
|
||||
...decision,
|
||||
messages: [...decision.messages, { id: 'python-snapshot-image', role: 'user', content, source: { kind: 'plugin', plugin: name } }],
|
||||
}
|
||||
})
|
||||
ctx.on('llm/stream', async function* (options, next) {
|
||||
if (options.sessionId !== config.parentSessionId || options.purpose !== undefined || failed) {
|
||||
yield* next()
|
||||
return
|
||||
}
|
||||
failed = true
|
||||
yield {
|
||||
type: 'finish',
|
||||
reason: { kind: 'error', failure: {
|
||||
code: 'IMAGE_OFFLOAD_REQUIRED', message: 'authored image budget failure', offloadImages: 1,
|
||||
} },
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Hold the advanced workflow child's first step until its parent records membership. */
|
||||
export const name = 'python-snapshot-workflow-order'
|
||||
|
||||
/**
|
||||
* @param {import('@deepseek-ai/cordis').Context} ctx - Scenario-local host context.
|
||||
* @param {{ parentSessionId: string, prompt: string }} config - Exact advanced scenario identities.
|
||||
*/
|
||||
export function apply(ctx, config) {
|
||||
const started = new Set()
|
||||
const pending = new Map()
|
||||
let disposed = false
|
||||
|
||||
ctx.effect(() => async () => {
|
||||
disposed = true
|
||||
const waits = [...pending.values()]
|
||||
for (const wait of waits) wait.reject(new Error('workflow snapshot barrier disposed'))
|
||||
await Promise.allSettled(waits.map(wait => wait.done))
|
||||
started.clear()
|
||||
})
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (disposed || session.id !== config.parentSessionId || event.type !== 'tool-workflow/agent-start') return
|
||||
started.add(event.data.childId)
|
||||
pending.get(event.data.childId)?.resolve()
|
||||
})
|
||||
ctx.on('agent/pre-step', async ({ agent, messages, turn, step, signal }, next) => {
|
||||
if (agent.session.header.parentSession !== config.parentSessionId || turn !== 1 || step !== 1
|
||||
|| !messages.some(message => message.content.some(block => block.type === 'text' && block.text === config.prompt))) {
|
||||
return next()
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
if (disposed) throw new Error('workflow snapshot barrier disposed')
|
||||
if (!started.has(agent.id)) {
|
||||
const wait = Promise.withResolvers()
|
||||
const abort = () => { wait.reject(signal.reason) }
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
wait.done = wait.promise.finally(() => {
|
||||
signal.removeEventListener('abort', abort)
|
||||
pending.delete(agent.id)
|
||||
})
|
||||
pending.set(agent.id, wait)
|
||||
await wait.done
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
if (disposed) throw new Error('workflow snapshot barrier disposed')
|
||||
return next()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/** Shared schema aliases must retain the catalog's declared-field checks. */
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { collectConfigCatalog } from './gen-config-catalog.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
const sharedSchema = `
|
||||
import Schema from '@deepseek-ai/schemastery'
|
||||
export interface LaunchConfig {
|
||||
/** Browser ownership mode. */
|
||||
mode: 'launch'
|
||||
/** Hide the launched browser. */
|
||||
headless: boolean
|
||||
}
|
||||
export interface AttachConfig {
|
||||
/** Browser ownership mode. */
|
||||
mode: 'attach'
|
||||
/** Existing browser address. */
|
||||
endpoint: string
|
||||
}
|
||||
export type BrowserConfig = LaunchConfig | AttachConfig
|
||||
export const Shared = Schema.union([
|
||||
Schema.object({ mode: Schema.const('launch'), headless: Schema.boolean() }),
|
||||
Schema.object({ mode: Schema.const('attach'), endpoint: Schema.string() }),
|
||||
])
|
||||
`
|
||||
|
||||
function fixture(schema = sharedSchema) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-config-catalog-'))
|
||||
roots.push(root)
|
||||
const write = (path: string, value: string): void => {
|
||||
const file = join(root, path)
|
||||
mkdirSync(dirname(file), { recursive: true })
|
||||
writeFileSync(file, value)
|
||||
}
|
||||
write('tsconfig.json', JSON.stringify({ compilerOptions: {
|
||||
baseUrl: '.', module: 'ESNext', moduleResolution: 'Bundler',
|
||||
paths: { '@test/runtime/config': ['./packages/test/runtime/src/config.ts'] },
|
||||
}, include: ['packages/**/*.ts'] }))
|
||||
write('packages/test/runtime/package.json', JSON.stringify({
|
||||
name: '@test/runtime', exports: { './config': { types: './lib/types/config.d.ts', default: './lib/config.js' } },
|
||||
}))
|
||||
write('packages/test/runtime/src/index.ts', "export { Shared } from './config.ts'\n")
|
||||
write('packages/test/runtime/src/config.ts', schema)
|
||||
write('packages/test/provider/package.json', JSON.stringify({ name: '@test/provider' }))
|
||||
write('packages/test/provider/src/index.ts', `
|
||||
import { Shared as SharedConfig, type BrowserConfig } from '@test/runtime/config'
|
||||
export type Config = BrowserConfig
|
||||
export const Config = SharedConfig
|
||||
export function apply(ctx: unknown, config: Config): void {}
|
||||
`)
|
||||
return { root, write }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('shared config schema catalog', () => {
|
||||
it('collects every branch through a renamed named import from a public source subpath', () => {
|
||||
const { root } = fixture()
|
||||
const provider = collectConfigCatalog(root).find(entry => entry.pkg === '@test/provider')
|
||||
expect(new Set(provider?.schemaKeys)).toEqual(new Set(['mode', 'headless', 'endpoint']))
|
||||
expect(provider?.configTypeName).toBe('Config')
|
||||
expect(provider?.pastes?.[0]?.text).toBe('export type Config = BrowserConfig')
|
||||
})
|
||||
|
||||
it('rejects a schema field absent from the shared config type', () => {
|
||||
const { root } = fixture(sharedSchema.replace('headless: Schema.boolean()', 'headless: Schema.boolean(), hidden: Schema.string()'))
|
||||
expect(() => collectConfigCatalog(root)).toThrow("schema validates key 'hidden' but config type 'Config' declares no such member")
|
||||
})
|
||||
|
||||
it('follows a local const alias without treating a completed branch as a cycle', () => {
|
||||
const { root } = fixture(sharedSchema.replace('export const Shared = Schema.union', 'export const Shared = Base\nconst Base = Schema.union'))
|
||||
const provider = collectConfigCatalog(root).find(entry => entry.pkg === '@test/provider')
|
||||
expect(new Set(provider?.schemaKeys)).toEqual(new Set(['mode', 'headless', 'endpoint']))
|
||||
})
|
||||
|
||||
it('rejects type-only imports used as runtime schemas', () => {
|
||||
const { root, write } = fixture()
|
||||
write('packages/test/provider/src/index.ts', `
|
||||
import type { Shared as SharedConfig, BrowserConfig } from '@test/runtime/config'
|
||||
export type Config = BrowserConfig
|
||||
export const Config = SharedConfig
|
||||
export function apply(ctx: unknown, config: Config): void {}
|
||||
`)
|
||||
expect(() => collectConfigCatalog(root)).toThrow("schema alias 'SharedConfig' must name a const or named value import")
|
||||
})
|
||||
|
||||
it('rejects a private subpath even when TypeScript can resolve its source', () => {
|
||||
const { root, write } = fixture()
|
||||
write('packages/test/runtime/package.json', JSON.stringify({ name: '@test/runtime', exports: {} }))
|
||||
expect(() => collectConfigCatalog(root)).toThrow("does not explicitly export './config'")
|
||||
})
|
||||
|
||||
it('rejects a missing source mapping', () => {
|
||||
const { root, write } = fixture()
|
||||
write('tsconfig.json', JSON.stringify({ compilerOptions: { paths: { '@test/runtime/config': ['./missing.ts'] } } }))
|
||||
expect(() => collectConfigCatalog(root)).toThrow('has no workspace source mapping')
|
||||
})
|
||||
|
||||
it('rejects a source mapping into another package', () => {
|
||||
const { root, write } = fixture()
|
||||
write('tsconfig.json', JSON.stringify({ compilerOptions: {
|
||||
baseUrl: '.', paths: { '@test/runtime/config': ['./packages/test/provider/src/index.ts'] },
|
||||
} }))
|
||||
expect(() => collectConfigCatalog(root)).toThrow('must resolve inside packages/test/runtime/src')
|
||||
})
|
||||
|
||||
it('rejects a schema value that the imported module does not export', () => {
|
||||
const { root } = fixture(sharedSchema.replace('export const Shared', 'const Shared'))
|
||||
expect(() => collectConfigCatalog(root)).toThrow("has no exported const 'Shared'")
|
||||
})
|
||||
|
||||
it('rejects recursive const aliases', () => {
|
||||
const { root } = fixture(`${sharedSchema.slice(0, sharedSchema.indexOf('export const Shared'))}
|
||||
export const Shared = Loop
|
||||
const Loop = Shared
|
||||
`)
|
||||
expect(() => collectConfigCatalog(root)).toThrow('cyclic schema alias')
|
||||
})
|
||||
|
||||
it('rejects a dynamic schema factory instead of dropping its keys', () => {
|
||||
const { root } = fixture(`${sharedSchema.slice(0, sharedSchema.indexOf('export const Shared'))}
|
||||
export const Shared = makeSchema()
|
||||
`)
|
||||
expect(() => collectConfigCatalog(root)).toThrow('not a statically walkable schemastery call')
|
||||
})
|
||||
|
||||
it('rejects an unresolved alias within a schema union', () => {
|
||||
const { root } = fixture(`${sharedSchema.slice(0, sharedSchema.indexOf('export const Shared'))}
|
||||
export const Shared = Schema.union([MissingSchema])
|
||||
`)
|
||||
expect(() => collectConfigCatalog(root)).toThrow("schema alias 'MissingSchema' must name a const or named value import")
|
||||
})
|
||||
})
|
||||
+109
-12
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve, sep } from 'node:path'
|
||||
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { LINK_MAP } from './gen-cordis-catalog.ts'
|
||||
import { parseJsDoc, pointer, rawJsDoc } from './jsdoc.ts'
|
||||
@@ -87,7 +87,7 @@ interface FileCtx {
|
||||
sf: ts.SourceFile
|
||||
/** Local binding name → `{ imported, specifier }`; default imports record
|
||||
* `imported: 'default'`. */
|
||||
imports: Map<string, { imported: string; specifier: string }>
|
||||
imports: Map<string, { imported: string; specifier: string; typeOnly?: boolean }>
|
||||
}
|
||||
|
||||
/** Throw one aggregate error for every violation the walk collected. */
|
||||
@@ -105,7 +105,7 @@ function loadFile(abs: string, rel: string, cache: Map<string, FileCtx>): FileCt
|
||||
if (cached) return cached
|
||||
const text = readFileSync(abs, 'utf8')
|
||||
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
|
||||
const imports = new Map<string, { imported: string; specifier: string }>()
|
||||
const imports: FileCtx['imports'] = new Map()
|
||||
for (const stmt of sf.statements) {
|
||||
if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue
|
||||
const specifier = stmt.moduleSpecifier.text
|
||||
@@ -114,7 +114,11 @@ function loadFile(abs: string, rel: string, cache: Map<string, FileCtx>): FileCt
|
||||
if (clause.name) imports.set(clause.name.text, { imported: 'default', specifier })
|
||||
if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
|
||||
for (const el of clause.namedBindings.elements) {
|
||||
imports.set(el.name.text, { imported: (el.propertyName ?? el.name).text, specifier })
|
||||
imports.set(el.name.text, {
|
||||
imported: (el.propertyName ?? el.name).text,
|
||||
specifier,
|
||||
typeOnly: clause.phaseModifier === ts.SyntaxKind.TypeKeyword || el.isTypeOnly,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
|
||||
@@ -218,6 +222,69 @@ interface World {
|
||||
cache: Map<string, FileCtx>
|
||||
/** Workspace package name → repo-relative package dir. */
|
||||
pkgDirByName: Map<string, string>
|
||||
/** Exact public export declarations used to verify shared schema imports. */
|
||||
pkgExportsByName: Map<string, unknown>
|
||||
/** Source-plane TypeScript resolution, loaded only for shared schema imports. */
|
||||
compilerOptions?: ts.CompilerOptions
|
||||
}
|
||||
|
||||
/** Resolve an explicitly exported workspace module to its mapped source file. */
|
||||
function loadWorkspaceSource(world: World, from: FileCtx, specifier: string): FileCtx {
|
||||
const pkg = specifier.split('/').slice(0, specifier.startsWith('@') ? 2 : 1).join('/')
|
||||
const dir = world.pkgDirByName.get(pkg)
|
||||
if (dir === undefined) throw new Error(`schema import '${specifier}' is not a workspace package`)
|
||||
const subpath = specifier === pkg ? '.' : `.${specifier.slice(pkg.length)}`
|
||||
const exports = world.pkgExportsByName.get(pkg)
|
||||
if (typeof exports !== 'object' || exports === null || Array.isArray(exports)
|
||||
|| !Object.hasOwn(exports, subpath) || (exports as Record<string, unknown>)[subpath] == null) {
|
||||
throw new Error(`workspace package '${pkg}' does not explicitly export '${subpath}'`)
|
||||
}
|
||||
if (world.compilerOptions === undefined) {
|
||||
const parsed = ts.getParsedCommandLineOfConfigFile(resolve(world.scanRoot, 'tsconfig.json'), {}, {
|
||||
...ts.sys,
|
||||
onUnRecoverableConfigFileDiagnostic(diagnostic) {
|
||||
throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
|
||||
},
|
||||
})
|
||||
if (parsed === undefined || parsed.errors.length > 0) {
|
||||
throw new Error(`cannot resolve schema import '${specifier}' through the workspace source tsconfig`)
|
||||
}
|
||||
world.compilerOptions = parsed.options
|
||||
}
|
||||
const resolved = ts.resolveModuleName(specifier, from.abs, world.compilerOptions, ts.sys).resolvedModule
|
||||
if (resolved === undefined) throw new Error(`schema import '${specifier}' has no workspace source mapping`)
|
||||
const sourcePath = relative(resolve(world.scanRoot, dir, 'src'), resolved.resolvedFileName)
|
||||
if (isAbsolute(sourcePath) || sourcePath === '..' || sourcePath.startsWith(`..${sep}`)
|
||||
|| !sourcePath.endsWith('.ts') || sourcePath.endsWith('.d.ts')) {
|
||||
throw new Error(`schema import '${specifier}' must resolve inside ${dir}/src to a TypeScript source file`)
|
||||
}
|
||||
const rel = relative(world.scanRoot, resolved.resolvedFileName).split(sep).join('/')
|
||||
return loadFile(resolved.resolvedFileName, rel, world.cache)
|
||||
}
|
||||
|
||||
/** Find a directly declared const schema, requiring public exports at imported entries. */
|
||||
function schemaConst(ctx: FileCtx, name: string, exported: boolean): ts.Expression | null {
|
||||
for (const statement of ctx.sf.statements) {
|
||||
if (!ts.isVariableStatement(statement) || !(statement.declarationList.flags & ts.NodeFlags.Const)) continue
|
||||
if (exported && !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue
|
||||
const declaration = statement.declarationList.declarations.find(item => ts.isIdentifier(item.name) && item.name.text === name)
|
||||
if (declaration?.initializer !== undefined) return declaration.initializer
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Follow only const identities and named value imports; never execute schema factories. */
|
||||
function schemaAlias(world: World, ctx: FileCtx, name: string): { ctx: FileCtx; expr: ts.Expression } {
|
||||
const local = schemaConst(ctx, name, false)
|
||||
if (local !== null) return { ctx, expr: local }
|
||||
const imported = ctx.imports.get(name)
|
||||
if (imported === undefined || imported.typeOnly || imported.imported === '*' || imported.imported === 'default') {
|
||||
throw new Error(`schema alias '${name}' must name a const or named value import`)
|
||||
}
|
||||
const target = loadWorkspaceSource(world, ctx, imported.specifier)
|
||||
const expr = schemaConst(target, imported.imported, true)
|
||||
if (expr === null) throw new Error(`schema import '${imported.specifier}' has no exported const '${imported.imported}'`)
|
||||
return { ctx: target, expr }
|
||||
}
|
||||
|
||||
/** How a schema key path fared against the declared config type: definitely
|
||||
@@ -290,7 +357,16 @@ function declForTypeName(world: World, ctx: FileCtx, name: string): { decl: Type
|
||||
return findExportedTypeDecl(world, loadRelative(world, ctx, imp.specifier), imp.imported) ?? 'unknown'
|
||||
}
|
||||
const dir = world.pkgDirByName.get(imp.specifier)
|
||||
if (dir === undefined) return 'unknown'
|
||||
if (dir === undefined) {
|
||||
try {
|
||||
const target = loadWorkspaceSource(world, ctx, imp.specifier)
|
||||
return findExportedTypeDecl(world, target, imp.imported) ?? 'unknown'
|
||||
} catch {
|
||||
// External or unsupported declarations remain unknown for type presence;
|
||||
// a runtime schema alias reports the same resolution failure separately.
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
const entryRel = `${dir}/src/index.ts`
|
||||
let entry: FileCtx
|
||||
try {
|
||||
@@ -413,17 +489,19 @@ function unwrapExpr(expr: ts.Expression): ts.Expression {
|
||||
* Statically walk a schemastery schema expression to its key paths plus the
|
||||
* packages whose schemas an intersect composes. A key path is the top-level
|
||||
* key or a nested path through object/array compositions (`agents[].id`).
|
||||
* Handles the declaration forms the repo uses — `z.object({…})` (possibly behind
|
||||
* chained calls) and `z.intersect([X.Config, …])` — and hard-errors on
|
||||
* Handles object/union calls, chained refinements, named const schema imports
|
||||
* through public workspace source paths, and `z.intersect([X.Config, …])`; errors on
|
||||
* anything else, so a schema the walk cannot see fails the gate instead of
|
||||
* silently thinning it. Nested values that are neither `object` nor `array`
|
||||
* compositions (primitives, unions, dynamic-key dicts) contribute no paths.
|
||||
*/
|
||||
function walkSchemaExpr(
|
||||
world: World,
|
||||
ctx: FileCtx,
|
||||
expr: ts.Expression,
|
||||
where: string,
|
||||
violations: string[],
|
||||
aliases = new Set<string>(),
|
||||
): { keys: string[]; composes: string[] } {
|
||||
const keys: string[] = []
|
||||
const composes: string[] = []
|
||||
@@ -451,6 +529,22 @@ function walkSchemaExpr(
|
||||
}
|
||||
const visit = (e: ts.Expression): void => {
|
||||
const call = unwrapExpr(e)
|
||||
if (ts.isIdentifier(call)) {
|
||||
const identity = `${ctx.abs}#${call.text}`
|
||||
if (aliases.has(identity)) {
|
||||
violations.push(`${where}: cyclic schema alias '${call.text}' in ${ctx.rel}.`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const target = schemaAlias(world, ctx, call.text)
|
||||
const result = walkSchemaExpr(world, target.ctx, target.expr, where, violations, new Set([...aliases, identity]))
|
||||
keys.push(...result.keys)
|
||||
composes.push(...result.composes)
|
||||
} catch (error) {
|
||||
violations.push(`${where}: ${error instanceof Error ? error.message : String(error)}.`)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) {
|
||||
violations.push(`${where}: schema expression is not a statically walkable schemastery call.`)
|
||||
return
|
||||
@@ -475,7 +569,7 @@ function walkSchemaExpr(
|
||||
const imp = ctx.imports.get(part.expression.text)
|
||||
if (imp && !imp.specifier.startsWith('.')) { composes.push(imp.specifier); continue }
|
||||
}
|
||||
if (ts.isCallExpression(part)) { visit(part); continue }
|
||||
if (ts.isCallExpression(part) || ts.isIdentifier(part)) { visit(part); continue }
|
||||
violations.push(`${where}: intersect element '${part.getText(ctx.sf)}' is neither a workspace plugin's Config nor an inline schema call.`)
|
||||
}
|
||||
return
|
||||
@@ -485,7 +579,8 @@ function walkSchemaExpr(
|
||||
if (method === 'union' && call.arguments[0] && ts.isArrayLiteralExpression(call.arguments[0])) {
|
||||
for (const el of call.arguments[0].elements) {
|
||||
const part = unwrapExpr(el)
|
||||
if (ts.isCallExpression(part)) { visit(part); continue }
|
||||
if (ts.isCallExpression(part) || ts.isIdentifier(part)) { visit(part); continue }
|
||||
violations.push(`${where}: union element '${part.getText(ctx.sf)}' is not a schema call or const alias.`)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -582,10 +677,11 @@ export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] {
|
||||
// Pre-pass: package name → dir, so schema-path lookups can follow
|
||||
// workspace-package imports while individual packages are still being walked.
|
||||
const pkgDirByName = new Map<string, string>()
|
||||
const pkgExportsByName = new Map<string, unknown>()
|
||||
const manifests: { dir: string; pkg: string }[] = []
|
||||
for (const manifestRel of globSync('packages/*/*/package.json', { cwd: scanRoot }).map(path => path.split(sep).join('/')).sort()) {
|
||||
const dir = manifestRel.slice(0, -'/package.json'.length)
|
||||
const manifest = JSON.parse(readFileSync(resolve(scanRoot, manifestRel), 'utf8')) as { name?: string; os?: string[]; cpu?: string[] }
|
||||
const manifest = JSON.parse(readFileSync(resolve(scanRoot, manifestRel), 'utf8')) as { name?: string; os?: string[]; cpu?: string[]; exports?: unknown }
|
||||
const pkg = manifest.name
|
||||
if (!pkg) {
|
||||
violations.push(`${manifestRel} has no "name".`)
|
||||
@@ -597,9 +693,10 @@ export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] {
|
||||
continue
|
||||
}
|
||||
pkgDirByName.set(pkg, dir)
|
||||
pkgExportsByName.set(pkg, manifest.exports)
|
||||
manifests.push({ dir, pkg })
|
||||
}
|
||||
const world: World = { scanRoot, cache, pkgDirByName }
|
||||
const world: World = { scanRoot, cache, pkgDirByName, pkgExportsByName }
|
||||
|
||||
for (const { dir, pkg } of manifests) {
|
||||
const entryRel = `${dir}/src/index.ts`
|
||||
@@ -719,7 +816,7 @@ export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] {
|
||||
// Statically walk the runtime schema (when one exists) for the subset check.
|
||||
const schemaExpr = findSchemaExpr(ctx, pluginClass)
|
||||
if (schemaExpr) {
|
||||
const { keys, composes } = walkSchemaExpr(ctx, unwrapExpr(schemaExpr), `${pkg} (${entryRel})`, violations)
|
||||
const { keys, composes } = walkSchemaExpr(world, ctx, unwrapExpr(schemaExpr), `${pkg} (${entryRel})`, violations)
|
||||
entry.schemaKeys = keys
|
||||
entry.schemaComposes = composes
|
||||
} else {
|
||||
|
||||
@@ -54,6 +54,7 @@ export { REGION_BEGIN, REGION_END }
|
||||
* errors, so the partition can never silently drift from the service API.
|
||||
*/
|
||||
export const SERVICE_PAGE: Record<string, string> = {
|
||||
mcpResources: 'mcp.md',
|
||||
agentLoop: 'core.md',
|
||||
mcpServers: 'tools.md',
|
||||
agentDefaultModel: 'core.md',
|
||||
@@ -64,7 +65,9 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
shell: 'shell.md',
|
||||
shellEnv: 'shell.md',
|
||||
clientModules: 'client-modules.md',
|
||||
codeRuntime: 'code-runtime.md',
|
||||
ptcRuntime: 'ptc-runtime.md',
|
||||
browserUse: 'browser-use.md',
|
||||
computerUse: 'computer-use.md',
|
||||
commands: 'commands.md',
|
||||
compaction: 'compaction.md',
|
||||
cordisInspect: 'extensions.md',
|
||||
@@ -75,7 +78,6 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
directoryPicker: 'workspace.md',
|
||||
deepseekLlmApiExtensions: 'llm-streaming.md',
|
||||
dynamicCordisRunner: 'extensions.md',
|
||||
e2b: 'subprocess.md',
|
||||
fileUploads: 'attachment.md',
|
||||
fileReferences: 'session-reference.md',
|
||||
fs: 'filesystem.md',
|
||||
@@ -86,11 +88,13 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
llm: 'llm-streaming.md',
|
||||
lsp: 'lsp.md',
|
||||
messageFeedback: 'feedback.md',
|
||||
sessionFeedback: 'feedback.md',
|
||||
permissionPresets: 'permission-presets.md',
|
||||
planMode: 'plan.md',
|
||||
terminals: 'terminal.md',
|
||||
sandbox: 'sandbox.md',
|
||||
sandboxPolicy: 'sandbox.md',
|
||||
ssh: 'ssh.md',
|
||||
sessionPersistence: 'persistence.md',
|
||||
sessionQuery: 'session-query.md',
|
||||
sessionFileReferences: 'session-reference.md',
|
||||
@@ -124,6 +128,8 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
webhookRuntime: 'webhook.md',
|
||||
workspaceRegistry: 'workspace.md',
|
||||
workspaceController: 'workspace.md',
|
||||
workspaceFiles: 'workspace.md',
|
||||
terminalController: 'workspace.md',
|
||||
directoryPickerController: 'workspace.md',
|
||||
}
|
||||
|
||||
@@ -146,7 +152,7 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
* to a model as `cordis_runtime_inspect what:"client"`).
|
||||
*/
|
||||
export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
agent: 'not a service: the DX accessor field on Agent.ctx (root accessor defaulting to undefined) — docs/subsystems/core.md owns the Agent handle',
|
||||
webTerminals: 'client-side terminal view models — packages/api/terminal-controller/README.md owns the API',
|
||||
appReady: 'not a service: launcher-provided successful-startup signal — packages/boot/cmdline/README.md owns the launcher contract',
|
||||
appExit: 'not a service: launcher-provided bounded process-exit callback — packages/boot/cmdline/README.md owns the launcher contract',
|
||||
cmdlineArgs: 'not a service: launcher-provided immutable app argument accessor — packages/boot/cmdline/README.md owns the launcher contract',
|
||||
@@ -176,6 +182,10 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
slots: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API',
|
||||
theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the API',
|
||||
workspaces: 'client-side interface-typed browser service — packages/api/workspace-controller/README.md owns the API',
|
||||
resources: 'client-side resource model (protocol providers, pins, live sources) — packages/client/resources/README.md owns the API',
|
||||
sidebarRight: 'client-side right-Sidebar navigation face — packages/client/ui-sidebar-right/README.md owns the API',
|
||||
sidebarRightTabs: 'client-side right-Sidebar tab-type registry — packages/client/ui-sidebar-right/README.md owns the API',
|
||||
documentPreviews: 'client-side document renderer registry — docs/subsystems/sidebar-right.md owns the API',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,6 +202,7 @@ export const EVENT_SCOPE_PAGE: Record<string, string> = {
|
||||
'api-session': 'session.md',
|
||||
'approval': 'approval.md',
|
||||
'commands': 'commands.md',
|
||||
'compaction': 'compaction.md',
|
||||
'cordis': 'extensions.md',
|
||||
'authorization': 'credentials.md',
|
||||
'credentials': 'credentials.md',
|
||||
@@ -200,6 +211,7 @@ export const EVENT_SCOPE_PAGE: Record<string, string> = {
|
||||
'goal': 'goal.md',
|
||||
'llm': 'llm-streaming.md',
|
||||
'mcp-manager': 'tools.md',
|
||||
'permission-presets': 'permission-presets.md',
|
||||
'session': 'session.md',
|
||||
'settings': 'settings.md',
|
||||
'skills': 'skills.md',
|
||||
@@ -242,6 +254,8 @@ export const EVENT_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
* appear on more than one page.
|
||||
*/
|
||||
export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
BrowserUseProviderName: 'browser-use.md',
|
||||
ComputerUseProviderName: 'computer-use.md',
|
||||
Agent: 'core.md',
|
||||
AgentCancelCause: 'core.md',
|
||||
AgentFactory: 'core.md',
|
||||
@@ -287,6 +301,8 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
MessageFeedbackFailure: 'feedback.md',
|
||||
MessageFeedbackItem: 'feedback.md',
|
||||
MessageFeedbackListRequest: 'feedback.md',
|
||||
SessionFeedbackRecordRequest: 'feedback.md',
|
||||
SessionFeedbackRecordResult: 'feedback.md',
|
||||
MessageFeedbackListResult: 'feedback.md',
|
||||
MessageFeedbackListValue: 'feedback.md',
|
||||
MessageFeedbackNoteBlank: 'feedback.md',
|
||||
@@ -347,6 +363,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
SessionSelectModelRequest: 'session.md',
|
||||
SessionSelectModelValue: 'session.md',
|
||||
SessionSummary: 'session.md',
|
||||
SessionMessageProjection: 'session.md',
|
||||
SessionUpdateQueueRequest: 'session.md',
|
||||
SessionUpdateQueueValue: 'session.md',
|
||||
EncodedFileUploadRequest: 'attachment.md',
|
||||
@@ -373,7 +390,8 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
SaveFileStreamAttachment: 'attachment.md',
|
||||
ImageAttachmentAccess: 'llm-streaming.md',
|
||||
ImageAttachmentRef: 'attachment.md',
|
||||
ImageRequestPolicy: 'attachment.md',
|
||||
ImageRequestTarget: 'attachment.md',
|
||||
ProjectedDimensions: 'attachment.md',
|
||||
PromptContentPart: 'attachment.md',
|
||||
RequestImageAttachment: 'attachment.md',
|
||||
SaveImageAttachment: 'attachment.md',
|
||||
@@ -389,9 +407,12 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
SubprocessOutputReader: 'subprocess.md',
|
||||
SubprocessSpawnSpec: 'subprocess.md',
|
||||
SubprocessTerminalHandle: 'subprocess.md',
|
||||
SubprocessTerminalEnvironment: 'subprocess.md',
|
||||
SubprocessTerminalSpawnSpec: 'subprocess.md',
|
||||
CodeRunRequest: 'code-runtime.md',
|
||||
CodeRunResult: 'code-runtime.md',
|
||||
PtcRunRequest: 'ptc-runtime.md',
|
||||
PtcRunSpec: 'ptc-runtime.md',
|
||||
PtcRunSandbox: 'ptc-runtime.md',
|
||||
PtcRunResult: 'ptc-runtime.md',
|
||||
CompactionResult: 'compaction.md',
|
||||
CompactionTrigger: 'compaction.md',
|
||||
PruneResult: 'compaction.md',
|
||||
@@ -410,6 +431,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
CreateGoalRequest: 'goal.md',
|
||||
EditGoalRequest: 'goal.md',
|
||||
GoalBlockReason: 'goal.md',
|
||||
GoalActivationChanged: 'goal.md',
|
||||
GoalChanged: 'goal.md',
|
||||
GoalRef: 'goal.md',
|
||||
GoalView: 'goal.md',
|
||||
@@ -460,6 +482,8 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
TerminalSpawnRequest: 'terminal.md',
|
||||
TerminalSpawnResult: 'terminal.md',
|
||||
SandboxPolicyRequest: 'sandbox.md',
|
||||
SshConnection: 'ssh.md',
|
||||
SshStreamEndpoint: 'ssh.md',
|
||||
ScopeKey: 'scope.md',
|
||||
Scoped: 'scope.md',
|
||||
EpochHeader: 'session.md',
|
||||
@@ -608,6 +632,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
WorkflowRun: 'workflow.md',
|
||||
VerifiedWebhookDelivery: 'webhook.md',
|
||||
WebhookRule: 'webhook.md',
|
||||
PermissionCatalog: 'permission-presets.md',
|
||||
PresetOption: 'permission-presets.md',
|
||||
PresetSpec: 'permission-presets.md',
|
||||
InvariantInstaller: 'invariants.md',
|
||||
@@ -632,6 +657,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
WorkspaceInsertSessionBeforeRequest: 'workspace.md',
|
||||
WorkspaceOrderValue: 'workspace.md',
|
||||
WorkspaceRenameRequest: 'workspace.md',
|
||||
WorkspaceUnarchiveSessionRequest: 'workspace.md',
|
||||
WorkspaceValue: 'workspace.md',
|
||||
ClientArtifactBaseline: 'client-modules.md',
|
||||
WebBootGraph: 'client-modules.md',
|
||||
@@ -676,11 +702,15 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
|
||||
'ReadonlyMap',
|
||||
'Request',
|
||||
'Response',
|
||||
'ReturnType',
|
||||
'Uint8Array',
|
||||
])
|
||||
|
||||
/** Project types deliberately documented outside the subsystems catalog. */
|
||||
export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
McpResourceProvider: 'scoped resource provider is owned by packages/mcp/mcp-resources/README.md',
|
||||
'z.ZodType': 'Zod response validation API is owned by https://zod.dev/packages/zod',
|
||||
Socket: 'Node.js byte stream API is owned by https://nodejs.org/api/net.html#class-netsocket',
|
||||
z: 'schemastery schema constructor is owned by vendor/schemastery (vendored upstream)',
|
||||
BeginCommandRequest: 'event-local request contract is owned by packages/client/ui-input-trigger/src/types.ts',
|
||||
InsertReferenceRequest: 'event-local request contract is owned by packages/client/ui-input-trigger/src/types.ts',
|
||||
@@ -750,17 +780,31 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
InvariantRegistration: 'service-local lifecycle handle is owned by packages/runtime-diagnostics/invariants/README.md',
|
||||
JsonValue: 'JSON value union is owned by packages/core/session/src/json.ts',
|
||||
KnobState: 'projection unit state fields are owned by packages/interaction/permission-presets/README.md',
|
||||
PermissionSelect: 'permissions projection payload is owned by packages/interaction/permission-presets/src/types.ts',
|
||||
PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md',
|
||||
RequestRunId: 'dynamic-package payload contract is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
RpcReceipt: 'carrier-layer receipt is owned by packages/client/connection/src/rpc.ts',
|
||||
Sandbox: 'external E2B SDK handle is owned by packages/e2b/e2b/README.md',
|
||||
SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts',
|
||||
SubagentRunEndInfo: 'event payload contract is owned by packages/subagent/subagent/src/types.ts',
|
||||
SubagentRunInfo: 'event payload contract is owned by packages/subagent/subagent/src/types.ts',
|
||||
WorkflowAgentEndInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
||||
WorkflowAgentInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
||||
WorkflowResultInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
||||
WorkspaceFileScope: 'Host workspace file lookup contract is owned by packages/api/workspace-files/README.md',
|
||||
WorkspaceByteRange: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',
|
||||
WorkspaceDirectoryListing: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',
|
||||
WorkspaceFileBytes: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',
|
||||
WorkspaceFileChange: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',
|
||||
WorkspaceFileWatchFrame: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',
|
||||
WorkspaceFileRange: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',
|
||||
WorkspaceFileStat: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',
|
||||
WorkspaceFileText: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',
|
||||
TerminalShell: 'Browser terminal shell profiles are owned by packages/api/terminal-controller/README.md',
|
||||
TerminalEnvironment: 'Browser terminal environment fields are owned by packages/api/terminal-controller/README.md',
|
||||
WebTerminalInfo: 'Browser terminal metadata is owned by packages/api/terminal-controller/README.md',
|
||||
TerminalCreateRequest: 'Browser terminal allocation fields are owned by packages/api/terminal-controller/README.md',
|
||||
TerminalAttachmentId: 'Browser terminal input ownership is owned by packages/api/terminal-controller/README.md',
|
||||
TerminalFrame: 'Browser terminal stream frames are owned by packages/api/terminal-controller/README.md',
|
||||
WebTerminalId: 'Browser terminal identity is owned by packages/api/terminal-controller/README.md',
|
||||
}
|
||||
|
||||
/** Repository data policy consumed by the Cordis catalog projector. */
|
||||
|
||||
@@ -10,16 +10,16 @@ const root = resolve(import.meta.dirname, '..')
|
||||
const CLIENT_OUT = 'packages/extensions/cordis-client-runner/src/client/api-catalog.ts'
|
||||
|
||||
const CLIENT_SERVICES: Readonly<Record<string, readonly string[]>> = {
|
||||
layout: ['toggleSidebar', 'openDetails', 'closeDetails'],
|
||||
layout: ['selectPanel', 'beginNavigation', 'toggleSidebar', 'openRightbar', 'closeRightbar'],
|
||||
locale: ['getLocale', 'getSnapshot', 'subscribe', 'setLocale', 'addLanguage', 'register', 'bind'],
|
||||
sessions: ['open', 'openSubagent', 'setSubagentCatalogOpen', 'refreshSubagents', 'search', 'fork', 'scope', 'binding'],
|
||||
slots: ['register', 'inject'],
|
||||
theme: ['getTheme', 'setTheme', 'setFontSize', 'register', 'overrideTokens'],
|
||||
uiWorkspace: [
|
||||
'connectWorkspace', 'startSession', 'archiveSession', 'pickDirectory', 'listDirectory',
|
||||
'createDirectory',
|
||||
'openSession', 'openWorkspace', 'forkSession', 'connectWorkspace', 'startSession', 'archiveSession', 'unarchiveSession',
|
||||
'pickDirectory', 'listDirectory', 'createDirectory',
|
||||
],
|
||||
workspaces: ['create', 'rename', 'delete', 'insertSessionBefore', 'archiveSession'],
|
||||
workspaces: ['create', 'rename', 'delete', 'insertSessionBefore', 'archiveSession', 'unarchiveSession'],
|
||||
}
|
||||
|
||||
const CLIENT_EVENTS = new Set([
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/** Production dependency selection and offline catalog freshness regressions. */
|
||||
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { runCommandWithTimeout } from './benchmark-npm-resolution.ts'
|
||||
import {
|
||||
collectDependencies, computeDependencyCatalog, createNpmResolutionEnvironment,
|
||||
deduplicateDependencies, isDependencyCatalogCurrent,
|
||||
} from './gen-dependency-catalog.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 3 })
|
||||
})
|
||||
|
||||
function lockfile() {
|
||||
const lock = {
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'': { dependencies: { '@deepseek-ai/dsh': 'latest' } },
|
||||
'node_modules/@deepseek-ai/dsh': {
|
||||
version: '1.0.0', dependencies: { library: '^2.0.0', alias: 'npm:[email protected]' },
|
||||
optionalDependencies: { unavailable: '1.0.0' },
|
||||
},
|
||||
'node_modules/library': { version: '1.0.0' },
|
||||
'node_modules/@deepseek-ai/dsh/node_modules/library': { version: '2.0.0', dependencies: { plugin: '*' } },
|
||||
'node_modules/alias': { name: 'original', version: '1.0.0' },
|
||||
'node_modules/plugin': {
|
||||
version: '1.0.0', dependencies: { library: '^1.0.0' }, peerDependencies: { peer: '*' },
|
||||
optionalDependencies: { 'native-linux': '*', 'native-helper': '*' },
|
||||
},
|
||||
'node_modules/peer': { version: '3.0.0', peer: true },
|
||||
'node_modules/native-linux': { version: '1.0.0', optional: true, os: ['linux'], cpu: ['arm64'], libc: ['glibc'] },
|
||||
'node_modules/native-helper': { version: '1.0.0', optional: true, devOptional: true },
|
||||
'node_modules/test-only': { version: '1.0.0', dev: true },
|
||||
},
|
||||
}
|
||||
for (const [location, entry] of Object.entries(lock.packages)) {
|
||||
if (location !== '') Object.assign(entry, { resolved: `https://registry.npmjs.org/${location}/package.tgz` })
|
||||
}
|
||||
return lock
|
||||
}
|
||||
|
||||
function fixture(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-dependency-catalog-test-'))
|
||||
roots.push(root)
|
||||
mkdirSync(join(root, 'scripts/dependency-catalog'), { recursive: true })
|
||||
writeFileSync(join(root, 'scripts/dependency-catalog/package-lock.json'), JSON.stringify(lockfile()))
|
||||
writeFileSync(join(root, 'scripts/dependency-catalog/resolution.json'), JSON.stringify({
|
||||
capturedAt: '2026-09-12T00:00:00.000Z', npm: '11.17.0', node: '24.19.0',
|
||||
platform: 'darwin', arch: 'arm64', registry: 'https://registry.npmjs.org/', installStrategy: 'hoisted',
|
||||
}))
|
||||
return root
|
||||
}
|
||||
|
||||
describe('published npm dependency catalog', () => {
|
||||
it('retains nested versions, aliases, peers, and optional candidates while excluding development-only packages', () => {
|
||||
const { version, rows } = collectDependencies(lockfile())
|
||||
expect(version).toBe('1.0.0')
|
||||
expect(rows).toHaveLength(7)
|
||||
expect(rows.filter(row => row.direct).map(row => [row.name, row.version])).toEqual([
|
||||
['library', '2.0.0'], ['original', '1.0.0'],
|
||||
])
|
||||
expect(rows.find(row => row.name === 'peer')).toMatchObject({ peer: true, optional: false })
|
||||
expect(rows.find(row => row.name === 'native-linux')).toMatchObject({
|
||||
optional: true, os: ['linux'], cpu: ['arm64'], libc: ['glibc'],
|
||||
})
|
||||
expect(rows.find(row => row.name === 'native-helper')).toMatchObject({ optional: true })
|
||||
expect(rows.map(row => row.name)).not.toContain('test-only')
|
||||
expect(rows.map(row => row.name)).not.toContain('unavailable')
|
||||
})
|
||||
|
||||
it('does not silently produce an incomplete catalog from invalid or unrelated resolutions', () => {
|
||||
expect(() => collectDependencies({ lockfileVersion: 2 })).toThrow('lockfileVersion 3')
|
||||
expect(() => collectDependencies({ lockfileVersion: 3, packages: {} })).toThrow('consumer must be an object')
|
||||
const input = lockfile()
|
||||
input.packages[''].dependencies['@deepseek-ai/dsh'] = 'next'
|
||||
expect(() => collectDependencies(input)).toThrow('must request only @deepseek-ai/dsh@latest')
|
||||
const missing = lockfile()
|
||||
Reflect.deleteProperty(missing.packages, 'node_modules/alias')
|
||||
expect(() => collectDependencies(missing)).toThrow('missing direct dependency alias')
|
||||
const linked = lockfile()
|
||||
Object.assign(linked.packages['node_modules/library'], { link: true })
|
||||
expect(() => collectDependencies(linked)).toThrow('is a local link')
|
||||
})
|
||||
|
||||
it('sorts packages independently of lockfile object order and keeps duplicate installation locations', () => {
|
||||
const input = lockfile()
|
||||
const reordered = { ...input, packages: Object.fromEntries(Object.entries(input.packages).reverse()) }
|
||||
expect(collectDependencies(reordered)).toEqual(collectDependencies(input))
|
||||
Object.assign(input.packages, {
|
||||
'node_modules/plugin/node_modules/library': {
|
||||
version: '1.0.0', resolved: 'https://registry.npmjs.org/library/-/library-1.0.0.tgz',
|
||||
},
|
||||
})
|
||||
expect(collectDependencies(input).rows.filter(row => row.name === 'library' && row.version === '1.0.0')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('deduplicates identical versions without losing installation locations or optional flags', () => {
|
||||
const input = lockfile()
|
||||
Object.assign(input.packages, {
|
||||
'node_modules/plugin/node_modules/library': {
|
||||
version: '1.0.0', optional: true, resolved: 'https://registry.npmjs.org/library/-/library-1.0.0.tgz',
|
||||
},
|
||||
})
|
||||
const rows = collectDependencies(input).rows
|
||||
const packages = deduplicateDependencies(rows)
|
||||
const repeated = packages.filter(entry => entry.name === 'library' && entry.version === '1.0.0')
|
||||
expect(repeated).toHaveLength(1)
|
||||
expect(repeated[0]?.installations).toEqual([
|
||||
{ location: 'node_modules/library', direct: false, optional: false, peer: false },
|
||||
{ location: 'node_modules/plugin/node_modules/library', direct: false, optional: true, peer: false },
|
||||
])
|
||||
expect(packages.filter(entry => entry.name === 'library').map(entry => entry.version)).toEqual(['1.0.0', '2.0.0'])
|
||||
expect(deduplicateDependencies([...rows].reverse())).toEqual(packages)
|
||||
expect(packages.flatMap(entry => entry.installations)).toHaveLength(rows.length)
|
||||
})
|
||||
|
||||
it('rejects missing, edited, or stale JSON without rewriting it', () => {
|
||||
const root = fixture()
|
||||
const output = computeDependencyCatalog(root)
|
||||
const path = join(root, 'docs/dependency-catalog.json')
|
||||
expect(isDependencyCatalogCurrent(root)).toBe(false)
|
||||
mkdirSync(join(root, 'docs'))
|
||||
writeFileSync(path, output)
|
||||
expect(isDependencyCatalogCurrent(root)).toBe(true)
|
||||
writeFileSync(path, `${output}stale\n`)
|
||||
expect(isDependencyCatalogCurrent(root)).toBe(false)
|
||||
expect(readFileSync(path, 'utf8')).toBe(`${output}stale\n`)
|
||||
writeFileSync(path, output)
|
||||
const updated = lockfile()
|
||||
updated.packages['node_modules/peer'].version = '3.1.0'
|
||||
writeFileSync(join(root, 'scripts/dependency-catalog/package-lock.json'), JSON.stringify(updated))
|
||||
expect(isDependencyCatalogCurrent(root)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects non-public package sources and incorrect registry metadata', () => {
|
||||
const input = lockfile()
|
||||
Object.assign(input.packages['node_modules/library'], { resolved: 'https://private.invalid/library.tgz' })
|
||||
expect(() => collectDependencies(input)).toThrow('not resolved from the public npm registry')
|
||||
const root = fixture()
|
||||
const metadataPath = join(root, 'scripts/dependency-catalog/resolution.json')
|
||||
writeFileSync(metadataPath, JSON.stringify({ registry: 'https://private.invalid/', installStrategy: 'hoisted' }))
|
||||
expect(() => computeDependencyCatalog(root)).toThrow('expected the public npm registry and hoisted install strategy')
|
||||
})
|
||||
|
||||
it('isolates scoped registries, resolver settings, and caches from user, global, and environment configuration', async () => {
|
||||
const root = fixture()
|
||||
const userConfig = join(root, 'user.npmrc')
|
||||
const globalConfig = join(root, 'global.npmrc')
|
||||
writeFileSync(userConfig, '@deepseek-ai:registry=https://user-override.invalid/\nstrict-peer-deps=true\n')
|
||||
writeFileSync(globalConfig, '@other:registry=https://global-override.invalid/\nprefer-dedupe=true\n')
|
||||
const inherited: NodeJS.ProcessEnv = {
|
||||
...Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.toLowerCase().startsWith('npm_config_'))),
|
||||
npm_config_userconfig: userConfig,
|
||||
NPM_CONFIG_GLOBALCONFIG: globalConfig,
|
||||
NPM_CONFIG_INSTALL_STRATEGY: 'nested',
|
||||
NPM_CONFIG_OFFLINE: 'true',
|
||||
NPM_CONFIG_CACHE: join(root, 'inherited-cache'),
|
||||
NPM_CONFIG_USER_AGENT: 'inherited-agent',
|
||||
}
|
||||
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
||||
const args = ['config', 'list', '--json', '--registry=https://registry.npmjs.org/', '--loglevel=error']
|
||||
const before = await runCommandWithTimeout(npm, args, { cwd: root, env: inherited, timeoutMs: 30_000 })
|
||||
expect(before).toMatchObject({ status: 0, signal: null, timedOut: false })
|
||||
expect(JSON.parse(before.output)).toMatchObject({
|
||||
'@deepseek-ai:registry': 'https://user-override.invalid/', 'install-strategy': 'nested',
|
||||
})
|
||||
const isolated = createNpmResolutionEnvironment(root, inherited)
|
||||
const after = await runCommandWithTimeout(npm, args, { cwd: root, env: isolated, timeoutMs: 30_000 })
|
||||
expect(after).toMatchObject({ status: 0, signal: null, timedOut: false })
|
||||
const settings = JSON.parse(after.output) as Record<string, unknown>
|
||||
expect(settings).toMatchObject({
|
||||
registry: 'https://registry.npmjs.org/', '@deepseek-ai:registry': 'https://registry.npmjs.org/',
|
||||
'install-strategy': 'hoisted', 'strict-peer-deps': false, 'prefer-dedupe': false, offline: false,
|
||||
cache: join(root, '.npm-cache'), userconfig: join(root, '.npmrc-user'), globalconfig: join(root, '.npmrc-global'),
|
||||
})
|
||||
expect(settings['@other:registry']).toBeUndefined()
|
||||
expect(isolated['NPM_CONFIG_USER_AGENT']).toBeUndefined()
|
||||
expect(inherited['npm_config_userconfig']).toBe(userConfig)
|
||||
})
|
||||
|
||||
it('keeps the checked-in JSON synchronized with its recorded npm resolution', () => {
|
||||
expect(isDependencyCatalogCurrent(resolve(import.meta.dirname, '..'))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,304 @@
|
||||
/** Generate the README npx dependency catalog from a recorded public npm resolution. */
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { runCommandWithTimeout } from './benchmark-npm-resolution.ts'
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..')
|
||||
const PACKAGE = '@deepseek-ai/dsh'
|
||||
const ENTRY = `node_modules/${PACKAGE}`
|
||||
const REGISTRY = 'https://registry.npmjs.org/'
|
||||
const LOCK = 'scripts/dependency-catalog/package-lock.json'
|
||||
const METADATA = 'scripts/dependency-catalog/resolution.json'
|
||||
const OUT = 'docs/dependency-catalog.json'
|
||||
|
||||
/** npm lockfile fields used to describe an installed package or optional candidate. */
|
||||
interface PackageEntry {
|
||||
name?: string
|
||||
version: string
|
||||
dependencies?: Record<string, string>
|
||||
optionalDependencies?: Record<string, string>
|
||||
dev?: boolean
|
||||
optional?: boolean
|
||||
peer?: boolean
|
||||
os?: string[]
|
||||
cpu?: string[]
|
||||
libc?: string[]
|
||||
}
|
||||
|
||||
/** One package location in npm's resolved tree; duplicate versions retain their locations. */
|
||||
export interface DependencyRow {
|
||||
readonly location: string
|
||||
readonly name: string
|
||||
readonly version: string
|
||||
readonly direct: boolean
|
||||
readonly optional: boolean
|
||||
readonly peer: boolean
|
||||
readonly os?: readonly string[]
|
||||
readonly cpu?: readonly string[]
|
||||
readonly libc?: readonly string[]
|
||||
}
|
||||
|
||||
interface ResolutionMetadata {
|
||||
capturedAt: string
|
||||
npm: string
|
||||
node: string
|
||||
platform: string
|
||||
arch: string
|
||||
registry: string
|
||||
installStrategy: string
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error(`dependency-catalog: ${label} must be an object`)
|
||||
}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0 || /[\r\n]/.test(value)) {
|
||||
throw new Error(`dependency-catalog: ${label} must be a nonempty single-line value`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function parseEntry(value: unknown, location: string): PackageEntry {
|
||||
const raw = record(value, location)
|
||||
const entry: PackageEntry = { version: string(raw['version'], `${location} version`) }
|
||||
if (raw['name'] !== undefined) entry.name = string(raw['name'], `${location} name`)
|
||||
if (raw['link'] === true) throw new Error(`dependency-catalog: ${location} is a local link`)
|
||||
const source = new URL(string(raw['resolved'], `${location} resolved`))
|
||||
if (source.origin !== new URL(REGISTRY).origin || source.username !== '' || source.password !== '') {
|
||||
throw new Error(`dependency-catalog: ${location} is not resolved from the public npm registry`)
|
||||
}
|
||||
for (const key of ['dev', 'optional', 'peer'] as const) {
|
||||
if (raw[key] === undefined) continue
|
||||
if (typeof raw[key] !== 'boolean') throw new Error(`dependency-catalog: ${location} ${key} must be boolean`)
|
||||
entry[key] = raw[key]
|
||||
}
|
||||
for (const key of ['dependencies', 'optionalDependencies'] as const) {
|
||||
if (raw[key] === undefined) continue
|
||||
entry[key] = Object.fromEntries(Object.entries(record(raw[key], `${location} ${key}`))
|
||||
.map(([name, spec]) => [string(name, 'dependency name'), string(spec, `${location} ${name}`)]))
|
||||
}
|
||||
for (const key of ['os', 'cpu', 'libc'] as const) {
|
||||
const values = raw[key]
|
||||
if (values === undefined) continue
|
||||
if (!Array.isArray(values)) throw new Error(`dependency-catalog: ${location} ${key} must be an array`)
|
||||
entry[key] = values.map(value => string(value, `${location} ${key}`))
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
/**
|
||||
* Read npm's resolved production tree without substituting workspace or development dependencies.
|
||||
* @param input - Parsed npm lockfile v3 from a consumer with only dsh as its dependency.
|
||||
* @returns The CLI version and sorted package locations, excluding the synthetic consumer and CLI itself.
|
||||
*/
|
||||
export function collectDependencies(input: unknown): { version: string; rows: DependencyRow[] } {
|
||||
const lock = record(input, 'lockfile')
|
||||
if (lock['lockfileVersion'] !== 3) throw new Error('dependency-catalog: expected npm lockfileVersion 3')
|
||||
const packages = record(lock['packages'], 'packages')
|
||||
const consumer = record(packages[''], 'consumer')
|
||||
const requested = record(consumer['dependencies'], 'consumer dependencies')
|
||||
if (Object.keys(requested).length !== 1 || requested[PACKAGE] !== 'latest') {
|
||||
throw new Error(`dependency-catalog: consumer must request only ${PACKAGE}@latest`)
|
||||
}
|
||||
const entries = new Map(Object.entries(packages).filter(([location]) => location !== '')
|
||||
.map(([location, entry]) => [location, parseEntry(entry, location)]))
|
||||
const cli = entries.get(ENTRY)
|
||||
if (cli === undefined || cli.dev) throw new Error(`dependency-catalog: missing production ${PACKAGE}`)
|
||||
const direct = new Set<string>()
|
||||
for (const name of Object.keys({ ...cli.dependencies, ...cli.optionalDependencies })) {
|
||||
const location = [`${ENTRY}/node_modules/${name}`, `node_modules/${name}`]
|
||||
.find(candidate => entries.has(candidate))
|
||||
if (location === undefined) {
|
||||
if (cli.optionalDependencies?.[name] !== undefined) continue
|
||||
throw new Error(`dependency-catalog: missing direct dependency ${name}`)
|
||||
}
|
||||
direct.add(location)
|
||||
}
|
||||
const rows: DependencyRow[] = []
|
||||
for (const [location, entry] of entries) {
|
||||
if (location === ENTRY || entry.dev) continue
|
||||
const name = location.split('node_modules/').at(-1)
|
||||
if (name === undefined || !/^(@[^/]+\/)?[^/]+$/.test(name)) {
|
||||
throw new Error(`dependency-catalog: invalid npm package location ${location}`)
|
||||
}
|
||||
rows.push({
|
||||
location,
|
||||
name: entry.name ?? name,
|
||||
version: entry.version,
|
||||
direct: direct.has(location),
|
||||
optional: entry.optional === true,
|
||||
peer: entry.peer === true,
|
||||
...(entry.os === undefined ? {} : { os: entry.os }),
|
||||
...(entry.cpu === undefined ? {} : { cpu: entry.cpu }),
|
||||
...(entry.libc === undefined ? {} : { libc: entry.libc }),
|
||||
})
|
||||
}
|
||||
if (rows.length === 0) throw new Error('dependency-catalog: empty dependency tree')
|
||||
rows.sort((a, b) => a.name.localeCompare(b.name, 'en')
|
||||
|| a.version.localeCompare(b.version, 'en') || a.location.localeCompare(b.location, 'en'))
|
||||
return { version: cli.version, rows }
|
||||
}
|
||||
|
||||
function readMetadata(scanRoot: string): ResolutionMetadata {
|
||||
const value = record(JSON.parse(readFileSync(resolve(scanRoot, METADATA), 'utf8')), 'resolution metadata')
|
||||
if (value['registry'] !== REGISTRY || value['installStrategy'] !== 'hoisted') {
|
||||
throw new Error('dependency-catalog: expected the public npm registry and hoisted install strategy')
|
||||
}
|
||||
return {
|
||||
capturedAt: string(value['capturedAt'], 'capturedAt'),
|
||||
npm: string(value['npm'], 'npm'),
|
||||
node: string(value['node'], 'node'),
|
||||
platform: string(value['platform'], 'platform'),
|
||||
arch: string(value['arch'], 'arch'),
|
||||
registry: value['registry'],
|
||||
installStrategy: value['installStrategy'],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine identical package versions while retaining each installation's npm flags and platform constraints.
|
||||
* @param rows - Resolved package locations, excluding the consumer and CLI.
|
||||
* @returns One sorted record per package name and version, with sorted installation locations.
|
||||
*/
|
||||
export function deduplicateDependencies(rows: readonly DependencyRow[]): {
|
||||
name: string
|
||||
version: string
|
||||
installations: Omit<DependencyRow, 'name' | 'version'>[]
|
||||
}[] {
|
||||
const packages = new Map<string, {
|
||||
name: string
|
||||
version: string
|
||||
installations: Omit<DependencyRow, 'name' | 'version'>[]
|
||||
}>()
|
||||
for (const { name, version, ...installation } of rows) {
|
||||
const key = JSON.stringify([name, version])
|
||||
const dependency = packages.get(key) ?? { name, version, installations: [] }
|
||||
dependency.installations.push(installation)
|
||||
packages.set(key, dependency)
|
||||
}
|
||||
const sorted = [...packages.values()].sort((a, b) => a.name.localeCompare(b.name, 'en')
|
||||
|| a.version.localeCompare(b.version, 'en'))
|
||||
for (const dependency of sorted) {
|
||||
dependency.installations.sort((a, b) => a.location.localeCompare(b.location, 'en'))
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
|
||||
function renderCatalog(lock: unknown, metadata: ResolutionMetadata): string {
|
||||
const { version, rows } = collectDependencies(lock)
|
||||
return `${JSON.stringify({
|
||||
package: PACKAGE,
|
||||
version,
|
||||
requested: `${PACKAGE}@latest`,
|
||||
resolution: metadata,
|
||||
dependencies: deduplicateDependencies(rows),
|
||||
}, null, 2)}\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the JSON catalog without network access or writes.
|
||||
* @param scanRoot - Repository root containing the recorded npm resolution.
|
||||
* @returns Deterministic JSON with one trailing newline.
|
||||
*/
|
||||
export function computeDependencyCatalog(scanRoot: string = ROOT): string {
|
||||
return renderCatalog(JSON.parse(readFileSync(resolve(scanRoot, LOCK), 'utf8')), readMetadata(scanRoot))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the generated JSON against the recorded npm resolution.
|
||||
* @param scanRoot - Repository root containing the resolution and catalog.
|
||||
* @returns Whether the catalog exists and matches its inputs byte for byte.
|
||||
*/
|
||||
export function isDependencyCatalogCurrent(scanRoot: string = ROOT): boolean {
|
||||
return existsSync(resolve(scanRoot, OUT))
|
||||
&& readFileSync(resolve(scanRoot, OUT), 'utf8') === computeDependencyCatalog(scanRoot)
|
||||
}
|
||||
|
||||
/**
|
||||
* Isolate npm configuration and cache from the maintainer and invoking package manager.
|
||||
* @param temporary - Private consumer directory owned by the caller.
|
||||
* @param inherited - Environment whose non-npm settings remain available to the child.
|
||||
* @returns Complete child environment, including isolated user/global configuration and cache paths.
|
||||
*/
|
||||
export function createNpmResolutionEnvironment(
|
||||
temporary: string,
|
||||
inherited: NodeJS.ProcessEnv = process.env,
|
||||
): NodeJS.ProcessEnv {
|
||||
const userConfig = join(temporary, '.npmrc-user')
|
||||
const globalConfig = join(temporary, '.npmrc-global')
|
||||
writeFileSync(userConfig, '')
|
||||
writeFileSync(globalConfig, '')
|
||||
writeFileSync(join(temporary, '.npmrc'), `registry=${REGISTRY}\n@deepseek-ai:registry=${REGISTRY}\ninstall-strategy=hoisted\n`)
|
||||
return {
|
||||
...Object.fromEntries(Object.entries(inherited).filter(([name]) => !name.toLowerCase().startsWith('npm_config_'))),
|
||||
npm_config_userconfig: userConfig,
|
||||
npm_config_globalconfig: globalConfig,
|
||||
npm_config_cache: join(temporary, '.npm-cache'),
|
||||
npm_config_update_notifier: 'false',
|
||||
}
|
||||
}
|
||||
|
||||
async function runNpm(args: readonly string[], cwd: string, env: NodeJS.ProcessEnv): Promise<string> {
|
||||
const result = await runCommandWithTimeout(process.platform === 'win32' ? 'npm.cmd' : 'npm', args, {
|
||||
cwd, env, timeoutMs: 300_000,
|
||||
})
|
||||
if (result.timedOut || result.status !== 0) {
|
||||
throw new Error(`dependency-catalog: npm failed (timedOut=${result.timedOut}, status=${String(result.status)}): ${result.output}`)
|
||||
}
|
||||
return result.output.trim()
|
||||
}
|
||||
|
||||
async function refreshResolution(): Promise<void> {
|
||||
const temporary = await mkdtemp(join(tmpdir(), 'dsh-dependency-catalog-'))
|
||||
try {
|
||||
writeFileSync(join(temporary, 'package.json'), `${JSON.stringify({
|
||||
name: 'dsh-dependency-catalog', version: '0.0.0', private: true, dependencies: { [PACKAGE]: 'latest' },
|
||||
}, null, 2)}\n`)
|
||||
const environment = createNpmResolutionEnvironment(temporary)
|
||||
const npm = await runNpm(['--version'], temporary, environment)
|
||||
await runNpm([
|
||||
'install', '--package-lock-only', '--lockfile-version=3', '--ignore-scripts', '--no-audit', '--no-fund',
|
||||
'--include=prod', '--include=optional', '--include=peer', '--legacy-peer-deps=false',
|
||||
'--install-strategy=hoisted', '--loglevel=error', `--registry=${REGISTRY}`,
|
||||
], temporary, environment)
|
||||
const lock = readFileSync(join(temporary, 'package-lock.json'), 'utf8')
|
||||
const metadata: ResolutionMetadata = {
|
||||
capturedAt: new Date().toISOString(), npm, node: process.versions.node,
|
||||
platform: process.platform, arch: process.arch, registry: REGISTRY, installStrategy: 'hoisted',
|
||||
}
|
||||
const catalog = renderCatalog(JSON.parse(lock), metadata)
|
||||
mkdirSync(resolve(ROOT, dirname(LOCK)), { recursive: true })
|
||||
writeFileSync(resolve(ROOT, LOCK), lock)
|
||||
writeFileSync(resolve(ROOT, METADATA), `${JSON.stringify(metadata, null, 2)}\n`)
|
||||
writeFileSync(resolve(ROOT, OUT), catalog)
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true, maxRetries: 3 })
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { values } = parseArgs({ options: { check: { type: 'boolean' }, refresh: { type: 'boolean' } } })
|
||||
if (values.check && values.refresh) throw new Error('dependency-catalog: --check cannot refresh the registry record')
|
||||
if (values.refresh) {
|
||||
await refreshResolution()
|
||||
} else if (values.check) {
|
||||
if (!isDependencyCatalogCurrent()) {
|
||||
throw new Error(`dependency-catalog: stale ${OUT}; run pnpm run gen-dependency-catalog`)
|
||||
}
|
||||
console.log('dependency-catalog: JSON matches the recorded npm resolution')
|
||||
return
|
||||
} else {
|
||||
writeFileSync(resolve(ROOT, OUT), computeDependencyCatalog())
|
||||
}
|
||||
console.log(`dependency-catalog: generated ${OUT}`)
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) await main()
|
||||
+78
-23
@@ -72,7 +72,7 @@ const GROUP_ORDER = [
|
||||
'bash',
|
||||
'pty',
|
||||
'sandbox',
|
||||
'e2b',
|
||||
'ssh',
|
||||
'fs',
|
||||
'skill',
|
||||
'compact',
|
||||
@@ -98,6 +98,33 @@ const GROUP_ORDER = [
|
||||
]
|
||||
|
||||
const SERVICE_ROLES: ServiceRole[] = [
|
||||
{
|
||||
key: 'mcpResources',
|
||||
pkg: 'mcp-resources',
|
||||
title: 'Scoped MCP resource access',
|
||||
mode: 'seam',
|
||||
implementations: ['mcp-client'],
|
||||
consumers: ['mcp-resources'],
|
||||
note: 'Connection-owned providers serve shared resource tools in the calling agent scope.',
|
||||
},
|
||||
{
|
||||
key: 'browserUse',
|
||||
pkg: 'browser-use',
|
||||
title: 'Browser-use provider registration',
|
||||
mode: 'seam',
|
||||
implementations: ['experimental-browser-use-playwright-mcp', 'experimental-browser-use-chrome-devtools-mcp', 'experimental-browser-use-stagehand-native'],
|
||||
consumers: ['experimental-browser-use-playwright-mcp', 'experimental-browser-use-chrome-devtools-mcp', 'experimental-browser-use-stagehand-native'],
|
||||
note: 'One provider-owned name per service instance. Providers own their tools and browser resources per live Session; the shared service has no browser operation API.',
|
||||
},
|
||||
{
|
||||
key: 'computerUse',
|
||||
pkg: 'computer-use',
|
||||
title: 'Computer-use provider registration',
|
||||
mode: 'seam',
|
||||
implementations: ['experimental-computer-use-cua-driver-mcp', 'experimental-computer-use-cua-driver-native'],
|
||||
consumers: ['experimental-computer-use-cua-driver-mcp', 'experimental-computer-use-cua-driver-native'],
|
||||
note: 'One provider-owned name per service instance. Each provider also owns its model tools; the service has no common action API, runtime selection, or Session workflow lock.',
|
||||
},
|
||||
{
|
||||
key: 'attachments',
|
||||
pkg: 'attachment',
|
||||
@@ -192,6 +219,20 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
mode: 'core',
|
||||
note: 'Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition.',
|
||||
},
|
||||
{
|
||||
key: 'workspaceFiles',
|
||||
pkg: 'api-workspace-files',
|
||||
title: 'Host workspace file Remote service',
|
||||
mode: 'core',
|
||||
note: 'Serves stat, paged text, byte windows, directory listings, and the change feed for files inside a Session\'s workspace root, confined by lstat, containment, and a stat re-check.',
|
||||
},
|
||||
{
|
||||
key: 'terminalController',
|
||||
pkg: 'api-terminal-controller',
|
||||
title: 'Session interactive terminal Remote controller',
|
||||
mode: 'core',
|
||||
note: 'Owns user terminal processes, default shell resolution and bounded screen recovery through the subprocess provider and typed Remote transport.',
|
||||
},
|
||||
{
|
||||
key: 'workspaceController',
|
||||
pkg: 'api-workspace-controller',
|
||||
@@ -306,6 +347,13 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
mode: 'core',
|
||||
note: 'Owns per-assistant-message feedback in the canonical Session log, target validation, per-item compare-and-set, and the Host unary Remote contract. Feedback stays outside model history; log export follows the consumer policy.',
|
||||
},
|
||||
{
|
||||
key: 'sessionFeedback',
|
||||
pkg: 'command-feedback',
|
||||
title: 'Session-level feedback recorder',
|
||||
mode: 'core',
|
||||
note: 'Records one Session-level remark with its category as a log-only feedback/record event on a live Session through the Host unary Remote contract; the /feedback command shares the same producer.',
|
||||
},
|
||||
{
|
||||
key: 'workspaceRegistry',
|
||||
pkg: 'workspace',
|
||||
@@ -456,19 +504,19 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
note: 'Folds revisioned objective state from the session log and keeps live continuation activation process-local.',
|
||||
},
|
||||
{
|
||||
key: 'e2b',
|
||||
pkg: 'e2b',
|
||||
title: 'E2B sandbox lifecycle owner',
|
||||
key: 'ssh',
|
||||
pkg: 'ssh',
|
||||
title: 'POSIX SSH connection owner',
|
||||
mode: 'core',
|
||||
consumers: ['fs-e2b', 'subprocess-e2b'],
|
||||
note: 'Owns one shared E2B SDK handle, remote working directory, and final sandbox disposition so both fundamental E2B providers inhabit the same Linux runtime.',
|
||||
consumers: ['fs-ssh', 'subprocess-ssh', 'sandbox-ssh'],
|
||||
note: 'Owns one authenticated OpenSSH connection, installed helper identity, independent program streams and disconnect cleanup for the paired remote providers.',
|
||||
},
|
||||
{
|
||||
key: 'subprocess',
|
||||
pkg: 'subprocess',
|
||||
title: 'Subprocess seam',
|
||||
mode: 'seam',
|
||||
implementations: ['subprocess-local', 'subprocess-e2b'],
|
||||
implementations: ['subprocess-local', 'subprocess-ssh'],
|
||||
consumers: ['bash-local', 'bash-sandbox', 'terminal-bash', 'lsp-stdio', 'subagent-acp', 'subagent-codex', 'subagent-claude-code'],
|
||||
note: 'The bash executors, the PTY shell backend, the LSP host, and the out-of-process ACP, Codex, and Claude Code subagent backends spawn through ctx.subprocess; the service owns process coordinates, tree/session lifetime, stdio dispositions, terminal mechanics, and kill escalation.',
|
||||
},
|
||||
@@ -503,7 +551,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'sandbox',
|
||||
title: 'Process-sandbox seam',
|
||||
mode: 'seam',
|
||||
implementations: ['sandbox-local'],
|
||||
implementations: ['sandbox-local', 'sandbox-ssh'],
|
||||
consumers: ['bash-sandbox', 'terminal-bash'],
|
||||
note: 'Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement.',
|
||||
},
|
||||
@@ -534,20 +582,20 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
note: 'User-facing preset table (`workspace-write`/`danger-full-access`) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events.',
|
||||
},
|
||||
{
|
||||
key: 'codeRuntime',
|
||||
pkg: 'code-runtime',
|
||||
title: 'Code-execution seam',
|
||||
key: 'ptcRuntime',
|
||||
pkg: 'ptc-runtime',
|
||||
title: 'PTC execution seam',
|
||||
mode: 'seam',
|
||||
implementations: ['code-runtime-worker-thread', 'experimental-code-runtime-python'],
|
||||
consumers: ['tools'],
|
||||
note: 'Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for PTC mode).',
|
||||
implementations: ['ptc-runtime-node', 'experimental-ptc-runtime-python'],
|
||||
consumers: ['tools', 'workflow-ptc'],
|
||||
note: 'Runs programs against host-provided async bindings; tools owns PTC presentation and workflow-ptc owns workflow orchestration.',
|
||||
},
|
||||
{
|
||||
key: 'fs',
|
||||
pkg: 'fs',
|
||||
title: 'Filesystem provider seam',
|
||||
mode: 'seam',
|
||||
implementations: ['fs-local', 'fs-sandbox', 'fs-e2b'],
|
||||
implementations: ['fs-local', 'fs-sandbox', 'fs-ssh'],
|
||||
consumers: ['tool-fs'],
|
||||
companions: ['fs-observation-policy'],
|
||||
note: 'tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-observation-policy contributes observed-state checks through the fs/* event gate.',
|
||||
@@ -642,7 +690,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'workflow',
|
||||
title: 'Workflow script engine',
|
||||
mode: 'seam',
|
||||
implementations: ['workflow-worker-thread'],
|
||||
implementations: ['workflow-ptc'],
|
||||
consumers: ['tool-workflow', 'tool-ralph'],
|
||||
note: 'One engine per context, as in bash, with no named-provider registry; the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents.',
|
||||
},
|
||||
@@ -1331,23 +1379,30 @@ function renderLifecycle(): string {
|
||||
' Note over Agent,Driver: claim pending next-step input plus one queued prompt',
|
||||
` Driver-->>SDK: ${mermaidCode('agent/inbox/spliced')} pure deletion`,
|
||||
` Driver-->>SDK: ${mermaidCode('agent/inbox/claimed')} { message, turn } per message`,
|
||||
` Driver->>Prompt: ${mermaidCode('system-prompt/assemble')} waterfall`,
|
||||
` Driver->>Hooks: ${mermaidCode('agent/pre-step')} waterfall`,
|
||||
' Hooks-->>Driver: authoritative reject or enter(messages)',
|
||||
' alt proposed step rejected or pre-step failed',
|
||||
' alt proposed step rejected, first batch empty, or pre-step failed',
|
||||
' Driver-->>Driver: claimed batch stays removed, the open turn spends no step',
|
||||
' else enter proposed step',
|
||||
` Driver->>Session: ${mermaidCode('step/start')}`,
|
||||
` Driver->>Hooks: ${mermaidCode('agent/request')} waterfall`,
|
||||
' Driver->>LLM: prepareCall(config, signal)',
|
||||
' Note over Driver,LLM: cancellation during either async phase commits neither system nor users',
|
||||
' Note over Driver,Session: synchronous admission using the prepared call capability',
|
||||
` Driver->>Session: ${mermaidCode('system/message')} ordered per-node reconciliation`,
|
||||
` Driver->>Session: ${mermaidCode('user/message')} per entered message`,
|
||||
` Driver->>Prompt: ${mermaidCode('system-prompt/assemble')} waterfall`,
|
||||
` Driver->>LLM: ${mermaidCode('agent/request')} waterfall, then ${mermaidCode('llm/stream')} waterfall`,
|
||||
` Driver->>Session: ${mermaidCode('request/header')} and ${mermaidCode('request/context')} as needed`,
|
||||
' Driver->>Driver: derive and freeze request from the log',
|
||||
` Driver->>LLM: bound prepared call through ${mermaidCode('llm/stream')} waterfall`,
|
||||
' LLM-->>Driver: StreamChunk*',
|
||||
` Driver-->>SDK: ${mermaidCode('agent/assistant-stream')} chunk*`,
|
||||
' alt final adapter or terminal in-band request failure',
|
||||
` Driver->>Session: ${mermaidCode('assistant/attempt')}`,
|
||||
` Driver-->>SDK: ${mermaidCode('agent/assistant-stream')} committed end`,
|
||||
` Driver->>Session: ${mermaidCode('step/end')}`,
|
||||
` Driver->>Hooks: ${mermaidCode('agent/request-error')} waterfall`,
|
||||
' Hooks-->>Driver: return retry action or preserve the original error',
|
||||
' Note over Driver,LLM: retry in the open step: prepare and reconcile the same rendered assembly without repeating pre-step or users',
|
||||
' else model request succeeded',
|
||||
` Driver->>Session: ${mermaidCode('assistant/message')}`,
|
||||
` Driver-->>SDK: ${mermaidCode('agent/assistant-stream')} committed end`,
|
||||
@@ -1381,7 +1436,7 @@ function renderLifecycle(): string {
|
||||
'',
|
||||
'The `assistant/message` event records every successful provider call, including content-less and `max-tokens` finishes, and embeds the exact compact timed stream. Empty content stays out of derived history. A failed, retried, cancelled, or stream-error attempt that reaches settlement without a surface message records its stream as `assistant/attempt`. Live `agent/assistant-stream` chunk frames are transient; replay reads either durable settlement, and a hard process loss before settlement leaves no durable attempt stream.',
|
||||
'',
|
||||
'`dsh-compaction-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.',
|
||||
'`dsh-compaction-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery runs within the open step and retries only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative. Each retry prepares its call and reconciles the retained rendered assembly before request derivation, without repeating assembly, pre-step, or user admission.',
|
||||
'',
|
||||
'The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages and `startsRequestSeries` unless replacement is intentional. Steering and injected context pass through the same waterfall after a later claim operation takes their next-step batch.',
|
||||
'',
|
||||
@@ -1409,7 +1464,7 @@ function renderToolPipeline(): string {
|
||||
` around["${mermaidCode('tools/execute')} waterfall<br/>timeout, retry, metrics (around dispatch)"]`,
|
||||
' toolBody["Registered tool execute() body"]',
|
||||
` fsGate["${mermaidCode('fs/write-intent')} or ${mermaidCode('fs/edit-intent')}<br/>tool-fs mutations only"]`,
|
||||
` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`,
|
||||
` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/ptc-dispatch')}"]`,
|
||||
` post["${mermaidCode('tools/post-execute')} waterfall<br/>accept, block, replace, add context"]`,
|
||||
' normalized["Registry outer normalization<br/>pipeline/result snapshot throws become isError"]',
|
||||
' finalize["ToolDefinition.finalizeContent<br/>last content-only invariant"]',
|
||||
@@ -1449,7 +1504,7 @@ function renderToolPipeline(): string {
|
||||
' allResults --> context',
|
||||
'```',
|
||||
'',
|
||||
'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The registry losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition\'s snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. PTC mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, return denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency.',
|
||||
'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The registry losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition\'s snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. PTC mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/ptc-dispatch`, return denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency.',
|
||||
'',
|
||||
...maintenanceFooter(maintenance),
|
||||
].join('\n')
|
||||
|
||||
@@ -6,36 +6,31 @@
|
||||
* surface-union member must resolve to one. `--check` verifies the artifact.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { githubSlug } from './verify-md-links.ts'
|
||||
import {
|
||||
annotateSurface, collectEventEnvelopeTypes, collectLogEvents, collectSurfaceEventTypes,
|
||||
type AnnotatedLogEventEntry, type EventEnvelopeTypeEntry,
|
||||
} from './persistence-catalog-source.ts'
|
||||
import { extractPersistenceSchema } from './persistence-schema.ts'
|
||||
import { persistenceCatalogText, type PersistenceCatalogLocale } from './persistence-catalog-text.ts'
|
||||
import { renderPersistencePair, type PersistenceArtifact } from './persistence-artifacts.ts'
|
||||
|
||||
export { annotateSurface, collectEventEnvelopeTypes, collectLogEvents, collectSurfaceEventTypes } from './persistence-catalog-source.ts'
|
||||
export type { AnnotatedLogEventEntry, EventEnvelopeTypeEntry, LogEventEntry } from './persistence-catalog-source.ts'
|
||||
import type { PersistenceSchemaInventory } from './persistence-schema-model.ts'
|
||||
import { renderPersistenceSchemaDefinitions, renderPersistenceSchemaIndex } from './render-persistence-schema.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'docs/persistence-catalog.md'
|
||||
const OUT_RUNTIME_TYPES = 'packages/core/session/src/known-event-types.ts'
|
||||
const OUT_SCHEMA = 'docs/persistence-schema.json'
|
||||
|
||||
/** The fenced-block info string for generated declaration blocks (skipped by
|
||||
* doc-typecheck, since their imported types are not standalone-compilable). */
|
||||
const FENCE = 'ts persistence-catalog'
|
||||
|
||||
/** The package that owns the durable event vocabulary. */
|
||||
const SESSION_PACKAGE = '@deepseek-ai/dsh-session'
|
||||
|
||||
/** The type-only module that plugin declaration merges augment. */
|
||||
const SESSION_TYPES_MODULE = '@deepseek-ai/dsh-session/types'
|
||||
|
||||
/** Event-envelope declarations rendered before the per-event vocabulary. */
|
||||
const EVENT_ENVELOPE_TYPE_NAMES = [
|
||||
'SessionEventType',
|
||||
'SurfaceEventType',
|
||||
'SurfaceOp',
|
||||
'SessionEvent',
|
||||
] as const
|
||||
|
||||
type EventEnvelopeTypeName = typeof EVENT_ENVELOPE_TYPE_NAMES[number]
|
||||
|
||||
/** Documentation target, relative to `docs/`, for linked payload types. */
|
||||
const LINK_MAP: Record<string, string> = {
|
||||
ToolCallId: 'subsystems/core.md',
|
||||
@@ -49,7 +44,7 @@ const LINK_MAP: Record<string, string> = {
|
||||
TurnEndReason: 'subsystems/session.md',
|
||||
SessionTitleEventData: 'subsystems/session-title.md',
|
||||
SessionTitleLlmRequestEventData: 'subsystems/session-title.md',
|
||||
SessionTitleModelProvenance: 'subsystems/session-title.md',
|
||||
SessionTitleModelIdentity: 'subsystems/session-title.md',
|
||||
SessionTitleProviderId: 'subsystems/session-title.md',
|
||||
SessionTitleSource: 'subsystems/session-title.md',
|
||||
TeamId: 'subsystems/agent-team.md',
|
||||
@@ -59,335 +54,72 @@ const LINK_MAP: Record<string, string> = {
|
||||
TeamTaskSnapshot: 'subsystems/agent-team.md',
|
||||
}
|
||||
|
||||
/** One log event, extracted from a `SessionEventMap` declaration. */
|
||||
export interface LogEventEntry {
|
||||
/** Scoped name, e.g. `turn/start`. */
|
||||
name: string
|
||||
/** The scope prefix, e.g. `turn` (everything before the first `/`). */
|
||||
scope: string
|
||||
/** Payload type text (the member's type annotation, whitespace-collapsed). */
|
||||
payload: string
|
||||
/** Source member declaration and complete JSDoc, dedented from its container. */
|
||||
declaration: string
|
||||
/** Description prose (the member's JSDoc), one line per paragraph. */
|
||||
doc: string
|
||||
/** Source pointer `packages/…/file.ts:line` of the declaration. */
|
||||
source: string
|
||||
}
|
||||
|
||||
/** A {@link LogEventEntry} plus its surface-eligibility badge. */
|
||||
export interface AnnotatedLogEventEntry extends LogEventEntry {
|
||||
/** Whether the type is a `SurfaceEventType` member (may carry `surfaceOp`). */
|
||||
surface: boolean
|
||||
}
|
||||
|
||||
/** One owning event-envelope declaration pasted into the generated catalog. */
|
||||
export interface EventEnvelopeTypeEntry {
|
||||
/** Exported declaration name. */
|
||||
name: EventEnvelopeTypeName
|
||||
/** Verbatim type declaration, including its complete leading JSDoc. */
|
||||
declaration: string
|
||||
/** Source pointer `packages/…/file.ts:line` of the declaration. */
|
||||
source: string
|
||||
}
|
||||
|
||||
const printer = ts.createPrinter({ removeComments: true })
|
||||
|
||||
/**
|
||||
* Render a member type on one line through the TypeScript printer, which adds
|
||||
* semicolon separators. Drop its trailing semicolon before `}` to match the
|
||||
* repository's inline-literal style.
|
||||
*/
|
||||
function payloadText(type: ts.TypeNode, sf: ts.SourceFile): string {
|
||||
return printer.printNode(ts.EmitHint.Unspecified, type, sf)
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/;\s*\}/g, ' }')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a declaration from its leading JSDoc through its closing token while
|
||||
* removing only the indentation imposed by its containing interface/module.
|
||||
*/
|
||||
function declarationText(text: string, sf: ts.SourceFile, node: ts.Node): string {
|
||||
const raw = rawJsDoc(text, node)
|
||||
const nodeStart = node.getStart(sf)
|
||||
const start = raw ? text.lastIndexOf(raw, nodeStart) : nodeStart
|
||||
const { line } = sf.getLineAndCharacterOfPosition(start)
|
||||
const lineStart = sf.getPositionOfLineAndCharacter(line, 0)
|
||||
const indent = text.slice(lineStart, start)
|
||||
return text.slice(lineStart, node.end)
|
||||
.split('\n')
|
||||
.map(lineText => lineText.startsWith(indent) ? lineText.slice(indent.length) : lineText)
|
||||
.join('\n')
|
||||
.trimEnd()
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `interface SessionEventMap` declaration in a source file: the owning
|
||||
* top-level declaration (in `@deepseek-ai/dsh-session`) and any declaration
|
||||
* merge inside a `declare module '@deepseek-ai/dsh-session/types'` block. Both forms
|
||||
* declare members of the SAME merged interface, so both are catalogued
|
||||
* uniformly. `topLevel` distinguishes the owning form so the caller can verify
|
||||
* it actually lives in the owning package — an unrelated local interface that
|
||||
* happens to share the name must not be catalogued as the on-disk vocabulary.
|
||||
*/
|
||||
function sessionEventMapDecls(sf: ts.SourceFile): { decl: ts.InterfaceDeclaration; topLevel: boolean }[] {
|
||||
const decls: { decl: ts.InterfaceDeclaration; topLevel: boolean }[] = []
|
||||
for (const stmt of sf.statements) {
|
||||
if (ts.isInterfaceDeclaration(stmt) && stmt.name.text === 'SessionEventMap') decls.push({ decl: stmt, topLevel: true })
|
||||
if (ts.isModuleDeclaration(stmt) && ts.isStringLiteral(stmt.name) && stmt.name.text === SESSION_TYPES_MODULE
|
||||
&& stmt.body && ts.isModuleBlock(stmt.body)) {
|
||||
for (const inner of stmt.body.statements) {
|
||||
if (ts.isInterfaceDeclaration(inner) && inner.name.text === 'SessionEventMap') decls.push({ decl: inner, topLevel: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
return decls
|
||||
}
|
||||
|
||||
/**
|
||||
* The npm package name owning a `packages/<group>/<pkg>/…` source file, read
|
||||
* from that package's manifest — or null when the manifest is missing or
|
||||
* unparseable (the caller treats null as "ownership unverifiable").
|
||||
*/
|
||||
function packageNameFor(rel: string, scanRoot: string): string | null {
|
||||
const dir = rel.split('/').slice(0, 3).join('/')
|
||||
try {
|
||||
const manifest = JSON.parse(readFileSync(resolve(scanRoot, dir, 'package.json'), 'utf8')) as { name?: string }
|
||||
return typeof manifest.name === 'string' ? manifest.name : null
|
||||
} catch {
|
||||
// Missing or malformed package.json — every real workspace package has one,
|
||||
// so this only arises in stripped-down fixture trees; either way ownership
|
||||
// cannot be verified and the caller reports the declaration.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every `SessionEventMap` merge, rejecting inherited, non-literal,
|
||||
* untyped, undocumented, duplicate, or incorrectly owned members in one report.
|
||||
*/
|
||||
export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
|
||||
const entries: LogEventEntry[] = []
|
||||
const violations: string[] = []
|
||||
const seen = new Map<string, string>()
|
||||
let owningDecl: string | null = null
|
||||
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('SessionEventMap')) continue
|
||||
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
|
||||
for (const { decl, topLevel } of sessionEventMapDecls(sf)) {
|
||||
const declSrc = pointer(rel, sf, decl)
|
||||
if (topLevel) {
|
||||
// The top-level form has one home: the single exported declaration in
|
||||
// the owning package. Same-named interfaces elsewhere are different
|
||||
// types and must not enter the on-disk catalog.
|
||||
const pkg = packageNameFor(rel, scanRoot)
|
||||
if (pkg !== SESSION_PACKAGE) {
|
||||
violations.push(`top-level interface SessionEventMap (${declSrc}) is outside ${SESSION_PACKAGE} (package ${pkg ?? 'unknown'}). Rename the interface, or contribute events via declare module '${SESSION_TYPES_MODULE}'.`)
|
||||
continue
|
||||
}
|
||||
const exported = decl.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false
|
||||
if (!exported) {
|
||||
violations.push(`top-level interface SessionEventMap (${declSrc}) is not exported; the owning vocabulary is the single exported declaration — rename a local helper interface.`)
|
||||
continue
|
||||
}
|
||||
if (owningDecl) {
|
||||
violations.push(`top-level interface SessionEventMap (${declSrc}) is already declared at ${owningDecl}; the owning vocabulary has exactly one home.`)
|
||||
continue
|
||||
}
|
||||
owningDecl = declSrc
|
||||
}
|
||||
if (decl.heritageClauses?.length) {
|
||||
violations.push(`SessionEventMap declaration (${declSrc}) uses extends; inherited keys would join keyof SessionEventMap without a catalog row — declare event members directly.`)
|
||||
}
|
||||
for (const member of decl.members) {
|
||||
const src = pointer(rel, sf, member)
|
||||
if (!ts.isPropertySignature(member) || !member.type) {
|
||||
// A method-form or type-less member still joins `keyof SessionEventMap`,
|
||||
// so skipping it silently would be exactly the undocumented-event hole
|
||||
// this catalog exists to close.
|
||||
const label = (member as { name?: ts.Node }).name?.getText(sf) ?? member.getText(sf).replace(/\s+/g, ' ')
|
||||
violations.push(`SessionEventMap member ${label} (${src}) is not a property signature with an explicit payload type; declare every log event as 'scope/name': <payload>.`)
|
||||
continue
|
||||
}
|
||||
if (!ts.isStringLiteral(member.name)) {
|
||||
violations.push(`log event at ${src} has a non-literal name; the catalog needs string-literal event names.`)
|
||||
continue
|
||||
}
|
||||
const name = member.name.text
|
||||
const where = `log event '${name}' (${src})`
|
||||
const prior = seen.get(name)
|
||||
if (prior) {
|
||||
violations.push(`${where} is already declared at ${prior}; an event type has exactly one declaration.`)
|
||||
continue
|
||||
}
|
||||
seen.set(name, src)
|
||||
const payload = payloadText(member.type, sf)
|
||||
const { doc, hasMode } = parseJsDoc(rawJsDoc(text, member))
|
||||
if (hasMode) {
|
||||
violations.push(`${where} carries an @mode tag, but a log event has no dispatch mode (it is not a cordis bus event — it rides the 'session/event' emit). Remove the tag.`)
|
||||
}
|
||||
if (!doc) {
|
||||
violations.push(`${where} has no description prose. Say what the event records and what its payload means — the JSDoc becomes the catalog entry.`)
|
||||
}
|
||||
const declaration = declarationText(text, sf, member)
|
||||
entries.push({ name, scope: name.split('/')[0] ?? name, payload, declaration, doc, source: src })
|
||||
}
|
||||
}
|
||||
}
|
||||
reportViolations('gen-persistence-catalog', violations)
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the exported declarations that compose the persisted event envelope,
|
||||
* preserving their source JSDoc and declaration text.
|
||||
*/
|
||||
export function collectEventEnvelopeTypes(scanRoot: string = root): EventEnvelopeTypeEntry[] {
|
||||
const found = new Map<EventEnvelopeTypeName, EventEnvelopeTypeEntry>()
|
||||
const violations: string[] = []
|
||||
const wanted = new Set<string>(EVENT_ENVELOPE_TYPE_NAMES)
|
||||
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 (!EVENT_ENVELOPE_TYPE_NAMES.some(name => text.includes(name))) continue
|
||||
if (packageNameFor(rel, scanRoot) !== SESSION_PACKAGE) continue
|
||||
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
|
||||
for (const stmt of sf.statements) {
|
||||
if (!ts.isTypeAliasDeclaration(stmt) || !wanted.has(stmt.name.text)) continue
|
||||
const name = stmt.name.text as EventEnvelopeTypeName
|
||||
const src = pointer(rel, sf, stmt)
|
||||
const where = `event-envelope type '${name}' (${src})`
|
||||
const prior = found.get(name)
|
||||
if (prior) {
|
||||
violations.push(`${where} is already declared at ${prior.source}; the persisted envelope type has exactly one owner.`)
|
||||
continue
|
||||
}
|
||||
if (!(stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)) {
|
||||
violations.push(`${where} is not exported.`)
|
||||
}
|
||||
const { doc, hasMode } = parseJsDoc(rawJsDoc(text, stmt))
|
||||
if (hasMode) violations.push(`${where} carries an @mode tag, but a persisted type has no dispatch mode.`)
|
||||
if (!doc) violations.push(`${where} has no description prose. The full JSDoc is part of the generated catalog.`)
|
||||
found.set(name, { name, declaration: declarationText(text, sf, stmt), source: src })
|
||||
}
|
||||
}
|
||||
const missing = EVENT_ENVELOPE_TYPE_NAMES.filter(name => !found.has(name))
|
||||
if (missing.length > 0) {
|
||||
violations.push(`missing event-envelope declaration(s): ${missing.join(', ')}.`)
|
||||
}
|
||||
reportViolations('gen-persistence-catalog', violations)
|
||||
return EVENT_ENVELOPE_TYPE_NAMES.map((name) => {
|
||||
const entry = found.get(name)
|
||||
if (!entry) throw new Error(`gen-persistence-catalog: missing checked event-envelope declaration '${name}'.`)
|
||||
return entry
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `SurfaceEventType` union — the surface-eligible subset of event
|
||||
* types — from source. Hard-errors when the alias is missing, declared more
|
||||
* than once, or contains a non-string-literal member: the badge derivation
|
||||
* relies on the union being a closed set of literal event names.
|
||||
* `scanRoot` defaults to the repo root; tests pass a fixture dir.
|
||||
*/
|
||||
export function collectSurfaceEventTypes(scanRoot: string = root): string[] {
|
||||
const found: { names: string[]; source: 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('SurfaceEventType')) continue
|
||||
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
|
||||
for (const stmt of sf.statements) {
|
||||
if (!ts.isTypeAliasDeclaration(stmt) || stmt.name.text !== 'SurfaceEventType') continue
|
||||
const src = pointer(rel, sf, stmt)
|
||||
const members = ts.isUnionTypeNode(stmt.type) ? [...stmt.type.types] : [stmt.type]
|
||||
const names: string[] = []
|
||||
for (const m of members) {
|
||||
if (ts.isLiteralTypeNode(m) && ts.isStringLiteral(m.literal)) names.push(m.literal.text)
|
||||
else throw new Error(`gen-persistence-catalog: SurfaceEventType (${src}) has a non-string-literal member; the badge derivation needs a closed literal union.`)
|
||||
}
|
||||
found.push({ names, source: src })
|
||||
}
|
||||
}
|
||||
const only = found[0]
|
||||
if (!only) throw new Error('gen-persistence-catalog: no SurfaceEventType union found under packages/*/*/src.')
|
||||
if (found.length > 1) throw new Error(`gen-persistence-catalog: SurfaceEventType is declared more than once (${found.map(f => f.source).join(', ')}); the surface subset has exactly one owner.`)
|
||||
return only.names
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the surface/log-only badge to each event. Hard-errors when a
|
||||
* `SurfaceEventType` union member names no collected event — a stale union
|
||||
* member would otherwise silently badge nothing.
|
||||
*/
|
||||
export function annotateSurface(events: LogEventEntry[], surfaceTypes: string[]): AnnotatedLogEventEntry[] {
|
||||
const names = new Set(events.map(e => e.name))
|
||||
const stale = surfaceTypes.filter(t => !names.has(t))
|
||||
if (stale.length > 0) {
|
||||
throw new Error(`gen-persistence-catalog: SurfaceEventType member(s) ${stale.map(t => `'${t}'`).join(', ')} name no declared log event (stale union member?).`)
|
||||
}
|
||||
const surface = new Set(surfaceTypes)
|
||||
return events.map(e => ({ ...e, surface: surface.has(e.name) }))
|
||||
}
|
||||
|
||||
/** Render the cross-link "Types:" line for a payload, or '' if none apply. */
|
||||
function typeLinks(payload: string): string {
|
||||
function typeLinks(payload: string, locale: PersistenceCatalogLocale): string {
|
||||
const seen = new Set<string>()
|
||||
for (const name of Object.keys(LINK_MAP)) {
|
||||
if (new RegExp(`\\b${name}\\b`).test(payload)) seen.add(name)
|
||||
}
|
||||
if (seen.size === 0) return ''
|
||||
const links = [...seen].sort().map(n => `[${n}](${LINK_MAP[n]})`)
|
||||
return `Types: ${links.join(' · ')}`
|
||||
const links = Object.entries(LINK_MAP).filter(([name]) => seen.has(name)).sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([name, path]) => {
|
||||
return `[${name}](${locale === 'zh' ? path.replace(/\.md$/u, '.zh.md') : path})`
|
||||
})
|
||||
return `${persistenceCatalogText[locale].types}${links.join(' · ')}`
|
||||
}
|
||||
|
||||
/** Render one log event entry. */
|
||||
function renderEvent(e: AnnotatedLogEventEntry): string[] {
|
||||
function renderEvent(e: AnnotatedLogEventEntry, locale: PersistenceCatalogLocale): string[] {
|
||||
const heading = `${e.name} — ${e.surface ? 'surface' : 'log-only'}`
|
||||
const out = [`<a id="${githubSlug(heading)}"></a>`, '', `#### \`${e.name}\` — ${e.surface ? 'surface' : 'log-only'}`, '']
|
||||
out.push('```' + FENCE, e.declaration, '```', '')
|
||||
const links = typeLinks(e.payload)
|
||||
const links = typeLinks(e.payload, locale)
|
||||
if (links) out.push(links, '')
|
||||
out.push(`Source: [\`${e.source}\`](../${e.source.split(':')[0]})`, '')
|
||||
out.push(`${persistenceCatalogText[locale].source}[\`${e.source}\`](../${e.source.split(':')[0]})`, '')
|
||||
return out
|
||||
}
|
||||
|
||||
/** Render the full catalog (pure, deterministic given the collected inputs). */
|
||||
export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnvelopeTypeEntry[]): string {
|
||||
export function render(
|
||||
events: AnnotatedLogEventEntry[],
|
||||
envelopeTypes: EventEnvelopeTypeEntry[],
|
||||
schema?: PersistenceSchemaInventory,
|
||||
locale: PersistenceCatalogLocale = 'en',
|
||||
): string {
|
||||
const text = persistenceCatalogText[locale]
|
||||
const lines: string[] = [
|
||||
'<!-- Generated by scripts/gen-persistence-catalog.ts — do not edit by hand.',
|
||||
' Run `pnpm run gen-persistence-catalog` to regenerate. -->',
|
||||
'',
|
||||
'# Session Persistence Event Catalog',
|
||||
`# ${text.title}`,
|
||||
'',
|
||||
'Every event type that can appear in a session\'s durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge into `@deepseek-ai/dsh-session/types` in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](subsystems/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](subsystems/persistence.md) (how the log is made durable), and the generated region of [session.md](subsystems/session.md#cordis-surface) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).',
|
||||
...(locale === 'zh' ? ['[English](persistence-catalog.md) | 中文', ''] : []),
|
||||
text.intro,
|
||||
'',
|
||||
'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. The archived [persistence-log-catalog record](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md) documents the original catalog decision.',
|
||||
text.generation,
|
||||
'',
|
||||
'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, the optional `ignorable` unknown-type skip marker, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`). Current writers stamp `SESSION_FORMAT_VERSION`; supported historical artifacts reach this current vocabulary through the build-static adjacent migration catalog ([the version lifecycle](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further current-version event types, which are outside this catalog by construction and require an explicit disposition at a later format edge.',
|
||||
text.envelopeIntro,
|
||||
'',
|
||||
'## Event envelope',
|
||||
...(schema ? [renderPersistenceSchemaIndex(schema, locale)] : []),
|
||||
`## ${text.envelope}`,
|
||||
'',
|
||||
'```' + FENCE,
|
||||
envelopeTypes.map(entry => entry.declaration).join('\n\n'),
|
||||
'```',
|
||||
'',
|
||||
`Sources: ${envelopeTypes.map(entry => `[\`${entry.source}\`](../${entry.source.split(':')[0]})`).join(' · ')}`,
|
||||
`${text.sources}${envelopeTypes.map(entry => `[\`${entry.source}\`](../${entry.source.split(':')[0]})`).join(' · ')}`,
|
||||
'',
|
||||
'## Events',
|
||||
`## ${text.events}`,
|
||||
'',
|
||||
]
|
||||
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(...renderEvent(e, locale))
|
||||
}
|
||||
}
|
||||
if (schema) lines.push(renderPersistenceSchemaDefinitions(schema, locale))
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
@@ -424,29 +156,41 @@ export function renderKnownEventTypes(events: AnnotatedLogEventEntry[]): string
|
||||
...names.map(name => ` '${name}',`),
|
||||
'])',
|
||||
'',
|
||||
'/** Event types whose model-visible effects require an explicit pure interpreter. */',
|
||||
'export const MESSAGE_PROJECTION_EVENT_TYPES: ReadonlySet<string> = new Set([',
|
||||
...events.filter(event => event.messageProjection).map(event => ` '${event.name}',`).sort(),
|
||||
'])',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** One generated artifact: repo-relative target and its freshly-rendered content. */
|
||||
interface GeneratedArtifact {
|
||||
readonly out: string
|
||||
readonly content: string
|
||||
/**
|
||||
* Render the complete generated persistence reference from one extracted inventory.
|
||||
* @param scanRoot - checkout whose source event declarations supply JSDoc.
|
||||
* @param schema - already extracted source schemas; shared with record validation.
|
||||
* @returns both catalog languages, pairing metadata, runtime names and machine schemas.
|
||||
*/
|
||||
export function persistenceCatalogArtifacts(scanRoot: string, schema: PersistenceSchemaInventory): PersistenceArtifact[] {
|
||||
const events = annotateSurface(collectLogEvents(scanRoot), collectSurfaceEventTypes(scanRoot))
|
||||
const envelope = collectEventEnvelopeTypes(scanRoot)
|
||||
return [
|
||||
...renderPersistencePair(scanRoot, OUT, render(events, envelope, schema), render(events, envelope, schema, 'zh')),
|
||||
{ path: OUT_RUNTIME_TYPES, content: renderKnownEventTypes(events) },
|
||||
{ path: OUT_SCHEMA, content: `${JSON.stringify(schema, null, 2)}\n` },
|
||||
]
|
||||
}
|
||||
|
||||
/** CLI entry: default writes the artifacts, `--check` fails if a committed copy
|
||||
* 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 {
|
||||
const events = annotateSurface(collectLogEvents(), collectSurfaceEventTypes())
|
||||
const artifacts: GeneratedArtifact[] = [
|
||||
{ out: OUT, content: render(events, collectEventEnvelopeTypes()) },
|
||||
{ out: OUT_RUNTIME_TYPES, content: renderKnownEventTypes(events) },
|
||||
]
|
||||
const schema = extractPersistenceSchema(root)
|
||||
const artifacts = persistenceCatalogArtifacts(root, schema)
|
||||
if (process.argv.includes('--check')) {
|
||||
const stale = artifacts.filter((artifact) => {
|
||||
let committed: string | null = null
|
||||
try {
|
||||
committed = readFileSync(resolve(root, artifact.out), 'utf8')
|
||||
committed = readFileSync(resolve(root, artifact.path), '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
|
||||
@@ -456,16 +200,16 @@ function main(): void {
|
||||
return committed !== artifact.content
|
||||
})
|
||||
if (stale.length === 0) {
|
||||
console.log(`gen-persistence-catalog: ${artifacts.map(a => a.out).join(', ')} are up to date.`)
|
||||
console.log(`gen-persistence-catalog: ${artifacts.map(a => a.path).join(', ')} are up to date.`)
|
||||
process.exit(0)
|
||||
}
|
||||
console.error(`gen-persistence-catalog: ${stale.map(a => a.out).join(', ')} stale. Run \`pnpm run gen-persistence-catalog\` and commit the result.`)
|
||||
console.error(`gen-persistence-catalog: ${stale.map(a => a.path).join(', ')} stale. Run \`pnpm run gen-persistence-catalog\` and commit the result.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
writeFileSync(resolve(root, artifact.out), artifact.content)
|
||||
console.log(`gen-persistence-catalog: wrote ${artifact.out}.`)
|
||||
writeFileSync(resolve(root, artifact.path), artifact.content)
|
||||
console.log(`gen-persistence-catalog: wrote ${artifact.path}.`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,32 @@
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import type { DshSessionFormatMigrationManifest } from '@deepseek-ai/dsh-package-manifest'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'packages/session/session-format-catalog/src/generated.ts'
|
||||
|
||||
/** Internal adjacent migration metadata discovered only in workspace migration packages. */
|
||||
interface SessionFormatMigrationDeclaration {
|
||||
/** Non-negative safe integer source version; negative zero is rejected. */
|
||||
from: number
|
||||
/** Non-negative safe integer target version, exactly from + 1. */
|
||||
to: number
|
||||
/** Non-empty package export path, such as `.` or `./migration`. */
|
||||
export: string
|
||||
/** Non-empty named export of the migration implementation. */
|
||||
migration: string
|
||||
/** Non-empty named export of the source version codec. */
|
||||
sourceCodec: string
|
||||
/** Non-empty named export of the target version codec. */
|
||||
targetCodec: string
|
||||
/** Non-empty named export of the target header validator. */
|
||||
targetHeaderValidator: string
|
||||
/** Non-empty named export of the target version restorer. */
|
||||
targetRestorer: string
|
||||
}
|
||||
|
||||
/** Validated adjacent migration metadata with its resolved package import path. */
|
||||
export interface SessionFormatMigrationManifest extends Readonly<Omit<DshSessionFormatMigrationManifest, 'export'>> {
|
||||
export interface SessionFormatMigrationManifest extends Readonly<Omit<SessionFormatMigrationDeclaration, 'export'>> {
|
||||
readonly packageName: string
|
||||
readonly importPath: string
|
||||
}
|
||||
@@ -72,7 +91,7 @@ export function collectSessionFormatMigrations(
|
||||
if (metadata === undefined) {
|
||||
throw new Error(`gen-session-format-catalog: ${rel} lacks dsh.sessionFormatMigration`)
|
||||
}
|
||||
const allowed: ReadonlySet<string> = new Set<keyof DshSessionFormatMigrationManifest>([
|
||||
const allowed: ReadonlySet<string> = new Set<keyof SessionFormatMigrationDeclaration>([
|
||||
'from', 'to', 'export', 'migration', 'sourceCodec', 'targetCodec',
|
||||
'targetHeaderValidator', 'targetRestorer',
|
||||
])
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
CLAUDE_AGENT_SDK_PACKAGE,
|
||||
assertRuntimeLicenses,
|
||||
claudeDistributionFromManifest,
|
||||
collectPythonDependencies,
|
||||
isOwnerAuthorizedRuntime,
|
||||
@@ -24,8 +25,11 @@ describe('THIRD_PARTY_NOTICES.md', () => {
|
||||
// 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', () => {
|
||||
const generated = render()
|
||||
// This case resolves all browser build graphs as well as installed license metadata.
|
||||
it('matches what the generator produces from the current manifests', {
|
||||
timeout: 120_000,
|
||||
}, async () => {
|
||||
const generated = await render()
|
||||
expect(generated).toContain('It depends on the third-party software listed below.')
|
||||
expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(generated)
|
||||
})
|
||||
@@ -42,6 +46,37 @@ function workspace(entries: Record<string, Manifest>): { manifests: Map<string,
|
||||
}
|
||||
|
||||
describe('tierExternalDeps', () => {
|
||||
it('keeps license rejection active when a browser library is declared for development', () => {
|
||||
const { manifests, names } = workspace({
|
||||
'packages/client/ui/package.json': { devDependencies: { 'browser-lib': '^1', 'test-tool': '^1' } },
|
||||
})
|
||||
const tiers = tierExternalDeps(manifests, names, new Set(['browser-lib']))
|
||||
const dependencies = [{ name: 'browser-lib', license: 'GPL-3.0-only' }, { name: 'test-tool', license: 'GPL-3.0-only' }]
|
||||
.filter(dep => tiers.get(dep.name))
|
||||
expect(dependencies.map(dep => dep.name)).toEqual(['browser-lib'])
|
||||
expect(() => { assertRuntimeLicenses(dependencies) }).toThrow('browser-lib (GPL-3.0-only)')
|
||||
expect(() => { assertRuntimeLicenses([{ name: 'browser-lib', license: 'MIT' }]) }).not.toThrow()
|
||||
expect(() => { assertRuntimeLicenses([{ name: CLAUDE_AGENT_SDK_PACKAGE, license: 'SEE LICENSE IN README.md' }]) })
|
||||
.not.toThrow()
|
||||
})
|
||||
|
||||
it('keeps browser-bundled development dependencies in runtime disclosures', () => {
|
||||
const { manifests, names } = workspace({
|
||||
'packages/client/ui/package.json': {
|
||||
name: '@fixture/ui', devDependencies: { react: '^18', 'browser-lib': '^1', 'type-only': '^1' },
|
||||
},
|
||||
})
|
||||
expect(tierExternalDeps(manifests, names, new Set(['react', 'browser-lib']))).toEqual(new Map([
|
||||
['tsx', true], ['react', true], ['browser-lib', true], ['type-only', false],
|
||||
]))
|
||||
})
|
||||
|
||||
it('rejects a browser library missing from the disclosed declarations', () => {
|
||||
const { manifests, names } = workspace({})
|
||||
expect(() => tierExternalDeps(manifests, names, new Set(['missing-lib'])))
|
||||
.toThrow('browser package missing-lib has no workspace dependency declaration')
|
||||
})
|
||||
|
||||
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.
|
||||
@@ -351,12 +386,12 @@ describe('official Claude distribution authorization', () => {
|
||||
|
||||
describe('manifestPatterns', () => {
|
||||
it('derives globs from the declared members, so a new member area is read', () => {
|
||||
expect(manifestPatterns(['packages/*/*', 'tools/*', 'native/landlock-run', 'native/landlock-run/packages/*'])).toEqual([
|
||||
expect(manifestPatterns(['packages/*/*', 'tools/*', 'native/system', 'native/system/packages/*'])).toEqual([
|
||||
'package.json',
|
||||
'packages/*/*/package.json',
|
||||
'tools/*/package.json',
|
||||
'native/landlock-run/package.json',
|
||||
'native/landlock-run/packages/*/package.json',
|
||||
'native/system/package.json',
|
||||
'native/system/packages/*/package.json',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ import { dirname, 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'
|
||||
import { browserBundledExternals } from './browser-bundled-externals.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'THIRD_PARTY_NOTICES.md'
|
||||
@@ -40,9 +41,11 @@ const DEV_ONLY_AREAS = [
|
||||
|
||||
/** First-party public native packages: reachable at runtime but not third-party. */
|
||||
const FIRST_PARTY = new Set([
|
||||
'@deepseek-ai/node-addon-landlock-run',
|
||||
'@deepseek-ai/node-addon-landlock-run-linux-arm64',
|
||||
'@deepseek-ai/node-addon-landlock-run-linux-x64',
|
||||
'@deepseek-ai/node-addon-system',
|
||||
'@deepseek-ai/node-addon-system-darwin-arm64',
|
||||
'@deepseek-ai/node-addon-system-darwin-x64',
|
||||
'@deepseek-ai/node-addon-system-linux-arm64',
|
||||
'@deepseek-ai/node-addon-system-linux-x64',
|
||||
])
|
||||
|
||||
/** Official SDK identity covered by the project's narrow owner authorization. */
|
||||
@@ -75,7 +78,6 @@ const OVERRIDES: Record<string, { license?: string; repo?: string }> = {
|
||||
// No repository field in the published manifest.
|
||||
'node-addon-require-builtin': { repo: 'https://www.npmjs.com/package/node-addon-require-builtin' },
|
||||
// No `license` field in the published manifest; the tarball's LICENSE.txt is the MIT text.
|
||||
'fs-ext': { license: 'MIT' },
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -295,7 +297,7 @@ function installedManifest(name: string, manifests: Map<string, Manifest>, expec
|
||||
let manifest: (Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string }) | undefined
|
||||
// Workspace-local link farms can expose a dependency that is not linked at
|
||||
// the repository root; both are backed by the root workspace's lockfile.
|
||||
for (const store of ['node_modules', 'native/landlock-run/node_modules']) {
|
||||
for (const store of ['node_modules', 'native/system/node_modules']) {
|
||||
const direct = resolve(root, store, name, 'package.json')
|
||||
if (existsSync(direct)) {
|
||||
const candidate = JSON.parse(readFileSync(direct, 'utf8')) as typeof manifest
|
||||
@@ -370,14 +372,11 @@ function normalizeRepo(raw: string | undefined): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Direct npm dependencies distributed through installed runtime libraries or
|
||||
* browser builds. Tooling declarations alone do not imply distribution.
|
||||
*/
|
||||
function collectNpmDeps(manifests: Map<string, Manifest>, names: Set<string>): ExternalDep[] {
|
||||
return [...tierExternalDeps(manifests, names)]
|
||||
function collectNpmDeps(manifests: Map<string, Manifest>, names: Set<string>, browser: ReadonlySet<string>): ExternalDep[] {
|
||||
return [...tierExternalDeps(manifests, names, browser)]
|
||||
.filter(([name]) => !FIRST_PARTY.has(name))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([name, runtime]) => ({ name, ...installedMetadata(name, manifests), runtime }))
|
||||
@@ -387,9 +386,12 @@ function collectNpmDeps(manifests: Map<string, Manifest>, names: Set<string>): E
|
||||
* 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.
|
||||
* @param browser - Direct third-party packages resolved by the browser builds.
|
||||
* @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> {
|
||||
export function tierExternalDeps(
|
||||
manifests: Map<string, Manifest>, names: Set<string>, browser: ReadonlySet<string> = new Set(),
|
||||
): Map<string, boolean> {
|
||||
const tiers = new Map<string, boolean>()
|
||||
// `tsx` is runtime by fiat: the root source-run scripts execute through its ESM hook.
|
||||
tiers.set('tsx', true)
|
||||
@@ -398,11 +400,14 @@ export function tierExternalDeps(manifests: Map<string, Manifest>, names: Set<st
|
||||
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)
|
||||
const runtime = browser.has(dep) || !devOnly && (RUNTIME_KINDS as readonly string[]).includes(kind)
|
||||
tiers.set(dep, (tiers.get(dep) ?? false) || runtime)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const name of browser) {
|
||||
if (!names.has(name) && !tiers.has(name)) throw new Error(`gen-third-party-notices: browser package ${name} has no workspace dependency declaration`)
|
||||
}
|
||||
return tiers
|
||||
}
|
||||
|
||||
@@ -438,7 +443,7 @@ export function parseVendoredRows(text: string): VendoredRow[] {
|
||||
* 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[] {
|
||||
function collectVendored(): (VendoredRow & { sourceDirectory: string })[] {
|
||||
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 })) {
|
||||
@@ -452,15 +457,15 @@ function collectVendored(): VendoredRow[] {
|
||||
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) {
|
||||
return rows.map((row) => {
|
||||
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
|
||||
return { ...row, sourceDirectory: `vendor/${dir}` }
|
||||
})
|
||||
}
|
||||
|
||||
/** Whether a parsed TOML value is a table rather than an array or scalar. */
|
||||
@@ -628,6 +633,18 @@ export function isPermissive(license: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject unapproved non-permissive licenses on installed or browser-bundled code.
|
||||
* @param dependencies - Disclosed runtime package identities and declared licenses.
|
||||
* @throws When a runtime package has no permissive license or exact owner authorization.
|
||||
*/
|
||||
export function assertRuntimeLicenses(dependencies: readonly { name: string; license: string }[]): void {
|
||||
const rejected = dependencies.filter(dep => !isPermissive(dep.license) && !isOwnerAuthorizedRuntime(dep.name))
|
||||
if (rejected.length > 0) {
|
||||
throw new Error(`gen-third-party-notices: runtime ${rejected.map(dep => `${dep.name} (${dep.license})`).join(', ')} is not a permissive license; review the distribution terms and record the decision before regenerating.`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the sentence that isolates non-permissive development tooling, or
|
||||
* nothing at all when every development dependency is permissive.
|
||||
@@ -670,14 +687,15 @@ ${rows.join('\n')}
|
||||
|
||||
/**
|
||||
* Render the complete notices document.
|
||||
* @returns the exact bytes `THIRD_PARTY_NOTICES.md` must hold.
|
||||
* @returns The exact bytes THIRD_PARTY_NOTICES.md must hold after resolving browser inputs.
|
||||
*/
|
||||
export function render(): string {
|
||||
export async function render(): Promise<string> {
|
||||
const browser = await browserBundledExternals(root)
|
||||
// The linked-manifest cache is keyed by name only, so it must not outlive
|
||||
// the manifests map it was resolved from; render() owns that single load.
|
||||
workspaceLinkedManifestCache.clear()
|
||||
const { manifests, names } = loadWorkspaceManifests()
|
||||
const npm = collectNpmDeps(manifests, names)
|
||||
const npm = collectNpmDeps(manifests, names, browser)
|
||||
const runtimeDeps = npm.filter(dep => dep.runtime)
|
||||
const devDeps = npm.filter(dep => !dep.runtime)
|
||||
const vendored = collectVendored()
|
||||
@@ -689,15 +707,7 @@ export function render(): string {
|
||||
? collectClaudeDistribution(manifests)
|
||||
: undefined
|
||||
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)
|
||||
&& !isOwnerAuthorizedRuntime(dep.name),
|
||||
)
|
||||
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.`)
|
||||
}
|
||||
assertRuntimeLicenses(runtimeDeps)
|
||||
const patchedLines = patched.map(({ spec, patch }) => `- \`${spec}\` — [\`${patch}\`](${patch})`)
|
||||
|
||||
return `<!-- Generated by scripts/gen-third-party-notices.ts — do not edit by hand.
|
||||
@@ -715,13 +725,13 @@ The complete npm transitive closure, including the Landlock launcher workspace,
|
||||
|
||||
The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm, and republished under the \`@deepseek-ai\` scope. 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 name | Upstream | License |
|
||||
| Package | Upstream name | Source | License |
|
||||
| --- | --- | --- | --- |
|
||||
${vendored.map(row => `| \`${row.npmName}\` | \`${row.upstreamName}\` | [${row.upstream.replace('https://', '')}](${row.upstream}) | MIT |`).join('\n')}
|
||||
${vendored.map(row => `| \`${row.npmName}\` | \`${row.upstreamName}\` | [${row.sourceDirectory}](${row.sourceDirectory}/) | MIT |`).join('\n')}
|
||||
|
||||
## Runtime npm dependencies
|
||||
|
||||
External packages that a workspace package resolves at runtime. The tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI, Web UI, and Python SDK runtime load by default.
|
||||
External packages installed for runtime use or distributed inside the prebuilt browser artifacts. Browser inputs are resolved through the shipping tsdown and Vite configurations, independently of npm dependency sections. The tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI, Web UI, and Python SDK runtime load by default.
|
||||
|
||||
${renderNpmTable(runtimeDeps)}
|
||||
|
||||
@@ -732,7 +742,7 @@ ${renderClaudeDistribution(claudeDistribution)}
|
||||
|
||||
## 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.
|
||||
External packages **directly declared** for development, tests, types, or tooling, without a runtime installation or browser-build relationship. A package here may still be pulled in transitively by a runtime dependency — \`pnpm-lock.yaml\` is the authority on that full closure.
|
||||
|
||||
${renderNpmTable(devDeps)}
|
||||
${renderNonPermissiveNote(nonPermissiveDev)}
|
||||
@@ -747,15 +757,15 @@ ${python.map(dep => `| [\`${dep.name}\`](${dep.repo}) | ${dep.license} | ${dep.r
|
||||
|
||||
## First-party native packages
|
||||
|
||||
\`@deepseek-ai/node-addon-landlock-run\` (and its platform packages) is built and released from this repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party.
|
||||
\`@deepseek-ai/node-addon-system\` (and its platform packages) is built and released from this repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party.
|
||||
`
|
||||
}
|
||||
|
||||
/** 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()
|
||||
async function main(): Promise<void> {
|
||||
const content = await render()
|
||||
if (process.argv.includes('--check')) {
|
||||
let committed: string | null = null
|
||||
try {
|
||||
@@ -778,5 +788,5 @@ function main(): void {
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
|
||||
main()
|
||||
await main()
|
||||
}
|
||||
+61
-13
@@ -46,6 +46,7 @@ import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent'
|
||||
import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent'
|
||||
import CordisHostRunner from '@deepseek-ai/dsh-cordis-host-runner'
|
||||
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
|
||||
import * as ToolPresent from '@deepseek-ai/dsh-tool-present'
|
||||
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'
|
||||
@@ -57,14 +58,18 @@ import Lsp from '@deepseek-ai/dsh-lsp'
|
||||
import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp'
|
||||
import * as ToolSkill from '@deepseek-ai/dsh-tool-skill'
|
||||
import * as ToolSessionQuery from '@deepseek-ai/dsh-tool-session-query'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs'
|
||||
import * as ToolJobs from '@deepseek-ai/dsh-tool-jobs'
|
||||
import BrowserUseRegistry from '@deepseek-ai/dsh-browser-use'
|
||||
import * as StagehandBrowserTools from '@deepseek-ai/dsh-experimental-browser-use-stagehand-native'
|
||||
import type TeamService from '@deepseek-ai/dsh-experimental-agent-team'
|
||||
import * as ToolTeam from '@deepseek-ai/dsh-experimental-tool-agent-team'
|
||||
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
|
||||
import McpResources from '@deepseek-ai/dsh-mcp-resources'
|
||||
import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
|
||||
import { registerListSubagentModels } from '../packages/subagent/tool-subagent/src/list-models.ts'
|
||||
import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
|
||||
import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-worker-thread'
|
||||
import WorkflowEngine from '@deepseek-ai/dsh-workflow'
|
||||
import type { WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
|
||||
import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph'
|
||||
import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
|
||||
import { githubSlug } from './verify-md-links.ts'
|
||||
@@ -96,6 +101,13 @@ class CatalogAttachmentStore extends AttachmentStore {
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'docs/tool-catalog.md'
|
||||
|
||||
/** Workflow tools expose their schemas without executing a program. */
|
||||
class CatalogWorkflowEngine extends WorkflowEngine {
|
||||
start(_request: WorkflowStartRequest): WorkflowRun {
|
||||
throw new Error('gen-tool-catalog: workflow execution is unavailable during schema harvest')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the descriptor needed to mount schema-producing consumers. Declares
|
||||
* the full capability set of the shipped in-process providers so consumers
|
||||
@@ -187,6 +199,33 @@ export interface ToolPackage {
|
||||
* guard proves it is exhaustive against the on-disk glob.
|
||||
*/
|
||||
const TOOL_PACKAGES: ToolPackage[] = [
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-mcp-resources',
|
||||
dir: 'mcp-resources',
|
||||
source: 'packages/mcp/mcp-resources/src/tools.ts',
|
||||
requires: ['ctx.tools', 'ctx.mcpResources'],
|
||||
writes: ['tool/call', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(McpResources)
|
||||
ctx.mcpResources.register('catalog', {
|
||||
request: () => Promise.reject(new Error('gen-tool-catalog: MCP requests are unreachable during schema harvest')),
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-experimental-browser-use-stagehand-native',
|
||||
dir: 'browser-use-stagehand-native',
|
||||
source: 'packages/experimental/browser-use-stagehand-native/src/index.ts',
|
||||
requires: ['ctx.browserUse', 'ctx.agents', 'ctx.tools', 'ctx.systemPrompt'],
|
||||
writes: ['tool/call', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(BrowserUseRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(StagehandBrowserTools, {
|
||||
mode: 'launch', model: { modelName: 'openai/gpt-5.4-mini', apiKey: 'catalog-placeholder' },
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-ask-user',
|
||||
dir: 'tool-ask-user',
|
||||
@@ -204,10 +243,10 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
pkg: '@deepseek-ai/dsh-tools',
|
||||
dir: 'tools',
|
||||
source: 'packages/core/tools/src/ptc.ts',
|
||||
requires: ['ctx.tools', 'ctx.codeRuntime (execution time)', 'ctx.systemPrompt'],
|
||||
writes: ['tool/call', 'one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call', 'tool/result'],
|
||||
requires: ['ctx.tools', 'ctx.ptcRuntime (execution time)', 'ctx.systemPrompt'],
|
||||
writes: ['tool/call', 'one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call', 'tool/result'],
|
||||
// The registry's OWN tool: run_code exists only under a non-native mode
|
||||
// (the registry registers it in its constructor; the code runtime is read
|
||||
// (the registry registers it in its constructor; the PTC runtime is read
|
||||
// at assembly/execution time, so the schema harvest needs none mounted).
|
||||
toolsConfig: { mode: 'ptc' },
|
||||
async mount() {},
|
||||
@@ -241,6 +280,18 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
note:
|
||||
'The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.jobs` runtime and is collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-present',
|
||||
dir: 'tool-present',
|
||||
source: 'packages/fs/tool-present/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.fs', 'ctx.sessionProjections'],
|
||||
writes: ['tool/call', 'deliverables/presented after a successful final result', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(LocalFileSystem)
|
||||
await ctx.plugin(ToolPresent)
|
||||
},
|
||||
note: 'Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-pwsh',
|
||||
dir: 'tool-pwsh',
|
||||
@@ -417,7 +468,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
registerCatalogSubagentProvider(ctx, 'mock')
|
||||
await ctx.plugin(VmWorkflowEngine, { provider: 'mock' })
|
||||
await ctx.plugin(CatalogWorkflowEngine)
|
||||
await ctx.plugin(ToolRalph, { subagentProvider: 'mock' })
|
||||
},
|
||||
note:
|
||||
@@ -502,7 +553,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
writes: ['tool/call', 'tool/result', 'user/message via agent.inject() for background completion notices'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(LocalJobRegistry)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(ToolJobs)
|
||||
},
|
||||
note:
|
||||
'The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers\' `ctx.jobs.start()`.',
|
||||
@@ -528,7 +579,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
tryMembership: (candidate: Agent) => candidate === agent ? membership : undefined,
|
||||
membership: () => membership,
|
||||
} as unknown as TeamService)
|
||||
await ctx.plugin(Object.assign((inner: Context) => {
|
||||
await ctx.plugin(Object.assign(async (inner: Context) => {
|
||||
agent = {
|
||||
id: session.id,
|
||||
session,
|
||||
@@ -536,7 +587,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
status: 'idle',
|
||||
} as unknown as Agent
|
||||
Object.assign(agent, { ctx: createScope(inner, agent).ctx })
|
||||
inner.agents.register(agent)
|
||||
await inner.agents.register(agent)
|
||||
}, { inject: ['tools', 'systemPrompt', 'agents', 'agentTeams'] }))
|
||||
await ctx.plugin(ToolTeam)
|
||||
catalogChildScopes.set(ctx, agent)
|
||||
@@ -564,12 +615,9 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
requires: ['ctx.tools', 'ctx.workflowEngine', 'ctx.systemPrompt', 'a calling Agent (exec.agent parents the script children)'],
|
||||
writes: ['tool/call', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
// The tool injects `workflows`; boot the vm engine over a scripted
|
||||
// subagent provider to satisfy it. The schema does not depend on which
|
||||
// provider backs the engine.
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
registerCatalogSubagentProvider(ctx, 'mock')
|
||||
await ctx.plugin(VmWorkflowEngine, { provider: 'mock' })
|
||||
await ctx.plugin(CatalogWorkflowEngine)
|
||||
await ctx.plugin(ToolWorkflow)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { bundleWorkflowGuest, generateWorkflowGuest } from './gen-workflow-guest.ts'
|
||||
|
||||
async function fixture(source: string): Promise<{ root: string; entry: string; output: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-workflow-guest-'))
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }))
|
||||
const directory = join(root, 'packages/workflow/workflow-ptc/src')
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(join(root, 'tsconfig.base.json'), '{"compilerOptions":{"target":"es2024"}}\n')
|
||||
const entry = join(directory, 'guest.ts')
|
||||
await writeFile(entry, source)
|
||||
return { root, entry, output: join(directory, 'guest-source.ts') }
|
||||
}
|
||||
|
||||
it('bundles source imports into a module that loads without their files', async () => {
|
||||
const { root, entry } = await fixture('import { answer } from "./answer.ts"; import { isBuiltin } from "node:module"; export const result = [answer, isBuiltin("node:vm")];')
|
||||
await writeFile(join(entry, '..', 'answer.ts'), 'export const answer: number = 42;')
|
||||
const source = await bundleWorkflowGuest(root)
|
||||
await rm(join(root, 'packages'), { recursive: true })
|
||||
const module = await import(`data:text/javascript,${encodeURIComponent(source)}`) as { result: unknown }
|
||||
expect(module.result).toEqual([42, true])
|
||||
expect(source).not.toContain(root)
|
||||
})
|
||||
|
||||
it('rejects unresolved imports instead of shipping an unavailable guest dependency', async () => {
|
||||
const { root } = await fixture('import "dsh-nonexistent-workflow-dependency";')
|
||||
await expect(bundleWorkflowGuest(root)).rejects.toThrow('workflow guest has an unresolved import')
|
||||
})
|
||||
|
||||
it('checks the generated artifact against the current source and does not rewrite stale output', async () => {
|
||||
const { root, entry, output } = await fixture('export const answer: number = 42;')
|
||||
await generateWorkflowGuest(root, false)
|
||||
const original = await readFile(output, 'utf8')
|
||||
await generateWorkflowGuest(root, true)
|
||||
await generateWorkflowGuest(root, false)
|
||||
expect(await readFile(output, 'utf8')).toBe(original)
|
||||
await writeFile(entry, 'export const answer: number = 43;')
|
||||
await expect(generateWorkflowGuest(root, true)).rejects.toThrow('workflow guest source is stale')
|
||||
expect(await readFile(output, 'utf8')).toBe(original)
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
/** Bundle the workflow guest as source data; --check rejects a stale committed module. */
|
||||
|
||||
import { readFile, realpath, writeFile } from 'node:fs/promises'
|
||||
import { isBuiltin } from 'node:module'
|
||||
import { resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { Rolldown } from 'tsdown'
|
||||
|
||||
const GUEST_ENTRY = 'packages/workflow/workflow-ptc/src/guest.ts'
|
||||
const GUEST_SOURCE = 'packages/workflow/workflow-ptc/src/guest-source.ts'
|
||||
|
||||
/**
|
||||
* Bundle typed guest sources without depending on workspace build outputs.
|
||||
* @param root - Repository root containing source aliases and the guest entry.
|
||||
* @returns One self-contained ESM module with only Node builtin imports.
|
||||
*/
|
||||
export async function bundleWorkflowGuest(root: string): Promise<string> {
|
||||
root = await realpath(root)
|
||||
const bundle = await Rolldown.rolldown({
|
||||
cwd: root,
|
||||
input: resolve(root, GUEST_ENTRY),
|
||||
platform: 'node',
|
||||
tsconfig: resolve(root, 'tsconfig.base.json'),
|
||||
external: id => isBuiltin(id),
|
||||
// The guest imports pure helpers from package barrels, without mounting their services.
|
||||
treeshake: { moduleSideEffects: false },
|
||||
onLog(level, log, defaultHandler) {
|
||||
if (log.code === 'UNRESOLVED_IMPORT') throw new Error(`workflow guest has an unresolved import: ${log.message}`)
|
||||
defaultHandler(level, log)
|
||||
},
|
||||
})
|
||||
try {
|
||||
const { output } = await bundle.generate({ format: 'esm', codeSplitting: false, sourcemap: false })
|
||||
const chunk = output[0]
|
||||
if (output.length !== 1) {
|
||||
throw new Error('workflow guest must bundle into exactly one JavaScript module')
|
||||
}
|
||||
const external = [...chunk.imports, ...chunk.dynamicImports].filter(id => !isBuiltin(id))
|
||||
if (external.length > 0) throw new Error(`workflow guest imports files outside its module: ${external.join(', ')}`)
|
||||
return `${chunk.code.trimEnd()}\n//# sourceURL=dsh-workflow-guest.js\n`
|
||||
} finally {
|
||||
await bundle.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the guest-source module or reject a stale committed copy.
|
||||
* @param root - Repository root whose guest sources are bundled.
|
||||
* @param check - Verify without writing when true.
|
||||
*/
|
||||
export async function generateWorkflowGuest(root: string, check: boolean): Promise<void> {
|
||||
const source = await bundleWorkflowGuest(root)
|
||||
const generated = [
|
||||
'/** Generated by scripts/gen-workflow-guest.ts. Do not edit directly. */',
|
||||
'',
|
||||
'/** Self-contained ESM guest transported into the mounted PTC runtime. */',
|
||||
`export const WORKFLOW_GUEST_SOURCE = ${JSON.stringify(source)}`,
|
||||
'',
|
||||
].join('\n')
|
||||
const path = resolve(root, GUEST_SOURCE)
|
||||
if (check) {
|
||||
const current = await readFile(path, 'utf8')
|
||||
if (current !== generated) throw new Error('workflow guest source is stale; run pnpm gen-workflow-guest')
|
||||
} else {
|
||||
await writeFile(path, generated)
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const { values } = parseArgs({ options: { check: { type: 'boolean', default: false } } })
|
||||
await generateWorkflowGuest(resolve(import.meta.dirname, '..'), values.check)
|
||||
}
|
||||
@@ -91,6 +91,8 @@ try {
|
||||
}
|
||||
const delay = Number(process.env.DSH_TEST_LEFTHOOK_DELAY_MS ?? 0)
|
||||
if (delay > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay)
|
||||
const replaceLockPath = process.env.DSH_TEST_LEFTHOOK_REPLACE_LOCK_PATH
|
||||
if (replaceLockPath !== undefined) writeFileSync(replaceLockPath, 'replacement owner\\n')
|
||||
const shouldFail = process.env.DSH_TEST_LEFTHOOK_FAIL === '1'
|
||||
if (!shouldFail) {
|
||||
const binary = join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'lefthook.cmd' : 'lefthook')
|
||||
@@ -532,21 +534,14 @@ describe('worktree-local Lefthook installer', { timeout: 90_000 }, () => {
|
||||
it('does not release an installer lock whose ownership changed', async () => {
|
||||
const fixture = createFixture()
|
||||
const lockPath = installLockPath(fixture)
|
||||
const runningPath = join(hooksPath(fixture, fixture.main), '.fake-lefthook-running')
|
||||
const install = runInstaller(fixture, fixture.main, { DSH_TEST_LEFTHOOK_DELAY_MS: '250' })
|
||||
try {
|
||||
await waitForPath(runningPath)
|
||||
} catch (error) {
|
||||
await install
|
||||
throw error
|
||||
}
|
||||
const replacementRecord = 'replacement owner\n'
|
||||
writeFileSync(lockPath, replacementRecord)
|
||||
// The fake child replaces the record while the installer holds the lock.
|
||||
const result = await runInstaller(fixture, fixture.main, {
|
||||
DSH_TEST_LEFTHOOK_REPLACE_LOCK_PATH: lockPath,
|
||||
})
|
||||
|
||||
const result = await install
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('installer lock ownership changed')
|
||||
expect(readFileSync(lockPath, 'utf8')).toBe(replacementRecord)
|
||||
expect(readFileSync(lockPath, 'utf8')).toBe('replacement owner\n')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('preserves trailing spaces in worktree paths', async () => {
|
||||
|
||||
@@ -30,7 +30,7 @@ const profiles = {
|
||||
test: {
|
||||
count: 84,
|
||||
indexes: [0, 3, 4, 5],
|
||||
sha256: '7800bf57f0d689a5623e4ad60315b10173158fb9cf988d3f005cb115f2245691',
|
||||
sha256: '7e4e1ed0cfca06ac94eeb26081195b5f753e7527775d5f5b95ccfefac9d2b137',
|
||||
},
|
||||
} as const satisfies Record<string, Profile>
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/** Baseline package selection through Node's real filesystem glob implementation. */
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { describe, expect, it, onTestFinished } from 'vitest'
|
||||
import { PRIVATE_EXPERIMENTAL_PACKAGE_DIRECTORIES } from './experimental-package-policy.ts'
|
||||
import { discoverNpmBaselineManifests } from './npm-baseline-packages.ts'
|
||||
|
||||
function fixture(manifests: readonly string[]): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-baseline-discovery-'))
|
||||
onTestFinished(() => { rmSync(root, { recursive: true, force: true }) })
|
||||
for (const manifest of manifests) {
|
||||
const file = join(root, manifest)
|
||||
mkdirSync(dirname(file), { recursive: true })
|
||||
writeFileSync(file, '{}\n')
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
describe('npm baseline package discovery', () => {
|
||||
it('discovers ordinary package roots and unlisted experimental packages', () => {
|
||||
const expected = [
|
||||
'apps/cli/package.json',
|
||||
'packages/core/session/package.json',
|
||||
'packages/experimental/agent-team/package.json',
|
||||
'packages/experimental/new-prototype/package.json',
|
||||
'vendor/cordis/package.json',
|
||||
]
|
||||
const root = fixture([
|
||||
...expected,
|
||||
'package.json',
|
||||
'packages/experimental/new-prototype/nested/package.json',
|
||||
'website/package.json',
|
||||
])
|
||||
|
||||
expect(discoverNpmBaselineManifests(root)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('excludes every directory in the current publication denylist', () => {
|
||||
const allowed = 'packages/experimental/new-prototype/package.json'
|
||||
const root = fixture([
|
||||
allowed,
|
||||
...PRIVATE_EXPERIMENTAL_PACKAGE_DIRECTORIES.map(directory => `${directory}/package.json`),
|
||||
])
|
||||
|
||||
expect(discoverNpmBaselineManifests(root)).toEqual([allowed])
|
||||
})
|
||||
|
||||
it('excludes an exact configured directory without excluding nearby names', () => {
|
||||
const root = fixture([
|
||||
'packages/experimental/internal/package.json',
|
||||
'packages/experimental/internal-tools/package.json',
|
||||
'packages/experimental/other/package.json',
|
||||
])
|
||||
expect(discoverNpmBaselineManifests(root, ['packages/experimental/internal'])).toEqual([
|
||||
'packages/experimental/internal-tools/package.json',
|
||||
'packages/experimental/other/package.json',
|
||||
])
|
||||
})
|
||||
|
||||
it('retains experimental packages when the configured denylist is empty', () => {
|
||||
const manifests = [
|
||||
'packages/experimental/inspector/package.json',
|
||||
'packages/experimental/new-prototype/package.json',
|
||||
]
|
||||
expect(discoverNpmBaselineManifests(fixture(manifests), [])).toEqual(manifests)
|
||||
})
|
||||
|
||||
it('leaves an empty package set for the baseline caller to reject', () => {
|
||||
expect(discoverNpmBaselineManifests(fixture([]))).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Manifest discovery for the npm baseline's vendor, harness, and application packages. */
|
||||
|
||||
import { globSync } from 'node:fs'
|
||||
import { PRIVATE_EXPERIMENTAL_PACKAGE_DIRECTORIES } from './experimental-package-policy.ts'
|
||||
|
||||
const PACKAGE_PATTERNS = [
|
||||
'vendor/*/package.json',
|
||||
'packages/*/*/package.json',
|
||||
'apps/*/package.json',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Discover baseline manifests while excluding the configured private experimental directories.
|
||||
* @param root - repository root to scan.
|
||||
* @param privateDirectories - repository-relative experimental directories excluded from publication.
|
||||
* @returns Sorted repository-relative manifest paths with forward slashes.
|
||||
*/
|
||||
export function discoverNpmBaselineManifests(
|
||||
root: string,
|
||||
privateDirectories: readonly string[] = PRIVATE_EXPERIMENTAL_PACKAGE_DIRECTORIES,
|
||||
): string[] {
|
||||
return globSync(PACKAGE_PATTERNS, {
|
||||
cwd: root,
|
||||
exclude: privateDirectories.map(directory => `${directory}/package.json`),
|
||||
}).map(path => path.replaceAll('\\', '/')).sort()
|
||||
}
|
||||
@@ -254,6 +254,99 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 +
|
||||
}
|
||||
}, 90_000)
|
||||
|
||||
it('allows Session history reads only in tests or with existing-call waivers', async () => {
|
||||
const suffix = randomUUID()
|
||||
const configPath = await writeContractConfig(suffix)
|
||||
const exampleRoot = `examples/oxlint-contract-${suffix}`
|
||||
const examplePath = `${exampleRoot}/tests/reads.ts`
|
||||
const testPaths = [
|
||||
`packages/core/session/tests/oxlint-contract-${suffix}.ts`,
|
||||
`apps/cli/tests/oxlint-contract-${suffix}.ts`,
|
||||
examplePath,
|
||||
`scripts/oxlint-contract-${suffix}.spec.ts`,
|
||||
]
|
||||
const productionPaths = [
|
||||
`packages/core/session/src/oxlint-contract-${suffix}.ts`,
|
||||
`scripts/oxlint-contract-${suffix}.ts`,
|
||||
]
|
||||
const paths = [...testPaths, ...productionPaths]
|
||||
const reads = `import { Session, SessionSeq } from '@deepseek-ai/dsh-session'
|
||||
|
||||
export function reads(session: Session): void {
|
||||
session.snapshotEvents()
|
||||
session.eventAt(SessionSeq(0))
|
||||
session.ownEvents()
|
||||
}
|
||||
`
|
||||
const existing = `import { Session, SessionSeq } from '@deepseek-ai/dsh-session'
|
||||
|
||||
export function reads(session: Session): void {
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
|
||||
session.snapshotEvents()
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
|
||||
session.eventAt(SessionSeq(0))
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
|
||||
session.ownEvents()
|
||||
}
|
||||
`
|
||||
const unrelated = `
|
||||
/** @deprecated Use the replacement API. */
|
||||
function oldApi(): void {}
|
||||
|
||||
export function unrelatedRead(): void {
|
||||
oldApi()
|
||||
}
|
||||
`
|
||||
|
||||
try {
|
||||
await mkdir(join(repositoryRoot, exampleRoot, 'tests'), { recursive: true })
|
||||
await writeFile(join(repositoryRoot, exampleRoot, 'tsconfig.json'), JSON.stringify({
|
||||
extends: '../../tsconfig.base.json',
|
||||
include: ['tests/**/*.ts'],
|
||||
}))
|
||||
await Promise.all([
|
||||
...testPaths.map(path => writeFile(join(repositoryRoot, path), reads)),
|
||||
...productionPaths.map(path => writeFile(join(repositoryRoot, path), existing)),
|
||||
])
|
||||
const args = ['--config', relative(repositoryRoot, configPath), '--format', 'unix', ...paths]
|
||||
const allowed = runRepositoryOxlint(args)
|
||||
expect(allowed.error).toBeUndefined()
|
||||
expect(allowed.signal).toBeNull()
|
||||
expect(allowed.status, normalizedOutput(allowed)).toBe(0)
|
||||
|
||||
await Promise.all([
|
||||
...testPaths.map(path => writeFile(join(repositoryRoot, path), reads + unrelated)),
|
||||
...productionPaths.map(path => writeFile(join(repositoryRoot, path), reads)),
|
||||
])
|
||||
const rejected = runRepositoryOxlint(args)
|
||||
const output = normalizedOutput(rejected)
|
||||
expect(rejected.error).toBeUndefined()
|
||||
expect(rejected.signal).toBeNull()
|
||||
expect(rejected.status, output).toBe(1)
|
||||
const diagnostics = output.split('\n').filter(line => /:\d+:\d+: `\w+` is deprecated\./.test(line))
|
||||
for (const path of testPaths) {
|
||||
const reported = diagnostics.filter(line => line.startsWith(`${path}:`))
|
||||
expect(reported, output).toHaveLength(1)
|
||||
expect(reported[0]).toContain('`oldApi` is deprecated')
|
||||
}
|
||||
for (const path of productionPaths) {
|
||||
expect(diagnostics.filter(line => line.startsWith(`${path}:`)), output).toHaveLength(3)
|
||||
}
|
||||
for (const method of ['snapshotEvents', 'eventAt', 'ownEvents', 'oldApi']) {
|
||||
expect(output).toContain(`\`${method}\` is deprecated`)
|
||||
}
|
||||
expect(output).toContain(
|
||||
'See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md).',
|
||||
)
|
||||
} finally {
|
||||
await Promise.all([
|
||||
...paths.filter(path => path !== examplePath).map(path => rm(join(repositoryRoot, path), { force: true })),
|
||||
rm(join(repositoryRoot, exampleRoot), { recursive: true, force: true }),
|
||||
rm(configPath, { force: true }),
|
||||
])
|
||||
}
|
||||
}, 90_000)
|
||||
|
||||
it('accepts an ignored-only staged selection', () => {
|
||||
const result = runOxlint([
|
||||
'--fix',
|
||||
|
||||
@@ -54,6 +54,7 @@ const SAFE_HOST_DEPENDENCY_EXPORTS = {
|
||||
|
||||
/** Runtime exports that require every consumer to resolve the provider's shared peer instance. */
|
||||
const PEER_REQUIRED_HOST_EXPORTS = {
|
||||
'@deepseek-ai/dsh-subprocess': ['SubprocessExecutableNotFoundError'],
|
||||
'@deepseek-ai/dsh-scope': ['carrierKeyOf', 'scopeOf', 'scopeTarget'],
|
||||
'@deepseek-ai/dsh-session': ['SESSION_FORMAT_VERSION'],
|
||||
'@deepseek-ai/dsh-session-persistence': ['SessionPersistenceNotFoundError'],
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/** Render complete persistence documentation pairs without Git or file mutation. */
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { hasLanguageSwitcher } from './translation-links.ts'
|
||||
import { translationPairPaths } from './translation-pairing-record.ts'
|
||||
import {
|
||||
blobHash, languageSwitcherTargets, parseTranslationMarkdown, parseTranslationPairingManifest,
|
||||
renderPairMeta, requiresSourceLanguageSwitcher, translationPairSourcePredicate,
|
||||
translationStructureDiff, translationStructureSignature,
|
||||
} from './translation-pairing.ts'
|
||||
|
||||
const isTranslationPairSource = translationPairSourcePredicate(parseTranslationPairingManifest(
|
||||
readFileSync(new URL('./translation-pairing.manifest.json', import.meta.url), 'utf8'),
|
||||
))
|
||||
|
||||
/** One repository-relative generated file and its complete UTF-8 content. */
|
||||
export interface PersistenceArtifact {
|
||||
readonly path: string
|
||||
readonly content: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a pair's code, structure and localized links, then render its three files.
|
||||
* Link existence remains the Markdown gate's responsibility. Pair hashes are
|
||||
* computed in-process; staging the documents stores the corresponding Git blobs.
|
||||
* @param root - checkout root used to resolve relative link identities.
|
||||
* @param source - repository-relative English document path.
|
||||
* @param en - complete authored or generated English Markdown.
|
||||
* @param zh - complete authored or generated Chinese Markdown.
|
||||
* @returns the documents and their matching consistency sidecar, without writing files.
|
||||
*/
|
||||
export function renderPersistencePair(root: string, source: string, en: string, zh: string): PersistenceArtifact[] {
|
||||
const paths = translationPairPaths(source)
|
||||
const sourceTree = parseTranslationMarkdown(en)
|
||||
const zhTree = parseTranslationMarkdown(zh)
|
||||
const sourceTargets = languageSwitcherTargets(paths.source)
|
||||
const zhTargets = languageSwitcherTargets(paths.zh)
|
||||
if (!hasLanguageSwitcher(zhTree, zh, sourceTargets)
|
||||
|| requiresSourceLanguageSwitcher(source) && !hasLanguageSwitcher(sourceTree, en, zhTargets)) {
|
||||
throw new Error(`${source}: both authored languages need their counterpart switcher`)
|
||||
}
|
||||
const context = { repoRoot: root, isTranslationPairSource, repositoryFileExists: () => true }
|
||||
const errors = translationStructureDiff(
|
||||
translationStructureSignature(sourceTree, zhTargets, { ...context, sourcePath: paths.source, markdown: en }),
|
||||
translationStructureSignature(zhTree, sourceTargets, { ...context, sourcePath: paths.zh, markdown: zh }),
|
||||
)
|
||||
if (errors.length > 0) throw new Error(`${source}: bilingual structure mismatch: ${errors.join('; ')}`)
|
||||
return [
|
||||
{ path: paths.source, content: en },
|
||||
{ path: paths.zh, content: zh },
|
||||
{ path: paths.meta, content: renderPairMeta(paths.source, blobHash(Buffer.from(en)), paths.zh, blobHash(Buffer.from(zh))) },
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/** Source discovery and declaration validation shared by persistence documentation and schemas. */
|
||||
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** The package that owns the durable event vocabulary. */
|
||||
const SESSION_PACKAGE = '@deepseek-ai/dsh-session'
|
||||
|
||||
/** The type-only module that plugin declaration merges augment. */
|
||||
const SESSION_TYPES_MODULE = '@deepseek-ai/dsh-session/types'
|
||||
|
||||
/** Event-envelope declarations rendered before the per-event vocabulary. */
|
||||
const EVENT_ENVELOPE_TYPE_NAMES = [
|
||||
'SessionEventType',
|
||||
'SurfaceEventType',
|
||||
'SurfaceOp',
|
||||
'SessionEvent',
|
||||
] as const
|
||||
|
||||
type EventEnvelopeTypeName = typeof EVENT_ENVELOPE_TYPE_NAMES[number]
|
||||
|
||||
/** One log event, extracted from a `SessionEventMap` declaration. */
|
||||
export interface LogEventEntry {
|
||||
/** Scoped name, e.g. `turn/start`. */
|
||||
name: string
|
||||
/** The scope prefix, e.g. `turn` (everything before the first `/`). */
|
||||
scope: string
|
||||
/** Payload type text (the member's type annotation, whitespace-collapsed). */
|
||||
payload: string
|
||||
/** Source member declaration and complete JSDoc, dedented from its container. */
|
||||
declaration: string
|
||||
/** Description prose (the member's JSDoc), one line per paragraph. */
|
||||
doc: string
|
||||
/** Source pointer `packages/…/file.ts:line` of the declaration. */
|
||||
source: string
|
||||
/** Whether interpretation requires a plugin-owned message projection. */
|
||||
messageProjection?: true
|
||||
}
|
||||
|
||||
/** A {@link LogEventEntry} plus its surface-eligibility badge. */
|
||||
export interface AnnotatedLogEventEntry extends LogEventEntry {
|
||||
/** Whether the type is a `SurfaceEventType` member (may carry `surfaceOp`). */
|
||||
surface: boolean
|
||||
}
|
||||
|
||||
/** One owning event-envelope declaration pasted into the generated catalog. */
|
||||
export interface EventEnvelopeTypeEntry {
|
||||
/** Exported declaration name. */
|
||||
name: EventEnvelopeTypeName
|
||||
/** Verbatim type declaration, including its complete leading JSDoc. */
|
||||
declaration: string
|
||||
/** Source pointer `packages/…/file.ts:line` of the declaration. */
|
||||
source: string
|
||||
}
|
||||
|
||||
const printer = ts.createPrinter({ removeComments: true })
|
||||
|
||||
/**
|
||||
* Render a member type on one line through the TypeScript printer, which adds
|
||||
* semicolon separators. Drop its trailing semicolon before `}` to match the
|
||||
* repository's inline-literal style.
|
||||
*/
|
||||
function payloadText(type: ts.TypeNode, sf: ts.SourceFile): string {
|
||||
return printer.printNode(ts.EmitHint.Unspecified, type, sf)
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/;\s*\}/g, ' }')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a declaration from its leading JSDoc through its closing token while
|
||||
* removing only the indentation imposed by its containing interface/module.
|
||||
*/
|
||||
function declarationText(text: string, sf: ts.SourceFile, node: ts.Node): string {
|
||||
const raw = rawJsDoc(text, node)
|
||||
const nodeStart = node.getStart(sf)
|
||||
const start = raw ? text.lastIndexOf(raw, nodeStart) : nodeStart
|
||||
const { line } = sf.getLineAndCharacterOfPosition(start)
|
||||
const lineStart = sf.getPositionOfLineAndCharacter(line, 0)
|
||||
const indent = text.slice(lineStart, start)
|
||||
return text.slice(lineStart, node.end)
|
||||
.split('\n')
|
||||
.map(lineText => lineText.startsWith(indent) ? lineText.slice(indent.length) : lineText)
|
||||
.join('\n')
|
||||
.trimEnd()
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `interface SessionEventMap` declaration in a source file: the owning
|
||||
* top-level declaration (in `@deepseek-ai/dsh-session`) and any declaration
|
||||
* merge inside a `declare module '@deepseek-ai/dsh-session/types'` block. Both forms
|
||||
* declare members of the SAME merged interface, so both are catalogued
|
||||
* uniformly. `topLevel` distinguishes the owning form so the caller can verify
|
||||
* it actually lives in the owning package — an unrelated local interface that
|
||||
* happens to share the name must not be catalogued as the on-disk vocabulary.
|
||||
*/
|
||||
function sessionEventMapDecls(sf: ts.SourceFile): { decl: ts.InterfaceDeclaration; topLevel: boolean }[] {
|
||||
const decls: { decl: ts.InterfaceDeclaration; topLevel: boolean }[] = []
|
||||
for (const stmt of sf.statements) {
|
||||
if (ts.isInterfaceDeclaration(stmt) && stmt.name.text === 'SessionEventMap') decls.push({ decl: stmt, topLevel: true })
|
||||
if (ts.isModuleDeclaration(stmt) && ts.isStringLiteral(stmt.name) && stmt.name.text === SESSION_TYPES_MODULE
|
||||
&& stmt.body && ts.isModuleBlock(stmt.body)) {
|
||||
for (const inner of stmt.body.statements) {
|
||||
if (ts.isInterfaceDeclaration(inner) && inner.name.text === 'SessionEventMap') decls.push({ decl: inner, topLevel: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
return decls
|
||||
}
|
||||
|
||||
/**
|
||||
* The npm package name owning a `packages/<group>/<pkg>/…` source file, read
|
||||
* from that package's manifest — or null when the manifest is missing or
|
||||
* unparseable (the caller treats null as "ownership unverifiable").
|
||||
*/
|
||||
function packageNameFor(rel: string, scanRoot: string): string | null {
|
||||
const dir = rel.split('/').slice(0, 3).join('/')
|
||||
try {
|
||||
const manifest = JSON.parse(readFileSync(resolve(scanRoot, dir, 'package.json'), 'utf8')) as { name?: string }
|
||||
return typeof manifest.name === 'string' ? manifest.name : null
|
||||
} catch {
|
||||
// Missing or malformed package.json — every real workspace package has one,
|
||||
// so this only arises in stripped-down fixture trees; either way ownership
|
||||
// cannot be verified and the caller reports the declaration.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every `SessionEventMap` merge, rejecting inherited, non-literal,
|
||||
* untyped, undocumented, duplicate, or incorrectly owned members in one report.
|
||||
*/
|
||||
export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
|
||||
const entries: LogEventEntry[] = []
|
||||
const violations: string[] = []
|
||||
const seen = new Map<string, string>()
|
||||
let owningDecl: string | null = null
|
||||
for (const rel of globSync('packages/*/*/src/**/*.{ts,tsx}', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
|
||||
const abs = resolve(scanRoot, rel)
|
||||
const text = readFileSync(abs, 'utf8')
|
||||
if (!text.includes('SessionEventMap')) continue
|
||||
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
|
||||
for (const { decl, topLevel } of sessionEventMapDecls(sf)) {
|
||||
const declSrc = pointer(rel, sf, decl)
|
||||
if (topLevel) {
|
||||
// The top-level form has one home: the single exported declaration in
|
||||
// the owning package. Same-named interfaces elsewhere are different
|
||||
// types and must not enter the on-disk catalog.
|
||||
const pkg = packageNameFor(rel, scanRoot)
|
||||
if (pkg !== SESSION_PACKAGE) {
|
||||
violations.push(`top-level interface SessionEventMap (${declSrc}) is outside ${SESSION_PACKAGE} (package ${pkg ?? 'unknown'}). Rename the interface, or contribute events via declare module '${SESSION_TYPES_MODULE}'.`)
|
||||
continue
|
||||
}
|
||||
const exported = decl.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false
|
||||
if (!exported) {
|
||||
violations.push(`top-level interface SessionEventMap (${declSrc}) is not exported; the owning vocabulary is the single exported declaration — rename a local helper interface.`)
|
||||
continue
|
||||
}
|
||||
if (owningDecl) {
|
||||
violations.push(`top-level interface SessionEventMap (${declSrc}) is already declared at ${owningDecl}; the owning vocabulary has exactly one home.`)
|
||||
continue
|
||||
}
|
||||
owningDecl = declSrc
|
||||
}
|
||||
if (decl.heritageClauses?.length) {
|
||||
violations.push(`SessionEventMap declaration (${declSrc}) uses extends; inherited keys would join keyof SessionEventMap without a catalog row — declare event members directly.`)
|
||||
}
|
||||
for (const member of decl.members) {
|
||||
const src = pointer(rel, sf, member)
|
||||
if (!ts.isPropertySignature(member) || !member.type) {
|
||||
// A method-form or type-less member still joins `keyof SessionEventMap`,
|
||||
// so skipping it silently would be exactly the undocumented-event hole
|
||||
// this catalog exists to close.
|
||||
const label = (member as { name?: ts.Node }).name?.getText(sf) ?? member.getText(sf).replace(/\s+/g, ' ')
|
||||
violations.push(`SessionEventMap member ${label} (${src}) is not a property signature with an explicit payload type; declare every log event as 'scope/name': <payload>.`)
|
||||
continue
|
||||
}
|
||||
if (!ts.isStringLiteral(member.name)) {
|
||||
violations.push(`log event at ${src} has a non-literal name; the catalog needs string-literal event names.`)
|
||||
continue
|
||||
}
|
||||
const name = member.name.text
|
||||
const where = `log event '${name}' (${src})`
|
||||
const prior = seen.get(name)
|
||||
if (prior) {
|
||||
violations.push(`${where} is already declared at ${prior}; an event type has exactly one declaration.`)
|
||||
continue
|
||||
}
|
||||
seen.set(name, src)
|
||||
const payload = payloadText(member.type, sf)
|
||||
const { doc, hasMode } = parseJsDoc(rawJsDoc(text, member))
|
||||
if (hasMode) {
|
||||
violations.push(`${where} carries an @mode tag, but a log event has no dispatch mode (it is not a cordis bus event — it rides the 'session/event' emit). Remove the tag.`)
|
||||
}
|
||||
if (!doc) {
|
||||
violations.push(`${where} has no description prose. Say what the event records and what its payload means — the JSDoc becomes the catalog entry.`)
|
||||
}
|
||||
const declaration = declarationText(text, sf, member)
|
||||
const messageProjection = ts.getJSDocTags(member).some(tag => tag.tagName.text === 'messageProjection')
|
||||
entries.push({ name, scope: name.split('/')[0] ?? name, payload, declaration, doc, source: src, ...messageProjection ? { messageProjection: true as const } : {} })
|
||||
}
|
||||
}
|
||||
}
|
||||
reportViolations('gen-persistence-catalog', violations)
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the exported declarations that compose the persisted event envelope,
|
||||
* preserving their source JSDoc and declaration text.
|
||||
*/
|
||||
export function collectEventEnvelopeTypes(scanRoot: string = root): EventEnvelopeTypeEntry[] {
|
||||
const found = new Map<EventEnvelopeTypeName, EventEnvelopeTypeEntry>()
|
||||
const violations: string[] = []
|
||||
const wanted = new Set<string>(EVENT_ENVELOPE_TYPE_NAMES)
|
||||
for (const rel of globSync('packages/*/*/src/**/*.{ts,tsx}', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
|
||||
const abs = resolve(scanRoot, rel)
|
||||
const text = readFileSync(abs, 'utf8')
|
||||
if (!EVENT_ENVELOPE_TYPE_NAMES.some(name => text.includes(name))) continue
|
||||
if (packageNameFor(rel, scanRoot) !== SESSION_PACKAGE) continue
|
||||
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
|
||||
for (const stmt of sf.statements) {
|
||||
if (!ts.isTypeAliasDeclaration(stmt) || !wanted.has(stmt.name.text)) continue
|
||||
const name = stmt.name.text as EventEnvelopeTypeName
|
||||
const src = pointer(rel, sf, stmt)
|
||||
const where = `event-envelope type '${name}' (${src})`
|
||||
const prior = found.get(name)
|
||||
if (prior) {
|
||||
violations.push(`${where} is already declared at ${prior.source}; the persisted envelope type has exactly one owner.`)
|
||||
continue
|
||||
}
|
||||
if (!(stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)) {
|
||||
violations.push(`${where} is not exported.`)
|
||||
}
|
||||
const { doc, hasMode } = parseJsDoc(rawJsDoc(text, stmt))
|
||||
if (hasMode) violations.push(`${where} carries an @mode tag, but a persisted type has no dispatch mode.`)
|
||||
if (!doc) violations.push(`${where} has no description prose. The full JSDoc is part of the generated catalog.`)
|
||||
found.set(name, { name, declaration: declarationText(text, sf, stmt), source: src })
|
||||
}
|
||||
}
|
||||
const missing = EVENT_ENVELOPE_TYPE_NAMES.filter(name => !found.has(name))
|
||||
if (missing.length > 0) {
|
||||
violations.push(`missing event-envelope declaration(s): ${missing.join(', ')}.`)
|
||||
}
|
||||
reportViolations('gen-persistence-catalog', violations)
|
||||
return EVENT_ENVELOPE_TYPE_NAMES.map((name) => {
|
||||
const entry = found.get(name)
|
||||
if (!entry) throw new Error(`gen-persistence-catalog: missing checked event-envelope declaration '${name}'.`)
|
||||
return entry
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `SurfaceEventType` union — the surface-eligible subset of event
|
||||
* types — from source. Hard-errors when the alias is missing, declared more
|
||||
* than once, or contains a non-string-literal member: the badge derivation
|
||||
* relies on the union being a closed set of literal event names.
|
||||
* `scanRoot` defaults to the repo root; tests pass a fixture dir.
|
||||
*/
|
||||
export function collectSurfaceEventTypes(scanRoot: string = root): string[] {
|
||||
const found: { names: string[]; source: string }[] = []
|
||||
for (const rel of globSync('packages/*/*/src/**/*.{ts,tsx}', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
|
||||
const abs = resolve(scanRoot, rel)
|
||||
const text = readFileSync(abs, 'utf8')
|
||||
if (!text.includes('SurfaceEventType')) continue
|
||||
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
|
||||
for (const stmt of sf.statements) {
|
||||
if (!ts.isTypeAliasDeclaration(stmt) || stmt.name.text !== 'SurfaceEventType') continue
|
||||
const src = pointer(rel, sf, stmt)
|
||||
const members = ts.isUnionTypeNode(stmt.type) ? [...stmt.type.types] : [stmt.type]
|
||||
const names: string[] = []
|
||||
for (const m of members) {
|
||||
if (ts.isLiteralTypeNode(m) && ts.isStringLiteral(m.literal)) names.push(m.literal.text)
|
||||
else throw new Error(`gen-persistence-catalog: SurfaceEventType (${src}) has a non-string-literal member; the badge derivation needs a closed literal union.`)
|
||||
}
|
||||
found.push({ names, source: src })
|
||||
}
|
||||
}
|
||||
const only = found[0]
|
||||
if (!only) throw new Error('gen-persistence-catalog: no SurfaceEventType union found under packages/*/*/src.')
|
||||
if (found.length > 1) throw new Error(`gen-persistence-catalog: SurfaceEventType is declared more than once (${found.map(f => f.source).join(', ')}); the surface subset has exactly one owner.`)
|
||||
return only.names
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the surface/log-only badge to each event. Hard-errors when a
|
||||
* `SurfaceEventType` union member names no collected event — a stale union
|
||||
* member would otherwise silently badge nothing.
|
||||
*/
|
||||
export function annotateSurface(events: LogEventEntry[], surfaceTypes: string[]): AnnotatedLogEventEntry[] {
|
||||
const names = new Set(events.map(e => e.name))
|
||||
const stale = surfaceTypes.filter(t => !names.has(t))
|
||||
if (stale.length > 0) {
|
||||
throw new Error(`gen-persistence-catalog: SurfaceEventType member(s) ${stale.map(t => `'${t}'`).join(', ')} name no declared log event (stale union member?).`)
|
||||
}
|
||||
const surface = new Set(surfaceTypes)
|
||||
for (const event of events) {
|
||||
if (event.messageProjection && surface.has(event.name)) {
|
||||
throw new Error(`gen-persistence-catalog: '${event.name}' cannot declare both a message projection and surface operations.`)
|
||||
}
|
||||
}
|
||||
return events.map(e => ({ ...e, surface: surface.has(e.name) }))
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/** Authored language pairs for generated persistence reference prose. */
|
||||
|
||||
/** Language of a generated persistence reference. */
|
||||
export type PersistenceCatalogLocale = 'en' | 'zh'
|
||||
|
||||
const english = {
|
||||
title: 'Session Persistence Event Catalog',
|
||||
intro: 'Every repository-declared durable Session event appears here with its source declaration and resolved types. The catalog covers the logical and physical headers, event envelopes, and every plugin declaration merge. See [Session](subsystems/session.md) for replay and [persistence](subsystems/persistence.md) for storage.',
|
||||
generation: 'Run `pnpm run gen-persistence-catalog` to regenerate both catalog languages, their pairing record, the known-event module, and the machine schema inventory. `pnpm run verify-persistence-catalog` checks all generated files. Declaration fences preserve source JSDoc and type references; resolved definitions expose their transitive structure.',
|
||||
envelopeIntro: 'The envelope carries `type`, `seq`, `time`, `data`, optional `ignorable`, and conditional `surfaceOp` / `sourceEventSeqs`. A **surface** event produces model history; a **log-only** event does not. The inventory covers this repository; external plugin types require their own declarations and are outside this catalog.',
|
||||
envelope: 'Event envelope', events: 'Events', sources: 'Sources: ', source: 'Source: ', types: 'Types: ',
|
||||
fingerprints: 'Persistence type fingerprints',
|
||||
fingerprintsIntro: 'The [machine inventory](persistence-schema.json) contains every reachable normalized type and its SHA-256 digest. Root digests include referenced types. Comments, source locations, alias names, erased brands, readonly markers, and harmless field, union, or intersection reordering do not affect these fingerprints. Tuple order, property names, value types, and optionality do. Catalog text and source locations can still produce a diff when digests stay unchanged.',
|
||||
historyIntro: 'The [format references](persistence-changes/historical-formats/README.md) cover every historical Session format. The [change records](persistence-changes/README.md) acknowledge exact transitions using snapshots kept in this tree. Follow the [review workflow](cookbook/reviewing-persistence-type-changes.md) to classify a change and record it. These checks cover declared type structure; opaque payload contents and behavior without type changes are outside their scope.',
|
||||
rootColumns: '| Root | Kind | SHA-256 | Resolved type |',
|
||||
definitions: 'Resolved persistence types',
|
||||
definitionsIntro: 'Each definition appears once. References preserve sharing and recursion; the digest beside a definition includes its complete reachable structure. Source names and locations identify its declarations but are excluded from its digest.',
|
||||
propertyColumns: '| Property | Presence | Type |', positionColumns: '| Position | Presence | Type |',
|
||||
optional: 'optional', required: 'required', rest: 'rest', index: 'index signature',
|
||||
emptyObject: 'Object with no declared properties.', arrayPrefix: 'Array of ', arraySuffix: '.',
|
||||
oneOf: 'One of:', opaque: ' (opaque)', opaqueExplanation: ": the declaration does not expose the stored value's internal fields.",
|
||||
}
|
||||
|
||||
const chinese: Record<keyof typeof english, string> = {
|
||||
title: '会话持久化事件目录',
|
||||
intro: '本目录列出仓库声明的每个持久化 Session 事件及其源码声明和解析类型,覆盖逻辑与物理 header、事件信封以及各插件的声明合并。回放规则参见 [Session](subsystems/session.zh.md),存储规则参见[持久化](subsystems/persistence.zh.md)。',
|
||||
generation: '运行 `pnpm run gen-persistence-catalog` 可重新生成目录的两种语言、配对记录、已知事件模块和机器 schema 目录。`pnpm run verify-persistence-catalog` 检查所有生成文件。声明围栏保留源码 JSDoc 和类型引用;解析后的定义展开其传递引用结构。',
|
||||
envelopeIntro: '信封包含 `type`、`seq`、`time`、`data`、可选的 `ignorable` 以及条件字段 `surfaceOp` / `sourceEventSeqs`。**surface** 事件产生模型历史,**log-only** 事件不产生模型历史。目录覆盖本仓库;外部插件类型需要独立声明,不属于本目录。',
|
||||
envelope: '事件信封', events: '事件', sources: '来源:', source: '来源:', types: '类型:',
|
||||
fingerprints: '持久化类型指纹',
|
||||
fingerprintsIntro: '[机器可读目录](persistence-schema.json)包含所有可达的规范化类型及其 SHA-256 摘要。根类型的摘要涵盖引用类型。注释、源码位置、别名、擦除的品牌标记、readonly 标记以及无语义变化的字段、联合类型或交叉类型重排不影响摘要;元组顺序、属性名称、值类型和可选性会影响摘要。摘要不变时,目录文本和源码位置仍可能产生 diff。',
|
||||
historyIntro: '[格式参考](persistence-changes/historical-formats/README.zh.md)覆盖每个历史 Session 格式。[变更记录](persistence-changes/README.zh.md)通过保存在本源码树中的快照确认精确的类型转换。按照[评审流程](cookbook/reviewing-persistence-type-changes.zh.md)分类并记录变更。这些检查覆盖已声明的类型结构;不透明载荷的内部内容和未改变类型的行为变更不在检查范围内。',
|
||||
rootColumns: '| 根类型 | 类别 | SHA-256 | 已解析类型 |',
|
||||
definitions: '已解析的持久化类型',
|
||||
definitionsIntro: '每个类型定义仅列出一次。引用保留共享和递归关系;定义旁的摘要涵盖其完整可达结构。源码名称和位置标识声明来源,但不参与摘要计算。',
|
||||
propertyColumns: '| 属性 | 存在性 | 类型 |', positionColumns: '| 位置 | 存在性 | 类型 |',
|
||||
optional: '可选', required: '必需', rest: '剩余项', index: '索引签名',
|
||||
emptyObject: '无已声明属性的对象。', arrayPrefix: '', arraySuffix: ' 的数组。',
|
||||
oneOf: '以下类型之一:', opaque: '(不透明)', opaqueExplanation: ':此声明未暴露存储值的内部字段。',
|
||||
}
|
||||
|
||||
/** Complete translated prose; adding an English key requires its Chinese counterpart. */
|
||||
export const persistenceCatalogText = { en: english, zh: chinese }
|
||||
@@ -0,0 +1,611 @@
|
||||
/** Current-tree persistence history rejects uncovered and incorrectly acknowledged type changes. */
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
|
||||
import type { PersistenceRoot, PersistenceSchemaInventory, SchemaNode, SchemaProperty } from './persistence-schema-model.ts'
|
||||
import { extractPersistenceSchema } from './persistence-schema.ts'
|
||||
import { persistenceCatalogArtifacts } from './gen-persistence-catalog.ts'
|
||||
import {
|
||||
classifyPersistenceChange,
|
||||
loadPersistenceHistory,
|
||||
parseHistoricalPersistenceSnapshot,
|
||||
parsePersistenceSnapshot,
|
||||
runPersistenceChanges as executePersistenceChanges,
|
||||
validatePersistenceHistory,
|
||||
verifyPersistenceChanges,
|
||||
} from './persistence-changes.ts'
|
||||
import type { PersistenceChangeRecord, PersistenceHistoryEntry } from './persistence-changes.ts'
|
||||
|
||||
function runPersistenceChanges(
|
||||
args: readonly string[], root: string, extract: (root: string) => PersistenceSchemaInventory,
|
||||
): string {
|
||||
return executePersistenceChanges(args, root, extract, (_root, current) => [{
|
||||
path: 'docs/persistence-schema.json', content: JSON.stringify(current, null, 2) + '\n',
|
||||
}])
|
||||
}
|
||||
|
||||
function jsonResult(source: string): { ok: boolean; files: readonly string[] } {
|
||||
return JSON.parse(source) as { ok: boolean; files: readonly string[] }
|
||||
}
|
||||
|
||||
const AUTHORED_PROSE = {
|
||||
en: { summary: 'Adds optional metadata.', compatibility: 'Readers may omit the metadata.', verification: 'The focused tests passed.' },
|
||||
zh: { summary: '添加可选元数据。', compatibility: '读取方可省略元数据。', verification: '定向测试通过。' },
|
||||
}
|
||||
|
||||
function proseFile(root: string): string {
|
||||
const path = join(root, 'prose.json')
|
||||
writeFileSync(path, JSON.stringify(AUTHORED_PROSE))
|
||||
return path
|
||||
}
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
|
||||
|
||||
function fixture(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-persistence-changes-'))
|
||||
roots.push(root)
|
||||
mkdirSync(join(root, 'docs/persistence-changes'), { recursive: true })
|
||||
return root
|
||||
}
|
||||
|
||||
type Shape = 'string' | 'number' | 'boolean' | { readonly [property: string]: Shape }
|
||||
|
||||
function typeRoot(key: string, shape: Shape, version = 3): PersistenceRoot {
|
||||
const nodes: SchemaNode[] = []
|
||||
function add(shape: Shape): number {
|
||||
const index = nodes.length
|
||||
nodes.push({ kind: 'primitive', type: 'never' })
|
||||
nodes[index] = typeof shape === 'string' ? { kind: 'primitive', type: shape } : {
|
||||
kind: 'object', indices: [], properties: Object.entries(shape).map(([name, child]) => ({
|
||||
name: name.replace(/\?$/u, ''), optional: name.endsWith('?'), type: add(child),
|
||||
})),
|
||||
}
|
||||
return index
|
||||
}
|
||||
if (key === 'SessionHeader') {
|
||||
const index = add(shape)
|
||||
const node = nodes[index]!
|
||||
if (node.kind !== 'object') throw new Error('header fixture requires an object')
|
||||
nodes[index] = { ...node, properties: [...node.properties, { name: 'version', optional: false, type: nodes.length }] }
|
||||
nodes.push({ kind: 'literal', value: version })
|
||||
} else if (key.startsWith('event:')) {
|
||||
add({ data: shape, type: 'string' })
|
||||
const node = nodes[0]!
|
||||
if (node.kind !== 'object') throw new Error('event fixture requires an object')
|
||||
const type = node.properties.find(item => item.name === 'type')!.type
|
||||
nodes[type] = { kind: 'literal', value: key.slice(6) }
|
||||
} else add(shape)
|
||||
const schema = canonicalizeSchema(nodes, 0)
|
||||
return { key, kind: ['SessionHeader', 'JsonlHeaderLine'].includes(key) ? 'header' : key === 'SessionEventEnvelope' ? 'envelope' : 'event',
|
||||
...(key.startsWith('event:') ? { event: key.slice(6), surface: false } : {}), schema, digest: schemaDigest(schema) }
|
||||
}
|
||||
|
||||
function inventory(eventShape: Shape = { value: 'string' }, version = 3): PersistenceSchemaInventory {
|
||||
return { formatVersion: 1, types: [], roots: [typeRoot('SessionHeader', { id: 'string' }, version), typeRoot('SessionEventEnvelope', { type: 'string' }), typeRoot('event:example/value', eventShape), typeRoot('JsonlHeaderLine', { version: 'number' })] }
|
||||
}
|
||||
|
||||
function entry(
|
||||
id: string, schema: PersistenceSchemaInventory, previous: string | null,
|
||||
baseline = false, decision: 'same-version' | 'version-bump' = 'same-version',
|
||||
): PersistenceHistoryEntry {
|
||||
return {
|
||||
record: { schemaVersion: 1, id, baseline, changes: schema.roots.map(root => ({
|
||||
root: root.key, previous, after: root.digest, decision,
|
||||
})) },
|
||||
snapshot: schema,
|
||||
}
|
||||
}
|
||||
|
||||
const BASE_ID = '2026-09-11-baseline'
|
||||
const NEXT_ID = '2026-09-11-change'
|
||||
|
||||
function historicalSurface(operation: 'required' | 'optional' | 'absent', event = 'example/value'): PersistenceSchemaInventory {
|
||||
const nodes: SchemaNode[] = [
|
||||
{ kind: 'object', indices: [], properties: [
|
||||
{ name: 'type', type: 1, optional: false },
|
||||
...(operation === 'absent' ? [] : [{ name: 'surfaceOp', type: 2, optional: operation === 'optional' }]),
|
||||
] },
|
||||
{ kind: 'literal', value: event },
|
||||
{ kind: 'primitive', type: 'string' },
|
||||
]
|
||||
const schema = canonicalizeSchema(nodes, 0)
|
||||
return { formatVersion: 1, types: [], roots: [{
|
||||
key: 'event:example/value', kind: 'event', event: 'example/value', surface: true, schema, digest: schemaDigest(schema),
|
||||
}] }
|
||||
}
|
||||
|
||||
describe('historical persistence snapshot parsing', () => {
|
||||
it('preserves source paths and fingerprints in historical inventories', () => {
|
||||
const snapshot = historicalSurface('required')
|
||||
const root = snapshot.roots[0]!
|
||||
const sources = ['packages/core/example/src/types.ts']
|
||||
const complete = { ...snapshot, types: [{ schema: root.schema, digest: root.digest, names: ['Example'], sources }] }
|
||||
expect(parseHistoricalPersistenceSnapshot(complete)).toEqual(complete)
|
||||
})
|
||||
|
||||
it.each([':8', ':8:3', '#L8', '#L8-L12'])('rejects source coordinates %s only in historical inventories', (suffix) => {
|
||||
const snapshot = historicalSurface('required')
|
||||
const root = snapshot.roots[0]!
|
||||
const sources = [`packages/core/example/src/types.ts${suffix}`]
|
||||
const complete = { ...snapshot, types: [{ schema: root.schema, digest: root.digest, names: [], sources }] }
|
||||
expect(() => parseHistoricalPersistenceSnapshot(complete)).toThrow('historical schema sources must omit line numbers')
|
||||
expect(parsePersistenceSnapshot(complete)).toEqual(complete)
|
||||
})
|
||||
|
||||
it('admits optional surface operations only through the historical parser', () => {
|
||||
const snapshot = historicalSurface('optional')
|
||||
expect(() => parsePersistenceSnapshot(snapshot)).toThrow('surface metadata')
|
||||
expect(parseHistoricalPersistenceSnapshot(snapshot)).toEqual(snapshot)
|
||||
expect(parsePersistenceSnapshot(historicalSurface('required'))).toEqual(historicalSurface('required'))
|
||||
})
|
||||
|
||||
it('still rejects absent operations, mismatched event tags, and invalid roots', () => {
|
||||
expect(() => parseHistoricalPersistenceSnapshot(historicalSurface('absent'))).toThrow('surface metadata')
|
||||
expect(() => parseHistoricalPersistenceSnapshot(historicalSurface('optional', 'wrong/tag'))).toThrow('type does not match')
|
||||
const snapshot = historicalSurface('optional')
|
||||
expect(() => parseHistoricalPersistenceSnapshot({ ...snapshot, roots: snapshot.roots.map(root => ({ ...root, key: 'event:wrong/key' })) })).toThrow('invalid event root key')
|
||||
expect(() => parseHistoricalPersistenceSnapshot({ ...snapshot, roots: snapshot.roots.map(root => ({ ...root, surface: false })) })).toThrow('surface metadata')
|
||||
})
|
||||
|
||||
it('preserves graph and digest validation in historical mode', () => {
|
||||
const snapshot = historicalSurface('optional')
|
||||
expect(() => parseHistoricalPersistenceSnapshot({ ...snapshot, roots: snapshot.roots.map(root => ({ ...root, digest: 'f'.repeat(64) })) })).toThrow('digest mismatch')
|
||||
expect(() => parseHistoricalPersistenceSnapshot({ ...snapshot, roots: snapshot.roots.map(root => ({ ...root, schema: { root: 0, nodes: [{ kind: 'array', element: 9 }] } })) })).toThrow('unknown schema node')
|
||||
})
|
||||
})
|
||||
|
||||
function onlyEvent(schema: PersistenceSchemaInventory): PersistenceSchemaInventory {
|
||||
return { ...schema, roots: schema.roots.filter(root => root.kind === 'event') }
|
||||
}
|
||||
|
||||
function finishDocuments(root: string, id: string): void {
|
||||
for (const suffix of ['.md', '.zh.md']) {
|
||||
const path = join(root, 'docs/persistence-changes', id + suffix)
|
||||
writeFileSync(path, readFileSync(path, 'utf8').replaceAll('TODO: explain this change.', 'Optional payload metadata preserves the recorded value.')
|
||||
.replaceAll('TODO: record validation evidence.', 'The focused persistence-history tests passed.'))
|
||||
}
|
||||
}
|
||||
|
||||
function commitCurrent(root: string, schema: PersistenceSchemaInventory): void {
|
||||
writeFileSync(join(root, 'docs/persistence-schema.json'), JSON.stringify(schema, null, 2) + '\n')
|
||||
}
|
||||
|
||||
function baseline(root: string, schema = inventory()): void {
|
||||
runPersistenceChanges(['--baseline', BASE_ID], root, () => schema)
|
||||
finishDocuments(root, BASE_ID)
|
||||
commitCurrent(root, schema)
|
||||
}
|
||||
|
||||
function unionBody(arms: readonly (readonly SchemaProperty[])[]): PersistenceRoot {
|
||||
const nodes: SchemaNode[] = [
|
||||
{ kind: 'object', indices: [], properties: [{ name: 'data', type: 1, optional: false }] },
|
||||
{ kind: 'union', types: arms.map((_, index) => 5 + index) },
|
||||
{ kind: 'primitive', type: 'string' },
|
||||
{ kind: 'primitive', type: 'number' },
|
||||
{ kind: 'primitive', type: 'boolean' },
|
||||
...arms.map(properties => ({ kind: 'object' as const, indices: [], properties })),
|
||||
]
|
||||
const schema = canonicalizeSchema(nodes, 0)
|
||||
return { ...typeRoot('event:example/value', {}), schema, digest: schemaDigest(schema) }
|
||||
}
|
||||
|
||||
describe('persistence change classification', () => {
|
||||
it('treats a new optional payload subtree as one additive change even with required descendants', () => {
|
||||
const before = typeRoot('event:example/value', { value: 'string' })
|
||||
const after = typeRoot('event:example/value', { value: 'string', 'details?': { name: 'string', count: 'number' } })
|
||||
expect(classifyPersistenceChange(before, after)).toEqual([{ path: 'event:example/value.data.details', kind: 'optional-property-added', description: 'optional property added', requiresVersionBump: false }])
|
||||
})
|
||||
|
||||
it('permits required-to-optional payload properties while rejecting opposite changes, type changes, and removals', () => {
|
||||
const required = typeRoot('event:example/value', { value: 'string' })
|
||||
const optional = typeRoot('event:example/value', { 'value?': 'string' })
|
||||
expect(classifyPersistenceChange(required, optional).every(change => !change.requiresVersionBump)).toBe(true)
|
||||
for (const after of [required, typeRoot('event:example/value', { 'value?': 'number' }), typeRoot('event:example/value', {})]) {
|
||||
expect(classifyPersistenceChange(optional, after).some(change => change.requiresVersionBump)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('pairs multiple discriminated union arms when a shared payload gains an optional property', () => {
|
||||
function unionRoot(optional: boolean): PersistenceRoot {
|
||||
const nodes: SchemaNode[] = [
|
||||
{ kind: 'object', indices: [], properties: [{ name: 'data', type: 1, optional: false }] },
|
||||
{ kind: 'union', types: [2, 3] },
|
||||
{ kind: 'object', indices: [], properties: [{ name: 'kind', type: 4, optional: false }, { name: 'payload', type: 6, optional: false }] },
|
||||
{ kind: 'object', indices: [], properties: [{ name: 'kind', type: 5, optional: false }, { name: 'payload', type: 6, optional: false }] },
|
||||
{ kind: 'literal', value: 'left' }, { kind: 'literal', value: 'right' },
|
||||
{ kind: 'object', indices: [], properties: [{ name: 'value', type: 7, optional: false }, ...(optional ? [{ name: 'label', type: 7, optional: true }] : [])] },
|
||||
{ kind: 'primitive', type: 'string' },
|
||||
]
|
||||
const schema = canonicalizeSchema(nodes, 0)
|
||||
return { ...typeRoot('event:example/value', {}), schema, digest: schemaDigest(schema) }
|
||||
}
|
||||
const differences = classifyPersistenceChange(unionRoot(false), unionRoot(true))
|
||||
expect(differences.length).toBeGreaterThan(0)
|
||||
expect(differences.every(change => !change.requiresVersionBump)).toBe(true)
|
||||
})
|
||||
|
||||
it('allows optional additions across undiscriminated union arms', () => {
|
||||
const first = [{ name: 'a', type: 2, optional: false }]
|
||||
const second = [{ name: 'b', type: 3, optional: false }]
|
||||
const extra = { name: 'x', type: 4, optional: true }
|
||||
const differences = classifyPersistenceChange(unionBody([first, second]), unionBody([[...first, extra], [...second, extra]]))
|
||||
expect(differences).toEqual([expect.objectContaining({ path: 'event:example/value.data.x', requiresVersionBump: false })])
|
||||
})
|
||||
|
||||
it('allows required-to-optional fields across undiscriminated union arms', () => {
|
||||
const before = unionBody([[{ name: 'a', type: 2, optional: false }], [{ name: 'b', type: 3, optional: false }]])
|
||||
const after = unionBody([[{ name: 'a', type: 2, optional: true }], [{ name: 'b', type: 3, optional: true }]])
|
||||
const differences = classifyPersistenceChange(before, after)
|
||||
expect(differences.map(change => change.path).sort()).toEqual(['event:example/value.data.a', 'event:example/value.data.b'])
|
||||
expect(differences.every(change => change.kind === 'property-made-optional' && !change.requiresVersionBump)).toBe(true)
|
||||
})
|
||||
|
||||
it('finds a complete matching when one union arm has multiple additive successors', () => {
|
||||
const required = [{ name: 'a', type: 2, optional: false }]
|
||||
const optional = [{ name: 'a', type: 2, optional: true }]
|
||||
const flexible = [{ name: '0', type: 4, optional: true }, ...optional]
|
||||
const constrained = [...required, { name: 'z', type: 4, optional: true }]
|
||||
const before = unionBody([required, optional])
|
||||
for (const after of [unionBody([flexible, constrained]), unionBody([constrained, flexible])]) {
|
||||
const differences = classifyPersistenceChange(before, after)
|
||||
expect(differences.length).toBeGreaterThan(0)
|
||||
expect(differences.every(change => !change.requiresVersionBump)).toBe(true)
|
||||
}
|
||||
const noCompleteMatching = unionBody([flexible, [{ name: 'different', type: 4, optional: true }]])
|
||||
expect(classifyPersistenceChange(before, noCompleteMatching).some(change => change.requiresVersionBump)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps union cardinality, property removal, new required fields, and value types strict', () => {
|
||||
const first = [{ name: 'a', type: 2, optional: false }]
|
||||
const second = [{ name: 'b', type: 3, optional: false }]
|
||||
const third = [{ name: 'c', type: 4, optional: false }]
|
||||
const before = unionBody([first, second])
|
||||
const variants = [
|
||||
unionBody([first, second, third]),
|
||||
unionBody([first]),
|
||||
unionBody([first, third]),
|
||||
unionBody([[...first, { name: 'x', type: 4, optional: false }], [...second, { name: 'x', type: 4, optional: true }]]),
|
||||
unionBody([[{ name: 'a', type: 3, optional: true }], [{ name: 'b', type: 3, optional: true }]]),
|
||||
]
|
||||
for (const after of variants) expect(classifyPersistenceChange(before, after).some(change => change.requiresVersionBump)).toBe(true)
|
||||
})
|
||||
|
||||
it('checks all recursive union arms without retaining provisional successes between candidates', () => {
|
||||
const next = { name: 'next', type: 1, optional: true }
|
||||
const first = [{ name: 'a', type: 2, optional: false }, next]
|
||||
const second = [{ name: 'b', type: 3, optional: false }, next]
|
||||
const extra = { name: 'x', type: 4, optional: true }
|
||||
const before = unionBody([first, second])
|
||||
const additive = unionBody([[...first, extra], [...second, extra]])
|
||||
expect(classifyPersistenceChange(before, additive).every(change => !change.requiresVersionBump)).toBe(true)
|
||||
const invalid = unionBody([[...first, extra], [{ name: 'b', type: 2, optional: false }, next, extra]])
|
||||
expect(classifyPersistenceChange(before, invalid).some(change => change.requiresVersionBump)).toBe(true)
|
||||
expect(classifyPersistenceChange(invalid, before).some(change => change.requiresVersionBump)).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves body scope through arrays and tuples and rejects a simultaneous value-type change', () => {
|
||||
function wrapped(optional: boolean, primitive: 'string' | 'number' = 'string'): PersistenceRoot {
|
||||
const nodes: SchemaNode[] = [
|
||||
{ kind: 'object', indices: [], properties: [{ name: 'data', type: 1, optional: false }] },
|
||||
{ kind: 'tuple', elements: [{ type: 2, optional: false, rest: false }] },
|
||||
{ kind: 'array', element: 3 },
|
||||
{ kind: 'object', indices: [], properties: [{ name: 'value', type: 4, optional }] },
|
||||
{ kind: 'primitive', type: primitive },
|
||||
]
|
||||
const schema = canonicalizeSchema(nodes, 0)
|
||||
return { ...typeRoot('event:example/value', {}), schema, digest: schemaDigest(schema) }
|
||||
}
|
||||
expect(classifyPersistenceChange(wrapped(false), wrapped(true)).every(change => !change.requiresVersionBump)).toBe(true)
|
||||
expect(classifyPersistenceChange(wrapped(false), wrapped(true, 'number')).some(change => change.requiresVersionBump)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps optional header/envelope properties and surface event additions strict', () => {
|
||||
for (const key of ['SessionHeader', 'JsonlHeaderLine', 'SessionEventEnvelope']) {
|
||||
expect(classifyPersistenceChange(typeRoot(key, {}), typeRoot(key, { 'metadata?': 'string' }))[0]?.requiresVersionBump).toBe(true)
|
||||
}
|
||||
const added = typeRoot('event:example/added', { value: 'string' })
|
||||
expect(classifyPersistenceChange(null, added)[0]?.requiresVersionBump).toBe(false)
|
||||
expect(classifyPersistenceChange(null, { ...added, surface: true })[0]?.requiresVersionBump).toBe(true)
|
||||
expect(classifyPersistenceChange(added, null)[0]?.requiresVersionBump).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistence history verification', () => {
|
||||
it('accepts a baseline and a successive optional addition without comparing the historical schema to current', () => {
|
||||
const before = inventory()
|
||||
const after = inventory({ value: 'string', 'label?': 'string' })
|
||||
const history = validatePersistenceHistory([entry(BASE_ID, before, null, true), entry(NEXT_ID, onlyEvent(after), BASE_ID)])
|
||||
expect(history.tips.get('event:example/value')?.root?.digest).toBe(after.roots[2]?.digest)
|
||||
expect(history.tips.get('SessionHeader')?.id).toBe(BASE_ID)
|
||||
})
|
||||
|
||||
it('rejects missing predecessors, forks, cycles, duplicates, and missing baseline', () => {
|
||||
const base = entry(BASE_ID, inventory(), null, true)
|
||||
const next = entry(NEXT_ID, onlyEvent(inventory({ value: 'string', 'label?': 'string' })), BASE_ID)
|
||||
expect(() => validatePersistenceHistory([next])).toThrow('exactly one baseline')
|
||||
expect(() => validatePersistenceHistory([base, entry(NEXT_ID, next.snapshot, '2026-09-11-missing')])).toThrow('missing predecessor')
|
||||
expect(() => validatePersistenceHistory([base, next, entry('2026-09-11-fork', next.snapshot, BASE_ID)])).toThrow('forked')
|
||||
expect(() => validatePersistenceHistory([base, base])).toThrow('exactly one baseline')
|
||||
const cycle = entry('2026-09-11-cycle', { ...next.snapshot, roots: [typeRoot('event:cycle/event', {})] }, '2026-09-11-cycle')
|
||||
expect(() => validatePersistenceHistory([base, cycle])).toThrow('cycle')
|
||||
})
|
||||
|
||||
it('binds breaking decisions to the same record header version transition', () => {
|
||||
const base = entry(BASE_ID, inventory(), null, true)
|
||||
const breaking = inventory({ value: 'number' }, 4)
|
||||
expect(() => validatePersistenceHistory([base, entry(NEXT_ID, onlyEvent(breaking), BASE_ID)])).toThrow('requires a format version bump')
|
||||
expect(() => validatePersistenceHistory([base, entry(NEXT_ID, onlyEvent(breaking), BASE_ID, false, 'version-bump')])).toThrow("this record's own")
|
||||
const touched = { ...breaking, roots: breaking.roots.filter(root => root.key === 'SessionHeader' || root.kind === 'event') }
|
||||
expect(validatePersistenceHistory([base, entry(NEXT_ID, touched, BASE_ID, false, 'version-bump')]).tips.get('SessionHeader')?.id).toBe(NEXT_ID)
|
||||
const later = entry('2026-09-11-later', onlyEvent(inventory({ value: 'boolean' }, 4)), NEXT_ID, false, 'version-bump')
|
||||
expect(() => validatePersistenceHistory([base, entry(NEXT_ID, touched, BASE_ID, false, 'version-bump'), later])).toThrow("this record's own")
|
||||
})
|
||||
|
||||
it('retains deletion tombstones and requires re-added roots to continue them', () => {
|
||||
const base = entry(BASE_ID, inventory(), null, true)
|
||||
const after = inventory({}, 4)
|
||||
const header = after.roots[0]!
|
||||
const record: PersistenceChangeRecord = { schemaVersion: 1, id: NEXT_ID, baseline: false, changes: [
|
||||
{ root: 'SessionHeader', previous: BASE_ID, after: header.digest, decision: 'version-bump' },
|
||||
{ root: 'event:example/value', previous: BASE_ID, after: null, decision: 'version-bump' },
|
||||
] }
|
||||
const deletion = { record, snapshot: { ...after, roots: [header] } }
|
||||
expect(validatePersistenceHistory([base, deletion]).tips.get('event:example/value')?.root).toBeNull()
|
||||
const restored = entry('2026-09-11-restore', onlyEvent(inventory()), NEXT_ID)
|
||||
expect(validatePersistenceHistory([base, deletion, restored]).tips.get('event:example/value')?.id).toBe(restored.record.id)
|
||||
expect(() => validatePersistenceHistory([base, deletion, entry('2026-09-11-restore', restored.snapshot, null)])).toThrow('forked')
|
||||
})
|
||||
|
||||
it('rejects malformed references, unknown schema variants, digest tampering, and extra snapshot roots', () => {
|
||||
const schema = inventory()
|
||||
const malformed = structuredClone(schema) as unknown as { roots: Array<{ schema: { nodes: unknown[] }; digest: string }> }
|
||||
malformed.roots[0]!.schema.nodes = [{ kind: 'array', element: 99 }]
|
||||
expect(() => parsePersistenceSnapshot(malformed)).toThrow('unknown schema node')
|
||||
malformed.roots[0]!.schema.nodes = [{ kind: 'future' }]
|
||||
expect(() => parsePersistenceSnapshot(malformed)).toThrow('unknown schema node kind')
|
||||
const tampered = structuredClone(schema)
|
||||
Object.assign(tampered.roots[0]!, { digest: '0'.repeat(64) })
|
||||
expect(() => parsePersistenceSnapshot(tampered)).toThrow('digest mismatch')
|
||||
expect(() => parsePersistenceSnapshot({ ...schema, formatVersion: 2 })).toThrow('normalization version')
|
||||
const next = entry(NEXT_ID, onlyEvent(inventory({ value: 'string', 'label?': 'string' })), BASE_ID)
|
||||
expect(() => validatePersistenceHistory([entry(BASE_ID, schema, null, true), { ...next, snapshot: inventory() }])).toThrow('snapshot roots')
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistence changes current-tree commands', () => {
|
||||
it('requires completed record prose, rejects stale generated output, and reports unacknowledged paths', () => {
|
||||
const root = fixture()
|
||||
const before = inventory()
|
||||
runPersistenceChanges(['--baseline', BASE_ID], root, () => before)
|
||||
expect(() => loadPersistenceHistory(root)).toThrow('complete compatibility')
|
||||
finishDocuments(root, BASE_ID)
|
||||
rmSync(join(root, 'docs/persistence-schema.json'))
|
||||
expect(() => verifyPersistenceChanges(root, before)).toThrow('is missing')
|
||||
commitCurrent(root, before)
|
||||
const after = inventory({ value: 'string', 'label?': 'string' })
|
||||
expect(() => verifyPersistenceChanges(root, after)).toThrow('is stale')
|
||||
commitCurrent(root, after)
|
||||
expect(() => verifyPersistenceChanges(root, after)).toThrow('event:example/value.data.label: optional property added (same-version allowed)')
|
||||
runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version'], root, () => after)
|
||||
finishDocuments(root, NEXT_ID)
|
||||
expect(runPersistenceChanges([], root, () => after)).toContain('4 roots match 2 history records')
|
||||
expect(() => runPersistenceChanges(['--baseline', '2026-09-11-reset'], root, () => after)).toThrow('cannot reset history')
|
||||
expect(() => runPersistenceChanges(['--record', '2026-09-11-empty', '--decision', 'same-version'], root, () => after)).toThrow('no persistence type changes')
|
||||
})
|
||||
|
||||
it('rejects changed bilingual machine declarations and unreferenced snapshots', () => {
|
||||
const root = fixture()
|
||||
baseline(root)
|
||||
const chinese = join(root, 'docs/persistence-changes', `${BASE_ID}.zh.md`)
|
||||
const original = readFileSync(chinese, 'utf8')
|
||||
writeFileSync(chinese, original.replace('baseline: true', 'baseline: false'))
|
||||
expect(() => loadPersistenceHistory(root)).toThrow('bilingual machine records differ')
|
||||
writeFileSync(chinese, original)
|
||||
writeFileSync(join(root, 'docs/persistence-changes/2026-09-11-orphan.schema.json'), '{}\n')
|
||||
expect(() => loadPersistenceHistory(root)).toThrow('unreferenced')
|
||||
})
|
||||
|
||||
it('excludes nested historical format documents and schemas from acknowledgements', () => {
|
||||
const root = fixture()
|
||||
baseline(root)
|
||||
const directory = join(root, 'docs/persistence-changes/historical-formats')
|
||||
mkdirSync(directory)
|
||||
writeFileSync(join(directory, 'v0.md'), '---\nkind: persistence-format\n---\n')
|
||||
writeFileSync(join(directory, 'v0.schema.json'), '{}\n')
|
||||
expect(loadPersistenceHistory(root).entries.map(entry => entry.record.id)).toEqual([BASE_ID])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['optional field', inventory({ value: 'string', 'label?': 'string' })],
|
||||
['required-to-optional field', inventory({ 'value?': 'string' })],
|
||||
['ordinary event addition', { ...inventory(), roots: [...inventory().roots, typeRoot('event:example/added', { count: 'number' })] }],
|
||||
] as const)('infers same-version for %s', (_name, after) => {
|
||||
const root = fixture()
|
||||
baseline(root)
|
||||
runPersistenceChanges(['--record', NEXT_ID, '--prose', proseFile(root)], root, () => after)
|
||||
const changes = loadPersistenceHistory(root).entries.find(entry => entry.record.id === NEXT_ID)?.record.changes
|
||||
expect(changes?.length).toBeGreaterThan(0)
|
||||
expect(changes?.every(change => change.decision === 'same-version')).toBe(true)
|
||||
verifyPersistenceChanges(root, after)
|
||||
})
|
||||
|
||||
it('infers a version bump when the source includes its own increasing header version', () => {
|
||||
const root = fixture()
|
||||
baseline(root)
|
||||
const after = inventory({ value: 'number' }, 4)
|
||||
runPersistenceChanges(['--record', NEXT_ID, '--prose', proseFile(root)], root, () => after)
|
||||
const changes = loadPersistenceHistory(root).entries.find(entry => entry.record.id === NEXT_ID)?.record.changes
|
||||
expect(changes?.map(change => change.root).sort()).toEqual(['SessionHeader', 'event:example/value'])
|
||||
expect(changes?.every(change => change.decision === 'version-bump')).toBe(true)
|
||||
verifyPersistenceChanges(root, after)
|
||||
})
|
||||
|
||||
it('rejects inferred bumps without a header transition and explicit incorrect assertions before writing', () => {
|
||||
const root = fixture()
|
||||
baseline(root)
|
||||
const prose = proseFile(root)
|
||||
const directory = join(root, 'docs/persistence-changes')
|
||||
const files = readdirSync(directory).sort()
|
||||
const inventoryPath = join(root, 'docs/persistence-schema.json')
|
||||
const before = readFileSync(inventoryPath, 'utf8')
|
||||
for (const [args, code] of [
|
||||
[[], 'version-transition-required'],
|
||||
[['--decision', 'same-version'], 'version-bump-required'],
|
||||
] as const) {
|
||||
const result = jsonResult(runPersistenceChanges(['--record', NEXT_ID, '--prose', prose, '--json', ...args], root,
|
||||
() => inventory({ value: 'number' })))
|
||||
expect(result).toMatchObject({ ok: false, code, files: [], changes: [expect.objectContaining({ requiresVersionBump: true })] })
|
||||
expect(readFileSync(inventoryPath, 'utf8')).toBe(before)
|
||||
expect(readdirSync(directory).sort()).toEqual(files)
|
||||
}
|
||||
})
|
||||
|
||||
it('authors a complete pair from explicit prose and reports source changes even when generated artifacts are stale', () => {
|
||||
const root = fixture()
|
||||
baseline(root)
|
||||
const after = inventory({ value: 'string', 'label?': 'string' })
|
||||
const pending = jsonResult(runPersistenceChanges(['--check', '--json'], root, () => after))
|
||||
expect(pending).toMatchObject({ ok: false, operation: 'check', code: 'stale-artifacts',
|
||||
roots: [{ root: 'event:example/value', kind: 'event', before: inventory().roots[2]?.digest, after: after.roots[2]?.digest }], changes: [
|
||||
{ root: 'event:example/value', path: 'event:example/value.data.label', kind: 'optional-property-added', requiresVersionBump: false },
|
||||
] })
|
||||
const written = jsonResult(runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version', '--prose', proseFile(root), '--json'], root, () => after))
|
||||
expect(written).toMatchObject({ ok: true, operation: 'record', recordId: NEXT_ID })
|
||||
expect(written.files).toEqual([
|
||||
'docs/persistence-schema.json', `docs/persistence-changes/${NEXT_ID}.md`, `docs/persistence-changes/${NEXT_ID}.zh.md`,
|
||||
`docs/persistence-changes/${NEXT_ID}.i18n.yaml`, `docs/persistence-changes/${NEXT_ID}.schema.json`,
|
||||
])
|
||||
expect(written).toMatchObject({ roots: [{ root: 'event:example/value', kind: 'event',
|
||||
before: inventory().roots[2]?.digest, after: after.roots[2]?.digest }] })
|
||||
expect(readFileSync(join(root, `docs/persistence-changes/${NEXT_ID}.md`), 'utf8')).toContain(AUTHORED_PROSE.en.compatibility)
|
||||
expect(jsonResult(runPersistenceChanges(['--check', '--json'], root, () => after)).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('refreshes only a terminal acknowledgement while preserving its authored prose and predecessor', () => {
|
||||
const root = fixture()
|
||||
baseline(root)
|
||||
const after = inventory({ value: 'string', 'label?': 'string' })
|
||||
const prose = proseFile(root)
|
||||
runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version', '--prose', prose], root, () => after)
|
||||
const path = join(root, `docs/persistence-changes/${NEXT_ID}.md`)
|
||||
const beforeText = readFileSync(path, 'utf8')
|
||||
const refreshed = inventory({ value: 'string', 'label?': 'string', 'extra?': 'number' })
|
||||
runPersistenceChanges(['--update', NEXT_ID], root, () => refreshed)
|
||||
const stripRecord = (value: string): string => value.replace(/```yaml persistence-change[\s\S]*?```/u, '')
|
||||
expect(stripRecord(readFileSync(path, 'utf8'))).toBe(stripRecord(beforeText))
|
||||
expect(loadPersistenceHistory(root).tips.get('event:example/value')?.id).toBe(NEXT_ID)
|
||||
expect(loadPersistenceHistory(root).entries.find(entry => entry.record.id === NEXT_ID)?.record.changes[0]?.previous).toBe(BASE_ID)
|
||||
expect(jsonResult(runPersistenceChanges(['--check', '--json'], root, () => refreshed)).ok).toBe(true)
|
||||
const final = inventory({ value: 'string', 'label?': 'string', 'extra?': 'number', 'last?': 'boolean' })
|
||||
runPersistenceChanges(['--record', '2026-09-11-successor', '--decision', 'same-version', '--prose', prose], root, () => final)
|
||||
expect(() => runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version'], root, () => final)).toThrow('with successors')
|
||||
expect(() => runPersistenceChanges(['--update', BASE_ID, '--decision', 'same-version'], root, () => final)).toThrow('cannot update the persistence baseline')
|
||||
})
|
||||
|
||||
it('completes a scaffold through update and rejects invalid prose or decisions before changing files', () => {
|
||||
const root = fixture()
|
||||
baseline(root)
|
||||
const after = inventory({ value: 'string', 'label?': 'string' })
|
||||
runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version'], root, () => after)
|
||||
const prose = proseFile(root)
|
||||
runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version', '--prose', prose], root, () => after)
|
||||
expect(runPersistenceChanges(['--check'], root, () => after)).toContain('roots match')
|
||||
expect(readFileSync(join(root, `docs/persistence-changes/${NEXT_ID}.md`), 'utf8')).not.toContain('TODO:')
|
||||
const pairedPaths = ['.md', '.zh.md', '.i18n.yaml'].map(suffix => join(root, `docs/persistence-changes/${NEXT_ID}${suffix}`))
|
||||
const completed = pairedPaths.map(path => readFileSync(path, 'utf8'))
|
||||
runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version', '--prose', prose], root, () => after)
|
||||
expect(pairedPaths.map(path => readFileSync(path, 'utf8'))).toEqual(completed)
|
||||
const path = join(root, `docs/persistence-changes/${NEXT_ID}.md`)
|
||||
const before = readFileSync(path, 'utf8')
|
||||
const invalid = inventory({ value: 'number' })
|
||||
const result = jsonResult(runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version', '--json'], root, () => invalid))
|
||||
expect(result).toMatchObject({ ok: false, code: 'version-bump-required', changes: [expect.objectContaining({ kind: 'type-changed', requiresVersionBump: true })] })
|
||||
expect(readFileSync(path, 'utf8')).toBe(before)
|
||||
for (const value of [
|
||||
{ ...AUTHORED_PROSE, extra: 'unsupported' },
|
||||
{ ...AUTHORED_PROSE, en: { ...AUTHORED_PROSE.en, compatibility: ' ' } },
|
||||
{ ...AUTHORED_PROSE, zh: { ...AUTHORED_PROSE.zh, verification: 'TODO: record validation evidence.' } },
|
||||
{ ...AUTHORED_PROSE, zh: { ...AUTHORED_PROSE.zh, verification: '```text\nUnpaired code.\n```' } },
|
||||
]) {
|
||||
writeFileSync(prose, JSON.stringify(value))
|
||||
expect(() => runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version', '--prose', prose], root, () => after)).toThrow()
|
||||
expect(readFileSync(path, 'utf8')).toBe(before)
|
||||
}
|
||||
})
|
||||
|
||||
// Six real CLI processes and TypeScript extraction share the Windows coverage test budget.
|
||||
it('executes the real CLI against a source-only temporary checkout without Git history', { timeout: 90_000 }, () => {
|
||||
const root = fixture()
|
||||
const physical = join(root, 'packages/session/session-persistence-jsonl/src')
|
||||
mkdirSync(physical, { recursive: true })
|
||||
writeFileSync(join(physical, 'format.ts'), "interface HeaderLine { type: 'session'; version: number; id: string; delegationDepth: number }; export {}\n")
|
||||
const session = join(root, 'packages/core/session/src')
|
||||
mkdirSync(session, { recursive: true })
|
||||
writeFileSync(join(session, '../package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-session' }))
|
||||
writeFileSync(join(root, 'tsconfig.host.json'), JSON.stringify({ compilerOptions: {
|
||||
target: 'ESNext', module: 'ESNext', moduleResolution: 'Bundler', types: [], skipLibCheck: true,
|
||||
paths: { '@deepseek-ai/dsh-session/types': ['./packages/core/session/src/types.ts'] },
|
||||
} }))
|
||||
const source = [
|
||||
'/** Stored header. */', 'export interface SessionHeader { version: 3; id: string }',
|
||||
'/** Stored event payloads. */', 'export interface SessionEventMap {', '/** Saved value. */',
|
||||
"'example/value': { value: string }", '}', '/** Surface event names. */', "export type SurfaceEventType = 'example/value'",
|
||||
'/** Persisted event names. */', 'export type SessionEventType = keyof SessionEventMap',
|
||||
'/** Surface placement. */', "export type SurfaceOp = 'append'", '/** Persisted event. */',
|
||||
'export type SessionEvent<K extends keyof SessionEventMap = keyof SessionEventMap> =',
|
||||
' { [P in K]: { type: P; seq: number; data: SessionEventMap[P]; surfaceOp: SurfaceOp } }[K]', '',
|
||||
].join('\n')
|
||||
writeFileSync(join(session, 'types.ts'), source)
|
||||
const script = resolve(import.meta.dirname, 'persistence-changes.ts')
|
||||
const cli = (...args: string[]): ReturnType<typeof spawnSync> => spawnSync(process.execPath, ['--import', import.meta.resolve('tsx/esm'), script, '--root', root, ...args], {
|
||||
cwd: root, encoding: 'utf8', timeout: 120_000,
|
||||
})
|
||||
const prose = proseFile(root)
|
||||
const initialized = cli('--baseline', BASE_ID, '--prose', prose)
|
||||
expect(initialized.error).toBeUndefined()
|
||||
expect(initialized.signal).toBeNull()
|
||||
expect(initialized.status, String(initialized.stderr)).toBe(0)
|
||||
const accepted = cli('--check')
|
||||
expect(accepted.error).toBeUndefined()
|
||||
expect(accepted.signal).toBeNull()
|
||||
expect(accepted.status, String(accepted.stderr)).toBe(0)
|
||||
const optional = source.replace('value: string', 'value: string; label?: string')
|
||||
writeFileSync(join(session, 'types.ts'), optional)
|
||||
const authored = cli('--record', NEXT_ID, '--prose', prose, '--json')
|
||||
expect(authored.error).toBeUndefined()
|
||||
expect(authored.signal).toBeNull()
|
||||
expect(authored.status, String(authored.stderr)).toBe(0)
|
||||
expect(JSON.parse(String(authored.stdout)) as unknown).toMatchObject({ ok: true, operation: 'record' })
|
||||
const beforeUpdate = readFileSync(join(root, `docs/persistence-changes/${NEXT_ID}.md`), 'utf8')
|
||||
writeFileSync(join(session, 'types.ts'), optional.replace('label?: string', 'label?: string; extra?: number'))
|
||||
const updated = cli('--update', NEXT_ID, '--json')
|
||||
expect(updated.error).toBeUndefined()
|
||||
expect(updated.signal).toBeNull()
|
||||
expect(updated.status, String(updated.stderr)).toBe(0)
|
||||
expect(JSON.parse(String(updated.stdout)) as unknown).toMatchObject({ ok: true, operation: 'update' })
|
||||
expect(readFileSync(join(root, `docs/persistence-changes/${NEXT_ID}.md`), 'utf8').replace(/```yaml persistence-change[\s\S]*?```/u, ''))
|
||||
.toBe(beforeUpdate.replace(/```yaml persistence-change[\s\S]*?```/u, ''))
|
||||
const generated = extractPersistenceSchema(root)
|
||||
for (const file of persistenceCatalogArtifacts(root, generated)) expect(readFileSync(join(root, file.path), 'utf8')).toBe(file.content)
|
||||
verifyPersistenceChanges(root, generated)
|
||||
writeFileSync(join(session, 'types.ts'), optional.replace('label?: string', 'label?: string; extra?: number').replace('value: string', 'value: number'))
|
||||
commitCurrent(root, extractPersistenceSchema(root))
|
||||
const rejected = cli('--check')
|
||||
expect(rejected.error).toBeUndefined()
|
||||
expect(rejected.signal).toBeNull()
|
||||
expect(rejected.status).toBe(1)
|
||||
expect(String(rejected.stderr)).toContain('version-bump required')
|
||||
const structured = cli('--check', '--json')
|
||||
expect(structured.error).toBeUndefined()
|
||||
expect(structured.signal).toBeNull()
|
||||
expect(structured.status).toBe(1)
|
||||
expect(JSON.parse(String(structured.stdout)) as unknown).toMatchObject({
|
||||
ok: false, code: 'unacknowledged-changes', changes: [expect.objectContaining({ kind: 'type-changed', requiresVersionBump: true })],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,812 @@
|
||||
/** Verify and acknowledge persistence type changes from current-tree schema history. */
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
||||
import { basename, join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { JSON_SCHEMA, load } from 'js-yaml'
|
||||
import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
|
||||
import type { CanonicalSchema, PersistenceRoot, PersistenceSchemaInventory, SchemaNode, SchemaTupleElement } from './persistence-schema-model.ts'
|
||||
import { extractPersistenceSchema } from './persistence-schema.ts'
|
||||
import { persistenceCatalogArtifacts } from './gen-persistence-catalog.ts'
|
||||
import { renderPersistencePair } from './persistence-artifacts.ts'
|
||||
import type { PersistenceArtifact } from './persistence-artifacts.ts'
|
||||
|
||||
const HISTORY_DIRECTORY = 'docs/persistence-changes'
|
||||
const CURRENT_SCHEMA = 'docs/persistence-schema.json'
|
||||
const ID_PATTERN = /^\d{4}-\d{2}-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/u
|
||||
const DIGEST_PATTERN = /^[a-f0-9]{64}$/u
|
||||
const EXPLANATION_PLACEHOLDER = 'TODO: explain this change.'
|
||||
const EVIDENCE_PLACEHOLDER = 'TODO: record validation evidence.'
|
||||
|
||||
/** The author's acknowledgement of one mechanically classified transition. */
|
||||
export type PersistenceDecision = 'same-version' | 'version-bump'
|
||||
|
||||
/** One root's successor; null after values preserve a deletion in its history. */
|
||||
export interface PersistenceChange {
|
||||
readonly root: string
|
||||
readonly previous: string | null
|
||||
readonly after: string | null
|
||||
readonly decision: PersistenceDecision
|
||||
}
|
||||
|
||||
/** A document's machine record, independent of its translated prose. */
|
||||
export interface PersistenceChangeRecord {
|
||||
readonly schemaVersion: 1
|
||||
readonly id: string
|
||||
readonly baseline: boolean
|
||||
readonly changes: readonly PersistenceChange[]
|
||||
}
|
||||
|
||||
/** A parsed acknowledgement and its self-contained after schemas. */
|
||||
export interface PersistenceHistoryEntry {
|
||||
readonly record: PersistenceChangeRecord
|
||||
readonly snapshot: PersistenceSchemaInventory
|
||||
}
|
||||
|
||||
/** One detected type change, with a path that reviewers can locate. */
|
||||
export interface PersistenceTypeChange {
|
||||
readonly kind: PersistenceTypeChangeKind
|
||||
readonly path: string
|
||||
readonly description: string
|
||||
readonly requiresVersionBump: boolean
|
||||
}
|
||||
|
||||
const CHANGE_DESCRIPTIONS = {
|
||||
'root-added': 'root added',
|
||||
'root-removed': 'root removed',
|
||||
'root-classification-changed': 'root classification changed',
|
||||
'type-changed': 'type changed',
|
||||
'property-removed': 'property removed',
|
||||
'property-made-optional': 'property made optional',
|
||||
'property-made-required': 'property made required',
|
||||
'optional-property-added': 'optional property added',
|
||||
'required-property-added': 'required property added',
|
||||
'index-signature-changed': 'index signature changed',
|
||||
'tuple-length-changed': 'tuple length changed',
|
||||
'tuple-element-cardinality-changed': 'tuple element cardinality changed',
|
||||
'union-variants-changed': 'union variants changed',
|
||||
} as const
|
||||
|
||||
/** Stable structural classification independent of diagnostic prose. */
|
||||
export type PersistenceTypeChangeKind = keyof typeof CHANGE_DESCRIPTIONS
|
||||
|
||||
/** Author-supplied paragraphs used to complete one language of a change record. */
|
||||
export interface PersistenceChangeProse {
|
||||
readonly summary: string
|
||||
readonly compatibility: string
|
||||
readonly verification: string
|
||||
}
|
||||
|
||||
/** Explicit bilingual prose; the CLI supplies no compatibility or validation claims. */
|
||||
export interface PersistenceChangeProsePair {
|
||||
readonly en: PersistenceChangeProse
|
||||
readonly zh: PersistenceChangeProse
|
||||
}
|
||||
|
||||
interface ReportedChange extends PersistenceTypeChange {
|
||||
readonly root: string
|
||||
}
|
||||
|
||||
interface RootTransition {
|
||||
readonly root: string
|
||||
readonly kind: PersistenceRoot['kind']
|
||||
readonly before: string | null
|
||||
readonly after: string | null
|
||||
}
|
||||
|
||||
class PersistenceChangeFailure extends Error {
|
||||
constructor(
|
||||
message: string, readonly code: string, readonly changes: readonly ReportedChange[] = [],
|
||||
readonly roots: readonly RootTransition[] = [],
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
interface CommandResult {
|
||||
readonly schemaVersion: 1
|
||||
readonly ok: boolean
|
||||
readonly operation: 'check' | 'baseline' | 'record' | 'update'
|
||||
readonly message: string
|
||||
readonly changes: readonly ReportedChange[]
|
||||
readonly roots: readonly RootTransition[]
|
||||
readonly files: readonly string[]
|
||||
readonly recordId?: string
|
||||
readonly code?: string
|
||||
}
|
||||
|
||||
interface Tip {
|
||||
readonly id: string
|
||||
readonly root: PersistenceRoot | null
|
||||
}
|
||||
|
||||
/** Verified per-root history tips; historical schemas need not match the current tree. */
|
||||
export interface PersistenceHistory {
|
||||
readonly entries: readonly PersistenceHistoryEntry[]
|
||||
readonly tips: ReadonlyMap<string, Tip>
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object`)
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function keys(value: Record<string, unknown>, expected: readonly string[], label: string, optional: readonly string[] = []): void {
|
||||
const missing = expected.find(key => !Object.hasOwn(value, key))
|
||||
const unexpected = Object.keys(value).find(key => !expected.includes(key) && !optional.includes(key))
|
||||
if (missing !== undefined || unexpected !== undefined) throw new Error(`${label}: ${missing === undefined ? `unknown field ${unexpected}` : `missing field ${missing}`}`)
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): unknown[] {
|
||||
if (!Array.isArray(value)) throw new Error(`${label} must be an array`)
|
||||
return value
|
||||
}
|
||||
|
||||
function textValue(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0) throw new Error(`${label} must be a non-empty string`)
|
||||
return value
|
||||
}
|
||||
|
||||
function bool(value: unknown, label: string): boolean {
|
||||
if (typeof value !== 'boolean') throw new Error(`${label} must be boolean`)
|
||||
return value
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
const id = textValue(value, label)
|
||||
if (!ID_PATTERN.test(id)) throw new Error(`${label} must be YYYY-MM-DD-slug`)
|
||||
return id
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
const result = textValue(value, label)
|
||||
if (!DIGEST_PATTERN.test(result)) throw new Error(`${label} must be a SHA-256 digest`)
|
||||
return result
|
||||
}
|
||||
|
||||
function reference(value: unknown, count: number, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0 || (value as number) >= count) throw new Error(`${label} references an unknown schema node`)
|
||||
return value as number
|
||||
}
|
||||
|
||||
function parseSchema(value: unknown, label: string): CanonicalSchema {
|
||||
const input = record(value, label)
|
||||
keys(input, ['root', 'nodes'], label)
|
||||
if (input.root !== 0) throw new Error(`${label}.root must be zero`)
|
||||
const nodes = array(input.nodes, `${label}.nodes`)
|
||||
if (nodes.length === 0) throw new Error(`${label}.nodes must not be empty`)
|
||||
const ref = (value: unknown): number => reference(value, nodes.length, label)
|
||||
for (const [index, raw] of nodes.entries()) {
|
||||
const node = record(raw, `${label}.nodes[${index}]`)
|
||||
switch (node.kind) {
|
||||
case 'primitive':
|
||||
keys(node, ['kind', 'type'], label)
|
||||
if (!['null', 'boolean', 'number', 'string', 'never'].includes(String(node.type))) throw new Error(`${label}: invalid primitive`)
|
||||
break
|
||||
case 'literal':
|
||||
keys(node, ['kind', 'value'], label)
|
||||
if (!['string', 'boolean', 'number'].includes(typeof node.value)
|
||||
|| typeof node.value === 'number' && !Number.isFinite(node.value)) throw new Error(`${label}: invalid literal`)
|
||||
break
|
||||
case 'opaque':
|
||||
keys(node, ['kind', 'reason'], label)
|
||||
if (!['any', 'unknown'].includes(String(node.reason))) throw new Error(`${label}: invalid opaque reason`)
|
||||
break
|
||||
case 'array':
|
||||
keys(node, ['kind', 'element'], label)
|
||||
ref(node.element)
|
||||
break
|
||||
case 'tuple':
|
||||
keys(node, ['kind', 'elements'], label)
|
||||
for (const rawElement of array(node.elements, label)) {
|
||||
const element = record(rawElement, label)
|
||||
keys(element, ['type', 'optional', 'rest'], label)
|
||||
ref(element.type)
|
||||
bool(element.optional, label)
|
||||
bool(element.rest, label)
|
||||
}
|
||||
break
|
||||
case 'object': {
|
||||
keys(node, ['kind', 'properties', 'indices'], label)
|
||||
const names = new Set<string>()
|
||||
for (const rawProperty of array(node.properties, label)) {
|
||||
const property = record(rawProperty, label)
|
||||
keys(property, ['name', 'type', 'optional'], label)
|
||||
if (typeof property.name !== 'string') throw new Error(`${label}: property name must be a string`)
|
||||
const name = property.name
|
||||
if (names.has(name)) throw new Error(`${label}: duplicate property ${name}`)
|
||||
names.add(name)
|
||||
ref(property.type)
|
||||
bool(property.optional, label)
|
||||
}
|
||||
for (const rawIndex of array(node.indices, label)) {
|
||||
const entry = record(rawIndex, label)
|
||||
keys(entry, ['key', 'value'], label)
|
||||
ref(entry.key)
|
||||
ref(entry.value)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'union':
|
||||
keys(node, ['kind', 'types'], label)
|
||||
if (array(node.types, label).length === 0) throw new Error(`${label}: empty union`)
|
||||
for (const item of node.types as unknown[]) ref(item)
|
||||
break
|
||||
default:
|
||||
throw new Error(`${label}: unknown schema node kind ${String(node.kind)}`)
|
||||
}
|
||||
}
|
||||
const schema = input as unknown as CanonicalSchema
|
||||
const canonical = canonicalizeSchema(schema.nodes, schema.root)
|
||||
if (JSON.stringify(canonical) !== JSON.stringify(schema)) throw new Error(`${label}: schema is not canonical`)
|
||||
return schema
|
||||
}
|
||||
|
||||
/** Parse a persisted schema inventory, rejecting malformed graphs and digest drift.
|
||||
* @param value - JSON read from the current inventory or an enforced acknowledgement snapshot.
|
||||
* @returns the validated inventory.
|
||||
*/
|
||||
export function parsePersistenceSnapshot(value: unknown): PersistenceSchemaInventory {
|
||||
return parseSnapshot(value, false)
|
||||
}
|
||||
|
||||
/** Parse a historical inventory with path-only source references and optional surface operations.
|
||||
* @param value - JSON captured from a historical source tree.
|
||||
* @returns the validated inventory; current acknowledgements use the strict parser.
|
||||
*/
|
||||
export function parseHistoricalPersistenceSnapshot(value: unknown): PersistenceSchemaInventory {
|
||||
return parseSnapshot(value, true)
|
||||
}
|
||||
|
||||
function parseSnapshot(value: unknown, historical: boolean): PersistenceSchemaInventory {
|
||||
const input = record(value, 'schema inventory')
|
||||
keys(input, ['formatVersion', 'roots', 'types'], 'schema inventory')
|
||||
if (input.formatVersion !== 1) throw new Error('unsupported persistence schema normalization version')
|
||||
const names = new Set<string>()
|
||||
for (const rawRoot of array(input.roots, 'schema roots')) {
|
||||
const root = record(rawRoot, 'schema root')
|
||||
keys(root, ['key', 'kind', 'digest', 'schema'], 'schema root', ['event', 'surface'])
|
||||
const key = textValue(root.key, 'root key')
|
||||
if (names.has(key)) throw new Error(`duplicate schema root ${key}`)
|
||||
names.add(key)
|
||||
if (root.kind === 'event') {
|
||||
if (key !== `event:${textValue(root.event, 'event name')}`) throw new Error(`invalid event root key ${key}`)
|
||||
bool(root.surface, 'event surface membership')
|
||||
} else if ((root.kind !== 'header' || !['SessionHeader', 'JsonlHeaderLine'].includes(key))
|
||||
&& (root.kind !== 'envelope' || key !== 'SessionEventEnvelope')) throw new Error(`invalid schema root ${key}`)
|
||||
if (root.kind !== 'event' && (root.event !== undefined || root.surface !== undefined)) throw new Error(`${key}: non-event metadata`)
|
||||
const schema = parseSchema(root.schema, key)
|
||||
if (root.kind === 'event') validateEventMetadata(schema, String(root.event), root.surface === true, historical)
|
||||
if (digest(root.digest, `${key} digest`) !== schemaDigest(schema)) throw new Error(`${key}: schema digest mismatch`)
|
||||
}
|
||||
for (const rawType of array(input.types, 'schema types')) {
|
||||
const type = record(rawType, 'schema type')
|
||||
keys(type, ['digest', 'schema', 'names', 'sources'], 'schema type')
|
||||
const schema = parseSchema(type.schema, 'shared schema')
|
||||
if (digest(type.digest, 'shared digest') !== schemaDigest(schema)) throw new Error('shared schema digest mismatch')
|
||||
for (const name of array(type.names, 'type names')) textValue(name, 'type name')
|
||||
for (const source of array(type.sources, 'type sources')) {
|
||||
const location = textValue(source, 'type source')
|
||||
if (historical && /:\d+(?::\d+)?$|#L\d+(?:-L\d+)?$/u.test(location)) {
|
||||
throw new Error('historical schema sources must omit line numbers')
|
||||
}
|
||||
}
|
||||
}
|
||||
return input as unknown as PersistenceSchemaInventory
|
||||
}
|
||||
|
||||
function validateEventMetadata(schema: CanonicalSchema, event: string, surface: boolean, historical: boolean): void {
|
||||
const pending = [0]
|
||||
const visited = new Set<number>()
|
||||
while (pending.length > 0) {
|
||||
const index = pending.pop() as number
|
||||
if (visited.has(index)) continue
|
||||
visited.add(index)
|
||||
const node = schema.nodes[index] as SchemaNode
|
||||
if (node.kind === 'union') { pending.push(...node.types); continue }
|
||||
if (node.kind !== 'object') throw new Error(`${event}: event schema must be an object`)
|
||||
const tag = node.properties.find(property => property.name === 'type')
|
||||
const value = tag === undefined ? undefined : schema.nodes[tag.type]
|
||||
if (tag?.optional !== false || value?.kind !== 'literal' || value.value !== event) throw new Error(`${event}: event schema type does not match its root`)
|
||||
const operation = node.properties.find(property => property.name === 'surfaceOp')
|
||||
if (surface ? operation === undefined || !historical && operation.optional : operation !== undefined) throw new Error(`${event}: surface metadata does not match its schema`)
|
||||
}
|
||||
}
|
||||
|
||||
function subDigest(schema: CanonicalSchema, node: number): string {
|
||||
return schemaDigest(canonicalizeSchema(schema.nodes, node))
|
||||
}
|
||||
|
||||
function matchUnionVariants(candidates: readonly (readonly number[])[]): number[] | undefined {
|
||||
const owners = new Map<number, number>()
|
||||
function assign(previous: number, visited: Set<number>): boolean {
|
||||
for (const next of candidates[previous] ?? []) {
|
||||
if (visited.has(next)) continue
|
||||
visited.add(next)
|
||||
const owner = owners.get(next)
|
||||
if (owner === undefined || assign(owner, visited)) {
|
||||
owners.set(next, previous)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
for (let previous = 0; previous < candidates.length; previous += 1) {
|
||||
if (!assign(previous, new Set())) return undefined
|
||||
}
|
||||
const matches: number[] = []
|
||||
for (const [next, previous] of owners) matches[previous] = next
|
||||
return matches
|
||||
}
|
||||
|
||||
/** Classify structural differences; only optional payload properties and ordinary event additions are additive.
|
||||
* @param before - predecessor root, or absence for an addition.
|
||||
* @param after - successor root, or absence for deletion.
|
||||
* @returns concrete changes and their format-bump requirement.
|
||||
*/
|
||||
export function classifyPersistenceChange(before: PersistenceRoot | null, after: PersistenceRoot | null): PersistenceTypeChange[] {
|
||||
if (before === null) {
|
||||
return after === null ? [] : [{ path: after.key, kind: 'root-added', description: 'root added',
|
||||
requiresVersionBump: after.kind !== 'event' || after.surface !== false }]
|
||||
}
|
||||
if (after === null) return [{ path: before.key, kind: 'root-removed', description: 'root removed', requiresVersionBump: true }]
|
||||
const key = after.key
|
||||
const oldRoot = before
|
||||
const newRoot = after
|
||||
const describe = (path: string, kind: PersistenceTypeChangeKind, requiresVersionBump = true): PersistenceTypeChange =>
|
||||
({ path, kind, description: CHANGE_DESCRIPTIONS[kind], requiresVersionBump })
|
||||
const changes: PersistenceTypeChange[] = []
|
||||
if (before.kind !== after.kind || before.surface !== after.surface) changes.push(describe(key, 'root-classification-changed'))
|
||||
if (before.digest === after.digest) return changes
|
||||
const fingerprints = [new Map<number, string>(), new Map<number, string>()] as const
|
||||
const fingerprint = (schema: CanonicalSchema, index: number, side: 0 | 1): string => {
|
||||
let result = fingerprints[side].get(index)
|
||||
if (result === undefined) { result = subDigest(schema, index); fingerprints[side].set(index, result) }
|
||||
return result
|
||||
}
|
||||
type Scope = 'event' | 'body' | 'strict'
|
||||
function compare(
|
||||
oldIndex: number, newIndex: number, path: string, scope: Scope, ancestors: ReadonlySet<string>,
|
||||
): PersistenceTypeChange[] {
|
||||
if (fingerprint(oldRoot.schema, oldIndex, 0) === fingerprint(newRoot.schema, newIndex, 1)) return []
|
||||
const pair = `${oldIndex}:${newIndex}:${scope}`
|
||||
if (ancestors.has(pair)) return []
|
||||
// Recursive pairs are assumptions for this candidate only. A failed sibling
|
||||
// or unmatched union arm cannot leave a cached success for another candidate.
|
||||
const active = new Set(ancestors).add(pair)
|
||||
const differences: PersistenceTypeChange[] = []
|
||||
const add = (path: string, kind: PersistenceTypeChangeKind, requiresVersionBump = true): void => {
|
||||
differences.push(describe(path, kind, requiresVersionBump))
|
||||
}
|
||||
const descend = (oldType: number, newType: number, child: string, childScope: Scope): void => {
|
||||
differences.push(...compare(oldType, newType, child, childScope, active))
|
||||
}
|
||||
const oldNode = oldRoot.schema.nodes[oldIndex] as SchemaNode
|
||||
const newNode = newRoot.schema.nodes[newIndex] as SchemaNode
|
||||
if (oldNode.kind !== newNode.kind) return [describe(path, 'type-changed')]
|
||||
if (oldNode.kind === 'object' && newNode.kind === 'object') {
|
||||
const oldProps = new Map(oldNode.properties.map(property => [property.name, property]))
|
||||
const newProps = new Map(newNode.properties.map(property => [property.name, property]))
|
||||
for (const [name, property] of oldProps) {
|
||||
const next = newProps.get(name)
|
||||
const child = `${path}.${name}`
|
||||
if (next === undefined) { add(child, 'property-removed'); continue }
|
||||
if (property.optional !== next.optional) add(child, next.optional ? 'property-made-optional' : 'property-made-required', scope !== 'body' || !next.optional)
|
||||
descend(property.type, next.type, child, scope === 'body' || scope === 'event' && name === 'data' ? 'body' : 'strict')
|
||||
}
|
||||
for (const [name, property] of newProps) {
|
||||
if (!oldProps.has(name)) add(`${path}.${name}`, property.optional ? 'optional-property-added' : 'required-property-added', scope !== 'body' || !property.optional)
|
||||
}
|
||||
const oldIndices = new Map(oldNode.indices.map(entry => [fingerprint(oldRoot.schema, entry.key, 0), entry]))
|
||||
const newIndices = new Map(newNode.indices.map(entry => [fingerprint(newRoot.schema, entry.key, 1), entry]))
|
||||
if (oldIndices.size !== newIndices.size || [...oldIndices.keys()].some(index => !newIndices.has(index))) add(path, 'index-signature-changed')
|
||||
for (const [index, entry] of oldIndices) {
|
||||
const next = newIndices.get(index)
|
||||
if (next !== undefined) descend(entry.value, next.value, `${path}[*]`, scope === 'body' ? 'body' : 'strict')
|
||||
}
|
||||
return differences
|
||||
}
|
||||
if (oldNode.kind === 'array' && newNode.kind === 'array') {
|
||||
return compare(oldNode.element, newNode.element, `${path}[]`, scope === 'body' ? 'body' : 'strict', active)
|
||||
}
|
||||
if (oldNode.kind === 'tuple' && newNode.kind === 'tuple') {
|
||||
if (oldNode.elements.length !== newNode.elements.length) return [describe(path, 'tuple-length-changed')]
|
||||
for (const [index, element] of oldNode.elements.entries()) {
|
||||
const next = newNode.elements[index] as SchemaTupleElement
|
||||
if (element.optional !== next.optional || element.rest !== next.rest) add(`${path}[${index}]`, 'tuple-element-cardinality-changed')
|
||||
descend(element.type, next.type, `${path}[${index}]`, scope === 'body' ? 'body' : 'strict')
|
||||
}
|
||||
return differences
|
||||
}
|
||||
if (oldNode.kind === 'union' && newNode.kind === 'union') {
|
||||
if (oldNode.types.length !== newNode.types.length) return [describe(path, 'union-variants-changed')]
|
||||
const candidates = oldNode.types.map(oldType => newNode.types.map(newType => compare(oldType, newType, path, scope, active)))
|
||||
const matching = matchUnionVariants(candidates.map(row => row.flatMap((candidate, index) =>
|
||||
candidate.every(change => !change.requiresVersionBump) ? [index] : [])))
|
||||
if (matching !== undefined) return matching.flatMap((next, previous) => candidates[previous]?.[next] ?? [])
|
||||
const oldTypes = new Map(oldNode.types.map(index => [fingerprint(oldRoot.schema, index, 0), index]))
|
||||
const newTypes = new Map(newNode.types.map(index => [fingerprint(newRoot.schema, index, 1), index]))
|
||||
const removed = [...oldTypes].filter(([hash]) => !newTypes.has(hash)).map(([, index]) => index)
|
||||
const added = [...newTypes].filter(([hash]) => !oldTypes.has(hash)).map(([, index]) => index)
|
||||
if (removed.length === 1 && added.length === 1) return compare(removed[0] as number, added[0] as number, path, scope, active)
|
||||
return [describe(path, 'union-variants-changed')]
|
||||
}
|
||||
return [describe(path, 'type-changed')]
|
||||
}
|
||||
changes.push(...compare(0, 0, key, before.kind === 'event' ? 'event' : 'strict', new Set()))
|
||||
if (changes.length === 0) changes.push(describe(key, 'type-changed'))
|
||||
return [...new Map(changes.map(change => [JSON.stringify([change.path, change.kind, change.requiresVersionBump]), change])).values()]
|
||||
}
|
||||
|
||||
function parseDocument(source: string, filename: string, allowIncomplete = false): PersistenceChangeRecord {
|
||||
const frontmatter = /^---\n([\s\S]*?)\n---\n/u.exec(source)
|
||||
if (frontmatter === null || record(load(frontmatter[1] as string, { schema: JSON_SCHEMA }), filename).kind !== 'persistence-change') throw new Error(`${filename}: kind must be persistence-change`)
|
||||
const openings = [...source.matchAll(/^```yaml persistence-change\s*$/gmu)]
|
||||
const block = /^```yaml persistence-change[^\S\n]*\n([\s\S]*?)^```[^\S\n]*$/mu.exec(source)
|
||||
if (openings.length !== 1 || block === null) throw new Error(`${filename}: expected exactly one persistence-change block`)
|
||||
const input = record(load(block[1] as string, { schema: JSON_SCHEMA }), filename)
|
||||
keys(input, ['schemaVersion', 'id', 'baseline', 'changes'], filename)
|
||||
if (input.schemaVersion !== 1) throw new Error(`${filename}: unsupported acknowledgement schema version`)
|
||||
const id = identifier(input.id, filename)
|
||||
if (basename(filename) !== `${id}.md`) throw new Error(`${filename}: record id does not match filename`)
|
||||
bool(input.baseline, filename)
|
||||
const roots = new Set<string>()
|
||||
for (const value of array(input.changes, `${filename} changes`)) {
|
||||
const change = record(value, filename)
|
||||
keys(change, ['root', 'previous', 'after', 'decision'], filename)
|
||||
const root = textValue(change.root, 'changed root')
|
||||
if (roots.has(root)) throw new Error(`${filename}: duplicate change for ${root}`)
|
||||
roots.add(root)
|
||||
if (change.previous !== null) identifier(change.previous, 'previous record')
|
||||
if (change.after !== null) digest(change.after, 'after digest')
|
||||
if (change.decision !== 'same-version' && change.decision !== 'version-bump') throw new Error(`${filename}: invalid compatibility decision`)
|
||||
}
|
||||
if (roots.size === 0) throw new Error(`${filename}: changes must not be empty`)
|
||||
if (!allowIncomplete && (source.includes(EXPLANATION_PLACEHOLDER) || source.includes(EVIDENCE_PLACEHOLDER))) throw new Error(`${filename}: complete compatibility and verification prose`)
|
||||
return input as unknown as PersistenceChangeRecord
|
||||
}
|
||||
|
||||
function headerVersion(root: PersistenceRoot | null): number | undefined {
|
||||
if (root === null) return undefined
|
||||
const node = root.schema.nodes[0]
|
||||
if (node?.kind !== 'object') return undefined
|
||||
const property = node.properties.find(item => item.name === 'version')
|
||||
if (property === undefined) return undefined
|
||||
const version = root.schema.nodes[property.type]
|
||||
return version?.kind === 'literal' && typeof version.value === 'number' && Number.isSafeInteger(version.value) ? version.value : undefined
|
||||
}
|
||||
|
||||
/** Check every historical transition and return each root's unique current tip.
|
||||
* @param entries - parsed documents and their self-contained schema snapshots.
|
||||
* @returns validated history and tips, without consulting Git or current source.
|
||||
*/
|
||||
export function validatePersistenceHistory(entries: readonly PersistenceHistoryEntry[]): PersistenceHistory {
|
||||
if (entries.filter(entry => entry.record.baseline).length !== 1) throw new Error('persistence history requires exactly one baseline')
|
||||
const records = new Map<string, PersistenceHistoryEntry>()
|
||||
for (const entry of entries) {
|
||||
if (records.has(entry.record.id)) throw new Error(`duplicate persistence record ${entry.record.id}`)
|
||||
records.set(entry.record.id, entry)
|
||||
const expected = entry.record.changes.filter(change => change.after !== null).map(change => change.root).sort()
|
||||
if (JSON.stringify(expected) !== JSON.stringify(entry.snapshot.roots.map(root => root.key).sort())) throw new Error(`${entry.record.id}: snapshot roots do not match acknowledged after schemas`)
|
||||
for (const change of entry.record.changes) {
|
||||
const root = entry.snapshot.roots.find(root => root.key === change.root)
|
||||
if ((root?.digest ?? null) !== change.after) throw new Error(`${entry.record.id}: after digest mismatch for ${change.root}`)
|
||||
if (entry.record.baseline && (change.previous !== null || change.after === null || change.decision !== 'same-version')) throw new Error(`${entry.record.id}: invalid baseline transition`)
|
||||
}
|
||||
}
|
||||
const states = new Map<string, 'visiting' | 'visited'>()
|
||||
const successors = new Map<string, string>()
|
||||
const nodes = new Map<string, { entry: PersistenceHistoryEntry; change: PersistenceChange }>()
|
||||
const tips = new Map<string, Tip>()
|
||||
const nodeKey = (id: string | null, root: string): string => JSON.stringify([id, root])
|
||||
for (const entry of entries) for (const change of entry.record.changes) {
|
||||
const parentKey = nodeKey(change.previous, change.root)
|
||||
if (successors.has(parentKey)) throw new Error(`forked persistence history for ${change.root}: ${successors.get(parentKey)} and ${entry.record.id}`)
|
||||
successors.set(parentKey, entry.record.id)
|
||||
nodes.set(nodeKey(entry.record.id, change.root), { entry, change })
|
||||
}
|
||||
function visit(id: string, root: string): PersistenceRoot | null {
|
||||
const key = nodeKey(id, root)
|
||||
const found = nodes.get(key)
|
||||
if (found === undefined) throw new Error(`missing predecessor ${id} for ${root}`)
|
||||
if (states.get(key) === 'visiting') throw new Error(`cycle in persistence history for ${root}`)
|
||||
const after = found.entry.snapshot.roots.find(item => item.key === root) ?? null
|
||||
if (states.get(key) === 'visited') return after
|
||||
states.set(key, 'visiting')
|
||||
const before = found.change.previous === null ? null : visit(found.change.previous, root)
|
||||
if (!found.entry.record.baseline) {
|
||||
const differences = classifyPersistenceChange(before, after)
|
||||
if (differences.length === 0) throw new Error(`${id}: unchanged acknowledgement for ${root}`)
|
||||
if (differences.some(change => change.requiresVersionBump) && found.change.decision !== 'version-bump') {
|
||||
throw new PersistenceChangeFailure(
|
||||
`${id}: ${root} requires a format version bump (${differences.filter(change => change.requiresVersionBump).map(change => change.path + ': ' + change.description).join('; ')})`,
|
||||
'version-bump-required', differences.map(change => ({ root, ...change })), [rootTransition(before, after)],
|
||||
)
|
||||
}
|
||||
if (found.change.decision === 'version-bump') {
|
||||
const header = found.entry.record.changes.find(change => change.root === 'SessionHeader')
|
||||
const oldHeader = header?.previous === null || header === undefined ? null : visit(header.previous, 'SessionHeader')
|
||||
const from = headerVersion(oldHeader)
|
||||
const to = headerVersion(found.entry.snapshot.roots.find(item => item.key === 'SessionHeader') ?? null)
|
||||
if (from === undefined || to === undefined || to <= from) {
|
||||
throw new PersistenceChangeFailure(`${id}: version-bump requires this record's own increasing SessionHeader.version transition`,
|
||||
'version-transition-required', differences.map(change => ({ root, ...change })), [rootTransition(before, after)])
|
||||
}
|
||||
}
|
||||
}
|
||||
states.set(key, 'visited')
|
||||
if (!successors.has(key)) tips.set(root, { id, root: after })
|
||||
return after
|
||||
}
|
||||
for (const entry of entries) for (const change of entry.record.changes) visit(entry.record.id, change.root)
|
||||
const baseline = entries.find(entry => entry.record.baseline) as PersistenceHistoryEntry
|
||||
if (!baseline.snapshot.roots.some(root => root.key === 'SessionHeader')
|
||||
|| !baseline.snapshot.roots.some(root => root.key === 'SessionEventEnvelope')
|
||||
|| !baseline.snapshot.roots.some(root => root.key === 'JsonlHeaderLine')) {
|
||||
throw new Error('baseline requires SessionHeader, JsonlHeaderLine, and SessionEventEnvelope roots')
|
||||
}
|
||||
return { entries, tips }
|
||||
}
|
||||
|
||||
/** Read and validate all current-tree persistence acknowledgement files.
|
||||
* @param root - checkout or isolated fixture root.
|
||||
* @returns checked history without comparing its tips to current source.
|
||||
*/
|
||||
export function loadPersistenceHistory(root: string): PersistenceHistory {
|
||||
return validatePersistenceHistory(readPersistenceEntries(root))
|
||||
}
|
||||
|
||||
function readPersistenceEntries(root: string, allowIncompleteId?: string): PersistenceHistoryEntry[] {
|
||||
const directory = join(root, HISTORY_DIRECTORY)
|
||||
if (!existsSync(directory)) throw new Error('persistence history is missing; use pnpm run persistence-changes --baseline ID for explicit initialization')
|
||||
const files = readdirSync(directory).sort()
|
||||
const documents = files.filter(file => file.endsWith('.md') && !file.endsWith('.zh.md') && file !== 'README.md' && file !== 'AGENTS.md')
|
||||
const snapshots = new Set(files.filter(file => file.endsWith('.schema.json')))
|
||||
const entries = documents.map((filename) => {
|
||||
const source = readFileSync(join(directory, filename), 'utf8').replaceAll('\r\n', '\n')
|
||||
const allowIncomplete = filename === `${allowIncompleteId}.md`
|
||||
const change = parseDocument(source, filename, allowIncomplete)
|
||||
const snapshotName = `${change.id}.schema.json`
|
||||
if (!snapshots.delete(snapshotName)) throw new Error(`${filename}: missing schema snapshot ${snapshotName}`)
|
||||
const snapshot = parsePersistenceSnapshot(JSON.parse(readFileSync(join(directory, snapshotName), 'utf8')))
|
||||
const translatedName = `${change.id}.zh.md`
|
||||
if (!files.includes(translatedName)) throw new Error(`${filename}: missing Chinese counterpart`)
|
||||
const translated = readFileSync(join(directory, translatedName), 'utf8').replaceAll('\r\n', '\n')
|
||||
const englishBlock = source.match(/^```yaml persistence-change[^\S\n]*\n([\s\S]*?)^```[^\S\n]*$/mu)?.[1]
|
||||
const chineseBlocks = [...translated.matchAll(/^```yaml persistence-change[^\S\n]*\n([\s\S]*?)^```[^\S\n]*$/gmu)]
|
||||
if (chineseBlocks.length !== 1 || chineseBlocks[0]?.[1] !== englishBlock) throw new Error(`${filename}: bilingual machine records differ`)
|
||||
if (!allowIncomplete && (translated.includes(EXPLANATION_PLACEHOLDER) || translated.includes(EVIDENCE_PLACEHOLDER))) throw new Error(`${translatedName}: complete compatibility and verification prose`)
|
||||
return { record: change, snapshot }
|
||||
})
|
||||
if (snapshots.size !== 0) throw new Error(`unreferenced persistence schema snapshot: ${[...snapshots].join(', ')}`)
|
||||
return entries
|
||||
}
|
||||
|
||||
function currentDifferences(history: PersistenceHistory, current: PersistenceSchemaInventory): string[] {
|
||||
const roots = new Map(current.roots.map(root => [root.key, root]))
|
||||
const differences: string[] = []
|
||||
for (const key of new Set([...history.tips.keys(), ...roots.keys()])) {
|
||||
if (classifyPersistenceChange(history.tips.get(key)?.root ?? null, roots.get(key) ?? null).length !== 0) differences.push(key)
|
||||
}
|
||||
return differences.sort()
|
||||
}
|
||||
|
||||
/** Verify current generated output and acknowledgement tips together.
|
||||
* @param root - checkout or isolated fixture root.
|
||||
* @param current - freshly extracted current-source inventory.
|
||||
* @returns verified history.
|
||||
*/
|
||||
export function verifyPersistenceChanges(root: string, current: PersistenceSchemaInventory): PersistenceHistory {
|
||||
const history = loadPersistenceHistory(root)
|
||||
const differences = reportedDifferences(history, current)
|
||||
const transitions = rootTransitions(history, current)
|
||||
const committedPath = join(root, CURRENT_SCHEMA)
|
||||
if (!existsSync(committedPath)) {
|
||||
throw new PersistenceChangeFailure(`${CURRENT_SCHEMA} is missing; regenerate the persistence catalog`, 'generated-artifact-missing', differences, transitions)
|
||||
}
|
||||
const committed = parsePersistenceSnapshot(JSON.parse(readFileSync(committedPath, 'utf8')))
|
||||
if (JSON.stringify(committed) !== JSON.stringify(current)) {
|
||||
throw new PersistenceChangeFailure(`${CURRENT_SCHEMA} is stale; regenerate the persistence catalog`, 'stale-artifacts', differences, transitions)
|
||||
}
|
||||
if (differences.length !== 0) {
|
||||
const details = differences.map(change => ` ${change.path}: ${change.description} (${change.requiresVersionBump ? 'version-bump required' : 'same-version allowed'})`)
|
||||
throw new PersistenceChangeFailure(`unacknowledged persistence type changes:\n${details.join('\n')}`, 'unacknowledged-changes', differences, transitions)
|
||||
}
|
||||
return history
|
||||
}
|
||||
|
||||
function rootTransition(before: PersistenceRoot | null, after: PersistenceRoot | null): RootTransition {
|
||||
const root = (after ?? before) as PersistenceRoot
|
||||
return { root: root.key, kind: root.kind, before: before?.digest ?? null, after: after?.digest ?? null }
|
||||
}
|
||||
|
||||
function rootTransitions(history: PersistenceHistory | undefined, current: PersistenceSchemaInventory): RootTransition[] {
|
||||
const changed = history === undefined ? current.roots.map(root => root.key) : currentDifferences(history, current)
|
||||
return changed.map(root => rootTransition(history?.tips.get(root)?.root ?? null, current.roots.find(item => item.key === root) ?? null))
|
||||
}
|
||||
|
||||
function reportedDifferences(history: PersistenceHistory, current: PersistenceSchemaInventory): ReportedChange[] {
|
||||
return currentDifferences(history, current).flatMap(root => classifyPersistenceChange(
|
||||
history.tips.get(root)?.root ?? null, current.roots.find(item => item.key === root) ?? null,
|
||||
).map(change => ({ root, ...change })))
|
||||
}
|
||||
|
||||
function machineBlock(change: PersistenceChangeRecord): string {
|
||||
return ['```yaml persistence-change', 'schemaVersion: 1', `id: ${change.id}`, `baseline: ${String(change.baseline)}`, 'changes:',
|
||||
...change.changes.flatMap(item => [` - root: ${JSON.stringify(item.root)}`, ` previous: ${item.previous === null ? 'null' : JSON.stringify(item.previous)}`, ` after: ${item.after === null ? 'null' : JSON.stringify(item.after)}`, ` decision: ${item.decision}`]), '```'].join('\n')
|
||||
}
|
||||
|
||||
function scaffold(change: PersistenceChangeRecord, chinese: boolean, prose?: PersistenceChangeProse): string {
|
||||
const summary = chinese ? '概述' : 'Summary'
|
||||
const compatibility = chinese ? '兼容性' : 'Compatibility'
|
||||
const verification = chinese ? '验证' : 'Verification'
|
||||
return ['---', `description: ${JSON.stringify(chinese ? '记录持久化类型更改及其兼容性确认。' : 'Records a persistence type transition and its compatibility acknowledgement.')}`, 'kind: persistence-change', '---', '',
|
||||
`# ${change.id}`, '', chinese ? `[English](${change.id}.md) | 中文` : `English | [中文](${change.id}.zh.md)`, '',
|
||||
`## ${summary}`, '', prose?.summary ?? EXPLANATION_PLACEHOLDER, '', '## ' + (chinese ? '目录' : 'Table of Contents'), '',
|
||||
`- [${chinese ? '声明' : 'Declaration'}](#declaration)`, `- [${compatibility}](#compatibility)`, `- [${verification}](#verification)`, `- [${chinese ? '开发备注' : 'Dev Note'}](#dev-note)`, '',
|
||||
'<a id="declaration"></a>', `## ${chinese ? '声明' : 'Declaration'}`, '', machineBlock(change), '',
|
||||
'<a id="compatibility"></a>', `## ${compatibility}`, '', prose?.compatibility ?? EXPLANATION_PLACEHOLDER, '',
|
||||
'<a id="verification"></a>', `## ${verification}`, '', prose?.verification ?? EVIDENCE_PLACEHOLDER, '', '<a id="dev-note"></a>', `## ${chinese ? '开发备注' : 'Dev Note'}`, '', chinese ? '无。' : 'None.', ''].join('\n')
|
||||
}
|
||||
|
||||
/** Parse explicit authored prose without supplying compatibility or validation claims.
|
||||
* @param value - decoded JSON supplied through --prose.
|
||||
* @returns complete English and Chinese section text.
|
||||
*/
|
||||
export function parsePersistenceProse(value: unknown): PersistenceChangeProsePair {
|
||||
const pair = record(value, 'persistence prose')
|
||||
keys(pair, ['en', 'zh'], 'persistence prose')
|
||||
for (const locale of ['en', 'zh']) {
|
||||
const sections = record(pair[locale], `persistence prose ${locale}`)
|
||||
keys(sections, ['summary', 'compatibility', 'verification'], `persistence prose ${locale}`)
|
||||
for (const [name, value] of Object.entries(sections)) {
|
||||
const text = textValue(value, `${locale}.${name}`)
|
||||
if (text.trim().length === 0 || text.includes(EXPLANATION_PLACEHOLDER) || text.includes(EVIDENCE_PLACEHOLDER)) {
|
||||
throw new Error(`${locale}.${name} requires authored prose without scaffold placeholders`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return pair as unknown as PersistenceChangeProsePair
|
||||
}
|
||||
|
||||
function updateDocument(source: string, change: PersistenceChangeRecord, chinese: boolean, prose?: PersistenceChangeProse): string {
|
||||
source = source.replace(/^```yaml persistence-change[^\S\n]*\n[\s\S]*?^```[^\S\n]*$/mu, machineBlock(change))
|
||||
if (prose === undefined) return source
|
||||
const headings = chinese ? ['概述', '兼容性', '验证'] : ['Summary', 'Compatibility', 'Verification']
|
||||
for (const [index, text] of [prose.summary, prose.compatibility, prose.verification].entries()) {
|
||||
const lines = source.split('\n')
|
||||
const heading = `## ${headings[index]}`
|
||||
const start = lines.indexOf(heading)
|
||||
if (start < 0 || lines.lastIndexOf(heading) !== start) throw new Error(`--prose requires one ${heading} section in the existing record`)
|
||||
let end = lines.findIndex((line, lineIndex) => lineIndex > start && /^##? /u.test(line))
|
||||
if (end < 0) end = lines.length
|
||||
let anchor = end
|
||||
while (anchor > start + 1 && lines[anchor - 1] === '') anchor -= 1
|
||||
if (/^<a id="[^"]+"><\/a>$/u.test(lines[anchor - 1] ?? '')) end = anchor - 1
|
||||
lines.splice(start + 1, end - start - 1, '', text.trim(), '')
|
||||
source = lines.join('\n')
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
function commandOperation(args: readonly string[]): CommandResult['operation'] {
|
||||
if (args.some(arg => arg === '--baseline' || arg.startsWith('--baseline='))) return 'baseline'
|
||||
if (args.some(arg => arg === '--record' || arg.startsWith('--record='))) return 'record'
|
||||
if (args.some(arg => arg === '--update' || arg.startsWith('--update='))) return 'update'
|
||||
return 'check'
|
||||
}
|
||||
|
||||
function executeCommand(
|
||||
args: readonly string[], root: string, extract: (root: string) => PersistenceSchemaInventory,
|
||||
artifacts: (root: string, current: PersistenceSchemaInventory) => readonly PersistenceArtifact[],
|
||||
): CommandResult {
|
||||
const { values } = parseArgs({ args: [...args], strict: true, allowPositionals: false, options: {
|
||||
check: { type: 'boolean' }, baseline: { type: 'string' }, record: { type: 'string' }, update: { type: 'string' },
|
||||
decision: { type: 'string' }, root: { type: 'string' }, prose: { type: 'string' }, json: { type: 'boolean' },
|
||||
} })
|
||||
if (values.root !== undefined) root = resolve(values.root)
|
||||
const selected = [values.check === true, values.baseline !== undefined, values.record !== undefined, values.update !== undefined]
|
||||
if (selected.filter(Boolean).length > 1) throw new Error('choose exactly one of --check, --baseline ID, --record ID, or --update ID')
|
||||
if (values.record === undefined && values.update === undefined && values.decision !== undefined) throw new Error('--decision requires --record or --update')
|
||||
const operation = commandOperation(args)
|
||||
if (operation === 'check' && values.prose !== undefined) throw new Error('--prose requires --baseline, --record, or --update')
|
||||
const prose = values.prose === undefined ? undefined : parsePersistenceProse(JSON.parse(readFileSync(resolve(root, values.prose), 'utf8')))
|
||||
const current = parsePersistenceSnapshot(extract(root))
|
||||
if (operation === 'check') {
|
||||
const history = verifyPersistenceChanges(root, current)
|
||||
return { schemaVersion: 1, ok: true, operation,
|
||||
message: `persistence changes: ${current.roots.length} roots match ${history.entries.length} history records.`, changes: [], roots: [], files: [] }
|
||||
}
|
||||
const baseline = operation === 'baseline'
|
||||
const update = operation === 'update'
|
||||
const id = identifier(values.baseline ?? values.record ?? values.update, 'record id')
|
||||
const directory = join(root, HISTORY_DIRECTORY)
|
||||
if (baseline && existsSync(directory) && readdirSync(directory).some(file => file.endsWith('.schema.json') || ID_PATTERN.test(file.replace(/\.md$/u, '')))) throw new Error('persistence baseline already exists; baseline creation cannot reset history')
|
||||
if (values.decision !== undefined && values.decision !== 'same-version' && values.decision !== 'version-bump') {
|
||||
throw new Error('--decision must be same-version or version-bump')
|
||||
}
|
||||
const entries = baseline ? [] : readPersistenceEntries(root, update ? id : undefined)
|
||||
const existing = update ? entries.find(entry => entry.record.id === id) : undefined
|
||||
if (update && existing === undefined) throw new Error(`${id}: cannot update a missing acknowledgement`)
|
||||
if (existing?.record.baseline === true) throw new Error('cannot update the persistence baseline')
|
||||
if (existing !== undefined && entries.some(entry => entry.record.changes.some(change => change.previous === id))) {
|
||||
throw new Error(`${id}: cannot update an acknowledgement with successors`)
|
||||
}
|
||||
const prior = entries.filter(entry => entry !== existing)
|
||||
const history = baseline ? undefined : validatePersistenceHistory(prior)
|
||||
const changed = baseline ? current.roots.map(root => root.key) : currentDifferences(history as PersistenceHistory, current)
|
||||
if (changed.length === 0) throw new Error('no persistence type changes to acknowledge')
|
||||
const differences = history === undefined ? [] : reportedDifferences(history, current)
|
||||
const decision = values.decision ?? (differences.some(change => change.requiresVersionBump) ? 'version-bump' : 'same-version')
|
||||
const roots = current.roots.filter(root => changed.includes(root.key))
|
||||
const change: PersistenceChangeRecord = { schemaVersion: 1, id, baseline, changes: changed.sort().map(key => ({
|
||||
root: key, previous: history?.tips.get(key)?.id ?? null,
|
||||
after: roots.find(root => root.key === key)?.digest ?? null, decision,
|
||||
})) }
|
||||
const snapshot: PersistenceSchemaInventory = { formatVersion: 1, roots, types: [] }
|
||||
validatePersistenceHistory([...prior, { record: change, snapshot }])
|
||||
const document = (chinese: boolean): string => {
|
||||
const supplied = chinese ? prose?.zh : prose?.en
|
||||
return existing === undefined ? scaffold(change, chinese, supplied)
|
||||
: updateDocument(readFileSync(join(directory, `${id}${chinese ? '.zh' : ''}.md`), 'utf8'), change, chinese, supplied)
|
||||
}
|
||||
const english = document(false)
|
||||
const chinese = document(true)
|
||||
parseDocument(english, `${id}.md`, prose === undefined && existing === undefined)
|
||||
parseDocument(chinese, `${id}.md`, prose === undefined && existing === undefined)
|
||||
const recordFiles = [
|
||||
...renderPersistencePair(root, `${HISTORY_DIRECTORY}/${id}.md`, english, chinese),
|
||||
{ path: `${HISTORY_DIRECTORY}/${id}.schema.json`, content: JSON.stringify(snapshot, null, 2) + '\n' },
|
||||
]
|
||||
if (!update && recordFiles.some(file => existsSync(join(root, file.path)))) throw new Error(`${id}: acknowledgement file already exists`)
|
||||
const outputs = [...artifacts(root, current), ...recordFiles]
|
||||
for (const file of outputs) mkdirSync(resolve(root, file.path, '..'), { recursive: true })
|
||||
for (const file of outputs) writeFileSync(resolve(root, file.path), file.content, { flag: recordFiles.includes(file) && !update ? 'wx' : 'w' })
|
||||
const completion = baseline
|
||||
? 'Complete both record documents and refresh their translation pairing.'
|
||||
: `Complete the compatibility and verification prose with --update ${id} --prose FILE.`
|
||||
const message = existing === undefined && prose === undefined
|
||||
? `Created ${HISTORY_DIRECTORY}/${id}.md and paired schema files. ${completion}`
|
||||
: `${update ? 'Updated' : 'Created'} ${HISTORY_DIRECTORY}/${id}.md; schema artifacts and bilingual pairing are current.`
|
||||
return { schemaVersion: 1, ok: true, operation, recordId: id, message,
|
||||
changes: differences,
|
||||
roots: rootTransitions(history, current), files: outputs.map(file => file.path) }
|
||||
}
|
||||
|
||||
/** Execute tree-only verification or author an explicit persistence acknowledgement.
|
||||
* @param args - check, baseline, record, or update arguments; --json selects structured output.
|
||||
* @param root - checkout root; defaults to this script's repository.
|
||||
* @param extract - current-source extraction function; fixtures supply their own source reader.
|
||||
* @param artifacts - renderer for current generated artifacts; fixtures may isolate the inventory artifact.
|
||||
* @returns text or one JSON result; JSON failures retain ok:false for the process entry point.
|
||||
*/
|
||||
export function runPersistenceChanges(
|
||||
args: readonly string[],
|
||||
root: string = resolve(import.meta.dirname, '..'),
|
||||
extract: (root: string) => PersistenceSchemaInventory = extractPersistenceSchema,
|
||||
artifacts: (root: string, current: PersistenceSchemaInventory) => readonly PersistenceArtifact[] = persistenceCatalogArtifacts,
|
||||
): string {
|
||||
try {
|
||||
const result = executeCommand(args, root, extract, artifacts)
|
||||
return args.includes('--json') ? JSON.stringify(result) : result.message
|
||||
} catch (error: unknown) {
|
||||
if (!args.includes('--json')) throw error
|
||||
const result: CommandResult = { schemaVersion: 1, ok: false, operation: commandOperation(args),
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
code: error instanceof PersistenceChangeFailure ? error.code : 'verification-failed',
|
||||
changes: error instanceof PersistenceChangeFailure ? error.changes : [],
|
||||
roots: error instanceof PersistenceChangeFailure ? error.roots : [], files: [] }
|
||||
return JSON.stringify(result)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
|
||||
try {
|
||||
const output = runPersistenceChanges(process.argv.slice(2))
|
||||
console.log(output)
|
||||
if (process.argv.includes('--json') && !(JSON.parse(output) as { ok: boolean }).ok) process.exitCode = 1
|
||||
} catch (error: unknown) {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/** Render historical format references from complete, validated in-tree schemas. */
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { basename, join, posix } from 'node:path'
|
||||
import { renderPersistencePair, type PersistenceArtifact } from './persistence-artifacts.ts'
|
||||
import type { PersistenceFormatEntry, PersistenceFormats } from './persistence-formats.ts'
|
||||
import { renderPersistenceSchemaDefinitions, renderPersistenceSchemaIndex } from './render-persistence-schema.ts'
|
||||
|
||||
type Language = 'en' | 'zh'
|
||||
|
||||
function replaceRegion(source: string, name: string, content: string, path: string): string {
|
||||
const start = `<!-- persistence-format-${name}:start -->`
|
||||
const end = `<!-- persistence-format-${name}:end -->`
|
||||
const opening = source.indexOf(start)
|
||||
const closing = source.indexOf(end)
|
||||
if (opening < 0 || closing < opening || source.indexOf(start, opening + start.length) >= 0
|
||||
|| source.indexOf(end, closing + end.length) >= 0) throw new Error(`${path}: expected one ${name} factual block`)
|
||||
return source.slice(0, opening + start.length) + '\n\n' + content.trim() + '\n\n' + source.slice(closing)
|
||||
}
|
||||
|
||||
function historicalSchema(entry: PersistenceFormatEntry, language: Language): string {
|
||||
const schema = basename(entry.schemaPath)
|
||||
const introduction = language === 'en'
|
||||
? `The [complete machine inventory](${schema}) contains ${entry.inventory.roots.length} roots and ${entry.inventory.types.length} reachable types. Digests include all referenced fields; source names and paths describe the selected historical tree.`
|
||||
: `[完整机器目录](${schema})包含 ${entry.inventory.roots.length} 个根类型和 ${entry.inventory.types.length} 种可达类型。摘要包含所有引用字段;源码名称和路径描述所选的历史源码树。`
|
||||
return [
|
||||
'<a id="schema"></a>',
|
||||
language === 'en' ? '## Complete schemas' : '## 完整 schema',
|
||||
'',
|
||||
introduction,
|
||||
'',
|
||||
renderPersistenceSchemaIndex(entry.inventory, language, [], 3),
|
||||
'<details>',
|
||||
language === 'en' ? '<summary>Complete resolved types</summary>' : '<summary>完整解析类型</summary>',
|
||||
'',
|
||||
renderPersistenceSchemaDefinitions(entry.inventory, language, () => undefined, 3),
|
||||
'</details>',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function formatIndex(formats: PersistenceFormats, language: Language): string {
|
||||
const path = (target: string): string => posix.relative('docs/persistence-changes/historical-formats',
|
||||
language === 'zh' && target.endsWith('.md') ? target.replace(/\.md$/u, '.zh.md') : target)
|
||||
return [
|
||||
language === 'en' ? '| Format | Source | Reference | Machine schema | Roots / types |' : '| 格式 | 来源 | 参考文档 | 机器 schema | 根类型 / 类型 |',
|
||||
'|---|---|---|---|---|',
|
||||
...formats.entries.map((entry) => {
|
||||
const source = entry.source === undefined ? language === 'en' ? 'Current checkout' : '当前工作树'
|
||||
: 'tag' in entry.source ? `\`${entry.source.tag}\`` : `PR #${entry.source.pullRequest}`
|
||||
const label = entry.version === formats.currentVersion ? language === 'en' ? 'Current catalog' : '当前目录' : `V${entry.version}`
|
||||
return `| ${entry.version} | ${source} | [${label}](${path(entry.document)}) | [JSON](${path(entry.schemaPath)}) | ${entry.inventory.roots.length} / ${entry.inventory.types.length} |`
|
||||
}),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh bounded schema regions and the format index, preserving authored evidence.
|
||||
* @param root - repository with both document languages and factual markers.
|
||||
* @param formats - complete validated versions through the current writer.
|
||||
* @returns paired references and consistency records without modifying files.
|
||||
*/
|
||||
export function persistenceFormatFactArtifacts(root: string, formats: PersistenceFormats): PersistenceArtifact[] {
|
||||
const artifacts: PersistenceArtifact[] = []
|
||||
for (const entry of formats.entries.filter(entry => entry.version < formats.currentVersion)) {
|
||||
const render = (language: Language): string => {
|
||||
const path = language === 'en' ? entry.document : entry.document.replace(/\.md$/u, '.zh.md')
|
||||
return replaceRegion(readFileSync(join(root, path), 'utf8'), 'schema', historicalSchema(entry, language), path)
|
||||
}
|
||||
artifacts.push(...renderPersistencePair(root, entry.document, render('en'), render('zh')))
|
||||
}
|
||||
const index = 'docs/persistence-changes/historical-formats/README.md'
|
||||
const renderIndex = (language: Language): string => {
|
||||
const path = language === 'en' ? index : index.replace(/\.md$/u, '.zh.md')
|
||||
return replaceRegion(readFileSync(join(root, path), 'utf8'), 'index', formatIndex(formats, language), path)
|
||||
}
|
||||
artifacts.push(...renderPersistencePair(root, index, renderIndex('en'), renderIndex('zh')))
|
||||
return artifacts
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
/** Format reference coverage follows the writer version and retains complete historical schemas. */
|
||||
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { dump } from 'js-yaml'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { loadPersistenceFormats, runPersistenceFormats } from './persistence-formats.ts'
|
||||
import { parseHistoricalPersistenceSnapshot } from './persistence-changes.ts'
|
||||
import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
|
||||
import type { PersistenceRoot, PersistenceSchemaInventory, PersistenceType, SchemaNode } from './persistence-schema-model.ts'
|
||||
|
||||
const temporary: string[] = []
|
||||
afterEach(() => { for (const root of temporary.splice(0)) rmSync(root, { recursive: true, force: true }) })
|
||||
|
||||
function rootSchema(metadata: Omit<PersistenceRoot, 'schema' | 'digest'>, nodes: readonly SchemaNode[]): PersistenceRoot {
|
||||
const schema = canonicalizeSchema(nodes, 0)
|
||||
return { ...metadata, schema, digest: schemaDigest(schema) }
|
||||
}
|
||||
|
||||
function completeInventory(roots: readonly PersistenceRoot[]): PersistenceSchemaInventory {
|
||||
const types = new Map<string, PersistenceType>()
|
||||
for (const root of roots) {
|
||||
root.schema.nodes.forEach((_, index) => {
|
||||
const schema = canonicalizeSchema(root.schema.nodes, index)
|
||||
const digest = schemaDigest(schema)
|
||||
types.set(digest, { digest, schema, names: [], sources: [] })
|
||||
})
|
||||
}
|
||||
return { formatVersion: 1, roots, types: [...types.values()] }
|
||||
}
|
||||
|
||||
function header(key: string, version: number | 'number', optional = false): PersistenceRoot {
|
||||
return rootSchema({ key, kind: 'header' }, [
|
||||
{ kind: 'object', indices: [], properties: [{ name: 'version', type: 1, optional }] },
|
||||
version === 'number' ? { kind: 'primitive', type: 'number' } : { kind: 'literal', value: version },
|
||||
])
|
||||
}
|
||||
|
||||
function inventory(version: number): PersistenceSchemaInventory {
|
||||
return completeInventory([
|
||||
header('SessionHeader', version), header('JsonlHeaderLine', 'number'),
|
||||
rootSchema({ key: 'SessionEventEnvelope', kind: 'envelope' }, [{ kind: 'object', indices: [], properties: [] }]),
|
||||
rootSchema({ key: 'event:example/value', kind: 'event', event: 'example/value', surface: false }, [
|
||||
{ kind: 'object', indices: [], properties: [{ name: 'type', type: 1, optional: false }, { name: 'data', type: 2, optional: false }] },
|
||||
{ kind: 'literal', value: 'example/value' }, { kind: 'primitive', type: 'string' },
|
||||
]),
|
||||
])
|
||||
}
|
||||
|
||||
function write(root: string, path: string, content: string): void {
|
||||
writeFileSync(join(root, path), content)
|
||||
}
|
||||
|
||||
function schemaPath(version: number, currentVersion: number): string {
|
||||
return version === currentVersion ? 'docs/persistence-schema.json' : `docs/persistence-changes/historical-formats/v${version}.schema.json`
|
||||
}
|
||||
|
||||
function replaceSnapshot(root: string, version: number, currentVersion: number, snapshot: PersistenceSchemaInventory): void {
|
||||
write(root, schemaPath(version, currentVersion), JSON.stringify(snapshot))
|
||||
}
|
||||
|
||||
function saveReference(root: string, version: number, currentVersion: number, snapshot = inventory(version)): void {
|
||||
const current = version === currentVersion
|
||||
const filename = current ? 'persistence-catalog' : `persistence-changes/historical-formats/v${version}`
|
||||
const schemaName = current ? 'persistence-schema.json' : `v${version}.schema.json`
|
||||
const record = {
|
||||
schemaVersion: 1, sessionFormatVersion: version,
|
||||
source: version === 1 ? { pullRequest: 3349 } : { tag: 'dsh-v0.1.0-rc.5' },
|
||||
roots: Object.fromEntries(snapshot.roots.map(root => [root.key, root.digest])),
|
||||
}
|
||||
const lines = [
|
||||
...current ? [] : ['---', 'kind: persistence-format', '---', '', '```yaml persistence-format', dump(record).trimEnd(), '```', ''],
|
||||
`# Session format v${version}`, '', `[Inventory](${schemaName})`, '', '| Root | Kind | SHA-256 |', '|---|---|---|',
|
||||
...snapshot.roots.map(root => `| \`${root.key}\` | ${root.kind} | \`${root.digest}\` |`), '',
|
||||
]
|
||||
for (const suffix of ['.md', '.zh.md']) write(root, `docs/${filename}${suffix}`, lines.join('\n'))
|
||||
replaceSnapshot(root, version, currentVersion, snapshot)
|
||||
}
|
||||
|
||||
function fixture(currentVersion = 3): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-persistence-formats-'))
|
||||
temporary.push(root)
|
||||
mkdirSync(join(root, 'packages/core/session/src'), { recursive: true })
|
||||
mkdirSync(join(root, 'docs/persistence-changes/historical-formats'), { recursive: true })
|
||||
write(root, 'packages/core/session/src/types.ts', `export const SESSION_FORMAT_VERSION = ${currentVersion} as const\n`)
|
||||
for (let version = 0; version <= currentVersion; version += 1) saveReference(root, version, currentVersion)
|
||||
return root
|
||||
}
|
||||
|
||||
function edit(root: string, path: string, from: string, to: string): void {
|
||||
const source = readFileSync(join(root, path), 'utf8')
|
||||
expect(source).toContain(from)
|
||||
write(root, path, source.replace(from, to))
|
||||
}
|
||||
|
||||
function editPair(root: string, version: number, from: string, to: string): void {
|
||||
for (const suffix of ['.md', '.zh.md']) edit(root, `docs/persistence-changes/historical-formats/v${version}${suffix}`, from, to)
|
||||
}
|
||||
|
||||
function prepareFacts(root: string, currentVersion = 3): void {
|
||||
for (const suffix of ['.md', '.zh.md']) {
|
||||
for (let version = 0; version < currentVersion; version += 1) {
|
||||
const path = `docs/persistence-changes/historical-formats/v${version}${suffix}`
|
||||
const switcher = suffix === '.md' ? `English | [中文](v${version}.zh.md)` : `[English](v${version}.md) | 中文`
|
||||
write(root, path, readFileSync(join(root, path), 'utf8') + `\n${switcher}\n\n<!-- persistence-format-schema:start -->\n\nPending schemas.\n\n<!-- persistence-format-schema:end -->\n`)
|
||||
}
|
||||
const switcher = suffix === '.md' ? 'English | [中文](README.zh.md)' : '[English](README.md) | 中文'
|
||||
write(root, `docs/persistence-changes/historical-formats/README${suffix}`, `# Session formats\n\n${switcher}\n\nAuthored context.\n\n<!-- persistence-format-index:start -->\n\nPending formats.\n\n<!-- persistence-format-index:end -->\n`)
|
||||
}
|
||||
}
|
||||
|
||||
describe('archive current persistence format', () => {
|
||||
it('keeps every reachable type and drops extractor-retained types and source line numbers', () => {
|
||||
const root = fixture()
|
||||
const original = inventory(3)
|
||||
const sources = [
|
||||
'packages/example/src/types.ts:12', 'packages/other/src/types.ts:8:2', 'packages/example/src/types.ts:34:5',
|
||||
'packages/plain/src/types.ts', 'packages/fragment/src/types.ts#L8', 'packages/fragment/src/types.ts#L8-L12',
|
||||
]
|
||||
const types = original.types.map(type => ({ ...type, names: ['Example'], sources }))
|
||||
const schema = canonicalizeSchema([{ kind: 'literal', value: 'unreferenced' }], 0)
|
||||
replaceSnapshot(root, 3, 3, { ...original, types: [...types, { schema, digest: schemaDigest(schema), names: [], sources: [] }] })
|
||||
const before = readFileSync(join(root, 'docs/persistence-schema.json'), 'utf8')
|
||||
|
||||
expect(runPersistenceFormats(['--archive', '3'], root)).toBe('Archived Session format v3 to docs/persistence-changes/historical-formats/v3.schema.json.')
|
||||
const serialized = readFileSync(join(root, 'docs/persistence-changes/historical-formats/v3.schema.json'), 'utf8')
|
||||
const archived = parseHistoricalPersistenceSnapshot(JSON.parse(serialized))
|
||||
expect(archived).toEqual({
|
||||
...original,
|
||||
types: types.map(type => ({ ...type, sources: [
|
||||
'packages/example/src/types.ts', 'packages/other/src/types.ts', 'packages/plain/src/types.ts', 'packages/fragment/src/types.ts',
|
||||
] })),
|
||||
})
|
||||
expect(serialized).toBe(JSON.stringify(archived, null, 2) + '\n')
|
||||
expect(readFileSync(join(root, 'docs/persistence-schema.json'), 'utf8')).toBe(before)
|
||||
expect(existsSync(join(root, 'docs/persistence-changes/historical-formats/v3.md'))).toBe(false)
|
||||
|
||||
saveReference(root, 3, 4, archived)
|
||||
write(root, 'packages/core/session/src/types.ts', 'export const SESSION_FORMAT_VERSION = 4 as const\n')
|
||||
saveReference(root, 4, 4)
|
||||
expect(loadPersistenceFormats(root).entries[3]?.inventory).toEqual(archived)
|
||||
})
|
||||
|
||||
it('creates the first archive directory and honors --root', () => {
|
||||
const root = fixture(0)
|
||||
rmSync(join(root, 'docs/persistence-changes/historical-formats'), { recursive: true })
|
||||
expect(runPersistenceFormats(['--root', root, '--archive', '0'])).toBe('Archived Session format v0 to docs/persistence-changes/historical-formats/v0.schema.json.')
|
||||
expect(JSON.parse(readFileSync(join(root, 'docs/persistence-changes/historical-formats/v0.schema.json'), 'utf8'))).toEqual(inventory(0))
|
||||
})
|
||||
|
||||
it.each(['-1', '1.5', '01', '3junk', '9007199254740992'])('rejects an invalid archive version %s', (version) => {
|
||||
const root = fixture()
|
||||
expect(() => runPersistenceFormats([`--archive=${version}`], root)).toThrow('--archive must be a non-negative safe integer')
|
||||
expect(existsSync(join(root, 'docs/persistence-changes/historical-formats/v3.schema.json'))).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['2', '4'])('rejects archive v%s when the current writer is v3', (version) => {
|
||||
const root = fixture()
|
||||
expect(() => runPersistenceFormats(['--archive', version], root)).toThrow(`Cannot archive v${version}: current writer is v3`)
|
||||
expect(existsSync(join(root, 'docs/persistence-changes/historical-formats/v3.schema.json'))).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses to overwrite an existing archive', () => {
|
||||
const root = fixture()
|
||||
write(root, 'docs/persistence-changes/historical-formats/v3.schema.json', 'Retained history.\n')
|
||||
expect(() => runPersistenceFormats(['--archive', '3'], root)).toThrow('docs/persistence-changes/historical-formats/v3.schema.json already exists')
|
||||
expect(readFileSync(join(root, 'docs/persistence-changes/historical-formats/v3.schema.json'), 'utf8')).toBe('Retained history.\n')
|
||||
})
|
||||
|
||||
it('rejects --archive with --write before writing', () => {
|
||||
const root = fixture()
|
||||
expect(() => runPersistenceFormats(['--archive', '3', '--write'], root)).toThrow('--archive and --write cannot be combined')
|
||||
expect(existsSync(join(root, 'docs/persistence-changes/historical-formats/v3.schema.json'))).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['invalid-json', 'digest-drift', 'missing-type', 'missing-source-metadata', 'outdated-header'])('rejects %s before creating an archive', (variant) => {
|
||||
const root = fixture()
|
||||
const original = inventory(3)
|
||||
if (variant === 'invalid-json') write(root, 'docs/persistence-schema.json', '{')
|
||||
else if (variant === 'digest-drift') replaceSnapshot(root, 3, 3, { ...original, roots: [{ ...original.roots[0]!, digest: '0'.repeat(64) }, ...original.roots.slice(1)] })
|
||||
else if (variant === 'missing-type') replaceSnapshot(root, 3, 3, { ...original, types: original.types.slice(1) })
|
||||
else if (variant === 'missing-source-metadata') edit(root, 'docs/persistence-schema.json', ',"sources":[]', '')
|
||||
else replaceSnapshot(root, 3, 3, inventory(2))
|
||||
expect(() => runPersistenceFormats(['--archive', '3'], root)).toThrow(variant === 'invalid-json' ? SyntaxError
|
||||
: variant === 'digest-drift' ? 'schema digest mismatch' : variant === 'missing-type' ? 'cover every reachable type'
|
||||
: variant === 'missing-source-metadata' ? 'missing field sources' : 'SessionHeader.version must match')
|
||||
expect(existsSync(join(root, 'docs/persistence-changes/historical-formats/v3.schema.json'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('complete persistence format references', () => {
|
||||
it.each(['.md', '.zh.md'])('rejects source coordinates in authored format evidence in %s', (suffix) => {
|
||||
const root = fixture()
|
||||
const path = `docs/persistence-changes/historical-formats/v1${suffix}`
|
||||
const original = readFileSync(join(root, path), 'utf8')
|
||||
for (const position of [':36', ':36:2', '#L36', '#L36-L38']) {
|
||||
write(root, path, original + `\nSource: \`packages/core/session/src/types.ts${position}\`.\n`)
|
||||
expect(() => loadPersistenceFormats(root)).toThrow('historical source references must omit line numbers')
|
||||
}
|
||||
write(root, path, original + '\nSource: `packages/core/session/src/types.ts`.\n')
|
||||
expect(loadPersistenceFormats(root).entries).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('loads a contiguous archive and the current catalog without Git or historical package sources', () => {
|
||||
const formats = loadPersistenceFormats(fixture())
|
||||
expect(formats.currentVersion).toBe(3)
|
||||
expect(formats.entries.map(entry => entry.version)).toEqual([0, 1, 2, 3])
|
||||
expect(formats.entries[0]?.source).toEqual({ tag: 'dsh-v0.1.0-rc.5' })
|
||||
expect(formats.entries[1]?.source).toEqual({ pullRequest: 3349 })
|
||||
expect(formats.entries[3]).toMatchObject({ document: 'docs/persistence-catalog.md', schemaPath: 'docs/persistence-schema.json' })
|
||||
expect(formats.entries[3]?.source).toBeUndefined()
|
||||
})
|
||||
|
||||
it('accepts a version-zero writer without a historical directory', () => {
|
||||
const root = fixture(0)
|
||||
rmSync(join(root, 'docs/persistence-changes/historical-formats'), { recursive: true })
|
||||
expect(loadPersistenceFormats(root).entries).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each(['.md', '.zh.md', '.schema.json'])('requires every historical companion %s', (suffix) => {
|
||||
const root = fixture()
|
||||
rmSync(join(root, `docs/persistence-changes/historical-formats/v1${suffix}`))
|
||||
expect(() => loadPersistenceFormats(root)).toThrow(`missing persistence format artifact v1${suffix}`)
|
||||
})
|
||||
|
||||
it('rejects a gap even when all remaining versions have complete companions', () => {
|
||||
const root = fixture()
|
||||
for (const suffix of ['.md', '.zh.md', '.schema.json']) rmSync(join(root, `docs/persistence-changes/historical-formats/v1${suffix}`))
|
||||
expect(() => loadPersistenceFormats(root)).toThrow('v1: missing persistence format artifact')
|
||||
})
|
||||
|
||||
it.each(['persistence-catalog.md', 'persistence-catalog.zh.md', 'persistence-schema.json'])('requires the current reference artifact %s', (filename) => {
|
||||
const root = fixture()
|
||||
rmSync(join(root, `docs/${filename}`))
|
||||
expect(() => loadPersistenceFormats(root)).toThrow(`missing persistence format artifact docs/${filename}`)
|
||||
})
|
||||
|
||||
it('requires a formerly current archive when the writer advances', () => {
|
||||
const root = fixture()
|
||||
write(root, 'packages/core/session/src/types.ts', 'export const SESSION_FORMAT_VERSION = 4 as const\n')
|
||||
saveReference(root, 4, 4)
|
||||
expect(() => loadPersistenceFormats(root)).toThrow('v3: missing persistence format artifact')
|
||||
saveReference(root, 3, 4)
|
||||
expect(loadPersistenceFormats(root).entries.map(entry => entry.version)).toEqual([0, 1, 2, 3, 4])
|
||||
})
|
||||
|
||||
it.each(['v3.md', 'v9.schema.json', 'v01.md', 'v-1.md', 'v2.schema.yaml', 'v3.i18n.yaml'])('rejects unexpected or duplicate format filename %s', (filename) => {
|
||||
const root = fixture()
|
||||
write(root, `docs/persistence-changes/historical-formats/${filename}`, '{}')
|
||||
expect(() => loadPersistenceFormats(root)).toThrow(`unexpected persistence format artifact ${filename}`)
|
||||
})
|
||||
|
||||
it('rejects bilingual declarations that differ', () => {
|
||||
const root = fixture()
|
||||
edit(root, 'docs/persistence-changes/historical-formats/v1.zh.md', 'pullRequest: 3349', 'pullRequest: 3397')
|
||||
expect(() => loadPersistenceFormats(root)).toThrow('bilingual machine records differ')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['kind: persistence-format', 'kind: persistence-release', 'frontmatter kind'],
|
||||
['sessionFormatVersion: 1', 'sessionFormatVersion: 0', 'must match its filename'],
|
||||
['schemaVersion: 1', 'schemaVersion: 2', 'unsupported persistence format record schema version'],
|
||||
['pullRequest: 3349', 'pullRequest: 0', 'positive integer'],
|
||||
['pullRequest: 3349', 'pullRequest: 1.5', 'positive integer'],
|
||||
['pullRequest: 3349', 'pullRequest: 3349\n tag: dsh-v0.1.0', 'expected fields'],
|
||||
['pullRequest: 3349', 'other: 3349', 'expected fields'],
|
||||
['source:\n pullRequest: 3349', 'source: null', 'expected an object'],
|
||||
['schemaVersion: 1', 'schemaVersion: 1\nunknown: true', 'expected fields'],
|
||||
])('rejects inconsistent machine data: %s', (from, to, error) => {
|
||||
const root = fixture()
|
||||
editPair(root, 1, from, to)
|
||||
expect(() => loadPersistenceFormats(root)).toThrow(error)
|
||||
})
|
||||
|
||||
it('rejects a source tag outside the DSH release namespace', () => {
|
||||
const root = fixture()
|
||||
editPair(root, 0, 'tag: dsh-v0.1.0-rc.5', 'tag: latest')
|
||||
expect(() => loadPersistenceFormats(root)).toThrow('invalid source tag')
|
||||
})
|
||||
|
||||
it.each(['missing', 'extra', 'wrong-digest'])('rejects a %s recorded root', (variant) => {
|
||||
const root = fixture()
|
||||
const digest = inventory(1).roots[0]!.digest
|
||||
const from = variant === 'extra' ? 'roots:\n' : ` SessionHeader: ${digest}\n`
|
||||
const to = variant === 'missing' ? '' : variant === 'extra' ? `roots:\n event:undeclared: ${'f'.repeat(64)}\n`
|
||||
: ` SessionHeader: ${'f'.repeat(64)}\n`
|
||||
editPair(root, 1, from, to)
|
||||
expect(() => loadPersistenceFormats(root)).toThrow('recorded roots do not match the complete schema inventory')
|
||||
})
|
||||
|
||||
it.each(['unknown-key', 'invalid-digest', 'duplicate-key', 'not-a-mapping'])('rejects invalid recorded roots: %s', (variant) => {
|
||||
const root = fixture()
|
||||
const digest = inventory(1).roots[0]!.digest
|
||||
const from = variant === 'not-a-mapping' ? dump({ roots: Object.fromEntries(inventory(1).roots.map(root => [root.key, root.digest])) })
|
||||
: ` SessionHeader: ${digest}\n`
|
||||
const to = variant === 'unknown-key' ? ` unknown: ${digest}\n` : variant === 'invalid-digest' ? ' SessionHeader: invalid\n'
|
||||
: variant === 'duplicate-key' ? from + from : 'roots: []\n'
|
||||
editPair(root, 1, from, to)
|
||||
expect(() => loadPersistenceFormats(root)).toThrow(variant === 'duplicate-key' ? 'duplicated mapping key'
|
||||
: variant === 'not-a-mapping' ? 'roots must be a mapping' : 'invalid recorded root')
|
||||
})
|
||||
|
||||
it.each(['missing', 'duplicate'])('rejects a %s machine block', (variant) => {
|
||||
const root = fixture()
|
||||
for (const suffix of ['.md', '.zh.md']) {
|
||||
const path = `docs/persistence-changes/historical-formats/v1${suffix}`
|
||||
const text = readFileSync(join(root, path), 'utf8')
|
||||
write(root, path, variant === 'missing' ? text.replace('```yaml persistence-format', '```yaml') : text + '\n```yaml persistence-format\n{}\n```\n')
|
||||
}
|
||||
expect(() => loadPersistenceFormats(root)).toThrow('expected exactly one persistence-format machine record')
|
||||
})
|
||||
|
||||
it.each(['SessionHeader', 'JsonlHeaderLine', 'SessionEventEnvelope', 'event:example/value'])('rejects an inventory missing %s', (key) => {
|
||||
const root = fixture()
|
||||
replaceSnapshot(root, 1, 3, completeInventory(inventory(1).roots.filter(root => root.key !== key)))
|
||||
expect(() => loadPersistenceFormats(root)).toThrow(key.startsWith('event:') ? 'must include an event root' : `missing schema root ${key}`)
|
||||
})
|
||||
|
||||
it.each([0, 1, 2, 3])('rejects a mismatched header version at v%s', (version) => {
|
||||
const root = fixture()
|
||||
replaceSnapshot(root, version, 3, completeInventory(inventory(version).roots.map(root => root.key === 'SessionHeader' ? header(root.key, version + 1) : root)))
|
||||
expect(() => loadPersistenceFormats(root)).toThrow('SessionHeader.version must match')
|
||||
})
|
||||
|
||||
it('retains historical number declarations but requires the current logical header literal', () => {
|
||||
const root = fixture()
|
||||
for (const version of [0, 1, 2]) {
|
||||
saveReference(root, version, 3, completeInventory(inventory(version).roots.map(root => root.key === 'SessionHeader' ? header(root.key, 'number') : root)))
|
||||
}
|
||||
expect(loadPersistenceFormats(root).entries).toHaveLength(4)
|
||||
replaceSnapshot(root, 3, 3, completeInventory(inventory(3).roots.map(root => root.key === 'SessionHeader' ? header(root.key, 'number') : root)))
|
||||
expect(() => loadPersistenceFormats(root)).toThrow('SessionHeader.version must match the current writer version')
|
||||
})
|
||||
|
||||
it.each(['SessionHeader', 'JsonlHeaderLine'])('requires a present %s version property', (key) => {
|
||||
const root = fixture()
|
||||
replaceSnapshot(root, 1, 3, completeInventory(inventory(1).roots.map(root => root.key === key ? header(key, 1, true) : root)))
|
||||
expect(() => loadPersistenceFormats(root)).toThrow(`${key}.version must match`)
|
||||
})
|
||||
|
||||
it('uses historical surface rules only for historical references', () => {
|
||||
const root = fixture()
|
||||
const event = rootSchema({ key: 'event:example/value', kind: 'event', event: 'example/value', surface: true }, [
|
||||
{ kind: 'object', indices: [], properties: [
|
||||
{ name: 'type', type: 1, optional: false }, { name: 'surfaceOp', type: 2, optional: true },
|
||||
] },
|
||||
{ kind: 'literal', value: 'example/value' }, { kind: 'primitive', type: 'string' },
|
||||
])
|
||||
saveReference(root, 1, 3, completeInventory(inventory(1).roots.map(root => root.kind === 'event' ? event : root)))
|
||||
expect(loadPersistenceFormats(root).entries).toHaveLength(4)
|
||||
replaceSnapshot(root, 3, 3, completeInventory(inventory(3).roots.map(root => root.kind === 'event' ? event : root)))
|
||||
expect(() => loadPersistenceFormats(root)).toThrow('surface metadata does not match its schema')
|
||||
})
|
||||
|
||||
it.each(['missing', 'duplicate', 'extra'])('rejects %s reachable type coverage', (variant) => {
|
||||
const root = fixture()
|
||||
const original = inventory(1)
|
||||
const extraSchema = canonicalizeSchema([{ kind: 'literal', value: 'unreferenced' }], 0)
|
||||
const types = variant === 'missing' ? original.types.slice(1)
|
||||
: variant === 'duplicate' ? [...original.types, original.types[0]!]
|
||||
: [...original.types, { schema: extraSchema, digest: schemaDigest(extraSchema), names: [], sources: [] }]
|
||||
replaceSnapshot(root, 1, 3, { ...original, types })
|
||||
expect(() => loadPersistenceFormats(root)).toThrow(variant === 'missing' ? 'cover every reachable type' : variant === 'duplicate' ? 'duplicate schema type' : 'unreferenced schema type')
|
||||
})
|
||||
|
||||
it('permits extractor-retained unreachable types only in the current inventory', () => {
|
||||
const root = fixture()
|
||||
const original = inventory(3)
|
||||
const schema = canonicalizeSchema([{ kind: 'literal', value: false }], 0)
|
||||
replaceSnapshot(root, 3, 3, {
|
||||
...original, types: [...original.types, { schema, digest: schemaDigest(schema), names: [], sources: [] }],
|
||||
})
|
||||
expect(loadPersistenceFormats(root).entries).toHaveLength(4)
|
||||
})
|
||||
|
||||
it.each(['duplicate-root', 'invalid-digest', 'invalid-graph', 'unexpected-field'])('rejects malformed schema inventory: %s', (variant) => {
|
||||
const root = fixture()
|
||||
const original = inventory(1)
|
||||
const first = original.roots[0]!
|
||||
const changed = variant === 'duplicate-root' ? { ...original, roots: [...original.roots, first] }
|
||||
: variant === 'invalid-digest' ? { ...original, roots: [{ ...first, digest: '0'.repeat(64) }, ...original.roots.slice(1)] }
|
||||
: variant === 'invalid-graph' ? { ...original, roots: [{ ...first, schema: { root: 0, nodes: [{ kind: 'array', element: 99 }] } }, ...original.roots.slice(1)] }
|
||||
: { ...original, extra: true }
|
||||
write(root, schemaPath(1, 3), JSON.stringify(changed))
|
||||
expect(() => loadPersistenceFormats(root)).toThrow(variant === 'duplicate-root' ? 'duplicate schema root' : variant === 'invalid-digest' ? 'schema digest mismatch' : variant === 'invalid-graph' ? 'unknown schema node' : 'unknown field extra')
|
||||
})
|
||||
|
||||
it.each(['persistence-changes/historical-formats/v1.md', 'persistence-changes/historical-formats/v1.zh.md', 'persistence-catalog.md', 'persistence-catalog.zh.md'])('requires the matching schema link in %s', (document) => {
|
||||
const root = fixture()
|
||||
const schema = document.startsWith('persistence-changes/historical-formats/') ? 'v1.schema.json' : 'persistence-schema.json'
|
||||
edit(root, `docs/${document}`, `](${schema})`, '](wrong.schema.json)')
|
||||
expect(() => loadPersistenceFormats(root)).toThrow(`missing link to ${schema}`)
|
||||
})
|
||||
|
||||
it.each(['persistence-catalog.md', 'persistence-catalog.zh.md'])('rejects a placeholder current catalog in %s', (document) => {
|
||||
const root = fixture()
|
||||
write(root, `docs/${document}`, '# Placeholder\n\n[Inventory](persistence-schema.json)\n')
|
||||
expect(() => loadPersistenceFormats(root)).toThrow('missing schema index entry for SessionHeader')
|
||||
})
|
||||
|
||||
it('checks generated schema regions and refreshes them without changing the recorded schemas or authored context', () => {
|
||||
const root = fixture()
|
||||
prepareFacts(root)
|
||||
const paths = [0, 1, 2, 3].map(version => schemaPath(version, 3))
|
||||
const before = paths.map(path => readFileSync(join(root, path), 'utf8'))
|
||||
expect(() => runPersistenceFormats([], root)).toThrow('Stale persistence format facts')
|
||||
expect(runPersistenceFormats(['--write'], root)).toContain('Refreshed')
|
||||
expect(runPersistenceFormats([], root)).toBe('Persistence formats: v0 through v3 verified (4 complete references).')
|
||||
expect(paths.map(path => readFileSync(join(root, path), 'utf8'))).toEqual(before)
|
||||
const index = readFileSync(join(root, 'docs/persistence-changes/historical-formats/README.md'), 'utf8')
|
||||
expect(index).toContain('Authored context.')
|
||||
expect(index).toContain('[V1](v1.md) | [JSON](v1.schema.json)')
|
||||
expect(index).toContain('[Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json)')
|
||||
const translatedIndex = readFileSync(join(root, 'docs/persistence-changes/historical-formats/README.zh.md'), 'utf8')
|
||||
expect(translatedIndex).toContain('[V1](v1.zh.md) | [JSON](v1.schema.json)')
|
||||
expect(translatedIndex).toContain('[当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json)')
|
||||
const english = readFileSync(join(root, 'docs/persistence-changes/historical-formats/v1.md'), 'utf8')
|
||||
expect(english).toContain('pullRequest: 3349')
|
||||
const [visible, collapsed] = english.split('<details>')
|
||||
expect(visible).toContain('\n## Complete schemas\n')
|
||||
expect(visible).toContain('\n### Persistence type fingerprints\n')
|
||||
expect(collapsed).toContain('\n### Resolved persistence types\n')
|
||||
expect(collapsed).toContain('\n#### `SessionHeader`\n')
|
||||
expect(english.match(/^## /gmu)).toHaveLength(1)
|
||||
expect(english.match(/^### /gmu)).toHaveLength(2)
|
||||
expect(english.match(/^#### /gmu)).toHaveLength(inventory(1).types.length)
|
||||
edit(root, 'docs/persistence-changes/historical-formats/v1.md', '<summary>Complete resolved types</summary>', '<summary>Missing definitions</summary>')
|
||||
expect(() => runPersistenceFormats([], root)).toThrow('Stale persistence format facts')
|
||||
})
|
||||
|
||||
it('renders a format index for a current version-zero writer', () => {
|
||||
const root = fixture(0)
|
||||
prepareFacts(root, 0)
|
||||
runPersistenceFormats(['--write'], root)
|
||||
expect(runPersistenceFormats([], root)).toBe('Persistence formats: v0 through v0 verified (1 complete reference).')
|
||||
})
|
||||
|
||||
it.each(['missing', 'stale'])('rejects a %s format pairing record and refreshes it with --write', (variant) => {
|
||||
const root = fixture()
|
||||
prepareFacts(root)
|
||||
runPersistenceFormats(['--write'], root)
|
||||
const sidecar = 'docs/persistence-changes/historical-formats/v1.i18n.yaml'
|
||||
if (variant === 'missing') rmSync(join(root, sidecar))
|
||||
else write(root, sidecar, 'schemaVersion: 1\n')
|
||||
expect(() => runPersistenceFormats([], root)).toThrow(`Stale persistence format facts: ${sidecar}`)
|
||||
runPersistenceFormats(['--write'], root)
|
||||
expect(() => runPersistenceFormats([], root)).not.toThrow()
|
||||
})
|
||||
|
||||
it('refuses to refresh facts until every format reference passes validation', () => {
|
||||
const root = fixture()
|
||||
prepareFacts(root)
|
||||
const index = readFileSync(join(root, 'docs/persistence-changes/historical-formats/README.md'), 'utf8')
|
||||
rmSync(join(root, 'docs/persistence-changes/historical-formats/v1.schema.json'))
|
||||
expect(() => runPersistenceFormats(['--write'], root)).toThrow('missing persistence format artifact')
|
||||
expect(readFileSync(join(root, 'docs/persistence-changes/historical-formats/README.md'), 'utf8')).toBe(index)
|
||||
})
|
||||
|
||||
it('refuses to refresh an archive after an event and all its types are removed from its schema', () => {
|
||||
const root = fixture()
|
||||
const retained = rootSchema({ key: 'event:example/retained', kind: 'event', event: 'example/retained', surface: false }, [
|
||||
{ kind: 'object', indices: [], properties: [{ name: 'type', type: 1, optional: false }] },
|
||||
{ kind: 'literal', value: 'example/retained' },
|
||||
])
|
||||
const complete = completeInventory([...inventory(1).roots, retained])
|
||||
saveReference(root, 1, 3, complete)
|
||||
prepareFacts(root)
|
||||
runPersistenceFormats(['--write'], root)
|
||||
const document = 'docs/persistence-changes/historical-formats/v1.md'
|
||||
const before = readFileSync(join(root, document), 'utf8')
|
||||
replaceSnapshot(root, 1, 3, completeInventory(complete.roots.filter(root => root.key !== 'event:example/value')))
|
||||
expect(() => runPersistenceFormats(['--write'], root)).toThrow('recorded roots do not match the complete schema inventory')
|
||||
expect(readFileSync(join(root, document), 'utf8')).toBe(before)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,230 @@
|
||||
/** Archive and verify complete Session format references through the current writer. */
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { JSON_SCHEMA, load } from 'js-yaml'
|
||||
import { readCurrentSessionFormatVersion } from './gen-session-format-catalog.ts'
|
||||
import { parseHistoricalPersistenceSnapshot, parsePersistenceSnapshot } from './persistence-changes.ts'
|
||||
import { persistenceFormatFactArtifacts } from './persistence-format-facts.ts'
|
||||
import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
|
||||
import type { PersistenceSchemaInventory } from './persistence-schema-model.ts'
|
||||
import { withoutPersistenceSourceLines } from './persistence-source-metadata.ts'
|
||||
|
||||
const DIRECTORY = 'docs/persistence-changes/historical-formats'
|
||||
const CURRENT_DOCUMENT = 'docs/persistence-catalog.md'
|
||||
const CURRENT_SCHEMA = 'docs/persistence-schema.json'
|
||||
|
||||
/** Historical checkout that supplies a format's complete declared persistence inventory. */
|
||||
export type PersistenceFormatSource = { readonly tag: string } | { readonly pullRequest: number }
|
||||
|
||||
/** One complete format reference; the current catalog follows the historical entries. */
|
||||
export interface PersistenceFormatEntry {
|
||||
readonly version: number
|
||||
readonly document: string
|
||||
readonly schemaPath: string
|
||||
readonly inventory: PersistenceSchemaInventory
|
||||
readonly source?: PersistenceFormatSource
|
||||
}
|
||||
|
||||
/** Contiguous format references ending at the source-declared writer version. */
|
||||
export interface PersistenceFormats {
|
||||
readonly currentVersion: number
|
||||
readonly entries: readonly PersistenceFormatEntry[]
|
||||
}
|
||||
|
||||
interface PersistenceFormatRecord {
|
||||
readonly source: PersistenceFormatSource
|
||||
readonly roots: ReadonlyMap<string, string>
|
||||
}
|
||||
|
||||
function fields(value: unknown, expected: readonly string[], label: string): Record<string, unknown> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label}: expected an object`)
|
||||
const record = value as Record<string, unknown>
|
||||
if (Object.keys(record).length !== expected.length || expected.some(key => !Object.hasOwn(record, key))) {
|
||||
throw new Error(`${label}: expected fields ${expected.join(', ')}`)
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
function machineBlock(document: string, label: string): string {
|
||||
if (/\.[cm]?[jt]sx?(?::\d+(?::\d+)?|#L\d+(?:-L\d+)?)/u.test(document)) {
|
||||
throw new Error(`${label}: historical source references must omit line numbers`)
|
||||
}
|
||||
const frontmatter = /^---\n([\s\S]*?)\n---(?:\n|$)/u.exec(document)?.[1]
|
||||
const metadata: unknown = frontmatter === undefined ? undefined : load(frontmatter, { schema: JSON_SCHEMA })
|
||||
if (metadata === null || typeof metadata !== 'object' || !('kind' in metadata) || metadata.kind !== 'persistence-format') {
|
||||
throw new Error(`${label}: expected persistence-format frontmatter kind`)
|
||||
}
|
||||
const blocks = [...document.matchAll(/^```yaml persistence-format[^\S\n]*\n([\s\S]*?)^```[^\S\n]*$/gmu)]
|
||||
if (blocks.length !== 1) throw new Error(`${label}: expected exactly one persistence-format machine record`)
|
||||
return (blocks[0] as RegExpMatchArray)[1] as string
|
||||
}
|
||||
|
||||
function parseSource(source: unknown, label: string): PersistenceFormatSource {
|
||||
if (source !== null && typeof source === 'object' && 'tag' in source) {
|
||||
const tag = fields(source, ['tag'], `${label} source`).tag
|
||||
if (typeof tag !== 'string' || !/^dsh-[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(tag)) throw new Error(`${label}: invalid source tag`)
|
||||
return { tag }
|
||||
}
|
||||
const pullRequest = fields(source, ['pullRequest'], `${label} source`).pullRequest
|
||||
if (!Number.isSafeInteger(pullRequest) || (pullRequest as number) <= 0) throw new Error(`${label}: source pullRequest must be a positive integer`)
|
||||
return { pullRequest: pullRequest as number }
|
||||
}
|
||||
|
||||
function parseRecord(block: string, version: number): PersistenceFormatRecord {
|
||||
const label = `v${version}`
|
||||
const record = fields(load(block, { schema: JSON_SCHEMA }), ['schemaVersion', 'sessionFormatVersion', 'source', 'roots'], label)
|
||||
if (record.schemaVersion !== 1) throw new Error(`${label}: unsupported persistence format record schema version`)
|
||||
if (record.sessionFormatVersion !== version) throw new Error(`${label}: record sessionFormatVersion must match its filename`)
|
||||
if (record.roots === null || typeof record.roots !== 'object' || Array.isArray(record.roots)) throw new Error(`${label}: roots must be a mapping`)
|
||||
const roots = new Map<string, string>()
|
||||
for (const [key, digest] of Object.entries(record.roots)) {
|
||||
if (!/^(?:SessionHeader|JsonlHeaderLine|SessionEventEnvelope|event:.+)$/u.test(key)
|
||||
|| typeof digest !== 'string' || !/^[a-f0-9]{64}$/u.test(digest)) throw new Error(`${label}: invalid recorded root ${key}`)
|
||||
roots.set(key, digest)
|
||||
}
|
||||
return { source: parseSource(record.source, label), roots }
|
||||
}
|
||||
|
||||
function validateInventory(inventory: PersistenceSchemaInventory, version: number, current: boolean): ReadonlySet<string> {
|
||||
const label = `v${version}`
|
||||
for (const key of ['SessionHeader', 'JsonlHeaderLine', 'SessionEventEnvelope']) {
|
||||
const root = inventory.roots.find(root => root.key === key)
|
||||
if (root === undefined) throw new Error(`${label}: missing schema root ${key}`)
|
||||
if (root.kind !== 'header') continue
|
||||
const node = root.schema.nodes[0]
|
||||
const field = node?.kind === 'object' ? node.properties.find(property => property.name === 'version') : undefined
|
||||
const type = field === undefined ? undefined : root.schema.nodes[field.type]
|
||||
const legacyNumber = !(current && key === 'SessionHeader') && type?.kind === 'primitive' && type.type === 'number'
|
||||
if (field?.optional !== false || !(legacyNumber || type?.kind === 'literal' && type.value === version)) {
|
||||
throw new Error(`${label}: ${key}.version must match the ${current ? 'current writer' : 'recorded format'} version`)
|
||||
}
|
||||
}
|
||||
if (!inventory.roots.some(root => root.kind === 'event')) throw new Error(`${label}: complete inventory must include an event root`)
|
||||
const reachable = new Set(inventory.roots.flatMap(root => root.schema.nodes
|
||||
.map((_, index) => schemaDigest(canonicalizeSchema(root.schema.nodes, index)))))
|
||||
const remaining = new Set(reachable)
|
||||
const listed = new Set<string>()
|
||||
for (const type of inventory.types) {
|
||||
if (listed.has(type.digest)) throw new Error(`${label}: duplicate schema type ${type.digest}`)
|
||||
listed.add(type.digest)
|
||||
// The current extractor can retain types erased by normalization; its generator gate checks that inventory.
|
||||
if (!current && !reachable.has(type.digest)) throw new Error(`${label}: unreferenced schema type ${type.digest}`)
|
||||
remaining.delete(type.digest)
|
||||
}
|
||||
if (remaining.size > 0) throw new Error(`${label}: schema types must cover every reachable type`)
|
||||
return reachable
|
||||
}
|
||||
|
||||
function validateDocument(
|
||||
document: string, schemaName: string, inventory: PersistenceSchemaInventory, label: string, current: boolean,
|
||||
): void {
|
||||
if (!document.includes(`](${schemaName})`)) throw new Error(`${label}: missing link to ${schemaName}`)
|
||||
if (!current) return
|
||||
for (const root of inventory.roots) {
|
||||
const row = `| \`${root.key}\` | ${root.kind} | \`${root.digest}\` |`
|
||||
if (!document.includes(row)) throw new Error(`${label}: missing schema index entry for ${root.key}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every required format pair and complete inventory without Git or historical source extraction.
|
||||
* @param root - checkout root containing the writer declaration and format references.
|
||||
* @returns ordered historical references followed by the current generated catalog.
|
||||
*/
|
||||
export function loadPersistenceFormats(root: string): PersistenceFormats {
|
||||
const currentVersion = readCurrentSessionFormatVersion(root)
|
||||
const directory = join(root, DIRECTORY)
|
||||
const files = new Set(existsSync(directory) ? readdirSync(directory) : [])
|
||||
const expected = new Set<string>()
|
||||
for (let version = 0; version < currentVersion; version += 1) {
|
||||
for (const suffix of ['.md', '.zh.md', '.schema.json']) {
|
||||
const name = `v${version}${suffix}`
|
||||
expected.add(name)
|
||||
if (!files.has(name)) throw new Error(`v${version}: missing persistence format artifact ${name}`)
|
||||
}
|
||||
expected.add(`v${version}.i18n.yaml`)
|
||||
}
|
||||
for (const file of files) {
|
||||
if (file.startsWith('v') && !expected.has(file)) throw new Error(`unexpected persistence format artifact ${file}`)
|
||||
}
|
||||
const read = (path: string): string => {
|
||||
if (!existsSync(join(root, path))) throw new Error(`missing persistence format artifact ${path}`)
|
||||
return readFileSync(join(root, path), 'utf8').replaceAll('\r\n', '\n')
|
||||
}
|
||||
const entries: PersistenceFormatEntry[] = []
|
||||
for (let version = 0; version <= currentVersion; version += 1) {
|
||||
const current = version === currentVersion
|
||||
const document = current ? CURRENT_DOCUMENT : `${DIRECTORY}/v${version}.md`
|
||||
const schemaPath = current ? CURRENT_SCHEMA : `${DIRECTORY}/v${version}.schema.json`
|
||||
const english = read(document)
|
||||
const chinese = read(document.replace(/\.md$/u, '.zh.md'))
|
||||
let record: PersistenceFormatRecord | undefined
|
||||
if (!current) {
|
||||
const block = machineBlock(english, document)
|
||||
if (block !== machineBlock(chinese, document.replace(/\.md$/u, '.zh.md'))) throw new Error(`v${version}: bilingual machine records differ`)
|
||||
record = parseRecord(block, version)
|
||||
}
|
||||
const inventory = (current ? parsePersistenceSnapshot : parseHistoricalPersistenceSnapshot)(JSON.parse(read(schemaPath)))
|
||||
validateInventory(inventory, version, current)
|
||||
const recordedRoots = record?.roots
|
||||
if (recordedRoots !== undefined && (recordedRoots.size !== inventory.roots.length
|
||||
|| inventory.roots.some(root => recordedRoots.get(root.key) !== root.digest))) {
|
||||
throw new Error(`v${version}: recorded roots do not match the complete schema inventory`)
|
||||
}
|
||||
const schemaName = current ? 'persistence-schema.json' : `v${version}.schema.json`
|
||||
validateDocument(english, schemaName, inventory, document, current)
|
||||
validateDocument(chinese, schemaName, inventory, document.replace(/\.md$/u, '.zh.md'), current)
|
||||
entries.push({ version, document, schemaPath, inventory, ...(record === undefined ? {} : { source: record.source }) })
|
||||
}
|
||||
return { currentVersion, entries }
|
||||
}
|
||||
|
||||
function archiveCurrentInventory(root: string, requestedVersion: string): string {
|
||||
const version = Number(requestedVersion)
|
||||
if (!/^(?:0|[1-9]\d*)$/u.test(requestedVersion) || !Number.isSafeInteger(version)) {
|
||||
throw new Error('--archive must be a non-negative safe integer')
|
||||
}
|
||||
const currentVersion = readCurrentSessionFormatVersion(root)
|
||||
if (version !== currentVersion) throw new Error(`Cannot archive v${version}: current writer is v${currentVersion}`)
|
||||
const path = `${DIRECTORY}/v${version}.schema.json`
|
||||
if (existsSync(join(root, path))) throw new Error(`Cannot archive v${version}: ${path} already exists`)
|
||||
const inventory = parsePersistenceSnapshot(JSON.parse(readFileSync(join(root, CURRENT_SCHEMA), 'utf8')))
|
||||
const reachable = validateInventory(inventory, version, true)
|
||||
const archive = withoutPersistenceSourceLines({ ...inventory, types: inventory.types.filter(type => reachable.has(type.digest)) })
|
||||
mkdirSync(join(root, DIRECTORY), { recursive: true })
|
||||
writeFileSync(join(root, path), JSON.stringify(archive, null, 2) + '\n', { flag: 'wx' })
|
||||
return `Archived Session format v${version} to ${path}.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive the current schema or validate and optionally refresh format references.
|
||||
* @param args - --archive N creates the current version's schema once; --write refreshes validated facts.
|
||||
* @param root - default checkout directory, overridden by --root when provided.
|
||||
* @returns the created schema path or verified format count and refreshed artifact count.
|
||||
*/
|
||||
export function runPersistenceFormats(args: readonly string[], root = resolve(import.meta.dirname, '..')): string {
|
||||
const { values } = parseArgs({ args: [...args], options: { root: { type: 'string' }, write: { type: 'boolean' }, archive: { type: 'string' } } })
|
||||
root = resolve(values.root ?? root)
|
||||
if (values.archive !== undefined) {
|
||||
if (values.write) throw new Error('--archive and --write cannot be combined')
|
||||
return archiveCurrentInventory(root, values.archive)
|
||||
}
|
||||
const formats = loadPersistenceFormats(root)
|
||||
const changed = persistenceFormatFactArtifacts(root, formats).filter(artifact => !existsSync(join(root, artifact.path))
|
||||
|| readFileSync(join(root, artifact.path), 'utf8') !== artifact.content)
|
||||
if (!values.write && changed.length > 0) throw new Error(`Stale persistence format facts: ${changed.map(artifact => artifact.path).join(', ')}. Run pnpm run verify-persistence-formats --write.`)
|
||||
if (values.write) for (const artifact of changed) writeFileSync(join(root, artifact.path), artifact.content)
|
||||
return `Persistence formats: v0 through v${formats.currentVersion} verified (${formats.entries.length} complete reference${formats.entries.length === 1 ? '' : 's'}).`
|
||||
+ (values.write ? ` Refreshed ${changed.length} file${changed.length === 1 ? '' : 's'}.` : '')
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
|
||||
try {
|
||||
console.log(runPersistenceFormats(process.argv.slice(2)))
|
||||
} catch (error: unknown) {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/** Render bounded release facts from validated offline records while preserving authored prose. */
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { renderPersistencePair } from './persistence-artifacts.ts'
|
||||
import type { PersistenceArtifact } from './persistence-artifacts.ts'
|
||||
import type { PersistenceReleaseEntry, PersistenceReleases } from './persistence-releases.ts'
|
||||
import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
|
||||
|
||||
type Language = 'en' | 'zh'
|
||||
|
||||
function count(value: number, noun: string): string {
|
||||
return `${value} ${noun}${value === 1 ? '' : 's'}`
|
||||
}
|
||||
|
||||
function replaceFacts(source: string, name: string, content: string, path: string): string {
|
||||
const start = `<!-- persistence-release-${name}:start -->`
|
||||
const end = `<!-- persistence-release-${name}:end -->`
|
||||
const opening = source.indexOf(start)
|
||||
const closing = source.indexOf(end)
|
||||
if (opening < 0 || closing < opening || source.indexOf(start, opening + start.length) >= 0
|
||||
|| source.indexOf(end, closing + end.length) >= 0) throw new Error(`${path}: expected one ${name} factual block`)
|
||||
return source.slice(0, opening + start.length) + content + source.slice(closing)
|
||||
}
|
||||
|
||||
function structuralChanges(entry: PersistenceReleaseEntry, language: Language): string {
|
||||
if (entry.record.previous === null) {
|
||||
return language === 'en'
|
||||
? 'This entry establishes the historical comparison starting point. Its declaration lists every extracted root; it makes no compatibility judgment about earlier versions.'
|
||||
: '本条记录建立历史比较起点。机器声明列出所有提取的根类型,不对更早版本作兼容性判断。'
|
||||
}
|
||||
if (entry.differences.length === 0) {
|
||||
return language === 'en'
|
||||
? 'Normalized root types and their transitive digests are unchanged from the preceding tag.'
|
||||
: '规范化后的根类型及其传递引用摘要与前一 tag 相同。'
|
||||
}
|
||||
const summary = language === 'en'
|
||||
? `Detected ${count(entry.record.changes.length, 'changed root')} and ${count(entry.differences.length, 'structural difference')}. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.`
|
||||
: `检测到 ${entry.record.changes.length} 个根类型变化、${entry.differences.length} 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。`
|
||||
const heading = language === 'en' ? '| Path | Change | Current minimum |' : '| 路径 | 变化 | 当前最低要求 |'
|
||||
const rows = entry.differences.map((change) => {
|
||||
const path = change.path.replaceAll('`', '\\`').replaceAll('|', '\\|')
|
||||
return `| \`${path}\` | \`${change.kind}\` | \`${change.requiresVersionBump ? 'version-bump' : 'same-version'}\` |`
|
||||
})
|
||||
return [summary, '', heading, '|---|---|---|', ...rows].join('\n')
|
||||
}
|
||||
|
||||
/** Compute paired factual updates without writing or changing machine declarations.
|
||||
* @param root - checkout containing existing documents with bounded factual markers.
|
||||
* @param archive - fully validated release records and reconstructed roots.
|
||||
* @returns complete bilingual documents and sidecars, validated together before any caller writes.
|
||||
*/
|
||||
export function persistenceReleaseFactArtifacts(root: string, archive: PersistenceReleases): PersistenceArtifact[] {
|
||||
const directory = 'docs/persistence-changes/releases'
|
||||
const rootTypes = new Map<string, readonly string[]>()
|
||||
const typeCounts = archive.entries.map((entry) => {
|
||||
const types = new Set<string>()
|
||||
for (const root of entry.roots.values()) {
|
||||
let digests = rootTypes.get(root.digest)
|
||||
if (digests === undefined) {
|
||||
digests = root.schema.nodes.map((_, index) => schemaDigest(canonicalizeSchema(root.schema.nodes, index)))
|
||||
rootTypes.set(root.digest, digests)
|
||||
}
|
||||
for (const digest of digests) types.add(digest)
|
||||
}
|
||||
return types.size
|
||||
})
|
||||
const read = (name: string): string => readFileSync(join(root, directory, name), 'utf8')
|
||||
const index = (language: Language): string => {
|
||||
const heading = language === 'en'
|
||||
? '| Tag | Source date (UTC) | Session version | Roots / types | Changed roots |'
|
||||
: '| Tag | 源码日期(UTC) | Session 版本 | 根 / 类型 | 变化根 |'
|
||||
const rows = archive.entries.map((entry, index) => {
|
||||
const tag = entry.release.tag
|
||||
const date = new Date(entry.release.sourceDate).toISOString().slice(0, 10)
|
||||
return `| [${tag}](${tag}${language === 'zh' ? '.zh' : ''}.md) | ${date} | ${entry.release.sessionFormatVersion} | ${entry.roots.size} / ${typeCounts[index]} | ${entry.record.changes.length} |`
|
||||
})
|
||||
return '\n\n' + [heading, '|---|---|---|---|---|', ...rows].join('\n') + '\n\n'
|
||||
}
|
||||
const artifacts = renderPersistencePair(root, `${directory}/README.md`,
|
||||
replaceFacts(read('README.md'), 'index', index('en'), 'README.md'),
|
||||
replaceFacts(read('README.zh.md'), 'index', index('zh'), 'README.zh.md'))
|
||||
for (const [index, entry] of archive.entries.entries()) {
|
||||
const document = (language: Language): string => {
|
||||
const path = `${entry.release.tag}${language === 'zh' ? '.zh' : ''}.md`
|
||||
const inventory = language === 'en'
|
||||
? `${count(entry.roots.size, 'root')} / ${count(typeCounts[index] as number, 'type')}`
|
||||
: `${entry.roots.size} 个根类型 / ${typeCounts[index]} 种类型`
|
||||
return replaceFacts(replaceFacts(read(path), 'inventory', inventory, path),
|
||||
'changes', '\n\n' + structuralChanges(entry, language) + '\n\n', path)
|
||||
}
|
||||
artifacts.push(...renderPersistencePair(root, `${directory}/${entry.release.tag}.md`, document('en'), document('zh')))
|
||||
}
|
||||
return artifacts
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
/** Offline release archives preserve historical schemas and reject incomplete or inconsistent transitions. */
|
||||
|
||||
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { dump } from 'js-yaml'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { parsePersistenceSnapshot } from './persistence-changes.ts'
|
||||
import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
|
||||
import type { PersistenceRoot, PersistenceSchemaInventory, PersistenceType, SchemaNode } from './persistence-schema-model.ts'
|
||||
import { loadPersistenceReleases, runPersistenceReleases } from './persistence-releases.ts'
|
||||
import type { PersistenceReleaseManifest, PersistenceReleaseRecord } from './persistence-releases.ts'
|
||||
|
||||
const TAGS = ['dsh-v0.1.0-alpha.1', 'dsh-v0.1.0-rc.1', 'dsh-v0.1.0-rc.2'] as const
|
||||
const temporary: string[] = []
|
||||
afterEach(() => { for (const root of temporary.splice(0)) rmSync(root, { recursive: true, force: true }) })
|
||||
|
||||
function rootSchema(key: string, nodes: readonly SchemaNode[], surface = false): PersistenceRoot {
|
||||
const schema = canonicalizeSchema(nodes, 0)
|
||||
return {
|
||||
key, kind: key.startsWith('event:') ? 'event' : key === 'SessionEventEnvelope' ? 'envelope' : 'header',
|
||||
...(key.startsWith('event:') ? { event: key.slice(6), surface } : {}), schema, digest: schemaDigest(schema),
|
||||
}
|
||||
}
|
||||
|
||||
function event(value: 'number' | 'string'): PersistenceRoot {
|
||||
return rootSchema('event:example/value', [
|
||||
{ kind: 'object', indices: [], properties: [
|
||||
{ name: 'type', type: 1, optional: false },
|
||||
{ name: 'data', type: 2, optional: false },
|
||||
{ name: 'surfaceOp', type: 3, optional: true },
|
||||
] },
|
||||
{ kind: 'literal', value: 'example/value' },
|
||||
{ kind: 'primitive', type: value },
|
||||
{ kind: 'primitive', type: 'string' },
|
||||
], true)
|
||||
}
|
||||
|
||||
function inventory(roots: readonly PersistenceRoot[]): PersistenceSchemaInventory {
|
||||
const types = new Map<string, PersistenceType>()
|
||||
for (const root of roots) {
|
||||
for (const [index] of root.schema.nodes.entries()) {
|
||||
const schema = canonicalizeSchema(root.schema.nodes, index)
|
||||
const digest = schemaDigest(schema)
|
||||
types.set(digest, { digest, schema, names: [], sources: [] })
|
||||
}
|
||||
}
|
||||
return { formatVersion: 1, roots, types: [...types.values()] }
|
||||
}
|
||||
|
||||
interface Fixture {
|
||||
readonly root: string
|
||||
readonly directory: string
|
||||
readonly manifest: PersistenceReleaseManifest
|
||||
readonly records: readonly PersistenceReleaseRecord[]
|
||||
readonly snapshots: readonly PersistenceSchemaInventory[]
|
||||
}
|
||||
|
||||
function saveRecord(directory: string, record: PersistenceReleaseRecord): void {
|
||||
const block = dump(record, { lineWidth: -1, noRefs: true })
|
||||
for (const suffix of ['.md', '.zh.md']) {
|
||||
const switcher = suffix === '.md' ? `English | [中文](${record.tag}.zh.md)` : `[English](${record.tag}.md) | 中文`
|
||||
writeFileSync(join(directory, record.tag + suffix), [
|
||||
'---', 'kind: persistence-release', '---', '', '# Archived release', '', switcher, '',
|
||||
'## Summary', '', 'Authored summary and evidence.', '',
|
||||
'| Inventory | Count |', '|---|---|',
|
||||
'| Reconstructed inventory | <!-- persistence-release-inventory:start -->pending<!-- persistence-release-inventory:end --> |', '',
|
||||
'```yaml persistence-release', block.trimEnd(), '```', '',
|
||||
'<!-- persistence-release-changes:start -->', '', 'Pending facts.', '', '<!-- persistence-release-changes:end -->', '',
|
||||
].join('\n'))
|
||||
}
|
||||
}
|
||||
|
||||
function fixture(): Fixture {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-persistence-releases-'))
|
||||
temporary.push(root)
|
||||
const directory = join(root, 'docs/persistence-changes/releases')
|
||||
mkdirSync(directory, { recursive: true })
|
||||
for (const suffix of ['.md', '.zh.md']) {
|
||||
const switcher = suffix === '.md' ? 'English | [中文](README.zh.md)' : '[English](README.md) | 中文'
|
||||
writeFileSync(join(directory, 'README' + suffix), [
|
||||
'# Releases', '', switcher, '', 'Authored introduction.', '',
|
||||
'<!-- persistence-release-index:start -->', '', 'Pending index.', '', '<!-- persistence-release-index:end -->', '',
|
||||
].join('\n'))
|
||||
}
|
||||
writeFileSync(join(directory, 'README.i18n.yaml'), 'schemaVersion: 1\n')
|
||||
const manifest: PersistenceReleaseManifest = {
|
||||
schemaVersion: 1, capturedAt: '2026-09-12',
|
||||
releases: TAGS.map((tag, index) => ({
|
||||
tag, sourceDate: '2026-09-01T10:00:00+08:00', publishedAt: index === 0 ? null : '2026-09-01T03:00:00Z',
|
||||
sessionFormatVersion: 0,
|
||||
})),
|
||||
}
|
||||
const before = event('string')
|
||||
const after = event('number')
|
||||
const header = rootSchema('SessionHeader', [
|
||||
{ kind: 'object', indices: [], properties: [{ name: 'version', type: 1, optional: false }] }, { kind: 'primitive', type: 'number' },
|
||||
])
|
||||
const snapshots = [inventory([
|
||||
header, rootSchema('JsonlHeaderLine', [{ kind: 'opaque', reason: 'unknown' }]),
|
||||
rootSchema('SessionEventEnvelope', [{ kind: 'object', indices: [], properties: [] }]), before,
|
||||
]), inventory([after]), inventory([])]
|
||||
const records = manifest.releases.map((release, index): PersistenceReleaseRecord => ({
|
||||
schemaVersion: 1, tag: release.tag, previous: TAGS[index - 1] ?? null,
|
||||
sessionFormatVersion: release.sessionFormatVersion,
|
||||
changes: snapshots[index]!.roots.map(root => ({ root: root.key, before: index === 0 ? null : before.digest, after: root.digest })),
|
||||
}))
|
||||
writeFileSync(join(directory, 'manifest.json'), JSON.stringify(manifest))
|
||||
for (const [index, record] of records.entries()) {
|
||||
saveRecord(directory, record)
|
||||
writeFileSync(join(directory, record.tag + '.schema.json'), JSON.stringify(snapshots[index]))
|
||||
writeFileSync(join(directory, record.tag + '.i18n.yaml'), 'schemaVersion: 1\n')
|
||||
}
|
||||
return { root, directory, manifest, records, snapshots }
|
||||
}
|
||||
|
||||
function replaceSnapshot(fixture: Fixture, index: number, snapshot: PersistenceSchemaInventory): void {
|
||||
writeFileSync(join(fixture.directory, TAGS[index]! + '.schema.json'), JSON.stringify(snapshot))
|
||||
}
|
||||
|
||||
describe('pinned persistence releases', () => {
|
||||
it.each(['.md', '.zh.md'])('rejects historical source coordinates in %s records', (suffix) => {
|
||||
const data = fixture()
|
||||
const path = join(data.directory, TAGS[0] + suffix)
|
||||
const original = readFileSync(path, 'utf8')
|
||||
for (const position of [':36', ':36:2', '#L36', '#L36-L38']) {
|
||||
writeFileSync(path, original + `\nSource: \`packages/core/session/src/types.ts${position}\`.\n`)
|
||||
expect(() => loadPersistenceReleases(data.root)).toThrow('historical source references must omit line numbers')
|
||||
}
|
||||
writeFileSync(path, original + '\nSource: `packages/core/session/src/types.ts`.\n')
|
||||
expect(loadPersistenceReleases(data.root).entries).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('rejects historical source coordinates in release schema metadata', () => {
|
||||
const data = fixture()
|
||||
const snapshot = data.snapshots[0]!
|
||||
replaceSnapshot(data, 0, { ...snapshot, types: snapshot.types.map((type, index) => index === 0
|
||||
? { ...type, sources: ['packages/core/session/src/types.ts:36'] } : type) })
|
||||
expect(() => loadPersistenceReleases(data.root)).toThrow('historical schema sources must omit line numbers')
|
||||
})
|
||||
|
||||
it('reconstructs breaking version-zero changes and unchanged releases from a tree without Git or package sources', () => {
|
||||
const data = fixture()
|
||||
const archive = loadPersistenceReleases(data.root)
|
||||
expect(archive.entries).toHaveLength(3)
|
||||
expect(archive.entries[0]!.roots.get('event:example/value')!.digest).toBe(event('string').digest)
|
||||
expect(archive.entries[1]!.roots.get('event:example/value')!.digest).toBe(event('number').digest)
|
||||
expect(archive.entries[1]!.differences).toEqual([expect.objectContaining({ root: 'event:example/value', kind: 'type-changed', requiresVersionBump: true })])
|
||||
expect(archive.entries[2]!.roots).toEqual(archive.entries[1]!.roots)
|
||||
expect(archive.entries[2]!.differences).toEqual([])
|
||||
expect(() => parsePersistenceSnapshot(data.snapshots[1])).toThrow('surface metadata')
|
||||
})
|
||||
|
||||
it.each(['.md', '.zh.md', '.i18n.yaml', '.schema.json'])('rejects a missing manifest release companion %s', (suffix) => {
|
||||
const data = fixture()
|
||||
rmSync(join(data.directory, TAGS[1] + suffix))
|
||||
expect(() => loadPersistenceReleases(data.root)).toThrow('missing release artifact')
|
||||
})
|
||||
|
||||
it.each(['README.md', 'README.zh.md', 'README.i18n.yaml'])('requires the fixed archive companion %s', (name) => {
|
||||
const data = fixture()
|
||||
rmSync(join(data.directory, name))
|
||||
expect(() => loadPersistenceReleases(data.root)).toThrow(`missing release artifact ${name}`)
|
||||
})
|
||||
|
||||
it.each(['orphan.md', 'orphan.schema.json', 'orphan.zh.md', 'orphan.i18n.yaml'])('rejects an unreferenced archive file %s', (filename) => {
|
||||
const data = fixture()
|
||||
writeFileSync(join(data.directory, filename), '{}')
|
||||
expect(() => loadPersistenceReleases(data.root)).toThrow('unreferenced persistence release artifact')
|
||||
})
|
||||
|
||||
it('rejects missing, duplicated, and misordered manifest entries', () => {
|
||||
const data = fixture()
|
||||
for (const releases of [
|
||||
data.manifest.releases.slice(1), [data.manifest.releases[0], ...data.manifest.releases], [...data.manifest.releases].reverse(),
|
||||
]) {
|
||||
writeFileSync(join(data.directory, 'manifest.json'), JSON.stringify({ ...data.manifest, releases }))
|
||||
expect(() => loadPersistenceReleases(data.root)).toThrow(/unreferenced|semantic-version order/u)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects malformed manifest identities and version metadata', () => {
|
||||
const data = fixture()
|
||||
for (const changes of [
|
||||
{ tag: 'dsh-v0.1.0-beta.1' }, { sourceDate: '2026-09-01' },
|
||||
{ publishedAt: 'yesterday' }, { sessionFormatVersion: -1 }, { sessionFormatVersion: 1.5 },
|
||||
]) {
|
||||
writeFileSync(join(data.directory, 'manifest.json'), JSON.stringify({ ...data.manifest,
|
||||
releases: data.manifest.releases.map((release, index) => index === 0 ? { ...release, ...changes } : release),
|
||||
}))
|
||||
expect(() => loadPersistenceReleases(data.root)).toThrow(/invalid value|nonnegative integer/u)
|
||||
}
|
||||
for (const changes of [{ schemaVersion: 2 }, { capturedAt: '2026-02-30' }, { releases: [] }]) {
|
||||
writeFileSync(join(data.directory, 'manifest.json'), JSON.stringify({ ...data.manifest, ...changes }))
|
||||
expect(() => loadPersistenceReleases(data.root)).toThrow(/schema version|capture date|must not be empty/u)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects removed manifest and record fields as unknown metadata', () => {
|
||||
const data = fixture()
|
||||
writeFileSync(join(data.directory, 'manifest.json'), JSON.stringify({ ...data.manifest, repository: 'example' }))
|
||||
expect(() => loadPersistenceReleases(data.root)).toThrow('unknown field repository')
|
||||
for (const field of ['commit', 'committedAt', 'sqliteSchemaVersion']) {
|
||||
writeFileSync(join(data.directory, 'manifest.json'), JSON.stringify({ ...data.manifest,
|
||||
releases: data.manifest.releases.map((release, index) => index === 0 ? { ...release, [field]: null } : release),
|
||||
}))
|
||||
expect(() => loadPersistenceReleases(data.root)).toThrow(`unknown field ${field}`)
|
||||
}
|
||||
writeFileSync(join(data.directory, 'manifest.json'), JSON.stringify(data.manifest))
|
||||
for (const field of ['commit', 'sqliteSchemaVersion']) {
|
||||
saveRecord(data.directory, { ...data.records[1]!, [field]: null })
|
||||
expect(() => loadPersistenceReleases(data.root)).toThrow(`unknown field ${field}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects wrong predecessor, format version, schema version, and duplicate roots', () => {
|
||||
const data = fixture()
|
||||
const original = data.records[1]!
|
||||
for (const changed of [
|
||||
{ ...original, previous: null },
|
||||
{ ...original, sessionFormatVersion: 2 }, { ...original, schemaVersion: 2 },
|
||||
{ ...original, changes: [...original.changes, ...original.changes] },
|
||||
]) {
|
||||
saveRecord(data.directory, changed as PersistenceReleaseRecord)
|
||||
expect(() => loadPersistenceReleases(data.root)).toThrow(/predecessor|match manifest|schema version|duplicate change/u)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects before and after digest mismatches', () => {
|
||||
const data = fixture()
|
||||
const original = data.records[1]!
|
||||
for (const field of ['before', 'after']) {
|
||||
saveRecord(data.directory, { ...original, changes: original.changes.map(change => ({ ...change, [field]: 'f'.repeat(64) })) })
|
||||
expect(() => loadPersistenceReleases(data.root)).toThrow(`${field} digest mismatch`)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an unchanged transition and an unacknowledged after root', () => {
|
||||
const data = fixture()
|
||||
saveRecord(data.directory, { ...data.records[2]!, changes: [{ root: 'event:example/value', before: event('number').digest, after: event('number').digest }] })
|
||||
replaceSnapshot(data, 2, data.snapshots[1]!)
|
||||
expect(() => loadPersistenceReleases(data.root)).toThrow('unchanged transition')
|
||||
saveRecord(data.directory, data.records[2]!)
|
||||
expect(() => loadPersistenceReleases(data.root)).toThrow('snapshot roots')
|
||||
})
|
||||
|
||||
it('reconstructs deletions without requiring an after schema or a historical version increase', () => {
|
||||
const data = fixture()
|
||||
saveRecord(data.directory, { ...data.records[2]!, changes: [{ root: 'event:example/value', before: event('number').digest, after: null }] })
|
||||
const result = loadPersistenceReleases(data.root)
|
||||
expect(result.entries[2]!.roots.has('event:example/value')).toBe(false)
|
||||
expect(result.entries[2]!.differences).toEqual([expect.objectContaining({ kind: 'root-removed', requiresVersionBump: true })])
|
||||
})
|
||||
|
||||
it('rejects missing, duplicate, and unreferenced reachable types', () => {
|
||||
const data = fixture()
|
||||
const snapshot = data.snapshots[1]!
|
||||
const unrelated = inventory([rootSchema('SessionHeader', [{ kind: 'primitive', type: 'boolean' }])]).types[0]!
|
||||
for (const types of [snapshot.types.slice(1), [...snapshot.types, snapshot.types[0]!], [...snapshot.types, unrelated]]) {
|
||||
replaceSnapshot(data, 1, { ...snapshot, types })
|
||||
expect(() => loadPersistenceReleases(data.root)).toThrow(/every reachable type|duplicate schema type|unreferenced schema type/u)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects mismatched translations, duplicate blocks, and the wrong document kind', () => {
|
||||
const data = fixture()
|
||||
const path = join(data.directory, TAGS[1] + '.zh.md')
|
||||
const source = readFileSync(path, 'utf8')
|
||||
for (const changed of [
|
||||
source.replace('schemaVersion: 1', 'schemaVersion: 2'), source + source,
|
||||
source.replace('kind: persistence-release', 'kind: persistence-change'),
|
||||
]) {
|
||||
writeFileSync(path, changed)
|
||||
expect(() => loadPersistenceReleases(data.root)).toThrow(/bilingual machine|exactly one|frontmatter kind/u)
|
||||
}
|
||||
})
|
||||
|
||||
it('matches literal header versions when a historical declaration pins one', () => {
|
||||
const data = fixture()
|
||||
const header = rootSchema('SessionHeader', [
|
||||
{ kind: 'object', indices: [], properties: [{ name: 'version', type: 1, optional: false }] }, { kind: 'literal', value: 9 },
|
||||
])
|
||||
const snapshot = inventory(data.snapshots[0]!.roots.map(root => root.key === header.key ? header : root))
|
||||
replaceSnapshot(data, 0, snapshot)
|
||||
saveRecord(data.directory, { ...data.records[0]!,
|
||||
changes: snapshot.roots.map(root => ({ root: root.key, before: null, after: root.digest })),
|
||||
})
|
||||
expect(() => loadPersistenceReleases(data.root)).toThrow('SessionHeader.version')
|
||||
})
|
||||
})
|
||||
|
||||
function directoryBytes(directory: string): Record<string, string> {
|
||||
return Object.fromEntries(readdirSync(directory).map(file => [file, readFileSync(join(directory, file), 'utf8')]))
|
||||
}
|
||||
|
||||
describe('release facts', () => {
|
||||
it('refreshes complete-state counts and singular wording while preserving authored text, declarations, and snapshots', () => {
|
||||
const data = fixture()
|
||||
const before = directoryBytes(data.directory)
|
||||
expect(() => runPersistenceReleases([], data.root)).toThrow('Stale persistence release facts')
|
||||
expect(directoryBytes(data.directory)).toEqual(before)
|
||||
expect(runPersistenceReleases(['--write'], data.root)).toContain('3 records, 2 adjacent transitions verified')
|
||||
expect(runPersistenceReleases([], data.root)).toContain('3 records, 2 adjacent transitions verified')
|
||||
const after = directoryBytes(data.directory)
|
||||
expect(after['README.md']).toContain(`| [${TAGS[1]}](${TAGS[1]}.md) | 2026-09-01 | 0 | 4 / 7 | 1 |`)
|
||||
expect(after[TAGS[1] + '.md']).toContain('4 roots / 7 types')
|
||||
expect(after[TAGS[1] + '.md']).toContain('Detected 1 changed root and 1 structural difference.')
|
||||
expect(after[TAGS[1] + '.md']).toContain('| `event:example/value.data` | `type-changed` | `version-bump` |')
|
||||
for (const [name, source] of Object.entries(before)) {
|
||||
if (name.endsWith('.json')) expect(after[name]).toBe(source)
|
||||
if (name.endsWith('.md')) {
|
||||
expect(after[name]).toContain(name.startsWith('README') ? 'Authored introduction.' : 'Authored summary and evidence.')
|
||||
if (!name.startsWith('README')) expect(after[name]?.match(/```yaml persistence-release\n[\s\S]*?```/u)?.[0])
|
||||
.toBe(source.match(/```yaml persistence-release\n[\s\S]*?```/u)?.[0])
|
||||
}
|
||||
}
|
||||
expect(after[TAGS[1] + '.i18n.yaml']).not.toBe(before[TAGS[1] + '.i18n.yaml'])
|
||||
expect(runPersistenceReleases(['--write'], data.root)).toContain('Refreshed 0 files.')
|
||||
expect(directoryBytes(data.directory)).toEqual(after)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['tag label', `[${TAGS[0]}]`, '[wrong-tag]'],
|
||||
['date', '| 2026-09-01 |', '| 1999-01-01 |'],
|
||||
['writer', '| 0 | 4 / 7 |', '| 9 | 4 / 7 |'],
|
||||
['roots', '| 4 / 7 |', '| 99 / 7 |'],
|
||||
['types', '| 4 / 7 |', '| 4 / 99 |'],
|
||||
['changes', '| 4 / 7 | 4 |', '| 4 / 7 | 99 |'],
|
||||
])('rejects stale index %s', (_label, original, replacement) => {
|
||||
const data = fixture()
|
||||
runPersistenceReleases(['--write'], data.root)
|
||||
const path = join(data.directory, 'README.md')
|
||||
const source = readFileSync(path, 'utf8')
|
||||
expect(source).toContain(original)
|
||||
writeFileSync(path, source.replace(original, replacement))
|
||||
expect(() => runPersistenceReleases([], data.root)).toThrow('Stale persistence release facts')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['inventory', '4 roots / 7 types', '4 roots / 999 types'],
|
||||
['root count', '1 changed root', '9 changed roots'],
|
||||
['difference count', '1 structural difference', '9 structural differences'],
|
||||
['path', '`event:example/value.data`', '`event:example/value.wrong`'],
|
||||
['kind', '`type-changed`', '`optional-property-added`'],
|
||||
['minimum', '`version-bump`', '`same-version`'],
|
||||
])('rejects stale record %s', (_label, original, replacement) => {
|
||||
const data = fixture()
|
||||
runPersistenceReleases(['--write'], data.root)
|
||||
const path = join(data.directory, TAGS[1] + '.md')
|
||||
const source = readFileSync(path, 'utf8')
|
||||
expect(source).toContain(original)
|
||||
writeFileSync(path, source.replace(original, replacement))
|
||||
expect(() => runPersistenceReleases([], data.root)).toThrow('Stale persistence release facts')
|
||||
})
|
||||
|
||||
it('checks Chinese facts too and allows authored prose outside generated regions to change', () => {
|
||||
const data = fixture()
|
||||
runPersistenceReleases(['--write'], data.root)
|
||||
const path = join(data.directory, TAGS[1] + '.zh.md')
|
||||
writeFileSync(path, readFileSync(path, 'utf8').replace('Authored summary and evidence.', 'Revised authored explanation.'))
|
||||
expect(() => runPersistenceReleases([], data.root)).not.toThrow()
|
||||
writeFileSync(path, readFileSync(path, 'utf8').replace('4 个根类型 / 7 种类型', '4 个根类型 / 999 种类型'))
|
||||
expect(() => runPersistenceReleases([], data.root)).toThrow('Stale persistence release facts')
|
||||
})
|
||||
|
||||
it('does not write anything when machine data is corrupt or a later pair cannot be rendered', () => {
|
||||
const data = fixture()
|
||||
const snapshotPath = join(data.directory, TAGS[2] + '.schema.json')
|
||||
const snapshot = readFileSync(snapshotPath, 'utf8')
|
||||
writeFileSync(snapshotPath, snapshot.replace('"formatVersion":1', '"formatVersion":2'))
|
||||
const corrupt = directoryBytes(data.directory)
|
||||
expect(() => runPersistenceReleases(['--write'], data.root)).toThrow('normalization version')
|
||||
expect(directoryBytes(data.directory)).toEqual(corrupt)
|
||||
writeFileSync(snapshotPath, snapshot)
|
||||
const path = join(data.directory, TAGS[2] + '.zh.md')
|
||||
writeFileSync(path, readFileSync(path, 'utf8').replace('<!-- persistence-release-changes:end -->', ''))
|
||||
const missingMarker = directoryBytes(data.directory)
|
||||
expect(() => runPersistenceReleases(['--write'], data.root)).toThrow('expected one changes factual block')
|
||||
expect(directoryBytes(data.directory)).toEqual(missingMarker)
|
||||
})
|
||||
|
||||
it('accepts matching literal writer versions and rejects a missing reconstructed header', () => {
|
||||
const data = fixture()
|
||||
const header = rootSchema('SessionHeader', [
|
||||
{ kind: 'object', indices: [], properties: [{ name: 'version', type: 1, optional: false }] }, { kind: 'literal', value: 0 },
|
||||
])
|
||||
for (const roots of [data.snapshots[0]!.roots.map(root => root.key === header.key ? header : root),
|
||||
data.snapshots[0]!.roots.filter(root => root.key !== header.key)]) {
|
||||
const snapshot = inventory(roots)
|
||||
replaceSnapshot(data, 0, snapshot)
|
||||
saveRecord(data.directory, { ...data.records[0]!,
|
||||
changes: roots.map(root => ({ root: root.key, before: null, after: root.digest })),
|
||||
})
|
||||
if (roots.some(root => root.key === header.key)) expect(() => loadPersistenceReleases(data.root)).not.toThrow()
|
||||
else expect(() => loadPersistenceReleases(data.root)).toThrow('reconstructed state is missing SessionHeader')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,257 @@
|
||||
/** Verify the pinned prerelease archive without Git, network access, or source extraction. */
|
||||
|
||||
import { readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { JSON_SCHEMA, load } from 'js-yaml'
|
||||
import { classifyPersistenceChange, parseHistoricalPersistenceSnapshot } from './persistence-changes.ts'
|
||||
import type { PersistenceTypeChange } from './persistence-changes.ts'
|
||||
import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
|
||||
import type { PersistenceRoot, PersistenceSchemaInventory } from './persistence-schema-model.ts'
|
||||
import { persistenceReleaseFactArtifacts } from './persistence-release-facts.ts'
|
||||
|
||||
const ARCHIVE_DIRECTORY = 'docs/persistence-changes/releases'
|
||||
const TAG_PATTERN = /^dsh-v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-(alpha|rc)\.(0|[1-9]\d*)$/u
|
||||
const DIGEST_PATTERN = /^[a-f0-9]{64}$/u
|
||||
|
||||
/** Published identity and version constants observed in one pinned tag. */
|
||||
export interface PersistenceRelease {
|
||||
readonly tag: string
|
||||
readonly sourceDate: string
|
||||
readonly publishedAt: string | null
|
||||
readonly sessionFormatVersion: number
|
||||
}
|
||||
|
||||
/** Offline corpus captured from the repository's alpha and release-candidate tags. */
|
||||
export interface PersistenceReleaseManifest {
|
||||
readonly schemaVersion: 1
|
||||
readonly capturedAt: string
|
||||
readonly releases: readonly PersistenceRelease[]
|
||||
}
|
||||
|
||||
/** One changed root between consecutive archived releases. */
|
||||
export interface PersistenceReleaseChange {
|
||||
readonly root: string
|
||||
readonly before: string | null
|
||||
readonly after: string | null
|
||||
}
|
||||
|
||||
/** Machine declaration shared byte-for-byte by a release's bilingual documents. */
|
||||
export interface PersistenceReleaseRecord {
|
||||
readonly schemaVersion: 1
|
||||
readonly tag: string
|
||||
readonly previous: string | null
|
||||
readonly sessionFormatVersion: number
|
||||
readonly changes: readonly PersistenceReleaseChange[]
|
||||
}
|
||||
|
||||
/** Validated release, changed after schemas, and complete reconstructed root state. */
|
||||
export interface PersistenceReleaseEntry {
|
||||
readonly release: PersistenceRelease
|
||||
readonly record: PersistenceReleaseRecord
|
||||
readonly snapshot: PersistenceSchemaInventory
|
||||
readonly roots: ReadonlyMap<string, PersistenceRoot>
|
||||
readonly differences: readonly (PersistenceTypeChange & { readonly root: string })[]
|
||||
}
|
||||
|
||||
/** Complete validated archive; classifications describe modern rules, not historical migration obligations. */
|
||||
export interface PersistenceReleases {
|
||||
readonly manifest: PersistenceReleaseManifest
|
||||
readonly entries: readonly PersistenceReleaseEntry[]
|
||||
}
|
||||
|
||||
function object(value: unknown, fields: readonly string[], label: string): Record<string, unknown> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label}: expected an object`)
|
||||
const result = value as Record<string, unknown>
|
||||
const missing = fields.find(field => !Object.hasOwn(result, field))
|
||||
const extra = Object.keys(result).find(field => !fields.includes(field))
|
||||
if (missing !== undefined || extra !== undefined) throw new Error(`${label}: ${missing === undefined ? `unknown field ${extra}` : `missing field ${missing}`}`)
|
||||
return result
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): unknown[] {
|
||||
if (!Array.isArray(value)) throw new Error(`${label}: expected an array`)
|
||||
return value
|
||||
}
|
||||
|
||||
function string(value: unknown, pattern: RegExp, label: string): string {
|
||||
if (typeof value !== 'string' || !pattern.test(value)) throw new Error(`${label}: invalid value`)
|
||||
return value
|
||||
}
|
||||
|
||||
function version(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(`${label}: expected a nonnegative integer`)
|
||||
return value as number
|
||||
}
|
||||
|
||||
function timestamp(value: unknown, label: string): void {
|
||||
const source = string(value, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u, label)
|
||||
if (!Number.isFinite(Date.parse(source))) throw new Error(`${label}: invalid timestamp`)
|
||||
}
|
||||
|
||||
function tagOrder(tag: string): number[] {
|
||||
const match = TAG_PATTERN.exec(tag) as RegExpExecArray
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3]), match[4] === 'alpha' ? 0 : 1, Number(match[5])]
|
||||
}
|
||||
|
||||
function compareTags(left: string, right: string): number {
|
||||
const before = tagOrder(left)
|
||||
const after = tagOrder(right)
|
||||
return before.map((value, index) => value - (after[index] as number)).find(value => value !== 0) ?? 0
|
||||
}
|
||||
|
||||
function parseManifest(value: unknown): PersistenceReleaseManifest {
|
||||
const input = object(value, ['schemaVersion', 'capturedAt', 'releases'], 'release manifest')
|
||||
if (input.schemaVersion !== 1) throw new Error('unsupported persistence release manifest schema version')
|
||||
const date = string(input.capturedAt, /^\d{4}-\d{2}-\d{2}$/u, 'capture date')
|
||||
if (!Number.isFinite(Date.parse(date)) || new Date(date).toISOString().slice(0, 10) !== date) throw new Error('release manifest: invalid capture date')
|
||||
let previous: string | undefined
|
||||
const releases = array(input.releases, 'releases')
|
||||
if (releases.length === 0) throw new Error('release manifest: releases must not be empty')
|
||||
for (const raw of releases) {
|
||||
const release = object(raw, ['tag', 'sourceDate', 'publishedAt', 'sessionFormatVersion'], 'release')
|
||||
const tag = string(release.tag, TAG_PATTERN, 'release tag')
|
||||
if (previous !== undefined && compareTags(previous, tag) >= 0) throw new Error(`${tag}: release tags must be unique and in semantic-version order`)
|
||||
previous = tag
|
||||
timestamp(release.sourceDate, `${tag} source date`)
|
||||
if (release.publishedAt !== null) timestamp(release.publishedAt, `${tag} publication date`)
|
||||
version(release.sessionFormatVersion, `${tag} Session format version`)
|
||||
}
|
||||
return input as unknown as PersistenceReleaseManifest
|
||||
}
|
||||
|
||||
function machineBlock(source: string, label: string): string {
|
||||
if (/\.[cm]?[jt]sx?(?::\d+(?::\d+)?|#L\d+(?:-L\d+)?)/u.test(source)) {
|
||||
throw new Error(`${label}: historical source references must omit line numbers`)
|
||||
}
|
||||
const metadata = /^---\n([\s\S]*?)\n---(?:\n|$)/u.exec(source)?.[1]
|
||||
const frontmatter: unknown = metadata === undefined ? undefined : load(metadata, { schema: JSON_SCHEMA })
|
||||
if (frontmatter === null || typeof frontmatter !== 'object' || !('kind' in frontmatter) || frontmatter.kind !== 'persistence-release') {
|
||||
throw new Error(`${label}: expected persistence-release frontmatter kind`)
|
||||
}
|
||||
const blocks = [...source.matchAll(/^```yaml persistence-release[^\S\n]*\n([\s\S]*?)^```[^\S\n]*$/gmu)]
|
||||
if (blocks.length !== 1) throw new Error(`${label}: expected exactly one persistence-release machine record`)
|
||||
return (blocks[0] as RegExpMatchArray)[1] as string
|
||||
}
|
||||
|
||||
function parseRecord(source: string, release: PersistenceRelease, previous: string | null): PersistenceReleaseRecord {
|
||||
const input = object(load(source, { schema: JSON_SCHEMA }), ['schemaVersion', 'tag', 'previous', 'sessionFormatVersion', 'changes'], release.tag)
|
||||
if (input.schemaVersion !== 1) throw new Error(`${release.tag}: unsupported persistence release record schema version`)
|
||||
for (const field of ['tag', 'sessionFormatVersion'] as const) {
|
||||
if (input[field] !== release[field]) throw new Error(`${release.tag}: record ${field} does not match manifest`)
|
||||
}
|
||||
if (input.previous !== previous) throw new Error(`${release.tag}: predecessor must be ${String(previous)}`)
|
||||
const roots = new Set<string>()
|
||||
for (const raw of array(input.changes, `${release.tag} changes`)) {
|
||||
const change = object(raw, ['root', 'before', 'after'], release.tag)
|
||||
const root = string(change.root, /^(?:SessionHeader|JsonlHeaderLine|SessionEventEnvelope|event:.+)$/u, 'changed root')
|
||||
if (roots.has(root)) throw new Error(`${release.tag}: duplicate change for ${root}`)
|
||||
roots.add(root)
|
||||
for (const field of ['before', 'after']) if (change[field] !== null) string(change[field], DIGEST_PATTERN, `${release.tag} ${field} digest`)
|
||||
}
|
||||
return input as unknown as PersistenceReleaseRecord
|
||||
}
|
||||
|
||||
function validateTypes(snapshot: PersistenceSchemaInventory, tag: string): void {
|
||||
const reachable = new Set<string>()
|
||||
for (const root of snapshot.roots) {
|
||||
for (const [index] of root.schema.nodes.entries()) reachable.add(schemaDigest(canonicalizeSchema(root.schema.nodes, index)))
|
||||
}
|
||||
const listed = new Set<string>()
|
||||
for (const type of snapshot.types) {
|
||||
if (listed.has(type.digest)) throw new Error(`${tag}: duplicate schema type ${type.digest}`)
|
||||
listed.add(type.digest)
|
||||
if (!reachable.has(type.digest)) throw new Error(`${tag}: unreferenced schema type ${type.digest}`)
|
||||
}
|
||||
if (listed.size !== reachable.size) throw new Error(`${tag}: schema types must cover every reachable type`)
|
||||
}
|
||||
|
||||
function validateVersion(roots: ReadonlyMap<string, PersistenceRoot>, release: PersistenceRelease): void {
|
||||
for (const key of ['SessionHeader', 'JsonlHeaderLine', 'SessionEventEnvelope']) {
|
||||
if (!roots.has(key)) throw new Error(`${release.tag}: reconstructed state is missing ${key}`)
|
||||
}
|
||||
const header = roots.get('SessionHeader') as PersistenceRoot
|
||||
const node = header.schema.nodes[0]
|
||||
const field = node?.kind === 'object' ? node.properties.find(property => property.name === 'version') : undefined
|
||||
const value = field === undefined ? undefined : header.schema.nodes[field.type]
|
||||
if (field?.optional !== false || !(value?.kind === 'primitive' && value.type === 'number'
|
||||
|| value?.kind === 'literal' && value.value === release.sessionFormatVersion)) {
|
||||
throw new Error(`${release.tag}: SessionHeader.version does not match the recorded format version`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Read and validate every pinned prerelease and reconstruct adjacent type changes.
|
||||
* @param root - repository directory containing the committed release archive.
|
||||
* @returns verified records and informational classifications, without enforcing modern version-bump rules.
|
||||
*/
|
||||
export function loadPersistenceReleases(root: string): PersistenceReleases {
|
||||
const directory = join(root, ARCHIVE_DIRECTORY)
|
||||
const manifest = parseManifest(JSON.parse(readFileSync(join(directory, 'manifest.json'), 'utf8')))
|
||||
const files = new Set(readdirSync(directory))
|
||||
const expected = new Set(['manifest.json', 'README.md', 'README.zh.md', 'README.i18n.yaml'])
|
||||
for (const filename of expected) if (!files.has(filename)) throw new Error(`missing release artifact ${filename}`)
|
||||
for (const release of manifest.releases) {
|
||||
for (const suffix of ['.md', '.zh.md', '.i18n.yaml', '.schema.json']) {
|
||||
const filename = release.tag + suffix
|
||||
expected.add(filename)
|
||||
if (!files.has(filename)) throw new Error(`${release.tag}: missing release artifact ${filename}`)
|
||||
}
|
||||
}
|
||||
for (const file of files) if (!expected.has(file)) throw new Error(`unreferenced persistence release artifact: ${file}`)
|
||||
const entries: PersistenceReleaseEntry[] = []
|
||||
const roots = new Map<string, PersistenceRoot>()
|
||||
for (const release of manifest.releases) {
|
||||
const read = (suffix: string): string => readFileSync(join(directory, release.tag + suffix), 'utf8').replaceAll('\r\n', '\n')
|
||||
const english = machineBlock(read('.md'), `${release.tag}.md`)
|
||||
const chinese = machineBlock(read('.zh.md'), `${release.tag}.zh.md`)
|
||||
if (english !== chinese) throw new Error(`${release.tag}: bilingual machine records differ`)
|
||||
const record = parseRecord(english, release, entries.at(-1)?.release.tag ?? null)
|
||||
const snapshot = parseHistoricalPersistenceSnapshot(JSON.parse(read('.schema.json')))
|
||||
validateTypes(snapshot, release.tag)
|
||||
const afterRoots = new Map(snapshot.roots.map(root => [root.key, root]))
|
||||
const expectedRoots = record.changes.filter(change => change.after !== null).map(change => change.root).sort()
|
||||
if (JSON.stringify([...afterRoots.keys()].sort()) !== JSON.stringify(expectedRoots)) throw new Error(`${release.tag}: snapshot roots do not match changed after schemas`)
|
||||
const differences: (PersistenceTypeChange & { readonly root: string })[] = []
|
||||
for (const change of record.changes) {
|
||||
const before = roots.get(change.root) ?? null
|
||||
const after = afterRoots.get(change.root) ?? null
|
||||
if (change.before !== (before?.digest ?? null)) throw new Error(`${release.tag}: before digest mismatch for ${change.root}`)
|
||||
if (change.after !== (after?.digest ?? null)) throw new Error(`${release.tag}: after digest mismatch for ${change.root}`)
|
||||
const changes = classifyPersistenceChange(before, after)
|
||||
if (changes.length === 0) throw new Error(`${release.tag}: unchanged transition for ${change.root}`)
|
||||
differences.push(...changes.map(value => ({ ...value, root: change.root })))
|
||||
if (after === null) roots.delete(change.root)
|
||||
else roots.set(change.root, after)
|
||||
}
|
||||
validateVersion(roots, release)
|
||||
entries.push({ release, record, snapshot, roots: new Map(roots), differences })
|
||||
}
|
||||
return { manifest, entries }
|
||||
}
|
||||
|
||||
/** Validate the archive and check or refresh its bounded factual Markdown regions.
|
||||
* @param args - CLI arguments; --write refreshes facts and pairing sidecars after complete validation.
|
||||
* @param root - default repository directory, overridden by --root when provided.
|
||||
* @returns the verified release counts and optional number of refreshed artifacts.
|
||||
*/
|
||||
export function runPersistenceReleases(args: readonly string[], root = resolve(import.meta.dirname, '..')): string {
|
||||
const { values } = parseArgs({ args: [...args], options: { root: { type: 'string' }, write: { type: 'boolean' } } })
|
||||
root = resolve(values.root ?? root)
|
||||
const archive = loadPersistenceReleases(root)
|
||||
const artifacts = persistenceReleaseFactArtifacts(root, archive)
|
||||
const changed = artifacts.filter(artifact => readFileSync(join(root, artifact.path), 'utf8') !== artifact.content)
|
||||
const stale = changed.filter(artifact => artifact.path.endsWith('.md'))
|
||||
if (!values.write && stale.length > 0) throw new Error(`Stale persistence release facts: ${stale.map(file => file.path).join(', ')}. Run pnpm run verify-persistence-releases --write.`)
|
||||
if (values.write) for (const artifact of changed) writeFileSync(join(root, artifact.path), artifact.content)
|
||||
return `Persistence release archive: ${archive.entries.length} records, ${archive.entries.length - 1} adjacent transitions verified.`
|
||||
+ (values.write ? ` Refreshed ${changed.length} file${changed.length === 1 ? '' : 's'}.` : '')
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
|
||||
try {
|
||||
console.log(runPersistenceReleases(process.argv.slice(2)))
|
||||
} catch (error: unknown) {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canonicalizeSchema, isArbitraryJsonSchema, schemaDigest, type SchemaNode } from './persistence-schema-model.ts'
|
||||
|
||||
const string: SchemaNode = { kind: 'primitive', type: 'string' }
|
||||
const number: SchemaNode = { kind: 'primitive', type: 'number' }
|
||||
|
||||
describe('canonical persisted type graphs', () => {
|
||||
it('ignores source ordering and shared versus duplicated subtypes', () => {
|
||||
const left: SchemaNode[] = [
|
||||
{ kind: 'object', properties: [{ name: 'b', type: 1, optional: false }, { name: 'a', type: 1, optional: true }], indices: [] }, string,
|
||||
]
|
||||
const right: SchemaNode[] = [
|
||||
string, string,
|
||||
{ kind: 'object', properties: [{ name: 'a', type: 0, optional: true }, { name: 'b', type: 1, optional: false }], indices: [] },
|
||||
]
|
||||
expect(canonicalizeSchema(left, 0)).toEqual(canonicalizeSchema(right, 2))
|
||||
})
|
||||
|
||||
it('minimizes differently factored recursive aliases to the same graph', () => {
|
||||
const self: SchemaNode[] = [
|
||||
{ kind: 'object', properties: [{ name: 'next', type: 0, optional: true }, { name: 'value', type: 1, optional: false }], indices: [] }, string,
|
||||
]
|
||||
const mutual: SchemaNode[] = [
|
||||
{ kind: 'object', properties: [{ name: 'value', type: 2, optional: false }, { name: 'next', type: 1, optional: true }], indices: [] },
|
||||
{ kind: 'object', properties: [{ name: 'next', type: 0, optional: true }, { name: 'value', type: 3, optional: false }], indices: [] }, string, string,
|
||||
]
|
||||
const normalized = canonicalizeSchema(self, 0)
|
||||
expect(canonicalizeSchema(mutual, 0)).toEqual(normalized)
|
||||
expect(canonicalizeSchema(normalized.nodes, 0)).toEqual(normalized)
|
||||
mutual[3] = number
|
||||
expect(schemaDigest(canonicalizeSchema(mutual, 0))).not.toBe(schemaDigest(normalized))
|
||||
})
|
||||
|
||||
it('flattens reordered unions and removes structurally equal alternatives', () => {
|
||||
const flat: SchemaNode[] = [{ kind: 'union', types: [1, 2] }, string, number]
|
||||
const factored: SchemaNode[] = [string, number, { kind: 'union', types: [0, 3] }, { kind: 'union', types: [1, 0] }]
|
||||
expect(canonicalizeSchema(flat, 0)).toEqual(canonicalizeSchema(factored, 2))
|
||||
expect(canonicalizeSchema([{ kind: 'union', types: [1, 2] }, string, string], 0)).toEqual(canonicalizeSchema([string], 0))
|
||||
expect(canonicalizeSchema([{ kind: 'union', types: [1, 2] }, { kind: 'literal', value: true }, { kind: 'literal', value: false }], 0))
|
||||
.toEqual(canonicalizeSchema([{ kind: 'primitive', type: 'boolean' }], 0))
|
||||
})
|
||||
|
||||
it('ignores recursive union, field, index signature and graph numbering order together', () => {
|
||||
const original: SchemaNode[] = [
|
||||
{ kind: 'union', types: [1, 2, 3] },
|
||||
{ kind: 'object', properties: [{ name: 'label', type: 5, optional: false }, { name: 'next', type: 0, optional: true }], indices: [] },
|
||||
{ kind: 'array', element: 0 },
|
||||
{ kind: 'object', properties: [], indices: [{ key: 5, value: 0 }, { key: 4, value: 1 }] },
|
||||
number,
|
||||
string,
|
||||
]
|
||||
const reordered: SchemaNode[] = [
|
||||
string,
|
||||
{ kind: 'object', properties: [], indices: [{ key: 3, value: 5 }, { key: 0, value: 4 }] },
|
||||
{ kind: 'array', element: 4 },
|
||||
number,
|
||||
{ kind: 'union', types: [1, 2, 5] },
|
||||
{ kind: 'object', properties: [{ name: 'next', type: 4, optional: true }, { name: 'label', type: 0, optional: false }], indices: [] },
|
||||
]
|
||||
expect(canonicalizeSchema(reordered, 4)).toEqual(canonicalizeSchema(original, 0))
|
||||
})
|
||||
|
||||
it('preserves tuple positions, element absence, and rest elements', () => {
|
||||
const baseline: SchemaNode[] = [{ kind: 'tuple', elements: [{ type: 1, optional: false, rest: false }, { type: 2, optional: true, rest: false }] }, string, number]
|
||||
const reversed: SchemaNode[] = [{ kind: 'tuple', elements: [{ type: 2, optional: false, rest: false }, { type: 1, optional: true, rest: false }] }, string, number]
|
||||
const rest: SchemaNode[] = [{ kind: 'tuple', elements: [{ type: 1, optional: false, rest: false }, { type: 2, optional: false, rest: true }] }, string, number]
|
||||
const digest = schemaDigest(canonicalizeSchema(baseline, 0))
|
||||
expect(schemaDigest(canonicalizeSchema(reversed, 0))).not.toBe(digest)
|
||||
expect(schemaDigest(canonicalizeSchema(rest, 0))).not.toBe(digest)
|
||||
})
|
||||
|
||||
it('recognizes arbitrary recursive JSON structurally and retains its definition', () => {
|
||||
const json: SchemaNode[] = [
|
||||
{ kind: 'union', types: [1, 2, 3, 4, 5, 6] },
|
||||
{ kind: 'primitive', type: 'null' }, { kind: 'primitive', type: 'boolean' }, number, string,
|
||||
{ kind: 'array', element: 0 }, { kind: 'object', properties: [], indices: [{ key: 4, value: 0 }] },
|
||||
]
|
||||
const schema = canonicalizeSchema(json, 0)
|
||||
expect(isArbitraryJsonSchema(schema)).toBe(true)
|
||||
expect(schema.nodes.some(node => node.kind === 'array' && node.element === 0)).toBe(true)
|
||||
json[0] = { kind: 'union', types: [1, 2, 3, 4, 5] }
|
||||
expect(isArbitraryJsonSchema(canonicalizeSchema(json, 0))).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing references and unproductive union cycles', () => {
|
||||
expect(() => canonicalizeSchema([{ kind: 'array', element: 5 }], 0)).toThrow('missing node 5')
|
||||
expect(() => canonicalizeSchema([{ kind: 'union', types: [0] }], 0)).toThrow('union cycle')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Source-independent JSON type graphs and canonical persistence fingerprints.
|
||||
* Numeric references are local to one graph; names and source locations are metadata.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
/** One JSON property, with absence represented independently of its value type. */
|
||||
export interface SchemaProperty {
|
||||
readonly name: string
|
||||
readonly type: number
|
||||
readonly optional: boolean
|
||||
}
|
||||
|
||||
/** One positional tuple element. */
|
||||
export interface SchemaTupleElement {
|
||||
readonly type: number
|
||||
readonly optional: boolean
|
||||
readonly rest: boolean
|
||||
}
|
||||
|
||||
/** A resolved persisted type; references address nodes in the enclosing graph. */
|
||||
export type SchemaNode =
|
||||
| { readonly kind: 'primitive'; readonly type: 'null' | 'boolean' | 'number' | 'string' | 'never' }
|
||||
| { readonly kind: 'literal'; readonly value: string | number | boolean }
|
||||
| { readonly kind: 'opaque'; readonly reason: 'any' | 'unknown' }
|
||||
| { readonly kind: 'array'; readonly element: number }
|
||||
| { readonly kind: 'tuple'; readonly elements: readonly SchemaTupleElement[] }
|
||||
| { readonly kind: 'object'; readonly properties: readonly SchemaProperty[]; readonly indices: readonly { readonly key: number; readonly value: number }[] }
|
||||
| { readonly kind: 'union'; readonly types: readonly number[] }
|
||||
|
||||
/** A self-contained minimized graph with deterministic root-first node numbering. */
|
||||
export interface CanonicalSchema {
|
||||
readonly root: 0
|
||||
readonly nodes: readonly SchemaNode[]
|
||||
}
|
||||
|
||||
/** One independently tracked persistent record vocabulary. */
|
||||
export interface PersistenceRoot {
|
||||
readonly key: string
|
||||
readonly kind: 'header' | 'envelope' | 'event'
|
||||
readonly event?: string
|
||||
readonly surface?: boolean
|
||||
readonly digest: string
|
||||
readonly schema: CanonicalSchema
|
||||
}
|
||||
|
||||
/** One reachable structural type and its non-fingerprinted source declaration metadata. */
|
||||
export interface PersistenceType {
|
||||
readonly digest: string
|
||||
readonly schema: CanonicalSchema
|
||||
readonly names: readonly string[]
|
||||
readonly sources: readonly string[]
|
||||
}
|
||||
|
||||
/** Complete current-source persistence inventory; version pins normalization too. */
|
||||
export interface PersistenceSchemaInventory {
|
||||
readonly formatVersion: 1
|
||||
readonly roots: readonly PersistenceRoot[]
|
||||
readonly types: readonly PersistenceType[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit direct graph edges in their normalized semantic order.
|
||||
* @param node - resolved graph node.
|
||||
* @returns referenced node indices, including repeated ordered edges.
|
||||
*/
|
||||
export function schemaChildren(node: SchemaNode): number[] {
|
||||
switch (node.kind) {
|
||||
case 'array': return [node.element]
|
||||
case 'tuple': return node.elements.map(element => element.type)
|
||||
case 'object': return [...node.properties.map(property => property.type), ...node.indices.flatMap(index => [index.key, index.value])]
|
||||
case 'union': return [...node.types]
|
||||
case 'primitive':
|
||||
case 'literal':
|
||||
case 'opaque': return []
|
||||
default: return assertNever(node)
|
||||
}
|
||||
}
|
||||
|
||||
function mapNode(node: SchemaNode, ref: (id: number) => number): SchemaNode {
|
||||
switch (node.kind) {
|
||||
case 'array': return { kind: 'array', element: ref(node.element) }
|
||||
case 'tuple': return { kind: 'tuple', elements: node.elements.map(element => ({ type: ref(element.type), optional: element.optional, rest: element.rest })) }
|
||||
case 'object': return {
|
||||
kind: 'object',
|
||||
properties: [...node.properties].sort((left, right) => compare(left.name, right.name))
|
||||
.map(property => ({ name: property.name, type: ref(property.type), optional: property.optional })),
|
||||
indices: node.indices.map(index => ({ key: ref(index.key), value: ref(index.value) }))
|
||||
.sort((left, right) => left.key - right.key || left.value - right.value),
|
||||
}
|
||||
case 'union': return { kind: 'union', types: [...new Set(node.types.map(ref))].sort((left, right) => left - right) }
|
||||
case 'primitive': return { kind: 'primitive', type: node.type }
|
||||
case 'literal': return { kind: 'literal', value: node.value }
|
||||
case 'opaque': return { kind: 'opaque', reason: node.reason }
|
||||
default: return assertNever(node)
|
||||
}
|
||||
}
|
||||
|
||||
function partition(nodes: readonly SchemaNode[]): number[] {
|
||||
let colors = nodes.map(() => 0)
|
||||
for (;;) {
|
||||
const signatures = nodes.map((node, index) => JSON.stringify([colors[index], mapNode(node, id => colors[id] as number)]))
|
||||
const ordered = [...new Set(signatures)].sort(compare)
|
||||
const ids = new Map(ordered.map((value, index) => [value, index]))
|
||||
const next = signatures.map(value => ids.get(value) as number)
|
||||
const unchanged = new Set(next).size === new Set(colors).size
|
||||
colors = next
|
||||
if (unchanged) return colors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimize bisimilar recursive nodes and number the reachable graph deterministically.
|
||||
* @param input - resolved nodes; property and union order may be arbitrary.
|
||||
* @param root - index of the requested root.
|
||||
* @returns canonical graph excluding unreachable nodes and duplicate structures.
|
||||
*/
|
||||
export function canonicalizeSchema(input: readonly SchemaNode[], root: number): CanonicalSchema {
|
||||
input = normalizeUnions(input)
|
||||
const selected: number[] = []
|
||||
const positions = new Map<number, number>()
|
||||
const select = (id: number): void => {
|
||||
if (!Number.isInteger(id) || id < 0 || id >= input.length) throw new Error(`persistence schema: missing node ${String(id)}`)
|
||||
if (positions.has(id)) return
|
||||
positions.set(id, selected.length)
|
||||
selected.push(id)
|
||||
for (const child of schemaChildren(input[id] as SchemaNode)) select(child)
|
||||
}
|
||||
select(root)
|
||||
let nodes = selected.map(id => mapNode(input[id] as SchemaNode, child => positions.get(child) as number))
|
||||
let rootIndex = 0
|
||||
for (;;) {
|
||||
const colors = partition(nodes)
|
||||
const representatives = new Map<number, number>()
|
||||
colors.forEach((color, index) => { if (!representatives.has(color)) representatives.set(color, index) })
|
||||
const aliases = new Map<number, number>()
|
||||
nodes.forEach((node, index) => {
|
||||
if (node.kind !== 'union') return
|
||||
const members = [...new Set(node.types.map(child => colors[child] as number))]
|
||||
if (members.length === 1) aliases.set(index, representatives.get(members[0] as number) as number)
|
||||
})
|
||||
if (aliases.size > 0) {
|
||||
const resolve = (id: number): number => {
|
||||
const seen = new Set<number>()
|
||||
while (aliases.has(id)) {
|
||||
if (seen.has(id)) throw new Error('persistence schema: union cycle has no material type')
|
||||
seen.add(id)
|
||||
id = aliases.get(id) as number
|
||||
}
|
||||
return id
|
||||
}
|
||||
rootIndex = resolve(rootIndex)
|
||||
nodes = nodes.map((node, index) => aliases.has(index) ? nodes[resolve(index)] as SchemaNode : mapNode(node, resolve))
|
||||
continue
|
||||
}
|
||||
const emitted = new Map<number, number>()
|
||||
const result: SchemaNode[] = []
|
||||
const visit = (id: number): number => {
|
||||
const color = colors[id] as number
|
||||
const existing = emitted.get(color)
|
||||
if (existing !== undefined) return existing
|
||||
const position = result.length
|
||||
emitted.set(color, position)
|
||||
result.push({ kind: 'primitive', type: 'never' })
|
||||
const representative = representatives.get(color) as number
|
||||
const colored = mapNode(nodes[representative] as SchemaNode, child => colors[child] as number)
|
||||
result[position] = mapNode(colored, child => visit(representatives.get(child) as number))
|
||||
return position
|
||||
}
|
||||
visit(rootIndex)
|
||||
return { root: 0, nodes: result }
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUnions(input: readonly SchemaNode[]): SchemaNode[] {
|
||||
const result = [...input]
|
||||
const flattened = (id: number, visiting: Set<number>): number[] => {
|
||||
const node = input[id]
|
||||
if (node === undefined) throw new Error(`persistence schema: missing node ${String(id)}`)
|
||||
if (node.kind !== 'union') return [id]
|
||||
if (visiting.has(id)) throw new Error('persistence schema: union cycle has no material type')
|
||||
const next = new Set(visiting).add(id)
|
||||
return node.types.flatMap(child => flattened(child, next))
|
||||
}
|
||||
for (const [id, node] of input.entries()) {
|
||||
if (node.kind !== 'union') continue
|
||||
let members = [...new Set(flattened(id, new Set()))]
|
||||
const primitives = new Set(members.flatMap((child) => {
|
||||
const value = input[child] as SchemaNode
|
||||
return value.kind === 'primitive' ? [value.type] : []
|
||||
}))
|
||||
const literals = members.flatMap((child) => {
|
||||
const value = input[child] as SchemaNode
|
||||
return value.kind === 'literal' ? [value.value] : []
|
||||
})
|
||||
if (!primitives.has('boolean') && literals.includes(true) && literals.includes(false)) {
|
||||
primitives.add('boolean')
|
||||
members.push(result.length)
|
||||
result.push({ kind: 'primitive', type: 'boolean' })
|
||||
}
|
||||
members = members.filter((child) => {
|
||||
const value = result[child] as SchemaNode
|
||||
if (value.kind === 'primitive') return value.type !== 'never'
|
||||
return value.kind !== 'literal' || !primitives.has(typeof value.value as 'string' | 'number' | 'boolean')
|
||||
})
|
||||
result[id] = members.length === 0 ? { kind: 'primitive', type: 'never' } : { kind: 'union', types: members }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the versioned SHA-256 fingerprint of a canonical graph.
|
||||
* @param schema - canonical resolved persisted type.
|
||||
* @returns lowercase hexadecimal digest.
|
||||
*/
|
||||
export function schemaDigest(schema: CanonicalSchema): string {
|
||||
return createHash('sha256').update('dsh-persistence-schema-v1\n').update(JSON.stringify(schema)).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognize the complete recursive JSON value language without relying on type names.
|
||||
* @param schema - canonical resolved type.
|
||||
* @returns whether the type permits arbitrary JSON values.
|
||||
*/
|
||||
export function isArbitraryJsonSchema(schema: CanonicalSchema): boolean {
|
||||
return schemaDigest(schema) === JSON_VALUE_DIGEST
|
||||
}
|
||||
|
||||
const JSON_VALUE_DIGEST = schemaDigest(canonicalizeSchema([
|
||||
{ kind: 'union', types: [1, 2, 3, 4, 5, 6] },
|
||||
{ kind: 'primitive', type: 'null' },
|
||||
{ kind: 'primitive', type: 'boolean' },
|
||||
{ kind: 'primitive', type: 'number' },
|
||||
{ kind: 'primitive', type: 'string' },
|
||||
{ kind: 'array', element: 0 },
|
||||
{ kind: 'object', properties: [], indices: [{ key: 4, value: 0 }] },
|
||||
], 0))
|
||||
|
||||
function compare(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
}
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`persistence schema: unsupported node ${JSON.stringify(value)}`)
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { collectLogEvents } from './gen-persistence-catalog.ts'
|
||||
import { extractPersistenceSchema } from './persistence-schema.ts'
|
||||
import { canonicalizeSchema, isArbitraryJsonSchema, schemaDigest, type PersistenceSchemaInventory } from './persistence-schema-model.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 3 })
|
||||
})
|
||||
|
||||
function put(root: string, file: string, source: string): void {
|
||||
const path = join(root, file)
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(path, source)
|
||||
}
|
||||
|
||||
function fixture(payload: string, options: { surface?: string; event?: string } = {}): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-persistence-schema-'))
|
||||
roots.push(root)
|
||||
put(root, 'tsconfig.host.json', JSON.stringify({ compilerOptions: {
|
||||
target: 'es2024', module: 'esnext', moduleResolution: 'bundler', strict: true, skipLibCheck: true,
|
||||
types: [], paths: {
|
||||
'@deepseek-ai/dsh-session/types': ['./packages/core/session/src/types.ts'],
|
||||
'@fixture/payload': ['./packages/domain/payload/src/types.ts'],
|
||||
},
|
||||
}, include: ['packages/**/src/**/*.ts'] }))
|
||||
put(root, 'packages/core/session/package.json', '{"name":"@deepseek-ai/dsh-session"}')
|
||||
put(root, 'packages/domain/payload/package.json', '{"name":"@fixture/payload"}')
|
||||
put(root, 'packages/session/session-persistence-jsonl/src/format.ts', "interface HeaderLine {type: 'session'; version: number; id: string; delegationDepth: number}\nexport {}\n")
|
||||
put(root, 'packages/domain/payload/src/types.ts', payload)
|
||||
put(root, 'packages/core/session/src/types.ts', `
|
||||
import type { Payload } from '@fixture/payload'
|
||||
export interface SessionHeader { version: 3; id: string; createdAt: number }
|
||||
export interface SessionEventMap {
|
||||
/** One recorded payload. */
|
||||
'test/record': Payload
|
||||
${options.event ?? ''}
|
||||
}
|
||||
export type SurfaceEventType = ${options.surface ?? 'never'}
|
||||
export type SessionEvent<T extends keyof SessionEventMap = keyof SessionEventMap> = {
|
||||
[K in keyof SessionEventMap]: { type: K; seq: number; time: number; data: SessionEventMap[K] }
|
||||
& (K extends SurfaceEventType ? { surfaceOp: 'append' | { replace: number } } : { surfaceOp?: never })
|
||||
}[T]
|
||||
`)
|
||||
return root
|
||||
}
|
||||
|
||||
function event(inventory: PersistenceSchemaInventory, name = 'test/record'): string {
|
||||
const root = inventory.roots.find(root => root.event === name)
|
||||
if (root === undefined) throw new Error(`missing test event ${name}`)
|
||||
return root.digest
|
||||
}
|
||||
|
||||
describe('persistent source type extraction', () => {
|
||||
it('ignores alias names, files, documentation, readonly, brands and property order', () => {
|
||||
const left = fixture('declare const brand: unique symbol; type Id = string & {readonly [brand]: "Id"}; export interface Payload { readonly id: Id; value?: number }')
|
||||
const right = fixture('type Renamed = string; /** Different documentation. */ export type Payload = {value?: number; id: Renamed}')
|
||||
expect(event(extractPersistenceSchema(left))).toBe(event(extractPersistenceSchema(right)))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['nested field order',
|
||||
'export interface Payload {\n id: string\n detail: {\n label: string\n count?: number\n }\n}',
|
||||
'export interface Payload {\n detail: {\n count?: number\n label: string\n }\n id: string\n}'],
|
||||
['anonymous union order',
|
||||
'export type Payload =\n { a: string }\n | { b: number }\n | { c?: boolean }',
|
||||
'export type Payload =\n { c?: boolean }\n | { b: number }\n | { a: string }'],
|
||||
['recursive union order',
|
||||
'export type Payload =\n { a: string; next?: Payload }\n | { b: number; next?: Payload }\n | null',
|
||||
'export type Payload =\n null\n | { next?: Payload; b: number }\n | { next?: Payload; a: string }'],
|
||||
['intersection and distributed union order',
|
||||
'type A = { a: string } | { b: number }; type B = { c: boolean }; export type Payload = A & B',
|
||||
'type A = { b: number } | { a: string }; type B = { c: boolean }; export type Payload = B & A'],
|
||||
['index signature order',
|
||||
'export interface Payload { [key: string]: string | number; [key: number]: number }',
|
||||
'export interface Payload { [key: number]: number; [key: string]: number | string }'],
|
||||
['type declaration order',
|
||||
'interface A { a: string }\ninterface B { b: number }\nexport type Payload = A | B',
|
||||
'interface B { b: number }\ninterface A { a: string }\nexport type Payload = B | A'],
|
||||
['merged declaration order',
|
||||
'export interface Payload { a: string }\nexport interface Payload { b: number }',
|
||||
'export interface Payload { b: number }\nexport interface Payload { a: string }'],
|
||||
['mapped key order',
|
||||
'export type Payload = { [K in "a" | "b" | "c"]: string }',
|
||||
'export type Payload = { [K in "c" | "b" | "a"]: string }'],
|
||||
['explicit enum member order',
|
||||
'enum Value {\n A = "a",\n B = "b",\n}\nexport interface Payload { value: Value }',
|
||||
'enum Value {\n B = "b",\n A = "a",\n}\nexport interface Payload { value: Value }'],
|
||||
])('keeps every root and type digest unchanged by %s', (_name, before, after) => {
|
||||
const root = fixture(before)
|
||||
const inventory = extractPersistenceSchema(root)
|
||||
put(root, 'packages/domain/payload/src/types.ts', after)
|
||||
const reordered = extractPersistenceSchema(root)
|
||||
expect(reordered.roots).toEqual(inventory.roots)
|
||||
expect(reordered.types.map(({ digest, schema }) => ({ digest, schema })))
|
||||
.toEqual(inventory.types.map(({ digest, schema }) => ({ digest, schema })))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['tuple positions', 'export type Payload = [string, number]', 'export type Payload = [number, string]'],
|
||||
['implicit enum values', 'enum Value { A, B }; export interface Payload { value: Value.A }', 'enum Value { B, A }; export interface Payload { value: Value.A }'],
|
||||
])('changes the digest when reordering changes %s', (_name, before, after) => {
|
||||
const root = fixture(before)
|
||||
const digest = event(extractPersistenceSchema(root))
|
||||
put(root, 'packages/domain/payload/src/types.ts', after)
|
||||
expect(event(extractPersistenceSchema(root))).not.toBe(digest)
|
||||
})
|
||||
|
||||
it('keeps header, envelope and event digests unchanged when root declarations are reordered', () => {
|
||||
const record = " /** One recorded payload. */\n 'test/record': Payload"
|
||||
const other = "/** Another recorded payload. */\n 'test/other': { value: number }"
|
||||
const root = fixture('export interface Payload { id: string }', {
|
||||
surface: "'test/record' | 'test/other'",
|
||||
event: other,
|
||||
})
|
||||
const inventory = extractPersistenceSchema(root)
|
||||
const source = readFileSync(join(root, 'packages/core/session/src/types.ts'), 'utf8')
|
||||
const reordered = source
|
||||
.replace(`${record}\n ${other}`, ` ${other}\n${record}`)
|
||||
.replace('version: 3; id: string; createdAt: number', 'createdAt: number; id: string; version: 3')
|
||||
.replace("'test/record' | 'test/other'", "'test/other' | 'test/record'")
|
||||
.replace('type: K; seq: number; time: number; data: SessionEventMap[K]', 'data: SessionEventMap[K]; time: number; seq: number; type: K')
|
||||
expect(reordered.indexOf("'test/other':")).toBeLessThan(reordered.indexOf("'test/record':"))
|
||||
put(root, 'packages/core/session/src/types.ts', reordered)
|
||||
put(root, 'packages/session/session-persistence-jsonl/src/format.ts', "interface HeaderLine {delegationDepth: number; id: string; version: number; type: 'session'}\nexport {}\n")
|
||||
const result = extractPersistenceSchema(root)
|
||||
expect(result.roots).toEqual(inventory.roots)
|
||||
expect(result.types.map(({ digest, schema }) => ({ digest, schema })))
|
||||
.toEqual(inventory.types.map(({ digest, schema }) => ({ digest, schema })))
|
||||
})
|
||||
|
||||
it('materializes generic, conditional and mapped aliases into their concrete properties', () => {
|
||||
const generic = fixture('type Box<T> = { [K in keyof T]: T[K] extends string ? string[] : T[K] }; export type Payload = Box<{label: string; count?: number}>')
|
||||
const concrete = fixture('export interface Payload {count?: number; label: string[]}')
|
||||
expect(event(extractPersistenceSchema(generic))).toBe(event(extractPersistenceSchema(concrete)))
|
||||
})
|
||||
|
||||
it('retains transitive recursion without depending on alias factoring', () => {
|
||||
const self = fixture('export interface Payload { value: string; next?: Payload }')
|
||||
const mutual = fixture('interface Other {next?: Payload; value: string} export interface Payload {value: string; next?: Other}')
|
||||
const changed = fixture('interface Other {next?: Payload; value: number} export interface Payload {value: string; next?: Other}')
|
||||
const digest = event(extractPersistenceSchema(self))
|
||||
expect(event(extractPersistenceSchema(mutual))).toBe(digest)
|
||||
expect(event(extractPersistenceSchema(changed))).not.toBe(digest)
|
||||
})
|
||||
|
||||
it('includes independent Host modules that augment nested payload maps without event declarations', () => {
|
||||
const root = fixture('export interface NestedMap {first: {value: string}}; export type Payload = NestedMap[keyof NestedMap]')
|
||||
const before = extractPersistenceSchema(root)
|
||||
put(root, 'packages/domain/extension/src/index.ts', "import '@fixture/payload'; declare module '@fixture/payload' {interface NestedMap {second: {value: number}}}")
|
||||
const after = extractPersistenceSchema(root)
|
||||
expect(collectLogEvents(root)).toHaveLength(1)
|
||||
expect(event(after)).not.toBe(event(before))
|
||||
const data = after.types.find(item => item.schema.nodes[0]?.kind === 'union' && item.schema.nodes.some(node => node.kind === 'primitive' && node.type === 'number'))
|
||||
expect(data).toBeDefined()
|
||||
})
|
||||
|
||||
it('discovers plugin event merges and keeps ordinary event additions out of the envelope digest', () => {
|
||||
const root = fixture('export interface Payload {id: string}')
|
||||
const before = extractPersistenceSchema(root)
|
||||
put(root, 'packages/domain/extension/src/index.ts', `import '@deepseek-ai/dsh-session/types'; declare module '@deepseek-ai/dsh-session/types' {
|
||||
interface SessionEventMap {
|
||||
/** A plugin event. */
|
||||
'plugin/new': { optional?: boolean }
|
||||
}}
|
||||
`)
|
||||
const after = extractPersistenceSchema(root)
|
||||
expect(after.roots.some(root => root.event === 'plugin/new')).toBe(true)
|
||||
expect(after.roots.find(root => root.kind === 'envelope')?.digest).toBe(before.roots.find(root => root.kind === 'envelope')?.digest)
|
||||
expect(event(after)).toBe(event(before))
|
||||
})
|
||||
|
||||
it('includes JSX-module events and rejects unsupported inherited declarations there', () => {
|
||||
const root = fixture('export interface Payload {id: string}')
|
||||
put(root, 'packages/domain/extension/src/index.tsx', `import '@deepseek-ai/dsh-session/types'; declare module '@deepseek-ai/dsh-session/types' {
|
||||
interface SessionEventMap {
|
||||
/** A JSX-module event. */
|
||||
'plugin/tsx': { value: number }
|
||||
}}
|
||||
`)
|
||||
const config = join(root, 'tsconfig.host.json')
|
||||
const settings = JSON.parse(readFileSync(config, 'utf8')) as { include: string[] }
|
||||
settings.include.push('packages/**/src/**/*.tsx')
|
||||
writeFileSync(config, JSON.stringify(settings))
|
||||
expect(extractPersistenceSchema(root).roots.some(root => root.event === 'plugin/tsx')).toBe(true)
|
||||
put(root, 'packages/domain/extension/src/index.tsx', `import '@deepseek-ai/dsh-session/types';
|
||||
interface Extra { 'plugin/inherited': {value: number} }
|
||||
declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap extends Extra {} }
|
||||
`)
|
||||
expect(() => extractPersistenceSchema(root)).toThrow('uses extends')
|
||||
})
|
||||
|
||||
it('rejects an event map whose compiler vocabulary exceeds the discovered source corpus', () => {
|
||||
const root = fixture("import '../../../../vendor/events.ts'; export interface Payload {id: string}")
|
||||
put(root, 'vendor/events.ts', `import '@deepseek-ai/dsh-session/types'; declare module '@deepseek-ai/dsh-session/types' {
|
||||
interface SessionEventMap {
|
||||
/** A contribution outside the package source corpus. */
|
||||
'outside/event': { value: number }
|
||||
}}
|
||||
`)
|
||||
expect(() => extractPersistenceSchema(root)).toThrow('omitted: outside/event')
|
||||
})
|
||||
|
||||
it('keeps surface metadata strict and optional field additions visible', () => {
|
||||
const old = extractPersistenceSchema(fixture('export interface Payload {id: string}'))
|
||||
const optional = extractPersistenceSchema(fixture('export interface Payload {id: string; detail?: string}'))
|
||||
const surface = extractPersistenceSchema(fixture('export interface Payload {id: string}', { surface: "'test/record'" }))
|
||||
expect(event(old)).not.toBe(event(optional))
|
||||
expect(event(old)).not.toBe(event(surface))
|
||||
expect(surface.roots.find(root => root.event === 'test/record')?.surface).toBe(true)
|
||||
expect(surface.roots.find(root => root.kind === 'envelope')?.digest).not.toBe(old.roots.find(root => root.kind === 'envelope')?.digest)
|
||||
})
|
||||
|
||||
it('tracks the physical JSONL header independently of the logical Session header', () => {
|
||||
const root = fixture('export interface Payload {id: string}')
|
||||
const before = extractPersistenceSchema(root)
|
||||
put(root, 'packages/session/session-persistence-jsonl/src/format.ts', "interface HeaderLine {type: 'session'; version: number; id: string; delegationDepth: number; marker?: string}\nexport {}\n")
|
||||
const after = extractPersistenceSchema(root)
|
||||
expect(after.roots.find(root => root.key === 'JsonlHeaderLine')?.digest).not.toBe(before.roots.find(root => root.key === 'JsonlHeaderLine')?.digest)
|
||||
expect(after.roots.find(root => root.key === 'SessionHeader')?.digest).toBe(before.roots.find(root => root.key === 'SessionHeader')?.digest)
|
||||
})
|
||||
|
||||
it('keeps arbitrary JSON structural while explicitly identifying unknown and any', () => {
|
||||
const root = fixture('type Arbitrary = null | boolean | number | string | Arbitrary[] | {[key: string]: Arbitrary}; export interface Payload {json: Arbitrary; opaque: unknown; unsafe: any}')
|
||||
const model = extractPersistenceSchema(root)
|
||||
expect(model.types.some(item => isArbitraryJsonSchema(item.schema))).toBe(true)
|
||||
expect(model.types.filter(item => item.schema.nodes[0]?.kind === 'opaque').map(item => item.schema.nodes[0]))
|
||||
.toEqual(expect.arrayContaining([{ kind: 'opaque', reason: 'unknown' }, { kind: 'opaque', reason: 'any' }]))
|
||||
put(root, 'packages/domain/payload/src/types.ts', 'type Arbitrary = null | boolean | number | string | Arbitrary[]; export interface Payload {json: Arbitrary; opaque: unknown; unsafe: any}')
|
||||
const changed = extractPersistenceSchema(root)
|
||||
expect(event(changed)).not.toBe(event(model))
|
||||
expect(changed.types.some(item => isArbitraryJsonSchema(item.schema))).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid reachable declaration files despite inherited skipLibCheck', () => {
|
||||
const root = fixture("export type { Payload } from './declared.js'")
|
||||
put(root, 'packages/domain/payload/src/declared.d.ts', 'type Bad = ReturnType<42>; export interface Payload {value: Bad}')
|
||||
expect(() => extractPersistenceSchema(root)).toThrow('TS2344')
|
||||
})
|
||||
|
||||
it('preserves authored declaration-file any without checking unrelated declarations', () => {
|
||||
const root = fixture("export type { Payload } from './declared.js'")
|
||||
put(root, 'packages/domain/payload/src/declared.d.ts', 'type Declared = any; export interface Payload {value: Declared}; type Unrelated = ReturnType<42>')
|
||||
const model = extractPersistenceSchema(root)
|
||||
expect(model.types.some(item => item.schema.nodes[0]?.kind === 'opaque' && item.schema.nodes[0].reason === 'any')).toBe(true)
|
||||
})
|
||||
|
||||
it('locates named transitive definitions at their declarations instead of their references', () => {
|
||||
const root = fixture("import type { Detail as ImportedDetail } from './detail.js'\nexport interface Payload { first: ImportedDetail; second: ImportedDetail }")
|
||||
put(root, 'packages/domain/payload/src/detail.ts', '/** Detailed payload. */\nexport interface Detail { code: string; done: true }\n')
|
||||
const model = extractPersistenceSchema(root)
|
||||
const detail = model.types.find(item => item.names.includes('packages/domain/payload/src/detail.ts#Detail'))
|
||||
expect(detail?.sources).toEqual(['packages/domain/payload/src/detail.ts:2'])
|
||||
})
|
||||
|
||||
it('keeps shared scalar declaration metadata at real aliases and omits plain property references', () => {
|
||||
const root = fixture([
|
||||
'type Label = string',
|
||||
"type Done = 'done'",
|
||||
'export interface Payload {',
|
||||
' label: Label',
|
||||
' other: string',
|
||||
' state: Done',
|
||||
" fallback: 'done'",
|
||||
' count: number',
|
||||
'}',
|
||||
].join('\n'))
|
||||
const model = extractPersistenceSchema(root)
|
||||
const string = model.types.find(item => item.schema.nodes[0]?.kind === 'primitive' && item.schema.nodes[0].type === 'string')
|
||||
const literal = model.types.find(item => item.schema.nodes[0]?.kind === 'literal' && item.schema.nodes[0].value === 'done')
|
||||
const number = model.types.find(item => item.schema.nodes[0]?.kind === 'primitive' && item.schema.nodes[0].type === 'number')
|
||||
expect(string?.names).toEqual(['packages/domain/payload/src/types.ts#Label'])
|
||||
expect(string?.sources).toEqual(['packages/domain/payload/src/types.ts:1'])
|
||||
expect(literal?.names).toEqual(['packages/domain/payload/src/types.ts#Done'])
|
||||
expect(literal?.sources).toEqual(['packages/domain/payload/src/types.ts:2'])
|
||||
expect(number?.names).toEqual([])
|
||||
expect(number?.sources).toEqual([])
|
||||
})
|
||||
|
||||
it('uses the declaration of an anonymous object literal and never its containing property', () => {
|
||||
const root = fixture('export interface Payload {\n detail:\n { code: string }\n}')
|
||||
const model = extractPersistenceSchema(root)
|
||||
const detail = model.types.find((item) => {
|
||||
const node = item.schema.nodes[0]
|
||||
return node?.kind === 'object' && node.properties.length === 1 && node.properties[0]?.name === 'code'
|
||||
})
|
||||
expect(detail?.sources).toEqual(['packages/domain/payload/src/types.ts:3'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing type', 'export interface Payload {value: Missing}', 'TS2304'],
|
||||
['callable value', 'export interface Payload {value: () => void}', 'callable data'],
|
||||
['class instance', 'export class Instance {value: string = ""}; export interface Payload {value: Instance}', 'class instances'],
|
||||
['required undefined', 'export interface Payload {value: string | undefined}', 'no supported JSON'],
|
||||
['unresolved template', 'export interface Payload {value: `prefix-${string}`}', 'no supported JSON'],
|
||||
['empty object type', 'export type Payload = {}', 'unconstrained empty object'],
|
||||
['symbol property', 'declare const key: unique symbol; export interface Payload {[key]: string; value: number}', 'symbol-keyed data'],
|
||||
['conflicting merged property', 'export interface Payload {value: string}; export interface Payload {value: number}', 'TS2717'],
|
||||
['class intersection', 'class Instance {value: string = ""}; export type Payload = Instance & {extra: string}', 'class instances'],
|
||||
['invalid generic alias', 'type Bad = ReturnType<42>; export interface Payload {value: Bad}', 'TS2344'],
|
||||
])('rejects %s rather than weakening the schema', (_name, source, error) => {
|
||||
expect(() => extractPersistenceSchema(fixture(source))).toThrow(error)
|
||||
})
|
||||
|
||||
// Repository-wide TypeScript extraction and reachable-node hashing use the Windows coverage test budget.
|
||||
it('includes every real repository event and fingerprints every reachable node', { timeout: 90_000 }, () => {
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const model = extractPersistenceSchema(root)
|
||||
expect(model.roots.filter(root => root.kind === 'event').map(root => root.event).sort())
|
||||
.toEqual(collectLogEvents(root).map(event => event.name).sort())
|
||||
expect(model.roots.some(root => root.kind === 'header')).toBe(true)
|
||||
expect(model.roots.some(root => root.kind === 'envelope')).toBe(true)
|
||||
const digests = new Set(model.types.map(type => type.digest))
|
||||
for (const root of model.roots) {
|
||||
expect(schemaDigest(root.schema)).toBe(root.digest)
|
||||
for (let node = 0; node < root.schema.nodes.length; node++) {
|
||||
expect(digests.has(schemaDigest(canonicalizeSchema(root.schema.nodes, node)))).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,462 @@
|
||||
/** Extract complete persistent Session record types from one source-only compiler program. */
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, extname, relative, resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { collectLogEvents } from './persistence-catalog-source.ts'
|
||||
import {
|
||||
canonicalizeSchema,
|
||||
schemaChildren,
|
||||
schemaDigest,
|
||||
type PersistenceRoot,
|
||||
type PersistenceSchemaInventory,
|
||||
type PersistenceType,
|
||||
type SchemaNode,
|
||||
type SchemaProperty,
|
||||
} from './persistence-schema-model.ts'
|
||||
|
||||
interface DeclarationMetadata {
|
||||
readonly names: Set<string>
|
||||
readonly sources: Set<string>
|
||||
}
|
||||
|
||||
interface RootInput {
|
||||
readonly key: string
|
||||
readonly kind: PersistenceRoot['kind']
|
||||
readonly event?: string
|
||||
readonly surface?: boolean
|
||||
readonly node: number
|
||||
}
|
||||
|
||||
/** A reachable TypeScript type that cannot be represented as a persisted JSON type. */
|
||||
export class PersistenceSchemaError extends Error {
|
||||
override name = 'PersistenceSchemaError'
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract every repository event, its complete envelope, and the Session header.
|
||||
* @param root - repository root, with source paths in tsconfig.host.json.
|
||||
* @returns canonical root fingerprints and all reachable type definitions.
|
||||
*/
|
||||
export function extractPersistenceSchema(root: string): PersistenceSchemaInventory {
|
||||
root = resolve(root)
|
||||
const events = collectLogEvents(root).sort((left, right) => compare(left.name, right.name))
|
||||
if (events.length === 0) throw new PersistenceSchemaError('persistence schema: no Session events were discovered')
|
||||
const filename = resolve(root, 'scripts/__persistence_schema_roots__.ts')
|
||||
const source = [
|
||||
"import type { SessionHeader, SessionEvent, SessionEventMap, SurfaceEventType } from '@deepseek-ai/dsh-session/types'",
|
||||
'export type HeaderRoot = SessionHeader',
|
||||
'export type SurfaceRoot = SurfaceEventType',
|
||||
'export type EventNamesRoot = keyof SessionEventMap',
|
||||
...events.map((event, index) => `export type EventRoot${String(index)} = SessionEvent<${JSON.stringify(event.name)}>`),
|
||||
'',
|
||||
].join('\n')
|
||||
const configPath = resolve(root, 'tsconfig.host.json')
|
||||
const config = ts.readConfigFile(configPath, file => readFileSync(file, 'utf8'))
|
||||
if (config.error !== undefined) throw new PersistenceSchemaError(diagnosticText(config.error))
|
||||
const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, root)
|
||||
const configErrors = parsed.errors.filter(error => error.code !== 18003)
|
||||
if (configErrors.length > 0) throw new PersistenceSchemaError(configErrors.map(diagnosticText).join('\n'))
|
||||
const options: ts.CompilerOptions = {
|
||||
...parsed.options,
|
||||
composite: false,
|
||||
incremental: false,
|
||||
noEmit: true,
|
||||
rootDir: root,
|
||||
noUnusedLocals: false,
|
||||
noUnusedParameters: false,
|
||||
// Reachable declaration-file errors must not become opaque any values.
|
||||
skipLibCheck: false,
|
||||
strict: true,
|
||||
exactOptionalPropertyTypes: true,
|
||||
}
|
||||
const host = ts.createCompilerHost(options)
|
||||
const originalGetSourceFile = host.getSourceFile.bind(host)
|
||||
host.getSourceFile = (path, languageVersion, onError, shouldCreateNewSourceFile) =>
|
||||
resolve(path) === filename
|
||||
? ts.createSourceFile(filename, source, languageVersion, true)
|
||||
: originalGetSourceFile(path, languageVersion, onError, shouldCreateNewSourceFile)
|
||||
const files = [...new Set([
|
||||
...hostSourceFiles(root, configPath),
|
||||
...events.map(event => resolve(root, event.source.slice(0, event.source.lastIndexOf(':')))),
|
||||
])]
|
||||
const program = ts.createProgram({ rootNames: [filename, ...files], options, host })
|
||||
const rootSource = program.getSourceFile(filename)
|
||||
if (rootSource === undefined) throw new PersistenceSchemaError('persistence schema: compiler omitted the requested roots')
|
||||
const diagnostics = [
|
||||
...program.getOptionsDiagnostics(), ...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics(rootSource),
|
||||
]
|
||||
if (diagnostics.length > 0) throw new PersistenceSchemaError(diagnostics.map(diagnosticText).join('\n'))
|
||||
const checker = program.getTypeChecker()
|
||||
const declarations = new Map(rootSource.statements.filter(ts.isTypeAliasDeclaration)
|
||||
.map(declaration => [declaration.name.text, declaration]))
|
||||
const declaration = (name: string): ts.TypeAliasDeclaration => {
|
||||
const found = declarations.get(name)
|
||||
if (found === undefined) throw new PersistenceSchemaError(`persistence schema: missing compiler root ${name}`)
|
||||
return found
|
||||
}
|
||||
const eventNamesDeclaration = declaration('EventNamesRoot')
|
||||
const compiledEvents = stringLiterals(checker.getTypeFromTypeNode(eventNamesDeclaration.type), 'keyof SessionEventMap')
|
||||
const discoveredEvents = new Set(events.map(event => event.name))
|
||||
const omitted = [...compiledEvents].filter(name => !discoveredEvents.has(name))
|
||||
const uncompiled = [...discoveredEvents].filter(name => !compiledEvents.has(name))
|
||||
if (omitted.length > 0 || uncompiled.length > 0) {
|
||||
throw new PersistenceSchemaError(`persistence schema: compiler and source event discovery disagree; omitted: ${omitted.join(', ')}; uncompiled: ${uncompiled.join(', ')}`)
|
||||
}
|
||||
const surfaceDeclaration = declaration('SurfaceRoot')
|
||||
const surface = stringLiterals(checker.getTypeFromTypeNode(surfaceDeclaration.type), 'SurfaceEventType')
|
||||
for (const name of surface) {
|
||||
if (!events.some(event => event.name === name)) throw new PersistenceSchemaError(`persistence schema: surface event ${name} has no declaration`)
|
||||
}
|
||||
const header = declaration('HeaderRoot')
|
||||
const physicalFile = resolve(root, 'packages/session/session-persistence-jsonl/src/format.ts')
|
||||
const physical = program.getSourceFile(physicalFile)?.statements
|
||||
.filter((node): node is ts.InterfaceDeclaration | ts.TypeAliasDeclaration =>
|
||||
ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node))
|
||||
.filter(declaration => declaration.name.text === 'HeaderLine')
|
||||
if (physical?.length !== 1) throw new PersistenceSchemaError('persistence schema: expected one current JSONL HeaderLine declaration')
|
||||
const physicalHeader = physical[0] as ts.InterfaceDeclaration | ts.TypeAliasDeclaration
|
||||
const declarationSources = validateReachableDeclarations(program, root, [...declarations.values(), physicalHeader])
|
||||
const extraction = new SchemaExtractor(root, program, declarationSources)
|
||||
const roots: RootInput[] = [{
|
||||
key: 'SessionHeader',
|
||||
kind: 'header',
|
||||
node: extraction.convert(checker.getTypeFromTypeNode(header.type), header),
|
||||
}]
|
||||
roots.push({ key: 'JsonlHeaderLine', kind: 'header', node: extraction.convert(checker.getTypeAtLocation(physicalHeader), physicalHeader) })
|
||||
const envelopes: number[] = []
|
||||
for (const [index, event] of events.entries()) {
|
||||
const item = declaration(`EventRoot${String(index)}`)
|
||||
const node = extraction.convert(checker.getTypeFromTypeNode(item.type), item)
|
||||
roots.push({ key: `event:${event.name}`, kind: 'event', event: event.name, surface: surface.has(event.name), node })
|
||||
envelopes.push(extraction.envelope(node, event.name))
|
||||
}
|
||||
roots.splice(2, 0, { key: 'SessionEventEnvelope', kind: 'envelope', node: extraction.union(envelopes) })
|
||||
return extraction.inventory(roots)
|
||||
}
|
||||
|
||||
function hostSourceFiles(root: string, configPath: string, seen = new Set<string>()): string[] {
|
||||
configPath = resolve(configPath)
|
||||
if (seen.has(configPath)) return []
|
||||
seen.add(configPath)
|
||||
const read = ts.readConfigFile(configPath, file => readFileSync(file, 'utf8'))
|
||||
if (read.error !== undefined) throw new PersistenceSchemaError(diagnosticText(read.error))
|
||||
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, dirname(configPath), undefined, configPath)
|
||||
const errors = parsed.errors.filter(error => error.code !== 18003)
|
||||
if (errors.length > 0) throw new PersistenceSchemaError(errors.map(diagnosticText).join('\n'))
|
||||
return [
|
||||
...parsed.fileNames.filter(file => /^packages\/[^/]+\/[^/]+\/src\//.test(slash(relative(root, file)))),
|
||||
...(parsed.projectReferences ?? []).flatMap(reference =>
|
||||
hostSourceFiles(root, extname(reference.path) === '.json' ? reference.path : resolve(reference.path, 'tsconfig.json'), seen)),
|
||||
]
|
||||
}
|
||||
|
||||
function validateReachableDeclarations(
|
||||
program: ts.Program,
|
||||
root: string,
|
||||
roots: readonly ts.Node[],
|
||||
): ReadonlyMap<ts.Type, readonly ts.Node[]> {
|
||||
const checker = program.getTypeChecker()
|
||||
const visited = new Set<ts.Node>()
|
||||
const declarations = new Map<ts.SourceFile, ts.Node[]>()
|
||||
const definitions = new Map<ts.Type, ts.Node[]>()
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (visited.has(node)) return
|
||||
visited.add(node)
|
||||
const file = node.getSourceFile()
|
||||
if (trackedSource(slash(relative(root, file.fileName)))) {
|
||||
const scopes = declarations.get(file) ?? []
|
||||
scopes.push(node)
|
||||
declarations.set(file, scopes)
|
||||
if (isNamedTypeDeclaration(node) || ts.isTypeLiteralNode(node)) {
|
||||
// Primitive aliases share checker types with unaliased properties.
|
||||
const type = checker.getTypeAtLocation(node)
|
||||
const entries = definitions.get(type) ?? []
|
||||
entries.push(node)
|
||||
definitions.set(type, entries)
|
||||
}
|
||||
}
|
||||
const target = ts.isTypeReferenceNode(node) ? node.typeName
|
||||
: ts.isExpressionWithTypeArguments(node) ? node.expression
|
||||
: ts.isTypeQueryNode(node) ? node.exprName
|
||||
: ts.isImportTypeNode(node) ? node.qualifier
|
||||
: undefined
|
||||
if (target !== undefined) {
|
||||
let symbol = checker.getSymbolAtLocation(target)
|
||||
if (symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias)) symbol = checker.getAliasedSymbol(symbol)
|
||||
for (const declaration of symbol?.declarations ?? []) {
|
||||
if (trackedSource(slash(relative(root, declaration.getSourceFile().fileName)))) visit(declaration)
|
||||
}
|
||||
}
|
||||
if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) return
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
for (const root of roots) visit(root)
|
||||
const errors = [...declarations].flatMap(([file, scopes]) => program.getSemanticDiagnostics(file)
|
||||
.filter((error) => {
|
||||
const start = error.start
|
||||
return start !== undefined && scopes.some(scope => start >= scope.getStart() && start < scope.end)
|
||||
}))
|
||||
if (errors.length > 0) throw new PersistenceSchemaError(errors.map(diagnosticText).join('\n'))
|
||||
return definitions
|
||||
}
|
||||
|
||||
class SchemaExtractor {
|
||||
readonly nodes: SchemaNode[] = []
|
||||
private readonly cache = new Map<ts.Type, number>()
|
||||
private readonly declarationMetadata = new Map<number, DeclarationMetadata>()
|
||||
|
||||
private readonly checker: ts.TypeChecker
|
||||
|
||||
constructor(
|
||||
private readonly root: string,
|
||||
program: ts.Program,
|
||||
private readonly declarationSources: ReadonlyMap<ts.Type, readonly ts.Node[]>,
|
||||
) {
|
||||
this.checker = program.getTypeChecker()
|
||||
}
|
||||
|
||||
convert(type: ts.Type, site: ts.Node): number {
|
||||
const cached = this.cache.get(type)
|
||||
if (cached !== undefined) {
|
||||
this.record(cached, type)
|
||||
return cached
|
||||
}
|
||||
const id = this.nodes.length
|
||||
this.nodes.push({ kind: 'primitive', type: 'never' })
|
||||
this.cache.set(type, id)
|
||||
this.record(id, type)
|
||||
const add = (node: SchemaNode): number => { this.nodes[id] = node; return id }
|
||||
const flags = type.flags
|
||||
if (flags & ts.TypeFlags.Any) {
|
||||
if ((type as ts.Type & { intrinsicName?: string }).intrinsicName === 'error') this.fail(type, site, 'unresolved compiler type')
|
||||
return add({ kind: 'opaque', reason: 'any' })
|
||||
}
|
||||
if (flags & ts.TypeFlags.Unknown) return add({ kind: 'opaque', reason: 'unknown' })
|
||||
if (flags & ts.TypeFlags.Never) return add({ kind: 'primitive', type: 'never' })
|
||||
if (flags & ts.TypeFlags.Null) return add({ kind: 'primitive', type: 'null' })
|
||||
if (flags & ts.TypeFlags.String) return add({ kind: 'primitive', type: 'string' })
|
||||
if (flags & ts.TypeFlags.Number) return add({ kind: 'primitive', type: 'number' })
|
||||
if (flags & ts.TypeFlags.Boolean) return add({ kind: 'primitive', type: 'boolean' })
|
||||
if (flags & ts.TypeFlags.StringLiteral) return add({ kind: 'literal', value: (type as ts.StringLiteralType).value })
|
||||
if (flags & ts.TypeFlags.NumberLiteral) return add({ kind: 'literal', value: (type as ts.NumberLiteralType).value })
|
||||
if (flags & ts.TypeFlags.BooleanLiteral) return add({ kind: 'literal', value: this.checker.typeToString(type) === 'true' })
|
||||
if (type.isUnion()) return add(this.unionNode(type.types.map(member => this.convert(member, site))))
|
||||
if (type.isIntersection()) {
|
||||
const material = type.types.filter(member => !this.phantom(member))
|
||||
for (const member of material) this.rejectClass(member, site)
|
||||
if (material.length === 1) {
|
||||
const target = this.convert(material[0] as ts.Type, site)
|
||||
return add({ kind: 'union', types: [target] })
|
||||
}
|
||||
if (material.length === 0 || material.some(member => (member.flags & ts.TypeFlags.Object) === 0)) {
|
||||
this.fail(type, site, 'unsupported material intersection')
|
||||
}
|
||||
return add(this.object(type, site))
|
||||
}
|
||||
if (this.checker.isTupleType(type)) {
|
||||
const reference = type as ts.TypeReference
|
||||
const target = reference.target as ts.TupleType
|
||||
return add({
|
||||
kind: 'tuple',
|
||||
elements: this.checker.getTypeArguments(reference).map((element, index) => {
|
||||
const flags = target.elementFlags[index] ?? ts.ElementFlags.Required
|
||||
const optional = (flags & ts.ElementFlags.Optional) !== 0
|
||||
const rest = (flags & (ts.ElementFlags.Rest | ts.ElementFlags.Variadic)) !== 0
|
||||
return { type: this.valueType(element, site, optional), optional, rest }
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) {
|
||||
const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number)
|
||||
if (element === undefined) this.fail(type, site, 'array element is unavailable')
|
||||
return add({ kind: 'array', element: this.convert(element, site) })
|
||||
}
|
||||
if (flags & ts.TypeFlags.Object) return add(this.object(type, site))
|
||||
this.fail(type, site, 'type has no supported JSON representation')
|
||||
}
|
||||
|
||||
private valueType(type: ts.Type, site: ts.Node, optional: boolean): number {
|
||||
if (!optional) return this.convert(type, site)
|
||||
const members = (type.isUnion() ? type.types : [type]).filter(member => !(member.flags & ts.TypeFlags.Undefined))
|
||||
return this.union(members.map(member => this.convert(member, site)))
|
||||
}
|
||||
|
||||
private object(type: ts.Type, site: ts.Node): SchemaNode {
|
||||
this.rejectClass(type, site)
|
||||
if (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0) this.fail(type, site, 'callable data is not JSON')
|
||||
const properties: SchemaProperty[] = []
|
||||
for (const property of this.checker.getPropertiesOfType(type).sort((left, right) => compare(left.name, right.name))) {
|
||||
if (property.getName().startsWith('__@')) this.fail(type, site, 'symbol-keyed data is not a JSON field')
|
||||
const declaration = property.valueDeclaration ?? property.declarations?.[0] ?? site
|
||||
const propertyType = this.checker.getTypeOfSymbolAtLocation(property, declaration)
|
||||
const optional = (property.flags & ts.SymbolFlags.Optional) !== 0
|
||||
if (optional && (propertyType.isUnion() ? propertyType.types : [propertyType])
|
||||
.every(member => (member.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Never)) !== 0)) continue
|
||||
const child = this.valueType(propertyType, declaration, optional)
|
||||
properties.push({ name: property.getName(), type: child, optional })
|
||||
}
|
||||
const indices = this.checker.getIndexInfosOfType(type).map(info => ({
|
||||
key: this.convert(info.keyType, site),
|
||||
value: this.convert(info.type, site),
|
||||
}))
|
||||
if (properties.length === 0 && indices.length === 0) this.fail(type, site, 'unconstrained empty object type is not an explicit JSON record')
|
||||
return { kind: 'object', properties, indices }
|
||||
}
|
||||
|
||||
private rejectClass(type: ts.Type, site: ts.Node): void {
|
||||
const symbol = type.getSymbol()
|
||||
if (symbol?.declarations?.some(declaration => ts.isClassDeclaration(declaration) || ts.isClassExpression(declaration))) {
|
||||
this.fail(type, site, 'class instances require an explicit persisted representation')
|
||||
}
|
||||
}
|
||||
|
||||
private phantom(type: ts.Type): boolean {
|
||||
if (!(type.flags & ts.TypeFlags.Object)) return false
|
||||
const properties = this.checker.getPropertiesOfType(type)
|
||||
return properties.length > 0 && properties.every(property => property.getName().startsWith('__@'))
|
||||
&& type.getCallSignatures().length === 0 && type.getConstructSignatures().length === 0
|
||||
&& this.checker.getIndexInfosOfType(type).length === 0
|
||||
}
|
||||
|
||||
union(types: readonly number[]): number {
|
||||
const node = this.unionNode(types)
|
||||
if (node.kind === 'union' && node.types.length === 1) return node.types[0] as number
|
||||
this.nodes.push(node)
|
||||
return this.nodes.length - 1
|
||||
}
|
||||
|
||||
private unionNode(types: readonly number[]): SchemaNode {
|
||||
if (types.length === 0) return { kind: 'primitive', type: 'never' }
|
||||
return { kind: 'union', types: [...new Set(types)] }
|
||||
}
|
||||
|
||||
envelope(id: number, event: string): number {
|
||||
const node = this.nodes[id] as SchemaNode
|
||||
if (node.kind === 'union') return this.union(node.types.map(type => this.envelope(type, event)))
|
||||
if (node.kind !== 'object') throw new PersistenceSchemaError('persistence schema: SessionEvent must resolve to object alternatives')
|
||||
const tag = node.properties.find(property => property.name === 'type')
|
||||
const data = node.properties.find(property => property.name === 'data')
|
||||
const literal = tag === undefined ? undefined : canonicalizeSchema(this.nodes, tag.type).nodes[0]
|
||||
if (tag?.optional !== false || data?.optional !== false || literal?.kind !== 'literal' || literal.value !== event) {
|
||||
throw new PersistenceSchemaError(`persistence schema: SessionEvent<${JSON.stringify(event)}> must retain its required type and data fields`)
|
||||
}
|
||||
const string = this.nodes.length
|
||||
this.nodes.push({ kind: 'primitive', type: 'string' })
|
||||
const envelope: SchemaNode = {
|
||||
kind: 'object',
|
||||
properties: node.properties.filter(property => property.name !== 'data')
|
||||
.map(property => property.name === 'type' ? { ...property, type: string } : property),
|
||||
indices: node.indices,
|
||||
}
|
||||
this.nodes.push(envelope)
|
||||
return this.nodes.length - 1
|
||||
}
|
||||
|
||||
inventory(inputs: readonly RootInput[]): PersistenceSchemaInventory {
|
||||
const roots = inputs.map((input) => {
|
||||
const schema = canonicalizeSchema(this.nodes, input.node)
|
||||
return {
|
||||
key: input.key,
|
||||
kind: input.kind,
|
||||
...(input.event === undefined ? {} : { event: input.event }),
|
||||
...(input.surface === undefined ? {} : { surface: input.surface }),
|
||||
digest: schemaDigest(schema),
|
||||
schema,
|
||||
}
|
||||
})
|
||||
const found = new Set<number>()
|
||||
const paths = new Map<number, Set<string>>()
|
||||
const visit = (id: number, path: string): void => {
|
||||
const names = paths.get(id) ?? new Set<string>()
|
||||
names.add(path)
|
||||
paths.set(id, names)
|
||||
if (found.has(id)) return
|
||||
found.add(id)
|
||||
const node = this.nodes[id] as SchemaNode
|
||||
if (node.kind === 'object') {
|
||||
for (const property of node.properties) visit(property.type, `${path}.${property.name}`)
|
||||
for (const index of node.indices) { visit(index.key, `${path}.[key]`); visit(index.value, `${path}.[value]`) }
|
||||
} else {
|
||||
for (const [index, child] of schemaChildren(node).entries()) visit(child, `${path}[${String(index)}]`)
|
||||
}
|
||||
}
|
||||
for (const input of inputs) visit(input.node, input.key)
|
||||
const types = new Map<string, { schema: PersistenceType['schema']; names: Set<string>; sources: Set<string> }>()
|
||||
for (const id of found) {
|
||||
const schema = canonicalizeSchema(this.nodes, id)
|
||||
const digest = schemaDigest(schema)
|
||||
const item = types.get(digest) ?? { schema, names: new Set<string>(), sources: new Set<string>() }
|
||||
const declarationMetadata = this.declarationMetadata.get(id)
|
||||
for (const name of declarationMetadata?.names ?? []) item.names.add(name)
|
||||
for (const source of declarationMetadata?.sources ?? []) item.sources.add(source)
|
||||
const kind = schema.nodes[0]?.kind
|
||||
if (kind !== 'primitive' && kind !== 'literal' && (declarationMetadata === undefined || declarationMetadata.names.size === 0)) {
|
||||
for (const path of paths.get(id) ?? []) item.names.add(path)
|
||||
}
|
||||
types.set(digest, item)
|
||||
}
|
||||
return {
|
||||
formatVersion: 1,
|
||||
roots,
|
||||
types: [...types].sort(([left], [right]) => compare(left, right)).map(([digest, item]) => ({
|
||||
digest,
|
||||
schema: item.schema,
|
||||
names: [...item.names].sort(compare),
|
||||
sources: [...item.sources].sort(compare),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
private record(id: number, type: ts.Type): void {
|
||||
const item = this.declarationMetadata.get(id) ?? { names: new Set<string>(), sources: new Set<string>() }
|
||||
const declarations = new Set([
|
||||
...this.declarationSources.get(type) ?? [],
|
||||
...type.aliasSymbol?.declarations ?? [],
|
||||
...type.getSymbol()?.declarations ?? [],
|
||||
])
|
||||
for (const declaration of declarations) {
|
||||
if (!isNamedTypeDeclaration(declaration) && !ts.isTypeLiteralNode(declaration) && !ts.isEnumMember(declaration)) continue
|
||||
const source = declaration.getSourceFile()
|
||||
const file = slash(relative(this.root, source.fileName))
|
||||
if (!trackedSource(file)) continue
|
||||
if (isNamedTypeDeclaration(declaration)) item.names.add(`${file}#${declaration.name.text}`)
|
||||
const position = source.getLineAndCharacterOfPosition(declaration.getStart())
|
||||
item.sources.add(`${file}:${String(position.line + 1)}`)
|
||||
}
|
||||
this.declarationMetadata.set(id, item)
|
||||
}
|
||||
|
||||
private fail(type: ts.Type, site: ts.Node, reason: string): never {
|
||||
const source = slash(relative(this.root, site.getSourceFile().fileName))
|
||||
const position = site.getSourceFile().getLineAndCharacterOfPosition(site.getStart())
|
||||
throw new PersistenceSchemaError(`persistence schema: ${source}:${String(position.line + 1)}: ${reason}: ${this.checker.typeToString(type)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function isNamedTypeDeclaration(node: ts.Node): node is ts.TypeAliasDeclaration | ts.InterfaceDeclaration | ts.EnumDeclaration {
|
||||
return ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node) || ts.isEnumDeclaration(node)
|
||||
}
|
||||
|
||||
function stringLiterals(type: ts.Type, name: string): Set<string> {
|
||||
if (type.flags & ts.TypeFlags.Never) return new Set()
|
||||
const members = type.isUnion() ? type.types : [type]
|
||||
if (members.some(member => !(member.flags & ts.TypeFlags.StringLiteral))) throw new PersistenceSchemaError(`persistence schema: ${name} is not a closed string-literal union`)
|
||||
return new Set(members.map(member => (member as ts.StringLiteralType).value))
|
||||
}
|
||||
|
||||
function diagnosticText(diagnostic: ts.Diagnostic): string {
|
||||
return `persistence schema: TypeScript TS${String(diagnostic.code)}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')}`
|
||||
}
|
||||
|
||||
function compare(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
}
|
||||
|
||||
function slash(path: string): string {
|
||||
return path.split(sep).join('/')
|
||||
}
|
||||
|
||||
function trackedSource(file: string): boolean {
|
||||
return (file.startsWith('packages/') || file.startsWith('vendor/')) && !file.includes('/node_modules/')
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Stable source-file metadata for saved persistence inventories. */
|
||||
|
||||
import type { PersistenceSchemaInventory } from './persistence-schema-model.ts'
|
||||
|
||||
/**
|
||||
* Remove source line positions without changing schemas, names, or fingerprints.
|
||||
* @param inventory - validated inventory whose source metadata will be copied.
|
||||
* @returns an inventory with unique source paths in their original order.
|
||||
*/
|
||||
export function withoutPersistenceSourceLines(inventory: PersistenceSchemaInventory): PersistenceSchemaInventory {
|
||||
return {
|
||||
...inventory,
|
||||
types: inventory.types.map(type => ({
|
||||
...type,
|
||||
sources: [...new Set(type.sources.map(source => source.replace(/(?::\d+(?::\d+)?|#L\d+(?:-L\d+)?)$/u, '')))],
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -450,7 +450,7 @@ describe('docsPages locale routes', () => {
|
||||
const translated = rootPages.filter(page => page.contentLocale === 'zh-CN')
|
||||
const fallbacks = rootPages.filter(page => page.contentLocale === 'en-US')
|
||||
|
||||
expect(translated).toHaveLength(46)
|
||||
expect(translated).toHaveLength(48)
|
||||
expect(translated.every(page => page.source.endsWith('.zh.md'))).toBe(true)
|
||||
expect(fallbacks).toEqual([])
|
||||
})
|
||||
|
||||
+99
-27
@@ -2,15 +2,23 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { once } from 'node:events'
|
||||
import { execa } from 'execa'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const repositoryRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
const runner = fileURLToPath(new URL('./publint-all.ts', import.meta.url))
|
||||
const roots: string[] = []
|
||||
const children: Array<{ kill: () => void; closed: Promise<unknown> }> = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
afterEach(async () => {
|
||||
// A test timeout can reach teardown before the test's pending await settles.
|
||||
const ownedRoots = roots.splice(0)
|
||||
await Promise.all(children.splice(0).map(async ({ kill, closed }) => {
|
||||
kill()
|
||||
await closed
|
||||
}))
|
||||
for (const root of ownedRoots) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function fixture(options: {
|
||||
@@ -42,53 +50,117 @@ function fixture(options: {
|
||||
return root
|
||||
}
|
||||
|
||||
function run(root: string) {
|
||||
return spawnSync(process.execPath, [
|
||||
/** Own direct Node children until close; Vitest's signal supplies the lane deadline. */
|
||||
function start(args: string[], signal: AbortSignal, cwd = repositoryRoot) {
|
||||
const child = execa(process.execPath, args, {
|
||||
cwd,
|
||||
cancelSignal: signal,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
stdin: 'ignore',
|
||||
stripFinalNewline: false,
|
||||
})
|
||||
// `error` is an outcome, not the completion edge for the process and its pipes.
|
||||
const closed = new Promise<void>(resolve => child.nodeChildProcess.once('close', () => { resolve() }))
|
||||
const result = Promise.all([child, closed]).then(([result]) => result)
|
||||
children.push({ kill: () => { child.kill('SIGKILL') }, closed: result })
|
||||
return { child, result }
|
||||
}
|
||||
|
||||
function expectCompleted(result: Awaited<ReturnType<typeof start>['result']>) {
|
||||
const diagnostics = [
|
||||
`publint subprocess: error=${String(result.cause)}; signal=${String(result.signal)}; exitCode=${String(result.exitCode)}`,
|
||||
`canceled=${result.isCanceled}; timedOut=${result.timedOut}`,
|
||||
result.shortMessage ?? '',
|
||||
`stdout:\n${result.stdout}`,
|
||||
`stderr:\n${result.stderr}`,
|
||||
].join('\n')
|
||||
expect(result.cause, diagnostics).toBeUndefined()
|
||||
expect(result.isCanceled, diagnostics).toBe(false)
|
||||
expect(result.timedOut, diagnostics).toBe(false)
|
||||
expect(result.signal, diagnostics).toBeUndefined()
|
||||
}
|
||||
|
||||
async function run(root: string, signal: AbortSignal) {
|
||||
const { result } = start([
|
||||
'--import', 'tsx', runner,
|
||||
'--packages-root', root,
|
||||
], {
|
||||
cwd: repositoryRoot,
|
||||
encoding: 'utf8',
|
||||
timeout: 5_000,
|
||||
})
|
||||
], signal)
|
||||
const completed = await result
|
||||
expectCompleted(completed)
|
||||
return completed
|
||||
}
|
||||
|
||||
describe('publint package runner', () => {
|
||||
it('lints recursively declared files from an in-memory publication view', () => {
|
||||
const result = run(fixture())
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
it('reports deadline cancellation after every owned child closes', async ({ signal }) => {
|
||||
const deadline = new AbortController()
|
||||
const active = [0, 1].map(() => start([
|
||||
'-e', "process.stderr.write('probe stderr\\n'); process.stdout.write('ready\\n'); setInterval(() => {}, 1000)",
|
||||
], AbortSignal.any([signal, deadline.signal])))
|
||||
const closed = active.map(() => false)
|
||||
active.forEach(({ child }, index) => child.nodeChildProcess.once('close', () => { closed[index] = true }))
|
||||
await Promise.all(active.map(({ child }) => once(child.stdout, 'data', { signal })))
|
||||
expect(closed).toEqual([false, false])
|
||||
// Start the deadline only after both children announce readiness; startup speed is not the oracle.
|
||||
const timer = setTimeout(() => { deadline.abort(new DOMException('fixture deadline expired', 'TimeoutError')) }, 0)
|
||||
try {
|
||||
const results = await Promise.all(active.map(({ result }) => result))
|
||||
expect(closed).toEqual([true, true])
|
||||
for (const [index, result] of results.entries()) {
|
||||
expect(result.isCanceled).toBe(true)
|
||||
expect(() => { expectCompleted(result) }).toThrow(/publint subprocess: error=TimeoutError: fixture deadline expired; signal=/)
|
||||
expect(() => { expectCompleted(result) }).toThrow(/canceled=true/)
|
||||
expect(() => { expectCompleted(result) }).toThrow(/ready/)
|
||||
expect(() => { expectCompleted(result) }).toThrow(/probe stderr/)
|
||||
expect(() => process.kill(active[index]!.child.pid!, 0)).toThrow(/ESRCH/)
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
})
|
||||
|
||||
it('reports spawn errors before checking the expected exit code', async ({ signal }) => {
|
||||
const { result } = start(['-e', ''], signal, join(fixture(), 'missing-cwd'))
|
||||
const completed = await result
|
||||
expect(completed.cause).toMatchObject({ code: 'ENOENT' })
|
||||
expect(() => { expectCompleted(completed) }).toThrow(/publint subprocess: error=.*ENOENT.*; signal=undefined/)
|
||||
})
|
||||
|
||||
it('lints recursively declared files from an in-memory publication view', async ({ signal }) => {
|
||||
const result = await run(fixture(), signal)
|
||||
expect(result.exitCode, result.stderr).toBe(0)
|
||||
expect(result.stdout).toContain('linting 1 package(s)')
|
||||
expect(result.stdout).toContain('All good!')
|
||||
})
|
||||
|
||||
it('rejects an export that exists in the workspace but is not published', () => {
|
||||
const result = run(fixture({ exportPath: './unpublished.js' }))
|
||||
expect(result.status).toBe(1)
|
||||
it('rejects an export that exists in the workspace but is not published', async ({ signal }) => {
|
||||
const result = await run(fixture({ exportPath: './unpublished.js' }), signal)
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stdout).toContain('unpublished.js')
|
||||
})
|
||||
|
||||
it('rejects a public export whose built file is missing', () => {
|
||||
const result = run(fixture({ exportPath: './lib/missing.js' }))
|
||||
expect(result.status).toBe(1)
|
||||
it('rejects a public export whose built file is missing', async ({ signal }) => {
|
||||
const result = await run(fixture({ exportPath: './lib/missing.js' }), signal)
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stdout).toContain('missing.js')
|
||||
})
|
||||
|
||||
it('accepts published relative JavaScript and CSS targets', () => {
|
||||
const result = run(fixture({
|
||||
it('accepts published relative JavaScript and CSS targets', async ({ signal }) => {
|
||||
const result = await run(fixture({
|
||||
indexSource: "export { helper } from './helper.js'\nimport './theme.css'\n",
|
||||
files: {
|
||||
'lib/helper.js': 'export const helper = true\n',
|
||||
'lib/theme.css': ':root {}\n',
|
||||
},
|
||||
}))
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
}), signal)
|
||||
expect(result.exitCode, result.stderr).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects unpublished relative JavaScript and CSS targets', () => {
|
||||
const result = run(fixture({
|
||||
it('rejects unpublished relative JavaScript and CSS targets', async ({ signal }) => {
|
||||
const result = await run(fixture({
|
||||
indexSource: "export { helper } from './missing.js'\nimport './missing.css'\n",
|
||||
}))
|
||||
expect(result.status).toBe(1)
|
||||
}), signal)
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stderr).toContain('imports "./missing.js"')
|
||||
expect(result.stderr).toContain('imports "./missing.css"')
|
||||
})
|
||||
|
||||
@@ -4,7 +4,6 @@ import { spawnSync, type SpawnSyncReturns } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
existsSync,
|
||||
globSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
@@ -18,15 +17,11 @@ import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep
|
||||
import { createInterface } from 'node:readline/promises'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { discoverNpmBaselineManifests } from './npm-baseline-packages.ts'
|
||||
import { validateTarballPayload } from './publication-payload.ts'
|
||||
|
||||
const DEFAULT_REGISTRY = 'https://registry.npm.harnessment.com'
|
||||
const DEFAULT_OUTPUT_DIRECTORY = '.artifacts/npm-baseline'
|
||||
const PACKAGE_PATTERNS = [
|
||||
'vendor/*/package.json',
|
||||
'packages/!(experimental)/*/package.json',
|
||||
'apps/*/package.json',
|
||||
] as const
|
||||
const DEPENDENCY_SECTIONS = [
|
||||
'dependencies',
|
||||
'devDependencies',
|
||||
@@ -242,7 +237,7 @@ class WorkspacePackageSet {
|
||||
) {}
|
||||
|
||||
static discover(root: string): WorkspacePackageSet {
|
||||
const manifestPaths = globSync(PACKAGE_PATTERNS, { cwd: root }).sort()
|
||||
const manifestPaths = discoverNpmBaselineManifests(root)
|
||||
if (manifestPaths.length === 0) {
|
||||
throw new Error('no package manifests found under vendor/, packages/, or apps/')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { getEventListeners } from 'node:events'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { agentEvents, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { WorkflowRunId } from '@deepseek-ai/dsh-workflow'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
|
||||
import * as spawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
|
||||
import { MockAdapter, textResponse } from '../packages/core/agent-loop/tests/mock-adapter.ts'
|
||||
import type {} from '@deepseek-ai/dsh-tool-workflow'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
// @ts-expect-error Scenario plugins are runtime JavaScript without declaration artifacts.
|
||||
import * as fixtureModule from './fixtures/python-snapshot-workflow-order.mjs'
|
||||
|
||||
const config = { parentSessionId: 'advanced-parent', prompt: 'workflow child prompt' }
|
||||
const fixture = fixtureModule as unknown as {
|
||||
name: string
|
||||
apply(ctx: Context, config: { parentSessionId: string; prompt: string }): void
|
||||
}
|
||||
const cleanups: (() => Promise<unknown>)[] = []
|
||||
afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup() })
|
||||
|
||||
async function harness() {
|
||||
const ctx = new Context()
|
||||
const store = ctx.plugin(SessionStore)
|
||||
await store
|
||||
cleanups.push(() => store.dispose())
|
||||
const fiber = ctx.plugin(fixture, config)
|
||||
await fiber
|
||||
cleanups.push(() => fiber.dispose())
|
||||
const parent = ctx.sessions.create(SessionId(config.parentSessionId))
|
||||
const other = ctx.sessions.create(SessionId('other-parent'))
|
||||
const session = ctx.sessions.create(SessionId('workflow-child'), { meta: { parentSession: parent.id } })
|
||||
// The dispatcher needs only the subject identity; the fixture reads its Session.
|
||||
const agent = { id: session.id, session } as Agent
|
||||
const controller = new AbortController()
|
||||
const messages = [createUserMessage({ content: [{ type: 'text', text: config.prompt }], source: { kind: 'user' } })]
|
||||
const decision: PreStepDecision = { kind: 'enter', messages }
|
||||
const next = vi.fn(async () => decision)
|
||||
const start = (owner = parent, childId = agent.id) => owner.append('tool-workflow/agent-start', {
|
||||
runId: WorkflowRunId('run'), seq: 1, label: 'workflow-child', childId,
|
||||
})
|
||||
const step = (overrides = {}) => agentEvents(ctx, agent).waterfall('agent/pre-step', {
|
||||
turn: 1, step: 1, messages, signal: controller.signal, ...overrides,
|
||||
}, next)
|
||||
return { ctx, fiber, parent, other, session, agent, controller, decision, next, start, step }
|
||||
}
|
||||
|
||||
describe('advanced Python snapshot workflow ordering', () => {
|
||||
it('blocks a real spawned child before its descriptor and first model request', async () => {
|
||||
const ctx = new Context()
|
||||
const entered = Promise.withResolvers<Agent>()
|
||||
const order: string[] = []
|
||||
const adapter = new MockAdapter([textResponse('child complete')])
|
||||
const assembly = ctx.plugin({
|
||||
name: 'workflow-order-driver-test',
|
||||
async apply(inner: Context) {
|
||||
await mountAgentLoopTestDependencies(inner)
|
||||
await inner.plugin(AgentLoop, { agents: [] })
|
||||
await inner.plugin(SubagentRuntime)
|
||||
await inner.plugin(spawn, { providerName: 'spawn' })
|
||||
inner.on('agent/pre-step', ({ agent }, next) => {
|
||||
if (agent.session.header.parentSession === config.parentSessionId) entered.resolve(agent)
|
||||
return next()
|
||||
})
|
||||
inner.on('session/event', (_session, event) => {
|
||||
if (event.type === 'tool-workflow/agent-start' || event.type === 'subagent/descriptor') order.push(event.type)
|
||||
})
|
||||
await inner.plugin(fixture, config)
|
||||
},
|
||||
})
|
||||
cleanups.push(() => assembly.dispose())
|
||||
await assembly
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const parent = await ctx.agentLoop.create(SessionId(config.parentSessionId), { provider: 'mock', model: 'mock' })
|
||||
const run = await ctx.subagents.start('spawn', {
|
||||
parent, prompt: [{ type: 'text', text: config.prompt }], signal: new AbortController().signal,
|
||||
})
|
||||
cleanups.push(() => run.dispose())
|
||||
const child = await entered.promise
|
||||
expect(child.id).toBe(run.id)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(child.session.snapshotEvents().some(event => event.type === 'subagent/descriptor')).toBe(false)
|
||||
parent.session.append('tool-workflow/agent-start', {
|
||||
runId: WorkflowRunId('run'), seq: 1, label: 'workflow-child', childId: child.id,
|
||||
})
|
||||
expect((await run.result).output).toEqual([{ type: 'text', text: 'child complete' }])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(order).toEqual(['tool-workflow/agent-start', 'subagent/descriptor'])
|
||||
})
|
||||
|
||||
it('holds the child until the exact parent records the exact member', async () => {
|
||||
const h = await harness()
|
||||
const pending = h.step()
|
||||
expect(h.next).not.toHaveBeenCalled()
|
||||
expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(1)
|
||||
h.start(h.other)
|
||||
h.start(h.parent, SessionId('other-child'))
|
||||
h.parent.append('tool-workflow/run-start', { runId: WorkflowRunId('run'), name: 'workflow' })
|
||||
await Promise.resolve()
|
||||
expect(h.next).not.toHaveBeenCalled()
|
||||
h.start()
|
||||
expect(await pending).toBe(h.decision)
|
||||
expect(h.next).toHaveBeenCalledOnce()
|
||||
expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('retains a start recorded before the child reaches its first step', async () => {
|
||||
const h = await harness()
|
||||
h.start()
|
||||
expect(await h.step()).toBe(h.decision)
|
||||
expect(h.next).toHaveBeenCalledOnce()
|
||||
expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each(['prompt', 'parent', 'turn', 'step'])('does not hold an unrelated %s', async (difference) => {
|
||||
const h = await harness()
|
||||
const overrides = difference === 'prompt' ? { messages: [] }
|
||||
: difference === 'turn' ? { turn: 2 }
|
||||
: difference === 'step' ? { step: 2 } : {}
|
||||
if (difference === 'parent') {
|
||||
const session = h.ctx.sessions.create(SessionId('unrelated-child'), { meta: { parentSession: h.other.id } })
|
||||
Object.assign(h.agent, { session })
|
||||
}
|
||||
expect(await h.step(overrides)).toBe(h.decision)
|
||||
expect(h.next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([false, true])('rejects cancellation and detaches the waiter (already aborted: %s)', async (alreadyAborted) => {
|
||||
const h = await harness()
|
||||
const reason = new Error('cancelled child')
|
||||
if (alreadyAborted) h.controller.abort(reason)
|
||||
const pending = h.step()
|
||||
const rejected = expect(pending).rejects.toBe(reason)
|
||||
h.controller.abort(reason)
|
||||
await rejected
|
||||
h.start()
|
||||
expect(h.next).not.toHaveBeenCalled()
|
||||
expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not admit a cancelled child when start and cancellation share a tick', async () => {
|
||||
const h = await harness()
|
||||
const reason = new Error('cancelled after membership')
|
||||
const pending = h.step()
|
||||
const rejected = expect(pending).rejects.toBe(reason)
|
||||
h.start()
|
||||
h.controller.abort(reason)
|
||||
await rejected
|
||||
expect(h.next).not.toHaveBeenCalled()
|
||||
expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('settles pending waits before disposal completes and removes both listeners', async () => {
|
||||
const h = await harness()
|
||||
const pending = h.step()
|
||||
const rejected = expect(pending).rejects.toThrow('workflow snapshot barrier disposed')
|
||||
await h.fiber.dispose()
|
||||
await rejected
|
||||
h.start()
|
||||
expect(h.next).not.toHaveBeenCalled()
|
||||
expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(0)
|
||||
expect(await h.step()).toBe(h.decision)
|
||||
})
|
||||
})
|
||||
@@ -42,11 +42,29 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('release families', () => {
|
||||
it('excludes private experimental packages from the dsh release', () => {
|
||||
it('publishes all current experimental packages', () => {
|
||||
const members = releaseFamily('dsh').members(resolve(import.meta.dirname, '../..'))
|
||||
|
||||
expect(members.some(member => member.directory.startsWith('packages/experimental/'))).toBe(false)
|
||||
expect(members.map(member => member.name)).not.toContain('@deepseek-ai/dsh-experimental-agent-team')
|
||||
expect(members
|
||||
.filter(member => member.directory.startsWith('packages/experimental/'))
|
||||
.map(member => member.name)).toEqual([
|
||||
'@deepseek-ai/dsh-experimental-agent-team-profile',
|
||||
'@deepseek-ai/dsh-experimental-agent-team-web-profile',
|
||||
'@deepseek-ai/dsh-experimental-agent-team',
|
||||
'@deepseek-ai/dsh-experimental-auto-review',
|
||||
'@deepseek-ai/dsh-experimental-browser-use-chrome-devtools-mcp',
|
||||
'@deepseek-ai/dsh-experimental-browser-use-playwright-mcp',
|
||||
'@deepseek-ai/dsh-experimental-browser-use-runtime',
|
||||
'@deepseek-ai/dsh-experimental-browser-use-stagehand-native',
|
||||
'@deepseek-ai/dsh-experimental-client-ui-agent-team',
|
||||
'@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp',
|
||||
'@deepseek-ai/dsh-experimental-computer-use-cua-driver-native',
|
||||
'@deepseek-ai/dsh-experimental-inspector',
|
||||
'@deepseek-ai/dsh-experimental-ptc-runtime-python',
|
||||
'@deepseek-ai/dsh-experimental-tool-agent-team',
|
||||
'@deepseek-ai/dsh-experimental-webworker-packer',
|
||||
'@deepseek-ai/dsh-experimental-webworker-runtime',
|
||||
])
|
||||
})
|
||||
|
||||
it('excludes private applications from the publish set', () => {
|
||||
@@ -58,6 +76,25 @@ describe('release families', () => {
|
||||
expect(releaseFamily('dsh').members(root).map(entry => entry.name)).toEqual(['@deepseek-ai/dsh-public'])
|
||||
})
|
||||
|
||||
it('publishes unlisted experimental packages while retaining private exclusions', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-release-experimental-'))
|
||||
roots.push(root)
|
||||
write(join(root, 'packages/experimental/prototype/package.json'), JSON.stringify({
|
||||
name: '@deepseek-ai/dsh-experimental-prototype',
|
||||
version: '0.0.1',
|
||||
publishConfig: { access: 'public' },
|
||||
}))
|
||||
write(join(root, 'packages/experimental/inspector/package.json'), JSON.stringify({
|
||||
name: '@deepseek-ai/dsh-experimental-inspector',
|
||||
version: '0.0.1',
|
||||
private: true,
|
||||
}))
|
||||
|
||||
expect(releaseFamily('dsh').members(root).map(entry => entry.name)).toEqual([
|
||||
'@deepseek-ai/dsh-experimental-prototype',
|
||||
])
|
||||
})
|
||||
|
||||
it('bumps private dsh workspaces without adding release tags', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-release-version-'))
|
||||
roots.push(root)
|
||||
|
||||
@@ -321,7 +321,10 @@ export abstract class ReleaseFamily {
|
||||
/** Release packages and apps: one shared version across the whole family. */
|
||||
class DshFamily extends ReleaseFamily {
|
||||
readonly id = 'dsh'
|
||||
readonly patterns = ['packages/!(experimental)/*/package.json', 'apps/*/package.json'] as const
|
||||
readonly patterns = [
|
||||
'packages/*/*/package.json',
|
||||
'apps/*/package.json',
|
||||
] as const
|
||||
readonly tagPrefix = 'dsh-v'
|
||||
|
||||
/** Require current artifacts from a complete official client build. */
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/** Installed-product isolation uses resolved package identities and runtime edges. */
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { verifyInstalledProductIsolation } from './installed-product-isolation.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
const experimental = '@deepseek-ai/dsh-experimental-example'
|
||||
|
||||
function fixture(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-installed-isolation-'))
|
||||
roots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function writePackage(root: string, key: string, manifest: Record<string, unknown>): string {
|
||||
const directory = join(root, 'node_modules', key)
|
||||
mkdirSync(directory, { recursive: true })
|
||||
writeFileSync(join(directory, 'package.json'), `${JSON.stringify({ name: key, ...manifest })}\n`)
|
||||
return directory
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('installed default-product isolation', () => {
|
||||
it('ignores development dependencies and unrelated installed experimental packages', () => {
|
||||
const root = fixture()
|
||||
const entry = writePackage(root, '@deepseek-ai/dsh', {
|
||||
dependencies: { core: '1.0.0' },
|
||||
devDependencies: { [experimental]: '1.0.0' },
|
||||
})
|
||||
writePackage(root, 'core', { peerDependencies: { '@deepseek-ai/dsh': '1.0.0' } })
|
||||
writePackage(root, experimental, {})
|
||||
|
||||
expect(verifyInstalledProductIsolation(entry)).toBe(2)
|
||||
})
|
||||
|
||||
it.each(['dependencies', 'optionalDependencies', 'peerDependencies'])(
|
||||
'rejects a transitive experimental %s',
|
||||
(section) => {
|
||||
const root = fixture()
|
||||
const entry = writePackage(root, '@deepseek-ai/dsh', { dependencies: { thirdParty: '1.0.0' } })
|
||||
writePackage(root, 'thirdParty', { [section]: { [experimental]: '1.0.0' } })
|
||||
|
||||
expect(() => verifyInstalledProductIsolation(entry)).toThrow(
|
||||
`@deepseek-ai/dsh -> thirdParty -> ${experimental}`,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
it('rejects experimental identities hidden behind an installed alias', () => {
|
||||
const root = fixture()
|
||||
const entry = writePackage(root, '@deepseek-ai/dsh', { dependencies: { safeName: 'file:../prototype' } })
|
||||
writePackage(root, 'safeName', { name: experimental })
|
||||
|
||||
expect(() => verifyInstalledProductIsolation(entry)).toThrow(experimental)
|
||||
})
|
||||
|
||||
it('rejects experimental npm aliases even when an optional package is omitted', () => {
|
||||
const root = fixture()
|
||||
const entry = writePackage(root, '@deepseek-ai/dsh', {
|
||||
optionalDependencies: { safeName: `npm:${experimental}@1.0.0` },
|
||||
})
|
||||
|
||||
expect(() => verifyInstalledProductIsolation(entry)).toThrow(experimental)
|
||||
})
|
||||
|
||||
it('follows nested installations rather than an unrelated hoisted package', () => {
|
||||
const root = fixture()
|
||||
const entry = writePackage(root, '@deepseek-ai/dsh', { dependencies: { core: '1.0.0' } })
|
||||
writePackage(root, 'core', {})
|
||||
writePackage(entry, 'core', { dependencies: { [experimental]: '1.0.0' } })
|
||||
|
||||
expect(() => verifyInstalledProductIsolation(entry)).toThrow(experimental)
|
||||
})
|
||||
|
||||
it('permits missing optional packages and optional peers but rejects missing dependencies', () => {
|
||||
const root = fixture()
|
||||
const entry = writePackage(root, '@deepseek-ai/dsh', {
|
||||
dependencies: { optional: '1.0.0' },
|
||||
optionalDependencies: { optional: '1.0.0' },
|
||||
peerDependencies: { peer: '1.0.0' },
|
||||
peerDependenciesMeta: { peer: { optional: true } },
|
||||
})
|
||||
expect(verifyInstalledProductIsolation(entry)).toBe(1)
|
||||
writePackage(root, '@deepseek-ai/dsh', { dependencies: { missing: '1.0.0' } })
|
||||
expect(() => verifyInstalledProductIsolation(entry)).toThrow('dependency is missing')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
/** Experimental-package isolation for the installed default product's dependency graph. */
|
||||
|
||||
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join } from 'node:path'
|
||||
|
||||
interface InstalledManifest {
|
||||
name: string
|
||||
dependencies?: Record<string, string>
|
||||
optionalDependencies?: Record<string, string>
|
||||
peerDependencies?: Record<string, string>
|
||||
peerDependenciesMeta?: Record<string, { optional?: boolean }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject experimental dependencies reachable from one installed product entry.
|
||||
* Other packages installed beside the entry and development dependencies do not join its graph.
|
||||
* @param directory - installed entry package directory.
|
||||
* @returns number of distinct installed packages visited.
|
||||
*/
|
||||
export function verifyInstalledProductIsolation(directory: string): number {
|
||||
const visited = new Set<string>()
|
||||
const queue = [{ directory, chain: [] as string[] }]
|
||||
for (const item of queue) {
|
||||
const canonical = realpathSync(item.directory)
|
||||
if (visited.has(canonical)) continue
|
||||
visited.add(canonical)
|
||||
const manifest = JSON.parse(readFileSync(join(canonical, 'package.json'), 'utf8')) as InstalledManifest
|
||||
const chain = [...item.chain, manifest.name]
|
||||
rejectExperimental(manifest.name, chain)
|
||||
for (const section of ['dependencies', 'optionalDependencies', 'peerDependencies'] as const) {
|
||||
for (const [name, range] of Object.entries(manifest[section] ?? {})) {
|
||||
rejectExperimental(name, [...chain, name])
|
||||
if (range.startsWith('npm:')) {
|
||||
rejectExperimental(range.slice(4), [...chain, `${name} (${range})`])
|
||||
}
|
||||
const dependency = installedPackage(canonical, name)
|
||||
if (dependency === undefined) {
|
||||
if (section === 'optionalDependencies'
|
||||
|| Object.hasOwn(manifest.optionalDependencies ?? {}, name)
|
||||
|| section === 'peerDependencies' && manifest.peerDependenciesMeta?.[name]?.optional === true) continue
|
||||
throw new Error(`default product dependency is missing: ${[...chain, name].join(' -> ')}`)
|
||||
}
|
||||
queue.push({ directory: dependency, chain })
|
||||
}
|
||||
}
|
||||
}
|
||||
return visited.size
|
||||
}
|
||||
|
||||
function rejectExperimental(name: string, chain: readonly string[]): void {
|
||||
if (name.startsWith('@deepseek-ai/dsh-experimental-')) {
|
||||
throw new Error(`default product includes an experimental package: ${chain.join(' -> ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a dependency directory through the installed package's ancestor node_modules. */
|
||||
function installedPackage(from: string, name: string): string | undefined {
|
||||
const resolver = createRequire(join(from, 'package.json'))
|
||||
for (const directory of resolver.resolve.paths(name) ?? []) {
|
||||
const candidate = join(directory, name)
|
||||
if (existsSync(join(candidate, 'package.json'))) return candidate
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { parseArgs } from 'node:util'
|
||||
import { releaseFamily } from './families.ts'
|
||||
import { capture, isEntry } from './process.ts'
|
||||
import { packedIdentity } from './tarball.ts'
|
||||
import { verifyInstalledProductIsolation } from './installed-product-isolation.ts'
|
||||
|
||||
/**
|
||||
* Environment for the installed artifact: no host Node hooks, no host DeepSeek
|
||||
@@ -104,9 +105,12 @@ function main(): void {
|
||||
// that cannot install them must still start — which is what optional means
|
||||
// here. Their entry package is a plain dependency of dsh-sandbox-local, so
|
||||
// its tarball is supplied through --from.
|
||||
capture('npm', ['install', '--no-audit', '--no-fund', '--package-lock=false', '--omit=optional'],
|
||||
capture('npm', ['install', '--no-audit', '--no-fund', '--package-lock=false', '--omit=optional', '--loglevel=http'],
|
||||
{ cwd: consumerRoot, env: environment })
|
||||
|
||||
const installedEntry = join(consumerRoot, 'node_modules', entry.packageName)
|
||||
const packageCount = verifyInstalledProductIsolation(installedEntry)
|
||||
console.log(`release verify-packed-install: ${String(packageCount)} default-product packages exclude experimental packages`)
|
||||
const bin = join(consumerRoot, 'node_modules', ...entry.packageName.split('/'), entry.binPath)
|
||||
const version = capture(process.execPath, [bin, '--version'], { cwd: consumerRoot, env: environment })
|
||||
if (version !== expected.version) {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/** Linked catalog regressions for transitive changes and shared definitions. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canonicalizeSchema, schemaDigest, type PersistenceSchemaInventory, type PersistenceType, type SchemaNode } from './persistence-schema-model.ts'
|
||||
import { renderPersistenceSchemaDefinitions, renderPersistenceSchemaIndex } from './render-persistence-schema.ts'
|
||||
|
||||
function fixture(optional = true): PersistenceSchemaInventory {
|
||||
const types = new Map<string, PersistenceType>()
|
||||
const roots = ['first/event', 'second/event'].map((event) => {
|
||||
const nodes: SchemaNode[] = [
|
||||
{ kind: 'object', properties: [{ name: 'type', type: 1, optional: false }, { name: 'data', type: 2, optional: false }], indices: [] },
|
||||
{ kind: 'literal', value: event },
|
||||
{ kind: 'object', properties: [{ name: 'error', type: 3, optional: true }], indices: [] },
|
||||
{ kind: 'object', properties: [{ name: 'reason', type: 4, optional }], indices: [] },
|
||||
{ kind: 'primitive', type: 'string' },
|
||||
]
|
||||
nodes.forEach((_, index) => {
|
||||
const schema = canonicalizeSchema(nodes, index)
|
||||
const digest = schemaDigest(schema)
|
||||
if (!types.has(digest)) types.set(digest, { digest, schema, names: index === 3 ? ['ErrorInfo'] : [], sources: index === 3 ? ['packages/core/example/src/types.ts:8'] : [] })
|
||||
})
|
||||
const schema = canonicalizeSchema(nodes, 0)
|
||||
return { key: `event:${event}`, kind: 'event' as const, event, surface: false, digest: schemaDigest(schema), schema }
|
||||
})
|
||||
return { formatVersion: 1, roots, types: [...types.values()] }
|
||||
}
|
||||
|
||||
describe('persistence schema catalog', () => {
|
||||
it('shows a shared nested definition once and updates both affected root fingerprints', () => {
|
||||
const before = fixture()
|
||||
const after = fixture(false)
|
||||
const definitions = renderPersistenceSchemaDefinitions(after)
|
||||
expect(definitions.match(/### `ErrorInfo`/gu)).toHaveLength(1)
|
||||
expect(definitions).toContain('| `reason` | required | `string` |')
|
||||
expect(definitions).toContain('[`ErrorInfo`](#persistence-type-errorinfo)')
|
||||
const index = renderPersistenceSchemaIndex(after)
|
||||
for (const [position, root] of after.roots.entries()) {
|
||||
expect(root.digest).not.toBe(before.roots[position]?.digest)
|
||||
expect(index).toContain(root.digest)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps named definition anchors when their digest changes', () => {
|
||||
const before = renderPersistenceSchemaDefinitions(fixture())
|
||||
const after = renderPersistenceSchemaDefinitions(fixture(false))
|
||||
expect(before).toContain('<a id="persistence-type-errorinfo"></a>')
|
||||
expect(after).toContain('<a id="persistence-type-errorinfo"></a>')
|
||||
expect(before).not.toBe(after)
|
||||
})
|
||||
|
||||
it('renders historical source paths and nested headings within their reference section', () => {
|
||||
const current = fixture()
|
||||
const inventory = { ...current, types: current.types.map(type => ({ ...type, sources: type.sources.map(source => source.replace(/:\d+$/u, '')) })) }
|
||||
const definitions = renderPersistenceSchemaDefinitions(inventory, 'en', () => undefined, 3)
|
||||
expect(definitions).toContain('Sources: `packages/core/example/src/types.ts`')
|
||||
expect(definitions).not.toContain('types.ts:8')
|
||||
expect(definitions).not.toContain('(../packages/')
|
||||
expect(definitions).toContain('### Resolved persistence types')
|
||||
expect(definitions).toContain('#### `ErrorInfo`')
|
||||
const index = renderPersistenceSchemaIndex(inventory, 'en', ['[Historical schema](v0.schema.json)'], 3)
|
||||
expect(index).toContain('[Historical schema](v0.schema.json)')
|
||||
expect(index).toContain('### Persistence type fingerprints')
|
||||
expect(index).not.toContain('(persistence-schema.json)')
|
||||
expect(renderPersistenceSchemaDefinitions(current)).toContain('[`packages/core/example/src/types.ts:8`](../packages/core/example/src/types.ts)')
|
||||
})
|
||||
|
||||
it('refuses a current inventory that omits a referenced definition', () => {
|
||||
const complete = fixture()
|
||||
const missing: PersistenceSchemaInventory = { ...complete, types: [] }
|
||||
expect(() => renderPersistenceSchemaIndex(missing)).toThrow(/absent from the inventory/u)
|
||||
})
|
||||
|
||||
it('renders a recursive definition through a finite self-reference', () => {
|
||||
const schema = canonicalizeSchema([{ kind: 'object', properties: [{ name: 'next', type: 0, optional: true }], indices: [] }], 0)
|
||||
const digest = schemaDigest(schema)
|
||||
const inventory: PersistenceSchemaInventory = {
|
||||
formatVersion: 1,
|
||||
roots: [{ key: 'SessionHeader', kind: 'header', digest, schema }],
|
||||
types: [{ digest, schema, names: ['Recursive'], sources: [] }],
|
||||
}
|
||||
expect(renderPersistenceSchemaDefinitions(inventory)).toContain('| `next` | optional | [`Recursive`](#persistence-type-recursive) |')
|
||||
})
|
||||
|
||||
it('uses intrinsic scalar labels even when an alias or reference supplied a name', () => {
|
||||
const schema = canonicalizeSchema([{ kind: 'primitive', type: 'string' }], 0)
|
||||
const digest = schemaDigest(schema)
|
||||
const inventory: PersistenceSchemaInventory = {
|
||||
formatVersion: 1,
|
||||
roots: [],
|
||||
types: [{ digest, schema, names: ['SessionHeader.agentPreset'], sources: [] }],
|
||||
}
|
||||
const rendered = renderPersistenceSchemaDefinitions(inventory)
|
||||
expect(rendered).toContain('### `string`')
|
||||
expect(rendered).not.toContain('### `SessionHeader.agentPreset`')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
/** Readable, linked persistence schemas rendered from the fingerprint inventory. */
|
||||
|
||||
import { githubSlug } from './verify-md-links.ts'
|
||||
import { persistenceCatalogText, type PersistenceCatalogLocale } from './persistence-catalog-text.ts'
|
||||
import {
|
||||
canonicalizeSchema,
|
||||
schemaChildren,
|
||||
schemaDigest,
|
||||
type CanonicalSchema,
|
||||
type PersistenceSchemaInventory,
|
||||
type PersistenceType,
|
||||
type SchemaNode,
|
||||
} from './persistence-schema-model.ts'
|
||||
|
||||
interface TypeDisplay {
|
||||
readonly type: PersistenceType
|
||||
readonly label: string
|
||||
readonly anchor: string
|
||||
}
|
||||
|
||||
function code(text: string): string {
|
||||
return '`' + text.replaceAll('`', '\\`').replaceAll('|', '\\|') + '`'
|
||||
}
|
||||
|
||||
function sourcePath(source: string): string {
|
||||
return source.replace(/:\d+(?::\d+)?$/u, '')
|
||||
}
|
||||
|
||||
function nodeAt(schema: CanonicalSchema, index: number): SchemaNode {
|
||||
const node = schema.nodes[index]
|
||||
if (!node) throw new Error(`persistence catalog: missing schema node ${String(index)}`)
|
||||
return node
|
||||
}
|
||||
|
||||
function childPath(node: SchemaNode, position: number): string {
|
||||
switch (node.kind) {
|
||||
case 'object': return node.properties[position]?.name ?? `index-${String(position - node.properties.length)}`
|
||||
case 'array': return 'item'
|
||||
case 'tuple': return `item-${String(position)}`
|
||||
case 'union': return `variant-${String(position)}`
|
||||
case 'primitive':
|
||||
case 'literal':
|
||||
case 'opaque': return String(position)
|
||||
default: return assertNever(node)
|
||||
}
|
||||
}
|
||||
|
||||
function displays(inventory: PersistenceSchemaInventory): Map<string, TypeDisplay> {
|
||||
const paths = new Map<string, string>()
|
||||
for (const root of inventory.roots) {
|
||||
const seen = new Set<number>()
|
||||
const visit = (index: number, path: string): void => {
|
||||
if (seen.has(index)) return
|
||||
seen.add(index)
|
||||
const digest = schemaDigest(canonicalizeSchema(root.schema.nodes, index))
|
||||
if (!paths.has(digest)) paths.set(digest, path)
|
||||
const node = nodeAt(root.schema, index)
|
||||
schemaChildren(node).forEach((child, position) => { visit(child, `${path}.${childPath(node, position)}`) })
|
||||
}
|
||||
visit(0, root.key)
|
||||
}
|
||||
const labels = inventory.types.map((type) => {
|
||||
const node = nodeAt(type.schema, 0)
|
||||
const label = node.kind === 'primitive' ? node.type
|
||||
: node.kind === 'literal' ? JSON.stringify(node.value)
|
||||
: node.kind === 'opaque' ? node.reason
|
||||
: type.names[0] ?? paths.get(type.digest) ?? node.kind
|
||||
return { type, label }
|
||||
})
|
||||
const counts = new Map<string, number>()
|
||||
for (const { label } of labels) counts.set(label, (counts.get(label) ?? 0) + 1)
|
||||
const used = new Set<string>()
|
||||
return new Map(labels.map(({ type, label: original }) => {
|
||||
const label = (counts.get(original) ?? 0) > 1
|
||||
? `${original} (${type.sources[0] ? sourcePath(type.sources[0]) : paths.get(type.digest) ?? 'anonymous'})`
|
||||
: original
|
||||
const base = `persistence-type-${githubSlug(label)}`
|
||||
let anchor = base
|
||||
for (let suffix = 2; used.has(anchor); suffix += 1) anchor = `${base}-${String(suffix)}`
|
||||
used.add(anchor)
|
||||
return [type.digest, { type, label, anchor }]
|
||||
}))
|
||||
}
|
||||
|
||||
function reference(digest: string, entries: ReadonlyMap<string, TypeDisplay>): string {
|
||||
const entry = entries.get(digest)
|
||||
if (!entry) throw new Error(`persistence catalog: reachable type ${digest} is absent from the inventory`)
|
||||
return `[${code(entry.label)}](#${entry.anchor})`
|
||||
}
|
||||
|
||||
function typeExpression(
|
||||
schema: CanonicalSchema,
|
||||
index: number,
|
||||
entries: ReadonlyMap<string, TypeDisplay>,
|
||||
locale: PersistenceCatalogLocale,
|
||||
): string {
|
||||
const node = nodeAt(schema, index)
|
||||
if (node.kind === 'primitive') return code(node.type)
|
||||
if (node.kind === 'literal') return code(JSON.stringify(node.value))
|
||||
if (node.kind === 'opaque') return `${code(node.reason)}${persistenceCatalogText[locale].opaque}`
|
||||
return reference(schemaDigest(canonicalizeSchema(schema.nodes, index)), entries)
|
||||
}
|
||||
|
||||
function definition(
|
||||
entry: TypeDisplay,
|
||||
entries: ReadonlyMap<string, TypeDisplay>,
|
||||
locale: PersistenceCatalogLocale,
|
||||
sourceLink: (source: string) => string | undefined,
|
||||
headingLevel: number,
|
||||
): string[] {
|
||||
const text = persistenceCatalogText[locale]
|
||||
const schema = entry.type.schema
|
||||
const node = nodeAt(schema, 0)
|
||||
const lines = [`<a id="${entry.anchor}"></a>`, '', `${'#'.repeat(headingLevel)} ${code(entry.label)}`, '', `SHA-256: ${code(entry.type.digest)}`, '']
|
||||
if (entry.type.sources.length > 0) {
|
||||
lines.push(`${text.sources}${entry.type.sources.map((source) => {
|
||||
const href = sourceLink(sourcePath(source))
|
||||
return href === undefined ? code(source) : `[${code(source)}](${href})`
|
||||
}).join(' · ')}`, '')
|
||||
}
|
||||
const expression = (index: number): string => typeExpression(schema, index, entries, locale)
|
||||
switch (node.kind) {
|
||||
case 'object':
|
||||
if (node.properties.length === 0 && node.indices.length === 0) lines.push(text.emptyObject, '')
|
||||
else {
|
||||
lines.push(text.propertyColumns, '|---|---|---|')
|
||||
for (const property of node.properties) {
|
||||
lines.push(`| ${code(property.name)} | ${property.optional ? text.optional : text.required} | ${expression(property.type)} |`)
|
||||
}
|
||||
for (const index of node.indices) lines.push(`| [${expression(index.key)}] | ${text.index} | ${expression(index.value)} |`)
|
||||
lines.push('')
|
||||
}
|
||||
break
|
||||
case 'array': lines.push(`${text.arrayPrefix}${expression(node.element)}${text.arraySuffix}`, ''); break
|
||||
case 'tuple':
|
||||
lines.push(text.positionColumns, '|---|---|---|')
|
||||
node.elements.forEach((element, index) => {
|
||||
const presence = element.rest ? text.rest : element.optional ? text.optional : text.required
|
||||
lines.push(`| ${String(index)} | ${presence} | ${expression(element.type)} |`)
|
||||
})
|
||||
lines.push('')
|
||||
break
|
||||
case 'union':
|
||||
lines.push(text.oneOf, '', ...node.types.map(index => `- ${expression(index)}`), '')
|
||||
break
|
||||
case 'primitive': lines.push(code(node.type), ''); break
|
||||
case 'literal': lines.push(code(JSON.stringify(node.value)), ''); break
|
||||
case 'opaque': lines.push(`${code(node.reason)}${text.opaqueExplanation}`, ''); break
|
||||
default: assertNever(node)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Render every tracked root with its exact digest and resolved type reference.
|
||||
* @param inventory - complete current-source schemas and declaration metadata.
|
||||
* @param locale - generated document language.
|
||||
* @param introduction - paragraphs before the root table; defaults to current-source links.
|
||||
* @param headingLevel - section depth within the containing reference.
|
||||
* @returns Markdown index including the history and contributor workflow links.
|
||||
*/
|
||||
export function renderPersistenceSchemaIndex(
|
||||
inventory: PersistenceSchemaInventory,
|
||||
locale: PersistenceCatalogLocale = 'en',
|
||||
introduction: readonly string[] = [persistenceCatalogText[locale].fingerprintsIntro, persistenceCatalogText[locale].historyIntro],
|
||||
headingLevel: 2 | 3 = 2,
|
||||
): string {
|
||||
const entries = displays(inventory)
|
||||
const text = persistenceCatalogText[locale]
|
||||
return [
|
||||
`${'#'.repeat(headingLevel)} ${text.fingerprints}`, '', ...introduction.flatMap(paragraph => [paragraph, '']),
|
||||
text.rootColumns, '|---|---|---|---|',
|
||||
...inventory.roots.map(root => `| ${code(root.key)} | ${root.kind} | ${code(root.digest)} | ${reference(root.digest, entries)} |`), '',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render every reachable type once, with links for shared and recursive definitions.
|
||||
* @param inventory - complete current-source schemas and declaration metadata.
|
||||
* @param locale - generated document language.
|
||||
* @param sourceLink - source path to URL; undefined keeps historical locations as text.
|
||||
* @param headingLevel - section depth; individual definitions use the next heading level.
|
||||
* @returns Markdown definitions whose anchors use names or owning paths instead of hashes.
|
||||
*/
|
||||
export function renderPersistenceSchemaDefinitions(
|
||||
inventory: PersistenceSchemaInventory,
|
||||
locale: PersistenceCatalogLocale = 'en',
|
||||
sourceLink: (source: string) => string | undefined = source => `../${source}`,
|
||||
headingLevel: 2 | 3 = 2,
|
||||
): string {
|
||||
const entries = displays(inventory)
|
||||
const text = persistenceCatalogText[locale]
|
||||
const sorted = [...entries.values()].sort((left, right) => left.anchor < right.anchor ? -1 : left.anchor > right.anchor ? 1 : 0)
|
||||
return [
|
||||
`${'#'.repeat(headingLevel)} ${text.definitions}`, '', text.definitionsIntro, '',
|
||||
...sorted.flatMap(entry => definition(entry, entries, locale, sourceLink, headingLevel + 1)),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`persistence catalog: unsupported type ${JSON.stringify(value)}`)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, relative } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { uniqueRepoFiles } from './repo-files.ts'
|
||||
|
||||
interface Tree {
|
||||
root: string
|
||||
clean: () => void
|
||||
}
|
||||
|
||||
function makeTree(): Tree {
|
||||
const parent = mkdtempSync(join(tmpdir(), 'repo-files-'))
|
||||
const root = join(parent, 'root')
|
||||
const outside = join(parent, 'outside') // reachable only through a symlinked dir
|
||||
mkdirSync(join(root, 'a'), { recursive: true })
|
||||
writeFileSync(join(root, 'a', 'snap.md'), 'real\n')
|
||||
mkdirSync(join(root, 'd'), { recursive: true })
|
||||
symlinkSync(join(root, 'a', 'snap.md'), join(root, 'd', 'snap.md'))
|
||||
mkdirSync(outside, { recursive: true })
|
||||
writeFileSync(join(outside, 'snap.md'), 'behind a symlinked dir\n')
|
||||
symlinkSync(outside, join(root, 'linked-dir'))
|
||||
mkdirSync(join(root, '.hidden'), { recursive: true })
|
||||
writeFileSync(join(root, '.hidden', 'snap.md'), 'hidden by dot\n')
|
||||
return { root, clean: () => { rmSync(parent, { recursive: true, force: true }) } }
|
||||
}
|
||||
|
||||
describe('uniqueRepoFiles', () => {
|
||||
it('enumerates ** matches without probing a symlinked file as a directory', () => {
|
||||
const tree = makeTree()
|
||||
try {
|
||||
// Node's internal glob (from some 24.x releases) lstat-probes
|
||||
// <symlink>/snap.md while expanding `**/snap.md` and throws ENOTDIR.
|
||||
// The walker must return the real files, deduplicated by canonical
|
||||
// target, without throwing on any node version.
|
||||
const files = uniqueRepoFiles(tree.root, ['**/snap.md'])
|
||||
const rootReal = realpathSync(tree.root)
|
||||
const reals = files.map(file => relative(rootReal, file.real)).sort()
|
||||
expect(reals).toEqual([join('a', 'snap.md')])
|
||||
} finally {
|
||||
tree.clean()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not follow symlinked directories under ** or wildcard-match dot names', () => {
|
||||
const tree = makeTree()
|
||||
try {
|
||||
const files = uniqueRepoFiles(tree.root, ['**/*.md'])
|
||||
const paths = files.map(file => relative(tree.root, file.abs)).sort()
|
||||
// The symlinked d/snap.md dedupes onto its a/snap.md target; the
|
||||
// linked-dir and .hidden targets must not appear at all.
|
||||
expect(paths).toEqual([join('a', 'snap.md')])
|
||||
} finally {
|
||||
tree.clean()
|
||||
}
|
||||
})
|
||||
|
||||
it('follows a literal segment that names a symlinked directory', () => {
|
||||
const tree = makeTree()
|
||||
try {
|
||||
// Node glob resolves literal segments with stat, so `linked-dir/**` and
|
||||
// `linked-dir/*` enter the symlinked directory's target; `**` and
|
||||
// wildcard segments resolve with dirent types and do not.
|
||||
const files = uniqueRepoFiles(tree.root, ['linked-dir/**/*.md'])
|
||||
expect(files.map(file => relative(tree.root, file.abs))).toEqual([join('linked-dir', 'snap.md')])
|
||||
expect(uniqueRepoFiles(tree.root, ['linked-dir/*.md']).map(file => relative(tree.root, file.abs)))
|
||||
.toEqual([join('linked-dir', 'snap.md')])
|
||||
// A wildcard first segment never enters the symlinked directory.
|
||||
expect(uniqueRepoFiles(tree.root, ['*/snap.md']).map(file => relative(tree.root, file.abs)))
|
||||
.toEqual([join('a', 'snap.md')])
|
||||
} finally {
|
||||
tree.clean()
|
||||
}
|
||||
})
|
||||
|
||||
it('follows repeated literal symlinked directories like node glob and terminates', () => {
|
||||
const parent = mkdtempSync(join(tmpdir(), 'repo-files-cycle-'))
|
||||
try {
|
||||
const root = join(parent, 'root')
|
||||
mkdirSync(join(root, 'a'), { recursive: true })
|
||||
writeFileSync(join(root, 'a', 'snap.md'), 'x\n')
|
||||
symlinkSync(root, join(root, 'cyc'))
|
||||
// Each literal `cyc` segment resolves through stat and enters the
|
||||
// symlinked directory again, exactly as node glob does for a repeated
|
||||
// literal; recursion stays bounded because each literal consumes one
|
||||
// pattern segment and `**` only enters real directories.
|
||||
expect(uniqueRepoFiles(root, ['cyc/**/snap.md']).map(file => relative(root, file.abs)))
|
||||
.toEqual([join('cyc', 'a', 'snap.md')])
|
||||
expect(uniqueRepoFiles(root, ['cyc/cyc/**/snap.md']).map(file => relative(root, file.abs)))
|
||||
.toEqual([join('cyc', 'cyc', 'a', 'snap.md')])
|
||||
expect(uniqueRepoFiles(root, ['cyc/cyc/cyc/**/snap.md']).map(file => relative(root, file.abs)))
|
||||
.toEqual([join('cyc', 'cyc', 'cyc', 'a', 'snap.md')])
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('reports a matched file canonical target for downstream existence checks', () => {
|
||||
const tree = makeTree()
|
||||
try {
|
||||
const files = uniqueRepoFiles(tree.root, ['a/*.md'])
|
||||
expect(files).toHaveLength(1)
|
||||
expect(files[0]!.real).toBe(join(realpathSync(tree.root), 'a', 'snap.md'))
|
||||
} finally {
|
||||
tree.clean()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails loudly on a broken symlink instead of shrinking the corpus', () => {
|
||||
const tree = makeTree()
|
||||
try {
|
||||
// A healthy tree with symlinked files must not throw.
|
||||
expect(() => uniqueRepoFiles(tree.root, ['a/*.md'])).not.toThrow()
|
||||
} finally {
|
||||
tree.clean()
|
||||
}
|
||||
const parent = mkdtempSync(join(tmpdir(), 'repo-files-broken-'))
|
||||
try {
|
||||
const root = join(parent, 'root')
|
||||
mkdirSync(root, { recursive: true })
|
||||
writeFileSync(join(root, 'gone.md'), 'x\n')
|
||||
symlinkSync(join(root, 'gone.md'), join(root, 'broken-link.md'))
|
||||
rmSync(join(root, 'gone.md'))
|
||||
// realpathSync on the matched broken link must throw, exactly as it did
|
||||
// under the node-glob implementation, instead of silently shrinking the
|
||||
// scanned corpus.
|
||||
expect(() => uniqueRepoFiles(root, ['*.md'])).toThrow()
|
||||
// A broken symlink under a literal non-final segment matches nothing,
|
||||
// again as node glob silently returns no match for it.
|
||||
symlinkSync(join(root, 'gone-dir'), join(root, 'broken-dir'))
|
||||
expect(uniqueRepoFiles(root, ['broken-dir/*.md'])).toEqual([])
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects glob syntax the walker does not model instead of matching nothing', () => {
|
||||
const tree = makeTree()
|
||||
try {
|
||||
// Node glob would interpret the bracket class; the walker must fail
|
||||
// loudly rather than expand the pattern as a literal and quietly match
|
||||
// nothing.
|
||||
expect(() => uniqueRepoFiles(tree.root, ['**/*.[cm]d'])).toThrow(/does not model glob syntax/)
|
||||
} finally {
|
||||
tree.clean()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a trailing ** segment instead of silently returning nothing', () => {
|
||||
const tree = makeTree()
|
||||
try {
|
||||
expect(() => uniqueRepoFiles(tree.root, ['a/**'])).toThrow(/trailing \*\*/)
|
||||
// A trailing slash yields an empty final segment that can never match.
|
||||
expect(() => uniqueRepoFiles(tree.root, ['a/**/'])).toThrow(/empty segments/)
|
||||
} finally {
|
||||
tree.clean()
|
||||
}
|
||||
})
|
||||
|
||||
it('folds a . segment and rejects a .. segment like node glob semantics', () => {
|
||||
const tree = makeTree()
|
||||
try {
|
||||
// Node glob normalizes a `.` segment away, so `./a/snap.md` matches
|
||||
// a/snap.md instead of looking for a directory named `.`.
|
||||
expect(uniqueRepoFiles(tree.root, ['./a/snap.md']).map(file => relative(tree.root, file.abs)))
|
||||
.toEqual([join('a', 'snap.md')])
|
||||
expect(uniqueRepoFiles(tree.root, ['a/./snap.md']).map(file => relative(tree.root, file.abs)))
|
||||
.toEqual([join('a', 'snap.md')])
|
||||
// A `..` segment escapes the scanned root; the walker must fail loudly
|
||||
// rather than silently match nothing.
|
||||
expect(() => uniqueRepoFiles(tree.root, ['a/../snap.md'])).toThrow(/does not model \.\. segments/)
|
||||
} finally {
|
||||
tree.clean()
|
||||
}
|
||||
})
|
||||
})
|
||||
+170
-4
@@ -1,7 +1,7 @@
|
||||
/** Shared repository file discovery and line-oriented reference scanning. */
|
||||
|
||||
import { globSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { relative, resolve, sep } from 'node:path'
|
||||
import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
||||
import { join, relative, resolve, sep } from 'node:path'
|
||||
|
||||
/** One authored path plus its canonical target for symlink deduplication. */
|
||||
export interface RepoFile {
|
||||
@@ -26,6 +26,173 @@ export function isArchivedAgentNotePath(path: string): boolean {
|
||||
return path.replaceAll('\\', '/').startsWith('.agents/notes/archived/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a pattern segment matches a directory or file name. Supports `*` and
|
||||
* `?` inside a segment and mirrors node's glob `dot: false`: a segment whose
|
||||
* first character is a wildcard does not match dot names. `**` is handled as a
|
||||
* whole segment by the walker, never here.
|
||||
*/
|
||||
function segmentMatches(pattern: string, name: string): boolean {
|
||||
if (name.startsWith('.') && (pattern.startsWith('*') || pattern.startsWith('?'))) return false
|
||||
let expression = ''
|
||||
for (const character of pattern) {
|
||||
if (character === '*') expression += '.*'
|
||||
else if (character === '?') expression += '.'
|
||||
else expression += character.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
return new RegExp(`^${expression}$`).test(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject glob syntax the walker does not model. Node's `fs.glob` understands
|
||||
* character classes, brace alternation, and extglobs; expanding those
|
||||
* silently as literals would match nothing and quietly shrink a gate's
|
||||
* corpus, so a pattern segment using them fails loudly instead. Pure literal
|
||||
* segments must therefore contain none of the rejected metacharacters
|
||||
* either, even where node glob would read them literally.
|
||||
*/
|
||||
const UNSUPPORTED_GLOB = /[[\]{}()!+@\\]/
|
||||
|
||||
function assertSupportedSegment(segment: string): void {
|
||||
if (UNSUPPORTED_GLOB.test(segment)) {
|
||||
throw new TypeError(`repo-files walker does not model glob syntax in segment: ${segment}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk `root` matching one repository-relative glob without node's `fs.glob`.
|
||||
* The repository's own walker exists because node's internal glob, from some
|
||||
* 24.x releases, lstat-probes `<matched>/<next segment>` for symlinked files
|
||||
* while expanding `**` and throws ENOTDIR instead of skipping (observed on
|
||||
* node 24.13.0 scanning `snapshots/acp/image-compaction`'s symlinked
|
||||
* `system-prompt.expected.md`). The walker decides directoryhood from dirent
|
||||
* types and stat results, never by probing a path under a file, so the same
|
||||
* tree enumerates identically on every node version.
|
||||
*
|
||||
* Segment semantics: literal segments resolve through `stat`, so a literal
|
||||
* naming a symlinked directory enters that directory exactly as node glob
|
||||
* does — including a literal repeated across segments, which node glob
|
||||
* resolves each time; wildcard segments and `**` resolve through dirent
|
||||
* types, so they never enter symlinked directories and never match dot
|
||||
* names. Node documents `follow: false` only for `**` expansion, so the
|
||||
* wildcard-side behavior is this walker's own contract, pinned by
|
||||
* repo-files.spec.ts, rather than a cross-version node guarantee; the four
|
||||
* consuming gates' current patterns contain no wildcard segment over a
|
||||
* symlinked directory, so the corpus is unchanged. `**` spans zero or more
|
||||
* directories. The final segment matches files and symlinks; a broken or
|
||||
* cyclic symlink then fails loudly in the caller's realpathSync rather than
|
||||
* shrinking the scanned corpus, while a broken symlink under a literal
|
||||
* non-final segment matches nothing, again as node glob does. Traversal
|
||||
* terminates without a visited set: each literal segment consumes one pattern
|
||||
* segment per recursion, and `**` recurses only into real directories, which
|
||||
* form a finite tree because symlinked directories are never expanded by it.
|
||||
* Pattern segments support `*`, `?`, and literals that contain none of
|
||||
* `[]{}()!+@\`; other node-glob syntax, including a trailing `**`, `..`, and
|
||||
* empty segments, is rejected loudly up front, while a `.` segment is folded
|
||||
* away exactly as node glob normalizes it. Returns repository-relative slash
|
||||
* paths, sorted.
|
||||
*/
|
||||
function expandGlob(root: string, pattern: string): string[] {
|
||||
const segments: string[] = []
|
||||
for (const segment of pattern.split('/')) {
|
||||
if (segment === '.') {
|
||||
// Node glob normalizes a `.` segment away; dropping it here keeps
|
||||
// `./README.md` and `a/./b` matching exactly as node glob does instead
|
||||
// of silently matching nothing.
|
||||
continue
|
||||
}
|
||||
if (segment === '..') {
|
||||
// A `..` segment escapes the scanned root and interacts with `**` in
|
||||
// ways node glob special-cases; no gate pattern uses one, so the walker
|
||||
// rejects the form loudly instead of silently matching nothing.
|
||||
throw new TypeError(`repo-files walker does not model .. segments in pattern: ${pattern}`)
|
||||
}
|
||||
if (segment === '') {
|
||||
// A leading, doubled, or trailing slash yields an empty segment that
|
||||
// can never match an entry; node glob would tolerate the form, so the
|
||||
// walker must reject it loudly instead of silently returning nothing.
|
||||
throw new TypeError(`repo-files walker does not model empty segments in pattern: ${pattern}`)
|
||||
}
|
||||
segments.push(segment)
|
||||
}
|
||||
if (segments.length === 0) {
|
||||
throw new TypeError(`repo-files walker does not model a pattern with no segments: ${pattern}`)
|
||||
}
|
||||
for (const segment of segments) {
|
||||
if (segment !== '**') assertSupportedSegment(segment)
|
||||
}
|
||||
// A trailing `**` would match files, directories, and symlinks below the
|
||||
// prefix; the walker models `**` only as a directory-spanning segment, so
|
||||
// it must reject the form loudly instead of silently returning nothing.
|
||||
if (segments[segments.length - 1] === '**') {
|
||||
throw new TypeError(`repo-files walker does not model a trailing ** segment in pattern: ${pattern}`)
|
||||
}
|
||||
const out: string[] = []
|
||||
|
||||
const visit = (dirAbs: string, dirRel: string, index: number): void => {
|
||||
if (index >= segments.length) return
|
||||
if (segments[index] !== '**') {
|
||||
visitSegment(dirAbs, dirRel, index)
|
||||
return
|
||||
}
|
||||
// `**` consumes zero directories here and one directory per recursion.
|
||||
visitSegment(dirAbs, dirRel, index + 1)
|
||||
for (const entry of readdirSync(dirAbs, { withFileTypes: true })) {
|
||||
if (entry.name.startsWith('.') || !entry.isDirectory()) continue
|
||||
visit(join(dirAbs, entry.name), dirRel === '.' ? entry.name : `${dirRel}/${entry.name}`, index)
|
||||
}
|
||||
}
|
||||
|
||||
const visitSegment = (dirAbs: string, dirRel: string, index: number): void => {
|
||||
if (index >= segments.length) return
|
||||
const segment = segments[index]
|
||||
if (segment === undefined) return
|
||||
if (segment === '**') {
|
||||
visit(dirAbs, dirRel, index)
|
||||
return
|
||||
}
|
||||
const last = index === segments.length - 1
|
||||
for (const entry of readdirSync(dirAbs, { withFileTypes: true })) {
|
||||
if (!segmentMatches(segment, entry.name)) continue
|
||||
const childAbs = join(dirAbs, entry.name)
|
||||
const childRel = dirRel === '.' ? entry.name : `${dirRel}/${entry.name}`
|
||||
if (last) {
|
||||
// Files and symlinks match; a broken or cyclic symlink then fails
|
||||
// loudly in the caller's realpathSync exactly as node glob did,
|
||||
// rather than silently shrinking the scanned corpus.
|
||||
if (entry.isFile() || entry.isSymbolicLink()) out.push(childRel)
|
||||
continue
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
visitSegment(childAbs, childRel, index + 1)
|
||||
continue
|
||||
}
|
||||
// A literal non-final segment resolves through stat like node glob's,
|
||||
// so it enters a symlinked directory; wildcard segments never reach
|
||||
// this branch because they are matched from dirent types above.
|
||||
if (!entry.isSymbolicLink() || hasWildcard(segment)) continue
|
||||
let target: ReturnType<typeof statSync>
|
||||
try {
|
||||
target = statSync(childAbs)
|
||||
} catch {
|
||||
// A broken symlink matches nothing under a literal segment, as node
|
||||
// glob silently returns no match for it.
|
||||
continue
|
||||
}
|
||||
if (!target.isDirectory()) continue
|
||||
visitSegment(childAbs, childRel, index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
visit(root, '.', 0)
|
||||
return out.sort()
|
||||
}
|
||||
|
||||
/** Whether a pattern segment contains `*` or `?` and is therefore wildcard. */
|
||||
function hasWildcard(segment: string): boolean {
|
||||
return segment.includes('*') || segment.includes('?')
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand repository-relative globs and deduplicate symlinked files.
|
||||
* @param root - absolute repository root.
|
||||
@@ -41,8 +208,7 @@ export function uniqueRepoFiles(
|
||||
const seen = new Set<string>()
|
||||
const files: RepoFile[] = []
|
||||
for (const pattern of patterns) {
|
||||
for (const match of globSync(pattern, { cwd: root })) {
|
||||
const repoPath = match.split(sep).join('/')
|
||||
for (const repoPath of expandGlob(root, pattern)) {
|
||||
if (isExcluded(repoPath)) continue
|
||||
const abs = resolve(root, repoPath)
|
||||
const real = realpathSync(abs)
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
/**
|
||||
* Acceptance-path coverage for the rescope codemod's exact-edit classifier: a
|
||||
* duplicated insertion — what a non-idempotent apply produces — must be
|
||||
* rejected rather than applied again.
|
||||
*/
|
||||
/** Recorded npm evidence stays intact while authored files and exact edits remain checked. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { exactEditState } from './rescope-vendor.ts'
|
||||
import { exactEditState, isRescopeExcluded } from './rescope-vendor.ts'
|
||||
|
||||
const ANCHOR = '\n## Sync procedure'
|
||||
const INSERTED = `\n15. **rescope**: one log entry.\n${ANCHOR}`
|
||||
|
||||
describe('rescope file selection', () => {
|
||||
it('preserves the recorded npm resolution', () => {
|
||||
expect(isRescopeExcluded('scripts/dependency-catalog/package-lock.json')).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'scripts/dependency-catalog/package.json',
|
||||
'scripts/dependency-catalog/source.ts',
|
||||
'scripts/other/package-lock.json',
|
||||
'packages/example/src/index.ts',
|
||||
'packages/example/package.json',
|
||||
])('keeps %s subject to upstream package-name checks', (file) => {
|
||||
expect(isRescopeExcluded(file)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('exactEditState', () => {
|
||||
it('classifies an insertion by its target form, so a duplicate is invalid', () => {
|
||||
expect(exactEditState(`log\n${ANCHOR}\n`, ANCHOR, INSERTED, 1)).toBe('pending')
|
||||
|
||||
+13
-14
@@ -222,15 +222,8 @@ const EXACT_EDITS: readonly ExactEdit[] = [
|
||||
{
|
||||
id: 'vendor-readme-preamble',
|
||||
file: 'vendor/README.md',
|
||||
find: 'All vendored packages keep their **original npm names** and are marked `private: true` — they are never published from this repo. `pnpm-workspace.yaml#linkWorkspacePackages` makes matching upstream semver ranges resolve these pinned workspaces, including imports from built `lib/`; disabling it substitutes npm copies behind the same names.',
|
||||
replace: 'All vendored packages are **renamed into the `@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-<x>` → `@deepseek-ai/cordis-plugin-<x>`): every harness package declares `cordis` as a peer dependency, so publishing the harness publishes this framework layer too, and a publication under the upstream names would squat them on the registry. Directory names and upstream version numbers are deliberately unchanged, so the manifest below still reads as an upstream snapshot. `pnpm-workspace.yaml#linkWorkspacePackages` makes those preserved semver ranges resolve these pinned workspaces, including imports from built `lib/`.',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'vendor-readme-schemastery-note',
|
||||
file: 'vendor/README.md',
|
||||
find: 'whose lazy `require(\'cosmokit\')` can race',
|
||||
replace: 'whose lazy `require(\'@deepseek-ai/cosmokit\')` can race',
|
||||
find: 'All vendored packages keep their **original npm names** (they are resolved through pnpm workspaces) and are marked `private: true` — they are never published from this repo.',
|
||||
replace: 'All vendored packages use the **`@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-<x>` → `@deepseek-ai/cordis-plugin-<x>`). The manifest table records upstream versions and source commits; each package manifest carries its Harness release version and publication metadata. Repository-owned runtime dependencies use `workspace:^`, so local builds resolve the pinned workspace packages and publication substitutes release ranges.',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
@@ -365,9 +358,9 @@ const VENDORED_LIBRARY = /^@deepseek-ai\\/(cosmokit|schemastery)(\\/|$)/
|
||||
find: `| Package | Upstream | License |
|
||||
| --- | --- | --- |
|
||||
\${vendored.map(row => \`| \\\`\${row.npmName}\\\` | [\${row.upstream.replace('https://', '')}](\${row.upstream}) | MIT |\`).join('\\n')}`,
|
||||
replace: `| Package | Upstream name | Upstream | License |
|
||||
replace: `| Package | Upstream name | Source | License |
|
||||
| --- | --- | --- | --- |
|
||||
\${vendored.map(row => \`| \\\`\${row.npmName}\\\` | \\\`\${row.upstreamName}\\\` | [\${row.upstream.replace('https://', '')}](\${row.upstream}) | MIT |\`).join('\\n')}`,
|
||||
\${vendored.map(row => \`| \\\`\${row.npmName}\\\` | \\\`\${row.upstreamName}\\\` | [\${row.sourceDirectory}](\${row.sourceDirectory}/) | MIT |\`).join('\\n')}`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
@@ -426,8 +419,12 @@ const VENDORED_LIBRARY = /^@deepseek-ai\\/(cosmokit|schemastery)(\\/|$)/
|
||||
})),
|
||||
]
|
||||
|
||||
/** Files the rescope must never rewrite. */
|
||||
function excluded(file: string): boolean {
|
||||
/**
|
||||
* Identify files whose recorded content must remain outside the rescope pass.
|
||||
* @param file - Repository-relative path with forward slash separators.
|
||||
* @returns Whether the codemod must preserve the file without scanning its tokens.
|
||||
*/
|
||||
export function isRescopeExcluded(file: string): boolean {
|
||||
if (file === 'scripts/rescope-vendor.ts') return true // the mapping itself
|
||||
if (file.startsWith('.agents/notes/')) return true // notes record what was true when written
|
||||
// Recorded model payloads quote documentation verbatim, so they must mirror the
|
||||
@@ -437,6 +434,8 @@ function excluded(file: string): boolean {
|
||||
if (file === 'docs/rescope.md' || file === 'docs/rescope.zh.md') return true
|
||||
if (file.endsWith('.i18n.yaml')) return true // blob-hash records, re-recorded by the pairing gate
|
||||
if (file === 'pnpm-lock.yaml') return true // regenerated by pnpm install
|
||||
// Raw npm registry resolution; only gen-dependency-catalog --refresh replaces this evidence.
|
||||
if (file === 'scripts/dependency-catalog/package-lock.json') return true
|
||||
if (/^vendor\/[^/]+\/(README\.md|LICENSE)$/.test(file)) return true // upstream files kept verbatim
|
||||
return !EXTENSIONS.some(extension => file.endsWith(extension))
|
||||
}
|
||||
@@ -565,7 +564,7 @@ function main(): void {
|
||||
const all = patterns(reverse)
|
||||
const files = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' })
|
||||
.split('\0')
|
||||
.filter(file => file !== '' && !excluded(file))
|
||||
.filter(file => file !== '' && !isRescopeExcluded(file))
|
||||
|
||||
const counts = new Map<string, { files: number; lines: number }>()
|
||||
const failures: string[] = []
|
||||
|
||||
+123
-4
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import {
|
||||
cliGateOptions,
|
||||
collectDescendants,
|
||||
defaultConcurrency,
|
||||
formatGateResultReason,
|
||||
gatesForMode,
|
||||
@@ -160,18 +161,85 @@ describe('gate graph validation', () => {
|
||||
await expect(runGates(subject, subject.length, execute)).resolves.toHaveLength(subject.length)
|
||||
})
|
||||
|
||||
it('builds the native addon before benchmarks through the ci-bench script chain', () => {
|
||||
const subject = withPnpmEntrypoint(() => gatesForMode('ci-bench'))
|
||||
const { scripts } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
|
||||
scripts: Record<string, string>
|
||||
}
|
||||
|
||||
expect(scripts['check:ci:bench']).toBe('tsx scripts/run-gates.ts ci-bench')
|
||||
expect(subject).toHaveLength(1)
|
||||
expect(subject[0]).toMatchObject({
|
||||
id: 'bench',
|
||||
displayCommand: 'pnpm run test:bench',
|
||||
args: ['/private/pnpm.cjs', 'run', 'test:bench'],
|
||||
})
|
||||
expect(scripts['test:bench']).toBe('npm run build:bench && npm run build:web && npm run test:bench:built')
|
||||
expect(scripts['build:bench']).toBe(
|
||||
'npm run build:native-system && npm run build:lib && tsdown --config benchmarks/tsdown.config.ts',
|
||||
)
|
||||
expect(scripts['build:native-system']).toBe('tsx native/system/scripts/build.ts --host-addon-only')
|
||||
expect(scripts['test:bench:built']).toBe('vitest run --config vitest.bench.config.ts')
|
||||
})
|
||||
|
||||
it('checks all maintained repository references locally and in CI', () => {
|
||||
const { scripts } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
|
||||
scripts: Record<string, string>
|
||||
}
|
||||
for (const mode of ['doc-sync', 'doc-quick', 'ci-static'] as const) {
|
||||
const gates = withPnpmEntrypoint(() => gatesForMode(mode))
|
||||
expect(gates).toContainEqual(expect.objectContaining({
|
||||
id: 'repository-references',
|
||||
displayCommand: 'pnpm run verify-repository-references',
|
||||
}))
|
||||
}
|
||||
expect(scripts['verify-repository-references']).toBe('tsx scripts/verify-repository-references.ts')
|
||||
})
|
||||
|
||||
it('keeps the public repository link policy in the documentation gate', () => {
|
||||
const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
|
||||
|
||||
expect(ids).toContain('public-repository-links')
|
||||
})
|
||||
|
||||
it('keeps the concrete terminology policy in the documentation gate', () => {
|
||||
const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
|
||||
|
||||
expect(ids).toContain('concrete-terms')
|
||||
})
|
||||
|
||||
it('checks retrospective releases alongside the current persistence history', () => {
|
||||
for (const mode of ['doc-sync', 'ci-static'] as const) {
|
||||
const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
|
||||
expect(ids).toEqual(expect.arrayContaining(['persistence-changes', 'persistence-releases']))
|
||||
}
|
||||
})
|
||||
|
||||
it('requires complete Session format references locally and in CI', () => {
|
||||
const { scripts } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
|
||||
scripts: Record<string, string>
|
||||
}
|
||||
for (const mode of ['doc-sync', 'doc-quick', 'ci-static'] as const) {
|
||||
expect(withPnpmEntrypoint(() => gatesForMode(mode))).toContainEqual(expect.objectContaining({
|
||||
id: 'persistence-formats',
|
||||
displayCommand: 'pnpm run verify-persistence-formats',
|
||||
}))
|
||||
}
|
||||
expect(scripts['verify-persistence-formats']).toBe('tsx scripts/persistence-formats.ts')
|
||||
})
|
||||
|
||||
it('keeps package-group subsystem ownership in the documentation gate', () => {
|
||||
const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
|
||||
|
||||
expect(ids).toContain('subsystem-pages')
|
||||
})
|
||||
|
||||
it('keeps the package README Summary limit in the documentation gate', () => {
|
||||
const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
|
||||
|
||||
expect(ids).toContain('package-readme-summaries')
|
||||
})
|
||||
|
||||
it('derives the quick documentation aggregate from marked doc-sync leaves', () => {
|
||||
const full = withPnpmEntrypoint(() => gatesForMode('doc-sync'))
|
||||
const quick = withPnpmEntrypoint(() => gatesForMode('doc-quick'))
|
||||
@@ -179,14 +247,21 @@ describe('gate graph validation', () => {
|
||||
expect(quick).toEqual(full.filter(gate => gate.quick === true))
|
||||
})
|
||||
|
||||
it('checks the recorded npm dependency catalog in both documentation aggregates', () => {
|
||||
for (const mode of ['doc-sync', 'doc-quick'] as const) {
|
||||
expect(withPnpmEntrypoint(() => gatesForMode(mode).find(gate => gate.id === 'dependency-catalog')))
|
||||
.toMatchObject({ args: ['/private/pnpm.cjs', 'run', 'verify-dependency-catalog'] })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the hygiene aggregate aligned with the package script checks', () => {
|
||||
const ids = withPnpmEntrypoint(() => gatesForMode('hygiene').map(subject => subject.id))
|
||||
|
||||
expect(ids).toEqual([
|
||||
'rescope-vendor', 'publint', 'constraints', 'package-dependencies', 'application-entrypoints',
|
||||
'rescope-vendor', 'publint', 'constraints', 'default-product-isolation', 'package-dependencies', 'application-entrypoints',
|
||||
'dsh-package-licenses', 'package-invariants', 'built-package-invariants', 'node-next-types',
|
||||
'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'no-bare-dispatcher', 'cordis-config',
|
||||
'runtime-closure', 'vendored-links',
|
||||
'runtime-closure',
|
||||
])
|
||||
expect(defaultConcurrency('hygiene', ids.length, 8)).toEqual({
|
||||
workers: 4,
|
||||
@@ -197,9 +272,9 @@ describe('gate graph validation', () => {
|
||||
it('schedules the longest documentation leaves before short checks', () => {
|
||||
const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
|
||||
|
||||
expect(ids.slice(0, 10)).toEqual([
|
||||
expect(ids.slice(0, 11)).toEqual([
|
||||
'doc-typecheck', 'docs-site-build', 'doc-graphs', 'markdown-links', 'type-equivalence',
|
||||
'cordis-catalog', 'cordis-inspect-catalog', 'mermaid', 'scoped-events', 'translation-pairing',
|
||||
'cordis-catalog', 'cordis-inspect-catalog', 'workflow-guest', 'mermaid', 'scoped-events', 'translation-pairing',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -231,6 +306,17 @@ describe('gate graph validation', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
|
||||
'executes default-product experimental isolation in %s',
|
||||
(mode) => {
|
||||
const gate = withPnpmEntrypoint(() => gatesForMode(mode)
|
||||
.find(subject => subject.id === 'default-product-isolation'))
|
||||
|
||||
expect(gate?.args).toContain('verify-default-product-isolation')
|
||||
expect(gate?.allowFailure).not.toBe(true)
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
|
||||
'keeps the client dependency policy in %s',
|
||||
(mode) => {
|
||||
@@ -240,6 +326,15 @@ describe('gate graph validation', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
|
||||
'keeps weighted approval policy tests in %s',
|
||||
(mode) => {
|
||||
const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
|
||||
|
||||
expect(ids).toContain('approval-policy')
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
|
||||
'keeps hard-coded Client UI copy enforcement in %s',
|
||||
(mode) => {
|
||||
@@ -881,6 +976,30 @@ describe('fail-fast scheduling', () => {
|
||||
})
|
||||
|
||||
describe('process-table parsing', () => {
|
||||
it('excludes the root when a parent link returns to it', () => {
|
||||
expect(collectDescendants(100, [[200, 100], [100, 200], [300, 200]]))
|
||||
.toEqual([200, 300])
|
||||
})
|
||||
|
||||
it('visits duplicate and cyclic descendant links only once', () => {
|
||||
expect(collectDescendants(100, [
|
||||
[200, 100], [200, 100], [300, 100], [200, 200], [400, 200], [200, 400], [500, 300], [900, 800],
|
||||
])).toEqual([200, 300, 400, 500])
|
||||
})
|
||||
|
||||
it('returns no descendants for an isolated or self-parented root', () => {
|
||||
expect(collectDescendants(100, [])).toEqual([])
|
||||
expect(collectDescendants(100, [[100, 100]])).toEqual([])
|
||||
})
|
||||
|
||||
it('walks a wide child set without spreading it into call arguments', () => {
|
||||
const children = Array.from({ length: 150_000 }, (_, i): [number, number] => [i + 3, 2])
|
||||
const descendants = collectDescendants(1, [[2, 1], ...children])
|
||||
expect(descendants).toHaveLength(children.length + 1)
|
||||
expect(descendants[0]).toBe(2)
|
||||
expect(descendants.at(-1)).toBe(150_002)
|
||||
})
|
||||
|
||||
it('parses `pid ppid` rows from a POSIX ps dump', () => {
|
||||
expect(parsePidPpidLines(' 123 1\n456 123\n 789 456\n')).toEqual([[123, 1], [456, 123], [789, 456]])
|
||||
})
|
||||
|
||||
+39
-16
@@ -266,6 +266,7 @@ export function gatesForMode(selected: Mode): Gate[] {
|
||||
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
||||
pnpmScript('client-domain-graph', 'verify-client-domain-graph', { label: 'client domain graph' }),
|
||||
pnpmScript('test', 'test'),
|
||||
pnpmScript('approval-policy', 'test:approval-policy', { label: 'Weighted approval policy' }),
|
||||
pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
snapshotGate(),
|
||||
@@ -285,7 +286,6 @@ export function gatesForMode(selected: Mode): Gate[] {
|
||||
...hygieneLeafGates(),
|
||||
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
||||
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
||||
pnpmScript('vendored-links', 'verify-vendored-links', { label: 'vendored links' }),
|
||||
]
|
||||
case 'doc-sync':
|
||||
return docSyncLeafGates()
|
||||
@@ -297,6 +297,7 @@ export function gatesForMode(selected: Mode): Gate[] {
|
||||
function ciSharedStaticGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
||||
pnpmScript('default-product-isolation', 'verify-default-product-isolation', { label: 'default product isolation' }),
|
||||
pnpmScript('application-entrypoints', 'verify-application-entrypoints', { label: 'application entrypoints' }),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('package-dependencies', 'verify-package-dependencies', { label: 'package dependencies' }),
|
||||
@@ -309,6 +310,7 @@ function ciSharedStaticGates(): Gate[] {
|
||||
pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }),
|
||||
pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }),
|
||||
pnpmScript('no-bare-dispatcher', 'verify-no-bare-dispatcher', { label: 'proxy-aware dispatchers' }),
|
||||
pnpmScript('approval-policy', 'test:approval-policy', { label: 'Weighted approval policy' }),
|
||||
pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
|
||||
]
|
||||
}
|
||||
@@ -367,7 +369,7 @@ function nodeCompatSmokeGates(options: { cliSmoke?: boolean } = {}): Gate[] {
|
||||
pnpmExec('source-worker-smoke', [
|
||||
'vitest',
|
||||
'run',
|
||||
'packages/workflow/workflow-worker-thread/tests/source-worker.compat.spec.ts',
|
||||
'packages/workflow/workflow-ptc/tests/source-runtime.compat.spec.ts',
|
||||
], { label: 'source worker smoke' }),
|
||||
pnpmExec('jsonl-zstd-smoke', [
|
||||
'vitest',
|
||||
@@ -629,7 +631,8 @@ function coverageGates(): Gate[] {
|
||||
streamOutput: true,
|
||||
})
|
||||
return [
|
||||
instrumented,
|
||||
pnpmScript('native-system', 'build:native-system'),
|
||||
{ ...instrumented, needs: ['native-system'] },
|
||||
pnpmExec('coverage-exempt-heavy', [
|
||||
'vitest',
|
||||
'run',
|
||||
@@ -638,6 +641,7 @@ function coverageGates(): Gate[] {
|
||||
...timeouts,
|
||||
], {
|
||||
label: 'test:coverage-exempt-heavy',
|
||||
needs: ['native-system'],
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -690,6 +694,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
|
||||
pnpmScript('rescope-vendor', 'rescope-vendor:check', { label: 'vendor rescope' }),
|
||||
pnpmScript('publint', 'publint', artifactOptions),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('default-product-isolation', 'verify-default-product-isolation', { label: 'default product isolation' }),
|
||||
pnpmScript('package-dependencies', 'verify-package-dependencies', { label: 'package dependencies' }),
|
||||
pnpmScript('application-entrypoints', 'verify-application-entrypoints', { label: 'application entrypoints' }),
|
||||
pnpmScript('dsh-package-licenses', 'verify-dsh-package-licenses', { label: 'DSH package licenses' }),
|
||||
@@ -729,6 +734,7 @@ function docSyncLeafGates(options: {
|
||||
pnpmScript('type-equivalence', 'verify-type-equiv', { label: 'type equivalence', quick: true }),
|
||||
pnpmScript('cordis-catalog', 'verify-cordis-catalog', { label: 'cordis catalog' }),
|
||||
pnpmScript('cordis-inspect-catalog', 'verify-cordis-inspect-catalog', { label: 'Cordis inspect catalog' }),
|
||||
pnpmScript('workflow-guest', 'verify-workflow-guest', { label: 'workflow guest source' }),
|
||||
pnpmScript('mermaid', 'verify-mermaid'),
|
||||
pnpmScript('scoped-events', 'verify-scoped-events', { label: 'scoped events' }),
|
||||
pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing', quick: true }),
|
||||
@@ -737,14 +743,21 @@ function docSyncLeafGates(options: {
|
||||
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' }),
|
||||
pnpmScript('dependency-catalog', 'verify-dependency-catalog', { label: 'npm dependency catalog', quick: true }),
|
||||
pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }),
|
||||
pnpmScript('persistence-changes', 'verify-persistence-changes', { label: 'persistence type history' }),
|
||||
pnpmScript('persistence-releases', 'verify-persistence-releases', { label: 'released persistence history' }),
|
||||
pnpmScript('persistence-formats', 'verify-persistence-formats', { label: 'Session format references', quick: true }),
|
||||
pnpmScript('session-format-catalog', 'verify-session-format-catalog', { label: 'Session format catalog' }),
|
||||
pnpmScript('public-repository-links', 'verify-public-repository-links', { label: 'public repository links', quick: true }),
|
||||
pnpmScript('repository-references', 'verify-repository-references', { label: 'repository references', quick: true }),
|
||||
pnpmScript('concrete-terms', 'verify-concrete-terms', { label: 'concrete terms', quick: true }),
|
||||
pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs', quick: true }),
|
||||
pnpmScript('subsystem-pages', 'verify-subsystem-pages', { label: 'subsystem pages' }),
|
||||
pnpmScript('package-paths', 'verify-package-paths', { label: 'package paths' }),
|
||||
pnpmScript('tsconfig-paths', 'verify-tsconfig-paths', { label: 'tsconfig paths' }),
|
||||
pnpmScript('config-source-ownership', 'verify-config-source-ownership', { label: 'config source ownership' }),
|
||||
pnpmScript('package-readme-summaries', 'verify-package-readme-summaries', { label: 'package README Summaries', quick: true }),
|
||||
pnpmScript('package-readme-model-experience', 'verify-package-readme-model-experience', { label: 'package README model experience', quick: true }),
|
||||
pnpmScript('agent-note-classification', 'verify-agent-note-classification', { label: 'agent note classification', quick: true }),
|
||||
pnpmScript('agent-note-format', 'verify-agent-note-format', { label: 'agent note format', quick: true }),
|
||||
@@ -756,7 +769,10 @@ function docSyncLeafGates(options: {
|
||||
label: 'documentation standard tests',
|
||||
quick: true,
|
||||
}),
|
||||
pnpmExec('docs-site-projection', ['vitest', 'run', 'scripts/project-doc-site.spec.ts', 'scripts/verify-doc-site-fragments.spec.ts'], {
|
||||
pnpmExec('docs-site-projection', [
|
||||
'vitest', 'run', 'scripts/project-doc-site.spec.ts', 'scripts/verify-doc-site-fragments.spec.ts',
|
||||
'website/tests/mermaid-viewer.spec.ts',
|
||||
], {
|
||||
label: 'documentation site checks',
|
||||
}),
|
||||
pnpmScript('package-readme-limitations', 'verify-package-readme-limitations', { label: 'package README limitations', quick: true }),
|
||||
@@ -782,6 +798,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate {
|
||||
'apps/cli/tests/built-bin.e2e.ts',
|
||||
'packages/host/directory-picker-native/tests/built-worker.e2e.ts',
|
||||
'packages/sdk/server/tests/built-scope-carrier.e2e.ts',
|
||||
'packages/fs/tool-present/tests/built-errors.e2e.ts',
|
||||
'packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts',
|
||||
'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts',
|
||||
'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts',
|
||||
@@ -790,8 +807,8 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate {
|
||||
// Built execution consumers: the only automated proof that package-name
|
||||
// imports reach their lib/ entrypoints under plain Node. The e2e lane runs
|
||||
// unbuilt, so these files self-skip there.
|
||||
'packages/workflow/workflow-worker-thread/tests/built-worker.e2e.ts',
|
||||
'packages/code-runtime/code-runtime-worker-thread/tests/built-lib.e2e.ts',
|
||||
'packages/workflow/workflow-ptc/tests/built-runtime.e2e.ts',
|
||||
'packages/ptc-runtime/ptc-runtime-node/tests/built-lib.e2e.ts',
|
||||
'packages/session/session-persistence-jsonl/tests/built-migration-worker.e2e.ts',
|
||||
'packages/lsp/lsp-stdio/tests/built-lib.e2e.ts',
|
||||
], {
|
||||
@@ -1500,23 +1517,29 @@ export function taskkillArgs(rootPid: number, descendants: number[]): string[][]
|
||||
return [rootPid, ...descendants].map(pid => ['/PID', String(pid), '/T', '/F'])
|
||||
}
|
||||
|
||||
/** Breadth-first walk of the pid/ppid rows starting at `root`. */
|
||||
function collectDescendants(root: number, rows: Array<[number, number]>): number[] {
|
||||
/**
|
||||
* Walk a process-table snapshot without revisiting duplicate or cyclic PID links.
|
||||
* @param root - process whose descendants are collected; excluded from the result.
|
||||
* @param rows - observed PID and parent PID pairs.
|
||||
* @returns distinct reachable descendants in breadth-first order.
|
||||
*/
|
||||
export function collectDescendants(root: number, rows: Array<[number, number]>): number[] {
|
||||
const byParent = new Map<number, number[]>()
|
||||
for (const [pid, ppid] of rows) {
|
||||
const children = byParent.get(ppid) ?? []
|
||||
children.push(pid)
|
||||
byParent.set(ppid, children)
|
||||
}
|
||||
const result: number[] = []
|
||||
const queue = byParent.get(root) ?? []
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
const pid = queue[index]
|
||||
if (pid === undefined) continue
|
||||
result.push(pid)
|
||||
queue.push(...(byParent.get(pid) ?? []))
|
||||
const seen = new Set([root])
|
||||
const queue = [root]
|
||||
for (const parent of queue) {
|
||||
for (const pid of byParent.get(parent) ?? []) {
|
||||
if (seen.has(pid)) continue
|
||||
seen.add(pid)
|
||||
queue.push(pid)
|
||||
}
|
||||
}
|
||||
return result
|
||||
return queue.slice(1)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createAssistantMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { SESSION_FORMAT_VERSION, SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import {
|
||||
canonicalSessionFixture,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
isPhysicalSessionFixture,
|
||||
} from './session-fixture-layout.ts'
|
||||
|
||||
const HEADER = ' {"type":"session","version":2,"id":"fixture","createdAt":1,"isSeeded":false,"delegationDepth":0} '
|
||||
const HEADER = ` {"type":"session","version":${SESSION_FORMAT_VERSION},"id":"fixture","createdAt":1,"isSeeded":false,"delegationDepth":0} `
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const FIXTURE_MESSAGE = createAssistantMessage({
|
||||
content: [{ type: 'text', text: 'part-0part-1part-2part-3' }],
|
||||
@@ -100,10 +100,67 @@ describe('canonicalSessionFixture', () => {
|
||||
const projected = [
|
||||
HEADER,
|
||||
'{"type":"turn/start","data":{"turn":1}}',
|
||||
'{"type":"request/header","data":{"header":{"config":{"provider":"mock","model":"mock"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}',
|
||||
'{"type":"request/header","data":{"header":{"config":{"provider":"mock","model":"mock"},"tools":"{{tools}}"},"reason":"initial"}}',
|
||||
'',
|
||||
].join('\n')
|
||||
expect(canonicalSessionFixture(projected)).toBe(projected)
|
||||
expect(decodedBody(projected)[1]).not.toHaveProperty('data.header.tools')
|
||||
})
|
||||
|
||||
it.each([0, 1, 2])('preserves v%i request-header tokens and source bytes after validation', (version) => {
|
||||
const source = [
|
||||
JSON.stringify({ type: 'session', version, id: 'fixture', createdAt: 1, delegationDepth: 0, ...version >= 2 ? { isSeeded: false } : {} }),
|
||||
'{"type":"turn/start","data":{"turn":1}}',
|
||||
'{"type":"step/start","data":{"turn":1,"step":1}}',
|
||||
'{"type":"request/header","data":{"header":{"config":{"provider":"mock","model":"mock"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}',
|
||||
'',
|
||||
].join('\n')
|
||||
expect(canonicalSessionFixture(source)).toBe(source)
|
||||
const request = decodedBody(source).find(event => event.type === 'request/header')
|
||||
expect(request).not.toHaveProperty('data.header.tools')
|
||||
expect(request).not.toHaveProperty('data.header.system')
|
||||
})
|
||||
|
||||
it('keeps genuine empty current tools for semantic replay to reject', () => {
|
||||
const source = [
|
||||
HEADER,
|
||||
'{"type":"turn/start","data":{"turn":1}}',
|
||||
'{"type":"request/header","data":{"header":{"config":{"provider":"mock","model":"mock"},"tools":[]},"reason":"initial"}}',
|
||||
'',
|
||||
].join('\n')
|
||||
const canonical = canonicalSessionFixture(source)
|
||||
expect(canonical).toBe(source)
|
||||
expect(() => decodedBody(canonical!)).toThrow(/session snapshot line 3:.*empty optional header fields must be omitted/)
|
||||
})
|
||||
|
||||
it.each([0, 1, 2])('preserves physically valid v%i bytes without requiring migration to current', (version) => {
|
||||
const header = { type: 'session', version, id: 'historical', createdAt: 1, delegationDepth: 0, ...(version === 2 ? { isSeeded: false } : {}) }
|
||||
const content = [
|
||||
JSON.stringify(header),
|
||||
JSON.stringify({ type: 'user/message', data: { role: 'user', id: 'historical-user', source: { kind: 'user' }, content: [] }, surfaceOp: 'append' }),
|
||||
'',
|
||||
].join('\n')
|
||||
expect(canonicalSessionFixture(content)).toBe(content)
|
||||
})
|
||||
|
||||
it.each([0, 1, 2])('rejects v%i sequence gaps and invalid source-event ranges with source line diagnostics', (version) => {
|
||||
const header = JSON.stringify({ type: 'session', version, id: 'historical', createdAt: 1, delegationDepth: 0, ...(version === 2 ? { isSeeded: false } : {}) })
|
||||
expect(() => canonicalSessionFixture(`${header}\n{"type":"feedback/record","seq":3,"data":{"text":"gap"}}\n`, 'gap.jsonl'))
|
||||
.toThrow(/gap\.jsonl: session snapshot line 2:.*seq/)
|
||||
expect(() => canonicalSessionFixture(`${header}\n{"type":"feedback/record","data":{},"sourceEventSeqs":[[2,0]]}\n`, 'range.jsonl'))
|
||||
.toThrow(/range\.jsonl: session snapshot line 2:/)
|
||||
})
|
||||
|
||||
it.each([0, 1, 2])('finalizes the v%i source inherited cut', (version) => {
|
||||
const header = { type: 'session', version, id: 'historical', createdAt: 1, delegationDepth: 0, ...(version === 2 ? { isSeeded: true } : { seedLength: 1 }) }
|
||||
expect(() => canonicalSessionFixture(`${JSON.stringify(header)}\n`, 'cut.jsonl'))
|
||||
.toThrow(/cut\.jsonl: session snapshot line 1:.*(?:inherited|seed)/)
|
||||
})
|
||||
|
||||
it('refuses unsupported generation headers', () => {
|
||||
const header = { type: 'session', version: 99, id: 'future', createdAt: 1, isSeeded: false, delegationDepth: 0 }
|
||||
expect(() => canonicalSessionFixture(`${JSON.stringify(header)}\n`, 'future.jsonl'))
|
||||
.toThrow(/future\.jsonl: session snapshot line 1:.*99/)
|
||||
})
|
||||
|
||||
it('fails loud on malformed records after a session header', () => {
|
||||
|
||||
@@ -11,6 +11,13 @@ import {
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type { SessionLogOffset as SessionLogOffsetType } from '@deepseek-ai/dsh-session'
|
||||
import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
|
||||
import { SessionFormatEventCollector, type SessionFormatArtifactDecoder, type SessionFormatCodec } from '@deepseek-ai/dsh-session-format'
|
||||
import { releasedV0SessionFormatCodec, releasedV1SessionFormatCodec } from '@deepseek-ai/dsh-session-format-v0-to-v1'
|
||||
import { releasedV2SessionFormatCodec } from '@deepseek-ai/dsh-session-format-v1-to-v2'
|
||||
|
||||
const historicalCodecs: readonly SessionFormatCodec[] = [
|
||||
releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, releasedV2SessionFormatCodec,
|
||||
]
|
||||
|
||||
/** Physical persistence artifacts validated by the WebWorker runtime fixture spec. */
|
||||
const WEBWORKER_PHYSICAL_SESSION_FIXTURE_ROOT =
|
||||
@@ -58,51 +65,6 @@ function validationHeader(value: unknown): unknown {
|
||||
return header
|
||||
}
|
||||
|
||||
function validationRow(source: Readonly<Record<string, unknown>>): Record<string, unknown> {
|
||||
if (source.type !== 'request/header') return { ...source }
|
||||
const data = source.data
|
||||
if (data === null || typeof data !== 'object' || Array.isArray(data)) return { ...source }
|
||||
const header = (data as Record<string, unknown>).header
|
||||
if (header === null || typeof header !== 'object' || Array.isArray(header)) return { ...source }
|
||||
if ((header as Record<string, unknown>).tools !== '{{tools}}') return { ...source }
|
||||
return {
|
||||
...source,
|
||||
data: {
|
||||
...data,
|
||||
header: { ...header, tools: [] },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function restoreRequestHeaderTokens(
|
||||
events: readonly SessionEvent[],
|
||||
rows: readonly Readonly<Record<string, unknown>>[],
|
||||
): SessionEvent[] {
|
||||
const sources = rows.filter(row => row.type === 'request/header')
|
||||
let sourceIndex = 0
|
||||
return events.map((event) => {
|
||||
if (event.type !== 'request/header') return event
|
||||
const source = sources[sourceIndex]
|
||||
sourceIndex += 1
|
||||
const sourceData = source?.data
|
||||
const sourceHeader = sourceData !== null && typeof sourceData === 'object' && !Array.isArray(sourceData)
|
||||
? (sourceData as Record<string, unknown>).header
|
||||
: undefined
|
||||
if (sourceHeader === null || typeof sourceHeader !== 'object' || Array.isArray(sourceHeader)
|
||||
|| (sourceHeader as Record<string, unknown>).tools !== '{{tools}}') return event
|
||||
return {
|
||||
...event,
|
||||
data: {
|
||||
...event.data,
|
||||
header: {
|
||||
...(event.data as unknown as { header: Record<string, unknown> }).header,
|
||||
tools: '{{tools}}',
|
||||
},
|
||||
},
|
||||
} as SessionEvent
|
||||
})
|
||||
}
|
||||
|
||||
function renderFixture(headerLine: string, events: readonly SessionEvent[]): string {
|
||||
return [
|
||||
headerLine,
|
||||
@@ -166,7 +128,7 @@ function parseFixtureRows(content: string, headerValue: unknown): SessionEvent[]
|
||||
// Versionless protocol fixtures and current projected snapshots use scalar
|
||||
// event rows. Current snapshots may contain owner-restored scrub tokens such
|
||||
// as `{{tools}}`; semantic replay restores those sidecars, while this layout
|
||||
// gate owns only envelopes, provenance ranges, and one-event-per-row form.
|
||||
// gate owns only envelopes, source-event ranges, and one-event-per-row form.
|
||||
const projectedCurrent = headerValue !== null
|
||||
&& typeof headerValue === 'object'
|
||||
&& !Array.isArray(headerValue)
|
||||
@@ -191,29 +153,28 @@ function parseFixtureRows(content: string, headerValue: unknown): SessionEvent[]
|
||||
}
|
||||
})
|
||||
}
|
||||
let restore: ReturnType<typeof sessionFormatCatalog.createRestore>
|
||||
const collector = new SessionFormatEventCollector()
|
||||
let decoder: SessionFormatArtifactDecoder
|
||||
try {
|
||||
restore = sessionFormatCatalog.createRestore(validationHeader(headerValue), {
|
||||
recovery: 'strict',
|
||||
validation: 'current',
|
||||
})
|
||||
const version = (headerValue as Record<string, unknown>).version
|
||||
const codec = historicalCodecs.find(candidate => candidate.version === version)
|
||||
if (codec === undefined) throw new Error(`unsupported session fixture version ${String(version)}`)
|
||||
decoder = codec.createDecoder(validationHeader(headerValue), 'strict')
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(`session snapshot line 1: ${detail}`, { cause: error })
|
||||
}
|
||||
for (const [index, row] of rows.entries()) {
|
||||
try {
|
||||
restore.decodeRow(validationRow(row))
|
||||
decoder.decodeRow(row, collector)
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(`session snapshot line ${rowLines[index] ?? 1}: ${detail}`, { cause: error })
|
||||
}
|
||||
}
|
||||
try {
|
||||
return restoreRequestHeaderTokens(
|
||||
[...restore.finish().events] as unknown as SessionEvent[],
|
||||
rows,
|
||||
)
|
||||
decoder.finish(collector)
|
||||
return [...collector.values] as unknown as SessionEvent[]
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
const line = fixtureDiagnosticLine(error, rowLines, eventLines)
|
||||
@@ -246,8 +207,9 @@ function withoutEnvelope(events: readonly SessionEvent[]): Array<Omit<SessionEve
|
||||
|
||||
/**
|
||||
* Canonicalize one JSONL document when its first record is a session header.
|
||||
* The header line remains byte-identical; body records decode to logical events,
|
||||
* re-encode one event per row, and omit storage sequence/time envelopes.
|
||||
* Historical generations receive strict source-codec validation and remain byte-identical;
|
||||
* conversion to the current format is not required. Current body records re-encode
|
||||
* one event per row and omit storage sequence/time envelopes; their header is preserved.
|
||||
* Non-session JSONL returns undefined.
|
||||
*
|
||||
* @param content - JSONL source text.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { LocalSpillStore } from '@deepseek-ai/dsh-spill-local'
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import * as locators from './snapshot-spill-locators.ts'
|
||||
import * as commands from '../snapshots/session/session-query-spill/resolve-spill-command.mjs'
|
||||
|
||||
it.skipIf(process.platform === 'win32')('resolves the exact spill verifier, retains real failures, and restores the shell', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "query-spill-'quoted-"))
|
||||
const ctx = new Context()
|
||||
const disposers: (() => Promise<void>)[] = []
|
||||
try {
|
||||
for (const fiber of [
|
||||
ctx.plugin(LocalSpillStore, { root, cleanupPeriodDays: 0 }),
|
||||
ctx.plugin(LocalFileSystem, { cwd: root }),
|
||||
ctx.plugin(LocalSubprocessRuntime),
|
||||
ctx.plugin(LocalBashExecutor),
|
||||
]) {
|
||||
disposers.push(() => fiber.dispose())
|
||||
await fiber
|
||||
}
|
||||
const logical = resolve('/tmp/dsh-acp-snap-query-verifier')
|
||||
const mapping = ctx.plugin(locators, { root, locatorRoot: logical })
|
||||
disposers.push(() => mapping.dispose())
|
||||
await mapping
|
||||
const saved = await ctx.spillStore.saveText({
|
||||
owner: { sessionId: SessionId('query-verifier') },
|
||||
source: { kind: 'tool', toolName: 'session_event_read', callId: ToolCallId('query'), label: 'result' },
|
||||
suggestedName: 'session_event_read.txt', content: 'request/header session_event_search',
|
||||
})
|
||||
const adapter = ctx.plugin(commands)
|
||||
disposers.push(() => adapter.dispose())
|
||||
await adapter
|
||||
const command = 'file="' + saved.locator + '"' + "; grep -Fq request/header \"$file\" && grep -Fq session_event_search \"$file\" && printf 'SPILL_CANONICAL_OK\\n'"
|
||||
const spec = ctx.shell.resolve({ command })
|
||||
const output = await ctx.shell.run(spec)
|
||||
expect(output.timedOut).toBe(false)
|
||||
expect(output.exitCode).toBe(0)
|
||||
expect(output.stdout.text).toBe('SPILL_CANONICAL_OK\n')
|
||||
expect(spec.command).toBe(command)
|
||||
const other = await ctx.shell.run(ctx.shell.resolve({ command: 'printf ordinary; exit 7' }))
|
||||
expect(other.exitCode).toBe(7)
|
||||
expect(other.stdout.text).toBe('ordinary')
|
||||
const unmatched = await ctx.shell.run(ctx.shell.resolve({ command: command + '; exit 9' }))
|
||||
expect(unmatched.exitCode).toBe(9)
|
||||
expect(unmatched.stderr.text).toContain('No such file')
|
||||
const physical = ctx.fs.processPath(await ctx.fs.resolve(saved.locator))
|
||||
await rm(physical)
|
||||
const missing = await ctx.shell.run(spec)
|
||||
expect(missing.exitCode).not.toBe(0)
|
||||
expect(missing.stdout.text).toBe('')
|
||||
await adapter.dispose()
|
||||
const restored = await ctx.shell.run(spec)
|
||||
expect(restored.exitCode).not.toBe(0)
|
||||
expect(restored.stderr.text).toContain(saved.locator)
|
||||
} finally {
|
||||
for (const dispose of disposers.reverse()) await dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { assertV2SnapshotCorpusPolicy } from './session-snapshot-corpus-policy.ts'
|
||||
import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
|
||||
import { assertSnapshotCorpusPolicy } from './session-snapshot-corpus-policy.ts'
|
||||
|
||||
const completeV0 = {
|
||||
key: 'session/v0',
|
||||
@@ -10,50 +11,42 @@ const completeV0 = {
|
||||
},
|
||||
} as const
|
||||
|
||||
describe('v2 recorded-session corpus policy', () => {
|
||||
it('accepts a current majority and the complete bounded v0/v1 migration set', () => {
|
||||
expect(assertV2SnapshotCorpusPolicy([
|
||||
{ key: 'session/current', selectedVersions: [2, 2, 2, 2, 2, 2, 2, 2] },
|
||||
const adjacent = Array.from({ length: SESSION_FORMAT_VERSION - 1 }, (_, index) => ({
|
||||
key: `sdk/v${index + 1}`,
|
||||
selectedVersions: [index + 1],
|
||||
retained: { version: index + 1, coverage: ['adjacent-migration'] as const },
|
||||
}))
|
||||
const current = { key: 'session/current', selectedVersions: Array<number>(8).fill(SESSION_FORMAT_VERSION) }
|
||||
|
||||
describe('recorded-session corpus policy', () => {
|
||||
it('accepts a current majority and complete bounded migration coverage', () => {
|
||||
expect(assertSnapshotCorpusPolicy([
|
||||
current,
|
||||
{
|
||||
key: 'session/multi-hop',
|
||||
selectedVersions: [0, 0, 0],
|
||||
retained: { version: 0, coverage: ['multi-hop', 'shipped-profile'] },
|
||||
},
|
||||
{
|
||||
key: 'session/packed',
|
||||
selectedVersions: [0],
|
||||
retained: { version: 0, coverage: ['packed-row'] },
|
||||
},
|
||||
{
|
||||
key: 'session/retry',
|
||||
selectedVersions: [0],
|
||||
retained: { version: 0, coverage: ['retry-failure'] },
|
||||
},
|
||||
{
|
||||
key: 'sdk/adjacent',
|
||||
selectedVersions: [1, 1],
|
||||
retained: { version: 1, coverage: ['adjacent-migration'] },
|
||||
},
|
||||
])).toEqual({ currentRoles: 8, retainedRoles: 7, retainedScenarios: 4 })
|
||||
{ key: 'session/packed', selectedVersions: [0], retained: { version: 0, coverage: ['packed-row'] } },
|
||||
{ key: 'session/retry', selectedVersions: [0], retained: { version: 0, coverage: ['retry-failure'] } },
|
||||
...adjacent,
|
||||
])).toEqual({ currentRoles: 8, retainedRoles: 5 + adjacent.length, retainedScenarios: 3 + adjacent.length })
|
||||
})
|
||||
|
||||
it('requires v0 coverage from v0 fixtures and adjacent coverage from v1 fixtures', () => {
|
||||
expect(() => assertV2SnapshotCorpusPolicy([
|
||||
{ key: 'session/current', selectedVersions: Array<number>(8).fill(2) },
|
||||
{
|
||||
key: 'sdk/wrong-generation',
|
||||
selectedVersions: [1],
|
||||
retained: {
|
||||
version: 1,
|
||||
coverage: ['adjacent-migration'],
|
||||
},
|
||||
},
|
||||
])).toThrow('v2 Session corpus lacks v0 coverage')
|
||||
it('requires v0 coverage from v0 fixtures', () => {
|
||||
expect(() => assertSnapshotCorpusPolicy([current, ...adjacent]))
|
||||
.toThrow('Session corpus lacks v0 coverage')
|
||||
})
|
||||
|
||||
it.each(adjacent)('requires direct coverage from $key', (fixture) => {
|
||||
expect(() => assertSnapshotCorpusPolicy([
|
||||
current, completeV0, ...adjacent.filter(other => other !== fixture),
|
||||
])).toThrow(`Session corpus lacks v${fixture.retained.version} coverage: adjacent-migration`)
|
||||
})
|
||||
|
||||
it('does not let one retained generation claim another edge\'s coverage', () => {
|
||||
expect(() => assertV2SnapshotCorpusPolicy([
|
||||
{ key: 'session/current', selectedVersions: Array<number>(8).fill(2) },
|
||||
expect(() => assertSnapshotCorpusPolicy([
|
||||
current,
|
||||
completeV0,
|
||||
{
|
||||
key: 'sdk/adjacent',
|
||||
@@ -61,51 +54,37 @@ describe('v2 recorded-session corpus policy', () => {
|
||||
retained: { version: 1, coverage: ['adjacent-migration', 'multi-hop'] },
|
||||
},
|
||||
])).toThrow('sdk/adjacent: v1 retained coverage must be adjacent-migration')
|
||||
expect(() => assertSnapshotCorpusPolicy([
|
||||
{ ...completeV0, retained: { version: 0, coverage: ['adjacent-migration'] } },
|
||||
])).toThrow('session/v0: v0 retained coverage must be multi-hop, packed-row, retry-failure, shipped-profile')
|
||||
})
|
||||
|
||||
it('rejects an undeclared historical role and a retained current generation', () => {
|
||||
expect(() => assertV2SnapshotCorpusPolicy([
|
||||
{ key: 'session/empty', selectedVersions: [] },
|
||||
])).toThrow('session/empty: scenario owns no selected Session role')
|
||||
expect(() => assertV2SnapshotCorpusPolicy([
|
||||
{ key: 'session/old', selectedVersions: [1] },
|
||||
])).toThrow('session/old: selected Session generation v1 does not match expected v2')
|
||||
expect(() => assertV2SnapshotCorpusPolicy([
|
||||
{
|
||||
key: 'session/not-historical',
|
||||
selectedVersions: [2],
|
||||
retained: { version: 2, coverage: ['adjacent-migration'] },
|
||||
},
|
||||
])).toThrow('session/not-historical: v2 corpus may retain only Session format v0 or v1')
|
||||
it('rejects absent roles, undeclared history, and mixed selected generations', () => {
|
||||
expect(() => assertSnapshotCorpusPolicy([{ key: 'session/empty', selectedVersions: [] }]))
|
||||
.toThrow('session/empty: scenario owns no selected Session role')
|
||||
expect(() => assertSnapshotCorpusPolicy([{ key: 'session/old', selectedVersions: [1] }]))
|
||||
.toThrow(`session/old: selected Session generation v1 does not match expected v${SESSION_FORMAT_VERSION}`)
|
||||
expect(() => assertSnapshotCorpusPolicy([{ ...completeV0, selectedVersions: [0, SESSION_FORMAT_VERSION] }]))
|
||||
.toThrow(`session/v0: selected Session generation v${SESSION_FORMAT_VERSION} does not match expected v0`)
|
||||
})
|
||||
|
||||
it('requires one direct v1 migration fixture', () => {
|
||||
expect(() => assertV2SnapshotCorpusPolicy([
|
||||
{ key: 'session/current', selectedVersions: Array<number>(8).fill(2) },
|
||||
completeV0,
|
||||
])).toThrow('v2 Session corpus lacks v1 coverage: adjacent-migration')
|
||||
})
|
||||
it.each([SESSION_FORMAT_VERSION, SESSION_FORMAT_VERSION + 1, -1, 0.5])(
|
||||
'rejects retained generation %s outside released history', (version) => {
|
||||
expect(() => assertSnapshotCorpusPolicy([{
|
||||
key: 'session/not-historical', selectedVersions: [version],
|
||||
retained: { version, coverage: ['adjacent-migration'] },
|
||||
}])).toThrow(`session/not-historical: retained Session format must precede current v${SESSION_FORMAT_VERSION}`)
|
||||
},
|
||||
)
|
||||
|
||||
it('bounds historical roles and requires the current generation to remain the majority', () => {
|
||||
const retained = [
|
||||
{
|
||||
...completeV0,
|
||||
selectedVersions: Array<number>(11).fill(0),
|
||||
},
|
||||
{
|
||||
key: 'sdk/v1',
|
||||
selectedVersions: [1],
|
||||
retained: { version: 1, coverage: ['adjacent-migration'] },
|
||||
},
|
||||
] as const
|
||||
expect(() => assertV2SnapshotCorpusPolicy([
|
||||
{ key: 'session/current', selectedVersions: Array<number>(20).fill(2) },
|
||||
...retained,
|
||||
])).toThrow('v2 Session corpus retains 12 historical roles; maximum is 10')
|
||||
expect(() => assertV2SnapshotCorpusPolicy([
|
||||
{ key: 'session/current', selectedVersions: [2] },
|
||||
{ ...retained[0], selectedVersions: [0, 0] },
|
||||
retained[1],
|
||||
])).toThrow('v2 Session corpus requires a current majority; current=1, retained=3')
|
||||
it('bounds historical roles and requires a current majority', () => {
|
||||
expect(() => assertSnapshotCorpusPolicy([
|
||||
{ ...current, selectedVersions: Array<number>(20).fill(SESSION_FORMAT_VERSION) },
|
||||
{ ...completeV0, selectedVersions: Array<number>(11).fill(0) },
|
||||
...adjacent,
|
||||
])).toThrow(`Session corpus retains ${11 + adjacent.length} historical roles; maximum is 10`)
|
||||
expect(() => assertSnapshotCorpusPolicy([
|
||||
{ ...current, selectedVersions: [SESSION_FORMAT_VERSION] }, completeV0, ...adjacent,
|
||||
])).toThrow(`Session corpus requires a current majority; current=1, retained=${1 + adjacent.length}`)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Enforced generation mix for the v2 recorded-session corpus. */
|
||||
/** Enforced current-writer majority and retained migration coverage. */
|
||||
|
||||
import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
|
||||
import type { SnapshotSessionFormatManifest } from '@deepseek-ai/dsh-session-snapshot'
|
||||
|
||||
/** One owning scenario's selected parent and child generations. */
|
||||
@@ -12,14 +13,13 @@ export interface SnapshotCorpusScenarioGenerations {
|
||||
readonly retained?: SnapshotSessionFormatManifest
|
||||
}
|
||||
|
||||
/** Counts returned after the v2 corpus policy accepts the inventory. */
|
||||
/** Counts returned after the corpus policy accepts the inventory. */
|
||||
export interface SnapshotCorpusGenerationSummary {
|
||||
readonly currentRoles: number
|
||||
readonly retainedRoles: number
|
||||
readonly retainedScenarios: number
|
||||
}
|
||||
|
||||
const CURRENT_VERSION = 2
|
||||
const MAX_RETAINED_ROLES = 10
|
||||
const REQUIRED_V0_COVERAGE = new Set([
|
||||
'multi-hop',
|
||||
@@ -27,28 +27,27 @@ const REQUIRED_V0_COVERAGE = new Set([
|
||||
'retry-failure',
|
||||
'shipped-profile',
|
||||
])
|
||||
const REQUIRED_V1_COVERAGE = new Set(['adjacent-migration'])
|
||||
const REQUIRED_ADJACENT_COVERAGE = new Set(['adjacent-migration'])
|
||||
|
||||
/**
|
||||
* Require a v2 majority plus a small explicit v0/v1 migration corpus.
|
||||
* Require a current-writer majority and bounded coverage of each released migration source.
|
||||
*
|
||||
* @param scenarios - Every owning top-level recorded-session scenario.
|
||||
* @returns Accepted current and retained role counts.
|
||||
*/
|
||||
export function assertV2SnapshotCorpusPolicy(
|
||||
export function assertSnapshotCorpusPolicy(
|
||||
scenarios: readonly SnapshotCorpusScenarioGenerations[],
|
||||
): SnapshotCorpusGenerationSummary {
|
||||
let currentRoles = 0
|
||||
let retainedRoles = 0
|
||||
let retainedScenarios = 0
|
||||
const v0Coverage = new Set<string>()
|
||||
const v1Coverage = new Set<string>()
|
||||
const coverageByVersion = new Map<number, Set<string>>()
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
if (scenario.selectedVersions.length === 0) {
|
||||
throw new Error(`${scenario.key}: scenario owns no selected Session role`)
|
||||
}
|
||||
const expectedVersion = scenario.retained?.version ?? CURRENT_VERSION
|
||||
const expectedVersion = scenario.retained?.version ?? SESSION_FORMAT_VERSION
|
||||
const mismatched = scenario.selectedVersions.find(version => version !== expectedVersion)
|
||||
if (mismatched !== undefined) {
|
||||
throw new Error(
|
||||
@@ -59,12 +58,13 @@ export function assertV2SnapshotCorpusPolicy(
|
||||
currentRoles += scenario.selectedVersions.length
|
||||
continue
|
||||
}
|
||||
if (scenario.retained.version !== 0 && scenario.retained.version !== 1) {
|
||||
throw new Error(`${scenario.key}: v2 corpus may retain only Session format v0 or v1`)
|
||||
if (!Number.isSafeInteger(scenario.retained.version)
|
||||
|| scenario.retained.version < 0 || scenario.retained.version >= SESSION_FORMAT_VERSION) {
|
||||
throw new Error(`${scenario.key}: retained Session format must precede current v${SESSION_FORMAT_VERSION}`)
|
||||
}
|
||||
const allowedCoverage = scenario.retained.version === 0
|
||||
? REQUIRED_V0_COVERAGE
|
||||
: REQUIRED_V1_COVERAGE
|
||||
: REQUIRED_ADJACENT_COVERAGE
|
||||
if (scenario.retained.coverage.some(item => !allowedCoverage.has(item))) {
|
||||
throw new Error(
|
||||
`${scenario.key}: v${scenario.retained.version} retained coverage must be ${[...allowedCoverage].join(', ')}`,
|
||||
@@ -72,24 +72,25 @@ export function assertV2SnapshotCorpusPolicy(
|
||||
}
|
||||
retainedRoles += scenario.selectedVersions.length
|
||||
retainedScenarios += 1
|
||||
const coverage = scenario.retained.version === 0 ? v0Coverage : v1Coverage
|
||||
const coverage = coverageByVersion.get(scenario.retained.version) ?? new Set<string>()
|
||||
coverageByVersion.set(scenario.retained.version, coverage)
|
||||
for (const item of scenario.retained.coverage) coverage.add(item)
|
||||
}
|
||||
|
||||
const missingV0Coverage = [...REQUIRED_V0_COVERAGE].filter(item => !v0Coverage.has(item))
|
||||
if (missingV0Coverage.length > 0) {
|
||||
throw new Error(`v2 Session corpus lacks v0 coverage: ${missingV0Coverage.join(', ')}`)
|
||||
}
|
||||
const missingV1Coverage = [...REQUIRED_V1_COVERAGE].filter(item => !v1Coverage.has(item))
|
||||
if (missingV1Coverage.length > 0) {
|
||||
throw new Error(`v2 Session corpus lacks v1 coverage: ${missingV1Coverage.join(', ')}`)
|
||||
for (let version = 0; version < SESSION_FORMAT_VERSION; version += 1) {
|
||||
const required = version === 0 ? REQUIRED_V0_COVERAGE : REQUIRED_ADJACENT_COVERAGE
|
||||
const coverage = coverageByVersion.get(version)
|
||||
const missing = [...required].filter(item => !coverage?.has(item))
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Session corpus lacks v${version} coverage: ${missing.join(', ')}`)
|
||||
}
|
||||
}
|
||||
if (retainedRoles > MAX_RETAINED_ROLES) {
|
||||
throw new Error(`v2 Session corpus retains ${retainedRoles} historical roles; maximum is ${MAX_RETAINED_ROLES}`)
|
||||
throw new Error(`Session corpus retains ${retainedRoles} historical roles; maximum is ${MAX_RETAINED_ROLES}`)
|
||||
}
|
||||
if (currentRoles <= retainedRoles) {
|
||||
throw new Error(
|
||||
`v2 Session corpus requires a current majority; current=${currentRoles}, retained=${retainedRoles}`,
|
||||
`Session corpus requires a current majority; current=${currentRoles}, retained=${retainedRoles}`,
|
||||
)
|
||||
}
|
||||
return { currentRoles, retainedRoles, retainedScenarios }
|
||||
|
||||
@@ -4,7 +4,6 @@ import { existsSync } from 'node:fs'
|
||||
import { lstat, readFile, readdir, realpath } from 'node:fs/promises'
|
||||
import { basename, dirname, join, relative, resolve } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
assertSessionFixtureVersion,
|
||||
captureExpectedWorkspaceSnapshot,
|
||||
@@ -18,7 +17,7 @@ import {
|
||||
sessionFixtureNames,
|
||||
type SnapshotManifest,
|
||||
} from '@deepseek-ai/dsh-session-snapshot'
|
||||
import { assertV2SnapshotCorpusPolicy } from './session-snapshot-corpus-policy.ts'
|
||||
import { assertSnapshotCorpusPolicy } from './session-snapshot-corpus-policy.ts'
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, '..')
|
||||
const corpusRoot = join(repoRoot, 'snapshots')
|
||||
@@ -26,6 +25,7 @@ const profiles = ['acp', 'sdk', 'session', 'web'] as const
|
||||
const snapshotAdapters = [
|
||||
'apps/web/tests/message-feedback-protocol.snapshot.ts',
|
||||
'apps/web/tests/minimal-preset.snapshot.ts',
|
||||
'apps/web/tests/preset-migration.snapshot.ts',
|
||||
'snapshots/acp/acp.snapshot.ts',
|
||||
'snapshots/sdk/sdk.snapshot.ts',
|
||||
'snapshots/session/headless.snapshot.ts',
|
||||
@@ -191,8 +191,7 @@ it('keeps every recorded session owned, pinned, redacted, and header-scrubbed',
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a current v2 majority plus the bounded declared v0/v1 migration corpus', async () => {
|
||||
expect(SESSION_FORMAT_VERSION).toBe(2)
|
||||
it('keeps a current-writer majority plus bounded declared historical migration coverage', async () => {
|
||||
const owners = (await scenarios()).filter(scenario => scenario.manifest.session === undefined)
|
||||
const inventory = await Promise.all(owners.map(async scenario => ({
|
||||
key: scenario.key,
|
||||
@@ -202,8 +201,8 @@ it('keeps a current v2 majority plus the bounded declared v0/v1 migration corpus
|
||||
: { retained: scenario.manifest.sessionFormat }),
|
||||
})))
|
||||
|
||||
expect(assertV2SnapshotCorpusPolicy(inventory)).toMatchObject({
|
||||
retainedRoles: 7,
|
||||
retainedScenarios: 5,
|
||||
expect(assertSnapshotCorpusPolicy(inventory)).toMatchObject({
|
||||
retainedRoles: 9,
|
||||
retainedScenarios: 7,
|
||||
})
|
||||
})
|
||||
+341
-159
@@ -11,6 +11,7 @@ import json
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -32,9 +33,8 @@ 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"
|
||||
MINIMAL_PROMPT = "Exercise the packaged minimal agent's persistent shell and string-replacement editor."
|
||||
MINIMAL_PROMPT = "Exercise the packaged minimal agent's persistent shell."
|
||||
MINIMAL_TEXT = "minimal agent smoke ok"
|
||||
MINIMAL_EDITOR_PATH_PREFIX = "Editor path: "
|
||||
FS_SEARCH_PROMPT = "Exercise the packaged filesystem search tools."
|
||||
FS_SEARCH_TEXT = "filesystem search smoke ok"
|
||||
FS_SEARCH_MARKER = "PACKAGED_FS_SEARCH_OK"
|
||||
@@ -101,6 +101,17 @@ RESTART_FIRST_SESSION_ID = "process-one"
|
||||
RESTART_SECOND_SESSION_ID = "process-two"
|
||||
SNAPSHOT_PLUGIN_CODE = """\
|
||||
return (ctx) => {
|
||||
ctx.on('tools/pre-execute', (exec, next) => {
|
||||
if (exec.name !== 'snapshot_double' || exec.arguments.value !== -1) return next()
|
||||
return {
|
||||
kind: 'deny',
|
||||
reason: 'Auto review rejected tool "snapshot_double"; its body was not executed',
|
||||
info: {
|
||||
name: 'AutoReviewDeniedError', code: 'AUTO_REVIEW_DENIED',
|
||||
reason: ' transport raw\\r\\nreason ',
|
||||
},
|
||||
}
|
||||
})
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'snapshot_double',
|
||||
description: 'Double a number for executable snapshot verification.',
|
||||
@@ -126,7 +137,7 @@ ADVANCED_SNAPSHOT_DIRECTORY = (
|
||||
Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "advanced"
|
||||
)
|
||||
ADVANCED_SNAPSHOT_FILENAMES = (
|
||||
"result.json", "session.v2.jsonl", "session.1.v2.jsonl", "session.2.v2.jsonl",
|
||||
"result.json", "session.v3.jsonl", "session.1.v3.jsonl", "session.2.v3.jsonl",
|
||||
)
|
||||
MINIMAL_SNAPSHOT_DIRECTORY = (
|
||||
Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "minimal"
|
||||
@@ -134,11 +145,15 @@ MINIMAL_SNAPSHOT_DIRECTORY = (
|
||||
if IS_WINDOWS:
|
||||
MINIMAL_SNAPSHOT_DIRECTORY /= "win-x64"
|
||||
MINIMAL_SNAPSHOT_FILENAMES = ("model-visible.json",)
|
||||
IN_HISTORY_SNAPSHOT_DIRECTORY = (
|
||||
Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "minimal-in-history"
|
||||
)
|
||||
IN_HISTORY_SNAPSHOT_FILENAMES = ("prompt-history.json",)
|
||||
RESTART_SNAPSHOT_DIRECTORY = (
|
||||
Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "restart"
|
||||
)
|
||||
RESTART_SNAPSHOT_FILENAMES = (
|
||||
"result.json", "requests.json", "session.1.v2.jsonl", "session.2.v2.jsonl",
|
||||
"result.json", "requests.json", "session.1.v3.jsonl", "session.2.v3.jsonl",
|
||||
)
|
||||
MCP_SERVER_SCRIPT = """\
|
||||
import json
|
||||
@@ -260,7 +275,7 @@ def write_advanced_profile_patch(root: Path, name: str, sessions: Path) -> Path:
|
||||
},
|
||||
},
|
||||
{"insert": [
|
||||
{"id": "code-runtime", "name": "@deepseek-ai/dsh-code-runtime-worker-thread"},
|
||||
{"id": "ptc-runtime", "name": "@deepseek-ai/dsh-ptc-runtime-node"},
|
||||
{"id": "cordis-host-runner", "name": "@deepseek-ai/dsh-cordis-host-runner"},
|
||||
{"id": "cordis-tool", "name": "@deepseek-ai/dsh-tool-cordis"},
|
||||
]},
|
||||
@@ -292,6 +307,9 @@ class MockModelHandler(BaseHTTPRequestHandler):
|
||||
requests: list[dict[str, object]] = []
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if self.path != "/v1/messages":
|
||||
self.send_error(404)
|
||||
return
|
||||
content_length = int(self.headers.get("content-length", "0"))
|
||||
body = json.loads(self.rfile.read(content_length))
|
||||
self.requests.append(body)
|
||||
@@ -300,8 +318,7 @@ class MockModelHandler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
chunks = completion_chunks(body)
|
||||
for chunk in chunks:
|
||||
self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode())
|
||||
self.wfile.write(b"data: [DONE]\n\n")
|
||||
self.wfile.write(f"event: {chunk['type']}\ndata: {json.dumps(chunk)}\n\n".encode())
|
||||
self.wfile.flush()
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
@@ -313,13 +330,19 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
|
||||
messages = body.get("messages")
|
||||
if not isinstance(messages, list) or not messages:
|
||||
raise AssertionError(f"model request has no messages: {body}")
|
||||
latest = messages[-1]
|
||||
# A system prompt update may follow the tool result without replacing it.
|
||||
latest = next(message for message in reversed(messages) if message.get("role") != "system")
|
||||
if not isinstance(latest, dict):
|
||||
raise AssertionError(f"model request has an invalid latest message: {body}")
|
||||
|
||||
if latest.get("role") == "tool":
|
||||
call_id, tool_name = latest_tool_call(messages)
|
||||
tool_text = message_text(latest.get("content"))
|
||||
tool_results = [
|
||||
block for block in latest.get("content", [])
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result"
|
||||
]
|
||||
if tool_results:
|
||||
result = tool_results[-1]
|
||||
call_id, tool_name = latest_tool_call(messages, result.get("tool_use_id"))
|
||||
tool_text = message_text(result.get("content"))
|
||||
mcp = mcp_tool_followup(call_id, tool_name, tool_text)
|
||||
if mcp is not None:
|
||||
return mcp
|
||||
@@ -329,7 +352,7 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
|
||||
spawn_node = spawn_node_tool_followup(call_id, tool_name, tool_text)
|
||||
if spawn_node is not None:
|
||||
return spawn_node
|
||||
minimal = minimal_tool_followup(body, call_id, tool_name, tool_text)
|
||||
minimal = minimal_tool_followup(call_id, tool_name, tool_text)
|
||||
if minimal is not None:
|
||||
return minimal
|
||||
advanced = advanced_tool_followup(body, call_id, tool_name, tool_text)
|
||||
@@ -344,18 +367,13 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
|
||||
raise AssertionError(f"unexpected tool follow-up: {tool_name}")
|
||||
|
||||
user_prompts = [
|
||||
message_text(message.get("content"))
|
||||
block["text"]
|
||||
for message in reversed(messages)
|
||||
if isinstance(message, dict) and message.get("role") == "user"
|
||||
for block in message.get("content", [])
|
||||
if isinstance(block, dict) and block.get("type") == "text"
|
||||
]
|
||||
minimal_prompt = next(
|
||||
(
|
||||
prompt
|
||||
for prompt in user_prompts
|
||||
if prompt.startswith(f"{MINIMAL_PROMPT}\n{MINIMAL_EDITOR_PATH_PREFIX}")
|
||||
),
|
||||
None,
|
||||
)
|
||||
minimal_prompt = next((prompt for prompt in user_prompts if prompt == MINIMAL_PROMPT), None)
|
||||
# The minimal composition's assembled system prompt, advertised tool schemas, and
|
||||
# model-visible messages are pinned by its snapshot, not asserted here.
|
||||
if minimal_prompt is not None:
|
||||
@@ -450,7 +468,7 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
|
||||
{"a": 19, "b": 23},
|
||||
)
|
||||
if prompt == PROFILE_PLUGIN_PROMPT:
|
||||
system_text = "\n".join(
|
||||
system_text = message_text(body.get("system")) + "\n" + "\n".join(
|
||||
message_text(message.get("content"))
|
||||
for message in messages
|
||||
if isinstance(message, dict) and message.get("role") == "system"
|
||||
@@ -528,12 +546,11 @@ def spawn_node_tool_followup(
|
||||
|
||||
|
||||
def minimal_tool_followup(
|
||||
body: dict[str, object],
|
||||
call_id: str,
|
||||
tool_name: str,
|
||||
tool_text: str,
|
||||
) -> list[dict[str, object]] | None:
|
||||
"""Verify the checked-in minimal composition's PTY and editor."""
|
||||
"""Verify the checked-in minimal composition's persistent PTY."""
|
||||
if not call_id.startswith("minimal-"):
|
||||
return None
|
||||
if call_id == "minimal-bash-1" and tool_name == MINIMAL_SHELL_TOOL:
|
||||
@@ -548,33 +565,6 @@ def minimal_tool_followup(
|
||||
expected = f"COUNT=2 CWD={MINIMAL_SHELL_SECOND_CWD}"
|
||||
if expected.lower() not in tool_text.lower():
|
||||
raise AssertionError(f"persistent shell 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(MINIMAL_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 MINIMAL_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(
|
||||
"minimal-editor",
|
||||
"str_replace_editor",
|
||||
{
|
||||
"command": "create",
|
||||
"path": editor_path,
|
||||
"file_text": "created by packaged editor\n",
|
||||
},
|
||||
)
|
||||
if call_id == "minimal-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(MINIMAL_TEXT)
|
||||
raise AssertionError(f"unexpected minimal-agent follow-up: {call_id} {tool_name}: {tool_text}")
|
||||
|
||||
@@ -615,6 +605,21 @@ def advanced_tool_followup(
|
||||
if call_id == "advanced-code" and tool_name == "run_code":
|
||||
if "42" not in tool_text:
|
||||
raise AssertionError(f"run_code returned no dynamic-tool value: {tool_text}")
|
||||
return tool_call_chunks("advanced-denied-native", "snapshot_double", {"value": -1})
|
||||
if call_id == "advanced-denied-native" and tool_name == "snapshot_double":
|
||||
if 'Auto review rejected tool "snapshot_double"; its body was not executed' not in tool_text:
|
||||
raise AssertionError(f"native denial did not preserve the model result: {tool_text}")
|
||||
if "transport raw" in tool_text:
|
||||
raise AssertionError("native denial leaked its user-facing reason to the model")
|
||||
return tool_call_chunks("advanced-denied-ptc", "run_code", {
|
||||
"code": "try { await tools.snapshot_double({ value: -1 }) } catch (error) { return error.message }",
|
||||
"description": "Catch a structured inner tool denial",
|
||||
})
|
||||
if call_id == "advanced-denied-ptc" and tool_name == "run_code":
|
||||
if 'Auto review rejected tool "snapshot_double"; its body was not executed' not in tool_text:
|
||||
raise AssertionError(f"PTC denial did not preserve the model result: {tool_text}")
|
||||
if "transport raw" in tool_text:
|
||||
raise AssertionError("PTC denial leaked its user-facing reason to the model")
|
||||
assert_advertised_tool(body, "subagent")
|
||||
return tool_call_chunks(
|
||||
"advanced-direct-child",
|
||||
@@ -658,64 +663,67 @@ def advanced_tool_followup(
|
||||
|
||||
|
||||
def text_chunks(text: str) -> list[dict[str, object]]:
|
||||
"""Build a complete streaming text response."""
|
||||
"""Build a complete Messages text response."""
|
||||
return [
|
||||
{"choices": [{"delta": {"role": "assistant", "content": None, "reasoning_content": ""}}]},
|
||||
{"choices": [{"delta": {"content": text}}]},
|
||||
{
|
||||
"choices": [{"delta": {"content": ""}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 3, "completion_tokens": 3},
|
||||
},
|
||||
message_start(),
|
||||
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
|
||||
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": text}},
|
||||
{"type": "content_block_stop", "index": 0},
|
||||
{"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 3}},
|
||||
{"type": "message_stop"},
|
||||
]
|
||||
|
||||
|
||||
def tool_call_chunks(call_id: str, name: str, arguments: dict[str, object]) -> list[dict[str, object]]:
|
||||
"""Build a complete streaming function-call response."""
|
||||
"""Build a complete Messages tool-use response."""
|
||||
return [
|
||||
{"choices": [{"delta": {"role": "assistant", "content": None, "reasoning_content": ""}}]},
|
||||
message_start(),
|
||||
{
|
||||
"choices": [{
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"index": 0,
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {"name": name, "arguments": json.dumps(arguments)},
|
||||
}],
|
||||
},
|
||||
}],
|
||||
"type": "content_block_start", "index": 0,
|
||||
"content_block": {"type": "tool_use", "id": call_id, "name": name, "input": {}},
|
||||
},
|
||||
{
|
||||
"choices": [{"delta": {"content": ""}, "finish_reason": "tool_calls"}],
|
||||
"usage": {"prompt_tokens": 3, "completion_tokens": 3},
|
||||
"type": "content_block_delta", "index": 0,
|
||||
"delta": {"type": "input_json_delta", "partial_json": json.dumps(arguments)},
|
||||
},
|
||||
{"type": "content_block_stop", "index": 0},
|
||||
{"type": "message_delta", "delta": {"stop_reason": "tool_use"}, "usage": {"output_tokens": 3}},
|
||||
{"type": "message_stop"},
|
||||
]
|
||||
|
||||
|
||||
def latest_tool_call(messages: list[object]) -> tuple[str, str]:
|
||||
def message_start() -> dict[str, object]:
|
||||
"""Start a Messages response with deterministic token usage."""
|
||||
return {
|
||||
"type": "message_start",
|
||||
"message": {"id": "msg_smoke", "model": "smoke-model", "usage": {"input_tokens": 3, "output_tokens": 0}},
|
||||
}
|
||||
|
||||
|
||||
def latest_tool_call(messages: list[object], result_id: object) -> tuple[str, str]:
|
||||
"""Find the assistant call id and name paired with the latest tool result."""
|
||||
for message in reversed(messages[:-1]):
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
calls = message.get("tool_calls")
|
||||
calls = message.get("content")
|
||||
if not isinstance(calls, list):
|
||||
continue
|
||||
for call in reversed(calls):
|
||||
if not isinstance(call, dict):
|
||||
continue
|
||||
function = call.get("function")
|
||||
call_id = call.get("id")
|
||||
if (
|
||||
isinstance(call_id, str)
|
||||
and isinstance(function, dict)
|
||||
and isinstance(function.get("name"), str)
|
||||
and call_id == result_id
|
||||
and call.get("type") == "tool_use"
|
||||
and isinstance(call.get("name"), str)
|
||||
):
|
||||
return call_id, function["name"]
|
||||
return call_id, call["name"]
|
||||
raise AssertionError(f"tool result has no preceding assistant tool call: {messages}")
|
||||
|
||||
|
||||
def message_text(content: object) -> str:
|
||||
"""Read OpenAI text content in either string or block-list form."""
|
||||
"""Read Messages text content in either string or block-list form."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
@@ -736,9 +744,8 @@ def advertised_tool_names(body: dict[str, object]) -> set[str]:
|
||||
for tool in tools:
|
||||
if not isinstance(tool, dict):
|
||||
continue
|
||||
function = tool.get("function")
|
||||
if isinstance(function, dict) and isinstance(function.get("name"), str):
|
||||
names.add(function["name"])
|
||||
if isinstance(tool.get("name"), str):
|
||||
names.add(tool["name"])
|
||||
return names
|
||||
|
||||
|
||||
@@ -769,7 +776,7 @@ def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--scenario",
|
||||
choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-spawn-node", "sdk-mcp", "sdk-snapshot", "sdk-restart", "sdk-profile-plugin", "sdk-live", "runner", "direct"),
|
||||
choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-fs-search", "sdk-spawn-node", "sdk-mcp", "sdk-snapshot", "sdk-restart", "sdk-profile-plugin", "sdk-live", "runner", "direct"),
|
||||
default="all",
|
||||
)
|
||||
parser.add_argument("--exe", type=Path)
|
||||
@@ -788,10 +795,10 @@ def main() -> None:
|
||||
parser.error("--scenario sdk-profile-plugin requires --installed-wheel")
|
||||
if args.installed_wheel:
|
||||
args.exe = assert_installed_wheel_environment()
|
||||
if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-spawn-node", "sdk-snapshot", "sdk-restart", "runner", "direct"} and args.exe is None:
|
||||
if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-fs-search", "sdk-spawn-node", "sdk-snapshot", "sdk-restart", "runner", "direct"} and args.exe is None:
|
||||
parser.error("--exe is required for custom, minimal, fs-search, spawn-node, snapshot, restart, runner, and direct scenarios")
|
||||
if args.update_snapshots and args.scenario not in {"all", "sdk-minimal", "sdk-snapshot", "sdk-restart"}:
|
||||
parser.error("--update-snapshots requires --scenario sdk-minimal, sdk-snapshot, sdk-restart, or all")
|
||||
if args.update_snapshots and args.scenario not in {"all", "sdk-minimal", "sdk-minimal-in-history", "sdk-snapshot", "sdk-restart"}:
|
||||
parser.error("--update-snapshots requires --scenario sdk-minimal, sdk-minimal-in-history, sdk-snapshot, sdk-restart, or all")
|
||||
if args.exe is not None and not args.exe.is_file():
|
||||
parser.error(f"runtime executable does not exist: {args.exe}")
|
||||
|
||||
@@ -816,6 +823,9 @@ def main() -> None:
|
||||
if args.scenario in {"all", "sdk-minimal"}:
|
||||
assert args.exe is not None
|
||||
smoke_sdk_minimal(model.url, args.exe.resolve(), args.update_snapshots)
|
||||
if args.scenario in {"all", "sdk-minimal-in-history"}:
|
||||
assert args.exe is not None
|
||||
smoke_sdk_minimal(model.url, args.exe.resolve(), args.update_snapshots, in_history=True)
|
||||
if args.scenario in {"all", "sdk-fs-search"}:
|
||||
assert args.exe is not None
|
||||
smoke_sdk_fs_search(model.url, args.exe.resolve())
|
||||
@@ -911,12 +921,9 @@ def smoke_sdk_live() -> None:
|
||||
session_id = "installed-wheel-live-api"
|
||||
shell_tool = "pwsh" if IS_WINDOWS else "bash"
|
||||
create_prompt = (
|
||||
f"Use the {shell_tool} tool to create the file at the absolute path below with exactly one line "
|
||||
f"containing {LIVE_API_SENTINEL}. Then reply with exactly {LIVE_API_SENTINEL}.\n{marker}"
|
||||
)
|
||||
verify_prompt = (
|
||||
"Use a tool to read the file created in the previous turn. "
|
||||
f"If its only line is {LIVE_API_SENTINEL}, reply with exactly {LIVE_API_SENTINEL}."
|
||||
f"Use the {shell_tool} tool to create the file at the absolute path below with exact UTF-8 "
|
||||
f"content {LIVE_API_SENTINEL}, with no newline or byte-order mark. "
|
||||
f"Then reply with exactly {LIVE_API_SENTINEL}.\n{marker}"
|
||||
)
|
||||
with DeepSeekHarness(
|
||||
provider="deepseek-official",
|
||||
@@ -932,34 +939,58 @@ def smoke_sdk_live() -> None:
|
||||
request_timeout_seconds=180,
|
||||
) as harness:
|
||||
created = harness.run(create_prompt, session_id=session_id)
|
||||
verified = harness.run(verify_prompt, session_id=session_id)
|
||||
assert_live_turn("create", created)
|
||||
if not marker.is_file():
|
||||
raise AssertionError(f"create turn did not create {marker}")
|
||||
if marker.read_bytes() != LIVE_API_SENTINEL.encode("utf-8"):
|
||||
raise AssertionError(f"create turn wrote unexpected bytes to {marker}")
|
||||
|
||||
for label, result in (("create", created), ("verify", verified)):
|
||||
if result.finish_reason != "completed":
|
||||
event_types = [event.get("type") for event in result.events]
|
||||
turn_end_data = next(
|
||||
(event.get("data") for event in reversed(result.events) if event.get("type") == "turn/end"),
|
||||
None,
|
||||
# The challenge is absent from the prior turn and the verification prompt.
|
||||
challenge = secrets.token_hex(32).encode("ascii")
|
||||
marker.write_bytes(challenge)
|
||||
with tempfile.TemporaryDirectory(prefix="receipt-", dir=root) as receipt_directory:
|
||||
receipt = Path(receipt_directory) / "receipt.txt"
|
||||
verify_prompt = (
|
||||
"The file created in the previous turn has changed externally. "
|
||||
"Use a tool to read that same file and copy its exact current content to the "
|
||||
"new receipt path below, without changing the source file. "
|
||||
"Preserve every byte; do not add a newline or byte-order mark. "
|
||||
f"{LIVE_API_SENTINEL} is only the completion acknowledgement, "
|
||||
"not a claim about the source or receipt contents. "
|
||||
f"Then reply with exactly {LIVE_API_SENTINEL}.\n{receipt}"
|
||||
)
|
||||
turn_end = safe_turn_end(turn_end_data)
|
||||
raise AssertionError(
|
||||
f"{label} turn ended with {result.finish_reason!r}; "
|
||||
f"final={result.final_response!r}; turn_end={turn_end!r}; events={event_types}"
|
||||
)
|
||||
if not any(event.get("type") == "tool/call" for event in result.events):
|
||||
raise AssertionError(
|
||||
f"{label} turn made no model-requested tool call; "
|
||||
f"final={result.final_response!r}"
|
||||
)
|
||||
if result.final_response.strip() != LIVE_API_SENTINEL:
|
||||
raise AssertionError(f"{label} turn returned {result.final_response!r}")
|
||||
if not marker.is_file():
|
||||
raise AssertionError(f"real-model tool turn did not create {marker}")
|
||||
if marker.read_text(encoding="utf-8").splitlines() != [LIVE_API_SENTINEL]:
|
||||
raise AssertionError(f"real-model tool turn wrote unexpected text to {marker}")
|
||||
verified = harness.run(verify_prompt, session_id=session_id)
|
||||
assert_live_turn("verify", verified)
|
||||
if not receipt.is_file():
|
||||
raise AssertionError(f"verify turn did not create receipt {receipt}")
|
||||
if receipt.read_bytes() != challenge:
|
||||
raise AssertionError(f"verify turn wrote unexpected bytes to receipt {receipt}")
|
||||
if not marker.is_file() or marker.read_bytes() != challenge:
|
||||
raise AssertionError(f"verify turn changed source file {marker}")
|
||||
assert_zstd_session_log(sessions)
|
||||
|
||||
|
||||
def assert_live_turn(label: str, result: RunResult) -> None:
|
||||
"""Require completed model tool use and the exact smoke answer for each live turn."""
|
||||
if result.finish_reason != "completed":
|
||||
event_types = [event.get("type") for event in result.events]
|
||||
turn_end_data = next(
|
||||
(event.get("data") for event in reversed(result.events) if event.get("type") == "turn/end"),
|
||||
None,
|
||||
)
|
||||
turn_end = safe_turn_end(turn_end_data)
|
||||
raise AssertionError(
|
||||
f"{label} turn ended with {result.finish_reason!r}; "
|
||||
f"final={result.final_response!r}; turn_end={turn_end!r}; events={event_types}"
|
||||
)
|
||||
if not any(event.get("type") == "tool/call" for event in result.events):
|
||||
raise AssertionError(
|
||||
f"{label} turn made no model-requested tool call; "
|
||||
f"final={result.final_response!r}"
|
||||
)
|
||||
if result.final_response.strip() != LIVE_API_SENTINEL:
|
||||
raise AssertionError(f"{label} turn returned {result.final_response!r}")
|
||||
|
||||
def safe_turn_end(value: object) -> object:
|
||||
"""Project a live-provider failure without retaining credential-bearing text."""
|
||||
if not isinstance(value, dict):
|
||||
@@ -1045,7 +1076,9 @@ 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_minimal(base_url: str, executable: Path, update_snapshots: bool) -> None:
|
||||
def smoke_sdk_minimal(
|
||||
base_url: str, executable: Path, update_snapshots: bool, *, in_history: bool = False,
|
||||
) -> None:
|
||||
"""Exercise the shipped standalone minimal profile through the packaged executable."""
|
||||
from deepseek_harness import DeepSeekHarness
|
||||
|
||||
@@ -1053,10 +1086,21 @@ def smoke_sdk_minimal(base_url: str, executable: Path, update_snapshots: bool) -
|
||||
first_request = len(MockModelHandler.requests)
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-sdk-minimal-") as temporary:
|
||||
root = Path(temporary).resolve()
|
||||
editor_path = root / "created.txt"
|
||||
prompt = f"{MINIMAL_PROMPT}\n{MINIMAL_EDITOR_PATH_PREFIX}{editor_path}"
|
||||
dsh_home = root / "home"
|
||||
sessions = dsh_home / "sessions"
|
||||
patches = ()
|
||||
if in_history:
|
||||
patch = root / "in-history.patch.yml"
|
||||
patch.write_text(json.dumps([
|
||||
{"id": "llm-deepseek", "config": {"models": [
|
||||
{"id": "smoke-model", "systemPromptUpdate": "in-history"},
|
||||
]}},
|
||||
{"insert": [{
|
||||
"id": "in-history-prompt",
|
||||
"name": (Path(__file__).resolve().parent / "fixtures/python-sdk-in-history-prompt.mjs").as_uri(),
|
||||
}]},
|
||||
]))
|
||||
patches = (str(patch),)
|
||||
with DeepSeekHarness(
|
||||
provider="deepseek-official",
|
||||
model="smoke-model",
|
||||
@@ -1064,23 +1108,30 @@ def smoke_sdk_minimal(base_url: str, executable: Path, update_snapshots: bool) -
|
||||
dsh_bin=str(executable),
|
||||
dsh_home=str(dsh_home),
|
||||
profile="sdk-minimal",
|
||||
patches=patches,
|
||||
api_key="sk-keyless-smoke",
|
||||
base_url=base_url,
|
||||
request_timeout_seconds=60,
|
||||
) as harness:
|
||||
result = harness.run(prompt, session_id="minimal-agent-smoke")
|
||||
result = harness.run(MINIMAL_PROMPT, session_id="minimal-agent-smoke")
|
||||
|
||||
event_text = json.dumps(result.events)
|
||||
if MINIMAL_TEXT not in event_text:
|
||||
raise AssertionError(f"minimal agent 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, MINIMAL_TEXT, "COUNT=1", "COUNT=2")
|
||||
|
||||
files = build_minimal_snapshot_files(MockModelHandler.requests[first_request:], root)
|
||||
compare_snapshot_files(
|
||||
files, update_snapshots, MINIMAL_SNAPSHOT_DIRECTORY, MINIMAL_SNAPSHOT_FILENAMES,
|
||||
)
|
||||
requests = MockModelHandler.requests[first_request:]
|
||||
if in_history:
|
||||
logs = read_session_logs(sessions)
|
||||
files = build_in_history_snapshot_files(result, requests, logs[result.session_id])
|
||||
compare_snapshot_files(
|
||||
files, update_snapshots, IN_HISTORY_SNAPSHOT_DIRECTORY, IN_HISTORY_SNAPSHOT_FILENAMES,
|
||||
)
|
||||
else:
|
||||
files = build_minimal_snapshot_files(requests, root)
|
||||
compare_snapshot_files(
|
||||
files, update_snapshots, MINIMAL_SNAPSHOT_DIRECTORY, MINIMAL_SNAPSHOT_FILENAMES,
|
||||
)
|
||||
|
||||
|
||||
def smoke_sdk_fs_search(base_url: str, executable: Path) -> None:
|
||||
@@ -1178,6 +1229,7 @@ def smoke_sdk_mcp(base_url: str, executable: Path | None) -> None:
|
||||
|
||||
assert result.final_response == MCP_TEXT, result.final_response
|
||||
assert discovery_log.read_text().splitlines() == [
|
||||
"server/discover",
|
||||
"initialize",
|
||||
"notifications/initialized",
|
||||
"tools/list",
|
||||
@@ -1278,19 +1330,33 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool)
|
||||
sessions = dsh_home / "sessions"
|
||||
patch = write_advanced_profile_patch(root, "snapshot.patch.yml", sessions)
|
||||
feedback_patch = write_profile_patch(root, "feedback.patch.yml", sessions, [{"insert": [
|
||||
{"id": "snapshot-image-offload", "name": (
|
||||
Path(__file__).resolve().parent / "fixtures/python-snapshot-image-offload.mjs"
|
||||
).as_uri(), "config": {"parentSessionId": SNAPSHOT_SESSION_ID}},
|
||||
{"id": "snapshot-workflow-order", "name": (
|
||||
Path(__file__).resolve().parent / "fixtures/python-snapshot-workflow-order.mjs"
|
||||
).as_uri(), "config": {
|
||||
"parentSessionId": SNAPSHOT_SESSION_ID, "prompt": SNAPSHOT_WORKFLOW_CHILD_PROMPT,
|
||||
}},
|
||||
{"id": "snapshot-message-feedback", "name": "@deepseek-ai/dsh-message-feedback",
|
||||
"config": {"maxNoteBytes": 1024}},
|
||||
{"id": "snapshot-feedback-producer", "name": (
|
||||
Path(__file__).resolve().parent.parent / "snapshots/sdk/text-turn/feedback-producer.mjs"
|
||||
).as_uri()},
|
||||
]}])
|
||||
creation_patch = write_profile_patch(root, "creation.patch.yml", sessions, [
|
||||
{"insert": [{
|
||||
"id": "serial-created-fixture",
|
||||
"name": str(Path(__file__).resolve().parents[1] / "packages/core/agent-loop/tests/fixtures/serial-created.mjs"),
|
||||
}]},
|
||||
])
|
||||
with DeepSeekHarness(
|
||||
provider="deepseek-official",
|
||||
model="smoke-model",
|
||||
cwd=str(root),
|
||||
dsh_bin=str(executable),
|
||||
dsh_home=str(dsh_home),
|
||||
patches=(str(patch), str(feedback_patch)),
|
||||
patches=(str(patch), str(feedback_patch), str(creation_patch)),
|
||||
env={
|
||||
"DSH_PERMISSION_MODE": "danger-full-access",
|
||||
"DSH_TELEMETRY_DISABLED": "1",
|
||||
@@ -1302,15 +1368,36 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool)
|
||||
result = harness.run(SNAPSHOT_PROMPT, session_id=SNAPSHOT_SESSION_ID)
|
||||
|
||||
assert result.final_response == SNAPSHOT_FINAL_TEXT, result.final_response
|
||||
offloads = [event for event in result.events if event.get("type") == "image/offload"]
|
||||
if len(offloads) != 1 or "surfaceOp" in offloads[0]:
|
||||
raise AssertionError(f"advanced snapshot expected one standalone image offload: {offloads}")
|
||||
targets = offloads[0]["data"]["targets"]
|
||||
if len(targets) != 1 or targets[0]["imageIndexes"] != [0]:
|
||||
raise AssertionError(f"advanced snapshot selected unexpected image occurrences: {targets}")
|
||||
feedback_types = [event.get("type") for event in result.events
|
||||
if str(event.get("type")).startswith("feedback/")]
|
||||
if feedback_types != ["feedback/record", "feedback/message-put", "feedback/message-put", "feedback/message-delete"]:
|
||||
if feedback_types != ["feedback/record", "feedback/record", "feedback/message-put", "feedback/message-put", "feedback/message-delete"]:
|
||||
raise AssertionError(f"advanced snapshot did not exercise all feedback mutations: {feedback_types}")
|
||||
methods = [notification.method for notification in result.notifications]
|
||||
if methods.count("subagent.started") != 2 or methods.count("subagent.finished") != 2:
|
||||
raise AssertionError(f"advanced snapshot emitted unexpected subagent lifecycle: {methods}")
|
||||
if not any(event.get("type") == "tool/code-dispatch" for event in result.events):
|
||||
raise AssertionError("advanced snapshot emitted no tool/code-dispatch event")
|
||||
ptc_events = [event for event in result.events
|
||||
if event.get("type") in ("tool/ptc-dispatch-start", "tool/ptc-dispatch")]
|
||||
if [event["type"] for event in ptc_events] != ["tool/ptc-dispatch-start", "tool/ptc-dispatch"] * 2:
|
||||
raise AssertionError(f"advanced snapshot emitted unexpected PTC dispatch events: {ptc_events}")
|
||||
for index, event in enumerate(ptc_events):
|
||||
data = event["data"]
|
||||
identity = (data.get("rootCallId"), data.get("parentCallId"), data.get("subCallId"))
|
||||
root_call = "advanced-code" if index < 2 else "advanced-denied-ptc"
|
||||
if identity != (root_call, root_call, root_call + ":ptc:1"):
|
||||
raise AssertionError(f"advanced snapshot emitted unexpected PTC dispatch identity: {identity}")
|
||||
if {"description", "parameters", "schema"}.intersection(data):
|
||||
raise AssertionError("PTC binding schema entered the packaged SDK wire")
|
||||
errors = [event["data"]["error"] for event in result.events
|
||||
if event.get("type") in ("tool/result", "tool/ptc-dispatch") and "error" in event["data"]]
|
||||
assert errors == [{
|
||||
"name": "AutoReviewDeniedError", "code": "AUTO_REVIEW_DENIED", "reason": " transport raw\r\nreason ",
|
||||
}] * 2, errors
|
||||
|
||||
logs = read_session_logs(sessions)
|
||||
child_ids = snapshot_child_ids(result)
|
||||
@@ -1648,8 +1735,25 @@ def session_header_version(content: str, label: str) -> int:
|
||||
return version
|
||||
|
||||
|
||||
def assert_current_session_version(version: int, label: str) -> None:
|
||||
"""Require generated logs to use the source writer generation, independent of goldens."""
|
||||
source = Path(__file__).resolve().parents[1] / "packages/core/session/src/types.ts"
|
||||
declarations = re.findall(
|
||||
r"^export const SESSION_FORMAT_VERSION = ([0-9]+)$",
|
||||
source.read_text(encoding="utf-8"),
|
||||
re.MULTILINE,
|
||||
)
|
||||
if len(declarations) != 1:
|
||||
raise AssertionError(f"{source}: expected one literal SESSION_FORMAT_VERSION declaration")
|
||||
current_version = int(declarations[0])
|
||||
if version != current_version:
|
||||
raise AssertionError(
|
||||
f"{label}: expected current Session format v{current_version}, got v{version}",
|
||||
)
|
||||
|
||||
|
||||
def assert_persisted_session_version(path: Path, content: str) -> int:
|
||||
"""Require a raw persistence basename and header to name the same generation."""
|
||||
"""Require generated persistence filenames and headers to use the current generation."""
|
||||
filename_version = persisted_session_filename_version(path)
|
||||
if filename_version is None:
|
||||
raise AssertionError(f"non-canonical Session persistence filename: {path.name}")
|
||||
@@ -1659,6 +1763,7 @@ def assert_persisted_session_version(path: Path, content: str) -> int:
|
||||
f"{path.name}: filename declares Session format v{filename_version}, "
|
||||
f"header declares v{header_version}",
|
||||
)
|
||||
assert_current_session_version(header_version, path.name)
|
||||
return header_version
|
||||
|
||||
|
||||
@@ -1766,6 +1871,61 @@ def snapshot_child_ids(result: "RunResult") -> list[str]:
|
||||
return child_ids
|
||||
|
||||
|
||||
def build_in_history_snapshot_files(
|
||||
result: "RunResult",
|
||||
requests: list[dict[str, object]],
|
||||
log: list[dict[str, object]],
|
||||
) -> dict[str, str]:
|
||||
"""Assert live requests, SDK subscriptions, and persistence retain both prompts."""
|
||||
systems = [event for event in result.events if event.get("type") == "system/message"]
|
||||
assert len(systems) == 2, systems
|
||||
assert [event.get("surfaceOp") for event in systems] == ["append", "append"], systems
|
||||
prompts = [message_text(event["data"]["message"]["content"]) for event in systems]
|
||||
assert "Python SDK prompt version 1." in prompts[0], prompts
|
||||
assert "Python SDK prompt version 2." not in prompts[0], prompts
|
||||
assert "Python SDK prompt version 2." in prompts[1], prompts
|
||||
assert "Python SDK prompt version 1." not in prompts[1], prompts
|
||||
assert prompts[0] != prompts[1], prompts
|
||||
assert [event for event in log if event.get("type") == "system/message"] == systems
|
||||
subscribed = [
|
||||
notification.payload["event"]
|
||||
for notification in result.notifications
|
||||
if notification.method == "session.event"
|
||||
and notification.payload.get("event", {}).get("type") == "system/message"
|
||||
]
|
||||
assert subscribed == systems, subscribed
|
||||
contexts = [event["data"] for event in result.events if event.get("type") == "request/context"]
|
||||
assert contexts and all(context.get("systemPromptUpdate") == "in-history" for context in contexts), contexts
|
||||
assert len([event for event in result.events if event.get("type") == "request/header"]) == 1
|
||||
assert all(event.get("surfaceOp") in (None, "append") for event in result.events)
|
||||
first_tool = next(index for index, event in enumerate(result.events) if event.get("type") == "tool/result")
|
||||
assert result.events.index(systems[1]) > first_tool
|
||||
assert len(requests) == 3, requests
|
||||
request_prompts = []
|
||||
for index, request in enumerate(requests):
|
||||
messages = request["messages"]
|
||||
assert message_text(request.get("system")) == prompts[0]
|
||||
assert request["tools"] == requests[0]["tools"], "prompt update changed tool schemas"
|
||||
positions = [position for position, message in enumerate(messages) if message["role"] == "system"]
|
||||
texts = [message_text(request["system"]), *[
|
||||
message_text(messages[position]["content"]) for position in positions
|
||||
]]
|
||||
assert texts == (prompts[:1] if index == 0 else prompts), texts
|
||||
if index > 0:
|
||||
previous = messages[positions[0] - 1]
|
||||
assert previous["role"] == "user" and any(
|
||||
block.get("type") == "tool_result" for block in previous["content"]
|
||||
), messages
|
||||
request_prompts.append(texts)
|
||||
evidence = {
|
||||
"requestSystemPrompts": request_prompts,
|
||||
"systemMessageOperations": [event["surfaceOp"] for event in systems],
|
||||
"subscribedSystemPrompts": prompts,
|
||||
"requestContexts": contexts,
|
||||
}
|
||||
return {"prompt-history.json": json.dumps(evidence, indent=2, ensure_ascii=False) + "\n"}
|
||||
|
||||
|
||||
def build_minimal_snapshot_files(
|
||||
requests: list[dict[str, object]],
|
||||
cwd: Path,
|
||||
@@ -1785,6 +1945,7 @@ def build_minimal_snapshot_files(
|
||||
if not isinstance(messages, list):
|
||||
raise AssertionError(f"minimal model request has no messages: {body}")
|
||||
snapshot.append({
|
||||
"system": minimal_snapshot_text(body.get("system"), cwd),
|
||||
"tools": minimal_snapshot_text(body.get("tools"), cwd),
|
||||
"messages": [
|
||||
minimal_snapshot_message(message, cwd)
|
||||
@@ -1799,22 +1960,30 @@ def minimal_snapshot_message(message: object, cwd: Path) -> dict[str, object]:
|
||||
if not isinstance(message, dict):
|
||||
raise AssertionError(f"minimal model request has an invalid message: {message}")
|
||||
role = message.get("role")
|
||||
if role in ("system", "user"):
|
||||
if role == "system":
|
||||
return {"role": role, "text": minimal_snapshot_text(message_text(message.get("content")), cwd)}
|
||||
if role == "user":
|
||||
content = []
|
||||
for block in message.get("content", []):
|
||||
if block.get("type") == "tool_result":
|
||||
content.append({"type": "tool_result", "tool_use_id": block.get("tool_use_id"), "content": "{{tool-result}}"})
|
||||
elif block.get("type") == "text":
|
||||
content.append(minimal_snapshot_text(block, cwd))
|
||||
else:
|
||||
raise AssertionError(f"minimal user message has unexpected content: {block}")
|
||||
return {"role": role, "content": content}
|
||||
if role == "assistant":
|
||||
calls = message.get("tool_calls")
|
||||
calls = message.get("content")
|
||||
if not isinstance(calls, list):
|
||||
raise AssertionError(f"minimal assistant message has no tool calls: {message}")
|
||||
return {
|
||||
"role": role,
|
||||
"toolCalls": [
|
||||
{"id": call.get("id"), "name": (call.get("function") or {}).get("name")}
|
||||
{"id": call.get("id"), "name": call.get("name")}
|
||||
for call in calls
|
||||
if isinstance(call, dict)
|
||||
if isinstance(call, dict) and call.get("type") == "tool_use"
|
||||
],
|
||||
}
|
||||
if role == "tool":
|
||||
return {"role": role, "toolCallId": message.get("tool_call_id"), "text": "{{tool-result}}"}
|
||||
raise AssertionError(f"minimal model request has an unexpected message role: {message}")
|
||||
|
||||
|
||||
@@ -1932,6 +2101,7 @@ def build_restart_snapshot_files(
|
||||
request_value = [
|
||||
{
|
||||
"model": request.get("model"),
|
||||
"system": "{{system}}" if request.get("system") else None,
|
||||
"messages": restart_request_messages(request),
|
||||
"toolNames": sorted(advertised_tool_names(request)),
|
||||
}
|
||||
@@ -2026,6 +2196,10 @@ def normalize_snapshot_value(
|
||||
}
|
||||
if normalized.get("type") == "session" and "createdAt" in normalized:
|
||||
normalized["createdAt"] = 0
|
||||
if normalized.get("type") == "subagent/catalog":
|
||||
data = normalized.get("data")
|
||||
if isinstance(data, dict) and "childCreatedAt" in data:
|
||||
data["childCreatedAt"] = 0
|
||||
if "seq" in normalized and "time" in normalized:
|
||||
normalized["time"] = 0
|
||||
if normalized.get("type") in ("assistant/message", "assistant/attempt"):
|
||||
@@ -2042,7 +2216,7 @@ def normalize_snapshot_value(
|
||||
dt = member.get("dt")
|
||||
if isinstance(dt, list):
|
||||
member["dt"] = [0] * len(dt)
|
||||
if isinstance(normalized.get("id"), str) and normalized.get("role") in ("assistant", "user"):
|
||||
if isinstance(normalized.get("id"), str) and normalized.get("role") in ("assistant", "system", "user"):
|
||||
if not normalized["id"].startswith("{{message:"):
|
||||
normalized["id"] = "{{messageId}}"
|
||||
if normalized.get("type") in ("feedback/message-put", "feedback/message-delete"):
|
||||
@@ -2054,11 +2228,12 @@ def normalize_snapshot_value(
|
||||
item["createdAt"] = 0
|
||||
item["updatedAt"] = 0
|
||||
scrub_snapshot_header(normalized)
|
||||
scrub_snapshot_system_message(normalized)
|
||||
return normalized
|
||||
|
||||
|
||||
def scrub_snapshot_header(value: dict[object, object]) -> None:
|
||||
"""Tokenize full request-header bulk while retaining tool names."""
|
||||
"""Tokenize full request-header tool schemas while retaining tool names."""
|
||||
data = value.get("data")
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
@@ -2066,8 +2241,6 @@ def scrub_snapshot_header(value: dict[object, object]) -> None:
|
||||
header = data.get("header")
|
||||
if not isinstance(header, dict):
|
||||
return
|
||||
if "system" in header:
|
||||
header["system"] = "{{system}}"
|
||||
tools = header.get("tools")
|
||||
if isinstance(tools, list):
|
||||
header["tools"] = [
|
||||
@@ -2076,6 +2249,20 @@ def scrub_snapshot_header(value: dict[object, object]) -> None:
|
||||
]
|
||||
|
||||
|
||||
def scrub_snapshot_system_message(value: dict[object, object]) -> None:
|
||||
"""Tokenize the rendered prompt text of a `system/message` surface node."""
|
||||
if value.get("type") != "system/message":
|
||||
return
|
||||
data = value.get("data")
|
||||
message = data.get("message") if isinstance(data, dict) else None
|
||||
content = message.get("content") if isinstance(message, dict) else None
|
||||
if not isinstance(content, list):
|
||||
return
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
block["text"] = "{{system}}"
|
||||
|
||||
|
||||
def render_jsonl(records: list[object]) -> str:
|
||||
"""Render parsed JSON values as compact, newline-terminated JSONL."""
|
||||
return "".join(
|
||||
@@ -2093,7 +2280,7 @@ def project_session_snapshot(records: list[dict[str, object]]) -> list[dict[str,
|
||||
return projected
|
||||
|
||||
|
||||
SESSION_FORMAT_PROVENANCE = "{{sessionFormatVersion}}"
|
||||
SESSION_FORMAT_TOKEN = "{{sessionFormatVersion}}"
|
||||
|
||||
|
||||
def expand_snapshot_stream_member(member: object) -> list[dict[str, object]]:
|
||||
@@ -2182,7 +2369,7 @@ def normalize_session_format_comparison(
|
||||
value: object,
|
||||
source_session_version: int | None = None,
|
||||
) -> object:
|
||||
"""Canonicalize only generation provenance that differs across immutable Session files."""
|
||||
"""Canonicalize only generation metadata that differs across immutable Session files."""
|
||||
if isinstance(value, list):
|
||||
return [
|
||||
normalize_session_format_comparison(expanded, source_session_version)
|
||||
@@ -2197,7 +2384,7 @@ def normalize_session_format_comparison(
|
||||
for key, item in value.items()
|
||||
}
|
||||
if normalized.get("type") == "session" and "version" in normalized:
|
||||
normalized["version"] = SESSION_FORMAT_PROVENANCE
|
||||
normalized["version"] = SESSION_FORMAT_TOKEN
|
||||
normalized.setdefault("isSeeded", False)
|
||||
ordered_header = {
|
||||
key: normalized[key]
|
||||
@@ -2213,24 +2400,11 @@ def normalize_session_format_comparison(
|
||||
normalized.pop("time", None)
|
||||
if source_session_version == 1 and normalized.get("type") == "assistant/message":
|
||||
normalized.pop("sourceEventSeqs", None)
|
||||
if normalized.get("type") == "session-log-deepseek/delivery-accepted":
|
||||
data = normalized.get("data")
|
||||
if isinstance(data, dict):
|
||||
data.pop("throughSeq", None)
|
||||
data.pop("sessionFormatVersion", None)
|
||||
data["sessionFormatVersion"] = SESSION_FORMAT_PROVENANCE
|
||||
if normalized.get("kind") == "session-reference":
|
||||
references = normalized.get("references")
|
||||
if isinstance(references, list):
|
||||
for reference in references:
|
||||
if isinstance(reference, dict):
|
||||
reference.pop("capturedFormatVersion", None)
|
||||
reference["capturedFormatVersion"] = SESSION_FORMAT_PROVENANCE
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_snapshot_comparison_text(name: str, content: str) -> str:
|
||||
"""Normalize Session generation provenance only while comparing committed expected outputs."""
|
||||
"""Normalize Session generation metadata only while comparing committed expected outputs."""
|
||||
if name.startswith("session") and name.endswith(".jsonl"):
|
||||
parsed = [json.loads(line) for line in content.splitlines() if line]
|
||||
header = parsed[0] if parsed else None
|
||||
@@ -2258,10 +2432,18 @@ def compare_snapshot_files(
|
||||
directory: Path,
|
||||
filenames: tuple[str, ...],
|
||||
) -> None:
|
||||
"""Write or exactly compare one scenario's expected snapshot files."""
|
||||
"""Compare ordered artifact roles and Session content across generations, or write generated filenames."""
|
||||
scenario = directory.name
|
||||
if tuple(files) != filenames:
|
||||
|
||||
def role_name(name: str) -> str:
|
||||
parsed = parse_snapshot_session_filename(name)
|
||||
return name if parsed is None else snapshot_session_filename(parsed[0], 0)
|
||||
|
||||
if tuple(map(role_name, files)) != tuple(map(role_name, filenames)):
|
||||
raise AssertionError(f"{scenario} snapshot builder produced {tuple(files)}, expected {filenames}")
|
||||
for name, content in files.items():
|
||||
if parse_snapshot_session_filename(name) is not None:
|
||||
assert_current_session_version(session_header_version(content, name), name)
|
||||
if update:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
for name, content in files.items():
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import * as adapter from './snapshot-shell-path.ts'
|
||||
|
||||
it.skipIf(process.platform === 'win32')('translates only the exact fixture command and preserves real shell failures', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'snapshot-shell-'))
|
||||
const ctx = new Context()
|
||||
const disposers: (() => Promise<void>)[] = []
|
||||
try {
|
||||
const subprocess = ctx.plugin(LocalSubprocessRuntime)
|
||||
disposers.push(() => subprocess.dispose())
|
||||
await subprocess
|
||||
const bash = ctx.plugin(LocalBashExecutor)
|
||||
disposers.push(() => bash.dispose())
|
||||
await bash
|
||||
const command = "printf 'actual bytes' > /fixture/recorded.txt"
|
||||
const livePath = join(root, "space and 'quote.txt")
|
||||
const fork = ctx.plugin(adapter, { command, recordedPath: '/fixture/recorded.txt', livePath })
|
||||
disposers.push(() => fork.dispose())
|
||||
await fork
|
||||
const outcome = await ctx.shell.run(ctx.shell.resolve({ command }))
|
||||
expect(outcome.timedOut).toBe(false)
|
||||
expect(outcome.exitCode).toBe(0)
|
||||
expect(await readFile(livePath, 'utf8')).toBe('actual bytes')
|
||||
const untouched = await ctx.shell.run(ctx.shell.resolve({ command: "printf '/fixture/recorded.txt'; exit 7" }))
|
||||
expect(untouched.exitCode).toBe(7)
|
||||
expect(untouched.stdout.text).toBe('/fixture/recorded.txt')
|
||||
await fork.dispose()
|
||||
await rm(livePath)
|
||||
const restored = await ctx.shell.run(ctx.shell.resolve({ command }))
|
||||
expect(restored.exitCode).not.toBe(0)
|
||||
await expect(readFile(livePath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
} finally {
|
||||
for (const dispose of disposers.reverse()) await dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Exact recorded shell-command path translation; execution and reported outcomes remain real. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-shell'
|
||||
|
||||
export const name = 'snapshot-shell-path'
|
||||
export const inject = ['shell']
|
||||
|
||||
/** One recorded command and its isolated live filesystem target. */
|
||||
export interface Config {
|
||||
command: string
|
||||
recordedPath: string
|
||||
livePath: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate one exact fixture command after tool logging and approval.
|
||||
* @param ctx - profile context with its real shell executor.
|
||||
* @param config - recorded command/path and allocated live target.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const shell = ctx.shell
|
||||
// oxlint-disable-next-line typescript/unbound-method -- preserve method identity for restoration; calls bind the receiver.
|
||||
const run = shell.run
|
||||
ctx.effect(() => {
|
||||
shell.run = spec => run.call(shell, spec.command === config.command
|
||||
? { ...spec, command: spec.command.replaceAll(config.recordedPath, "'" + config.livePath.replaceAll("'", "'\"'\"'") + "'") }
|
||||
: spec)
|
||||
return () => { shell.run = run }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { LocalSpillStore } from '@deepseek-ai/dsh-spill-local'
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import * as locators from './snapshot-spill-locators.ts'
|
||||
|
||||
it.each([false, true])('keeps concurrent physical spill files private while retaining logical locator length and bytes (reverse allocation=%s)', async (reverseAllocation) => {
|
||||
const roots: string[] = []
|
||||
const disposers: (() => Promise<void>)[] = []
|
||||
const secondAllocated = Promise.withResolvers<undefined>()
|
||||
try {
|
||||
const runs = await Promise.all([0, 1].map(async (index) => {
|
||||
if (reverseAllocation && index === 0) await secondAllocated.promise
|
||||
const root = await mkdtemp(join(tmpdir(), 'snapshot-locator-'))
|
||||
roots.push(root)
|
||||
if (index === 1) secondAllocated.resolve(undefined)
|
||||
const ctx = new Context()
|
||||
const storeFiber = ctx.plugin(LocalSpillStore, { root, cleanupPeriodDays: 0 })
|
||||
disposers.push(() => storeFiber.dispose())
|
||||
await storeFiber
|
||||
const fsFiber = ctx.plugin(LocalFileSystem, { cwd: root })
|
||||
disposers.push(() => fsFiber.dispose())
|
||||
await fsFiber
|
||||
|
||||
|
||||
const locatorRoot = resolve('/tmp/dsh-acp-snap-123456789')
|
||||
const fork = ctx.plugin(locators, { root, locatorRoot })
|
||||
disposers.push(() => fork.dispose())
|
||||
await fork
|
||||
const content = `physical UTF-8 内容 ${index}`
|
||||
const ref = await ctx.spillStore.saveText({
|
||||
owner: { sessionId: SessionId('same-session') },
|
||||
source: { kind: 'tool', toolName: 'bash', callId: ToolCallId('same-call'), label: 'result' },
|
||||
suggestedName: 'bash.txt', content,
|
||||
})
|
||||
expect(ref.bytes).toBe(Buffer.byteLength(content))
|
||||
expect(ref.locator.startsWith(locatorRoot)).toBe(true)
|
||||
const target = await ctx.fs.resolve(ref.locator)
|
||||
expect(target.displayPath).toBe(ref.locator)
|
||||
const physicalPath = ctx.fs.processPath(target)
|
||||
expect(physicalPath.startsWith(await realpath(root))).toBe(true)
|
||||
expect(await readFile(physicalPath, 'utf8')).toBe(content)
|
||||
expect(await ctx.fs.readText(target)).toBe(content)
|
||||
await expect(ctx.fs.resolve(join(locatorRoot, 'missing.txt'))).rejects.toThrow('not saved by this run')
|
||||
const ordinary = join(root, 'ordinary.txt')
|
||||
await writeFile(ordinary, 'ordinary')
|
||||
expect(await ctx.fs.readText(await ctx.fs.resolve(ordinary))).toBe('ordinary')
|
||||
await fork.dispose()
|
||||
const restored = await ctx.fs.resolve(ref.locator)
|
||||
expect(ctx.fs.processPath(restored)).not.toBe(physicalPath)
|
||||
expect(await ctx.fs.stat(restored)).toBeUndefined()
|
||||
return { root, physicalPath, locator: ref.locator, content }
|
||||
}))
|
||||
expect(runs[0]?.physicalPath).not.toBe(runs[1]?.physicalPath)
|
||||
expect(runs[0]?.locator.length).toBe(runs[1]?.locator.length)
|
||||
// Promise.all preserves input order; allocation completion order may differ.
|
||||
await rm(runs[0]!.root, { recursive: true, force: true })
|
||||
expect(await readFile(runs[1]?.physicalPath as string, 'utf8')).toBe(runs[1]?.content)
|
||||
} finally {
|
||||
for (const dispose of disposers.reverse()) await dispose()
|
||||
await Promise.all(roots.map(root => rm(root, { recursive: true, force: true })))
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
/** Fixture-only logical locators over real local spill files; preview budgets retain recorded path lengths. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { join, relative, resolve, sep } from 'node:path'
|
||||
import type { SpillLocator } from '@deepseek-ai/dsh-spill'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
|
||||
export const name = 'snapshot-spill-locators'
|
||||
export const inject = ['spillStore', 'fs']
|
||||
|
||||
/** Live storage and recorded locator prefixes supplied by the snapshot owner. */
|
||||
export interface Config {
|
||||
root: string
|
||||
locatorRoot: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate only locators returned by this fixture's real spill backend.
|
||||
* @param ctx - profile context with real spill and filesystem providers.
|
||||
* @param config - per-run storage and stable logical prefix.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const root = resolve(config.root)
|
||||
const locatorRoot = resolve(config.locatorRoot)
|
||||
const paths = new Map<string, string>()
|
||||
const store = ctx.spillStore
|
||||
const fs = ctx.fs
|
||||
// oxlint-disable-next-line typescript/unbound-method -- preserve method identity for restoration; calls bind the receiver.
|
||||
const saveText = store.saveText
|
||||
// oxlint-disable-next-line typescript/unbound-method -- preserve method identity for restoration; calls bind the receiver.
|
||||
const resolvePath = fs.resolve
|
||||
ctx.effect(() => {
|
||||
store.saveText = async (input) => {
|
||||
const saved = await saveText.call(store, input)
|
||||
const suffix = relative(root, saved.locator)
|
||||
if (suffix.startsWith('..') || resolve(root, suffix) !== saved.locator) {
|
||||
throw new Error('snapshot spill backend returned a locator outside its live root')
|
||||
}
|
||||
const locator = join(locatorRoot, suffix) as SpillLocator
|
||||
paths.set(locator, saved.locator)
|
||||
return { ...saved, locator }
|
||||
}
|
||||
fs.resolve = async (path, opts) => {
|
||||
const live = paths.get(path)
|
||||
if (live !== undefined) {
|
||||
const target = await resolvePath.call(fs, live, opts)
|
||||
return { ...target, displayPath: path }
|
||||
}
|
||||
if (path.startsWith(locatorRoot + sep)) {
|
||||
throw new Error('snapshot spill locator was not saved by this run')
|
||||
}
|
||||
return resolvePath.call(fs, path, opts)
|
||||
}
|
||||
return () => {
|
||||
store.saveText = saveText
|
||||
fs.resolve = resolvePath
|
||||
paths.clear()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { dirname, join, parse } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { SandboxedFileSystem } from '@deepseek-ai/dsh-fs-sandbox'
|
||||
import { canonicalPath } from '@deepseek-ai/dsh-sandbox'
|
||||
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { assertWorkspaceOutsideTemp, outsideTempWorkspaceParent } from './snapshot-workspace-parent.ts'
|
||||
|
||||
// Host disk exhaustion is not simulated: placement and the real write fence are the regression oracles.
|
||||
describe('snapshot workspace parent', () => {
|
||||
// Windows directory permissions and root bypass do not enforce POSIX write bits.
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('uses home when the temp parent is not writable', async () => {
|
||||
const base = await mkdtemp(join(outsideTempWorkspaceParent(), '.dsh-snapshot-readonly-'))
|
||||
try {
|
||||
const temporary = join(base, '_temp')
|
||||
await mkdir(temporary)
|
||||
await chmod(base, 0o500)
|
||||
expect(outsideTempWorkspaceParent(temporary)).toBe(homedir())
|
||||
} finally {
|
||||
await chmod(base, 0o700)
|
||||
await rm(base, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('uses home when a temp sibling would require a system directory or inherit its grant', () => {
|
||||
expect(outsideTempWorkspaceParent('/tmp')).toBe(homedir())
|
||||
expect(outsideTempWorkspaceParent(parse(tmpdir()).root)).toBe(homedir())
|
||||
expect(outsideTempWorkspaceParent(join(canonicalPath('/tmp'), 'runner', '_temp'))).toBe(homedir())
|
||||
})
|
||||
|
||||
it('rejects automatically writable temporary workspaces, including symlink aliases', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-snapshot-parent-'))
|
||||
try {
|
||||
expect(() => { assertWorkspaceOutsideTemp(root) }).toThrow('must be outside temporary writable root')
|
||||
const alias = join(root, 'alias')
|
||||
await symlink(tmpdir(), alias, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
expect(() => { assertWorkspaceOutsideTemp(alias) }).toThrow('must be outside temporary writable root')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('allows the allocated workspace but denies sibling writes and cleans the complete tree', async () => {
|
||||
const base = await mkdtemp(join(outsideTempWorkspaceParent(), 'dsh-snapshot-parent-'))
|
||||
const ctx = new Context()
|
||||
const fibers: Awaited<ReturnType<Context['plugin']>>[] = []
|
||||
try {
|
||||
const temporary = join(base, '_temp')
|
||||
await mkdir(temporary)
|
||||
expect(outsideTempWorkspaceParent(temporary)).toBe(canonicalPath(base))
|
||||
const workspace = await mkdtemp(join(outsideTempWorkspaceParent(temporary), 'workspace-'))
|
||||
const outside = join(base, 'outside.txt')
|
||||
assertWorkspaceOutsideTemp(workspace)
|
||||
expect(dirname(workspace)).toBe(canonicalPath(base))
|
||||
expect((await stat(workspace)).dev).toBe((await stat(temporary)).dev)
|
||||
fibers.push(await ctx.plugin(SessionProjectionRegistry))
|
||||
fibers.push(await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: workspace }))
|
||||
fibers.push(await ctx.plugin(SandboxedFileSystem, { cwd: workspace }))
|
||||
const inside = join(workspace, 'inside.txt')
|
||||
await ctx.fs.writeText(await ctx.fs.resolve(inside), 'inside')
|
||||
expect(await readFile(inside, 'utf8')).toBe('inside')
|
||||
await expect(ctx.fs.writeText(await ctx.fs.resolve(outside), 'outside'))
|
||||
.rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
expect(existsSync(outside)).toBe(false)
|
||||
} finally {
|
||||
try {
|
||||
for (const fiber of fibers.reverse()) await fiber.dispose()
|
||||
} finally {
|
||||
await rm(base, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
expect(existsSync(base)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
/** Workspace placement for snapshots that must not inherit temporary-directory write grants. */
|
||||
|
||||
import { accessSync, constants } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { dirname, isAbsolute, parse, relative, sep } from 'node:path'
|
||||
import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
function contains(root: string, path: string): boolean {
|
||||
const suffix = relative(root, path)
|
||||
return suffix === '' || suffix !== '..' && !suffix.startsWith('..' + sep) && !isAbsolute(suffix)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a writable temp sibling parent, or home when that parent is unavailable for writes.
|
||||
* The caller atomically allocates and owns cleanup of the generated workspace.
|
||||
* @param tempRoot - platform temporary directory.
|
||||
* @param home - fallback when temp siblings require a system directory or a non-writable parent.
|
||||
* @returns existing parent outside the automatic temporary write grants.
|
||||
*/
|
||||
export function outsideTempWorkspaceParent(tempRoot = tmpdir(), home = homedir()): string {
|
||||
const temporary = canonicalPath(tempRoot)
|
||||
const systemTemporary = canonicalPath('/tmp')
|
||||
const parent = dirname(temporary)
|
||||
if (temporary === systemTemporary || parent === parse(parent).root || contains(systemTemporary, parent)) return home
|
||||
try {
|
||||
accessSync(parent, constants.W_OK)
|
||||
} catch (error) {
|
||||
// A non-writable parent cannot host siblings; allocation failures still propagate from mkdtemp.
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') return home
|
||||
throw error
|
||||
}
|
||||
return parent
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a workspace whose write could succeed without the session's workspace grant.
|
||||
* @param cwd - allocated workspace to check, with symlinks resolved before comparison.
|
||||
* @returns nothing; throws when an automatic temporary write grant contains the workspace.
|
||||
*/
|
||||
export function assertWorkspaceOutsideTemp(cwd: string): void {
|
||||
const path = canonicalPath(cwd)
|
||||
for (const root of writableRoots({ mode: 'workspace-write', workspaceRoot: '/tmp' })) {
|
||||
if (contains(root, path)) throw new Error('snapshot workspace ' + cwd + ' must be outside temporary writable root ' + root)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,22 @@
|
||||
{"type":"session","version":3,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","isSeeded":false,"origin":"subagent","delegationDepth":1}
|
||||
{"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}}
|
||||
{"type":"approval/policy","data":{"policy":"never","source":"delegation"}}
|
||||
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin","plugin":"serial-created-fixture"}}]}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Check direct child"}}
|
||||
{"type":"step/start","data":{"turn":1,"step":1}}
|
||||
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin","plugin":"serial-created-fixture"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","reasoningEffort":"high","maxTokens":256000},"adapterDefaults":{"maxTokens":true},"tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"initial"}}
|
||||
{"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}}
|
||||
{"type":"session/title","data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[12],"source":{"kind":"fallback"}}}
|
||||
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{child-1}}","sessionFormatVersion":3,"throughSeq":16}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["DIRECT_CHILD_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,22 @@
|
||||
{"type":"session","version":3,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","isSeeded":false,"origin":"subagent","delegationDepth":1}
|
||||
{"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}}
|
||||
{"type":"approval/policy","data":{"policy":"never","source":"delegation"}}
|
||||
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin","plugin":"serial-created-fixture"}}]}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn"}}
|
||||
{"type":"step/start","data":{"turn":1,"step":1}}
|
||||
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin","plugin":"serial-created-fixture"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","reasoningEffort":"high","maxTokens":256000},"adapterDefaults":{"maxTokens":true},"tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"initial"}}
|
||||
{"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}}
|
||||
{"type":"session/title","data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[12],"source":{"kind":"fallback"}}}
|
||||
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{child-2}}","sessionFormatVersion":3,"throughSeq":16}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["WORKFLOW_CHILD_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,94 @@
|
||||
{"type":"session","version":3,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
|
||||
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
|
||||
{"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
|
||||
{"type":"approval/policy","data":{"policy":"never"}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin","plugin":"serial-created-fixture"}}]}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","data":{"turn":1,"step":1}}
|
||||
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin","plugin":"serial-created-fixture"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}}],"source":{"kind":"plugin","plugin":"python-snapshot-image-offload"}},"surfaceOp":"append"}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","subagent","workflow"]},"reason":"initial"}}
|
||||
{"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}}
|
||||
{"type":"session/title","data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[11],"source":{"kind":"fallback"}}}
|
||||
{"type":"assistant/attempt","data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"code":"IMAGE_OFFLOAD_REQUIRED","message":"authored image budget failure","offloadImages":1}}}}]}}
|
||||
{"type":"image/offload","data":{"targets":[{"seq":13,"imageIndexes":[0]}]}}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","subagent","workflow"]},"reason":"series"}}
|
||||
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":19}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n ctx.on('tools/pre-execute', (exec, next) => {\\n if (exec.name !== 'snapshot_double' || exec.arguments.value !== -1) return next()\\n return {\\n kind: 'deny',\\n reason: 'Auto review rejected tool \\\"snapshot_double\\\"; its body was not executed',\\n info: {\\n name: 'AutoReviewDeniedError', code: 'AUTO_REVIEW_DENIED',\\n reason: ' transport raw\\\\r\\\\nreason ',\\n },\\n }\\n })\\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-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-define","name":"cordis_define","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-define","args":["{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n ctx.on('tools/pre-execute', (exec, next) => {\\n if (exec.name !== 'snapshot_double' || exec.arguments.value !== -1) return next()\\n return {\\n kind: 'deny',\\n reason: 'Auto review rejected tool \\\"snapshot_double\\\"; its body was not executed',\\n info: {\\n name: 'AutoReviewDeniedError', code: 'AUTO_REVIEW_DENIED',\\n reason: ' transport raw\\\\r\\\\nreason ',\\n },\\n }\\n })\\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":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n ctx.on('tools/pre-execute', (exec, next) => {\\n if (exec.name !== 'snapshot_double' || exec.arguments.value !== -1) return next()\\n return {\\n kind: 'deny',\\n reason: 'Auto review rejected tool \\\"snapshot_double\\\"; its body was not executed',\\n info: {\\n name: 'AutoReviewDeniedError', code: 'AUTO_REVIEW_DENIED',\\n reason: ' transport raw\\\\r\\\\nreason ',\\n },\\n }\\n })\\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":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"advanced-define","name":"cordis_define","arguments":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n ctx.on('tools/pre-execute', (exec, next) => {\\n if (exec.name !== 'snapshot_double' || exec.arguments.value !== -1) return next()\\n return {\\n kind: 'deny',\\n reason: 'Auto review rejected tool \\\"snapshot_double\\\"; its body was not executed',\\n info: {\\n name: 'AutoReviewDeniedError', code: 'AUTO_REVIEW_DENIED',\\n reason: ' transport raw\\\\r\\\\nreason ',\\n },\\n }\\n })\\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","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-define"},"content":[{"type":"tool-result","toolCallId":"advanced-define","content":[{"type":"text","text":"Defined snap-1/pkg-1 (Snapshot Double); it is not running yet. Use cordis_run to activate this Package."}],"isError":false}],"role":"user","id":"{{messageId}}"},"meta":{"pluginId":"snap-1","packageId":"pkg-1"}},"sourceEventSeqs":[22],"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","data":{"turn":1,"step":2}}
|
||||
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":25}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-run","name":"cordis_run","arguments":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-run","name":"cordis_run","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-run","args":["{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-run","name":"cordis_run","arguments":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":2,"callId":"advanced-run","name":"cordis_run","arguments":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-run"},"content":[{"type":"tool-result","toolCallId":"advanced-run","content":[{"type":"text","text":"snap-1/pkg-1 is running (run-1)."}],"isError":false}],"role":"user","id":"{{messageId}}"},"meta":{"pluginId":"snap-1","packageId":"pkg-1","pluginRunId":"run-1"}},"sourceEventSeqs":[28],"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":2}}
|
||||
{"type":"step/start","data":{"turn":1,"step":3}}
|
||||
{"type":"system/message","data":{"turn":1,"step":3,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{messageId}}"}},"sourceEventSeqs":[9],"surfaceOp":{"op":"replace","startSeq":9,"endSeq":9}}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"change","startsSeries":true}}
|
||||
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":33}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":3,"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-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-code","name":"run_code","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-code","args":["{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"]},{"type":"chunk","time":0,"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":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":3,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}
|
||||
{"type":"tool/ptc-dispatch-start","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:ptc:1","name":"snapshot_double","arguments":{"value":21}}}
|
||||
{"type":"tool/ptc-dispatch","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:ptc:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"content":[{"type":"text","text":"42"}]}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":3,"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":[36],"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":3}}
|
||||
{"type":"step/start","data":{"turn":1,"step":4}}
|
||||
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":41}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-denied-native","name":"snapshot_double","arguments":"{\"value\": -1}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-denied-native","name":"snapshot_double","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-denied-native","args":["{\"value\": -1}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-denied-native","name":"snapshot_double","arguments":"{\"value\": -1}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":4,"callId":"advanced-denied-native","name":"snapshot_double","arguments":"{\"value\": -1}"}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-denied-native"},"content":[{"type":"tool-result","toolCallId":"advanced-denied-native","content":[{"type":"text","text":"Error: Auto review rejected tool \"snapshot_double\"; its body was not executed"}],"isError":true}],"role":"user","id":"{{messageId}}"},"error":{"name":"AutoReviewDeniedError","code":"AUTO_REVIEW_DENIED","reason":" transport raw\r\nreason "}},"sourceEventSeqs":[44],"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":4}}
|
||||
{"type":"step/start","data":{"turn":1,"step":5}}
|
||||
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":47}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-denied-ptc","name":"run_code","arguments":"{\"code\": \"try { await tools.snapshot_double({ value: -1 }) } catch (error) { return error.message }\", \"description\": \"Catch a structured inner tool denial\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-denied-ptc","name":"run_code","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-denied-ptc","args":["{\"code\": \"try { await tools.snapshot_double({ value: -1 }) } catch (error) { return error.message }\", \"description\": \"Catch a structured inner tool denial\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-denied-ptc","name":"run_code","arguments":"{\"code\": \"try { await tools.snapshot_double({ value: -1 }) } catch (error) { return error.message }\", \"description\": \"Catch a structured inner tool denial\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":5,"callId":"advanced-denied-ptc","name":"run_code","arguments":"{\"code\": \"try { await tools.snapshot_double({ value: -1 }) } catch (error) { return error.message }\", \"description\": \"Catch a structured inner tool denial\"}"}}
|
||||
{"type":"tool/ptc-dispatch-start","data":{"rootCallId":"advanced-denied-ptc","parentCallId":"advanced-denied-ptc","subCallId":"advanced-denied-ptc:ptc:1","name":"snapshot_double","arguments":{"value":-1}}}
|
||||
{"type":"tool/ptc-dispatch","data":{"rootCallId":"advanced-denied-ptc","parentCallId":"advanced-denied-ptc","subCallId":"advanced-denied-ptc:ptc:1","name":"snapshot_double","arguments":{"value":-1},"isError":true,"error":{"name":"AutoReviewDeniedError","code":"AUTO_REVIEW_DENIED","reason":" transport raw\r\nreason "},"content":[{"type":"text","text":"Error: Auto review rejected tool \"snapshot_double\"; its body was not executed"}]}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-denied-ptc"},"content":[{"type":"tool-result","toolCallId":"advanced-denied-ptc","content":[{"type":"text","text":"Auto review rejected tool \"snapshot_double\"; its body was not executed"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[50],"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":5}}
|
||||
{"type":"step/start","data":{"turn":1,"step":6}}
|
||||
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":55}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":6,"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-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-direct-child","name":"subagent","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-direct-child","args":["{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"]},{"type":"chunk","time":0,"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":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":6,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}
|
||||
{"type":"subagent/catalog","data":{"version":0,"childId":"{{child-1}}","childCreatedAt":0,"mode":"one-shot","label":"Check direct child"}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":6,"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":[58],"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":6}}
|
||||
{"type":"step/start","data":{"turn":1,"step":7}}
|
||||
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":62}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":7,"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-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-workflow","name":"workflow","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-workflow","args":["{\"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":"chunk","time":0,"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":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":7,"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-workflow/run-start","data":{"runId":"{{workflow-run}}","name":"advanced-exe-snapshot"}}
|
||||
{"type":"subagent/catalog","data":{"version":0,"childId":"{{child-2}}","childCreatedAt":0,"mode":"one-shot"}}
|
||||
{"type":"tool-workflow/agent-start","data":{"runId":"{{workflow-run}}","seq":1,"label":"workflow-child","phase":"Delegate","childId":"{{child-2}}"}}
|
||||
{"type":"tool-workflow/agent-end","data":{"runId":"{{workflow-run}}","seq":1,"outcome":"completed"}}
|
||||
{"type":"tool-workflow/run-end","data":{"runId":"{{workflow-run}}","stopReason":"completed"}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":7,"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":[65],"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":7}}
|
||||
{"type":"step/start","data":{"turn":1,"step":8}}
|
||||
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":73}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":8,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\": \"snap-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-undefine","name":"cordis_undefine","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-undefine","args":["{\"pluginId\": \"snap-1\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\": \"snap-1\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":8,"callId":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\": \"snap-1\"}"}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":8,"message":{"source":{"kind":"tool","callId":"advanced-undefine"},"content":[{"type":"tool-result","toolCallId":"advanced-undefine","content":[{"type":"text","text":"Removed dynamic Plugin snap-1 and all of its Packages."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[76],"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":8}}
|
||||
{"type":"step/start","data":{"turn":1,"step":9}}
|
||||
{"type":"system/message","data":{"turn":1,"step":9,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{messageId}}"}},"sourceEventSeqs":[32],"surfaceOp":{"op":"replace","startSeq":32,"endSeq":32}}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","subagent","workflow"]},"reason":"change","startsSeries":true}}
|
||||
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":81}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":9,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}},"id":"{{message:1}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["ADVANCED_EXECUTABLE_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":9}}
|
||||
{"type":"command/run","data":{"commandId":"{{command:1}}","name":"feedback","source":{"kind":"user"}}}
|
||||
{"type":"feedback/record","data":{"text":"The session needs a clearer explanation."}}
|
||||
{"type":"command/done","data":{"commandId":"{{command:1}}","kind":"success","text":"Feedback recorded for session {{parent}}\nAnonymous user: {{anonymous-user}}."}}
|
||||
{"type":"feedback/record","data":{"category":"other"}}
|
||||
{"type":"feedback/message-put","data":{"sessionId":"{{parent}}","item":{"messageId":"{{message:1}}","rating":"negative","note":"Explain the result.","category":"task-result","version":"{{feedback-version:1}}","createdAt":0,"updatedAt":0}}}
|
||||
{"type":"feedback/message-put","data":{"sessionId":"{{parent}}","item":{"messageId":"{{message:1}}","rating":"positive","note":"The explanation is clear now.","version":"{{feedback-version:2}}","createdAt":0,"updatedAt":0}}}
|
||||
{"type":"feedback/message-delete","data":{"sessionId":"{{parent}}","messageId":"{{message:1}}"}}
|
||||
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"requestSystemPrompts": [
|
||||
[
|
||||
"You are a helpful software engineer assistant.\n\nPython SDK prompt version 1."
|
||||
],
|
||||
[
|
||||
"You are a helpful software engineer assistant.\n\nPython SDK prompt version 1.",
|
||||
"You are a helpful software engineer assistant.\n\nPython SDK prompt version 2."
|
||||
],
|
||||
[
|
||||
"You are a helpful software engineer assistant.\n\nPython SDK prompt version 1.",
|
||||
"You are a helpful software engineer assistant.\n\nPython SDK prompt version 2."
|
||||
]
|
||||
],
|
||||
"systemMessageOperations": [
|
||||
"append",
|
||||
"append"
|
||||
],
|
||||
"subscribedSystemPrompts": [
|
||||
"You are a helpful software engineer assistant.\n\nPython SDK prompt version 1.",
|
||||
"You are a helpful software engineer assistant.\n\nPython SDK prompt version 2."
|
||||
],
|
||||
"requestContexts": [
|
||||
{
|
||||
"provider": "deepseek-official",
|
||||
"model": "smoke-model",
|
||||
"contextWindow": 1000000,
|
||||
"systemPromptUpdate": "in-history"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,243 +1,65 @@
|
||||
[
|
||||
{
|
||||
"system": "You are a helpful software engineer assistant.",
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run commands in a bash shell\n* When invoking this tool, the contents of the \"command\" parameter does NOT need to be XML-escaped.\n* You don't have access to the internet via this tool.\n* You do have access to a mirror of common linux and python packages via apt and pip.\n* State is persistent across command calls and discussions with the user.\n* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.\n* Please avoid commands that may produce a very large amount of output.\n* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The bash command to run. Relative path is preferred in the command."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "str_replace_editor",
|
||||
"description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with `<response clipped>`\n* A null placeholder for a parameter unused by the selected command is treated as omitted. Required parameters still need values; omit `str_replace.new_str` rather than setting it to null when deleting a match\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.",
|
||||
"enum": [
|
||||
"view",
|
||||
"create",
|
||||
"str_replace",
|
||||
"insert"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`."
|
||||
},
|
||||
"file_text": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Required string parameter of `create` command, with the content of the file to be created. A null placeholder is treated as omitted by commands that do not use this parameter."
|
||||
},
|
||||
"insert_line": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Required integer parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`. A null placeholder is treated as omitted by commands that do not use this parameter."
|
||||
},
|
||||
"new_str": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional string parameter of `str_replace` command containing the new string (if omitted, no string will be added). Required string parameter of `insert` command containing the string to insert. A null placeholder is accepted only by commands that do not use this parameter."
|
||||
},
|
||||
"old_str": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Required string parameter of `str_replace` command containing the string in `path` to replace. A null placeholder is treated as omitted by commands that do not use this parameter."
|
||||
},
|
||||
"view_range": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional parameter of `view` command when `path` points to a file. If omitted or null, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command",
|
||||
"path"
|
||||
]
|
||||
}
|
||||
"name": "bash",
|
||||
"description": "Run commands in a bash shell\n* When invoking this tool, the contents of the \"command\" parameter does NOT need to be XML-escaped.\n* Network access depends on the task environment. Prefer configured mirrors/proxies when they are available.\n* State is persistent across command calls and discussions with the user.\n* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.\n* Please avoid commands that may produce a very large amount of output.\n* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The bash command to run. Relative path is preferred in the command."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"text": "You are a helpful software engineer assistant."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"text": "Exercise the packaged minimal agent's persistent shell and string-replacement editor.\nEditor path: {{cwd}}/created.txt"
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Exercise the packaged minimal agent's persistent shell."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"system": "You are a helpful software engineer assistant.",
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run commands in a bash shell\n* When invoking this tool, the contents of the \"command\" parameter does NOT need to be XML-escaped.\n* You don't have access to the internet via this tool.\n* You do have access to a mirror of common linux and python packages via apt and pip.\n* State is persistent across command calls and discussions with the user.\n* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.\n* Please avoid commands that may produce a very large amount of output.\n* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The bash command to run. Relative path is preferred in the command."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "str_replace_editor",
|
||||
"description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with `<response clipped>`\n* A null placeholder for a parameter unused by the selected command is treated as omitted. Required parameters still need values; omit `str_replace.new_str` rather than setting it to null when deleting a match\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.",
|
||||
"enum": [
|
||||
"view",
|
||||
"create",
|
||||
"str_replace",
|
||||
"insert"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`."
|
||||
},
|
||||
"file_text": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Required string parameter of `create` command, with the content of the file to be created. A null placeholder is treated as omitted by commands that do not use this parameter."
|
||||
},
|
||||
"insert_line": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Required integer parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`. A null placeholder is treated as omitted by commands that do not use this parameter."
|
||||
},
|
||||
"new_str": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional string parameter of `str_replace` command containing the new string (if omitted, no string will be added). Required string parameter of `insert` command containing the string to insert. A null placeholder is accepted only by commands that do not use this parameter."
|
||||
},
|
||||
"old_str": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Required string parameter of `str_replace` command containing the string in `path` to replace. A null placeholder is treated as omitted by commands that do not use this parameter."
|
||||
},
|
||||
"view_range": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional parameter of `view` command when `path` points to a file. If omitted or null, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command",
|
||||
"path"
|
||||
]
|
||||
}
|
||||
"name": "bash",
|
||||
"description": "Run commands in a bash shell\n* When invoking this tool, the contents of the \"command\" parameter does NOT need to be XML-escaped.\n* Network access depends on the task environment. Prefer configured mirrors/proxies when they are available.\n* State is persistent across command calls and discussions with the user.\n* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.\n* Please avoid commands that may produce a very large amount of output.\n* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The bash command to run. Relative path is preferred in the command."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"text": "You are a helpful software engineer assistant."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"text": "Exercise the packaged minimal agent's persistent shell and string-replacement editor.\nEditor path: {{cwd}}/created.txt"
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Exercise the packaged minimal agent's persistent shell."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
@@ -249,130 +71,46 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"toolCallId": "minimal-bash-1",
|
||||
"text": "{{tool-result}}"
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "minimal-bash-1",
|
||||
"content": "{{tool-result}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"system": "You are a helpful software engineer assistant.",
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run commands in a bash shell\n* When invoking this tool, the contents of the \"command\" parameter does NOT need to be XML-escaped.\n* You don't have access to the internet via this tool.\n* You do have access to a mirror of common linux and python packages via apt and pip.\n* State is persistent across command calls and discussions with the user.\n* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.\n* Please avoid commands that may produce a very large amount of output.\n* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The bash command to run. Relative path is preferred in the command."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "str_replace_editor",
|
||||
"description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with `<response clipped>`\n* A null placeholder for a parameter unused by the selected command is treated as omitted. Required parameters still need values; omit `str_replace.new_str` rather than setting it to null when deleting a match\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.",
|
||||
"enum": [
|
||||
"view",
|
||||
"create",
|
||||
"str_replace",
|
||||
"insert"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`."
|
||||
},
|
||||
"file_text": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Required string parameter of `create` command, with the content of the file to be created. A null placeholder is treated as omitted by commands that do not use this parameter."
|
||||
},
|
||||
"insert_line": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Required integer parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`. A null placeholder is treated as omitted by commands that do not use this parameter."
|
||||
},
|
||||
"new_str": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional string parameter of `str_replace` command containing the new string (if omitted, no string will be added). Required string parameter of `insert` command containing the string to insert. A null placeholder is accepted only by commands that do not use this parameter."
|
||||
},
|
||||
"old_str": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Required string parameter of `str_replace` command containing the string in `path` to replace. A null placeholder is treated as omitted by commands that do not use this parameter."
|
||||
},
|
||||
"view_range": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional parameter of `view` command when `path` points to a file. If omitted or null, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command",
|
||||
"path"
|
||||
]
|
||||
}
|
||||
"name": "bash",
|
||||
"description": "Run commands in a bash shell\n* When invoking this tool, the contents of the \"command\" parameter does NOT need to be XML-escaped.\n* Network access depends on the task environment. Prefer configured mirrors/proxies when they are available.\n* State is persistent across command calls and discussions with the user.\n* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.\n* Please avoid commands that may produce a very large amount of output.\n* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The bash command to run. Relative path is preferred in the command."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"text": "You are a helpful software engineer assistant."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"text": "Exercise the packaged minimal agent's persistent shell and string-replacement editor.\nEditor path: {{cwd}}/created.txt"
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Exercise the packaged minimal agent's persistent shell."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
@@ -384,9 +122,14 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"toolCallId": "minimal-bash-1",
|
||||
"text": "{{tool-result}}"
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "minimal-bash-1",
|
||||
"content": "{{tool-result}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
@@ -397,173 +140,15 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"toolCallId": "minimal-bash-2",
|
||||
"text": "{{tool-result}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run commands in a bash shell\n* When invoking this tool, the contents of the \"command\" parameter does NOT need to be XML-escaped.\n* You don't have access to the internet via this tool.\n* You do have access to a mirror of common linux and python packages via apt and pip.\n* State is persistent across command calls and discussions with the user.\n* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.\n* Please avoid commands that may produce a very large amount of output.\n* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The bash command to run. Relative path is preferred in the command."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "str_replace_editor",
|
||||
"description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with `<response clipped>`\n* A null placeholder for a parameter unused by the selected command is treated as omitted. Required parameters still need values; omit `str_replace.new_str` rather than setting it to null when deleting a match\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.",
|
||||
"enum": [
|
||||
"view",
|
||||
"create",
|
||||
"str_replace",
|
||||
"insert"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`."
|
||||
},
|
||||
"file_text": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Required string parameter of `create` command, with the content of the file to be created. A null placeholder is treated as omitted by commands that do not use this parameter."
|
||||
},
|
||||
"insert_line": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Required integer parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`. A null placeholder is treated as omitted by commands that do not use this parameter."
|
||||
},
|
||||
"new_str": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional string parameter of `str_replace` command containing the new string (if omitted, no string will be added). Required string parameter of `insert` command containing the string to insert. A null placeholder is accepted only by commands that do not use this parameter."
|
||||
},
|
||||
"old_str": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Required string parameter of `str_replace` command containing the string in `path` to replace. A null placeholder is treated as omitted by commands that do not use this parameter."
|
||||
},
|
||||
"view_range": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional parameter of `view` command when `path` points to a file. If omitted or null, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command",
|
||||
"path"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"text": "You are a helpful software engineer assistant."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"text": "Exercise the packaged minimal agent's persistent shell and string-replacement editor.\nEditor path: {{cwd}}/created.txt"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"toolCalls": [
|
||||
"content": [
|
||||
{
|
||||
"id": "minimal-bash-1",
|
||||
"name": "bash"
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "minimal-bash-2",
|
||||
"content": "{{tool-result}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"toolCallId": "minimal-bash-1",
|
||||
"text": "{{tool-result}}"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"toolCalls": [
|
||||
{
|
||||
"id": "minimal-bash-2",
|
||||
"name": "bash"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"toolCallId": "minimal-bash-2",
|
||||
"text": "{{tool-result}}"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"toolCalls": [
|
||||
{
|
||||
"id": "minimal-editor",
|
||||
"name": "str_replace_editor"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"toolCallId": "minimal-editor",
|
||||
"text": "{{tool-result}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Loaded 100 of 5314 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user