Bläddra i källkod

Merge pull request #2878 from deepseek-harness/worktree-fixpnpmwin

fix(build): support standalone pnpm entrypoints
imccyu 3 veckor sedan
förälder
incheckning
d508a09952

+ 2 - 2
.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.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-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: c42616f8d1dcacd925719bc8662683f04022573b
+2026-06-16-pnpm-over-yarn.zh.md: df2adbb3873415e2c732f4eb5f876f17e3c86003

+ 4 - 1
.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md

@@ -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. The [native Windows pull-request job](2026-08-08-native-windows-pull-request-ci.md) provisions `@pnpm/exe`, so its complete inventory supplies a real PE-entry integration signal.
 - **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
 

+ 4 - 1
.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md

@@ -12,11 +12,12 @@ Status: implemented
 
 ## 决策
 
-采用 **pnpm 11.7.0**,通过 `packageManager` 字段固定版本,经 Corepack 安装(与 Yarn 使用的机制相同)
+采用 **pnpm 11.7.0**,通过 `packageManager` 字段固定版本。贡献者环境使用 Corepack,CI 则通过 `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,因此命令路径和参数在各平台上均保留字面内容。[原生 Windows 拉取请求作业](2026-08-08-native-windows-pull-request-ci.zh.md)会提供 `@pnpm/exe`,因此其完整清单会产生真实的 PE 入口集成信号。
 - **约束变为包管理器无关。** `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 运行再进入命令**——可接受更多启动器形式,但会改变每条子命令的引号、元字符展开、可执行文件解析和信号行为。
 
 ## 后果
 

+ 2 - 2
.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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-08-native-windows-pull-request-ci.md
-2026-08-08-native-windows-pull-request-ci.md: d4883cf1363a33a444f1172829149c0c41f21c10
-2026-08-08-native-windows-pull-request-ci.zh.md: 0ba9629e873d82aba33a2cbad9e46e264ffe4312
+2026-08-08-native-windows-pull-request-ci.md: 1f8bf7c9e5249ce218fd0d169ed82008c2dbbd36
+2026-08-08-native-windows-pull-request-ci.zh.md: efe044e601aebc92f5d9446a1a683c935dcd783b

+ 1 - 1
.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md

@@ -14,7 +14,7 @@ A coverage audit found that stale branch state had restored temporary exclusions
 
 The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) remains `windows node 24 / wine blocking` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. The stable `windows` job id remains a dependency of `all checks passed`. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology.
 
-Every pull request also starts an ordinary independent `windows-native` job named `windows node 24 / native complete` on the organization-owned `dsh-windows-2025-16core` runner. It enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. A 120-minute timeout bounds a stuck gate without treating the measured performance target as a correctness deadline.
+Every pull request also starts an ordinary independent `windows-native` job named `windows node 24 / native complete` on the organization-owned `dsh-windows-2025-16core` runner. It enables Developer Mode for workspace symlinks, provisions the repository-pinned `@pnpm/exe` through `pnpm/action-setup` standalone mode, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. Package scripts therefore expose `pnpm.exe` through `npm_execpath`, making the complete inventory exercise shell-free package-manager re-entry on Windows. A 120-minute timeout bounds a stuck gate without treating the measured performance target as a correctness deadline.
 
 The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. Static, documentation, package, built-artifact, lint, and snapshot inventories run in the same job as observational gates: their failures remain visible without changing the native aggregate result because Linux owns their blocking verdict.
 

+ 1 - 1
.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md

@@ -14,7 +14,7 @@ Status: implemented
 
 [ci.yml](../../../../.github/workflows/ci.yml) 中必需的 `windows` 作业仍是在 `ubuntu-latest` 上运行的 `windows node 24 / wine blocking`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试;nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org,归档通过该校验前绝不会投入使用。稳定的 `windows` 作业 ID 仍是 `all checks passed` 的依赖项。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。
 
-每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动一个常规且独立的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。门禁卡住时,120 分钟超时会为其设定上限,同时不把实测性能目标当作正确性截止时间。
+每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动一个常规且独立的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 的 standalone 模式提供仓库固定版本的 `@pnpm/exe`,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。因此 package script 会通过 `npm_execpath` 暴露 `pnpm.exe`,让完整清单在 Windows 上覆盖无 shell 的包管理器再进入。门禁卡住时,120 分钟超时会为其设定上限,同时不把实测性能目标当作正确性截止时间。
 
 原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。静态检查、文档、包、构建产物、lint 与快照清单在同一作业内作为观测性门禁运行;其失败保持可见,但不会改变原生聚合结果,因为这些检查的阻断性判定由 Linux 负责。
 

+ 2 - 0
.github/workflows/ci.yml

@@ -437,6 +437,8 @@ jobs:
       - uses: pnpm/action-setup@v4
         with:
           dest: ${{ runner.temp }}/setup-pnpm
+          # Exercise package-script re-entry through the native pnpm.exe distribution.
+          standalone: true
 
       - uses: actions/setup-node@v6
         with:

+ 3 - 5
scripts/build.ts

@@ -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',

+ 8 - 1
scripts/ci-workflow.spec.ts

@@ -80,7 +80,14 @@ describe('CI workflow', () => {
     expect(windowsNative.env).toMatchObject({
       DSH_COVERAGE_TEST_TIMEOUT_MS: '30000',
     })
-    const nativeCommandSteps = (windowsNative.steps as unknown[]).filter((step): step is Record<string, unknown> & { run: string } => (
+    const nativeSteps = windowsNative.steps as unknown[]
+    const nativePnpmSetup = nativeSteps.find(step => (
+      isRecord(step)
+      && typeof step.uses === 'string'
+      && step.uses.startsWith('pnpm/action-setup@')
+    ))
+    expect(nativePnpmSetup).toMatchObject({ with: { standalone: true } })
+    const nativeCommandSteps = nativeSteps.filter((step): step is Record<string, unknown> & { run: string } => (
       isRecord(step) && typeof step.run === 'string'
     ))
     expect(nativeCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete')

+ 30 - 5
scripts/coverage-partitions.spec.ts

@@ -28,6 +28,14 @@ async function temporaryRoot(): Promise<string> {
   return await mkdtemp(join(tmpdir(), 'dsh-coverage-partitions-'))
 }
 
+function successfulCommandRecorder(commands: CoverageCommand[]) {
+  return vi.fn(async (command: CoverageCommand) => {
+    commands.push(command)
+    await writeBlob(command)
+    return passed
+  })
+}
+
 describe('coverage partition count', () => {
   it.each([
     [undefined, undefined],
@@ -77,11 +85,7 @@ describe('coverage partition coordinator', () => {
   it('runs every single-worker partition before one merged threshold check', async () => {
     const root = await temporaryRoot()
     const commands: CoverageCommand[] = []
-    const runCommand = vi.fn(async (command: CoverageCommand) => {
-      commands.push(command)
-      await writeBlob(command)
-      return passed
-    })
+    const runCommand = successfulCommandRecorder(commands)
     const coordinator = new CoveragePartitionCoordinator({
       root,
       partitions: 3,
@@ -99,6 +103,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 +129,25 @@ describe('coverage partition coordinator', () => {
     })
   })
 
+  it('runs a native pnpm entrypoint directly', async () => {
+    const root = await temporaryRoot()
+    const commands: CoverageCommand[] = []
+    const runCommand = successfulCommandRecorder(commands)
+    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
scripts/coverage-partitions.ts

@@ -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 - 0
scripts/pnpm-invocation.spec.ts

@@ -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 - 0
scripts/pnpm-invocation.ts

@@ -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
scripts/run-gates.spec.ts

@@ -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
scripts/run-gates.ts

@@ -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
scripts/run-web-snapshots.ts

@@ -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) {