test(release): verify dual-version npm layout
This commit is contained in:
parent
91b5a01980
commit
b46d36d3bf
10 changed files with 482 additions and 17 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: 6174e2a46482362c0ff54b5dec261440ce4f6131
|
||||
2026-08-26-published-dependency-faces.zh.md: 090031e9cde07d75aae79a87207d879fe220257d
|
||||
2026-08-26-published-dependency-faces.md: aa7714e5c83aaf1c68ef300bdb10006e534bf88d
|
||||
2026-08-26-published-dependency-faces.zh.md: e0ba33ff25651d1adf378af87fd6065ed9e54d03
|
||||
|
|
|
|||
|
|
@ -56,6 +56,12 @@ pnpm run benchmark:npm-resolution -- --runs=5 --timeout-ms=300000
|
|||
pnpm run benchmark:npm-resolution -- --ref=origin/master --runs=5 --timeout-ms=300000
|
||||
```
|
||||
|
||||
Verify package placement through two incompatible synthetic DSH releases. The verifier copies every current DSH manifest into `0.1.0` and `0.2.0`, asks npm for a package lock only, and rejects cross-release DSH resolution, unexpected DSH locations, unequal release inventories, multiple Cordis installations, and package archive requests. The local index contains only installed current-platform metadata, so npm-accepted probes for unavailable optional packages are reported without failing the check.
|
||||
|
||||
```sh
|
||||
pnpm run verify-npm-install-layout
|
||||
```
|
||||
|
||||
Rank the next Host package by applying the current policy in memory, measuring a baseline, trying each reachable unconfigured package, and serially retesting the fastest coarse candidates. Positive `gainSeconds` is `baseline median - candidate median`; `--candidates` limits the roster, `--jobs` controls coarse concurrency, and neither phase writes manifests. A selected candidate still requires export classification before it joins `hostPackages`.
|
||||
|
||||
```sh
|
||||
|
|
@ -64,7 +70,7 @@ pnpm run benchmark:npm-resolution:next -- --runs=1 --finalist-runs=5 --finalists
|
|||
|
||||
### Performance verification
|
||||
|
||||
[`benchmark-npm-resolution`](../../../../scripts/benchmark-npm-resolution.ts) and [`benchmark-next-package-dependency`](../../../../scripts/benchmark-next-package-dependency.ts) remain manual rather than CI gates because resolver time varies with machine load and metadata completion order. Their fresh-consumer, metadata-only runs isolate npm's dependency-tree calculation from registry latency and archive downloads, so relative results identify peer relays without creating a release-time performance promise.
|
||||
[`verify-npm-install-layout`](../../../../scripts/verify-npm-install-layout.ts) is a deterministic package-path and version check in the `Release (dsh)` workflow on every pull request and master push; it does not enforce resolver duration. [`benchmark-npm-resolution`](../../../../scripts/benchmark-npm-resolution.ts) and [`benchmark-next-package-dependency`](../../../../scripts/benchmark-next-package-dependency.ts) remain manual because resolver time varies with machine load and metadata completion order. Their fresh-consumer, metadata-only runs isolate npm's dependency-tree calculation from registry latency and archive downloads, so relative results identify peer relays without creating a release-time performance promise.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
|
@ -84,4 +90,4 @@ The published dependency graph follows artifact ownership instead of source-dire
|
|||
|
||||
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, 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.
|
||||
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, the two-release layout check, and a fresh next-package benchmark. The metadata-only benchmark is diagnostic evidence, not a release-time performance promise.
|
||||
|
|
|
|||
|
|
@ -56,6 +56,12 @@ pnpm run benchmark:npm-resolution -- --runs=5 --timeout-ms=300000
|
|||
pnpm run benchmark:npm-resolution -- --ref=origin/master --runs=5 --timeout-ms=300000
|
||||
```
|
||||
|
||||
通过两个互不兼容的 DSH 合成版本验证包落位。验证器把每份当前 DSH manifest 分别复制为 `0.1.0` 和 `0.2.0`,只要求 npm 生成 package lock,并拒绝跨版本 DSH 解析、非预期 DSH 路径、两套版本清单不一致、多个 Cordis 实例以及包归档请求。本地索引只包含当前平台已安装的 metadata,因此只报告而不拒绝 npm 已接受的不可用可选包探测。
|
||||
|
||||
```sh
|
||||
pnpm run verify-npm-install-layout
|
||||
```
|
||||
|
||||
计算下一项 Host 包时,命令会在内存中应用当前策略、测量 baseline、逐个尝试可达且未配置的包,并串行复测粗筛中最快的候选。正数 `gainSeconds` 等于 `baseline median - candidate median`;`--candidates` 限定名册,`--jobs` 控制粗筛并发度,两个阶段都不写 manifest。选中的候选仍需先完成导出分类,才能加入 `hostPackages`。
|
||||
|
||||
```sh
|
||||
|
|
@ -64,7 +70,7 @@ pnpm run benchmark:npm-resolution:next -- --runs=1 --finalist-runs=5 --finalists
|
|||
|
||||
### 性能验证
|
||||
|
||||
[`benchmark-npm-resolution`](../../../../scripts/benchmark-npm-resolution.ts) 与 [`benchmark-next-package-dependency`](../../../../scripts/benchmark-next-package-dependency.ts) 保持为手动工具而非 CI 门禁,因为 resolver 耗时会随机器负载和 metadata 完成顺序变化。它们通过全新 consumer 和仅 metadata 的运行,把 npm 依赖树计算与 registry 延迟、包归档下载分离,因此相对结果可以定位 peer 中继,但不构成发布时性能承诺。
|
||||
[`verify-npm-install-layout`](../../../../scripts/verify-npm-install-layout.ts) 是 `Release (dsh)` workflow 在每个 pull request 和 master push 上运行的确定性包路径与版本检查;它不限制 resolver 耗时。[`benchmark-npm-resolution`](../../../../scripts/benchmark-npm-resolution.ts) 与 [`benchmark-next-package-dependency`](../../../../scripts/benchmark-next-package-dependency.ts) 保持为手动工具,因为 resolver 耗时会随机器负载和 metadata 完成顺序变化。它们通过全新 consumer 和仅 metadata 的运行,把 npm 依赖树计算与 registry 延迟、包归档下载分离,因此相对结果可以定位 peer 中继,但不构成发布时性能承诺。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
|
@ -84,4 +90,4 @@ pnpm run benchmark:npm-resolution:next -- --runs=1 --finalist-runs=5 --finalists
|
|||
|
||||
把公开纯类型关系放进 `devDependencies`,意味着独立 TypeScript 消费者在使用该声明时必须自行安装被引用的类型包。发布 profile 会安装完整的受支持包族;若要支持独立组装的 TypeScript 消费者,需要另一套策略。
|
||||
|
||||
显式 override、Host 列表与导出分类都是需要评审的决策。当 class constructor、symbol 和访问模块私有 registry 的函数跨包传递身份或状态时,它们要求 peer;仅仅属于 value import 并不能证明导出可重复安装。修改分类会改变安装图,因此需要运行聚焦 verifier 测试并重新执行 next-package benchmark。仅 metadata benchmark 是诊断证据,不是发布时安装耗时承诺。
|
||||
显式 override、Host 列表与导出分类都是需要评审的决策。当 class constructor、symbol 和访问模块私有 registry 的函数跨包传递身份或状态时,它们要求 peer;仅仅属于 value import 并不能证明导出可重复安装。修改分类会改变安装图,因此需要运行聚焦 verifier 测试、双版本布局检查并重新执行 next-package benchmark。仅 metadata benchmark 是诊断证据,不是发布时安装耗时承诺。
|
||||
|
|
|
|||
46
.github/workflows/release.yml
vendored
46
.github/workflows/release.yml
vendored
|
|
@ -2,9 +2,9 @@
|
|||
# entries, all on one version. The vendored framework and the native packages are
|
||||
# separate sequences with their own workflows and version lines.
|
||||
#
|
||||
# Pack runs without credentials on every pull request and master push, so a
|
||||
# pull request proves the whole publish set still packs. Publication is a manual
|
||||
# workflow_dispatch of release-publish.yml from a dsh-v* tag.
|
||||
# Pack and dependency-layout verification run without credentials on every pull
|
||||
# request and master push. Publication is a manual workflow_dispatch of
|
||||
# release-publish.yml from a dsh-v* tag.
|
||||
name: Release (dsh)
|
||||
|
||||
on:
|
||||
|
|
@ -26,6 +26,46 @@ env:
|
|||
DSH_TELEMETRY_DISABLED: '1'
|
||||
|
||||
jobs:
|
||||
dependencies:
|
||||
name: Dependency layout
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.PRIMARY_NODE_VERSION }}
|
||||
|
||||
- name: Configure pnpm store path
|
||||
id: pnpm-store
|
||||
run: |
|
||||
store_root="$HOME/.local/share/pnpm/store"
|
||||
echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
|
||||
store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
|
||||
echo "path=$store_path" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
|
||||
|
||||
- name: Install (immutable)
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Verify dependency policy
|
||||
run: pnpm run verify-package-dependencies
|
||||
|
||||
- name: Verify npm install layout
|
||||
run: pnpm run verify-npm-install-layout
|
||||
|
||||
pack:
|
||||
name: Pack npm tarballs
|
||||
runs-on: ubuntu-24.04
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@
|
|||
"verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts",
|
||||
"verify-application-entrypoints": "tsx scripts/verify-application-entrypoints.ts",
|
||||
"verify-package-dependencies": "tsx scripts/verify-package-dependencies.ts",
|
||||
"verify-npm-install-layout": "tsx scripts/verify-npm-install-layout.ts",
|
||||
"verify-client-packages": "tsx scripts/verify-client-packages.ts",
|
||||
"verify-client-ui-i18n": "tsx scripts/verify-client-ui-i18n.ts",
|
||||
"verify-vendored-links": "tsx scripts/verify-vendored-links.ts",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
buildRegistryIndex,
|
||||
parseBenchmarkOptions,
|
||||
publishWorkspaceRange,
|
||||
resolveNpmPackageLock,
|
||||
type RegistryIndex,
|
||||
} from './benchmark-npm-resolution.ts'
|
||||
|
||||
|
|
@ -81,4 +82,26 @@ describe('npm resolution benchmark', () => {
|
|||
expect(result.archiveRequests).toBe(0)
|
||||
expect(result.unknownPackages).toEqual([])
|
||||
})
|
||||
|
||||
it('returns npm placement for two aliased package versions without requesting archives', async () => {
|
||||
const index: RegistryIndex = new Map([[
|
||||
'@deepseek-ai/dsh',
|
||||
new Map([
|
||||
['0.1.0', { name: '@deepseek-ai/dsh', version: '0.1.0' }],
|
||||
['0.2.0', { name: '@deepseek-ai/dsh', version: '0.2.0' }],
|
||||
]),
|
||||
]])
|
||||
|
||||
const result = await resolveNpmPackageLock(index, {
|
||||
'@deepseek-ai/dsh': '0.2.0',
|
||||
'dsh-previous': 'npm:@deepseek-ai/dsh@0.1.0',
|
||||
}, 10_000)
|
||||
|
||||
expect(result.archiveRequests).toBe(0)
|
||||
expect(result.packageLock.packages['node_modules/@deepseek-ai/dsh']?.version).toBe('0.2.0')
|
||||
expect(result.packageLock.packages['node_modules/dsh-previous']).toMatchObject({
|
||||
name: '@deepseek-ai/dsh',
|
||||
version: '0.1.0',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -69,6 +69,27 @@ export interface BenchmarkRun {
|
|||
readonly unknownPackages: readonly string[]
|
||||
}
|
||||
|
||||
/** Published-package fields retained in npm's package-lock layout. */
|
||||
export interface NpmLockPackage {
|
||||
readonly name?: string
|
||||
readonly version?: string
|
||||
readonly dependencies?: Readonly<Record<string, string>>
|
||||
readonly optionalDependencies?: Readonly<Record<string, string>>
|
||||
readonly peerDependencies?: Readonly<Record<string, string>>
|
||||
readonly peerDependenciesMeta?: Readonly<Record<string, { readonly optional?: boolean }>>
|
||||
}
|
||||
|
||||
/** The installed paths selected by npm without materializing package archives. */
|
||||
export interface NpmPackageLock {
|
||||
readonly lockfileVersion: number
|
||||
readonly packages: Readonly<Record<string, NpmLockPackage>>
|
||||
}
|
||||
|
||||
/** npm resolution observations together with its computed install layout. */
|
||||
export interface NpmPackageLockResolution extends BenchmarkRun {
|
||||
readonly packageLock: NpmPackageLock
|
||||
}
|
||||
|
||||
/** Parse one positive-integer command-line option or use its default. */
|
||||
export function parsePositiveIntegerOption(raw: string | undefined, fallback: number, name: string): number {
|
||||
if (raw === undefined) return fallback
|
||||
|
|
@ -250,6 +271,19 @@ function npmExecutable(): string {
|
|||
return process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
||||
}
|
||||
|
||||
function readNpmPackageLock(path: string): NpmPackageLock {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('npm produced an invalid package-lock.json')
|
||||
}
|
||||
const { lockfileVersion, packages } = parsed as Record<string, unknown>
|
||||
if (!Number.isSafeInteger(lockfileVersion) || packages === null
|
||||
|| typeof packages !== 'object' || Array.isArray(packages)) {
|
||||
throw new Error('npm produced an invalid package-lock.json')
|
||||
}
|
||||
return parsed as NpmPackageLock
|
||||
}
|
||||
|
||||
async function runNpm(
|
||||
cwd: string,
|
||||
registry: string,
|
||||
|
|
@ -302,17 +336,17 @@ async function runNpm(
|
|||
}
|
||||
|
||||
/**
|
||||
* Resolve the CLI install graph once without downloading package archives.
|
||||
* Ask npm to compute an install layout without downloading package archives.
|
||||
* @param index - Package metadata exposed through the local registry.
|
||||
* @param targetVersion - Version of `@deepseek-ai/dsh` to install.
|
||||
* @param dependencies - Root dependencies whose install layout npm computes.
|
||||
* @param timeoutMs - Hard wall-clock limit for the npm child process.
|
||||
* @returns Timing and registry-request observations.
|
||||
* @returns The package lock plus timing and registry-request observations.
|
||||
*/
|
||||
export async function benchmarkNpmResolution(
|
||||
export async function resolveNpmPackageLock(
|
||||
index: RegistryIndex,
|
||||
targetVersion: string,
|
||||
dependencies: Readonly<Record<string, string>>,
|
||||
timeoutMs: number,
|
||||
): Promise<BenchmarkRun> {
|
||||
): Promise<NpmPackageLockResolution> {
|
||||
let registryRequests = 0
|
||||
let archiveRequests = 0
|
||||
const unknownPackages = new Set<string>()
|
||||
|
|
@ -357,7 +391,7 @@ export async function benchmarkNpmResolution(
|
|||
name: 'dsh-npm-resolution-benchmark',
|
||||
version: '0.0.0',
|
||||
private: true,
|
||||
dependencies: { [TARGET_PACKAGE]: targetVersion },
|
||||
dependencies,
|
||||
}, null, 2)}\n`)
|
||||
const result = await runNpm(consumer, registry, timeoutMs)
|
||||
if (result.timedOut) throw new Error(`npm resolution exceeded ${String(timeoutMs)} ms`)
|
||||
|
|
@ -366,6 +400,7 @@ export async function benchmarkNpmResolution(
|
|||
registryRequests,
|
||||
archiveRequests,
|
||||
unknownPackages: [...unknownPackages].sort(),
|
||||
packageLock: readNpmPackageLock(join(consumer, 'package-lock.json')),
|
||||
}
|
||||
} finally {
|
||||
await close(server)
|
||||
|
|
@ -373,6 +408,27 @@ export async function benchmarkNpmResolution(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the CLI install graph once without downloading package archives.
|
||||
* @param index - Package metadata exposed through the local registry.
|
||||
* @param targetVersion - Version of `@deepseek-ai/dsh` to install.
|
||||
* @param timeoutMs - Hard wall-clock limit for the npm child process.
|
||||
* @returns Timing and registry-request observations.
|
||||
*/
|
||||
export async function benchmarkNpmResolution(
|
||||
index: RegistryIndex,
|
||||
targetVersion: string,
|
||||
timeoutMs: number,
|
||||
): Promise<BenchmarkRun> {
|
||||
const result = await resolveNpmPackageLock(index, { [TARGET_PACKAGE]: targetVersion }, timeoutMs)
|
||||
return {
|
||||
durationMs: result.durationMs,
|
||||
registryRequests: result.registryRequests,
|
||||
archiveRequests: result.archiveRequests,
|
||||
unknownPackages: result.unknownPackages,
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const options = parseBenchmarkOptions(process.argv.slice(2))
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
|
|
|||
|
|
@ -603,7 +603,7 @@ describe('npm release workflows', () => {
|
|||
for (const file of ['release.yml', 'release-vendor.yml']) {
|
||||
const workflow = loadWorkflow(`.github/workflows/${file}`)
|
||||
if (!isRecord(workflow.jobs)) throw new TypeError(`${file} must define jobs`)
|
||||
expect(Object.keys(workflow.jobs).sort()).toEqual(['pack'])
|
||||
expect(Object.keys(workflow.jobs).sort()).toEqual(file === 'release.yml' ? ['dependencies', 'pack'] : ['pack'])
|
||||
}
|
||||
|
||||
// publication is workflow_dispatch-only (never a PR check) and keeps the
|
||||
|
|
@ -618,6 +618,20 @@ describe('npm release workflows', () => {
|
|||
expect(publish.concurrency).toMatchObject({ group: 'Release-publish' })
|
||||
}
|
||||
})
|
||||
|
||||
it('runs dependency policy and npm layout checks in the DSH release workflow', () => {
|
||||
const workflow = loadWorkflow('.github/workflows/release.yml')
|
||||
const dependencies = workflowJob(workflow, 'dependencies')
|
||||
if (!isRecord(workflow.on) || !Array.isArray(dependencies.steps)) {
|
||||
throw new TypeError('DSH release workflow must define triggers and dependency steps')
|
||||
}
|
||||
const commands = dependencies.steps.flatMap(step =>
|
||||
isRecord(step) && typeof step.run === 'string' ? [step.run] : [])
|
||||
|
||||
expect(Object.keys(workflow.on).sort()).toEqual(['pull_request', 'push', 'workflow_dispatch'])
|
||||
expect(commands).toContain('pnpm run verify-package-dependencies')
|
||||
expect(commands).toContain('pnpm run verify-npm-install-layout')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Documentation site publication', () => {
|
||||
|
|
|
|||
102
scripts/verify-npm-install-layout.spec.ts
Normal file
102
scripts/verify-npm-install-layout.spec.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { NpmPackageLock, RegistryIndex } from './benchmark-npm-resolution.ts'
|
||||
import {
|
||||
assertDualDshInstallLayout,
|
||||
buildDualDshRegistry,
|
||||
} from './verify-npm-install-layout.ts'
|
||||
|
||||
function validLayout(): NpmPackageLock {
|
||||
return {
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'': { dependencies: { '@deepseek-ai/dsh': '0.2.0', 'dsh-previous': 'npm:@deepseek-ai/dsh@0.1.0' } },
|
||||
'node_modules/@deepseek-ai/cordis': { version: '4.0.1' },
|
||||
'node_modules/@deepseek-ai/dsh': {
|
||||
version: '0.2.0',
|
||||
dependencies: { '@deepseek-ai/dsh-child': '^0.2.0' },
|
||||
peerDependencies: { '@deepseek-ai/cordis': '^4.0.1' },
|
||||
},
|
||||
'node_modules/@deepseek-ai/dsh-child': {
|
||||
version: '0.2.0',
|
||||
dependencies: { '@deepseek-ai/dsh-leaf': '^0.2.0' },
|
||||
},
|
||||
'node_modules/@deepseek-ai/dsh-leaf': { version: '0.2.0' },
|
||||
'node_modules/dsh-previous': {
|
||||
name: '@deepseek-ai/dsh',
|
||||
version: '0.1.0',
|
||||
dependencies: { '@deepseek-ai/dsh-child': '^0.1.0' },
|
||||
peerDependencies: { '@deepseek-ai/cordis': '^4.0.1' },
|
||||
},
|
||||
'node_modules/dsh-previous/node_modules/@deepseek-ai/dsh-child': {
|
||||
version: '0.1.0',
|
||||
dependencies: { '@deepseek-ai/dsh-leaf': '^0.1.0' },
|
||||
},
|
||||
'node_modules/dsh-previous/node_modules/@deepseek-ai/dsh-leaf': { version: '0.1.0' },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('npm install layout verifier', () => {
|
||||
it('creates two incompatible versions of every DSH package', () => {
|
||||
const index: RegistryIndex = new Map([
|
||||
['@deepseek-ai/dsh', new Map([['0.1.1-rc.2', {
|
||||
name: '@deepseek-ai/dsh',
|
||||
version: '0.1.1-rc.2',
|
||||
dependencies: { '@deepseek-ai/dsh-child': '^0.1.1-rc.2' },
|
||||
peerDependencies: { '@deepseek-ai/cordis': '^4.0.1' },
|
||||
}]])],
|
||||
['@deepseek-ai/dsh-child', new Map([['0.1.1-rc.2', {
|
||||
name: '@deepseek-ai/dsh-child',
|
||||
version: '0.1.1-rc.2',
|
||||
}]])],
|
||||
['@deepseek-ai/cordis', new Map([['4.0.1', {
|
||||
name: '@deepseek-ai/cordis',
|
||||
version: '4.0.1',
|
||||
}]])],
|
||||
])
|
||||
|
||||
const dual = buildDualDshRegistry(index, '0.1.1-rc.2')
|
||||
|
||||
expect([...dual.get('@deepseek-ai/dsh')?.keys() ?? []]).toEqual(['0.1.0', '0.2.0'])
|
||||
expect(dual.get('@deepseek-ai/dsh')?.get('0.1.0')).toMatchObject({
|
||||
version: '0.1.0',
|
||||
dependencies: { '@deepseek-ai/dsh-child': '^0.1.0' },
|
||||
peerDependencies: { '@deepseek-ai/cordis': '^4.0.1' },
|
||||
})
|
||||
expect(dual.get('@deepseek-ai/dsh')?.get('0.2.0')).toMatchObject({
|
||||
version: '0.2.0',
|
||||
dependencies: { '@deepseek-ai/dsh-child': '^0.2.0' },
|
||||
})
|
||||
expect(dual.get('@deepseek-ai/cordis')).toBe(index.get('@deepseek-ai/cordis'))
|
||||
})
|
||||
|
||||
it('accepts isolated DSH releases with one shared Cordis installation', () => {
|
||||
expect(assertDualDshInstallLayout(validLayout())).toEqual({
|
||||
dshPackagesPerVersion: 3,
|
||||
checkedDshEdges: 4,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an internal edge that crosses release versions', () => {
|
||||
const layout = validLayout()
|
||||
const packages = { ...layout.packages }
|
||||
Reflect.deleteProperty(packages, 'node_modules/dsh-previous/node_modules/@deepseek-ai/dsh-leaf')
|
||||
|
||||
expect(() => assertDualDshInstallLayout({ ...layout, packages })).toThrow(
|
||||
'node_modules/dsh-previous/node_modules/@deepseek-ai/dsh-child: dependencies '
|
||||
+ '@deepseek-ai/dsh-leaf resolves to node_modules/@deepseek-ai/dsh-leaf@0.2.0, expected 0.1.0',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a second Cordis installation', () => {
|
||||
const layout = validLayout()
|
||||
const packages = {
|
||||
...layout.packages,
|
||||
'node_modules/dsh-previous/node_modules/@deepseek-ai/cordis': { version: '4.0.1' },
|
||||
}
|
||||
|
||||
expect(() => assertDualDshInstallLayout({ ...layout, packages })).toThrow(
|
||||
'expected one shared @deepseek-ai/cordis',
|
||||
)
|
||||
})
|
||||
})
|
||||
217
scripts/verify-npm-install-layout.ts
Normal file
217
scripts/verify-npm-install-layout.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
/** Verify npm's physical package placement for two incompatible DSH releases. */
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { posix, resolve } from 'node:path'
|
||||
import {
|
||||
buildRegistryIndex,
|
||||
resolveNpmPackageLock,
|
||||
type NpmLockPackage,
|
||||
type NpmPackageLock,
|
||||
type RegistryIndex,
|
||||
} from './benchmark-npm-resolution.ts'
|
||||
|
||||
const DSH_PACKAGE = '@deepseek-ai/dsh'
|
||||
const CORDIS_PACKAGE = '@deepseek-ai/cordis'
|
||||
const NESTED_DSH_ALIAS = 'dsh-previous'
|
||||
const NESTED_DSH_PATH = `node_modules/${NESTED_DSH_ALIAS}`
|
||||
const DEPENDENCY_FIELDS = ['dependencies', 'optionalDependencies', 'peerDependencies'] as const
|
||||
const TIMEOUT_MS = 300_000
|
||||
|
||||
/** Synthetic incompatible versions used to expose cross-release placement errors. */
|
||||
export const SYNTHETIC_DSH_VERSIONS = ['0.1.0', '0.2.0'] as const
|
||||
|
||||
interface MutableRegistryManifest {
|
||||
name: string
|
||||
version: string
|
||||
dependencies?: Record<string, string>
|
||||
optionalDependencies?: Record<string, string>
|
||||
peerDependencies?: Record<string, string>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** Summary of a verified two-release npm layout. */
|
||||
export interface DshInstallLayoutSummary {
|
||||
readonly dshPackagesPerVersion: number
|
||||
readonly checkedDshEdges: number
|
||||
}
|
||||
|
||||
function isDshPackage(name: string): boolean {
|
||||
return name === DSH_PACKAGE || name.startsWith(`${DSH_PACKAGE}-`)
|
||||
}
|
||||
|
||||
function cloneForVersion(manifest: object, version: string): MutableRegistryManifest {
|
||||
const cloned = structuredClone(manifest) as MutableRegistryManifest
|
||||
cloned.version = version
|
||||
for (const field of DEPENDENCY_FIELDS) {
|
||||
const dependencies = cloned[field]
|
||||
if (dependencies === undefined) continue
|
||||
for (const name of Object.keys(dependencies)) {
|
||||
if (isDshPackage(name)) dependencies[name] = `^${version}`
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the working release with two incompatible, internally consistent DSH releases.
|
||||
* @param index - Registry metadata containing the working release.
|
||||
* @param sourceVersion - Workspace version copied into each synthetic release.
|
||||
* @returns Registry metadata containing both synthetic DSH releases and unchanged external packages.
|
||||
*/
|
||||
export function buildDualDshRegistry(index: RegistryIndex, sourceVersion: string): RegistryIndex {
|
||||
const output = new Map(index)
|
||||
let dshPackages = 0
|
||||
for (const [name, versions] of index) {
|
||||
if (!isDshPackage(name)) {
|
||||
output.set(name, versions)
|
||||
continue
|
||||
}
|
||||
const source = versions.get(sourceVersion)
|
||||
if (source === undefined) throw new Error(`${name} has no workspace version ${sourceVersion}`)
|
||||
dshPackages++
|
||||
output.set(name, new Map(SYNTHETIC_DSH_VERSIONS.map(version => [
|
||||
version,
|
||||
cloneForVersion(source, version),
|
||||
])))
|
||||
}
|
||||
if (dshPackages === 0) throw new Error('registry contains no DSH packages')
|
||||
return output
|
||||
}
|
||||
|
||||
function packageNameAtPath(path: string, manifest: NpmLockPackage): string | undefined {
|
||||
if (manifest.name !== undefined) return manifest.name
|
||||
const marker = 'node_modules/'
|
||||
const markerIndex = path.lastIndexOf(marker)
|
||||
if (markerIndex < 0) return undefined
|
||||
const segments = path.slice(markerIndex + marker.length).split('/')
|
||||
if (segments[0]?.startsWith('@')) {
|
||||
return segments[1] === undefined ? undefined : `${segments[0]}/${segments[1]}`
|
||||
}
|
||||
return segments[0]
|
||||
}
|
||||
|
||||
function resolvePackagePath(
|
||||
packages: Readonly<Record<string, NpmLockPackage>>,
|
||||
sourcePath: string,
|
||||
dependency: string,
|
||||
): string | undefined {
|
||||
let directory = sourcePath
|
||||
while (directory !== '.') {
|
||||
const candidate = posix.join(directory, 'node_modules', dependency)
|
||||
if (packages[candidate] !== undefined) return candidate
|
||||
directory = posix.dirname(directory)
|
||||
}
|
||||
const rootCandidate = posix.join('node_modules', dependency)
|
||||
return packages[rootCandidate] === undefined ? undefined : rootCandidate
|
||||
}
|
||||
|
||||
function setDifference(left: ReadonlySet<string>, right: ReadonlySet<string>): string[] {
|
||||
return [...left].filter(value => !right.has(value)).sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that npm isolates both DSH releases while sharing the Cordis runtime.
|
||||
* @param packageLock - Metadata-only package lock produced by npm.
|
||||
* @returns Counts for the verified DSH packages and dependency edges.
|
||||
*/
|
||||
export function assertDualDshInstallLayout(packageLock: NpmPackageLock): DshInstallLayoutSummary {
|
||||
const [nestedVersion, rootVersion] = SYNTHETIC_DSH_VERSIONS
|
||||
const errors: string[] = []
|
||||
const namesByVersion = new Map<string, Set<string>>([
|
||||
[nestedVersion, new Set()],
|
||||
[rootVersion, new Set()],
|
||||
])
|
||||
const installed = Object.entries(packageLock.packages)
|
||||
let checkedDshEdges = 0
|
||||
|
||||
for (const [path, manifest] of installed) {
|
||||
const name = packageNameAtPath(path, manifest)
|
||||
if (name === undefined || !isDshPackage(name)) continue
|
||||
const version = manifest.version
|
||||
if (version !== nestedVersion && version !== rootVersion) {
|
||||
errors.push(`${path}: expected DSH version ${nestedVersion} or ${rootVersion}, got ${String(version)}`)
|
||||
continue
|
||||
}
|
||||
namesByVersion.get(version)?.add(name)
|
||||
const expectedPath = version === rootVersion
|
||||
? `node_modules/${name}`
|
||||
: name === DSH_PACKAGE
|
||||
? NESTED_DSH_PATH
|
||||
: `${NESTED_DSH_PATH}/node_modules/${name}`
|
||||
if (path !== expectedPath) {
|
||||
errors.push(`${path}: expected ${name}@${version} at ${expectedPath}`)
|
||||
}
|
||||
|
||||
for (const field of DEPENDENCY_FIELDS) {
|
||||
for (const dependency of Object.keys(manifest[field] ?? {})) {
|
||||
if (!isDshPackage(dependency)) continue
|
||||
const targetPath = resolvePackagePath(packageLock.packages, path, dependency)
|
||||
const optionalPeer = field === 'peerDependencies'
|
||||
&& manifest.peerDependenciesMeta?.[dependency]?.optional === true
|
||||
if (targetPath === undefined) {
|
||||
if (field === 'optionalDependencies' || optionalPeer) continue
|
||||
errors.push(`${path}: ${field} ${dependency} does not resolve`)
|
||||
continue
|
||||
}
|
||||
checkedDshEdges++
|
||||
const targetVersion = packageLock.packages[targetPath]?.version
|
||||
if (targetVersion !== version) {
|
||||
errors.push(
|
||||
`${path}: ${field} ${dependency} resolves to ${targetPath}@${String(targetVersion)}, expected ${version}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nestedNames = namesByVersion.get(nestedVersion) ?? new Set<string>()
|
||||
const rootNames = namesByVersion.get(rootVersion) ?? new Set<string>()
|
||||
if (!nestedNames.has(DSH_PACKAGE)) errors.push(`${NESTED_DSH_PATH}: missing ${DSH_PACKAGE}@${nestedVersion}`)
|
||||
if (!rootNames.has(DSH_PACKAGE)) errors.push(`node_modules/${DSH_PACKAGE}: missing ${DSH_PACKAGE}@${rootVersion}`)
|
||||
const onlyNested = setDifference(nestedNames, rootNames)
|
||||
const onlyRoot = setDifference(rootNames, nestedNames)
|
||||
if (onlyNested.length > 0) errors.push(`only ${nestedVersion} contains: ${onlyNested.join(', ')}`)
|
||||
if (onlyRoot.length > 0) errors.push(`only ${rootVersion} contains: ${onlyRoot.join(', ')}`)
|
||||
|
||||
const cordisPaths = installed.flatMap(([path, manifest]) =>
|
||||
packageNameAtPath(path, manifest) === CORDIS_PACKAGE ? [path] : [])
|
||||
if (cordisPaths.length !== 1 || cordisPaths[0] !== `node_modules/${CORDIS_PACKAGE}`) {
|
||||
errors.push(`expected one shared ${CORDIS_PACKAGE} at node_modules/${CORDIS_PACKAGE}, got ${cordisPaths.join(', ')}`)
|
||||
}
|
||||
|
||||
if (errors.length > 0) throw new Error(`invalid npm install layout:\n${errors.map(error => ` - ${error}`).join('\n')}`)
|
||||
return { dshPackagesPerVersion: rootNames.size, checkedDshEdges }
|
||||
}
|
||||
|
||||
function workspaceVersion(root: string): string {
|
||||
const manifest = JSON.parse(readFileSync(resolve(root, 'apps/cli/package.json'), 'utf8')) as { version?: unknown }
|
||||
if (typeof manifest.version !== 'string') throw new Error('apps/cli/package.json has no string version')
|
||||
return manifest.version
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const index = buildDualDshRegistry(buildRegistryIndex(root), workspaceVersion(root))
|
||||
const [nestedVersion, rootVersion] = SYNTHETIC_DSH_VERSIONS
|
||||
const result = await resolveNpmPackageLock(index, {
|
||||
[DSH_PACKAGE]: rootVersion,
|
||||
[NESTED_DSH_ALIAS]: `npm:${DSH_PACKAGE}@${nestedVersion}`,
|
||||
}, TIMEOUT_MS)
|
||||
if (result.archiveRequests !== 0) throw new Error(`npm requested ${String(result.archiveRequests)} package archive(s)`)
|
||||
const summary = assertDualDshInstallLayout(result.packageLock)
|
||||
console.log(
|
||||
`verify-npm-install-layout: ${String(summary.dshPackagesPerVersion)} DSH package(s) per release and `
|
||||
+ `${String(summary.checkedDshEdges)} internal edge(s) verified in ${(result.durationMs / 1000).toFixed(2)} s; `
|
||||
+ `both releases share one Cordis installation; ${String(result.unknownPackages.length)} unavailable optional `
|
||||
+ 'package name(s) ignored by npm.',
|
||||
)
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
try {
|
||||
await main()
|
||||
} catch (error) {
|
||||
console.error(`verify-npm-install-layout: ${error instanceof Error ? error.message : String(error)}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue