mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
merge: bring master into feat/pwsh-persistent-pty
This commit is contained in:
@@ -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}.`,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,5 +1,5 @@
|
||||
{
|
||||
"AGENTS.md": 1900,
|
||||
"AGENTS.md": 1950,
|
||||
"docs/AGENTS.md": 1320,
|
||||
"docs/architecture.md": 2400,
|
||||
"docs/cordis-primer.md": 600,
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -73,6 +73,7 @@ class CatalogAttachmentStore extends AttachmentStore {
|
||||
maxImagesPerMessage: 1,
|
||||
maxMessageImageBytes: 1,
|
||||
maxImagePixels: 1,
|
||||
maxImageDimension: 1,
|
||||
mediaTypes: Object.freeze(['image/png'] as const),
|
||||
})
|
||||
|
||||
|
||||
@@ -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\\.'
|
||||
|
||||
@@ -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' }],
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)(\\/|$)/
|
||||
|
||||
|
||||
@@ -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"/],
|
||||
|
||||
@@ -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
@@ -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}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -117,6 +117,7 @@ class TestAttachmentStore extends AttachmentStore {
|
||||
maxImagesPerMessage: 1,
|
||||
maxMessageImageBytes: 1,
|
||||
maxImagePixels: 1,
|
||||
maxImageDimension: 1,
|
||||
mediaTypes: ['image/png'],
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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:^' })
|
||||
})
|
||||
})
|
||||
@@ -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.' },
|
||||
|
||||
Reference in New Issue
Block a user