fix(build): support standalone pnpm entrypoints

This commit is contained in:
imccyu
2026-08-21 11:09:25 +08:00
parent 0e40664c67
commit 89674edc93
11 changed files with 140 additions and 56 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-06-16-pnpm-over-yarn.md
2026-06-16-pnpm-over-yarn.md: 30b34c65fdea94b20dec4d627a0fca40de760fd1
2026-06-16-pnpm-over-yarn.zh.md: c2c289ad2e43b6bdcb0997e9a4e18421d6705c6a
2026-06-16-pnpm-over-yarn.md: 2f655e9d009df8a933c4168082e491d5acdaa941
2026-06-16-pnpm-over-yarn.zh.md: a74113c814b45691d0f5391206ed15df1056db48
@@ -12,11 +12,12 @@ The switching cost is at its lowest right now. Nothing publishes from this repo
## Decision
Adopt **pnpm 11.7.0**, pinned via the `packageManager` field and installed through Corepack (same mechanism Yarn used):
Adopt **pnpm 11.7.0**, pinned via the `packageManager` field. Contributor setup uses Corepack, while CI installs that pin through `pnpm/action-setup`:
- **Workspaces** move from the `package.json` `workspaces` array + `.yarnrc.yml` to `pnpm-workspace.yaml` (`vendor/*`, `packages/*` — the same globs; `examples/*` stay non-workspace, matching the prior setup and tsdown's explicit globs).
- **Strict symlinked linker** (pnpm's default) replaces Yarn's hoisted `node-modules` linker. We deliberately add **no** `node-linker=hoisted` / `shamefully-hoist` escape hatch: pnpm's non-flat `node_modules` makes phantom dependencies (importing an undeclared transitive dep) fail loudly, which is a *feature* for a repo whose whole quality story is mechanical gates ([mechanical quality gates](2026-06-11-quality-gates.md)). The gate suite — typecheck, lint, test, build, knip — is the safety net that proves no such phantom imports exist.
- **Build-script allowlist.** pnpm 10+ does not run dependency lifecycle scripts unless allowlisted. `pnpm-workspace.yaml` carries an explicit `allowBuilds` map (`esbuild`, `lefthook`, `@google/genai`, `protobufjs`) — the same supply-chain-hardening posture the repo already takes toward model/tool output, now applied to install-time code execution. `peerDependencyRules.allowedVersions.typescript: '>=5 <7'` silences benign peer-range warnings for the in-repo TypeScript.
- **Shell-free package-manager re-entry.** Repository scripts that start another pnpm command resolve `npm_execpath` by file form: `.js`, `.cjs`, and `.mjs` entries run under the current Node executable, while native and shebang executables run directly. Neither path uses a shell, so command paths and arguments retain their literal contents across platforms.
- **Constraints become package-manager-independent.** `yarn.config.cjs` (which imported `@yarnpkg/types` and used `Yarn.workspaces()` / `workspace.set()`) is replaced by `scripts/check-workspace-constraints.ts`, a plain tsx script run as `pnpm run constraints`. It enforces the identical invariants — every package `private: true`; `@deepseek-ai/dsh-*` packages declare `cordis` as both a peer- and dev-dependency with matching ranges, use the root `package.json` version, and set `type: module`; vendored packages checked for privacy only — over the same `vendor` + `packages` scope.
- All `yarn …` verbs across CI, lefthook hooks, `package.json` scripts, and docs become `pnpm …` / `pnpm run …`. `yarn.lock``pnpm-lock.yaml` (lockfile v9). `.gitignore` swaps `.yarn/` for `.pnpm-store/`. Vendored READMEs (e.g. `vendor/cordis/README.md`) keep their upstream `yarn` examples untouched per the Vendoring Policy.
@@ -25,6 +26,8 @@ Adopt **pnpm 11.7.0**, pinned via the `packageManager` field and installed throu
- **Keep Yarn 4** — zero churn, but bets on the less-traveled linker mode and a constraints engine tied to one package manager.
- **npm workspaces** — ubiquitous, but no constraints story and weaker monorepo ergonomics.
- **pnpm with the hoisted linker** — smoother migration, but throws away the phantom-dependency safety that is the main correctness reason to move.
- **Always run `npm_execpath` through Node** — works for pnpm's JavaScript distribution but asks Node to parse the ELF, Mach-O, or PE executable supplied by `@pnpm/exe`.
- **Run re-entry commands through a shell** — accepts more launcher forms but changes quoting, metacharacter expansion, executable resolution, and signal behavior for every child command.
## Consequences
@@ -12,11 +12,12 @@ Status: implemented
## 决策
采用 **pnpm 11.7.0**,通过 `packageManager` 字段固定版本,经 Corepack 安装(与 Yarn 使用的机制相同)
采用 **pnpm 11.7.0**,通过 `packageManager` 字段固定版本。贡献者环境使用 CorepackCI 则通过 `pnpm/action-setup` 安装该固定版本
- **Workspaces** 从 `package.json``workspaces` 数组 + `.yarnrc.yml` 迁移到 `pnpm-workspace.yaml``vendor/*``packages/*`——同样的 glob`examples/*` 保持非 workspace,与先前设置及 tsdown 的显式 glob 一致)。
- **严格符号链接链接器**(pnpm 默认)取代 Yarn 的提升式 `node-modules` 链接器。我们刻意**不**添加 `node-linker=hoisted` / `shamefully-hoist` 逃生口:pnpm 的非扁平 `node_modules` 会使幻影依赖(引用未声明的传递依赖)明确报错,这对于一个以机械门禁为核心质量保障的仓库(见[机械质量门禁](2026-06-11-quality-gates.zh.md))是一项*优势*。门禁套件(类型检查、lint、test、build、knip)是证明不存在此类幻影导入的安全网。
- **构建脚本白名单。** pnpm 10+ 不运行依赖的生命周期脚本,除非将其加入白名单。`pnpm-workspace.yaml` 携带一份显式的 `allowBuilds` 映射(`esbuild``lefthook``@google/genai``protobufjs`)——与本仓库对模型/工具输出已有的供应链加固姿态一致,现在也应用于安装时的代码执行。`peerDependencyRules.allowedVersions.typescript: '>=5 <7'` 消除仓库内 TypeScript 的良性 peer 范围警告。
- **无 shell 的包管理器再进入。** 需要启动另一条 pnpm 命令的仓库脚本按文件形式解析 `npm_execpath``.js``.cjs``.mjs` 入口由当前 Node 可执行文件运行,原生及带 shebang 的可执行入口则直接运行。两条路径都不使用 shell,因此命令路径和参数在各平台上均保留字面内容。
- **约束变为包管理器无关。** `yarn.config.cjs`(导入 `@yarnpkg/types`,使用 `Yarn.workspaces()` / `workspace.set()`)被 `scripts/check-workspace-constraints.ts` 取代——一个纯 tsx 脚本,通过 `pnpm run constraints` 运行。它在相同的 `vendor` + `packages` 范围上强制执行完全相同的不变式:每个包 `private: true``@deepseek-ai/dsh-*` 包将 `cordis` 同时声明为对等依赖(peer dependency)和 dev 依赖且范围一致、使用根 `package.json` 的版本、设置 `type: module`vendor 包仅检查是否为私有。
- 所有 CI、lefthook 钩子、`package.json` 脚本和文档中的 `yarn …` 动词变为 `pnpm …` / `pnpm run …``yarn.lock``pnpm-lock.yaml`lockfile v9)。`.gitignore``.yarn/` 换为 `.pnpm-store/`。vendor README(如 `vendor/cordis/README.md`)按 Vendoring Policy 保持其上游 `yarn` 示例不变。
@@ -25,6 +26,8 @@ Status: implemented
- **保留 Yarn 4**——零变动,但押注于使用率较低的链接器模式和一个绑定单一包管理器的约束引擎。
- **npm workspaces**——无处不在,但没有约束方案,monorepo 开发体验也较差。
- **pnpm 搭配提升式链接器**——迁移更平滑,但放弃了幻影依赖安全性,而这正是迁移的核心正确性理由。
- **始终通过 Node 运行 `npm_execpath`**——适用于 pnpm 的 JavaScript 分发,但会让 Node 尝试解析 `@pnpm/exe` 提供的 ELF、Mach-O 或 PE 可执行文件。
- **通过 shell 运行再进入命令**——可接受更多启动器形式,但会改变每条子命令的引号、元字符展开、可执行文件解析和信号行为。
## 后果
+3 -5
View File
@@ -11,14 +11,12 @@ import {
resolveClientBuildEnvironment,
writeClientBuildRecord,
} from './client-build-environment.ts'
import { pnpmInvocation } from './pnpm-invocation.ts'
/** Run one package script through the package manager that invoked this build. */
function runScript(script: string, environment: NodeJS.ProcessEnv): void {
const packageManager = process.env.npm_execpath
if (packageManager === undefined || packageManager === '') {
throw new Error('build: npm_execpath is unavailable; invoke the build through a package script')
}
const result = spawnSync(process.execPath, [packageManager, 'run', script], {
const invocation = pnpmInvocation(['run', script], environment)
const result = spawnSync(invocation.command, invocation.args, {
cwd: resolve(import.meta.dirname, '..'),
env: environment,
stdio: 'inherit',
+25
View File
@@ -99,6 +99,8 @@ describe('coverage partition coordinator', () => {
'merged coverage report',
])
for (const [index, command] of commands.slice(0, 3).entries()) {
expect(command.command).toBe(process.execPath)
expect(command.args[0]).toBe('/pnpm.cjs')
expect(command.args).toEqual(expect.arrayContaining([
'--coverage',
'--coverage.reportOnFailure',
@@ -123,6 +125,29 @@ describe('coverage partition coordinator', () => {
})
})
it('runs a native pnpm entrypoint directly', async () => {
const root = await temporaryRoot()
const commands: CoverageCommand[] = []
const runCommand = vi.fn(async (command: CoverageCommand) => {
commands.push(command)
await writeBlob(command)
return passed
})
const coordinator = new CoveragePartitionCoordinator({
root,
partitions: 2,
pnpmEntrypoint: '/tools/pnpm',
runCommand,
})
await expect(coordinator.run()).resolves.toBe(0)
expect(commands).toHaveLength(3)
for (const command of commands) {
expect(command.command).toBe('/tools/pnpm')
expect(command.args[0]).toBe('exec')
}
})
it('merges normal test failures and returns their failed status', async () => {
const root = await temporaryRoot()
const reported = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+28 -25
View File
@@ -2,6 +2,7 @@
import { spawn } from 'node:child_process'
import { lstat, mkdir, readdir, rm, unlink } from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
import { pnpmInvocation } from './pnpm-invocation.ts'
/** Environment variable selecting the number of instrumented coverage processes. */
export const COVERAGE_PARTITIONS_ENV = 'DSH_COVERAGE_PARTITIONS'
@@ -16,7 +17,9 @@ export const COVERAGE_TEST_TIMEOUT_ENV = 'DSH_COVERAGE_TEST_TIMEOUT_MS'
export interface CoverageCommand {
/** Diagnostic identity. */
label: string
/** Node arguments; the first argument is pnpm's JavaScript entrypoint. */
/** Executable launched without a platform shell. */
command: string
/** Arguments passed to the executable. */
args: string[]
/** Environment additions for the child. */
env: Record<string, string | undefined>
@@ -47,7 +50,7 @@ export interface CoveragePartitionCoordinatorOptions {
root: string
/** Number of concurrent single-worker Vitest processes. */
partitions: number
/** pnpm JavaScript entrypoint from `npm_execpath`. */
/** pnpm JavaScript or executable entrypoint from `npm_execpath`. */
pnpmEntrypoint: string
/** Additional arguments shared by every partition. */
vitestArgs?: string[]
@@ -142,23 +145,23 @@ export class CoveragePartitionCoordinator {
private partitionCommand(index: number): CoverageCommand {
const blobPath = join(this.blobsRoot, `partition-${index}.json`)
const reportsDirectory = join(this.temporaryRoot, `coverage-${index}`)
const invocation = pnpmInvocation([
'exec',
'vitest',
'run',
'--coverage',
'--coverage.reportOnFailure',
'--maxWorkers=1',
`--shard=${index}/${this.partitions}`,
'--reporter=default',
'--reporter=blob',
`--outputFile.blob=${this.relativePath(blobPath)}`,
`--coverage.reportsDirectory=${this.relativePath(reportsDirectory)}`,
...this.vitestArgs,
], { npm_execpath: this.pnpmEntrypoint })
return {
label: `partition ${index}/${this.partitions}`,
args: [
this.pnpmEntrypoint,
'exec',
'vitest',
'run',
'--coverage',
'--coverage.reportOnFailure',
'--maxWorkers=1',
`--shard=${index}/${this.partitions}`,
'--reporter=default',
'--reporter=blob',
`--outputFile.blob=${this.relativePath(blobPath)}`,
`--coverage.reportsDirectory=${this.relativePath(reportsDirectory)}`,
...this.vitestArgs,
],
...invocation,
env: {
[COVERAGE_PARTITIONS_ENV]: undefined,
[COVERAGE_PARTITION_MODE_ENV]: '1',
@@ -169,15 +172,15 @@ export class CoveragePartitionCoordinator {
}
private mergeCommand(): CoverageCommand {
const invocation = pnpmInvocation([
'exec',
'vitest',
`--merge-reports=${this.relativePath(this.blobsRoot)}`,
'--coverage',
], { npm_execpath: this.pnpmEntrypoint })
return {
label: 'merged coverage report',
args: [
this.pnpmEntrypoint,
'exec',
'vitest',
`--merge-reports=${this.relativePath(this.blobsRoot)}`,
'--coverage',
],
...invocation,
env: {
[COVERAGE_PARTITIONS_ENV]: undefined,
[COVERAGE_PARTITION_MODE_ENV]: undefined,
@@ -213,7 +216,7 @@ function runCoverageCommand(command: CoverageCommand): Promise<CoverageCommandRe
if (value === undefined) Reflect.deleteProperty(env, name)
else env[name] = value
}
const child = spawn(process.execPath, command.args, {
const child = spawn(command.command, command.args, {
cwd: command.cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { pnpmInvocation } from './pnpm-invocation.ts'
describe('pnpm invocation', () => {
it.each([
'/tools/pnpm.js',
'/tools/pnpm.cjs',
'/tools/pnpm.mjs',
'/tools/PNPM.CJS',
'/tools/with spaces/工具/$pnpm;.mjs',
])('runs the JavaScript entrypoint %j through Node', (entrypoint) => {
expect(pnpmInvocation(['run', 'build'], { npm_execpath: entrypoint })).toEqual({
command: process.execPath,
args: [entrypoint, 'run', 'build'],
})
})
it.each([
'/tools/pnpm',
'/tools/with spaces/$pnpm;',
String.raw`C:\Program Files\工具\$pnpm;\pnpm.exe`,
])('runs the executable entrypoint %j directly', (entrypoint) => {
expect(pnpmInvocation(['run', 'build'], { npm_execpath: entrypoint })).toEqual({
command: entrypoint,
args: ['run', 'build'],
})
})
it.each([undefined, ''])('rejects an unavailable lifecycle entrypoint', (entrypoint) => {
expect(() => pnpmInvocation([], { npm_execpath: entrypoint }))
.toThrow('npm_execpath is unavailable; invoke the script through pnpm run')
})
})
+21
View File
@@ -0,0 +1,21 @@
/** Resolve shell-free child-process invocations for the pnpm process that launched a package script. */
/**
* Resolve pnpm's executable and arguments from its lifecycle environment.
* @param args - Arguments to pass to pnpm.
* @param environment - Lifecycle environment containing `npm_execpath`.
* @returns A command and argument array suitable for `spawn` or `spawnSync` without a shell.
*/
export function pnpmInvocation(
args: readonly string[],
environment: NodeJS.ProcessEnv = process.env,
): { command: string; args: string[] } {
const entrypoint = environment.npm_execpath
if (entrypoint === undefined || entrypoint === '') {
throw new Error('pnpm invocation: npm_execpath is unavailable; invoke the script through pnpm run.')
}
if (/\.[cm]?js$/iu.test(entrypoint)) {
return { command: process.execPath, args: [entrypoint, ...args] }
}
return { command: entrypoint, args: [...args] }
}
+12 -2
View File
@@ -31,9 +31,9 @@ function resultFor(subject: Gate, status: GateResult['status'] = 'passed'): Gate
}
}
function withPnpmEntrypoint<T>(action: () => T): T {
function withPnpmEntrypoint<T>(action: () => T, entrypoint = '/private/pnpm.cjs'): T {
const previous = process.env.npm_execpath
process.env.npm_execpath = '/private/pnpm.cjs'
process.env.npm_execpath = entrypoint
try {
return action()
} finally {
@@ -83,6 +83,16 @@ describe('gate graph validation', () => {
expect(ids).toContain('public-repository-links')
})
it('launches a native pnpm entrypoint directly', () => {
const entrypoint = String.raw`C:\Program Files\pnpm\pnpm.exe`
const subject = withPnpmEntrypoint(() => gatesForMode('ci-windows-blocking')[0], entrypoint)
expect(subject).toMatchObject({
command: entrypoint,
args: ['run', 'build'],
})
})
it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
'keeps the DSH package license policy in %s',
(mode) => {
+1 -9
View File
@@ -17,6 +17,7 @@ import {
coverageTestTimeoutArgs,
parseCoveragePartitionCount,
} from './coverage-partitions.ts'
import { pnpmInvocation } from './pnpm-invocation.ts'
/** A named aggregate exposed by the gate runner. */
export type Mode =
@@ -195,15 +196,6 @@ function pnpmExec(id: string, args: string[], options: Partial<Gate> = {}): Gate
}
}
function pnpmInvocation(args: string[]): Pick<Gate, 'command' | 'args'> {
const entrypoint = process.env.npm_execpath
if (entrypoint === undefined || entrypoint === '') {
throw new Error('run-gates: npm_execpath is unavailable; invoke the runner through a pnpm package script.')
}
// Windows cannot spawn the pnpm.cmd shim directly; the JavaScript entrypoint keeps every host shell-free.
return { command: process.execPath, args: [entrypoint, ...args] }
}
/**
* Construct the complete gate list for a named aggregate.
* @param selected - aggregate mode to construct.
+7 -11
View File
@@ -1,5 +1,6 @@
/** Run serial browser owners before one bounded snapshot pool. */
import { spawn } from 'node:child_process'
import { pnpmInvocation } from './pnpm-invocation.ts'
const serialFiles = [
'apps/web/tests/hmr-live.e2e.ts',
@@ -10,20 +11,15 @@ const workers = Number.parseInt(workerRaw ?? '', 10)
if (!Number.isSafeInteger(workers) || workers < 2 || String(workers) !== workerRaw) {
throw new Error(`DSH_WEB_SNAPSHOT_WORKERS must be an integer greater than 1, got ${JSON.stringify(workerRaw)}.`)
}
const pnpmEntrypoint = process.env.npm_execpath
if (pnpmEntrypoint === undefined || pnpmEntrypoint === '') {
throw new Error('parallel web snapshots must be invoked through a pnpm package script.')
}
const baseArgs = [pnpmEntrypoint, 'exec', 'vitest', 'run', '--config', 'vitest.web.config.ts']
const invocation = pnpmInvocation(['exec', 'vitest', 'run', '--config', 'vitest.web.config.ts'])
let serialStatus = 0
for (const file of serialFiles) {
serialStatus = await run([...baseArgs, file])
serialStatus = await run(invocation.command, [...invocation.args, file])
if (serialStatus !== 0) break
}
if (serialStatus === 0) {
process.exitCode = await run([
...baseArgs,
process.exitCode = await run(invocation.command, [
...invocation.args,
...serialFiles.map(file => `--exclude=${file}`),
'--fileParallelism',
`--maxWorkers=${String(workers)}`,
@@ -32,9 +28,9 @@ if (serialStatus === 0) {
process.exitCode = serialStatus
}
function run(args: string[]): Promise<number> {
function run(command: string, args: string[]): Promise<number> {
return new Promise((resolveRun, reject) => {
const child = spawn(process.execPath, args, { stdio: 'inherit' })
const child = spawn(command, args, { stdio: 'inherit' })
child.once('error', reject)
child.once('exit', (exitCode, signalCode) => {
if (signalCode !== null) {