瀏覽代碼

feat: verify Host dependency export identity

imccyu 3 周之前
父節點
當前提交
c55beac34a

+ 2 - 2
.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml

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

+ 8 - 6
.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md

@@ -14,9 +14,9 @@ The package that chooses a Client build input is the shipped profile, while a Ho
 
 ### Package selection
 
-[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) owns dependency-section policy. It always covers packages under `packages/client/` and every non-experimental package that declares `dsh.client`. The directory includes static Client inputs without a dynamic row, while `dsh.client` identifies dynamically loaded packages outside it; a `"./client"` export alone is an API and does not select npm dependency policy. Every selected package's Host entry is scanned, including entries under `packages/client/`.
+[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) owns dependency-section policy. It always covers packages under `packages/client/` and every non-experimental package that declares `dsh.client`. Inside the directory, `dsh.client` marks a Client/Host package whose Host entry is scanned; a package without that declaration is a Client-only static build input. Outside the directory, `dsh.client` selects the same Client/Host scan. A `"./client"` export alone is an API and does not select npm dependency policy.
 
-[`package-dependency-policy.ts`](../../../../scripts/package-dependency-policy.ts) provides explicit Client-face include and exclude lists. An include handles an exceptional package without `dsh.client`, while an exclude removes an automatically discovered dual-face package outside `packages/client/`. The verifier rejects unknown, stale, redundant, duplicate, overlapping, and ineffective entries. The include list is empty; the exclude list contains `@deepseek-ai/dsh-api-session-controller`, because adding it back would migrate nine more Host edges while its five-run candidate retest improved median resolution by only 0.15 seconds.
+[`package-dependency-policy.ts`](../../../../scripts/package-dependency-policy.ts) provides explicit Client-face include and exclude lists. An include handles an exceptional package without `dsh.client`, while an exclude removes an automatically discovered dual-face package outside `packages/client/`. The verifier rejects unknown, stale, redundant, duplicate, overlapping, and ineffective entries. The include list is empty; the exclude list contains `@deepseek-ai/dsh-api-session-controller` and `@deepseek-ai/dsh-api-workspace-controller`. Adding Session Controller back would migrate nine more Host edges while its five-run candidate retest improved median resolution by only 0.15 seconds.
 
 Host-only packages join the same policy through a separate explicit list. The list contains `@deepseek-ai/dsh-llm` and `@deepseek-ai/dsh-session`; source imports do not expand it.
 
@@ -24,9 +24,11 @@ Host-only packages join the same policy through a separate explicit list. The li
 
 Every covered package keeps `@deepseek-ai/cordis` in matching `peerDependencies` and `devDependencies`. Cordis is the shared plugin runtime whose identity the application controls.
 
-A workspace package reached by a runtime value import from the Host entry closure belongs only in `dependencies`. Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, invariant companions, and existing metadata-only peers belong only in `devDependencies`. Existing third-party dependencies outside these managed relationships keep their declared section. Workspace references use `workspace:^`.
+A workspace package reached by a runtime value import from the Host entry closure belongs only in `dependencies` when every imported runtime export appears in the policy's `safeHostDependencyExports` table. An export whose constructor identity or module state must be shared appears in `peerRequiredHostExports`; importing one such export keeps the whole package edge in matching `peerDependencies` and `devDependencies`. Each table key is an exact module specifier and each value is a reviewed export set. The verifier follows runtime local imports from the Host entry, records named and default imports and re-exports, and rejects exports present in neither table; namespace, dynamic, and side-effect imports remain unbounded and cannot enter either table.
 
-The verifier reads source manifests and source files, so it runs on a clean tree without built `lib/`. Its `--fix` mode performs only the section and range changes implied by this classification and removes stale peer metadata.
+Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, invariant companions, and existing metadata-only peers belong only in `devDependencies`. Existing third-party dependencies outside these managed relationships keep their declared section. Workspace references use `workspace:^`.
+
+The verifier reads source manifests and source files, so it runs on a clean tree without built `lib/`. An unclassified Host runtime export is a policy violation that blocks all `--fix` writes; a maintainer must review the export and classify it, change the source relationship, or change the package selection. Once source safety passes, `--fix` performs only the section and range changes implied by the classification and removes stale peer metadata.
 
 ### Performance verification
 
@@ -48,8 +50,8 @@ The benchmark is manual rather than a CI gate. It performs metadata-only install
 
 ## Consequences
 
-The published dependency graph follows artifact ownership instead of source-directory coupling. Client bundles and shipped profiles provide browser identities, Host modules install the values they load, and only Cordis remains a repository-wide peer for covered packages.
+The published dependency graph follows artifact ownership instead of source-directory coupling. Client bundles and shipped profiles provide browser identities, Host modules install duplicate-safe values they load, and Cordis plus explicitly peer-required Host exports retain shared package instances.
 
 Moving a public type-only relationship to `devDependencies` means a standalone TypeScript consumer must install the referenced type package when it consumes that declaration. The shipped profiles install the complete supported package family; supporting independently assembled TypeScript consumers would require a different policy.
 
-The explicit overrides and Host list are reviewable decisions. Adding an exception changes the installed graph and requires the focused verifier tests plus a fresh next-package benchmark. The metadata-only benchmark is diagnostic evidence, not a release-time performance promise.
+The explicit overrides, Host list, and export classifications are reviewable decisions. Class constructors used by `instanceof`, symbols, and accessors for module-private registries require peers when their identity or state crosses package boundaries; being a value import alone does not make an export duplicate-safe. Changing a classification changes the installed graph and requires the focused verifier tests plus a fresh next-package benchmark. The metadata-only benchmark is diagnostic evidence, not a release-time performance promise.

+ 8 - 6
.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md

@@ -14,9 +14,9 @@ Client 构建输入由发布 profile 选择,而 Host value import 由导入它
 
 ### 包选择
 
-[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) 统一负责依赖区段策略。它始终覆盖 `packages/client/` 下的包,以及声明 `dsh.client` 的每个非实验包。该目录包含没有动态 row 的静态 Client 输入,而 `dsh.client` 标识目录外的动态装载包;仅有 `"./client"` export 只是 API,不参与 npm 依赖策略选包。每个选中包的 Host 入口都会接受扫描,包括 `packages/client/` 下的入口。
+[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) 统一负责依赖区段策略。它始终覆盖 `packages/client/` 下的包,以及声明 `dsh.client` 的每个非实验包。在该目录内,`dsh.client` 标记需要扫描 Host 入口的 Client/Host 包;没有该声明的包是仅供 Client 编译的静态输入。在目录外,`dsh.client` 选择相同的 Client/Host 扫描。仅有 `"./client"` export 只是 API,不参与 npm 依赖策略选包。
 
-[`package-dependency-policy.ts`](../../../../scripts/package-dependency-policy.ts) 提供显式 Client 门面 include 与 exclude 列表。include 用于没有 `dsh.client` 的例外包,exclude 用于移除 `packages/client/` 之外自动发现的双面包。验证器拒绝未知、失效、冗余、重复、相互重叠和无法生效的配置项。include 列表为空;exclude 列表包含 `@deepseek-ai/dsh-api-session-controller`,因为把它加回会多迁移九条 Host 边,而五次候选复测的 resolver 中位数仅改善 0.15 秒。
+[`package-dependency-policy.ts`](../../../../scripts/package-dependency-policy.ts) 提供显式 Client 门面 include 与 exclude 列表。include 用于没有 `dsh.client` 的例外包,exclude 用于移除 `packages/client/` 之外自动发现的双面包。验证器拒绝未知、失效、冗余、重复、相互重叠和无法生效的配置项。include 列表为空;exclude 列表包含 `@deepseek-ai/dsh-api-session-controller` 和 `@deepseek-ai/dsh-api-workspace-controller`。把 Session Controller 加回会多迁移九条 Host 边,而五次候选复测的 resolver 中位数仅改善 0.15 秒。
 
 Host-only 包通过另一份显式列表加入同一策略。该列表包含 `@deepseek-ai/dsh-llm` 和 `@deepseek-ai/dsh-session`;源码 import 不会自动扩大列表。
 
@@ -24,9 +24,11 @@ Host-only 包通过另一份显式列表加入同一策略。该列表包含 `@d
 
 每个受管包都把 `@deepseek-ai/cordis` 保持在范围一致的 `peerDependencies` 和 `devDependencies` 中。Cordis 是由应用控制身份的共享插件运行时。
 
-Host 入口闭包中的运行期 value import 所到达的 workspace 包只属于 `dependencies`。Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject`、invariant companion 和仅有元数据的现存 peer 只属于 `devDependencies`。不属于这些受管关系的现有第三方 dependency 保持原区段。Workspace 引用使用 `workspace:^`
+Host 入口闭包中的运行期 value import 所到达的 workspace 包,只有在每个运行期导出都列入策略的 `safeHostDependencyExports` 表时才只属于 `dependencies`。constructor 身份或模块状态必须共享的导出列入 `peerRequiredHostExports`;一旦使用这类导出,整条包依赖边就保留在范围一致的 `peerDependencies` 与 `devDependencies` 中。表的每个 key 都是精确 module specifier,每个 value 都是经审查的导出集合。验证器从 Host 入口沿运行期本地 import 扫描,记录具名与默认 import 和 re-export,并拒绝两个表都未收录的导出;namespace、dynamic 和 side-effect import 无法限定导出范围,因此不能进入任一表
 
-验证器读取源码 manifest 和源码文件,因此可以在没有已构建 `lib/` 的干净工作树上运行。其 `--fix` 模式只执行分类所确定的区段与范围变更,并删除失效的 peer 元数据。
+Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject`、invariant companion 和仅有元数据的现存 peer 只属于 `devDependencies`。不属于这些受管关系的现有第三方 dependency 保持原区段。Workspace 引用使用 `workspace:^`。
+
+验证器读取源码 manifest 和源码文件,因此可以在没有已构建 `lib/` 的干净工作树上运行。未分类的 Host 运行期导出属于策略违规,会阻止 `--fix` 的全部写入;维护者必须审查该导出,并选择分类该导出、修改源码关系或修改选包范围。源码安全检查通过后,`--fix` 只执行分类所确定的区段与范围变更,并删除失效的 peer 元数据。
 
 ### 性能验证
 
@@ -48,8 +50,8 @@ Benchmark 是手动诊断工具而非 CI 门禁。它在全新 consumer 中执
 
 ## 结果
 
-发布依赖图按产物归属而不是源码目录耦合分类。Client bundle 与发布 profile 提供浏览器运行时身份,Host 模块安装自己加载的实体,而 Cordis 是受管包中唯一的全仓 peer
+发布依赖图按产物归属而不是源码目录耦合分类。Client bundle 与发布 profile 提供浏览器运行时身份,Host 模块安装自己加载的可重复实体,而 Cordis 和显式标为 peer-required 的 Host 导出继续共享包实例
 
 把公开纯类型关系放进 `devDependencies`,意味着独立 TypeScript 消费者在使用该声明时必须自行安装被引用的类型包。发布 profile 会安装完整的受支持包族;若要支持独立组装的 TypeScript 消费者,需要另一套策略。
 
-显式 override 与 Host 列表都是需要评审的决策。增加例外会改变安装图,因此需要运行聚焦 verifier 测试并重新执行 next-package benchmark。仅 metadata benchmark 是诊断证据,不是发布时安装耗时承诺。
+显式 override、Host 列表与导出分类都是需要评审的决策。当 class constructor、symbol 和访问模块私有 registry 的函数跨包传递身份或状态时,它们要求 peer;仅仅属于 value import 并不能证明导出可重复安装。修改分类会改变安装图,因此需要运行聚焦 verifier 测试并重新执行 next-package benchmark。仅 metadata benchmark 是诊断证据,不是发布时安装耗时承诺。

+ 1 - 1
packages/client/AGENTS.md

@@ -61,7 +61,7 @@ Npm sections describe installation and development relationships; each build fac
 1. **Every client package keeps Cordis in matching `peerDependencies` and `devDependencies`.** This includes the static packages because their Node face participates in the same Cordis plugin contract.
 2. **A package under `packages/client/` is always covered; `dsh.client` marks a Client/Host package outside that directory.** Explicit include/exclude entries handle exceptions. Every covered package's Host entry is scanned, while a `./client` export alone does not select dependency policy.
 3. **Browser and type relationships are development-only.** Client imports, type-only imports, module augmentations, `dsh.client.inject`, invariant companions, and metadata-only peers belong only in `devDependencies`.
-4. **Host value imports are ordinary dependencies.** A workspace value reached from the package's Host entry belongs only in `dependencies`; it is not a peer merely because another package may also install it.
+4. **Host value imports require classified exports.** A workspace value reached from the package's Host entry belongs only in `dependencies` when its exact module specifier and runtime export appear in `safeHostDependencyExports`. Exports whose identity or module state must be shared appear in `peerRequiredHostExports` and keep the whole package edge in matching `peerDependencies` and `devDependencies`. The verifier rejects unclassified exports before `--fix` writes manifests.
 5. **Ordinary installed libraries stay in `dependencies`.** This includes private implementation libraries bundled into `lib/client.js` and bare imports left in a statically linked `lib/index.js`; the final Vite host, not the library build, merges and splits the latter.
 6. **Browser and Node build faces declare externality independently.** A dynamic browser half uses the baseline plus `dsh.client.external`; a statically linked face externalizes every bare specifier; a Node face externalizes its production dependencies ([`tsdown.client.ts`](tsdown.client.ts)). Moving a name between npm sections must not silently change bundle contents.
 7. **Keep the published payload closed.** Every relative runtime import and emitted asset must be covered by `files`; the repository publint pass checks the exact publication view.

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

@@ -63,6 +63,16 @@ describe('next package benchmark graph', () => {
         ['@f/types', ['packages/g/probe/src/types.ts']],
       ]),
       hostRuntimeSourceUses: new Map([['@f/runtime', ['packages/g/probe/src/index.ts']]]),
+      hostRuntimeExportUses: [{
+        packageName: '@f/runtime',
+        specifier: '@f/runtime',
+        exportName: 'runtimeValue',
+        sourcePath: 'packages/g/probe/src/index.ts',
+        line: 1,
+        column: 10,
+        sourceLine: "import { runtimeValue } from '@f/runtime'",
+      }],
+      peerRequiredHostDependencies: new Set(),
       clientInject: new Set(),
     }
     const index = new Map<string, Map<string, MutableRegistryManifest>>([

+ 11 - 0
scripts/verify-client-packages.ts

@@ -100,6 +100,17 @@ export function collectLocalSourceSpecifiers(path: string, source: string): Set<
   return collectSourceFileUses(sourceFile, false, 'local')
 }
 
+/**
+ * Collect relative module specifiers retained by one production source file.
+ * @param path - File path used to select TypeScript's parser mode.
+ * @param source - Source text to inspect.
+ * @returns Relative imports, exports, and requires that survive compilation.
+ */
+export function collectRuntimeLocalSourceSpecifiers(path: string, source: string): Set<string> {
+  const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
+  return collectSourceFileUses(sourceFile, true, 'local')
+}
+
 function importCarriesRuntimeValue(node: ts.ImportDeclaration): boolean {
   const clause = node.importClause
   if (clause === undefined) return true

+ 190 - 5
scripts/verify-package-dependencies.spec.ts

@@ -7,10 +7,13 @@ import {
   type PackageDependencyPolicy,
 } from './package-dependency-policy.ts'
 import {
+  collectHostDependencyExportPolicyViolations,
   collectPackageDependencyViolations,
+  collectRuntimeSourceExportUses,
   discoverPackageDependencyScope,
   fixPackageDependencies,
   formatManagedRuntimeDependencies,
+  formatPeerRequiredRuntimeDependencies,
   readPackageDependencyFacts,
   repairPackageDependencyManifest,
   type PackageDependencyFacts,
@@ -43,6 +46,8 @@ function policy(fields: Partial<PackageDependencyPolicy> = {}): PackageDependenc
     clientFaceInclude: [],
     clientFaceExclude: [],
     hostPackages: [],
+    safeHostDependencyExports: {},
+    peerRequiredHostExports: {},
     ...fields,
   }
 }
@@ -66,6 +71,16 @@ function facts(manifest: PackageDependencyManifest): PackageDependencyFacts {
     hostRuntimeSourceUses: new Map([
       ['@deepseek-ai/dsh-runtime', ['packages/core/probe/src/index.ts']],
     ]),
+    hostRuntimeExportUses: [{
+      packageName: '@deepseek-ai/dsh-runtime',
+      specifier: '@deepseek-ai/dsh-runtime',
+      exportName: 'runtimeValue',
+      sourcePath: 'packages/core/probe/src/index.ts',
+      line: 1,
+      column: 10,
+      sourceLine: "import { runtimeValue } from '@deepseek-ai/dsh-runtime'",
+    }],
+    peerRequiredHostDependencies: new Set(),
     clientInject: new Set(),
   }
 }
@@ -74,11 +89,19 @@ describe('package dependency scope', () => {
   it('keeps the measured Host relay roster explicit', () => {
     expect(PACKAGE_DEPENDENCY_POLICY.clientFaceExclude).toEqual([
       '@deepseek-ai/dsh-api-session-controller',
+      '@deepseek-ai/dsh-api-workspace-controller',
     ])
     expect(PACKAGE_DEPENDENCY_POLICY.hostPackages).toEqual([
       '@deepseek-ai/dsh-llm',
       '@deepseek-ai/dsh-session',
     ])
+    expect(PACKAGE_DEPENDENCY_POLICY.safeHostDependencyExports['@deepseek-ai/schemastery']).toEqual(['default'])
+    expect(PACKAGE_DEPENDENCY_POLICY.peerRequiredHostExports['@deepseek-ai/dsh-scope']).toEqual([
+      'carrierKeyOf', 'scopeOf', 'scopeTarget',
+    ])
+    expect(PACKAGE_DEPENDENCY_POLICY.peerRequiredHostExports['@deepseek-ai/dsh-typert-protocol']).toEqual([
+      'TypertLookupFailure', 'TypertRemoteFailure', 'remoteMethods',
+    ])
   })
 
   it('discovers the Client directory, dsh.client declarations, and configured Host packages', () => {
@@ -103,7 +126,7 @@ describe('package dependency scope', () => {
       ['@f/dual', 'client-host'],
       ['@f/forced-client', 'client-host'],
       ['@f/dynamic-client', 'client-host'],
-      ['@f/static', 'client-host'],
+      ['@f/static', 'client-only'],
       ['@f/host', 'configured-host'],
     ])
   })
@@ -129,6 +152,49 @@ describe('package dependency scope', () => {
       expect.stringContaining('unknown release package @f/missing'),
     ]))
   })
+
+  it('rejects stale, duplicate, and unbounded safe Host export entries', () => {
+    const consumer = pkg('@f/consumer', 'packages/core/consumer/package.json')
+    const provider = pkg('@f/provider', 'packages/core/provider/package.json')
+    const workspaceNames = new Set([CORDIS, consumer.name, provider.name])
+    const consumerFacts: PackageDependencyFacts = {
+      manifestPath: consumer.manifestPath,
+      role: 'configured-host',
+      manifest: consumer.manifest,
+      workspaceNames,
+      allSourceUses: new Map(),
+      hostRuntimeSourceUses: new Map([[provider.name, ['packages/core/consumer/src/index.ts']]]),
+      hostRuntimeExportUses: [{
+        packageName: provider.name,
+        specifier: `${provider.name}/api`,
+        exportName: 'safeValue',
+        sourcePath: 'packages/core/consumer/src/index.ts',
+        line: 1,
+        column: 10,
+        sourceLine: "import { safeValue } from '@f/provider/api'",
+      }],
+      peerRequiredHostDependencies: new Set(),
+      clientInject: new Set(),
+    }
+
+    expect(collectHostDependencyExportPolicyViolations(
+      [consumerFacts],
+      workspaceNames,
+      {
+        safeHostDependencyExports: {
+          [`${provider.name}/api`]: ['safeValue', 'safeValue', '*', 'staleValue'],
+        },
+        peerRequiredHostExports: {
+          [`${provider.name}/api`]: ['safeValue'],
+        },
+      },
+    )).toEqual(expect.arrayContaining([
+      expect.stringContaining('export safeValue more than once'),
+      expect.stringContaining('cannot classify unbounded'),
+      expect.stringContaining('unused @f/provider/api export staleValue'),
+      expect.stringContaining('appears in both Host export classifications'),
+    ]))
+  })
 })
 
 describe('face-aware source classification', () => {
@@ -142,9 +208,11 @@ describe('face-aware source classification', () => {
       'packages/g/dual/src/index.ts': [
         "import { value } from '@f/runtime'",
         "import type { Shared } from '@f/types'",
+        "import type { Hidden } from './types.ts'",
         "export { nested } from './nested.ts'",
       ].join('\n'),
       'packages/g/dual/src/nested.ts': "export { nested } from '@f/nested'",
+      'packages/g/dual/src/types.ts': "import { hidden } from '@f/hidden'; export type Hidden = typeof hidden",
       'packages/g/dual/src/client/index.ts': "import { browser } from '@f/browser'",
     }
     for (const [path, source] of Object.entries(files)) {
@@ -153,11 +221,63 @@ describe('face-aware source classification', () => {
     }
 
     const found = readPackageDependencyFacts(root, subject, 'client-host', new Set([
-      CORDIS, '@f/runtime', '@f/types', '@f/nested', '@f/browser', '@f/injected',
+      CORDIS, '@f/runtime', '@f/types', '@f/nested', '@f/hidden', '@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'])
+    expect(found.hostRuntimeExportUses).toEqual([
+      {
+        packageName: '@f/nested',
+        specifier: '@f/nested',
+        exportName: 'nested',
+        sourcePath: 'packages/g/dual/src/nested.ts',
+        line: 1,
+        column: 10,
+        sourceLine: "export { nested } from '@f/nested'",
+      },
+      {
+        packageName: '@f/runtime',
+        specifier: '@f/runtime',
+        exportName: 'value',
+        sourcePath: 'packages/g/dual/src/index.ts',
+        line: 1,
+        column: 10,
+        sourceLine: "import { value } from '@f/runtime'",
+      },
+    ])
+    expect([...found.allSourceUses.keys()].sort()).toEqual([
+      '@f/browser', '@f/hidden', '@f/nested', '@f/runtime', '@f/types',
+    ])
+  })
+
+  it('identifies exact runtime exports without treating type imports as values', () => {
+    const source = [
+      "import defaultValue, { value as local, type Kind } from '@f/root'",
+      "import * as namespace from '@f/namespace'",
+      "import '@f/effect'",
+      "import type { TypeOnly } from '@f/types'",
+      "export { source as renamed, type SourceType } from '@f/reexport'",
+      "export * from '@f/star'",
+      "void import('@f/dynamic')",
+      "void require('@f/required')",
+      'void defaultValue; void local; void namespace',
+    ].join('\n')
+    const uses = collectRuntimeSourceExportUses('probe.ts', source)
+    expect(uses.map(({ specifier, exportName }) => ({ specifier, exportName }))).toEqual([
+      { specifier: '@f/dynamic', exportName: '*' },
+      { specifier: '@f/effect', exportName: '(side effect)' },
+      { specifier: '@f/namespace', exportName: '*' },
+      { specifier: '@f/reexport', exportName: 'source' },
+      { specifier: '@f/required', exportName: '*' },
+      { specifier: '@f/root', exportName: 'default' },
+      { specifier: '@f/root', exportName: 'value' },
+      { specifier: '@f/star', exportName: '*' },
+    ])
+    expect(uses.find(use => use.specifier === '@f/root' && use.exportName === 'value')).toMatchObject({
+      line: 1,
+      column: 24,
+      sourceLine: "import defaultValue, { value as local, type Kind } from '@f/root'",
+    })
   })
 })
 
@@ -186,8 +306,73 @@ describe('dependency sections', () => {
     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',
+      'verify-package-dependencies: 1 managed Host runtime edge(s) remain in dependencies across 1 package(s):',
+      '  @deepseek-ai/dsh-probe -> @deepseek-ai/dsh-runtime: @deepseek-ai/dsh-runtime#runtimeValue',
+    ])
+  })
+
+  it('reports an unapproved Host runtime export without rewriting its dependency section', () => {
+    const manifest: PackageDependencyManifest = {
+      name: '@deepseek-ai/dsh-probe',
+      dependencies: { '@deepseek-ai/dsh-runtime': 'workspace:^' },
+      devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-types': 'workspace:^' },
+      peerDependencies: { [CORDIS]: 'workspace:^' },
+    }
+    const subject = facts(manifest)
+    const safetyViolations = collectHostDependencyExportPolicyViolations(
+      [subject],
+      subject.workspaceNames,
+      { safeHostDependencyExports: {}, peerRequiredHostExports: {} },
+    )
+    const state = {
+      facts: [subject], packages: [], policyViolations: safetyViolations, workspaceNames: subject.workspaceNames,
+    }
+
+    expect(safetyViolations).toEqual([
+      'packages/core/probe/src/index.ts:1:10: @deepseek-ai/dsh-runtime#runtimeValue is not classified as '
+      + 'safe or peer-required — import { runtimeValue } from \'@deepseek-ai/dsh-runtime\'',
+    ])
+    expect(fixPackageDependencies('/unused', state)).toEqual([])
+    expect(manifest.dependencies).toEqual({ '@deepseek-ai/dsh-runtime': 'workspace:^' })
+  })
+
+  it('keeps an edge as a peer when one imported export requires shared identity', () => {
+    const manifest: PackageDependencyManifest = {
+      name: '@deepseek-ai/dsh-probe',
+      dependencies: { '@deepseek-ai/dsh-runtime': 'workspace:^' },
+      devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-types': 'workspace:^' },
+      peerDependencies: { [CORDIS]: 'workspace:^' },
+    }
+    const subject: PackageDependencyFacts = {
+      ...facts(manifest),
+      peerRequiredHostDependencies: new Set(['@deepseek-ai/dsh-runtime']),
+    }
+    expect(collectHostDependencyExportPolicyViolations(
+      [subject],
+      subject.workspaceNames,
+      {
+        safeHostDependencyExports: {},
+        peerRequiredHostExports: {
+          '@deepseek-ai/dsh-runtime': ['runtimeValue'],
+        },
+      },
+    )).toEqual([])
+
+    repairPackageDependencyManifest(subject)
+    expect(manifest.dependencies).toBeUndefined()
+    expect(manifest.peerDependencies).toMatchObject({
+      [CORDIS]: 'workspace:^',
+      '@deepseek-ai/dsh-runtime': 'workspace:^',
+    })
+    expect(manifest.devDependencies).toMatchObject({
+      [CORDIS]: 'workspace:^',
+      '@deepseek-ai/dsh-runtime': 'workspace:^',
+    })
+    expect(formatPeerRequiredRuntimeDependencies({
+      facts: [subject], packages: [], policyViolations: [], workspaceNames: subject.workspaceNames,
+    })).toEqual([
+      'verify-package-dependencies: 1 Host runtime edge(s) remain in peerDependencies because their exports require shared identity across 1 package(s):',
+      '  @deepseek-ai/dsh-probe -> @deepseek-ai/dsh-runtime: @deepseek-ai/dsh-runtime#runtimeValue',
     ])
   })
 

+ 260 - 38
scripts/verify-package-dependencies.ts

@@ -2,14 +2,14 @@
 
 import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
 import { dirname, extname, join, normalize, relative, resolve, sep } from 'node:path'
+import ts from 'typescript'
 import {
   hasClientDeclaration,
   PACKAGE_DEPENDENCY_POLICY,
   type PackageDependencyPolicy,
 } from './package-dependency-policy.ts'
 import {
-  collectLocalSourceSpecifiers,
-  collectRuntimeSourcePackageUses,
+  collectRuntimeLocalSourceSpecifiers,
   collectSourcePackageUses,
 } from './verify-client-packages.ts'
 
@@ -24,7 +24,7 @@ const WORKSPACE_MANIFEST_GLOBS = [
 ]
 
 type DependencySection = 'dependencies' | 'devDependencies' | 'optionalDependencies' | 'peerDependencies'
-export type PackageDependencyRole = 'client-host' | 'configured-host'
+export type PackageDependencyRole = 'client-only' | 'client-host' | 'configured-host'
 
 /** Manifest fields read and repaired by the package dependency policy. */
 export interface PackageDependencyManifest {
@@ -55,9 +55,22 @@ export interface PackageDependencyFacts {
   readonly workspaceNames: ReadonlySet<string>
   readonly allSourceUses: ReadonlyMap<string, readonly string[]>
   readonly hostRuntimeSourceUses: ReadonlyMap<string, readonly string[]>
+  readonly hostRuntimeExportUses: readonly HostRuntimeExportUse[]
+  readonly peerRequiredHostDependencies: ReadonlySet<string>
   readonly clientInject: ReadonlySet<string>
 }
 
+/** One runtime export reached from a package's Host source closure. */
+export interface HostRuntimeExportUse {
+  readonly packageName: string
+  readonly specifier: string
+  readonly exportName: string
+  readonly sourcePath: string
+  readonly line: number
+  readonly column: number
+  readonly sourceLine: string
+}
+
 /** Complete policy input read from the repository. */
 export interface PackageDependencyState {
   readonly facts: readonly PackageDependencyFacts[]
@@ -154,13 +167,13 @@ export function discoverPackageDependencyScope(
   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 clientHost = (hasClientDeclaration(pkg.manifest.dsh) || include.has(pkg.name)) && !exclude.has(pkg.name)
+    const clientOnly = clientDirectory && !clientHost
     const configuredHost = host.has(pkg.name)
-    if (configuredHost && clientHost) {
+    if (configuredHost && (clientHost || clientOnly)) {
       violations.push(`hostPackages redundantly names Client-faced package ${pkg.name}`)
     }
-    const role = clientHost ? 'client-host' : configuredHost ? 'configured-host' : undefined
+    const role = clientHost ? 'client-host' : clientOnly ? 'client-only' : configuredHost ? 'configured-host' : undefined
     if (role !== undefined) selected.push({ ...pkg, role })
   }
   return {
@@ -175,6 +188,95 @@ function addUse(target: Map<string, string[]>, name: string, path: string): void
   target.set(name, paths)
 }
 
+const NAMESPACE_RUNTIME_EXPORT = '*'
+const SIDE_EFFECT_RUNTIME_EXPORT = '(side effect)'
+
+interface RuntimeSourceExportUse {
+  readonly specifier: string
+  readonly exportName: string
+  readonly line: number
+  readonly column: number
+  readonly sourceLine: string
+}
+
+/** Collect exact runtime exports imported or re-exported by one source file. */
+export function collectRuntimeSourceExportUses(path: string, source: string): RuntimeSourceExportUse[] {
+  const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
+  const uses = new Map<string, RuntimeSourceExportUse>()
+  const sourceLines = source.split(/\r?\n/u)
+  const record = (specifier: string, exportName: string, locationNode: ts.Node): void => {
+    const key = `${specifier}\0${exportName}`
+    if (uses.has(key)) return
+    const position = sourceFile.getLineAndCharacterOfPosition(locationNode.getStart(sourceFile))
+    uses.set(key, {
+      specifier,
+      exportName,
+      line: position.line + 1,
+      column: position.character + 1,
+      sourceLine: sourceLines[position.line]?.trim() ?? '',
+    })
+  }
+  const add = (
+    specifierNode: ts.Expression | undefined,
+    exportName: string,
+    locationNode: ts.Node = specifierNode ?? sourceFile,
+  ): void => {
+    if (specifierNode === undefined || !ts.isStringLiteralLike(specifierNode)) return
+    if (packageNameOf(specifierNode.text) === undefined) return
+    record(specifierNode.text, exportName, locationNode)
+  }
+  const visit = (node: ts.Node): void => {
+    if (ts.isImportDeclaration(node)) {
+      const clause = node.importClause
+      if (clause === undefined) {
+        add(node.moduleSpecifier, SIDE_EFFECT_RUNTIME_EXPORT)
+      } else if (clause.phaseModifier !== ts.SyntaxKind.TypeKeyword) {
+        if (clause.name !== undefined) add(node.moduleSpecifier, 'default', clause.name)
+        const bindings = clause.namedBindings
+        if (bindings !== undefined && ts.isNamespaceImport(bindings)) {
+          add(node.moduleSpecifier, NAMESPACE_RUNTIME_EXPORT, bindings.name)
+        } else if (bindings !== undefined && bindings.elements.length === 0) {
+          add(node.moduleSpecifier, SIDE_EFFECT_RUNTIME_EXPORT)
+        } else if (bindings !== undefined) {
+          for (const element of bindings.elements) {
+            const imported = element.propertyName ?? element.name
+            if (!element.isTypeOnly) add(node.moduleSpecifier, imported.text, imported)
+          }
+        }
+      }
+    } else if (ts.isExportDeclaration(node) && !node.isTypeOnly) {
+      const clause = node.exportClause
+      if (clause === undefined || ts.isNamespaceExport(clause)) {
+        add(node.moduleSpecifier, NAMESPACE_RUNTIME_EXPORT)
+      } else if (clause.elements.length === 0) {
+        add(node.moduleSpecifier, SIDE_EFFECT_RUNTIME_EXPORT)
+      } else {
+        for (const element of clause.elements) {
+          const imported = element.propertyName ?? element.name
+          if (!element.isTypeOnly) add(node.moduleSpecifier, imported.text, imported)
+        }
+      }
+    } else if (ts.isImportEqualsDeclaration(node)
+      && !node.isTypeOnly
+      && ts.isExternalModuleReference(node.moduleReference)) {
+      add(node.moduleReference.expression, NAMESPACE_RUNTIME_EXPORT, node.name)
+    } else if (ts.isCallExpression(node)
+      && (node.expression.kind === ts.SyntaxKind.ImportKeyword
+        || ts.isIdentifier(node.expression) && node.expression.text === 'require')) {
+      add(node.arguments[0], NAMESPACE_RUNTIME_EXPORT)
+    } else if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) {
+      record('react/jsx-runtime', NAMESPACE_RUNTIME_EXPORT, node)
+    }
+    ts.forEachChild(node, visit)
+  }
+  visit(sourceFile)
+  return [...uses.values()].sort((left, right) =>
+    left.specifier.localeCompare(right.specifier)
+    || left.exportName.localeCompare(right.exportName)
+    || left.line - right.line
+    || left.column - right.column)
+}
+
 function resolveLocal(importer: string, specifier: string): string | undefined {
   const raw = resolve(dirname(importer), specifier)
   const candidates = extname(raw) === ''
@@ -183,8 +285,12 @@ function resolveLocal(importer: string, specifier: string): string | undefined {
   return candidates.find(candidate => existsSync(candidate))
 }
 
-function readHostRuntimeUses(root: string, pkg: WorkspacePackageManifest): Map<string, string[]> {
-  const uses = new Map<string, string[]>()
+function readHostRuntimeUses(root: string, pkg: WorkspacePackageManifest): {
+  packageUses: Map<string, string[]>
+  exportUses: HostRuntimeExportUse[]
+} {
+  const packageUses = new Map<string, string[]>()
+  const exportUses = new Map<string, HostRuntimeExportUse>()
   const seen = new Set<string>()
   const visit = (path: string): void => {
     const normalized = normalize(path)
@@ -192,14 +298,29 @@ function readHostRuntimeUses(root: string, pkg: WorkspacePackageManifest): Map<s
     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)) {
+    for (const use of collectRuntimeSourceExportUses(normalized, source)) {
+      const name = packageNameOf(use.specifier)
+      if (name === undefined) continue
+      addUse(packageUses, name, displayPath)
+      const fact = { packageName: name, ...use, sourcePath: displayPath }
+      exportUses.set(`${use.specifier}\0${use.exportName}\0${displayPath}\0${String(use.line)}\0${String(use.column)}`, fact)
+    }
+    for (const specifier of collectRuntimeLocalSourceSpecifiers(normalized, source)) {
       const target = resolveLocal(normalized, specifier)
       if (target !== undefined) visit(target)
     }
   }
   visit(resolve(root, pkg.dir, 'src/index.ts'))
-  return uses
+  return {
+    packageUses,
+    exportUses: [...exportUses.values()].sort((left, right) =>
+      left.packageName.localeCompare(right.packageName)
+      || left.specifier.localeCompare(right.specifier)
+      || left.exportName.localeCompare(right.exportName)
+      || left.sourcePath.localeCompare(right.sourcePath)
+      || left.line - right.line
+      || left.column - right.column),
+  }
 }
 
 function readAllSourceUses(root: string, pkg: WorkspacePackageManifest): Map<string, string[]> {
@@ -218,19 +339,82 @@ export function readPackageDependencyFacts(
   pkg: WorkspacePackageManifest,
   role: PackageDependencyRole,
   workspaceNames: ReadonlySet<string>,
+  policy: PackageDependencyPolicy = PACKAGE_DEPENDENCY_POLICY,
 ): PackageDependencyFacts {
   const inject = pkg.manifest.dsh?.client?.inject ?? []
+  const hostRuntime = role === 'client-only'
+    ? { packageUses: new Map<string, string[]>(), exportUses: [] }
+    : readHostRuntimeUses(root, pkg)
   return {
     manifestPath: pkg.manifestPath,
     role,
     manifest: pkg.manifest,
     workspaceNames,
     allSourceUses: readAllSourceUses(root, pkg),
-    hostRuntimeSourceUses: readHostRuntimeUses(root, pkg),
+    hostRuntimeSourceUses: hostRuntime.packageUses,
+    hostRuntimeExportUses: hostRuntime.exportUses,
+    peerRequiredHostDependencies: new Set(hostRuntime.exportUses
+      .filter(use => policy.peerRequiredHostExports[use.specifier]?.includes(use.exportName) === true)
+      .map(use => use.packageName)),
     clientInject: new Set(inject.map(packageNameOf).filter(name => name !== undefined)),
   }
 }
 
+/** Validate reviewed Host export classifications against current source facts. */
+export function collectHostDependencyExportPolicyViolations(
+  facts: readonly PackageDependencyFacts[],
+  workspaceNames: ReadonlySet<string>,
+  policy: Pick<PackageDependencyPolicy, 'peerRequiredHostExports' | 'safeHostDependencyExports'>,
+): string[] {
+  const violations: string[] = []
+  const allRuntimeUses = facts.flatMap(fact => fact.hostRuntimeExportUses)
+  const classifications = [
+    ['safeHostDependencyExports', policy.safeHostDependencyExports],
+    ['peerRequiredHostExports', policy.peerRequiredHostExports],
+  ] as const
+  for (const [field, entries] of classifications) {
+    for (const [specifier, exportNames] of Object.entries(entries)) {
+      const provider = packageNameOf(specifier)
+      if (provider === undefined || !workspaceNames.has(provider)) {
+        violations.push(`${field} specifier ${specifier} is not a workspace package`)
+      }
+      if (exportNames.length === 0) {
+        violations.push(`${field} lists no exports for ${specifier}`)
+      }
+      for (const exportName of duplicates(exportNames)) {
+        violations.push(`${field} lists ${specifier} export ${exportName} more than once`)
+      }
+      for (const exportName of exportNames) {
+        if (exportName === '' || exportName === NAMESPACE_RUNTIME_EXPORT || exportName === SIDE_EFFECT_RUNTIME_EXPORT) {
+          violations.push(`${field} cannot classify unbounded ${specifier} export ${exportName}`)
+          continue
+        }
+        if (!allRuntimeUses.some(use => use.specifier === specifier && use.exportName === exportName)) {
+          violations.push(`${field} lists unused ${specifier} export ${exportName}`)
+        }
+        if (field === 'safeHostDependencyExports'
+          && policy.peerRequiredHostExports[specifier]?.includes(exportName) === true) {
+          violations.push(`${specifier} export ${exportName} appears in both Host export classifications`)
+        }
+      }
+    }
+  }
+
+  for (const fact of facts) {
+    for (const use of fact.hostRuntimeExportUses) {
+      if (use.packageName === fact.manifest.name || use.packageName === CORDIS) continue
+      if (!workspaceNames.has(use.packageName) && fact.manifest.peerDependencies?.[use.packageName] === undefined) continue
+      if (policy.safeHostDependencyExports[use.specifier]?.includes(use.exportName) === true) continue
+      if (policy.peerRequiredHostExports[use.specifier]?.includes(use.exportName) === true) continue
+      violations.push(
+        `${use.sourcePath}:${String(use.line)}:${String(use.column)}: `
+        + `${use.specifier}#${use.exportName} is not classified as safe or peer-required — ${use.sourceLine}`,
+      )
+    }
+  }
+  return violations.sort()
+}
+
 /** Read every package covered by the current dependency policy. */
 export function readPackageDependencyState(
   root: string,
@@ -239,10 +423,15 @@ export function readPackageDependencyState(
   const packages = readWorkspacePackageManifests(root)
   const workspaceNames = new Set(packages.all.map(pkg => pkg.name))
   const discovered = discoverPackageDependencyScope(packages.release, policy)
+  const facts = discovered.selected.map(pkg =>
+    readPackageDependencyFacts(root, pkg, pkg.role, workspaceNames, policy))
   return {
-    facts: discovered.selected.map(pkg => readPackageDependencyFacts(root, pkg, pkg.role, workspaceNames)),
+    facts,
     packages: packages.release,
-    policyViolations: discovered.violations,
+    policyViolations: [
+      ...discovered.violations,
+      ...collectHostDependencyExportPolicyViolations(facts, workspaceNames, policy),
+    ].sort(),
     workspaceNames,
   }
 }
@@ -255,9 +444,11 @@ export function expectedPackageDependencies(
   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'
+    const section = current?.section === 'peer-dev' || sectionName === 'peer-dev'
+      ? 'peer-dev'
+      : current?.section === 'dependencies' || sectionName === 'dependencies'
+        ? 'dependencies'
+        : 'devDependencies'
     expected.set(name, { section, origins: new Set([...(current?.origins ?? []), origin]) })
   }
 
@@ -274,7 +465,8 @@ export function expectedPackageDependencies(
   }
   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)
+    const expectedSection = facts.peerRequiredHostDependencies.has(name) ? 'peer-dev' : 'dependencies'
+    for (const path of paths) add(name, expectedSection, path)
   }
   return new Map([...expected].map(([name, rule]) => [name, {
     section: rule.section,
@@ -282,23 +474,46 @@ export function expectedPackageDependencies(
   }]))
 }
 
-/** Format the managed Host runtime edges that remain ordinary dependencies. */
+interface ManagedRuntimeEdge {
+  readonly consumer: string
+  readonly dependency: string
+  readonly exports: readonly string[]
+}
+
+function managedRuntimeEdges(
+  state: PackageDependencyState,
+  expectedSection: 'dependencies' | 'peer-dev',
+): ManagedRuntimeEdge[] {
+  return state.facts.flatMap(facts => [...expectedPackageDependencies(facts)]
+    .filter(([name, rule]) => name !== CORDIS && rule.section === expectedSection)
+    .map(([dependency]) => ({
+      consumer: facts.manifest.name ?? facts.manifestPath,
+      dependency,
+      exports: [...new Set(facts.hostRuntimeExportUses
+        .filter(use => use.packageName === dependency)
+        .map(use => `${use.specifier}#${use.exportName}`))].sort(),
+    })))
+    .sort((left, right) =>
+      left.consumer.localeCompare(right.consumer) || left.dependency.localeCompare(right.dependency))
+}
+
+/** Format Host runtime edges whose reviewed exports permit 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)
+  const rows = managedRuntimeEdges(state, 'dependencies')
+  const packages = new Set(rows.map(row => row.consumer)).size
   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(', ')}`),
+    `${GATE}: ${String(rows.length)} managed Host runtime edge(s) remain in dependencies across ${String(packages)} package(s):`,
+    ...rows.map(row => `  ${row.consumer} -> ${row.dependency}: ${row.exports.join(', ')}`),
+  ]
+}
+
+/** Format Host runtime edges retained as peers by their imported export classification. */
+export function formatPeerRequiredRuntimeDependencies(state: PackageDependencyState): string[] {
+  const rows = managedRuntimeEdges(state, 'peer-dev')
+  const packages = new Set(rows.map(row => row.consumer)).size
+  return [
+    `${GATE}: ${String(rows.length)} Host runtime edge(s) remain in peerDependencies because their exports require shared identity across ${String(packages)} package(s):`,
+    ...rows.map(row => `  ${row.consumer} -> ${row.dependency}: ${row.exports.join(', ')}`),
   ]
 }
 
@@ -323,6 +538,7 @@ function describeSections(sections: readonly DependencySection[]): string {
 /** Return all manifest and policy violations in stable order. */
 export function collectPackageDependencyViolations(state: PackageDependencyState): string[] {
   const violations = [...state.policyViolations]
+  if (violations.length > 0) return [...new Set(violations)].sort()
   for (const facts of state.facts) {
     for (const [name, rule] of expectedPackageDependencies(facts)) {
       const actual = declaredSections(facts.manifest, name)
@@ -454,9 +670,13 @@ function main(): void {
   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)
+    if (state.policyViolations.length > 0) {
+      console.error(`${GATE}: --fix skipped because dependency policy review failed.`)
+    } else {
+      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) {
@@ -468,11 +688,13 @@ function main(): void {
   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['client-only']?.length ?? 0)} Client-only,`
+    + ` ${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)
+    for (const line of formatPeerRequiredRuntimeDependencies(state)) console.log(line)
   }
 }