fix(coverage): probe the pwsh exemption with the executor's own resolution

The review caught the pwshCoverageExclusions comment claiming the inverse of
the code's behavior: a bare-PATH probe FAILING grants the exemption, so a
win32 host where only install-location pwsh (or 5.1) resolves kept the
exemption while its suites ran - over-exempting, never tightening.

resolvePwshPath/candidatePwshPaths move to the dependency-free
pwsh-local/src/resolve.ts (index.ts re-exports; public API unchanged) and
vitest.config.ts probes with that shared definition, so the exemption is
active exactly when the suites skip. The two spec headers stop saying
'on PATH' for a probe that is deliberately not PATH-only, and the parity
note records the abort backport as the one both-ways parity change (both
languages, pairing re-recorded); catalogs regenerated for the line shift.
This commit is contained in:
Huanqi Cao
2026-08-03 00:21:17 +08:00
parent 37bceb7dad
commit b482f12d57
9 changed files with 78 additions and 61 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/feature/2026-08-02-pwsh-tool-bash-parity.md
2026-08-02-pwsh-tool-bash-parity.md: fb0f3fff1ed00dfde286781733881facfb1e6c7b
2026-08-02-pwsh-tool-bash-parity.zh.md: 9dc84bcd5e53c52d5d68b2db61cf79fe7fb97bb9
2026-08-02-pwsh-tool-bash-parity.md: 417c6d6bc91eb3afaa38976e0013e4fbe72854ca
2026-08-02-pwsh-tool-bash-parity.zh.md: 926433526b3820f7b4770acb3ee172448970b601
@@ -27,6 +27,7 @@ The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately mi
## Consequences
- The bash and pwsh tools are now behaviorally interchangeable for foreground and background shell work (minus sandbox), and the pwsh prompt/description sentences are each backed by the renderer — the reviewer's grep-against-code check passes.
- Parity ran BOTH ways once: the pwsh tool's structured foreground abort (`HarnessError('tool call aborted', TOOL_ABORTED)` with name `AbortError`) was backported to the bash tool, replacing its uncoded `Error('command aborted')` — a model-visible/logged change pinned by exact-shape tests on both sides and by the cancel-tool-calls fixture.
- `@deepseek-ai/dsh-bash-env` is a new shipped package; `dsh-tool-bash`'s `dshHome` config moved there, so compositions mounting the shell tools must also mount `bash-env` (the spine bundles do).
- Windows-only semantics (CRLF normalization, forced-termination exit-1/signal-null, POSIX-only self-signal) remain pinned by tests as before.
- The pwsh tool's per-file coverage gate rides on the scriptable fake-executor suite (`tests/tools.spec.ts`); the real-pwsh integration and Loader-composition suites self-skip where `pwsh` is absent, mirroring the bash suites' division of labor.
@@ -26,7 +26,8 @@ Status: implemented
## 后果
- bash 与 pwsh 工具在前台与后台 shell 工作(减 sandbox)上行为可互换,pwsh 的 prompt/描述句每句都有渲染器背书——reviewer 的"拿代码 grep 对证"检查通过。
- bash 与 pwsh 工具在前台与后台 shell 工作(减 sandbox)上行为可互换,pwsh 的 prompt/描述句每句都有渲染器背书——reviewer 的拿代码 grep 对证检查通过。
- 对齐也反向发生过一次:pwsh 工具的结构化前台中止(`HarnessError('tool call aborted', TOOL_ABORTED)`name 为 `AbortError`)被回移到 bash 工具,取代其无码的 `Error('command aborted')`——这是模型可见/入日志的变更,由两侧的精确形状测试与 cancel-tool-calls fixture 钉住。
- `@deepseek-ai/dsh-bash-env` 成为新的交付包;`dsh-tool-bash``dshHome` 配置迁往那里,因此挂载 shell 工具的组合也必须挂载 `bash-env`spine bundle 已如此)。
- Windows 专属语义(CRLF 归一化、强制终止 exit-1/signal-null、仅 POSIX 的自信号)一如既往由测试钉住。
- pwsh 工具的 per-file 覆盖门禁由可脚本化的 fake-executor 套件(`tests/tools.spec.ts`)承担;真实 pwsh 的集成与 Loader 组合套件在无 `pwsh` 的宿主自跳过,与 bash 套件的分工一致。
+1 -1
View File
@@ -1004,7 +1004,7 @@ export interface Config {
}
```
Source: [`packages/bash/pwsh-local/src/index.ts:55`](../packages/bash/pwsh-local/src/index.ts)
Source: [`packages/bash/pwsh-local/src/index.ts:54`](../packages/bash/pwsh-local/src/index.ts)
## `@deepseek-ai/dsh-repeat-tool-guard`
+4 -49
View File
@@ -13,14 +13,13 @@
* @module @deepseek-ai/dsh-pwsh-local
*/
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { Context } from 'cordis'
import z from 'schemastery'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { resolvePwshPath } from './resolve.ts'
/* jscpd:ignore-start -- deliberate call-for-call mirror of dsh-bash-local (Agent Note: pwsh-tool-and-executor). */
/**
@@ -77,53 +76,9 @@ export interface Config {
/** The shape after schemastery applied the defaults (cwd/pwshPath have none). */
type ResolvedConfig = Required<Omit<Config, 'cwd' | 'pwshPath'>> & Pick<Config, 'cwd' | 'pwshPath'>
/**
* Well-known Windows PowerShell install locations plus PATH entries, newest
* first. Explicitly parameterized (env) so resolution is a pure function of
* its inputs on every platform.
* @param env - the environment to probe; defaults to the process environment.
* @returns candidate `pwsh` executable paths in resolution order.
*/
export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string[] {
const programFiles = env.ProgramFiles ?? 'C:\\Program Files'
const systemRoot = env.SystemRoot ?? 'C:\\Windows'
const candidates = [
join(programFiles, 'PowerShell', '7', 'pwsh.exe'),
]
// Microsoft Store installs (and any user-added location) live on PATH;
// entries may carry surrounding quotes from `setx`-style definitions.
for (const entry of (env.PATH ?? '').split(';')) {
const trimmed = entry.trim().replace(/^"|"$/g, '')
if (trimmed.length === 0) continue
candidates.push(join(trimmed, 'pwsh.exe'))
}
// Windows PowerShell 5.1 remains the last-resort fallback on legacy hosts.
candidates.push(join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'))
return candidates
}
/**
* Resolve the pwsh executable this executor spawns.
* @param configured - an explicit `pwshPath` config value, trusted as-is.
* @param env - the environment to probe on Windows; defaults to the process environment.
* @param platform - the platform to resolve for; defaults to the process platform.
* @returns the first existing well-known location on Windows (PowerShell 7
* install, a PATH entry such as the Microsoft Store install, then Windows
* PowerShell 5.1), else `pwsh` for PATH resolution.
*/
export function resolvePwshPath(
configured?: string,
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
): string {
if (configured !== undefined && configured.length > 0) return configured
if (platform === 'win32') {
for (const candidate of candidatePwshPaths(env)) {
if (existsSync(candidate)) return candidate
}
}
return 'pwsh'
}
// Resolution lives in its own dependency-free module so the repository's
// coverage-gate probe shares the exact definition the suites use.
export { candidatePwshPaths, resolvePwshPath } from './resolve.ts'
/** Project a settled collect-mode reader into the final CollectedOutput shape. */
function finalOutput(reader: SubprocessOutputReader): CollectedOutput {
+60
View File
@@ -0,0 +1,60 @@
/**
* PowerShell executable resolution, dependency-free so non-package consumers
* (the repository's coverage-gate probe in `vitest.config.ts`) can share the
* ONE resolution definition with the executor and its suites — a probe that
* resolved differently from the code under test could exempt a file whose
* suites actually run.
*
* @module @deepseek-ai/dsh-pwsh-local/resolve
*/
import { existsSync } from 'node:fs'
import { join } from 'node:path'
/**
* Well-known Windows PowerShell install locations plus PATH entries, newest
* first. Explicitly parameterized (env) so resolution is a pure function of
* its inputs on every platform.
* @param env - the environment to probe; defaults to the process environment.
* @returns candidate `pwsh` executable paths in resolution order.
*/
export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string[] {
const programFiles = env.ProgramFiles ?? 'C:\\Program Files'
const systemRoot = env.SystemRoot ?? 'C:\\Windows'
const candidates = [
join(programFiles, 'PowerShell', '7', 'pwsh.exe'),
]
// Microsoft Store installs (and any user-added location) live on PATH;
// entries may carry surrounding quotes from `setx`-style definitions.
for (const entry of (env.PATH ?? '').split(';')) {
const trimmed = entry.trim().replace(/^"|"$/g, '')
if (trimmed.length === 0) continue
candidates.push(join(trimmed, 'pwsh.exe'))
}
// Windows PowerShell 5.1 remains the last-resort fallback on legacy hosts.
candidates.push(join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'))
return candidates
}
/**
* Resolve the pwsh executable this executor spawns.
* @param configured - an explicit `pwshPath` config value, trusted as-is.
* @param env - the environment to probe on Windows; defaults to the process environment.
* @param platform - the platform to resolve for; defaults to the process platform.
* @returns the first existing well-known location on Windows (PowerShell 7
* install, a PATH entry such as the Microsoft Store install, then Windows
* PowerShell 5.1), else `pwsh` for PATH resolution.
*/
export function resolvePwshPath(
configured?: string,
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
): string {
if (configured !== undefined && configured.length > 0) return configured
if (platform === 'win32') {
for (const candidate of candidatePwshPaths(env)) {
if (existsSync(candidate)) return candidate
}
}
return 'pwsh'
}
@@ -3,8 +3,8 @@
* service plus a REAL pwsh executable, exercised through the executor seam
* (`resolve` → `run`/`start`). These verify the world — actual PowerShell
* runs, output capture, truncation and spill, deadlines, kill escalation, and
* the background-handle contract. The suite self-skips when no `pwsh` is on
* PATH (a CI accommodation for hosts without PowerShell); the pure unit tests
* the background-handle contract. The suite self-skips when no usable `pwsh`
* resolves (a CI accommodation for hosts without PowerShell); the pure unit tests
* (config validation, executable resolution) run on every platform. PowerShell
* writes CRLF on Windows, so exact text assertions normalize line endings.
*/
@@ -4,7 +4,7 @@
* process. These verify the world — actual commands run, stdout/stderr come
* back, exit codes render, timeouts abort, background tasks settle through the
* generic task runtime, and per-session cwd resolution works. The suite
* self-skips when no `pwsh` is on PATH (a CI accommodation for hosts without
* self-skips when no usable `pwsh` resolves (a CI accommodation for hosts without
* PowerShell); the fake-executor suite (tools.spec.ts) carries the coverage
* gate.
*/
+5 -5
View File
@@ -1,5 +1,6 @@
import { spawnSync } from 'node:child_process'
import tsconfigPaths from 'vite-tsconfig-paths'
import { resolvePwshPath } from './packages/bash/pwsh-local/src/resolve.ts'
import { defineConfig } from 'vitest/config'
import { vitestExecArgv } from './vitest.shared.ts'
import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.ts'
@@ -44,11 +45,10 @@ const windowsCoverageExclusions = process.platform === 'win32'
// self-skip without a real pwsh (executor.spec.ts hasPwsh), leaving this file
// far below per-file 100% on pwsh-less hosts; the exemption keeps those hosts
// green while CI runners ship pwsh and still enforce the full bar. The probe
// is deliberately PATH-only (narrower than the suites' resolvePwshPath): a
// win32 host where only install-location pwsh or 5.1 resolves forfeits the
// exemption while the suites still run, so the gate can only get stricter,
// never falsely green.
const pwshCoverageExclusions = spawnSync('pwsh', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
// runs the suites' own resolution (the dependency-free resolve.ts module),
// so the exemption is active exactly when the suites skip — a mismatched
// narrower probe could exempt the file on hosts whose suites actually run.
const pwshCoverageExclusions = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
? []
: ['packages/bash/pwsh-local/src/index.ts']