Procházet zdrojové kódy

feat: enforce published dependency policy

imccyu před 1 měsícem
rodič
revize
de256e8bc1

+ 3 - 0
package.json

@@ -50,6 +50,8 @@
     "test:web:perf": "npm run build && npm run test:web:perf:built",
     "test:web:perf:built": "DSH_SNAPSHOT=replay vitest run --config vitest.web.perf.config.ts",
     "test:web:stress": "npm run build && vitest run --config vitest.web-stress.config.ts",
+    "benchmark:npm-resolution": "tsx scripts/benchmark-npm-resolution.ts",
+    "benchmark:npm-resolution:next": "tsx scripts/benchmark-next-package-dependency.ts",
     "test:gui": "vitest run packages/client packages/host",
     "check:all": "tsx scripts/run-gates.ts check-all",
     "check:ci": "tsx scripts/run-gates.ts ci-primary",
@@ -104,6 +106,7 @@
     "verify-optional-dependency-imports": "tsx scripts/verify-optional-dependency-imports.ts",
     "verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts",
     "verify-application-entrypoints": "tsx scripts/verify-application-entrypoints.ts",
+    "verify-package-dependencies": "tsx scripts/verify-package-dependencies.ts",
     "verify-client-packages": "tsx scripts/verify-client-packages.ts",
     "verify-client-ui-i18n": "tsx scripts/verify-client-ui-i18n.ts",
     "verify-vendored-links": "tsx scripts/verify-vendored-links.ts",

+ 114 - 0
scripts/benchmark-next-package-dependency.spec.ts

@@ -0,0 +1,114 @@
+import { describe, expect, it } from 'vitest'
+import {
+  applyFactsToRegistry,
+  discoverBenchmarkCandidates,
+  parseNextPackageBenchmarkOptions,
+  type MutableRegistryManifest,
+} from './benchmark-next-package-dependency.ts'
+import type {
+  PackageDependencyFacts,
+  PackageDependencyManifest,
+  WorkspacePackageManifest,
+} from './verify-package-dependencies.ts'
+import type { RegistryIndex } from './benchmark-npm-resolution.ts'
+
+describe('next package benchmark options', () => {
+  it('parses candidate and repetition controls', () => {
+    expect(parseNextPackageBenchmarkOptions([
+      '--',
+      '--candidates=@f/a,@f/b',
+      '--runs=2',
+      '--finalist-runs=4',
+      '--finalists=3',
+      '--jobs=6',
+      '--timeout-ms=9000',
+    ])).toEqual({
+      candidates: ['@f/a', '@f/b'],
+      coarseRuns: 2,
+      finalistRuns: 4,
+      finalists: 3,
+      jobs: 6,
+      timeoutMs: 9000,
+    })
+  })
+
+  it('rejects invalid positive integers', () => {
+    expect(() => parseNextPackageBenchmarkOptions(['--jobs=0'])).toThrow('--jobs must be a positive integer')
+  })
+})
+
+describe('next package benchmark graph', () => {
+  it('applies a source-derived candidate without changing the filesystem', () => {
+    const manifest: PackageDependencyManifest & { version: string } = {
+      name: '@f/probe',
+      version: '1.0.0',
+      peerDependencies: {
+        '@deepseek-ai/cordis': 'workspace:^',
+        '@f/runtime': 'workspace:^',
+        '@f/types': 'workspace:^',
+      },
+      devDependencies: {
+        '@deepseek-ai/cordis': 'workspace:^',
+        '@f/runtime': 'workspace:^',
+        '@f/types': 'workspace:^',
+      },
+    }
+    const facts: PackageDependencyFacts = {
+      manifestPath: 'packages/g/probe/package.json',
+      role: 'configured-host',
+      manifest,
+      workspaceNames: new Set(['@deepseek-ai/cordis', '@f/probe', '@f/runtime', '@f/types']),
+      allSourceUses: new Map([
+        ['@f/runtime', ['packages/g/probe/src/index.ts']],
+        ['@f/types', ['packages/g/probe/src/types.ts']],
+      ]),
+      hostRuntimeSourceUses: new Map([['@f/runtime', ['packages/g/probe/src/index.ts']]]),
+      clientInject: new Set(),
+    }
+    const index = new Map<string, Map<string, MutableRegistryManifest>>([
+      ['@f/probe', new Map([['1.0.0', structuredClone(manifest) as MutableRegistryManifest]])],
+    ])
+    applyFactsToRegistry(index, facts, new Map([
+      ['@deepseek-ai/cordis', '4.0.1'],
+      ['@f/probe', '1.0.0'],
+      ['@f/runtime', '2.0.0'],
+      ['@f/types', '3.0.0'],
+    ]))
+
+    expect(index.get('@f/probe')?.get('1.0.0')).toMatchObject({
+      dependencies: { '@f/runtime': '^2.0.0' },
+      peerDependencies: { '@deepseek-ai/cordis': '^4.0.1' },
+    })
+    expect(index.get('@f/probe')?.get('1.0.0')?.dependencies).not.toHaveProperty('@f/types')
+  })
+
+  it('finds reachable unconfigured packages with non-Cordis peers', () => {
+    const index = new Map([
+      ['@deepseek-ai/dsh', new Map([['1.0.0', {
+        name: '@deepseek-ai/dsh', version: '1.0.0', dependencies: { '@f/a': '^1.0.0', '@f/b': '^1.0.0' },
+      }]])],
+      ['@f/a', new Map([['1.0.0', {
+        name: '@f/a', version: '1.0.0', peerDependencies: { '@f/runtime': '^1.0.0' },
+      }]])],
+      ['@f/b', new Map([['1.0.0', {
+        name: '@f/b', version: '1.0.0', peerDependencies: { '@deepseek-ai/cordis': '^4.0.0' },
+      }]])],
+      ['@f/runtime', new Map([['1.0.0', { name: '@f/runtime', version: '1.0.0' }]])],
+    ]) as RegistryIndex
+    const release = new Map<string, WorkspacePackageManifest>([
+      ['@f/a', {
+        name: '@f/a', dir: 'packages/g/a', manifestPath: 'packages/g/a/package.json', manifest: { name: '@f/a' },
+      }],
+      ['@f/b', {
+        name: '@f/b', dir: 'packages/g/b', manifestPath: 'packages/g/b/package.json', manifest: { name: '@f/b' },
+      }],
+    ])
+
+    expect(discoverBenchmarkCandidates(
+      index,
+      new Map([['@deepseek-ai/dsh', '1.0.0'], ['@f/a', '1.0.0'], ['@f/b', '1.0.0']]),
+      release,
+      new Set(),
+    )).toEqual(['@f/a'])
+  })
+})

+ 271 - 0
scripts/benchmark-next-package-dependency.ts

@@ -0,0 +1,271 @@
+/** Benchmark which additional Host package most reduces npm peer resolution. */
+
+import { availableParallelism } from 'node:os'
+import { resolve } from 'node:path'
+import { parseArgs } from 'node:util'
+import {
+  benchmarkNpmResolution,
+  buildRegistryIndex,
+  parsePositiveIntegerOption,
+  publishWorkspaceRange,
+  type RegistryIndex,
+} from './benchmark-npm-resolution.ts'
+import {
+  readPackageDependencyFacts,
+  readPackageDependencyState,
+  readWorkspacePackageManifests,
+  repairPackageDependencyManifest,
+  type PackageDependencyFacts,
+  type WorkspacePackageManifest,
+} from './verify-package-dependencies.ts'
+
+const TARGET_PACKAGE = '@deepseek-ai/dsh'
+const CORDIS = '@deepseek-ai/cordis'
+
+interface Options {
+  readonly candidates?: readonly string[]
+  readonly coarseRuns: number
+  readonly finalistRuns: number
+  readonly finalists: number
+  readonly jobs: number
+  readonly timeoutMs: number
+}
+
+export interface MutableRegistryManifest {
+  name: string
+  version: string
+  dependencies?: Record<string, string>
+  optionalDependencies?: Record<string, string>
+  peerDependencies?: Record<string, string>
+  peerDependenciesMeta?: Record<string, { optional?: boolean }>
+}
+
+interface Measurement {
+  readonly package: string
+  readonly seconds: readonly number[]
+  readonly medianSeconds: number
+}
+
+/** Parse benchmark selection and repetition options. */
+export function parseNextPackageBenchmarkOptions(args: readonly string[]): Options {
+  const normalized = args[0] === '--' ? args.slice(1) : args
+  const { values } = parseArgs({
+    args: [...normalized],
+    options: {
+      candidates: { type: 'string' },
+      runs: { type: 'string' },
+      'finalist-runs': { type: 'string' },
+      finalists: { type: 'string' },
+      jobs: { type: 'string' },
+      'timeout-ms': { type: 'string' },
+    },
+    allowPositionals: false,
+  })
+  return {
+    ...(values.candidates === undefined
+      ? {}
+      : { candidates: values.candidates.split(',').filter(Boolean) }),
+    coarseRuns: parsePositiveIntegerOption(values.runs, 1, '--runs'),
+    finalistRuns: parsePositiveIntegerOption(values['finalist-runs'], 3, '--finalist-runs'),
+    finalists: parsePositiveIntegerOption(values.finalists, 5, '--finalists'),
+    jobs: parsePositiveIntegerOption(values.jobs, Math.min(8, availableParallelism()), '--jobs'),
+    timeoutMs: parsePositiveIntegerOption(values['timeout-ms'], 120_000, '--timeout-ms'),
+  }
+}
+
+function median(values: readonly number[]): number {
+  const sorted = [...values].sort((left, right) => left - right)
+  const middle = Math.floor(sorted.length / 2)
+  return sorted.length % 2 === 0
+    ? ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2
+    : sorted[middle] ?? 0
+}
+
+function cloneIndex(index: RegistryIndex): Map<string, Map<string, MutableRegistryManifest>> {
+  return new Map([...index].map(([name, versions]) => [
+    name,
+    new Map([...versions].map(([version, manifest]) => [
+      version,
+      structuredClone(manifest) as MutableRegistryManifest,
+    ])),
+  ]))
+}
+
+function publishedSection(
+  values: Readonly<Record<string, string>> | undefined,
+  workspaceVersions: ReadonlyMap<string, string>,
+): Record<string, string> | undefined {
+  if (values === undefined) return undefined
+  return Object.fromEntries(Object.entries(values).map(([name, range]) => {
+    const version = workspaceVersions.get(name)
+    return [name, version === undefined ? range : publishWorkspaceRange(range, version)]
+  }))
+}
+
+/** Apply one source-derived policy result to an in-memory registry manifest. */
+export function applyFactsToRegistry(
+  index: Map<string, Map<string, MutableRegistryManifest>>,
+  facts: PackageDependencyFacts,
+  workspaceVersions: ReadonlyMap<string, string>,
+): void {
+  const source = structuredClone(facts.manifest)
+  repairPackageDependencyManifest({ ...facts, manifest: source })
+  const version = workspaceVersions.get(source.name ?? '')
+  const target = version === undefined ? undefined : index.get(source.name ?? '')?.get(version)
+  if (target === undefined) throw new Error(`local registry has no ${source.name ?? 'unnamed package'}@${version ?? 'unknown'}`)
+  for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies'] as const) {
+    const values = publishedSection(source[field], workspaceVersions)
+    if (values !== undefined) target[field] = values
+    else if (field === 'dependencies') delete target.dependencies
+    else if (field === 'optionalDependencies') delete target.optionalDependencies
+    else delete target.peerDependencies
+  }
+  if (source.peerDependenciesMeta === undefined) delete target.peerDependenciesMeta
+  else target.peerDependenciesMeta = structuredClone(source.peerDependenciesMeta) as Record<string, { optional?: boolean }>
+}
+
+function currentVersion(pkg: WorkspacePackageManifest): string {
+  const version = pkg.manifest.version
+  if (typeof version !== 'string') throw new Error(`${pkg.manifestPath}: missing package version`)
+  return version
+}
+
+/** Find reachable Host candidates whose published manifests still carry non-Cordis peers. */
+export function discoverBenchmarkCandidates(
+  index: RegistryIndex,
+  workspaceVersions: ReadonlyMap<string, string>,
+  releasePackages: ReadonlyMap<string, WorkspacePackageManifest>,
+  policyPackages: ReadonlySet<string>,
+): string[] {
+  const reached = new Set<string>()
+  const queue = [TARGET_PACKAGE]
+  for (let cursor = 0; cursor < queue.length; cursor += 1) {
+    const name = queue[cursor]
+    if (name === undefined || reached.has(name)) continue
+    const version = workspaceVersions.get(name)
+    const manifest = version === undefined ? undefined : index.get(name)?.get(version)
+    if (manifest === undefined) continue
+    reached.add(name)
+    const installed = {
+      ...manifest.dependencies,
+      ...manifest.optionalDependencies,
+      ...Object.fromEntries(Object.entries(manifest.peerDependencies ?? {})
+        .filter(([peer]) => (manifest.peerDependenciesMeta?.[peer] as { optional?: boolean } | undefined)?.optional !== true)),
+    }
+    for (const dependency of Object.keys(installed).sort()) {
+      if (!reached.has(dependency)) queue.push(dependency)
+    }
+  }
+  return [...reached].filter((name) => {
+    if (policyPackages.has(name) || !releasePackages.has(name)) return false
+    const version = workspaceVersions.get(name)
+    const manifest = version === undefined ? undefined : index.get(name)?.get(version)
+    return Object.keys(manifest?.peerDependencies ?? {}).some(peer => peer !== CORDIS)
+  }).sort()
+}
+
+async function measure(
+  index: RegistryIndex,
+  targetVersion: string,
+  runs: number,
+  timeoutMs: number,
+): Promise<number[]> {
+  const seconds: number[] = []
+  for (let run = 0; run < runs; run += 1) {
+    const result = await benchmarkNpmResolution(index, targetVersion, timeoutMs)
+    if (result.archiveRequests > 0) throw new Error('metadata-only benchmark requested package archives')
+    seconds.push(Number((result.durationMs / 1000).toFixed(2)))
+  }
+  return seconds
+}
+
+async function mapConcurrent<T, R>(
+  values: readonly T[],
+  jobs: number,
+  operation: (value: T) => Promise<R>,
+): Promise<R[]> {
+  const results: R[] = []
+  let next = 0
+  await Promise.all(Array.from({ length: Math.min(jobs, values.length) }, async () => {
+    while (next < values.length) {
+      const index = next
+      next += 1
+      const value = values[index]
+      if (value === undefined) return
+      results[index] = await operation(value)
+    }
+  }))
+  return results
+}
+
+async function main(): Promise<void> {
+  const options = parseNextPackageBenchmarkOptions(process.argv.slice(2))
+  const root = resolve(import.meta.dirname, '..')
+  const packages = readWorkspacePackageManifests(root)
+  const workspaceVersions = new Map(packages.all.map(pkg => [pkg.name, currentVersion(pkg)]))
+  const releaseByName = new Map(packages.release.map(pkg => [pkg.name, pkg]))
+  const state = readPackageDependencyState(root)
+  if (state.policyViolations.length > 0) throw new Error(state.policyViolations.join('\n'))
+  const base = cloneIndex(buildRegistryIndex(root))
+  for (const facts of state.facts) applyFactsToRegistry(base, facts, workspaceVersions)
+  const targetVersion = workspaceVersions.get(TARGET_PACKAGE)
+  if (targetVersion === undefined) throw new Error(`workspace has no ${TARGET_PACKAGE}`)
+  const policyNames = new Set(state.facts.map(facts => facts.manifest.name).filter(name => name !== undefined))
+  const discovered = discoverBenchmarkCandidates(base, workspaceVersions, releaseByName, policyNames)
+  const candidates = options.candidates ?? discovered
+  for (const name of candidates) {
+    if (!discovered.includes(name)) throw new Error(`${name} is not a reachable unconfigured Host candidate`)
+  }
+  const candidateFacts = new Map(candidates.map((name) => {
+    const pkg = releaseByName.get(name)
+    if (pkg === undefined) throw new Error(`release set has no ${name}`)
+    return [name, readPackageDependencyFacts(root, pkg, 'configured-host', state.workspaceNames)]
+  }))
+
+  const baselineSeconds = await measure(base, targetVersion, options.finalistRuns, options.timeoutMs)
+  const baseline = median(baselineSeconds)
+  console.log(JSON.stringify({ type: 'baseline', seconds: baselineSeconds, medianSeconds: baseline }))
+
+  const coarse = await mapConcurrent(candidates, options.jobs, async (name): Promise<Measurement> => {
+    const index = cloneIndex(base)
+    const facts = candidateFacts.get(name)
+    if (facts === undefined) throw new Error(`missing source facts for ${name}`)
+    applyFactsToRegistry(index, facts, workspaceVersions)
+    const seconds = await measure(index, targetVersion, options.coarseRuns, options.timeoutMs)
+    const result = { package: name, seconds, medianSeconds: median(seconds) }
+    console.log(JSON.stringify({ type: 'coarse', ...result }))
+    return result
+  })
+  const finalists = coarse.sort((left, right) => left.medianSeconds - right.medianSeconds)
+    .slice(0, options.finalists)
+  const measured: Measurement[] = []
+  for (const finalist of finalists) {
+    const index = cloneIndex(base)
+    const facts = candidateFacts.get(finalist.package)
+    if (facts === undefined) throw new Error(`missing source facts for ${finalist.package}`)
+    applyFactsToRegistry(index, facts, workspaceVersions)
+    const seconds = await measure(index, targetVersion, options.finalistRuns, options.timeoutMs)
+    measured.push({ package: finalist.package, seconds, medianSeconds: median(seconds) })
+  }
+  const ranking = measured.sort((left, right) => left.medianSeconds - right.medianSeconds)
+    .map(result => ({
+      ...result,
+      gainSeconds: Number((baseline - result.medianSeconds).toFixed(2)),
+    }))
+  console.log(JSON.stringify({
+    type: 'result',
+    baselineSeconds,
+    baselineMedianSeconds: baseline,
+    candidateCount: candidates.length,
+    ranking,
+  }, null, 2))
+}
+
+if (import.meta.main) {
+  try {
+    await main()
+  } catch (error) {
+    console.error(`benchmark-next-package-dependency: ${error instanceof Error ? error.message : String(error)}`)
+    process.exitCode = 1
+  }
+}

+ 84 - 0
scripts/benchmark-npm-resolution.spec.ts

@@ -0,0 +1,84 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+  benchmarkNpmResolution,
+  buildRegistryIndex,
+  parseBenchmarkOptions,
+  publishWorkspaceRange,
+  type RegistryIndex,
+} from './benchmark-npm-resolution.ts'
+
+const roots: string[] = []
+
+afterEach(() => {
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+function writeJson(root: string, path: string, value: unknown): void {
+  const absolute = join(root, path)
+  mkdirSync(dirname(absolute), { recursive: true })
+  writeFileSync(absolute, `${JSON.stringify(value, null, 2)}\n`)
+}
+
+describe('npm resolution benchmark', () => {
+  it('parses repeat, timeout, threshold, and ref options', () => {
+    expect(parseBenchmarkOptions([])).toEqual({ runs: 1, timeoutMs: 300_000 })
+    expect(parseBenchmarkOptions([
+      '--runs', '3', '--timeout-ms', '45000', '--max-ms', '20000', '--ref', 'master',
+    ])).toEqual({ runs: 3, timeoutMs: 45_000, maxMs: 20_000, ref: 'master' })
+    expect(parseBenchmarkOptions(['--', '--runs', '2'])).toEqual({ runs: 2, timeoutMs: 300_000 })
+    expect(() => parseBenchmarkOptions(['--runs', '0'])).toThrow('--runs must be a positive integer')
+  })
+
+  it('projects workspace protocols to published ranges', () => {
+    expect(publishWorkspaceRange('workspace:^', '1.2.3')).toBe('^1.2.3')
+    expect(publishWorkspaceRange('workspace:~', '1.2.3')).toBe('~1.2.3')
+    expect(publishWorkspaceRange('workspace:*', '1.2.3')).toBe('1.2.3')
+    expect(publishWorkspaceRange('^4.0.0', '1.2.3')).toBe('^4.0.0')
+  })
+
+  it('combines installed metadata with current publishable workspace fields', () => {
+    const root = mkdtempSync(join(tmpdir(), 'dsh-npm-registry-index-'))
+    roots.push(root)
+    writeJson(root, 'node_modules/.pnpm/external@2.0.0/node_modules/external/package.json', {
+      name: 'external',
+      version: '2.0.0',
+      dependencies: { child: '^1.0.0' },
+      devDependencies: { ignored: '^1.0.0' },
+    })
+    writeJson(root, 'apps/cli/package.json', {
+      name: '@deepseek-ai/dsh',
+      version: '0.1.0',
+      dependencies: { '@deepseek-ai/dsh-child': 'workspace:^', external: '^2.0.0' },
+      devDependencies: { ignored: 'workspace:^' },
+    })
+    writeJson(root, 'packages/core/child/package.json', {
+      name: '@deepseek-ai/dsh-child',
+      version: '0.1.0',
+    })
+
+    const index = buildRegistryIndex(root)
+
+    expect(index.get('external')?.get('2.0.0')).toMatchObject({ dependencies: { child: '^1.0.0' } })
+    expect(index.get('@deepseek-ai/dsh')?.get('0.1.0')).toEqual({
+      name: '@deepseek-ai/dsh',
+      version: '0.1.0',
+      dependencies: { '@deepseek-ai/dsh-child': '^0.1.0', external: '^2.0.0' },
+    })
+  })
+
+  it('runs npm against the local registry without requesting an archive', async () => {
+    const index: RegistryIndex = new Map([[
+      '@deepseek-ai/dsh',
+      new Map([['0.1.0', { name: '@deepseek-ai/dsh', version: '0.1.0' }]]),
+    ]])
+    const result = await benchmarkNpmResolution(index, '0.1.0', 10_000)
+
+    expect(result.durationMs).toBeGreaterThan(0)
+    expect(result.registryRequests).toBeGreaterThan(0)
+    expect(result.archiveRequests).toBe(0)
+    expect(result.unknownPackages).toEqual([])
+  })
+})

+ 417 - 0
scripts/benchmark-npm-resolution.ts

@@ -0,0 +1,417 @@
+/** Benchmark npm's dependency-tree resolution against an all-local registry. */
+
+import { execFileSync, spawn } 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'
+import { join, resolve } from 'node:path'
+import { performance } from 'node:perf_hooks'
+import { parseArgs } from 'node:util'
+
+const TARGET_PACKAGE = '@deepseek-ai/dsh'
+const DEFAULT_TIMEOUT_MS = 300_000
+const WORKSPACE_MANIFEST_GLOBS = [
+  'apps/*/package.json',
+  'packages/*/*/package.json',
+  'vendor/*/package.json',
+  'native/landlock-run/package.json',
+  'native/landlock-run/packages/*/package.json',
+]
+const INSTALLED_MANIFEST_GLOBS = [
+  'node_modules/.pnpm/*/node_modules/*/package.json',
+  'node_modules/.pnpm/*/node_modules/@*/*/package.json',
+]
+const PUBLISHED_FIELDS = [
+  'dependencies',
+  'optionalDependencies',
+  'peerDependencies',
+  'peerDependenciesMeta',
+  'engines',
+  'os',
+  'cpu',
+  'bin',
+] as const
+
+interface PackageManifest {
+  readonly name?: unknown
+  readonly version?: unknown
+  readonly dependencies?: Record<string, string>
+  readonly optionalDependencies?: Record<string, string>
+  readonly peerDependencies?: Record<string, string>
+  readonly peerDependenciesMeta?: Record<string, unknown>
+  readonly engines?: unknown
+  readonly os?: unknown
+  readonly cpu?: unknown
+  readonly bin?: unknown
+}
+
+interface RegistryVersion extends PackageManifest {
+  readonly name: string
+  readonly version: string
+}
+
+/** Package versions served by the local benchmark registry. */
+export type RegistryIndex = ReadonlyMap<string, ReadonlyMap<string, RegistryVersion>>
+
+/** Parsed command-line options for one benchmark invocation. */
+export interface BenchmarkOptions {
+  readonly ref?: string
+  readonly runs: number
+  readonly timeoutMs: number
+  readonly maxMs?: number
+}
+
+/** One measured npm resolution. */
+export interface BenchmarkRun {
+  readonly durationMs: number
+  readonly registryRequests: number
+  readonly archiveRequests: number
+  readonly unknownPackages: readonly string[]
+}
+
+/** Parse one positive-integer command-line option or use its default. */
+export function parsePositiveIntegerOption(raw: string | undefined, fallback: number, name: string): number {
+  if (raw === undefined) return fallback
+  const value = Number.parseInt(raw, 10)
+  if (!Number.isSafeInteger(value) || value < 1 || String(value) !== raw) {
+    throw new Error(`${name} must be a positive integer, got ${JSON.stringify(raw)}`)
+  }
+  return value
+}
+
+/**
+ * Parse supported benchmark arguments.
+ * @param args - Command-line arguments after the script path.
+ * @returns Validated benchmark options.
+ */
+export function parseBenchmarkOptions(args: readonly string[]): BenchmarkOptions {
+  const normalized = args[0] === '--' ? args.slice(1) : args
+  const { values } = parseArgs({
+    args: [...normalized],
+    options: {
+      ref: { type: 'string' },
+      runs: { type: 'string' },
+      'timeout-ms': { type: 'string' },
+      'max-ms': { type: 'string' },
+    },
+    allowPositionals: false,
+  })
+  const maxMs = values['max-ms'] === undefined
+    ? undefined
+    : parsePositiveIntegerOption(values['max-ms'], 0, '--max-ms')
+  return {
+    runs: parsePositiveIntegerOption(values.runs, 1, '--runs'),
+    timeoutMs: parsePositiveIntegerOption(values['timeout-ms'], DEFAULT_TIMEOUT_MS, '--timeout-ms'),
+    ...(values.ref === undefined ? {} : { ref: values.ref }),
+    ...(maxMs === undefined ? {} : { maxMs }),
+  }
+}
+
+function workspaceManifestPath(path: string): boolean {
+  return /^(?:apps\/[^/]+|packages\/[^/]+\/[^/]+|vendor\/[^/]+|native\/landlock-run(?:\/packages\/[^/]+)?)\/package\.json$/.test(path)
+}
+
+function workspaceManifestPaths(root: string, ref: string | undefined): string[] {
+  if (ref === undefined) return globSync(WORKSPACE_MANIFEST_GLOBS, { cwd: root }).sort()
+  return execFileSync('git', ['ls-tree', '-r', '--name-only', ref, '--', 'apps', 'packages', 'vendor', 'native'], {
+    cwd: root,
+    encoding: 'utf8',
+  }).split('\n').filter(workspaceManifestPath).sort()
+}
+
+function readGitFiles(root: string, ref: string, paths: readonly string[]): ReadonlyMap<string, string> {
+  const output = execFileSync('git', ['cat-file', '--batch'], {
+    cwd: root,
+    input: paths.map(path => `${ref}:${path}\n`).join(''),
+    maxBuffer: 64 * 1024 * 1024,
+  })
+  const contents = new Map<string, string>()
+  let offset = 0
+  for (const path of paths) {
+    const headerEnd = output.indexOf(0x0a, offset)
+    if (headerEnd < 0) throw new Error(`git cat-file returned no header for ${ref}:${path}`)
+    const header = output.subarray(offset, headerEnd).toString('utf8')
+    if (header.endsWith(' missing')) throw new Error(`git ref ${ref} has no ${path}`)
+    const size = Number.parseInt(header.split(' ')[2] ?? '', 10)
+    if (!Number.isSafeInteger(size) || size < 0) {
+      throw new Error(`git cat-file returned an invalid size for ${ref}:${path}`)
+    }
+    const contentStart = headerEnd + 1
+    const contentEnd = contentStart + size
+    if (output[contentEnd] !== 0x0a) throw new Error(`git cat-file truncated ${ref}:${path}`)
+    contents.set(path, output.subarray(contentStart, contentEnd).toString('utf8'))
+    offset = contentEnd + 1
+  }
+  return contents
+}
+
+/**
+ * Convert a workspace protocol range to the range published by pnpm pack.
+ * @param range - Dependency range from a workspace manifest.
+ * @param targetVersion - Current version of the referenced workspace package.
+ * @returns The registry-facing semver range.
+ */
+export function publishWorkspaceRange(range: string, targetVersion: string): string {
+  if (range === 'workspace:*') return targetVersion
+  if (range === 'workspace:^') return `^${targetVersion}`
+  if (range === 'workspace:~') return `~${targetVersion}`
+  if (range.startsWith('workspace:')) return range.slice('workspace:'.length)
+  return range
+}
+
+function copyPublishedManifest(
+  source: PackageManifest,
+  workspaceVersions: ReadonlyMap<string, string>,
+): RegistryVersion | undefined {
+  if (typeof source.name !== 'string' || typeof source.version !== 'string') return undefined
+  const output: Record<string, unknown> = { name: source.name, version: source.version }
+  for (const field of PUBLISHED_FIELDS) {
+    const value = source[field]
+    if (value === undefined) continue
+    if (field === 'dependencies' || field === 'optionalDependencies' || field === 'peerDependencies') {
+      output[field] = Object.fromEntries(Object.entries(value as Record<string, string>).map(([name, range]) => {
+        const targetVersion = workspaceVersions.get(name)
+        return [name, targetVersion === undefined ? range : publishWorkspaceRange(range, targetVersion)]
+      }))
+    } else {
+      output[field] = structuredClone(value)
+    }
+  }
+  return output as unknown as RegistryVersion
+}
+
+function addManifest(index: Map<string, Map<string, RegistryVersion>>, manifest: RegistryVersion): void {
+  const versions = index.get(manifest.name) ?? new Map<string, RegistryVersion>()
+  versions.set(manifest.version, manifest)
+  index.set(manifest.name, versions)
+}
+
+/**
+ * Build registry metadata from installed external packages and workspace manifests.
+ * @param root - Repository root containing the pnpm virtual store.
+ * @param ref - Optional Git ref used instead of working-tree workspace manifests.
+ * @returns Package metadata served by the benchmark registry.
+ */
+export function buildRegistryIndex(root: string, ref?: string): RegistryIndex {
+  const index = new Map<string, Map<string, RegistryVersion>>()
+  for (const path of globSync(INSTALLED_MANIFEST_GLOBS, { cwd: root }).sort()) {
+    const manifest = JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest
+    const copied = copyPublishedManifest(manifest, new Map())
+    if (copied !== undefined) addManifest(index, copied)
+  }
+
+  const paths = workspaceManifestPaths(root, ref)
+  const refContents = ref === undefined ? undefined : readGitFiles(root, ref, paths)
+  const workspace = paths.map(path =>
+    JSON.parse(refContents?.get(path) ?? readFileSync(resolve(root, path), 'utf8')) as PackageManifest)
+  const workspaceVersions = new Map(workspace.flatMap(manifest =>
+    typeof manifest.name === 'string' && typeof manifest.version === 'string'
+      ? [[manifest.name, manifest.version] as const]
+      : []))
+  for (const manifest of workspace) {
+    const copied = copyPublishedManifest(manifest, workspaceVersions)
+    if (copied !== undefined) addManifest(index, copied)
+  }
+  return index
+}
+
+function latestVersion(versions: ReadonlyMap<string, RegistryVersion>): string {
+  const sorted = [...versions.keys()].sort((left, right) => left.localeCompare(right, 'en', { numeric: true }))
+  const latest = sorted.at(-1)
+  if (latest === undefined) throw new Error('local registry package has no versions')
+  return latest
+}
+
+function listen(server: Server): Promise<number> {
+  return new Promise((resolveListen, reject) => {
+    server.once('error', reject)
+    server.listen(0, '127.0.0.1', () => {
+      server.off('error', reject)
+      const address = server.address()
+      if (address === null || typeof address === 'string') {
+        reject(new Error('local registry did not expose a TCP port'))
+        return
+      }
+      resolveListen(address.port)
+    })
+  })
+}
+
+function close(server: Server): Promise<void> {
+  return new Promise((resolveClose, reject) => {
+    server.close((error) => {
+      if (error === undefined) resolveClose()
+      else reject(error)
+    })
+  })
+}
+
+function npmExecutable(): string {
+  return process.platform === 'win32' ? 'npm.cmd' : 'npm'
+}
+
+async function runNpm(
+  cwd: string,
+  registry: string,
+  timeoutMs: number,
+): Promise<{ durationMs: number; output: string; timedOut: boolean }> {
+  const started = performance.now()
+  const child = spawn(npmExecutable(), [
+    'install',
+    '--package-lock-only',
+    '--ignore-scripts',
+    '--no-audit',
+    '--no-fund',
+    '--loglevel=error',
+    `--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,
+      npm_config_cache: join(cwd, '.npm-cache'),
+      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 })
+    })
+  })
+  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()}`)
+  }
+  return { durationMs, output, timedOut: false }
+}
+
+/**
+ * Resolve the CLI install graph once without downloading package archives.
+ * @param index - Package metadata exposed through the local registry.
+ * @param targetVersion - Version of `@deepseek-ai/dsh` to install.
+ * @param timeoutMs - Hard wall-clock limit for the npm child process.
+ * @returns Timing and registry-request observations.
+ */
+export async function benchmarkNpmResolution(
+  index: RegistryIndex,
+  targetVersion: string,
+  timeoutMs: number,
+): Promise<BenchmarkRun> {
+  let registryRequests = 0
+  let archiveRequests = 0
+  const unknownPackages = new Set<string>()
+  let registry = ''
+  const server = createServer((request, response) => {
+    registryRequests++
+    const pathname = new URL(request.url ?? '/', registry).pathname
+    if (pathname.startsWith('/tarballs/')) {
+      archiveRequests++
+      response.writeHead(500, { 'content-type': 'application/json' })
+      response.end(JSON.stringify({ error: 'package-lock-only benchmark requested an archive' }))
+      return
+    }
+    const name = decodeURIComponent(pathname.slice(1))
+    const versions = index.get(name)
+    if (versions === undefined) {
+      unknownPackages.add(name)
+      response.writeHead(404, { 'content-type': 'application/json' })
+      response.end(JSON.stringify({ error: 'not_found' }))
+      return
+    }
+    const materialized = Object.fromEntries([...versions].map(([version, manifest]) => [version, {
+      ...manifest,
+      dist: { tarball: `${registry}tarballs/${encodeURIComponent(name)}-${version}.tgz` },
+    }]))
+    const body = JSON.stringify({
+      name,
+      'dist-tags': { latest: latestVersion(versions) },
+      versions: materialized,
+    })
+    response.writeHead(200, {
+      'content-type': 'application/json',
+      'content-length': Buffer.byteLength(body),
+    })
+    response.end(body)
+  })
+  const port = await listen(server)
+  registry = `http://127.0.0.1:${String(port)}/`
+  const consumer = mkdtempSync(join(tmpdir(), 'dsh-npm-resolution-'))
+  try {
+    writeFileSync(join(consumer, 'package.json'), `${JSON.stringify({
+      name: 'dsh-npm-resolution-benchmark',
+      version: '0.0.0',
+      private: true,
+      dependencies: { [TARGET_PACKAGE]: targetVersion },
+    }, null, 2)}\n`)
+    const result = await runNpm(consumer, registry, timeoutMs)
+    if (result.timedOut) throw new Error(`npm resolution exceeded ${String(timeoutMs)} ms`)
+    return {
+      durationMs: result.durationMs,
+      registryRequests,
+      archiveRequests,
+      unknownPackages: [...unknownPackages].sort(),
+    }
+  } finally {
+    await close(server)
+    rmSync(consumer, { recursive: true, force: true })
+  }
+}
+
+async function main(): Promise<void> {
+  const options = parseBenchmarkOptions(process.argv.slice(2))
+  const root = resolve(import.meta.dirname, '..')
+  const started = performance.now()
+  const index = buildRegistryIndex(root, options.ref)
+  const targetVersions = index.get(TARGET_PACKAGE)
+  if (targetVersions === undefined) throw new Error(`local registry contains no ${TARGET_PACKAGE}`)
+  const targetVersion = latestVersion(targetVersions)
+  const npmVersion = execFileSync(npmExecutable(), ['--version'], { encoding: 'utf8' }).trim()
+  console.log(
+    `benchmark-npm-resolution: npm ${npmVersion}, ${options.ref === undefined ? 'working tree' : options.ref}, `
+    + `${String(index.size)} package name(s), setup ${(performance.now() - started).toFixed(0)} ms.`,
+  )
+  const durations: number[] = []
+  for (let run = 1; run <= options.runs; run++) {
+    const result = await benchmarkNpmResolution(index, targetVersion, options.timeoutMs)
+    durations.push(result.durationMs)
+    console.log(
+      `benchmark-npm-resolution: run ${String(run)}/${String(options.runs)} resolved ${TARGET_PACKAGE}@${targetVersion}`
+      + ` in ${(result.durationMs / 1000).toFixed(2)} s with ${String(result.registryRequests)} metadata request(s)`
+      + ` and ${String(result.unknownPackages.length)} local 404 package name(s).`,
+    )
+    if (result.archiveRequests > 0) throw new Error('npm requested package archives during the metadata-only benchmark')
+  }
+  const minimum = Math.min(...durations)
+  const maximum = Math.max(...durations)
+  console.log(
+    `benchmark-npm-resolution: ${String(options.runs)} run(s), min ${(minimum / 1000).toFixed(2)} s, max ${(maximum / 1000).toFixed(2)} s.`,
+  )
+  if (options.maxMs !== undefined && maximum > options.maxMs) {
+    throw new Error(`npm resolution exceeded --max-ms=${String(options.maxMs)} (max ${maximum.toFixed(0)} ms)`)
+  }
+}
+
+if (import.meta.main) {
+  try {
+    await main()
+  } catch (error) {
+    console.error(`benchmark-npm-resolution: ${error instanceof Error ? error.message : String(error)}`)
+    process.exitCode = 1
+  }
+}

+ 52 - 0
scripts/package-dependency-policy.ts

@@ -0,0 +1,52 @@
+/** Explicit exceptions and Host packages for the published dependency policy. */
+
+/** Packages treated as Client/Host packages without declaring `dsh.client`. */
+const CLIENT_FACE_INCLUDE: readonly string[] = []
+
+/** Packages exempted from automatic Client/Host treatment despite declaring `dsh.client`. */
+const CLIENT_FACE_EXCLUDE: readonly string[] = [
+  '@deepseek-ai/dsh-api-session-controller',
+]
+
+/** Host-only packages whose peer relays are deliberately flattened. */
+const HOST_DEPENDENCY_PACKAGES: readonly string[] = [
+  '@deepseek-ai/dsh-llm',
+  '@deepseek-ai/dsh-session',
+]
+
+/** Complete configurable input to package dependency classification. */
+export interface PackageDependencyPolicy {
+  readonly clientFaceInclude: readonly string[]
+  readonly clientFaceExclude: readonly string[]
+  readonly hostPackages: readonly string[]
+}
+
+/** Repository dependency policy consumed by verification and benchmarking. */
+export const PACKAGE_DEPENDENCY_POLICY: PackageDependencyPolicy = {
+  clientFaceInclude: CLIENT_FACE_INCLUDE,
+  clientFaceExclude: CLIENT_FACE_EXCLUDE,
+  hostPackages: HOST_DEPENDENCY_PACKAGES,
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+/** Whether a package manifest declares a dynamically loaded Client entry. */
+export function hasClientDeclaration(dshField: unknown): boolean {
+  return isRecord(dshField) && Object.hasOwn(dshField, 'client')
+}
+
+/** Whether the repository policy flattens one package's non-Cordis peers. */
+export function usesFlattenedPackageDependencies(
+  manifestPath: string,
+  packageName: string,
+  dshField: unknown,
+  policy: PackageDependencyPolicy = PACKAGE_DEPENDENCY_POLICY,
+): boolean {
+  if (!manifestPath.startsWith('packages/') || manifestPath.startsWith('packages/experimental/')) return false
+  if (policy.hostPackages.includes(packageName)) return true
+  if (manifestPath.startsWith('packages/client/')) return true
+  const included = hasClientDeclaration(dshField) || policy.clientFaceInclude.includes(packageName)
+  return included && !policy.clientFaceExclude.includes(packageName)
+}

+ 54 - 8
scripts/package-invariants.spec.ts

@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'
 import {
   collectPackageInvariantViolations,
 } from './package-invariants.ts'
+import { usesFlattenedPackageDependencies } from './package-dependency-policy.ts'
 
 const roots: string[] = []
 
@@ -28,7 +29,10 @@ export const apply = (ctx: { invariants: { register(name: string, install: typeo
 
 function fixture(options: {
   packageName?: string
+  packageDirectory?: string
   source?: string
+  clientDeclaration?: boolean
+  clientExport?: boolean
   invariantExport?: boolean
   invariantDependency?: boolean
   invariantReference?: boolean
@@ -36,19 +40,34 @@ function fixture(options: {
 } = {}): string {
   const root = mkdtempSync(join(tmpdir(), 'dsh-package-invariants-'))
   roots.push(root)
-  const dir = join(root, 'packages/core/probe')
+  const packageDirectory = options.packageDirectory ?? 'packages/core/probe'
+  const dir = join(root, packageDirectory)
   mkdirSync(join(dir, 'src'), { recursive: true })
   const packageName = options.packageName ?? '@deepseek-ai/dsh-probe'
+  const exports = options.invariantExport === false ? {} : {
+    './invariant': {
+      types: './lib/types/invariant.d.ts',
+      default: './lib/invariant.js',
+    },
+    ...(options.clientExport === true ? {
+      './client': {
+        types: './lib/types/client/index.d.ts',
+        default: './lib/client.js',
+      },
+    } : {}),
+  }
+  const dsh = options.clientDeclaration === true ? { client: {} } : undefined
+  const developmentOnlyInvariant = usesFlattenedPackageDependencies(
+    `${packageDirectory}/package.json`,
+    packageName,
+    dsh,
+  )
   const manifest = {
     name: packageName,
-    exports: options.invariantExport === false ? {} : {
-      './invariant': {
-        types: './lib/types/invariant.d.ts',
-        default: './lib/invariant.js',
-      },
-    },
+    ...(dsh === undefined ? {} : { dsh }),
+    exports,
     files: ['lib/index.js', 'lib/invariant.js'],
-    peerDependencies: options.invariantDependency === false ? {} : {
+    peerDependencies: options.invariantDependency === false || developmentOnlyInvariant ? {} : {
       '@deepseek-ai/dsh-invariants': 'workspace:^',
     },
     devDependencies: options.invariantDependency === false ? {} : {
@@ -72,6 +91,33 @@ describe('package invariant gate', () => {
     expect(collectPackageInvariantViolations(fixture())).toEqual([])
   })
 
+  it('accepts development-only invariants for configured Host dependencies', () => {
+    expect(collectPackageInvariantViolations(fixture({ packageName: '@deepseek-ai/dsh-llm' }))).toEqual([])
+  })
+
+  it('accepts development-only invariants for client packages', () => {
+    expect(collectPackageInvariantViolations(fixture({
+      packageName: '@deepseek-ai/dsh-client-probe',
+      packageDirectory: 'packages/client/probe',
+    }))).toEqual([])
+  })
+
+  it('accepts development-only invariants for packages with a dsh.client entry', () => {
+    expect(collectPackageInvariantViolations(fixture({ clientDeclaration: true, clientExport: true }))).toEqual([])
+  })
+
+  it('keeps invariant peers for packages that only export a Client API', () => {
+    expect(collectPackageInvariantViolations(fixture({ clientExport: true }))).toEqual([])
+  })
+
+  it('keeps invariant peers for experimental packages with a dsh.client entry', () => {
+    expect(collectPackageInvariantViolations(fixture({
+      packageDirectory: 'packages/experimental/probe',
+      clientDeclaration: true,
+      clientExport: true,
+    }))).toEqual([])
+  })
+
   it('accepts an invariant reference owned by a package-local leaf project', () => {
     const root = fixture({ invariantReference: false })
     const dir = join(root, 'packages/core/probe')

+ 15 - 8
scripts/package-invariants.ts

@@ -7,12 +7,14 @@
 import { existsSync, globSync, readFileSync } from 'node:fs'
 import { dirname, relative, resolve, sep } from 'node:path'
 import ts from 'typescript'
+import { usesFlattenedPackageDependencies } from './package-dependency-policy.ts'
 
 /** Required explanation marker for an intentionally empty installer. */
 const NO_RUNTIME_INVARIANT_MARKER = 'No runtime invariant:'
 
 interface PackageManifest {
   name?: string
+  dsh?: unknown
   exports?: Record<string, { types?: string; default?: string } | string | undefined>
   files?: string[]
   peerDependencies?: Record<string, string>
@@ -96,18 +98,23 @@ function checkManifest(
     addViolation(violations, owner.manifestPath, 'files must publish lib/invariant.js')
   }
   if (owner.packageName === '@deepseek-ai/dsh-invariants') return
-  if (manifest.peerDependencies?.['@deepseek-ai/dsh-invariants'] !== 'workspace:^') {
-    addViolation(
-      violations,
-      owner.manifestPath,
-      '@deepseek-ai/dsh-invariants must be a workspace:^ peerDependency',
-    )
+  const developmentOnlyInvariant = usesFlattenedPackageDependencies(
+    owner.manifestPath,
+    owner.packageName,
+    manifest.dsh,
+  )
+  const expectedRange = 'workspace:^'
+  const peerRange = manifest.peerDependencies?.['@deepseek-ai/dsh-invariants']
+  if (developmentOnlyInvariant ? peerRange !== undefined : peerRange !== expectedRange) {
+    addViolation(violations, owner.manifestPath, developmentOnlyInvariant
+      ? '@deepseek-ai/dsh-invariants must not be a peerDependency under this package dependency policy'
+      : '@deepseek-ai/dsh-invariants must be a workspace:^ peerDependency')
   }
-  if (manifest.devDependencies?.['@deepseek-ai/dsh-invariants'] !== 'workspace:^') {
+  if (manifest.devDependencies?.['@deepseek-ai/dsh-invariants'] !== expectedRange) {
     addViolation(
       violations,
       owner.manifestPath,
-      '@deepseek-ai/dsh-invariants must also be a workspace:^ devDependency',
+      `@deepseek-ai/dsh-invariants must be a ${expectedRange} devDependency`,
     )
   }
 }

+ 10 - 1
scripts/run-gates.spec.ts

@@ -102,7 +102,7 @@ describe('gate graph validation', () => {
     const ids = withPnpmEntrypoint(() => gatesForMode('hygiene').map(subject => subject.id))
 
     expect(ids).toEqual([
-      'rescope-vendor', 'publint', 'constraints', 'application-entrypoints',
+      'rescope-vendor', 'publint', 'constraints', 'package-dependencies', 'application-entrypoints',
       'dsh-package-licenses', 'package-invariants', 'built-package-invariants', 'node-next-types',
       'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'cordis-config',
       'runtime-closure', 'vendored-links',
@@ -141,6 +141,15 @@ describe('gate graph validation', () => {
     },
   )
 
+  it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
+    'keeps package dependency enforcement in %s',
+    (mode) => {
+      const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
+
+      expect(ids).toContain('package-dependencies')
+    },
+  )
+
   it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
     'keeps the client dependency policy in %s',
     (mode) => {

+ 2 - 0
scripts/run-gates.ts

@@ -278,6 +278,7 @@ function ciSharedStaticGates(): Gate[] {
     pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
     pnpmScript('application-entrypoints', 'verify-application-entrypoints', { label: 'application entrypoints' }),
     pnpmScript('constraints', 'constraints'),
+    pnpmScript('package-dependencies', 'verify-package-dependencies', { label: 'package dependencies' }),
     pnpmScript('dsh-package-licenses', 'verify-dsh-package-licenses', { label: 'DSH package licenses' }),
     pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
     pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
@@ -667,6 +668,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
     pnpmScript('rescope-vendor', 'rescope-vendor:check', { label: 'vendor rescope' }),
     pnpmScript('publint', 'publint', artifactOptions),
     pnpmScript('constraints', 'constraints'),
+    pnpmScript('package-dependencies', 'verify-package-dependencies', { label: 'package dependencies' }),
     pnpmScript('application-entrypoints', 'verify-application-entrypoints', { label: 'application entrypoints' }),
     pnpmScript('dsh-package-licenses', 'verify-dsh-package-licenses', { label: 'DSH package licenses' }),
     pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),

+ 19 - 143
scripts/verify-client-packages.spec.ts

@@ -1,4 +1,4 @@
-/** Tests for client package modes, dependency sections, and module requests. */
+/** Tests for client package modes and module requests. */
 
 import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
@@ -6,6 +6,7 @@ import { dirname, join } from 'node:path'
 import { afterEach, describe, expect, it } from 'vitest'
 import {
   collectClientPackageViolations,
+  collectLocalSourceSpecifiers,
   collectRuntimeSourcePackageUses,
   collectRuntimeSourceSpecifiers,
   collectSourcePackageUses,
@@ -106,6 +107,19 @@ describe('source package uses', () => {
       '@deepseek-ai/dsh-b/remote',
       'react',
     ])
+    expect([...collectLocalSourceSpecifiers('feature.ts', [
+      "import type { A } from './types.ts'",
+      "export { value } from './value.ts'",
+      "const load = () => import('./lazy.ts')",
+      "const legacy = require('./legacy.ts')",
+      "declare module './augmentation.ts' {}",
+      "import '@deepseek-ai/dsh-a'",
+    ].join('\n'))].sort()).toEqual([
+      './lazy.ts',
+      './legacy.ts',
+      './types.ts',
+      './value.ts',
+    ])
   })
 })
 
@@ -152,109 +166,6 @@ describe('package modes', () => {
   })
 })
 
-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 conversation = pkg('conversation', {
-      inject: ['@deepseek-ai/dsh-client-feature'],
-      sourceUses: {
-        '@deepseek-ai/dsh-agent': ['packages/client/conversation/src/index.ts'],
-        '@deepseek-ai/dsh-client-ui-slots': ['packages/client/conversation/src/client/slots.ts'],
-        react: ['packages/client/conversation/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, conversation], {
-      platformModules: ['react', slots.name],
-    }))).toEqual([])
-  })
-
-  it('rejects internal dependencies, static peers, and mismatched peer development ranges', () => {
-    const slots = pkg('ui-slots', { dynamic: false, staticLinked: true })
-    const subject = pkg('feature', {
-      sourceUses: {
-        '@deepseek-ai/dsh-agent': ['packages/client/feature/src/index.ts'],
-        [slots.name]: ['packages/client/feature/src/view.tsx'],
-      },
-      dependencies: { '@deepseek-ai/dsh-agent': 'workspace:^' },
-      peerDependencies: { [CORDIS]: 'workspace:^', [slots.name]: 'workspace:^' },
-      devDependencies: { [CORDIS]: 'workspace:^', [slots.name]: 'workspace:*' },
-    })
-    const found = collectClientPackageViolations(facts([slots, subject]))
-    expect(found).toHaveLength(2)
-    expect(found.join('\n')).toContain('peer-installed DSH relationship')
-    expect(found.join('\n')).toContain('static client input')
-  })
-
-  it('requires every peer to have the same development range', () => {
-    const subject = pkg('feature', {
-      peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/cordis-plugin-loader': 'workspace:^' },
-    })
-    expect(collectClientPackageViolations(facts([subject]))).toEqual([
-      'packages/client/feature/package.json: peerDependencies.@deepseek-ai/cordis-plugin-loader'
-      + ' is workspace:^, so devDependencies.@deepseek-ai/cordis-plugin-loader must use the same range;'
-      + ' found no declaration',
-    ])
-  })
-
-  it('requires statically linked third-party runtime imports in dependencies', () => {
-    const primitives = pkg('ui-primitives', {
-      dynamic: false,
-      staticLinked: true,
-      runtimeSourceUses: { shiki: ['packages/client/ui-primitives/src/highlight.ts'] },
-      devDependencies: { [CORDIS]: 'workspace:^', shiki: '^4.3.1' },
-    })
-    const found = collectClientPackageViolations(facts([primitives]))
-    expect(found).toHaveLength(1)
-    expect(found[0]).toContain('runtime import retained by a statically linked artifact')
-    expect(found[0]).toContain('declare it only in dependencies')
-
-    const valid = { ...primitives, dependencies: { shiki: '^4.3.1' }, devDependencies: { [CORDIS]: 'workspace:^' } }
-    expect(collectClientPackageViolations(facts([valid]))).toEqual([])
-  })
-
-  it('keeps the web shell runtime inputs development-only', () => {
-    const web = pkg('web', {
-      dynamic: false,
-      staticLinked: true,
-      runtimeSourceUses: {
-        '@deepseek-ai/cordis-plugin-loader': ['packages/client/web/src/boot.ts'],
-        react: ['packages/client/web/src/seed.ts'],
-      },
-      devDependencies: {
-        [CORDIS]: 'workspace:^',
-        '@deepseek-ai/cordis-plugin-loader': 'workspace:^',
-        react: '^18.2.0',
-      },
-    })
-    expect(collectClientPackageViolations(facts([web]))).toEqual([])
-  })
-
-  it('allows npm dependency cycles', () => {
-    const a = pkg('a', {
-      peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-b': 'workspace:^' },
-      devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-b': 'workspace:^' },
-    })
-    const b = pkg('b', {
-      peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-a': 'workspace:^' },
-      devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-a': 'workspace:^' },
-    })
-    expect(collectClientPackageViolations(facts([a, b]))).toEqual([])
-  })
-})
-
 describe('module requests', () => {
   it('rejects runtime requests from one client feature package to another dynamic row', () => {
     const ui = declaration('ui', {
@@ -378,7 +289,7 @@ describe('manifest declarations', () => {
     ])
   })
 
-  it('fixes unambiguous dependency sections and declaration entries', () => {
+  it('fixes malformed declaration entries without changing dependency sections', () => {
     const root = mkdtempSync(join(tmpdir(), 'client-packages-fix-'))
     roots.push(root)
     const subject = pkg('feature', {
@@ -426,43 +337,8 @@ describe('manifest declarations', () => {
       external: ['@deepseek-ai/dsh-missing'],
       inject: ['@deepseek-ai/dsh-agent'],
     })
-    expect(fixed.dependencies).toBeUndefined()
-    expect(fixed.peerDependencies).toEqual({
-      '@deepseek-ai/cordis-plugin-loader': 'workspace:^',
-      [CORDIS]: 'workspace:^',
-      '@deepseek-ai/dsh-agent': 'workspace:*',
-    })
-    expect(fixed.devDependencies).toEqual({
-      '@deepseek-ai/dsh-client-ui-slots': 'workspace:^',
-      [CORDIS]: 'workspace:^',
-      '@deepseek-ai/dsh-agent': 'workspace:*',
-      '@deepseek-ai/cordis-plugin-loader': 'workspace:^',
-    })
-  })
-
-  it('fixes a statically linked runtime import into dependencies', () => {
-    const root = mkdtempSync(join(tmpdir(), 'client-packages-static-fix-'))
-    roots.push(root)
-    const subject = pkg('ui-primitives', {
-      dynamic: false,
-      staticLinked: true,
-      runtimeSourceUses: { shiki: ['packages/client/ui-primitives/src/highlight.ts'] },
-      devDependencies: { [CORDIS]: 'workspace:^', shiki: '^4.3.1' },
-    })
-    mkdirSync(dirname(join(root, subject.manifest)), { recursive: true })
-    writeFileSync(join(root, subject.manifest), JSON.stringify({
-      name: subject.name,
-      peerDependencies: subject.peerDependencies,
-      devDependencies: subject.devDependencies,
-    }))
-    writeFileSync(join(root, 'package.json'), JSON.stringify({ private: true }))
-
-    expect(fixClientPackageManifests(root, facts([subject]))).toEqual([subject.manifest])
-    const fixed = JSON.parse(readFileSync(join(root, subject.manifest), 'utf8')) as {
-      dependencies: Record<string, string>
-      devDependencies: Record<string, string>
-    }
-    expect(fixed.dependencies).toEqual({ shiki: '^4.3.1' })
-    expect(fixed.devDependencies).toEqual({ [CORDIS]: 'workspace:^' })
+    expect(fixed.dependencies).toEqual(subject.dependencies)
+    expect(fixed.peerDependencies).toEqual(subject.peerDependencies)
+    expect(fixed.devDependencies).toEqual(subject.devDependencies)
   })
 })

+ 24 - 290
scripts/verify-client-packages.ts

@@ -1,6 +1,5 @@
 /**
- * Verify client package modes, npm dependency sections, and the synchronous
- * browser module-request graph.
+ * Verify client package modes and the synchronous browser module-request graph.
  */
 
 import { globSync, readFileSync, writeFileSync } from 'node:fs'
@@ -17,8 +16,6 @@ const PLATFORM_SOURCE = 'packages/client/web/src/platform.ts'
 const PARSER_PRELOAD_SOURCE = 'packages/client/modules/src/index.ts'
 const STATIC_PRESET_SOURCE = 'packages/client/tsdown.client.ts'
 const CORDIS = '@deepseek-ai/cordis'
-const DSH_PREFIX = '@deepseek-ai/dsh-'
-const CLIENT_WEB = '@deepseek-ai/dsh-client-web'
 
 /** One workspace package's browser-module declaration. */
 export interface ClientDeclaration {
@@ -92,6 +89,17 @@ export function collectRuntimeSourceSpecifiers(path: string, source: string): Se
   return collectSourceFileUses(sourceFile, true, 'specifier')
 }
 
+/**
+ * Collect relative module specifiers used to follow one source entry's local closure.
+ * @param path - File path used to select TypeScript's parser mode.
+ * @param source - Source text to inspect.
+ * @returns Relative imports, exports, requires, and import types.
+ */
+export function collectLocalSourceSpecifiers(path: string, source: string): Set<string> {
+  const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
+  return collectSourceFileUses(sourceFile, false, 'local')
+}
+
 function importCarriesRuntimeValue(node: ts.ImportDeclaration): boolean {
   const clause = node.importClause
   if (clause === undefined) return true
@@ -114,12 +122,17 @@ function exportCarriesRuntimeValue(node: ts.ExportDeclaration): boolean {
 function collectSourceFileUses(
   sourceFile: ts.SourceFile,
   runtimeOnly: boolean,
-  key: 'package' | 'specifier',
+  key: 'local' | 'package' | 'specifier',
 ): Set<string> {
   const uses = new Set<string>()
 
   const add = (specifier: ts.Expression | undefined): void => {
-    if (specifier === undefined || !ts.isStringLiteral(specifier) || !isBareSpecifier(specifier.text)) return
+    if (specifier === undefined || !ts.isStringLiteralLike(specifier)) return
+    if (key === 'local') {
+      if (specifier.text.startsWith('.')) uses.add(specifier.text)
+      return
+    }
+    if (!isBareSpecifier(specifier.text)) return
     uses.add(key === 'package' ? packageNameOf(specifier.text) : specifier.text)
   }
   const visit = (node: ts.Node): void => {
@@ -135,9 +148,10 @@ function collectSourceFileUses(
       && (node.expression.kind === ts.SyntaxKind.ImportKeyword
         || ts.isIdentifier(node.expression) && node.expression.text === 'require')) {
       add(node.arguments[0])
-    } else if (!runtimeOnly && ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) {
+    } else if (!runtimeOnly && key !== 'local' && ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) {
       add(node.name)
-    } else if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) {
+    } else if (key !== 'local'
+      && (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node))) {
       uses.add('react')
     }
     ts.forEachChild(node, visit)
@@ -170,7 +184,6 @@ export function collectClientPackageViolations(facts: ClientPackageFacts): strin
   return [
     ...facts.malformed,
     ...collectModeViolations(facts),
-    ...collectDependencyViolations(facts),
     ...collectModuleViolations(facts),
   ].sort((left, right) => left.localeCompare(right))
 }
@@ -181,10 +194,8 @@ interface ManifestDocument {
   changed: boolean
 }
 
-type DependencySection = 'dependencies' | 'peerDependencies' | 'devDependencies'
-
 /**
- * Repair manifest declarations whose intended result follows uniquely from the policy.
+ * Repair malformed or redundant `dsh.client` declaration entries.
  * @param root - Absolute repository root.
  * @param facts - Facts used by the verification pass.
  * @returns Repository-relative manifests written by the fixer.
@@ -217,53 +228,6 @@ export function fixClientPackageManifests(root: string, facts: ClientPackageFact
     ) || target.changed
   }
 
-  const staticInputs = new Set([
-    ...facts.staticLinkedPackages,
-    ...facts.platformModules.map(packageNameOf),
-  ])
-  staticInputs.delete(CORDIS)
-  const inferredRanges = dependencyRangeCandidates(root)
-  for (const pkg of facts.packages) {
-    const target = document(pkg.manifest)
-    const expected = expectedSections(pkg, staticInputs)
-    for (const [name, rule] of expected) {
-      const range = preferredRange(target.manifest, name, rule.kind, inferredRanges)
-      if (range === undefined) continue
-      target.changed = rule.kind === 'dependency'
-        ? ensureDependencyOnly(target.manifest, name, range) || target.changed
-        : rule.kind === 'dev'
-          ? ensureDevOnly(target.manifest, name, range) || target.changed
-          : ensurePeerDev(target.manifest, name, range) || target.changed
-    }
-
-    if (pkg.dynamic) {
-      const productionNames = new Set([
-        ...Object.keys(section(target.manifest, 'dependencies')),
-        ...Object.keys(section(target.manifest, 'peerDependencies')),
-      ])
-      for (const name of productionNames) {
-        if (expected.has(name)) continue
-        const range = preferredRange(
-          target.manifest,
-          name,
-          staticInputs.has(name) ? 'dev' : 'peer-dev',
-          inferredRanges,
-        )
-        if (range === undefined) continue
-        if (staticInputs.has(name)) {
-          target.changed = ensureDevOnly(target.manifest, name, range) || target.changed
-        } else if (section(target.manifest, 'dependencies')[name] !== undefined && isInternalDsh(name)) {
-          target.changed = ensurePeerDev(target.manifest, name, range) || target.changed
-        }
-      }
-    }
-
-    for (const [name, range] of Object.entries(section(target.manifest, 'peerDependencies'))) {
-      target.changed = setDependency(target.manifest, 'devDependencies', name, range) || target.changed
-    }
-    target.changed = deleteEmptySections(target.manifest) || target.changed
-  }
-
   const changed = [...documents.values()].filter(target => target.changed).sort((left, right) =>
     left.path.localeCompare(right.path))
   for (const target of changed) {
@@ -295,102 +259,6 @@ function normalizeClientArray(
   return true
 }
 
-function ensureDevOnly(manifest: Manifest, name: string, range: string): boolean {
-  let changed = deleteDependency(manifest, 'dependencies', name)
-  changed = deleteDependency(manifest, 'peerDependencies', name) || changed
-  return setDependency(manifest, 'devDependencies', name, range) || changed
-}
-
-function ensureDependencyOnly(manifest: Manifest, name: string, range: string): boolean {
-  let changed = deleteDependency(manifest, 'peerDependencies', name)
-  changed = deleteDependency(manifest, 'devDependencies', name) || changed
-  return setDependency(manifest, 'dependencies', name, range) || changed
-}
-
-function ensurePeerDev(manifest: Manifest, name: string, range: string): boolean {
-  let changed = deleteDependency(manifest, 'dependencies', name)
-  changed = setDependency(manifest, 'peerDependencies', name, range) || changed
-  return setDependency(manifest, 'devDependencies', name, range) || changed
-}
-
-function setDependency(manifest: Manifest, field: DependencySection, name: string, range: string): boolean {
-  const dependencies = mutableSection(manifest, field)
-  if (dependencies[name] === range) return false
-  dependencies[name] = range
-  return true
-}
-
-function deleteDependency(manifest: Manifest, field: DependencySection, name: string): boolean {
-  const dependencies = section(manifest, field)
-  if (dependencies[name] === undefined) return false
-  manifest[field] = Object.fromEntries(Object.entries(dependencies).filter(([key]) => key !== name))
-  return true
-}
-
-function deleteEmptySections(manifest: Manifest): boolean {
-  let changed = false
-  for (const field of ['dependencies', 'peerDependencies', 'devDependencies'] as const) {
-    if (manifest[field] === undefined || Object.keys(section(manifest, field)).length > 0) continue
-    if (field === 'dependencies') delete manifest.dependencies
-    else if (field === 'peerDependencies') delete manifest.peerDependencies
-    else delete manifest.devDependencies
-    changed = true
-  }
-  return changed
-}
-
-function preferredRange(
-  manifest: Manifest,
-  name: string,
-  kind: ExpectedRule['kind'],
-  inferred: ReadonlyMap<string, ReadonlySet<string>>,
-): string | undefined {
-  const order: readonly DependencySection[] = kind === 'dependency'
-    ? ['dependencies', 'devDependencies', 'peerDependencies']
-    : kind === 'dev'
-      ? ['devDependencies', 'peerDependencies', 'dependencies']
-      : ['peerDependencies', 'devDependencies', 'dependencies']
-  for (const field of order) {
-    const range = section(manifest, field)[name]
-    if (range !== undefined) return range
-  }
-  if (isInternalDsh(name)) return 'workspace:^'
-  const candidates = inferred.get(name)
-  return candidates?.size === 1 ? [...candidates][0] : undefined
-}
-
-function dependencyRangeCandidates(root: string): Map<string, Set<string>> {
-  const candidates = new Map<string, Set<string>>()
-  const paths = globSync([
-    'package.json',
-    ...MANIFEST_GLOBS,
-    'website/package.json',
-  ], { cwd: root }).map(normalizePath)
-  for (const path of new Set(paths)) {
-    const manifest = JSON.parse(readFileSync(resolve(root, path), 'utf8')) as Manifest
-    for (const field of ['dependencies', 'peerDependencies', 'devDependencies'] as const) {
-      for (const [name, range] of Object.entries(section(manifest, field))) {
-        const ranges = candidates.get(name) ?? new Set<string>()
-        ranges.add(range)
-        candidates.set(name, ranges)
-      }
-    }
-  }
-  return candidates
-}
-
-function section(manifest: Manifest, field: DependencySection): Record<string, string> {
-  return manifest[field] ?? {}
-}
-
-function mutableSection(manifest: Manifest, field: DependencySection): Record<string, string> {
-  const value = manifest[field]
-  if (value !== undefined) return value
-  const created: Record<string, string> = {}
-  manifest[field] = created
-  return created
-}
-
 function collectModeViolations(facts: ClientPackageFacts): string[] {
   const violations: string[] = []
   for (const pkg of facts.packages) {
@@ -435,114 +303,6 @@ function collectModeViolations(facts: ClientPackageFacts): string[] {
   return violations
 }
 
-interface ExpectedRule {
-  readonly kind: 'dependency' | 'dev' | 'peer-dev'
-  readonly origins: Set<string>
-}
-
-function collectDependencyViolations(facts: ClientPackageFacts): string[] {
-  const violations: string[] = []
-  const staticInputs = new Set([
-    ...facts.staticLinkedPackages,
-    ...facts.platformModules.map(packageNameOf),
-  ])
-  staticInputs.delete(CORDIS)
-
-  for (const pkg of [...facts.packages].sort((left, right) => left.manifest.localeCompare(right.manifest))) {
-    const expected = expectedSections(pkg, staticInputs)
-    for (const [name, rule] of [...expected].sort(([left], [right]) => left.localeCompare(right))) {
-      const actual = declaredSections(pkg, name)
-      if (rule.kind === 'dependency') {
-        if (actual.length === 1 && actual[0] === 'dependencies') continue
-        violations.push(
-          pkg.manifest + ': ' + name + ' (' + describeOrigins(rule.origins) + ') is a runtime import'
-          + ' retained by a statically linked artifact; declare it only in dependencies, found '
-          + describeSections(actual),
-        )
-        continue
-      }
-      if (rule.kind === 'dev') {
-        if (actual.length === 1 && actual[0] === 'devDependencies') continue
-        violations.push(
-          pkg.manifest + ': ' + name + ' (' + describeOrigins(rule.origins) + ') is a static client input;'
-          + ' declare it only in devDependencies, found ' + describeSections(actual),
-        )
-        continue
-      }
-
-      const peerRange = pkg.peerDependencies[name]
-      const devRange = pkg.devDependencies[name]
-      if (actual.length === 2
-        && actual.includes('peerDependencies')
-        && actual.includes('devDependencies')
-        && peerRange === devRange) continue
-      violations.push(
-        pkg.manifest + ': ' + name + ' (' + describeOrigins(rule.origins) + ')'
-        + ' is a peer-installed DSH relationship; declare it in peerDependencies and devDependencies'
-        + ' with matching ranges, not dependencies; found ' + describeSections(actual)
-        + describeRangeMismatch(peerRange, devRange),
-      )
-    }
-
-    for (const [name, peerRange] of Object.entries(pkg.peerDependencies).sort(([left], [right]) => left.localeCompare(right))) {
-      if (expected.has(name)) continue
-      const devRange = pkg.devDependencies[name]
-      if (devRange === peerRange) continue
-      violations.push(
-        pkg.manifest + ': peerDependencies.' + name + ' is ' + peerRange + ', so devDependencies.' + name
-        + ' must use the same range; found ' + (devRange ?? 'no declaration'),
-      )
-    }
-
-    if (!pkg.dynamic) continue
-    for (const section of ['dependencies', 'peerDependencies'] as const) {
-      for (const name of Object.keys(pkg[section]).sort()) {
-        if (expected.has(name)) continue
-        if (staticInputs.has(name)) {
-          violations.push(
-            pkg.manifest + ': dynamic package declares static input ' + name + ' in ' + section + ';'
-            + ' move it to devDependencies or delete the stale declaration',
-          )
-        } else if (section === 'dependencies' && isInternalDsh(name)) {
-          violations.push(
-            pkg.manifest + ': dynamic package declares ' + name + ' in dependencies;'
-            + ' dynamic DSH relationships are peer plus dev, and static client inputs are dev-only',
-          )
-        }
-      }
-    }
-  }
-  return violations
-}
-
-function expectedSections(pkg: ClientPackage, staticInputs: ReadonlySet<string>): Map<string, ExpectedRule> {
-  const expected = new Map<string, ExpectedRule>([
-    [CORDIS, { kind: 'peer-dev', origins: new Set(['client package baseline']) }],
-  ])
-  if (!pkg.dynamic) {
-    if (pkg.name === CLIENT_WEB) return expected
-    for (const [name, locations] of Object.entries(pkg.runtimeSourceUses)) {
-      if (name === pkg.name || name === CORDIS || isInternalDsh(name)) continue
-      expected.set(name, { kind: 'dependency', origins: new Set(locations) })
-    }
-    return expected
-  }
-
-  const add = (name: string, origin: string): void => {
-    if (name === pkg.name) return
-    const kind = staticInputs.has(name) ? 'dev' : isInternalDsh(name) ? 'peer-dev' : undefined
-    if (kind === undefined) return
-    const current = expected.get(name)
-    if (current !== undefined) current.origins.add(origin)
-    else expected.set(name, { kind, origins: new Set([origin]) })
-  }
-  for (const [name, locations] of Object.entries(pkg.sourceUses)) {
-    for (const location of locations) add(name, location)
-  }
-  for (const name of pkg.inject) add(name, 'dsh.client.inject')
-  return expected
-}
-
 interface ModuleEdge {
   readonly from: string
   readonly to: string
@@ -895,32 +655,6 @@ function rowPackageOf(specifier: string, rows: ReadonlySet<string>): string | un
   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('#')
 }
@@ -956,7 +690,7 @@ async function main(): Promise<void> {
   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(facts.packages.length - dynamic) + ' statically linked) satisfy package-mode and module-request rules; '
     + String(requests) + ' explicit external request(s).',
   )
 }

+ 260 - 0
scripts/verify-package-dependencies.spec.ts

@@ -0,0 +1,260 @@
+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 {
+  PACKAGE_DEPENDENCY_POLICY,
+  type PackageDependencyPolicy,
+} from './package-dependency-policy.ts'
+import {
+  collectPackageDependencyViolations,
+  discoverPackageDependencyScope,
+  fixPackageDependencies,
+  formatManagedRuntimeDependencies,
+  readPackageDependencyFacts,
+  repairPackageDependencyManifest,
+  type PackageDependencyFacts,
+  type PackageDependencyManifest,
+  type WorkspacePackageManifest,
+} from './verify-package-dependencies.ts'
+
+const CORDIS = '@deepseek-ai/cordis'
+const roots: string[] = []
+
+afterEach(() => {
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+function pkg(
+  name: string,
+  manifestPath: string,
+  manifest: Partial<PackageDependencyManifest> = {},
+): WorkspacePackageManifest {
+  return {
+    name,
+    manifestPath,
+    dir: dirname(manifestPath),
+    manifest: { name, ...manifest },
+  }
+}
+
+function policy(fields: Partial<PackageDependencyPolicy> = {}): PackageDependencyPolicy {
+  return {
+    clientFaceInclude: [],
+    clientFaceExclude: [],
+    hostPackages: [],
+    ...fields,
+  }
+}
+
+function facts(manifest: PackageDependencyManifest): PackageDependencyFacts {
+  return {
+    manifestPath: 'packages/core/probe/package.json',
+    role: 'configured-host',
+    manifest,
+    workspaceNames: new Set([
+      CORDIS,
+      '@deepseek-ai/dsh-runtime',
+      '@deepseek-ai/dsh-types',
+      '@deepseek-ai/dsh-stale',
+      '@deepseek-ai/schemastery',
+    ]),
+    allSourceUses: new Map([
+      ['@deepseek-ai/dsh-runtime', ['packages/core/probe/src/index.ts']],
+      ['@deepseek-ai/dsh-types', ['packages/core/probe/src/types.ts']],
+    ]),
+    hostRuntimeSourceUses: new Map([
+      ['@deepseek-ai/dsh-runtime', ['packages/core/probe/src/index.ts']],
+    ]),
+    clientInject: new Set(),
+  }
+}
+
+describe('package dependency scope', () => {
+  it('keeps the measured Host relay roster explicit', () => {
+    expect(PACKAGE_DEPENDENCY_POLICY.clientFaceExclude).toEqual([
+      '@deepseek-ai/dsh-api-session-controller',
+    ])
+    expect(PACKAGE_DEPENDENCY_POLICY.hostPackages).toEqual([
+      '@deepseek-ai/dsh-llm',
+      '@deepseek-ai/dsh-session',
+    ])
+  })
+
+  it('discovers the Client directory, dsh.client declarations, and configured Host packages', () => {
+    const packages = [
+      pkg('@f/static', 'packages/client/static/package.json'),
+      pkg('@f/dynamic-client', 'packages/client/dynamic/package.json', { dsh: { client: {} } }),
+      pkg('@f/dual', 'packages/api/dual/package.json', { dsh: { client: {} } }),
+      pkg('@f/export-only', 'packages/api/export-only/package.json', { exports: { './client': './lib/client.js' } }),
+      pkg('@f/forced-client', 'packages/api/forced/package.json'),
+      pkg('@f/excluded', 'packages/api/excluded/package.json', { dsh: { client: {} } }),
+      pkg('@f/host', 'packages/core/host/package.json'),
+    ]
+
+    const found = discoverPackageDependencyScope(packages, policy({
+      clientFaceInclude: ['@f/forced-client'],
+      clientFaceExclude: ['@f/excluded'],
+      hostPackages: ['@f/host'],
+    }))
+
+    expect(found.violations).toEqual([])
+    expect(found.selected.map(item => [item.name, item.role])).toEqual([
+      ['@f/dual', 'client-host'],
+      ['@f/forced-client', 'client-host'],
+      ['@f/dynamic-client', 'client-host'],
+      ['@f/static', 'client-host'],
+      ['@f/host', 'configured-host'],
+    ])
+  })
+
+  it('rejects stale, redundant, overlapping, and unknown configuration', () => {
+    const packages = [
+      pkg('@f/client', 'packages/client/client/package.json'),
+      pkg('@f/dual', 'packages/api/dual/package.json', { dsh: { client: {} } }),
+      pkg('@f/host', 'packages/core/host/package.json'),
+    ]
+    const found = discoverPackageDependencyScope(packages, policy({
+      clientFaceInclude: ['@f/dual', '@f/missing', '@f/host'],
+      clientFaceExclude: ['@f/client', '@f/host', '@f/missing'],
+      hostPackages: ['@f/dual'],
+    }))
+
+    expect(found.violations).toEqual(expect.arrayContaining([
+      expect.stringContaining('clientFaceInclude redundantly names automatically discovered package @f/dual'),
+      expect.stringContaining('@f/host appears in both clientFaceInclude and clientFaceExclude'),
+      expect.stringContaining('clientFaceExclude cannot exempt packages/client package @f/client'),
+      expect.stringContaining('clientFaceExclude names @f/host, which declares no dsh.client entry'),
+      expect.stringContaining('hostPackages redundantly names Client-faced package @f/dual'),
+      expect.stringContaining('unknown release package @f/missing'),
+    ]))
+  })
+})
+
+describe('face-aware source classification', () => {
+  it('counts Host values as dependencies and Client values as development inputs', () => {
+    const root = mkdtempSync(join(tmpdir(), 'dsh-package-faces-'))
+    roots.push(root)
+    const subject = pkg('@f/dual', 'packages/g/dual/package.json', {
+      dsh: { client: { inject: ['@f/injected'] } },
+    })
+    const files = {
+      'packages/g/dual/src/index.ts': [
+        "import { value } from '@f/runtime'",
+        "import type { Shared } from '@f/types'",
+        "export { nested } from './nested.ts'",
+      ].join('\n'),
+      'packages/g/dual/src/nested.ts': "export { nested } from '@f/nested'",
+      'packages/g/dual/src/client/index.ts': "import { browser } from '@f/browser'",
+    }
+    for (const [path, source] of Object.entries(files)) {
+      mkdirSync(dirname(join(root, path)), { recursive: true })
+      writeFileSync(join(root, path), source)
+    }
+
+    const found = readPackageDependencyFacts(root, subject, 'client-host', new Set([
+      CORDIS, '@f/runtime', '@f/types', '@f/nested', '@f/browser', '@f/injected',
+    ]))
+
+    expect([...found.hostRuntimeSourceUses.keys()].sort()).toEqual(['@f/nested', '@f/runtime'])
+    expect([...found.allSourceUses.keys()].sort()).toEqual(['@f/browser', '@f/nested', '@f/runtime', '@f/types'])
+  })
+})
+
+describe('dependency sections', () => {
+  it('accepts Host dependencies, development-only inputs, and shared Cordis', () => {
+    const manifest: PackageDependencyManifest = {
+      name: '@deepseek-ai/dsh-probe',
+      dependencies: {
+        '@deepseek-ai/dsh-runtime': 'workspace:^',
+        '@deepseek-ai/schemastery': 'workspace:^',
+        external: '^1.0.0',
+      },
+      devDependencies: {
+        '@deepseek-ai/dsh-types': 'workspace:^',
+        [CORDIS]: 'workspace:^',
+      },
+      peerDependencies: { [CORDIS]: 'workspace:^' },
+    }
+    expect(collectPackageDependencyViolations({
+      facts: [facts(manifest)], packages: [], policyViolations: [], workspaceNames: facts(manifest).workspaceNames,
+    })).toEqual([])
+  })
+
+  it('lists managed Host runtime dependencies for fix review', () => {
+    const subject = facts({ name: '@deepseek-ai/dsh-probe' })
+    expect(formatManagedRuntimeDependencies({
+      facts: [subject], packages: [], policyViolations: [], workspaceNames: subject.workspaceNames,
+    })).toEqual([
+      'verify-package-dependencies: 1 managed Host runtime dependency edge(s) remain in dependencies across 1 package(s):',
+      '  @deepseek-ai/dsh-probe: @deepseek-ai/dsh-runtime',
+    ])
+  })
+
+  it('reports wrong sections, workspace ranges, and stale peer metadata', () => {
+    const manifest: PackageDependencyManifest = {
+      name: '@deepseek-ai/dsh-probe',
+      dependencies: { '@deepseek-ai/dsh-types': 'workspace:*' },
+      devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-runtime': 'workspace:^' },
+      peerDependencies: { [CORDIS]: 'workspace:*', '@deepseek-ai/dsh-runtime': 'workspace:^' },
+      peerDependenciesMeta: { '@deepseek-ai/dsh-missing': { optional: true } },
+    }
+    const state = {
+      facts: [facts(manifest)], packages: [], policyViolations: [], workspaceNames: facts(manifest).workspaceNames,
+    }
+    const violations = collectPackageDependencyViolations(state)
+    expect(violations).toEqual(expect.arrayContaining([
+      expect.stringContaining('@deepseek-ai/dsh-runtime'),
+      expect.stringContaining('@deepseek-ai/dsh-types'),
+      expect.stringContaining(`${CORDIS} must be matching peerDependencies + devDependencies`),
+      expect.stringContaining('dependencies.@deepseek-ai/dsh-types must use workspace:^'),
+      expect.stringContaining('peerDependenciesMeta.@deepseek-ai/dsh-missing has no matching'),
+    ]))
+  })
+
+  it('repairs owned relationships without changing unrelated dependencies', () => {
+    const root = mkdtempSync(join(tmpdir(), 'dsh-package-dependencies-'))
+    roots.push(root)
+    const manifestPath = 'package.json'
+    const manifest: PackageDependencyManifest = {
+      name: '@deepseek-ai/dsh-probe',
+      dependencies: { '@deepseek-ai/schemastery': 'workspace:*', external: '^1.0.0' },
+      devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-runtime': 'workspace:^' },
+      peerDependencies: {
+        [CORDIS]: 'workspace:^',
+        '@deepseek-ai/dsh-runtime': 'workspace:^',
+        '@deepseek-ai/dsh-stale': 'workspace:^',
+      },
+      peerDependenciesMeta: { '@deepseek-ai/dsh-stale': { optional: true } },
+    }
+    writeFileSync(join(root, manifestPath), `${JSON.stringify(manifest, null, 2)}\n`)
+    const subject = { ...facts(manifest), manifestPath }
+    const state = { facts: [subject], packages: [], policyViolations: [], workspaceNames: subject.workspaceNames }
+
+    expect(fixPackageDependencies(root, state)).toEqual([manifestPath])
+    const fixed = JSON.parse(readFileSync(join(root, manifestPath), 'utf8')) as PackageDependencyManifest
+    expect(fixed.dependencies).toEqual({
+      '@deepseek-ai/schemastery': 'workspace:^',
+      external: '^1.0.0',
+      '@deepseek-ai/dsh-runtime': 'workspace:^',
+    })
+    expect(fixed.devDependencies).toEqual({
+      [CORDIS]: 'workspace:^',
+      '@deepseek-ai/dsh-types': 'workspace:^',
+      '@deepseek-ai/dsh-stale': 'workspace:^',
+    })
+    expect(fixed.peerDependencies).toEqual({ [CORDIS]: 'workspace:^' })
+    expect(fixed.peerDependenciesMeta).toBeUndefined()
+  })
+
+  it('repairs an in-memory manifest for benchmark simulation', () => {
+    const manifest: PackageDependencyManifest = {
+      name: '@deepseek-ai/dsh-probe',
+      peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-runtime': 'workspace:^' },
+      devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-runtime': 'workspace:^' },
+    }
+    repairPackageDependencyManifest(facts(manifest))
+    expect(manifest.dependencies).toEqual({ '@deepseek-ai/dsh-runtime': 'workspace:^' })
+    expect(manifest.peerDependencies).toEqual({ [CORDIS]: 'workspace:^' })
+  })
+})

+ 479 - 0
scripts/verify-package-dependencies.ts

@@ -0,0 +1,479 @@
+/** Verify and repair npm dependency sections from published Client and Host faces. */
+
+import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
+import { dirname, extname, join, normalize, relative, resolve, sep } from 'node:path'
+import {
+  hasClientDeclaration,
+  PACKAGE_DEPENDENCY_POLICY,
+  type PackageDependencyPolicy,
+} from './package-dependency-policy.ts'
+import {
+  collectLocalSourceSpecifiers,
+  collectRuntimeSourcePackageUses,
+  collectSourcePackageUses,
+} from './verify-client-packages.ts'
+
+const GATE = 'verify-package-dependencies'
+const CORDIS = '@deepseek-ai/cordis'
+const WORKSPACE_RANGE = 'workspace:^'
+const RELEASE_MANIFEST_GLOB = 'packages/!(experimental)/*/package.json'
+const WORKSPACE_MANIFEST_GLOBS = [
+  'apps/*/package.json',
+  'packages/*/*/package.json',
+  'vendor/*/package.json',
+]
+
+type DependencySection = 'dependencies' | 'devDependencies' | 'optionalDependencies' | 'peerDependencies'
+export type PackageDependencyRole = 'client-host' | 'configured-host'
+
+/** Manifest fields read and repaired by the package dependency policy. */
+export interface PackageDependencyManifest {
+  name?: string
+  version?: string
+  exports?: unknown
+  dependencies?: Record<string, string>
+  devDependencies?: Record<string, string>
+  optionalDependencies?: Record<string, string>
+  peerDependencies?: Record<string, string>
+  peerDependenciesMeta?: Record<string, unknown>
+  dsh?: { client?: { inject?: string[] } }
+}
+
+/** One workspace package and its source location. */
+export interface WorkspacePackageManifest {
+  readonly dir: string
+  readonly manifestPath: string
+  readonly manifest: PackageDependencyManifest
+  readonly name: string
+}
+
+/** Source and manifest facts for one package covered by the policy. */
+export interface PackageDependencyFacts {
+  readonly manifestPath: string
+  readonly role: PackageDependencyRole
+  readonly manifest: PackageDependencyManifest
+  readonly workspaceNames: ReadonlySet<string>
+  readonly allSourceUses: ReadonlyMap<string, readonly string[]>
+  readonly hostRuntimeSourceUses: ReadonlyMap<string, readonly string[]>
+  readonly clientInject: ReadonlySet<string>
+}
+
+/** Complete policy input read from the repository. */
+export interface PackageDependencyState {
+  readonly facts: readonly PackageDependencyFacts[]
+  readonly packages: readonly WorkspacePackageManifest[]
+  readonly policyViolations: readonly string[]
+  readonly workspaceNames: ReadonlySet<string>
+}
+
+export interface ExpectedPackageDependency {
+  readonly section: 'dependencies' | 'devDependencies' | 'peer-dev'
+  readonly origins: readonly string[]
+}
+
+function normalizePath(path: string): string {
+  return path.split(sep).join('/')
+}
+
+function packageNameOf(specifier: string): string | undefined {
+  if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('#') || specifier.includes(':')) {
+    return undefined
+  }
+  const parts = specifier.split('/')
+  return specifier.startsWith('@') ? parts.length >= 2 ? `${parts[0]}/${parts[1]}` : undefined : parts[0]
+}
+
+/** Read package manifests used for scope discovery and workspace-name checks. */
+export function readWorkspacePackageManifests(root: string): {
+  all: WorkspacePackageManifest[]
+  release: WorkspacePackageManifest[]
+} {
+  const read = (manifestPath: string): WorkspacePackageManifest => {
+    const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) as PackageDependencyManifest
+    if (typeof manifest.name !== 'string') throw new Error(`${manifestPath}: missing package name`)
+    return {
+      dir: dirname(manifestPath),
+      manifestPath,
+      manifest,
+      name: manifest.name,
+    }
+  }
+  const all = globSync(WORKSPACE_MANIFEST_GLOBS, { cwd: root }).map(normalizePath).sort().map(read)
+  const releasePaths = new Set(globSync(RELEASE_MANIFEST_GLOB, { cwd: root }).map(normalizePath))
+  return { all, release: all.filter(pkg => releasePaths.has(pkg.manifestPath)) }
+}
+
+function duplicates(values: readonly string[]): string[] {
+  const seen = new Set<string>()
+  const duplicated = new Set<string>()
+  for (const value of values) {
+    if (seen.has(value)) duplicated.add(value)
+    seen.add(value)
+  }
+  return [...duplicated].sort()
+}
+
+/** Discover Client faces and configured Host packages, validating explicit overrides. */
+export function discoverPackageDependencyScope(
+  packages: readonly WorkspacePackageManifest[],
+  policy: PackageDependencyPolicy,
+): { selected: Array<WorkspacePackageManifest & { role: PackageDependencyRole }>; violations: string[] } {
+  const violations: string[] = []
+  const byName = new Map(packages.map(pkg => [pkg.name, pkg]))
+  const include = new Set(policy.clientFaceInclude)
+  const exclude = new Set(policy.clientFaceExclude)
+  const host = new Set(policy.hostPackages)
+
+  for (const [field, values] of [
+    ['clientFaceInclude', policy.clientFaceInclude],
+    ['clientFaceExclude', policy.clientFaceExclude],
+    ['hostPackages', policy.hostPackages],
+  ] as const) {
+    for (const name of duplicates(values)) violations.push(`${field} lists ${name} more than once`)
+    for (const name of values) {
+      if (!byName.has(name)) violations.push(`${field} names unknown release package ${name}`)
+    }
+  }
+  for (const name of include) {
+    if (exclude.has(name)) violations.push(`${name} appears in both clientFaceInclude and clientFaceExclude`)
+    const pkg = byName.get(name)
+    if (pkg !== undefined
+      && (pkg.manifestPath.startsWith('packages/client/') || hasClientDeclaration(pkg.manifest.dsh))) {
+      violations.push(`clientFaceInclude redundantly names automatically discovered package ${name}`)
+    }
+  }
+  for (const name of exclude) {
+    const pkg = byName.get(name)
+    if (pkg !== undefined && pkg.manifestPath.startsWith('packages/client/')) {
+      violations.push(`clientFaceExclude cannot exempt packages/client package ${name}`)
+    } else if (pkg !== undefined && !hasClientDeclaration(pkg.manifest.dsh)) {
+      violations.push(`clientFaceExclude names ${name}, which declares no dsh.client entry`)
+    }
+  }
+
+  const selected: Array<WorkspacePackageManifest & { role: PackageDependencyRole }> = []
+  for (const pkg of packages) {
+    const clientDirectory = pkg.manifestPath.startsWith('packages/client/')
+    const clientHost = clientDirectory
+      || ((hasClientDeclaration(pkg.manifest.dsh) || include.has(pkg.name)) && !exclude.has(pkg.name))
+    const configuredHost = host.has(pkg.name)
+    if (configuredHost && clientHost) {
+      violations.push(`hostPackages redundantly names Client-faced package ${pkg.name}`)
+    }
+    const role = clientHost ? 'client-host' : configuredHost ? 'configured-host' : undefined
+    if (role !== undefined) selected.push({ ...pkg, role })
+  }
+  return {
+    selected: selected.sort((left, right) => left.manifestPath.localeCompare(right.manifestPath)),
+    violations: [...new Set(violations)].sort(),
+  }
+}
+
+function addUse(target: Map<string, string[]>, name: string, path: string): void {
+  const paths = target.get(name) ?? []
+  if (!paths.includes(path)) paths.push(path)
+  target.set(name, paths)
+}
+
+function resolveLocal(importer: string, specifier: string): string | undefined {
+  const raw = resolve(dirname(importer), specifier)
+  const candidates = extname(raw) === ''
+    ? [`${raw}.ts`, `${raw}.tsx`, `${raw}.mts`, `${raw}.cts`, join(raw, 'index.ts'), join(raw, 'index.tsx')]
+    : [raw, raw.replace(/\.js$/, '.ts'), raw.replace(/\.jsx$/, '.tsx'), raw.replace(/\.mjs$/, '.mts'), raw.replace(/\.cjs$/, '.cts')]
+  return candidates.find(candidate => existsSync(candidate))
+}
+
+function readHostRuntimeUses(root: string, pkg: WorkspacePackageManifest): Map<string, string[]> {
+  const uses = new Map<string, string[]>()
+  const seen = new Set<string>()
+  const visit = (path: string): void => {
+    const normalized = normalize(path)
+    if (seen.has(normalized) || !existsSync(normalized)) return
+    seen.add(normalized)
+    const source = readFileSync(normalized, 'utf8')
+    const displayPath = normalizePath(relative(root, normalized))
+    for (const name of collectRuntimeSourcePackageUses(normalized, source)) addUse(uses, name, displayPath)
+    for (const specifier of collectLocalSourceSpecifiers(normalized, source)) {
+      const target = resolveLocal(normalized, specifier)
+      if (target !== undefined) visit(target)
+    }
+  }
+  visit(resolve(root, pkg.dir, 'src/index.ts'))
+  return uses
+}
+
+function readAllSourceUses(root: string, pkg: WorkspacePackageManifest): Map<string, string[]> {
+  const uses = new Map<string, string[]>()
+  for (const sourcePath of globSync('src/**/*.{ts,tsx,mts,cts}', { cwd: resolve(root, pkg.dir) }).sort()) {
+    const source = readFileSync(resolve(root, pkg.dir, sourcePath), 'utf8')
+    const displayPath = `${pkg.dir}/${normalizePath(sourcePath)}`
+    for (const name of collectSourcePackageUses(sourcePath, source)) addUse(uses, name, displayPath)
+  }
+  return uses
+}
+
+/** Read source usage for one already-classified package. */
+export function readPackageDependencyFacts(
+  root: string,
+  pkg: WorkspacePackageManifest,
+  role: PackageDependencyRole,
+  workspaceNames: ReadonlySet<string>,
+): PackageDependencyFacts {
+  const inject = pkg.manifest.dsh?.client?.inject ?? []
+  return {
+    manifestPath: pkg.manifestPath,
+    role,
+    manifest: pkg.manifest,
+    workspaceNames,
+    allSourceUses: readAllSourceUses(root, pkg),
+    hostRuntimeSourceUses: readHostRuntimeUses(root, pkg),
+    clientInject: new Set(inject.map(packageNameOf).filter(name => name !== undefined)),
+  }
+}
+
+/** Read every package covered by the current dependency policy. */
+export function readPackageDependencyState(
+  root: string,
+  policy: PackageDependencyPolicy = PACKAGE_DEPENDENCY_POLICY,
+): PackageDependencyState {
+  const packages = readWorkspacePackageManifests(root)
+  const workspaceNames = new Set(packages.all.map(pkg => pkg.name))
+  const discovered = discoverPackageDependencyScope(packages.release, policy)
+  return {
+    facts: discovered.selected.map(pkg => readPackageDependencyFacts(root, pkg, pkg.role, workspaceNames)),
+    packages: packages.release,
+    policyViolations: discovered.violations,
+    workspaceNames,
+  }
+}
+
+/** Derive the required npm section for each relationship owned by the policy. */
+export function expectedPackageDependencies(
+  facts: PackageDependencyFacts,
+): ReadonlyMap<string, ExpectedPackageDependency> {
+  const expected = new Map<string, { section: ExpectedPackageDependency['section']; origins: Set<string> }>()
+  const add = (name: string, sectionName: ExpectedPackageDependency['section'], origin: string): void => {
+    if (name === facts.manifest.name || name === CORDIS) return
+    const current = expected.get(name)
+    const section = current?.section === 'dependencies' || sectionName === 'dependencies'
+      ? 'dependencies'
+      : 'devDependencies'
+    expected.set(name, { section, origins: new Set([...(current?.origins ?? []), origin]) })
+  }
+
+  expected.set(CORDIS, { section: 'peer-dev', origins: new Set(['shared Cordis runtime']) })
+  for (const [name, paths] of facts.allSourceUses) {
+    if (!facts.workspaceNames.has(name)) continue
+    for (const path of paths) add(name, 'devDependencies', path)
+  }
+  for (const name of facts.clientInject) {
+    if (facts.workspaceNames.has(name)) add(name, 'devDependencies', 'dsh.client.inject')
+  }
+  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
+    for (const path of paths) add(name, 'dependencies', path)
+  }
+  return new Map([...expected].map(([name, rule]) => [name, {
+    section: rule.section,
+    origins: [...rule.origins].sort(),
+  }]))
+}
+
+/** Format the managed Host runtime edges that remain ordinary dependencies. */
+export function formatManagedRuntimeDependencies(state: PackageDependencyState): string[] {
+  const rows = state.facts.flatMap((facts) => {
+    const dependencies = [...expectedPackageDependencies(facts)]
+      .filter(([, rule]) => rule.section === 'dependencies')
+      .map(([name]) => name)
+      .sort()
+    if (dependencies.length === 0) return []
+    return [{
+      name: facts.manifest.name ?? facts.manifestPath,
+      dependencies,
+    }]
+  }).sort((left, right) => left.name.localeCompare(right.name))
+  const edges = rows.reduce((total, row) => total + row.dependencies.length, 0)
+  return [
+    `${GATE}: ${String(edges)} managed Host runtime dependency edge(s) remain in dependencies across ${String(rows.length)} package(s):`,
+    ...rows.map(row => `  ${row.name}: ${row.dependencies.join(', ')}`),
+  ]
+}
+
+function section(manifest: PackageDependencyManifest, name: DependencySection): Record<string, string> {
+  return manifest[name] ?? {}
+}
+
+function mutableSection(manifest: PackageDependencyManifest, name: DependencySection): Record<string, string> {
+  manifest[name] ??= {}
+  return manifest[name]
+}
+
+function declaredSections(manifest: PackageDependencyManifest, name: string): DependencySection[] {
+  return (['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const)
+    .filter(sectionName => section(manifest, sectionName)[name] !== undefined)
+}
+
+function describeSections(sections: readonly DependencySection[]): string {
+  return sections.length === 0 ? 'no dependency section' : sections.join(' + ')
+}
+
+/** Return all manifest and policy violations in stable order. */
+export function collectPackageDependencyViolations(state: PackageDependencyState): string[] {
+  const violations = [...state.policyViolations]
+  for (const facts of state.facts) {
+    for (const [name, rule] of expectedPackageDependencies(facts)) {
+      const actual = declaredSections(facts.manifest, name)
+      if (rule.section === 'peer-dev') {
+        if (actual.length === 2
+          && actual.includes('peerDependencies')
+          && actual.includes('devDependencies')
+          && section(facts.manifest, 'peerDependencies')[name] === WORKSPACE_RANGE
+          && section(facts.manifest, 'devDependencies')[name] === WORKSPACE_RANGE
+          && facts.manifest.peerDependenciesMeta?.[name] === undefined) continue
+        violations.push(
+          `${facts.manifestPath}: ${name} must be matching peerDependencies + devDependencies at ${WORKSPACE_RANGE}; found ${describeSections(actual)}`,
+        )
+        continue
+      }
+      const expectedSection = rule.section
+      const range = section(facts.manifest, expectedSection)[name]
+      if (actual.length === 1
+        && actual[0] === expectedSection
+        && (!facts.workspaceNames.has(name) || range === WORKSPACE_RANGE)) continue
+      violations.push(
+        `${facts.manifestPath}: ${name} (${rule.origins.join(', ')}) must be ${expectedSection}-only`
+        + (facts.workspaceNames.has(name) ? ` at ${WORKSPACE_RANGE}` : '')
+        + `; found ${describeSections(actual)}`,
+      )
+    }
+    for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const) {
+      for (const [name, range] of Object.entries(section(facts.manifest, sectionName))) {
+        if (!facts.workspaceNames.has(name) || range === WORKSPACE_RANGE) continue
+        violations.push(`${facts.manifestPath}: ${sectionName}.${name} must use ${WORKSPACE_RANGE}, found ${range}`)
+      }
+    }
+    for (const name of Object.keys(facts.manifest.peerDependenciesMeta ?? {})) {
+      if (facts.manifest.peerDependencies?.[name] === undefined) {
+        violations.push(`${facts.manifestPath}: peerDependenciesMeta.${name} has no matching peerDependencies entry`)
+      }
+    }
+  }
+  return [...new Set(violations)].sort()
+}
+
+function deleteDependency(
+  manifest: PackageDependencyManifest,
+  sectionName: DependencySection,
+  name: string,
+): void {
+  const dependencies = manifest[sectionName]
+  if (dependencies?.[name] === undefined) return
+  const retained = Object.fromEntries(Object.entries(dependencies).filter(([key]) => key !== name))
+  if (Object.keys(retained).length > 0) {
+    manifest[sectionName] = retained
+    return
+  }
+  switch (sectionName) {
+    case 'dependencies': delete manifest.dependencies; break
+    case 'devDependencies': delete manifest.devDependencies; break
+    case 'optionalDependencies': delete manifest.optionalDependencies; break
+    case 'peerDependencies': delete manifest.peerDependencies; break
+  }
+}
+
+function deletePeerMeta(manifest: PackageDependencyManifest, name: string): void {
+  if (manifest.peerDependenciesMeta?.[name] === undefined) return
+  const retained = Object.fromEntries(Object.entries(manifest.peerDependenciesMeta)
+    .filter(([key]) => key !== name))
+  if (Object.keys(retained).length > 0) manifest.peerDependenciesMeta = retained
+  else delete manifest.peerDependenciesMeta
+}
+
+function preferredRange(
+  facts: PackageDependencyFacts,
+  name: string,
+  target: ExpectedPackageDependency['section'],
+): string | undefined {
+  if (facts.workspaceNames.has(name)) return WORKSPACE_RANGE
+  const order: readonly DependencySection[] = target === 'dependencies'
+    ? ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
+    : ['devDependencies', 'peerDependencies', 'dependencies', 'optionalDependencies']
+  return order.map(sectionName => section(facts.manifest, sectionName)[name]).find(value => value !== undefined)
+}
+
+/** Apply the dependency policy to one in-memory manifest. */
+export function repairPackageDependencyManifest(facts: PackageDependencyFacts): void {
+  for (const [name, rule] of expectedPackageDependencies(facts)) {
+    if (rule.section === 'peer-dev') {
+      for (const sectionName of ['dependencies', 'optionalDependencies'] as const) {
+        deleteDependency(facts.manifest, sectionName, name)
+      }
+      mutableSection(facts.manifest, 'peerDependencies')[name] = WORKSPACE_RANGE
+      mutableSection(facts.manifest, 'devDependencies')[name] = WORKSPACE_RANGE
+      deletePeerMeta(facts.manifest, name)
+      continue
+    }
+    const range = preferredRange(facts, name, rule.section)
+    if (range === undefined) continue
+    for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const) {
+      if (sectionName !== rule.section) deleteDependency(facts.manifest, sectionName, name)
+    }
+    mutableSection(facts.manifest, rule.section)[name] = range
+    deletePeerMeta(facts.manifest, name)
+  }
+  for (const name of Object.keys(facts.manifest.peerDependenciesMeta ?? {})) {
+    if (facts.manifest.peerDependencies?.[name] === undefined) deletePeerMeta(facts.manifest, name)
+  }
+  for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const) {
+    for (const name of Object.keys(section(facts.manifest, sectionName))) {
+      if (facts.workspaceNames.has(name)) mutableSection(facts.manifest, sectionName)[name] = WORKSPACE_RANGE
+    }
+  }
+}
+
+/** Repair every covered manifest and return repository-relative changed paths. */
+export function fixPackageDependencies(root: string, state: PackageDependencyState): string[] {
+  if (state.policyViolations.length > 0) return []
+  const changed: string[] = []
+  for (const facts of state.facts) {
+    const before = `${JSON.stringify(facts.manifest, null, 2)}\n`
+    repairPackageDependencyManifest(facts)
+    const after = `${JSON.stringify(facts.manifest, null, 2)}\n`
+    if (after === before) continue
+    writeFileSync(resolve(root, facts.manifestPath), after)
+    changed.push(facts.manifestPath)
+  }
+  return changed.sort()
+}
+
+function main(): void {
+  const root = resolve(import.meta.dirname, '..')
+  let state = readPackageDependencyState(root)
+  const fix = process.argv.includes('--fix')
+  if (fix) {
+    const changed = fixPackageDependencies(root, state)
+    console.log(`${GATE}: fixed ${String(changed.length)} manifest(s).`)
+    state = readPackageDependencyState(root)
+  }
+  const violations = collectPackageDependencyViolations(state)
+  if (violations.length > 0) {
+    console.error(`${GATE}: ${String(violations.length)} violation(s):`)
+    for (const violation of violations) console.error(`  ${violation}`)
+    process.exitCode = 1
+    return
+  }
+  const roles = Object.groupBy(state.facts, fact => fact.role)
+  console.log(
+    `${GATE}: ${String(state.facts.length)} package(s) match the published dependency policy`
+    + ` (${String(roles['client-host']?.length ?? 0)} Client/Host,`
+    + ` ${String(roles['configured-host']?.length ?? 0)} configured Host).`,
+  )
+  if (fix) {
+    for (const line of formatManagedRuntimeDependencies(state)) console.log(line)
+  }
+}
+
+if (import.meta.main) main()