Merge branch 'master' into feat/cordis-loader-resolver

This commit is contained in:
Shigma authored and GitHub committed 2026-10-22 16:35:55 +08:00
commit 90499e2e61
4874 files changed
+178404 -689558

No files matched your search

+4 -4
View File
@@ -204,7 +204,7 @@ async function main(): Promise<void> {
const packages = readWorkspacePackageManifests(root)
const workspaceVersions = new Map(packages.all.map(pkg => [pkg.name, currentVersion(pkg)]))
const releaseByName = new Map(packages.release.map(pkg => [pkg.name, pkg]))
const state = readPackageDependencyState(root)
const state = await readPackageDependencyState(root)
if (state.policyViolations.length > 0) throw new Error(state.policyViolations.join('\n'))
const base = cloneIndex(buildRegistryIndex(root))
for (const facts of state.facts) applyFactsToRegistry(base, facts, workspaceVersions)
@@ -216,11 +216,11 @@ async function main(): Promise<void> {
for (const name of candidates) {
if (!discovered.includes(name)) throw new Error(`${name} is not a reachable unconfigured Host candidate`)
}
const candidateFacts = new Map(candidates.map((name) => {
const candidateFacts = new Map(await Promise.all(candidates.map(async (name) => {
const pkg = releaseByName.get(name)
if (pkg === undefined) throw new Error(`release set has no ${name}`)
return [name, readPackageDependencyFacts(root, pkg, 'configured-host', state.workspaceNames)]
}))
return [name, await readPackageDependencyFacts(root, pkg, 'configured-host', state.workspaceNames)] as const
})))
const baselineSeconds = await measure(base, targetVersion, options.finalistRuns, options.timeoutMs)
const baseline = median(baselineSeconds)
+1 -1
View File
@@ -174,7 +174,7 @@ describe('Python runtime executable builder CLI', () => {
expect(result.status).toBe(0)
expect(result.stdout).toContain('exec pkg')
expect(result.stdout).toContain('--sea --targets node24-macos-x64')
expect(result.stdout).toContain('--sea --compress Brotli --targets node24-macos-x64')
expect(result.stdout).toContain('keep only node-pty prebuilds darwin-x64')
expect(result.stdout).toContain('lock optional resource downloads and copy Office skills')
expect(result.stdout).toContain(join(root, 'dist-exe', 'macos-x64'))
+3 -1
View File
@@ -232,7 +232,7 @@ class BuildCli {
' --dry-run print every command and config patch without executing.',
' --help print this help.',
'',
'Build route: @yao-pkg/pkg --sea (root devDependency, pnpm-patched); see .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.',
'Build route: @yao-pkg/pkg --sea --compress Brotli (root devDependency, pnpm-patched); see .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.',
`Stages the node carrier in ${PYTHON_RUNTIME_DIR}/${PYTHON_NODE_SUBDIR} and writes executables to ${OUT_DIR}/.`,
].join('\n')
}
@@ -399,6 +399,8 @@ class SingleExeBuild {
'pkg',
this.staging,
'--sea',
'--compress',
'Brotli',
'--targets',
target.spec,
'--output',
+5 -4
View File
@@ -7,7 +7,8 @@ const root = resolve(import.meta.dirname, '..')
const artifactGraph = {
'native': [],
'host-src': ['native'],
'host-typert': [],
'host-src': ['native', 'host-typert'],
'host-bundle': ['host-src'],
'desktop-bundle': ['host-bundle'],
'client-src': ['desktop-bundle'],
@@ -80,12 +81,12 @@ describe('repository build steps', () => {
})
})
it('type-checks every program with the source-only Typert pass beside Host compilation', () => {
it('type-checks every program after source-only Typert generation', () => {
const gates = buildGates(root, 'typecheck')
expect(graph(gates)).toEqual({
'host-src': [],
'host-typert': [],
'client-src': ['host-src', 'host-typert'],
'host-src': ['host-typert'],
'client-src': ['host-src'],
'host-check': ['host-src'],
'client-check': ['client-src'],
})
+8 -10
View File
@@ -5,8 +5,8 @@
* `--scope check` type-checks only the Host and Client aggregate programs against the package
* declarations already on disk; the default runs both step sets as one dependency graph, so the
* aggregate checks overlap the remaining artifact steps. `--scope typecheck` (`pnpm run typecheck`)
* type-checks every package project and both aggregate programs; beside Host compilation it writes
* the Typert declarations that Client compilation imports, without bundling.
* generates Typert declarations before package compilation, then type-checks every package project
* and both aggregate programs without bundling.
*/
import { rmSync } from 'node:fs'
@@ -68,11 +68,11 @@ export function parseBuildArguments(args: readonly string[]): BuildArguments {
/**
* Construct the build steps of one scope as a gate dependency graph.
* Artifact steps keep the serial order native, Host compilation, Host bundle (Typert),
* Desktop bundle, Client compilation, Client bundle, then Web. The typecheck scope runs Host
* compilation beside the bundle-free Typert pass, which reads only source, and then Client
* compilation, which needs both outputs; it writes no bundle. In the complete and typecheck
* scopes each aggregate check waits only for its own face's package compilation; alone, the
* Artifact steps generate Typert from source before Host compilation, then bundle Host,
* Desktop, and Client artifacts before Web. Native output also precedes Host compilation.
* The typecheck scope generates Typert before compiling Host and Client packages; it writes no
* bundle. In complete and typecheck scopes each aggregate check waits only for its own face's
* package compilation; alone, the
* checks have no predecessors and read whatever declarations already exist. The typecheck scope
* checks aggregates in build mode, which skips a program whose tsbuildinfo is current; the other
* scopes use project mode, which compiles no reference.
@@ -92,7 +92,7 @@ export function buildGates(
if (scope === 'complete' || scope === 'artifacts') {
gates.push(pnpmGate('native', 'native addon', ['run', 'build:native-system'], [], env))
for (const step of libraryBuildSteps(root, 'all')) {
gates.push(libraryGate(step, step.id === 'host-src' ? ['native'] : step.needs, env))
gates.push(libraryGate(step, step.id === 'host-src' ? ['native', ...step.needs] : step.needs, env))
}
gates.push(pnpmGate('web', 'Web build', ['run', 'build:web'], ['client-bundle'], env))
}
@@ -107,8 +107,6 @@ export function buildGates(
displayCommand: `tsc ${scope === 'typecheck' ? '-b' : '-p'} tsconfig.${face}.json`,
command: process.execPath,
args: scope === 'typecheck' ? aggregateBuildArgs(face) : aggregateCheckArgs(face),
// No Host aggregate file imports a generated `/typert` or `/remote` subpath, so
// host-check never reads what a concurrent host-typert step writes.
needs: scope === 'check' ? [] : [`${face}-src`],
env,
})
+165
View File
@@ -16,6 +16,7 @@ import {
checkExperimentalDependencyIsolation,
checkExperimentalManifest,
checkExperimentalNameExceptions,
detailDocumentFiles,
expectedDshPackageFiles,
readWorkspaceManifests,
type WorkspaceManifest,
@@ -327,6 +328,72 @@ describe('package payload constraints', () => {
])
})
it('publishes tools entry points and shared runtime chunks', () => {
const dir = 'packages/core/tools'
const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest']
expect(checkWorkspaceManifest({ dir, manifest })).toEqual([])
for (const omitted of ['lib/pipeline.js', 'lib/shared-*.js']) {
expect(checkWorkspaceManifest({ dir, manifest: {
...manifest, files: manifest.files!.filter(file => file !== omitted),
} })).toEqual([expect.stringContaining('package.json files must be')])
}
expect(checkWorkspaceManifest({ dir, manifest: {
...manifest,
exports: {
...manifest.exports,
'./pipeline': { types: './lib/types/pipeline.d.ts', default: './lib/index.js' },
},
} })).toContainEqual(expect.stringContaining('exports["./pipeline"] must expose'))
})
it('publishes the directory of each locale detail document after the locale resources', () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-detail-files-'))
onTestFinished(() => { rmSync(dir, { recursive: true, force: true }) })
mkdirSync(join(dir, 'docs/guide'), { recursive: true })
mkdirSync(join(dir, 'locale'))
writeFileSync(join(dir, 'docs/DETAIL.md'), '')
writeFileSync(join(dir, 'docs/guide/DETAIL.zh.md'), '')
writeFileSync(join(dir, 'docs/DETAIL.fr.md'), '')
writeFileSync(join(dir, 'locale/en.json'), JSON.stringify({ meta: { title: 'T', detail: 'docs/DETAIL.md' } }))
writeFileSync(join(dir, 'locale/zh.json'), JSON.stringify({ meta: { title: 'T', detail: 'docs/guide/DETAIL.zh.md' } }))
writeFileSync(join(dir, 'locale/fr.json'), JSON.stringify({ meta: { title: 'T', detail: 'docs/DETAIL.fr.md' } }))
writeFileSync(join(dir, 'locale/de.json'), JSON.stringify({ meta: { title: 'T' } }))
writeFileSync(join(dir, 'locale/ja.json'), '{')
const detailFiles = detailDocumentFiles(dir)
expect(detailFiles).toEqual(['docs'])
expect(expectedDshPackageFiles({ exports: { './locale/*.json': './locale/*.json' } }, detailFiles)).toEqual([
'locale/*.json', 'docs', 'lib/index.js', 'lib/types/**/*.d.ts',
])
})
it('rejects a detail document that is missing, outside the package, or at the package root', () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-detail-invalid-'))
onTestFinished(() => { rmSync(dir, { recursive: true, force: true }) })
mkdirSync(join(dir, 'locale'))
const declare = (detail: unknown): void => {
writeFileSync(join(dir, 'locale/en.json'), JSON.stringify({ meta: { detail } }))
}
declare('docs/DETAIL.md')
expect(() => detailDocumentFiles(dir)).toThrow(/DETAIL\.md.*does not exist/)
mkdirSync(join(dir, 'docs'))
writeFileSync(join(dir, 'docs/DETAIL.md'), '')
expect(detailDocumentFiles(dir)).toEqual(['docs'])
declare('DETAIL.md')
expect(() => detailDocumentFiles(dir)).toThrow(/must name a file in a subdirectory/)
declare('../DETAIL.md')
expect(() => detailDocumentFiles(dir)).toThrow(/must be a relative path inside the package/)
writeFileSync(join(dir, 'locale/en.json'), 'null')
expect(detailDocumentFiles(dir)).toEqual([])
})
it('rejects a bundle whose locale detail document is missing from its publication files', () => {
const dir = 'packages/experimental/badge-skill-bundle'
const manifest = JSON.parse(readFileSync(join(import.meta.dirname, '..', dir, 'package.json'), 'utf8')) as WorkspaceManifest['manifest']
expect(checkWorkspaceManifest({ dir, manifest })).toEqual([])
const files = (manifest.files ?? []).filter(file => file !== 'docs')
expect(checkWorkspaceManifest({ dir, manifest: { ...manifest, files } }).join('\n')).toContain('"docs"')
})
it('does not infer locale payloads from unrelated or non-JSON exports', () => {
expect(expectedDshPackageFiles({
exports: {
@@ -350,6 +417,13 @@ describe('package payload constraints', () => {
},
)
it('exempts the private plugin market generator from release rules in its own directory only', () => {
const dir = 'apps/plugin-market-generator'
const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest']
expect(checkWorkspaceManifest({ dir, manifest })).toEqual([])
expect(checkWorkspaceManifest({ dir: 'apps/other-tool', manifest })).not.toEqual([])
})
it('rejects locale publication entries without their resource exports', () => {
const dir = 'packages/experimental/auto-review'
const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest']
@@ -386,6 +460,67 @@ describe('package payload constraints', () => {
const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest']
expect(checkWorkspaceManifest({ dir, manifest })).toEqual([])
})
it.each([
{ './assets/*': './lib/assets/*' },
{ './assets/*': { default: './lib/assets/*' } },
])('publishes built package assets after the browser Client entries: %j', (assets) => {
expect(expectedDshPackageFiles({
exports: {
'./client': { types: './lib/types/client/index.d.ts', node: './lib/client.node.js', default: './lib/client.js' },
'./loader': './lib/loader.js',
...assets,
},
})).toEqual([
'lib/index.js', 'lib/client.js', 'lib/client.node.js', 'lib/assets', 'lib/loader.js', 'lib/types/**/*.d.ts',
])
})
it.each([
{},
{ './assets/*': './assets/*' },
{ './assets/*': { types: './lib/assets/*' } },
{ './assets/extra': './lib/assets/*' },
])('does not publish package assets without the exact asset export target: %j', (assets) => {
expect(expectedDshPackageFiles({ exports: { './client': './lib/client.js', ...assets } }))
.toEqual(['lib/index.js', 'lib/client.js', 'lib/types/**/*.d.ts'])
})
it('rejects a package-asset export whose payload omits the built assets', () => {
const dir = 'packages/client/probe'
const manifest = {
name: '@deepseek-ai/dsh-client-probe',
type: 'module',
main: 'lib/index.js',
types: 'lib/types/index.d.ts',
exports: {
'.': { types: './lib/types/index.d.ts', default: './lib/index.js' },
'./client': { types: './lib/types/client/index.d.ts', default: './lib/client.js' },
'./assets/*': './lib/assets/*',
},
files: ['lib/index.js', 'lib/client.js', 'lib/assets', 'lib/types/**/*.d.ts'],
peerDependencies: { '@deepseek-ai/cordis': 'workspace:~' },
devDependencies: { '@deepseek-ai/cordis': 'workspace:~' },
} satisfies WorkspaceManifest['manifest']
const payloadErrors = (candidate: WorkspaceManifest['manifest']): string[] =>
checkWorkspaceManifest({ dir, manifest: candidate }).filter(error => error.includes('package.json files must be'))
expect(payloadErrors(manifest)).toEqual([])
expect(payloadErrors({ ...manifest, files: manifest.files.filter(file => file !== 'lib/assets') })).toEqual([
expect.stringContaining('@deepseek-ai/dsh-client-probe: package.json files must be '
+ '["lib/index.js","lib/client.js","lib/assets","lib/types/**/*.d.ts"]'),
])
})
it.each(['ui-chat', 'ui-settings-account'])(
'accepts the published package assets for %s and rejects their omission', (name) => {
const dir = `packages/client/${name}`
const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest']
expect(checkWorkspaceManifest({ dir, manifest })).toEqual([])
expect(checkWorkspaceManifest({ dir, manifest: {
...manifest, files: manifest.files!.filter(file => file !== 'lib/assets'),
} })).toEqual([expect.stringContaining('package.json files must be')])
},
)
})
it('publishes CLI runtime declarations and rejects a payload that omits them', () => {
@@ -442,6 +577,18 @@ it('requires the Inspector Worker, Client chunks, and mirrored DevTools resource
}
})
it('requires both physical Client entries only for opted-in packages', () => {
for (const dir of ['packages/client/connection', 'packages/api/gateway', 'packages/typert/registry']) {
const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest']
expect(checkWorkspaceManifest({ dir, manifest })).toEqual([])
for (const omitted of ['lib/client.node.js', 'lib/client.browser.js']) {
const files = manifest.files!.filter(file => file !== omitted)
expect(checkWorkspaceManifest({ dir, manifest: { ...manifest, files } }))
.toEqual([expect.stringContaining('package.json files must be')])
}
}
})
it('requires the standalone shortcut protocol and rejects unrelated runtime files', () => {
const dir = 'packages/client/shortcuts'
const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest']
@@ -454,3 +601,21 @@ it('requires the standalone shortcut protocol and rejects unrelated runtime file
.toEqual([expect.stringContaining('package.json files must be')])
}
})
it('requires the OAR license while rejecting unrelated payload files', () => {
const dir = 'packages/experimental/subagent-oar'
const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest']
const files = [
'icon.svg', 'locale/*.json', 'lib/index.js',
'LICENSE-OAR', 'lib/types/**/*.d.ts',
]
expect(expectedDshPackageFiles(manifest)).toEqual(files)
expect(checkWorkspaceManifest({ dir, manifest })).toEqual([])
for (const invalidFiles of [
files.filter(file => file !== 'LICENSE-OAR'),
[...files, 'lib/unrelated.js'],
]) {
expect(checkWorkspaceManifest({ dir, manifest: { ...manifest, files: invalidFiles } }))
.toEqual([expect.stringContaining('package.json files must be')])
}
})
+86 -16
View File
@@ -19,7 +19,14 @@ import {
} from './experimental-package-policy.ts'
import { hasTypertRemoteNavigation, isForbiddenPublicationFile } from './publication-payload.ts'
import type { DshBundleManifest } from '../packages/util/package-manifest/src/types.ts'
import { OPTIONAL_BUNDLES, bundlePatchFiles } from '../packages/boot/app-boot/src/profile.ts'
import { detailPath } from '../packages/util/plugin-detail/src/document.ts'
import { bundlePatchFiles } from '../packages/boot/app-boot/src/profile.ts'
import { OPTIONAL_BUNDLES } from '../packages/boot/official-bundles/src/bundles.ts'
import {
PACKAGE_ASSET_EXPORT,
PACKAGE_ASSET_EXPORT_TARGET,
PACKAGE_ASSET_FILES_ENTRY,
} from '../packages/client/modules/src/assets.ts'
import { collectProjectReferenceFaceViolations } from './project-reference-faces.ts'
const root = resolve(import.meta.dirname, '..')
@@ -57,8 +64,16 @@ const publicationSourceAllowlist: Readonly<Record<string, readonly string[]>> =
const publishedRepositoryUrl = 'git+https://github.com/deepseek-ai/deepseek-harness.git'
/** Packages that participate in the experimental policy. */
const experimentalPackageDirectory = /^packages\/experimental\/[^/]+$/
/** Ordinary directories whose packages this repository publishes: one release member each. */
const standardReleaseMemberDirectory = /^(?:packages\/(?!experimental\/)[^/]+\/[^/]+|apps\/(?!desktop(?:-host)?$)[^/]+|vendor\/[^/]+)$/
/** Private reference tool run from the repository; it publishes no files. */
const pluginMarketGeneratorDirectory = 'apps/plugin-market-generator'
/**
* Ordinary directories whose packages this repository publishes: one release member each. The reference plugin
* market generator is a private repository tool, not a published application.
*/
const standardReleaseMemberDirectory = new RegExp(
`^(?:packages/(?!experimental/)[^/]+/[^/]+|apps/(?!desktop(?:-host)?$|${pluginMarketGeneratorDirectory.slice('apps/'.length)}$)[^/]+|vendor/[^/]+)$`,
'u',
)
/** Installable application assembled by electron-builder rather than published to npm. */
const desktopApplicationDirectory = 'apps/desktop'
const localArtifactDirs = new Set(['node_modules'])
@@ -170,6 +185,13 @@ export function readWorkspaceManifests(repositoryRoot: string): WorkspaceManifes
}
const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
// Tools entry points share error classes and invocation stages through runtime chunks.
'@deepseek-ai/dsh-tools': ['lib/pipeline.js', 'lib/shared-*.js'],
// The embedded OAR implementation retains its upstream license.
'@deepseek-ai/dsh-experimental-subagent-oar': ['LICENSE-OAR'],
// CUA publishes its client API and attributed native source for local macOS builds.
'@deepseek-ai/dsh-cua': ['lib/remote-types.js', 'lib/api.js', 'lib/bridge.js', 'lib/native-client.js', 'lib/protocol.js'],
'@deepseek-ai/dsh-experimental-cua-macos': ['native/bin', 'native/Package.swift', 'native/Sources', 'native/tests/swift', 'native/tests/test_swift.py', 'native/LICENSE.cua-driver', 'native/upstream.json', 'native/README.md', 'native/README.zh.md', 'native/protocol.schema.json'],
'@deepseek-ai/dsh-webhook-github': ['examples/github-review/cordis.yml', 'examples/github-review/github-ready-review-rule.mjs'],
// Owned Worker bundles import this public bootstrap before their business entry.
'@deepseek-ai/dsh-app-boot': ['lib/worker/profile-resolution-bootstrap.js'],
@@ -195,12 +217,15 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
'@deepseek-ai/dsh-experimental-inspector': ['lib/client.*.js', 'lib/worker.js', 'lib/devtools/**'],
// Creator's composition guidance travels with the declaration package.
'@deepseek-ai/dsh-agent-preset': ['skills'],
// The Web Host mounts the default-off settings owner independently of each
// Agent-scoped delegation-tool instance.
// The Web Host mounts the default-off settings owner independently of the
// delegation-tool instances.
'@deepseek-ai/dsh-tool-subagent': ['lib/model-selection-settings.js'],
// The JSONL backend resolves its private verification Worker relative to
// import.meta.url; it is shipped without a public package subpath.
'@deepseek-ai/dsh-session-persistence-jsonl': ['lib/worker.cjs'],
// The plugin market parses fetched indexes in a private Worker resolved
// relative to import.meta.url; it is shipped without a public package subpath.
'@deepseek-ai/dsh-plugin-market': ['lib/worker.cjs'],
// The argv-prefix runner entry ships beside the lib as its own bundle;
// sandbox-local resolves it through the package's ./runner export. tsdown
// also shares its generated FFI code through a hashed runtime chunk.
@@ -238,17 +263,50 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl
}
/**
* Compute canonical publication patterns, including declared and exported icon paths and exported locale JSON resources.
* @param manifest - workspace package manifest.
* @returns the icon and deduplicated locale targets followed by runtime and declaration payloads.
* List the publication entries that locale `meta.detail` documents need: the top-level directory holding
* each document and its images.
* @param packageDir - absolute package directory.
* @returns sorted, deduplicated entries.
* @throws Error when a declared document is not a package-relative Markdown file in a subdirectory or does not exist,
* so a typo cannot publish a directory that holds no document.
*/
export function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] {
export function detailDocumentFiles(packageDir: string): readonly string[] {
const entries = new Set<string>()
for (const file of globSync('locale/*.json', { cwd: packageDir })) {
let meta: unknown
try {
meta = (JSON.parse(readFileSync(join(packageDir, file), 'utf8')) as { meta?: unknown } | null)?.meta
} catch (error: unknown) {
// SyntaxError: verify-package-meta reports malformed locale files; this check only adds publication entries.
void error
continue
}
const label = `${join(packageDir, file)}: meta.detail`
const detail = detailPath(typeof meta === 'object' && meta !== null ? (meta as { detail?: unknown }).detail : undefined, label)
if (detail === undefined) continue
const slash = detail.indexOf('/')
if (slash < 0) throw new Error(`${label} ${JSON.stringify(detail)} must name a file in a subdirectory such as docs/`)
if (!existsSync(join(packageDir, detail))) throw new Error(`${label} ${JSON.stringify(detail)} does not exist`)
entries.add(detail.slice(0, slash))
}
return [...entries].sort()
}
/**
* Compute canonical publication patterns, including declared and exported icon paths, exported locale JSON resources,
* detail documents, and the built package-asset directory when `exports["./assets/*"]` targets it.
* @param manifest - workspace package manifest.
* @param detailFiles - entries from {@link detailDocumentFiles}.
* @returns the icon, deduplicated locale targets, and detail documents followed by runtime, package-asset, and declaration payloads.
*/
export function expectedDshPackageFiles(manifest: PackageManifest, detailFiles: readonly string[] = []): readonly string[] {
const localeFiles = new Set<string>()
for (const resource of Object.keys(manifest.exports ?? {})) {
if (!/^\.\/(?:.+\/)?locale\/[^/]+\.json$/u.test(resource)) continue
const target = exportDefault(manifest, resource)
if (target?.startsWith('./') && target.endsWith('.json')) localeFiles.add(target.slice(2))
}
const browserClient = exportDefault(manifest, './client')
const bundle = manifest.dsh?.bundle
const bundleFiles = bundle === undefined ? [] : bundlePatchFiles(bundle).map(file => file.replace(/^\.\//, ''))
const extras = [
@@ -265,6 +323,7 @@ export function expectedDshPackageFiles(manifest: PackageManifest): readonly str
return [
...new Set(icons),
...[...localeFiles].sort(),
...detailFiles,
'lib/index.js',
...manifest.bin ? ['lib/bin.js'] : [],
// Worker-thread packages ship a CJS worker entry; the browser worker
@@ -272,11 +331,12 @@ export function expectedDshPackageFiles(manifest: PackageManifest): readonly str
// Keyed on the artifact path, like ./client below.
...exportDefault(manifest, './worker') === './lib/worker.cjs' ? ['lib/worker.cjs'] : [],
...exportDefault(manifest, './worker') === './lib/worker.js' ? ['lib/worker.js'] : [],
// UI plugin packages ship their browser bundle beside the node lib
// (single-artifact ruling: dist/ retired, ./client resolves lib/client.js).
// Keyed on the artifact path, not the subpath name: a package's ./client is
// a browser-safe source channel, not a bundle.
...exportDefault(manifest, './client') === './lib/client.js' ? ['lib/client.js'] : [],
// Only physical browser bundles need a publication entry; a ./client
// subpath may instead expose a browser-safe emitted source tree.
...browserClient === './lib/client.js' ? ['lib/client.js'] : [],
...browserClient === './lib/client.browser.js' ? ['lib/client.browser.js'] : [],
...exportCondition(manifest, './client', 'node') === './lib/client.node.js' ? ['lib/client.node.js'] : [],
...exportDefault(manifest, PACKAGE_ASSET_EXPORT) === PACKAGE_ASSET_EXPORT_TARGET ? [PACKAGE_ASSET_FILES_ENTRY] : [],
// runtime's shell-held loader subpath ships as its own bundle beside the client half.
...exportDefault(manifest, './loader') === './lib/loader.js' ? ['lib/loader.js'] : [],
// A store subpath ships its own bundle (single-entry builds; no shared chunk).
@@ -331,6 +391,12 @@ function exportDefault(manifest: PackageManifest, subpath: string): string | und
return typeof target === 'string' ? target : undefined
}
/** Exact named condition target of an export entry. */
function exportCondition(manifest: PackageManifest, subpath: string, condition: string): string | undefined {
const target = exportFields(manifest.exports?.[subpath])?.[condition]
return typeof target === 'string' ? target : undefined
}
/** Whether any export's runtime default points into the tsc-emitted lib/types tree. */
function usesEmittedTreeDefaults(manifest: PackageManifest): boolean {
return Object.keys(manifest.exports ?? {}).some(subpath =>
@@ -471,7 +537,7 @@ export function checkWorkspaceManifest({ dir, manifest }: WorkspaceManifest): st
}
}
if (dir.startsWith('apps/') && dir !== desktopApplicationDirectory && manifest.name?.startsWith('@deepseek-ai/')) {
if (dir.startsWith('apps/') && dir !== desktopApplicationDirectory && dir !== pluginMarketGeneratorDirectory && manifest.name?.startsWith('@deepseek-ai/')) {
const expectedFiles = appPackageFiles[manifest.name]
if (expectedFiles === undefined) {
errors.push(`${label}: app package has no publication files policy`)
@@ -514,7 +580,11 @@ export function checkWorkspaceManifest({ dir, manifest }: WorkspaceManifest): st
if (rootEntry?.default !== './lib/index.js') {
errors.push(`${label}: package.json exports["."].default must be "./lib/index.js"`)
}
const expectedFiles = expectedDshPackageFiles(manifest)
if (manifest.name === '@deepseek-ai/dsh-tools'
&& !hasExportPair(manifest, './pipeline', './lib/types/pipeline.d.ts', './lib/pipeline.js')) {
errors.push(`${label}: exports["./pipeline"] must expose lib/types/pipeline.d.ts and lib/pipeline.js`)
}
const expectedFiles = expectedDshPackageFiles(manifest, detailDocumentFiles(join(root, dir)))
if (!sameStringList(manifest.files, expectedFiles)) {
errors.push(`${label}: package.json files must be ${JSON.stringify(expectedFiles)}`)
}
+16 -4
View File
@@ -5,6 +5,7 @@ import { spawnSync } from 'node:child_process'
import { runInNewContext } from 'node:vm'
import * as yaml from 'js-yaml'
import { describe, expect, it } from 'vitest'
import { BUILD_BACKED_NODE_COMPAT_VERSION } from './run-gates.ts'
interface Step {
name?: string
@@ -24,7 +25,11 @@ interface CompatibilityJob {
}
const workflow = yaml.load(readFileSync(resolve(import.meta.dirname, '../.github/workflows/ci.yml'), 'utf8')) as {
jobs: { 'node-compat': CompatibilityJob; 'python-sdk': { 'runs-on': string } }
jobs: { 'node-compat': CompatibilityJob }
}
// Python keeps its own required verdict in ci-python.yml.
const pythonWorkflow = yaml.load(readFileSync(resolve(import.meta.dirname, '../.github/workflows/ci-python.yml'), 'utf8')) as {
jobs: { 'python-sdk': { 'runs-on': string } }
}
const job = workflow.jobs['node-compat']
const labels = ['self-hosted', 'linux', 'x64', 'vm-backup']
@@ -54,6 +59,14 @@ function route(options: { mode?: string; author?: string; repository?: string; f
}
describe('Node compatibility self-hosted routing', () => {
it('runs the build-backed compatibility minor and declares it for installed CLI users', () => {
expect(job.strategy.matrix.include.filter(entry => String(entry.node) === BUILD_BACKED_NODE_COMPAT_VERSION)).toHaveLength(1)
for (const manifest of ['package.json', 'apps/cli/package.json']) {
const pkg = JSON.parse(readFileSync(resolve(import.meta.dirname, '..', manifest), 'utf8')) as { engines: { node: string } }
expect(pkg.engines.node).toBe(`>=${BUILD_BACKED_NODE_COMPAT_VERSION}.0`)
}
})
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')
@@ -67,18 +80,17 @@ describe('Node compatibility self-hosted routing', () => {
expect(route({ repository: '' })).toBe('ubuntu-latest')
})
it('preserves all three required version jobs and their concurrency', () => {
it('preserves the supported version jobs and their concurrency', () => {
expect(job.if).toBe("github.event_name == 'pull_request'")
expect(job.strategy['fail-fast']).toBe(false)
expect(job.strategy.matrix.include).toEqual([
{ node: '22.19', name: 'node 22.19', runner: 'ubuntu-latest', gate_concurrency: '1' },
{ node: '24.9', name: 'node 24.9', runner: 'ubuntu-latest', gate_concurrency: '1' },
{ node: 26, name: 'node 26', runner: 'ubuntu-latest', gate_concurrency: '1' },
])
expect(job.env.DSH_GATE_CONCURRENCY).toBe('${{ matrix.gate_concurrency }}')
expect(job.steps.map(step => step.run)).toContain('pnpm run check:node-compat')
expect(job.steps.map(step => step.run)).toContain('pnpm exec vitest run packages/boot/app-boot/tests/loader-shape.compat.spec.ts')
expect(workflow.jobs['python-sdk']['runs-on']).toBe('ubuntu-latest')
expect(pythonWorkflow.jobs['python-sdk']['runs-on']).toBe('ubuntu-latest')
})
it('isolates version installs and enables hosted package caching only on hosted runners', () => {
+137 -51
View File
@@ -14,12 +14,18 @@ const runnerPrivatePnpmDestination = /^\$\{\{ runner\.temp \}\}\/setup-pnpm-\$\{
const nativeWindowsPnpmDestination = '${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}'
describe('CI workflow', () => {
it('keeps at most ten worker jobs active while runtime targets follow the SDK suite', () => {
it('keeps at most ten worker jobs active across PR workflows while Python runtime targets follow the SDK suite', () => {
const ci = loadWorkflow('.github/workflows/ci.yml')
const ciWindows = loadWorkflow('.github/workflows/ci-windows.yml')
const ciPython = loadWorkflow('.github/workflows/ci-python.yml')
const compatibility = workflowJob(ci, 'node-compat')
const runtime = workflowJob(ci, 'python-runtime')
if (!isRecord(ci.jobs) || !isRecord(compatibility.strategy) || !isRecord(runtime.with)) {
throw new TypeError('CI must define jobs, compatibility scheduling, and runtime inputs')
const runtime = workflowJob(ciPython, 'python-runtime')
if (!isRecord(ci.jobs)
|| !isRecord(ciWindows.jobs)
|| !isRecord(ciPython.jobs)
|| !isRecord(compatibility.strategy)
|| !isRecord(runtime.with)) {
throw new TypeError('CI, CI Windows, and CI Python must define jobs, compatibility scheduling, and runtime inputs')
}
const compatibilityLimit = compatibility.strategy['max-parallel']
const targets = runtime.with.targets
@@ -30,21 +36,35 @@ describe('CI workflow', () => {
expect(compatibilityLimit).toBeGreaterThan(0)
expect(compatibility.strategy).toMatchObject({
'fail-fast': false,
matrix: { include: [{ node: '22.19' }, { node: '24.9' }, { node: 26 }] },
matrix: { include: [{ node: '24.9' }, { node: 26 }] },
})
expect(workflowJob(ci, 'all-checks-passed').needs).toContain('node-compat')
const ordinaryJobs = Object.entries(ci.jobs)
.filter(([name]) => !['node-compat', 'python-runtime', 'all-checks-passed'].includes(name))
.filter(([name]) => !['node-compat', 'all-checks-passed'].includes(name))
for (const [name, job] of ordinaryJobs) {
if (!isRecord(job)) throw new TypeError(`${name} must define a job`)
expect(job).not.toHaveProperty('strategy')
expect(job).not.toHaveProperty('uses')
}
expect(ordinaryJobs.map(([name]) => name)).toContain('python-sdk')
// Python reports its own required verdict, so neither its jobs nor a
// reusable workflow caller remain in the Linux or Windows workflow.
expect(Object.keys(ci.jobs).filter(name => name.startsWith('python'))).toEqual([])
expect(Object.keys(ciWindows.jobs)).not.toContain('python-runtime')
const pythonSdk = workflowJob(ciPython, 'python-sdk')
expect(pythonSdk).not.toHaveProperty('strategy')
expect(pythonSdk).not.toHaveProperty('uses')
expect(runtime.needs).toBe('python-sdk')
expect(runtime.strategy).toBeUndefined()
expect(runtime.with).toMatchObject({ targets: 'node24-linux-x64,node24-win-x64' })
expect(runtime.uses).toBe('./.github/workflows/build-exe-for-python-sdk.yml')
const windowsJobs = Object.entries(ciWindows.jobs)
.filter(([name]) => name !== 'windows-checks-passed')
for (const [name, job] of windowsJobs) {
if (!isRecord(job)) throw new TypeError(`${name} must define a job`)
expect(job).not.toHaveProperty('strategy')
expect(job).not.toHaveProperty('uses')
}
const builder = loadWorkflow('.github/workflows/build-exe-for-python-sdk.yml')
if (!isRecord(builder.jobs)) throw new TypeError('Python runtime builder must define jobs')
@@ -63,19 +83,54 @@ describe('CI workflow', () => {
if (!isRecord(build.strategy)) throw new TypeError('Python runtime build must define its target matrix')
expect(build.strategy.matrix).toEqual({ include: '${{ fromJSON(needs.plan.outputs.matrix) }}' })
const beforeSdk = ordinaryJobs.length + compatibilityLimit
const afterSdk = ordinaryJobs.length - 1 + compatibilityLimit + Math.max(1, targets.split(',').length)
// Python's runtime targets start only after the SDK suite; Windows starts
// immediately. Plan and wheel stages precede the two-target matrix.
const windowsActive = windowsJobs.length
const beforeSdk = ordinaryJobs.length + compatibilityLimit + windowsActive + 1
const afterSdk = ordinaryJobs.length + compatibilityLimit + windowsActive + Math.max(1, targets.split(',').length)
expect(beforeSdk).toBeLessThanOrEqual(10)
expect(afterSdk).toBeLessThanOrEqual(10)
})
it('keeps the union of required PR verdicts equal to the pre-split blocking set', () => {
const verdicts = [
['ci.yml', 'all-checks-passed'],
['ci-windows.yml', 'windows-checks-passed'],
['ci-python.yml', 'python-checks-passed'],
] as const
const blocking: string[] = []
for (const [file, name] of verdicts) {
const workflow = loadWorkflow('.github/workflows/' + file)
const verdict = workflowJob(workflow, name)
if (!isRecord(workflow.jobs) || !Array.isArray(verdict.needs)) {
throw new TypeError(`${file} must define jobs and the ${name} verdict needs`)
}
const jobs = workflow.jobs
expect(verdict.needs.every(id => typeof id === 'string' && id in jobs)).toBe(true)
blocking.push(...(verdict.needs as string[]))
}
// Splitting the workflows only moves jobs between verdicts; the blocking
// set itself must stay identical, or branch protection would accept a
// failure it rejected before the split.
expect(blocking.sort()).toEqual([
'node-24',
'node-24-bench',
'node-24-consumers',
'node-24-coverage',
'node-compat',
'python-runtime',
'python-sdk',
'windows-build',
'windows-native-tests',
])
})
it('retains aggregate diagnostics in required builds and protected publication', () => {
const ci = loadWorkflow('.github/workflows/ci.yml')
for (const [name, command] of [
['node-24-consumers', 'pnpm run check:ci:consumers'],
['windows-build', 'pnpm run check:ci:windows-blocking'],
for (const [file, name, command] of [
['ci.yml', 'node-24-consumers', 'pnpm run check:ci:consumers'],
['ci-windows.yml', 'windows-build', 'pnpm run check:ci:windows-blocking'],
] as const) {
const job = workflowJob(ci, name)
const job = workflowJob(loadWorkflow('.github/workflows/' + file), name)
expect(job.steps).toContainEqual(expect.objectContaining({ run: command }))
}
for (const [file, command] of [
@@ -86,7 +141,10 @@ describe('CI workflow', () => {
.toContainEqual(expect.objectContaining({ name: 'Build', run: command }))
}
const scripts = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { scripts: Record<string, string> }
expect(scripts.scripts['build:lib:host']).toContain('tsc -b tsconfig.host.json')
const hostSteps = scripts.scripts['build:lib:host']!.split(' && ')
expect(hostSteps[0]).toBe('tsx scripts/compile-referenced-projects.ts host-libraries')
expect(hostSteps).toContain('node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc -p tsconfig.host.json')
expect(hostSteps.at(-1)).toBe('pnpm --filter @deepseek-ai/dsh-desktop run bundle')
expect(scripts.scripts['build:lib:client']).toContain('tsc -b tsconfig.client.json')
expect(scripts.scripts.typecheck).toBe('tsx scripts/build.ts --scope typecheck')
})
@@ -179,7 +237,7 @@ describe('CI workflow', () => {
})
it('skips coverage-history uploads on cancellation but retains Wine cleanup', () => {
const coverage = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'windows-coverage')
const coverage = workflowJob(loadWorkflow('.github/workflows/ci-windows.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() }}',
@@ -188,10 +246,10 @@ describe('CI workflow', () => {
})
it.each([
['node-24-coverage', 'Linux', 'LINUX', 'pnpm run check:ci:coverage'],
['windows-coverage', 'Windows', 'WINDOWS', 'pnpm run check:ci:coverage'],
])('reuses %s coverage timings only within its platform and runner pool', (jobName, platform, variable, command) => {
const job = workflowJob(loadWorkflow('.github/workflows/ci.yml'), jobName)
['node-24-coverage', 'ci.yml', 'Linux', 'LINUX', 'pnpm run check:ci:coverage'],
['windows-coverage', 'ci-windows.yml', 'Windows', 'WINDOWS', 'pnpm run check:ci:coverage'],
])('reuses %s coverage timings only within its platform and runner pool', (jobName, file, platform, variable, command) => {
const job = workflowJob(loadWorkflow('.github/workflows/' + file), jobName)
if (!Array.isArray(job.steps)) throw new TypeError(`${jobName} must define steps`)
const steps = job.steps.filter(isRecord)
const checkout = steps.findIndex(step => step.uses === 'actions/[email protected]')
@@ -216,7 +274,7 @@ describe('CI workflow', () => {
})
it('isolates every pnpm action setup destination per runner', () => {
const files = ['.github/workflows/ci.yml', '.github/workflows/ci-master.yml']
const files = ['.github/workflows/ci.yml', '.github/workflows/ci-windows.yml', '.github/workflows/ci-master.yml']
const setups: Array<{ jobName: string; step: unknown }> = []
for (const file of files) {
const workflow: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'))
@@ -301,13 +359,16 @@ describe('CI workflow', () => {
}
})
it('keeps split native Windows PR jobs with failover, plus a master-only standby', () => {
it('keeps split native Windows PR jobs with failover in their own workflow, plus a master-only standby', () => {
const workflow = loadWorkflow('.github/workflows/ci.yml')
const windowsWorkflow = loadWorkflow('.github/workflows/ci-windows.yml')
const masterWorkflow = loadWorkflow('.github/workflows/ci-master.yml')
if (!isRecord(workflow.jobs)
|| !isRecord(workflow.jobs['windows-build'])
|| !isRecord(workflow.jobs['windows-coverage'])
|| !isRecord(workflow.jobs['windows-native-tests'])
|| !isRecord(windowsWorkflow.jobs)
|| !isRecord(windowsWorkflow.jobs['windows-build'])
|| !isRecord(windowsWorkflow.jobs['windows-coverage'])
|| !isRecord(windowsWorkflow.jobs['windows-native-tests'])
|| !isRecord(windowsWorkflow.jobs['windows-checks-passed'])
|| !isRecord(workflow.jobs['node-24'])
|| !isRecord(workflow.jobs['node-24-coverage'])
|| !isRecord(workflow.jobs['node-24-bench'])
@@ -316,12 +377,13 @@ describe('CI workflow', () => {
|| !isRecord(workflow.jobs['all-checks-passed'])
|| !isRecord(masterWorkflow.jobs)
|| !isRecord(masterWorkflow.jobs['serial-windows'])) {
throw new TypeError('CI workflow must define windows-build, windows-coverage, windows-native-tests, node-24, node-24-coverage, node-24-bench, node-24-consumers, node-compat, and all-checks-passed; ci-master must define serial-windows')
throw new TypeError('CI workflow must define node-24, node-24-coverage, node-24-bench, node-24-consumers, node-compat, and all-checks-passed; CI Windows must define windows-build, windows-coverage, windows-native-tests, and windows-checks-passed; ci-master must define serial-windows')
}
const windowsBuild = workflow.jobs['windows-build']
const windowsCoverage = workflow.jobs['windows-coverage']
const windowsNativeTests = workflow.jobs['windows-native-tests']
const windowsBuild = windowsWorkflow.jobs['windows-build']
const windowsCoverage = windowsWorkflow.jobs['windows-coverage']
const windowsNativeTests = windowsWorkflow.jobs['windows-native-tests']
const windowsAggregate = windowsWorkflow.jobs['windows-checks-passed']
const serialWindows = masterWorkflow.jobs['serial-windows']
const node24 = workflow.jobs['node-24']
const node24Coverage = workflow.jobs['node-24-coverage']
@@ -329,9 +391,10 @@ describe('CI workflow', () => {
const node24Consumers = workflow.jobs['node-24-consumers']
const nodeCompat = workflow.jobs['node-compat']
const aggregate = workflow.jobs['all-checks-passed']
if (!Array.isArray(aggregate.needs)) {
throw new TypeError('CI aggregate must define needs')
if (!Array.isArray(aggregate.needs) || !Array.isArray(windowsAggregate.needs)) {
throw new TypeError('CI and CI Windows aggregates must define needs')
}
expect(Object.keys(workflow.jobs).filter(name => name.startsWith('windows'))).toEqual([])
// The split native jobs all resolve their pool through the Windows switch.
for (const [jobName, job] of [['windows-build', windowsBuild], ['windows-coverage', windowsCoverage], ['windows-native-tests', windowsNativeTests]] as const) {
expect(typeof job['runs-on']).toBe('string')
@@ -411,7 +474,7 @@ describe('CI workflow', () => {
expect(nativeTestCommand).toContain('tool-pwsh/tests/loader.spec.ts')
expect(nativeTestCommand).toContain('workflow-ptc.spec.ts')
expect(workflow.jobs['windows-observational']).toBeUndefined()
expect(windowsWorkflow.jobs['windows-observational']).toBeUndefined()
expect(windowsBuild['continue-on-error']).not.toBe(true)
const observational = buildCommands.find(step => step.id === 'observational')
expect(observational).toMatchObject({
@@ -470,10 +533,13 @@ describe('CI workflow', () => {
expect(serialGate).toBeDefined()
expect(serialGate!.env).toMatchObject({ DSH_COVERAGE_TEST_TIMEOUT_MS: '90000' })
expect(aggregate.needs).not.toContain('windows')
// windows-coverage is temporarily non-blocking while Windows ACP
// half-close tests are stabilized; observational stays out too.
expect(aggregate.needs).not.toContain('windows')
expect(aggregate.needs).toContain('windows-build')
expect(windowsAggregate.needs).toEqual(['windows-build', 'windows-native-tests'])
expect(windowsAggregate.name).toBe('windows checks passed')
expect(windowsAggregate['runs-on']).toBe(aggregate['runs-on'])
expect(windowsAggregate.if).toBe(aggregate.if)
// The benchmark lane is a required verdict input and runs alone so its
// wall-clock budgets never share a runner with a concurrent aggregate.
expect(aggregate.needs).toContain('node-24-bench')
@@ -489,10 +555,7 @@ describe('CI workflow', () => {
env: { DSH_GATE_VERBOSE: '1' },
run: 'pnpm run check:ci:bench',
})
expect(aggregate.needs).not.toContain('windows-coverage')
expect(aggregate.needs).toContain('windows-native-tests')
expect(aggregate.needs).not.toContain('windows-observational')
expect(aggregate.needs).not.toContain('serial-windows')
expect(windowsAggregate.needs).not.toContain('serial-windows')
// Linux failover is a separate switch: the three enterprise Linux workers
// and the verdict job resolve their pool through DSH_CI_FAILOVER_LINUX,
@@ -668,12 +731,20 @@ describe('CI workflow', () => {
expect(uploadIndex).toBeGreaterThan(benchmarkIndex)
})
it('gives the Wine Host TypeScript compile the repository heap budget', () => {
it('splits the Wine Host TypeScript compile and retains its heap budget', () => {
const wineGates = readFileSync(resolve(root, 'scripts/wine-windows-gates.sh'), 'utf8')
expect(wineGates).toContain(
'wine_node "$scratch/logs/host-tsc.log" --max-old-space-size=4096 "$tsc_js" -b tsconfig.host.json --pretty false',
const references = wineGates.indexOf(
'wine_node "$scratch/logs/host-references.log" --import tsx/esm scripts/compile-referenced-projects.ts host',
)
const aggregate = wineGates.indexOf(
'wine_node "$scratch/logs/host-tsc.log" --max-old-space-size=4096 "$tsc_js" -p tsconfig.host.json --pretty false',
)
const metadata = wineGates.indexOf('wine_node "$scratch/logs/host-typert.log" --import tsx/esm scripts/generate-typert.ts')
expect(metadata).toBeGreaterThan(-1)
expect(references).toBeGreaterThan(metadata)
expect(aggregate).toBeGreaterThan(references)
expect(wineGates).not.toContain('-b tsconfig.host.json')
})
it('cancels superseded master runs without changing the post-merge job inventory', () => {
@@ -701,6 +772,18 @@ describe('CI workflow', () => {
}
expect(Object.keys(workflow.on).sort()).toEqual(['push', 'workflow_dispatch'])
expect(Object.keys(prWorkflow.on)).toEqual(['pull_request'])
const windowsWorkflow = loadWorkflow('.github/workflows/ci-windows.yml')
if (!isRecord(windowsWorkflow.on)) throw new TypeError('CI Windows workflow must define on')
expect(Object.keys(windowsWorkflow.on)).toEqual(['pull_request'])
expect(windowsWorkflow.concurrency).toEqual(workflow.concurrency)
// Duplicated env must not drift: a Node bump on one lane only would run
// Linux and Windows against different versions.
expect(windowsWorkflow.env).toEqual(prWorkflow.env)
const pythonWorkflow = loadWorkflow('.github/workflows/ci-python.yml')
if (!isRecord(pythonWorkflow.on)) throw new TypeError('CI Python workflow must define on')
expect(Object.keys(pythonWorkflow.on)).toEqual(['pull_request'])
expect(pythonWorkflow.concurrency).toEqual(workflow.concurrency)
expect(pythonWorkflow.env).toEqual(prWorkflow.env)
// Drills share the parent run’s supersession policy.
for (const name of ['serial-linux-selfhosted', 'serial-windows']) {
@@ -778,27 +861,29 @@ describe('CI workflow', () => {
expect(config).not.toContain('packages/lsp/lsp-stdio/src/instance.ts')
})
it('requires release-shaped Python runtime validation on Linux and Windows x64', () => {
const workflow = loadWorkflow('.github/workflows/ci.yml')
it('requires release-shaped Python runtime validation on Linux and Windows x64 in CI Python', () => {
const workflow = loadWorkflow('.github/workflows/ci-python.yml')
const pythonRuntime = workflowJob(workflow, 'python-runtime')
const aggregate = workflowJob(workflow, 'all-checks-passed')
const aggregate = workflowJob(workflow, 'python-checks-passed')
if (!Array.isArray(aggregate.needs)) {
throw new TypeError('CI aggregate must define required job dependencies')
throw new TypeError('CI Python aggregate must define required job dependencies')
}
expect(pythonRuntime).toMatchObject({
if: "github.event_name == 'pull_request'",
name: 'python runtime / release-shaped matrix',
uses: './.github/workflows/build-exe-for-python-sdk.yml',
with: {
targets: 'node24-linux-x64,node24-win-x64',
ci: true,
},
with: { targets: 'node24-linux-x64,node24-win-x64', ci: true },
secrets: {
DEEPSEEK_API_KEY_EXTERNAL: '${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}',
},
})
expect(aggregate.needs).toContain('python-runtime')
expect(aggregate.needs).toEqual(['python-sdk', 'python-runtime'])
for (const file of ['ci.yml', 'ci-windows.yml']) {
const other = loadWorkflow('.github/workflows/' + file)
if (!isRecord(other.jobs)) throw new TypeError(`${file} must define jobs`)
expect(other.jobs).not.toHaveProperty('python-runtime')
}
})
it('builds SSH executables only for explicit manual or release requests', () => {
@@ -1261,6 +1346,7 @@ describe('Weighted approval workflow', () => {
expect(review.types).toEqual(['submitted', 'edited', 'dismissed'])
expect(reviewEvent.permissions).toEqual({})
expect(publisher.permissions).toEqual({
checks: 'write',
contents: 'read',
'pull-requests': 'write',
statuses: 'write',
@@ -1296,7 +1382,7 @@ describe('Weighted approval workflow', () => {
if: "failure() && steps.revoke.outputs.active == 'true'",
run: 'node .github/review-ownership/check-approval.mjs error',
})
const pythonJob = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'python-sdk')
const pythonJob = workflowJob(loadWorkflow('.github/workflows/ci-python.yml'), 'python-sdk')
expect(pythonJob.steps).toContainEqual({
name: 'Test production blame scoring',
run: "uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'",
+39 -1
View File
@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
@@ -27,6 +27,11 @@ function addProject(root: string, path: string, outDir = 'lib/types'): void {
write(join(root, path, 'src/index.ts'), 'export {}\n')
}
function link(target: string, path: string): void {
mkdirSync(dirname(path), { recursive: true })
symlinkSync(target, path, process.platform === 'win32' ? 'junction' : 'dir')
}
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
@@ -82,6 +87,39 @@ describe('RepositoryCleaner', () => {
expect(existsSync(join(root, 'native/system/tsconfig.tsbuildinfo'))).toBe(false)
})
it('removes pnpm links to deleted or cleaned packages and keeps live links', async () => {
const root = fixture()
addProject(root, 'packages/core/live')
write(join(root, 'packages/core/live/package.json'), '{}')
write(join(root, 'packages/hooks/stale/lib/index.js'))
const hoisted = join(root, 'node_modules/.pnpm/node_modules')
link(join(root, 'packages/core/live'), join(hoisted, '@deepseek-ai/dsh-live'))
link(join(root, 'packages/hooks/gone'), join(hoisted, '@deepseek-ai/dsh-gone'))
link(join(root, 'packages/hooks/stale'), join(hoisted, '@deepseek-ai/dsh-stale'))
link(join(root, 'packages/hooks/plain'), join(hoisted, 'plain'))
link(join(hoisted, 'loop'), join(hoisted, 'loop'))
link(join(root, 'packages/hooks/top'), join(root, 'node_modules/@deepseek-ai/dsh-top'))
await new RepositoryCleaner(root).clean()
expect(readdirSync(join(hoisted, '@deepseek-ai'))).toEqual(['dsh-live'])
expect(readdirSync(hoisted)).toEqual(['@deepseek-ai'])
expect(readdirSync(join(root, 'node_modules/@deepseek-ai'))).toEqual([])
expect(existsSync(join(hoisted, '@deepseek-ai/dsh-live/package.json'))).toBe(true)
})
it('does not scan node_modules outside the repository', async () => {
const root = fixture()
const externalModules = fixture()
addProject(root, 'products/shell')
link(join(root, 'packages/hooks/gone'), join(externalModules, 'gone'))
link(externalModules, join(root, 'node_modules'))
await new RepositoryCleaner(root).clean()
expect(readdirSync(externalModules)).toEqual(['gone'])
})
it('refuses project outputs reached through a symlink outside the repository', async () => {
const root = fixture()
const externalProject = fixture()
+56 -6
View File
@@ -1,3 +1,4 @@
import { type Dirent } from 'node:fs'
import { lstat, readdir, realpath, rm } from 'node:fs/promises'
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -6,8 +7,12 @@ import { repositoryConfigHost } from './ts-project.ts'
const knownOrphanEntries = new Set(['node_modules', 'lib', '.typecheck'])
function errorCode(error: unknown): unknown {
return error instanceof Error && 'code' in error ? error.code : undefined
}
function isMissing(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
return errorCode(error) === 'ENOENT'
}
async function exists(path: string): Promise<boolean> {
@@ -20,16 +25,48 @@ async function exists(path: string): Promise<boolean> {
}
}
async function childDirectories(path: string): Promise<string[]> {
async function directoryEntries(path: string): Promise<Dirent[]> {
try {
const entries = await readdir(path, { withFileTypes: true })
return entries.filter(entry => entry.isDirectory()).map(entry => join(path, entry.name))
return await readdir(path, { withFileTypes: true })
} catch (error) {
if (isMissing(error)) return []
throw error
}
}
async function childDirectories(path: string): Promise<string[]> {
return (await directoryEntries(path)).filter(entry => entry.isDirectory()).map(entry => join(path, entry.name))
}
/** Lists symbolic links directly in a node_modules directory and in its `@scope` directories. */
async function moduleLinks(modulesDirectory: string): Promise<string[]> {
const links = (directory: string, entries: Dirent[]): string[] =>
entries.filter(entry => entry.isSymbolicLink()).map(entry => join(directory, entry.name))
const entries = await directoryEntries(modulesDirectory)
const result = links(modulesDirectory, entries)
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.startsWith('@')) continue
const scope = join(modulesDirectory, entry.name)
result.push(...links(scope, await directoryEntries(scope)))
}
return result
}
/** Resolves a link's canonical target, or undefined when the target cannot be reached. */
async function linkTarget(link: string): Promise<string | undefined> {
try {
return await realpath(link)
} catch (error) {
if (['ENOENT', 'ENOTDIR', 'ELOOP'].includes(String(errorCode(error)))) return undefined
throw error
}
}
function isDescendant(root: string, path: string): boolean {
const relativePath = relative(root, path)
return relativePath !== '' && relativePath !== '..' && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath)
}
function repositoryPath(root: string, path: string): string {
return relative(root, path).split(sep).join('/')
}
@@ -108,6 +145,20 @@ export class RepositoryCleaner {
}
}
// pnpm install keeps links to deleted workspace packages. A link is residue when its
// target is unreachable or is removed by this plan; node_modules outside the repository is not scanned.
const removedPaths = (await Promise.all([...targets].map(linkTarget))).filter(path => path !== undefined)
for (const modulesDirectory of ['node_modules', 'node_modules/.pnpm/node_modules']) {
const canonicalModules = await linkTarget(join(this.root, modulesDirectory))
if (canonicalModules === undefined || !isDescendant(canonicalRoot, canonicalModules)) continue
for (const link of await moduleLinks(join(this.root, modulesDirectory))) {
const target = await linkTarget(link)
if (target === undefined || removedPaths.some(path => path === target || isDescendant(path, target))) {
await this.addIfPresent(targets, link, canonicalRoot)
}
}
}
if (unsafeOrphans.length > 0) {
throw new Error([
'clean: refusing to remove package directories without package.json; unknown entries remain:',
@@ -159,8 +210,7 @@ export class RepositoryCleaner {
}
private assertDescendant(root: string, path: string, displayPath: string): void {
const repositoryRelative = relative(root, path)
if (repositoryRelative === '' || repositoryRelative === '..' || repositoryRelative.startsWith(`..${sep}`) || isAbsolute(repositoryRelative)) {
if (!isDescendant(root, path)) {
throw new Error(`clean: refusing deletion target outside repository: ${displayPath}`)
}
}
+32
View File
@@ -0,0 +1,32 @@
/**
* The type roots declare one wildcard image module per supported package
* asset extension, each with a string default export.
*/
import { join } from 'node:path'
import ts from 'typescript'
import { describe, expect, it } from 'vitest'
import { PACKAGE_ASSET_MEDIA_TYPES } from '../packages/client/modules/src/assets.ts'
const DECLARATIONS = join(import.meta.dirname, 'types/client-assets/index.d.ts')
describe('client asset type declarations', () => {
it('declare a string default export for exactly the supported extensions', () => {
const program = ts.createProgram({
rootNames: [DECLARATIONS],
options: { noEmit: true, noLib: true, types: [] },
})
const checker = program.getTypeChecker()
const declarations = program.getSourceFile(DECLARATIONS)
if (declarations === undefined) throw new Error(`${DECLARATIONS} is not in the program`)
const modules = checker.getAmbientModules()
.filter(module => module.declarations?.some(declaration => declaration.getSourceFile() === declarations))
expect(modules.map(module => JSON.parse(module.getName()) as string).sort())
.toEqual([...PACKAGE_ASSET_MEDIA_TYPES.keys()].map(extension => `*${extension}`).sort())
for (const module of modules) {
const defaultExport = checker.getExportsOfModule(module).find(symbol => symbol.getName() === 'default')
if (defaultExport === undefined) throw new Error(`${module.getName()} declares no default export`)
expect(checker.typeToString(checker.getTypeOfSymbol(defaultExport)), module.getName()).toBe('string')
}
})
})
@@ -8,6 +8,7 @@ import {
assertClientBuildEnvironment,
clientBuildEnvironmentDefines,
clientBuildProcessEnvironment,
clientBuildValueDefines,
officialClientBuildEnvironment,
readClientBuildRecord,
repositoryClientBuildEnvironment,
@@ -28,6 +29,8 @@ const roots: string[] = []
const dshBuildWorkflows = [
'build-exe-for-python-sdk.yml',
'ci.yml',
'ci-python.yml',
'ci-windows.yml',
'e2e.yml',
'release.yml',
'release-publish.yml',
@@ -140,6 +143,40 @@ describe('client build environment', () => {
expect(repositoryCommitHash('/unused', { DSH_CLIENT_COMMIT_HASH: COMMIT_HASH })).toBe(COMMIT_HASH.slice(0, 7))
})
it.each([undefined, 'true', 'false'])('isolates local branding while retaining repository metadata (dirty=%s)', (dirty) => {
const parent = {
DSH_BUILD_CLIENT_PROFILE: 'official',
DSH_CLIENT_BUILD_PROFILE: 'official',
DSH_CLIENT_TITLE: 'DeepSeek Harness',
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
DSH_CLIENT_VERSION: '1.2.3',
DSH_CLIENT_GIT_DIRTY: dirty,
DSH_CLIENT_EXTRA: 'inherited',
}
const local = resolveClientBuildEnvironment(parent, 'local')
expect(local).toEqual({
DSH_CLIENT_BUILD_PROFILE: 'local',
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
DSH_CLIENT_VERSION: '1.2.3',
...(dirty === 'true' ? { DSH_CLIENT_GIT_DIRTY: 'true' } : {}),
})
expect(clientBuildProcessEnvironment(parent, local)).toEqual(local)
expect(resolveClientBuildEnvironment({ ...parent, DSH_BUILD_CLIENT_PROFILE: 'local' })).toEqual(local)
expect(resolveClientBuildEnvironment({ ...parent, ...local }, 'official')).toEqual({
DSH_CLIENT_BUILD_PROFILE: 'official',
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
DSH_CLIENT_TITLE: 'DeepSeek Harness',
DSH_CLIENT_VERSION: '1.2.3',
})
})
it('requires source metadata for an explicit local build', () => {
expect(() => { resolveClientBuildEnvironment({}, 'local') }).toThrow(/DSH_CLIENT_COMMIT_HASH/)
expect(() => {
resolveClientBuildEnvironment({ DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7) }, 'local')
}).toThrow(/DSH_CLIENT_VERSION/)
})
it('owns repository version, commit, and dirty metadata for complete builds', () => {
const fixtureRoot = repositoryFixture()
const commit = git(fixtureRoot, ['rev-parse', '--short=7', 'HEAD'])
@@ -216,20 +253,23 @@ describe('client build environment', () => {
})
it('defines only public client values over a non-enumerable fallback', () => {
expect(clientBuildEnvironmentDefines({
const environment = {
PATH: '/bin',
DSH_TEST_API_KEY: 'secret',
DSH_CLIENT_VARIANT: 'quoted "value"',
DSH_CLIENT_EMPTY: '',
DSH_CLIENT_UNSET: undefined,
})).toEqual({
'process.env': '{}',
}
const values = {
'process.env.DSH_CLIENT_EMPTY': '""',
'process.env.DSH_CLIENT_VARIANT': '"quoted \\"value\\""',
})
}
expect(clientBuildEnvironmentDefines(environment)).toEqual({ 'process.env': '{}', ...values })
// Node faces keep their runtime `process.env` and fix only the public values.
expect(clientBuildValueDefines(environment)).toEqual(values)
})
it('feeds the same build-process value to dynamic tsdown bundles and the Vite shell', async () => {
it('feeds the same build-process value to dynamic tsdown bundles, their Node faces, and the Vite shell', async () => {
process.env[PROBE_NAME] = 'shared-value'
const configs = clientBundle(join(import.meta.dirname, '../packages/client/ui-sidebar'), [
@@ -241,6 +281,9 @@ describe('client build environment', () => {
'process.env': '{}',
[PROBE_KEY]: '"shared-value"',
})
const node = configs.find(config => config.name === '@deepseek-ai/dsh-client-ui-sidebar')
expect(node?.define).toMatchObject({ [PROBE_KEY]: '"shared-value"' })
expect(node?.define).not.toHaveProperty(['process.env'])
const viteConfigPath = '../apps/web/vite.config.ts'
const viteModule: unknown = await import(viteConfigPath)
@@ -278,6 +321,13 @@ describe('client build environment', () => {
const chunked = buildFixture(officialEnvironment)
write(join(chunked, 'packages/client/example/lib/client.pdf.js'), 'module.exports = {}\n')
expect(() => { readClientBuildRecord(chunked) }).toThrow(/artifacts differ/)
const dual = buildFixture(officialEnvironment)
write(join(dual, 'packages/client/example/lib/client.node.js'), 'export const node = true\n')
write(join(dual, 'packages/client/example/lib/client.node.helper.js'), 'export const helper = true\n')
expect(() => { readClientBuildRecord(dual) }).not.toThrow()
write(join(dual, 'packages/client/example/lib/client.browser.js'), 'module.exports = { browser: true }\n')
expect(() => { readClientBuildRecord(dual) }).toThrow(/artifacts differ/)
})
it('keeps public client values out of workflow-wide environments', () => {
+25 -7
View File
@@ -38,6 +38,7 @@ const CLIENT_ARTIFACT_PATTERNS = [
'packages/*/*/lib/client.js.map',
'packages/*/*/lib/client.*.js',
'packages/*/*/lib/client.*.js.map',
'packages/*/*/lib/assets/**/*',
] as const
/** Public values embedded in one set of client artifacts. */
@@ -180,6 +181,8 @@ function clientBuildEnvironment(environment: NodeJS.ProcessEnv): ClientBuildEnvi
/**
* Resolve the exact public environment selected for a complete client build.
* Named profiles discard inherited public overrides. Local builds retain the supplied
* commit, version, and true dirty marker; repositoryClientBuildEnvironment supplies that metadata.
* @param environment - parent process environment.
* @param profile - explicit profile, or the non-public selector when omitted.
* @returns the inherited public values when no profile is selected, otherwise the named profile.
@@ -189,22 +192,25 @@ export function resolveClientBuildEnvironment(
profile: string | undefined = environment[CLIENT_BUILD_PROFILE_SELECTOR],
): ClientBuildEnvironment {
if (profile === undefined) return clientBuildEnvironment(environment)
if (profile === 'official') {
if (profile === 'official' || profile === 'local') {
const commitHash = environment[CLIENT_COMMIT_HASH_VARIABLE]
const version = environment[CLIENT_VERSION_VARIABLE]
if (commitHash === undefined) {
throw new Error(`${CLIENT_COMMIT_HASH_VARIABLE} is required for the official client build profile`)
throw new Error(`${CLIENT_COMMIT_HASH_VARIABLE} is required for the ${profile} client build profile`)
}
if (version === undefined) {
throw new Error(`${CLIENT_VERSION_VARIABLE} is required for the official client build profile`)
throw new Error(`${CLIENT_VERSION_VARIABLE} is required for the ${profile} client build profile`)
}
return {
DSH_CLIENT_COMMIT_HASH: commitHash,
DSH_CLIENT_VERSION: version,
...OFFICIAL_CLIENT_BUILD_ENVIRONMENT,
...(profile === 'official' ? OFFICIAL_CLIENT_BUILD_ENVIRONMENT : {
DSH_CLIENT_BUILD_PROFILE: 'local',
...(environment.DSH_CLIENT_GIT_DIRTY === 'true' ? { DSH_CLIENT_GIT_DIRTY: 'true' } : {}),
}),
}
}
throw new Error(`unknown client build profile ${JSON.stringify(profile)}; expected "official"`)
throw new Error(`unknown client build profile ${JSON.stringify(profile)}; expected "official" or "local"`)
}
/**
@@ -265,7 +271,19 @@ export function assertClientBuildEnvironment(
export function clientBuildEnvironmentDefines(
environment: NodeJS.ProcessEnv,
): Record<string, string> {
const defines: Record<string, string> = { 'process.env': '{}' }
return { 'process.env': '{}', ...clientBuildValueDefines(environment) }
}
/**
* Create Node-face substitutions that fix each public client build value into the artifact.
* `process.env` itself stays untouched, so Host code keeps reading its other variables at runtime.
* @param environment - environment inherited by the build process.
* @returns one deterministic tsdown `define` expression per public value the build sets.
*/
export function clientBuildValueDefines(
environment: NodeJS.ProcessEnv,
): Record<string, string> {
const defines: Record<string, string> = {}
for (const [name, value] of Object.entries(clientBuildEnvironment(environment))) {
defines[`process.env.${name}`] = JSON.stringify(value)
}
@@ -331,7 +349,7 @@ export function readClientBuildRecord(
function clientArtifactDigest(root: string): ClientArtifactDigest {
const paths = globSync([...CLIENT_ARTIFACT_PATTERNS], { cwd: root })
.map(path => path.replaceAll('\\', '/'))
.filter(path => statSync(resolve(root, path)).isFile())
.filter(path => !/\/lib\/client\.node(?:\.|$)/.test(path) && statSync(resolve(root, path)).isFile())
.sort()
if (paths.length === 0) throw new Error('complete client build produced no Vite or dynamic client artifacts')
+516 -70
View File
@@ -1,100 +1,546 @@
/**
* Stylesheets enter client bundles through virtual modules, so the loader must
* register their physical files as watch dependencies.
* Stylesheets and package assets enter client bundles through plugin loaders,
* which register their physical files as watch dependencies. Package assets
* are emitted under `lib/assets/` with revisioned file names and referenced by document-relative URLs.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { transform } from 'lightningcss'
import { runInNewContext } from 'node:vm'
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { dirname, join } from 'node:path'
import { build, type TsdownBundle, type UserConfig } from 'tsdown'
import { afterEach, describe, expect, it } from 'vitest'
import { packageAssetRevision } from '../packages/client/modules/src/assets.ts'
import { clientBundle } from '../packages/client/tsdown.client.ts'
interface CssPlugin {
name: string
resolveId?: (source: string, importer?: string) => string | null
load?: (this: { addWatchFile(id: string): void }, id: string) => Promise<string | null>
interface EmittedAsset {
type: 'asset'
fileName: string
source: Uint8Array
originalFileName: string
}
function cssPlugin(name: 'dsh-css-modules-inline' | 'dsh-css-global-inline' | 'dsh-css-text-inline'): CssPlugin {
const configs = clientBundle(
join(import.meta.dirname, '../packages/client/ui-sidebar'),
['lib/types/index.js'],
)({ env: { DSH_BUILD_FACE: 'client' } })
const client = configs.find(config => config.platform === 'browser')
if (client === undefined) throw new Error('client config missing')
const plugins = (client as { plugins: CssPlugin[] }).plugins
const plugin = plugins.find(candidate => candidate.name === name)
if (plugin === undefined) throw new Error(`${name} missing from client config`)
interface PluginContext {
emitFile(file: EmittedAsset): string
addWatchFile(id: string): void
}
interface BundlePlugin {
name: string
buildStart?: (this: PluginContext) => void
resolveId?: (source: string, importer?: string) => string | null
load?: (this: PluginContext, id: string) => string | null | Promise<string | null>
writeBundle?: (this: { meta: { watchMode: boolean } }, outputOptions: { dir?: string }, bundle: Record<string, unknown>) => Promise<void>
renderChunk?: (code: string, chunk: unknown, outputOptions: { format: string }) => { toString(): string } | null
}
type PluginName = 'dsh-css-modules-inline' | 'dsh-css-global-inline' | 'dsh-css-text-inline' | 'dsh-package-asset'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-asset-fixture'
const ASSET_EXPORTS = { './client': './lib/client.js', './assets/*': './lib/assets/*' }
const WHALE = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3])
const WHALE_REV = packageAssetRevision(WHALE)
const WHALE_URL = `plugins/@deepseek-ai/dsh-client-asset-fixture/assets/whale-${WHALE_REV}.png`
/** The resolver call identifier a compiled module emits before rendering. */
const RESOLVER_CALL = /__dshPackageAsset_[0-9a-f]{12}(?=\()/u
/** Read the resolver call identifier from one compiled module. */
function resolverCall(output: string | null): string {
const call = RESOLVER_CALL.exec(output ?? '')?.[0]
if (call === undefined) throw new Error('compiled module carries no resolver call')
return call
}
/**
* Evaluate the stylesheet text an emitted style module or inline-text module computes.
* @param output - emitted module source.
* @param resolve - stands in for the module loader's asset resolver.
* @returns the stylesheet text with each resolved reference.
*/
function stylesheetText(output: string | null, resolve: (reference: string) => string): string {
if (output === null) throw new Error('stylesheet module was not emitted')
const call = RESOLVER_CALL.exec(output)?.[0]
const body = output
.replace(/^export default (.*);$/mu, 'return typeof css === "undefined" ? $1 : css;')
.replace(/^export \{\};$/mu, 'return css;')
return runInNewContext(`(() => {\n${body}\n})()`, { ...call === undefined ? {} : { [call]: resolve }, document: undefined }) as string
}
const temporaryRoots: string[] = []
afterEach(async () => {
await Promise.all(temporaryRoots.splice(0).map(root => rm(root, { recursive: true, force: true })))
})
/** Create a spec-owned temporary directory, addressed by its real path. */
async function temporaryDirectory(prefix: string): Promise<string> {
const root = await realpath(await mkdtemp(join(tmpdir(), prefix)))
temporaryRoots.push(root)
return root
}
async function writeFiles(root: string, files: Readonly<Record<string, string | Uint8Array>>): Promise<void> {
for (const [path, content] of Object.entries(files)) {
await mkdir(dirname(join(root, path)), { recursive: true })
await writeFile(join(root, path), content)
}
}
/** Create a client package fixture whose manifest declares the given exports. */
async function assetPackage(
files: Readonly<Record<string, string | Uint8Array>>,
exports: Readonly<Record<string, string>> = ASSET_EXPORTS,
): Promise<string> {
const root = await temporaryDirectory('dsh-client-asset-')
await writeFiles(root, { 'package.json': JSON.stringify({ name: PACKAGE_NAME, type: 'module', exports }), ...files })
return root
}
/** Client-face configs of one package, as its tsdown.config.ts would return them. */
function clientFaceConfigs(packageDir: string, dualClient = false): UserConfig[] {
return clientBundle(packageDir, ['lib/types/index.js'], { dualClient })({ env: { DSH_BUILD_FACE: 'client' } })
}
function configPlugin(config: UserConfig | undefined, name: string): BundlePlugin {
if (config === undefined) throw new Error('config missing')
const plugin = (config as { plugins: BundlePlugin[] }).plugins.find(candidate => candidate.name === name)
if (plugin === undefined) throw new Error(`${name} missing from ${String(config.name)}`)
return plugin
}
/** Look up plugins of one browser config, so stylesheet and asset plugins share its per-build state. */
function browserPlugins(packageDir: string): (name: PluginName) => BundlePlugin {
const client = clientFaceConfigs(packageDir).find(config => config.platform === 'browser')
return name => configPlugin(client, name)
}
function bundlePlugin(name: PluginName): BundlePlugin {
return browserPlugins(join(import.meta.dirname, '../packages/client/ui-sidebar'))(name)
}
interface RecordingContext extends PluginContext {
readonly emitted: EmittedAsset[]
readonly watched: string[]
}
function recordingContext(): RecordingContext {
const emitted: EmittedAsset[] = []
const watched: string[] = []
return {
emitted,
watched,
emitFile(file) {
emitted.push(file)
return file.fileName
},
addWatchFile(id) { watched.push(id) },
}
}
/** Emitted files with hex sources, so Buffer and Uint8Array bytes compare equal. */
function emittedFiles(context: { readonly emitted: readonly EmittedAsset[] }): object[] {
return context.emitted.map(file => ({ ...file, source: Buffer.from(file.source).toString('hex') }))
}
function hooks(plugin: BundlePlugin): Required<Pick<BundlePlugin, 'resolveId' | 'load'>> {
if (plugin.resolveId === undefined || plugin.load === undefined) throw new Error(`${plugin.name} hooks are incomplete`)
return { resolveId: plugin.resolveId, load: plugin.load }
}
/** Resolve and load one import through a plugin. */
async function loadImport(plugin: BundlePlugin, context: PluginContext, source: string, importer: string): Promise<string | null> {
const { resolveId, load } = hooks(plugin)
const id = resolveId(source, importer)
if (typeof id !== 'string') throw new Error(`${plugin.name} did not resolve ${source}`)
return await load.call(context, id)
}
describe('client bundle CSS Modules', () => {
it('registers the source stylesheet as a watch dependency', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-client-css-watch-'))
try {
const stylesheet = join(root, 'Fixture.module.css')
const importer = join(root, 'index.ts')
await writeFile(stylesheet, '.root { color: red; }\n')
const plugin = cssPlugin('dsh-css-modules-inline')
const virtualId = plugin.resolveId?.('./Fixture.module.css', importer)
if (typeof virtualId !== 'string' || plugin.load === undefined) {
throw new Error('CSS Modules plugin hooks are incomplete')
}
const watched: string[] = []
const root = await temporaryDirectory('dsh-client-css-watch-')
const stylesheet = join(root, 'Fixture.module.css')
await writeFile(stylesheet, '.root { color: red; }\n')
const context = recordingContext()
const output = await plugin.load.call({ addWatchFile: id => watched.push(id) }, virtualId)
const output = await loadImport(bundlePlugin('dsh-css-modules-inline'), context, './Fixture.module.css', join(root, 'index.ts'))
expect(watched).toEqual([stylesheet])
expect(output).toContain('data-plugin-css')
} finally {
await rm(root, { recursive: true, force: true })
}
expect(context.watched).toEqual([stylesheet])
expect(output).toContain('data-plugin-css')
})
})
describe('client bundle global CSS', () => {
it('compiles a side-effect stylesheet into a watched style injector', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-client-global-css-watch-'))
try {
const stylesheet = join(root, 'base.css')
const importer = join(root, 'index.ts')
await writeFile(stylesheet, 'body { color: red; }\n')
const plugin = cssPlugin('dsh-css-global-inline')
const virtualId = plugin.resolveId?.('./base.css', importer)
if (typeof virtualId !== 'string' || plugin.load === undefined) {
throw new Error('global CSS plugin hooks are incomplete')
}
const watched: string[] = []
const root = await temporaryDirectory('dsh-client-global-css-watch-')
const stylesheet = join(root, 'base.css')
await writeFile(stylesheet, 'body { color: red; }\n')
const context = recordingContext()
const output = await plugin.load.call({ addWatchFile: id => watched.push(id) }, virtualId)
const output = await loadImport(bundlePlugin('dsh-css-global-inline'), context, './base.css', join(root, 'index.ts'))
expect(watched).toEqual([stylesheet])
expect(output).toContain('data-plugin-css')
expect(output).toContain('body{color:red}')
} finally {
await rm(root, { recursive: true, force: true })
}
expect(context.watched).toEqual([stylesheet])
expect(output).toContain('data-plugin-css')
expect(output).toContain('body{color:red}')
})
it('compiles inline stylesheets as watched text without a module side effect', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-client-inline-css-watch-'))
try {
const stylesheet = join(root, 'base.css')
const importer = join(root, 'index.ts')
await writeFile(stylesheet, 'body { color: red; }\n')
const plugin = cssPlugin('dsh-css-text-inline')
const virtualId = plugin.resolveId?.('./base.css?inline', importer)
if (typeof virtualId !== 'string' || plugin.load === undefined) {
throw new Error('inline CSS plugin hooks are incomplete')
}
const watched: string[] = []
const root = await temporaryDirectory('dsh-client-inline-css-watch-')
const stylesheet = join(root, 'base.css')
await writeFile(stylesheet, 'body { color: red; }\n')
const context = recordingContext()
const output = await plugin.load.call({ addWatchFile: id => watched.push(id) }, virtualId)
const output = await loadImport(bundlePlugin('dsh-css-text-inline'), context, './base.css?inline', join(root, 'index.ts'))
expect(watched).toEqual([stylesheet])
expect(output).toContain('export default "body{color:red}"')
expect(output).not.toContain('data-plugin-css')
expect(context.watched).toEqual([stylesheet])
expect(output).toContain('export default "body{color:red}"')
expect(output).not.toContain('data-plugin-css')
})
})
describe('client bundle package asset imports', () => {
it('emits an imported asset once per build and exports its revisioned URL', async () => {
const root = await assetPackage({ 'src/client/assets/icons/[email protected]': WHALE })
const plugin = browserPlugins(root)('dsh-package-asset')
const { resolveId, load } = hooks(plugin)
const asset = join(root, 'src/client/assets/icons/[email protected]')
const context = recordingContext()
const id = resolveId('./assets/icons/[email protected]', join(root, 'lib/types/client/index.js'))
if (id === null) throw new Error('asset import did not resolve')
const first = await load.call(context, id)
const second = await load.call(context, id)
const expected = `export default ${resolverCall(first)}("plugins/@deepseek-ai/dsh-client-asset-fixture/assets/icons/whale%402x-${WHALE_REV}.png");`
expect(id).toBe(asset)
expect([first, second]).toEqual([expected, expected])
expect(emittedFiles(context)).toEqual([{
type: 'asset',
fileName: `assets/icons/whale@2x-${WHALE_REV}.png`,
source: Buffer.from(WHALE).toString('hex'),
originalFileName: asset,
}])
expect(context.watched).toEqual([asset])
plugin.buildStart?.call(context)
expect(await load.call(context, id)).toBe(expected)
expect(context.emitted).toHaveLength(2)
})
it('renders each resolver call as the module loader require.asset in the CommonJS bundle only', async () => {
const root = await assetPackage({ 'src/client/assets/whale.png': WHALE })
const plugin = browserPlugins(root)('dsh-package-asset')
const { renderChunk } = plugin
if (renderChunk === undefined) throw new Error('asset plugin has no renderChunk hook')
const call = resolverCall(await hooks(plugin).load.call(recordingContext(), join(root, 'src/client/assets/whale.png')))
// Plugin data that only shares the fixed prefix stays as written.
const literal = 'const text = "__dshPackageAsset(";\n'
const code = `const a = ${call}("${WHALE_URL}");\nconst css = "x" + ${call}("${WHALE_URL}") + "y";\n${literal}`
expect(renderChunk(code, {}, { format: 'cjs' })?.toString())
.toBe(`const a = require.asset("${WHALE_URL}");\nconst css = "x" + require.asset("${WHALE_URL}") + "y";\n${literal}`)
expect(renderChunk(code, {}, { format: 'es' })).toBeNull()
expect(renderChunk('const a = 1;\n', {}, { format: 'cjs' })).toBeNull()
})
it('builds an asset import into require.asset and keeps plugin strings that share the prefix', async () => {
const root = await assetPackage({
'src/client/assets/whale.png': WHALE,
'lib/types/client/index.js': 'import whale from "./assets/whale.png"\nexport const url = whale\nexport const text = "__dshPackageAsset("\n',
})
const config = clientFaceConfigs(root).find(candidate => candidate.platform === 'browser')
if (config === undefined) throw new Error('browser config missing')
let builds: TsdownBundle[] = []
try {
builds = await build({
...config, cwd: root, config: false, tsconfig: false,
write: false, clean: false, exports: false, report: false, logLevel: 'silent',
})
const output = builds.flatMap(bundle => bundle.chunks).find(chunk => chunk.type === 'chunk' && chunk.fileName === 'client.js')
const code = output?.type === 'chunk' ? output.code : ''
expect(code).toContain(`require.asset(${JSON.stringify(WHALE_URL)})`)
expect(code).not.toContain('__dshPackageAsset_')
expect(code).toContain('"__dshPackageAsset("')
} finally {
await rm(root, { recursive: true, force: true })
for (const bundle of builds) await bundle[Symbol.asyncDispose]()
}
})
it('ignores imports and modules that are not supported assets', async () => {
const root = await assetPackage({ 'src/client/index.ts': '', 'src/client/assets/font.woff2': 'font' })
const { resolveId, load } = hooks(browserPlugins(root)('dsh-package-asset'))
const importer = join(root, 'src/client/index.ts')
const context = recordingContext()
expect(resolveId('./index.ts', importer)).toBeNull()
expect(resolveId('./assets/font.woff2', importer)).toBeNull()
expect(resolveId('./assets/whale.png', undefined)).toBeNull()
expect(await load.call(context, importer)).toBeNull()
expect(await load.call(context, 'whale.png')).toBeNull()
expect(context.emitted).toEqual([])
})
it('rejects files outside the package src/client/assets directory', async () => {
const other = await assetPackage({ 'src/client/assets/whale.png': WHALE })
const root = await assetPackage({
'src/client/whale.png': WHALE,
'node_modules/dependency/package.json': JSON.stringify({ name: 'dependency' }),
'node_modules/dependency/whale.png': WHALE,
})
const plugin = browserPlugins(root)('dsh-package-asset')
const { load } = hooks(plugin)
const importer = join(root, 'src/client/index.ts')
const context = recordingContext()
await expect(loadImport(plugin, context, './whale.png', importer))
.rejects.toThrow(`${join(root, 'src/client/whale.png')} is outside src/client/assets; move the asset there`)
expect(() => load.call(context, join(other, 'src/client/assets/whale.png'))).toThrow('is outside src/client/assets')
await expect(loadImport(plugin, context, 'dependency/whale.png', importer)).rejects.toThrow('is outside src/client/assets')
expect(context.emitted).toEqual([])
})
it.each([
['is missing', { './client': './lib/client.js' }],
['targets another directory', { './assets/*': './src/client/assets/*' }],
])('rejects an asset while the asset export %s', async (_case, exports) => {
const root = await assetPackage({ 'src/client/assets/whale.png': WHALE }, exports)
const { load } = hooks(browserPlugins(root)('dsh-package-asset'))
expect(() => load.call(recordingContext(), join(root, 'src/client/assets/whale.png')))
.toThrow('needs package.json exports {"./assets/*":"./lib/assets/*"} and "lib/assets" in files')
})
it('rejects a missing asset file by name', async () => {
const root = await assetPackage({})
const asset = join(root, 'src/client/assets/missing.png')
const { load } = hooks(browserPlugins(root)('dsh-package-asset'))
expect(() => load.call(recordingContext(), asset)).toThrow(`${PACKAGE_NAME}: cannot resolve asset ${asset}`)
})
it.each([
'src/client/assets/100%.png', 'src/client/assets/icons#1/whale.png', 'src/client/assets/Node_Modules/whale.png',
])('rejects %s, which the asset route cannot serve', async (path) => {
const root = await assetPackage({ [path]: WHALE })
const { load } = hooks(browserPlugins(root)('dsh-package-asset'))
expect(() => load.call(recordingContext(), join(root, path)))
.toThrow(`${join(root, path)} has a node_modules path segment or one containing %, #, ?, \\ or NUL, which the asset route cannot serve; rename it`)
})
it('resolves every copy of a reference and tolerates a minifier dropping one', async () => {
const root = await assetPackage({
'src/client/assets/whale.png': WHALE,
'src/client/assets/old.png': WHALE,
'src/client/theme.css': '.a { background: url(./assets/old.png); background: url(./assets/whale.png); } .b { mask: url(./assets/whale.png); }',
})
const output = await loadImport(browserPlugins(root)('dsh-css-global-inline'), recordingContext(), './theme.css', join(root, 'src/client/index.ts'))
const css = stylesheetText(output, reference => `resolved:${reference}`)
expect(css.split(`url(resolved:${WHALE_URL})`)).toHaveLength(3)
// lightningcss keeps only the last background, so the placeholder of old.png is missing from the text.
expect(css).not.toContain('old-')
expect(css).not.toContain('__dshPackageAsset')
})
it('rewrites the urls inside image-set()', async () => {
const root = await assetPackage({
'src/client/assets/whale.png': WHALE,
'src/client/theme.css': '.a { background-image: image-set(url(./assets/whale.png) 1x, url("data:image/png;base64,AA==") 2x); }',
})
const context = recordingContext()
const output = await loadImport(browserPlugins(root)('dsh-css-global-inline'), context, './theme.css', join(root, 'src/client/index.ts'))
const css = stylesheetText(output, reference => `resolved:${reference}`)
expect(css).toContain(`resolved:${WHALE_URL}`)
expect(css).toContain('data:image/png;base64,AA==')
expect(context.emitted.map(file => file.fileName)).toEqual([`assets/whale-${WHALE_REV}.png`])
})
it('keeps literal placeholder-like text and resolves only the references the rewrite inserted', async () => {
const root = await assetPackage({
'src/client/assets/whale.png': WHALE,
'src/client/assets/__dshPackageAsset_logo.png': WHALE,
'src/client/theme.css': [
'.a::after { content: "__dshPackageAsset0__"; } .b { background: url(./assets/whale.png); }',
'.c { background: url(./assets/__dshPackageAsset_logo.png); }',
].join('\n'),
})
const output = await loadImport(browserPlugins(root)('dsh-css-global-inline'), recordingContext(), './theme.css', join(root, 'src/client/index.ts'))
const css = stylesheetText(output, reference => `resolved:${reference}`)
expect(css).toContain('content:"__dshPackageAsset0__"')
expect(css).toContain(`url(resolved:${WHALE_URL})`)
// A real file whose name shares the placeholder prefix is still a package asset.
expect(css).toContain(`url(resolved:plugins/@deepseek-ai/dsh-client-asset-fixture/assets/__dshPackageAsset_logo-${WHALE_REV}.png)`)
})
it('rejects an uppercase extension, which the asset import types do not declare', async () => {
const root = await assetPackage({ 'src/client/assets/whale.PNG': WHALE })
const { load } = hooks(browserPlugins(root)('dsh-package-asset'))
const asset = join(root, 'src/client/assets/whale.PNG')
expect(() => load.call(recordingContext(), asset)).toThrow(`${asset} has an uppercase extension`)
})
it('passes a file name lightningcss escapes through the resolver call, and rejects a fragment or query', async () => {
const root = await assetPackage({
'src/client/assets/whale(2x).png': WHALE,
'src/client/View.module.css': '.a { mask: url(\'./assets/whale(2x).png\'); }',
'src/client/Bad.module.css': '.b { mask: url(\'./assets/whale(2x).png#part\'); }',
})
const plugin = browserPlugins(root)('dsh-css-modules-inline')
const importer = join(root, 'src/client/index.ts')
const output = await loadImport(plugin, recordingContext(), './View.module.css', importer)
const reference = `plugins/@deepseek-ai/dsh-client-asset-fixture/assets/whale%282x%29-${WHALE_REV}.png`
// The served Web app returns the reference unchanged; the text must still parse as one url() declaration.
const css = stylesheetText(output, asset => asset)
expect(css).toContain(`url(${reference})`)
expect(transform({ filename: 'View.css', code: Buffer.from(css), minify: true }).code.toString()).toContain(`mask:url(${reference})`)
await expect(loadImport(plugin, recordingContext(), './Bad.module.css', importer))
.rejects.toThrow('a package asset url() takes no fragment or query')
})
it('rejects asset imports in the Node Client face', async () => {
const root = await assetPackage({})
const node = clientFaceConfigs(root, true).find(config => config.name === `${PACKAGE_NAME}/client-node`)
const { resolveId } = configPlugin(node, 'dsh-package-asset-browser-only')
if (resolveId === undefined) throw new Error('browser-only guard has no resolveId hook')
const importer = join(root, 'lib/types/client/index.js')
expect(() => resolveId('./assets/whale.png', importer)).toThrow(`${PACKAGE_NAME}: asset imports are browser-only (./assets/whale.png)`)
expect(resolveId('./index.js', importer)).toBeNull()
})
})
describe('client bundle stylesheet url()', () => {
it('rewrites relative CSS Modules urls and keeps urls that name no package file', async () => {
const root = await assetPackage({
'src/client/assets/whale.png': WHALE,
'src/client/View.module.css': [
'.whale { mask: url(\'./assets/whale.png\') center / 100% 100% no-repeat; }',
'@supports (mask-image: url(\'\')) { .empty { background: url(\'\'); } }',
'.data { background: url("data:image/svg+xml,%3Csvg%3E"); }',
'.fragment { mask: url(#mask); }',
'.rooted { background: url(/static/a.png); }',
'.remote { background: url(https://example.com/a.png); }',
].join('\n'),
})
const context = recordingContext()
const output = await loadImport(browserPlugins(root)('dsh-css-modules-inline'), context, './View.module.css', join(root, 'src/client/index.ts'))
expect(output).toContain(`url(" + ${resolverCall(output)}(${JSON.stringify(WHALE_URL)}) + ")`)
const css = stylesheetText(output, reference => `resolved:${reference}`)
expect(css).toContain(`url(resolved:${WHALE_URL})`)
for (const preserved of ['(mask-image:url(\'\'))', 'background:url()', 'url(data:image/svg+xml,%3Csvg%3E)', 'url(#mask)', 'url(/static/a.png)', 'url(https://example.com/a.png)']) {
expect(css).toContain(preserved)
}
expect(emittedFiles(context)).toEqual([{
type: 'asset',
fileName: `assets/whale-${WHALE_REV}.png`,
source: Buffer.from(WHALE).toString('hex'),
originalFileName: join(root, 'src/client/assets/whale.png'),
}])
})
it.each([
['dsh-css-global-inline', './theme.css'],
['dsh-css-text-inline', './theme.css?inline'],
] as const)('rewrites relative urls through %s', async (name, source) => {
const root = await assetPackage({
'src/client/assets/whale.png': WHALE,
'src/client/theme.css': 'body { background: url(./assets/whale.png); }',
})
const context = recordingContext()
const output = await loadImport(browserPlugins(root)(name), context, source, join(root, 'src/client/index.ts'))
expect(stylesheetText(output, reference => `resolved:${reference}`)).toContain(`url(resolved:${WHALE_URL})`)
expect(context.emitted.map(file => file.fileName)).toEqual([`assets/whale-${WHALE_REV}.png`])
})
it('leaves stylesheets outside the package src untouched', async () => {
const root = await assetPackage({
'node_modules/@fortune-sheet/react/dist/index.css': '.sheet { background: url("EwaAntH.gif"); }',
})
const context = recordingContext()
const output = await loadImport(
browserPlugins(root)('dsh-css-global-inline'),
context,
'../../node_modules/@fortune-sheet/react/dist/index.css',
join(root, 'src/client/index.ts'),
)
expect(output).toContain('url(EwaAntH.gif)')
expect(context.emitted).toEqual([])
})
it.each([
['./assets/font.woff2', 'is not a supported asset; supported extensions: .svg, .png, .webp, .jpg, .jpeg'],
['./whale.png', 'is outside src/client/assets; move the asset there'],
['./assets/missing.png', 'cannot resolve asset'],
])('rejects url(%s)', async (url, message) => {
const root = await assetPackage({
'src/client/whale.png': WHALE,
'src/client/assets/font.woff2': 'font',
'src/client/View.module.css': `.x { background: url(${url}); }`,
})
await expect(loadImport(browserPlugins(root)('dsh-css-modules-inline'), recordingContext(), './View.module.css', join(root, 'src/client/index.ts')))
.rejects.toThrow(message)
})
})
describe('client bundle asset output', () => {
it('prunes lib/assets files this build did not emit and the directories they leave empty', async () => {
const root = await assetPackage({
'lib/client.js': '',
'lib/assets/whale.png': WHALE,
'lib/assets/icons/kept.svg': '<svg/>',
'lib/assets/icons/stale.svg': '<svg/>',
'lib/assets/old/deep/stale.webp': 'webp',
})
const { writeBundle } = browserPlugins(root)('dsh-package-asset')
if (writeBundle === undefined) throw new Error('asset plugin has no writeBundle hook')
const out = join(root, 'lib')
const build = { meta: { watchMode: false } }
await writeBundle.call(build, { dir: out }, { 'client.js': {}, 'assets/whale.png': {}, 'assets/icons/kept.svg': {} })
expect(['client.js', 'assets/whale.png', 'assets/icons/kept.svg', 'assets/icons/stale.svg', 'assets/old']
.map(path => [path, existsSync(join(out, path))]))
.toEqual([
['client.js', true],
['assets/whale.png', true],
['assets/icons/kept.svg', true],
['assets/icons/stale.svg', false],
['assets/old', false],
])
await writeBundle.call(build, { dir: out }, { 'client.js': {} })
expect([existsSync(join(out, 'client.js')), existsSync(join(out, 'assets'))]).toEqual([true, false])
await writeBundle.call(build, { dir: out }, { 'client.js': {} })
})
it('keeps earlier lib/assets files in a watch rebuild for pages running the previous generation', async () => {
const root = await assetPackage({
'lib/client.js': '',
'lib/assets/whale-000000000000.png': WHALE,
})
const { writeBundle } = browserPlugins(root)('dsh-package-asset')
if (writeBundle === undefined) throw new Error('asset plugin has no writeBundle hook')
const out = join(root, 'lib')
await writeBundle.call({ meta: { watchMode: true } }, { dir: out }, { 'client.js': {}, 'assets/whale-111111111111.png': {} })
expect(existsSync(join(out, 'assets/whale-000000000000.png'))).toBe(true)
})
})
+76 -7
View File
@@ -75,6 +75,29 @@ describe('client bundle build faces', () => {
expect(() => staticLinked(root, ['lib/types/index.js'])).toThrow(/declares no string package name/)
})
it('opts shared Clients into independent Node ESM and browser factory entries', () => {
for (const dir of ['client/connection', 'api/gateway', 'typert/registry']) {
const configs = clientBundle(packageDir(dir), ['lib/types/index.js'], { dualClient: true })(
{ env: { DSH_BUILD_FACE: 'client' } },
)
const node = configs.find(config => config.name?.endsWith('/client-node'))
const browser = configs.find(config => config.name?.endsWith('/client'))
expect(node?.entry).toEqual({ 'client.node': 'lib/types/client/index.js' })
expect(node).toMatchObject({ platform: 'node', format: ['esm'], sourcemap: true })
expect(node?.inputOptions).toMatchObject({ resolve: { conditionNames: ['node', 'import', 'module', 'default'] } })
expect(node?.outputOptions).toMatchObject({ entryFileNames: 'client.node.js' })
expect(browser?.platform).toBe('browser')
expect(browser?.outputOptions).toMatchObject({ entryFileNames: 'client.browser.js' })
expect(configs.find(config => config.platform === 'node' && config.name?.endsWith('/client'))).toBeUndefined()
}
const ordinary = clientBundle(REQUESTING_PACKAGE_DIR, ['lib/types/index.js'])(
{ env: { DSH_BUILD_FACE: 'client' } },
)
expect(ordinary.some(config => config.name?.endsWith('/client-node'))).toBe(false)
expect(ordinary.find(config => config.platform === 'browser')?.outputOptions)
.toMatchObject({ entryFileNames: 'client.js' })
})
it('watches source in development and consumes emitted JavaScript in the Client build', () => {
const bundle = clientBundle(REQUESTING_PACKAGE_DIR, ['lib/types/index.js'])
const development = bundle({ env: {} }).find(config => config.platform === 'browser')
@@ -86,15 +109,42 @@ describe('client bundle build faces', () => {
})
})
function dynamicImportFixture(prefix: string): string {
const root = mkdtempSync(join(tmpdir(), prefix))
onTestFinished(() => { rmSync(root, { recursive: true, force: true }) })
const entry = join(root, 'lib/types/client/index.js')
mkdirSync(dirname(entry), { recursive: true })
writeFileSync(join(root, 'package.json'), JSON.stringify({ name: REQUESTING_PACKAGE, type: 'module' }))
writeFileSync(entry, 'export const load = () => import("./terminal.js")\n')
writeFileSync(join(dirname(entry), 'terminal.js'), 'export const marker = "terminal"\n')
return root
}
describe('client bundle dynamic imports', () => {
it('keeps a dual Client browser chunk disjoint from its Node output', async () => {
const root = dynamicImportFixture('dsh-dual-client-chunk-')
const configs = clientBundle(root, ['lib/types/index.js'], { dualClient: true })(
{ env: { DSH_BUILD_FACE: 'client' } },
)
for (const platform of ['node', 'browser'] as const) {
const config = configs.find(config => config.name?.endsWith(platform === 'node' ? '/client-node' : '/client'))
if (config === undefined) throw new Error(`${platform} config missing`)
const builds = await build({
...config, cwd: root, config: false, tsconfig: false,
write: false, clean: false, exports: false, report: false, logLevel: 'silent',
})
try {
const names = builds.flatMap(bundle => bundle.chunks).map(chunk => chunk.fileName)
expect(names).toContain(platform === 'node' ? 'client.node.js' : 'client.browser.js')
expect(names).toContain(platform === 'node' ? 'client.node.terminal.js' : 'client.browser.terminal.js')
} finally {
for (const bundle of builds) await bundle[Symbol.asyncDispose]()
}
}
})
it('compiles import() to the module loader asynchronous operation', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-client-dynamic-import-'))
onTestFinished(() => { rmSync(root, { recursive: true, force: true }) })
const entry = join(root, 'lib/types/client/index.js')
mkdirSync(dirname(entry), { recursive: true })
writeFileSync(join(root, 'package.json'), JSON.stringify({ name: REQUESTING_PACKAGE, type: 'module' }))
writeFileSync(entry, 'export const load = () => import("./terminal.js")\n')
writeFileSync(join(dirname(entry), 'terminal.js'), 'export const marker = "terminal"\n')
const root = dynamicImportFixture('dsh-client-dynamic-import-')
const config = clientConfigs()[0]
if (config === undefined) throw new Error('client config missing')
@@ -184,6 +234,25 @@ describe('client bundle purity gate', () => {
expect(() => resolveId('@deepseek-ai/dsh-spill-policy/notice/internal')).toThrow(/purity/)
})
it('inlines the stateless file-drag entry without admitting its subpaths or UI plugin', () => {
expect(resolveId('@deepseek-ai/dsh-util-file-drag')).toBeNull()
expect(() => resolveId('@deepseek-ai/dsh-util-file-drag/internal')).toThrow(/purity/)
expect(() => resolveId('@deepseek-ai/dsh-client-ui-file-drag')).toThrow(/purity/)
const deps = clientConfigs()[0]?.deps as { neverBundle: (specifier: string) => boolean }
expect(deps.neverBundle('@deepseek-ai/dsh-util-file-drag')).toBe(false)
})
it('inlines the browser-safe Timer entry while sharing Cordis through the module table', () => {
const timer = '@deepseek-ai/cordis-plugin-timer'
expect(resolveId(timer)).toBeNull()
expect(() => resolveId(`${timer}/src/index.ts`)).toThrow(/purity/)
expect(() => resolveId('@deepseek-ai/cordis-plugin-hmr')).toThrow(/purity/)
const config = clientConfigs(packageDir('extensions/cordis-client-runner'))[0]
const deps = config?.deps as { neverBundle: (specifier: string) => boolean }
expect(deps.neverBundle(timer)).toBe(false)
expect(deps.neverBundle('@deepseek-ai/cordis')).toBe(true)
})
it('lets exact generated Remote contributions inline without admitting their package implementation', () => {
expect(resolveId('@deepseek-ai/dsh-goal/remote')).toBeNull()
expect(() => resolveId('@deepseek-ai/dsh-goal')).toThrow(/purity/)
@@ -0,0 +1,24 @@
/** Compatibility checks execute command entrypoints; importing helpers cannot detect a silent no-op. */
import { spawnSync } from 'node:child_process'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const root = resolve(import.meta.dirname, '..')
describe('repository command entrypoints', () => {
it.each([
['run-gates.ts', ['invalid-compat-mode'], 'run-gates: expected mode'],
['build.ts', ['--scope', 'invalid-compat-scope'], 'build: --scope must be'],
] as const)('executes %s argument validation on the running Node version', (script, args, diagnostic) => {
const child = spawnSync(process.execPath, ['--import', 'tsx/esm', resolve(root, 'scripts', script), ...args], {
cwd: root,
encoding: 'utf8',
timeout: 30_000,
})
expect(child.error).toBeUndefined()
expect(child.signal).toBeNull()
expect(child.status).toBe(1)
expect(child.stderr).toContain(diagnostic)
})
})
+31 -9
View File
@@ -14,6 +14,7 @@ const compiler = fileURLToPath(import.meta.resolve('typescript/bin/tsc'))
function fixture(): { root: string; leaf: string } {
const root = mkdtempSync(join(tmpdir(), 'dsh-referenced-projects-'))
onTestFinished(() => { removeFixtureSafely(root) })
mkdirSync(join(root, 'packages'))
const leaf = join(root, 'leaf package')
mkdirSync(join(leaf, 'src'), { recursive: true })
writeFileSync(join(root, 'package.json'), JSON.stringify({ type: 'module' }))
@@ -110,6 +111,31 @@ describe('internal artifact build order', () => {
expect(commands()).toEqual(expected.map(args => ({ args, title: 'Inherited Build Title' })))
})
it('compiles a Host metadata consumer from a clean tree', () => {
const { root } = fixture()
const producer = writeTypertWorkspace(root)
const commands = recordedCommands(root)
const consumer = join(root, 'consumer')
mkdirSync(join(consumer, 'src'), { recursive: true })
writeFileSync(join(consumer, 'src/index.ts'), 'import { TYPERT } from "../../packages/fixture/probe/lib/typert.host.js"\nexport const value: unknown = TYPERT\n')
writeFileSync(join(consumer, 'tsconfig.json'), JSON.stringify({
extends: '../tsconfig.base.json', compilerOptions: { rootDir: 'src', outDir: 'lib/types' },
include: ['src'], references: [{ path: '../packages/fixture/probe' }],
}))
writeFileSync(join(root, 'tsconfig.host.json'), JSON.stringify({
extends: './tsconfig.base.json', compilerOptions: { noEmit: true }, files: [],
references: [{ path: producer }, { path: consumer }],
}))
expect(existsSync(join(producer, 'lib'))).toBe(false)
buildLibraryArtifacts(root, true)
expect(readFileSync(join(consumer, 'lib/types/index.js'), 'utf8')).toContain('TYPERT')
expect(commands().map(command => command.args)).toEqual([
['exec', 'tsdown', '--config-loader', 'native', '--env.DSH_BUILD_FACE', 'host'],
])
})
it('stops after a failed Host bundler before Desktop and Client builds', () => {
const { root } = fixture()
const commands = recordedCommands(root, true)
@@ -174,25 +200,21 @@ describe('bundle-free Host contracts', { timeout: 60_000 }, () => {
expect(outputs()).toEqual(expect.arrayContaining(['typert.host.d.ts', 'typert.host.js']))
})
it('fails when the Typert pass rejects a contributor while Host compilation completes', async () => {
const { pkg, run, outputs } = typertWorkspace()
it('does not compile Host packages after Typert rejects a contributor', async () => {
const { pkg, run } = typertWorkspace()
setPublishedFiles(pkg, [])
await expect(run()).rejects.toThrow('Host Typert exited with 1')
expect(outputs()).toEqual(['types/index.d.ts', 'types/index.js'])
expect(existsSync(join(pkg, 'lib'))).toBe(false)
})
it('reports both steps when both fail', async () => {
it('reports generation before an unrelated compiler error', async () => {
const { pkg, run } = typertWorkspace('/** @typert schema */\nexport interface Payload { value: string }\nexport const value: number = "invalid"\n')
setPublishedFiles(pkg, [])
const failure: unknown = await run().then(() => undefined, (error: unknown) => error)
expect(failure).toBeInstanceOf(AggregateError)
expect((failure as AggregateError).errors.map(error => String(error))).toEqual([
expect.stringContaining('host compiler exited with'),
expect.stringContaining('Host Typert exited with 1'),
])
expect(String(failure)).toContain('Host Typert exited with 1')
})
})
+19 -25
View File
@@ -1,7 +1,6 @@
/**
* Compile and bundle package projects without repeating aggregate test and script typechecks.
* Contract steps replace the Host bundle with the bundle-free Typert pass for consumers that read
* only declarations.
* Source-only Typert generation precedes compilation; contract steps omit runtime bundles.
*/
import { spawn, spawnSync } from 'node:child_process'
@@ -147,7 +146,7 @@ function compilerStep(
// Typert reads workspace packages only from source (generate-typert.ts
// rejects build outputs), so it needs no compiled package. It runs under tsx's
// ESM hook rather than through `pnpm exec`, whose startup would lengthen the
// critical path before Client compilation.
// critical path before package compilation.
function typertStep(): LibraryStep {
return {
id: 'host-typert',
@@ -166,16 +165,17 @@ function pnpmStep(id: LibraryStepId, label: string, args: readonly string[], nee
/**
* List the package-library build steps in Host, Desktop, then Client dependency order.
* Host bundling runs Typert, whose Remote declarations Client compilation imports.
* Source-only Typert generation supplies declarations before any package compilation.
* @param root - Repository root containing the compiler and bundle configurations.
* @param faces - `host` lists Host compilation and bundling, which produce the Typert outputs; `all` adds Desktop and Client.
* @param faces - `host` lists Typert generation, Host compilation, and bundling; `all` adds Desktop and Client.
* @param mode - Check package types by default; benchmark emission defers diagnostics to required full builds.
* @returns The ordered steps; each step needs its predecessor.
* @throws If an aggregate configuration is invalid.
*/
export function libraryBuildSteps(root: string, faces: 'host' | 'all', mode: CompilationMode = 'checked'): LibraryStep[] {
const host: LibraryStep[] = [
compilerStep(root, 'host', mode, []),
typertStep(),
compilerStep(root, 'host', mode, ['host-typert']),
pnpmStep('host-bundle', 'Host bundle', ['exec', 'tsdown', '--config-loader', 'native', '--env.DSH_BUILD_FACE', 'host'], ['host-src']),
]
if (faces === 'host') return host
@@ -188,19 +188,18 @@ export function libraryBuildSteps(root: string, faces: 'host' | 'all', mode: Com
}
/**
* List the bundle-free package steps that produce every declaration the type checkers, oxlint, and
* documentation checks read: Host compilation and the Typert pass, which writes the generated
* Typert declarations from source without bundling, run independently; Client compilation, which
* imports both, needs the two. Unlike {@link libraryBuildSteps}, no step writes runnable bundles.
* List bundle-free metadata generation and package compilation steps.
* Source-only Typert generation supplies declarations before Host compilation; Client
* compilation follows the Host declarations. No step writes runtime bundles.
* @param root - Repository root containing the compiler configurations.
* @param faces - `host` lists Host compilation and the Typert pass; `all` adds Client compilation.
* @param faces - `host` lists the Typert pass and Host compilation; `all` adds Client compilation.
* @returns The steps in report order.
* @throws If an aggregate configuration is invalid.
*/
export function contractSteps(root: string, faces: 'host' | 'all'): LibraryStep[] {
const host = [compilerStep(root, 'host', 'checked', []), typertStep()]
const host = [typertStep(), compilerStep(root, 'host', 'checked', ['host-typert'])]
if (faces === 'host') return host
return [...host, compilerStep(root, 'client', 'checked', ['host-src', 'host-typert'])]
return [...host, compilerStep(root, 'client', 'checked', ['host-src'])]
}
function runStep(root: string, step: LibraryStep): void {
@@ -209,7 +208,7 @@ function runStep(root: string, step: LibraryStep): void {
if (result.status !== 0) throw stepFailure(step, result.status ?? result.signal)
}
function runStepConcurrently(root: string, step: LibraryStep): Promise<void> {
function runStepAsync(root: string, step: LibraryStep): Promise<void> {
return new Promise((settle, fail) => {
const child = spawn(step.command, step.args, { cwd: root, stdio: 'inherit' })
child.once('error', fail)
@@ -227,7 +226,7 @@ function stepFailure(step: LibraryStep, exit: number | NodeJS.Signals | null): E
/**
* Emit package libraries serially in Host, Desktop, then Client dependency order.
* @param root - Repository root containing the compiler and bundle configurations.
* @param hostOnly - Stop after Host compilation and bundling, which produce the Typert outputs.
* @param hostOnly - Stop after Typert generation, Host compilation, and bundling.
* @param mode - Check package types by default; benchmark emission defers diagnostics to required full builds.
* @throws If any compiler or bundler fails.
*/
@@ -236,18 +235,13 @@ export function buildLibraryArtifacts(root: string, hostOnly: boolean, mode: Com
}
/**
* Compile Host package projects and, concurrently, write the generated Typert declarations
* without bundling. Both steps run to completion even when the other fails.
* @param root - Repository root containing the compiler configuration; the Typert pass runs there.
* @returns A promise that settles after both steps exit.
* @throws The failed step's error, or an `AggregateError` naming both steps when both fail.
* Generate Typert declarations from source, then compile Host package projects.
* @param root - Repository root containing the compiler configuration and source contributors.
* @returns a promise that settles after generation and compilation exit.
* @throws the first failed step's error; dependent compilation does not start after generation fails.
*/
export async function buildHostContracts(root: string): Promise<void> {
const results = await Promise.allSettled(contractSteps(root, 'host').map(step => runStepConcurrently(root, step)))
const failures: unknown[] = []
for (const result of results) if (result.status === 'rejected') failures.push(result.reason)
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'compile-referenced-projects: host compiler and Host Typert both failed')
for (const step of contractSteps(root, 'host')) await runStepAsync(root, step)
}
if (import.meta.main) {
+93
View File
@@ -0,0 +1,93 @@
/** Raw retention and candidate evaluation preserve the running plugin's config identity. */
import { Context, type Fiber } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import z from '@deepseek-ai/schemastery'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
function root(): Context {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
return ctx
}
describe('Fiber configuration access', () => {
it('keeps raw and running references unchanged while resolving valid or invalid candidates', async () => {
const ctx = root()
await ctx.plugin(Loader)
const Config = z.object({ value: z.number().min(0).volatile() })
const applied = vi.fn()
ctx.loader.builtins.consumer = { Config, apply: applied }
const raw = { value: { __jsExpr: '1 + 1' } }
const id = await ctx.loader.create({ name: 'cordis:consumer', config: raw })
const fiber = ctx.loader.resolve(id).fiber!
await fiber.await()
const running = applied.mock.calls[0]![1] as ReturnType<typeof Config>
expect(fiber.rawConfig).toBe(raw)
expect(fiber.effectiveConfig).toBe(running)
const candidate = fiber.resolveConfig({ value: { __jsExpr: '2 + 2' } }) as ReturnType<typeof Config>
expect(candidate.value.get()).toBe(4)
expect(candidate.value).not.toBe(running.value)
expect(() => fiber.resolveConfig({ value: -1 })).toThrow()
expect(fiber.rawConfig).toBe(raw)
expect(fiber.effectiveConfig).toBe(running)
expect(running.value.get()).toBe(2)
expect(applied).toHaveBeenCalledOnce()
})
it('uses the canonical fiber through registry wrappers and retains raw input without updating', async () => {
const ctx = root()
const initial = { value: 1 }
const apply = vi.fn()
const wrapper = ctx.plugin(apply, initial)
await wrapper
const fiber = wrapper.ctx.fiber
const hook = vi.fn(function (this: Fiber, _config: unknown, next: () => unknown) {
expect(this).toBe(fiber)
return next()
})
fiber.ctx.on('internal/config', hook)
const raw = { value: 2 }
wrapper.rawConfig = raw
expect(fiber.rawConfig).toBe(raw)
expect(wrapper.effectiveConfig).toBe(initial)
expect(wrapper.resolveConfig(raw)).toBe(raw)
expect(hook).toHaveBeenCalledOnce()
expect(fiber.effectiveConfig).toBe(initial)
expect(apply).toHaveBeenCalledOnce()
await wrapper.restart()
expect(fiber.effectiveConfig).toBe(raw)
expect(apply).toHaveBeenCalledTimes(2)
})
it('publishes updates through registry wrappers to the canonical fiber', async () => {
const ctx = root()
const initial = { value: 1 }
const apply = vi.fn()
const wrapper = ctx.plugin(apply, initial)
await wrapper
const raw = { value: 2 }
wrapper.update(raw)
await wrapper.await()
expect(apply).toHaveBeenLastCalledWith(wrapper.ctx, raw)
expect(wrapper.rawConfig).toBe(raw)
expect(wrapper.effectiveConfig).toBe(raw)
expect(wrapper.ctx.fiber.rawConfig).toBe(raw)
expect(wrapper.ctx.fiber.effectiveConfig).toBe(raw)
expect(apply).toHaveBeenCalledTimes(2)
})
it('retains a pending raw update until its injection is available', async () => {
const ctx = root()
ctx.provide('configAccessTest')
const apply = vi.fn()
const wrapper = ctx.plugin({ inject: ['configAccessTest'], apply }, { value: 1 })
const raw = { value: 2 }
wrapper.update(raw)
expect(wrapper.rawConfig).toBe(raw)
expect(wrapper.effectiveConfig).toBeUndefined()
ctx.set('configAccessTest', {})
await wrapper
expect(wrapper.effectiveConfig).toBe(raw)
expect(apply).toHaveBeenCalledOnce()
})
})
+101
View File
@@ -0,0 +1,101 @@
/** Timer iterator ordering and cancellation on the vendored Cordis lifecycle. */
import { Context } from '@deepseek-ai/cordis'
import Timer from '@deepseek-ai/cordis-plugin-timer'
import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
describe('Cordis Timer', () => {
let ctx: Context
beforeEach(async () => {
vi.useFakeTimers()
ctx = new Context()
await ctx.plugin(Timer)
})
afterEach(async () => {
try {
await ctx.fiber.dispose()
} finally {
vi.useRealTimers()
}
})
it('completes concurrent interval reads in order, one per tick', async () => {
const iterator = ctx.interval(10)
const completed: number[] = []
const reads = [0, 1, 2].map(index => iterator.next().then(
(value) => { completed.push(index); return value },
() => undefined,
))
for (let index = 0; index < reads.length; index++) {
await vi.advanceTimersByTimeAsync(10)
expect(completed).toEqual(Array.from({ length: index + 1 }, (_, i) => i))
}
expect(await Promise.all(reads)).toEqual(Array(3).fill({ done: false, value: undefined }))
await iterator.return!()
expect(vi.getTimerCount()).toBe(0)
})
it.each(['return', 'throw', 'dispose'] as const)('settles all pending interval reads on %s', async (operation) => {
const iterator = ctx.interval<string>(10)
const completed: PromiseSettledResult<IteratorResult<void, string>>[] = []
for (let index = 0; index < 3; index++) {
void iterator.next().then(
value => completed.push({ status: 'fulfilled', value }),
(reason: unknown) => completed.push({ status: 'rejected', reason }),
)
}
const error = new Error('stopped')
if (operation === 'return') await iterator.return!('finished')
else if (operation === 'throw') await iterator.throw!(error)
else await ctx.fiber.dispose()
await vi.advanceTimersByTimeAsync(0)
expect(completed).toHaveLength(3)
if (operation === 'return') {
expect(completed).toEqual(Array(3).fill({ status: 'fulfilled', value: { done: true, value: 'finished' } }))
await expect(iterator.next()).resolves.toEqual({ done: true, value: 'finished' })
} else {
const reason = operation === 'throw' ? error : new Error('Context has been disposed')
expect(completed).toEqual(Array(3).fill({ status: 'rejected', reason }))
await expect(iterator.next()).rejects.toThrow(reason.message)
}
expect(vi.getTimerCount()).toBe(0)
})
it.each([false, true])('stops throttle calls and trailing work after disposal (noTrailing=%s)', async (noTrailing) => {
const callback = vi.fn((value: string) => value.length)
const throttled = ctx.throttle(callback, 10, noTrailing)
expectTypeOf(throttled).parameters.toEqualTypeOf<[string]>()
expectTypeOf(throttled).returns.toBeVoid()
throttled('first')
throttled('second')
await vi.advanceTimersByTimeAsync(10)
expect(callback.mock.calls).toEqual(noTrailing ? [['first']] : [['first'], ['second']])
throttled('before disposal')
const calls = callback.mock.calls.length
throttled.dispose()
await vi.advanceTimersByTimeAsync(20)
throttled('after disposal')
expect(callback).toHaveBeenCalledTimes(calls)
expect(vi.getTimerCount()).toBe(0)
})
it('cancels debounce work and makes both wrappers inert after owner disposal', async () => {
const callback = vi.fn((value: string) => value.length)
const debounced = ctx.debounce(callback, 10)
const throttled = ctx.throttle(callback, 10)
expectTypeOf(debounced).parameters.toEqualTypeOf<[string]>()
expectTypeOf(debounced).returns.toBeVoid()
debounced('first')
debounced('last')
await vi.advanceTimersByTimeAsync(10)
expect(callback).toHaveBeenCalledExactlyOnceWith('last')
debounced('pending')
await ctx.fiber.dispose()
debounced('disposed')
throttled('disposed')
await vi.advanceTimersByTimeAsync(10)
expect(callback).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
})
})
+8 -3
View File
@@ -159,7 +159,7 @@ function workerExitReport(output: string, pool: string): string | undefined {
}
describe('coverage fork configuration entry', () => {
it('preserves the real Vitest failure and adds metadata before its error report', async ({ signal }) => {
it('preserves the real Vitest failure and attributes the same exit Vitest reports', async ({ signal }) => {
const [stock, diagnostic] = await Promise.all([
configuredPool('stock', 'exit', signal),
configuredPool('diagnostic', 'exit', signal),
@@ -196,8 +196,13 @@ describe('coverage fork configuration entry', () => {
})
expect(record.files.map(file => file.replaceAll('\\', '/')))
.toEqual([resolve('scripts/fixtures/coverage-fork.fixture.ts').replaceAll('\\', '/')])
expect(output.indexOf('coverage-worker-exit:'))
.toBeLessThan(output.indexOf(workerExitReport(output, 'coverage-forks')!))
const report = workerExitReport(output, 'coverage-forks')!
// A worker error is logged when the channel closes under a send, which precedes the exit the
// record describes. Vitest then prints no "Worker exited unexpectedly" line, so only that
// line states an order the record can hold.
if (report === 'Worker exited unexpectedly') {
expect(output.indexOf('coverage-worker-exit:')).toBeLessThan(output.indexOf(report))
}
})
it('keeps a successful configured run silent and successful', async ({ signal }) => {
+2 -1
View File
@@ -10,9 +10,10 @@ const child = spawn(process.execPath, [
'apps/cli/src/bin.ts',
'--profile',
'headless',
'--patch',
'apps/cli/config/examples/ptc.yml',
task,
], {
stdio: 'inherit',
env: { ...process.env, DSH_TOOLS_MODE: 'ptc' },
})
child.on('exit', (code, signal) => { process.exit(signal !== null ? 1 : code ?? 1) })
+57
View File
@@ -0,0 +1,57 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import yaml from 'js-yaml'
import { expect, it } from 'vitest'
it.each(['macos', 'windows'])('%s packaging accepts test branches but preserves production and tag restrictions', (platform) => {
const workflow = yaml.load(readFileSync(resolve(import.meta.dirname, `../.github/workflows/${platform}-package.yml`), 'utf8')) as {
jobs: { package: { steps: Array<{ name?: string; run?: string }> } }
}
const command = workflow.jobs.package.steps.find(step => step.name === 'Validate selected source and adapter support')!.run!
expect(command.startsWith('node -e "')).toBe(true)
expect(command.endsWith('"')).toBe(true)
const script = command.slice('node -e "'.length, -1)
const root = mkdtempSync(join(tmpdir(), 'desktop-source-'))
try {
const env = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '', GIT_CONFIG_PARAMETERS: '',
GIT_CONFIG_COUNT: '0', GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_INDEX_FILE: undefined }
const git = (...args: string[]) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'tag.gpgSign=false', ...args],
{ cwd: root, env, encoding: 'utf8', stdio: 'pipe' }).trim()
git('init', '--initial-branch=master')
mkdirSync(join(root, 'apps/desktop/scripts'), { recursive: true })
writeFileSync(join(root, 'package.json'), '{"version":"1.0.0"}')
writeFileSync(join(root, 'apps/desktop/scripts/desktop-ci-version.mjs'), 'PACKAGE_VERSION_MODE')
git('add', '.')
git('-c', 'user.name=Fixture', '-c', '[email protected]', '-c', 'commit.gpgsign=false', 'commit', '-m', 'fixture')
const sha = git('rev-parse', 'HEAD')
git('tag', 'dsh-v1.0.0')
git('tag', 'dsh-v2.0.0')
const run = (preset: string, ref: string) => {
const result = spawnSync(process.execPath, ['-e', script], {
cwd: root, encoding: 'utf8', env: { ...env, DSH_PACKAGING_PRESET: preset, PACKAGE_SOURCE_REF: ref },
})
expect(result.error).toBeUndefined()
expect(result.signal).toBeNull()
return result
}
for (const preset of ['test', 'production']) expect(run(preset, 'master').status).toBe(0)
git('checkout', '-b', 'feature/desktop-update')
expect(run('test', 'feature/desktop-update').status).toBe(0)
expect(run('production', 'feature/desktop-update').status).not.toBe(0)
expect(run('test', 'refs/pull/42/head').status).not.toBe(0)
expect(run('test', 'refs/heads/feature/desktop-update').status).not.toBe(0)
expect(run('unknown', 'feature/desktop-update').status).not.toBe(0)
git('checkout', '--detach', sha)
for (const ref of [sha, 'master', 'HEAD', 'refs/pull/42/head']) {
const result = run('test', ref)
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('Select a branch in this repository, not a SHA or PR ref')
}
for (const preset of ['test', 'production']) expect(run(preset, 'dsh-v1.0.0').status).toBe(0)
expect(run('test', 'dsh-v2.0.0').status).not.toBe(0)
writeFileSync(join(root, 'apps/desktop/scripts/desktop-ci-version.mjs'), 'old adapter')
expect(run('test', 'dsh-v1.0.0').status).not.toBe(0)
} finally { rmSync(root, { recursive: true, force: true, maxRetries: 3 }) }
})
+9
View File
@@ -18,6 +18,15 @@ const workflow = yaml.load(readFileSync(resolve(import.meta.dirname, '../.github
}>
}
it('rejects debugging packages before dependency setup and pins the checked attempt for routing', () => {
const steps = workflow.jobs.route!.steps
const preflight = steps.findIndex(step => step.id === 'preflight')
expect(preflight).toBeGreaterThan(0)
expect(preflight).toBeLessThan(steps.findIndex(step => step.uses?.startsWith('pnpm/')))
expect(steps[preflight]?.run).toBe('node apps/desktop/scripts/preflight-desktop-publish.mjs')
expect(steps.find(step => step.id === 'route')?.env?.DESKTOP_PACKAGE_ATTEMPT).toBe('${{ steps.preflight.outputs.attempt }}')
})
it('selects packages manually and gates production secrets with the production Environment', () => {
expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch'])
expect(workflow.on.workflow_dispatch).toMatchObject({ inputs: {
+5
View File
@@ -55,9 +55,11 @@ 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/util/file-drag': 'File naming and native transfer declarations without a plugin entry.',
'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/boot/official-bundles': 'Bundle-list data library the launcher and plugin manager import; plain constant exports.',
'packages/client/store': 'Browser-side state primitives; plain function/type exports.',
'packages/client/ui-primitives': 'Browser-side UI component library; plain component exports.',
'packages/client/ui-slots': 'Browser-side slot-map declarations; plain type exports.',
@@ -81,6 +83,7 @@ const PACKAGE_LIBRARIES: Readonly<Record<string, string>> = {
'packages/subprocess/win32-process': 'Low-level Win32 process and Job Object primitives.',
'packages/test-support/session-snapshot': 'Test infrastructure; mounts nothing into a product composition.',
'packages/test-support/agent-loop-testkit': 'Test helper library; mounts nothing into a product composition.',
'packages/test-support/tool-testkit': 'Tool fixture library; exports helpers without a plugin entry.',
'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.',
@@ -99,6 +102,8 @@ const PACKAGE_LIBRARIES: Readonly<Record<string, string>> = {
'packages/util/native-command': 'Host-side subprocess runner utility.',
'packages/util/output-retention': 'Zero-dependency retention utility.',
'packages/util/package-manifest': 'Shared package manifest declarations with type-only exports.',
'packages/util/plugin-detail': 'Plugin detail document library.',
'packages/util/plugin-market-index': 'Plugin market index validation library.',
'packages/util/time': 'Zero-dependency time-zone canonicalization utility.',
'packages/util/timeout': 'Zero-dependency timeout utility.',
'packages/util/values': 'Stateless lossless-JSON and immutable-value helpers.',
+1 -1
View File
@@ -138,7 +138,7 @@ function formatDiagnostics(diagnostics: readonly ts.Diagnostic[], blocks: Block[
/**
* Reuse the Host aggregate references from a temp project one directory below
* root. Generated Client API examples opt out because their declarations do
* not exist until Host tsdown has run.
* not exist until source-only Typert generation has run.
*/
function workspaceReferences(): { path: string }[] {
const file = join(root, 'tsconfig.host.json')
+3 -1
View File
@@ -1,7 +1,9 @@
/** Experimental package identities, publication policy, and retained npm names. */
/** Existing npm names retained by capabilities now housed under experimental/. */
/** Explicit npm identities retained across experimental classification and package-role moves. */
export const EXPERIMENTAL_PACKAGE_NAME_EXCEPTIONS: Readonly<Record<string, string>> = {
'packages/experimental/cua': '@deepseek-ai/dsh-cua',
'packages/experimental/cua-bundle': '@deepseek-ai/dsh-cua-bundle',
'packages/experimental/tool-session-query': '@deepseek-ai/dsh-tool-session-query',
'packages/experimental/session-title-all-prompts-llm': '@deepseek-ai/dsh-session-title-all-prompts-llm',
'packages/experimental/tool-terminal': '@deepseek-ai/dsh-tool-terminal',
@@ -0,0 +1,8 @@
/** Keep the authoring request snapshot stable without replacing native timers. */
export function apply(ctx) {
const original = Date.now
ctx.effect(() => {
Date.now = () => 1792108800000
return () => { Date.now = original }
}, 'authoring snapshot clock')
}
+14 -14
View File
@@ -1,27 +1,27 @@
/** Add a native tool after a file read and unregister it after its first execution. */
/** Activate a native tool after a file read and disable it after its first execution. */
export const name = 'python-sdk-dynamic-tools'
export const inject = ['tools']
/** @param {import('@deepseek-ai/cordis').Context} ctx - Scenario-owned tool registry. */
export function apply(ctx) {
let removeTool
let active = false
ctx.tools.register({
name: 'snapshot_ping',
description: 'Return pong once.',
parameters: { type: 'object', properties: {}, additionalProperties: false },
output: {
schema: { type: 'string' },
render(_args, value) { return [{ type: 'text', text: value }] },
},
async execute() { return 'pong' },
}, { when: async ({ modelSelection }) => active && modelSelection.provider === 'deepseek-official' })
ctx.on('tools/post-execute', async (exec, result, next) => {
const downstream = await next()
if (result.isError) return downstream
if (exec.name === 'read' && exec.arguments.file_path === 'dynamic-tool-task.txt') {
removeTool = ctx.effect(() => ctx.tools.register({
name: 'snapshot_ping',
description: 'Return pong once.',
parameters: { type: 'object', properties: {}, additionalProperties: false },
output: {
schema: { type: 'string' },
render(_args, value) { return [{ type: 'text', text: value }] },
},
async execute() { return 'pong' },
}))
active = true
} else if (exec.name === 'snapshot_ping') {
removeTool?.()
removeTool = undefined
active = false
}
return downstream
})
+1 -1
View File
@@ -5,7 +5,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { promisify } from 'node:util'
import { PROFILE_TEMPLATES } from '@deepseek-ai/dsh-app-boot'
import { PROFILE_TEMPLATES } from '@deepseek-ai/dsh-official-bundles'
import {
computeTranslationPairingRecord,
renderTranslationPairingRecord,
+25
View File
@@ -97,6 +97,31 @@ export function apply(ctx: unknown, config: Config): void {}
expect(new Set(provider?.schemaKeys)).toEqual(new Set(['mode', 'headless', 'endpoint']))
})
it.each([false, true])('checks a class Config imported from a sibling module (invalid field: %s)', (invalid) => {
const { root, write } = fixture()
write('packages/test/provider/src/config.ts', `
import Schema from '@deepseek-ai/schemastery'
export interface Config {
/** Maximum result count. */
limit: number
}
export const Config = Schema.object({ ${invalid ? 'hidden' : 'limit'}: Schema.number() })
`)
write('packages/test/provider/src/index.ts', `
import { Config } from './config.ts'
export default class Provider {
static Config = Config
constructor(ctx: unknown, config: Config) {}
}
`)
if (invalid) {
expect(() => collectConfigCatalog(root)).toThrow("schema validates key 'hidden' but config type 'Config' declares no such member")
} else {
const provider = collectConfigCatalog(root).find(entry => entry.pkg === '@test/provider')
expect(provider?.schemaKeys).toEqual(['limit'])
}
})
it('rejects type-only imports used as runtime schemas', () => {
const { root, write } = fixture()
write('packages/test/provider/src/index.ts', `
+48 -5
View File
@@ -50,6 +50,7 @@ export { REGION_BEGIN, REGION_END }
* errors, so the partition can never silently drift from the service API.
*/
export const SERVICE_PAGE: Record<string, string> = {
headlessStartup: 'boot.md',
translator: 'translation.md',
cotTranslation: 'translation.md',
speechToText: 'voice-input.md',
@@ -59,6 +60,7 @@ export const SERVICE_PAGE: Record<string, string> = {
productAnalytics: 'product-telemetry.md',
connection: 'web-server.md',
pluginManager: 'boot.md',
pluginMarket: 'boot.md',
pluginRegistryProbe: 'boot.md',
configEditor: 'boot.md',
profileContext: 'boot.md',
@@ -77,6 +79,7 @@ export const SERVICE_PAGE: Record<string, string> = {
ptcRuntime: 'ptc-runtime.md',
browserUse: 'browser-use.md',
computerUse: 'computer-use.md',
cuaAccess: 'computer-use.md',
commands: 'commands.md',
compaction: 'compaction.md',
cordisInspect: 'extensions.md',
@@ -190,17 +193,18 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
shortcuts: 'client-side interface-typed keyboard service — packages/client/shortcuts/README.md owns the API',
userQuestionPanels: 'client-side slot-contract accessor (UserQuestionPanels) — packages/client/ui-tool/README.md owns the API',
commandUi: 'client-side interface-typed browser service — packages/client/ui-commands/README.md owns the API',
fileDrag: 'client-side file preparation and transfer service — packages/client/ui-file-drag/README.md owns the API',
feedbackUi: 'client-side feedback dialog service — packages/client/ui-message-feedback/README.md owns the API',
conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the API',
layout: 'client-side interface-typed browser service — packages/client/ui-layout/README.md owns the API',
pluginNavigation: 'client-side bundle navigation — packages/client/ui-plugin-manager/README.md owns the API',
pluginChanges: 'client-side read-only management activity — packages/client/ui-plugin-manager/README.md owns the API',
locale: 'client-side interface-typed browser service — packages/client/locale/README.md owns the API',
modelDirectories: 'client-side interface-typed browser service — packages/client/ui-model-selection/README.md owns the API',
modules: 'client-side interface-typed browser service — packages/client/modules/README.md owns the API',
remote: 'client-side interface-typed gateway accessor (ClientRemote) — packages/api/gateway/README.md owns the API',
sessionLogDownload: 'client-side browser download controller — packages/session-query/session-log-export/README.md owns the API',
inputTriggers: 'client-side interface-typed browser service — packages/client/ui-input-trigger/README.md owns the API',
timer: 'client-side dynamic-package timer service — packages/extensions/cordis-client-runner/README.md owns the API',
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',
@@ -221,6 +225,7 @@ export const EVENT_SCOPE_PAGE: Record<string, string> = {
'app-boot': 'boot.md',
hmr: 'boot.md',
'plugin-manager': 'boot.md',
'plugin-market': 'boot.md',
'agent': 'core.md',
'agent-loop': 'core.md',
'agent-preset': 'core.md',
@@ -264,6 +269,7 @@ export const EVENT_SCOPE_PAGE: Record<string, string> = {
* exemption cannot mask another declaration in that scope.
*/
export const EVENT_WALK_EXEMPTIONS: Record<string, string> = {
'agent/request': 'deprecated compatibility hook — packages/core/agent/README.md documents migration',
'command/executed': 'client-face local command acknowledgment — packages/client/ui-commands/README.md owns the API',
'connection/reset': 'client-face transport signal — packages/api/session-controller/README.md owns the API',
'locale/change': 'client-face locale switch signal — packages/client/locale/README.md owns the API',
@@ -302,6 +308,14 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
ProfileReconciliation: 'boot.md',
PluginInfo: 'boot.md',
BundleInfo: 'boot.md',
MarketIndexSummary: 'boot.md',
MarketListingEntry: 'boot.md',
MarketQuery: 'boot.md',
MarketSearchResult: 'boot.md',
MarketSort: 'boot.md',
PluginDetail: 'boot.md',
BundleDetail: 'boot.md',
NpmFileSource: 'boot.md',
ChangeResult: 'boot.md',
InstallBundleOptions: 'boot.md',
PluginEntryId: 'boot.md',
@@ -312,10 +326,21 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
PluginChange: 'boot.md',
PluginInstallLogChunk: 'boot.md',
PluginInstallProgress: 'boot.md',
PluginUpdateProgress: 'boot.md',
PluginUpdateResult: 'boot.md',
PluginVersionList: 'boot.md',
ListVersionsOptions: 'boot.md',
UpdateBundleOptions: 'boot.md',
PluginRegistries: 'boot.md',
InspectOptions: 'boot.md',
BrowserUseProviderName: 'browser-use.md',
ComputerUseProviderName: 'computer-use.md',
CuaApplicationId: 'computer-use.md',
CuaApplicationGrant: 'computer-use.md',
CuaPermissionProcess: 'computer-use.md',
CuaApplicationState: 'computer-use.md',
CuaSystemPermission: 'computer-use.md',
CuaSystemPermissionState: 'computer-use.md',
RenderedDocumentBytes: 'office-to-pdf.md',
OfficeToPdfRequest: 'office-to-pdf.md',
OfficeToPdfResult: 'office-to-pdf.md',
@@ -329,6 +354,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
AllowedModelRoute: 'subagent.md',
SubagentModelSelectionSettings: 'subagent.md',
AgentOptions: 'core.md',
TurnBoundaryProjection: 'core.md',
AgentStatus: 'core.md',
AssistantStreamFrame: 'core.md',
ContentBlock: 'llm-streaming.md',
@@ -441,6 +467,8 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
PromptFileBinding: 'attachment.md',
FileUploadReceiptId: 'attachment.md',
FileUploadValue: 'attachment.md',
ImageUploadValue: 'attachment.md',
StagedAttachment: 'attachment.md',
SessionStartSource: 'core.md',
ToolCallId: 'core.md',
SessionLogSnapshot: 'session-query.md',
@@ -710,6 +738,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
ModDefinition: 'claude-code-mods.md',
SurfaceSnapshot: 'claude-code-mods.md',
TokenMeasurement: 'token-meter.md',
ContextPressureProjection: 'token-meter.md',
PtcDispatchLog: 'tools.md',
PostToolDecision: 'tools.md',
PreToolDecision: 'tools.md',
@@ -721,9 +750,18 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
ToolExecutionResult: 'tools.md',
ToolExecutionToken: 'tools.md',
ToolGuard: 'tools.md',
ToolPresentationMode: 'tools.md',
ToolRuntime: 'tools.md',
ToolsService: 'tools.md',
ToolExecutor: 'tools.md',
ToolModelInput: 'tools.md',
ToolPromptSection: 'tools.md',
ToolBatchInput: 'tools.md',
ToolCallResult: 'tools.md',
ToolBatchResult: 'tools.md',
ToolRestriction: 'tools.md',
ToolActivationContext: 'tools.md',
ToolRegistrationOptions: 'tools.md',
ResolvedToolSet: 'tools.md',
ToolSchema: 'tools.md',
SettingsNamespace: 'settings.md',
SettingsDescriptor: 'settings.md',
@@ -780,6 +818,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
PresetOption: 'permission-presets.md',
PresetSpec: 'permission-presets.md',
WebRoute: 'web-server.md',
WebResourceRoute: 'web-server.md',
IndexInjection: 'web-server.md',
StorageBackend: 'storage.md',
StorageForms: 'storage.md',
@@ -814,8 +853,9 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
WorkflowRunInfo: 'workflow.md',
WorkflowStartRequest: 'workflow.md',
ProjectionDefinition: 'session-projection.md',
ProjectionHandle: 'session-projection.md',
SessionProjectionMap: 'session-projection.md',
SessionProjectionStateMap: 'session-projection.md',
ProjectionKeySelection: 'session-projection.md',
ProjectionChangeListener: 'session-projection.md',
ProjectionSnapshot: 'session-projection.md',
ProjectionCheckpoint: 'session-projection.md',
@@ -853,6 +893,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
'Record',
'Readonly',
'ReadonlyMap',
'ReadonlySet',
'Request',
'Response',
'IncomingMessage',
@@ -868,6 +909,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
ConnectionRequestRejection: 'transport rejection status is owned by packages/client/connection/src/rpc.ts',
ConnectionTrustRequest: 'transport authentication input is owned by packages/client/connection/src/rpc.ts',
PeerAdmission: 'Peer admission outcome is owned by packages/client/connection/src/rpc.ts',
ConnectionAdmission: 'physical socket admission and release are owned by packages/client/connection/src/rpc.ts',
PeerScope: 'Peer scope contract is owned by packages/typert/protocol/src/types.ts',
RemoteAccessPolicy: 'access policy contract is owned by packages/api/remote-access/README.md',
RemoteAccessRequest: 'Gateway decision requests are owned by packages/api/remote-access/README.md',
@@ -949,6 +991,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
JsonValue: 'JSON value union is owned by packages/core/session/src/json.ts',
KnobState: 'projection unit state fields are owned by packages/interaction/permission-presets/README.md',
PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md',
PromptPresentation: 'presentation inputs are 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',
SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts',
@@ -1007,11 +1050,11 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = {
jsDoc: '/** Return an async iterator of timer ticks. */',
},
{
signature: 'throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): F & { dispose: () => void }',
signature: 'throttle<F extends (...args: any[]) => any>(callback: F, delay: number, noTrailing?: boolean): ((...args: Parameters<F>) => void) & { dispose: () => void }',
jsDoc: '/** Return a throttled function whose timer is disposed with the current fiber. */',
},
{
signature: 'debounce<F extends (...args: any[]) => void>(callback: F, delay: number): F & { dispose: () => void }',
signature: 'debounce<F extends (...args: any[]) => any>(callback: F, delay: number): ((...args: Parameters<F>) => void) & { dispose: () => void }',
jsDoc: '/** Return a debounced function whose timer is disposed with the current fiber. */',
},
],
+34 -10
View File
@@ -107,6 +107,14 @@ const GROUP_ORDER = [
]
const SERVICE_ROLES: ServiceRole[] = [
{
key: 'headlessStartup',
pkg: 'headless',
title: 'Headless invocation options',
mode: 'core',
consumers: ['headless'],
note: 'Supplies parsed task and run options to the headless runner and its sandbox-policy configuration.',
},
{
key: 'cotTranslation',
pkg: 'experimental-client-ui-cot-translation',
@@ -146,6 +154,14 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['plugin-manager', 'ui-settings-plugin-inventory'],
note: 'Shares profile package operations with the CLI and reports persisted and running state to Web and agent callers.',
},
{
key: 'pluginMarket',
pkg: 'plugin-market',
title: 'Plugin market index reader',
mode: 'core',
consumers: ['client-ui-plugin-market', 'plugin-market'],
note: 'Fetches and validates the index sources the user configures; installation goes through the plugin manager.',
},
{
key: 'profileContext',
pkg: 'app-boot',
@@ -207,6 +223,11 @@ const SERVICE_ROLES: ServiceRole[] = [
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: 'cuaAccess', pkg: 'cua', title: 'Computer Use permission controls',
mode: 'core', consumers: ['cua'],
note: 'The native provider owns application consent and cancellation; the optional Client page queries and changes those decisions through its Remote.',
},
{
key: 'officeToPdf', pkg: 'office-to-pdf', title: 'Office to PDF conversion',
mode: 'core', consumers: ['client-ui-sidebar-documentpreview'],
@@ -553,10 +574,10 @@ const SERVICE_ROLES: ServiceRole[] = [
{
key: 'tools',
pkg: 'tools',
title: 'Tool registry and guarded execution pipeline',
title: 'Scoped tool registry and executor selection',
mode: 'core',
consumers: ['agent-loop', 'tool-ask-user', 'tool-bash', 'tool-cordis', 'tool-fs', 'tool-terminal', 'tool-skill', 'tool-subagent', 'tool-todo', 'tool-web'],
note: 'Registers capabilities, owns PTC mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation.',
note: 'Registers capabilities and scoped executors. Native and PTC providers own descriptions and complete raw call batches; their optional shared pipeline handles tool policy and dispatch.',
},
{
key: 'userQuestions',
@@ -734,8 +755,8 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'PTC execution seam',
mode: 'seam',
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.',
consumers: ['tool-executor-ptc', 'workflow-ptc'],
note: 'Runs programs against host-provided async bindings; tool-executor-ptc owns PTC presentation and workflow-ptc owns workflow orchestration.',
},
{
key: 'fs',
@@ -810,7 +831,7 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'web',
title: 'Web access provider registry',
mode: 'seam',
implementations: ['web-search-exa', 'web-search-perplexity', 'web-search-deepseek', 'web-fetch-http'],
implementations: ['web-search-exa', 'web-search-perplexity', 'web-search-deepseek', 'web-deepseek-api', 'web-fetch-http'],
consumers: ['tool-web'],
note: 'Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names.',
},
@@ -1556,7 +1577,8 @@ 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->>Tools: resolve({ agent, modelSelection, signal }) once per proposed step',
` Driver->>Prompt: ${mermaidCode('system-prompt/assemble')} waterfall with the step tool set`,
` Driver->>Hooks: ${mermaidCode('agent/pre-step')} waterfall`,
' Hooks-->>Driver: authoritative reject or enter(messages)',
' alt proposed step rejected, first batch empty, or pre-step failed',
@@ -1596,6 +1618,7 @@ function renderLifecycle(): string {
' end',
' end',
` Driver->>Session: ${mermaidCode('step/end')}`,
' Driver->>Tools: dispose the step tool set',
' opt natural stop and next-step inbox empty',
` Driver->>Hooks: ${mermaidCode('agent/turn-stopping')} serial terminal checkpoint`,
' end',
@@ -1627,7 +1650,7 @@ function renderToolPipeline(): string {
const maintenance = 'curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs'
return [
...generatedHeader('Tool Execution Pipeline'),
'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering run without changing the loop. The `tools/pre-execute` waterfall runs first, monotonic guards run next, and the `tools/execute` and `tools/post-execute` waterfalls follow; the three waterfalls may transform a call. Definition-owned `projectContent` installs prepared content before post-execute; `finalizeContent` and `tools/result` run afterward.',
'Built-in executors own raw batch parsing, scheduling, cancellation, and Session call/result logging. This graph shows where their shared invocation pipeline applies policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering without changing the loop. The `tools/pre-execute` waterfall runs first, monotonic guards run next, and the `tools/execute` and `tools/post-execute` waterfalls follow; the three waterfalls may transform a call. Definition-owned `projectContent` installs prepared content before post-execute; `finalizeContent` and `tools/result` run afterward.',
'',
'```mermaid',
'flowchart TD',
@@ -1644,14 +1667,15 @@ function renderToolPipeline(): string {
` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/ptc-dispatch')}"]`,
' project["ToolDefinition.projectContent<br/>execution-prepared text and images"]',
` post["${mermaidCode('tools/post-execute')} waterfall<br/>accept, block, replace, add context"]`,
' normalized["Registry outer normalization<br/>pipeline/result snapshot throws become isError"]',
' normalized["Invocation pipeline normalization<br/>pipeline/result snapshot throws become isError"]',
' finalize["ToolDefinition.finalizeContent<br/>last content-only invariant"]',
` final["${mermaidCode('tools/result')} synchronous notification<br/>frozen authoritative outcome"]`,
' context["Active-batch additionalContexts FIFO<br/>injected user/message after recorded tool results"]',
` toolResult["Session event: ${mermaidCode('tool/result')}<br/>single model-facing outcome"]`,
' allResults["Tool batch settled<br/>recorded tool/result events complete"]',
' presentResult["UI completed card<br/>presentResult(args, result)"]',
' model --> toolCall',
' executor["Selected ToolExecutor.execute<br/>raw batch, Agent, optional logging step"]',
' model --> executor --> toolCall',
' toolCall --> presentCall',
' toolCall --> pre',
' pre -->|allow| guards',
@@ -1684,7 +1708,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/ptc-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 pipeline 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 ordered 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')
+7 -5
View File
@@ -4,11 +4,11 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, uti
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { ON_DEMAND_BUNDLES } from '../packages/boot/app-boot/src/official-bundles.ts'
import { ON_DEMAND_BUNDLES } from '../packages/boot/official-bundles/src/index.ts'
import { generateOfficialBundleCatalog, renderOfficialBundleCatalog, syncOfficialBundleCatalog } from './gen-official-bundle-catalog.ts'
const roots: string[] = []
const output = 'packages/boot/app-boot/src/official-bundles.generated.ts'
const output = 'packages/boot/official-bundles/src/catalog.generated.ts'
function write(root: string, path: string, content: unknown): void {
mkdirSync(dirname(join(root, path)), { recursive: true })
@@ -31,6 +31,7 @@ function fixture(): string {
write(root, `${dir}/icon.svg`, '<svg xmlns="http://www.w3.org/2000/svg"/>')
write(root, `${dir}/locale/en.json`, { meta: { title: 'Native agent', description: 'Delegate a task.' } })
write(root, `${dir}/locale/zh.json`, { meta: { title: '原生智能体', description: '委派任务。' } })
write(root, `${dir}/locale/ja.json`, { meta: { title: 'ネイティブエージェント', description: 'タスクを委派します。' } })
}
write(root, output, '')
return root
@@ -49,7 +50,8 @@ describe('Official bundle catalog generation', () => {
const entries = generateOfficialBundleCatalog(fixture())
expect(entries.map(entry => entry.packageName)).toEqual(ON_DEMAND_BUNDLES)
expect(entries[0]?.meta).toEqual({
title: { en: 'Native agent', zh: '原生智能体' }, description: { en: 'Delegate a task.', zh: '委派任务。' },
title: { en: 'Native agent', zh: '原生智能体', ja: 'ネイティブエージェント' },
description: { en: 'Delegate a task.', zh: '委派任务。', ja: 'タスクを委派します。' },
icon: `data:image/svg+xml;base64,${Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>').toString('base64')}`,
})
expect(renderOfficialBundleCatalog(entries)).not.toContain('1.2.3')
@@ -79,14 +81,14 @@ describe('Official bundle catalog generation', () => {
expect(() => generateOfficialBundleCatalog(root, [ON_DEMAND_BUNDLES[0]!, ON_DEMAND_BUNDLES[0]!])).toThrow('distinct package names')
})
it.each(['icon', 'en', 'zh'])('rejects missing %s metadata', (resource) => {
it.each(['icon', 'en', 'zh', 'ja'])('rejects missing %s metadata', (resource) => {
const root = fixture()
const dir = directory(ON_DEMAND_BUNDLES[0]!)
rmSync(join(root, dir, resource === 'icon' ? 'icon.svg' : `locale/${resource}.json`))
expect(() => generateOfficialBundleCatalog(root)).toThrow()
})
it.each(['en', 'zh'])('requires explicit %s fields instead of metadata fallback text', (language) => {
it.each(['en', 'zh', 'ja'])('requires explicit %s fields instead of metadata fallback text', (language) => {
const root = fixture()
write(root, `${directory(ON_DEMAND_BUNDLES[0]!)}/locale/${language}.json`, { meta: { description: 'Only a description.' } })
expect(() => generateOfficialBundleCatalog(root)).toThrow(`explicit ${language} title and description`)
+7 -7
View File
@@ -5,11 +5,11 @@ import { resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import ts from 'typescript'
import type { LocalizedText } from '../packages/util/package-manifest/src/types.ts'
import { ON_DEMAND_BUNDLES } from '../packages/boot/app-boot/src/official-bundle-packages.ts'
import type { OfficialBundleCatalogEntry } from '../packages/boot/app-boot/src/official-bundles.ts'
import { ON_DEMAND_BUNDLES } from '../packages/boot/official-bundles/src/bundles.ts'
import type { OfficialBundleCatalogEntry } from '../packages/boot/official-bundles/src/index.ts'
import { readPluginMeta, resolvePluginResource } from '../packages/boot/app-boot/src/package-meta.ts'
const OUTPUT = 'packages/boot/app-boot/src/official-bundles.generated.ts'
const OUTPUT = 'packages/boot/official-bundles/src/catalog.generated.ts'
/**
* Read complete metadata without importing or activating any provider code.
@@ -49,7 +49,7 @@ export function generateOfficialBundleCatalog(root: string, names: readonly stri
throw new Error(`${packageName}: Official catalog entries must be public DSH bundle packages`)
}
const parentURL = pathToFileURL(resolve(root, entry.path)).href
for (const language of ['en', 'zh']) {
for (const language of ['en', 'zh', 'ja']) {
const file = resolvePluginResource(`${packageName}/locale/${language}.json`, parentURL)
const dictionary = JSON.parse(readFileSync(file, 'utf8')) as { meta?: { title?: unknown; description?: unknown } }
if (typeof dictionary.meta?.title !== 'string' || dictionary.meta.title.trim() === ''
@@ -63,7 +63,7 @@ export function generateOfficialBundleCatalog(root: string, names: readonly stri
throw new Error(`${packageName}: Official catalog entries require an icon and localized title and description`)
}
for (const value of [meta.title, meta.description]) {
for (const language of ['en', 'zh']) {
for (const language of ['en', 'zh', 'ja']) {
if (typeof value[language] !== 'string' || value[language].trim() === '') {
throw new Error(`${packageName}: Official metadata requires ${language} text`)
}
@@ -76,11 +76,11 @@ export function generateOfficialBundleCatalog(root: string, names: readonly stri
/**
* Render a source-plane catalog with no package imports or pinned runtime version.
* @param entries - Validated metadata in catalog order.
* @returns Deterministic TypeScript source for app-boot's generated module.
* @returns Deterministic TypeScript source for the official-bundles generated module.
*/
export function renderOfficialBundleCatalog(entries: readonly OfficialBundleCatalogEntry[]): string {
return '/** Generated offline Official bundle metadata. Run pnpm gen-official-bundle-catalog; do not edit. */\n\n'
+ "import type { OfficialBundleCatalogEntry } from './official-bundles.ts'\n\n"
+ "import type { OfficialBundleCatalogEntry } from './index.ts'\n\n"
+ '/** Official on-demand entries, independent of installed packages and registry availability. */\n'
+ 'export const OFFICIAL_ON_DEMAND_CATALOG: readonly OfficialBundleCatalogEntry[] = [\n'
+ entries.map(entry => ' {\n'
+2 -2
View File
@@ -21,7 +21,7 @@ import { collectPluginRecords, type PluginRecordEntry } from './plugin-record-ca
export { annotateSurface, collectEventEnvelopeTypes, collectLogEvents, collectSurfaceEventTypes } from './persistence-catalog-source.ts'
export type { AnnotatedLogEventEntry, EventEnvelopeTypeEntry, LogEventEntry } from './persistence-catalog-source.ts'
import { persistenceSchemaSnapshot, type PersistenceSchemaInventory } from './persistence-schema-model.ts'
import { encodePersistenceSnapshot, type PersistenceSchemaInventory } from './persistence-schema-model.ts'
import { renderPersistenceSchemaDefinitions, renderPersistenceSchemaIndex } from './render-persistence-schema.ts'
const root = resolve(import.meta.dirname, '..')
@@ -225,7 +225,7 @@ export function persistenceCatalogArtifacts(scanRoot: string, schema: Persistenc
...renderPersistencePair(scanRoot, OUT, render(events, envelope, schema), render(events, envelope, schema, 'zh')),
...renderPersistencePair(scanRoot, OUT_PLUGIN_RECORDS, renderPluginRecordCatalog(pluginRecords), renderPluginRecordCatalog(pluginRecords, 'zh')),
{ path: OUT_RUNTIME_TYPES, content: renderKnownEventTypes(events) },
{ path: OUT_SCHEMA, content: `${JSON.stringify(persistenceSchemaSnapshot(schema), null, 2)}\n` },
{ path: OUT_SCHEMA, content: encodePersistenceSnapshot(schema) },
]
}
+40
View File
@@ -33,10 +33,13 @@ describe('THIRD_PARTY_NOTICES.md', () => {
}, async () => {
const generated = await render()
expect(generated).toContain('It depends on the third-party software listed below.')
expect(generated).toContain('| [`cross-spawn`](https://github.com/moxystudio/node-cross-spawn) | MIT |')
expect(generated).not.toContain('https://github.com/git@')
expect(generated).toContain(`| [\`numpy\`](https://github.com/numpy/numpy) | ${primaryRuntimeLock.pythonPackages.numpy} | BSD-3-Clause |`)
expect(generated).toContain('## LibreOffice conversion kit')
expect(generated).toContain('Recipients must have access to those corresponding sources and notices.')
expect(generated.split('## Development-only npm dependencies')[0]).toContain('| [`chrome-devtools-frontend`]')
expect(generated.split('## Development-only npm dependencies')[0]).toContain('| [`@botiverse/oar`]')
expect(generated).toContain('third-party license and notice files supplied by the npm source')
expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(generated)
})
@@ -53,6 +56,43 @@ function workspace(entries: Record<string, Manifest>): { manifests: Map<string,
}
describe('tierExternalDeps', () => {
it('discloses host-bundled OAR as runtime code and applies runtime license checks', () => {
const { manifests, names } = workspace({
'packages/experimental/subagent-oar/package.json': {
name: '@deepseek-ai/dsh-experimental-subagent-oar',
devDependencies: { '@botiverse/oar': '0.67.0', 'host-build-tool': '^1' },
},
})
const tiers = tierExternalDeps(manifests, names)
expect(tiers.get('@botiverse/oar')).toBe(true)
expect(tiers.get('host-build-tool')).toBe(false)
const dependencies = [{ name: '@botiverse/oar', license: 'GPL-3.0-only' }]
.filter(dependency => tiers.get(dependency.name))
expect(() => { assertRuntimeLicenses(dependencies) }).toThrow('@botiverse/oar (GPL-3.0-only)')
expect(() => { assertRuntimeLicenses([{ name: '@botiverse/oar', license: 'Apache-2.0' }]) }).not.toThrow()
})
it('keeps unrelated OAR development declarations out of runtime disclosure', () => {
const { manifests, names } = workspace({
'packages/experimental/unrelated/package.json': {
name: '@deepseek-ai/dsh-experimental-unrelated',
devDependencies: { '@botiverse/oar': '0.67.0' },
},
})
expect(tierExternalDeps(manifests, names).get('@botiverse/oar')).toBe(false)
})
it('rejects an OAR embedding owner that omits its required dependency declaration', () => {
const { manifests, names } = workspace({
'packages/experimental/subagent-oar/package.json': {
name: '@deepseek-ai/dsh-experimental-subagent-oar',
devDependencies: { 'host-build-tool': '^1' },
},
})
expect(() => tierExternalDeps(manifests, names))
.toThrow('@deepseek-ai/dsh-experimental-subagent-oar embeds @botiverse/oar but does not declare it')
})
it('limits the LibreOffice exception to its reviewed package identity and MPL terms', () => {
for (const name of [
'@deepseek-ai/libreoffice-kit', '@deepseek-ai/libreoffice-kit-wasm',
+20 -6
View File
@@ -41,6 +41,12 @@ const DEV_ONLY_AREAS = [
'native/',
] as const
/** Third-party implementations embedded by named Host packages' shipping build configurations. */
const HOST_BUNDLED_DEPENDENCIES: Readonly<Record<string, readonly string[]>> = {
// subagent-oar/tsdown.config.ts embeds the patched adapter instead of installing a second copy.
'@deepseek-ai/dsh-experimental-subagent-oar': ['@botiverse/oar'],
}
/** First-party public native packages: reachable at runtime but not third-party. */
const FIRST_PARTY = new Set([
'@deepseek-ai/node-addon-system',
@@ -134,7 +140,7 @@ interface ExternalDep {
name: string
license: string
repo: string
/** True when some shipped workspace consumer reaches it through runtime dependency edges. */
/** True when a shipped workspace consumer installs it at runtime or embeds its implementation. */
runtime: boolean
}
@@ -388,6 +394,7 @@ function normalizeRepo(raw: string | undefined): string | undefined {
let url = raw
.replace(/^git\+ssh:\/\/git@/, 'https://')
.replace(/^git\+/, '')
.replace(/^git@([^:]+):/, 'https://$1/')
.replace(/^git:\/\//, 'https://')
.replace(/^github:/, 'https://github.com/')
.replace(/\.git$/, '')
@@ -397,7 +404,7 @@ function normalizeRepo(raw: string | undefined): string | undefined {
/**
* Direct npm dependencies distributed through installed runtime libraries or
* browser builds. Tooling declarations alone do not imply distribution.
* embedded in Host and browser bundles. Tooling declarations alone do not imply distribution.
*/
function collectNpmDeps(manifests: Map<string, Manifest>, names: Set<string>, browser: ReadonlySet<string>): ExternalDep[] {
return [...tierExternalDeps(manifests, names, browser)]
@@ -407,7 +414,7 @@ function collectNpmDeps(manifests: Map<string, Manifest>, names: Set<string>, br
}
/**
* Tier every external dependency the workspace declares.
* Tier every external dependency the workspace declares, including named Host embeddings.
* @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.
@@ -421,10 +428,17 @@ export function tierExternalDeps(
tiers.set('tsx', true)
for (const [path, manifest] of manifests) {
const devOnly = DEV_ONLY_AREAS.some(area => (area.endsWith('/') ? path.startsWith(area) : path === area))
const hostBundled = manifest.name === undefined ? [] : HOST_BUNDLED_DEPENDENCIES[manifest.name] ?? []
for (const dependency of hostBundled) {
if (!ALL_KINDS.some(kind => Object.hasOwn(manifest[kind] ?? {}, dependency))) {
throw new Error(`gen-third-party-notices: ${manifest.name} embeds ${dependency} but does not declare it`)
}
}
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 = browser.has(dep) || !devOnly && (RUNTIME_KINDS as readonly string[]).includes(kind)
const runtime = browser.has(dep) || hostBundled.includes(dep)
|| !devOnly && (RUNTIME_KINDS as readonly string[]).includes(kind)
tiers.set(dep, (tiers.get(dep) ?? false) || runtime)
}
}
@@ -789,7 +803,7 @@ ${vendored.map(row => `| \`${row.npmName}\` | \`${row.upstreamName}\` | [${row.s
## Runtime npm dependencies
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.
External packages installed for runtime use or embedded in distributed Host or browser artifacts. Browser inputs are resolved through the shipping tsdown and Vite configurations, independently of npm dependency sections. Host embedding declarations identify the distributing package and its embedded dependencies. 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)}
@@ -812,7 +826,7 @@ The [kit repository](https://github.com/deepseek-ai/dsh-libreoffice-kit) supplie
## Development-only npm dependencies
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.
External packages **directly declared** for development, tests, types, or tooling, without a runtime installation or distribution inside a Host or browser bundle. 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)}
+73 -35
View File
@@ -7,7 +7,7 @@
*/
import { globSync, readFileSync, writeFileSync } from 'node:fs'
import { basename, resolve } from 'node:path'
import { basename, dirname, resolve } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import LlmRuntime from '@deepseek-ai/dsh-llm'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
@@ -19,7 +19,11 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SqliteSessionQueryEngine from '@deepseek-ai/dsh-session-query-sqlite'
import GoalService from '@deepseek-ai/dsh-goal'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import { NativeToolExecutor } from '@deepseek-ai/dsh-tool-executor-native'
import { PtcExecutor } from '@deepseek-ai/dsh-tool-executor-ptc'
import NodePtcRuntime from '@deepseek-ai/dsh-ptc-runtime-node'
import LocalSandboxProvider from '@deepseek-ai/dsh-sandbox-local'
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env'
import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local'
@@ -38,7 +42,6 @@ import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-http'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
import type { SubagentProvider } from '@deepseek-ai/dsh-subagent'
import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control'
import * as ToolSubagentListAgents from '@deepseek-ai/dsh-tool-subagent-control/list-agents'
import SkillRegistry from '@deepseek-ai/dsh-skill'
import * as SkillFileSystem from '@deepseek-ai/dsh-skill-filesystem'
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
@@ -70,10 +73,12 @@ import * as ToolTeam from '@deepseek-ai/dsh-experimental-tool-agent-team'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import type PluginManager from '@deepseek-ai/dsh-plugin-manager'
import * as PluginManagerTools from '@deepseek-ai/dsh-plugin-manager/tools'
import type PluginMarket from '@deepseek-ai/dsh-plugin-market'
import * as PluginMarketTools from '@deepseek-ai/dsh-plugin-market/tools'
import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy'
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 ToolSubagentOar from '@deepseek-ai/dsh-experimental-tool-subagent-oar'
import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
import WorkflowEngine from '@deepseek-ai/dsh-workflow'
import type { WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
@@ -164,13 +169,8 @@ export interface ToolPackage {
mount: (ctx: Context) => Promise<void>
/** Agent-like scope key whose tool view is catalogued instead of the global view. */
scope?: (ctx: Context) => Agent
/**
* Config for the caller's `ToolRuntime` mount. The registry itself ships a
* model-facing tool (`run_code`, registered under a non-native `mode`), so
* ITS catalog entry boots the registry in the mode that exposes it;
* every other entry uses the default (native) registry.
*/
toolsConfig?: ToolsConfig
/** Select the program transport when harvesting the PTC package. */
executor?: 'ptc'
/**
* A deployment note rendered after the package's tools, for a fact that
* booting the package alone cannot show. The registered tool NAME can be a
@@ -200,6 +200,18 @@ const TOOL_PACKAGES: ToolPackage[] = [
await ctx.plugin(PluginManagerTools)
},
},
{
pkg: '@deepseek-ai/dsh-plugin-market',
dir: 'plugin-market',
source: 'packages/boot/plugin-market/src/tools.ts',
requires: ['ctx.tools', 'ctx.pluginMarket'],
writes: ['tool/call', 'tool/result'],
async mount(ctx) {
// Schema harvest never fetches an index.
ctx.provide('pluginMarket', {} as PluginMarket)
await ctx.plugin(PluginMarketTools)
},
},
{
pkg: '@deepseek-ai/dsh-mcp-resources',
dir: 'mcp-resources',
@@ -241,26 +253,29 @@ const TOOL_PACKAGES: ToolPackage[] = [
'ask_user_question keeps the original blocking behavior by default; set `mode: timed` to opt into a foreground timeout and pending result while the question remains answerable. In timed mode, `timeout: -1` keeps that call blocking indefinitely.',
},
{
pkg: '@deepseek-ai/dsh-tools',
dir: 'tools',
source: 'packages/core/tools/src/ptc.ts',
requires: ['ctx.workingDirectory (Agent executions)', 'ctx.tools', 'ctx.ptcRuntime (execution time)', 'ctx.systemPrompt'],
pkg: '@deepseek-ai/dsh-tool-executor-ptc',
dir: 'tool-executor-ptc',
source: 'packages/core/tool-executor-ptc/src/ptc.ts',
requires: ['ctx.workingDirectory (Agent executions)', 'ctx.tools', 'ctx.ptcRuntime', '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 PTC runtime is read
// at assembly/execution time, so the schema harvest needs none mounted).
toolsConfig: { mode: 'ptc' },
async mount() {},
executor: 'ptc',
async mount(ctx) {
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalSandboxProvider)
await ctx.plugin(SandboxPolicy)
await ctx.plugin(NodePtcRuntime)
},
note:
'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: ptc` (see the PTC mode Agent Note). Under `ptc` it is the registry\'s only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime\'s language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.',
'The PTC executor exposes only run_code to the model and declares other visible capabilities in a generated SDK in the runtime’s language. Program bindings enter the shared guarded tool pipeline in submission order; concurrency-safe bodies overlap up to maxParallelSubCalls, and nested results remain linked to the outer program.',
},
{
pkg: '@deepseek-ai/dsh-plan-mode',
dir: 'plan-mode',
source: 'packages/plan/plan-mode/src/index.ts',
requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.userQuestions (execution time, opportunistic)'],
requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.agents', 'ctx.userQuestions (execution time, opportunistic)'],
writes: ['tool/call', 'plan/mode inactive on an approved review', 'tool/result'],
async mount(ctx) {
await ctx.plugin(AgentRegistry)
await ctx.plugin(PlanModeController, { section: 'Tool catalog schema harvest.' })
},
note:
@@ -288,9 +303,10 @@ const TOOL_PACKAGES: ToolPackage[] = [
pkg: '@deepseek-ai/dsh-tool-present',
dir: 'tool-present',
source: 'packages/deliverables/tool-present/src/index.ts',
requires: ['ctx.workingDirectory', 'ctx.tools', 'ctx.fs', 'ctx.sessionProjections'],
requires: ['ctx.workingDirectory', 'ctx.tools', 'ctx.fs', 'ctx.agents'],
writes: ['tool/call', 'deliverables/presented after a successful final result', 'tool/result'],
async mount(ctx) {
await ctx.plugin(AgentRegistry)
await ctx.plugin(ToolPresent)
},
note: 'Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards.',
@@ -494,16 +510,31 @@ const TOOL_PACKAGES: ToolPackage[] = [
pkg: '@deepseek-ai/dsh-tool-session-query',
dir: 'tool-session-query',
source: 'packages/experimental/tool-session-query/src/index.ts',
requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.sessionQuery', 'a calling Agent for workspace authority'],
requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.sessionQuery', 'ctx.agents', 'a calling Agent for workspace authority'],
writes: ['tool/call', 'tool/result'],
async mount(ctx) {
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SqliteSessionQueryEngine, { path: ':memory:' })
await ctx.plugin(ToolSessionQuery)
},
note:
'The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies.',
},
{
pkg: '@deepseek-ai/dsh-experimental-tool-subagent-oar',
dir: 'tool-subagent-oar',
source: 'packages/experimental/tool-subagent-oar/src/index.ts',
requires: ['ctx.tools', 'ctx.subagents'],
writes: ['tool/call', 'tool/result', 'parent subagent catalog and completion notices'],
async mount(ctx) {
await ctx.plugin(SubagentRuntime)
await ctx.plugin(ToolSubagentOar, {
providers: { codex: 'oar-codex', claude: 'oar-claude' },
})
},
note: 'The subagent_oar tool selects among configured aliases for external providers. This catalog shows Codex and Claude Code aliases; each deployment chooses its providers. Native model and permission settings belong to the selected provider configuration.',
},
{
pkg: '@deepseek-ai/dsh-tool-subagent',
dir: 'tool-subagent',
@@ -511,18 +542,17 @@ const TOOL_PACKAGES: ToolPackage[] = [
list_subagent_models: 'packages/subagent/tool-subagent/src/list-models.ts',
subagent: 'packages/subagent/tool-subagent/src/index.ts',
},
requires: ['ctx.tools', 'ctx.subagents', 'ctx.systemPrompt', 'ctx.llm for model discovery and selected-route validation'],
requires: ['ctx.tools', 'ctx.subagents', 'ctx.systemPrompt', 'ctx.sessionProjections', 'ctx.llm for model discovery and selected-route validation'],
writes: ['tool/call', 'tool/result', 'child session events through the chosen provider'],
shippedNames: ['subagent', 'subagent_fork'],
async mount(ctx) {
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LlmRuntime)
registerCatalogSubagentProvider(ctx, 'mock')
await ctx.plugin(ToolSubagent, { provider: 'mock' })
registerListSubagentModels(ctx, { routes: [{ provider: 'mock', model: 'mock' }] })
await ctx.plugin(ToolSubagent, { provider: 'mock', modelDiscovery: true })
},
note:
'The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance selects its backend through `provider` and reads model-selection settings through `modelSelectionSettings`. Every delegation returns a child id promptly; local children accept follow-ups, while external backends report their single execution automatically.',
'The load-time `toolName` selects the delegation name (default `subagent`); `modelSelection` adds optional route fields. `modelDiscovery` defaults off and registers shared discovery on one instance per scope. Web and Desktop presets enable spawn discovery and route fields and leave `subagent_fork` route fields disabled. The Host settings service records authorization for each new top-level Session; child Sessions inherit it. Without a recorded policy, discovery and explicit selection permit only the current parent route. Every delegation returns a child id promptly; local children accept follow-ups, while external backends report their single execution automatically.',
},
{
pkg: '@deepseek-ai/dsh-tool-subagent-control',
@@ -532,7 +562,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
list_agents: 'packages/subagent/tool-subagent-control/src/list-agents.ts',
send_message: 'packages/subagent/tool-subagent-control/src/index.ts',
},
requires: ['ctx.tools', 'ctx.subagents', 'ctx.agents and ctx.sessionProjections (list_agents only)'],
requires: ['ctx.tools', 'ctx.subagents', 'ctx.agents'],
writes: ['tool/call', 'tool/result', 'child session events through ctx.subagents'],
async mount(ctx) {
await ctx.plugin(SubagentRuntime)
@@ -540,10 +570,9 @@ const TOOL_PACKAGES: ToolPackage[] = [
await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionStore)
await ctx.plugin(ToolSubagentControl)
await ctx.plugin(ToolSubagentListAgents)
},
note:
'The globally named discovery, interruption, and local-message tools over managed subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries).',
'The globally named discovery, interruption, and local-message tools over managed subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while one instance of this package registers `send_message`, `interrupt_agent`, and `list_agents`.',
},
{
pkg: '@deepseek-ai/dsh-tool-jobs',
@@ -693,7 +722,7 @@ export type ToolCatalog = CatalogPackage[]
/**
* Assert the boot manifest covers every shipped tool package on disk (a
* `tool-*` leaf under `packages/`).
* `tool-*` leaf under `packages/`, excluding test support).
* Booting has no source declaration to enumerate, so this glob restores the
* "a new tool cannot be silently undocumented" guarantee: an unlisted package
* fails the generator (and the freshness gate) until it is added to
@@ -702,7 +731,11 @@ export type ToolCatalog = CatalogPackage[]
* `scanRoot` defaults to the repo root; a test may point it at a fixture tree.
*/
export function assertManifestComplete(packages: ToolPackage[] = TOOL_PACKAGES, scanRoot: string = root): void {
const onDisk = globSync('packages/*/tool-*', { cwd: scanRoot }).map(p => basename(p)).sort()
// Native execution owns no tool definitions; PTC contributes run_code.
const onDisk = globSync('packages/*/tool-*', { cwd: scanRoot })
.filter(path => basename(dirname(path)) !== 'test-support')
.map(p => basename(p))
.filter(dir => dir !== 'tool-executor-native').sort()
const listed = new Set(packages.map(p => p.dir))
const missing = onDisk.filter(dir => !listed.has(dir))
if (missing.length > 0) {
@@ -755,9 +788,14 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES
await ctx.plugin(SystemPrompt)
await ctx.plugin(LocalFileSystem)
await ctx.plugin(WorkingDirectory)
await ctx.plugin(ToolRuntime, entry.toolsConfig ?? {})
await ctx.plugin(ToolRuntime)
const executor = entry.executor === 'ptc' ? new PtcExecutor(ctx) : new NativeToolExecutor(ctx)
ctx.tools.setExecutor(executor)
await entry.mount(ctx)
const schemas = ctx.tools.schemas(entry.scope?.(ctx)).sort((a, b) => a.name.localeCompare(b.name))
const scope = entry.scope?.(ctx)
const described = executor.describe(ctx.tools.definitions(scope))
const schemas = [...new Map([...ctx.tools.schemas(scope), ...described.schemas].map(schema => [schema.name, schema])).values()]
.sort((a, b) => a.name.localeCompare(b.name))
assertToolsHarvested(entry, schemas.length)
catalog.push({
pkg: entry.pkg,
+29 -6
View File
@@ -36,6 +36,15 @@ function captureConsole(): { log: () => string; error: () => string } {
return { log: text(log), error: text(error) }
}
function mapDependencyToSource(root: string): void {
const basePath = join(root, 'tsconfig.base.json')
const base = JSON.parse(readFileSync(basePath, 'utf8')) as { compilerOptions: Record<string, unknown> }
writeFileSync(basePath, JSON.stringify({
...base,
compilerOptions: { ...base.compilerOptions, paths: { '@fixture/dep/types': ['./packages/fixture/dep/src/types.ts'] } },
}))
}
function typertOutputs(pkg: string): Record<string, string> {
const output = join(pkg, 'lib')
return Object.fromEntries(globSync('typert.*', { cwd: output }).sort()
@@ -110,12 +119,7 @@ describe('bundle-free Typert pass', { timeout: 60_000 }, () => {
])
expect(existsSync(join(pkg, 'lib'))).toBe(false)
const basePath = join(root, 'tsconfig.base.json')
const base = JSON.parse(readFileSync(basePath, 'utf8')) as { compilerOptions: Record<string, unknown> }
writeFileSync(basePath, JSON.stringify({
...base,
compilerOptions: { ...base.compilerOptions, paths: { '@fixture/dep/types': ['./packages/fixture/dep/src/types.ts'] } },
}))
mapDependencyToSource(root)
expect(main([], root)).toBe(0)
expect(Object.keys(typertOutputs(pkg))).toEqual(['typert.host.d.ts', 'typert.host.js'])
})
@@ -136,6 +140,25 @@ describe('bundle-free Typert pass', { timeout: 60_000 }, () => {
expect(existsSync(join(pkg, 'lib'))).toBe(false)
})
it.each([false, true])('rejects a producer that reaches its own generated metadata through a source dependency (existing output: %s)', (compiled) => {
const { root, pkg } = workspace(DEPENDENT_TYPERT_SOURCE)
const dependency = writeCompiledDependency(root, false)
writeFileSync(join(dependency, 'src/types.ts'), "export type Shape = import('@fixture/probe/typert').Payload\n")
symlinkSync(pkg, join(root, 'node_modules/@fixture/probe'), 'junction')
mapDependencyToSource(root)
if (compiled) {
mkdirSync(join(pkg, 'lib'))
writeFileSync(join(pkg, 'lib/typert.host.d.ts'), 'export interface Payload { value: string }\n')
}
const output = captureConsole()
expect(main([], root)).toBe(1)
expect(output.error()).toContain(compiled ? 'loads workspace build outputs instead of source' : 'cannot resolve workspace imports')
expect(output.error()).toContain('@fixture/probe/typert')
expect(existsSync(join(pkg, 'lib/typert.host.js'))).toBe(false)
})
it('reports a failed write as a possibly mixed generation', () => {
const { root, pkg } = workspace()
writeFileSync(join(pkg, 'lib'), 'not a directory\n')
+6 -8
View File
@@ -1,13 +1,11 @@
/**
* Generate the repository's Typert artifacts without bundling.
*
* The pass writes the same files, through the same generator calls, as the workspace-mode Typert
* plugin in the Host tsdown pass (`tsdown.config.ts`): every explicit contributor's
* `lib/typert.host.*` and `lib/typert.remote-client.*`. Gates that read only generated
* declarations run it instead of bundling. It reads workspace packages only from source and fails
* when the Host program would load a package's `lib/` output, so it can run beside Host package
* compilation. Like the plugin it skips TypeScript diagnostics, which that compilation owns;
* Typert's own checks still fail the pass.
* Writes every explicit contributor's `lib/typert.host.*` and `lib/typert.remote-client.*`
* with the same generator and writer as the workspace-mode Typert bundler plugin. It reads
* workspace packages only from source and rejects imports requiring compiled output, so metadata
* generation precedes package compilation. The compiler owns TypeScript diagnostics;
* Typert analysis and emission failures still fail this pass.
*
* Usage: `tsx scripts/generate-typert.ts`, from the repository root or any directory below it.
* Exit status 0 means every artifact was written, 1 means generation failed, and 2 means the
@@ -55,7 +53,7 @@ const STAGE_ACTIONS: Readonly<Record<TypertGenerationStage, string>> = {
}
// Host package compilation owns TypeScript diagnostics, as it does for the
// tsdown plugin; rejecting build outputs lets this pass run beside it.
// tsdown plugin; rejecting build outputs lets this pass run before package compilation.
const GENERATION_OPTIONS = { checkDiagnostics: false, rejectBuildOutputs: true } as const
/**
+35 -35
View File
@@ -47,9 +47,9 @@ describe('volatile Loader updates', () => {
const fiber = await mountEntry(ctx, { Config, apply(owner: Context) {
owner.on('loader/volatile-update', events)
} }, { title: 'first' })
const reference = fiber.config as ReturnType<typeof Config>
const reference = fiber.effectiveConfig as ReturnType<typeof Config>
await update(fiber, { title: 'second' })
expect(fiber.config).toBe(reference)
expect(fiber.effectiveConfig).toBe(reference)
expect(reference.get()).toEqual({ title: 'second' })
expect(events).toHaveBeenCalledExactlyOnceWith([[]])
})
@@ -70,7 +70,7 @@ describe('volatile Loader updates', () => {
await update(first, { title: 'next', nested: { count: 2 } })
await first.await()
expect(mount).toHaveBeenCalledTimes(2)
expect(first.config).toBe(initial)
expect(first.effectiveConfig).toBe(initial)
expect(initial.title).toBe(title)
expect(configs[1]!.title.get()).toBe('hello')
expect(observed).toEqual([{ paths: [['title'], ['nested', 'count']], title: 'next', count: 2 }])
@@ -117,9 +117,9 @@ describe('volatile Loader updates', () => {
hooks.push(config.title.get())
})
} }, {})
const initial = fiber.config as ReturnType<typeof Config>
const initial = fiber.effectiveConfig as ReturnType<typeof Config>
await update(fiber, { title: 'accepted' })
expect(fiber.config).toBe(initial)
expect(fiber.effectiveConfig).toBe(initial)
expect(initial.title.get()).toBe('accepted')
await update(fiber, { title: 'accepted' })
expect(hooks).toEqual([])
@@ -180,11 +180,11 @@ describe('volatile Loader updates', () => {
const oldUrl = 'https://a.example/'
const newUrl = 'https://b.example/'
const fiber = await mountEntry(ctx, { Config: schema, apply }, { live: 'a', url: oldUrl })
const before = fiber.config as ReturnType<typeof schema>
const before = fiber.effectiveConfig as ReturnType<typeof schema>
await update(fiber, { live: 'b', url: newUrl })
await fiber.await()
expect(apply).toHaveBeenCalledTimes(2)
expect((fiber.config as ReturnType<typeof schema>).url.href).toBe(newUrl)
expect((fiber.effectiveConfig as ReturnType<typeof schema>).url.href).toBe(newUrl)
expect(before.live.get()).toBe('a')
await update(fiber, { live: 'c', url: newUrl, data: 'first' })
await update(fiber, { live: 'd', url: newUrl, data: 'second' })
@@ -199,7 +199,7 @@ describe('volatile Loader updates', () => {
owner.on('loader/volatile-update', events)
} }, { values: [undefined] })
await update(fiber, { values: [null] })
expect((fiber.config as ReturnType<typeof schema>).values.get()).toEqual([null])
expect((fiber.effectiveConfig as ReturnType<typeof schema>).values.get()).toEqual([null])
expect(events).toHaveBeenCalledExactlyOnceWith([['values']])
})
})
@@ -308,7 +308,7 @@ it('updates volatile children through Group and Include custom updates and saves
expect(includeEntry.fiber).toBe(originalInclude)
expect(group.fiber).toBe(originalGroup)
expect(consumer.fiber).toBe(originalConsumer)
expect(consumer.fiber!.config).toBe(initial)
expect(consumer.fiber!.effectiveConfig).toBe(initial)
expect(configs).toHaveLength(1)
expect(notifications).toHaveBeenCalledTimes(2)
expect(childUpdates).not.toHaveBeenCalled()
@@ -318,7 +318,7 @@ it('updates volatile children through Group and Include custom updates and saves
entry.fiber!.update({ title: 'custom-update', fixed: 'ordinary' })
expect(childUpdates).toHaveBeenCalledOnce()
expect(entry.options.config).toEqual({ title: 'custom-update', fixed: 'ordinary' })
expect((entry.fiber!.config as ReturnType<typeof Config>).title.get()).toBe('hello')
expect((entry.fiber!.effectiveConfig as ReturnType<typeof Config>).title.get()).toBe('hello')
entry.fiber!.update({ title: 'ephemeral', fixed: 'ordinary' }, true)
expect(childUpdates).toHaveBeenCalledTimes(2)
expect(entry.options.config).toEqual({ title: 'custom-update', fixed: 'ordinary' })
@@ -332,11 +332,11 @@ it('commits volatile edits in place and retains direct Fiber updates', async ()
const notifications = vi.fn()
const apply = vi.fn(function (owner: Context) { owner.on('loader/volatile-update', notifications) })
const fiber = await mountEntry(ctx, { Config, apply }, {})
const original = fiber.config as ReturnType<typeof Config>
const original = fiber.effectiveConfig as ReturnType<typeof Config>
await ctx.loader.create({ name: 'cordis:disabled', disabled: true })
await update(fiber, { title: 'enabled' })
expect(apply).toHaveBeenCalledOnce()
expect(fiber.config).toBe(original)
expect(fiber.effectiveConfig).toBe(original)
expect(original.title.get()).toBe('enabled')
expect(notifications).toHaveBeenCalledExactlyOnceWith([['title']])
await update(fiber, { title: 'invalid', nested: { count: -1 } })
@@ -344,7 +344,7 @@ it('commits volatile edits in place and retains direct Fiber updates', async ()
await update(fiber, { title: 'mixed', fixed: 'changed' })
expect(apply).toHaveBeenCalledTimes(2)
expect(original.title.get()).toBe('enabled')
const current = fiber.config as ReturnType<typeof Config>
const current = fiber.effectiveConfig as ReturnType<typeof Config>
expect(current.title.get()).toBe('mixed')
const hook = vi.fn((_config: unknown, _noSave: boolean, next: () => void) => { next() })
@@ -365,7 +365,7 @@ it('parses a volatile-only update through the config hook without patching the e
const fiber = await mountEntry(ctx, { Config, apply(owner: Context) {
owner.on('loader/volatile-update', () => { trace.push('notify') })
} }, { block: { title: 'initial' } })
const current = fiber.config as ReturnType<typeof Config>
const current = fiber.effectiveConfig as ReturnType<typeof Config>
const resolveHook = vi.fn((_raw: unknown, next: () => unknown) => next())
fiber.ctx.on('internal/config', resolveHook)
const contextPatch = vi.fn((_entry: unknown, next: () => void) => { next() })
@@ -379,7 +379,7 @@ it('parses a volatile-only update through the config hook without patching the e
expect(resolveHook).toHaveBeenCalledOnce()
expect(trace).toEqual(['validate', 'notify'])
expect(current.block.get()).toEqual({ title: 'changed' })
expect(fiber._config).toBe(raw)
expect(fiber.rawConfig).toBe(raw)
})
it.each([true, false])('keeps old references untouched when isolation replaces a dependency (available: %s)', async (available) => {
@@ -452,7 +452,7 @@ it.each(['group', 'include'] as const)('applies profile patches to a %s subtree
await reconcileProfilePatches(ctx, patch('second'), 'volatile-test')
expect(parent.fiber).toBe(parentFiber)
expect(entry.fiber).toBe(childFiber)
expect(entry.fiber!.config).toBe(original)
expect(entry.fiber!.effectiveConfig).toBe(original)
expect(original.title).toBe(reference)
expect(reference.get()).toBe('second')
expect(configs).toHaveLength(1)
@@ -481,9 +481,9 @@ it('logs an invalid candidate and keeps its references while other entries commi
await invalid.entry!.update({ config: { title: 'invalid', nested: { count: -1 } } })
await healthy.entry!.update({ config: { title: 'healthy' } })
expect(warning).toHaveBeenCalledTimes(2)
expect((invalid.config as ReturnType<typeof Config>).title.get()).toBe('hello')
expect(invalid._config).toEqual({ title: 'invalid', nested: { count: -1 } })
expect((healthy.config as ReturnType<typeof Config>).title.get()).toBe('healthy')
expect((invalid.effectiveConfig as ReturnType<typeof Config>).title.get()).toBe('hello')
expect(invalid.rawConfig).toEqual({ title: 'invalid', nested: { count: -1 } })
expect((healthy.effectiveConfig as ReturnType<typeof Config>).title.get()).toBe('healthy')
expect(events).toHaveBeenCalledExactlyOnceWith([['title']])
})
@@ -500,7 +500,7 @@ it.each([false, true])('retains raw expression edits for dependency restoration
await fiber.entry!.update({ config: raw })
await fiber.await()
expect(values).toEqual(volatile ? ['same'] : ['same', 'same'])
expect(fiber._config).toBe(raw)
expect(fiber.rawConfig).toBe(raw)
await provider.dispose()
await provide('old-route', 'new-route')
await fiber.await()
@@ -540,10 +540,10 @@ it('compares ordinary URL transforms by value and remounts when an expression re
const fixed = { __jsExpr: "ctx.get('volatile-transform-source').value" }
const raw = { url: 'https://example.com/', fixed, live: 'old' }
const fiber = await mountEntry(ctx, { Config: schema, apply: mount }, raw)
const current = fiber.config as ReturnType<typeof schema>
const current = fiber.effectiveConfig as ReturnType<typeof schema>
await update(fiber, { ...raw, live: 'new' })
expect(mount).toHaveBeenCalledOnce()
expect(fiber.config).toBe(current)
expect(fiber.effectiveConfig).toBe(current)
expect(current.url.href).toBe(raw.url)
expect(current.fixed).toBe('initial')
expect(current.live.get()).toBe('new')
@@ -554,7 +554,7 @@ it('compares ordinary URL transforms by value and remounts when an expression re
expect(current.fixed).toBe('initial')
expect(current.live.get()).toBe('new')
expect(events).toHaveBeenCalledOnce()
const replaced = fiber.config as ReturnType<typeof schema>
const replaced = fiber.effectiveConfig as ReturnType<typeof schema>
expect(replaced.fixed).toBe('changed')
expect(replaced.live.get()).toBe('remounted')
})
@@ -568,13 +568,13 @@ it('remounts when an ordinary parent expression removes a live reference path',
const mount = vi.fn(function (owner: Context) { owner.on('loader/volatile-update', events) })
const raw = { block: { __jsExpr: "ctx.get('volatile-path-source').block" }, live: 'old' }
const fiber = await mountEntry(ctx, { Config: schema, apply: mount }, raw)
const current = fiber.config as ReturnType<typeof schema>
const current = fiber.effectiveConfig as ReturnType<typeof schema>
source.block = undefined
await update(fiber, { ...raw, live: 'new' })
expect(mount).toHaveBeenCalledTimes(2)
expect(current.block.value.get()).toBe('old')
expect(events).not.toHaveBeenCalled()
expect((fiber.config as ReturnType<typeof schema>).block).toBeUndefined()
expect((fiber.effectiveConfig as ReturnType<typeof schema>).block).toBeUndefined()
})
it('keeps an ordinary plugin mounted when an absent object becomes its explicit default', async () => {
@@ -582,13 +582,13 @@ it('keeps an ordinary plugin mounted when an absent object becomes its explicit
const Config = z.object({ nested: z.object({ fixed: z.string() }).default({ fixed: 'default' }) })
const mount = vi.fn()
const fiber = await mountEntry(ctx, { Config, apply: mount }, {})
const config: unknown = fiber.config
const config: unknown = fiber.effectiveConfig
await update(fiber, { nested: { fixed: 'default' } })
expect(fiber.config).toBe(config)
expect(fiber.effectiveConfig).toBe(config)
expect(mount).toHaveBeenCalledTimes(1)
await update(fiber, { nested: { fixed: 'changed' } })
expect(mount).toHaveBeenCalledTimes(2)
expect(fiber.config).toEqual({ nested: { fixed: 'changed' } })
expect(fiber.effectiveConfig).toEqual({ nested: { fixed: 'changed' } })
})
it('keeps a noSave volatile value as the pending raw config across a no-op entry update', async () => {
@@ -596,11 +596,11 @@ it('keeps a noSave volatile value as the pending raw config across a no-op entry
const fiber = await mountEntry(ctx, { Config, apply() {} }, { title: 'saved' })
fiber.update({ title: 'ephemeral' }, true)
await fiber.await()
expect((fiber.config as ReturnType<typeof Config>).title.get()).toBe('ephemeral')
expect((fiber.effectiveConfig as ReturnType<typeof Config>).title.get()).toBe('ephemeral')
expect(fiber.entry!.options.config).toEqual({ title: 'saved' })
await fiber.entry!.update({ config: { title: 'saved' } })
expect(fiber._config).toEqual({ title: 'ephemeral' })
expect((fiber.config as ReturnType<typeof Config>).title.get()).toBe('ephemeral')
expect(fiber.rawConfig).toEqual({ title: 'ephemeral' })
expect((fiber.effectiveConfig as ReturnType<typeof Config>).title.get()).toBe('ephemeral')
})
it('isolates a failing notification listener and dispatches with a Context receiver', async () => {
@@ -613,11 +613,11 @@ it('isolates a failing notification listener and dispatches with a Context recei
const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
onTestFinished(() => { warning.mockRestore() })
await update(fiber, { title: 'committed' })
expect((fiber.config as ReturnType<typeof Config>).title.get()).toBe('committed')
expect((fiber.effectiveConfig as ReturnType<typeof Config>).title.get()).toBe('committed')
expect(receivers).toEqual([fiber])
expect(warning).toHaveBeenCalledWith(expect.objectContaining({ message: 'listener boom' }))
await update(other, { title: 'unaffected' })
expect((other.config as ReturnType<typeof Config>).title.get()).toBe('unaffected')
expect((other.effectiveConfig as ReturnType<typeof Config>).title.get()).toBe('unaffected')
})
it('remounts instead of committing when an ordinary field parses into a fresh class instance', async () => {
@@ -626,9 +626,9 @@ it('remounts instead of committing when an ordinary field parses into a fresh cl
const schema = z.object({ handle: z.transform(z.string(), value => new Handle(value)), live: z.string().volatile() })
const mount = vi.fn()
const fiber = await mountEntry(ctx, { Config: schema, apply: mount }, { handle: 'h', live: 'old' })
const before = fiber.config as ReturnType<typeof schema>
const before = fiber.effectiveConfig as ReturnType<typeof schema>
await update(fiber, { handle: 'h', live: 'new' })
expect(mount).toHaveBeenCalledTimes(2)
expect(before.live.get()).toBe('old')
expect((fiber.config as ReturnType<typeof schema>).live.get()).toBe('new')
expect((fiber.effectiveConfig as ReturnType<typeof schema>).live.get()).toBe('new')
})
+1 -1
View File
@@ -8,7 +8,7 @@ import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execa } from 'execa'
import { describe, expect, it, onTestFinished } from 'vitest'
import { ON_DEMAND_BUNDLES } from '../packages/boot/app-boot/src/official-bundle-packages.ts'
import { ON_DEMAND_BUNDLES } from '../packages/boot/official-bundles/src/bundles.ts'
import { generateOfficialBundleCatalog } from './gen-official-bundle-catalog.ts'
import {
createRegistryManifest,
+104 -17
View File
@@ -1,6 +1,7 @@
/**
* Gate for the invariant `FALLBACK_LOCALE` rests on: every shipped dictionary
* declares the same keys in `zh` and `en`.
* declares the same keys in `zh`, `en`, and `ja`; Japanese dictionaries also
* preserve the English literal-template placeholders.
*
* The locale runtime resolves a key through the active locale, then through
* the single fallback locale (`en`), then surfaces the key itself. With
@@ -33,7 +34,7 @@ function relative(file: string): string {
return file.slice(root.length).replaceAll('\\', '/')
}
/** Every `.ts` source file under each workspace package's `src`, excluding declarations. */
/** Every TypeScript source file under each workspace package's `src`, excluding declarations. */
function sourceFiles(): string[] {
const files: string[] = []
const packagesRoot = resolve(root, 'packages')
@@ -70,7 +71,7 @@ function walk(dir: string, out: string[]): void {
for (const entry of readEntries(dir)) {
const full = resolve(dir, entry.name)
if (entry.isDirectory()) walk(full, out)
else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) out.push(full)
else if ((/\.tsx?$/u.test(entry.name)) && !entry.name.endsWith('.d.ts')) out.push(full)
}
}
@@ -82,6 +83,8 @@ interface Dictionary {
name: string
/** Declared keys, sorted. */
keys: string[]
/** Literal templates used to compare named placeholders. */
templates: Readonly<Record<string, string>>
}
/**
@@ -98,7 +101,7 @@ function dictionariesIn(file: string): Dictionary[] {
// before parsing — the silent narrowing this gate exists to prevent. A bare
// `\b(zh|en)\b` misses `zhSettings`/`accessZh`, because `\b` does not hold
// between `h` and an uppercase letter.
if (!/\b(zh|en)\b|\b(zh|en)[A-Z]|(Zh|En)\b/.test(text)) return []
if (!/\b(zh|en|ja)\b|\b(zh|en|ja)[A-Z]|(Zh|En|Ja)\b/.test(text)) return []
const source = ts.createSourceFile(file, text, ts.ScriptTarget.ESNext, true)
const found: Dictionary[] = []
const rel = relative(file)
@@ -125,7 +128,7 @@ function dictionariesIn(file: string): Dictionary[] {
const literal = unwrap(decl.initializer)
if (literal === undefined || !ts.isObjectLiteralExpression(literal)) continue
if (localeOf(decl.name.text) === undefined) continue
found.push({ file: rel, name: decl.name.text, keys: keysOf(literal) })
found.push({ file: rel, name: decl.name.text, keys: keysOf(literal), templates: templatesOf(literal) })
}
}
@@ -150,7 +153,7 @@ function dictionariesIn(file: string): Dictionary[] {
if (name === 'register' && node.arguments.length >= 3) {
const [ns, tag, dict] = node.arguments
if (ns === undefined || tag === undefined || !ts.isStringLiteral(tag)) return
if (tag.text !== 'zh' && tag.text !== 'en') return
if (tag.text !== 'zh' && tag.text !== 'en' && tag.text !== 'ja') return
const raw = unwrap(dict)
const literal = raw !== undefined && ts.isIdentifier(raw)
? (() => {
@@ -171,10 +174,10 @@ function dictionariesIn(file: string): Dictionary[] {
// The namespace expression's source text identifies the pair, so the
// zh and en calls for one namespace meet and calls for different
// namespaces stay apart.
found.push({ file: rel, name: `${tag.text}@register:${ns.getText(source)}`, keys: keysOf(dictionary) })
found.push({ file: rel, name: `${tag.text}@register:${ns.getText(source)}`, keys: keysOf(dictionary), templates: templatesOf(dictionary) })
}
}
if (ts.isArrayLiteralExpression(node) && node.elements.length === 2) {
if (ts.isArrayLiteralExpression(node) && node.elements.length >= 2) {
const site = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1
for (const element of node.elements) {
if (!ts.isArrayLiteralExpression(element) || element.elements.length !== 2) continue
@@ -182,8 +185,8 @@ function dictionariesIn(file: string): Dictionary[] {
const literal = unwrap(dict)
if (tag === undefined || !ts.isStringLiteral(tag)) continue
if (literal === undefined || !ts.isObjectLiteralExpression(literal)) continue
if (tag.text !== 'zh' && tag.text !== 'en') continue
found.push({ file: rel, name: `${tag.text}@inline:${site}`, keys: keysOf(literal) })
if (tag.text !== 'zh' && tag.text !== 'en' && tag.text !== 'ja') continue
found.push({ file: rel, name: `${tag.text}@inline:${site}`, keys: keysOf(literal), templates: templatesOf(literal) })
}
}
ts.forEachChild(node, visit)
@@ -192,6 +195,18 @@ function dictionariesIn(file: string): Dictionary[] {
return found
}
/** Literal templates; shared spread dictionaries are checked at their own declaration. */
function templatesOf(literal: ts.ObjectLiteralExpression): Readonly<Record<string, string>> {
const templates: Record<string, string> = {}
for (const property of literal.properties) {
if (!ts.isPropertyAssignment(property) || !ts.isStringLiteralLike(property.initializer)) continue
if (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) {
templates[property.name.text] = property.initializer.text
}
}
return templates
}
/** Declared property names of an object literal, sorted. */
function keysOf(literal: ts.ObjectLiteralExpression): string[] {
const keys: string[] = []
@@ -224,9 +239,9 @@ function unwrap(node: ts.Expression | undefined): ts.Expression | undefined {
* @param name - export name or synthetic inline name.
* @returns locale plus pair key, or undefined when the name names no locale.
*/
function localeOf(name: string): { locale: 'zh' | 'en'; pair: string } | undefined {
for (const locale of ['zh', 'en'] as const) {
const other = locale === 'zh' ? 'Zh' : 'En'
function localeOf(name: string): { locale: 'zh' | 'en' | 'ja'; pair: string } | undefined {
for (const locale of ['zh', 'en', 'ja'] as const) {
const other = locale[0]!.toUpperCase() + locale.slice(1)
if (name === locale) return { locale, pair: '' }
// Synthetic names for inline shapes carry their own pair key after the
// first ':' (the enclosing array's line, or the namespace expression).
@@ -239,8 +254,62 @@ function localeOf(name: string): { locale: 'zh' | 'en'; pair: string } | undefin
return undefined
}
/**
* Check that each object-form registration supplies its Japanese dictionary.
* @param file - absolute source path.
* @returns registration sites missing Japanese, or using an unresolved local identifier.
*/
function registrationProblems(file: string): string[] {
const text = readFileSync(file, 'utf8')
if (!text.includes('.register(')) return []
const source = ts.createSourceFile(file, text, ts.ScriptTarget.ESNext, true)
const locals = new Map<string, ts.Expression>()
const imported = new Set<string>()
const collect = (node: ts.Node): void => {
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer !== undefined) {
locals.set(node.name.text, node.initializer)
}
if (ts.isImportSpecifier(node)) imported.add(node.name.text)
ts.forEachChild(node, collect)
}
collect(source)
const problems: string[] = []
const visit = (node: ts.Node): void => {
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)
&& node.expression.name.text === 'register' && node.arguments.length === 2) {
const argument = unwrap(node.arguments[1])
const dicts = argument !== undefined && ts.isIdentifier(argument)
? unwrap(locals.get(argument.text)) : argument
if (dicts !== undefined && ts.isObjectLiteralExpression(dicts)) {
const entries = new Map<string, ts.Expression>()
for (const prop of dicts.properties) {
if (ts.isShorthandPropertyAssignment(prop)) entries.set(prop.name.text, prop.name)
else if (ts.isPropertyAssignment(prop) && (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name))) {
entries.set(prop.name.text, prop.initializer)
}
}
if (entries.has('zh') && entries.has('en')) {
const ja = entries.get('ja')
const site = `${relative(file)}:${source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1}`
if (ja === undefined) problems.push(`${site}: registration has no Japanese dictionary`)
else if (ts.isIdentifier(ja) && !imported.has(ja.text) && !locals.has(ja.text)) {
problems.push(`${site}: Japanese dictionary ${ja.text} is not imported or declared`)
}
}
}
}
ts.forEachChild(node, visit)
}
visit(source)
return problems
}
describe('shipped locale dictionaries', () => {
it('declares the same keys in zh and en, so the single fallback locale always resolves', () => {
it('registers Japanese alongside every object-form bilingual dictionary', () => {
expect(sourceFiles().flatMap(registrationProblems)).toEqual([])
})
it('keeps bilingual keys aligned and Japanese keys and placeholders complete', () => {
const files = sourceFiles()
// Guard the discovery itself: an empty or narrowed sweep would pass every
// assertion below while checking nothing.
@@ -256,9 +325,9 @@ describe('shipped locale dictionaries', () => {
if (dicts.length > 0) perFile.set(relative(file), dicts)
}
const groups = new Map<string, Map<'zh' | 'en', Dictionary>>()
const place = (key: string, locale: 'zh' | 'en', dict: Dictionary): void => {
const slot = groups.get(key) ?? new Map<'zh' | 'en', Dictionary>()
const groups = new Map<string, Map<'zh' | 'en' | 'ja', Dictionary>>()
const place = (key: string, locale: 'zh' | 'en' | 'ja', dict: Dictionary): void => {
const slot = groups.get(key) ?? new Map<'zh' | 'en' | 'ja', Dictionary>()
if (slot.has(locale)) {
throw new Error(`two ${locale} dictionaries claim pair ${key}: ${slot.get(locale)?.file} and ${dict.file}`)
}
@@ -298,6 +367,24 @@ describe('shipped locale dictionaries', () => {
const enOnly = en.keys.filter(k => !zh.keys.includes(k))
if (zhOnly.length > 0) problems.push(`${zh.file} ${zh.name} has keys absent from ${en.name}: ${zhOnly.join(', ')}`)
if (enOnly.length > 0) problems.push(`${en.file} ${en.name} has keys absent from ${zh.name}: ${enOnly.join(', ')}`)
const ja = slot.get('ja')
if (ja === undefined) {
problems.push(`${key} has no Japanese dictionary`)
} else {
const missing = en.keys.filter(k => !ja.keys.includes(k))
const extra = ja.keys.filter(k => !en.keys.includes(k))
if (missing.length > 0 || extra.length > 0) {
problems.push(`${ja.file} ${ja.name}: missing [${missing.join(', ')}], extra [${extra.join(', ')}]`)
}
for (const [name, text] of Object.entries(en.templates)) {
const translated = ja.templates[name]
if (translated === undefined) continue
const placeholders = (value: string): string => (value.match(/\{\w+\}/gu) ?? []).sort().join(', ')
if (placeholders(text) !== placeholders(translated)) {
problems.push(`${ja.file} ${ja.name}.${name} changes placeholders from ${en.name}`)
}
}
}
}
// The shipped dictionary count only grows; a collapse means discovery or
+1 -1
View File
@@ -34,7 +34,7 @@ it('confines signed Mac packaging to manual dispatch and a dedicated Apple Silic
it('defaults to notarization and labels the explicit debugging option', () => {
expect(workflow.on.workflow_dispatch).toMatchObject({ inputs: { skip_notarization: {
type: 'boolean', default: false,
description: 'Skip notarization/stapling: internal debugging only, not for distribution or updates',
description: '跳过公证和 stapling:仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请勿勾选',
} } })
const steps = workflow.jobs.package!.steps
expect(steps.filter(step => step.run?.includes('args+=(--skip-notarization)'))).toHaveLength(2)
+24 -85
View File
@@ -64,7 +64,6 @@
"187ad4724635a2d845592799c3a2600437c0d2fe17e0d1c3018018d5e8b0b863": 1
},
"apps/desktop/tests/update-dialog.spec.ts": {
"3226b936a41e00f2cfd8b9563d3bfcc8eaef7cf04996abe74fb981169a07b91b": 1,
"4000017f1f3dabd4bdf8eb91120cf4ff0dd09bad89d4683151f4785974d204a5": 1
},
"apps/desktop/tests/update-http-executor.spec.ts": {
@@ -97,7 +96,6 @@
"d0d869812e25f9b60a88de7207566804dc7d1f38c74e0c1d67a7b25fee5af3e9": 1
},
"apps/web/tests/shipped-composition.e2e.ts": {
"5bb6193769cfbb9fc09abd705fed714917784de269546fde14ac1171fb00644b": 1,
"c0a4d4dd9b52881eb8190c56fb8ea5a25a70590b78b1e4a0996defab767699f0": 1
},
"apps/web/tests/smoke-real.e2e.ts": {
@@ -164,15 +162,12 @@
"35f4c7349b7474bef7d2d1c5a8beaab93bcfac1e0d002520f80207444e3f256b": 11,
"4c16575f6e4a1c435b105717d0e05ad94779455de997597797059bd1668d7f99": 1,
"53cc383237bafd28d032833825d5a99158e938f7884de5bc5ab0011d48845643": 6,
"5cbd6ad5d73374671c654d285cb43b7cd1f40691d2d33f4d58ad5c7d951314ac": 1,
"665e53c977eb2565742192bbdd1861ca453b5b4ffd88d51a58533556235f335e": 1,
"7fe58280287ff63d40e979e72329f975e11015a95e9a8eec207a5a78748d3c89": 1,
"8f28ec738a702607e9ad458a76d5c695a459fed28009b6aac835ef4789026bfb": 1,
"97b408a8e61b28b507aab6f709875826b82d6505d4b967aec56914e7f831b20b": 1,
"a25e6d8d728e1de71699956f42f62a2e38af439317082fbb0354161ead666ab6": 1,
"b121cee14635b00f7891217013e4682493cf96f676710be60722fdf8b862aea3": 1,
"b428751e788e277116156e105c127f813dc300b61c6664b08d46e6c5ccdc3ae8": 1,
"da388e50e43adcb0b1331d55e3551780c382448686fe6a9dca486937c1667df0": 1
"b121cee14635b00f7891217013e4682493cf96f676710be60722fdf8b862aea3": 1
},
"packages/api/gateway/tests/gateway.host.spec.ts": {
"344e2cbbed2a8ef8d5356a24def92ac3453ffaa2efa58f5481d59206f5b79cbc": 1,
@@ -209,9 +204,6 @@
"packages/api/session-controller/src/list.ts": {
"a288679a4dcd49ed1fea620d9f3442c1f518406ad8d2d49dba1fafe8a45fbcbd": 1
},
"packages/api/session-controller/src/model-selection-projection.ts": {
"921e80b20107c8b88b0fc3cb1ebc1fe52d465175fe782cf9e7a0b72e505ff427": 1
},
"packages/api/session-controller/tests/agent.host.spec.ts": {
"40b9b8efe71e65b5bddad33acf9b7595d58a7427f077e3aa9ccc488ebb23f4d0": 1,
"4ddb5771e76b6c641d7049711207ce32902b1599afc4736514bab35c6e1fdf07": 1,
@@ -222,21 +214,15 @@
"e929814f19128d947cc0188ecfe39833ffd5cf3f2c7155ddfef94ba9740d2bfd": 1
},
"packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts": {
"246d9f25f7a12a2a3946993bc28f1c5a47d7dee57a4dab75bd44a10d39c5d6e4": 1,
"8204ecbe96e5e470dde7a6dad4f8a0e6809c30bd8095af16a3a5d378e6c6c0c7": 4,
"e9721557773cba96e50cea1b0c6737991a9fb2490bcd83e934311e46310d8b68": 1,
"e9ce9370ab27e1dbff54a0ae9a250b10f0314069d41fef58c1822652747be8ff": 1
},
"packages/api/session-controller/tests/commands-upload-file.host.spec.ts": {
"ecd545e1cc141b29fc9da1988bc3ceab4135f376c0c4d12aaa1a1ab67e2d4b05": 1,
"ff123c922407e6cb6d42b06534e1e7fb2da129cb09dc6a82d801b8d536540341": 1
"e9721557773cba96e50cea1b0c6737991a9fb2490bcd83e934311e46310d8b68": 1
},
"packages/api/session-controller/tests/control-queue.host.spec.ts": {
"9b2258e1e99500b30d66b0b93e0f0bc5077278877903fc8cc6712167411f6109": 1,
"f2fe8bd862649a2492b7f99f0c35e78ba051a248f339d248d0dcfab163c69752": 1
},
"packages/api/session-controller/tests/controller.host.spec.ts": {
"40725eab4d86929b16d87c88d5cf83168035def78b4d901613dd0369876f0731": 4,
"40725eab4d86929b16d87c88d5cf83168035def78b4d901613dd0369876f0731": 3,
"6667f55d27d315e8906a2ffdd1b7ab83c3785036fce5575f865b1dab9f3710b3": 2
},
"packages/api/session-controller/tests/event-script.client.ts": {
@@ -258,11 +244,8 @@
},
"packages/api/session-controller/tests/session-cold.host.spec.ts": {
"23129d43b519124d7c915c0f1dfd075c1d84579c176678fa8905d0ba02403406": 1,
"36809c8a43a7b9240089e359618107af0a42e948791f0502d6366df65d43208f": 2,
"4e05d4d65c076fff556c0dd813f53e90b8c98390825e7293033d8450a8f94ff0": 1,
"526a22860f8bc330caf4a3f28a28948de5c04b4e17125f04b3738808cfbd4e3e": 1,
"9d54088afe33da8176bd4898a775c5a5181111a52a8f682ca300ade28684feac": 1,
"a6a04773270367c3ea0f54b577d3f766ff738afb6702e09caac1b3fa66a7397d": 1
"526a22860f8bc330caf4a3f28a28948de5c04b4e17125f04b3738808cfbd4e3e": 1
},
"packages/api/session-controller/tests/session-fork.host.spec.ts": {
"dbbb38fac6189a3be6f7c29f8df6ef34679f5956c168d08d4f058b5d66b24089": 1
@@ -270,9 +253,6 @@
"packages/api/session-controller/tests/session-history-journal.host.spec.ts": {
"4e6289bbcd36e12927fd2ac87dc09b4de8ac884f2a0041af58383a0144b8b9b1": 1
},
"packages/api/session-controller/tests/session-models.host.spec.ts": {
"7f2d35b6be247c2410599d4b91260905bc2ada88f62507af622fe067a9295f5b": 1
},
"packages/api/session-controller/tests/session-pending-submissions.client.spec.ts": {
"f35467810e1f7bd9ea7659c947c274c48d310411d0eabcf528366708ea3b7ca1": 1
},
@@ -383,9 +363,6 @@
"packages/bundle/web-app/tests/startup.spec.ts": {
"2e78263d16d54ccfd8d322bcdf1bf738bd8134e15c24063ece9a1d1b52f4ebd9": 1
},
"packages/client/connection/src/browser-auth.ts": {
"98acffd14d7c77e3412ce5a7d8e9c555b66de76eb12c14d4b22b251684b60447": 1
},
"packages/client/connection/src/rpc-schema.ts": {
"21827725b3bf3a544927f354c5443228176a36e5926a26cc8640a59ba3eca717": 1,
"afa692cdc0fd53b274f5d276d34ea54141b26bee0c5d67055a2fdb056dc398fe": 1,
@@ -410,9 +387,6 @@
"12a2d457945d43a02ab415318a8f6744ef620abd1fe272e12a164fa4ff476289": 1,
"37d0e226122b7a3a4253e22583da5c77cc3690554dc959c74bf570589f3224fd": 1
},
"packages/client/hmr/src/client/index.ts": {
"c680fd791fbbade1ee77c9fff0289e5106cacf1574ea0ccd5d2a218e62a44262": 1
},
"packages/client/hmr/tests/node-half.client.spec.ts": {
"46197efe57255c9d92d64476470c89d0602c7333bf98ad75509bf43c1b357a3b": 1
},
@@ -431,7 +405,6 @@
"packages/client/modules/tests/node-half.client.spec.ts": {
"621628e4e895b24171e82d1b3fa3ecb12970e8eb0dfccab8bacc8c5784188a28": 2,
"6c4ace8d135ab45685d0152eefb0f8156bbf95f66cefedd9a1a8da081d3cb180": 1,
"6d4d3e6035bfeaac7068a832f0d5c89f1112961164709c0eeb18dd8937d74cac": 1,
"8eb8ea3592cbd4e828a33b2cbe2876089d3923738996fb2259ded563811a1a85": 1,
"c4f0b7f87a16d26a1e4e7ef7a07991e789f0bbcfdbe2240a97fcd879668b5b3f": 1,
"f549d88ac2b1db67fb33f18a024c116051ff83d3f8ad0433ba4ceedc679e9044": 2
@@ -452,9 +425,6 @@
"packages/client/ui-attachment/tests/attachment-rail.client.spec.tsx": {
"b42ab24a43e0f7542bf1deb8f7ac3a76698d29e098f8dc575af6c08774987c32": 1
},
"packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx": {
"ab22ebe23f59b5aa76d42502a0421f3953d1d170e8a4f900a0c442daf6f1397d": 1
},
"packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts": {
"a7d8cc8d42e37ad5cc4882241492ac572ed8163c7fb8663180e893c7178e906f": 1
},
@@ -760,9 +730,7 @@
"24334994394c1d4336d192b2f38986beadcd0f6b235108370c3a736c34eebe98": 1,
"33e462c9f8d9ef55f1b54a408f047caaa9467facc4fc88887fa5979fdce6c09f": 1,
"455072902745ebd120bdeaaebf1b00b2d747a300409066715375263bcc4e8a63": 1,
"4df26af834cb4f8d55bb6908736ddeb6971b4c4c1cbd5a9ace2f9cfe9c0b94e4": 1,
"6078d7e1390a0c3c94892edd74526a76e6cbbda8360656bf9f97d367371c0c4c": 1,
"71b9c7a18658556ab36c9af394f29dac5885fab402304b0b6f4d5127d6acdeff": 1,
"9117562f15005ef8ec18dbad9c29cdc723c4af2c6a9c217af3cd7d8380de999b": 1,
"98169406d3c872458f70f3f8f70abdd6eeaca296816ce91cdcf8392cfa6a77bf": 1,
"afecf8092cd8de095ffbe00a1df00816897e8205854fd33a9d2beeaee88eca95": 1,
@@ -778,9 +746,6 @@
"packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx": {
"9f5585ad6cebba08252165f2dcc63c1e16101e0306fab754afb641b417e7789d": 1
},
"packages/client/ui-workspace/tests/animated-rows.client.spec.tsx": {
"a898fd26a24bbfa72e00b9b825779a95c1d1c2bd447ebf8f21e8b2d38c3fb08c": 1
},
"packages/client/ui-workspace/tests/apply.client.spec.ts": {
"b467270b0c2a95d6b29939f6baf4d5c8e5031e99a1d15437a3060f35735d8d37": 1
},
@@ -940,16 +905,29 @@
"packages/core/system-prompt/src/index.ts": {
"7fe58280287ff63d40e979e72329f975e11015a95e9a8eec207a5a78748d3c89": 1
},
"packages/core/tools/src/index.ts": {
"6061de0a1e01547eb1abf9d4e1b5fa79fda112fa35dda980775491123413dcce": 1
"packages/core/tool-executor-ptc/tests/ptc.spec.ts": {
"52faf4558fcd91bd27dbdfa7e497843e8eeb75ea5f68ee231335018d5b3f2053": 1
},
"packages/core/tool-executor-ptc/tests/py-types.spec.ts": {
"0d733b2c7c458f6a7c4d287ed741c321842339cbe5908a8720660883423474c0": 9,
"23ebbf16be06365be256c1ceb77869576a026644f3514e1737030c4556849f4a": 1,
"3ec1a0855f2cf4a5b3646725ea95c07addd33d5957ba6af530b944cd26f1ea8f": 1,
"43155833aa226b066cf374829e013176403ddbd6acd313ccf90f6cfb92ceec06": 1,
"5d1224b16a64bb138a708eb3e3e130df2ddf1f8f656617e3f1b3f2583250cf63": 1,
"6aecd519658d0d2fb040f0289d6db823e3f6bbb26fcb2b118dc7f7f2c1d3eb06": 1,
"9b2b04bd2ecde0c56b00ae050b721d56b3c29a1c834b982c9c2f6e62dbcd0a04": 1,
"d334b0b55263b6cb2125317ce96b766155b11455d40d600590f5302f5e0543cc": 1,
"d6650a0b52b5f7311afff190373b4a1cbd31ba44f9fa0dff09395f6f2901e32d": 1
},
"packages/core/tool-executor-ptc/tests/ts-types.spec.ts": {
"054c87ca977d98749baf70bb5dcad1bb8d9e725d08a480b7804a181434633873": 1,
"0d733b2c7c458f6a7c4d287ed741c321842339cbe5908a8720660883423474c0": 1,
"84a5877f154b369cc0528281904d1dc18e20fd3ad74a26c939301ba0db20ae02": 1,
"d80a3368311f07e812a76ff265125b072175f58b6003d9f96a37d4f3e70e3dec": 1
},
"packages/core/tools/src/schema.ts": {
"62870121f1e1644b67c62606e94a375bb40f880cc88edf7cd6baab0249e9da46": 1
},
"packages/core/tools/src/testing.ts": {
"5be3dd49608bd752ae1371aae8c8d748a07158f7a597e2506af79a621f74f0b3": 1,
"6fe9024d30e0d4757a348b83d246925b3f5aeaf2e79a0eff4bf12f3c9fb5826b": 1
},
"packages/core/tools/tests/execution-mode.spec.ts": {
"33dec0693f265044c830377981b51852b773e1a8a765e0b3398a4542c646537b": 1,
"f9c9afc4ff66b96c59303ffa5475e26719ef101dc45504fee4b56145ff4be02c": 1
@@ -964,20 +942,6 @@
"c21022b1c0c54a213857769a5d72bc249dce83dd63c9d6f2370139bbea3288c9": 1,
"d1250fc465aac30c8474c1ee43ecbafc0a8c072d5e607961da1295acc710a257": 1
},
"packages/core/tools/tests/ptc.spec.ts": {
"52faf4558fcd91bd27dbdfa7e497843e8eeb75ea5f68ee231335018d5b3f2053": 1
},
"packages/core/tools/tests/py-types.spec.ts": {
"0d733b2c7c458f6a7c4d287ed741c321842339cbe5908a8720660883423474c0": 9,
"23ebbf16be06365be256c1ceb77869576a026644f3514e1737030c4556849f4a": 1,
"3ec1a0855f2cf4a5b3646725ea95c07addd33d5957ba6af530b944cd26f1ea8f": 1,
"43155833aa226b066cf374829e013176403ddbd6acd313ccf90f6cfb92ceec06": 1,
"5d1224b16a64bb138a708eb3e3e130df2ddf1f8f656617e3f1b3f2583250cf63": 1,
"6aecd519658d0d2fb040f0289d6db823e3f6bbb26fcb2b118dc7f7f2c1d3eb06": 1,
"9b2b04bd2ecde0c56b00ae050b721d56b3c29a1c834b982c9c2f6e62dbcd0a04": 1,
"d334b0b55263b6cb2125317ce96b766155b11455d40d600590f5302f5e0543cc": 1,
"d6650a0b52b5f7311afff190373b4a1cbd31ba44f9fa0dff09395f6f2901e32d": 1
},
"packages/core/tools/tests/schema.spec.ts": {
"2994ddac72096564a65b1d11cfe50c5b5f21de0cca775ed7b758d031b9a4df99": 1,
"7724a969f2b794fc521b1a246d444c75616cf27e6ce073b00e8c8956b626634d": 1,
@@ -1001,12 +965,6 @@
"d3478c9f3a88906a17d5daa6d19c3694a33618cfc000c7c0ee8f3e16f2697322": 1,
"f9c9afc4ff66b96c59303ffa5475e26719ef101dc45504fee4b56145ff4be02c": 2
},
"packages/core/tools/tests/ts-types.spec.ts": {
"054c87ca977d98749baf70bb5dcad1bb8d9e725d08a480b7804a181434633873": 1,
"0d733b2c7c458f6a7c4d287ed741c321842339cbe5908a8720660883423474c0": 1,
"84a5877f154b369cc0528281904d1dc18e20fd3ad74a26c939301ba0db20ae02": 1,
"d80a3368311f07e812a76ff265125b072175f58b6003d9f96a37d4f3e70e3dec": 1
},
"packages/credentials/credentials-local/tests/drain.spec.ts": {
"a96f4a3497a1c661f76e47ef5fda3c32d2759a7a5f9c519527fa46801a91bf79": 1
},
@@ -1035,9 +993,6 @@
"3ae979a4364fe22772c89133e79ea216cdb4f3163c7cb02c47eb1a5d4788e661": 3,
"ae5776af8c1e9965a4eca94bcbc07b65da8d2e9e24e4570d2141b34d60b4f90f": 1
},
"packages/experimental/auto-review/tests/auto-review.spec.ts": {
"5bb6193769cfbb9fc09abd705fed714917784de269546fde14ac1171fb00644b": 1
},
"packages/experimental/browser-use-runtime/src/index.ts": {
"04bc8a44533574365dfedf563e2356458bcf95f990a00906938c977c1d683356": 1
},
@@ -1457,10 +1412,6 @@
"d2c3345795169400442563b4923671c090a724eacaaae7f0c190297b967c1c4d": 1,
"dfa30a521aae411e70eee81fc4c0511178589d0656dea01c1c97131bfe097793": 1
},
"packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts": {
"aa5de2d4958ec64219565d53ec5a9d7f40f1f60037a2d52d1a9c3c65db034d73": 1,
"be027af1ef5cb3bfe41eeb19b54dbb176ec7c23ae367d709059839f9851833d5": 1
},
"packages/ptc-runtime/ptc-runtime-node/tests/output-json.spec.ts": {
"8c4c486bbc27559714f4e0aaf376c72c49ac9157539b03c6c12853b8d3fc6e74": 1
},
@@ -1539,10 +1490,6 @@
"cb524acd0159b942e7974d76b581b1f419b8653d1734946a3f154dda8e8f4762": 1,
"f10c6a0a3a762be54c2de87ab5d787e37a21a89c9d468bf81d0658c2bf15d581": 1
},
"packages/session-query/session-log-export/tests/client-apply.client.spec.tsx": {
"04984a2ce1486250236e638e0197da0a1a840855ed3975c59130a85358c7e5fe": 1,
"eee35b4d8641824c6eaacfe03ed60d353d81d5257d1111132f34d46a7b13ef35": 1
},
"packages/session-query/session-log-export/tests/controller.client.spec.ts": {
"746ebc5aade01a50cc9a74fc07b1023addf021aef80b62c069a437ac140c9e64": 1,
"c920d88f6eb309ef5991608642a1ab0fd972690538b33c34d86c68d07ddd6edf": 1
@@ -1550,10 +1497,6 @@
"packages/session-query/session-log-export/tests/dialog.client.spec.tsx": {
"a9b1969d76db0b05f87c862956f8ff45c52f1f4a5b32a206339de0c9fee99a77": 1
},
"packages/session-query/session-log-export/tests/header-action.client.spec.tsx": {
"238a292bbe3a61acbc4f2d96fff474df5fba1577357219a88c914813786b56a7": 1,
"47d3a14a42ef7142ea22071677b42265f02fba5a2a0cf4210f4f9c7c5a876d10": 1
},
"packages/session-query/session-log-export/tests/loader-composition.host.spec.ts": {
"59c63f734e8bba862325f776e3fcbebd78a84cfb4161f8df41c216950d9bf351": 1,
"e12d49327a6708ae3ace371e28ed0deb471dc8895c584ba94a86af344c15cec3": 1
@@ -2068,9 +2011,6 @@
"packages/web/tool-web/tests/spill.spec.ts": {
"ecf3420d023d28e1cbc687d438bb66ee49d55a4ae4cb0828dd0f59b926363903": 1
},
"packages/web/web-fetch-http/tests/fetch-http.spec.ts": {
"366f13ff1ff2522ad824a1d4333085e2c771c0b7ca22783aa41ca2e23233b259": 1
},
"packages/web/web-search-deepseek/tests/deepseek.spec.ts": {
"37c9617ff75d6a383f58dd63f939a88803e4ff5e1676bfe7e1c0cc340fc65014": 5,
"598014dcfe77101781e3c6a02709097e3b0151a335e909c44719fac08fdea433": 2
@@ -2103,11 +2043,10 @@
"ebe146aded1e6f3e71f166a7f88720e560b336bbb92d19cea0b2bdab58c46f8a": 1
},
"scripts/persistence-changes.ts": {
"2c01874686204d2e1723ae4a9c54f15c98a53cc1e88682b0b432f79f91dd28ac": 3,
"2c01874686204d2e1723ae4a9c54f15c98a53cc1e88682b0b432f79f91dd28ac": 1,
"eff6e18c764150f86c2db750beb6df4dc99d4ae44bbd9c745d092afda3f45982": 1
},
"scripts/persistence-developer.spec.ts": {
"325d28707ea86aa06f716bd3c4bd21d9c4e91727a778513fe4271bfd2c04c3c5": 1,
"908e86b966e23795a88c1adc8a8b0326a1dbbd40f03c48104ca7acb3f4f08157": 1
},
"scripts/persistence-releases.ts": {
+2 -1
View File
@@ -8,7 +8,8 @@ import { describe, expect, it, onTestFinished } from 'vitest'
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
import { loadOverlayPatches } from '../packages/boot/app-boot/src/index.ts'
import { readPluginMeta } from '../packages/boot/app-boot/src/package-meta.ts'
import { OPTIONAL_BUNDLES, PROFILE_TEMPLATES, bundlePatchPaths, composeEntries, initProfile, readProfileManifest } from '../packages/boot/app-boot/src/profile.ts'
import { bundlePatchPaths, composeEntries, initProfile, readProfileManifest } from '../packages/boot/app-boot/src/profile.ts'
import { OPTIONAL_BUNDLES, PROFILE_TEMPLATES } from '../packages/boot/official-bundles/src/bundles.ts'
import type { DshBundleManifest } from '../packages/util/package-manifest/src/types.ts'
import { createPluginProfile } from '../apps/desktop/src/project-manager.ts'
+7 -14
View File
@@ -51,6 +51,8 @@ const DUPLICATE_SAFE_PACKAGES: readonly string[] = [
* prominent heading in the pull request description.
*/
const SAFE_HOST_DEPENDENCY_EXPORTS = {
'@deepseek-ai/dsh-atomic-write': ['withFileLock'],
'@deepseek-ai/dsh-computer-use/brand': ['ComputerUseProviderName'],
'@deepseek-ai/dsh-credentials': ['credentialKey'],
'@deepseek-ai/dsh-deque': ['Deque'],
'@deepseek-ai/dsh-llm': ['callConfigEquals'],
@@ -61,6 +63,11 @@ const SAFE_HOST_DEPENDENCY_EXPORTS = {
/** Runtime exports that require every consumer to resolve the provider's shared peer instance. */
const PEER_REQUIRED_HOST_EXPORTS = {
// CUA checks UserQuestionError and preserves AttachmentError from image admission.
'@deepseek-ai/dsh-attachment': ['admitEncodedImages'],
'@deepseek-ai/dsh-user-questions': ['UserQuestionError'],
// `instanceof` checks need the translator's own class instance.
'@deepseek-ai/dsh-experimental-translator': ['TranslationError'],
'@deepseek-ai/dsh-scope': ['carrierKeyOf', 'createScope', 'scopeOf', 'scopeTarget'],
'@deepseek-ai/dsh-session': ['SESSION_FORMAT_VERSION'],
'@deepseek-ai/dsh-session-persistence': ['SessionPersistenceNotFoundError'],
@@ -101,17 +108,3 @@ function isRecord(value: unknown): value is Record<string, unknown> {
export function hasClientDeclaration(dshField: unknown): boolean {
return isRecord(dshField) && Object.hasOwn(dshField, 'client')
}
/** Whether the repository policy flattens one package's non-Cordis peers. */
export function usesFlattenedPackageDependencies(
manifestPath: string,
packageName: string,
dshField: unknown,
policy: PackageDependencyPolicy = PACKAGE_DEPENDENCY_POLICY,
): boolean {
if (!manifestPath.startsWith('packages/') || manifestPath.startsWith('packages/experimental/')) return false
if (policy.hostPackages.includes(packageName)) return true
if (manifestPath.startsWith('packages/client/')) return true
const included = hasClientDeclaration(dshField) || policy.clientFaceInclude.includes(packageName)
return included && !policy.clientFaceExclude.includes(packageName)
}
+60 -37
View File
@@ -5,7 +5,7 @@ import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSyn
import { tmpdir } from 'node:os'
import { join, posix, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
import { canonicalizeSchema, encodePersistenceSnapshot, schemaDigest } from './persistence-schema-model.ts'
import type { PersistenceRoot, PersistenceSchemaInventory, SchemaNode, SchemaProperty, SourceCompatibility } from './persistence-schema-model.ts'
import { extractPersistenceSchema } from './persistence-schema.ts'
import { persistenceCatalogArtifacts } from './gen-persistence-catalog.ts'
@@ -25,7 +25,7 @@ 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',
path: 'docs/persistence-schema.json', content: encodePersistenceSnapshot(current),
}])
}
@@ -102,6 +102,25 @@ function entry(
}
}
interface SnapshotText {
formatVersion: number
roots: Record<string, unknown>[]
types: Record<string, unknown>[]
nodes: Record<string, Record<string, unknown>>
}
/** Encode a snapshot, then apply one invalid edit to its committed JSON fields. */
function tamper(snapshot: PersistenceSchemaInventory, change: (value: SnapshotText) => void): string {
const value = JSON.parse(encodePersistenceSnapshot(snapshot)) as SnapshotText
change(value)
return JSON.stringify(value)
}
/** Replace the node that the first root graph starts from. */
function replaceRootNode(node: Record<string, unknown>): (value: SnapshotText) => void {
return (value) => { value.nodes[String(value.roots[0]!.digest).slice(0, 16)] = node }
}
const BASE_ID = '2026-09-11-baseline'
const NEXT_ID = '2026-09-11-change'
@@ -126,7 +145,7 @@ describe('historical persistence snapshot parsing', () => {
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)
expect(parseHistoricalPersistenceSnapshot(encodePersistenceSnapshot(complete))).toEqual(complete)
})
it.each([':8', ':8:3', '#L8', '#L8-L12'])('rejects source coordinates %s only in historical inventories', (suffix) => {
@@ -134,29 +153,31 @@ describe('historical persistence snapshot parsing', () => {
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)
expect(() => parseHistoricalPersistenceSnapshot(encodePersistenceSnapshot(complete))).toThrow('historical schema sources must omit line numbers')
expect(parsePersistenceSnapshot(encodePersistenceSnapshot(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'))
expect(() => parsePersistenceSnapshot(encodePersistenceSnapshot(snapshot))).toThrow('surface metadata')
expect(parseHistoricalPersistenceSnapshot(encodePersistenceSnapshot(snapshot))).toEqual(snapshot)
expect(parsePersistenceSnapshot(encodePersistenceSnapshot(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')
expect(() => parseHistoricalPersistenceSnapshot(encodePersistenceSnapshot(historicalSurface('absent')))).toThrow('surface metadata')
expect(() => parseHistoricalPersistenceSnapshot(encodePersistenceSnapshot(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')
expect(() => parseHistoricalPersistenceSnapshot(tamper(snapshot, (value) => { value.roots[0]!.key = 'event:wrong/key' }))).toThrow('invalid event root key')
expect(() => parseHistoricalPersistenceSnapshot(tamper(snapshot, (value) => { value.roots[0]!.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')
expect(() => parseHistoricalPersistenceSnapshot(tamper(snapshot, (value) => {
value.roots[0]!.digest = String(value.roots[0]!.digest).slice(0, 16) + 'f'.repeat(48)
}))).toThrow('digest mismatch')
expect(() => parseHistoricalPersistenceSnapshot(tamper(snapshot, replaceRootNode({ kind: 'array', element: '9'.repeat(16) })))).toThrow('unknown schema node')
})
})
@@ -312,10 +333,10 @@ describe('accepted persistence baseline', () => {
const current = inventory({ value: version === 4 ? 'number' : 'boolean' }, version)
if (version === 5) runPersistenceChanges(['--record', FUTURE_ID, '--prose', proseFile(root)], root, () => current)
const snapshotPath = join(root, `docs/persistence-changes/${FINALIZED_ID}.schema.json`)
const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf8')) as PersistenceSchemaInventory
const snapshot = parsePersistenceSnapshot(readFileSync(snapshotPath, 'utf8'))
const replacement = typeRoot('event:example/value', { value: 'number', extra: 'string' })
const original = snapshot.roots.find(root => root.key === replacement.key)!
writeFileSync(snapshotPath, JSON.stringify({ ...snapshot,
writeFileSync(snapshotPath, encodePersistenceSnapshot({ ...snapshot,
roots: snapshot.roots.map(root => root.key === replacement.key ? replacement : root) }))
for (const suffix of ['.md', '.zh.md']) {
const path = join(root, `docs/persistence-changes/${FINALIZED_ID}${suffix}`)
@@ -375,7 +396,7 @@ function finishDocuments(root: string, id: string): void {
}
function commitCurrent(root: string, schema: PersistenceSchemaInventory): void {
writeFileSync(join(root, 'docs/persistence-schema.json'), JSON.stringify(schema, null, 2) + '\n')
writeFileSync(join(root, 'docs/persistence-schema.json'), encodePersistenceSnapshot(schema))
}
function baseline(root: string, schema = inventory()): void {
@@ -624,15 +645,12 @@ describe('persistence history verification', () => {
it('rejects malformed references, unknown schema variants, digest tampering, and extra snapshot roots', () => {
const schema = inventory()
const malformed: { roots: readonly { schema: { nodes: readonly unknown[] }; digest: string }[] } = structuredClone(schema)
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: 3 })).toThrow('normalization version')
expect(() => parsePersistenceSnapshot(tamper(schema, replaceRootNode({ kind: 'array', element: '9'.repeat(16) })))).toThrow('unknown schema node')
expect(() => parsePersistenceSnapshot(tamper(schema, replaceRootNode({ kind: 'future' })))).toThrow('unknown schema node kind')
expect(() => parsePersistenceSnapshot(tamper(schema, (value) => {
value.roots[0]!.digest = String(value.roots[0]!.digest).slice(0, 16) + '0'.repeat(48)
}))).toThrow('digest mismatch')
expect(() => parsePersistenceSnapshot(tamper(schema, (value) => { value.formatVersion = 3 }))).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')
})
@@ -951,7 +969,7 @@ describe('recorded source compatibility policy', () => {
expect(after.digest).not.toBe(before.digest)
expect(classifyPersistenceChange(before, after)).toEqual([expect.objectContaining({ kind: 'attribution-kind-added', requiresCompatibilityReview: false })])
const snapshot: PersistenceSchemaInventory = { formatVersion: 2, roots: [after], types: [] }
expect(parsePersistenceSnapshot(JSON.parse(JSON.stringify(snapshot)))).toEqual(snapshot)
expect(parsePersistenceSnapshot(encodePersistenceSnapshot(snapshot))).toEqual(snapshot)
})
it('allows ordinary optional fields in an existing group alongside a qualified addition', () => {
@@ -1017,25 +1035,30 @@ describe('recorded source compatibility policy', () => {
it.each(['auto-review', 'compact-basic', 'ptc-mode'])('rejects saved qualification of reserved producer kind %s', (kind) => {
const root = attributedRoot([EXISTING_SOURCE, { kind }], { policy: attributionPolicy([kind]) })
const snapshot: PersistenceSchemaInventory = { formatVersion: 2, roots: [root], types: [] }
expect(() => parsePersistenceSnapshot(snapshot)).toThrow('invalid source compatibility')
expect(() => parsePersistenceSnapshot(encodePersistenceSnapshot(snapshot))).toThrow('invalid source compatibility')
})
it('rejects old-format policy fields, policy tampering, unsupported promises and invalid bindings', () => {
const root = attributedRoot([EXISTING_SOURCE])
const snapshot: PersistenceSchemaInventory = { formatVersion: 2, roots: [root], types: [] }
expect(() => parsePersistenceSnapshot({ ...snapshot, formatVersion: 1 })).toThrow('unknown field compatibility')
expect(() => parsePersistenceSnapshot(encodePersistenceSnapshot({ ...snapshot, formatVersion: 1 }))).toThrow('unknown field compatibility')
const patchPolicy = (patch: Record<string, unknown>) => (value: SnapshotText): void => {
const properties = Object.values(value.nodes).flatMap(node => node.kind === 'object' ? node.properties as Record<string, unknown>[] : [])
Object.assign(properties.find(property => property.compatibility !== undefined)!.compatibility as object, patch)
}
for (const patch of [{ version: 2 }, { policy: 'arbitrary' }, { discriminator: 'type' }, { unknownKinds: 'discard' }]) {
const tampered = JSON.parse(JSON.stringify(snapshot)) as PersistenceSchemaInventory
const property = tampered.roots[0]!.schema.nodes.flatMap(node => node.kind === 'object' ? node.properties : []).find(property => property.compatibility !== undefined)!
Object.assign(property.compatibility!, patch)
expect(() => parsePersistenceSnapshot(tampered)).toThrow('unsupported source compatibility')
expect(() => parsePersistenceSnapshot(tamper(snapshot, patchPolicy(patch)))).toThrow('unsupported source compatibility')
}
const wrongRole = attributedRoot([EXISTING_SOURCE], { policy: attributionPolicy([], 'developer') })
expect(() => parsePersistenceSnapshot({ ...snapshot, roots: [wrongRole] })).toThrow('invalid source compatibility')
const changedQualification = attributedRoot([EXISTING_SOURCE], { policy: attributionPolicy([EXISTING_SOURCE.kind]) })
expect(() => parsePersistenceSnapshot({ ...snapshot, roots: [{ ...changedQualification, digest: root.digest }] })).toThrow('digest mismatch')
expect(() => parsePersistenceSnapshot(encodePersistenceSnapshot({ ...snapshot, roots: [wrongRole] }))).toThrow('invalid source compatibility')
expect(() => parsePersistenceSnapshot(tamper(snapshot, patchPolicy({ attributionKinds: [EXISTING_SOURCE.kind] })))).toThrow('key does not match')
const qualified: PersistenceSchemaInventory = { formatVersion: 2, types: [],
roots: [attributedRoot([EXISTING_SOURCE, NEW_SOURCE, { kind: 'other-attribution' }], { policy: attributionPolicy([NEW_SOURCE.kind, 'other-attribution']) })] }
for (const kinds of [['other-attribution', NEW_SOURCE.kind], [NEW_SOURCE.kind, NEW_SOURCE.kind, 'other-attribution']]) {
expect(() => parsePersistenceSnapshot(tamper(qualified, patchPolicy({ attributionKinds: kinds })))).toThrow('node is not canonical')
}
const unknownKind = attributedRoot([EXISTING_SOURCE], { policy: attributionPolicy(['absent']) })
expect(() => parsePersistenceSnapshot({ ...snapshot, roots: [unknownKind] })).toThrow('invalid source compatibility')
expect(() => parsePersistenceSnapshot(encodePersistenceSnapshot({ ...snapshot, roots: [unknownKind] }))).toThrow('invalid source compatibility')
})
})
+142 -130
View File
@@ -4,9 +4,15 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from
import { basename, join, resolve } from 'node:path'
import { parseArgs } from 'node:util'
import { JSON_SCHEMA, load } from 'js-yaml'
import { canonicalizeSchema, reachableSchemaTypes, schemaDigest } from './persistence-schema-model.ts'
import {
SCHEMA_NODE_KEY_LENGTH, canonicalizeSchema, encodePersistenceSnapshot, renderPersistenceSnapshot,
schemaChildren, schemaDigest, schemaTableEntry, serializeSchemaNode,
} from './persistence-schema-model.ts'
import { matchingSourceCompatibility, sourceKindGroups, validSourceCompatibility } from './persistence-source-policy.ts'
import type { CanonicalSchema, PersistenceRoot, PersistenceSchemaInventory, SchemaNode, SchemaTupleElement } from './persistence-schema-model.ts'
import type {
CanonicalSchema, PersistenceRoot, PersistenceSchemaInventory, PersistenceType,
SchemaNode, SchemaProperty, SchemaTupleElement, SourceCompatibility,
} from './persistence-schema-model.ts'
import { extractPersistenceSchema } from './persistence-schema.ts'
import { persistenceCatalogArtifacts } from './gen-persistence-catalog.ts'
import { renderPersistencePair } from './persistence-artifacts.ts'
@@ -169,128 +175,138 @@ function digest(value: unknown, label: string): string {
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 parseNode(value: unknown, label: string, formatVersion: 1 | 2, ref: (value: unknown) => number): SchemaNode {
const node = record(value, label)
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`)
return { kind: 'primitive', type: node.type as Extract<SchemaNode, { kind: 'primitive' }>['type'] }
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`)
return { kind: 'literal', value: node.value as string | number | boolean }
case 'opaque':
keys(node, ['kind', 'reason'], label)
if (!['any', 'unknown'].includes(String(node.reason))) throw new Error(`${label}: invalid opaque reason`)
return { kind: 'opaque', reason: node.reason as 'any' | 'unknown' }
case 'array':
keys(node, ['kind', 'element'], label)
return { kind: 'array', element: ref(node.element) }
case 'tuple':
keys(node, ['kind', 'elements'], label)
return { kind: 'tuple', elements: array(node.elements, label).map((rawElement): SchemaTupleElement => {
const element = record(rawElement, label)
keys(element, ['type', 'optional', 'rest'], label)
return { type: ref(element.type), optional: bool(element.optional, label), rest: bool(element.rest, label) }
}) }
case 'object': {
keys(node, ['kind', 'properties', 'indices'], label)
const names = new Set<string>()
const properties = array(node.properties, label).map((rawProperty): SchemaProperty => {
const property = record(rawProperty, label)
keys(property, ['name', 'type', 'optional'], label, formatVersion === 2 ? ['compatibility'] : [])
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)
return { name, type: ref(property.type), optional: bool(property.optional, label),
...(property.compatibility === undefined ? {} : { compatibility: parseCompatibility(property.compatibility, label) }) }
})
const indices = array(node.indices, label).map((rawIndex) => {
const entry = record(rawIndex, label)
keys(entry, ['key', 'value'], label)
return { key: ref(entry.key), value: ref(entry.value) }
})
return { kind: 'object', properties, indices }
}
case 'union':
keys(node, ['kind', 'types'], label)
if (array(node.types, label).length === 0) throw new Error(`${label}: empty union`)
return { kind: 'union', types: (node.types as unknown[]).map(ref) }
default:
throw new Error(`${label}: unknown schema node kind ${String(node.kind)}`)
}
}
function parseSchema(value: unknown, label: string, formatVersion: 1 | 2): 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, formatVersion === 2 ? ['compatibility'] : [])
if (property.compatibility !== undefined) {
const policy = record(property.compatibility, `${label} source compatibility`)
keys(policy, ['version', 'policy', 'binding', 'discriminator', 'unknownKinds', 'attributionKinds'], label)
if (policy.version !== 1 || policy.policy !== 'session-source-attribution'
|| !['session.user-message.source', 'session.developer-message.source'].includes(String(policy.binding))
|| policy.discriminator !== 'kind' || policy.unknownKinds !== 'preserve') {
throw new Error(`${label}: unsupported source compatibility policy`)
}
for (const kind of array(policy.attributionKinds, label)) textValue(kind, 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)}`)
}
function parseCompatibility(value: unknown, label: string): SourceCompatibility {
const policy = record(value, `${label} source compatibility`)
keys(policy, ['version', 'policy', 'binding', 'discriminator', 'unknownKinds', 'attributionKinds'], label)
if (policy.version !== 1 || policy.policy !== 'session-source-attribution'
|| (policy.binding !== 'session.user-message.source' && policy.binding !== 'session.developer-message.source')
|| policy.discriminator !== 'kind' || policy.unknownKinds !== 'preserve') {
throw new Error(`${label}: unsupported source compatibility policy`)
}
const schema = input as unknown as CanonicalSchema
for (const node of schema.nodes) {
if (node.kind !== 'object') continue
for (const property of node.properties) {
if (property.compatibility !== undefined && !validSourceCompatibility(schema.nodes, node, property)) {
throw new Error(`${label}: invalid source compatibility binding or attribution kinds`)
}
}
}
const canonical = canonicalizeSchema(schema.nodes, schema.root)
if (JSON.stringify(canonical) !== JSON.stringify(schema)) throw new Error(`${label}: schema is not canonical`)
return schema
return { version: 1, policy: 'session-source-attribution', binding: policy.binding, discriminator: 'kind', unknownKinds: 'preserve',
attributionKinds: array(policy.attributionKinds, label).map(kind => textValue(kind, label)) }
}
/** Parse full or compact inventories, restoring type graphs and rejecting invalid digests.
* @param value - JSON read from the current inventory or an enforced acknowledgement snapshot.
* @returns the validated inventory.
/** Parse the committed current inventory or an enforced acknowledgement snapshot.
* @param text - JSON text in the layout written by encodePersistenceSnapshot.
* @returns the validated inventory with canonical graphs.
*/
export function parsePersistenceSnapshot(value: unknown): PersistenceSchemaInventory {
return parseSnapshot(value, false)
export function parsePersistenceSnapshot(text: string): PersistenceSchemaInventory {
return parseSnapshot(text, false)
}
/** Parse a historical inventory with path-only source references and optional surface operations.
* @param value - JSON captured from a historical source tree.
* @param text - JSON text 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)
export function parseHistoricalPersistenceSnapshot(text: string): PersistenceSchemaInventory {
return parseSnapshot(text, true)
}
function parseSnapshot(value: unknown, historical: boolean): PersistenceSchemaInventory {
const input = record(value, 'schema inventory')
keys(input, ['formatVersion', 'roots', 'types'], 'schema inventory')
function parseSnapshot(text: string, historical: boolean): PersistenceSchemaInventory {
const parsed: unknown = JSON.parse(text)
const input = record(parsed, 'schema inventory')
keys(input, ['formatVersion', 'roots', 'types', 'nodes'], 'schema inventory')
const formatVersion = input.formatVersion
if (formatVersion !== 1 && formatVersion !== 2) throw new Error('unsupported persistence schema normalization version')
const table = record(input.nodes, 'schema nodes')
const nodeKeys = Object.keys(table)
const positions = new Map(nodeKeys.map((key, index) => [key, index]))
const position = (value: unknown, label: string): number => {
const found = typeof value === 'string' ? positions.get(value) : undefined
if (found === undefined) throw new Error(`${label} references an unknown schema node`)
return found
}
const graph = nodeKeys.map(key =>
parseNode(table[key], `schema node ${key}`, formatVersion, value => position(value, `schema node ${key}`)))
for (const [index, key] of nodeKeys.entries()) {
const node = graph[index] as SchemaNode
if (node.kind === 'object') {
for (const property of node.properties) {
if (property.compatibility !== undefined && !validSourceCompatibility(graph, node, property)) {
throw new Error(`schema node ${key}: invalid source compatibility binding or attribution kinds`)
}
}
}
const [canonicalKey, canonical] = schemaTableEntry(graph, index)
if (canonicalKey !== key) throw new Error(`schema node ${key}: key does not match its subgraph digest`)
if (JSON.stringify(canonical) !== JSON.stringify(table[key])) throw new Error(`schema node ${key}: node is not canonical`)
}
const used = new Set<number>()
const graphOf = (graphDigest: string, label: string): CanonicalSchema => {
const prefix = graphDigest.slice(0, SCHEMA_NODE_KEY_LENGTH)
const index = positions.get(prefix)
if (index === undefined) throw new Error(`${label}: no schema node for digest prefix ${prefix}`)
const pending = [index]
while (pending.length > 0) {
const next = pending.pop() as number
if (used.has(next)) continue
used.add(next)
pending.push(...schemaChildren(graph[next] as SchemaNode))
}
const schema = canonicalizeSchema(graph, index)
if (graphDigest !== schemaDigest(schema)) throw new Error(`${label}: schema digest mismatch`)
return schema
}
const names = new Set<string>()
const schemas: CanonicalSchema[] = []
for (const rawRoot of array(input.roots, 'schema roots')) {
const roots = array(input.roots, 'schema roots').map((rawRoot): PersistenceRoot => {
const root = record(rawRoot, 'schema root')
keys(root, ['key', 'kind', 'digest', 'schema'], 'schema root', ['event', 'surface'])
keys(root, ['key', 'kind', 'digest'], '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)
@@ -300,26 +316,17 @@ function parseSnapshot(value: unknown, historical: boolean): PersistenceSchemaIn
} else if ((root.kind !== 'header' || !['SessionHeader', 'JsonlHeaderLine'].includes(key))
&& (root.kind !== 'envelope' || key !== 'SessionEventEnvelope')) throw new Error(`invalid schema root ${key}`)
if (root.kind !== 'event' && (root.event !== undefined || root.surface !== undefined)) throw new Error(`${key}: non-event metadata`)
const schema = parseSchema(root.schema, key, formatVersion)
const schema = graphOf(digest(root.digest, `${key} digest`), 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`)
schemas.push(schema)
}
let reachable: Map<string, CanonicalSchema> | undefined
const types = array(input.types, 'schema types').map((rawType) => {
return root.kind === 'event'
? { key, kind: 'event', event: root.event as string, surface: root.surface as boolean, digest: root.digest as string, schema }
: { key, kind: root.kind, digest: root.digest as string, schema }
})
const types = array(input.types, 'schema types').map((rawType): PersistenceType => {
const type = record(rawType, 'schema type')
keys(type, ['digest', 'names', 'sources'], 'schema type', ['schema'])
keys(type, ['digest', 'names', 'sources'], 'schema type')
const typeDigest = digest(type.digest, 'shared digest')
let schema: CanonicalSchema
if ('schema' in type) {
schema = parseSchema(type.schema, 'shared schema', formatVersion)
if (typeDigest !== schemaDigest(schema)) throw new Error('shared schema digest mismatch')
} else {
reachable ??= reachableSchemaTypes(schemas)
const found = reachable.get(typeDigest)
if (found === undefined) throw new Error(`shared schema digest ${typeDigest} is not reachable from roots`)
schema = found
}
const schema = graphOf(typeDigest, `shared schema ${typeDigest}`)
const typeNames = array(type.names, 'type names').map(name => textValue(name, 'type name'))
const sources = array(type.sources, 'type sources').map((source) => {
const location = textValue(source, 'type source')
@@ -330,7 +337,13 @@ function parseSnapshot(value: unknown, historical: boolean): PersistenceSchemaIn
})
return { digest: typeDigest, schema, names: typeNames, sources }
})
return { ...input as unknown as PersistenceSchemaInventory, types }
const orphan = nodeKeys.find((_, index) => !used.has(index))
if (orphan !== undefined) throw new Error(`schema node ${orphan} is not reachable from any root or type`)
const inventory: PersistenceSchemaInventory = { formatVersion, roots, types }
const serialized = new Map(nodeKeys.map((key, index) =>
[key, serializeSchemaNode(graph[index] as SchemaNode, child => nodeKeys[child] as string)]))
if (renderPersistenceSnapshot(inventory, serialized) !== text) throw new Error('schema inventory is not in canonical layout')
return inventory
}
function validateEventMetadata(schema: CanonicalSchema, event: string, surface: boolean, historical: boolean): void {
@@ -654,7 +667,7 @@ function readPersistenceEntries(root: string, allowIncompleteId?: string): Persi
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 snapshot = parsePersistenceSnapshot(readFileSync(join(directory, snapshotName), 'utf8').replaceAll('\r\n', '\n'))
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')
@@ -705,8 +718,7 @@ export function verifyPersistenceChanges(root: string, current: PersistenceSchem
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)) {
if (readFileSync(committedPath, 'utf8') !== encodePersistenceSnapshot(current)) {
throw new PersistenceChangeFailure(`${CURRENT_SCHEMA} is stale; regenerate the persistence catalog`, 'stale-artifacts', differences, transitions)
}
if (differences.length !== 0) {
@@ -812,7 +824,7 @@ function executeCommand(
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))
const current = parsePersistenceSnapshot(encodePersistenceSnapshot(extract(root)))
if (operation === 'check') {
const history = verifyPersistenceChanges(root, current)
return { schemaVersion: 1, ok: true, operation,
@@ -867,7 +879,7 @@ function executeCommand(
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' },
{ path: `${HISTORY_DIRECTORY}/${id}.schema.json`, content: encodePersistenceSnapshot(snapshot) },
]
if (!update && recordFiles.some(file => existsSync(join(root, file.path)))) throw new Error(`${id}: acknowledgement file already exists`)
const outputs = [...artifacts(root, current), ...recordFiles]
+1 -1
View File
@@ -9,7 +9,7 @@ import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
import { classifyPersistenceChange, parsePersistenceSnapshot } from './persistence-changes.ts'
function toolAdditionSchema() {
const inventory = parsePersistenceSnapshot(JSON.parse(readFileSync(new URL('../docs/persistence-schema.json', import.meta.url), 'utf8')) as unknown)
const inventory = parsePersistenceSnapshot(readFileSync(new URL('../docs/persistence-schema.json', import.meta.url), 'utf8'))
const before = inventory.roots.find(root => root.key === 'event:developer/message')!
const nodes = [...structuredClone(before.schema.nodes)]
const blockIndex = nodes.findIndex(node => node.kind === 'object' && node.properties.some((property) => {
+19 -11
View File
@@ -7,7 +7,7 @@ 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 { canonicalizeSchema, encodePersistenceSnapshot, schemaDigest } from './persistence-schema-model.ts'
import type { PersistenceRoot, PersistenceSchemaInventory, PersistenceType, SchemaNode } from './persistence-schema-model.ts'
const temporary: string[] = []
@@ -57,7 +57,12 @@ function schemaPath(version: number, currentVersion: number): string {
}
function replaceSnapshot(root: string, version: number, currentVersion: number, snapshot: PersistenceSchemaInventory): void {
write(root, schemaPath(version, currentVersion), JSON.stringify(snapshot))
write(root, schemaPath(version, currentVersion), encodePersistenceSnapshot(snapshot))
}
/** Keep a digest's node-table prefix while changing the full digest. */
function driftDigest(digest: string): string {
return digest.slice(0, 16) + '0'.repeat(48)
}
function saveReference(root: string, version: number, currentVersion: number, snapshot = inventory(version)): void {
@@ -125,14 +130,14 @@ describe('archive current persistence format', () => {
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))
const archived = parseHistoricalPersistenceSnapshot(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(serialized).toBe(encodePersistenceSnapshot(archived))
expect(readFileSync(join(root, 'docs/persistence-schema.json'), 'utf8')).toBe(before)
expect(existsSync(join(root, 'docs/persistence-changes/historical-formats/v3.md'))).toBe(false)
@@ -146,7 +151,7 @@ describe('archive current persistence format', () => {
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))
expect(parseHistoricalPersistenceSnapshot(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) => {
@@ -178,7 +183,7 @@ describe('archive current persistence format', () => {
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 === 'digest-drift') replaceSnapshot(root, 3, 3, { ...original, roots: [{ ...original.roots[0]!, digest: driftDigest(original.roots[0]!.digest) }, ...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))
@@ -378,11 +383,14 @@ describe('complete persistence format references', () => {
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))
if (variant === 'duplicate-root') replaceSnapshot(root, 1, 3, { ...original, roots: [...original.roots, first] })
else if (variant === 'invalid-digest') replaceSnapshot(root, 1, 3, { ...original, roots: [{ ...first, digest: driftDigest(first.digest) }, ...original.roots.slice(1)] })
else {
const changed = JSON.parse(encodePersistenceSnapshot(original)) as { nodes: Record<string, unknown>; extra?: boolean }
if (variant === 'invalid-graph') changed.nodes[first.digest.slice(0, 16)] = { kind: 'array', element: '9'.repeat(16) }
else changed.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')
})
+4 -4
View File
@@ -7,7 +7,7 @@ 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 { canonicalizeSchema, encodePersistenceSnapshot, schemaDigest } from './persistence-schema-model.ts'
import type { PersistenceSchemaInventory } from './persistence-schema-model.ts'
import { withoutPersistenceSourceLines } from './persistence-source-metadata.ts'
@@ -166,7 +166,7 @@ export function loadPersistenceFormats(root: string): PersistenceFormats {
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)))
const inventory = (current ? parsePersistenceSnapshot : parseHistoricalPersistenceSnapshot)(read(schemaPath))
validateInventory(inventory, version, current)
const recordedRoots = record?.roots
if (recordedRoots !== undefined && (recordedRoots.size !== inventory.roots.length
@@ -190,11 +190,11 @@ function archiveCurrentInventory(root: string, requestedVersion: string): string
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 inventory = parsePersistenceSnapshot(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' })
writeFileSync(join(root, path), encodePersistenceSnapshot(archive), { flag: 'wx' })
return `Archived Session format v${version} to ${path}.`
}
+5 -5
View File
@@ -6,7 +6,7 @@ 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 { canonicalizeSchema, encodePersistenceSnapshot, 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'
@@ -108,14 +108,14 @@ function fixture(): Fixture {
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 + '.schema.json'), encodePersistenceSnapshot(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))
writeFileSync(join(fixture.directory, TAGS[index]! + '.schema.json'), encodePersistenceSnapshot(snapshot))
}
describe('pinned persistence releases', () => {
@@ -148,7 +148,7 @@ describe('pinned persistence releases', () => {
expect(archive.entries[1]!.differences).toEqual([expect.objectContaining({ root: 'event:example/value', kind: 'type-changed', requiresCompatibilityReview: 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')
expect(() => parsePersistenceSnapshot(encodePersistenceSnapshot(data.snapshots[1]!))).toThrow('surface metadata')
})
it.each(['.md', '.zh.md', '.i18n.yaml', '.schema.json'])('rejects a missing manifest release companion %s', (suffix) => {
@@ -369,7 +369,7 @@ describe('release facts', () => {
const data = fixture()
const snapshotPath = join(data.directory, TAGS[2] + '.schema.json')
const snapshot = readFileSync(snapshotPath, 'utf8')
writeFileSync(snapshotPath, snapshot.replace('"formatVersion":1', '"formatVersion":3'))
writeFileSync(snapshotPath, snapshot.replace('"formatVersion": 1', '"formatVersion": 3'))
const corrupt = directoryBytes(data.directory)
expect(() => runPersistenceReleases(['--write'], data.root)).toThrow('normalization version')
expect(directoryBytes(data.directory)).toEqual(corrupt)
+1 -1
View File
@@ -206,7 +206,7 @@ export function loadPersistenceReleases(root: string): PersistenceReleases {
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')))
const snapshot = parseHistoricalPersistenceSnapshot(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()
+3 -3
View File
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { classifyPersistenceChange } from './persistence-changes.ts'
import { canonicalizeSchema, schemaDigest, type PersistenceSchemaInventory, type SchemaNode, type SchemaProperty } from './persistence-schema-model.ts'
import { canonicalizeSchema, encodePersistenceSnapshot, schemaDigest, type PersistenceSchemaInventory, type SchemaNode, type SchemaProperty } from './persistence-schema-model.ts'
import { renderPersistenceReview, reviewPersistenceSchemas, runPersistenceReview } from './persistence-review.ts'
function fixture(options: {
@@ -116,8 +116,8 @@ describe('persistence review', () => {
try {
const before = join(directory, 'before.json')
const after = join(directory, 'after.json')
const oldText = JSON.stringify(fixture())
const newText = JSON.stringify(fixture({ extraKind: true }))
const oldText = encodePersistenceSnapshot(fixture())
const newText = encodePersistenceSnapshot(fixture({ extraKind: true }))
writeFileSync(before, oldText)
writeFileSync(after, newText)
const args = ['--before', before, '--after', after]
+1 -1
View File
@@ -215,7 +215,7 @@ export function renderPersistenceReview(report: PersistenceReview): string {
export function runPersistenceReview(args: readonly string[]): string {
const { values } = parseArgs({ args: [...args], options: { before: { type: 'string' }, after: { type: 'string' }, json: { type: 'boolean' } }, strict: true, allowPositionals: false })
if (values.before === undefined || values.after === undefined) throw new Error('persistence review: --before and --after inventory paths are required')
const read = (path: string): PersistenceSchemaInventory => parsePersistenceSnapshot(JSON.parse(readFileSync(path, 'utf8')))
const read = (path: string): PersistenceSchemaInventory => parsePersistenceSnapshot(readFileSync(path, 'utf8'))
const report = reviewPersistenceSchemas(read(values.before), read(values.after))
return values.json ? JSON.stringify(report, null, 2) + '\n' : renderPersistenceReview(report)
}
+93 -36
View File
@@ -1,10 +1,10 @@
/**
* Source-independent JSON type graphs and canonical persistence fingerprints.
* Numeric references are local to one graph; names and source locations are metadata.
* Numeric references are local to one in-memory graph; serialized snapshots reference nodes by digest prefix.
* Names and source locations are metadata.
*/
import { createHash } from 'node:crypto'
import { isDeepStrictEqual } from 'node:util'
/** Recorded reader promise for explicitly attributed source additions. */
export interface SourceCompatibility {
@@ -72,51 +72,108 @@ export interface PersistenceSchemaInventory {
readonly types: readonly PersistenceType[]
}
/** Type metadata with an explicit graph when roots cannot reconstruct it exactly. */
export interface PersistenceTypeIndex extends Omit<PersistenceType, 'schema'> {
readonly schema?: CanonicalSchema
}
/** Serialized node: the node fields with every reference replaced by a node key. */
export type SerializedSchemaNode =
| Extract<SchemaNode, { kind: 'primitive' | 'literal' | 'opaque' }>
| { readonly kind: 'array'; readonly element: string }
| { readonly kind: 'tuple'; readonly elements: readonly (Omit<SchemaTupleElement, 'type'> & { readonly type: string })[] }
| {
readonly kind: 'object'
readonly properties: readonly (Omit<SchemaProperty, 'type'> & { readonly type: string })[]
readonly indices: readonly { readonly key: string; readonly value: string }[]
}
| { readonly kind: 'union'; readonly types: readonly string[] }
/** Compact inventory representation; formatVersion still pins normalization. */
export interface PersistenceSchemaSnapshot {
readonly formatVersion: PersistenceSchemaInventory['formatVersion']
readonly roots: readonly PersistenceRoot[]
readonly types: readonly PersistenceTypeIndex[]
/** Length of the digest prefix that names a node in a serialized node table. */
export const SCHEMA_NODE_KEY_LENGTH = 16
/**
* Name a node by the digest prefix of the canonical subgraph it roots.
* @param nodes - graph containing the node.
* @param index - node to name.
* @returns key shared by every bisimilar node in any graph.
*/
export function schemaNodeKey(nodes: readonly SchemaNode[], index: number): string {
return schemaDigest(canonicalizeSchema(nodes, index)).slice(0, SCHEMA_NODE_KEY_LENGTH)
}
/**
* Index every normalized type reachable from the supplied graphs.
* @param schemas - complete canonical root graphs.
* @returns canonical subgraphs keyed by their structural digest.
* Produce the node-table entry for one node from its canonical subgraph.
* @param nodes - graph containing the node; its node may be non-canonical.
* @param index - node to serialize.
* @returns the node key and canonical serialized node.
*/
export function reachableSchemaTypes(schemas: readonly CanonicalSchema[]): Map<string, CanonicalSchema> {
const types = new Map<string, CanonicalSchema>()
for (const schema of schemas) {
for (const [index] of schema.nodes.entries()) {
const type = canonicalizeSchema(schema.nodes, index)
types.set(schemaDigest(type), type)
export function schemaTableEntry(nodes: readonly SchemaNode[], index: number): readonly [string, SerializedSchemaNode] {
const canonical = canonicalizeSchema(nodes, index)
const node = serializeSchemaNode(canonical.nodes[0] as SchemaNode, child => schemaNodeKey(canonical.nodes, child))
return [schemaDigest(canonical).slice(0, SCHEMA_NODE_KEY_LENGTH), node]
}
/**
* Replace node references with node keys, ordering unordered references by key.
* @param node - graph node.
* @param key - key of a referenced node index.
* @returns the serialized node.
*/
export function serializeSchemaNode(node: SchemaNode, key: (index: number) => string): SerializedSchemaNode {
switch (node.kind) {
case 'array': return { kind: 'array', element: key(node.element) }
case 'tuple': return { kind: 'tuple', elements: node.elements.map(element => ({ type: key(element.type), optional: element.optional, rest: element.rest })) }
case 'object': return {
kind: 'object',
properties: node.properties.map(property => ({ name: property.name, type: key(property.type), optional: property.optional,
...(property.compatibility === undefined ? {} : { compatibility: property.compatibility }) })),
indices: node.indices.map(index => ({ key: key(index.key), value: key(index.value) }))
.sort((left, right) => compare(left.key, right.key) || compare(left.value, right.value)),
}
case 'union': return { kind: 'union', types: node.types.map(key).sort(compare) }
case 'primitive':
case 'literal':
case 'opaque': return node
default: return assertNever(node)
}
}
/**
* Serialize an inventory with one node table keyed by subgraph digest prefix.
* Roots and types locate their graph through their digest prefix,
* so a type change rewrites only the changed node, its ancestors, and their digests.
* @param inventory - validated inventory with canonical graphs.
* @returns JSON text with one root, type, or node per line.
*/
export function encodePersistenceSnapshot(inventory: PersistenceSchemaInventory): string {
const table = new Map<string, SerializedSchemaNode>()
for (const { schema } of [...inventory.roots, ...inventory.types]) {
for (const index of schema.nodes.keys()) {
const [key, node] = schemaTableEntry(schema.nodes, index)
const existing = table.get(key)
if (existing !== undefined && JSON.stringify(existing) !== JSON.stringify(node)) {
throw new Error(`persistence schema: node key collision ${key}`)
}
table.set(key, node)
}
}
return types
return renderPersistenceSnapshot(inventory, table)
}
/**
* Omit type graphs exactly reconstructable from roots, retaining all other graphs.
* @param inventory - expanded inventory, including types erased from roots by normalization.
* @returns a lossless snapshot preserving root graphs, type order, and declaration metadata.
* Render inventory metadata and a node table in the committed text layout.
* @param inventory - root and type metadata; their graphs are not read.
* @param table - every node reachable from the roots and types.
* @returns JSON text with keys sorted and one root, type, or node per line.
*/
export function persistenceSchemaSnapshot(inventory: PersistenceSchemaInventory): PersistenceSchemaSnapshot {
const reachable = reachableSchemaTypes(inventory.roots.map(root => root.schema))
return {
formatVersion: inventory.formatVersion,
roots: inventory.roots,
types: inventory.types.map(({ digest, schema, names, sources }) => ({
digest,
...(isDeepStrictEqual(schema, reachable.get(digest)) ? {} : { schema }),
names,
sources,
})),
}
export function renderPersistenceSnapshot(inventory: PersistenceSchemaInventory, table: ReadonlyMap<string, SerializedSchemaNode>): string {
const lines = (values: readonly string[], open: string, close: string): string =>
values.length === 0 ? open + close : `${open}\n${values.map(value => ` ${value}`).join(',\n')}\n ${close}`
const roots = inventory.roots.map(root => JSON.stringify({ key: root.key, kind: root.kind,
...(root.event === undefined ? {} : { event: root.event }),
...(root.surface === undefined ? {} : { surface: root.surface }), digest: root.digest }))
const types = inventory.types.map(type => JSON.stringify({ digest: type.digest, names: type.names, sources: type.sources }))
const nodes = [...table].sort(([left], [right]) => compare(left, right)).map(([key, node]) => `${JSON.stringify(key)}: ${JSON.stringify(node)}`)
return `{\n "formatVersion": ${String(inventory.formatVersion)},\n`
+ ` "roots": ${lines(roots, '[', ']')},\n`
+ ` "types": ${lines(types, '[', ']')},\n`
+ ` "nodes": ${lines(nodes, '{', '}')}\n}\n`
}
/**
+85 -44
View File
@@ -1,10 +1,10 @@
/** Compact inventories retain root fingerprints and restore shared recursive type graphs. */
/** Snapshot text stores one node table keyed by subgraph digest and restores every root and type graph. */
import { describe, expect, it } from 'vitest'
import { parseHistoricalPersistenceSnapshot, parsePersistenceSnapshot } from './persistence-changes.ts'
import { canonicalizeSchema, persistenceSchemaSnapshot, schemaDigest, type PersistenceSchemaInventory } from './persistence-schema-model.ts'
import { canonicalizeSchema, encodePersistenceSnapshot, schemaDigest, type PersistenceSchemaInventory, type SchemaNode } from './persistence-schema-model.ts'
import { reviewPersistenceSchemas } from './persistence-review.ts'
import { renderPersistenceSchemaDefinitions, renderPersistenceSchemaIndex } from './render-persistence-schema.ts'
import { renderPersistenceSchemaDefinitions } from './render-persistence-schema.ts'
function inventory(): PersistenceSchemaInventory {
const schema = canonicalizeSchema([
@@ -22,22 +22,51 @@ function inventory(): PersistenceSchemaInventory {
}
}
describe('compact persistence snapshots', () => {
it('stores only root graphs and restores shared recursive definitions without changing reports', () => {
interface SnapshotJson {
roots: Record<string, unknown>[]
types: Record<string, unknown>[]
nodes: Record<string, Record<string, unknown>>
}
function edit(text: string, change: (value: SnapshotJson) => void): string {
const value = JSON.parse(text) as SnapshotJson
change(value)
return JSON.stringify(value)
}
describe('persistence snapshot text', () => {
it('stores each shared node once and restores graphs without changing reports', () => {
const original = inventory()
const snapshot = persistenceSchemaSnapshot(original)
expect(snapshot.roots).toEqual(original.roots)
expect(snapshot.types).toEqual(original.types.map(({ digest, names, sources }) => ({ digest, names, sources })))
const restored = parsePersistenceSnapshot(JSON.parse(JSON.stringify(snapshot)))
const text = encodePersistenceSnapshot(original)
const parsed = JSON.parse(text) as SnapshotJson
expect(Object.keys(parsed.nodes)).toHaveLength(3)
expect(parsed.roots[0]).toEqual({ key: 'SessionHeader', kind: 'header', digest: original.roots[0]!.digest })
expect(text.split('\n').every(line => !/"type": \d/u.test(line))).toBe(true)
const restored = parsePersistenceSnapshot(text)
expect(restored).toEqual(original)
expect(reviewPersistenceSchemas(original, restored)).toEqual(reviewPersistenceSchemas(original, original))
for (const rendering of ['current', 'historical'] as const) {
expect(renderPersistenceSchemaIndex(restored, 'en', undefined, 2, rendering))
.toBe(renderPersistenceSchemaIndex(original, 'en', undefined, 2, rendering))
expect(renderPersistenceSchemaDefinitions(restored, 'en', undefined, 2, rendering))
.toBe(renderPersistenceSchemaDefinitions(original, 'en', undefined, 2, rendering))
expect(parseHistoricalPersistenceSnapshot(text)).toEqual(original)
expect(encodePersistenceSnapshot(restored)).toBe(text)
})
it('changes only the new node and its ancestors when a union gains a variant', () => {
const graph = (variants: readonly string[]): PersistenceSchemaInventory => {
const nodes: SchemaNode[] = [
{ kind: 'object', properties: [{ name: 'other', type: 1, optional: false }, { name: 'source', type: 2, optional: false }], indices: [] },
{ kind: 'object', properties: [{ name: 'value', type: 3, optional: false }], indices: [] },
{ kind: 'union', types: variants.map((_, index) => 4 + index) },
{ kind: 'primitive', type: 'number' },
...variants.map((value): SchemaNode => ({ kind: 'literal', value })),
]
const schema = canonicalizeSchema(nodes, 0)
return { formatVersion: 1, roots: [{ key: 'SessionHeader', kind: 'header', digest: schemaDigest(schema), schema }], types: [] }
}
expect(parseHistoricalPersistenceSnapshot(snapshot)).toEqual(original)
const keys = (value: PersistenceSchemaInventory): string[] =>
Object.keys((JSON.parse(encodePersistenceSnapshot(value)) as SnapshotJson).nodes)
const before = new Set(keys(graph(['a', 'c'])))
const after = keys(graph(['a', 'b', 'c']))
expect(after.filter(key => !before.has(key))).toHaveLength(3)
expect([...before].filter(key => !after.includes(key))).toHaveLength(2)
})
it.each([true, false])('retains independent recursive types with root graphs=%s', (withRoots) => {
@@ -48,45 +77,57 @@ describe('compact persistence snapshots', () => {
], 0)
const independent = { digest: schemaDigest(schema), schema, names: ['example.ts#Independent', 'example.ts#Alias'], sources: ['example.ts'] }
const original = { ...base, roots: withRoots ? base.roots : [], types: [independent, ...base.types] }
const snapshot = persistenceSchemaSnapshot(original)
expect(snapshot.types[0]).toEqual(independent)
if (withRoots) expect(snapshot.types.slice(1).every(type => type.schema === undefined)).toBe(true)
else expect(snapshot.types).toEqual(original.types)
const restored = parsePersistenceSnapshot(JSON.parse(JSON.stringify(snapshot)))
const restored = parsePersistenceSnapshot(encodePersistenceSnapshot(original))
expect(restored).toEqual(original)
expect(parseHistoricalPersistenceSnapshot(snapshot)).toEqual(original)
expect(persistenceSchemaSnapshot(restored)).toEqual(snapshot)
expect(renderPersistenceSchemaDefinitions(restored)).toBe(renderPersistenceSchemaDefinitions(original))
})
it('reads legacy full graphs and keeps partial acknowledgement inventories empty', () => {
const original = inventory()
expect(parsePersistenceSnapshot(JSON.parse(JSON.stringify(original)))).toEqual(original)
const partial = { ...original, types: [] }
expect(parsePersistenceSnapshot(persistenceSchemaSnapshot(partial))).toEqual(partial)
it('rejects tampered node keys, unknown references, orphan nodes, and digest mismatches', () => {
const text = encodePersistenceSnapshot(inventory())
const rootKey = inventory().roots[0]!.digest.slice(0, 16)
const second = Object.keys((JSON.parse(text) as SnapshotJson).nodes).find(key => key !== rootKey)!
expect(() => parsePersistenceSnapshot(edit(text, (value) => {
value.nodes = Object.fromEntries(Object.entries(value.nodes).map(([key, node]) => [key === rootKey ? 'f'.repeat(16) : key, node]))
}))).toThrow('key does not match')
expect(() => parsePersistenceSnapshot(edit(text, (value) => { value.nodes[rootKey] = { kind: 'array', element: '0'.repeat(16) } })))
.toThrow('unknown schema node')
expect(() => parsePersistenceSnapshot(edit(text, (value) => { value.nodes[second] = { kind: 'primitive', type: 'number' } })))
.toThrow('key does not match')
expect(() => parsePersistenceSnapshot(edit(encodePersistenceSnapshot({ ...inventory(), roots: [] }), (value) => { value.types = [] })))
.toThrow('not reachable from any root or type')
expect(() => parsePersistenceSnapshot(edit(text, (value) => { value.roots[0]!.digest = 'f'.repeat(64) }))).toThrow('no schema node for digest prefix')
})
it('rejects compact digests that do not identify a root subgraph', () => {
const snapshot = persistenceSchemaSnapshot(inventory())
const schema = canonicalizeSchema([{ kind: 'literal', value: 'unreachable' }], 0)
expect(() => parsePersistenceSnapshot({ ...snapshot, types: [{ digest: schemaDigest(schema), names: [], sources: [] }] }))
.toThrow('is not reachable from roots')
it('rejects node entries whose properties or union members are not in canonical order', () => {
const text = encodePersistenceSnapshot(inventory())
const nodes = (JSON.parse(text) as SnapshotJson).nodes
const [objectKey] = Object.entries(nodes).find(([, node]) => ((node.properties as unknown[] | undefined)?.length ?? 0) > 1)!
expect(() => parsePersistenceSnapshot(edit(text, (value) => {
(value.nodes[objectKey]!.properties as unknown[]).reverse()
}))).toThrow('node is not canonical')
const unionSchema = canonicalizeSchema([
{ kind: 'union', types: [1, 2] }, { kind: 'literal', value: 'a' }, { kind: 'literal', value: 'b' },
], 0)
const union = encodePersistenceSnapshot({ formatVersion: 1, roots: [],
types: [{ digest: schemaDigest(unionSchema), schema: unionSchema, names: [], sources: [] }] })
const [unionKey] = Object.entries((JSON.parse(union) as SnapshotJson).nodes).find(([, node]) => node.kind === 'union')!
expect(() => parsePersistenceSnapshot(edit(union, (value) => {
const types = value.nodes[unionKey]!.types as string[]
value.nodes[unionKey]!.types = [types[0], ...types]
}))).toThrow('node is not canonical')
})
it('still validates explicit legacy graphs instead of replacing them with root graphs', () => {
const original = inventory()
const schema = canonicalizeSchema([{ kind: 'primitive', type: 'number' }], 0)
expect(() => parsePersistenceSnapshot({ ...original, types: original.types.map(type => ({ ...type, schema })) }))
.toThrow('shared schema digest mismatch')
it('rejects valid content outside the canonical text layout', () => {
const text = encodePersistenceSnapshot(inventory())
expect(() => parsePersistenceSnapshot(JSON.stringify(JSON.parse(text), null, 2) + '\n')).toThrow('canonical layout')
})
it('rejects invalid metadata and malformed explicit schemas in compact inventories', () => {
const snapshot = persistenceSchemaSnapshot(inventory())
const type = snapshot.types[0]!
expect(() => parsePersistenceSnapshot({ ...snapshot, types: [{ ...type, names: [3] }] })).toThrow('type name')
expect(() => parsePersistenceSnapshot({ ...snapshot, types: [{ ...type, sources: [3] }] })).toThrow('type source')
expect(() => parsePersistenceSnapshot({ ...snapshot, types: [{ ...type, schema: null }] })).toThrow('shared schema')
expect(() => parseHistoricalPersistenceSnapshot({ ...snapshot, types: [{ ...type, sources: ['types.ts:3'] }] }))
it('rejects invalid type metadata', () => {
const text = encodePersistenceSnapshot(inventory())
expect(() => parsePersistenceSnapshot(edit(text, (value) => { value.types[0]!.names = [3] }))).toThrow('type name')
expect(() => parsePersistenceSnapshot(edit(text, (value) => { value.types[0]!.sources = [3] }))).toThrow('type source')
expect(() => parsePersistenceSnapshot(edit(text, (value) => { value.types[0]!.schema = {} }))).toThrow('unknown field schema')
expect(() => parseHistoricalPersistenceSnapshot(edit(text, (value) => { value.types[0]!.sources = ['types.ts:3'] })))
.toThrow('must omit line numbers')
})
})
+3 -3
View File
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import { collectLogEvents } from './gen-persistence-catalog.ts'
import { extractPersistenceSchema } from './persistence-schema.ts'
import { classifyPersistenceChange, parsePersistenceSnapshot } from './persistence-changes.ts'
import { canonicalizeSchema, isArbitraryJsonSchema, schemaDigest, type PersistenceSchemaInventory } from './persistence-schema-model.ts'
import { canonicalizeSchema, encodePersistenceSnapshot, isArbitraryJsonSchema, schemaDigest, type PersistenceSchemaInventory } from './persistence-schema-model.ts'
const roots: string[] = []
afterEach(() => {
@@ -63,7 +63,7 @@ describe('persistent source type extraction', () => {
expect(inventory.types.some(type => type.names.includes('packages/domain/payload/src/types.ts#Section'))).toBe(true)
expect(inventory.types.some(type => type.names.length === 0 && type.sources.includes('packages/domain/payload/src/types.ts:2'))).toBe(true)
expect(inventory.types.some(type => type.schema.nodes[0]?.kind === 'array' && type.names.length === 0)).toBe(true)
expect(parsePersistenceSnapshot(inventory)).toEqual(inventory)
expect(parsePersistenceSnapshot(encodePersistenceSnapshot(inventory))).toEqual(inventory)
})
it('ignores alias names, files, documentation, readonly, brands and property order', () => {
@@ -421,7 +421,7 @@ describe('source compatibility authoring', () => {
discriminator: 'kind', unknownKinds: 'preserve', attributionKinds: ['attribution'] })
expect(classifyPersistenceChange(before.roots.find(root => root.kind === 'event')!, root))
.toEqual([expect.objectContaining({ kind: 'attribution-kind-added', requiresCompatibilityReview: false })])
expect(parsePersistenceSnapshot({ ...after, types: [] })).toEqual({ ...after, types: [] })
expect(parsePersistenceSnapshot(encodePersistenceSnapshot({ ...after, types: [] }))).toEqual({ ...after, types: [] })
})
it('binds inherited source declarations only on explicitly eligible required roles', () => {
+94
View File
@@ -0,0 +1,94 @@
/** Execute both shipped SEA providers against compressed empty-file assets. */
import { createRequire } from 'node:module'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { runInNewContext } from 'node:vm'
import { brotliCompressSync } from 'node:zlib'
import ts from 'typescript'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const pkgRoot = dirname(require.resolve('@yao-pkg/pkg/package.json'))
const bundle = readFileSync(join(pkgRoot, 'prelude/sea-bootstrap.bundle.js'), 'utf8')
function initializer(source: string, name: string): string {
const parsed = ts.createSourceFile('prelude.js', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
let expression: string | undefined
const visit = (node: ts.Node): void => {
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name) {
expression = node.initializer?.getText(parsed)
}
ts.forEachChild(node, visit)
}
visit(parsed)
if (expression === undefined) throw new Error(`pkg prelude is missing ${name}`)
return expression
}
function commonJs(callbacks: Record<string, (exports: object, module: { exports: unknown }) => void>) {
return () => {
const exports = {}
const module: { exports: unknown } = { exports }
Object.values(callbacks)[0]!(exports, module)
return module.exports
}
}
const workerSource: unknown = runInNewContext(`(${initializer(bundle, 'require_worker_bootstrap_string')})()`, { __commonJS: commonJs })
if (typeof workerSource !== 'string') throw new Error('pkg worker bootstrap must be JavaScript text')
interface Provider {
readFileSync(path: string): Buffer
}
function provider(source: string, contents: Buffer, declaredSize = contents.length): Provider {
const archive = brotliCompressSync(contents)
const manifest = {
compression: 2,
entrypoint: '/fixture', directories: {}, symlinks: {},
offsets: { '/fixture': [0, archive.length] },
stats: { '/fixture': { size: declaredSize, isFile: true, isDirectory: false } },
}
class MemoryProvider {
mkdirSync(): void {}
}
class VirtualFileSystem {
mount(): void {}
}
const loaded = runInNewContext(`(${initializer(source, 'require_sea_vfs_setup')})()`, {
__commonJS: commonJs,
require_bootstrap_shared: (): unknown => require(join(pkgRoot, 'prelude/bootstrap-shared.js')),
require: (name: string): object => {
if (name === 'node:sea') return {
getAsset: () => JSON.stringify(manifest),
getRawAsset: () => Uint8Array.from(archive).buffer,
}
if (name === 'node:vfs') return { MemoryProvider, VirtualFileSystem }
throw new Error(`unexpected pkg fixture dependency: ${name}`)
},
Buffer,
process: { env: {}, platform: 'linux' },
}) as { provider: Provider }
return loaded.provider
}
describe.each([['main', bundle], ['worker', workerSource]] as const)('pkg compressed assets in %s', (_name, source) => {
it('reads and caches an empty file', () => {
const subject = provider(source, Buffer.alloc(0))
expect(subject.readFileSync('/fixture')).toEqual(Buffer.alloc(0))
expect(subject.readFileSync('/fixture')).toEqual(Buffer.alloc(0))
})
it('preserves nonempty bytes and isolates cached data from callers', () => {
const subject = provider(source, Buffer.from('content'))
const first = subject.readFileSync('/fixture')
first.fill(0)
expect(subject.readFileSync('/fixture')).toEqual(Buffer.from('content'))
})
it('rejects nonempty output declared as an empty file', () => {
expect(() => provider(source, Buffer.from('x'), 0).readFileSync('/fixture'))
.toThrow('decompressed size does not match stats.size')
})
})
+67
View File
@@ -0,0 +1,67 @@
/** Plugin feature previews are one fixed-canvas SVG per required UI language, beside their detail document. */
import { globSync, readFileSync } from 'node:fs'
import { basename, dirname, extname, join } from 'node:path'
import { JSDOM } from 'jsdom'
import { expect, it, onTestFinished } from 'vitest'
const root = `${import.meta.dirname}/..`
const svgPaths = globSync('packages/*/*/docs/*.svg', { cwd: root }).sort()
/** Combined size of one language pair, as the standalone bundle and Host routes both serve the files individually. */
const MAX_PAIR_BYTES = 1024 * 1024
it('keeps every preview a self-contained 640×300 SVG with English and Chinese variants', () => {
// Guard against a narrowed corpus: each preview moved into a bundle's docs directory stays checked.
expect(svgPaths.length).toBeGreaterThan(0)
const browser = new JSDOM('')
onTestFinished(() => { browser.window.close() })
const parser = new browser.window.DOMParser()
const pairs = new Map<string, { en?: string; zh?: string }>()
for (const path of svgPaths) {
const name = basename(path, '.svg')
const chinese = name.endsWith('.zh')
const stem = join(dirname(path), chinese ? name.slice(0, -3) : name)
const pair = pairs.get(stem) ?? {}
if (chinese) pair.zh = path
else pair.en = path
pairs.set(stem, pair)
}
for (const [stem, pair] of pairs) {
expect(pair, stem).toEqual({ en: `${stem}.svg`, zh: `${stem}.zh.svg` })
}
for (const [, pair] of pairs) {
const sources: string[] = []
let bytes = 0
for (const path of [pair.en, pair.zh]) {
if (path === undefined) throw new Error('Preview pair is incomplete')
const svg = readFileSync(join(root, path), 'utf8')
sources.push(svg)
bytes += Buffer.byteLength(svg)
const document = parser.parseFromString(svg, 'image/svg+xml')
expect(document.querySelector('parsererror'), path).toBeNull()
expect(document.documentElement.namespaceURI, path).toBe('http://www.w3.org/2000/svg')
expect(document.documentElement.localName, path).toBe('svg')
expect(document.documentElement.getAttribute('width'), path).toBe('640')
expect(document.documentElement.getAttribute('height'), path).toBe('300')
expect(document.documentElement.getAttribute('viewBox'), path).toBe('0 0 640 300')
expect(document.querySelector('path, rect, circle, ellipse, line, polyline, polygon'), path).not.toBeNull()
expect(document.querySelector('script, style, foreignObject, text, animate, animateTransform, animateMotion, set'), path).toBeNull()
const ids = new Set([...document.querySelectorAll('[id]')].map(element => element.id))
for (const element of [document.documentElement, ...document.querySelectorAll('*')]) {
for (const attribute of element.attributes) {
expect(attribute.name, path).not.toMatch(/^on/iu)
const references = [...attribute.value.matchAll(/url\(([^)]+)\)/gu)].map(match => (match[1] ?? '').replace(/['"]/gu, '').trim())
if (attribute.localName === 'href') references.push(attribute.value)
for (const reference of references) {
if (element.localName === 'image' && attribute.localName === 'href'
&& /^data:image\/(?:png|jpeg|webp);base64,[A-Za-z\d+/]+=*$/u.test(reference)) continue
expect(reference.startsWith('#') && ids.has(reference.slice(1)), `${path}: ${reference}`).toBe(true)
}
}
}
}
expect(bytes, pair.en).toBeLessThanOrEqual(MAX_PAIR_BYTES)
// The Chinese variant carries translated content; identical files would mean a forgotten translation.
expect(sources[0], pair.en).not.toBe(sources[1])
expect(extname(pair.en ?? ''), String(pair.en)).toBe('.svg')
}
})
+2
View File
@@ -16,6 +16,7 @@ export interface ProductPackagePolicy {
* Optional integrations may be reached by an optional bundle without becoming default capabilities.
*/
export const PRODUCT_PACKAGE_POLICY: Readonly<Record<string, ProductPackagePolicy>> = {
'packages/core/agent-tool-presentation': { category: 'optional', reason: 'Deprecated selector retained for existing executor configurations.' },
'packages/browser-use/browser-use': { category: 'optional', reason: 'Service definition used by explicitly selected browser providers.' },
'packages/computer-use/computer-use': { category: 'optional', reason: 'Service definition used by explicitly selected computer providers.' },
'packages/lsp/lsp': { category: 'optional', reason: 'Service definition for deployment-configured language servers.' },
@@ -38,6 +39,7 @@ export const PRODUCT_PACKAGE_POLICY: Readonly<Record<string, ProductPackagePolic
'packages/sdk/client': { category: 'sdk', reason: 'Published TypeScript embedding client runs in the consumer process.' },
'packages/typert/generator': { category: 'build', reason: 'Build-time generation of type metadata.' },
'packages/test-support/agent-loop-testkit': { category: 'test', reason: 'Published agent-loop test harness.' },
'packages/test-support/tool-testkit': { category: 'test', reason: 'Shared tool definition and execution fixtures for tests and benchmarks.' },
'packages/test-support/client-runtime': { category: 'test', reason: 'Published browser plugin test harness.' },
'packages/test-support/llm-mock-server': { category: 'test', reason: 'Published model-provider test server.' },
'packages/test-support/llm-replay': { category: 'test', reason: 'Published recorded-model replay harness.' },
+17 -1
View File
@@ -5,6 +5,8 @@ import { fileURLToPath } from 'node:url'
import { once } from 'node:events'
import { execa } from 'execa'
import { afterEach, describe, expect, it } from 'vitest'
import type { Message } from 'publint'
import { isBrowserBundleFormatFalsePositive } from './publint-browser-format.ts'
const repositoryRoot = fileURLToPath(new URL('..', import.meta.url))
const runner = fileURLToPath(new URL('./publint-all.ts', import.meta.url))
@@ -35,7 +37,7 @@ function fixture(options: {
version: '0.0.1',
type: 'module',
license: 'MIT',
engines: { node: '>=22.19' },
engines: { node: '>=24.9.0' },
sideEffects: false,
files: ['lib'],
exports: { '.': { default: options.exportPath ?? './lib/index.js' } },
@@ -91,6 +93,20 @@ async function run(root: string, signal: AbortSignal) {
return completed
}
describe('publint browser format filtering', () => {
it('exempts browser factories but not the sibling Node ESM artifact', () => {
const format = (file: string): Message => ({
code: 'FILE_INVALID_FORMAT',
args: { actualFilePath: `package/lib/${file}` },
path: ['./client'],
}) as Message
expect(isBrowserBundleFormatFalsePositive(format('client.browser.js'))).toBe(true)
expect(isBrowserBundleFormatFalsePositive(format('client.js'))).toBe(true)
expect(isBrowserBundleFormatFalsePositive(format('client.node.js'))).toBe(false)
expect(isBrowserBundleFormatFalsePositive(format('client.node.helper.js'))).toBe(false)
})
})
describe('publint package runner', () => {
it('reports deadline cancellation after every owned child closes', async ({ signal }) => {
const deadline = new AbortController()
+1 -16
View File
@@ -12,6 +12,7 @@ import { publint, type Message, type PackFile } from 'publint'
import { formatMessage } from 'publint/utils'
import ts from 'typescript'
import { defaultParallelism } from './parallelism.ts'
import { isBrowserBundleFormatFalsePositive } from './publint-browser-format.ts'
const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY'
const repositoryRoot = resolve(import.meta.dirname, '..')
@@ -177,22 +178,6 @@ function relativeImports(file: string, sourceText: string): RelativeImport[] {
return imports
}
/**
* publint reads the CommonJS interop preamble inside the prebuilt browser
* bundles and reports CJS-written-as-ESM. Node never resolves those files:
* `lib/client.js` is evaluated by the page module system as a classic script,
* and `lib/worker.js` by `new Worker(url, { type: 'module' })` — both outside
* the Node resolution publint models. Exactly that verdict on exactly those
* files is suppressed; every other publint error stays fatal.
*/
function isBrowserBundleFormatFalsePositive(message: Message): boolean {
if (message.code !== 'FILE_INVALID_FORMAT') return false
const filePath = (message.args as { actualFilePath?: string }).actualFilePath ?? ''
const exportKey = Array.isArray(message.path) ? message.path.join('/') : ''
return /(^|\/)lib\/(client|worker)\.js$/.test(filePath)
|| /(^|\/)\.\/(client|worker)$/.test(exportKey)
}
async function runPublint(target: PackageTarget): Promise<PublintResult> {
try {
const files = publicationFiles(target)
+16
View File
@@ -0,0 +1,16 @@
import type { Message } from 'publint'
/**
* Browser Client and Worker artifacts run outside Node's ESM loader, so their
* CommonJS-format verdict is false; a Node Client entry must never be exempt.
* Other publint diagnostics remain fatal.
* @param message - one publint diagnostic for a published file.
* @returns true only for the known browser script format verdict.
*/
export function isBrowserBundleFormatFalsePositive(message: Message): boolean {
if (message.code !== 'FILE_INVALID_FORMAT') return false
const filePath = (message.args as { actualFilePath?: string }).actualFilePath ?? ''
const exportKey = Array.isArray(message.path) ? message.path.join('/') : ''
return /(^|\/)lib\/(?:client(?:\.browser)?|worker)\.js$/.test(filePath)
|| filePath === '' && /(^|\/)\.\/worker$/.test(exportKey)
}
@@ -44,6 +44,7 @@ async function harness(direct = false) {
runId: WorkflowRunId('run'), seq: 1, label: 'workflow-child', childId,
})
const step = (overrides = {}) => agentEvents(ctx, agent).waterfall('agent/pre-step', {
modelSelection: { provider: 'mock', model: 'mock' },
turn: 1, step: 1, messages, signal: controller.signal, ...overrides,
}, next)
return { ctx, fiber, parent, other, session, agent, controller, decision, next, start, step }
+7 -1
View File
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { officialClientBuildEnvironment, writeClientBuildRecord } from '../client-build-environment.ts'
import { ON_DEMAND_BUNDLES } from '../../packages/boot/app-boot/src/official-bundles.ts'
import { ON_DEMAND_BUNDLES } from '../../packages/boot/official-bundles/src/bundles.ts'
import { releaseFamily, type ReleaseMember } from './families.ts'
import { compareVersions, nextVendorVersion, planShared, reachesPayload } from './bump.ts'
@@ -66,11 +66,15 @@ describe('release families', () => {
'@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp',
'@deepseek-ai/dsh-experimental-computer-use-cua-driver-native',
'@deepseek-ai/dsh-experimental-cot-translation-bundle',
'@deepseek-ai/dsh-cua-bundle',
'@deepseek-ai/dsh-experimental-cua-macos',
'@deepseek-ai/dsh-cua',
'@deepseek-ai/dsh-hook-protocol',
'@deepseek-ai/dsh-hooks-claude-code',
'@deepseek-ai/dsh-hooks-codex',
'@deepseek-ai/dsh-experimental-inspector-profile',
'@deepseek-ai/dsh-experimental-inspector',
'@deepseek-ai/dsh-experimental-plugin-market-bundle',
'@deepseek-ai/dsh-experimental-ptc-runtime-python',
'@deepseek-ai/dsh-experimental-ralph-bundle',
'@deepseek-ai/dsh-experimental-session-inspector',
@@ -80,10 +84,12 @@ describe('release families', () => {
'@deepseek-ai/dsh-skill-badge',
'@deepseek-ai/dsh-experimental-speech-to-text-sensevoice',
'@deepseek-ai/dsh-experimental-speech-to-text',
'@deepseek-ai/dsh-experimental-subagent-oar',
'@deepseek-ai/dsh-experimental-terminal-bundle',
'@deepseek-ai/dsh-experimental-tool-agent-team',
'@deepseek-ai/dsh-tool-ralph',
'@deepseek-ai/dsh-tool-session-query',
'@deepseek-ai/dsh-experimental-tool-subagent-oar',
'@deepseek-ai/dsh-tool-terminal',
'@deepseek-ai/dsh-experimental-tool-worktree',
'@deepseek-ai/dsh-experimental-translator',
+1 -1
View File
@@ -16,7 +16,7 @@ import {
readClientBuildRecord,
} from '../client-build-environment.ts'
import { validateTarballPayload } from '../publication-payload.ts'
import { ON_DEMAND_BUNDLES } from '../../packages/boot/app-boot/src/official-bundle-packages.ts'
import { ON_DEMAND_BUNDLES } from '../../packages/boot/official-bundles/src/bundles.ts'
/**
* Dependency sections a consumer must publish after, because npm resolves them
@@ -4,7 +4,7 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join } from 'node:path'
import { hasExperimentalPackageReference } from '../experimental-package-policy.ts'
import { OPTIONAL_BUNDLES } from '../../packages/boot/app-boot/src/profile.ts'
import { OPTIONAL_BUNDLES } from '../../packages/boot/official-bundles/src/bundles.ts'
interface InstalledManifest {
name: string
+29 -14
View File
@@ -1,9 +1,9 @@
/**
* Pack one release family's whole publish set into a single directory and
* record its publish order for the publish step. Default serial packs execute
* Pack one family's public packages into a staging directory; the release command
* also records their publish order. Default serial packs execute
* in that order; explicitly parallel packs use pnpm's bounded scheduler.
*
* The pack step is the release boundary: it runs without credentials, produces
* The release command is the release boundary: it runs without credentials, produces
* every tarball from one commit, and hands the publish step exactly those bytes
* ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
*/
@@ -68,10 +68,12 @@ async function packMembers(
}
/**
* @returns The validated `--concurrency` value; 1 (the default) packs the
* members one at a time, exactly as the credentialed publish workflows run it.
* Resolve the maximum number of concurrent package packs.
* @param raw - Optional decimal worker count from the packing command line.
* @returns A positive integer, defaulting to one for serial packing.
* @throws If the argument is not a positive safe integer in decimal form.
*/
function parseConcurrency(raw: string | undefined): number {
export function parsePackConcurrency(raw: string | undefined): number {
if (raw === undefined) return 1
const parsed = Number.parseInt(raw, 10)
if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) {
@@ -80,6 +82,25 @@ function parseConcurrency(raw: string | undefined): number {
return parsed
}
/**
* Replace a staging directory with validated family tarballs without a publication record.
* The caller must verify any client build profile and artifact hashes its packaging mode requires.
* @param root - Repository containing the family members.
* @param family - Package selection, version, and payload policy.
* @param destination - Absolute staging directory owned by this packing invocation.
* @param concurrency - Validated maximum number of concurrent package packs.
* @returns Tarball filenames in publish order, after every package passes validation.
*/
export async function packFamilyMembers(
root: string, family: ReleaseFamily, destination: string, concurrency: number,
): Promise<string[]> {
const members = family.publishOrder(family.members(root)).order
family.verifyVersions(members)
rmSync(destination, { recursive: true, force: true })
mkdirSync(destination, { recursive: true })
return packMembers(family, members, destination, concurrency)
}
/** Pack the family named by `--family` into `--out`. */
async function main(): Promise<void> {
const { values } = parseArgs({
@@ -87,19 +108,13 @@ async function main(): Promise<void> {
allowPositionals: false,
})
if (values.family === undefined) throw new Error('usage: pack.ts --family <dsh|vendor> [--out dist/npm] [--concurrency 1]')
const concurrency = parseConcurrency(values.concurrency)
const concurrency = parsePackConcurrency(values.concurrency)
const family = releaseFamily(values.family)
const root = process.cwd()
const destination = resolve(root, values.out ?? DEFAULT_OUTPUT)
const members = family.publishOrder(family.members(root)).order
family.verifyBuildArtifacts(root)
family.verifyVersions(members)
rmSync(destination, { recursive: true, force: true })
mkdirSync(destination, { recursive: true })
const order = await packMembers(family, members, destination, concurrency)
const order = await packFamilyMembers(root, family, destination, concurrency)
writeFileSync(join(destination, PUBLISH_ORDER_FILE), `${order.join('\n')}\n`)
console.log(`release pack: family ${family.id}, ${String(order.length)} tarball(s) in ${values.out ?? DEFAULT_OUTPUT}`)
+19 -2
View File
@@ -38,7 +38,8 @@ const path = require('node:path')
const consumer = process.env.INIT_CWD || process.cwd()
const cache = path.join(consumer, '.npm-cache', '_cacache')
const files = fs.existsSync(cache) ? fs.readdirSync(cache, { recursive: true }).filter(name => fs.statSync(path.join(cache, name)).isFile()) : []
fs.writeFileSync(process.env.CACHE_EVIDENCE, JSON.stringify({ consumer, files }))
fs.writeFileSync(process.env.CACHE_EVIDENCE, JSON.stringify({ consumer, files,
installed: ['matching', 'wrong-os', 'wrong-cpu'].filter(name => fs.existsSync(path.join(consumer, 'node_modules', name))) }))
`
writeFileSync(join(source, 'probe.cjs'), probe + (outcome === 'install failure' ? '\nprocess.exit(37)\n' : ''))
writeFileSync(join(source, 'lib/bin.js'), outcome === 'entry failure' ? 'process.exit(37)\n' : 'console.log("0.0.1")\n')
@@ -51,6 +52,21 @@ fs.writeFileSync(process.env.CACHE_EVIDENCE, JSON.stringify({ consumer, files })
expect(archive.error).toBeUndefined()
expect(archive.signal).toBeNull()
expect(archive.status, archive.stderr).toBe(0)
for (const [name, platform] of [
['matching', { os: [process.platform], cpu: ['any'] }],
['wrong-os', { os: [`!${process.platform}`] }],
['wrong-cpu', { cpu: [process.arch === 'arm64' ? 'x64' : 'arm64'] }],
] as const) {
const packageDirectory = join(directory, name, 'package')
mkdirSync(packageDirectory, { recursive: true })
writeFileSync(join(packageDirectory, 'package.json'), JSON.stringify({ name, version: '0.0.1', ...platform }))
const packedPlatform = spawnSync('tar', ['-czf', join(packed, `${name}.tgz`), '-C', join(directory, name), 'package'], {
encoding: 'utf8', timeout: 30_000,
})
expect(packedPlatform.error).toBeUndefined()
expect(packedPlatform.signal).toBeNull()
expect(packedPlatform.status, packedPlatform.stderr).toBe(0)
}
const environment = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^npm_config_/i.test(key)))
Object.assign(environment, {
npm_config_cache: ambient,
@@ -68,7 +84,8 @@ fs.writeFileSync(process.env.CACHE_EVIDENCE, JSON.stringify({ consumer, files })
expect(result.error).toBeUndefined()
expect(result.signal).toBeNull()
expect(result.status, result.stdout + result.stderr).toBe(outcome === 'success' ? 0 : 1)
const observation = JSON.parse(readFileSync(evidence, 'utf8')) as { consumer: string; files: string[] }
const observation = JSON.parse(readFileSync(evidence, 'utf8')) as { consumer: string; files: string[]; installed: string[] }
expect(observation.installed).toEqual(['matching'])
expect(observation.files.length, 'npm must write tarball content into the consumer-private cache').toBeGreaterThan(0)
expect(observation.consumer).toContain('dsh-packed-dsh-')
expect(existsSync(observation.consumer), 'the verifier finally must remove its consumer and cache').toBe(false)
+29 -9
View File
@@ -24,7 +24,7 @@ import { pathToFileURL } from 'node:url'
import { parseArgs } from 'node:util'
import { releaseFamily } from './families.ts'
import { capture, isEntry } from './process.ts'
import { packedIdentity } from './tarball.ts'
import { packedManifest } from './tarball.ts'
import { verifyInstalledProductIsolation } from './installed-product-isolation.ts'
/**
@@ -45,30 +45,46 @@ function consumerEnvironment(consumerRoot: string): NodeJS.ProcessEnv {
return environment
}
function acceptsPlatform(value: unknown, current: string): boolean {
if (value === undefined) return true
if (!Array.isArray(value) || !value.every((tag): tag is string => typeof tag === 'string')) {
throw new Error('packed platform constraints must be arrays of strings')
}
let accepted = value.every(tag => tag.startsWith('!'))
for (const tag of value) {
if (tag === `!${current}`) return false
if (tag === current || (tag === 'any' && value.length === 1)) accepted = true
}
return accepted
}
/**
* Every packed tarball in the given directories, as `file:` dependency entries.
* Packed tarballs and their compatibility with the verification host, as `file:` dependency entries.
*
* The directories are read by their contents rather than a pack order file: a
* directory here can hold tarballs packed only to satisfy a cross-sequence
* dependency, which no release order describes.
* @param directories - absolute directories holding packed tarballs.
* @returns Package name to tarball file URL, and the version each carries.
* @returns Package name to tarball URL, version, and host compatibility.
*/
function packedDependencies(directories: readonly string[]): Map<string, { url: string; version: string }> {
const dependencies = new Map<string, { url: string; version: string }>()
function packedDependencies(directories: readonly string[]): Map<string, { url: string; version: string; compatible: boolean }> {
const dependencies = new Map<string, { url: string; version: string; compatible: boolean }>()
for (const directory of directories) {
const tarballs = readdirSync(directory).filter(name => name.endsWith('.tgz')).sort()
if (tarballs.length === 0) throw new Error(`${directory} holds no packed tarball`)
for (const filename of tarballs) {
const tarball = join(directory, filename)
const { name, version } = packedIdentity(tarball)
dependencies.set(name, { url: pathToFileURL(tarball).href, version })
const manifest = packedManifest(tarball)
const { name, version } = manifest
if (typeof name !== 'string' || typeof version !== 'string') throw new Error(`${tarball} manifest lacks name/version`)
const compatible = acceptsPlatform(manifest.os, process.platform) && acceptsPlatform(manifest.cpu, process.arch)
dependencies.set(name, { url: pathToFileURL(tarball).href, version, compatible })
}
}
return dependencies
}
/** Install every tarball under `--from` and drive the `--family` entry. */
/** Install platform-compatible tarballs under `--from` and drive the `--family` entry. */
function main(): void {
const { values } = parseArgs({
options: { family: { type: 'string' }, from: { type: 'string', multiple: true } },
@@ -96,7 +112,11 @@ function main(): void {
name: `dsh-packed-install-${family.id}`,
version: '0.0.0',
private: true,
dependencies: Object.fromEntries([...packed].map(([name, entryPacked]) => [name, entryPacked.url])),
dependencies: Object.fromEntries([...packed].filter(([, entryPacked]) => entryPacked.compatible)
.map(([name, entryPacked]) => [name, entryPacked.url])),
// Keep foreign-platform metadata local without turning those tarballs into required installations.
optionalDependencies: Object.fromEntries([...packed].filter(([, entryPacked]) => !entryPacked.compatible)
.map(([name, entryPacked]) => [name, entryPacked.url])),
}, null, 2)}\n`)
const environment = consumerEnvironment(consumerRoot)
+146 -1
View File
@@ -1,11 +1,18 @@
/** Recorded npm evidence stays intact while authored files and exact edits remain checked. */
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { exactEditState, isRescopeExcluded } from './rescope-vendor.ts'
import { exactEditState, isRescopeExcluded, rescopeText } from './rescope-vendor.ts'
const ANCHOR = '\n## Sync procedure'
const INSERTED = `\n15. **rescope**: one log entry.\n${ANCHOR}`
function expectRoundTrip(file: string, text: string, scoped: string, lines: number): void {
expect(rescopeText(text, file)).toEqual({ text: scoped, lines })
expect(rescopeText(scoped, file)).toEqual({ text: scoped, lines: 0 })
expect(rescopeText(scoped, file, true)).toEqual({ text, lines })
}
describe('rescope file selection', () => {
it('preserves the recorded npm resolution', () => {
expect(isRescopeExcluded('scripts/dependency-catalog/package-lock.json')).toBe(true)
@@ -51,3 +58,141 @@ describe('exactEditState', () => {
expect(exactEditState('x\n', 'a = 1', 'b = 2', 1)).toBe('invalid')
})
})
describe('product identifiers', () => {
it.each([
'apps/cli/tests/optional-bundle-transitions.e2e.ts',
'apps/web/tests/agent-preset-selection.e2e.ts',
'apps/web/tests/developer-tools-settings.e2e.ts',
'docs/subsystems/schedule.md',
'docs/subsystems/schedule.zh.md',
'docs/upgrade-guide/v0.2.0-rc.2/schedule-bundle-retired/guide.md',
'docs/upgrade-guide/v0.2.0-rc.2/schedule-bundle-retired/guide.zh.md',
'docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.md',
'docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.zh.md',
'docs/user/guide/schedule.md',
'docs/user/guide/schedule.zh.md',
'packages/bundle/web-app/cordis.patch.yml',
'packages/client/ui-agent-preset/src/client/CreatePluginMenuItem.tsx',
'packages/client/ui-agent-preset/src/client/analytics.ts',
'packages/client/ui-agent-preset/tests/analytics.client.spec.ts',
'packages/client/ui-agent-preset/tests/components.client.spec.tsx',
'packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx',
'packages/client/ui-agent-preset/tests/section-store.client.spec.ts',
'packages/client/ui-conversation/src/client/input/submission-analytics.ts',
'packages/client/ui-conversation/tests/service-orchestration.client.spec.ts',
'packages/client/ui-entry-customization/src/client/index.ts',
'packages/client/ui-entry-customization/tests/menu.client.spec.tsx',
'packages/client/ui-workspace/tests/workspaces-service.client.spec.ts',
'packages/extensions/cordis-host-runner/tests/inspect-registry.spec.ts',
'snapshots/session/cordis-inspect-liveness/client-fixture.mjs',
'snapshots/session/cordis-inspect-timeout/client-fixture.mjs',
])('preserves the product values in %s', (file) => {
const text = readFileSync(new URL(`../${file}`, import.meta.url), 'utf8')
expect(text).toMatch(/(['"`])cordis(?:\/inspect-query(?:-resolved)?)?\1/)
expect(isRescopeExcluded(file)).toBe(false)
expect(rescopeText(text, file)).toEqual({ text, lines: 0 })
})
it('still renames other libraries in a preset consumer and reverses them', () => {
const file = 'packages/client/ui-agent-preset/src/client/CreatePluginMenuItem.tsx'
const text = "const preset = 'cordis'\nimport utils from 'cosmokit'\nimport Schema from 'schemastery'\n"
const scoped = "const preset = 'cordis'\nimport utils from '@deepseek-ai/cosmokit'\nimport Schema from '@deepseek-ai/schemastery'\n"
expect(rescopeText(text, file)).toEqual({ text: scoped, lines: 2 })
expect(rescopeText(scoped, file)).toEqual({ text: scoped, lines: 0 })
expect(rescopeText(scoped, file, true)).toEqual({ text, lines: 2 })
})
it('keeps unlisted package references subject to residue detection', () => {
const file = 'packages/example/src/index.ts'
const text = "import { Context } from 'cordis'\nimport type Schema from 'schemastery'\n"
const scoped = "import { Context } from '@deepseek-ai/cordis'\nimport type Schema from '@deepseek-ai/schemastery'\n"
expect(rescopeText(text, file)).toEqual({ text: scoped, lines: 2 })
expect(rescopeText(scoped, file)).toEqual({ text: scoped, lines: 0 })
expect(rescopeText(scoped, file, true)).toEqual({ text, lines: 2 })
expect(rescopeText("ctx.emit('cordis/inspect-query')", file).text).toBe("ctx.emit('@deepseek-ai/cordis/inspect-query')")
})
it.each([
['import', "import { Context } from 'cordis'", "import { Context } from '@deepseek-ai/cordis'"],
['multiline import', "import {\n Context,\n} from 'cordis'", "import {\n Context,\n} from '@deepseek-ai/cordis'"],
['side-effect import', "import 'cordis'", "import '@deepseek-ai/cordis'"],
['export', "export { Context } from 'cordis/subpath'", "export { Context } from '@deepseek-ai/cordis/subpath'"],
['import equals', "import Cordis = require('cordis')", "import Cordis = require('@deepseek-ai/cordis')"],
['dynamic import', 'const cordis = import(`cordis`)', 'const cordis = import(`@deepseek-ai/cordis`)'],
['require', 'const cordis = require("cordis/subpath")', 'const cordis = require("@deepseek-ai/cordis/subpath")'],
['import type', "type Context = import('cordis').Context", "type Context = import('@deepseek-ai/cordis').Context"],
['module declaration', "declare module 'cordis' {}", "declare module '@deepseek-ai/cordis' {}"],
['JSDoc type', "/** @param {import('cordis').Context} ctx */\nfunction apply(ctx) {}", "/** @param {import('@deepseek-ai/cordis').Context} ctx */\nfunction apply(ctx) {}"],
['JSDoc import', "/** @import { Context } from 'cordis' */\nfunction apply(ctx) {}", "/** @import { Context } from '@deepseek-ai/cordis' */\nfunction apply(ctx) {}"],
['same-line imports', "import 'cordis'; import 'cordis/subpath'", "import '@deepseek-ai/cordis'; import '@deepseek-ai/cordis/subpath'"],
])('rewrites %s without changing same-file product data', (_label, source, target) => {
const file = 'packages/client/ui-agent-preset/src/client/CreatePluginMenuItem.tsx'
const product = "const preset = 'cordis'\nctx.emit('cordis/inspect-query')\nconst fixture = \"import 'cordis'\"\n"
const text = `${product}${source}\n`
const scoped = `${product}${target}\n`
expectRoundTrip(file, text, scoped, 1)
})
it.each([
['apps/cli/tests/optional-bundle-transitions.e2e.ts', 1],
['packages/client/ui-conversation/src/client/input/submission-analytics.ts', 1],
['packages/client/ui-conversation/tests/service-orchestration.client.spec.ts', 1],
['packages/client/ui-entry-customization/src/client/index.ts', 1],
['packages/client/ui-entry-customization/tests/menu.client.spec.tsx', 1],
['packages/client/ui-workspace/tests/workspaces-service.client.spec.ts', 1],
['packages/extensions/cordis-host-runner/tests/inspect-registry.spec.ts', 2],
['snapshots/session/cordis-inspect-liveness/client-fixture.mjs', 1],
['snapshots/session/cordis-inspect-timeout/client-fixture.mjs', 2],
] as const)('reverses real package references in %s and restores them', (file, lines) => {
const scoped = readFileSync(new URL(`../${file}`, import.meta.url), 'utf8')
const text = scoped.replaceAll("'@deepseek-ai/cordis'", "'cordis'")
expect(text).not.toBe(scoped)
expectRoundTrip(file, text, scoped, lines)
expect(rescopeText(text, file, true)).toEqual({ text, lines: 0 })
})
it('rewrites multiline fenced imports while preserving prose and fenced product data', () => {
const file = 'docs/subsystems/schedule.md'
const text = "The `cordis` preset.\n```ts\nconst preset = 'cordis'\nimport {\n Context,\n} from 'cordis'\n```\n"
const scoped = "The `cordis` preset.\n```ts\nconst preset = 'cordis'\nimport {\n Context,\n} from '@deepseek-ai/cordis'\n```\n"
expectRoundTrip(file, text, scoped, 1)
})
it.each(['cordis', "'cordis'", '"cordis/subpath"'])('rewrites the YAML name %s without changing product comments', (name) => {
const text = `# The 'cordis' preset.\n- name: ${name} # 'cordis' stays product data\n`
const scoped = `# The 'cordis' preset.\n- name: ${name.replace('cordis', '@deepseek-ai/cordis')} # 'cordis' stays product data\n`
for (const [file, wrap] of [
['packages/bundle/web-app/cordis.patch.yml', (value: string) => value],
['docs/subsystems/schedule.md', (value: string) => `The \`cordis\` preset.\n\`\`\`yaml\n${value}\`\`\`\n`],
] as const) {
expectRoundTrip(file, wrap(text), wrap(scoped), 1)
}
})
it.each(['scripts/rescope-vendor.spec.ts', 'scripts/gen-third-party-notices.spec.ts'])('keeps both package spellings in the fixture owner %s', (file) => {
const text = "import 'cordis'\nimport '@deepseek-ai/cordis'\nconst fixture = \"import 'cosmokit'\"\n"
expect(rescopeText(text, file)).toEqual({ text, lines: 0 })
expect(rescopeText(text, file, true)).toEqual({ text, lines: 0 })
})
it('keeps schema runtime identifiers while rewriting their same-file import', () => {
const file = 'vendor/loader/src/config/diff.ts'
const text = "const key = Symbol.for('schemastery')\nconst metadata = { vendor: 'schemastery' }\nimport Schema from 'schemastery'\n"
const scoped = "const key = Symbol.for('schemastery')\nconst metadata = { vendor: 'schemastery' }\nimport Schema from '@deepseek-ai/schemastery'\n"
expectRoundTrip(file, text, scoped, 1)
})
it('rewrites declarations in an older event-name exception', () => {
const file = 'packages/extensions/cordis-host-runner/src/index.ts'
const text = "ctx.emit('cordis/inspect-query')\nimport { Context } from 'cordis'\ndeclare module 'cordis' {}\n"
const scoped = "ctx.emit('cordis/inspect-query')\nimport { Context } from '@deepseek-ai/cordis'\ndeclare module '@deepseek-ai/cordis' {}\n"
expectRoundTrip(file, text, scoped, 2)
})
it.each(['```ts\n```\n', "```ts\nimport 'cordis'\n"])('keeps fence line counts without requiring a closing fence', (text) => {
const file = 'docs/subsystems/schedule.md'
const scoped = text.replace("import 'cordis'", "import '@deepseek-ai/cordis'")
expectRoundTrip(file, text, scoped, scoped === text ? 0 : 1)
})
})
+130 -34
View File
@@ -12,9 +12,9 @@
* `cordis.yml`, the Loader's `cordis:` builtin prefix, `cordis-config-entry`,
* `@deepseek-ai/dsh-tool-cordis`, and `cordiverse/cordis`, and makes the
* rewrite idempotent because the scoped name's `cordis` is preceded by `/`.
* Markdown follows the rename inside every fence, and in `docs/` prose too:
* a tutorial that teaches an unresolvable name is wrong, while prose elsewhere
* records what was true when it was written.
* Markdown follows the token rule in fences and `docs/` prose. Audited product
* names stay intact; their source module references and YAML plugin names still
* follow the rename. Prose elsewhere records what was true when it was written.
*
* Sites the token rule cannot express (dot-notation access, unquoted object
* keys, regex literals, the vendored-manifest table) are listed in
@@ -31,6 +31,7 @@ import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import ts from 'typescript'
const root = resolve(import.meta.dirname, '..')
@@ -66,13 +67,14 @@ interface ExactEdit {
}
/**
* A file where an upstream name also appears as a vendor DIRECTORY name or an
* upstream runtime identifier: the generic pass is disabled for the listed
* names and {@link EXACT_EDITS} renames the real package-name occurrences.
* A file where an upstream name also identifies runtime metadata, directories,
* presets, events or locale keys. Its module references and YAML plugin names
* still change; fixture owners retain both package spellings throughout.
*/
interface GenericSkip {
readonly file: string
readonly upstream: readonly string[]
readonly fixture?: true
}
const GENERIC_SKIPS: readonly GenericSkip[] = [
@@ -83,8 +85,10 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [
// Native schema detection and its fixture share the vendored runtime's Symbol.for identifier.
{ file: 'packages/boot/app-boot/src/config-schema/native.ts', upstream: ['schemastery'] },
{ file: 'packages/boot/app-boot/tests/config-schema.spec.ts', upstream: ['schemastery'] },
// The codemod's positive and negative fixtures contain both package spellings.
{ file: 'scripts/rescope-vendor.spec.ts', upstream: RENAMES.map(rename => rename.upstream), fixture: true },
// Asserts the vendored-manifest table, which gains an upstream-name column.
{ file: 'scripts/gen-third-party-notices.spec.ts', upstream: RENAMES.map(rename => rename.upstream) },
{ file: 'scripts/gen-third-party-notices.spec.ts', upstream: RENAMES.map(rename => rename.upstream), fixture: true },
// `cordis` is also an agent-preset id, so in these files the bare name is
// product data, not a package reference. Renaming it changed which preset
// the creator flow stages and which id the roster reports.
@@ -98,6 +102,30 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [
{ file: 'apps/cli/tests/profiles/web/tests/fixtures/creator-plugin-manager.mjs', upstream: ['cordis'] },
{ file: 'apps/web/tests/agent-preset-authoring.e2e.ts', upstream: ['cordis'] },
{ file: 'packages/preset/agent-preset-registry/tests/session.spec.ts', upstream: ['cordis'] },
// Preset selection, reminder policy and UI copy use the same product id.
{ file: 'apps/cli/tests/optional-bundle-transitions.e2e.ts', upstream: ['cordis'] },
{ file: 'apps/web/tests/agent-preset-selection.e2e.ts', upstream: ['cordis'] },
{ file: 'apps/web/tests/developer-tools-settings.e2e.ts', upstream: ['cordis'] },
{ file: 'docs/subsystems/schedule.md', upstream: ['cordis'] },
{ file: 'docs/subsystems/schedule.zh.md', upstream: ['cordis'] },
{ file: 'docs/upgrade-guide/v0.2.0-rc.2/schedule-bundle-retired/guide.md', upstream: ['cordis'] },
{ file: 'docs/upgrade-guide/v0.2.0-rc.2/schedule-bundle-retired/guide.zh.md', upstream: ['cordis'] },
{ file: 'docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.md', upstream: ['cordis'] },
{ file: 'docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.zh.md', upstream: ['cordis'] },
{ file: 'docs/user/guide/schedule.md', upstream: ['cordis'] },
{ file: 'docs/user/guide/schedule.zh.md', upstream: ['cordis'] },
{ file: 'packages/bundle/web-app/cordis.patch.yml', upstream: ['cordis'] },
{ file: 'packages/client/ui-agent-preset/src/client/CreatePluginMenuItem.tsx', upstream: ['cordis'] },
{ file: 'packages/client/ui-agent-preset/src/client/analytics.ts', upstream: ['cordis'] },
{ file: 'packages/client/ui-agent-preset/tests/analytics.client.spec.ts', upstream: ['cordis'] },
{ file: 'packages/client/ui-agent-preset/tests/components.client.spec.tsx', upstream: ['cordis'] },
{ file: 'packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx', upstream: ['cordis'] },
{ file: 'packages/client/ui-agent-preset/tests/section-store.client.spec.ts', upstream: ['cordis'] },
{ file: 'packages/client/ui-conversation/src/client/input/submission-analytics.ts', upstream: ['cordis'] },
{ file: 'packages/client/ui-conversation/tests/service-orchestration.client.spec.ts', upstream: ['cordis'] },
{ file: 'packages/client/ui-entry-customization/src/client/index.ts', upstream: ['cordis'] },
{ file: 'packages/client/ui-entry-customization/tests/menu.client.spec.tsx', upstream: ['cordis'] },
{ file: 'packages/client/ui-workspace/tests/workspaces-service.client.spec.ts', upstream: ['cordis'] },
// The preset-roster loop names the `cordis` preset id, not a package.
{ file: 'apps/cli/tests/windows-shell.spec.ts', upstream: ['cordis'] },
// GROUP_ORDER holds `packages/<group>/` directory names, not package names.
@@ -124,6 +152,10 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [
{ file: 'packages/extensions/tool-cordis/src/providers.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/ui-cordis/src/client/index.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/ui-cordis/src/client/inventory.ts', upstream: ['cordis'] },
// Inspector fixtures use the same extension event domain.
{ file: 'packages/extensions/cordis-host-runner/tests/inspect-registry.spec.ts', upstream: ['cordis'] },
{ file: 'snapshots/session/cordis-inspect-liveness/client-fixture.mjs', upstream: ['cordis'] },
{ file: 'snapshots/session/cordis-inspect-timeout/client-fixture.mjs', upstream: ['cordis'] },
// `cordis/tree` is an Inspector observation topic, not a package subpath.
{ file: 'packages/experimental/inspector/src/shared/bridge/messages/cordis.ts', upstream: ['cordis'] },
{ file: 'packages/experimental/inspector/tests/cordis-query.host.spec.ts', upstream: ['cordis'] },
@@ -261,7 +293,7 @@ const EXACT_EDITS: readonly ExactEdit[] = [
* identity to share — the framework itself is a requested module-table row
* (external), while these are ordinary libraries a browser bundle inlines.
*/
const VENDORED_LIBRARY = /^@deepseek-ai\\/(cosmokit|schemastery)(\\/|$)/
const VENDORED_LIBRARY = /^@deepseek-ai\\/(?:(?:cosmokit|schemastery)(?:\\/|$)|cordis-plugin-timer$)/
/** Generated descriptor/codec contribution with no shared runtime identity. */`,
expect: 1,
@@ -470,7 +502,7 @@ function patterns(reverse: boolean): Pattern[] {
.map(rename => ({
...rename,
token: new RegExp(`(['"\`])${escapeRegExp(rename.from)}((?:/[^'"\`\\s]*)?)\\1`, 'g'),
yamlName: new RegExp(`^(\\s*(?:-\\s*)?name:[ \\t]+)${escapeRegExp(rename.from)}([ \\t]*(?:#.*)?)$`, 'gm'),
yamlName: new RegExp(`^(\\s*(?:-\\s*)?name:[ \\t]+)(['"]?)${escapeRegExp(rename.from)}((?:/[^\\s'"]*)?)\\2([ \\t]*(?:#.*)?)$`, 'gm'),
}))
}
@@ -478,45 +510,109 @@ function skipped(file: string, pattern: Pattern): boolean {
return GENERIC_SKIPS.some(skip => skip.file === file && skip.upstream.includes(pattern.upstream))
}
function rewriteLine(line: string, file: string, all: readonly Pattern[]): string {
function rewriteLine(line: string, file: string, all: readonly Pattern[], yaml = false): string {
let out = line
for (const pattern of all) {
if (skipped(file, pattern)) continue
out = out.replace(pattern.token, (_match, quote: string, subpath: string) => `${quote}${pattern.to}${subpath}${quote}`)
out = out.replace(pattern.yamlName, (_match, prefix: string, suffix: string) => `${prefix}${pattern.to}${suffix}`)
const preserve = skipped(file, pattern)
if (!preserve) out = out.replace(pattern.token, (_match, quote: string, subpath: string) => `${quote}${pattern.to}${subpath}${quote}`)
if (!preserve || yaml) {
out = out.replace(pattern.yamlName, (_match, prefix: string, quote: string, subpath: string, suffix: string) => `${prefix}${quote}${pattern.to}${subpath}${quote}${suffix}`)
}
}
return out
}
/** Rewrite only module literals whose other same-file uses are product data. */
function rewriteModules(text: string, file: string, all: readonly Pattern[], sourceName = file): string {
const skip = GENERIC_SKIPS.find(entry => entry.file === file)
if (!skip || skip.fixture) return text
const active = all.filter(pattern => skip.upstream.includes(pattern.upstream))
const source = ts.createSourceFile(sourceName, text, ts.ScriptTarget.Latest, true)
const edits = new Map<number, { end: number; value: string }>()
const visited = new Set<ts.Node>()
const visit = (node: ts.Node): void => {
if (visited.has(node)) return
visited.add(node)
let literal: ts.Node | undefined
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node) || ts.isJSDocImportTag(node)) {
literal = node.moduleSpecifier
} else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
literal = node.moduleReference.expression
} else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
literal = node.argument.literal
} else if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === 'require'))) {
literal = node.arguments[0]
} else if (ts.isModuleDeclaration(node)) {
literal = node.name
}
if (literal && ts.isStringLiteralLike(literal)) {
const start = literal.getStart(source) + 1
const end = literal.getEnd() - 1
const value = text.slice(start, end)
const pattern = active.find(entry => value === entry.from || value.startsWith(`${entry.from}/`))
if (pattern) edits.set(start, { end, value: pattern.to + value.slice(pattern.from.length) })
}
ts.forEachChild(node, visit)
for (const doc of ts.getJSDocCommentsAndTags(node)) visit(doc)
}
visit(source)
let out = text
for (const [start, edit] of [...edits].sort(([left], [right]) => right - left)) {
out = out.slice(0, start) + edit.value + out.slice(edit.end)
}
return out
}
/**
* Rewrite a file's eligible lines.
*
* Markdown splits in two. Every fence is code a reader copies or a
* configuration they mount, so every fence follows the rename regardless of its
* info string. Prose follows it only under `docs/`, where a sentence quoting
* `` `cordis` `` teaches an unresolved package name; elsewhere
* prose is a record of what was true when it was written, and the same spelling
* can mean something else entirely — the Python SDK's `cordis` option, or the
* unvendored `@cordisjs/plugin-http`.
* Rewrite eligible source, fences and docs prose. Audited names in source and
* TypeScript/JavaScript fences change only in module syntax; YAML names remain
* eligible too. Other names retain the token rule. Prose outside docs is kept.
*/
function rewrite(text: string, file: string, all: readonly Pattern[]): { text: string; lines: number } {
const markdown = file.endsWith('.md')
const prose = markdown && file.startsWith('docs/')
let insideFence = false
let lines = 0
const out = text.split('\n').map((line) => {
const original = text.split('\n')
let source = text
if (!markdown && /\.(ts|tsx|js|mjs|cjs|tpl)$/.test(file)) source = rewriteModules(text, file, all)
let fenceStart: number | undefined
let language = ''
const out = source.split('\n')
const finishFence = (end: number): void => {
if (fenceStart === undefined || fenceStart === end || !/^(?:[cm]?[jt]sx?|typescript|javascript)?$/.test(language)) return
const extension = language === 'jsx' || language === 'tsx' ? 'tsx' : language === 'js' || language === 'javascript' || language === 'mjs' || language === 'cjs' ? 'js' : 'ts'
const body = out.slice(fenceStart, end).join('\n')
out.splice(fenceStart, end - fenceStart, ...rewriteModules(body, file, all, `fence.${extension}`).split('\n'))
}
for (const [index, line] of out.entries()) {
if (markdown) {
if (/^\s*```/.test(line)) {
insideFence = !insideFence
return line
const fence = /^\s*```(\S*)/.exec(line)
if (fence) {
if (fenceStart === undefined) {
fenceStart = index + 1
language = fence[1] ?? ''
} else {
finishFence(index)
fenceStart = undefined
}
continue
}
if (!insideFence && !prose) return line
if (fenceStart === undefined && !prose) continue
}
const next = rewriteLine(line, file, all)
if (next !== line) lines += 1
return next
})
return { text: out.join('\n'), lines }
out[index] = rewriteLine(line, file, all, /\.ya?ml$/.test(file) || (fenceStart !== undefined && (language === 'yaml' || language === 'yml')))
}
if (fenceStart !== undefined) finishFence(out.length)
return { text: out.join('\n'), lines: out.filter((line, index) => line !== original[index]).length }
}
/**
* Apply the CLI's generic package rename with the audited name exceptions.
* @param text - Complete source text.
* @param file - Repository-relative path with forward slash separators.
* @param reverse - Restore upstream package names when true.
* @returns Rewritten text and the number of changed lines.
*/
export function rescopeText(text: string, file: string, reverse = false): { text: string; lines: number } {
return rewrite(text, file, patterns(reverse))
}
function classify(file: string): string {
+26 -7
View File
@@ -370,12 +370,22 @@ describe('gate graph validation', () => {
expect(ids).toEqual([
'rescope-vendor', 'publint', 'constraints', 'default-product-isolation', 'product-use', 'official-bundle-catalog', 'provider-data', 'package-dependencies', 'application-entrypoints',
'dsh-package-licenses', 'node-next-types',
'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'client-route-resolution', 'no-bare-dispatcher',
'plugin-record-callers', 'no-unknown-casts',
'optional-dependency-imports', 'client-packages', 'installer-locales', 'locale-metadata', 'client-ui-i18n', 'client-route-resolution', 'no-bare-dispatcher',
'plugin-record-callers', 'no-unknown-casts', 'no-top-level-await',
'cordis-config', 'runtime-closure',
])
})
it('includes JSON resource checks in local and CI aggregates', () => {
for (const mode of ['i18n', 'check-all', 'ci-static', 'hygiene'] as const) {
const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(gate => gate.id))
expect(ids).toEqual(expect.arrayContaining(['installer-locales', 'locale-metadata']))
if (mode !== 'i18n') expect(ids).not.toContain('locale-dictionaries')
}
expect(withPnpmEntrypoint(() => gatesForMode('i18n').map(gate => gate.id)))
.toEqual(['installer-locales', 'locale-metadata', 'locale-dictionaries'])
})
it('caps the local hygiene aggregate at eight workers', () => {
const ids = withPnpmEntrypoint(() => gatesForMode('hygiene').map(subject => subject.id))
@@ -846,7 +856,7 @@ describe('Typert contract preparation', () => {
})
describe('Node compatibility graph', () => {
it.each(['22.19.0', '24.9.0', '26.0.0'])('runs all source compatibility smokes serially on Node %s', (version) => {
it.each(['24.9.0', '24.19.0', '26.0.0'])('runs all source compatibility smokes serially on Node %s', (version) => {
const subject = withNodeVersion(version, () =>
withPnpmEntrypoint(() => gatesForMode('node-compat')))
@@ -861,6 +871,7 @@ describe('Node compatibility graph', () => {
'packages/session/session-persistence-jsonl/tests/zstd.compat.spec.ts',
'apps/cli/tests/source-launch.compat.spec.ts',
'scripts/vitest-environment.compat.spec.ts',
'scripts/command-entrypoints.compat.spec.ts',
'packages/boot/app-boot/tests/profile-resolution.spec.ts',
'packages/boot/app-boot/tests/profile-resolution-service.spec.ts',
'packages/boot/app-boot/tests/profile-resolution-worker-bootstrap.spec.ts',
@@ -878,8 +889,16 @@ describe('Node compatibility graph', () => {
expect(subject.find(item => item.id === 'source-compat-smokes')?.env).toEqual({ VITEST_MAX_WORKERS: '1' })
})
it.each([undefined, '1'])('builds the complete Node 22 artifacts once with skip-typecheck=%s', (skipTypecheck) => {
const subject = withNodeVersion('22.19.0', () =>
it.each(['24.19.0', '26.0.0'])('keeps Node %s compatibility source-only', (version) => {
const subject = withNodeVersion(version, () =>
withPnpmEntrypoint(() => gatesForMode('node-compat')))
expect(subject.map(item => item.id)).not.toContain('build')
expect(subject.map(item => item.id)).not.toContain('cli-lazy-search-startup-smoke')
})
it.each([undefined, '1'])('builds the complete Node 24.9 artifacts once with skip-typecheck=%s', (skipTypecheck) => {
const subject = withNodeVersion('24.9.0', () =>
withEnv('DSH_NODE_COMPAT_SKIP_TYPECHECK', skipTypecheck, () =>
withPnpmEntrypoint(() => gatesForMode('node-compat'))))
@@ -894,8 +913,8 @@ describe('Node compatibility graph', () => {
})
})
it.each(['passed', 'failed'] as const)('runs the Node 22 lazy-search smoke only after a passed build (%s)', async (buildStatus) => {
const subject = withNodeVersion('22.19.0', () =>
it.each(['passed', 'failed'] as const)('runs the Node 24.9 lazy-search smoke only after a passed build (%s)', async (buildStatus) => {
const subject = withNodeVersion('24.9.0', () =>
withEnv('DSH_NODE_COMPAT_SKIP_TYPECHECK', '1', () =>
withPnpmEntrypoint(() => gatesForMode('node-compat'))))
let buildCompleted = false
+20 -11
View File
@@ -35,6 +35,7 @@ export type Mode =
| 'hygiene'
| 'doc-sync'
| 'doc-quick'
| 'i18n'
type GateResultStatus = 'passed' | 'failed' | 'skipped'
type GateState = 'pending' | 'running' | GateResultStatus
@@ -95,6 +96,9 @@ type ResultObserver = (result: GateResult) => void
/** Worker cap of the documentation and hygiene aggregates, locally and in CI, whose leaves each build a full ts.Program. */
const PROGRAM_MODE_CAP = 8
/** CI pins this minor in .github/workflows/ci.yml; its compatibility leg owns the built CLI smoke. */
export const BUILD_BACKED_NODE_COMPAT_VERSION = '24.9'
const root = resolve(import.meta.dirname, '..')
if (import.meta.main) {
process.exitCode = await main(process.argv.slice(2))
@@ -166,11 +170,12 @@ function parseMode(raw: string | undefined): Mode {
case 'check-all':
case 'hygiene':
case 'doc-sync':
case 'i18n':
case 'doc-quick':
return raw
default:
throw new Error(
`run-gates: expected mode ci-primary | ci-linux-primary | ci-static | ci-lint-contracts-ready | ci-coverage | ci-unit | ci-bench | ci-snapshot | ci-artifacts | ci-consumers | ci-windows-blocking | ci-windows-complete | ci-windows-observational-ready | node-compat | check-all | hygiene | doc-sync | doc-quick, got ${JSON.stringify(raw)}.`,
`run-gates: expected mode ci-primary | ci-linux-primary | ci-static | ci-lint-contracts-ready | ci-coverage | ci-unit | ci-bench | ci-snapshot | ci-artifacts | ci-consumers | ci-windows-blocking | ci-windows-complete | ci-windows-observational-ready | node-compat | check-all | hygiene | doc-sync | doc-quick | i18n, got ${JSON.stringify(raw)}.`,
)
}
}
@@ -360,6 +365,8 @@ export function gatesForMode(selected: Mode): Gate[] {
return docSyncLeafGates()
case 'doc-quick':
return docQuickLeafGates()
case 'i18n':
return i18nGates(true)
}
}
@@ -382,17 +389,27 @@ function ciSharedStaticGates(): Gate[] {
]
}
function i18nGates(includeDictionaries: boolean): Gate[] {
return [
pnpmScript('installer-locales', 'verify-installer-locales', { label: 'Windows installer locales' }),
pnpmScript('locale-metadata', 'verify-locale-metadata', { label: 'Japanese plugin metadata' }),
...includeDictionaries ? [pnpmExec('locale-dictionaries', ['vitest', 'run', 'scripts/locale-dictionary-parity.spec.ts'], { label: 'locale dictionaries' })] : [],
]
}
function sharedHygieneGates(): Gate[] {
return [
pnpmScript('optional-dependency-imports', 'verify-optional-dependency-imports', {
label: 'optional dependency imports',
}),
pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }),
...i18nGates(false),
pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }),
pnpmScript('client-route-resolution', 'verify-client-route-resolution', { label: 'client route resolution' }),
pnpmScript('no-bare-dispatcher', 'verify-no-bare-dispatcher', { label: 'proxy-aware dispatchers' }),
pnpmScript('plugin-record-callers', 'verify-plugin-record-callers', { label: 'plugin record callers' }),
pnpmScript('no-unknown-casts', 'verify-no-unknown-casts', { label: 'no new unknown casts' }),
pnpmScript('no-top-level-await', 'verify-no-top-level-await', { label: 'no module-scope await' }),
]
}
@@ -428,7 +445,7 @@ function nodeCompatGates(): Gate[] {
const typecheck = flagEnabled('DSH_NODE_COMPAT_SKIP_TYPECHECK')
? []
: [pnpmScript('typecheck', 'typecheck')]
if (runningNodeMajor() !== 22) {
if (!process.versions.node.startsWith(`${BUILD_BACKED_NODE_COMPAT_VERSION}.`)) {
return [...typecheck, ...nodeCompatSmokeGates()]
}
return [
@@ -451,6 +468,7 @@ function nodeCompatSmokeGates(options: { cliSmoke?: boolean } = {}): Gate[] {
'packages/session/session-persistence-jsonl/tests/zstd.compat.spec.ts',
'apps/cli/tests/source-launch.compat.spec.ts',
'scripts/vitest-environment.compat.spec.ts',
'scripts/command-entrypoints.compat.spec.ts',
'packages/boot/app-boot/tests/profile-resolution.spec.ts',
'packages/boot/app-boot/tests/profile-resolution-service.spec.ts',
'packages/boot/app-boot/tests/profile-resolution-worker-bootstrap.spec.ts',
@@ -474,15 +492,6 @@ function nodeCompatSmokeGates(options: { cliSmoke?: boolean } = {}): Gate[] {
return gates
}
/** Active Node major used to select version-specific compatibility checks. */
function runningNodeMajor(): number {
const major = Number.parseInt(process.versions.node.split('.')[0] ?? '', 10)
if (!Number.isSafeInteger(major)) {
throw new Error(`run-gates: cannot parse Node version ${JSON.stringify(process.versions.node)}.`)
}
return major
}
function ciStaticGates(options: { ownsBuild: boolean }): Gate[] {
return [
...ciSharedStaticGates(),
+44 -12
View File
@@ -75,21 +75,26 @@ SPAWN_NODE_COMMAND = (
)
LEGACY_CUSTOM_DISABLED_ROWS = (
"agent-instructions",
"deliverable-file-references",
"file-reference-local",
"goal",
"goal-round-driver",
"command-goal",
"plan-mode",
"present",
"skill",
"skill-filesystem",
"skill-office",
"workspace-dependencies",
"time-context",
"tool-ask-user",
"tool-fs",
"tool-fs-search",
"tool-goal",
"tool-schedule",
"tool-skill",
"tool-str-replace-editor",
"tool-subagent-control",
"tool-subagent-list-agents",
"tool-subagent-fork",
"tool-todo",
"tool-web",
@@ -145,7 +150,7 @@ RESTART_SNAPSHOT_DIRECTORY = (
Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "restart"
)
RESTART_SNAPSHOT_FILENAMES = (
"result.json", "requests.json", "session.1.v4.jsonl", "session.2.v4.jsonl",
"result.json", "requests.json", "session.1.v4.jsonl", "session.2.v4.jsonl", "resume.json",
)
RECOVERY_SNAPSHOT_DIRECTORY = (
Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "scheduler-recovery"
@@ -253,7 +258,8 @@ def write_advanced_profile_patch(
) -> Path:
"""Write the shared custom, snapshot, and restart profile patch."""
return write_profile_patch(root, name, sessions, [
{"id": "tools", "config": {"mode": mode}},
{"id": "tool-executor-native", "disabled": mode != "native"},
{"id": "tool-executor-ptc", "disabled": mode != "ptc"},
{
"id": "system-prompt",
"config": {
@@ -916,7 +922,16 @@ def _smoke_sdk_authoring_cached(base_url: str, executable: Path, update_snapshot
skill.parent.mkdir(parents=True)
skill.write_text("---\nname: office-docx\ndescription: CUSTOM_OFFICE_DOCX\n---\nUse the bundled Python for custom document work.\n")
patch = root / "skills.patch.yml"
patch.write_text(json.dumps([{"id": "skill-office", "disabled": True}] if mode == "disabled" else []))
# Fix the model-visible clock in this child only; native timers stay live.
patch_rows = [
{"insert": [{"id": "authoring-clock", "name": (
Path(__file__).resolve().parent / "fixtures/python-sdk-authoring-clock.mjs"
).as_uri()}]},
{"id": "time-context", "config": {"timeZone": "UTC"}},
]
if mode == "disabled":
patch_rows.append({"id": "skill-office", "disabled": True})
patch.write_text(json.dumps(patch_rows))
first = len(MockModelHandler.requests)
with DeepSeekHarness(
provider="deepseek-official", model="smoke-model", cwd=str(root),
@@ -1747,10 +1762,10 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots
patch = write_advanced_profile_patch(root, "restart.patch.yml", sessions)
first_request = len(MockModelHandler.requests)
def connect() -> DeepSeekHarness:
def connect(model: str = "smoke-model") -> DeepSeekHarness:
return DeepSeekHarness(
provider="deepseek-official",
model="smoke-model",
model=model,
cwd=str(root),
dsh_bin=str(executable),
dsh_home=str(dsh_home),
@@ -1798,13 +1813,8 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots
root,
sessions,
)
compare_snapshot_files(
files, update_snapshots, RESTART_SNAPSHOT_DIRECTORY, RESTART_SNAPSHOT_FILENAMES,
native_writer_output=True,
)
before_resume = logs[RESTART_FIRST_SESSION_ID]
with connect() as harness:
with connect("resumed-smoke-model") as harness:
session = harness.resume_session(RESTART_FIRST_SESSION_ID)
if session.id != RESTART_FIRST_SESSION_ID:
raise AssertionError(f"resume changed the saved identity: {session.id}")
@@ -1842,6 +1852,28 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots
if RESUME_TEXT not in render_jsonl(restored):
raise AssertionError("resumed response was not appended to the saved session")
selection_events = [
{"type": record["type"], "data": record["data"]}
for record in restored[len(before_resume):]
if record.get("type") == "model/selection"
]
if selection_events != [{"type": "model/selection", "data": {
"provider": "deepseek-official", "model": "resumed-smoke-model",
}}]:
raise AssertionError(f"resume did not persist the initialized model: {selection_events}")
if requests[0].get("model") != "resumed-smoke-model":
raise AssertionError(f"resumed request ignored the initialized model: {requests[0]}")
files["resume.json"] = json.dumps({
"model": requests[0].get("model"),
"final_response": resumed.final_response,
"eventTypes": [event.get("type") for event in resumed.events],
"selectionEvents": selection_events,
}, indent=2, ensure_ascii=False) + "\n"
compare_snapshot_files(
files, update_snapshots, RESTART_SNAPSHOT_DIRECTORY, RESTART_SNAPSHOT_FILENAMES,
native_writer_output=True,
)
def smoke_sdk_scheduler_recovery(base_url: str, executable: Path, update_snapshots: bool) -> None:
"""Keep a failed tool turn usable through the real Messages serializer and SDK."""
@@ -2580,6 +2580,70 @@
"childSessionId": "{{child-1}}"
}
},
{
"method": "session.event",
"payload": {
"sessionId": "{{child-1}}",
"event": {
"type": "subagent/descriptor",
"seq": 0,
"time": 0,
"data": {
"version": 3,
"mode": "continuable",
"provider": "spawn",
"label": "Check direct child",
"agentProvider": "deepseek-official",
"agentModel": "smoke-model",
"agentReasoningEffort": "high"
}
}
}
},
{
"method": "session.event",
"payload": {
"sessionId": "{{child-1}}",
"event": {
"type": "sandbox/mode",
"seq": 1,
"time": 0,
"data": {
"mode": "danger-full-access",
"source": "delegation"
}
}
}
},
{
"method": "session.event",
"payload": {
"sessionId": "{{child-1}}",
"event": {
"type": "approval/policy",
"seq": 2,
"time": 0,
"data": {
"policy": "never",
"source": "delegation"
}
}
}
},
{
"method": "session.event",
"payload": {
"sessionId": "{{child-1}}",
"event": {
"type": "permission/preset",
"seq": 3,
"time": 0,
"data": {
"preset": "danger-full-access"
}
}
}
},
{
"method": "session.event",
"payload": {
@@ -3405,6 +3469,70 @@
"childSessionId": "{{child-2}}"
}
},
{
"method": "session.event",
"payload": {
"sessionId": "{{child-2}}",
"event": {
"type": "subagent/descriptor",
"seq": 0,
"time": 0,
"data": {
"version": 3,
"mode": "continuable",
"provider": "spawn",
"label": "advanced-exe-snapshot child 1",
"agentProvider": "deepseek-official",
"agentModel": "smoke-model",
"agentReasoningEffort": "high"
}
}
}
},
{
"method": "session.event",
"payload": {
"sessionId": "{{child-2}}",
"event": {
"type": "sandbox/mode",
"seq": 1,
"time": 0,
"data": {
"mode": "danger-full-access",
"source": "delegation"
}
}
}
},
{
"method": "session.event",
"payload": {
"sessionId": "{{child-2}}",
"event": {
"type": "approval/policy",
"seq": 2,
"time": 0,
"data": {
"policy": "never",
"source": "delegation"
}
}
}
},
{
"method": "session.event",
"payload": {
"sessionId": "{{child-2}}",
"event": {
"type": "permission/preset",
"seq": 3,
"time": 0,
"data": {
"preset": "danger-full-access"
}
}
}
},
{
"method": "session.event",
"payload": {
@@ -8,7 +8,7 @@
}
},
"catalogs": [
"Query the packaged Python environment for Office authoring.Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\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`).<system-reminder>\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n<available_skills>\n- `office`: Create, edit, inspect, or convert Office documents, spreadsheets, presentations, and PDFs. Load to choose the relevant workflow.\n- `office-docx`: Create, read, edit, and validate Word documents (.docx).\n- `office-export-pdf`: Convert an Office document to a requested PDF with LibreOffice Kit.\n- `office-pptx`: Create, read, edit, and validate PowerPoint presentations (.pptx).\n- `office-recalculate`: Refresh spreadsheet formula caches and verify calculated values with LibreOffice Kit.\n- `office-render`: Render requested document images or inspect visual layout with LibreOffice Kit.\n- `office-xlsx`: Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx).\n</available_skills>\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n</system-reminder>"
"Query the packaged Python environment for Office authoring.Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\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`).<system-reminder>\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n<available_skills>\n- `office`: Create, edit, inspect, or convert Office documents, spreadsheets, presentations, and PDFs. Load to choose the relevant workflow.\n- `office-docx`: Create, read, edit, and validate Word documents (.docx).\n- `office-export-pdf`: Convert an Office document to a requested PDF with LibreOffice Kit.\n- `office-pptx`: Create, read, edit, and validate PowerPoint presentations (.pptx).\n- `office-recalculate`: Refresh spreadsheet formula caches and verify calculated values with LibreOffice Kit.\n- `office-render`: Render requested document images or inspect visual layout with LibreOffice Kit.\n- `office-xlsx`: Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx).\n</available_skills>\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n</system-reminder>Time sampled while preparing turn 1, step 1: 2026-10-16T00:00:00+00:00[UTC]\nBrowser time zone for this request: unavailable. Ask the user to clarify otherwise-unqualified dates and times.\nElapsed since the preceding model-visible message: unavailable."
],
"result": {
"python": "{{python}}",
@@ -8,7 +8,7 @@
}
},
"catalogs": [
"Query the packaged Python environment for Office authoring.Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\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`).<system-reminder>\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n<available_skills>\n- `diagnose-windows-sandbox-acl`: Use on Windows for unexpected DSH sandbox access denials: workspace writes or listing fail, or an ordinarily readable path cannot be read. One bundled command inspects the path and every ancestor and repairs the ACL problems it proves in that same run. Expected confinement denials need no ACL repair.\n- `office`: Create, edit, inspect, or convert Office documents, spreadsheets, presentations, and PDFs. Load to choose the relevant workflow.\n- `office-docx`: Create, read, edit, and validate Word documents (.docx).\n- `office-export-pdf`: Convert an Office document to a requested PDF with LibreOffice Kit.\n- `office-pptx`: Create, read, edit, and validate PowerPoint presentations (.pptx).\n- `office-recalculate`: Refresh spreadsheet formula caches and verify calculated values with LibreOffice Kit.\n- `office-render`: Render requested document images or inspect visual layout with LibreOffice Kit.\n- `office-xlsx`: Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx).\n</available_skills>\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n</system-reminder>"
"Query the packaged Python environment for Office authoring.Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\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`).<system-reminder>\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n<available_skills>\n- `diagnose-windows-sandbox-acl`: Use on Windows for unexpected DSH sandbox access denials: workspace writes or listing fail, or an ordinarily readable path cannot be read. One bundled command inspects the path and every ancestor and repairs the ACL problems it proves in that same run. Expected confinement denials need no ACL repair.\n- `office`: Create, edit, inspect, or convert Office documents, spreadsheets, presentations, and PDFs. Load to choose the relevant workflow.\n- `office-docx`: Create, read, edit, and validate Word documents (.docx).\n- `office-export-pdf`: Convert an Office document to a requested PDF with LibreOffice Kit.\n- `office-pptx`: Create, read, edit, and validate PowerPoint presentations (.pptx).\n- `office-recalculate`: Refresh spreadsheet formula caches and verify calculated values with LibreOffice Kit.\n- `office-render`: Render requested document images or inspect visual layout with LibreOffice Kit.\n- `office-xlsx`: Read, create, edit, and validate Excel workbooks, data, and formulas (.xlsx).\n</available_skills>\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n</system-reminder>Time sampled while preparing turn 1, step 1: 2026-10-16T00:00:00+00:00[UTC]\nBrowser time zone for this request: unavailable. Ask the user to clarify otherwise-unqualified dates and times.\nElapsed since the preceding model-visible message: unavailable."
],
"result": {
"python": "{{python}}",
@@ -30,7 +30,7 @@
"events": [
{
"type": "request/header",
"seq": 11,
"seq": 12,
"time": 0,
"data": {
"header": {
@@ -45,9 +45,9 @@
"maxTokens": true
},
"tools": [
"ask_user_question",
"create_goal",
"edit",
"exit_plan_mode",
"get_goal",
"glob",
"grep",
@@ -56,8 +56,13 @@
"job_list",
"job_output",
"list_agents",
"present",
"read",
"read_image",
"schedule_create",
"schedule_delete",
"schedule_list",
"schedule_update",
"send_message",
"skill",
"subagent",
@@ -76,7 +81,7 @@
},
{
"type": "request/context",
"seq": 12,
"seq": 13,
"time": 0,
"data": {
"provider": "deepseek-official",
@@ -86,7 +91,7 @@
},
{
"type": "request/header",
"seq": 19,
"seq": 20,
"time": 0,
"data": {
"header": {
@@ -101,9 +106,9 @@
"maxTokens": true
},
"tools": [
"ask_user_question",
"create_goal",
"edit",
"exit_plan_mode",
"get_goal",
"glob",
"grep",
@@ -112,8 +117,13 @@
"job_list",
"job_output",
"list_agents",
"present",
"read",
"read_image",
"schedule_create",
"schedule_delete",
"schedule_list",
"schedule_update",
"send_message",
"skill",
"snapshot_ping",
@@ -133,7 +143,7 @@
},
{
"type": "developer/message",
"seq": 20,
"seq": 21,
"time": 0,
"data": {
"turn": 1,
@@ -157,7 +167,7 @@
},
{
"type": "request/header",
"seq": 26,
"seq": 27,
"time": 0,
"data": {
"header": {
@@ -172,9 +182,9 @@
"maxTokens": true
},
"tools": [
"ask_user_question",
"create_goal",
"edit",
"exit_plan_mode",
"get_goal",
"glob",
"grep",
@@ -183,8 +193,13 @@
"job_list",
"job_output",
"list_agents",
"present",
"read",
"read_image",
"schedule_create",
"schedule_delete",
"schedule_list",
"schedule_update",
"send_message",
"skill",
"subagent",
@@ -203,7 +218,7 @@
},
{
"type": "developer/message",
"seq": 27,
"seq": 28,
"time": 0,
"data": {
"turn": 1,
@@ -0,0 +1,26 @@
{
"model": "resumed-smoke-model",
"final_response": "RESUMED_PROCESS_OK",
"eventTypes": [
"agent/inbox/spliced",
"turn/start",
"agent/inbox/spliced",
"step/start",
"user/message",
"request/header",
"request/context",
"session-log-deepseek/delivery-accepted",
"assistant/message",
"step/end",
"turn/end"
],
"selectionEvents": [
{
"type": "model/selection",
"data": {
"provider": "deepseek-official",
"model": "resumed-smoke-model"
}
}
]
}
+14 -14
View File
@@ -4,7 +4,8 @@ import { spawn } from 'node:child_process'
import { Duplex, type Readable, type Writable } from 'node:stream'
import { join } from 'node:path'
import { z } from 'zod'
import { JsonChannel } from '../../packages/ptc-runtime/ptc-runtime-node/src/channel.ts'
import { createProcessRpc } from '../../packages/ptc-runtime/ptc-runtime-node/src/rpc.ts'
import type { HostMethods, ProgramMethods } from '../../packages/ptc-runtime/ptc-runtime-node/src/protocol.ts'
import { decodePtcJsonWire } from '../../packages/ptc-runtime/ptc-runtime-node/src/json-wire.ts'
import { SUBPROCESS_CONTROL_ENV, SUBPROCESS_CONTROL_FD } from '../../packages/subprocess/subprocess/src/control.ts'
@@ -40,18 +41,17 @@ export async function probeRestrictedWorker(
const streams: ReadonlyArray<Readable | Writable | null | undefined> = child.stdio
const control = streams[SUBPROCESS_CONTROL_FD]
assert(control instanceof Duplex)
const channel = new JsonChannel(control, 1_048_576, (raw) => {
const message = z.object({ type: z.string(), value: z.unknown().optional(), error: z.unknown().optional() }).parse(raw)
if (message.type === 'ready') {
const code = `const fs=await import('node:fs/promises'); const denied=[]; for(const path of ${JSON.stringify([join(checkout, 'package.json'), hostNode])}) { try { await fs.readFile(path); denied.push('readable') } catch(error) { denied.push(error.code) } } return {executable:process.execPath,env:Object.keys(process.env),denied};`
void channel.send({ type: 'boot', data: { code, namespaces: [], maxOutputBytes: 65_536 } })
.catch((error: unknown) => { completed.reject(error) })
} else if (message.type === 'done') {
if (message.error !== undefined) completed.reject(new Error(JSON.stringify(message.error)))
else completed.resolve(decodePtcJsonWire(message.value))
channel.close()
} else completed.reject(new Error(`Unexpected restricted-worker message: ${message.type}`))
}, (error) => { completed.reject(error) })
const code = `const fs=await import('node:fs/promises'); const denied=[]; for(const path of ${JSON.stringify([join(checkout, 'package.json'), hostNode])}) { try { await fs.readFile(path); denied.push('readable') } catch(error) { denied.push(error.code) } } return {executable:process.execPath,env:Object.keys(process.env),denied};`
// The probe program calls no bindings and prints nothing, so the host serves only readiness.
const channel = createProcessRpc<ProgramMethods, Pick<HostMethods, 'ready'>>(control, 1_048_576, {
ready() {
void channel.run(workspace, { code, namespaces: [], maxOutputBytes: 65_536 }).then((result) => {
if (result.error !== undefined) completed.reject(new Error(JSON.stringify(result.error)))
else completed.resolve(decodePtcJsonWire(result.value))
channel.$close()
}, (error: unknown) => { completed.reject(error) })
},
}, [], (error) => { completed.reject(error) })
const force = setTimeout(() => { child.kill('SIGKILL') }, 20_000)
try {
const result = z.object({ executable: z.string(), env: z.array(z.string()), denied: z.array(z.enum(['EPERM', 'EACCES'])) }).parse(await completed.promise)
@@ -60,7 +60,7 @@ export async function probeRestrictedWorker(
assert.equal(result.denied.length, 2)
await exited
} finally {
channel.close()
channel.$close()
child.kill('SIGTERM')
try { await exited } finally { clearTimeout(force) }
}
+11 -6
View File
@@ -49,10 +49,12 @@ function evaluateCondition(expression: string, cancelled: boolean, results: stri
}, { timeout: 1000 }) as boolean
}
const verdicts = [['ci.yml', 'all-checks-passed'], ['ci-windows.yml', 'windows-checks-passed'], ['ci-python.yml', 'python-checks-passed']] as const
describe('master-only platform scheduling', () => {
it.each(['success', 'failure', 'skipped', 'cancelled'])(
'reports %s dependencies in active runs but never starts a cancelled-run verdict', (result) => {
const aggregate = workflow('ci.yml').jobs['all-checks-passed']!
it.each(verdicts.flatMap(([file, job]) => ['success', 'failure', 'skipped', 'cancelled'].map(result => [file, job, result] as const)))(
'%s %s reports %s dependencies in active runs but never starts a cancelled-run verdict', (file, job, result) => {
const aggregate = workflow(file).jobs[job]!
const results = aggregate.needs!.map(() => 'success')
results[0] = result
const condition = aggregate.if as string
@@ -72,8 +74,8 @@ describe('master-only platform scheduling', () => {
expect(evaluateCondition(workflow('ci.yml').jobs['all-checks-passed']!.if as string, true, ['success'])).toBe(false)
})
it('keeps only Linux and Windows x64 runtimes in required PR CI', () => {
const pr = workflow('ci.yml')
it('keeps the required Python PR verdict and its two x64 targets in CI Python', () => {
const pr = workflow('ci-python.yml')
expect(Object.keys(pr.on)).toEqual(['pull_request'])
expect(pr.jobs['python-runtime']).toMatchObject({
if: "github.event_name == 'pull_request'",
@@ -82,7 +84,7 @@ describe('master-only platform scheduling', () => {
})
expect(pr.jobs.windows).toBeUndefined()
expect(JSON.stringify(pr.jobs)).not.toMatch(/wine-windows-gates|check:windows-wine/)
const aggregate = pr.jobs['all-checks-passed']!
const aggregate = pr.jobs['python-checks-passed']!
expect(aggregate.needs).toContain('python-runtime')
expect(aggregate.needs).not.toContain('windows')
expect(aggregate.needs!.every(id => id in pr.jobs)).toBe(true)
@@ -90,6 +92,9 @@ describe('master-only platform scheduling', () => {
expect(aggregate.steps).toContainEqual(expect.objectContaining({
if: "contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped')",
}))
for (const file of ['ci.yml', 'ci-windows.yml']) {
expect(workflow(file).jobs['python-runtime']).toBeUndefined()
}
})
it('runs all three deferred carriers on master pushes with fail-loud API credentials', () => {
+1
View File
@@ -395,6 +395,7 @@ describe('translation scope discovery', () => {
it.each([
'packages/example/guide.md',
'packages/example/docs/DETAIL.md',
'packages/example/CONTRIBUTING.md',
'packages/example/BRAND_GUIDELINES.md',
'other/tutorial.md',
+138 -18
View File
@@ -146,6 +146,11 @@
"symbol": "Inbox",
"source": "packages/core/agent/src/runtime-types.ts"
},
{
"doc": "docs/subsystems/core.md",
"symbol": "Model",
"source": "packages/core/agent/src/runtime-types.ts"
},
{
"doc": "docs/subsystems/core.md",
"symbol": "InboxTarget",
@@ -914,12 +919,12 @@
{
"doc": "docs/subsystems/tools.md",
"symbol": "ToolOutputDefinition",
"source": "packages/core/tools/src/index.ts"
"source": "packages/core/tools/src/contracts.ts"
},
{
"doc": "docs/subsystems/tools.md",
"symbol": "ToolDefinition",
"source": "packages/core/tools/src/index.ts"
"source": "packages/core/tools/src/contracts.ts"
},
{
"doc": "docs/subsystems/tools.md",
@@ -949,82 +954,97 @@
{
"doc": "docs/subsystems/tools.md",
"symbol": "ToolExecutionToken",
"source": "packages/core/tools/src/index.ts"
"source": "packages/core/tools/src/contracts.ts"
},
{
"doc": "docs/subsystems/tools.md",
"symbol": "ToolExecutionInput",
"source": "packages/core/tools/src/index.ts"
"source": "packages/core/tools/src/contracts.ts"
},
{
"doc": "docs/subsystems/tools.md",
"symbol": "ToolExecution",
"source": "packages/core/tools/src/index.ts"
"source": "packages/core/tools/src/contracts.ts"
},
{
"doc": "docs/subsystems/tools.md",
"symbol": "ToolDispatchExecution",
"source": "packages/core/tools/src/index.ts"
"source": "packages/core/tools/src/contracts.ts"
},
{
"doc": "docs/subsystems/tools.md",
"symbol": "ToolExecutionMode",
"source": "packages/core/tools/src/index.ts"
"source": "packages/core/tools/src/contracts.ts"
},
{
"doc": "docs/subsystems/tools.md",
"symbol": "PtcDispatchLog",
"source": "packages/core/tools/src/index.ts"
"source": "packages/core/tool-executor-ptc/src/runtime-types.ts"
},
{
"doc": "docs/subsystems/tools.md",
"symbol": "ToolRunContext",
"source": "packages/core/tools/src/index.ts"
"source": "packages/core/tools/src/contracts.ts"
},
{
"doc": "docs/subsystems/tools.md",
"symbol": "ToolGuard",
"source": "packages/core/tools/src/index.ts"
"source": "packages/core/tools/src/contracts.ts"
},
{
"doc": "docs/subsystems/tools.md",
"symbol": "ToolErrorInfo",
"source": "packages/core/tools/src/index.ts"
"source": "packages/core/tools/src/contracts.ts"
},
{
"doc": "docs/subsystems/tools.md",
"symbol": "ToolRestriction",
"source": "packages/core/tools/src/index.ts"
"source": "packages/core/tools/src/contracts.ts"
},
{
"doc": "docs/subsystems/tools.md",
"symbol": "ToolActivationContext",
"source": "packages/core/tools/src/contracts.ts"
},
{
"doc": "docs/subsystems/tools.md",
"symbol": "ToolRegistrationOptions",
"source": "packages/core/tools/src/contracts.ts"
},
{
"doc": "docs/subsystems/tools.md",
"symbol": "ResolvedToolSet",
"source": "packages/core/tools/src/contracts.ts"
},
{
"doc": "docs/subsystems/tools.md",
"symbol": "ToolFailure",
"source": "packages/core/tools/src/index.ts"
"source": "packages/core/tools/src/contracts.ts"
},
{
"doc": "docs/subsystems/tools.md",
"symbol": "ToolExecutionSuccess",
"source": "packages/core/tools/src/index.ts"
"source": "packages/core/tools/src/contracts.ts"
},
{
"doc": "docs/subsystems/tools.md",
"symbol": "ToolExecutionFailure",
"source": "packages/core/tools/src/index.ts"
"source": "packages/core/tools/src/contracts.ts"
},
{
"doc": "docs/subsystems/tools.md",
"symbol": "ToolExecutionResult",
"source": "packages/core/tools/src/index.ts"
"source": "packages/core/tools/src/contracts.ts"
},
{
"doc": "docs/subsystems/tools.md",
"symbol": "PreToolDecision",
"source": "packages/core/tools/src/index.ts"
"source": "packages/core/tools/src/contracts.ts"
},
{
"doc": "docs/subsystems/tools.md",
"symbol": "PostToolDecision",
"source": "packages/core/tools/src/index.ts"
"source": "packages/core/tools/src/contracts.ts"
},
{
"doc": "docs/subsystems/tools.md",
@@ -1931,6 +1951,16 @@
"symbol": "WebRoute",
"source": "packages/host/webserver/src/index.ts"
},
{
"doc": "docs/subsystems/web-server.md",
"symbol": "WebResource",
"source": "packages/host/webserver/src/index.ts"
},
{
"doc": "docs/subsystems/web-server.md",
"symbol": "WebResourceRoute",
"source": "packages/host/webserver/src/index.ts"
},
{
"doc": "docs/subsystems/web-server.md",
"symbol": "Config",
@@ -2006,6 +2036,11 @@
"symbol": "WebBootBatch",
"source": "packages/client/modules/src/client/manifest.ts"
},
{
"doc": "docs/subsystems/client-modules.md",
"symbol": "WebBootRelease",
"source": "packages/client/modules/src/client/manifest.ts"
},
{
"doc": "docs/subsystems/client-modules.md",
"symbol": "WebBootGraph",
@@ -2176,6 +2211,11 @@
"symbol": "PresetSpec",
"source": "packages/interaction/permission-presets/src/index.ts"
},
{
"doc": "docs/subsystems/session-projection.md",
"symbol": "ProjectionHandle",
"source": "packages/session/session-projection/src/index.ts"
},
{
"doc": "docs/subsystems/session-projection.md",
"symbol": "ProjectionDefinition",
@@ -2476,6 +2516,86 @@
"doc": "docs/subsystems/boot.md",
"symbol": "PendingModule",
"source": "packages/boot/hmr/src/index.ts"
},
{
"doc": "docs/subsystems/tools.md",
"symbol": "ToolModelInput",
"source": "packages/core/tools/src/executor.ts"
},
{
"doc": "docs/subsystems/tools.md",
"symbol": "ToolBatchInput",
"source": "packages/core/tools/src/executor.ts"
},
{
"doc": "docs/subsystems/tools.md",
"symbol": "ToolCallResult",
"source": "packages/core/tools/src/executor.ts"
},
{
"doc": "docs/subsystems/tools.md",
"symbol": "ToolBatchResult",
"source": "packages/core/tools/src/executor.ts"
},
{
"doc": "docs/subsystems/tools.md",
"symbol": "ToolExecutor",
"source": "packages/core/tools/src/executor.ts"
},
{
"doc": "docs/subsystems/core.md",
"symbol": "ModelSelection",
"source": "packages/core/agent/src/types.ts"
},
{
"doc": "docs/subsystems/core.md",
"symbol": "ModelSelectionProjectionState",
"source": "packages/core/agent/src/model-selection.ts"
},
{
"doc": "docs/subsystems/core.md",
"symbol": "ModelSelectionProjection",
"source": "packages/core/agent/src/types.ts"
},
{
"doc": "docs/subsystems/core.md",
"symbol": "TurnBoundaryProjection",
"source": "packages/core/agent/src/types.ts"
},
{
"doc": "docs/subsystems/token-meter.md",
"symbol": "ContextPressureProjection",
"source": "packages/llm/token-meter/src/projection.ts"
},
{
"doc": "docs/subsystems/session-projection.md",
"symbol": "SessionProjectionSummaryExcluded",
"source": "packages/session/session-projection/src/types.ts"
},
{
"doc": "docs/subsystems/session-projection.md",
"symbol": "SessionProjectionViewValue",
"source": "packages/session/session-projection/src/types.ts"
},
{
"doc": "docs/subsystems/session-projection.md",
"symbol": "SessionProjectionViewValues",
"source": "packages/session/session-projection/src/types.ts"
},
{
"doc": "docs/subsystems/session-projection.md",
"symbol": "SessionProjectionSummaryExcludedKey",
"source": "packages/session/session-projection/src/types.ts"
},
{
"doc": "docs/subsystems/session-projection.md",
"symbol": "SessionProjectionSummaryValues",
"source": "packages/session/session-projection/src/types.ts"
},
{
"doc": "docs/subsystems/session-projection.md",
"symbol": "ProjectionKeySelection",
"source": "packages/session/session-projection/src/index.ts"
}
]
}
+2 -6
View File
@@ -1,9 +1,5 @@
/**
* Program faces of the repository's Typert pass. The Host face alone emits
* Host reflection and the Host-for-Client Remote projection; no package
* publishes Client reflection. The Host tsdown plugin (`tsdown.config.ts`)
* and the bundle-free pass (`generate-typert.ts`) both generate exactly these
* faces. Node loads this module through type stripping from
* `tsdown.config.ts`, so it uses only erasable TypeScript syntax.
* Program faces of source-only Typert generation. The Host face emits Host reflection
* and the Host-for-Client Remote projection; no package publishes Client reflection.
*/
export const WORKSPACE_TYPERT_FACES = ['host'] as const
+30
View File
@@ -0,0 +1,30 @@
/**
* Package images under `src/client/assets/`: the client build preset replaces
* each import with the asset's document-relative, content-revisioned URL.
* The module names mirror the supported extensions of the client-modules
* asset format.
*/
declare module '*.svg' {
const url: string
export default url
}
declare module '*.png' {
const url: string
export default url
}
declare module '*.webp' {
const url: string
export default url
}
declare module '*.jpg' {
const url: string
export default url
}
declare module '*.jpeg' {
const url: string
export default url
}
+164
View File
@@ -10,9 +10,12 @@ import {
collectRuntimeLocalSourceSpecifiers,
collectRuntimeSourcePackageUses,
collectRuntimeSourceSpecifiers,
collectSourceAssetSpecifiers,
collectSourcePackageUses,
collectStylesheetAssetUrls,
fixClientPackageManifests,
readClientDeclarations,
readStylesheetAssetReferences,
type ClientDeclaration,
type ClientPackage,
type ClientPackageFacts,
@@ -33,10 +36,13 @@ function declaration(
name: short.startsWith('@') ? short : '@deepseek-ai/dsh-client-' + short,
manifest: 'packages/client/' + short.replace(/^.*\//, '') + '/package.json',
dynamic: true,
platform: 'web',
external: [],
inject: [],
runtimeSourceUses: {},
runtimeSourceSpecifiers: {},
assetExport: undefined,
assetReferences: [],
...fields,
}
}
@@ -285,6 +291,164 @@ describe('module requests', () => {
})
})
describe('package assets', () => {
const art = { file: 'packages/client/ui/src/client/View.tsx', reference: '"./assets/art.webp"' }
const mask = { file: 'packages/client/ui/src/client/View.module.css', reference: 'url("../assets/mask.png")' }
it('collects relative runtime module specifiers with a package-asset extension', () => {
expect([...collectSourceAssetSpecifiers('View.tsx', [
"import art from './assets/art.webp'",
"import back from '../assets/icons/back.SVG'",
"import './assets/side-effect.png'",
"export { default as photo } from './assets/photo.jpeg'",
"const lazy = () => import('./assets/lazy.jpg')",
"const legacy = require('./assets/legacy.svg')",
"import type Art from './assets/type-only.webp'",
"import styles from './View.module.css'",
"import font from './assets/font.woff2'",
"import raw from './assets/raw.svg?raw'",
"import shared from '@deepseek-ai/dsh-client-other/assets/shared.svg'",
"import rooted from '/assets/rooted.svg'",
].join('\n'))]).toEqual([
'./assets/art.webp',
'../assets/icons/back.SVG',
'./assets/side-effect.png',
'./assets/photo.jpeg',
'./assets/lazy.jpg',
'./assets/legacy.svg',
])
})
it.each(['View.module.css', 'global.css'])('collects relative url() values from %s', (path) => {
expect(collectStylesheetAssetUrls(path, [
".plain { background: url('./x.png'); }",
'.parent { mask: url("../assets/x.png") center / 100% no-repeat; }',
'.bare { background: url(./bare.svg); }',
".empty { background: url(''); }",
'.inline { background: url("data:image/svg+xml,%3Csvg%3E%3C/svg%3E"); }',
".fragment { filter: url('#frag'); }",
".rooted { background: url('/abs/x.png'); }",
'.network { background: url(//cdn.example.com/x.png); }',
".remote { background: url('https://example.com/x.png'); }",
"@supports (mask-image: url('')) { .nested { mask: url(./nested.svg); } }",
".set { background-image: image-set(url('./[email protected]') 1x, url('data:image/png;base64,AA==') 2x); }",
].join('\n'))).toEqual(['./x.png', '../assets/x.png', './bare.svg', './nested.svg', './[email protected]'])
})
it('rejects an unparsable stylesheet with its path', () => {
expect(() => collectStylesheetAssetUrls('src/View.module.css', '.a { composes: b; } .c .d { composes: a; }'))
.toThrow('verify-client-packages: cannot parse stylesheet src/View.module.css: The `composes` property')
})
it('reads url() references only from package stylesheets that Client sources import', () => {
const root = mkdtempSync(join(tmpdir(), 'client-package-assets-'))
roots.push(root)
const files: Record<string, string> = {
'packages/client/ui/src/client/View.module.css': ".view { mask: url('../assets/mask.png'); background: url(data:,x); }",
'packages/client/ui/src/client/global.css': '.global { background: url(./assets/global.svg); }',
'packages/client/ui/src/client/text.css': '.text { background: url("./assets/text.webp"); }',
'packages/client/ui/src/styles/static.css': '@font-face { src: url(./static.woff2); }',
'packages/client/ui/styles/outside.css': '.outside { background: url(./outside.png); }',
}
for (const [path, value] of Object.entries(files)) {
mkdirSync(dirname(join(root, path)), { recursive: true })
writeFileSync(join(root, path), value)
}
expect(readStylesheetAssetReferences(root, 'packages/client/ui', new Map([
['packages/client/ui/src/client/View.tsx', ['./View.module.css', './global.css', './assets/art.webp']],
['packages/client/ui/src/client/lazy.ts', ['./text.css?inline', '../../styles/outside.css', './View.module.css']],
['packages/client/ui/src/index.ts', ['katex/dist/katex.css', './client/missing.css.ts']],
]))).toEqual({
stylesheets: [
'packages/client/ui/src/client/View.module.css',
'packages/client/ui/src/client/global.css',
'packages/client/ui/src/client/text.css',
],
references: [
{ file: 'packages/client/ui/src/client/View.module.css', reference: 'url("../assets/mask.png")' },
{ file: 'packages/client/ui/src/client/global.css', reference: 'url("./assets/global.svg")' },
{ file: 'packages/client/ui/src/client/text.css', reference: 'url("./assets/text.webp")' },
],
})
})
it('reads the Web platform and the raw asset export from manifests', () => {
const root = mkdtempSync(join(tmpdir(), 'client-package-asset-manifests-'))
roots.push(root)
const files: Record<string, unknown> = {
'packages/g/web/package.json': {
name: '@f/web', dsh: { client: { platform: 'web' } }, exports: { './assets/*': './lib/assets/*' },
},
'packages/g/conditional/package.json': {
name: '@f/conditional', exports: { './assets/*': { default: './lib/assets/*' } },
},
'packages/g/none/package.json': { name: '@f/none', dsh: { client: { platform: 1 } }, exports: './lib/index.js' },
}
for (const [path, value] of Object.entries(files)) {
mkdirSync(dirname(join(root, path)), { recursive: true })
writeFileSync(join(root, path), JSON.stringify(value))
}
expect(readClientDeclarations(root).declarations.map(entry => [entry.name, entry.platform, entry.assetExport]))
.toEqual([
['@f/conditional', undefined, { default: './lib/assets/*' }],
['@f/none', undefined, undefined],
['@f/web', 'web', './lib/assets/*'],
])
})
it('accepts referenced assets with the export and unreferenced packages without it', () => {
expect(collectClientPackageViolations(facts([], {
declarations: [
declaration('ui', { assetExport: './lib/assets/*', assetReferences: [mask, art] }),
declaration('plain'),
declaration('shell', { dynamic: false, platform: undefined }),
],
}))).toEqual([])
})
it.each([
['./lib/assets/**', '"./lib/assets/**"'],
[{ default: './lib/assets/*' }, '{"default":"./lib/assets/*"}'],
[null, 'null'],
])('rejects the asset export target %j', (target, shown) => {
const ui = declaration('ui', { assetExport: target, assetReferences: [art] })
expect(collectClientPackageViolations(facts([], { declarations: [ui] }))).toEqual([
ui.manifest + ': exports["./assets/*"] is ' + shown + '; it must be exactly "./lib/assets/*"',
])
})
it.each([
['a statically linked package', { dynamic: false, platform: undefined }],
['a row for another platform', { platform: 'desktop' }],
['a row without a platform', { platform: undefined }],
] as const)('rejects the asset export on %s', (_label, fields) => {
const ui = declaration('ui', { ...fields, assetExport: './lib/assets/*', assetReferences: [art] })
expect(collectClientPackageViolations(facts([], { declarations: [ui] }))).toEqual([
ui.manifest + ': ' + ui.name + ' declares exports["./assets/*"] without a dsh.client row for platform "web";'
+ ' only dynamic Web Client packages publish package assets',
])
})
it('rejects a referenced asset without the export and names the first reference', () => {
const ui = declaration('ui', { assetReferences: [mask, art] })
expect(collectClientPackageViolations(facts([], { declarations: [ui] }))).toEqual([
ui.manifest + ': ' + ui.name + ' references package asset url("../assets/mask.png") in '
+ 'packages/client/ui/src/client/View.module.css but declares no exports["./assets/*"];'
+ ' add "./assets/*": "./lib/assets/*" and publish "lib/assets"',
])
})
it('rejects an unused asset export', () => {
const ui = declaration('ui', { assetExport: './lib/assets/*' })
expect(collectClientPackageViolations(facts([], { declarations: [ui] }))).toEqual([
ui.manifest + ': ' + ui.name + ' declares an unused ./assets/* export;'
+ ' no Client source or stylesheet it imports references a package asset',
])
})
})
describe('manifest declarations', () => {
it('reports malformed arrays without hiding other packages', () => {
const root = mkdtempSync(join(tmpdir(), 'client-packages-'))
+202 -15
View File
@@ -1,11 +1,18 @@
/**
* Verify client package modes and the synchronous browser module-request graph.
* Verify client package modes, the synchronous browser module-request graph, and package-asset publication.
*/
import { globSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve, sep } from 'node:path'
import { dirname, posix, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
import { transform } from 'lightningcss'
import ts from 'typescript'
import {
PACKAGE_ASSET_EXPORT,
PACKAGE_ASSET_EXPORT_TARGET,
PACKAGE_ASSET_FILES_ENTRY,
packageAssetMediaType,
} from '../packages/client/modules/src/assets.ts'
import { TypeScriptProject } from './ts-project.ts'
const GATE = 'verify-client-packages'
@@ -16,18 +23,38 @@ const PLATFORM_SOURCE = 'packages/client/web/src/platform.ts'
const PARSER_PRELOAD_SOURCE = 'packages/client/modules/src/index.ts'
const STATIC_PRESET_SOURCE = 'packages/client/tsdown.client.ts'
const CORDIS = '@deepseek-ai/cordis'
/** Stylesheet requests the dynamic build preset compiles: CSS Modules, global sheets, and `?inline` text. */
const STYLESHEET_REQUEST = /\.css(?:\?inline)?$/u
const INLINE_STYLESHEET_QUERY = /\?inline$/u
/** One source location whose reference makes the dynamic build preset emit a package asset. */
export interface AssetReference {
/** Repository-relative, `/`-separated source or stylesheet path. */
readonly file: string
/** The JSON-quoted module specifier, or `url("<value>")` for a stylesheet reference. */
readonly reference: string
}
/** One workspace package's browser-module declaration. */
export interface ClientDeclaration {
readonly name: string
readonly manifest: string
readonly dynamic: boolean
/** String `dsh.client.platform`; undefined without a row or a string platform. */
readonly platform: string | undefined
readonly external: readonly string[]
readonly runtimeSourceUses: Readonly<Record<string, readonly string[]>>
/** Exact runtime specifiers used to validate `dsh.client.external` declarations. */
readonly runtimeSourceSpecifiers: Readonly<Record<string, readonly string[]>>
/** Informational package dependencies declared by the row. */
readonly inject: readonly string[]
/** Raw `exports["./assets/*"]` value; undefined when the manifest declares no such subpath. */
readonly assetExport: unknown
/**
* Package-asset references in a dynamic Web row's Client sources under `src/` and in the package stylesheets they
* import, sorted by file and then reference; empty for every other package.
*/
readonly assetReferences: readonly AssetReference[]
}
/** One package directly under packages/client. */
@@ -112,6 +139,94 @@ export function collectRuntimeLocalSourceSpecifiers(path: string, source: string
return collectSourceFileUses(sourceFile, true, 'local', includeRootRelative)
}
/**
* Collect package-asset module specifiers retained by one production source file.
* @param path - File path used to select TypeScript's parser mode.
* @param source - Source text to inspect.
* @returns Relative runtime imports, re-exports, and requires whose extension has a package-asset media type.
*/
export function collectSourceAssetSpecifiers(path: string, source: string): Set<string> {
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
return new Set([...collectSourceFileUses(sourceFile, true, 'local')].filter(isAssetSpecifier))
}
function isAssetSpecifier(specifier: string): boolean {
return packageAssetMediaType(specifier) !== undefined
}
/**
* Collect the relative `url()` values of one stylesheet, which the dynamic build preset resolves to package assets.
* @param path - Stylesheet path; a `.module.css` name enables CSS Modules syntax, as in the build preset.
* @param source - Stylesheet text.
* @returns `url()` values in source order, excluding empty values, fragments, root-relative paths, and URLs with a
* scheme such as `data:` or `https:`.
* @throws when lightningcss cannot parse the stylesheet; the message names `path`.
*/
export function collectStylesheetAssetUrls(path: string, source: string): string[] {
const urls: string[] = []
try {
transform({
filename: path,
code: Buffer.from(source),
cssModules: path.endsWith('.module.css'),
visitor: {
Url(url) {
if (isRelativeStylesheetUrl(url.url)) urls.push(url.url)
},
// lightningcss does not visit the urls inside image-set(), so its options are read here.
Image(image) {
if (image.type !== 'image-set') return
for (const option of image.value.options) {
if (option.image.type === 'url' && isRelativeStylesheetUrl(option.image.value.url)) urls.push(option.image.value.url)
}
},
},
})
} catch (error) {
throw new Error(
GATE + ': cannot parse stylesheet ' + path + ': ' + (error instanceof Error ? error.message : String(error)),
{ cause: error },
)
}
return urls
}
function isRelativeStylesheetUrl(url: string): boolean {
return url !== '' && !url.startsWith('#') && !url.startsWith('/') && !/^[a-z][a-z\d+.-]*:/iu.test(url)
}
/**
* Read package-asset references from the package stylesheets that Client sources import. The dynamic build preset
* compiles exactly these stylesheets; a stylesheet no Client source imports, such as a copied static payload, keeps
* its `url()` values unchanged.
* @param root - Absolute repository root.
* @param packageDirectory - Repository-relative, `/`-separated package directory.
* @param importers - Repository-relative, `/`-separated Client source paths and their runtime local module specifiers.
* @returns Imported `.css` and `.css?inline` stylesheets under the package's `src/`, in path order, and their references.
*/
export function readStylesheetAssetReferences(
root: string,
packageDirectory: string,
importers: ReadonlyMap<string, Iterable<string>>,
): { readonly stylesheets: readonly string[]; readonly references: readonly AssetReference[] } {
const sourcePrefix = packageDirectory + '/src/'
const imported = new Set<string>()
for (const [file, specifiers] of importers) {
for (const specifier of specifiers) {
if (!specifier.startsWith('.') || !STYLESHEET_REQUEST.test(specifier)) continue
const stylesheet = posix.join(posix.dirname(file), specifier.replace(INLINE_STYLESHEET_QUERY, ''))
if (stylesheet.startsWith(sourcePrefix)) imported.add(stylesheet)
}
}
const stylesheets = [...imported].sort()
return {
stylesheets,
references: stylesheets.flatMap(file =>
collectStylesheetAssetUrls(file, readFileSync(resolve(root, file), 'utf8'))
.map(url => ({ file, reference: 'url(' + JSON.stringify(url) + ')' }))),
}
}
function importCarriesRuntimeValue(node: ts.ImportDeclaration): boolean {
const clause = node.importClause
if (clause === undefined) return true
@@ -190,7 +305,7 @@ export function readClientDeclarations(root: string): ClientDeclarations {
/**
* Return every client package policy violation.
* @param facts - Package modes, manifests, source uses, and platform module lists.
* @param facts - Package modes, manifests, source uses, package-asset references, and platform module lists.
* @returns Stable self-contained diagnostics.
*/
export function collectClientPackageViolations(facts: ClientPackageFacts): string[] {
@@ -198,6 +313,7 @@ export function collectClientPackageViolations(facts: ClientPackageFacts): strin
...facts.malformed,
...collectModeViolations(facts),
...collectModuleViolations(facts),
...collectAssetViolations(facts),
].sort((left, right) => left.localeCompare(right))
}
@@ -440,9 +556,51 @@ function formatCycle(
return manifest + ': synchronous dsh.client.external cycle: ' + chain + ' ' + (entry?.from ?? '')
}
/** Whether the dynamic build preset builds this package's browser face, the only face that emits package assets. */
function buildsDynamicWebClient(declaration: ClientDeclaration): boolean {
return declaration.dynamic && declaration.platform === 'web'
}
function collectAssetViolations(facts: ClientPackageFacts): string[] {
const violations: string[] = []
const exportKey = 'exports[' + JSON.stringify(PACKAGE_ASSET_EXPORT) + ']'
for (const pkg of facts.declarations) {
const declared = pkg.assetExport !== undefined
const covered = buildsDynamicWebClient(pkg)
if (declared && pkg.assetExport !== PACKAGE_ASSET_EXPORT_TARGET) {
violations.push(
pkg.manifest + ': ' + exportKey + ' is ' + JSON.stringify(pkg.assetExport)
+ '; it must be exactly ' + JSON.stringify(PACKAGE_ASSET_EXPORT_TARGET),
)
}
if (declared && !covered) {
violations.push(
pkg.manifest + ': ' + pkg.name + ' declares ' + exportKey + ' without a dsh.client row for platform "web";'
+ ' only dynamic Web Client packages publish package assets',
)
}
if (!covered) continue
const first = pkg.assetReferences[0]
if (!declared && first !== undefined) {
violations.push(
pkg.manifest + ': ' + pkg.name + ' references package asset ' + first.reference + ' in ' + first.file
+ ' but declares no ' + exportKey + '; add ' + JSON.stringify(PACKAGE_ASSET_EXPORT) + ': '
+ JSON.stringify(PACKAGE_ASSET_EXPORT_TARGET) + ' and publish ' + JSON.stringify(PACKAGE_ASSET_FILES_ENTRY),
)
} else if (declared && first === undefined) {
violations.push(
pkg.manifest + ': ' + pkg.name + ' declares an unused ' + PACKAGE_ASSET_EXPORT + ' export;'
+ ' no Client source or stylesheet it imports references a package asset',
)
}
}
return violations
}
interface Manifest {
name?: unknown
dsh?: unknown
exports?: unknown
dependencies?: Record<string, string>
peerDependencies?: Record<string, string>
devDependencies?: Record<string, string>
@@ -457,27 +615,27 @@ function readDeclaration(
if (typeof manifest.name !== 'string') return undefined
const dsh = isRecord(manifest.dsh) ? manifest.dsh : undefined
const rawClient = dsh?.client
if (rawClient === undefined) {
return {
name: manifest.name, manifest: manifestPath, dynamic: false, external: [], inject: [],
runtimeSourceUses: {}, runtimeSourceSpecifiers: {},
}
}
const assetExport = isRecord(manifest.exports) ? manifest.exports[PACKAGE_ASSET_EXPORT] : undefined
const rowless = {
name: manifest.name, manifest: manifestPath, dynamic: false, platform: undefined, external: [], inject: [],
runtimeSourceUses: {}, runtimeSourceSpecifiers: {}, assetExport, assetReferences: [],
} satisfies ClientDeclaration
if (rawClient === undefined) return rowless
if (!isRecord(rawClient)) {
malformed.push(manifestPath + ': ' + manifest.name + ' dsh.client must be an object')
return {
name: manifest.name, manifest: manifestPath, dynamic: false, external: [], inject: [],
runtimeSourceUses: {}, runtimeSourceSpecifiers: {},
}
return rowless
}
return {
name: manifest.name,
manifest: manifestPath,
dynamic: true,
platform: typeof rawClient.platform === 'string' ? rawClient.platform : undefined,
external: stringArray(rawClient.external, manifest.name, manifestPath, 'external', malformed),
inject: stringArray(rawClient.inject, manifest.name, manifestPath, 'inject', malformed),
runtimeSourceUses: {},
runtimeSourceSpecifiers: {},
assetExport,
assetReferences: [],
}
}
@@ -563,9 +721,13 @@ async function readFacts(root: string): Promise<ClientPackageFacts> {
const staticLinkedPackages = await readStaticLinkedRoster(root)
const project = new TypeScriptProject(root, 'client')
const sourceFiles = project.sourceFiles()
let webStylesheets = 0
const declarations = bareDeclarations.map((declaration): ClientDeclaration => {
const runtimeSourceUses = new Map<string, Set<string>>()
const runtimeSourceSpecifiers = new Map<string, Set<string>>()
const assetReferences: AssetReference[] = []
const scansAssets = buildsDynamicWebClient(declaration)
const importers = new Map<string, ReadonlySet<string>>()
const sourcePrefix = dirname(declaration.manifest) + '/src/'
for (const sourceFile of sourceFiles) {
if (sourceFile.isDeclarationFile) continue
@@ -581,6 +743,24 @@ async function readFacts(root: string): Promise<ClientPackageFacts> {
locations.add(file)
runtimeSourceSpecifiers.set(specifier, locations)
}
if (!scansAssets) continue
const localSpecifiers = collectSourceFileUses(sourceFile, true, 'local')
importers.set(file, localSpecifiers)
for (const specifier of localSpecifiers) {
if (isAssetSpecifier(specifier)) assetReferences.push({ file, reference: JSON.stringify(specifier) })
}
}
if (scansAssets) {
// A Web row always has a browser half, so an empty source set means the Client program stopped covering it.
if (importers.size === 0) {
throw new Error(
GATE + ': the Client program loads no source under ' + sourcePrefix + ' for dynamic Web row '
+ declaration.name + '; its package-asset references cannot be read',
)
}
const stylesheets = readStylesheetAssetReferences(root, dirname(declaration.manifest), importers)
webStylesheets += stylesheets.stylesheets.length
assetReferences.push(...stylesheets.references)
}
return {
...declaration,
@@ -592,8 +772,13 @@ async function readFacts(root: string): Promise<ClientPackageFacts> {
[...runtimeSourceSpecifiers].sort(([left], [right]) => left.localeCompare(right))
.map(([specifier, locations]) => [specifier, [...locations].sort()]),
),
assetReferences: assetReferences.sort((left, right) =>
left.file.localeCompare(right.file) || left.reference.localeCompare(right.reference)),
}
})
if (webStylesheets === 0) {
throw new Error(GATE + ': no dynamic Web row imports a stylesheet from its src/ directory; the stylesheet scan read an empty corpus')
}
const byManifest = new Map(declarations.map(entry => [entry.manifest, entry]))
const packages: ClientPackage[] = []
@@ -701,10 +886,12 @@ async function main(): Promise<void> {
const dynamic = facts.packages.filter(pkg => pkg.dynamic).length
const requests = facts.declarations.reduce((total, pkg) => total + pkg.external.length, 0)
const assetExports = facts.declarations.filter(pkg => pkg.assetExport !== undefined).length
console.log(
GATE + ': ' + String(facts.packages.length) + ' client packages (' + String(dynamic) + ' dynamic, '
+ String(facts.packages.length - dynamic) + ' statically linked) satisfy package-mode and module-request rules; '
+ String(requests) + ' explicit external request(s).',
+ String(facts.packages.length - dynamic) + ' statically linked) satisfy package-mode, module-request, and '
+ 'package-asset rules; ' + String(requests) + ' explicit external request(s), ' + String(assetExports)
+ ' package-asset export(s).',
)
}
+16 -1
View File
@@ -11,6 +11,7 @@ import { describe, expect, it } from 'vitest'
import {
bundleManifestPaths,
bundlePluginDependencyErrors,
exclusivePlaneRowIds,
metadataExpressionErrors,
packageTestFixtureDependencyErrors,
packageTestPluginDependencyErrors,
@@ -48,6 +49,17 @@ describe('verify-cordis-config metadata expressions', () => {
})
describe('workspace Bundle discovery and product dependency closures', () => {
it('allows scoped executors on both planes while retaining other plugin rows', () => {
expect([...exclusivePlaneRowIds([{ insert: [
{ id: 'native', name: '@deepseek-ai/dsh-tool-executor-native' },
{ id: 'ptc', name: '@deepseek-ai/dsh-tool-executor-ptc' },
{ id: 'tools', name: '@deepseek-ai/dsh-tools' },
{ id: 'group', name: 'cordis:group', group: true, config: [
{ id: 'tool-executor-ptc', name: '@deepseek-ai/dsh-tool-fs' },
] },
] }])]).toEqual(['tools', 'group', 'tool-executor-ptc'])
})
it('discovers a Bundle outside packages/bundle from its manifest declaration', () => {
const fixture = mkdtempSync(join(tmpdir(), 'dsh-bundle-discovery-'))
try {
@@ -80,11 +92,14 @@ describe('workspace Bundle discovery and product dependency closures', () => {
}
const self = { file, name: '@deepseek-ai/dsh-subagent-example' }
expect(bundlePluginDependencyErrors(manifestPath, manifest, [self])).toEqual([])
expect(bundlePluginDependencyErrors(manifestPath, {
...manifest, optionalDependencies: { '@deepseek-ai/dsh-platform-plugin': 'workspace:*' },
}, [{ file, name: '@deepseek-ai/dsh-platform-plugin' }])).toEqual([])
expect(bundlePluginDependencyErrors(manifestPath, manifest, [
self,
{ file, name: '@deepseek-ai/dsh-missing-plugin' },
])).toEqual([
`${file}: @deepseek-ai/dsh-missing-plugin must be declared in ${manifestPath} dependencies`,
`${file}: @deepseek-ai/dsh-missing-plugin must be declared in ${manifestPath} dependencies or optionalDependencies`,
])
})
})
+15 -7
View File
@@ -119,7 +119,8 @@ function validateClientHalvesDeclared(): string[] {
}
/**
* No shipped agent preset may repeat a row the host composition still runs.
* No shipped agent preset may repeat an unscoped row the host composition still runs.
* Tool executors select through the mounting scope and may appear on both planes.
*
* A preset contributes what ONE session adds to the host's registries. A row
* active on both planes is therefore mounted twice — once per process and once
@@ -157,7 +158,7 @@ function validatePresetPlaneSeparation(): string[] {
const active = new Set([...rowIds(hostFile), ...overlayRows].filter(id => !disabled.has(id)))
for (const file of overlayFiles) {
for (const definition of presetDefinitions(loadEntries(file))) {
for (const id of collectRowIds(definition.plugins)) {
for (const id of exclusivePlaneRowIds(definition.plugins)) {
if (active.has(id)) problems.push(`${file}#${definition.id}: row "${id}" is also active in the host composition; a row belongs to exactly one plane`)
}
}
@@ -178,10 +179,15 @@ function loadEntries(file: string): unknown[] {
* @returns the declared ids.
*/
function rowIds(file: string): Set<string> {
return collectRowIds(loadEntries(file))
return exclusivePlaneRowIds(loadEntries(file))
}
function collectRowIds(rows: unknown): Set<string> {
/**
* Collect rows that cannot be shared between the host and a preset.
* @param rows - parsed Loader entries, including inserts and groups.
* @returns ids excluding the executor plugins whose registrations are scope-owned.
*/
export function exclusivePlaneRowIds(rows: unknown): Set<string> {
const ids = new Set<string>()
const walk = (value: unknown): void => {
if (isUnknownArray(value)) {
@@ -189,7 +195,9 @@ function collectRowIds(rows: unknown): Set<string> {
return
}
if (!isRecord(value)) return
if (typeof value.id === 'string' && typeof value.name === 'string') ids.add(value.id)
if (typeof value.id === 'string' && typeof value.name === 'string'
&& value.name !== '@deepseek-ai/dsh-tool-executor-native'
&& value.name !== '@deepseek-ai/dsh-tool-executor-ptc') ids.add(value.id)
walk(value.insert)
if (isCordisGroupEntry(value)) walk(value.config)
}
@@ -396,8 +404,8 @@ export function bundlePluginDependencyErrors(
return missingPluginDependencies(
// A Bundle may mount its own package (for example, its provider or runtime row).
references.filter(reference => packageNameFromSpecifier(reference.name) !== manifest.name),
manifest.dependencies ?? {},
`${manifestPath} dependencies`,
{ ...manifest.dependencies, ...manifest.optionalDependencies },
`${manifestPath} dependencies or optionalDependencies`,
)
}
@@ -13,7 +13,7 @@ const roots: string[] = []
const experimental = '@deepseek-ai/dsh-experimental-prototype'
const core = '@deepseek-ai/dsh-core'
const base = '@deepseek-ai/dsh-base'
const profile = 'packages/boot/app-boot/src/profile.ts'
const profile = 'packages/boot/official-bundles/src/bundles.ts'
const preset = 'packages/bundle/web-app/presets/standard.patch.yml'
const patch = 'packages/bundle/base/cordis.patch.yml'
@@ -488,6 +488,6 @@ describe('default product isolation', () => {
])('rejects absent, empty, or uninspectable default profile definitions', (source) => {
const root = fixture()
write(root, profile, source)
expect(() => verifyDefaultProductIsolation(root)).toThrow('profile')
expect(() => verifyDefaultProductIsolation(root)).toThrow(`${profile}: `)
})
})
+1 -1
View File
@@ -23,7 +23,7 @@ import {
import { isExperimentalPackageName } from './experimental-package-policy.ts'
// The independently published entry package owns platform-engine dependencies.
const EXTERNAL_KIT_PACKAGES = new Set(['@deepseek-ai/libreoffice-kit'])
const PROFILE_SOURCE = 'packages/boot/app-boot/src/profile.ts'
const PROFILE_SOURCE = 'packages/boot/official-bundles/src/bundles.ts'
const PRESET_PATTERN = 'packages/bundle/web-app/presets/*.patch.yml'
const RUNTIME_SECTIONS = ['dependencies', 'optionalDependencies', 'peerDependencies'] as const
+121
View File
@@ -0,0 +1,121 @@
/** Metadata coverage rejects missing translations through the same checker used by CI. */
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { localeMetadataProblems } from './verify-locale-metadata.ts'
const roots: string[] = []
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
function fixture(ja: unknown = { meta: { title: '名前 {name}', description: '説明' } }): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-locale-meta-'))
roots.push(root)
const directory = join(root, 'packages/demo/plugin/locale')
mkdirSync(directory, { recursive: true })
writeFileSync(join(directory, 'en.json'), JSON.stringify({ meta: { title: 'Name {name}' } }))
writeFileSync(join(directory, 'zh.json'), JSON.stringify({ meta: { title: '名称 {name}', description: '描述' } }))
writeFileSync(join(directory, 'ja.json'), JSON.stringify(ja))
return root
}
it('accepts Japanese fields from the union of English and Chinese metadata', () => {
expect(localeMetadataProblems(fixture())).toEqual([])
})
it('rejects missing Japanese files, including newly added packages', () => {
const root = fixture()
rmSync(join(root, 'packages/demo/plugin/locale/ja.json'))
expect(localeMetadataProblems(root).join('\n')).toContain('ja.json')
})
it.each([
[{ meta: { title: '名前 {name}' } }, 'missing meta.description'],
[{ meta: { title: '名前 {other}', description: '説明' } }, 'changes placeholders'],
[{ meta: { title: '', description: '説明' } }, 'expected non-empty text'],
[{ meta: { title: '名前 {name}', description: '説明', extra: '余分' } }, 'unexpected meta.extra'],
])('rejects incomplete or inconsistent Japanese metadata', (ja, diagnostic) => {
expect(localeMetadataProblems(fixture(ja)).join('\n')).toContain(diagnostic)
})
it('checks referenced detail documents', () => {
const root = fixture()
const directory = join(root, 'packages/demo/plugin/locale')
const meta = { title: 'Name {name}', description: 'Description.', detail: 'docs/DETAIL.md' }
for (const language of ['en', 'zh', 'ja']) writeFileSync(join(directory, `${language}.json`), JSON.stringify({ meta }))
expect(localeMetadataProblems(root).join('\n')).toContain('missing detail resource')
mkdirSync(join(directory, '../docs'))
writeFileSync(join(directory, '../docs/DETAIL.md'), '# Detail\n')
expect(localeMetadataProblems(root)).toEqual([])
})
it('does not mistake package.json in the locale service package for a translation', () => {
const root = fixture()
const directory = join(root, 'packages/client/locale')
mkdirSync(directory, { recursive: true })
writeFileSync(join(directory, 'package.json'), '{"name":"locale"}')
mkdirSync(join(directory, 'src'))
writeFileSync(join(directory, 'src/index.ts'), 'export {}')
expect(localeMetadataProblems(root)).toEqual([])
})
it('rejects an empty discovery', () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-locale-empty-'))
roots.push(root)
expect(localeMetadataProblems(root)).toEqual(['no package locale metadata found'])
})
it('rejects loadable plugins without any locale directory', () => {
const root = fixture()
const directory = join(root, 'packages/demo/new-plugin')
mkdirSync(join(directory, 'src'), { recursive: true })
writeFileSync(join(directory, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-new-plugin' }))
writeFileSync(join(directory, 'src/index.ts'), 'export function apply() {}')
expect(localeMetadataProblems(root).join('\n')).toContain('new-plugin/locale')
})
it('checks nested subpath locale resources and package-relative detail files', () => {
const root = fixture()
const directory = join(root, 'packages/demo/plugin/locale/tools')
mkdirSync(directory)
for (const language of ['en', 'zh']) writeFileSync(join(directory, `${language}.json`), JSON.stringify({ meta: { title: 'Tools', detail: 'docs/TOOLS.md' } }))
mkdirSync(join(root, 'packages/demo/plugin/docs'))
writeFileSync(join(root, 'packages/demo/plugin/docs/TOOLS.md'), '# Tools\n')
expect(localeMetadataProblems(root).join('\n')).toContain('locale/tools')
expect(localeMetadataProblems(root).join('\n')).toContain('ja.json')
writeFileSync(join(directory, 'ja.json'), JSON.stringify({ meta: { title: 'ツール', detail: 'docs/TOOLS.md' } }))
expect(localeMetadataProblems(root)).toEqual([])
})
it.each(['../../other/README.md', 'docs\\DETAIL.md', 'docs/DETAIL.txt', '/README.md'])('rejects runtime-invalid detail path %s', (detail) => {
const root = fixture({ meta: { title: '名前 {name}', description: '説明', detail } })
expect(localeMetadataProblems(root).join('\n')).toMatch(/must (?:be a relative path|name a Markdown file)/u)
})
it('requires sentence punctuation for English descriptions', () => {
const root = fixture()
const file = join(root, 'packages/demo/plugin/locale/en.json')
writeFileSync(file, JSON.stringify({ meta: { title: 'Name {name}', description: 'Description' } }))
expect(localeMetadataProblems(root).join('\n')).toContain('sentence punctuation')
writeFileSync(file, JSON.stringify({ meta: { title: 'Name {name}', description: 'Description.' } }))
expect(localeMetadataProblems(root)).toEqual([])
})
it('permits missing Chinese resources for loadable plugins', () => {
const root = fixture({ meta: { title: '名前 {name}' } })
const directory = join(root, 'packages/demo/plugin')
mkdirSync(join(directory, 'src'))
writeFileSync(join(directory, 'src/index.ts'), 'export function apply() {}')
writeFileSync(join(directory, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-demo' }))
rmSync(join(directory, 'locale/zh.json'))
expect(localeMetadataProblems(root)).toEqual([])
})
it('excludes packages formally classified as test helpers', () => {
const root = fixture()
const directory = join(root, 'packages/test-support/llm-replay')
mkdirSync(join(directory, 'src'), { recursive: true })
writeFileSync(join(directory, 'src/index.ts'), 'export function apply() {}')
writeFileSync(join(directory, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-llm-replay' }))
expect(localeMetadataProblems(root)).toEqual([])
})
+72
View File
@@ -0,0 +1,72 @@
/** Check Japanese coverage of package metadata and plugin templates without loading plugins. */
import { existsSync, globSync, readFileSync } from 'node:fs'
import { basename, posix, join, resolve } from 'node:path'
import { detailPath } from '../packages/util/plugin-detail/src/document.ts'
import { PRODUCT_PACKAGE_POLICY } from './product-package-policy.ts'
import { collectConfigCatalog } from './gen-config-catalog.ts'
/** Flatten metadata strings and reject values that cannot be translated. */
function strings(value: unknown, key = ''): Record<string, string> {
if (typeof value === 'string' && value.trim() !== '') return { [key]: value }
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error(`${key}: expected non-empty text`)
return Object.fromEntries(Object.entries(value).flatMap(([name, child]) => Object.entries(strings(child, key ? `${key}.${name}` : name))))
}
/**
* Compare Japanese metadata with every field supplied by English or Chinese.
* @param root - repository or fixture root.
* @returns actionable missing-file, field, placeholder and resource diagnostics.
*/
export function localeMetadataProblems(root: string): string[] {
const directories = new Set(globSync('packages/**/locale/**/*.json', {
cwd: root, exclude: ['packages/**/node_modules/**', 'packages/**/lib/**', 'packages/**/tests/**'],
}).map(file => file.replaceAll('\\', '/')).filter(file => ['en.json', 'zh.json', 'ja.json'].includes(basename(file))).map(file => posix.dirname(file)))
for (const entry of collectConfigCatalog(root)) {
const dir = entry.dir.replaceAll('\\', '/')
if (PRODUCT_PACKAGE_POLICY[dir]?.category === 'test') continue
if (entry.kind !== 'config' && entry.kind !== 'no-config') continue
const directory = `${dir}/locale`
directories.add(directory)
}
const problems: string[] = []
if (directories.size === 0) return ['no package locale metadata found']
for (const directory of [...directories].sort()) {
try {
const read = (language: string): Record<string, string> => {
const file = join(root, directory, `${language}.json`)
const value: unknown = JSON.parse(readFileSync(file, 'utf8'))
const dictionary = strings(value)
const detail = detailPath(dictionary['meta.detail'], `${directory}/${language}.json: meta.detail`)
const packageDir = directory.slice(0, directory.lastIndexOf('/locale'))
if (detail !== undefined && !existsSync(resolve(root, packageDir, detail))) {
throw new Error(`missing detail resource ${detail}`)
}
return dictionary
}
const en = read('en')
const zh = existsSync(join(root, directory, 'zh.json')) ? read('zh') : {}
const description = en['meta.description']
if (description !== undefined && !/[.!?]$/u.test(description)) problems.push(`${directory}/en.json: meta.description must end with sentence punctuation`)
const ja = read('ja')
const expected = { ...zh, ...en }
for (const key of new Set([...Object.keys(expected), ...Object.keys(ja)])) {
if (ja[key] === undefined) problems.push(`${directory}/ja.json: missing ${key}`)
else if (expected[key] === undefined) problems.push(`${directory}/ja.json: unexpected ${key}`)
else {
const placeholders = (text: string): string => (text.match(/\{\w+\}/gu) ?? []).sort().join(',')
if (placeholders(ja[key]) !== placeholders(expected[key])) problems.push(`${directory}/ja.json: ${key} changes placeholders`)
}
}
} catch (error) {
problems.push(`${directory}: ${error instanceof Error ? error.message : String(error)}`)
}
}
return problems
}
if (import.meta.filename === resolve(process.argv[1] ?? '')) {
const problems = localeMetadataProblems(resolve(import.meta.dirname, '..'))
for (const problem of problems) console.error(problem)
if (problems.length > 0) process.exitCode = 1
else console.log('verify-locale-metadata: all package and template metadata has complete Japanese translations.')
}
+3 -1
View File
@@ -45,11 +45,12 @@ describe('Markdown source discovery', () => {
])
})
it('checks package instructions and nested docs while excluding frozen notes', () => {
it('checks package instructions, nested docs, and detail documents while excluding frozen notes', () => {
const root = layout({
'packages/AGENTS.md': '[missing](missing.md)\n',
'packages/core/README.md': '[missing](missing.md)\n',
'packages/core/probe/README.md': '[missing](missing.md)\n',
'packages/core/probe/docs/DETAIL.md': '![missing](missing.png)\n',
'.agents/notes/archived/process/frozen.md': '[historical](missing.md)\n',
})
const sources = markdownLinkSourcePaths(root)
@@ -58,6 +59,7 @@ describe('Markdown source discovery', () => {
join('packages', 'AGENTS.md'),
join('packages', 'core', 'README.md'),
join('packages', 'core', 'probe', 'README.md'),
join('packages', 'core', 'probe', 'docs', 'DETAIL.md'),
])
})
})
+2
View File
@@ -24,6 +24,8 @@ const PATTERNS = [
'packages/*.md',
'packages/*/*.md',
'packages/*/*/*.md',
// Plugin detail documents that locale `meta.detail` names.
'packages/*/*/docs/*.md',
'AGENTS.md',
'.agents/skills/**/*.md',
]
Loaded 100 of 4874 files, more files were not shown because too many files have changed in this diff. Show more