فهرست منبع

fix(release): harden dependency verification

imccyu 1 ماه پیش
والد
کامیت
9162bc69bd

+ 2 - 2
.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

+ 3 - 3
.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

+ 3 - 3
.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

+ 1 - 0
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<string, Map<string, MutableRegistryManifest>>([

+ 75 - 0
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
+        }
+      }
+    }
+  })
 })

+ 108 - 30
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<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'],
-  })
-  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 })
-    })
+    timeoutMs,
   })
-  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 })
   }

+ 3 - 3
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. */

+ 73 - 2
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',

+ 19 - 10
scripts/verify-package-dependencies.ts

@@ -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, {