From c55beac34adfe8696bf949704fa2327202ecff7e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:06:46 +0800 Subject: [PATCH] feat: verify Host dependency export identity --- ...08-26-published-dependency-faces.i18n.yaml | 4 +- .../2026-08-26-published-dependency-faces.md | 14 +- ...026-08-26-published-dependency-faces.zh.md | 14 +- packages/client/AGENTS.md | 2 +- .../benchmark-next-package-dependency.spec.ts | 10 + scripts/verify-client-packages.ts | 11 + scripts/verify-package-dependencies.spec.ts | 195 +++++++++++- scripts/verify-package-dependencies.ts | 298 +++++++++++++++--- 8 files changed, 490 insertions(+), 58 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml index 2056e59e6e..433b12fa8c 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-26-published-dependency-faces.md -2026-08-26-published-dependency-faces.md: 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 diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md index 437044a0dc..04b02afe37 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md @@ -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. diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md index b8398a9168..468fb443e2 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md @@ -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 是诊断证据,不是发布时安装耗时承诺。 diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index cf7964203b..6ef0c5e42c 100644 --- a/packages/client/AGENTS.md +++ b/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. diff --git a/scripts/benchmark-next-package-dependency.spec.ts b/scripts/benchmark-next-package-dependency.spec.ts index 9248a3288e..2a0ee10189 100644 --- a/scripts/benchmark-next-package-dependency.spec.ts +++ b/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>([ diff --git a/scripts/verify-client-packages.ts b/scripts/verify-client-packages.ts index 94e70497ae..9dfb1599cb 100644 --- a/scripts/verify-client-packages.ts +++ b/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 { + 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 diff --git a/scripts/verify-package-dependencies.spec.ts b/scripts/verify-package-dependencies.spec.ts index 253a5ea7c3..23c1bbca69 100644 --- a/scripts/verify-package-dependencies.spec.ts +++ b/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 = {}): 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', ]) }) diff --git a/scripts/verify-package-dependencies.ts b/scripts/verify-package-dependencies.ts index fda6184864..f8ac1cf3dd 100644 --- a/scripts/verify-package-dependencies.ts +++ b/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 readonly allSourceUses: ReadonlyMap readonly hostRuntimeSourceUses: ReadonlyMap + readonly hostRuntimeExportUses: readonly HostRuntimeExportUse[] + readonly peerRequiredHostDependencies: ReadonlySet readonly clientInject: ReadonlySet } +/** 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 = [] 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, 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() + 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 { - const uses = new Map() +function readHostRuntimeUses(root: string, pkg: WorkspacePackageManifest): { + packageUses: Map + exportUses: HostRuntimeExportUse[] +} { + const packageUses = new Map() + const exportUses = new Map() const seen = new Set() const visit = (path: string): void => { const normalized = normalize(path) @@ -192,14 +298,29 @@ function readHostRuntimeUses(root: string, pkg: WorkspacePackageManifest): Map + 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 { @@ -218,19 +339,82 @@ export function readPackageDependencyFacts( pkg: WorkspacePackageManifest, role: PackageDependencyRole, workspaceNames: ReadonlySet, + policy: PackageDependencyPolicy = PACKAGE_DEPENDENCY_POLICY, ): PackageDependencyFacts { const inject = pkg.manifest.dsh?.client?.inject ?? [] + const hostRuntime = role === 'client-only' + ? { packageUses: new Map(), 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, + policy: Pick, +): 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) } }