From b46d36d3bf4ba352df62cc86b6329c038c4209cf Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:23:01 +0800 Subject: [PATCH] test(release): verify dual-version npm layout --- ...08-26-published-dependency-faces.i18n.yaml | 4 +- .../2026-08-26-published-dependency-faces.md | 10 +- ...026-08-26-published-dependency-faces.zh.md | 10 +- .github/workflows/release.yml | 46 +++- package.json | 1 + scripts/benchmark-npm-resolution.spec.ts | 23 ++ scripts/benchmark-npm-resolution.ts | 70 +++++- scripts/ci-workflow.spec.ts | 16 +- scripts/verify-npm-install-layout.spec.ts | 102 ++++++++ scripts/verify-npm-install-layout.ts | 217 ++++++++++++++++++ 10 files changed, 482 insertions(+), 17 deletions(-) create mode 100644 scripts/verify-npm-install-layout.spec.ts create mode 100644 scripts/verify-npm-install-layout.ts diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml index 689c45dea9..e09edf4f86 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-26-published-dependency-faces.md -2026-08-26-published-dependency-faces.md: 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 diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md index 6174e2a464..aa7714e5c8 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md @@ -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. diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md index 090031e9cd..e0ba33ff25 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md @@ -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 是诊断证据,不是发布时安装耗时承诺。 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ae1940c0c..ad6c64ae67 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/package.json b/package.json index 2ec0a09049..f88f78a491 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/benchmark-npm-resolution.spec.ts b/scripts/benchmark-npm-resolution.spec.ts index 92a6bdbfcb..e450614a44 100644 --- a/scripts/benchmark-npm-resolution.spec.ts +++ b/scripts/benchmark-npm-resolution.spec.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', + }) + }) }) diff --git a/scripts/benchmark-npm-resolution.ts b/scripts/benchmark-npm-resolution.ts index 57834d4f09..2e7ff71732 100644 --- a/scripts/benchmark-npm-resolution.ts +++ b/scripts/benchmark-npm-resolution.ts @@ -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> + readonly optionalDependencies?: Readonly> + readonly peerDependencies?: Readonly> + readonly peerDependenciesMeta?: Readonly> +} + +/** The installed paths selected by npm without materializing package archives. */ +export interface NpmPackageLock { + readonly lockfileVersion: number + readonly packages: Readonly> +} + +/** 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 + 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>, timeoutMs: number, -): Promise { +): Promise { let registryRequests = 0 let archiveRequests = 0 const unknownPackages = new Set() @@ -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 { + 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 { const options = parseBenchmarkOptions(process.argv.slice(2)) const root = resolve(import.meta.dirname, '..') diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 0a59f36ae7..fa9fc052f5 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -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', () => { diff --git a/scripts/verify-npm-install-layout.spec.ts b/scripts/verify-npm-install-layout.spec.ts new file mode 100644 index 0000000000..3c36c9bdd7 --- /dev/null +++ b/scripts/verify-npm-install-layout.spec.ts @@ -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', + ) + }) +}) diff --git a/scripts/verify-npm-install-layout.ts b/scripts/verify-npm-install-layout.ts new file mode 100644 index 0000000000..9a125ea864 --- /dev/null +++ b/scripts/verify-npm-install-layout.ts @@ -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 + optionalDependencies?: Record + peerDependencies?: Record + [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>, + 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, right: ReadonlySet): 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>([ + [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() + const rootNames = namesByVersion.get(rootVersion) ?? new Set() + 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 { + 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 + } +}