feat: verify Host dependency export identity
This commit is contained in:
parent
e440634456
commit
c55beac34a
8 changed files with 490 additions and 58 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 是诊断证据,不是发布时安装耗时承诺。
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>>([
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
])
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue