merge: bring master into feat/pwsh-persistent-pty

This commit is contained in:
_Kerman
2026-08-18 13:36:46 +08:00
835 changed files with 15867 additions and 5244 deletions
@@ -0,0 +1,49 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { resolveLinuxNodePtyAddon } from './build-exe-for-python-sdk-native-pty.ts'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
describe('resolveLinuxNodePtyAddon', () => {
it('prefers the manylinux build produced by the release workflow', () => {
const root = temporaryPackage()
const built = createAddon(root, 'build', 'Release', 'pty.node')
createAddon(root, 'prebuilds', 'linux-x64', 'pty.node')
expect(resolveLinuxNodePtyAddon(root, 'x64')).toBe(built)
})
it('uses the target prebuild after an ordinary beta install', () => {
const root = temporaryPackage()
const prebuilt = createAddon(root, 'prebuilds', 'linux-arm64', 'pty.node')
expect(resolveLinuxNodePtyAddon(root, 'arm64')).toBe(prebuilt)
})
it('reports both expected locations when no addon is installed', () => {
const root = temporaryPackage()
expect(() => resolveLinuxNodePtyAddon(root, 'x64')).toThrow(
`node-pty addon is absent from both ${join(root, 'build', 'Release', 'pty.node')} and ${join(root, 'prebuilds', 'linux-x64', 'pty.node')}`,
)
})
})
function temporaryPackage(): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-node-pty-addon-'))
roots.push(root)
return root
}
function createAddon(root: string, ...segments: string[]): string {
const path = join(root, ...segments)
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, '')
return path
}
@@ -0,0 +1,23 @@
/** Resolve the native node-pty input used by the Python SDK runtime builder. */
import { existsSync } from 'node:fs'
import { join } from 'node:path'
/**
* Prefer the workflow's manylinux build and fall back to node-pty's target prebuild.
* @param packageDirectory - installed node-pty package directory.
* @param arch - Linux target architecture.
* @returns the existing addon path.
*/
export function resolveLinuxNodePtyAddon(
packageDirectory: string,
arch: 'x64' | 'arm64',
): string {
const built = join(packageDirectory, 'build', 'Release', 'pty.node')
if (existsSync(built)) return built
const prebuilt = join(packageDirectory, 'prebuilds', `linux-${arch}`, 'pty.node')
if (existsSync(prebuilt)) return prebuilt
throw new Error(
`build-exe-for-python-sdk: node-pty addon is absent from both ${built} and ${prebuilt}.`,
)
}
+12 -3
View File
@@ -11,6 +11,7 @@ import { existsSync, statSync } from 'node:fs'
import { chmod, copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'
import { basename, dirname, join, resolve, sep } from 'node:path'
import { parseArgs } from 'node:util'
import { resolveLinuxNodePtyAddon } from './build-exe-for-python-sdk-native-pty.ts'
const root = resolve(import.meta.dirname, '..')
@@ -409,8 +410,8 @@ class SingleExeBuild {
}
/**
* Put the target node-pty addon in the staged closure. Linux npm installs
* build it from source, but legacy deploy omits that side-effect directory.
* Put the target node-pty addon in the staged closure. The release workflow
* provides a manylinux build; ordinary installs use node-pty's target prebuild.
* @param target - the pkg target whose native addon is being staged.
*/
private async prepareNativePty(target: Target): Promise<void> {
@@ -418,8 +419,16 @@ class SingleExeBuild {
if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${stagedBuild}`)
else await rm(stagedBuild, { recursive: true, force: true })
if (target.platform !== 'linux') return
const source = join(root, 'packages', 'subprocess', 'subprocess-local', 'node_modules', 'node-pty', 'build', 'Release', 'pty.node')
const packageDirectory = join(
root,
'packages',
'subprocess',
'subprocess-local',
'node_modules',
'node-pty',
)
const destination = join(stagedBuild, 'Release', 'pty.node')
const source = resolveLinuxNodePtyAddon(packageDirectory, target.arch)
if (this.cli.dryRun) {
console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`)
return
+10 -1
View File
@@ -135,7 +135,16 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
'@deepseek-ai/dsh-base': ['cordis.patch.yml'],
'@deepseek-ai/dsh-web-app': ['cordis.patch.yml'],
'@deepseek-ai/dsh-headless': ['cordis.patch.yml'],
// Statically linked client libraries keep their stylesheets next to the emitted
// JavaScript, which imports them by relative path: the compile shell runs
// them through its own CSS pipeline, so the sheets are published artifacts.
// The glob covers whichever sheets a package emits; sourcemaps stay
// unpublished, as everywhere else in the repository.
'@deepseek-ai/dsh-client-ui-primitives': ['lib/**/*.css'],
'@deepseek-ai/dsh-client-web': ['lib/**/*.css'],
'@deepseek-ai/dsh-client-ui-theme': ['lib/styles'],
// The CPython side ships as source .py files, published as-is rather than built.
'@deepseek-ai/dsh-code-runtime-python': ['py/**/*.py'],
// The Python runtime uses a distinct closed-resolution bin; the public CLI
// keeps config-owned bare-package resolution through lib/bin.js.
'@deepseek-ai/dsh-sdk-jsonrpc-demo': ['lib/packaged-bin.js'],
@@ -167,7 +176,7 @@ function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] {
...exportDefault(manifest, './client') === './lib/client.js' ? ['lib/client.js'] : [],
// runtime's shell-held loader subpath ships as its own bundle beside the client half.
...exportDefault(manifest, './loader') === './lib/loader.js' ? ['lib/loader.js'] : [],
// web-react's store subpath ships its own bundle (single-entry builds; no shared chunk).
// A store subpath ships its own bundle (single-entry builds; no shared chunk).
...exportDefault(manifest, './store') === './lib/store/index.js' ? ['lib/store/index.js'] : [],
// A surface bundle's startup row is its own bundle: the Loader imports it
// as a row module, so it cannot ride inside the package entry.
+4
View File
@@ -72,6 +72,9 @@ describe('CI workflow', () => {
expect(windowsNative['runs-on']).toContain('dsh-windows-2025-16core')
expect(windowsNative.name).toBe('windows node 24 / native complete')
expect(windowsNative.if).toBe("github.event_name == 'pull_request'")
expect(windowsNative.env).toMatchObject({
DSH_COVERAGE_TEST_TIMEOUT_MS: '30000',
})
const nativeCommandSteps = (windowsNative.steps as unknown[]).filter((step): step is Record<string, unknown> & { run: string } => (
isRecord(step) && typeof step.run === 'string'
))
@@ -342,6 +345,7 @@ describe('Python release workflows', () => {
expect(manylinuxAddon).toMatchObject({ if: "runner.os == 'Linux'" })
expect(JSON.stringify(manylinuxAddon)).toContain('manylinux_2_28_x86_64')
expect(JSON.stringify(manylinuxAddon)).toContain('manylinux_2_28_aarch64')
expect(JSON.stringify(manylinuxAddon)).toContain('npm_config_build_from_source=true pnpm run install')
expect(JSON.stringify(manylinuxAddon)).toContain('$HOME/setup-pnpm:$HOME/setup-pnpm:ro')
expect(JSON.stringify(manylinuxAddon)).toContain('node-pty-glibc-versions.txt')
expect(JSON.stringify(manylinuxAddon)).toContain('le 2.28')
+54 -6
View File
@@ -1,6 +1,6 @@
/**
* CSS Modules enter client bundles through virtual modules, so the loader must
* explicitly register the underlying stylesheet as a watch dependency.
* Stylesheets enter client bundles through virtual modules, so the loader must
* register their physical files as watch dependencies.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -14,7 +14,7 @@ interface CssPlugin {
load?: (this: { addWatchFile(id: string): void }, id: string) => Promise<string | null>
}
function cssPlugin(): CssPlugin {
function cssPlugin(name: 'dsh-css-modules-inline' | 'dsh-css-global-inline' | 'dsh-css-text-inline'): CssPlugin {
const configs = clientBundle(
'@deepseek-ai/dsh-client-test',
['lib/types/index.js', 'lib/types/invariant.js'],
@@ -22,8 +22,8 @@ function cssPlugin(): CssPlugin {
const client = configs.find(config => config.platform === 'browser')
if (client === undefined) throw new Error('client config missing')
const plugins = (client as { plugins: CssPlugin[] }).plugins
const plugin = plugins.find(candidate => candidate.name === 'dsh-css-modules-inline')
if (plugin === undefined) throw new Error('CSS Modules plugin missing from client config')
const plugin = plugins.find(candidate => candidate.name === name)
if (plugin === undefined) throw new Error(`${name} missing from client config`)
return plugin
}
@@ -34,7 +34,7 @@ describe('client bundle CSS Modules', () => {
const stylesheet = join(root, 'Fixture.module.css')
const importer = join(root, 'index.ts')
await writeFile(stylesheet, '.root { color: red; }\n')
const plugin = cssPlugin()
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')
@@ -50,3 +50,51 @@ describe('client bundle CSS Modules', () => {
}
})
})
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 output = await plugin.load.call({ addWatchFile: id => watched.push(id) }, virtualId)
expect(watched).toEqual([stylesheet])
expect(output).toContain('data-plugin-css')
expect(output).toContain('body{color:red}')
} finally {
await rm(root, { recursive: true, force: true })
}
})
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 output = await plugin.load.call({ addWatchFile: id => watched.push(id) }, virtualId)
expect(watched).toEqual([stylesheet])
expect(output).toContain('export default "body{color:red}"')
expect(output).not.toContain('data-plugin-css')
} finally {
await rm(root, { recursive: true, force: true })
}
})
})
+48 -12
View File
@@ -4,7 +4,7 @@
*/
import { fileURLToPath } from 'node:url'
import { describe, expect, it, vi } from 'vitest'
import { CLIENT_EXTERNALS, clientBundle } from '../packages/client/tsdown.client.ts'
import { clientBundle, requestedExternals } from '../packages/client/tsdown.client.ts'
type ResolveId = (source: string) => null | { id: string; external: boolean }
@@ -14,7 +14,10 @@ interface CssModulePlugin {
load?: (this: { addWatchFile: (id: string) => void }, id: string) => Promise<unknown>
}
function clientConfigs(id = '@deepseek-ai/dsh-client-test') {
/** A representative dynamic bundle using the shared client baseline. */
const REQUESTING_PACKAGE = '@deepseek-ai/dsh-client-ui-conversation'
function clientConfigs(id = REQUESTING_PACKAGE) {
return clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])(
{ env: { DSH_BUILD_FACE: 'client' } },
).filter(config => config.platform === 'browser')
@@ -36,10 +39,10 @@ function clientSourceMapPath(packagePath: string): string {
return fileURLToPath(new URL(`../packages/${packagePath}/lib/client.js.map`, import.meta.url))
}
function purityResolveId(): ResolveId {
function purityResolveId(id = REQUESTING_PACKAGE): ResolveId {
// libEntry is spelled at every call site (no default) so the
// package-invariants text check can see the invariant entry per package.
const configs = clientConfigs()
const configs = clientConfigs(id)
const plugins = (configs[0] as { plugins: { name: string; resolveId?: unknown }[] }).plugins
const gate = plugins.find(p => p.name === 'dsh-client-bundle-purity')
if (gate?.resolveId === undefined) throw new Error('purity plugin missing from client config')
@@ -59,15 +62,16 @@ function cssModulePlugin(): CssModulePlugin {
describe('client bundle purity gate', () => {
const resolveId = purityResolveId()
it('leaves platform table entries and non-scoped specifiers alone', () => {
it('leaves default externals and non-scoped specifiers alone', () => {
expect(resolveId('@deepseek-ai/dsh-client-ui-slots')).toBeNull()
expect(resolveId('@deepseek-ai/dsh-client-web-react')).toBeNull()
expect(resolveId('@deepseek-ai/dsh-client-ui-primitives')).toBeNull()
expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull()
expect(resolveId('react')).toBeNull()
expect(resolveId('zod')).toBeNull()
})
it('rejects retired table entries (web-react/store left the 8-entry seed)', () => {
it('rejects the retired web-react platform package', () => {
expect(() => resolveId('@deepseek-ai/dsh-client-web-react')).toThrow(/purity/)
expect(() => resolveId('@deepseek-ai/dsh-client-web-react/store')).toThrow(/purity/)
})
@@ -89,17 +93,49 @@ describe('client bundle purity gate', () => {
expect(() => resolveId('@deepseek-ai/dsh-client-web')).toThrow(/purity/)
})
it('throws on cross-plugin value imports — bare plugin names and /client subpaths alike (the rewrite arm is gone)', () => {
it('throws on cross-plugin value imports — bare plugin names and /client subpaths alike', () => {
expect(() => resolveId('@deepseek-ai/dsh-client-connection')).toThrow(/purity/)
expect(() => resolveId('@deepseek-ai/dsh-client-runtime')).toThrow(/purity/)
expect(() => resolveId('@deepseek-ai/dsh-client-ui-layout/client')).toThrow(/purity/)
})
it('carries exactly one documented temporary exemption: runtime/client (store engine pending rehoming)', () => {
it('admits the parser-preloaded runtime for every dynamic bundle', () => {
expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull()
const clientChannels = CLIENT_EXTERNALS.filter(
entry => entry.startsWith('@deepseek-ai/') && entry.endsWith('/client'))
expect(clientChannels).toEqual(['@deepseek-ai/dsh-client-runtime/client'])
const withoutRequest = purityResolveId('@deepseek-ai/dsh-client-ui-goal')
expect(withoutRequest('@deepseek-ai/dsh-client-runtime/client')).toBeNull()
})
it('externalizes the baseline independently of each package manifest', () => {
const requesting = clientConfigs()[0]?.deps as { neverBundle: (specifier: string) => boolean }
const plain = clientConfigs('@deepseek-ai/dsh-client-connection')[0]?.deps as {
neverBundle: (specifier: string) => boolean
}
expect(requesting.neverBundle('react')).toBe(true)
expect(requesting.neverBundle('zod')).toBe(false)
expect(plain.neverBundle('react')).toBe(true)
expect(plain.neverBundle('@deepseek-ai/dsh-client-runtime/client')).toBe(true)
})
})
describe('client bundle module requests', () => {
it('requests what the declaration lists', () => {
const requests = requestedExternals('@deepseek-ai/dsh-client-fixture', {
external: ['react', 'react/jsx-runtime', '@deepseek-ai/dsh-client-ui-slots'],
})
expect([...requests].sort()).toEqual([
'@deepseek-ai/dsh-client-ui-slots', 'react', 'react/jsx-runtime',
])
})
it('requests nothing when the declaration is absent', () => {
expect(requestedExternals('@deepseek-ai/dsh-client-fixture', {}).size).toBe(0)
})
it('rejects a malformed declaration instead of reading past it', () => {
expect(() => requestedExternals('@deepseek-ai/dsh-client-fixture', { external: 'react' }))
.toThrow(/dsh\.client\.external must be a string array/)
})
})
+1
View File
@@ -38,4 +38,5 @@ export const coverageExemptHeavySuites: readonly CoverageExemptSuite[] = [
{ filter: 'scripts/install-lefthook.spec.ts', exclude: 'scripts/install-lefthook.spec.ts' },
{ filter: 'scripts/oxlint-contract.spec.ts', exclude: 'scripts/oxlint-contract.spec.ts' },
{ filter: 'scripts/change-scope.spec.ts', exclude: 'scripts/change-scope.spec.ts' },
{ filter: 'scripts/translation-pairing-merge.spec.ts', exclude: 'scripts/translation-pairing-merge.spec.ts' },
]
+26 -1
View File
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import type { TsdownBundle } from 'tsdown'
import { discoverPluginDirs, watchClientPlugins } from './dev-web.ts'
import { discoverLibraryDirs, discoverPluginDirs, watchClientPlugins } from './dev-web.ts'
it('discovers dsh.client packages with sibling roles', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-discovery-'))
@@ -24,6 +24,31 @@ it('discovers dsh.client packages with sibling roles', async () => {
}
})
it('discovers client-preset packages the shell links, excluding loader-delivered and test infrastructure', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-library-'))
try {
const write = async (dir: string, manifest: unknown, config: string): Promise<void> => {
await mkdir(join(root, dir), { recursive: true })
await writeFile(join(root, dir, 'package.json'), JSON.stringify(manifest))
await writeFile(join(root, dir, 'tsdown.config.ts'), config)
}
const clientPreset = "import { clientLibrary } from '../tsdown.client.ts'\nexport default clientLibrary('x', [])\n"
// Linked by the compile shell: client preset, no loader-delivered half.
await write('packages/client/linked', {}, clientPreset)
// Loader-delivered: discoverPluginDirs owns it, so it must not appear twice.
await write('packages/client/delivered', { dsh: { client: { platform: 'web' } } }, clientPreset)
// Test infrastructure builds through the preset but never enters the shell graph.
await write('packages/test-support/harness', {}, clientPreset)
// Host package with its own config: not a client-face build at all.
await write('packages/host/server', {}, "import { defineConfig } from 'tsdown'\nexport default defineConfig({})\n")
expect(discoverLibraryDirs(root)).toEqual(['packages/client/linked'])
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('rebuilds a client-plugin bundle after its source changes', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-watch-'))
let bundles: TsdownBundle[] = []
+137 -13
View File
@@ -1,17 +1,28 @@
/**
* Watch-build for client-plugin HMR: runs every `dsh.client` plugin package
* through the tsdown JS API in watch mode. Reload signaling is not this
* script's business — the host webserver stat-polls the bundles it serves and
* broadcasts `rebuilt` frames itself (`dsh web`), so any process that
* rewrites `lib/client.js` files triggers reloads; this script is merely the
* convenient way to keep them all rebuilt on source change.
* Watch-build for the web dev loop: rebuilds every artifact the browser reads
* from a source edit. Reload signaling is not this script's business — the host
* webserver stat-polls the bundles it serves and broadcasts `rebuilt` frames
* itself (`dsh web`), so any process that rewrites `lib/client.js` files
* triggers reloads; this script is merely the convenient way to keep them all
* rebuilt on source change.
*
* Usage: `pnpm exec tsx scripts/dev-web.ts [--poll[=ms]]`. Requires the
* packages' node halves built once (`tsc -b tsconfig.build.json`): the lib
* config's entries are tsc output. `--poll` switches the source-file watcher
* to polling (default 500ms): network mounts (weka) deliver no inotify
* Three stages, because the compile shell links built lib products rather than
* sources: `tsc -b tsconfig.client.json` emits `lib/types` (the tsdown lib
* entries are that emit, not `src`), tsdown bundles `lib/index.js` and
* `lib/client.js`, and `vite build` rewrites `apps/web/dist`, which `dsh web`
* serves. A missing stage does not fail — it silently shows the previous
* artifact, so an edit appears to do nothing.
*
* MUST NOT run concurrently with `pnpm run build`: both write the same
* `lib/` and `apps/web/dist/` trees.
*
* Usage: `pnpm exec tsx scripts/dev-web.ts [--poll[=ms]]`. Requires one prior
* `pnpm run build`: every stage is incremental over the previous stage's output
* and none of them bootstraps a missing tree. `--poll` switches the source
* watchers to polling (default 500ms): network mounts (weka) deliver no inotify
* events, so native watching sees the initial build only and never a source
* change.
* change. Polling has to reach tsc too — a native-watching tsc never re-emits
* `lib/types`, which strands the other two stages on stale input.
*
* Each package keeps its own tsdown.config.ts untouched: this script layers
* `watch` through API-level inline config (tsdown workspace mode fills inline
@@ -20,11 +31,24 @@
import { globSync, readFileSync } from 'node:fs'
import { dirname, join, resolve, sep } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { execa } from 'execa'
import { build } from 'tsdown'
import type { TsdownBundle } from 'tsdown'
const repoRoot = fileURLToPath(new URL('..', import.meta.url))
/** Client-face type emit feeding every tsdown lib entry in the watch set. */
const CLIENT_TYPE_PROGRAM = 'tsconfig.client.json'
/** Compile-shell workspace whose dist `dsh web` serves. */
const SHELL_PACKAGE = '@deepseek-ai/dsh-web-frontend'
/**
* Test infrastructure builds through the client preset but never enters the
* shell's module graph, so it is not a dev-loop artifact.
*/
const TEST_INFRASTRUCTURE_PREFIX = 'packages/test-support/'
/**
* Discover the watch workspace by declaration: every packages/<group>/<name>
* whose package.json carries `dsh.client` with platform "web" is a client
@@ -44,6 +68,32 @@ export function discoverPluginDirs(root = repoRoot): string[] {
return dirs
}
/**
* Discover the statically linked library packages: the other half of the same
* partition {@link discoverPluginDirs} takes. A package that builds through the
* client preset without declaring `dsh.client` has no loader-delivered browser
* half, so the compile shell links its `lib/index.js` instead — and an edit to
* its source reaches the browser only once that bundle is rewritten. Deriving
* the set from the build preset rather than a hand list keeps it correct when
* dependency sections move around; deriving it from `dependencies` would not,
* because client packages declare their build inputs as devDependencies.
* @param root - repository root containing the grouped package directories.
* @returns workspace-relative library package directories.
*/
export function discoverLibraryDirs(root = repoRoot): string[] {
const dirs: string[] = []
for (const configPath of globSync('packages/*/*/tsdown.config.ts', { cwd: root }).sort()) {
const dir = dirname(configPath).split(sep).join('/')
if (dir.startsWith(TEST_INFRASTRUCTURE_PREFIX)) continue
if (!readFileSync(join(root, configPath), 'utf8').includes('tsdown.client.ts')) continue
const manifest = JSON.parse(readFileSync(join(root, dir, 'package.json'), 'utf8')) as {
dsh?: { client?: unknown }
}
if (manifest.dsh?.client === undefined) dirs.push(dir)
}
return dirs
}
/**
* Start the tsdown watch build used by `pnpm run dev:web`.
* @param root - repository or fixture root passed to tsdown.
@@ -85,14 +135,56 @@ export async function watchClientPlugins(
return bundles
}
/**
* Live watcher processes to terminate when this script is interrupted. Stages
* register themselves as they start, so the set is complete from the first
* spawn: an interrupt during a later stage's startup still tears down the
* earlier ones instead of orphaning them.
*/
const stages: StageHandle[] = []
/**
* Spawn one watcher stage, inheriting stdio, registering it for teardown, and
* failing loud if it ever exits: a dead stage leaves the artifact chain silently
* stale, which reads as "my edit did nothing" — the one failure this script
* exists to prevent.
* @param stage - command label used in the exit diagnostic.
* @param command - executable, resolved from the workspace bin when local.
* @param args - command arguments.
* @param local - whether to resolve `command` from the workspace's installed bins.
*/
function spawnStage(stage: string, command: string, args: readonly string[], local: boolean): void {
const child = execa(command, [...args], {
cwd: repoRoot,
stdio: 'inherit',
preferLocal: local,
reject: false,
})
stages.push({ kill: () => { child.kill() } })
void child.then((result) => {
console.error(`dev-web: ${stage} exited (code ${String(result.exitCode)}); the artifact chain is now stale`)
process.exit(1)
})
}
/** The only capability this script needs from a live watcher process. */
interface StageHandle {
readonly kill: () => void
}
const invokedPath = process.argv[1]
const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href
if (isMain) {
const pluginDirs = discoverPluginDirs()
const libraryDirs = discoverLibraryDirs()
if (pluginDirs.length === 0) {
console.error('dev-web: no dsh.client (platform "web") packages found under packages/')
process.exit(1)
}
if (libraryDirs.length === 0) {
console.error('dev-web: no client-preset library packages found under packages/ — the compile shell links their lib products, so an empty set means the discovery predicate is stale')
process.exit(1)
}
const args = process.argv.slice(2)
const pollArg = args.find(a => a === '--poll' || a.startsWith('--poll='))
@@ -106,9 +198,41 @@ if (isMain) {
process.exit(1)
}
await watchClientPlugins(repoRoot, pluginDirs, pollInterval)
// Registered before any stage starts: `stages` is read at signal time, so an
// interrupt during tsdown's initial builds still kills whatever is running.
const stop = (): void => { for (const stage of stages) stage.kill() }
process.once('SIGINT', stop)
process.once('SIGTERM', stop)
// tsc has no polling interval flag, so `--poll` selects its fixed-interval
// watchers rather than an interval. Dropping that translation leaves tsc
// natively watching on a network mount where inotify never fires: it stops
// re-emitting lib/types, and the two later stages then rebuild forever from
// stale input without printing anything.
spawnStage(`tsc -b ${CLIENT_TYPE_PROGRAM} --watch`, 'tsc', [
'-b', CLIENT_TYPE_PROGRAM, '--watch', '--preserveWatchOutput',
...pollInterval !== undefined
? ['--watchFile', 'fixedPollingInterval', '--watchDirectory', 'fixedPollingInterval']
: [],
], true)
// tsdown's initial builds are awaited before the dist watcher starts so vite's
// first build reads current lib bundles rather than whatever the last full
// build left. Its own watch then covers later lib rewrites — those files are
// in its module graph.
await watchClientPlugins(repoRoot, [...pluginDirs, ...libraryDirs], pollInterval)
// Through the shell's own `watch` script rather than vite's API: vite is not a
// repository-root dependency, and more importantly the vite root is its
// working directory — `resolve.dedupe` resolves react from that root, so
// running vite from anywhere but apps/web silently switches which react copy
// the bundle gets.
spawnStage('vite build --watch', 'pnpm', ['--filter', SHELL_PACKAGE, 'run', 'watch'], false)
console.log(
`dev-web: watching ${String(pluginDirs.length)} dsh.client plugin packages`
+ `${pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : ''}:\n ${pluginDirs.join('\n ')}`,
+ ` and ${String(libraryDirs.length)} statically linked library packages`
+ (pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : '')
+ `, plus tsc -b ${CLIENT_TYPE_PROGRAM} and the ${SHELL_PACKAGE} dist build:\n `
+ [...pluginDirs, ...libraryDirs].join('\n '),
)
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"AGENTS.md": 1900,
"AGENTS.md": 1950,
"docs/AGENTS.md": 1320,
"docs/architecture.md": 2400,
"docs/cordis-primer.md": 600,
+1 -1
View File
@@ -12,7 +12,7 @@ export function builtDeclarationPath(candidate: string): string {
if (sourceFile?.[1] && sourceFile[2]) {
return `${sourceFile[1]}/lib/types/${sourceFile[2]}.d.ts`
}
// Directory subpath entries (web-react's /store, runtime's /client): the
// Directory subpath entries (for example, runtime's /client): the
// source dir maps to the same dir under lib/types (index resolution applies).
const sourceDir = /^(.*)\/src\/(.+)$/.exec(candidate)
if (sourceDir?.[1] && sourceDir[2]) {
+2 -1
View File
@@ -135,7 +135,8 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
dshHomePath: 'not a service: boot-provided root accessor function (typeof dshHomePath | undefined) for Loader !!js config expressions — packages/boot/app-boot/README.md owns the boot contract',
launchEnvironment: 'not a service: launcher-provided root accessor value (LaunchEnvironmentSnapshot | undefined) — packages/util/launch-environment/README.md owns this launcher contract',
connection: 'interface-typed (HostConnectionHandle); implementing class HostConnectionService is declared in rpc-host.ts — packages/client/connection/README.md owns the API',
appShell: 'client-side interface-typed browser service — packages/client/web/README.md owns the API',
uiRenderer: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API',
settingsSchema: 'client-side schema introspection service — packages/client/ui-settings/README.md owns the API',
settingsScope: 'client-side settings-namespace transport service — packages/client/ui-settings/README.md owns the API',
chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-conversation/README.md owns the API',
commandUi: 'client-side interface-typed browser service — packages/client/ui-commands/README.md owns the API',
+1
View File
@@ -73,6 +73,7 @@ class CatalogAttachmentStore extends AttachmentStore {
maxImagesPerMessage: 1,
maxMessageImageBytes: 1,
maxImagePixels: 1,
maxImageDimension: 1,
mediaTypes: Object.freeze(['image/png'] as const),
})
+1 -1
View File
@@ -23,7 +23,7 @@ const OWNERSHIP_MARKER_VERSION = 1
const OWNERSHIP_MARKER_OWNER = 'deepseek-harness worktree-local lefthook hooks'
const INSTALL_LOCK = 'dsh-lefthook-install.lock'
const INSTALL_LOCK_TIMEOUT_MS = 30_000
const INSTALL_LOCK_INITIALIZATION_TIMEOUT_MS = 1_000
const INSTALL_LOCK_INITIALIZATION_TIMEOUT_MS = 5_000
const INSTALL_LOCK_POLL_MS = 50
const ALLOW_HOOKS_PATH_OVERRIDE = 'DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE'
const REPOSITORY_EXTENSION_PATTERN = '^extensions\\.'
+5 -5
View File
@@ -23,9 +23,9 @@ const pairingMergeDriver = 'scripts/merge-translation-pairing-driver.sh %O %A %B
const scriptsDirectory = fileURLToPath(new URL('.', import.meta.url))
const tsxPackageDirectory = dirname(fileURLToPath(import.meta.resolve('tsx/package.json')))
const fixtures: string[] = []
// Multi-worktree cases spawn several Git and Node subprocesses; coverage concurrency can
// legitimately exceed Vitest's default deadline without changing the installer behavior.
const MULTI_PROCESS_TEST_TIMEOUT_MS = 20_000
// Multi-worktree cases spawn several Git and Node subprocesses; native Windows
// coverage concurrency can delay them without changing installer behavior.
const MULTI_PROCESS_TEST_TIMEOUT_MS = 30_000
interface Fixture {
container: string
@@ -184,7 +184,7 @@ function installLockPath(fixture: Fixture): string {
}
async function waitForPath(path: string): Promise<void> {
const deadline = Date.now() + 5_000
const deadline = Date.now() + 10_000
while (!existsSync(path)) {
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${path}`)
await new Promise(resolveWait => setTimeout(resolveWait, 10))
@@ -211,7 +211,7 @@ function runInstaller(
})
}
describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
describe('worktree-local Lefthook installer', { timeout: 30_000 }, () => {
for (const [label, extraEnv] of [
['CI', { CI: 'true' }],
['GitHub Actions', { GITHUB_ACTIONS: 'true' }],
+33 -5
View File
@@ -13,7 +13,11 @@ afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function fixture(exportPath = './lib/index.js'): string {
function fixture(options: {
exportPath?: string
indexSource?: string
files?: Record<string, string>
} = {}): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-publint-all-'))
roots.push(root)
const packageDir = join(root, 'packages/core/probe')
@@ -26,10 +30,14 @@ function fixture(exportPath = './lib/index.js'): string {
engines: { node: '>=22.19' },
sideEffects: false,
files: ['lib'],
exports: { '.': { default: exportPath } },
exports: { '.': { default: options.exportPath ?? './lib/index.js' } },
}, null, 2)}\n`)
writeFileSync(join(packageDir, 'README.md'), '# Probe\n')
writeFileSync(join(packageDir, 'lib/index.js'), 'export const probe = true\n')
writeFileSync(join(packageDir, 'lib/index.js'), options.indexSource ?? 'export const probe = true\n')
for (const [path, source] of Object.entries(options.files ?? {})) {
mkdirSync(join(packageDir, path, '..'), { recursive: true })
writeFileSync(join(packageDir, path), source)
}
writeFileSync(join(packageDir, 'unpublished.js'), 'export const hidden = true\n')
return root
}
@@ -54,14 +62,34 @@ describe('publint package runner', () => {
})
it('rejects an export that exists in the workspace but is not published', () => {
const result = run(fixture('./unpublished.js'))
const result = run(fixture({ exportPath: './unpublished.js' }))
expect(result.status).toBe(1)
expect(result.stdout).toContain('unpublished.js')
})
it('rejects a public export whose built file is missing', () => {
const result = run(fixture('./lib/missing.js'))
const result = run(fixture({ exportPath: './lib/missing.js' }))
expect(result.status).toBe(1)
expect(result.stdout).toContain('missing.js')
})
it('accepts published relative JavaScript and CSS targets', () => {
const result = run(fixture({
indexSource: "export { helper } from './helper.js'\nimport './theme.css'\n",
files: {
'lib/helper.js': 'export const helper = true\n',
'lib/theme.css': ':root {}\n',
},
}))
expect(result.status, result.stderr).toBe(0)
})
it('rejects unpublished relative JavaScript and CSS targets', () => {
const result = run(fixture({
indexSource: "export { helper } from './missing.js'\nimport './missing.css'\n",
}))
expect(result.status).toBe(1)
expect(result.stderr).toContain('imports "./missing.js"')
expect(result.stderr).toContain('imports "./missing.css"')
})
})
+88 -8
View File
@@ -7,10 +7,11 @@ import {
statSync,
} from 'node:fs'
import { availableParallelism } from 'node:os'
import { dirname, relative, resolve, sep } from 'node:path'
import { dirname, posix, relative, resolve, sep } from 'node:path'
import { parseArgs } from 'node:util'
import { publint, type Message, type PackFile } from 'publint'
import { formatMessage } from 'publint/utils'
import ts from 'typescript'
const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY'
const repositoryRoot = resolve(import.meta.dirname, '..')
@@ -32,8 +33,21 @@ interface PackageManifest {
}
type PublintResult =
| { path: string; status: 'passed'; messages: Message[]; manifest: Record<string, unknown> }
| { path: string; status: 'failed'; messages: Message[]; manifest: Record<string, unknown>; failure?: string }
| {
path: string
status: 'passed'
messages: Message[]
closureViolations: string[]
manifest: Record<string, unknown>
}
| {
path: string
status: 'failed'
messages: Message[]
closureViolations: string[]
manifest: Record<string, unknown>
failure?: string
}
function workspacePackages(): PackageTarget[] {
return globSync('packages/*/*/package.json', { cwd: packagesRoot })
@@ -103,21 +117,84 @@ function addPath(path: string, paths: Set<string>): void {
}
}
interface RelativeImport {
specifier: string
line: number
}
/** Return relative imports whose targets are absent from the publication view. */
function publicationClosureViolations(target: PackageTarget, files: readonly PackFile[]): string[] {
const published = new Set(files.map(file => file.name))
const violations: string[] = []
for (const file of files) {
if (!/\.(?:js|mjs|cjs)$/.test(file.name)) continue
const bytes = file.data instanceof ArrayBuffer ? new Uint8Array(file.data) : file.data
const source = typeof bytes === 'string' ? bytes : Buffer.from(bytes).toString('utf8')
for (const imported of relativeImports(file.name, source)) {
const resolved = posix.normalize(posix.join(posix.dirname(file.name), imported.specifier))
if (resolutionCandidates(resolved).some(candidate => published.has(candidate))) continue
violations.push(
`${target.path}/${file.name.slice('package/'.length)}:${String(imported.line)}`
+ ` imports ${JSON.stringify(imported.specifier)}, but ${target.manifest.name ?? target.path}`
+ ` does not publish ${JSON.stringify(resolved.slice('package/'.length))}`,
)
}
}
return violations
}
/** Paths a relative JavaScript module request can resolve to in a published package. */
function resolutionCandidates(target: string): string[] {
const base = target.replace(/\/+$/, '')
return [
target,
...['.js', '.mjs', '.cjs', '/index.js', '/index.mjs', '/index.cjs'].map(suffix => base + suffix),
]
}
/** Extract relative static imports, re-exports, dynamic imports, and requires. */
function relativeImports(file: string, sourceText: string): RelativeImport[] {
const source = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, false, ts.ScriptKind.JS)
const imports: RelativeImport[] = []
const record = (node: ts.Node, literal: ts.Expression | undefined): void => {
if (literal === undefined || !ts.isStringLiteralLike(literal) || !literal.text.startsWith('.')) return
imports.push({
specifier: literal.text,
line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
})
}
const visit = (node: ts.Node): void => {
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
record(node, node.moduleSpecifier)
} else if (ts.isCallExpression(node)
&& (node.expression.kind === ts.SyntaxKind.ImportKeyword
|| ts.isIdentifier(node.expression) && node.expression.text === 'require')) {
record(node, node.arguments[0])
}
ts.forEachChild(node, visit)
}
visit(source)
return imports
}
async function runPublint(target: PackageTarget): Promise<PublintResult> {
try {
const files = publicationFiles(target)
const closureViolations = publicationClosureViolations(target, files)
const result = await publint({
pkgDir: 'package',
pack: { files: publicationFiles(target) },
pack: { files },
})
const manifest = result.pkg as Record<string, unknown>
return result.messages.some(message => message.type === 'error')
? { path: target.path, status: 'failed', messages: result.messages, manifest }
: { path: target.path, status: 'passed', messages: result.messages, manifest }
return result.messages.some(message => message.type === 'error') || closureViolations.length > 0
? { path: target.path, status: 'failed', messages: result.messages, closureViolations, manifest }
: { path: target.path, status: 'passed', messages: result.messages, closureViolations, manifest }
} catch (error: unknown) {
return {
path: target.path,
status: 'failed',
messages: [],
closureViolations: [],
manifest: target.manifest as Record<string, unknown>,
failure: error instanceof Error ? error.message : String(error),
}
@@ -150,7 +227,10 @@ function printResult(result: PublintResult): void {
for (const message of result.messages) {
console.log(formatMessage(message, result.manifest, { color: false }) ?? message.code)
}
if (result.status === 'passed' && result.messages.length === 0) console.log('All good!')
for (const violation of result.closureViolations) console.error(violation)
if (result.status === 'passed' && result.messages.length === 0 && result.closureViolations.length === 0) {
console.log('All good!')
}
}
const packages = workspacePackages()
+31 -2
View File
@@ -104,6 +104,35 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [
// GROUP_ORDER holds `packages/<group>/` directory names, not package names.
{ file: 'scripts/gen-module-graph.ts', upstream: ['cordis'] },
{ file: 'scripts/gen-doc-graphs.ts', upstream: ['cordis'] },
// `cordis/*` is the extensions event domain, not a package subpath. The
// generated catalogs and every producer/consumer must preserve that wire id.
{ file: 'docs/event-producer-consumer.md', upstream: ['cordis'] },
{ file: 'docs/event-producer-consumer.zh.md', upstream: ['cordis'] },
{ file: 'docs/subsystems/extensions.md', upstream: ['cordis'] },
{ file: 'docs/subsystems/extensions.zh.md', upstream: ['cordis'] },
{ file: 'packages/api/remotes/src/remote-events.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/cordis-client-runner/src/client/index.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/cordis-client-runner/src/client/runtime.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/cordis-client-runner/tests/orchestrator.client.spec.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/cordis-host-runner/src/index.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/cordis-host-runner/src/inspect-registry.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/cordis-host-runner/src/types.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/cordis-host-runner/tests/helpers.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/cordis-host-runner/tests/runner.spec.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/cordis-host-runner/tests/versioning.spec.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/tool-cordis/src/api-catalog.ts', upstream: ['cordis'] },
{ 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'] },
{ file: 'scripts/gen-cordis-catalog.ts', upstream: ['cordis'] },
// The UI locale namespace and input-trigger source id are product keys.
{ file: 'packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx', upstream: ['cordis'] },
{ file: 'packages/extensions/ui-cordis/src/client/CordisActionRow.tsx', upstream: ['cordis'] },
{ file: 'packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx', upstream: ['cordis'] },
{ file: 'packages/extensions/ui-cordis/src/client/CordisPanel.tsx', upstream: ['cordis'] },
{ file: 'packages/extensions/ui-cordis/src/client/CordisRunRow.tsx', upstream: ['cordis'] },
{ file: 'packages/extensions/ui-cordis/src/client/locales.ts', upstream: ['cordis'] },
]
/** A string that must appear exactly `count` times once the rescope has run. */
@@ -275,8 +304,8 @@ const EXACT_EDITS: readonly ExactEdit[] = [
replace: `/**
* Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
* would read them as plugin packages. They carry no cross-plugin runtime
* identity to share — the framework itself is a platform module (external),
* while these are ordinary libraries a browser bundle inlines.
* 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)(\\/|$)/
+38
View File
@@ -92,6 +92,15 @@ describe('gate graph validation', () => {
},
)
it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
'keeps the client dependency policy in %s',
(mode) => {
const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
expect(ids).toContain('client-packages')
},
)
it('keeps native Windows coverage blocking while portability inventory remains observational', () => {
const gates = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))
const byId = new Map(gates.map(subject => [subject.id, subject]))
@@ -101,6 +110,35 @@ describe('gate graph validation', () => {
expect(byId.get('duplication')?.allowFailure).toBe(true)
})
it('applies one configured test and polling timeout to both coverage gates', () => {
const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', '15000', () =>
withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')))
for (const id of ['coverage', 'coverage-exempt-heavy']) {
expect(gates.find(subject => subject.id === id)?.args).toEqual(expect.arrayContaining([
'--testTimeout=15000',
'--expect.poll.timeout=15000',
]))
}
})
it('keeps Vitest timeout defaults when the coverage override is absent', () => {
const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', undefined, () =>
withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')))
for (const id of ['coverage', 'coverage-exempt-heavy']) {
expect(gates.find(subject => subject.id === id)?.args).not.toEqual(expect.arrayContaining([
expect.stringMatching(/^--(?:testTimeout|expect\.poll\.timeout)=/),
]))
}
})
it('rejects an invalid coverage timeout before starting a gate', () => {
expect(() => withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', '0', () =>
withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))))
.toThrow('DSH_COVERAGE_TEST_TIMEOUT_MS must be a positive integer')
})
it.each([
['empty', [], /gate graph has no gates/],
['duplicate ids', [gate('same'), gate('same')], /duplicate gate id "same"/],
+15
View File
@@ -252,6 +252,7 @@ function ciSharedStaticGates(): Gate[] {
pnpmScript('optional-dependency-imports', 'verify-optional-dependency-imports', {
label: 'optional dependency imports',
}),
pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }),
pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
]
}
@@ -485,6 +486,9 @@ function lintGate(options: { needs?: string[] } = {}): Gate {
// small share. A budget of 1 gives each gate 1 worker; lanes that need a
// strict total of one (the serial reference jobs) also set
// DSH_GATE_CONCURRENCY=1, which keeps the gates from overlapping at all.
// DSH_COVERAGE_TEST_TIMEOUT_MS raises Vitest's per-test and expect.poll
// defaults together for instrumented lanes whose scheduling overhead exceeds
// those defaults. Explicit fixture timeouts remain authoritative.
function coverageWorkerArgs(): { instrumented: string[]; exempt: string[] } {
const [flag] = positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers')
if (flag === undefined) return { instrumented: [], exempt: [] }
@@ -497,14 +501,23 @@ function coverageWorkerArgs(): { instrumented: string[]; exempt: string[] } {
}
}
function coverageTimeoutArgs(): string[] {
return [
...positiveIntArg('DSH_COVERAGE_TEST_TIMEOUT_MS', '--testTimeout'),
...positiveIntArg('DSH_COVERAGE_TEST_TIMEOUT_MS', '--expect.poll.timeout'),
]
}
function coverageGates(): Gate[] {
const workers = coverageWorkerArgs()
const timeouts = coverageTimeoutArgs()
return [
pnpmExec('coverage', [
'vitest',
'run',
'--coverage',
...workers.instrumented,
...timeouts,
], {
label: 'test:coverage',
env: { [COVERAGE_EXEMPT_ENV]: '1' },
@@ -514,6 +527,7 @@ function coverageGates(): Gate[] {
'run',
...coverageExemptHeavySuites.map(suite => suite.filter),
...workers.exempt,
...timeouts,
], {
label: 'test:coverage-exempt-heavy',
}),
@@ -571,6 +585,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
pnpmScript('optional-dependency-imports', 'verify-optional-dependency-imports', {
label: 'optional dependency imports',
}),
pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }),
]
}
+118 -31
View File
@@ -28,7 +28,6 @@ WORKFLOW_WORKER_TEXT = "workflow worker smoke ok"
MINIMAL_PROMPT = "Exercise the packaged minimal agent's persistent Bash and string-replacement editor."
MINIMAL_TEXT = "minimal agent smoke ok"
MINIMAL_EDITOR_PATH_PREFIX = "Editor path: "
MINIMAL_SYSTEM_PROMPT = "You are a helpful software engineer assistant."
MINIMAL_CORDIS = (
Path(__file__).resolve().parent.parent / "examples" / "jsonrpc-agent" / "minimal.cordis.yml"
)
@@ -65,10 +64,18 @@ SNAPSHOT_WORKFLOW_SCRIPT = (
f"const reply = await agent('{SNAPSHOT_WORKFLOW_CHILD_PROMPT}', {{ label: 'workflow-child' }})\n"
"return { reply }"
)
SNAPSHOT_DIRECTORY = (
ADVANCED_SNAPSHOT_DIRECTORY = (
Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "advanced"
)
SNAPSHOT_FILENAMES = ("result.json", "session.jsonl", "session.1.jsonl", "session.2.jsonl")
ADVANCED_SNAPSHOT_FILENAMES = ("result.json", "session.jsonl", "session.1.jsonl", "session.2.jsonl")
MINIMAL_SNAPSHOT_DIRECTORY = (
Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "minimal"
)
MINIMAL_SNAPSHOT_FILENAMES = ("model-visible.json",)
# The agent loop's dynamic runtime-context snapshot is the one model-visible message this
# expected output cannot carry: the same composition emits it on macOS and not on Linux
# (deepseek-harness#2488), and the file must replay on both. Everything else is compared.
RUNTIME_CONTEXT_PREFIX = "Current runtime context"
CUSTOM_CORDIS = """\
- id: sdk-jsonrpc-server
name: '@deepseek-ai/dsh-sdk-jsonrpc-server'
@@ -170,17 +177,9 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
),
None,
)
# The minimal composition's assembled system prompt, advertised tool schemas, and
# model-visible messages are pinned by its snapshot, not asserted here.
if minimal_prompt is not None:
names = advertised_tool_names(body)
if names != {"bash", "str_replace_editor"}:
raise AssertionError(f"minimal agent smoke advertised unexpected tools: {names}")
system_prompts = [
message_text(message.get("content"))
for message in messages
if isinstance(message, dict) and message.get("role") == "system"
]
if system_prompts != [MINIMAL_SYSTEM_PROMPT]:
raise AssertionError(f"minimal agent smoke assembled unexpected system prompts: {system_prompts}")
return tool_call_chunks(
"minimal-bash-1",
"bash",
@@ -485,8 +484,8 @@ def main() -> None:
args = parser.parse_args()
if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-snapshot", "direct"} and args.exe is None:
parser.error("--exe is required for custom, minimal, snapshot, and direct scenarios")
if args.update_snapshots and args.scenario not in {"all", "sdk-snapshot"}:
parser.error("--update-snapshots requires --scenario sdk-snapshot or all")
if args.update_snapshots and args.scenario not in {"all", "sdk-minimal", "sdk-snapshot"}:
parser.error("--update-snapshots requires --scenario sdk-minimal, sdk-snapshot, or all")
if args.exe is not None and not args.exe.is_file():
parser.error(f"runtime executable does not exist: {args.exe}")
@@ -498,7 +497,7 @@ def main() -> None:
smoke_sdk_custom(model.url, args.exe.resolve())
if args.scenario in {"all", "sdk-minimal"}:
assert args.exe is not None
smoke_sdk_minimal(model.url, args.exe.resolve())
smoke_sdk_minimal(model.url, args.exe.resolve(), args.update_snapshots)
if args.scenario in {"all", "sdk-snapshot"}:
assert args.exe is not None
smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots)
@@ -558,10 +557,12 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None:
assert_session_log(sessions, root, EXPECTED_TEXT, CODE_WORKER_TEXT, WORKFLOW_WORKER_TEXT)
def smoke_sdk_minimal(base_url: str, executable: Path) -> None:
def smoke_sdk_minimal(base_url: str, executable: Path, update_snapshots: bool) -> None:
"""Exercise the checked-in minimal composition through the packaged executable."""
from deepseek_harness import DeepSeekHarness
# One mock model serves every scenario of a run, so the snapshot takes this turn's slice.
first_request = len(MockModelHandler.requests)
with tempfile.TemporaryDirectory(prefix="dsh-sdk-minimal-") as temporary:
root = Path(temporary).resolve()
editor_path = root / "created.txt"
@@ -587,6 +588,11 @@ def smoke_sdk_minimal(base_url: str, executable: Path) -> None:
raise AssertionError(f"packaged editor wrote unexpected content: {editor_path.read_text()!r}")
assert_session_log(sessions, root, MINIMAL_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp")
files = build_minimal_snapshot_files(MockModelHandler.requests[first_request:], root)
compare_snapshot_files(
files, update_snapshots, MINIMAL_SNAPSHOT_DIRECTORY, MINIMAL_SNAPSHOT_FILENAMES,
)
def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None:
"""Drive and compare the advanced SDK/executable behavioral snapshot."""
@@ -628,7 +634,9 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool)
raise AssertionError("second advanced child log has no workflow-subagent result")
files = build_snapshot_files(result, logs, child_ids, root)
compare_snapshot_files(files, update_snapshots)
compare_snapshot_files(
files, update_snapshots, ADVANCED_SNAPSHOT_DIRECTORY, ADVANCED_SNAPSHOT_FILENAMES,
)
def smoke_direct(base_url: str, executable: Path) -> None:
@@ -802,6 +810,79 @@ def snapshot_child_ids(result: "RunResult") -> list[str]:
return child_ids
def build_minimal_snapshot_files(
requests: list[dict[str, object]],
cwd: Path,
) -> dict[str, str]:
"""Render the minimal composition's model-visible surface as expected output.
Every assembled system prompt, advertised tool schema, and system or user message is
kept verbatim: they carry what the deployment actually shows the model, so a plugin
that contributes an unintended system section or user message cannot pass unnoticed.
Assistant and tool payloads keep only their call identity, and the dynamic
runtime-context snapshot is dropped, because their text differs across the platforms
this expected output must replay on.
"""
snapshot = []
for body in requests:
messages = body.get("messages")
if not isinstance(messages, list):
raise AssertionError(f"minimal model request has no messages: {body}")
snapshot.append({
"tools": minimal_snapshot_text(body.get("tools"), cwd),
"messages": [
minimal_snapshot_message(message, cwd)
for message in messages
if not is_runtime_context_message(message)
],
})
return {"model-visible.json": json.dumps(snapshot, indent=2, ensure_ascii=False) + "\n"}
def is_runtime_context_message(message: object) -> bool:
"""Identify the agent loop's dynamic runtime-context snapshot, current or cleared."""
return (
isinstance(message, dict)
and message.get("role") == "user"
and message_text(message.get("content")).startswith(RUNTIME_CONTEXT_PREFIX)
)
def minimal_snapshot_message(message: object, cwd: Path) -> dict[str, object]:
"""Reduce one model-visible message to its stable, behavior-carrying parts."""
if not isinstance(message, dict):
raise AssertionError(f"minimal model request has an invalid message: {message}")
role = message.get("role")
if role in ("system", "user"):
return {"role": role, "text": minimal_snapshot_text(message_text(message.get("content")), cwd)}
if role == "assistant":
calls = message.get("tool_calls")
if not isinstance(calls, list):
raise AssertionError(f"minimal assistant message has no tool calls: {message}")
return {
"role": role,
"toolCalls": [
{"id": call.get("id"), "name": (call.get("function") or {}).get("name")}
for call in calls
if isinstance(call, dict)
],
}
if role == "tool":
return {"role": role, "toolCallId": message.get("tool_call_id"), "text": "{{tool-result}}"}
raise AssertionError(f"minimal model request has an unexpected message role: {message}")
def minimal_snapshot_text(value: object, cwd: Path) -> object:
"""Replace the scenario's temporary working directory everywhere it appears."""
if isinstance(value, str):
return value.replace(str(cwd), "{{cwd}}")
if isinstance(value, list):
return [minimal_snapshot_text(item, cwd) for item in value]
if isinstance(value, dict):
return {key: minimal_snapshot_text(item, cwd) for key, item in value.items()}
return value
def build_snapshot_files(
result: "RunResult",
logs: dict[str, list[dict[str, object]]],
@@ -838,8 +919,6 @@ def build_snapshot_files(
files[f"session.{index}.jsonl"] = render_jsonl(
[normalize_snapshot_value(record, replacements) for record in logs[child_id]]
)
if tuple(files) != SNAPSHOT_FILENAMES:
raise AssertionError(f"advanced snapshot file set drifted: {tuple(files)}")
return files
@@ -930,27 +1009,35 @@ def render_jsonl(records: list[object]) -> str:
)
def compare_snapshot_files(files: dict[str, str], update: bool) -> None:
"""Write or exactly compare the advanced executable snapshot files."""
def compare_snapshot_files(
files: dict[str, str],
update: bool,
directory: Path,
filenames: tuple[str, ...],
) -> None:
"""Write or exactly compare one scenario's expected snapshot files."""
scenario = directory.name
if tuple(files) != filenames:
raise AssertionError(f"{scenario} snapshot builder produced {tuple(files)}, expected {filenames}")
if update:
SNAPSHOT_DIRECTORY.mkdir(parents=True, exist_ok=True)
directory.mkdir(parents=True, exist_ok=True)
for name, content in files.items():
(SNAPSHOT_DIRECTORY / name).write_text(content, encoding="utf-8")
print(f"smoke-python-runtime: updated snapshots in {SNAPSHOT_DIRECTORY}")
(directory / name).write_text(content, encoding="utf-8")
print(f"smoke-python-runtime: updated snapshots in {directory}")
existing = {
path.name
for path in SNAPSHOT_DIRECTORY.iterdir()
for path in directory.iterdir()
if path.is_file()
} if SNAPSHOT_DIRECTORY.is_dir() else set()
expected = set(SNAPSHOT_FILENAMES)
} if directory.is_dir() else set()
expected = set(filenames)
if existing != expected:
raise AssertionError(
"advanced snapshot files differ: "
f"{scenario} snapshot files differ: "
f"missing={sorted(expected - existing)}, unexpected={sorted(existing - expected)}"
)
for name, actual in files.items():
expected_text = (SNAPSHOT_DIRECTORY / name).read_text(encoding="utf-8")
expected_text = (directory / name).read_text(encoding="utf-8")
if actual == expected_text:
continue
diff = "".join(difflib.unified_diff(
@@ -960,7 +1047,7 @@ def compare_snapshot_files(files: dict[str, str], update: bool) -> None:
tofile=f"actual/{name}",
))
raise AssertionError(
f"advanced executable snapshot mismatch in {name}; "
f"{scenario} executable snapshot mismatch in {name}; "
"rerun with --update-snapshots after reviewing the behavior\n"
f"{diff}"
)
@@ -0,0 +1,430 @@
[
{
"tools": [
{
"type": "function",
"function": {
"name": "bash",
"description": "Run commands in a bash shell\n* When invoking this tool, the contents of the \"command\" parameter does NOT need to be XML-escaped.\n* You don't have access to the internet via this tool.\n* You do have access to a mirror of common linux and python packages via apt and pip.\n* State is persistent across command calls and discussions with the user.\n* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.\n* Please avoid commands that may produce a very large amount of output.\n* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to run. Relative path is preferred in the command."
}
},
"required": [
"command"
]
}
}
},
{
"type": "function",
"function": {
"name": "str_replace_editor",
"description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with `<response clipped>`\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.",
"enum": [
"view",
"create",
"str_replace",
"insert"
]
},
"path": {
"type": "string",
"description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`."
},
"file_text": {
"type": "string",
"description": "Required parameter of `create` command, with the content of the file to be created."
},
"insert_line": {
"type": "integer",
"description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`."
},
"new_str": {
"type": "string",
"description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert."
},
"old_str": {
"type": "string",
"description": "Required parameter of `str_replace` command containing the string in `path` to replace."
},
"view_range": {
"type": "array",
"description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.",
"items": {
"type": "integer"
}
}
},
"required": [
"command",
"path"
]
}
}
}
],
"messages": [
{
"role": "system",
"text": "You are a helpful software engineer assistant."
},
{
"role": "user",
"text": "Exercise the packaged minimal agent's persistent Bash and string-replacement editor.\nEditor path: {{cwd}}/created.txt"
}
]
},
{
"tools": [
{
"type": "function",
"function": {
"name": "bash",
"description": "Run commands in a bash shell\n* When invoking this tool, the contents of the \"command\" parameter does NOT need to be XML-escaped.\n* You don't have access to the internet via this tool.\n* You do have access to a mirror of common linux and python packages via apt and pip.\n* State is persistent across command calls and discussions with the user.\n* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.\n* Please avoid commands that may produce a very large amount of output.\n* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to run. Relative path is preferred in the command."
}
},
"required": [
"command"
]
}
}
},
{
"type": "function",
"function": {
"name": "str_replace_editor",
"description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with `<response clipped>`\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.",
"enum": [
"view",
"create",
"str_replace",
"insert"
]
},
"path": {
"type": "string",
"description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`."
},
"file_text": {
"type": "string",
"description": "Required parameter of `create` command, with the content of the file to be created."
},
"insert_line": {
"type": "integer",
"description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`."
},
"new_str": {
"type": "string",
"description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert."
},
"old_str": {
"type": "string",
"description": "Required parameter of `str_replace` command containing the string in `path` to replace."
},
"view_range": {
"type": "array",
"description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.",
"items": {
"type": "integer"
}
}
},
"required": [
"command",
"path"
]
}
}
}
],
"messages": [
{
"role": "system",
"text": "You are a helpful software engineer assistant."
},
{
"role": "user",
"text": "Exercise the packaged minimal agent's persistent Bash and string-replacement editor.\nEditor path: {{cwd}}/created.txt"
},
{
"role": "assistant",
"toolCalls": [
{
"id": "minimal-bash-1",
"name": "bash"
}
]
},
{
"role": "tool",
"toolCallId": "minimal-bash-1",
"text": "{{tool-result}}"
}
]
},
{
"tools": [
{
"type": "function",
"function": {
"name": "bash",
"description": "Run commands in a bash shell\n* When invoking this tool, the contents of the \"command\" parameter does NOT need to be XML-escaped.\n* You don't have access to the internet via this tool.\n* You do have access to a mirror of common linux and python packages via apt and pip.\n* State is persistent across command calls and discussions with the user.\n* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.\n* Please avoid commands that may produce a very large amount of output.\n* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to run. Relative path is preferred in the command."
}
},
"required": [
"command"
]
}
}
},
{
"type": "function",
"function": {
"name": "str_replace_editor",
"description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with `<response clipped>`\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.",
"enum": [
"view",
"create",
"str_replace",
"insert"
]
},
"path": {
"type": "string",
"description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`."
},
"file_text": {
"type": "string",
"description": "Required parameter of `create` command, with the content of the file to be created."
},
"insert_line": {
"type": "integer",
"description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`."
},
"new_str": {
"type": "string",
"description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert."
},
"old_str": {
"type": "string",
"description": "Required parameter of `str_replace` command containing the string in `path` to replace."
},
"view_range": {
"type": "array",
"description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.",
"items": {
"type": "integer"
}
}
},
"required": [
"command",
"path"
]
}
}
}
],
"messages": [
{
"role": "system",
"text": "You are a helpful software engineer assistant."
},
{
"role": "user",
"text": "Exercise the packaged minimal agent's persistent Bash and string-replacement editor.\nEditor path: {{cwd}}/created.txt"
},
{
"role": "assistant",
"toolCalls": [
{
"id": "minimal-bash-1",
"name": "bash"
}
]
},
{
"role": "tool",
"toolCallId": "minimal-bash-1",
"text": "{{tool-result}}"
},
{
"role": "assistant",
"toolCalls": [
{
"id": "minimal-bash-2",
"name": "bash"
}
]
},
{
"role": "tool",
"toolCallId": "minimal-bash-2",
"text": "{{tool-result}}"
}
]
},
{
"tools": [
{
"type": "function",
"function": {
"name": "bash",
"description": "Run commands in a bash shell\n* When invoking this tool, the contents of the \"command\" parameter does NOT need to be XML-escaped.\n* You don't have access to the internet via this tool.\n* You do have access to a mirror of common linux and python packages via apt and pip.\n* State is persistent across command calls and discussions with the user.\n* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.\n* Please avoid commands that may produce a very large amount of output.\n* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to run. Relative path is preferred in the command."
}
},
"required": [
"command"
]
}
}
},
{
"type": "function",
"function": {
"name": "str_replace_editor",
"description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with `<response clipped>`\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.",
"enum": [
"view",
"create",
"str_replace",
"insert"
]
},
"path": {
"type": "string",
"description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`."
},
"file_text": {
"type": "string",
"description": "Required parameter of `create` command, with the content of the file to be created."
},
"insert_line": {
"type": "integer",
"description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`."
},
"new_str": {
"type": "string",
"description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert."
},
"old_str": {
"type": "string",
"description": "Required parameter of `str_replace` command containing the string in `path` to replace."
},
"view_range": {
"type": "array",
"description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.",
"items": {
"type": "integer"
}
}
},
"required": [
"command",
"path"
]
}
}
}
],
"messages": [
{
"role": "system",
"text": "You are a helpful software engineer assistant."
},
{
"role": "user",
"text": "Exercise the packaged minimal agent's persistent Bash and string-replacement editor.\nEditor path: {{cwd}}/created.txt"
},
{
"role": "assistant",
"toolCalls": [
{
"id": "minimal-bash-1",
"name": "bash"
}
]
},
{
"role": "tool",
"toolCallId": "minimal-bash-1",
"text": "{{tool-result}}"
},
{
"role": "assistant",
"toolCalls": [
{
"id": "minimal-bash-2",
"name": "bash"
}
]
},
{
"role": "tool",
"toolCallId": "minimal-bash-2",
"text": "{{tool-result}}"
},
{
"role": "assistant",
"toolCalls": [
{
"id": "minimal-editor",
"name": "str_replace_editor"
}
]
},
{
"role": "tool",
"toolCallId": "minimal-editor",
"text": "{{tool-result}}"
}
]
}
]
+1
View File
@@ -117,6 +117,7 @@ class TestAttachmentStore extends AttachmentStore {
maxImagesPerMessage: 1,
maxMessageImageBytes: 1,
maxImagePixels: 1,
maxImageDimension: 1,
mediaTypes: ['image/png'],
}
+5
View File
@@ -351,6 +351,11 @@
"symbol": "ToolProviderResult",
"source": "packages/core/system-prompt/src/index.ts"
},
{
"doc": "docs/subsystems/llm-streaming.md",
"symbol": "ReplayEnvelope",
"source": "packages/llm/llm/src/types.ts"
},
{
"doc": "docs/subsystems/llm-streaming.md",
"symbol": "StreamChunk",
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { resolveClientImport } from './verify-client-domain-graph.ts'
describe('client domain import resolution', () => {
it('preserves imports that leave src/client from a top-level file', () => {
expect(resolveClientImport('styles.ts', '../styles/base.css?inline'))
.toBe('../styles/base.css?inline')
})
it('normalizes imports between domains inside src/client', () => {
expect(resolveClientImport('input/hub.ts', '../queue/store.ts'))
.toBe('queue/store.ts')
})
})
+33 -27
View File
@@ -15,7 +15,7 @@
*/
import { globSync, readdirSync, readFileSync, statSync } from 'node:fs'
import { join, resolve, sep } from 'node:path'
import { join, posix, resolve, sep } from 'node:path'
const root = resolve(import.meta.dirname, '..')
const CLIENT_DIR = join(root, 'packages/client')
@@ -41,6 +41,16 @@ function domainOf(rel: string): string {
return ix === -1 ? '' : rel.slice(0, ix)
}
/**
* Resolve one relative import to a client-directory-relative path.
* @param file - Importing file relative to `src/client`.
* @param specifier - Relative module specifier from that file.
* @returns Normalized path, preserving leading `..` segments outside `src/client`.
*/
export function resolveClientImport(file: string, specifier: string): string {
return posix.normalize(posix.join(posix.dirname(file), specifier))
}
function checkPackage(pkgName: string, clientDir: string): Violation[] {
const violations: Violation[] = []
const files = listSources(clientDir)
@@ -52,17 +62,8 @@ function checkPackage(pkgName: string, clientDir: string): Violation[] {
for (const match of source.matchAll(/from\s+['"](\.[^'"]+)['"]/g)) {
const spec = match[1]
if (spec === undefined) continue
// Resolve the relative specifier against the importing file's directory
// to a client-dir-relative path.
const fromDir = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''
const parts = (fromDir ? fromDir.split('/') : [])
for (const seg of spec.split('/')) {
if (seg === '.') continue
if (seg === '..') parts.pop()
else parts.push(seg)
}
const target = parts.join('/')
if (target.startsWith('..')) continue // out of client dir (package root) — package-level rules govern
const target = resolveClientImport(rel, spec)
if (target === '..' || target.startsWith('../')) continue // package-level rules govern
const toDomain = domainOf(target)
if (toDomain === '' || CONTRACT_DIRS.has(toDomain)) continue // top-level shared file or contract layer
if (fromDomain === toDomain) continue // inside one domain
@@ -78,21 +79,26 @@ function checkPackage(pkgName: string, clientDir: string): Violation[] {
return violations
}
const violations: Violation[] = []
for (const pkg of readdirSync(CLIENT_DIR)) {
const clientDir = join(CLIENT_DIR, pkg, 'src/client')
try {
if (!statSync(clientDir).isDirectory()) continue
} catch {
// No client half in this package — nothing to layer-check.
continue
function main(): void {
const violations: Violation[] = []
for (const pkg of readdirSync(CLIENT_DIR)) {
const clientDir = join(CLIENT_DIR, pkg, 'src/client')
try {
if (!statSync(clientDir).isDirectory()) continue
} catch {
// No client half in this package — nothing to layer-check.
continue
}
violations.push(...checkPackage(pkg, clientDir))
}
violations.push(...checkPackage(pkg, clientDir))
if (violations.length > 0) {
console.error(`verify-client-domain-graph: ${violations.length} violation(s):`)
for (const v of violations) console.error(` ${v.file} -> ${v.imported}\n ${v.reason}`)
process.exitCode = 1
return
}
console.log('verify-client-domain-graph: client domain layering clean.')
}
if (violations.length > 0) {
console.error(`verify-client-domain-graph: ${violations.length} violation(s):`)
for (const v of violations) console.error(` ${v.file} -> ${v.imported}\n ${v.reason}`)
process.exit(1)
}
console.log('verify-client-domain-graph: client domain layering clean.')
if (import.meta.filename === resolve(process.argv[1] ?? '')) main()
+402
View File
@@ -0,0 +1,402 @@
/** Tests for client package modes, dependency sections, and module requests. */
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
collectClientPackageViolations,
collectRuntimeSourcePackageUses,
collectSourcePackageUses,
fixClientPackageManifests,
readClientDeclarations,
type ClientDeclaration,
type ClientPackage,
type ClientPackageFacts,
} from './verify-client-packages.ts'
const CORDIS = '@deepseek-ai/cordis'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function declaration(
short: string,
fields: Partial<Omit<ClientDeclaration, 'name' | 'manifest'>> = {},
): ClientDeclaration {
return {
name: short.startsWith('@') ? short : '@deepseek-ai/dsh-client-' + short,
manifest: 'packages/client/' + short.replace(/^.*\//, '') + '/package.json',
dynamic: true,
external: [],
inject: [],
...fields,
}
}
function pkg(
short: string,
fields: Partial<Omit<ClientPackage, 'name' | 'manifest'>> = {},
): ClientPackage {
return {
...declaration(short),
staticLinked: false,
sourceUses: {},
runtimeSourceUses: {},
dependencies: {},
peerDependencies: { [CORDIS]: 'workspace:^' },
devDependencies: { [CORDIS]: 'workspace:^' },
...fields,
}
}
function facts(
packages: readonly ClientPackage[],
options: Partial<Omit<ClientPackageFacts, 'packages'>> = {},
): ClientPackageFacts {
return {
packages,
declarations: options.declarations ?? packages,
staticLinkedPackages: options.staticLinkedPackages ?? new Set(
packages.filter(item => item.staticLinked).map(item => item.name),
),
platformModules: options.platformModules ?? [],
preloadedExternals: options.preloadedExternals ?? [],
parserPreloadIds: options.parserPreloadIds
?? (options.preloadedExternals ?? []).map(value => value.replace(/\/client$/, '')),
malformed: options.malformed ?? [],
}
}
describe('source package uses', () => {
it('counts type imports, module augmentations, dynamic imports, and JSX', () => {
const uses = collectSourcePackageUses('feature.tsx', [
"import type { A } from '@deepseek-ai/dsh-a/subpath'",
"declare module '@deepseek-ai/dsh-client-ui-slots' {}",
"const load = () => import('@deepseek-ai/dsh-b')",
'export const view = <div />',
"export type { Local } from './local.ts'",
].join('\n'))
expect([...uses].sort()).toEqual([
'@deepseek-ai/dsh-a',
'@deepseek-ai/dsh-b',
'@deepseek-ai/dsh-client-ui-slots',
'react',
])
expect([...collectRuntimeSourcePackageUses('feature.tsx', [
"import type { A } from '@deepseek-ai/dsh-a/subpath'",
"declare module '@deepseek-ai/dsh-client-ui-slots' {}",
"const load = () => import('@deepseek-ai/dsh-b')",
'export const view = <div />',
].join('\n'))].sort()).toEqual([
'@deepseek-ai/dsh-b',
'react',
])
})
})
describe('package modes', () => {
it('accepts one dynamic package and one statically linked package', () => {
const dynamic = pkg('runtime')
const shell = pkg('ui-slots', { dynamic: false, staticLinked: true })
expect(collectClientPackageViolations(facts([dynamic, shell]))).toEqual([])
})
it('rejects a package with both modes or neither mode', () => {
const both = pkg('both', { staticLinked: true })
const neither = pkg('neither', { dynamic: false })
const found = collectClientPackageViolations(facts([both, neither]))
expect(found).toHaveLength(2)
expect(found.join('\n')).toContain('must be dynamic or statically linked, not both')
expect(found.join('\n')).toContain('has no supported client package mode')
})
it('requires seeded workspace packages to use staticLinked and preloads to name dynamic rows', () => {
const slots = declaration('ui-slots', { dynamic: false })
const runtime = declaration('runtime', { dynamic: false })
const found = collectClientPackageViolations(facts([], {
declarations: [slots, runtime],
platformModules: [slots.name],
preloadedExternals: [runtime.name + '/client'],
}))
expect(found).toHaveLength(2)
expect(found.join('\n')).toContain('does not use the staticLinked preset')
expect(found.join('\n')).toContain('has no dynamic dsh.client row')
})
it('requires every preloaded external to have a parser preload row', () => {
const runtime = declaration('runtime')
expect(collectClientPackageViolations(facts([], {
declarations: [runtime],
preloadedExternals: [runtime.name + '/client'],
parserPreloadIds: [],
}))).toEqual([
'packages/client/web/src/platform.ts: parser-preloaded external '
+ '"@deepseek-ai/dsh-client-runtime/client" has no matching PARSER_PRELOAD_IDS row in '
+ 'packages/client/modules/src/index.ts',
])
})
})
describe('dependency sections', () => {
it('accepts dynamic peer plus dev relationships, static dev inputs, and private dependencies', () => {
const slots = pkg('ui-slots', { dynamic: false, staticLinked: true })
const runtime = pkg('runtime', {
inject: ['@deepseek-ai/dsh-client-feature'],
sourceUses: {
'@deepseek-ai/dsh-agent': ['packages/client/runtime/src/index.ts'],
'@deepseek-ai/dsh-client-ui-slots': ['packages/client/runtime/src/client/slots.ts'],
react: ['packages/client/runtime/src/client/view.tsx'],
},
dependencies: { immer: '^10.1.1' },
peerDependencies: {
[CORDIS]: 'workspace:^',
'@deepseek-ai/dsh-agent': 'workspace:^',
'@deepseek-ai/dsh-client-feature': 'workspace:^',
},
devDependencies: {
[CORDIS]: 'workspace:^',
'@deepseek-ai/dsh-agent': 'workspace:^',
'@deepseek-ai/dsh-client-feature': 'workspace:^',
'@deepseek-ai/dsh-client-ui-slots': 'workspace:^',
react: '^18.2.0',
},
})
expect(collectClientPackageViolations(facts([slots, runtime], {
platformModules: ['react', slots.name],
}))).toEqual([])
})
it('rejects internal dependencies, static peers, and mismatched peer development ranges', () => {
const slots = pkg('ui-slots', { dynamic: false, staticLinked: true })
const subject = pkg('feature', {
sourceUses: {
'@deepseek-ai/dsh-agent': ['packages/client/feature/src/index.ts'],
[slots.name]: ['packages/client/feature/src/view.tsx'],
},
dependencies: { '@deepseek-ai/dsh-agent': 'workspace:^' },
peerDependencies: { [CORDIS]: 'workspace:^', [slots.name]: 'workspace:^' },
devDependencies: { [CORDIS]: 'workspace:^', [slots.name]: 'workspace:*' },
})
const found = collectClientPackageViolations(facts([slots, subject]))
expect(found).toHaveLength(2)
expect(found.join('\n')).toContain('peer-installed DSH relationship')
expect(found.join('\n')).toContain('static client input')
})
it('requires every peer to have the same development range', () => {
const subject = pkg('feature', {
peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/cordis-plugin-loader': 'workspace:^' },
})
expect(collectClientPackageViolations(facts([subject]))).toEqual([
'packages/client/feature/package.json: peerDependencies.@deepseek-ai/cordis-plugin-loader'
+ ' is workspace:^, so devDependencies.@deepseek-ai/cordis-plugin-loader must use the same range;'
+ ' found no declaration',
])
})
it('requires statically linked third-party runtime imports in dependencies', () => {
const primitives = pkg('ui-primitives', {
dynamic: false,
staticLinked: true,
runtimeSourceUses: { shiki: ['packages/client/ui-primitives/src/highlight.ts'] },
devDependencies: { [CORDIS]: 'workspace:^', shiki: '^4.3.1' },
})
const found = collectClientPackageViolations(facts([primitives]))
expect(found).toHaveLength(1)
expect(found[0]).toContain('runtime import retained by a statically linked artifact')
expect(found[0]).toContain('declare it only in dependencies')
const valid = { ...primitives, dependencies: { shiki: '^4.3.1' }, devDependencies: { [CORDIS]: 'workspace:^' } }
expect(collectClientPackageViolations(facts([valid]))).toEqual([])
})
it('keeps the web shell runtime inputs development-only', () => {
const web = pkg('web', {
dynamic: false,
staticLinked: true,
runtimeSourceUses: {
'@deepseek-ai/cordis-plugin-loader': ['packages/client/web/src/boot.ts'],
react: ['packages/client/web/src/seed.ts'],
},
devDependencies: {
[CORDIS]: 'workspace:^',
'@deepseek-ai/cordis-plugin-loader': 'workspace:^',
react: '^18.2.0',
},
})
expect(collectClientPackageViolations(facts([web]))).toEqual([])
})
it('allows npm dependency cycles', () => {
const a = pkg('a', {
peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-b': 'workspace:^' },
devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-b': 'workspace:^' },
})
const b = pkg('b', {
peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-a': 'workspace:^' },
devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-a': 'workspace:^' },
})
expect(collectClientPackageViolations(facts([a, b]))).toEqual([])
})
})
describe('module requests', () => {
it('accepts a dynamic row supplier and its client subpath', () => {
const ui = declaration('ui', { external: ['@deepseek-ai/dsh-client-slots/client'] })
const slots = declaration('slots')
expect(collectClientPackageViolations(facts([], { declarations: [ui, slots] }))).toEqual([])
})
it('rejects an explicit baseline request', () => {
const ui = declaration('ui', { external: ['react'] })
expect(collectClientPackageViolations(facts([], {
declarations: [ui],
platformModules: ['react'],
}))).toEqual([
ui.manifest + ': dsh.client.external repeats baseline module "react"; remove the explicit declaration',
])
})
it('rejects duplicates, empty values, self-requests, and missing suppliers', () => {
const ui = declaration('ui', {
external: ['', '@deepseek-ai/dsh-client-ui', '@deepseek-ai/dsh-missing', '@deepseek-ai/dsh-missing'],
inject: ['', '@deepseek-ai/dsh-a', '@deepseek-ai/dsh-a'],
})
const found = collectClientPackageViolations(facts([], { declarations: [ui] }))
expect(found).toHaveLength(6)
expect(found.join('\n')).toContain('dsh.client.external contains an empty value')
expect(found.join('\n')).toContain('dsh.client.inject contains an empty value')
expect(found.join('\n')).toContain('names its own row')
expect(found.join('\n')).toContain('has no supplier')
})
it('rejects synchronous module-request cycles but ignores inject cycles', () => {
const a = declaration('a', {
external: ['@deepseek-ai/dsh-client-b'],
inject: ['@deepseek-ai/dsh-client-b'],
})
const b = declaration('b', {
external: ['@deepseek-ai/dsh-client-a'],
inject: ['@deepseek-ai/dsh-client-a'],
})
const found = collectClientPackageViolations(facts([], { declarations: [a, b] }))
expect(found).toHaveLength(1)
expect(found[0]).toContain('synchronous dsh.client.external cycle')
})
})
describe('manifest declarations', () => {
it('reports malformed arrays without hiding other packages', () => {
const root = mkdtempSync(join(tmpdir(), 'client-packages-'))
roots.push(root)
const files: Record<string, unknown> = {
'packages/g/a/package.json': {
name: '@f/a', dsh: { client: { external: 'react', inject: ['@f/b', 1] } },
},
'packages/g/b/package.json': { name: '@f/b', dsh: { client: {} } },
}
for (const [path, value] of Object.entries(files)) {
mkdirSync(dirname(join(root, path)), { recursive: true })
writeFileSync(join(root, path), JSON.stringify(value))
}
const result = readClientDeclarations(root)
expect(result.declarations).toHaveLength(2)
expect(result.malformed).toEqual([
'packages/g/a/package.json: @f/a dsh.client.external must be a string array',
'packages/g/a/package.json: @f/a dsh.client.inject must be a string array',
])
})
it('fixes unambiguous dependency sections and declaration entries', () => {
const root = mkdtempSync(join(tmpdir(), 'client-packages-fix-'))
roots.push(root)
const subject = pkg('feature', {
external: ['', 'react', '@deepseek-ai/dsh-client-feature', '@deepseek-ai/dsh-missing'],
inject: ['', '@deepseek-ai/dsh-agent', '@deepseek-ai/dsh-agent'],
sourceUses: {
'@deepseek-ai/dsh-agent': ['packages/client/feature/src/index.ts'],
'@deepseek-ai/dsh-client-ui-slots': ['packages/client/feature/src/view.tsx'],
},
dependencies: {
[CORDIS]: 'workspace:^',
'@deepseek-ai/dsh-agent': 'workspace:*',
},
peerDependencies: {
'@deepseek-ai/dsh-client-ui-slots': 'workspace:^',
'@deepseek-ai/cordis-plugin-loader': 'workspace:^',
},
devDependencies: {},
})
const slots = declaration('ui-slots', { dynamic: false })
const manifest = {
name: subject.name,
dsh: { client: { external: subject.external, inject: subject.inject, platform: 'web' } },
dependencies: subject.dependencies,
peerDependencies: subject.peerDependencies,
devDependencies: subject.devDependencies,
}
mkdirSync(dirname(join(root, subject.manifest)), { recursive: true })
writeFileSync(join(root, subject.manifest), JSON.stringify(manifest))
writeFileSync(join(root, 'package.json'), JSON.stringify({ private: true }))
expect(fixClientPackageManifests(root, facts([subject], {
declarations: [subject, slots],
staticLinkedPackages: new Set([slots.name]),
platformModules: ['react', slots.name],
}))).toEqual([subject.manifest])
const fixed = JSON.parse(readFileSync(join(root, subject.manifest), 'utf8')) as {
dsh: { client: { external: string[]; inject: string[] } }
dependencies?: Record<string, string>
peerDependencies: Record<string, string>
devDependencies: Record<string, string>
}
expect(fixed.dsh.client).toMatchObject({
external: ['@deepseek-ai/dsh-missing'],
inject: ['@deepseek-ai/dsh-agent'],
})
expect(fixed.dependencies).toBeUndefined()
expect(fixed.peerDependencies).toEqual({
'@deepseek-ai/cordis-plugin-loader': 'workspace:^',
[CORDIS]: 'workspace:^',
'@deepseek-ai/dsh-agent': 'workspace:*',
})
expect(fixed.devDependencies).toEqual({
'@deepseek-ai/dsh-client-ui-slots': 'workspace:^',
[CORDIS]: 'workspace:^',
'@deepseek-ai/dsh-agent': 'workspace:*',
'@deepseek-ai/cordis-plugin-loader': 'workspace:^',
})
})
it('fixes a statically linked runtime import into dependencies', () => {
const root = mkdtempSync(join(tmpdir(), 'client-packages-static-fix-'))
roots.push(root)
const subject = pkg('ui-primitives', {
dynamic: false,
staticLinked: true,
runtimeSourceUses: { shiki: ['packages/client/ui-primitives/src/highlight.ts'] },
devDependencies: { [CORDIS]: 'workspace:^', shiki: '^4.3.1' },
})
mkdirSync(dirname(join(root, subject.manifest)), { recursive: true })
writeFileSync(join(root, subject.manifest), JSON.stringify({
name: subject.name,
peerDependencies: subject.peerDependencies,
devDependencies: subject.devDependencies,
}))
writeFileSync(join(root, 'package.json'), JSON.stringify({ private: true }))
expect(fixClientPackageManifests(root, facts([subject]))).toEqual([subject.manifest])
const fixed = JSON.parse(readFileSync(join(root, subject.manifest), 'utf8')) as {
dependencies: Record<string, string>
devDependencies: Record<string, string>
}
expect(fixed.dependencies).toEqual({ shiki: '^4.3.1' })
expect(fixed.devDependencies).toEqual({ [CORDIS]: 'workspace:^' })
})
})
+914
View File
@@ -0,0 +1,914 @@
/**
* Verify client package modes, npm dependency sections, and the synchronous
* browser module-request graph.
*/
import { globSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
import ts from 'typescript'
import { TypeScriptProject } from './ts-project.ts'
const GATE = 'verify-client-packages'
const CLIENT_MANIFEST_GLOB = 'packages/client/*/package.json'
const MANIFEST_GLOBS = ['packages/*/*/package.json', 'apps/*/package.json', 'vendor/*/package.json']
const CONFIG_GLOB = 'packages/*/*/tsdown.config.ts'
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'
const DSH_PREFIX = '@deepseek-ai/dsh-'
const CLIENT_WEB = '@deepseek-ai/dsh-client-web'
/** One workspace package's browser-module declaration. */
export interface ClientDeclaration {
/** npm package name. */
readonly name: string
/** Repository-relative package manifest. */
readonly manifest: string
/** Whether the manifest declares a dynamic dsh.client row. */
readonly dynamic: boolean
/** Exact module-table specifiers requested by the row. */
readonly external: readonly string[]
/** Informational package dependencies declared by the row. */
readonly inject: readonly string[]
}
/** One package directly under packages/client. */
export interface ClientPackage extends ClientDeclaration {
/** Whether its build config uses the staticLinked preset. */
readonly staticLinked: boolean
/** Production source locations grouped by imported package name. */
readonly sourceUses: Readonly<Record<string, readonly string[]>>
/** Production source locations grouped by runtime-imported package name. */
readonly runtimeSourceUses: Readonly<Record<string, readonly string[]>>
/** Installed implementation dependencies. */
readonly dependencies: Readonly<Record<string, string>>
/** Consumer-supplied dependencies. */
readonly peerDependencies: Readonly<Record<string, string>>
/** Dependencies available while developing the package. */
readonly devDependencies: Readonly<Record<string, string>>
}
/** Complete source-plane input to the client package verifier. */
export interface ClientPackageFacts {
/** Packages directly under packages/client. */
readonly packages: readonly ClientPackage[]
/** Every workspace package, including packages without a browser row. */
readonly declarations: readonly ClientDeclaration[]
/** Packages whose build config uses the staticLinked preset. */
readonly staticLinkedPackages: ReadonlySet<string>
/** Specifiers the web shell seeds into the module table. */
readonly platformModules: readonly string[]
/** Dynamic factories the HTML parser loads before shell boot. */
readonly preloadedExternals: readonly string[]
/** Package rows whose bundles the HTML parser executes before shell boot. */
readonly parserPreloadIds: readonly string[]
/** Manifest field errors found while reading declarations. */
readonly malformed: readonly string[]
}
/** Result of reading every workspace browser-module declaration. */
export interface ClientDeclarations {
/** One declaration record per named workspace manifest. */
readonly declarations: ClientDeclaration[]
/** Manifest field errors that prevent a reliable declaration. */
readonly malformed: string[]
}
/**
* Collect bare packages referenced by one production source file.
* @param path - File path used to select TypeScript's parser mode.
* @param source - Source text to inspect.
* @returns Bare package names referenced by imports, declarations, or JSX.
*/
export function collectSourcePackageUses(path: string, source: string): Set<string> {
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
return collectSourceFilePackageUses(sourceFile, false)
}
/**
* Collect bare packages whose values one production source file reaches at runtime.
* @param path - File path used to select TypeScript's parser mode.
* @param source - Source text to inspect.
* @returns Bare package names retained by runtime imports, exports, requires, or JSX.
*/
export function collectRuntimeSourcePackageUses(path: string, source: string): Set<string> {
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
return collectSourceFilePackageUses(sourceFile, true)
}
function importCarriesRuntimeValue(node: ts.ImportDeclaration): boolean {
const clause = node.importClause
if (clause === undefined) return true
if (clause.phaseModifier === ts.SyntaxKind.TypeKeyword) return false
const bindings = clause.namedBindings
return clause.name !== undefined
|| bindings === undefined
|| ts.isNamespaceImport(bindings)
|| bindings.elements.length === 0
|| bindings.elements.some(element => !element.isTypeOnly)
}
function exportCarriesRuntimeValue(node: ts.ExportDeclaration): boolean {
if (node.isTypeOnly) return false
const clause = node.exportClause
if (clause === undefined || ts.isNamespaceExport(clause)) return true
return clause.elements.length === 0 || clause.elements.some(element => !element.isTypeOnly)
}
function collectSourceFilePackageUses(sourceFile: ts.SourceFile, runtimeOnly: boolean): Set<string> {
const uses = new Set<string>()
const add = (specifier: ts.Expression | undefined): void => {
if (specifier === undefined || !ts.isStringLiteral(specifier) || !isBareSpecifier(specifier.text)) return
uses.add(packageNameOf(specifier.text))
}
const visit = (node: ts.Node): void => {
if (ts.isImportDeclaration(node)) {
if (!runtimeOnly || importCarriesRuntimeValue(node)) add(node.moduleSpecifier)
} else if (ts.isExportDeclaration(node)) {
if (!runtimeOnly || exportCarriesRuntimeValue(node)) add(node.moduleSpecifier)
} else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
if (!runtimeOnly || !node.isTypeOnly) add(node.moduleReference.expression)
} else if (!runtimeOnly && ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
add(node.argument.literal)
} else if (ts.isCallExpression(node)
&& (node.expression.kind === ts.SyntaxKind.ImportKeyword
|| ts.isIdentifier(node.expression) && node.expression.text === 'require')) {
add(node.arguments[0])
} else if (!runtimeOnly && ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) {
add(node.name)
} else if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) {
uses.add('react')
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return uses
}
/**
* Read browser-module declarations from workspace manifests.
* @param root - Absolute repository root.
* @returns Declarations and malformed dsh.client fields.
*/
export function readClientDeclarations(root: string): ClientDeclarations {
const malformed: string[] = []
const declarations = globSync(MANIFEST_GLOBS, { cwd: root })
.map(normalizePath)
.sort()
.flatMap(path => readDeclaration(root, path, malformed) ?? [])
return { declarations, malformed }
}
/**
* Return every client package policy violation.
* @param facts - Package modes, manifests, source uses, and platform module lists.
* @returns Stable self-contained diagnostics.
*/
export function collectClientPackageViolations(facts: ClientPackageFacts): string[] {
return [
...facts.malformed,
...collectModeViolations(facts),
...collectDependencyViolations(facts),
...collectModuleViolations(facts),
].sort((left, right) => left.localeCompare(right))
}
interface ManifestDocument {
readonly path: string
readonly manifest: Manifest
changed: boolean
}
type DependencySection = 'dependencies' | 'peerDependencies' | 'devDependencies'
/**
* Repair manifest declarations whose intended result follows uniquely from the policy.
* @param root - Absolute repository root.
* @param facts - Facts used by the verification pass.
* @returns Repository-relative manifests written by the fixer.
*/
export function fixClientPackageManifests(root: string, facts: ClientPackageFacts): string[] {
const documents = new Map<string, ManifestDocument>()
const document = (path: string): ManifestDocument => {
const cached = documents.get(path)
if (cached !== undefined) return cached
const loaded: ManifestDocument = {
path,
manifest: JSON.parse(readFileSync(resolve(root, path), 'utf8')) as Manifest,
changed: false,
}
documents.set(path, loaded)
return loaded
}
const baseline = new Set([...facts.platformModules, ...facts.preloadedExternals])
for (const declaration of facts.declarations.filter(entry => entry.dynamic)) {
const target = document(declaration.manifest)
const dsh = isRecord(target.manifest.dsh) ? target.manifest.dsh : undefined
const client = isRecord(dsh?.client) ? dsh.client : undefined
if (client === undefined) continue
target.changed = normalizeClientArray(client, 'inject', () => false) || target.changed
target.changed = normalizeClientArray(
client,
'external',
value => baseline.has(value) || rowPackageOf(value, new Set([declaration.name])) === declaration.name,
) || target.changed
}
const staticInputs = new Set([
...facts.staticLinkedPackages,
...facts.platformModules.map(packageNameOf),
])
staticInputs.delete(CORDIS)
const inferredRanges = dependencyRangeCandidates(root)
for (const pkg of facts.packages) {
const target = document(pkg.manifest)
const expected = expectedSections(pkg, staticInputs)
for (const [name, rule] of expected) {
const range = preferredRange(target.manifest, name, rule.kind, inferredRanges)
if (range === undefined) continue
target.changed = rule.kind === 'dependency'
? ensureDependencyOnly(target.manifest, name, range) || target.changed
: rule.kind === 'dev'
? ensureDevOnly(target.manifest, name, range) || target.changed
: ensurePeerDev(target.manifest, name, range) || target.changed
}
if (pkg.dynamic) {
const productionNames = new Set([
...Object.keys(section(target.manifest, 'dependencies')),
...Object.keys(section(target.manifest, 'peerDependencies')),
])
for (const name of productionNames) {
if (expected.has(name)) continue
const range = preferredRange(
target.manifest,
name,
staticInputs.has(name) ? 'dev' : 'peer-dev',
inferredRanges,
)
if (range === undefined) continue
if (staticInputs.has(name)) {
target.changed = ensureDevOnly(target.manifest, name, range) || target.changed
} else if (section(target.manifest, 'dependencies')[name] !== undefined && isInternalDsh(name)) {
target.changed = ensurePeerDev(target.manifest, name, range) || target.changed
}
}
}
for (const [name, range] of Object.entries(section(target.manifest, 'peerDependencies'))) {
target.changed = setDependency(target.manifest, 'devDependencies', name, range) || target.changed
}
target.changed = deleteEmptySections(target.manifest) || target.changed
}
const changed = [...documents.values()].filter(target => target.changed).sort((left, right) =>
left.path.localeCompare(right.path))
for (const target of changed) {
writeFileSync(resolve(root, target.path), JSON.stringify(target.manifest, null, 2) + '\n')
}
return changed.map(target => target.path)
}
function normalizeClientArray(
client: Record<string, unknown>,
field: 'external' | 'inject',
remove: (value: string) => boolean,
): boolean {
const value = client[field]
if (!Array.isArray(value) || value.some(entry => typeof entry !== 'string')) return false
const seen = new Set<string>()
const normalized = value.filter((entry: string) => {
if (entry === '' || seen.has(entry) || remove(entry)) return false
seen.add(entry)
return true
})
if (normalized.length === value.length && normalized.every((entry, index) => entry === value[index])) return false
if (normalized.length === 0) {
if (field === 'external') delete client.external
else delete client.inject
} else {
client[field] = normalized
}
return true
}
function ensureDevOnly(manifest: Manifest, name: string, range: string): boolean {
let changed = deleteDependency(manifest, 'dependencies', name)
changed = deleteDependency(manifest, 'peerDependencies', name) || changed
return setDependency(manifest, 'devDependencies', name, range) || changed
}
function ensureDependencyOnly(manifest: Manifest, name: string, range: string): boolean {
let changed = deleteDependency(manifest, 'peerDependencies', name)
changed = deleteDependency(manifest, 'devDependencies', name) || changed
return setDependency(manifest, 'dependencies', name, range) || changed
}
function ensurePeerDev(manifest: Manifest, name: string, range: string): boolean {
let changed = deleteDependency(manifest, 'dependencies', name)
changed = setDependency(manifest, 'peerDependencies', name, range) || changed
return setDependency(manifest, 'devDependencies', name, range) || changed
}
function setDependency(manifest: Manifest, field: DependencySection, name: string, range: string): boolean {
const dependencies = mutableSection(manifest, field)
if (dependencies[name] === range) return false
dependencies[name] = range
return true
}
function deleteDependency(manifest: Manifest, field: DependencySection, name: string): boolean {
const dependencies = section(manifest, field)
if (dependencies[name] === undefined) return false
manifest[field] = Object.fromEntries(Object.entries(dependencies).filter(([key]) => key !== name))
return true
}
function deleteEmptySections(manifest: Manifest): boolean {
let changed = false
for (const field of ['dependencies', 'peerDependencies', 'devDependencies'] as const) {
if (manifest[field] === undefined || Object.keys(section(manifest, field)).length > 0) continue
if (field === 'dependencies') delete manifest.dependencies
else if (field === 'peerDependencies') delete manifest.peerDependencies
else delete manifest.devDependencies
changed = true
}
return changed
}
function preferredRange(
manifest: Manifest,
name: string,
kind: ExpectedRule['kind'],
inferred: ReadonlyMap<string, ReadonlySet<string>>,
): string | undefined {
const order: readonly DependencySection[] = kind === 'dependency'
? ['dependencies', 'devDependencies', 'peerDependencies']
: kind === 'dev'
? ['devDependencies', 'peerDependencies', 'dependencies']
: ['peerDependencies', 'devDependencies', 'dependencies']
for (const field of order) {
const range = section(manifest, field)[name]
if (range !== undefined) return range
}
if (isInternalDsh(name)) return 'workspace:^'
const candidates = inferred.get(name)
return candidates?.size === 1 ? [...candidates][0] : undefined
}
function dependencyRangeCandidates(root: string): Map<string, Set<string>> {
const candidates = new Map<string, Set<string>>()
const paths = globSync([
'package.json',
...MANIFEST_GLOBS,
'website/package.json',
], { cwd: root }).map(normalizePath)
for (const path of new Set(paths)) {
const manifest = JSON.parse(readFileSync(resolve(root, path), 'utf8')) as Manifest
for (const field of ['dependencies', 'peerDependencies', 'devDependencies'] as const) {
for (const [name, range] of Object.entries(section(manifest, field))) {
const ranges = candidates.get(name) ?? new Set<string>()
ranges.add(range)
candidates.set(name, ranges)
}
}
}
return candidates
}
function section(manifest: Manifest, field: DependencySection): Record<string, string> {
return manifest[field] ?? {}
}
function mutableSection(manifest: Manifest, field: DependencySection): Record<string, string> {
const value = manifest[field]
if (value !== undefined) return value
const created: Record<string, string> = {}
manifest[field] = created
return created
}
function collectModeViolations(facts: ClientPackageFacts): string[] {
const violations: string[] = []
for (const pkg of facts.packages) {
if (pkg.dynamic && pkg.staticLinked) {
violations.push(
pkg.manifest + ': ' + pkg.name + ' declares dsh.client and uses the staticLinked preset;'
+ ' a client package must be dynamic or statically linked, not both',
)
} else if (!pkg.dynamic && !pkg.staticLinked) {
violations.push(
pkg.manifest + ': ' + pkg.name + ' has no supported client package mode;'
+ ' declare dsh.client or use the staticLinked preset',
)
}
}
const workspaceNames = new Set(facts.declarations.map(entry => entry.name))
for (const specifier of facts.platformModules) {
const owner = packageNameOf(specifier)
if (!workspaceNames.has(owner) || owner === CORDIS || facts.staticLinkedPackages.has(owner)) continue
violations.push(
PLATFORM_SOURCE + ': seeded workspace module ' + JSON.stringify(specifier)
+ ' belongs to ' + owner + ', whose build does not use the staticLinked preset',
)
}
const rows = rowNames(facts.declarations)
for (const specifier of facts.preloadedExternals) {
if (rowPackageOf(specifier, rows) === undefined) {
violations.push(
PLATFORM_SOURCE + ': parser-preloaded external ' + JSON.stringify(specifier)
+ ' has no dynamic dsh.client row',
)
}
if (!facts.parserPreloadIds.includes(stripClientSuffix(specifier))) {
violations.push(
PLATFORM_SOURCE + ': parser-preloaded external ' + JSON.stringify(specifier)
+ ' has no matching PARSER_PRELOAD_IDS row in ' + PARSER_PRELOAD_SOURCE,
)
}
}
return violations
}
interface ExpectedRule {
readonly kind: 'dependency' | 'dev' | 'peer-dev'
readonly origins: Set<string>
}
function collectDependencyViolations(facts: ClientPackageFacts): string[] {
const violations: string[] = []
const staticInputs = new Set([
...facts.staticLinkedPackages,
...facts.platformModules.map(packageNameOf),
])
staticInputs.delete(CORDIS)
for (const pkg of [...facts.packages].sort((left, right) => left.manifest.localeCompare(right.manifest))) {
const expected = expectedSections(pkg, staticInputs)
for (const [name, rule] of [...expected].sort(([left], [right]) => left.localeCompare(right))) {
const actual = declaredSections(pkg, name)
if (rule.kind === 'dependency') {
if (actual.length === 1 && actual[0] === 'dependencies') continue
violations.push(
pkg.manifest + ': ' + name + ' (' + describeOrigins(rule.origins) + ') is a runtime import'
+ ' retained by a statically linked artifact; declare it only in dependencies, found '
+ describeSections(actual),
)
continue
}
if (rule.kind === 'dev') {
if (actual.length === 1 && actual[0] === 'devDependencies') continue
violations.push(
pkg.manifest + ': ' + name + ' (' + describeOrigins(rule.origins) + ') is a static client input;'
+ ' declare it only in devDependencies, found ' + describeSections(actual),
)
continue
}
const peerRange = pkg.peerDependencies[name]
const devRange = pkg.devDependencies[name]
if (actual.length === 2
&& actual.includes('peerDependencies')
&& actual.includes('devDependencies')
&& peerRange === devRange) continue
violations.push(
pkg.manifest + ': ' + name + ' (' + describeOrigins(rule.origins) + ')'
+ ' is a peer-installed DSH relationship; declare it in peerDependencies and devDependencies'
+ ' with matching ranges, not dependencies; found ' + describeSections(actual)
+ describeRangeMismatch(peerRange, devRange),
)
}
for (const [name, peerRange] of Object.entries(pkg.peerDependencies).sort(([left], [right]) => left.localeCompare(right))) {
if (expected.has(name)) continue
const devRange = pkg.devDependencies[name]
if (devRange === peerRange) continue
violations.push(
pkg.manifest + ': peerDependencies.' + name + ' is ' + peerRange + ', so devDependencies.' + name
+ ' must use the same range; found ' + (devRange ?? 'no declaration'),
)
}
if (!pkg.dynamic) continue
for (const section of ['dependencies', 'peerDependencies'] as const) {
for (const name of Object.keys(pkg[section]).sort()) {
if (expected.has(name)) continue
if (staticInputs.has(name)) {
violations.push(
pkg.manifest + ': dynamic package declares static input ' + name + ' in ' + section + ';'
+ ' move it to devDependencies or delete the stale declaration',
)
} else if (section === 'dependencies' && isInternalDsh(name)) {
violations.push(
pkg.manifest + ': dynamic package declares ' + name + ' in dependencies;'
+ ' dynamic DSH relationships are peer plus dev, and static client inputs are dev-only',
)
}
}
}
}
return violations
}
function expectedSections(pkg: ClientPackage, staticInputs: ReadonlySet<string>): Map<string, ExpectedRule> {
const expected = new Map<string, ExpectedRule>([
[CORDIS, { kind: 'peer-dev', origins: new Set(['client package baseline']) }],
])
if (!pkg.dynamic) {
if (pkg.name === CLIENT_WEB) return expected
for (const [name, locations] of Object.entries(pkg.runtimeSourceUses)) {
if (name === pkg.name || name === CORDIS || isInternalDsh(name)) continue
expected.set(name, { kind: 'dependency', origins: new Set(locations) })
}
return expected
}
const add = (name: string, origin: string): void => {
if (name === pkg.name) return
const kind = staticInputs.has(name) ? 'dev' : isInternalDsh(name) ? 'peer-dev' : undefined
if (kind === undefined) return
const current = expected.get(name)
if (current !== undefined) current.origins.add(origin)
else expected.set(name, { kind, origins: new Set([origin]) })
}
for (const [name, locations] of Object.entries(pkg.sourceUses)) {
for (const location of locations) add(name, location)
}
for (const name of pkg.inject) add(name, 'dsh.client.inject')
return expected
}
interface ModuleEdge {
readonly from: string
readonly to: string
readonly specifier: string
}
function collectModuleViolations(facts: ClientPackageFacts): string[] {
const violations: string[] = []
const baseline = new Set([...facts.platformModules, ...facts.preloadedExternals])
const rows = rowNames(facts.declarations)
const byName = new Map(facts.declarations.map(entry => [entry.name, entry]))
const edges: ModuleEdge[] = []
for (const pkg of facts.declarations.filter(entry => entry.dynamic)) {
for (const field of ['external', 'inject'] as const) {
const seen = new Set<string>()
for (const value of pkg[field]) {
if (value === '') violations.push(pkg.manifest + ': dsh.client.' + field + ' contains an empty value')
else if (seen.has(value)) {
violations.push(pkg.manifest + ': dsh.client.' + field + ' lists ' + JSON.stringify(value) + ' twice')
}
seen.add(value)
}
}
for (const specifier of new Set(pkg.external)) {
if (specifier === '') continue
if (baseline.has(specifier)) {
violations.push(
pkg.manifest + ': dsh.client.external repeats baseline module ' + JSON.stringify(specifier)
+ '; remove the explicit declaration',
)
continue
}
const supplier = rowPackageOf(specifier, rows)
if (supplier === pkg.name) {
violations.push(pkg.manifest + ': dsh.client.external names its own row ' + JSON.stringify(specifier))
} else if (supplier !== undefined) {
edges.push({ from: pkg.name, to: supplier, specifier })
} else {
const owner = stripClientSuffix(specifier)
violations.push(
pkg.manifest + ': dsh.client.external ' + JSON.stringify(specifier) + ' has no supplier;'
+ (byName.has(owner)
? ' workspace package ' + owner
+ ' declares no dynamic dsh.client row and the shell does not seed this specifier'
: ' no dynamic row or PLATFORM_MODULES entry answers it'),
)
}
}
}
violations.push(...collectModuleCycles(edges, byName))
return violations
}
function collectModuleCycles(
edges: readonly ModuleEdge[],
byName: ReadonlyMap<string, ClientDeclaration>,
): string[] {
const outgoing = new Map<string, ModuleEdge[]>()
for (const edge of [...edges].sort((left, right) => left.specifier.localeCompare(right.specifier))) {
outgoing.set(edge.from, [...outgoing.get(edge.from) ?? [], edge])
}
const finished = new Set<string>()
const onPath = new Set<string>()
const path: ModuleEdge[] = []
const reported = new Map<string, string>()
const walk = (name: string): void => {
onPath.add(name)
for (const edge of outgoing.get(name) ?? []) {
if (onPath.has(edge.to)) {
const start = path.findIndex(entry => entry.from === edge.to)
const cycle = start === -1 ? [edge] : [...path.slice(start), edge]
const key = cycleKey(cycle)
if (!reported.has(key)) reported.set(key, formatCycle(cycle, byName))
} else if (!finished.has(edge.to)) {
path.push(edge)
walk(edge.to)
path.pop()
}
}
onPath.delete(name)
finished.add(name)
}
for (const name of [...outgoing.keys()].sort()) {
if (!finished.has(name)) walk(name)
}
return [...reported.values()]
}
function cycleKey(cycle: readonly ModuleEdge[]): string {
const labels = cycle.map(edge => edge.from + ' ' + edge.specifier)
const first = [...labels].sort()[0]
const offset = first === undefined ? 0 : labels.indexOf(first)
return [...labels.slice(offset), ...labels.slice(0, offset)].join(' -> ')
}
function formatCycle(
cycle: readonly ModuleEdge[],
byName: ReadonlyMap<string, ClientDeclaration>,
): string {
const entry = cycle[0]
const chain = cycle.map(edge => edge.from + ' --(' + edge.specifier + ')-->').join(' ')
const manifest = entry === undefined ? 'packages/client' : byName.get(entry.from)?.manifest ?? entry.from
return manifest + ': synchronous dsh.client.external cycle: ' + chain + ' ' + (entry?.from ?? '')
}
interface Manifest {
name?: unknown
dsh?: unknown
dependencies?: Record<string, string>
peerDependencies?: Record<string, string>
devDependencies?: Record<string, string>
}
function readDeclaration(
root: string,
manifestPath: string,
malformed: string[],
): ClientDeclaration | undefined {
const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) as Manifest
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: [] }
}
if (!isRecord(rawClient)) {
malformed.push(manifestPath + ': ' + manifest.name + ' dsh.client must be an object')
return { name: manifest.name, manifest: manifestPath, dynamic: false, external: [], inject: [] }
}
return {
name: manifest.name,
manifest: manifestPath,
dynamic: true,
external: stringArray(rawClient.external, manifest.name, manifestPath, 'external', malformed),
inject: stringArray(rawClient.inject, manifest.name, manifestPath, 'inject', malformed),
}
}
function stringArray(
value: unknown,
packageName: string,
manifestPath: string,
field: string,
malformed: string[],
): readonly string[] {
if (value === undefined) return []
if (!Array.isArray(value) || value.some(entry => typeof entry !== 'string')) {
malformed.push(manifestPath + ': ' + packageName + ' dsh.client.' + field + ' must be a string array')
return []
}
return value as string[]
}
async function readStaticLinkedRoster(root: string): Promise<Set<string>> {
const presetUrl = pathToFileURL(resolve(import.meta.dirname, '..', STATIC_PRESET_SOURCE)).href
const preset = await import(presetUrl) as { isStaticLinkedConfig?: unknown }
if (typeof preset.isStaticLinkedConfig !== 'function') {
throw new Error(GATE + ': ' + STATIC_PRESET_SOURCE + ' exports no isStaticLinkedConfig')
}
const predicate = preset.isStaticLinkedConfig as (configs: readonly unknown[]) => boolean
const roster = new Set<string>()
for (const configPath of globSync(CONFIG_GLOB, { cwd: root }).map(normalizePath).sort()) {
const loaded = await import(pathToFileURL(resolve(root, configPath)).href) as { default?: unknown }
if (typeof loaded.default !== 'function') continue
const configs = (loaded.default as (input: { env: Record<string, string> }) => unknown)({
env: { DSH_BUILD_FACE: 'client' },
})
if (!Array.isArray(configs) || !predicate(configs)) continue
const manifest = JSON.parse(
readFileSync(resolve(root, configPath.replace(/tsdown\.config\.ts$/, 'package.json')), 'utf8'),
) as Manifest
if (typeof manifest.name === 'string') roster.add(manifest.name)
}
return roster
}
function unwrapExpression(expression: ts.Expression): ts.Expression {
let current = expression
while (ts.isAsExpression(current) || ts.isSatisfiesExpression(current) || ts.isParenthesizedExpression(current)) {
current = current.expression
}
return current
}
function readStringLiteralArray(root: string, sourcePath: string, name: string): string[] {
const path = resolve(root, sourcePath)
const source = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, false, ts.ScriptKind.TS)
const constants = new Map<string, string>()
for (const statement of source.statements) {
if (!ts.isVariableStatement(statement)) continue
for (const declaration of statement.declarationList.declarations) {
if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue
const initializer = unwrapExpression(declaration.initializer)
if (ts.isStringLiteral(initializer)) constants.set(declaration.name.text, initializer.text)
}
}
for (const statement of source.statements) {
if (!ts.isVariableStatement(statement)) continue
for (const declaration of statement.declarationList.declarations) {
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== name) continue
const expression = declaration.initializer === undefined ? undefined : unwrapExpression(declaration.initializer)
if (expression === undefined || !ts.isArrayLiteralExpression(expression)) {
throw new Error(GATE + ': ' + name + ' in ' + sourcePath + ' must be an array literal')
}
return expression.elements.map((element) => {
const value = unwrapExpression(element)
if (ts.isStringLiteral(value)) return value.text
if (ts.isIdentifier(value) && constants.has(value.text)) return constants.get(value.text) as string
throw new Error(GATE + ': ' + name + ' in ' + sourcePath + ' must contain only string constants')
})
}
}
throw new Error(GATE + ': ' + sourcePath + ' declares no ' + name)
}
async function readFacts(root: string): Promise<ClientPackageFacts> {
const { declarations, malformed } = readClientDeclarations(root)
const byManifest = new Map(declarations.map(entry => [entry.manifest, entry]))
const staticLinkedPackages = await readStaticLinkedRoster(root)
const project = new TypeScriptProject(root, 'client')
const packages: ClientPackage[] = []
for (const manifestPath of globSync(CLIENT_MANIFEST_GLOB, { cwd: root }).map(normalizePath).sort()) {
const declaration = byManifest.get(manifestPath)
if (declaration === undefined) throw new Error(GATE + ': no declaration facts for ' + manifestPath)
const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) as Manifest
if (typeof manifest.name !== 'string') throw new Error(GATE + ': ' + manifestPath + ' has no package name')
const sourceUses = new Map<string, Set<string>>()
const runtimeSourceUses = new Map<string, Set<string>>()
const packageDirectory = dirname(manifestPath)
const sourcePrefix = packageDirectory + '/src/'
for (const sourceFile of project.sourceFiles()) {
if (sourceFile.isDeclarationFile) continue
const file = project.relativePath(sourceFile)
if (!file.startsWith(sourcePrefix)) continue
for (const name of collectSourceFilePackageUses(sourceFile, false)) {
const locations = sourceUses.get(name) ?? new Set<string>()
locations.add(file)
sourceUses.set(name, locations)
}
for (const name of collectSourceFilePackageUses(sourceFile, true)) {
const locations = runtimeSourceUses.get(name) ?? new Set<string>()
locations.add(file)
runtimeSourceUses.set(name, locations)
}
}
packages.push({
...declaration,
staticLinked: staticLinkedPackages.has(declaration.name),
sourceUses: Object.fromEntries(
[...sourceUses].sort(([left], [right]) => left.localeCompare(right))
.map(([name, locations]) => [name, [...locations].sort()]),
),
runtimeSourceUses: Object.fromEntries(
[...runtimeSourceUses].sort(([left], [right]) => left.localeCompare(right))
.map(([name, locations]) => [name, [...locations].sort()]),
),
dependencies: manifest.dependencies ?? {},
peerDependencies: manifest.peerDependencies ?? {},
devDependencies: manifest.devDependencies ?? {},
})
}
return {
packages,
declarations,
staticLinkedPackages,
platformModules: readStringLiteralArray(root, PLATFORM_SOURCE, 'PLATFORM_MODULES'),
preloadedExternals: readStringLiteralArray(root, PLATFORM_SOURCE, 'PRELOADED_CLIENT_EXTERNALS'),
parserPreloadIds: readStringLiteralArray(root, PARSER_PRELOAD_SOURCE, 'PARSER_PRELOAD_IDS'),
malformed,
}
}
function packageNameOf(specifier: string): string {
const segments = specifier.split('/')
return segments.slice(0, specifier.startsWith('@') ? 2 : 1).join('/')
}
function stripClientSuffix(specifier: string): string {
return specifier.endsWith('/client') ? specifier.slice(0, -'/client'.length) : specifier
}
function rowNames(declarations: readonly ClientDeclaration[]): Set<string> {
return new Set(declarations.filter(entry => entry.dynamic).map(entry => entry.name))
}
function rowPackageOf(specifier: string, rows: ReadonlySet<string>): string | undefined {
if (rows.has(specifier)) return specifier
const stripped = stripClientSuffix(specifier)
return rows.has(stripped) ? stripped : undefined
}
function declaredSections(pkg: ClientPackage, name: string): string[] {
return (['dependencies', 'peerDependencies', 'devDependencies'] as const)
.filter(section => pkg[section][name] !== undefined)
}
function describeSections(sections: readonly string[]): string {
return sections.length === 0 ? 'no dependency declaration' : sections.join(' + ')
}
function describeRangeMismatch(peer: string | undefined, dev: string | undefined): string {
if (peer === undefined || dev === undefined || peer === dev) return ''
return ' (peer ' + peer + ', dev ' + dev + ')'
}
function describeOrigins(origins: ReadonlySet<string>): string {
const sorted = [...origins].sort()
const [first, second, ...rest] = sorted
if (first === undefined) return 'production use'
if (second === undefined) return first
return rest.length === 0 ? first + ', ' + second : first + ', ' + second + ', and ' + String(rest.length) + ' more'
}
function isInternalDsh(name: string): boolean {
return name === CORDIS || name.startsWith(DSH_PREFIX)
}
function isBareSpecifier(specifier: string): boolean {
return !specifier.startsWith('.') && !specifier.startsWith('/') && !specifier.startsWith('#')
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function normalizePath(path: string): string {
return path.split(sep).join('/')
}
async function main(): Promise<void> {
const root = resolve(import.meta.dirname, '..')
let facts = await readFacts(root)
if (process.argv.includes('--fix')) {
const changed = fixClientPackageManifests(root, facts)
console.log(
changed.length === 0
? GATE + ': no mechanically fixable manifest changes.'
: GATE + ': fixed ' + String(changed.length) + ' manifest(s): ' + changed.join(', '),
)
facts = await readFacts(root)
}
const violations = collectClientPackageViolations(facts)
if (violations.length > 0) {
console.error(GATE + ': ' + String(violations.length) + ' violation(s):')
for (const violation of violations) console.error(' ' + violation)
process.exit(1)
}
const dynamic = facts.packages.filter(pkg => pkg.dynamic).length
const requests = facts.declarations.reduce((total, pkg) => total + pkg.external.length, 0)
console.log(
GATE + ': ' + String(facts.packages.length) + ' client packages (' + String(dynamic) + ' dynamic, '
+ String(facts.packages.length - dynamic) + ' statically linked) satisfy dependency and module-request rules; '
+ String(requests) + ' explicit external request(s).',
)
}
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
await main()
}
@@ -51,6 +51,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' },
'packages/core/agent-tool-presentation': { kind: 'indirect', reason: 'The row only selects between the two projections dsh-tools owns; it registers no prompt, schema, or result of its own.' },
'packages/code-runtime/code-runtime-worker-thread': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' },
'packages/code-runtime/code-runtime-python': { kind: 'indirect', reason: 'The CPython subprocess backend delegates model rendering to Code Mode in dsh-tools.' },
'packages/client/ui-agent-preset': { kind: 'indirect', reason: 'Browser-side settings row; the preset it selects owns every model-facing effect.' },
'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' },
'packages/preset/agent-presets': { kind: 'indirect', reason: 'The mount installs a preset\'s own plugins, which own every model-facing registration it makes visible.' },
@@ -63,8 +64,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/client/ui-slots': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
'packages/client/ui-attachment': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
'packages/client/ui-primitives': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
'packages/client/web-react': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
'packages/client/schema-form': { kind: 'none', reason: 'Browser-side form-rendering library; registers nothing model-facing.' },
'packages/client/ui-renderer': { kind: 'none', reason: 'Browser-side render assembly; registers nothing model-facing.' },
'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
'packages/api/remotes': { kind: 'none', reason: 'The Remote BFF selects business methods and identity policy; selected services own any model-visible effect.' },
'packages/client/runtime': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },