fix: complete dependency policy generation

This commit is contained in:
imccyu 2026-08-26 23:52:19 +08:00
parent 943a544899
commit 91b5a01980
9 changed files with 191 additions and 58 deletions

View file

@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # 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 # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-26-published-dependency-faces.md
2026-08-26-published-dependency-faces.md: a4c745e8bc811027791317b334d285274f24dd20 2026-08-26-published-dependency-faces.md: 6174e2a46482362c0ff54b5dec261440ce4f6131
2026-08-26-published-dependency-faces.zh.md: 73b8dd28f40a63ea2a13544be535be7832138ba2 2026-08-26-published-dependency-faces.zh.md: 090031e9cde07d75aae79a87207d879fe220257d

View file

@ -28,6 +28,8 @@ A workspace package reached by a runtime value import from the Host entry closur
Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, invariant companions, and existing metadata-only peers belong only in `devDependencies`. Existing third-party dependencies outside these managed relationships keep their declared section. Workspace references use `workspace:^`. Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, invariant companions, and existing metadata-only peers belong only in `devDependencies`. Existing third-party dependencies outside these managed relationships keep their declared section. Workspace references use `workspace:^`.
Some development relationships exist only in `dsh.client.inject` or TypeScript project references. The policy's `configurationOnlyDevDependencies` table names only those reviewed edges and keeps them in `devDependencies`.
The verifier reads source manifests and source files, so it runs on a clean tree without built `lib/`. An unclassified Host runtime export is a policy violation that blocks all `--fix` writes; a maintainer must review the export and classify it, change the source relationship, or change the package selection. Once source safety passes, `--fix` performs only the section and range changes implied by the classification and removes stale peer metadata. The verifier reads source manifests and source files, so it runs on a clean tree without built `lib/`. An unclassified Host runtime export is a policy violation that blocks all `--fix` writes; a maintainer must review the export and classify it, change the source relationship, or change the package selection. Once source safety passes, `--fix` performs only the section and range changes implied by the classification and removes stale peer metadata.
### Maintainer workflow ### Maintainer workflow
@ -40,12 +42,11 @@ pnpm run verify-package-dependencies
Classify each new Host runtime export in [`package-dependency-policy.ts`](../../../../scripts/package-dependency-policy.ts) before generating manifests. `safeHostDependencyExports` permits an ordinary dependency; `peerRequiredHostExports` keeps the whole provider package edge in matching peer and development sections. An export may appear in exactly one table. After refactoring a peer-required export so duplicate package copies are safe, move that exact specifier and export to the safe table; an edge becomes an ordinary dependency only after none of its imported exports remain peer-required. Classify each new Host runtime export in [`package-dependency-policy.ts`](../../../../scripts/package-dependency-policy.ts) before generating manifests. `safeHostDependencyExports` permits an ordinary dependency; `peerRequiredHostExports` keeps the whole provider package edge in matching peer and development sections. An export may appear in exactly one table. After refactoring a peer-required export so duplicate package copies are safe, move that exact specifier and export to the safe table; an edge becomes an ordinary dependency only after none of its imported exports remain peer-required.
Generate managed manifest sections, refresh the lockfile separately, and review both results. `--fix` writes nothing while a policy violation exists; after success it prints the ordinary-dependency and peer-required edge lists, but does not update the lockfile. Generate the managed manifests and every directly derived artifact with one command. `--fix` writes nothing while a policy violation exists; after success it refreshes `pnpm-lock.yaml`, regenerates both module-graph languages and their pairing record, and prints the ordinary-dependency and peer-required edge lists.
```sh ```sh
pnpm run verify-package-dependencies -- --fix pnpm run verify-package-dependencies -- --fix
pnpm install --lockfile-only git diff -- packages pnpm-lock.yaml docs/module-graph.md docs/module-graph.zh.md docs/module-graph.i18n.yaml
git diff -- packages pnpm-lock.yaml
``` ```
Measure the working-tree graph and a Git ref through the local metadata-only registry. Each run creates a fresh consumer and npm cache, executes `npm install --package-lock-only`, rejects archive downloads, and leaves the repository unchanged. `--runs` controls repetitions, `--timeout-ms` bounds each run, and optional `--max-ms` makes the command fail when the slowest run exceeds a threshold. Measure the working-tree graph and a Git ref through the local metadata-only registry. Each run creates a fresh consumer and npm cache, executes `npm install --package-lock-only`, rejects archive downloads, and leaves the repository unchanged. `--runs` controls repetitions, `--timeout-ms` bounds each run, and optional `--max-ms` makes the command fail when the slowest run exceeds a threshold.

View file

@ -28,6 +28,8 @@ Host 入口闭包中的运行期 value import 所到达的 workspace 包,只
Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject`、invariant companion 和仅有元数据的现存 peer 只属于 `devDependencies`。不属于这些受管关系的现有第三方 dependency 保持原区段。Workspace 引用使用 `workspace:^`。 Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject`、invariant companion 和仅有元数据的现存 peer 只属于 `devDependencies`。不属于这些受管关系的现有第三方 dependency 保持原区段。Workspace 引用使用 `workspace:^`。
部分开发期关系只存在于 `dsh.client.inject` 或 TypeScript project reference 中。策略的 `configurationOnlyDevDependencies` 表只列出这些已评审的依赖边,并将它们保留在 `devDependencies` 中。
验证器读取源码 manifest 和源码文件,因此可以在没有已构建 `lib/` 的干净工作树上运行。未分类的 Host 运行期导出属于策略违规,会阻止 `--fix` 的全部写入;维护者必须审查该导出,并选择分类该导出、修改源码关系或修改选包范围。源码安全检查通过后,`--fix` 只执行分类所确定的区段与范围变更,并删除失效的 peer 元数据。 验证器读取源码 manifest 和源码文件,因此可以在没有已构建 `lib/` 的干净工作树上运行。未分类的 Host 运行期导出属于策略违规,会阻止 `--fix` 的全部写入;维护者必须审查该导出,并选择分类该导出、修改源码关系或修改选包范围。源码安全检查通过后,`--fix` 只执行分类所确定的区段与范围变更,并删除失效的 peer 元数据。
### 维护流程 ### 维护流程
@ -40,12 +42,11 @@ pnpm run verify-package-dependencies
生成 manifest 前,在 [`package-dependency-policy.ts`](../../../../scripts/package-dependency-policy.ts) 中分类每个新增 Host 运行期导出。`safeHostDependencyExports` 允许普通 dependency;`peerRequiredHostExports` 让整个提供包依赖边保留在范围一致的 peer 与开发区段。一个导出只能出现在一个表中。把 peer-required 导出重构到重复安装安全后,将该精确 specifier 与导出移入 safe 表;只有当一条依赖边的所有 import 都不再使用 peer-required 导出时,它才会成为普通 dependency。 生成 manifest 前,在 [`package-dependency-policy.ts`](../../../../scripts/package-dependency-policy.ts) 中分类每个新增 Host 运行期导出。`safeHostDependencyExports` 允许普通 dependency;`peerRequiredHostExports` 让整个提供包依赖边保留在范围一致的 peer 与开发区段。一个导出只能出现在一个表中。把 peer-required 导出重构到重复安装安全后,将该精确 specifier 与导出移入 safe 表;只有当一条依赖边的所有 import 都不再使用 peer-required 导出时,它才会成为普通 dependency。
生成受管 manifest 区段后,单独刷新 lockfile,并审查两部分结果。存在策略违规时,`--fix` 不写任何文件;成功后,它会分别打印普通 dependency 与 peer-required 依赖边,但不会更新 lockfile。 用一条命令生成受管 manifest 和所有直接派生产物。存在策略违规时,`--fix` 不写任何文件;成功后,它会刷新 `pnpm-lock.yaml`、重新生成中英文 module graph 及其配对记录,并打印普通 dependency 与 peer-required 依赖边。
```sh ```sh
pnpm run verify-package-dependencies -- --fix pnpm run verify-package-dependencies -- --fix
pnpm install --lockfile-only git diff -- packages pnpm-lock.yaml docs/module-graph.md docs/module-graph.zh.md docs/module-graph.i18n.yaml
git diff -- packages pnpm-lock.yaml
``` ```
通过仅 metadata 的本地 registry 测量工作树依赖图与 Git ref。每轮都会创建全新 consumer 与 npm cache,执行 `npm install --package-lock-only`,拒绝下载包归档,并保持仓库不变。`--runs` 控制重复次数,`--timeout-ms` 限制单轮耗时,可选 `--max-ms` 会在最慢一轮超过阈值时让命令失败。 通过仅 metadata 的本地 registry 测量工作树依赖图与 Git ref。每轮都会创建全新 consumer 与 npm cache,执行 `npm install --package-lock-only`,拒绝下载包归档,并保持仓库不变。`--runs` 控制重复次数,`--timeout-ms` 限制单轮耗时,可选 `--max-ms` 会在最慢一轮超过阈值时让命令失败。

View file

@ -60,7 +60,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. 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. 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`. 3. **Browser and type relationships are development-only.** Client imports, type-only imports, module augmentations, TypeScript project references, `dsh.client.inject`, invariant companions, and metadata-only peers belong only in `devDependencies`. Configuration-only entries that Knip cannot infer from imports are listed in the dependency policy and projected into `knip.json` by `--fix`.
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. 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. 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. 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.

View file

@ -1,21 +1,21 @@
/** /** Generate the paired shared-instance package graph from workspace peer dependencies. */
* Generate `docs/module-graph.md` from in-repo `peerDependencies`, the canonical
* runtime edges. The deterministic output groups packages by directory and
* renders both Mermaid and a dependency table; `--check` verifies freshness.
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { readFileSync, writeFileSync } from 'node:fs'
import { import {
collectPackageGraph, collectPackageGraph,
escapeMermaidLabel as escLabel, escapeMermaidLabel as escLabel,
graphNodeId as nodeId, graphNodeId as nodeId,
type PackageGraphNode, type PackageGraphNode,
} from './package-graph.ts' } from './package-graph.ts'
import { gitBlobHash, storeGitBlob } from './translation-pairing-git.ts'
import { renderTranslationPairingRecord, translationPairPaths } from './translation-pairing-record.ts'
const root = resolve(import.meta.dirname, '..') const root = resolve(import.meta.dirname, '..')
const OUT = 'docs/module-graph.md' const SOURCE = 'docs/module-graph.md'
const PATHS = translationPairPaths(SOURCE)
type Pkg = PackageGraphNode type Pkg = PackageGraphNode
type Locale = 'en' | 'zh'
const GROUP_ORDER = [ const GROUP_ORDER = [
'util', 'util',
@ -46,42 +46,55 @@ function packageLink(pkg: Pkg): string {
return `[\`${pkg.short}\`](../${pkg.rel})` return `[\`${pkg.short}\`](../${pkg.rel})`
} }
/** Render the full docs/module-graph.md content (pure, deterministic). */ /**
function render(pkgs: Pkg[]): string { * Render one locale of the complete deterministic package graph.
* @param pkgs - Dependency-first package nodes.
* @param locale - Output document language.
* @returns Complete generated Markdown.
*/
export function renderModuleGraph(pkgs: readonly Pkg[], locale: Locale): string {
const edges: string[] = [] const edges: string[] = []
for (const p of pkgs) { for (const pkg of pkgs) {
for (const d of p.deps) edges.push(` ${nodeId('pkg', p.short)} --> ${nodeId('pkg', d)}`) for (const dependency of pkg.deps) edges.push(` ${nodeId('pkg', pkg.short)} --> ${nodeId('pkg', dependency)}`)
} }
const byShort = new Map(pkgs.map(pkg => [pkg.short, pkg])) const byShort = new Map(pkgs.map(pkg => [pkg.short, pkg]))
const groups = [...new Set(pkgs.map(pkg => pkg.group))].sort((a, b) => { const groups = [...new Set(pkgs.map(pkg => pkg.group))].sort((left, right) => {
const ia = GROUP_ORDER.indexOf(a) const leftIndex = GROUP_ORDER.indexOf(left)
const ib = GROUP_ORDER.indexOf(b) const rightIndex = GROUP_ORDER.indexOf(right)
const na = ia === -1 ? Number.MAX_SAFE_INTEGER : ia const normalizedLeft = leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex
const nb = ib === -1 ? Number.MAX_SAFE_INTEGER : ib const normalizedRight = rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex
return na - nb || a.localeCompare(b) return normalizedLeft - normalizedRight || left.localeCompare(right)
}) })
const groupBlocks: string[] = [] const groupBlocks: string[] = []
for (const group of groups) { for (const group of groups) {
groupBlocks.push(` subgraph ${nodeId('group', group)}["packages/${escLabel(group)}"]`) groupBlocks.push(` subgraph ${nodeId('group', group)}["packages/${escLabel(group)}"]`)
for (const pkg of pkgs.filter(p => p.group === group).sort((a, b) => a.short.localeCompare(b.short))) { for (const pkg of pkgs.filter(candidate => candidate.group === group)
.sort((left, right) => left.short.localeCompare(right.short))) {
groupBlocks.push(` ${nodeId('pkg', pkg.short)}["${escLabel(pkg.short)}"]`) groupBlocks.push(` ${nodeId('pkg', pkg.short)}["${escLabel(pkg.short)}"]`)
} }
groupBlocks.push(' end') groupBlocks.push(' end')
} }
const rows = pkgs.map((p) => { const rows = pkgs.map((pkg) => {
const deps = p.deps.length ? p.deps.map((d) => { const dependencies = pkg.deps.length > 0
const dep = byShort.get(d) ? pkg.deps.map((dependency) => {
return dep ? packageLink(dep) : `\`${d}\`` const target = byShort.get(dependency)
}).join(', ') : '—' return target ? packageLink(target) : `\`${dependency}\``
return `| ${packageLink(p)} | \`${p.group}\` | ${deps} |` }).join(', ')
: '—'
return `| ${packageLink(pkg)} | \`${pkg.group}\` | ${dependencies} |`
}) })
const chinese = locale === 'zh'
return [ return [
'<!-- Generated by scripts/gen-module-graph.ts — do not edit by hand.', chinese
' Run `pnpm run gen-module-graph` to regenerate. -->', ? '<!-- 由 scripts/gen-module-graph.ts 生成——请勿手工编辑。\n 运行 `pnpm run gen-module-graph` 重新生成。 -->'
: '<!-- Generated by scripts/gen-module-graph.ts — do not edit by hand.\n Run `pnpm run gen-module-graph` to regenerate. -->',
'', '',
'# Module dependency graph', chinese ? '# 共享实例依赖关系图' : '# Shared-instance dependency graph',
'', '',
'Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, derived from each package\'s `peerDependencies` (the canonical runtime-dependency signal) and grouped by the `packages/<group>/<pkg>` hierarchy. An edge `a --> b` means package `a` depends on package `b`. Names have the `@deepseek-ai/dsh-` prefix stripped.', ...(chinese ? ['[English](module-graph.md) | 中文', ''] : []),
chinese
? '`@deepseek-ai/dsh-*` harness 包之间的 peer 依赖关系。peer 表示消费端需要提供共享实例,不包括普通运行时 dependency 或仅开发期关系。该图按 `packages/<group>/<pkg>` 层级分组;边 `a --> b` 表示包 `a` peer 依赖包 `b`。名称中的 `@deepseek-ai/dsh-` 前缀已移除。'
: 'Peer dependencies among the `@deepseek-ai/dsh-*` harness packages. A peer means the consumer requires a shared instance; ordinary runtime dependencies and development-only relationships are not shown. The graph is grouped by the `packages/<group>/<pkg>` hierarchy. An edge `a --> b` means package `a` has package `b` as a peer. Names omit the `@deepseek-ai/dsh-` prefix.',
'', '',
'```mermaid', '```mermaid',
'flowchart TD', 'flowchart TD',
@ -89,31 +102,76 @@ function render(pkgs: Pkg[]): string {
...edges, ...edges,
'```', '```',
'', '',
'| Package | Group | Depends on |', chinese ? '| 包 | 分组 | Peer 依赖 |' : '| Package | Group | Peer dependencies |',
'| --- | --- | --- |', '| --- | --- | --- |',
...rows, ...rows,
'', '',
].join('\n') ].join('\n')
} }
const content = render(collectPackageGraph(root, GROUP_ORDER, 'gen-module-graph')) /**
* Compute both localized graph documents from the current workspace manifests.
if (process.argv.includes('--check')) { * @param scanRoot - Repository root containing packages and documentation.
let committed: string | null = null * @returns Repository-relative output paths and exact generated content.
try { */
committed = readFileSync(resolve(root, OUT), 'utf8') export function computeModuleGraphOutputs(scanRoot: string = root): ReadonlyMap<string, string> {
} catch { const packages = collectPackageGraph(scanRoot, GROUP_ORDER, 'gen-module-graph')
// A missing artifact is the expected read failure. Any read failure has the return new Map([
// same remedy here—regenerate—so it is reported as stale below. [PATHS.source, renderModuleGraph(packages, 'en')],
committed = null [PATHS.zh, renderModuleGraph(packages, 'zh')],
} ])
if (committed === content) {
console.log(`gen-module-graph: ${OUT} is up to date.`)
process.exit(0)
}
console.error(`gen-module-graph: ${OUT} is stale. Run \`pnpm run gen-module-graph\` and commit ${OUT}.`)
process.exit(1)
} }
writeFileSync(resolve(root, OUT), content) /**
console.log(`gen-module-graph: wrote ${OUT}.`) * Write both graph documents and their recovery record.
* @param scanRoot - Repository root containing packages and documentation.
* @returns Repository-relative paths whose content changed.
*/
export function writeModuleGraph(scanRoot: string = root): string[] {
const outputs = computeModuleGraphOutputs(scanRoot)
const changed: string[] = []
for (const [path, content] of outputs) {
const destination = resolve(scanRoot, path)
if (existsSync(destination) && readFileSync(destination, 'utf8') === content) continue
writeFileSync(destination, content)
changed.push(path)
}
const source = Buffer.from(outputs.get(PATHS.source) ?? '')
const zh = Buffer.from(outputs.get(PATHS.zh) ?? '')
const record = renderTranslationPairingRecord(PATHS, {
sourceHash: storeGitBlob(scanRoot, source),
zhHash: storeGitBlob(scanRoot, zh),
})
const recordPath = resolve(scanRoot, PATHS.meta)
if (!existsSync(recordPath) || readFileSync(recordPath, 'utf8') !== record) {
writeFileSync(recordPath, record)
changed.push(PATHS.meta)
}
return changed.sort()
}
/** CLI entry: regenerate by default, or verify all paired outputs with `--check`. @returns Nothing. */
export function main(): void {
const outputs = computeModuleGraphOutputs(root)
const record = renderTranslationPairingRecord(PATHS, {
sourceHash: gitBlobHash(Buffer.from(outputs.get(PATHS.source) ?? '')),
zhHash: gitBlobHash(Buffer.from(outputs.get(PATHS.zh) ?? '')),
})
const expected = new Map([...outputs, [PATHS.meta, record]])
if (process.argv.includes('--check')) {
const stale = [...expected].filter(([path, content]) => (
!existsSync(resolve(root, path)) || readFileSync(resolve(root, path), 'utf8') !== content
)).map(([path]) => path)
if (stale.length === 0) {
console.log(`gen-module-graph: ${expected.size} artifact(s) are up to date.`)
return
}
console.error(`gen-module-graph: stale — ${stale.join(', ')}. Run \`pnpm run gen-module-graph\` and commit the result.`)
process.exitCode = 1
return
}
const changed = writeModuleGraph(root)
console.log(`gen-module-graph: ${expected.size} artifact(s) computed, ${String(changed.length)} written.`)
}
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) main()

View file

@ -15,6 +15,20 @@ const HOST_DEPENDENCY_PACKAGES: readonly string[] = [
'@deepseek-ai/dsh-session', '@deepseek-ai/dsh-session',
] ]
/** Development-only package relationships not represented by source imports. */
const CONFIGURATION_ONLY_DEV_DEPENDENCIES = {
'@deepseek-ai/dsh-client-locale': ['@deepseek-ai/dsh-api-remotes'],
'@deepseek-ai/dsh-client-ui-conversation': [
'@deepseek-ai/dsh-api-remotes',
'@deepseek-ai/dsh-client-ui-workspace',
],
'@deepseek-ai/dsh-client-ui-model-selection': ['@deepseek-ai/dsh-client-ui-input-trigger'],
'@deepseek-ai/dsh-client-ui-sidebar': ['@deepseek-ai/dsh-client-ui-workspace'],
'@deepseek-ai/dsh-client-ui-subagent': ['@deepseek-ai/dsh-client-ui-input-trigger'],
'@deepseek-ai/dsh-client-ui-theme': ['@deepseek-ai/dsh-api-remotes'],
'@deepseek-ai/dsh-client-ui-tool': ['@deepseek-ai/dsh-api-remotes'],
} as const satisfies Readonly<Record<string, readonly string[]>>
/** /**
* Runtime exports whose values remain valid when npm installs another package copy. * Runtime exports whose values remain valid when npm installs another package copy.
*/ */
@ -47,6 +61,7 @@ export interface PackageDependencyPolicy {
readonly clientFaceInclude: readonly string[] readonly clientFaceInclude: readonly string[]
readonly clientFaceExclude: readonly string[] readonly clientFaceExclude: readonly string[]
readonly hostPackages: readonly string[] readonly hostPackages: readonly string[]
readonly configurationOnlyDevDependencies: Readonly<Record<string, readonly string[]>>
readonly safeHostDependencyExports: HostDependencyExports readonly safeHostDependencyExports: HostDependencyExports
readonly peerRequiredHostExports: HostDependencyExports readonly peerRequiredHostExports: HostDependencyExports
} }
@ -56,6 +71,7 @@ export const PACKAGE_DEPENDENCY_POLICY: PackageDependencyPolicy = {
clientFaceInclude: CLIENT_FACE_INCLUDE, clientFaceInclude: CLIENT_FACE_INCLUDE,
clientFaceExclude: CLIENT_FACE_EXCLUDE, clientFaceExclude: CLIENT_FACE_EXCLUDE,
hostPackages: HOST_DEPENDENCY_PACKAGES, hostPackages: HOST_DEPENDENCY_PACKAGES,
configurationOnlyDevDependencies: CONFIGURATION_ONLY_DEV_DEPENDENCIES,
safeHostDependencyExports: SAFE_HOST_DEPENDENCY_EXPORTS, safeHostDependencyExports: SAFE_HOST_DEPENDENCY_EXPORTS,
peerRequiredHostExports: PEER_REQUIRED_HOST_EXPORTS, peerRequiredHostExports: PEER_REQUIRED_HOST_EXPORTS,
} }

View file

@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it } from 'vitest'
import { renderModuleGraph } from './gen-module-graph.ts'
import { collectPackageGraph } from './package-graph.ts' import { collectPackageGraph } from './package-graph.ts'
const roots: string[] = [] const roots: string[] = []
@ -49,3 +50,23 @@ describe('collectPackageGraph', () => {
.toThrow('fixture: @deepseek-ai/dsh-consumer references missing in-repo peer @deepseek-ai/dsh-missing') .toThrow('fixture: @deepseek-ai/dsh-consumer references missing in-repo peer @deepseek-ai/dsh-missing')
}) })
}) })
describe('renderModuleGraph', () => {
it('renders the same peer edge in both generated languages', () => {
const packages = [
{ short: 'provider', name: '@deepseek-ai/dsh-provider', group: 'core', rel: 'packages/core/provider', deps: [] },
{ short: 'consumer', name: '@deepseek-ai/dsh-consumer', group: 'core', rel: 'packages/core/consumer', deps: ['provider'] },
]
const english = renderModuleGraph(packages, 'en')
const chinese = renderModuleGraph(packages, 'zh')
expect(english).toContain('# Shared-instance dependency graph')
expect(chinese).toContain('# 共享实例依赖关系图')
expect(chinese).toContain('[English](module-graph.md) | 中文')
for (const output of [english, chinese]) {
expect(output).toContain('pkg_consumer --> pkg_provider')
expect(output).toContain('| [`consumer`](../packages/core/consumer) | `core` | [`provider`](../packages/core/provider) |')
}
})
})

View file

@ -46,6 +46,7 @@ function policy(fields: Partial<PackageDependencyPolicy> = {}): PackageDependenc
clientFaceInclude: [], clientFaceInclude: [],
clientFaceExclude: [], clientFaceExclude: [],
hostPackages: [], hostPackages: [],
configurationOnlyDevDependencies: {},
safeHostDependencyExports: {}, safeHostDependencyExports: {},
peerRequiredHostExports: {}, peerRequiredHostExports: {},
...fields, ...fields,
@ -95,6 +96,18 @@ describe('package dependency scope', () => {
'@deepseek-ai/dsh-llm', '@deepseek-ai/dsh-llm',
'@deepseek-ai/dsh-session', '@deepseek-ai/dsh-session',
]) ])
expect(PACKAGE_DEPENDENCY_POLICY.configurationOnlyDevDependencies).toEqual({
'@deepseek-ai/dsh-client-locale': ['@deepseek-ai/dsh-api-remotes'],
'@deepseek-ai/dsh-client-ui-conversation': [
'@deepseek-ai/dsh-api-remotes',
'@deepseek-ai/dsh-client-ui-workspace',
],
'@deepseek-ai/dsh-client-ui-model-selection': ['@deepseek-ai/dsh-client-ui-input-trigger'],
'@deepseek-ai/dsh-client-ui-sidebar': ['@deepseek-ai/dsh-client-ui-workspace'],
'@deepseek-ai/dsh-client-ui-subagent': ['@deepseek-ai/dsh-client-ui-input-trigger'],
'@deepseek-ai/dsh-client-ui-theme': ['@deepseek-ai/dsh-api-remotes'],
'@deepseek-ai/dsh-client-ui-tool': ['@deepseek-ai/dsh-api-remotes'],
})
expect(PACKAGE_DEPENDENCY_POLICY.safeHostDependencyExports['@deepseek-ai/schemastery']).toEqual(['default']) expect(PACKAGE_DEPENDENCY_POLICY.safeHostDependencyExports['@deepseek-ai/schemastery']).toEqual(['default'])
expect(PACKAGE_DEPENDENCY_POLICY.peerRequiredHostExports['@deepseek-ai/dsh-scope']).toEqual([ expect(PACKAGE_DEPENDENCY_POLICY.peerRequiredHostExports['@deepseek-ai/dsh-scope']).toEqual([
'carrierKeyOf', 'scopeOf', 'scopeTarget', 'carrierKeyOf', 'scopeOf', 'scopeTarget',
@ -219,7 +232,6 @@ describe('face-aware source classification', () => {
mkdirSync(dirname(join(root, path)), { recursive: true }) mkdirSync(dirname(join(root, path)), { recursive: true })
writeFileSync(join(root, path), source) writeFileSync(join(root, path), source)
} }
const found = readPackageDependencyFacts(root, subject, 'client-host', new Set([ const found = readPackageDependencyFacts(root, subject, 'client-host', new Set([
CORDIS, '@f/runtime', '@f/types', '@f/nested', '@f/hidden', '@f/browser', '@f/injected', CORDIS, '@f/runtime', '@f/types', '@f/nested', '@f/hidden', '@f/browser', '@f/injected',
])) ]))

View file

@ -1,8 +1,10 @@
/** Verify and repair npm dependency sections from published Client and Host faces. */ /** Verify and repair npm dependency sections from published Client and Host faces. */
import { spawnSync } from 'node:child_process'
import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs' import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, extname, join, normalize, relative, resolve, sep } from 'node:path' import { dirname, extname, join, normalize, relative, resolve, sep } from 'node:path'
import ts from 'typescript' import ts from 'typescript'
import { writeModuleGraph } from './gen-module-graph.ts'
import { import {
hasClientDeclaration, hasClientDeclaration,
PACKAGE_DEPENDENCY_POLICY, PACKAGE_DEPENDENCY_POLICY,
@ -439,6 +441,7 @@ export function readPackageDependencyState(
/** Derive the required npm section for each relationship owned by the policy. */ /** Derive the required npm section for each relationship owned by the policy. */
export function expectedPackageDependencies( export function expectedPackageDependencies(
facts: PackageDependencyFacts, facts: PackageDependencyFacts,
policy: PackageDependencyPolicy = PACKAGE_DEPENDENCY_POLICY,
): ReadonlyMap<string, ExpectedPackageDependency> { ): ReadonlyMap<string, ExpectedPackageDependency> {
const expected = new Map<string, { section: ExpectedPackageDependency['section']; origins: Set<string> }>() const expected = new Map<string, { section: ExpectedPackageDependency['section']; origins: Set<string> }>()
const add = (name: string, sectionName: ExpectedPackageDependency['section'], origin: string): void => { const add = (name: string, sectionName: ExpectedPackageDependency['section'], origin: string): void => {
@ -460,6 +463,12 @@ export function expectedPackageDependencies(
for (const name of facts.clientInject) { for (const name of facts.clientInject) {
if (facts.workspaceNames.has(name)) add(name, 'devDependencies', 'dsh.client.inject') if (facts.workspaceNames.has(name)) add(name, 'devDependencies', 'dsh.client.inject')
} }
for (const name of PACKAGE_DEPENDENCY_POLICY.configurationOnlyDevDependencies[facts.manifest.name ?? ''] ?? []) {
if (facts.workspaceNames.has(name)) add(name, 'devDependencies', 'configured development-only relationship')
}
for (const name of policy.configurationOnlyDevDependencies[facts.manifest.name ?? ''] ?? []) {
if (facts.workspaceNames.has(name)) add(name, 'devDependencies', 'configured development-only relationship')
}
for (const name of Object.keys(facts.manifest.peerDependencies ?? {})) { for (const name of Object.keys(facts.manifest.peerDependencies ?? {})) {
if (name !== CORDIS) add(name, 'devDependencies', 'existing non-Cordis peer') if (name !== CORDIS) add(name, 'devDependencies', 'existing non-Cordis peer')
} }
@ -665,6 +674,16 @@ export function fixPackageDependencies(root: string, state: PackageDependencySta
return changed.sort() return changed.sort()
} }
function refreshPnpmLockfile(root: string): void {
const result = spawnSync(
'pnpm',
['install', '--lockfile-only', '--ignore-scripts', '--no-frozen-lockfile'],
{ cwd: root, shell: process.platform === 'win32', stdio: 'inherit' },
)
if (result.error !== undefined) throw new Error(`could not refresh pnpm-lock.yaml: ${result.error.message}`)
if (result.status !== 0) throw new Error(`pnpm lockfile refresh exited with status ${String(result.status)}`)
}
function main(): void { function main(): void {
const root = resolve(import.meta.dirname, '..') const root = resolve(import.meta.dirname, '..')
let state = readPackageDependencyState(root) let state = readPackageDependencyState(root)
@ -675,6 +694,11 @@ function main(): void {
} else { } else {
const changed = fixPackageDependencies(root, state) const changed = fixPackageDependencies(root, state)
console.log(`${GATE}: fixed ${String(changed.length)} manifest(s).`) console.log(`${GATE}: fixed ${String(changed.length)} manifest(s).`)
refreshPnpmLockfile(root)
const graphChanges = writeModuleGraph(root)
console.log(
`${GATE}: refreshed pnpm-lock.yaml and wrote ${String(graphChanges.length)} module-graph artifact(s).`,
)
state = readPackageDependencyState(root) state = readPackageDependencyState(root)
} }
} }