fix(release): harden dependency verification

This commit is contained in:
imccyu 2026-08-27 02:03:00 +08:00
parent b46d36d3bf
commit 9162bc69bd
9 changed files with 287 additions and 53 deletions

View file

@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-26-published-dependency-faces.md
2026-08-26-published-dependency-faces.md: aa7714e5c83aaf1c68ef300bdb10006e534bf88d
2026-08-26-published-dependency-faces.zh.md: e0ba33ff25651d1adf378af87fd6065ed9e54d03
2026-08-26-published-dependency-faces.md: 668d4c721446a87388ae50951156d1b24f91138c
2026-08-26-published-dependency-faces.zh.md: b10a285a81490f8eb66d70d3e73c2f676ad1b35c

View file

@ -26,11 +26,11 @@ Every covered package keeps `@deepseek-ai/cordis` in matching `peerDependencies`
A workspace package reached by a runtime value import from the Host entry closure belongs only in `dependencies` when every imported runtime export appears in the policy's `safeHostDependencyExports` table. An export whose constructor identity or module state must be shared appears in `peerRequiredHostExports`; importing one such export keeps the whole package edge in matching `peerDependencies` and `devDependencies`. Each table key is an exact module specifier and each value is a reviewed export set. The verifier follows runtime local imports from the Host entry, records named and default imports and re-exports, and rejects exports present in neither table; namespace, dynamic, and side-effect imports remain unbounded and cannot enter either table.
Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, invariant companions, and existing metadata-only peers belong only in `devDependencies`. Existing third-party dependencies outside these managed relationships keep their declared section. Workspace references use `workspace:^`.
Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, invariant companions, and existing metadata-only peers belong only in `devDependencies`. Ordinary third-party packages imported by the Host runtime belong in `dependencies`; other third-party relationships keep their declared section. Workspace references use `workspace:^`.
Some development relationships exist only in `dsh.client.inject` or TypeScript project references. The policy's `configurationOnlyDevDependencies` table names only those reviewed edges and keeps them in `devDependencies`.
The verifier reads source manifests and source files, so it runs on a clean tree without built `lib/`. An unclassified Host runtime export is a policy violation that blocks all `--fix` writes; a maintainer must review the export and classify it, change the source relationship, or change the package selection. Once source safety passes, `--fix` performs only the section and range changes implied by the classification and removes stale peer metadata.
The verifier reads source manifests and source files, so it runs on a clean tree without built `lib/`. Every selected Host face must have `src/index.ts`. An unclassified Host runtime export is a policy violation that blocks all `--fix` writes; a maintainer must review the export and classify it, change the source relationship, or change the package selection. Once source safety passes, `--fix` performs only the section and range changes implied by the classification and removes stale peer metadata.
### Maintainer workflow
@ -49,7 +49,7 @@ pnpm run verify-package-dependencies -- --fix
git diff -- packages pnpm-lock.yaml docs/module-graph.md docs/module-graph.zh.md docs/module-graph.i18n.yaml
```
Measure the working-tree graph and a Git ref through the local metadata-only registry. Each run creates a fresh consumer and npm cache, executes `npm install --package-lock-only`, rejects archive downloads, and leaves the repository unchanged. `--runs` controls repetitions, `--timeout-ms` bounds each run, and optional `--max-ms` makes the command fail when the slowest run exceeds a threshold.
Measure the working-tree graph and a Git ref through the local metadata-only registry. Each run creates a fresh consumer and npm cache, replaces inherited npm configuration with explicit peer, hoisting, and registry settings, executes `npm install --package-lock-only`, rejects archive downloads, and leaves the repository unchanged. `--runs` controls repetitions, `--timeout-ms` terminates the npm process tree after its deadline, and optional `--max-ms` makes the command fail when the slowest run exceeds a threshold.
```sh
pnpm run benchmark:npm-resolution -- --runs=5 --timeout-ms=300000

View file

@ -26,11 +26,11 @@ Host-only 包通过另一份显式列表加入同一策略。该列表包含 `@d
Host 入口闭包中的运行期 value import 所到达的 workspace 包,只有在每个运行期导出都列入策略的 `safeHostDependencyExports` 表时才只属于 `dependencies`。constructor 身份或模块状态必须共享的导出列入 `peerRequiredHostExports`;一旦使用这类导出,整条包依赖边就保留在范围一致的 `peerDependencies` 与 `devDependencies` 中。表的每个 key 都是精确 module specifier,每个 value 都是经审查的导出集合。验证器从 Host 入口沿运行期本地 import 扫描,记录具名与默认 import 和 re-export,并拒绝两个表都未收录的导出;namespace、dynamic 和 side-effect import 无法限定导出范围,因此不能进入任一表。
Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject`、invariant companion 和仅有元数据的现存 peer 只属于 `devDependencies`。不属于这些受管关系的现有第三方 dependency 保持原区段。Workspace 引用使用 `workspace:^`。
Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject`、invariant companion 和仅有元数据的现存 peer 只属于 `devDependencies`。Host 运行时导入的普通第三方包属于 `dependencies`;其他第三方关系保持原区段。Workspace 引用使用 `workspace:^`。
部分开发期关系只存在于 `dsh.client.inject` 或 TypeScript project reference 中。策略的 `configurationOnlyDevDependencies` 表只列出这些已评审的依赖边,并将它们保留在 `devDependencies` 中。
验证器读取源码 manifest 和源码文件,因此可以在没有已构建 `lib/` 的干净工作树上运行。未分类的 Host 运行期导出属于策略违规,会阻止 `--fix` 的全部写入;维护者必须审查该导出,并选择分类该导出、修改源码关系或修改选包范围。源码安全检查通过后,`--fix` 只执行分类所确定的区段与范围变更,并删除失效的 peer 元数据。
验证器读取源码 manifest 和源码文件,因此可以在没有已构建 `lib/` 的干净工作树上运行。每个被选中的 Host face 都必须存在 `src/index.ts`。未分类的 Host 运行期导出属于策略违规,会阻止 `--fix` 的全部写入;维护者必须审查该导出,并选择分类该导出、修改源码关系或修改选包范围。源码安全检查通过后,`--fix` 只执行分类所确定的区段与范围变更,并删除失效的 peer 元数据。
### 维护流程
@ -49,7 +49,7 @@ pnpm run verify-package-dependencies -- --fix
git diff -- packages pnpm-lock.yaml docs/module-graph.md docs/module-graph.zh.md docs/module-graph.i18n.yaml
```
通过仅 metadata 的本地 registry 测量工作树依赖图与 Git ref。每轮都会创建全新 consumer 与 npm cache,执行 `npm install --package-lock-only`,拒绝下载包归档,并保持仓库不变。`--runs` 控制重复次数,`--timeout-ms` 限制单轮耗时,可选 `--max-ms` 会在最慢一轮超过阈值时让命令失败。
通过仅 metadata 的本地 registry 测量工作树依赖图与 Git ref。每轮都会创建全新 consumer 与 npm cache,用明确的 peer、hoisting 和 registry 设置替换继承的 npm 配置,执行 `npm install --package-lock-only`,拒绝下载包归档,并保持仓库不变。`--runs` 控制重复次数,`--timeout-ms` 会在期限到达后终止 npm 进程树,可选 `--max-ms` 会在最慢一轮超过阈值时让命令失败。
```sh
pnpm run benchmark:npm-resolution -- --runs=5 --timeout-ms=300000

View file

@ -73,6 +73,7 @@ describe('next package benchmark graph', () => {
sourceLine: "import { runtimeValue } from '@f/runtime'",
}],
peerRequiredHostDependencies: new Set(),
configurationOnlyDevDependencies: new Set(),
clientInject: new Set(),
}
const index = new Map<string, Map<string, MutableRegistryManifest>>([

View file

@ -8,6 +8,7 @@ import {
parseBenchmarkOptions,
publishWorkspaceRange,
resolveNpmPackageLock,
runCommandWithTimeout,
type RegistryIndex,
} from './benchmark-npm-resolution.ts'
@ -104,4 +105,78 @@ describe('npm resolution benchmark', () => {
version: '0.1.0',
})
})
it('isolates peer resolution from inherited npm configuration', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-hostile-npm-config-'))
roots.push(root)
const userConfig = join(root, 'user.npmrc')
writeFileSync(userConfig, '@deepseek-ai:registry=http://127.0.0.1:1/\nlegacy-peer-deps=true\nomit=peer\n')
const previous = {
userConfig: process.env.npm_config_userconfig,
legacyPeerDeps: process.env.npm_config_legacy_peer_deps,
omit: process.env.npm_config_omit,
}
process.env.npm_config_userconfig = userConfig
process.env.npm_config_legacy_peer_deps = 'true'
process.env.npm_config_omit = 'peer'
try {
const index: RegistryIndex = new Map([
['@deepseek-ai/dsh', new Map([['0.1.0', {
name: '@deepseek-ai/dsh',
version: '0.1.0',
peerDependencies: { '@deepseek-ai/dsh-peer': '1.0.0' },
}]])],
['@deepseek-ai/dsh-peer', new Map([['1.0.0', {
name: '@deepseek-ai/dsh-peer',
version: '1.0.0',
}]])],
])
const result = await resolveNpmPackageLock(index, { '@deepseek-ai/dsh': '0.1.0' }, 10_000)
expect(result.archiveRequests).toBe(0)
expect(result.packageLock.packages['node_modules/@deepseek-ai/dsh-peer']?.version).toBe('1.0.0')
} finally {
if (previous.userConfig === undefined) delete process.env.npm_config_userconfig
else process.env.npm_config_userconfig = previous.userConfig
if (previous.legacyPeerDeps === undefined) delete process.env.npm_config_legacy_peer_deps
else process.env.npm_config_legacy_peer_deps = previous.legacyPeerDeps
if (previous.omit === undefined) delete process.env.npm_config_omit
else process.env.npm_config_omit = previous.omit
}
})
it.skipIf(process.platform === 'win32')('force-kills a timed-out process tree', async () => {
const source = [
"const { spawn } = require('node:child_process')",
"process.on('SIGTERM', () => {})",
'const child = spawn(process.execPath, [\'-e\', "process.on(\'SIGTERM\', () => {}); setInterval(() => {}, 1000)"], { stdio: \'ignore\' })',
'console.log(child.pid)',
'setInterval(() => {}, 1000)',
].join(';')
let descendantPid: number | undefined
try {
const result = await runCommandWithTimeout(process.execPath, ['-e', source], {
cwd: process.cwd(),
env: process.env,
timeoutMs: 1_000,
terminationGraceMs: 100,
})
const reportedPid = Number.parseInt(result.output.trim(), 10)
if (!Number.isSafeInteger(reportedPid)) throw new Error(`child reported invalid pid ${result.output.trim()}`)
descendantPid = reportedPid
expect(result.timedOut).toBe(true)
expect(result.signal).toBe('SIGKILL')
expect(() => { process.kill(reportedPid, 0) }).toThrow()
} finally {
if (descendantPid !== undefined && Number.isSafeInteger(descendantPid)) {
try {
process.kill(descendantPid, 'SIGKILL')
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error
}
}
}
})
})

View file

@ -1,6 +1,6 @@
/** Benchmark npm's dependency-tree resolution against an all-local registry. */
import { execFileSync, spawn } from 'node:child_process'
import { execFileSync, spawn, spawnSync, type ChildProcess } from 'node:child_process'
import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createServer, type Server } from 'node:http'
import { tmpdir } from 'node:os'
@ -10,6 +10,8 @@ import { parseArgs } from 'node:util'
const TARGET_PACKAGE = '@deepseek-ai/dsh'
const DEFAULT_TIMEOUT_MS = 300_000
const TERMINATION_GRACE_MS = 1_000
const FORCED_EXIT_TIMEOUT_MS = 5_000
const WORKSPACE_MANIFEST_GLOBS = [
'apps/*/package.json',
'packages/*/*/package.json',
@ -271,6 +273,92 @@ function npmExecutable(): string {
return process.platform === 'win32' ? 'npm.cmd' : 'npm'
}
function delay(ms: number): Promise<void> {
return new Promise(resolveDelay => setTimeout(resolveDelay, ms))
}
function signalProcessTree(child: ChildProcess, signal: 'SIGTERM' | 'SIGKILL'): void {
if (child.pid === undefined) {
child.kill(signal)
return
}
if (process.platform === 'win32') {
const force = signal === 'SIGKILL' ? ['/F'] : []
const result = spawnSync('taskkill', ['/PID', String(child.pid), '/T', ...force], {
stdio: 'ignore',
windowsHide: true,
})
if (result.error !== undefined) throw result.error
if (result.status !== 0 && child.exitCode === null && child.signalCode === null) child.kill(signal)
return
}
try {
process.kill(-child.pid, signal)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error
}
}
/**
* Run one command with bounded process-tree termination after its deadline.
* @param command - Executable path or name.
* @param args - Arguments passed without shell interpolation on POSIX.
* @param options - Working directory, environment, timeout, and termination grace.
* @returns Exit facts, captured output, duration, and whether timeout handling began.
*/
export async function runCommandWithTimeout(
command: string,
args: readonly string[],
options: {
readonly cwd: string
readonly env: NodeJS.ProcessEnv
readonly timeoutMs: number
readonly terminationGraceMs?: number
},
): Promise<{ status: number | null; signal: NodeJS.Signals | null; durationMs: number; output: string; timedOut: boolean }> {
const started = performance.now()
const child = spawn(command, [...args], {
cwd: options.cwd,
detached: process.platform !== 'win32',
env: options.env,
shell: process.platform === 'win32',
stdio: ['ignore', 'pipe', 'pipe'],
})
let output = ''
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (chunk) => { output += String(chunk) })
child.stderr.on('data', (chunk) => { output += String(chunk) })
const exited = new Promise<{ status: number | null; signal: NodeJS.Signals | null }>((resolveExit, reject) => {
child.once('error', reject)
child.once('close', (status, signal) => { resolveExit({ status, signal }) })
})
let timeout: NodeJS.Timeout | undefined
try {
const first = await Promise.race([
exited.then(outcome => ({ type: 'exit' as const, outcome })),
new Promise<{ type: 'timeout' }>((resolveTimeout) => {
timeout = setTimeout(() => { resolveTimeout({ type: 'timeout' }) }, options.timeoutMs)
}),
])
if (first.type === 'exit') {
return { ...first.outcome, durationMs: performance.now() - started, output, timedOut: false }
}
signalProcessTree(child, 'SIGTERM')
await delay(options.terminationGraceMs ?? TERMINATION_GRACE_MS)
signalProcessTree(child, 'SIGKILL')
const forced = await Promise.race([
exited,
delay(FORCED_EXIT_TIMEOUT_MS).then(() => undefined),
])
if (forced === undefined) throw new Error('timed-out process tree did not exit after SIGKILL')
return { ...forced, durationMs: performance.now() - started, output, timedOut: true }
} finally {
if (timeout !== undefined) clearTimeout(timeout)
}
}
function readNpmPackageLock(path: string): NpmPackageLock {
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
@ -289,50 +377,39 @@ async function runNpm(
registry: string,
timeoutMs: number,
): Promise<{ durationMs: number; output: string; timedOut: boolean }> {
const started = performance.now()
const child = spawn(npmExecutable(), [
const npmrc = join(cwd, '.npmrc')
const globalNpmrc = join(cwd, '.npmrc-global')
writeFileSync(npmrc, `registry=${registry}\n@deepseek-ai:registry=${registry}\n`)
writeFileSync(globalNpmrc, '')
const inheritedEnvironment = Object.fromEntries(Object.entries(process.env)
.filter(([name]) => !name.toLowerCase().startsWith('npm_config_')))
const result = await runCommandWithTimeout(npmExecutable(), [
'install',
'--package-lock-only',
'--ignore-scripts',
'--no-audit',
'--no-fund',
'--loglevel=error',
'--include=peer',
'--install-strategy=hoisted',
'--legacy-peer-deps=false',
`--registry=${registry}`,
], {
cwd,
// Windows resolves npm through a .cmd shim, which spawn() refuses
// without a shell since the CVE-2024-27980 hardening.
shell: process.platform === 'win32',
env: {
...process.env,
...inheritedEnvironment,
npm_config_cache: join(cwd, '.npm-cache'),
npm_config_globalconfig: globalNpmrc,
npm_config_userconfig: npmrc,
npm_config_update_notifier: 'false',
},
stdio: ['ignore', 'pipe', 'pipe'],
timeoutMs,
})
let output = ''
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (chunk) => { output += String(chunk) })
child.stderr.on('data', (chunk) => { output += String(chunk) })
const outcome = await new Promise<{ status: number | null; timedOut: boolean }>((resolveExit, reject) => {
let timeoutReached = false
const timer = setTimeout(() => {
timeoutReached = true
child.kill('SIGTERM')
}, timeoutMs)
child.once('error', reject)
child.once('exit', (status) => {
clearTimeout(timer)
resolveExit({ status, timedOut: timeoutReached })
})
})
const durationMs = performance.now() - started
if (outcome.timedOut) return { durationMs, output, timedOut: true }
if (outcome.status !== 0) {
throw new Error(`npm install exited ${String(outcome.status)} after ${durationMs.toFixed(0)} ms\n${output.trim()}`)
if (result.timedOut) return result
if (result.status !== 0) {
throw new Error(`npm install exited ${String(result.status)} after ${result.durationMs.toFixed(0)} ms\n${result.output.trim()}`)
}
return { durationMs, output, timedOut: false }
return result
}
/**
@ -403,6 +480,7 @@ export async function resolveNpmPackageLock(
packageLock: readNpmPackageLock(join(consumer, 'package-lock.json')),
}
} finally {
server.closeAllConnections()
await close(server)
rmSync(consumer, { recursive: true, force: true })
}

View file

@ -35,14 +35,14 @@ const CONFIGURATION_ONLY_DEV_DEPENDENCIES = {
const SAFE_HOST_DEPENDENCY_EXPORTS = {
'@deepseek-ai/dsh-api-session-controller/remote-events': ['SESSION_CONTROLLER_REMOTE_EVENTS'],
'@deepseek-ai/dsh-credentials': ['credentialKey'],
'@deepseek-ai/dsh-host-apiproxy': ['toFetchHandler'],
'@deepseek-ai/dsh-host-apiproxy/api': ['RpcId', 'clientRequestSchema'],
'@deepseek-ai/dsh-llm': ['MessageId', 'callConfigEquals', 'deepFreeze', 'freezeMessage'],
'@deepseek-ai/dsh-llm/brand': ['ToolCallId'],
'@deepseek-ai/dsh-session': ['isJsonValue'],
'@deepseek-ai/dsh-session/types': ['SessionId'],
'@deepseek-ai/dsh-settings': ['settingsNamespace'],
'@deepseek-ai/dsh-system-prompt': ['FIRST_PARTY_SECTION_ORDER'],
'@deepseek-ai/dsh-timeout': ['MAX_TIMER_DELAY_MS'],
'@deepseek-ai/dsh-typert-protocol': ['RemoteError', 'remoteErrorOf'],
'@deepseek-ai/dsh-util-crypto': ['randomUUID'],
'@deepseek-ai/schemastery': ['default'],
} as const satisfies HostDependencyExports
@ -50,7 +50,7 @@ const SAFE_HOST_DEPENDENCY_EXPORTS = {
/** Runtime exports that require every consumer to resolve the provider's shared peer instance. */
const PEER_REQUIRED_HOST_EXPORTS = {
'@deepseek-ai/dsh-scope': ['carrierKeyOf', 'scopeOf', 'scopeTarget'],
'@deepseek-ai/dsh-typert-protocol': ['TypertLookupFailure', 'TypertRemoteFailure', 'remoteMethods'],
'@deepseek-ai/dsh-typert-protocol': ['Remote', 'TypertRemoteService', 'remoteMethods'],
} as const satisfies HostDependencyExports
/** Exact import specifier to reviewed runtime exports. */

View file

@ -82,6 +82,7 @@ function facts(manifest: PackageDependencyManifest): PackageDependencyFacts {
sourceLine: "import { runtimeValue } from '@deepseek-ai/dsh-runtime'",
}],
peerRequiredHostDependencies: new Set(),
configurationOnlyDevDependencies: new Set(),
clientInject: new Set(),
}
}
@ -109,11 +110,17 @@ describe('package dependency scope', () => {
'@deepseek-ai/dsh-client-ui-tool': ['@deepseek-ai/dsh-api-remotes'],
})
expect(PACKAGE_DEPENDENCY_POLICY.safeHostDependencyExports['@deepseek-ai/schemastery']).toEqual(['default'])
expect(PACKAGE_DEPENDENCY_POLICY.safeHostDependencyExports['@deepseek-ai/dsh-session/types']).toEqual([
'SessionId',
])
expect(PACKAGE_DEPENDENCY_POLICY.safeHostDependencyExports['@deepseek-ai/dsh-typert-protocol']).toEqual([
'RemoteError', 'remoteErrorOf',
])
expect(PACKAGE_DEPENDENCY_POLICY.peerRequiredHostExports['@deepseek-ai/dsh-scope']).toEqual([
'carrierKeyOf', 'scopeOf', 'scopeTarget',
])
expect(PACKAGE_DEPENDENCY_POLICY.peerRequiredHostExports['@deepseek-ai/dsh-typert-protocol']).toEqual([
'TypertLookupFailure', 'TypertRemoteFailure', 'remoteMethods',
'Remote', 'TypertRemoteService', 'remoteMethods',
])
})
@ -187,6 +194,7 @@ describe('package dependency scope', () => {
sourceLine: "import { safeValue } from '@f/provider/api'",
}],
peerRequiredHostDependencies: new Set(),
configurationOnlyDevDependencies: new Set(),
clientInject: new Set(),
}
@ -211,6 +219,15 @@ describe('package dependency scope', () => {
})
describe('face-aware source classification', () => {
it('fails when a managed Host package has no Host entry', () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-package-missing-host-'))
roots.push(root)
const subject = pkg('@f/host', 'packages/g/host/package.json')
expect(() => readPackageDependencyFacts(root, subject, 'configured-host', new Set([subject.name])))
.toThrow('packages/g/host/package.json: Host runtime entry packages/g/host/src/index.ts does not exist')
})
it('counts Host values as dependencies and Client values as development inputs', () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-package-faces-'))
roots.push(root)
@ -234,9 +251,12 @@ describe('face-aware source classification', () => {
}
const found = readPackageDependencyFacts(root, subject, 'client-host', new Set([
CORDIS, '@f/runtime', '@f/types', '@f/nested', '@f/hidden', '@f/browser', '@f/injected',
]))
]), policy({
configurationOnlyDevDependencies: { '@f/dual': ['@f/injected'] },
}))
expect([...found.hostRuntimeSourceUses.keys()].sort()).toEqual(['@f/nested', '@f/runtime'])
expect([...found.configurationOnlyDevDependencies]).toEqual(['@f/injected'])
expect(found.hostRuntimeExportUses).toEqual([
{
packageName: '@f/nested',
@ -294,6 +314,57 @@ describe('face-aware source classification', () => {
})
describe('dependency sections', () => {
it('does not leak repository configuration into captured dependency facts', () => {
const manifest: PackageDependencyManifest = {
name: '@deepseek-ai/dsh-client-locale',
dependencies: { '@deepseek-ai/dsh-runtime': 'workspace:^' },
devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-types': 'workspace:^' },
peerDependencies: { [CORDIS]: 'workspace:^' },
}
const base = facts(manifest)
const subject: PackageDependencyFacts = {
...base,
workspaceNames: new Set([...base.workspaceNames, '@deepseek-ai/dsh-api-remotes']),
}
expect(collectPackageDependencyViolations({
facts: [subject], packages: [], policyViolations: [], workspaceNames: subject.workspaceNames,
})).toEqual([])
})
it('requires non-workspace Host runtime imports in dependencies', () => {
const manifest: PackageDependencyManifest = {
name: '@deepseek-ai/dsh-probe',
dependencies: { '@deepseek-ai/dsh-runtime': 'workspace:^' },
devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-types': 'workspace:^', external: '^1.0.0' },
peerDependencies: { [CORDIS]: 'workspace:^' },
}
const subject: PackageDependencyFacts = {
...facts(manifest),
hostRuntimeSourceUses: new Map([
['@deepseek-ai/dsh-runtime', ['packages/core/probe/src/index.ts']],
['external', ['packages/core/probe/src/index.ts']],
]),
}
const state = {
facts: [subject], packages: [], policyViolations: [], workspaceNames: subject.workspaceNames,
}
expect(collectPackageDependencyViolations(state)).toContain(
'packages/core/probe/package.json: external (packages/core/probe/src/index.ts) '
+ 'must be dependencies-only; found devDependencies',
)
repairPackageDependencyManifest(subject)
expect(manifest.dependencies?.external).toBe('^1.0.0')
expect(manifest.devDependencies?.external).toBeUndefined()
delete manifest.dependencies?.external
expect(collectPackageDependencyViolations(state)).toContain(
'packages/core/probe/package.json: external (packages/core/probe/src/index.ts) '
+ 'must be dependencies-only; found no dependency section',
)
})
it('accepts Host dependencies, development-only inputs, and shared Cordis', () => {
const manifest: PackageDependencyManifest = {
name: '@deepseek-ai/dsh-probe',

View file

@ -59,6 +59,7 @@ export interface PackageDependencyFacts {
readonly hostRuntimeSourceUses: ReadonlyMap<string, readonly string[]>
readonly hostRuntimeExportUses: readonly HostRuntimeExportUse[]
readonly peerRequiredHostDependencies: ReadonlySet<string>
readonly configurationOnlyDevDependencies: ReadonlySet<string>
readonly clientInject: ReadonlySet<string>
}
@ -296,7 +297,7 @@ function readHostRuntimeUses(root: string, pkg: WorkspacePackageManifest): {
const seen = new Set<string>()
const visit = (path: string): void => {
const normalized = normalize(path)
if (seen.has(normalized) || !existsSync(normalized)) return
if (seen.has(normalized)) return
seen.add(normalized)
const source = readFileSync(normalized, 'utf8')
const displayPath = normalizePath(relative(root, normalized))
@ -312,7 +313,11 @@ function readHostRuntimeUses(root: string, pkg: WorkspacePackageManifest): {
if (target !== undefined) visit(target)
}
}
visit(resolve(root, pkg.dir, 'src/index.ts'))
const entry = resolve(root, pkg.dir, 'src/index.ts')
if (!existsSync(entry)) {
throw new Error(`${pkg.manifestPath}: Host runtime entry ${normalizePath(relative(root, entry))} does not exist`)
}
visit(entry)
return {
packageUses,
exportUses: [...exportUses.values()].sort((left, right) =>
@ -358,6 +363,9 @@ export function readPackageDependencyFacts(
peerRequiredHostDependencies: new Set(hostRuntime.exportUses
.filter(use => policy.peerRequiredHostExports[use.specifier]?.includes(use.exportName) === true)
.map(use => use.packageName)),
configurationOnlyDevDependencies: new Set(
policy.configurationOnlyDevDependencies[pkg.manifest.name ?? ''] ?? [],
),
clientInject: new Set(inject.map(packageNameOf).filter(name => name !== undefined)),
}
}
@ -405,7 +413,7 @@ export function collectHostDependencyExportPolicyViolations(
for (const fact of facts) {
for (const use of fact.hostRuntimeExportUses) {
if (use.packageName === fact.manifest.name || use.packageName === CORDIS) continue
if (!workspaceNames.has(use.packageName) && fact.manifest.peerDependencies?.[use.packageName] === undefined) continue
if (!workspaceNames.has(use.packageName)) continue
if (policy.safeHostDependencyExports[use.specifier]?.includes(use.exportName) === true) continue
if (policy.peerRequiredHostExports[use.specifier]?.includes(use.exportName) === true) continue
violations.push(
@ -427,12 +435,16 @@ export function readPackageDependencyState(
const discovered = discoverPackageDependencyScope(packages.release, policy)
const facts = discovered.selected.map(pkg =>
readPackageDependencyFacts(root, pkg, pkg.role, workspaceNames, policy))
const selectedNames = new Set(facts.map(fact => fact.manifest.name))
return {
facts,
packages: packages.release,
policyViolations: [
...discovered.violations,
...collectHostDependencyExportPolicyViolations(facts, workspaceNames, policy),
...Object.keys(policy.configurationOnlyDevDependencies)
.filter(name => !selectedNames.has(name))
.map(name => `configurationOnlyDevDependencies names unmanaged package ${name}`),
].sort(),
workspaceNames,
}
@ -441,7 +453,6 @@ export function readPackageDependencyState(
/** Derive the required npm section for each relationship owned by the policy. */
export function expectedPackageDependencies(
facts: PackageDependencyFacts,
policy: PackageDependencyPolicy = PACKAGE_DEPENDENCY_POLICY,
): ReadonlyMap<string, ExpectedPackageDependency> {
const expected = new Map<string, { section: ExpectedPackageDependency['section']; origins: Set<string> }>()
const add = (name: string, sectionName: ExpectedPackageDependency['section'], origin: string): void => {
@ -463,18 +474,16 @@ export function expectedPackageDependencies(
for (const name of facts.clientInject) {
if (facts.workspaceNames.has(name)) add(name, 'devDependencies', 'dsh.client.inject')
}
for (const name of PACKAGE_DEPENDENCY_POLICY.configurationOnlyDevDependencies[facts.manifest.name ?? ''] ?? []) {
if (facts.workspaceNames.has(name)) add(name, 'devDependencies', 'configured development-only relationship')
}
for (const name of policy.configurationOnlyDevDependencies[facts.manifest.name ?? ''] ?? []) {
for (const name of facts.configurationOnlyDevDependencies) {
if (facts.workspaceNames.has(name)) add(name, 'devDependencies', 'configured development-only relationship')
}
for (const name of Object.keys(facts.manifest.peerDependencies ?? {})) {
if (name !== CORDIS) add(name, 'devDependencies', 'existing non-Cordis peer')
}
for (const [name, paths] of facts.hostRuntimeSourceUses) {
if (!facts.workspaceNames.has(name) && facts.manifest.peerDependencies?.[name] === undefined) continue
const expectedSection = facts.peerRequiredHostDependencies.has(name) ? 'peer-dev' : 'dependencies'
const expectedSection = facts.workspaceNames.has(name) && facts.peerRequiredHostDependencies.has(name)
? 'peer-dev'
: 'dependencies'
for (const path of paths) add(name, expectedSection, path)
}
return new Map([...expected].map(([name, rule]) => [name, {