From 9162bc69bd9c1e681ef4266a394e133ef31c78ae Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:03:00 +0800 Subject: [PATCH] fix(release): harden dependency verification --- ...08-26-published-dependency-faces.i18n.yaml | 4 +- .../2026-08-26-published-dependency-faces.md | 6 +- ...026-08-26-published-dependency-faces.zh.md | 6 +- .../benchmark-next-package-dependency.spec.ts | 1 + scripts/benchmark-npm-resolution.spec.ts | 75 ++++++++++ scripts/benchmark-npm-resolution.ts | 138 ++++++++++++++---- scripts/package-dependency-policy.ts | 6 +- scripts/verify-package-dependencies.spec.ts | 75 +++++++++- scripts/verify-package-dependencies.ts | 29 ++-- 9 files changed, 287 insertions(+), 53 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml index e09edf4f86..3604d6fe6a 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md index aa7714e5c8..668d4c7214 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md @@ -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 diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md index e0ba33ff25..b10a285a81 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md @@ -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 diff --git a/scripts/benchmark-next-package-dependency.spec.ts b/scripts/benchmark-next-package-dependency.spec.ts index 2a0ee10189..489b72c3cf 100644 --- a/scripts/benchmark-next-package-dependency.spec.ts +++ b/scripts/benchmark-next-package-dependency.spec.ts @@ -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>([ diff --git a/scripts/benchmark-npm-resolution.spec.ts b/scripts/benchmark-npm-resolution.spec.ts index e450614a44..7511cb34c5 100644 --- a/scripts/benchmark-npm-resolution.spec.ts +++ b/scripts/benchmark-npm-resolution.spec.ts @@ -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 + } + } + } + }) }) diff --git a/scripts/benchmark-npm-resolution.ts b/scripts/benchmark-npm-resolution.ts index 2e7ff71732..b8089117ad 100644 --- a/scripts/benchmark-npm-resolution.ts +++ b/scripts/benchmark-npm-resolution.ts @@ -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 { + 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 }) } diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index 2d8f7eb56c..50ed98bd17 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -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. */ diff --git a/scripts/verify-package-dependencies.spec.ts b/scripts/verify-package-dependencies.spec.ts index ad4752f9a7..a59e88a215 100644 --- a/scripts/verify-package-dependencies.spec.ts +++ b/scripts/verify-package-dependencies.spec.ts @@ -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', diff --git a/scripts/verify-package-dependencies.ts b/scripts/verify-package-dependencies.ts index 3782d61309..b20e16af00 100644 --- a/scripts/verify-package-dependencies.ts +++ b/scripts/verify-package-dependencies.ts @@ -59,6 +59,7 @@ export interface PackageDependencyFacts { readonly hostRuntimeSourceUses: ReadonlyMap readonly hostRuntimeExportUses: readonly HostRuntimeExportUse[] readonly peerRequiredHostDependencies: ReadonlySet + readonly configurationOnlyDevDependencies: ReadonlySet readonly clientInject: ReadonlySet } @@ -296,7 +297,7 @@ function readHostRuntimeUses(root: string, pkg: WorkspacePackageManifest): { const seen = new Set() 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 { const expected = new Map }>() 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, {