Merge remote-tracking branch 'origin/master' into fix/locale-default-english

# Conflicts:
#	packages/client/ui-settings-models/tests/apply.client.spec.ts
#	packages/client/ui-settings-plugins/tests/apply.client.spec.ts
#	packages/client/ui-theme/tests/apply.client.spec.ts
This commit is contained in:
Chinesezjc
2026-08-18 10:53:18 +08:00
354 changed files with 6918 additions and 3794 deletions
+8 -1
View File
@@ -135,6 +135,13 @@ 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'],
@@ -169,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.
+54 -6
View File
@@ -1,6 +1,6 @@
/**
* CSS Modules enter client bundles through virtual modules, so the loader must
* explicitly register the underlying stylesheet as a watch dependency.
* Stylesheets enter client bundles through virtual modules, so the loader must
* register their physical files as watch dependencies.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -14,7 +14,7 @@ interface CssPlugin {
load?: (this: { addWatchFile(id: string): void }, id: string) => Promise<string | null>
}
function cssPlugin(): CssPlugin {
function cssPlugin(name: 'dsh-css-modules-inline' | 'dsh-css-global-inline' | 'dsh-css-text-inline'): CssPlugin {
const configs = clientBundle(
'@deepseek-ai/dsh-client-test',
['lib/types/index.js', 'lib/types/invariant.js'],
@@ -22,8 +22,8 @@ function cssPlugin(): CssPlugin {
const client = configs.find(config => config.platform === 'browser')
if (client === undefined) throw new Error('client config missing')
const plugins = (client as { plugins: CssPlugin[] }).plugins
const plugin = plugins.find(candidate => candidate.name === 'dsh-css-modules-inline')
if (plugin === undefined) throw new Error('CSS Modules plugin missing from client config')
const plugin = plugins.find(candidate => candidate.name === name)
if (plugin === undefined) throw new Error(`${name} missing from client config`)
return plugin
}
@@ -34,7 +34,7 @@ describe('client bundle CSS Modules', () => {
const stylesheet = join(root, 'Fixture.module.css')
const importer = join(root, 'index.ts')
await writeFile(stylesheet, '.root { color: red; }\n')
const plugin = cssPlugin()
const plugin = cssPlugin('dsh-css-modules-inline')
const virtualId = plugin.resolveId?.('./Fixture.module.css', importer)
if (typeof virtualId !== 'string' || plugin.load === undefined) {
throw new Error('CSS Modules plugin hooks are incomplete')
@@ -50,3 +50,51 @@ describe('client bundle CSS Modules', () => {
}
})
})
describe('client bundle global CSS', () => {
it('compiles a side-effect stylesheet into a watched style injector', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-client-global-css-watch-'))
try {
const stylesheet = join(root, 'base.css')
const importer = join(root, 'index.ts')
await writeFile(stylesheet, 'body { color: red; }\n')
const plugin = cssPlugin('dsh-css-global-inline')
const virtualId = plugin.resolveId?.('./base.css', importer)
if (typeof virtualId !== 'string' || plugin.load === undefined) {
throw new Error('global CSS plugin hooks are incomplete')
}
const watched: string[] = []
const output = await plugin.load.call({ addWatchFile: id => watched.push(id) }, virtualId)
expect(watched).toEqual([stylesheet])
expect(output).toContain('data-plugin-css')
expect(output).toContain('body{color:red}')
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('compiles inline stylesheets as watched text without a module side effect', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-client-inline-css-watch-'))
try {
const stylesheet = join(root, 'base.css')
const importer = join(root, 'index.ts')
await writeFile(stylesheet, 'body { color: red; }\n')
const plugin = cssPlugin('dsh-css-text-inline')
const virtualId = plugin.resolveId?.('./base.css?inline', importer)
if (typeof virtualId !== 'string' || plugin.load === undefined) {
throw new Error('inline CSS plugin hooks are incomplete')
}
const watched: string[] = []
const output = await plugin.load.call({ addWatchFile: id => watched.push(id) }, virtualId)
expect(watched).toEqual([stylesheet])
expect(output).toContain('export default "body{color:red}"')
expect(output).not.toContain('data-plugin-css')
} finally {
await rm(root, { recursive: true, force: true })
}
})
})
+48 -12
View File
@@ -4,7 +4,7 @@
*/
import { fileURLToPath } from 'node:url'
import { describe, expect, it, vi } from 'vitest'
import { CLIENT_EXTERNALS, clientBundle } from '../packages/client/tsdown.client.ts'
import { clientBundle, requestedExternals } from '../packages/client/tsdown.client.ts'
type ResolveId = (source: string) => null | { id: string; external: boolean }
@@ -14,7 +14,10 @@ interface CssModulePlugin {
load?: (this: { addWatchFile: (id: string) => void }, id: string) => Promise<unknown>
}
function clientConfigs(id = '@deepseek-ai/dsh-client-test') {
/** A representative dynamic bundle using the shared client baseline. */
const REQUESTING_PACKAGE = '@deepseek-ai/dsh-client-ui-conversation'
function clientConfigs(id = REQUESTING_PACKAGE) {
return clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])(
{ env: { DSH_BUILD_FACE: 'client' } },
).filter(config => config.platform === 'browser')
@@ -36,10 +39,10 @@ function clientSourceMapPath(packagePath: string): string {
return fileURLToPath(new URL(`../packages/${packagePath}/lib/client.js.map`, import.meta.url))
}
function purityResolveId(): ResolveId {
function purityResolveId(id = REQUESTING_PACKAGE): ResolveId {
// libEntry is spelled at every call site (no default) so the
// package-invariants text check can see the invariant entry per package.
const configs = clientConfigs()
const configs = clientConfigs(id)
const plugins = (configs[0] as { plugins: { name: string; resolveId?: unknown }[] }).plugins
const gate = plugins.find(p => p.name === 'dsh-client-bundle-purity')
if (gate?.resolveId === undefined) throw new Error('purity plugin missing from client config')
@@ -59,15 +62,16 @@ function cssModulePlugin(): CssModulePlugin {
describe('client bundle purity gate', () => {
const resolveId = purityResolveId()
it('leaves platform table entries and non-scoped specifiers alone', () => {
it('leaves default externals and non-scoped specifiers alone', () => {
expect(resolveId('@deepseek-ai/dsh-client-ui-slots')).toBeNull()
expect(resolveId('@deepseek-ai/dsh-client-web-react')).toBeNull()
expect(resolveId('@deepseek-ai/dsh-client-ui-primitives')).toBeNull()
expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull()
expect(resolveId('react')).toBeNull()
expect(resolveId('zod')).toBeNull()
})
it('rejects retired table entries (web-react/store left the 8-entry seed)', () => {
it('rejects the retired web-react platform package', () => {
expect(() => resolveId('@deepseek-ai/dsh-client-web-react')).toThrow(/purity/)
expect(() => resolveId('@deepseek-ai/dsh-client-web-react/store')).toThrow(/purity/)
})
@@ -89,17 +93,49 @@ describe('client bundle purity gate', () => {
expect(() => resolveId('@deepseek-ai/dsh-client-web')).toThrow(/purity/)
})
it('throws on cross-plugin value imports — bare plugin names and /client subpaths alike (the rewrite arm is gone)', () => {
it('throws on cross-plugin value imports — bare plugin names and /client subpaths alike', () => {
expect(() => resolveId('@deepseek-ai/dsh-client-connection')).toThrow(/purity/)
expect(() => resolveId('@deepseek-ai/dsh-client-runtime')).toThrow(/purity/)
expect(() => resolveId('@deepseek-ai/dsh-client-ui-layout/client')).toThrow(/purity/)
})
it('carries exactly one documented temporary exemption: runtime/client (store engine pending rehoming)', () => {
it('admits the parser-preloaded runtime for every dynamic bundle', () => {
expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull()
const clientChannels = CLIENT_EXTERNALS.filter(
entry => entry.startsWith('@deepseek-ai/') && entry.endsWith('/client'))
expect(clientChannels).toEqual(['@deepseek-ai/dsh-client-runtime/client'])
const withoutRequest = purityResolveId('@deepseek-ai/dsh-client-ui-goal')
expect(withoutRequest('@deepseek-ai/dsh-client-runtime/client')).toBeNull()
})
it('externalizes the baseline independently of each package manifest', () => {
const requesting = clientConfigs()[0]?.deps as { neverBundle: (specifier: string) => boolean }
const plain = clientConfigs('@deepseek-ai/dsh-client-connection')[0]?.deps as {
neverBundle: (specifier: string) => boolean
}
expect(requesting.neverBundle('react')).toBe(true)
expect(requesting.neverBundle('zod')).toBe(false)
expect(plain.neverBundle('react')).toBe(true)
expect(plain.neverBundle('@deepseek-ai/dsh-client-runtime/client')).toBe(true)
})
})
describe('client bundle module requests', () => {
it('requests what the declaration lists', () => {
const requests = requestedExternals('@deepseek-ai/dsh-client-fixture', {
external: ['react', 'react/jsx-runtime', '@deepseek-ai/dsh-client-ui-slots'],
})
expect([...requests].sort()).toEqual([
'@deepseek-ai/dsh-client-ui-slots', 'react', 'react/jsx-runtime',
])
})
it('requests nothing when the declaration is absent', () => {
expect(requestedExternals('@deepseek-ai/dsh-client-fixture', {}).size).toBe(0)
})
it('rejects a malformed declaration instead of reading past it', () => {
expect(() => requestedExternals('@deepseek-ai/dsh-client-fixture', { external: 'react' }))
.toThrow(/dsh\.client\.external must be a string array/)
})
})
+26 -1
View File
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import type { TsdownBundle } from 'tsdown'
import { discoverPluginDirs, watchClientPlugins } from './dev-web.ts'
import { discoverLibraryDirs, discoverPluginDirs, watchClientPlugins } from './dev-web.ts'
it('discovers dsh.client packages with sibling roles', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-discovery-'))
@@ -24,6 +24,31 @@ it('discovers dsh.client packages with sibling roles', async () => {
}
})
it('discovers client-preset packages the shell links, excluding loader-delivered and test infrastructure', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-library-'))
try {
const write = async (dir: string, manifest: unknown, config: string): Promise<void> => {
await mkdir(join(root, dir), { recursive: true })
await writeFile(join(root, dir, 'package.json'), JSON.stringify(manifest))
await writeFile(join(root, dir, 'tsdown.config.ts'), config)
}
const clientPreset = "import { clientLibrary } from '../tsdown.client.ts'\nexport default clientLibrary('x', [])\n"
// Linked by the compile shell: client preset, no loader-delivered half.
await write('packages/client/linked', {}, clientPreset)
// Loader-delivered: discoverPluginDirs owns it, so it must not appear twice.
await write('packages/client/delivered', { dsh: { client: { platform: 'web' } } }, clientPreset)
// Test infrastructure builds through the preset but never enters the shell graph.
await write('packages/test-support/harness', {}, clientPreset)
// Host package with its own config: not a client-face build at all.
await write('packages/host/server', {}, "import { defineConfig } from 'tsdown'\nexport default defineConfig({})\n")
expect(discoverLibraryDirs(root)).toEqual(['packages/client/linked'])
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('rebuilds a client-plugin bundle after its source changes', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-watch-'))
let bundles: TsdownBundle[] = []
+137 -13
View File
@@ -1,17 +1,28 @@
/**
* Watch-build for client-plugin HMR: runs every `dsh.client` plugin package
* through the tsdown JS API in watch mode. Reload signaling is not this
* script's business — the host webserver stat-polls the bundles it serves and
* broadcasts `rebuilt` frames itself (`dsh web`), so any process that
* rewrites `lib/client.js` files triggers reloads; this script is merely the
* convenient way to keep them all rebuilt on source change.
* Watch-build for the web dev loop: rebuilds every artifact the browser reads
* from a source edit. Reload signaling is not this script's business — the host
* webserver stat-polls the bundles it serves and broadcasts `rebuilt` frames
* itself (`dsh web`), so any process that rewrites `lib/client.js` files
* triggers reloads; this script is merely the convenient way to keep them all
* rebuilt on source change.
*
* Usage: `pnpm exec tsx scripts/dev-web.ts [--poll[=ms]]`. Requires the
* packages' node halves built once (`tsc -b tsconfig.build.json`): the lib
* config's entries are tsc output. `--poll` switches the source-file watcher
* to polling (default 500ms): network mounts (weka) deliver no inotify
* Three stages, because the compile shell links built lib products rather than
* sources: `tsc -b tsconfig.client.json` emits `lib/types` (the tsdown lib
* entries are that emit, not `src`), tsdown bundles `lib/index.js` and
* `lib/client.js`, and `vite build` rewrites `apps/web/dist`, which `dsh web`
* serves. A missing stage does not fail — it silently shows the previous
* artifact, so an edit appears to do nothing.
*
* MUST NOT run concurrently with `pnpm run build`: both write the same
* `lib/` and `apps/web/dist/` trees.
*
* Usage: `pnpm exec tsx scripts/dev-web.ts [--poll[=ms]]`. Requires one prior
* `pnpm run build`: every stage is incremental over the previous stage's output
* and none of them bootstraps a missing tree. `--poll` switches the source
* watchers to polling (default 500ms): network mounts (weka) deliver no inotify
* events, so native watching sees the initial build only and never a source
* change.
* change. Polling has to reach tsc too — a native-watching tsc never re-emits
* `lib/types`, which strands the other two stages on stale input.
*
* Each package keeps its own tsdown.config.ts untouched: this script layers
* `watch` through API-level inline config (tsdown workspace mode fills inline
@@ -20,11 +31,24 @@
import { globSync, readFileSync } from 'node:fs'
import { dirname, join, resolve, sep } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { execa } from 'execa'
import { build } from 'tsdown'
import type { TsdownBundle } from 'tsdown'
const repoRoot = fileURLToPath(new URL('..', import.meta.url))
/** Client-face type emit feeding every tsdown lib entry in the watch set. */
const CLIENT_TYPE_PROGRAM = 'tsconfig.client.json'
/** Compile-shell workspace whose dist `dsh web` serves. */
const SHELL_PACKAGE = '@deepseek-ai/dsh-web-frontend'
/**
* Test infrastructure builds through the client preset but never enters the
* shell's module graph, so it is not a dev-loop artifact.
*/
const TEST_INFRASTRUCTURE_PREFIX = 'packages/test-support/'
/**
* Discover the watch workspace by declaration: every packages/<group>/<name>
* whose package.json carries `dsh.client` with platform "web" is a client
@@ -44,6 +68,32 @@ export function discoverPluginDirs(root = repoRoot): string[] {
return dirs
}
/**
* Discover the statically linked library packages: the other half of the same
* partition {@link discoverPluginDirs} takes. A package that builds through the
* client preset without declaring `dsh.client` has no loader-delivered browser
* half, so the compile shell links its `lib/index.js` instead — and an edit to
* its source reaches the browser only once that bundle is rewritten. Deriving
* the set from the build preset rather than a hand list keeps it correct when
* dependency sections move around; deriving it from `dependencies` would not,
* because client packages declare their build inputs as devDependencies.
* @param root - repository root containing the grouped package directories.
* @returns workspace-relative library package directories.
*/
export function discoverLibraryDirs(root = repoRoot): string[] {
const dirs: string[] = []
for (const configPath of globSync('packages/*/*/tsdown.config.ts', { cwd: root }).sort()) {
const dir = dirname(configPath).split(sep).join('/')
if (dir.startsWith(TEST_INFRASTRUCTURE_PREFIX)) continue
if (!readFileSync(join(root, configPath), 'utf8').includes('tsdown.client.ts')) continue
const manifest = JSON.parse(readFileSync(join(root, dir, 'package.json'), 'utf8')) as {
dsh?: { client?: unknown }
}
if (manifest.dsh?.client === undefined) dirs.push(dir)
}
return dirs
}
/**
* Start the tsdown watch build used by `pnpm run dev:web`.
* @param root - repository or fixture root passed to tsdown.
@@ -85,14 +135,56 @@ export async function watchClientPlugins(
return bundles
}
/**
* Live watcher processes to terminate when this script is interrupted. Stages
* register themselves as they start, so the set is complete from the first
* spawn: an interrupt during a later stage's startup still tears down the
* earlier ones instead of orphaning them.
*/
const stages: StageHandle[] = []
/**
* Spawn one watcher stage, inheriting stdio, registering it for teardown, and
* failing loud if it ever exits: a dead stage leaves the artifact chain silently
* stale, which reads as "my edit did nothing" — the one failure this script
* exists to prevent.
* @param stage - command label used in the exit diagnostic.
* @param command - executable, resolved from the workspace bin when local.
* @param args - command arguments.
* @param local - whether to resolve `command` from the workspace's installed bins.
*/
function spawnStage(stage: string, command: string, args: readonly string[], local: boolean): void {
const child = execa(command, [...args], {
cwd: repoRoot,
stdio: 'inherit',
preferLocal: local,
reject: false,
})
stages.push({ kill: () => { child.kill() } })
void child.then((result) => {
console.error(`dev-web: ${stage} exited (code ${String(result.exitCode)}); the artifact chain is now stale`)
process.exit(1)
})
}
/** The only capability this script needs from a live watcher process. */
interface StageHandle {
readonly kill: () => void
}
const invokedPath = process.argv[1]
const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href
if (isMain) {
const pluginDirs = discoverPluginDirs()
const libraryDirs = discoverLibraryDirs()
if (pluginDirs.length === 0) {
console.error('dev-web: no dsh.client (platform "web") packages found under packages/')
process.exit(1)
}
if (libraryDirs.length === 0) {
console.error('dev-web: no client-preset library packages found under packages/ — the compile shell links their lib products, so an empty set means the discovery predicate is stale')
process.exit(1)
}
const args = process.argv.slice(2)
const pollArg = args.find(a => a === '--poll' || a.startsWith('--poll='))
@@ -106,9 +198,41 @@ if (isMain) {
process.exit(1)
}
await watchClientPlugins(repoRoot, pluginDirs, pollInterval)
// Registered before any stage starts: `stages` is read at signal time, so an
// interrupt during tsdown's initial builds still kills whatever is running.
const stop = (): void => { for (const stage of stages) stage.kill() }
process.once('SIGINT', stop)
process.once('SIGTERM', stop)
// tsc has no polling interval flag, so `--poll` selects its fixed-interval
// watchers rather than an interval. Dropping that translation leaves tsc
// natively watching on a network mount where inotify never fires: it stops
// re-emitting lib/types, and the two later stages then rebuild forever from
// stale input without printing anything.
spawnStage(`tsc -b ${CLIENT_TYPE_PROGRAM} --watch`, 'tsc', [
'-b', CLIENT_TYPE_PROGRAM, '--watch', '--preserveWatchOutput',
...pollInterval !== undefined
? ['--watchFile', 'fixedPollingInterval', '--watchDirectory', 'fixedPollingInterval']
: [],
], true)
// tsdown's initial builds are awaited before the dist watcher starts so vite's
// first build reads current lib bundles rather than whatever the last full
// build left. Its own watch then covers later lib rewrites — those files are
// in its module graph.
await watchClientPlugins(repoRoot, [...pluginDirs, ...libraryDirs], pollInterval)
// Through the shell's own `watch` script rather than vite's API: vite is not a
// repository-root dependency, and more importantly the vite root is its
// working directory — `resolve.dedupe` resolves react from that root, so
// running vite from anywhere but apps/web silently switches which react copy
// the bundle gets.
spawnStage('vite build --watch', 'pnpm', ['--filter', SHELL_PACKAGE, 'run', 'watch'], false)
console.log(
`dev-web: watching ${String(pluginDirs.length)} dsh.client plugin packages`
+ `${pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : ''}:\n ${pluginDirs.join('\n ')}`,
+ ` and ${String(libraryDirs.length)} statically linked library packages`
+ (pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : '')
+ `, plus tsc -b ${CLIENT_TYPE_PROGRAM} and the ${SHELL_PACKAGE} dist build:\n `
+ [...pluginDirs, ...libraryDirs].join('\n '),
)
}
+1 -1
View File
@@ -12,7 +12,7 @@ export function builtDeclarationPath(candidate: string): string {
if (sourceFile?.[1] && sourceFile[2]) {
return `${sourceFile[1]}/lib/types/${sourceFile[2]}.d.ts`
}
// Directory subpath entries (web-react's /store, runtime's /client): the
// Directory subpath entries (for example, runtime's /client): the
// source dir maps to the same dir under lib/types (index resolution applies).
const sourceDir = /^(.*)\/src\/(.+)$/.exec(candidate)
if (sourceDir?.[1] && sourceDir[2]) {
+2 -1
View File
@@ -135,7 +135,8 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
dshHomePath: 'not a service: boot-provided root accessor function (typeof dshHomePath | undefined) for Loader !!js config expressions — packages/boot/app-boot/README.md owns the boot contract',
launchEnvironment: 'not a service: launcher-provided root accessor value (LaunchEnvironmentSnapshot | undefined) — packages/util/launch-environment/README.md owns this launcher contract',
connection: 'interface-typed (HostConnectionHandle); implementing class HostConnectionService is declared in rpc-host.ts — packages/client/connection/README.md owns the API',
appShell: 'client-side interface-typed browser service — packages/client/web/README.md owns the API',
uiRenderer: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API',
settingsSchema: 'client-side schema introspection service — packages/client/ui-settings/README.md owns the API',
settingsScope: 'client-side settings-namespace transport service — packages/client/ui-settings/README.md owns the API',
chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-conversation/README.md owns the API',
commandUi: 'client-side interface-typed browser service — packages/client/ui-commands/README.md owns the API',
+33 -5
View File
@@ -13,7 +13,11 @@ afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function fixture(exportPath = './lib/index.js'): string {
function fixture(options: {
exportPath?: string
indexSource?: string
files?: Record<string, string>
} = {}): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-publint-all-'))
roots.push(root)
const packageDir = join(root, 'packages/core/probe')
@@ -26,10 +30,14 @@ function fixture(exportPath = './lib/index.js'): string {
engines: { node: '>=22.19' },
sideEffects: false,
files: ['lib'],
exports: { '.': { default: exportPath } },
exports: { '.': { default: options.exportPath ?? './lib/index.js' } },
}, null, 2)}\n`)
writeFileSync(join(packageDir, 'README.md'), '# Probe\n')
writeFileSync(join(packageDir, 'lib/index.js'), 'export const probe = true\n')
writeFileSync(join(packageDir, 'lib/index.js'), options.indexSource ?? 'export const probe = true\n')
for (const [path, source] of Object.entries(options.files ?? {})) {
mkdirSync(join(packageDir, path, '..'), { recursive: true })
writeFileSync(join(packageDir, path), source)
}
writeFileSync(join(packageDir, 'unpublished.js'), 'export const hidden = true\n')
return root
}
@@ -54,14 +62,34 @@ describe('publint package runner', () => {
})
it('rejects an export that exists in the workspace but is not published', () => {
const result = run(fixture('./unpublished.js'))
const result = run(fixture({ exportPath: './unpublished.js' }))
expect(result.status).toBe(1)
expect(result.stdout).toContain('unpublished.js')
})
it('rejects a public export whose built file is missing', () => {
const result = run(fixture('./lib/missing.js'))
const result = run(fixture({ exportPath: './lib/missing.js' }))
expect(result.status).toBe(1)
expect(result.stdout).toContain('missing.js')
})
it('accepts published relative JavaScript and CSS targets', () => {
const result = run(fixture({
indexSource: "export { helper } from './helper.js'\nimport './theme.css'\n",
files: {
'lib/helper.js': 'export const helper = true\n',
'lib/theme.css': ':root {}\n',
},
}))
expect(result.status, result.stderr).toBe(0)
})
it('rejects unpublished relative JavaScript and CSS targets', () => {
const result = run(fixture({
indexSource: "export { helper } from './missing.js'\nimport './missing.css'\n",
}))
expect(result.status).toBe(1)
expect(result.stderr).toContain('imports "./missing.js"')
expect(result.stderr).toContain('imports "./missing.css"')
})
})
+88 -8
View File
@@ -7,10 +7,11 @@ import {
statSync,
} from 'node:fs'
import { availableParallelism } from 'node:os'
import { dirname, relative, resolve, sep } from 'node:path'
import { dirname, posix, relative, resolve, sep } from 'node:path'
import { parseArgs } from 'node:util'
import { publint, type Message, type PackFile } from 'publint'
import { formatMessage } from 'publint/utils'
import ts from 'typescript'
const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY'
const repositoryRoot = resolve(import.meta.dirname, '..')
@@ -32,8 +33,21 @@ interface PackageManifest {
}
type PublintResult =
| { path: string; status: 'passed'; messages: Message[]; manifest: Record<string, unknown> }
| { path: string; status: 'failed'; messages: Message[]; manifest: Record<string, unknown>; failure?: string }
| {
path: string
status: 'passed'
messages: Message[]
closureViolations: string[]
manifest: Record<string, unknown>
}
| {
path: string
status: 'failed'
messages: Message[]
closureViolations: string[]
manifest: Record<string, unknown>
failure?: string
}
function workspacePackages(): PackageTarget[] {
return globSync('packages/*/*/package.json', { cwd: packagesRoot })
@@ -103,21 +117,84 @@ function addPath(path: string, paths: Set<string>): void {
}
}
interface RelativeImport {
specifier: string
line: number
}
/** Return relative imports whose targets are absent from the publication view. */
function publicationClosureViolations(target: PackageTarget, files: readonly PackFile[]): string[] {
const published = new Set(files.map(file => file.name))
const violations: string[] = []
for (const file of files) {
if (!/\.(?:js|mjs|cjs)$/.test(file.name)) continue
const bytes = file.data instanceof ArrayBuffer ? new Uint8Array(file.data) : file.data
const source = typeof bytes === 'string' ? bytes : Buffer.from(bytes).toString('utf8')
for (const imported of relativeImports(file.name, source)) {
const resolved = posix.normalize(posix.join(posix.dirname(file.name), imported.specifier))
if (resolutionCandidates(resolved).some(candidate => published.has(candidate))) continue
violations.push(
`${target.path}/${file.name.slice('package/'.length)}:${String(imported.line)}`
+ ` imports ${JSON.stringify(imported.specifier)}, but ${target.manifest.name ?? target.path}`
+ ` does not publish ${JSON.stringify(resolved.slice('package/'.length))}`,
)
}
}
return violations
}
/** Paths a relative JavaScript module request can resolve to in a published package. */
function resolutionCandidates(target: string): string[] {
const base = target.replace(/\/+$/, '')
return [
target,
...['.js', '.mjs', '.cjs', '/index.js', '/index.mjs', '/index.cjs'].map(suffix => base + suffix),
]
}
/** Extract relative static imports, re-exports, dynamic imports, and requires. */
function relativeImports(file: string, sourceText: string): RelativeImport[] {
const source = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, false, ts.ScriptKind.JS)
const imports: RelativeImport[] = []
const record = (node: ts.Node, literal: ts.Expression | undefined): void => {
if (literal === undefined || !ts.isStringLiteralLike(literal) || !literal.text.startsWith('.')) return
imports.push({
specifier: literal.text,
line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
})
}
const visit = (node: ts.Node): void => {
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
record(node, node.moduleSpecifier)
} else if (ts.isCallExpression(node)
&& (node.expression.kind === ts.SyntaxKind.ImportKeyword
|| ts.isIdentifier(node.expression) && node.expression.text === 'require')) {
record(node, node.arguments[0])
}
ts.forEachChild(node, visit)
}
visit(source)
return imports
}
async function runPublint(target: PackageTarget): Promise<PublintResult> {
try {
const files = publicationFiles(target)
const closureViolations = publicationClosureViolations(target, files)
const result = await publint({
pkgDir: 'package',
pack: { files: publicationFiles(target) },
pack: { files },
})
const manifest = result.pkg as Record<string, unknown>
return result.messages.some(message => message.type === 'error')
? { path: target.path, status: 'failed', messages: result.messages, manifest }
: { path: target.path, status: 'passed', messages: result.messages, manifest }
return result.messages.some(message => message.type === 'error') || closureViolations.length > 0
? { path: target.path, status: 'failed', messages: result.messages, closureViolations, manifest }
: { path: target.path, status: 'passed', messages: result.messages, closureViolations, manifest }
} catch (error: unknown) {
return {
path: target.path,
status: 'failed',
messages: [],
closureViolations: [],
manifest: target.manifest as Record<string, unknown>,
failure: error instanceof Error ? error.message : String(error),
}
@@ -150,7 +227,10 @@ function printResult(result: PublintResult): void {
for (const message of result.messages) {
console.log(formatMessage(message, result.manifest, { color: false }) ?? message.code)
}
if (result.status === 'passed' && result.messages.length === 0) console.log('All good!')
for (const violation of result.closureViolations) console.error(violation)
if (result.status === 'passed' && result.messages.length === 0 && result.closureViolations.length === 0) {
console.log('All good!')
}
}
const packages = workspacePackages()
+31 -2
View File
@@ -104,6 +104,35 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [
// GROUP_ORDER holds `packages/<group>/` directory names, not package names.
{ file: 'scripts/gen-module-graph.ts', upstream: ['cordis'] },
{ file: 'scripts/gen-doc-graphs.ts', upstream: ['cordis'] },
// `cordis/*` is the extensions event domain, not a package subpath. The
// generated catalogs and every producer/consumer must preserve that wire id.
{ file: 'docs/event-producer-consumer.md', upstream: ['cordis'] },
{ file: 'docs/event-producer-consumer.zh.md', upstream: ['cordis'] },
{ file: 'docs/subsystems/extensions.md', upstream: ['cordis'] },
{ file: 'docs/subsystems/extensions.zh.md', upstream: ['cordis'] },
{ file: 'packages/api/remotes/src/remote-events.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/cordis-client-runner/src/client/index.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/cordis-client-runner/src/client/runtime.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/cordis-client-runner/tests/orchestrator.client.spec.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/cordis-host-runner/src/index.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/cordis-host-runner/src/inspect-registry.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/cordis-host-runner/src/types.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/cordis-host-runner/tests/helpers.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/cordis-host-runner/tests/runner.spec.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/cordis-host-runner/tests/versioning.spec.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/tool-cordis/src/api-catalog.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/tool-cordis/src/providers.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/ui-cordis/src/client/index.ts', upstream: ['cordis'] },
{ file: 'packages/extensions/ui-cordis/src/client/inventory.ts', upstream: ['cordis'] },
{ file: 'scripts/gen-cordis-catalog.ts', upstream: ['cordis'] },
// The UI locale namespace and input-trigger source id are product keys.
{ file: 'packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx', upstream: ['cordis'] },
{ file: 'packages/extensions/ui-cordis/src/client/CordisActionRow.tsx', upstream: ['cordis'] },
{ file: 'packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx', upstream: ['cordis'] },
{ file: 'packages/extensions/ui-cordis/src/client/CordisPanel.tsx', upstream: ['cordis'] },
{ file: 'packages/extensions/ui-cordis/src/client/CordisRunRow.tsx', upstream: ['cordis'] },
{ file: 'packages/extensions/ui-cordis/src/client/locales.ts', upstream: ['cordis'] },
]
/** A string that must appear exactly `count` times once the rescope has run. */
@@ -275,8 +304,8 @@ const EXACT_EDITS: readonly ExactEdit[] = [
replace: `/**
* Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
* would read them as plugin packages. They carry no cross-plugin runtime
* identity to share — the framework itself is a platform module (external),
* while these are ordinary libraries a browser bundle inlines.
* identity to share — the framework itself is a requested module-table row
* (external), while these are ordinary libraries a browser bundle inlines.
*/
const VENDORED_LIBRARY = /^@deepseek-ai\\/(cosmokit|schemastery)(\\/|$)/
+9
View File
@@ -92,6 +92,15 @@ describe('gate graph validation', () => {
},
)
it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
'keeps the client dependency policy in %s',
(mode) => {
const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
expect(ids).toContain('client-packages')
},
)
it('keeps native Windows coverage blocking while portability inventory remains observational', () => {
const gates = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))
const byId = new Map(gates.map(subject => [subject.id, subject]))
+2
View File
@@ -252,6 +252,7 @@ function ciSharedStaticGates(): Gate[] {
pnpmScript('optional-dependency-imports', 'verify-optional-dependency-imports', {
label: 'optional dependency imports',
}),
pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }),
pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
]
}
@@ -584,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' }),
]
}
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { resolveClientImport } from './verify-client-domain-graph.ts'
describe('client domain import resolution', () => {
it('preserves imports that leave src/client from a top-level file', () => {
expect(resolveClientImport('styles.ts', '../styles/base.css?inline'))
.toBe('../styles/base.css?inline')
})
it('normalizes imports between domains inside src/client', () => {
expect(resolveClientImport('input/hub.ts', '../queue/store.ts'))
.toBe('queue/store.ts')
})
})
+33 -27
View File
@@ -15,7 +15,7 @@
*/
import { globSync, readdirSync, readFileSync, statSync } from 'node:fs'
import { join, resolve, sep } from 'node:path'
import { join, posix, resolve, sep } from 'node:path'
const root = resolve(import.meta.dirname, '..')
const CLIENT_DIR = join(root, 'packages/client')
@@ -41,6 +41,16 @@ function domainOf(rel: string): string {
return ix === -1 ? '' : rel.slice(0, ix)
}
/**
* Resolve one relative import to a client-directory-relative path.
* @param file - Importing file relative to `src/client`.
* @param specifier - Relative module specifier from that file.
* @returns Normalized path, preserving leading `..` segments outside `src/client`.
*/
export function resolveClientImport(file: string, specifier: string): string {
return posix.normalize(posix.join(posix.dirname(file), specifier))
}
function checkPackage(pkgName: string, clientDir: string): Violation[] {
const violations: Violation[] = []
const files = listSources(clientDir)
@@ -52,17 +62,8 @@ function checkPackage(pkgName: string, clientDir: string): Violation[] {
for (const match of source.matchAll(/from\s+['"](\.[^'"]+)['"]/g)) {
const spec = match[1]
if (spec === undefined) continue
// Resolve the relative specifier against the importing file's directory
// to a client-dir-relative path.
const fromDir = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''
const parts = (fromDir ? fromDir.split('/') : [])
for (const seg of spec.split('/')) {
if (seg === '.') continue
if (seg === '..') parts.pop()
else parts.push(seg)
}
const target = parts.join('/')
if (target.startsWith('..')) continue // out of client dir (package root) — package-level rules govern
const target = resolveClientImport(rel, spec)
if (target === '..' || target.startsWith('../')) continue // package-level rules govern
const toDomain = domainOf(target)
if (toDomain === '' || CONTRACT_DIRS.has(toDomain)) continue // top-level shared file or contract layer
if (fromDomain === toDomain) continue // inside one domain
@@ -78,21 +79,26 @@ function checkPackage(pkgName: string, clientDir: string): Violation[] {
return violations
}
const violations: Violation[] = []
for (const pkg of readdirSync(CLIENT_DIR)) {
const clientDir = join(CLIENT_DIR, pkg, 'src/client')
try {
if (!statSync(clientDir).isDirectory()) continue
} catch {
// No client half in this package — nothing to layer-check.
continue
function main(): void {
const violations: Violation[] = []
for (const pkg of readdirSync(CLIENT_DIR)) {
const clientDir = join(CLIENT_DIR, pkg, 'src/client')
try {
if (!statSync(clientDir).isDirectory()) continue
} catch {
// No client half in this package — nothing to layer-check.
continue
}
violations.push(...checkPackage(pkg, clientDir))
}
violations.push(...checkPackage(pkg, clientDir))
if (violations.length > 0) {
console.error(`verify-client-domain-graph: ${violations.length} violation(s):`)
for (const v of violations) console.error(` ${v.file} -> ${v.imported}\n ${v.reason}`)
process.exitCode = 1
return
}
console.log('verify-client-domain-graph: client domain layering clean.')
}
if (violations.length > 0) {
console.error(`verify-client-domain-graph: ${violations.length} violation(s):`)
for (const v of violations) console.error(` ${v.file} -> ${v.imported}\n ${v.reason}`)
process.exit(1)
}
console.log('verify-client-domain-graph: client domain layering clean.')
if (import.meta.filename === resolve(process.argv[1] ?? '')) main()
+402
View File
@@ -0,0 +1,402 @@
/** Tests for client package modes, dependency sections, and module requests. */
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
collectClientPackageViolations,
collectRuntimeSourcePackageUses,
collectSourcePackageUses,
fixClientPackageManifests,
readClientDeclarations,
type ClientDeclaration,
type ClientPackage,
type ClientPackageFacts,
} from './verify-client-packages.ts'
const CORDIS = '@deepseek-ai/cordis'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function declaration(
short: string,
fields: Partial<Omit<ClientDeclaration, 'name' | 'manifest'>> = {},
): ClientDeclaration {
return {
name: short.startsWith('@') ? short : '@deepseek-ai/dsh-client-' + short,
manifest: 'packages/client/' + short.replace(/^.*\//, '') + '/package.json',
dynamic: true,
external: [],
inject: [],
...fields,
}
}
function pkg(
short: string,
fields: Partial<Omit<ClientPackage, 'name' | 'manifest'>> = {},
): ClientPackage {
return {
...declaration(short),
staticLinked: false,
sourceUses: {},
runtimeSourceUses: {},
dependencies: {},
peerDependencies: { [CORDIS]: 'workspace:^' },
devDependencies: { [CORDIS]: 'workspace:^' },
...fields,
}
}
function facts(
packages: readonly ClientPackage[],
options: Partial<Omit<ClientPackageFacts, 'packages'>> = {},
): ClientPackageFacts {
return {
packages,
declarations: options.declarations ?? packages,
staticLinkedPackages: options.staticLinkedPackages ?? new Set(
packages.filter(item => item.staticLinked).map(item => item.name),
),
platformModules: options.platformModules ?? [],
preloadedExternals: options.preloadedExternals ?? [],
parserPreloadIds: options.parserPreloadIds
?? (options.preloadedExternals ?? []).map(value => value.replace(/\/client$/, '')),
malformed: options.malformed ?? [],
}
}
describe('source package uses', () => {
it('counts type imports, module augmentations, dynamic imports, and JSX', () => {
const uses = collectSourcePackageUses('feature.tsx', [
"import type { A } from '@deepseek-ai/dsh-a/subpath'",
"declare module '@deepseek-ai/dsh-client-ui-slots' {}",
"const load = () => import('@deepseek-ai/dsh-b')",
'export const view = <div />',
"export type { Local } from './local.ts'",
].join('\n'))
expect([...uses].sort()).toEqual([
'@deepseek-ai/dsh-a',
'@deepseek-ai/dsh-b',
'@deepseek-ai/dsh-client-ui-slots',
'react',
])
expect([...collectRuntimeSourcePackageUses('feature.tsx', [
"import type { A } from '@deepseek-ai/dsh-a/subpath'",
"declare module '@deepseek-ai/dsh-client-ui-slots' {}",
"const load = () => import('@deepseek-ai/dsh-b')",
'export const view = <div />',
].join('\n'))].sort()).toEqual([
'@deepseek-ai/dsh-b',
'react',
])
})
})
describe('package modes', () => {
it('accepts one dynamic package and one statically linked package', () => {
const dynamic = pkg('runtime')
const shell = pkg('ui-slots', { dynamic: false, staticLinked: true })
expect(collectClientPackageViolations(facts([dynamic, shell]))).toEqual([])
})
it('rejects a package with both modes or neither mode', () => {
const both = pkg('both', { staticLinked: true })
const neither = pkg('neither', { dynamic: false })
const found = collectClientPackageViolations(facts([both, neither]))
expect(found).toHaveLength(2)
expect(found.join('\n')).toContain('must be dynamic or statically linked, not both')
expect(found.join('\n')).toContain('has no supported client package mode')
})
it('requires seeded workspace packages to use staticLinked and preloads to name dynamic rows', () => {
const slots = declaration('ui-slots', { dynamic: false })
const runtime = declaration('runtime', { dynamic: false })
const found = collectClientPackageViolations(facts([], {
declarations: [slots, runtime],
platformModules: [slots.name],
preloadedExternals: [runtime.name + '/client'],
}))
expect(found).toHaveLength(2)
expect(found.join('\n')).toContain('does not use the staticLinked preset')
expect(found.join('\n')).toContain('has no dynamic dsh.client row')
})
it('requires every preloaded external to have a parser preload row', () => {
const runtime = declaration('runtime')
expect(collectClientPackageViolations(facts([], {
declarations: [runtime],
preloadedExternals: [runtime.name + '/client'],
parserPreloadIds: [],
}))).toEqual([
'packages/client/web/src/platform.ts: parser-preloaded external '
+ '"@deepseek-ai/dsh-client-runtime/client" has no matching PARSER_PRELOAD_IDS row in '
+ 'packages/client/modules/src/index.ts',
])
})
})
describe('dependency sections', () => {
it('accepts dynamic peer plus dev relationships, static dev inputs, and private dependencies', () => {
const slots = pkg('ui-slots', { dynamic: false, staticLinked: true })
const runtime = pkg('runtime', {
inject: ['@deepseek-ai/dsh-client-feature'],
sourceUses: {
'@deepseek-ai/dsh-agent': ['packages/client/runtime/src/index.ts'],
'@deepseek-ai/dsh-client-ui-slots': ['packages/client/runtime/src/client/slots.ts'],
react: ['packages/client/runtime/src/client/view.tsx'],
},
dependencies: { immer: '^10.1.1' },
peerDependencies: {
[CORDIS]: 'workspace:^',
'@deepseek-ai/dsh-agent': 'workspace:^',
'@deepseek-ai/dsh-client-feature': 'workspace:^',
},
devDependencies: {
[CORDIS]: 'workspace:^',
'@deepseek-ai/dsh-agent': 'workspace:^',
'@deepseek-ai/dsh-client-feature': 'workspace:^',
'@deepseek-ai/dsh-client-ui-slots': 'workspace:^',
react: '^18.2.0',
},
})
expect(collectClientPackageViolations(facts([slots, runtime], {
platformModules: ['react', slots.name],
}))).toEqual([])
})
it('rejects internal dependencies, static peers, and mismatched peer development ranges', () => {
const slots = pkg('ui-slots', { dynamic: false, staticLinked: true })
const subject = pkg('feature', {
sourceUses: {
'@deepseek-ai/dsh-agent': ['packages/client/feature/src/index.ts'],
[slots.name]: ['packages/client/feature/src/view.tsx'],
},
dependencies: { '@deepseek-ai/dsh-agent': 'workspace:^' },
peerDependencies: { [CORDIS]: 'workspace:^', [slots.name]: 'workspace:^' },
devDependencies: { [CORDIS]: 'workspace:^', [slots.name]: 'workspace:*' },
})
const found = collectClientPackageViolations(facts([slots, subject]))
expect(found).toHaveLength(2)
expect(found.join('\n')).toContain('peer-installed DSH relationship')
expect(found.join('\n')).toContain('static client input')
})
it('requires every peer to have the same development range', () => {
const subject = pkg('feature', {
peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/cordis-plugin-loader': 'workspace:^' },
})
expect(collectClientPackageViolations(facts([subject]))).toEqual([
'packages/client/feature/package.json: peerDependencies.@deepseek-ai/cordis-plugin-loader'
+ ' is workspace:^, so devDependencies.@deepseek-ai/cordis-plugin-loader must use the same range;'
+ ' found no declaration',
])
})
it('requires statically linked third-party runtime imports in dependencies', () => {
const primitives = pkg('ui-primitives', {
dynamic: false,
staticLinked: true,
runtimeSourceUses: { shiki: ['packages/client/ui-primitives/src/highlight.ts'] },
devDependencies: { [CORDIS]: 'workspace:^', shiki: '^4.3.1' },
})
const found = collectClientPackageViolations(facts([primitives]))
expect(found).toHaveLength(1)
expect(found[0]).toContain('runtime import retained by a statically linked artifact')
expect(found[0]).toContain('declare it only in dependencies')
const valid = { ...primitives, dependencies: { shiki: '^4.3.1' }, devDependencies: { [CORDIS]: 'workspace:^' } }
expect(collectClientPackageViolations(facts([valid]))).toEqual([])
})
it('keeps the web shell runtime inputs development-only', () => {
const web = pkg('web', {
dynamic: false,
staticLinked: true,
runtimeSourceUses: {
'@deepseek-ai/cordis-plugin-loader': ['packages/client/web/src/boot.ts'],
react: ['packages/client/web/src/seed.ts'],
},
devDependencies: {
[CORDIS]: 'workspace:^',
'@deepseek-ai/cordis-plugin-loader': 'workspace:^',
react: '^18.2.0',
},
})
expect(collectClientPackageViolations(facts([web]))).toEqual([])
})
it('allows npm dependency cycles', () => {
const a = pkg('a', {
peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-b': 'workspace:^' },
devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-b': 'workspace:^' },
})
const b = pkg('b', {
peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-a': 'workspace:^' },
devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-a': 'workspace:^' },
})
expect(collectClientPackageViolations(facts([a, b]))).toEqual([])
})
})
describe('module requests', () => {
it('accepts a dynamic row supplier and its client subpath', () => {
const ui = declaration('ui', { external: ['@deepseek-ai/dsh-client-slots/client'] })
const slots = declaration('slots')
expect(collectClientPackageViolations(facts([], { declarations: [ui, slots] }))).toEqual([])
})
it('rejects an explicit baseline request', () => {
const ui = declaration('ui', { external: ['react'] })
expect(collectClientPackageViolations(facts([], {
declarations: [ui],
platformModules: ['react'],
}))).toEqual([
ui.manifest + ': dsh.client.external repeats baseline module "react"; remove the explicit declaration',
])
})
it('rejects duplicates, empty values, self-requests, and missing suppliers', () => {
const ui = declaration('ui', {
external: ['', '@deepseek-ai/dsh-client-ui', '@deepseek-ai/dsh-missing', '@deepseek-ai/dsh-missing'],
inject: ['', '@deepseek-ai/dsh-a', '@deepseek-ai/dsh-a'],
})
const found = collectClientPackageViolations(facts([], { declarations: [ui] }))
expect(found).toHaveLength(6)
expect(found.join('\n')).toContain('dsh.client.external contains an empty value')
expect(found.join('\n')).toContain('dsh.client.inject contains an empty value')
expect(found.join('\n')).toContain('names its own row')
expect(found.join('\n')).toContain('has no supplier')
})
it('rejects synchronous module-request cycles but ignores inject cycles', () => {
const a = declaration('a', {
external: ['@deepseek-ai/dsh-client-b'],
inject: ['@deepseek-ai/dsh-client-b'],
})
const b = declaration('b', {
external: ['@deepseek-ai/dsh-client-a'],
inject: ['@deepseek-ai/dsh-client-a'],
})
const found = collectClientPackageViolations(facts([], { declarations: [a, b] }))
expect(found).toHaveLength(1)
expect(found[0]).toContain('synchronous dsh.client.external cycle')
})
})
describe('manifest declarations', () => {
it('reports malformed arrays without hiding other packages', () => {
const root = mkdtempSync(join(tmpdir(), 'client-packages-'))
roots.push(root)
const files: Record<string, unknown> = {
'packages/g/a/package.json': {
name: '@f/a', dsh: { client: { external: 'react', inject: ['@f/b', 1] } },
},
'packages/g/b/package.json': { name: '@f/b', dsh: { client: {} } },
}
for (const [path, value] of Object.entries(files)) {
mkdirSync(dirname(join(root, path)), { recursive: true })
writeFileSync(join(root, path), JSON.stringify(value))
}
const result = readClientDeclarations(root)
expect(result.declarations).toHaveLength(2)
expect(result.malformed).toEqual([
'packages/g/a/package.json: @f/a dsh.client.external must be a string array',
'packages/g/a/package.json: @f/a dsh.client.inject must be a string array',
])
})
it('fixes unambiguous dependency sections and declaration entries', () => {
const root = mkdtempSync(join(tmpdir(), 'client-packages-fix-'))
roots.push(root)
const subject = pkg('feature', {
external: ['', 'react', '@deepseek-ai/dsh-client-feature', '@deepseek-ai/dsh-missing'],
inject: ['', '@deepseek-ai/dsh-agent', '@deepseek-ai/dsh-agent'],
sourceUses: {
'@deepseek-ai/dsh-agent': ['packages/client/feature/src/index.ts'],
'@deepseek-ai/dsh-client-ui-slots': ['packages/client/feature/src/view.tsx'],
},
dependencies: {
[CORDIS]: 'workspace:^',
'@deepseek-ai/dsh-agent': 'workspace:*',
},
peerDependencies: {
'@deepseek-ai/dsh-client-ui-slots': 'workspace:^',
'@deepseek-ai/cordis-plugin-loader': 'workspace:^',
},
devDependencies: {},
})
const slots = declaration('ui-slots', { dynamic: false })
const manifest = {
name: subject.name,
dsh: { client: { external: subject.external, inject: subject.inject, platform: 'web' } },
dependencies: subject.dependencies,
peerDependencies: subject.peerDependencies,
devDependencies: subject.devDependencies,
}
mkdirSync(dirname(join(root, subject.manifest)), { recursive: true })
writeFileSync(join(root, subject.manifest), JSON.stringify(manifest))
writeFileSync(join(root, 'package.json'), JSON.stringify({ private: true }))
expect(fixClientPackageManifests(root, facts([subject], {
declarations: [subject, slots],
staticLinkedPackages: new Set([slots.name]),
platformModules: ['react', slots.name],
}))).toEqual([subject.manifest])
const fixed = JSON.parse(readFileSync(join(root, subject.manifest), 'utf8')) as {
dsh: { client: { external: string[]; inject: string[] } }
dependencies?: Record<string, string>
peerDependencies: Record<string, string>
devDependencies: Record<string, string>
}
expect(fixed.dsh.client).toMatchObject({
external: ['@deepseek-ai/dsh-missing'],
inject: ['@deepseek-ai/dsh-agent'],
})
expect(fixed.dependencies).toBeUndefined()
expect(fixed.peerDependencies).toEqual({
'@deepseek-ai/cordis-plugin-loader': 'workspace:^',
[CORDIS]: 'workspace:^',
'@deepseek-ai/dsh-agent': 'workspace:*',
})
expect(fixed.devDependencies).toEqual({
'@deepseek-ai/dsh-client-ui-slots': 'workspace:^',
[CORDIS]: 'workspace:^',
'@deepseek-ai/dsh-agent': 'workspace:*',
'@deepseek-ai/cordis-plugin-loader': 'workspace:^',
})
})
it('fixes a statically linked runtime import into dependencies', () => {
const root = mkdtempSync(join(tmpdir(), 'client-packages-static-fix-'))
roots.push(root)
const subject = pkg('ui-primitives', {
dynamic: false,
staticLinked: true,
runtimeSourceUses: { shiki: ['packages/client/ui-primitives/src/highlight.ts'] },
devDependencies: { [CORDIS]: 'workspace:^', shiki: '^4.3.1' },
})
mkdirSync(dirname(join(root, subject.manifest)), { recursive: true })
writeFileSync(join(root, subject.manifest), JSON.stringify({
name: subject.name,
peerDependencies: subject.peerDependencies,
devDependencies: subject.devDependencies,
}))
writeFileSync(join(root, 'package.json'), JSON.stringify({ private: true }))
expect(fixClientPackageManifests(root, facts([subject]))).toEqual([subject.manifest])
const fixed = JSON.parse(readFileSync(join(root, subject.manifest), 'utf8')) as {
dependencies: Record<string, string>
devDependencies: Record<string, string>
}
expect(fixed.dependencies).toEqual({ shiki: '^4.3.1' })
expect(fixed.devDependencies).toEqual({ [CORDIS]: 'workspace:^' })
})
})
+914
View File
@@ -0,0 +1,914 @@
/**
* Verify client package modes, npm dependency sections, and the synchronous
* browser module-request graph.
*/
import { globSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
import ts from 'typescript'
import { TypeScriptProject } from './ts-project.ts'
const GATE = 'verify-client-packages'
const CLIENT_MANIFEST_GLOB = 'packages/client/*/package.json'
const MANIFEST_GLOBS = ['packages/*/*/package.json', 'apps/*/package.json', 'vendor/*/package.json']
const CONFIG_GLOB = 'packages/*/*/tsdown.config.ts'
const PLATFORM_SOURCE = 'packages/client/web/src/platform.ts'
const PARSER_PRELOAD_SOURCE = 'packages/client/modules/src/index.ts'
const STATIC_PRESET_SOURCE = 'packages/client/tsdown.client.ts'
const CORDIS = '@deepseek-ai/cordis'
const DSH_PREFIX = '@deepseek-ai/dsh-'
const CLIENT_WEB = '@deepseek-ai/dsh-client-web'
/** One workspace package's browser-module declaration. */
export interface ClientDeclaration {
/** npm package name. */
readonly name: string
/** Repository-relative package manifest. */
readonly manifest: string
/** Whether the manifest declares a dynamic dsh.client row. */
readonly dynamic: boolean
/** Exact module-table specifiers requested by the row. */
readonly external: readonly string[]
/** Informational package dependencies declared by the row. */
readonly inject: readonly string[]
}
/** One package directly under packages/client. */
export interface ClientPackage extends ClientDeclaration {
/** Whether its build config uses the staticLinked preset. */
readonly staticLinked: boolean
/** Production source locations grouped by imported package name. */
readonly sourceUses: Readonly<Record<string, readonly string[]>>
/** Production source locations grouped by runtime-imported package name. */
readonly runtimeSourceUses: Readonly<Record<string, readonly string[]>>
/** Installed implementation dependencies. */
readonly dependencies: Readonly<Record<string, string>>
/** Consumer-supplied dependencies. */
readonly peerDependencies: Readonly<Record<string, string>>
/** Dependencies available while developing the package. */
readonly devDependencies: Readonly<Record<string, string>>
}
/** Complete source-plane input to the client package verifier. */
export interface ClientPackageFacts {
/** Packages directly under packages/client. */
readonly packages: readonly ClientPackage[]
/** Every workspace package, including packages without a browser row. */
readonly declarations: readonly ClientDeclaration[]
/** Packages whose build config uses the staticLinked preset. */
readonly staticLinkedPackages: ReadonlySet<string>
/** Specifiers the web shell seeds into the module table. */
readonly platformModules: readonly string[]
/** Dynamic factories the HTML parser loads before shell boot. */
readonly preloadedExternals: readonly string[]
/** Package rows whose bundles the HTML parser executes before shell boot. */
readonly parserPreloadIds: readonly string[]
/** Manifest field errors found while reading declarations. */
readonly malformed: readonly string[]
}
/** Result of reading every workspace browser-module declaration. */
export interface ClientDeclarations {
/** One declaration record per named workspace manifest. */
readonly declarations: ClientDeclaration[]
/** Manifest field errors that prevent a reliable declaration. */
readonly malformed: string[]
}
/**
* Collect bare packages referenced by one production source file.
* @param path - File path used to select TypeScript's parser mode.
* @param source - Source text to inspect.
* @returns Bare package names referenced by imports, declarations, or JSX.
*/
export function collectSourcePackageUses(path: string, source: string): Set<string> {
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
return collectSourceFilePackageUses(sourceFile, false)
}
/**
* Collect bare packages whose values one production source file reaches at runtime.
* @param path - File path used to select TypeScript's parser mode.
* @param source - Source text to inspect.
* @returns Bare package names retained by runtime imports, exports, requires, or JSX.
*/
export function collectRuntimeSourcePackageUses(path: string, source: string): Set<string> {
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
return collectSourceFilePackageUses(sourceFile, true)
}
function importCarriesRuntimeValue(node: ts.ImportDeclaration): boolean {
const clause = node.importClause
if (clause === undefined) return true
if (clause.phaseModifier === ts.SyntaxKind.TypeKeyword) return false
const bindings = clause.namedBindings
return clause.name !== undefined
|| bindings === undefined
|| ts.isNamespaceImport(bindings)
|| bindings.elements.length === 0
|| bindings.elements.some(element => !element.isTypeOnly)
}
function exportCarriesRuntimeValue(node: ts.ExportDeclaration): boolean {
if (node.isTypeOnly) return false
const clause = node.exportClause
if (clause === undefined || ts.isNamespaceExport(clause)) return true
return clause.elements.length === 0 || clause.elements.some(element => !element.isTypeOnly)
}
function collectSourceFilePackageUses(sourceFile: ts.SourceFile, runtimeOnly: boolean): Set<string> {
const uses = new Set<string>()
const add = (specifier: ts.Expression | undefined): void => {
if (specifier === undefined || !ts.isStringLiteral(specifier) || !isBareSpecifier(specifier.text)) return
uses.add(packageNameOf(specifier.text))
}
const visit = (node: ts.Node): void => {
if (ts.isImportDeclaration(node)) {
if (!runtimeOnly || importCarriesRuntimeValue(node)) add(node.moduleSpecifier)
} else if (ts.isExportDeclaration(node)) {
if (!runtimeOnly || exportCarriesRuntimeValue(node)) add(node.moduleSpecifier)
} else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
if (!runtimeOnly || !node.isTypeOnly) add(node.moduleReference.expression)
} else if (!runtimeOnly && ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
add(node.argument.literal)
} else if (ts.isCallExpression(node)
&& (node.expression.kind === ts.SyntaxKind.ImportKeyword
|| ts.isIdentifier(node.expression) && node.expression.text === 'require')) {
add(node.arguments[0])
} else if (!runtimeOnly && ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) {
add(node.name)
} else if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) {
uses.add('react')
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return uses
}
/**
* Read browser-module declarations from workspace manifests.
* @param root - Absolute repository root.
* @returns Declarations and malformed dsh.client fields.
*/
export function readClientDeclarations(root: string): ClientDeclarations {
const malformed: string[] = []
const declarations = globSync(MANIFEST_GLOBS, { cwd: root })
.map(normalizePath)
.sort()
.flatMap(path => readDeclaration(root, path, malformed) ?? [])
return { declarations, malformed }
}
/**
* Return every client package policy violation.
* @param facts - Package modes, manifests, source uses, and platform module lists.
* @returns Stable self-contained diagnostics.
*/
export function collectClientPackageViolations(facts: ClientPackageFacts): string[] {
return [
...facts.malformed,
...collectModeViolations(facts),
...collectDependencyViolations(facts),
...collectModuleViolations(facts),
].sort((left, right) => left.localeCompare(right))
}
interface ManifestDocument {
readonly path: string
readonly manifest: Manifest
changed: boolean
}
type DependencySection = 'dependencies' | 'peerDependencies' | 'devDependencies'
/**
* Repair manifest declarations whose intended result follows uniquely from the policy.
* @param root - Absolute repository root.
* @param facts - Facts used by the verification pass.
* @returns Repository-relative manifests written by the fixer.
*/
export function fixClientPackageManifests(root: string, facts: ClientPackageFacts): string[] {
const documents = new Map<string, ManifestDocument>()
const document = (path: string): ManifestDocument => {
const cached = documents.get(path)
if (cached !== undefined) return cached
const loaded: ManifestDocument = {
path,
manifest: JSON.parse(readFileSync(resolve(root, path), 'utf8')) as Manifest,
changed: false,
}
documents.set(path, loaded)
return loaded
}
const baseline = new Set([...facts.platformModules, ...facts.preloadedExternals])
for (const declaration of facts.declarations.filter(entry => entry.dynamic)) {
const target = document(declaration.manifest)
const dsh = isRecord(target.manifest.dsh) ? target.manifest.dsh : undefined
const client = isRecord(dsh?.client) ? dsh.client : undefined
if (client === undefined) continue
target.changed = normalizeClientArray(client, 'inject', () => false) || target.changed
target.changed = normalizeClientArray(
client,
'external',
value => baseline.has(value) || rowPackageOf(value, new Set([declaration.name])) === declaration.name,
) || target.changed
}
const staticInputs = new Set([
...facts.staticLinkedPackages,
...facts.platformModules.map(packageNameOf),
])
staticInputs.delete(CORDIS)
const inferredRanges = dependencyRangeCandidates(root)
for (const pkg of facts.packages) {
const target = document(pkg.manifest)
const expected = expectedSections(pkg, staticInputs)
for (const [name, rule] of expected) {
const range = preferredRange(target.manifest, name, rule.kind, inferredRanges)
if (range === undefined) continue
target.changed = rule.kind === 'dependency'
? ensureDependencyOnly(target.manifest, name, range) || target.changed
: rule.kind === 'dev'
? ensureDevOnly(target.manifest, name, range) || target.changed
: ensurePeerDev(target.manifest, name, range) || target.changed
}
if (pkg.dynamic) {
const productionNames = new Set([
...Object.keys(section(target.manifest, 'dependencies')),
...Object.keys(section(target.manifest, 'peerDependencies')),
])
for (const name of productionNames) {
if (expected.has(name)) continue
const range = preferredRange(
target.manifest,
name,
staticInputs.has(name) ? 'dev' : 'peer-dev',
inferredRanges,
)
if (range === undefined) continue
if (staticInputs.has(name)) {
target.changed = ensureDevOnly(target.manifest, name, range) || target.changed
} else if (section(target.manifest, 'dependencies')[name] !== undefined && isInternalDsh(name)) {
target.changed = ensurePeerDev(target.manifest, name, range) || target.changed
}
}
}
for (const [name, range] of Object.entries(section(target.manifest, 'peerDependencies'))) {
target.changed = setDependency(target.manifest, 'devDependencies', name, range) || target.changed
}
target.changed = deleteEmptySections(target.manifest) || target.changed
}
const changed = [...documents.values()].filter(target => target.changed).sort((left, right) =>
left.path.localeCompare(right.path))
for (const target of changed) {
writeFileSync(resolve(root, target.path), JSON.stringify(target.manifest, null, 2) + '\n')
}
return changed.map(target => target.path)
}
function normalizeClientArray(
client: Record<string, unknown>,
field: 'external' | 'inject',
remove: (value: string) => boolean,
): boolean {
const value = client[field]
if (!Array.isArray(value) || value.some(entry => typeof entry !== 'string')) return false
const seen = new Set<string>()
const normalized = value.filter((entry: string) => {
if (entry === '' || seen.has(entry) || remove(entry)) return false
seen.add(entry)
return true
})
if (normalized.length === value.length && normalized.every((entry, index) => entry === value[index])) return false
if (normalized.length === 0) {
if (field === 'external') delete client.external
else delete client.inject
} else {
client[field] = normalized
}
return true
}
function ensureDevOnly(manifest: Manifest, name: string, range: string): boolean {
let changed = deleteDependency(manifest, 'dependencies', name)
changed = deleteDependency(manifest, 'peerDependencies', name) || changed
return setDependency(manifest, 'devDependencies', name, range) || changed
}
function ensureDependencyOnly(manifest: Manifest, name: string, range: string): boolean {
let changed = deleteDependency(manifest, 'peerDependencies', name)
changed = deleteDependency(manifest, 'devDependencies', name) || changed
return setDependency(manifest, 'dependencies', name, range) || changed
}
function ensurePeerDev(manifest: Manifest, name: string, range: string): boolean {
let changed = deleteDependency(manifest, 'dependencies', name)
changed = setDependency(manifest, 'peerDependencies', name, range) || changed
return setDependency(manifest, 'devDependencies', name, range) || changed
}
function setDependency(manifest: Manifest, field: DependencySection, name: string, range: string): boolean {
const dependencies = mutableSection(manifest, field)
if (dependencies[name] === range) return false
dependencies[name] = range
return true
}
function deleteDependency(manifest: Manifest, field: DependencySection, name: string): boolean {
const dependencies = section(manifest, field)
if (dependencies[name] === undefined) return false
manifest[field] = Object.fromEntries(Object.entries(dependencies).filter(([key]) => key !== name))
return true
}
function deleteEmptySections(manifest: Manifest): boolean {
let changed = false
for (const field of ['dependencies', 'peerDependencies', 'devDependencies'] as const) {
if (manifest[field] === undefined || Object.keys(section(manifest, field)).length > 0) continue
if (field === 'dependencies') delete manifest.dependencies
else if (field === 'peerDependencies') delete manifest.peerDependencies
else delete manifest.devDependencies
changed = true
}
return changed
}
function preferredRange(
manifest: Manifest,
name: string,
kind: ExpectedRule['kind'],
inferred: ReadonlyMap<string, ReadonlySet<string>>,
): string | undefined {
const order: readonly DependencySection[] = kind === 'dependency'
? ['dependencies', 'devDependencies', 'peerDependencies']
: kind === 'dev'
? ['devDependencies', 'peerDependencies', 'dependencies']
: ['peerDependencies', 'devDependencies', 'dependencies']
for (const field of order) {
const range = section(manifest, field)[name]
if (range !== undefined) return range
}
if (isInternalDsh(name)) return 'workspace:^'
const candidates = inferred.get(name)
return candidates?.size === 1 ? [...candidates][0] : undefined
}
function dependencyRangeCandidates(root: string): Map<string, Set<string>> {
const candidates = new Map<string, Set<string>>()
const paths = globSync([
'package.json',
...MANIFEST_GLOBS,
'website/package.json',
], { cwd: root }).map(normalizePath)
for (const path of new Set(paths)) {
const manifest = JSON.parse(readFileSync(resolve(root, path), 'utf8')) as Manifest
for (const field of ['dependencies', 'peerDependencies', 'devDependencies'] as const) {
for (const [name, range] of Object.entries(section(manifest, field))) {
const ranges = candidates.get(name) ?? new Set<string>()
ranges.add(range)
candidates.set(name, ranges)
}
}
}
return candidates
}
function section(manifest: Manifest, field: DependencySection): Record<string, string> {
return manifest[field] ?? {}
}
function mutableSection(manifest: Manifest, field: DependencySection): Record<string, string> {
const value = manifest[field]
if (value !== undefined) return value
const created: Record<string, string> = {}
manifest[field] = created
return created
}
function collectModeViolations(facts: ClientPackageFacts): string[] {
const violations: string[] = []
for (const pkg of facts.packages) {
if (pkg.dynamic && pkg.staticLinked) {
violations.push(
pkg.manifest + ': ' + pkg.name + ' declares dsh.client and uses the staticLinked preset;'
+ ' a client package must be dynamic or statically linked, not both',
)
} else if (!pkg.dynamic && !pkg.staticLinked) {
violations.push(
pkg.manifest + ': ' + pkg.name + ' has no supported client package mode;'
+ ' declare dsh.client or use the staticLinked preset',
)
}
}
const workspaceNames = new Set(facts.declarations.map(entry => entry.name))
for (const specifier of facts.platformModules) {
const owner = packageNameOf(specifier)
if (!workspaceNames.has(owner) || owner === CORDIS || facts.staticLinkedPackages.has(owner)) continue
violations.push(
PLATFORM_SOURCE + ': seeded workspace module ' + JSON.stringify(specifier)
+ ' belongs to ' + owner + ', whose build does not use the staticLinked preset',
)
}
const rows = rowNames(facts.declarations)
for (const specifier of facts.preloadedExternals) {
if (rowPackageOf(specifier, rows) === undefined) {
violations.push(
PLATFORM_SOURCE + ': parser-preloaded external ' + JSON.stringify(specifier)
+ ' has no dynamic dsh.client row',
)
}
if (!facts.parserPreloadIds.includes(stripClientSuffix(specifier))) {
violations.push(
PLATFORM_SOURCE + ': parser-preloaded external ' + JSON.stringify(specifier)
+ ' has no matching PARSER_PRELOAD_IDS row in ' + PARSER_PRELOAD_SOURCE,
)
}
}
return violations
}
interface ExpectedRule {
readonly kind: 'dependency' | 'dev' | 'peer-dev'
readonly origins: Set<string>
}
function collectDependencyViolations(facts: ClientPackageFacts): string[] {
const violations: string[] = []
const staticInputs = new Set([
...facts.staticLinkedPackages,
...facts.platformModules.map(packageNameOf),
])
staticInputs.delete(CORDIS)
for (const pkg of [...facts.packages].sort((left, right) => left.manifest.localeCompare(right.manifest))) {
const expected = expectedSections(pkg, staticInputs)
for (const [name, rule] of [...expected].sort(([left], [right]) => left.localeCompare(right))) {
const actual = declaredSections(pkg, name)
if (rule.kind === 'dependency') {
if (actual.length === 1 && actual[0] === 'dependencies') continue
violations.push(
pkg.manifest + ': ' + name + ' (' + describeOrigins(rule.origins) + ') is a runtime import'
+ ' retained by a statically linked artifact; declare it only in dependencies, found '
+ describeSections(actual),
)
continue
}
if (rule.kind === 'dev') {
if (actual.length === 1 && actual[0] === 'devDependencies') continue
violations.push(
pkg.manifest + ': ' + name + ' (' + describeOrigins(rule.origins) + ') is a static client input;'
+ ' declare it only in devDependencies, found ' + describeSections(actual),
)
continue
}
const peerRange = pkg.peerDependencies[name]
const devRange = pkg.devDependencies[name]
if (actual.length === 2
&& actual.includes('peerDependencies')
&& actual.includes('devDependencies')
&& peerRange === devRange) continue
violations.push(
pkg.manifest + ': ' + name + ' (' + describeOrigins(rule.origins) + ')'
+ ' is a peer-installed DSH relationship; declare it in peerDependencies and devDependencies'
+ ' with matching ranges, not dependencies; found ' + describeSections(actual)
+ describeRangeMismatch(peerRange, devRange),
)
}
for (const [name, peerRange] of Object.entries(pkg.peerDependencies).sort(([left], [right]) => left.localeCompare(right))) {
if (expected.has(name)) continue
const devRange = pkg.devDependencies[name]
if (devRange === peerRange) continue
violations.push(
pkg.manifest + ': peerDependencies.' + name + ' is ' + peerRange + ', so devDependencies.' + name
+ ' must use the same range; found ' + (devRange ?? 'no declaration'),
)
}
if (!pkg.dynamic) continue
for (const section of ['dependencies', 'peerDependencies'] as const) {
for (const name of Object.keys(pkg[section]).sort()) {
if (expected.has(name)) continue
if (staticInputs.has(name)) {
violations.push(
pkg.manifest + ': dynamic package declares static input ' + name + ' in ' + section + ';'
+ ' move it to devDependencies or delete the stale declaration',
)
} else if (section === 'dependencies' && isInternalDsh(name)) {
violations.push(
pkg.manifest + ': dynamic package declares ' + name + ' in dependencies;'
+ ' dynamic DSH relationships are peer plus dev, and static client inputs are dev-only',
)
}
}
}
}
return violations
}
function expectedSections(pkg: ClientPackage, staticInputs: ReadonlySet<string>): Map<string, ExpectedRule> {
const expected = new Map<string, ExpectedRule>([
[CORDIS, { kind: 'peer-dev', origins: new Set(['client package baseline']) }],
])
if (!pkg.dynamic) {
if (pkg.name === CLIENT_WEB) return expected
for (const [name, locations] of Object.entries(pkg.runtimeSourceUses)) {
if (name === pkg.name || name === CORDIS || isInternalDsh(name)) continue
expected.set(name, { kind: 'dependency', origins: new Set(locations) })
}
return expected
}
const add = (name: string, origin: string): void => {
if (name === pkg.name) return
const kind = staticInputs.has(name) ? 'dev' : isInternalDsh(name) ? 'peer-dev' : undefined
if (kind === undefined) return
const current = expected.get(name)
if (current !== undefined) current.origins.add(origin)
else expected.set(name, { kind, origins: new Set([origin]) })
}
for (const [name, locations] of Object.entries(pkg.sourceUses)) {
for (const location of locations) add(name, location)
}
for (const name of pkg.inject) add(name, 'dsh.client.inject')
return expected
}
interface ModuleEdge {
readonly from: string
readonly to: string
readonly specifier: string
}
function collectModuleViolations(facts: ClientPackageFacts): string[] {
const violations: string[] = []
const baseline = new Set([...facts.platformModules, ...facts.preloadedExternals])
const rows = rowNames(facts.declarations)
const byName = new Map(facts.declarations.map(entry => [entry.name, entry]))
const edges: ModuleEdge[] = []
for (const pkg of facts.declarations.filter(entry => entry.dynamic)) {
for (const field of ['external', 'inject'] as const) {
const seen = new Set<string>()
for (const value of pkg[field]) {
if (value === '') violations.push(pkg.manifest + ': dsh.client.' + field + ' contains an empty value')
else if (seen.has(value)) {
violations.push(pkg.manifest + ': dsh.client.' + field + ' lists ' + JSON.stringify(value) + ' twice')
}
seen.add(value)
}
}
for (const specifier of new Set(pkg.external)) {
if (specifier === '') continue
if (baseline.has(specifier)) {
violations.push(
pkg.manifest + ': dsh.client.external repeats baseline module ' + JSON.stringify(specifier)
+ '; remove the explicit declaration',
)
continue
}
const supplier = rowPackageOf(specifier, rows)
if (supplier === pkg.name) {
violations.push(pkg.manifest + ': dsh.client.external names its own row ' + JSON.stringify(specifier))
} else if (supplier !== undefined) {
edges.push({ from: pkg.name, to: supplier, specifier })
} else {
const owner = stripClientSuffix(specifier)
violations.push(
pkg.manifest + ': dsh.client.external ' + JSON.stringify(specifier) + ' has no supplier;'
+ (byName.has(owner)
? ' workspace package ' + owner
+ ' declares no dynamic dsh.client row and the shell does not seed this specifier'
: ' no dynamic row or PLATFORM_MODULES entry answers it'),
)
}
}
}
violations.push(...collectModuleCycles(edges, byName))
return violations
}
function collectModuleCycles(
edges: readonly ModuleEdge[],
byName: ReadonlyMap<string, ClientDeclaration>,
): string[] {
const outgoing = new Map<string, ModuleEdge[]>()
for (const edge of [...edges].sort((left, right) => left.specifier.localeCompare(right.specifier))) {
outgoing.set(edge.from, [...outgoing.get(edge.from) ?? [], edge])
}
const finished = new Set<string>()
const onPath = new Set<string>()
const path: ModuleEdge[] = []
const reported = new Map<string, string>()
const walk = (name: string): void => {
onPath.add(name)
for (const edge of outgoing.get(name) ?? []) {
if (onPath.has(edge.to)) {
const start = path.findIndex(entry => entry.from === edge.to)
const cycle = start === -1 ? [edge] : [...path.slice(start), edge]
const key = cycleKey(cycle)
if (!reported.has(key)) reported.set(key, formatCycle(cycle, byName))
} else if (!finished.has(edge.to)) {
path.push(edge)
walk(edge.to)
path.pop()
}
}
onPath.delete(name)
finished.add(name)
}
for (const name of [...outgoing.keys()].sort()) {
if (!finished.has(name)) walk(name)
}
return [...reported.values()]
}
function cycleKey(cycle: readonly ModuleEdge[]): string {
const labels = cycle.map(edge => edge.from + ' ' + edge.specifier)
const first = [...labels].sort()[0]
const offset = first === undefined ? 0 : labels.indexOf(first)
return [...labels.slice(offset), ...labels.slice(0, offset)].join(' -> ')
}
function formatCycle(
cycle: readonly ModuleEdge[],
byName: ReadonlyMap<string, ClientDeclaration>,
): string {
const entry = cycle[0]
const chain = cycle.map(edge => edge.from + ' --(' + edge.specifier + ')-->').join(' ')
const manifest = entry === undefined ? 'packages/client' : byName.get(entry.from)?.manifest ?? entry.from
return manifest + ': synchronous dsh.client.external cycle: ' + chain + ' ' + (entry?.from ?? '')
}
interface Manifest {
name?: unknown
dsh?: unknown
dependencies?: Record<string, string>
peerDependencies?: Record<string, string>
devDependencies?: Record<string, string>
}
function readDeclaration(
root: string,
manifestPath: string,
malformed: string[],
): ClientDeclaration | undefined {
const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) as Manifest
if (typeof manifest.name !== 'string') return undefined
const dsh = isRecord(manifest.dsh) ? manifest.dsh : undefined
const rawClient = dsh?.client
if (rawClient === undefined) {
return { name: manifest.name, manifest: manifestPath, dynamic: false, external: [], inject: [] }
}
if (!isRecord(rawClient)) {
malformed.push(manifestPath + ': ' + manifest.name + ' dsh.client must be an object')
return { name: manifest.name, manifest: manifestPath, dynamic: false, external: [], inject: [] }
}
return {
name: manifest.name,
manifest: manifestPath,
dynamic: true,
external: stringArray(rawClient.external, manifest.name, manifestPath, 'external', malformed),
inject: stringArray(rawClient.inject, manifest.name, manifestPath, 'inject', malformed),
}
}
function stringArray(
value: unknown,
packageName: string,
manifestPath: string,
field: string,
malformed: string[],
): readonly string[] {
if (value === undefined) return []
if (!Array.isArray(value) || value.some(entry => typeof entry !== 'string')) {
malformed.push(manifestPath + ': ' + packageName + ' dsh.client.' + field + ' must be a string array')
return []
}
return value as string[]
}
async function readStaticLinkedRoster(root: string): Promise<Set<string>> {
const presetUrl = pathToFileURL(resolve(import.meta.dirname, '..', STATIC_PRESET_SOURCE)).href
const preset = await import(presetUrl) as { isStaticLinkedConfig?: unknown }
if (typeof preset.isStaticLinkedConfig !== 'function') {
throw new Error(GATE + ': ' + STATIC_PRESET_SOURCE + ' exports no isStaticLinkedConfig')
}
const predicate = preset.isStaticLinkedConfig as (configs: readonly unknown[]) => boolean
const roster = new Set<string>()
for (const configPath of globSync(CONFIG_GLOB, { cwd: root }).map(normalizePath).sort()) {
const loaded = await import(pathToFileURL(resolve(root, configPath)).href) as { default?: unknown }
if (typeof loaded.default !== 'function') continue
const configs = (loaded.default as (input: { env: Record<string, string> }) => unknown)({
env: { DSH_BUILD_FACE: 'client' },
})
if (!Array.isArray(configs) || !predicate(configs)) continue
const manifest = JSON.parse(
readFileSync(resolve(root, configPath.replace(/tsdown\.config\.ts$/, 'package.json')), 'utf8'),
) as Manifest
if (typeof manifest.name === 'string') roster.add(manifest.name)
}
return roster
}
function unwrapExpression(expression: ts.Expression): ts.Expression {
let current = expression
while (ts.isAsExpression(current) || ts.isSatisfiesExpression(current) || ts.isParenthesizedExpression(current)) {
current = current.expression
}
return current
}
function readStringLiteralArray(root: string, sourcePath: string, name: string): string[] {
const path = resolve(root, sourcePath)
const source = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, false, ts.ScriptKind.TS)
const constants = new Map<string, string>()
for (const statement of source.statements) {
if (!ts.isVariableStatement(statement)) continue
for (const declaration of statement.declarationList.declarations) {
if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue
const initializer = unwrapExpression(declaration.initializer)
if (ts.isStringLiteral(initializer)) constants.set(declaration.name.text, initializer.text)
}
}
for (const statement of source.statements) {
if (!ts.isVariableStatement(statement)) continue
for (const declaration of statement.declarationList.declarations) {
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== name) continue
const expression = declaration.initializer === undefined ? undefined : unwrapExpression(declaration.initializer)
if (expression === undefined || !ts.isArrayLiteralExpression(expression)) {
throw new Error(GATE + ': ' + name + ' in ' + sourcePath + ' must be an array literal')
}
return expression.elements.map((element) => {
const value = unwrapExpression(element)
if (ts.isStringLiteral(value)) return value.text
if (ts.isIdentifier(value) && constants.has(value.text)) return constants.get(value.text) as string
throw new Error(GATE + ': ' + name + ' in ' + sourcePath + ' must contain only string constants')
})
}
}
throw new Error(GATE + ': ' + sourcePath + ' declares no ' + name)
}
async function readFacts(root: string): Promise<ClientPackageFacts> {
const { declarations, malformed } = readClientDeclarations(root)
const byManifest = new Map(declarations.map(entry => [entry.manifest, entry]))
const staticLinkedPackages = await readStaticLinkedRoster(root)
const project = new TypeScriptProject(root, 'client')
const packages: ClientPackage[] = []
for (const manifestPath of globSync(CLIENT_MANIFEST_GLOB, { cwd: root }).map(normalizePath).sort()) {
const declaration = byManifest.get(manifestPath)
if (declaration === undefined) throw new Error(GATE + ': no declaration facts for ' + manifestPath)
const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) as Manifest
if (typeof manifest.name !== 'string') throw new Error(GATE + ': ' + manifestPath + ' has no package name')
const sourceUses = new Map<string, Set<string>>()
const runtimeSourceUses = new Map<string, Set<string>>()
const packageDirectory = dirname(manifestPath)
const sourcePrefix = packageDirectory + '/src/'
for (const sourceFile of project.sourceFiles()) {
if (sourceFile.isDeclarationFile) continue
const file = project.relativePath(sourceFile)
if (!file.startsWith(sourcePrefix)) continue
for (const name of collectSourceFilePackageUses(sourceFile, false)) {
const locations = sourceUses.get(name) ?? new Set<string>()
locations.add(file)
sourceUses.set(name, locations)
}
for (const name of collectSourceFilePackageUses(sourceFile, true)) {
const locations = runtimeSourceUses.get(name) ?? new Set<string>()
locations.add(file)
runtimeSourceUses.set(name, locations)
}
}
packages.push({
...declaration,
staticLinked: staticLinkedPackages.has(declaration.name),
sourceUses: Object.fromEntries(
[...sourceUses].sort(([left], [right]) => left.localeCompare(right))
.map(([name, locations]) => [name, [...locations].sort()]),
),
runtimeSourceUses: Object.fromEntries(
[...runtimeSourceUses].sort(([left], [right]) => left.localeCompare(right))
.map(([name, locations]) => [name, [...locations].sort()]),
),
dependencies: manifest.dependencies ?? {},
peerDependencies: manifest.peerDependencies ?? {},
devDependencies: manifest.devDependencies ?? {},
})
}
return {
packages,
declarations,
staticLinkedPackages,
platformModules: readStringLiteralArray(root, PLATFORM_SOURCE, 'PLATFORM_MODULES'),
preloadedExternals: readStringLiteralArray(root, PLATFORM_SOURCE, 'PRELOADED_CLIENT_EXTERNALS'),
parserPreloadIds: readStringLiteralArray(root, PARSER_PRELOAD_SOURCE, 'PARSER_PRELOAD_IDS'),
malformed,
}
}
function packageNameOf(specifier: string): string {
const segments = specifier.split('/')
return segments.slice(0, specifier.startsWith('@') ? 2 : 1).join('/')
}
function stripClientSuffix(specifier: string): string {
return specifier.endsWith('/client') ? specifier.slice(0, -'/client'.length) : specifier
}
function rowNames(declarations: readonly ClientDeclaration[]): Set<string> {
return new Set(declarations.filter(entry => entry.dynamic).map(entry => entry.name))
}
function rowPackageOf(specifier: string, rows: ReadonlySet<string>): string | undefined {
if (rows.has(specifier)) return specifier
const stripped = stripClientSuffix(specifier)
return rows.has(stripped) ? stripped : undefined
}
function declaredSections(pkg: ClientPackage, name: string): string[] {
return (['dependencies', 'peerDependencies', 'devDependencies'] as const)
.filter(section => pkg[section][name] !== undefined)
}
function describeSections(sections: readonly string[]): string {
return sections.length === 0 ? 'no dependency declaration' : sections.join(' + ')
}
function describeRangeMismatch(peer: string | undefined, dev: string | undefined): string {
if (peer === undefined || dev === undefined || peer === dev) return ''
return ' (peer ' + peer + ', dev ' + dev + ')'
}
function describeOrigins(origins: ReadonlySet<string>): string {
const sorted = [...origins].sort()
const [first, second, ...rest] = sorted
if (first === undefined) return 'production use'
if (second === undefined) return first
return rest.length === 0 ? first + ', ' + second : first + ', ' + second + ', and ' + String(rest.length) + ' more'
}
function isInternalDsh(name: string): boolean {
return name === CORDIS || name.startsWith(DSH_PREFIX)
}
function isBareSpecifier(specifier: string): boolean {
return !specifier.startsWith('.') && !specifier.startsWith('/') && !specifier.startsWith('#')
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function normalizePath(path: string): string {
return path.split(sep).join('/')
}
async function main(): Promise<void> {
const root = resolve(import.meta.dirname, '..')
let facts = await readFacts(root)
if (process.argv.includes('--fix')) {
const changed = fixClientPackageManifests(root, facts)
console.log(
changed.length === 0
? GATE + ': no mechanically fixable manifest changes.'
: GATE + ': fixed ' + String(changed.length) + ' manifest(s): ' + changed.join(', '),
)
facts = await readFacts(root)
}
const violations = collectClientPackageViolations(facts)
if (violations.length > 0) {
console.error(GATE + ': ' + String(violations.length) + ' violation(s):')
for (const violation of violations) console.error(' ' + violation)
process.exit(1)
}
const dynamic = facts.packages.filter(pkg => pkg.dynamic).length
const requests = facts.declarations.reduce((total, pkg) => total + pkg.external.length, 0)
console.log(
GATE + ': ' + String(facts.packages.length) + ' client packages (' + String(dynamic) + ' dynamic, '
+ String(facts.packages.length - dynamic) + ' statically linked) satisfy dependency and module-request rules; '
+ String(requests) + ' explicit external request(s).',
)
}
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
await main()
}
@@ -64,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.' },