mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
build(client): enforce client package boundaries
This commit is contained in:
@@ -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'],
|
||||
|
||||
@@ -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')
|
||||
@@ -49,7 +52,7 @@ function purityResolveId(): ResolveId {
|
||||
function cssModulePlugin(): CssModulePlugin {
|
||||
const configs = clientConfigs()
|
||||
const plugins = (configs[0] as { plugins: CssModulePlugin[] }).plugins
|
||||
const plugin = plugins.find(candidate => candidate.name === 'dsh-css-modules-inline')
|
||||
const plugin = plugins.find(candidate => candidate.name === 'dsh-css-inline')
|
||||
if (plugin?.resolveId === undefined || plugin.load === undefined) {
|
||||
throw new Error('CSS Modules plugin missing from client config')
|
||||
}
|
||||
@@ -59,9 +62,10 @@ 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-ui-primitives')).toBeNull()
|
||||
expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull()
|
||||
expect(resolveId('react')).toBeNull()
|
||||
expect(resolveId('zod')).toBeNull()
|
||||
})
|
||||
@@ -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
@@ -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 '),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -275,8 +275,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]))
|
||||
|
||||
@@ -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,317 @@
|
||||
/** 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,
|
||||
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: {},
|
||||
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 ?? [],
|
||||
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',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
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('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:^',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,819 @@
|
||||
/**
|
||||
* 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 STATIC_PRESET_SOURCE = 'packages/client/tsdown.client.ts'
|
||||
const CORDIS = '@deepseek-ai/cordis'
|
||||
const DSH_PREFIX = '@deepseek-ai/dsh-'
|
||||
|
||||
/** 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[]>>
|
||||
/** 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[]
|
||||
/** 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)
|
||||
}
|
||||
|
||||
function collectSourceFilePackageUses(sourceFile: ts.SourceFile): 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) || ts.isExportDeclaration(node)) {
|
||||
add(node.moduleSpecifier)
|
||||
} else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
|
||||
add(node.moduleReference.expression)
|
||||
} else if (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 (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 === '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 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 === '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) continue
|
||||
violations.push(
|
||||
PLATFORM_SOURCE + ': parser-preloaded external ' + JSON.stringify(specifier)
|
||||
+ ' has no dynamic dsh.client row',
|
||||
)
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
interface ExpectedRule {
|
||||
readonly kind: '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 === '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) 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 staticModules = new Set(facts.platformModules)
|
||||
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
|
||||
}
|
||||
if (staticModules.has(specifier)) 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 readStringLiteralArray(root: string, name: string): string[] {
|
||||
const path = resolve(root, PLATFORM_SOURCE)
|
||||
const source = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, false, ts.ScriptKind.TS)
|
||||
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 && ts.isAsExpression(declaration.initializer)
|
||||
? declaration.initializer.expression
|
||||
: declaration.initializer
|
||||
if (expression === undefined || !ts.isArrayLiteralExpression(expression)) {
|
||||
throw new Error(GATE + ': ' + name + ' in ' + PLATFORM_SOURCE + ' must be an array literal')
|
||||
}
|
||||
return expression.elements.map((element) => {
|
||||
if (!ts.isStringLiteral(element)) {
|
||||
throw new Error(GATE + ': ' + name + ' in ' + PLATFORM_SOURCE + ' must contain only string literals')
|
||||
}
|
||||
return element.text
|
||||
})
|
||||
}
|
||||
}
|
||||
throw new Error(GATE + ': ' + PLATFORM_SOURCE + ' 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 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)) {
|
||||
const locations = sourceUses.get(name) ?? new Set<string>()
|
||||
locations.add(file)
|
||||
sourceUses.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()]),
|
||||
),
|
||||
dependencies: manifest.dependencies ?? {},
|
||||
peerDependencies: manifest.peerDependencies ?? {},
|
||||
devDependencies: manifest.devDependencies ?? {},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
packages,
|
||||
declarations,
|
||||
staticLinkedPackages,
|
||||
platformModules: readStringLiteralArray(root, 'PLATFORM_MODULES'),
|
||||
preloadedExternals: readStringLiteralArray(root, 'PRELOADED_CLIENT_EXTERNALS'),
|
||||
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()
|
||||
}
|
||||
@@ -95,6 +95,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/client/ui-settings-plugin-inventory': { kind: 'none', reason: 'Browser-side inventory projection; registers nothing model-facing.' },
|
||||
'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/render-service': { kind: 'none', reason: 'Browser-side UI assembly layer; registers nothing model-facing.' },
|
||||
'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' },
|
||||
'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' },
|
||||
'packages/e2b/fs-e2b': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
|
||||
|
||||
Reference in New Issue
Block a user