diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8a341d6f..f6bfb6b3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -286,7 +286,7 @@ jobs:
retention-days: 7
# The bench smoke: `workflow_dispatch` only, off by default, and informational (like `tools-trial`
- # above). One platform is enough, because it exercises the harness rather than the platform — the
+ # above). Three host platforms exercise the portable harness; the
# `validate-posix` matrix is the gate, with `validate-windows` and `validate-macos-intel` as
# non-blocking counterparts. Both
# sides are the *same* staged worker, so the run
@@ -297,7 +297,20 @@ jobs:
# **never** run in CI.
bench-smoke:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.bench }}
- runs-on: ubuntu-24.04
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: ubuntu-24.04
+ target: linux-x64
+ worker: dsh-ops-worker
+ - os: windows-latest
+ target: win32-x64
+ worker: dsh-ops-worker.exe
+ - os: macos-latest
+ target: darwin-arm64
+ worker: dsh-ops-worker
+ runs-on: ${{ matrix.os }}
continue-on-error: true
timeout-minutes: 30
env:
@@ -342,7 +355,7 @@ jobs:
- name: Tool-level comparison, one binary on both sides
shell: bash
run: |
- worker="runtime/linux-x64/dsh-ops-worker"
+ worker="runtime/${{ matrix.target }}/${{ matrix.worker }}"
mkdir -p bench/runs/smoke/fixtures
node bench/cli.mjs tools \
--baseline "$worker" --candidate "$worker" \
@@ -358,7 +371,7 @@ jobs:
- uses: actions/upload-artifact@v7
if: always()
with:
- name: bench-smoke-report
+ name: bench-smoke-${{ matrix.target }}-report
path: |
bench/runs/smoke/**
bench/runs/*/run.json
diff --git a/bench/README.md b/bench/README.md
index a196fd86..506f2d0d 100644
--- a/bench/README.md
+++ b/bench/README.md
@@ -2,6 +2,14 @@
## 测试前的准备
+Linux / macOS:使用当前平台构建的 worker;`setup --dry-run` 会说明跳过 Windows PowerShell pin、PSModulePath、Windows Bash IPC 和随包 Windows shell 检查的原因。当前 shell 行已支持各平台,不要再从早期 Windows-only 默认值推断配置。模型 sweep 仍需自己的 OpenAI 兼容服务与凭据;`tools` 自检不调用模型。
+
+Linux / macOS: build the worker for the current host. `setup --dry-run` explains skipped Windows-only checks. `ab`, `compare`/`report`, `panel` and `tools` use the same entry point on every platform. A model sweep needs your own OpenAI-compatible endpoint; the tools self-check needs no model. Every sweep now fingerprints the composed profiles even without `--seal`, recording platform and component configuration in the arm summaries. `enabled` is a configuration fact, not proof of tool readiness; unresolved expressions remain `unknown`.
+
+`compare` 默认拒绝不同平台(或已知/未知平台)混配,退出码为 2;诊断时可加 `--allow-cross-platform`,报告会明确标记差异不是插件效应。旧数据两侧都缺平台时仍可读取,显示 `unknown`,不能据此声称同平台。CI 的可选 tools-only smoke 覆盖 Linux / Windows / macOS,各自上传 artifact;不运行模型 sweep,不产出跨平台结论。
+
+`compare` rejects mismatched platforms with exit 2. `--allow-cross-platform` is an explicit diagnostic override and adds a “not a plugin effect” warning. Both-unknown legacy archives remain readable. The optional, non-blocking tools smoke runs on Linux, Windows and macOS with separate artifacts.
+
```console
npm ci --ignore-scripts
npm run build && npm run build:ptc # lib/ + 原生 worker
diff --git a/bench/compare.mjs b/bench/compare.mjs
index dd5ccf01..69b5bdc8 100644
--- a/bench/compare.mjs
+++ b/bench/compare.mjs
@@ -40,6 +40,7 @@ import {
percentChange,
} from './lib/compare-core.mjs'
import { RUNS_DIR } from './lib/next-folder.mjs'
+import { environmentLines } from './lib/environment.mjs'
/** The sealed contract document inside a run directory. */
const SEALED_RUN = 'run.json'
@@ -78,6 +79,7 @@ const USAGE = `usage: node bench/compare.mjs [options]
--noise-floor-pct N pin the A/A noise floor; without a floor every cost
direction is withheld (noise-floor-unknown)
--noise measure the A/A noise floor from two baseline runs
+ --allow-cross-platform diagnostics only: allow different platforms, with a warning
--json print the comparison document as JSON instead of the report
--out where the rendered artifact goes (default: the sweep's group
folder, named "DSH-OPS Benchmark Report .md" — a run with no
@@ -143,7 +145,10 @@ export function parseArgs(argv) {
if (index >= argv.length) throw new Error(`${name} needs a value`)
return argv[index]
}
- if (name === '--json') {
+ if (name === '--allow-cross-platform') {
+ if (inline !== null) throw new Error('--allow-cross-platform takes no value')
+ options.allowCrossPlatform = true
+ } else if (name === '--json') {
if (inline !== null) throw new Error('--json takes no value; the comparison document goes to stdout and --out names a file')
options.json = true
} else if (name === '--aggregate') options.aggregate = take()
@@ -516,6 +521,7 @@ export function renderReport(comparison) {
const lines = [
REPORT_TITLE,
'',
+ ...environmentLines(comparison.environment),
...taskSection(comparison),
'',
...toolSection(comparison),
@@ -625,14 +631,19 @@ export async function main(argv, io = {}) {
return EXIT.usage
}
- const comparison = compareSummaries(base.doc, cand.doc, {
+ let comparison
+ try { comparison = compareSummaries(base.doc, cand.doc, {
+ allowCrossPlatform: options.allowCrossPlatform,
aggregate: options.aggregate,
tolerancePct: options.tolerancePct,
failOn: options.failOn,
regressionScope: options.regressionScope,
noiseFloorPct: options.noiseFloorPct ?? undefined,
noise: noise ?? undefined,
- })
+ }) } catch (error) {
+ err(`compare: ${String(error?.message ?? error)}`)
+ return EXIT.usage
+ }
const payload = options.json
? `${JSON.stringify(comparison, null, 2)}\n`
: `${renderReport(comparison)}\n`
diff --git a/bench/lib/compare-core.mjs b/bench/lib/compare-core.mjs
index 9197c1c4..700e4f39 100644
--- a/bench/lib/compare-core.mjs
+++ b/bench/lib/compare-core.mjs
@@ -33,6 +33,7 @@
*/
import { CORE_BUCKETS, median, minValue, pairMetrics } from './aggregate.mjs'
+import { compareEnvironment } from './environment.mjs'
export { median, minValue }
@@ -481,6 +482,7 @@ export function classifyExcluded(pairs, taskId = null) {
* @returns {object} the noise-floor document; `pct: null` when nothing could be paired.
*/
export function noiseFloor(baseA, baseB, options = {}) {
+ compareEnvironment(baseA, baseB, options)
const aggregate = AGGREGATES.includes(options.aggregate) ? options.aggregate : 'median'
const merged = pairMetrics(normalizeResults(baseA), normalizeResults(baseB))
const usablePairs = merged.pairs.filter((pair) => passed(pair.base) && passed(pair.cand))
@@ -547,7 +549,7 @@ export function resolveNoiseFloor(options = {}, base = null, cand = null) {
}
const noise = options.noise
if (noise && typeof noise === 'object' && !Array.isArray(noise) && (noise.base || noise.cand)) {
- return noiseFloor(noise.base ?? base, noise.cand ?? base, { aggregate: options.aggregate })
+ return noiseFloor(noise.base ?? base, noise.cand ?? base, { aggregate: options.aggregate, allowCrossPlatform: options.allowCrossPlatform })
}
return {
format: NOISE_FORMAT,
@@ -702,6 +704,8 @@ export function formatNumber(value) {
* @returns {object} the comparison structure, including the gate and the verdict.
*/
export function compareSummaries(base, cand, options = {}) {
+ const environment = compareEnvironment(base, cand, options)
+ if (options.noise) compareEnvironment(base, options.noise.base, options)
const aggregate = AGGREGATES.includes(options.aggregate) ? options.aggregate : 'median'
const tolerancePct = isNumber(options.tolerancePct) ? options.tolerancePct : 2
const failOn = FAIL_ON.includes(options.failOn) ? options.failOn : 'both'
@@ -853,6 +857,7 @@ export function compareSummaries(base, cand, options = {}) {
return {
format: COMPARE_FORMAT,
+ environment,
base: { label: sideLabel(base, 'base'), profile: sideProfile(base, 'base'), model: baseModel, dshVersion: sideDshVersion(base, 'base') },
cand: { label: sideLabel(cand, 'cand'), profile: sideProfile(cand, 'cand'), model: candModel, dshVersion: sideDshVersion(cand, 'cand') },
aggregate,
diff --git a/bench/lib/environment.mjs b/bench/lib/environment.mjs
new file mode 100644
index 00000000..f5629b91
--- /dev/null
+++ b/bench/lib/environment.mjs
@@ -0,0 +1,49 @@
+import { parse } from 'yaml'
+
+const COMPONENTS = ['shell', 'file', 'ptc', 'discovery', 'panel', 'tui', 'hide-official']
+
+/** Read configuration facts, never evaluate an expression or infer publication readiness. */
+export function componentFacts(text) {
+ const facts = Object.fromEntries(COMPONENTS.map(name => [name, 'unknown']))
+ let rows
+ try { rows = parse(text) } catch { return facts }
+ if (!Array.isArray(rows)) return facts
+ for (const name of COMPONENTS) {
+ const found = rows.filter(row => row?.id === `dsh-ops-${name}`)
+ if (!found.length) facts[name] = 'absent'
+ else if (found.length === 1) {
+ const row = found[0]
+ if (row.disabled === true || row.config?.enabled === false) facts[name] = 'disabled'
+ else if ((row.disabled === undefined || row.disabled === false) && (row.config?.enabled === undefined || row.config.enabled === true)) facts[name] = 'enabled'
+ }
+ }
+ return facts
+}
+
+export function platformOf(run) {
+ const value = run?.meta?.platform ?? run?.platform
+ return typeof value === 'string' && value.trim() ? value : 'unknown'
+}
+
+/** Old unknown/unknown archives remain readable; a known/unknown pair needs the override too. */
+export function compareEnvironment(base, cand, options = {}) {
+ const platforms = { base: platformOf(base), cand: platformOf(cand) }
+ const crossPlatform = platforms.base !== platforms.cand
+ if (crossPlatform && options.allowCrossPlatform !== true) {
+ throw new Error(`platform mismatch: base=${platforms.base}, cand=${platforms.cand}; use --allow-cross-platform only for diagnostics (not a plugin effect)`)
+ }
+ return { platforms, crossPlatform, components: {
+ base: base?.components ?? base?.arms?.base?.components ?? null,
+ cand: cand?.components ?? cand?.arms?.cand?.components ?? null,
+ } }
+}
+
+export function environmentLines(environment) {
+ if (!environment) return []
+ return [
+ `Platform: base=${environment.platforms.base}; cand=${environment.platforms.cand}.`,
+ ...(environment.crossPlatform ? ['WARNING: cross-platform diagnostic; differences are not a plugin effect.'] : []),
+ `Component rows (configuration, not a readiness receipt): base=${JSON.stringify(environment.components.base)}; cand=${JSON.stringify(environment.components.cand)}.`,
+ '',
+ ]
+}
diff --git a/bench/report/panel.mjs b/bench/report/panel.mjs
index a90d83cf..1a901cff 100644
--- a/bench/report/panel.mjs
+++ b/bench/report/panel.mjs
@@ -375,6 +375,10 @@ function renderConclusion() {
: '噪声底(A/A)±' + SUMMARY.noise.pct.toFixed(1) + '% → ' + (SUMMARY.readable ? '超过噪声 ⇒ 可读方向' : '落在噪声内 ⇒ 只报区间,不报方向')
}
function renderOverview(node) {
+ const environment = el('div', 'card')
+ environment.appendChild(el('h3', null, 'Platform / component configuration'))
+ environment.appendChild(el('pre', null, JSON.stringify({ platform: obj(RUN.meta).platform || 'unknown', components: Object.fromEntries(Object.entries(obj(RUN.arms)).map(([key, arm]) => [key, obj(arm).components || null])) }, null, 2)))
+ node.appendChild(environment)
const keys = pairKeys()
const taskRows = SUMMARY.tasks.map((task) => {
const base = task.arms[keys[0]]
diff --git a/bench/run.mjs b/bench/run.mjs
index 84640ec9..a1483f61 100644
--- a/bench/run.mjs
+++ b/bench/run.mjs
@@ -42,6 +42,7 @@ import { RUNS_DIR } from './lib/next-folder.mjs'
import { DEFAULT_REPEAT, armOrder, createInterrupt, planPairs, planSessions, repeatsById, runSweep } from './run/schedule.mjs'
import { sealRun } from './run/seal.mjs'
import { renderReport, reportFileName } from './compare.mjs'
+import { componentFacts } from './lib/environment.mjs'
/** The runner-only flags that take a value; `--task`/`--repeat`/`--folder`/`--base`/`--cand` are parsed by the CLI. */
const VALUE_FLAGS = ['--sleep-ms', '--out', '--heartbeat-ms', '--stall-ms', '--timeout', '--dsh', '--llm-patch', '--model']
@@ -233,7 +234,7 @@ export function dumpProfileConfig(entry, profile, context = {}) {
const detail = String(result.stderr ?? '').trim().split('\n').filter(Boolean).pop() ?? ''
throw new Error(`"dsh --profile ${profile} --dump-config" exited ${String(result.status)}${detail === '' ? '' : `: ${detail}`}`)
}
- return `${result.stdout ?? ''}\n${result.stderr ?? ''}`
+ return result.stdout ?? ''
}
/**
@@ -253,6 +254,7 @@ export function profileFingerprints(arms, context = {}) {
throw new Error(`"dsh --profile ${arm.profile} --dump-config" printed nothing, so the ${arm.side} arm has no profile fingerprint`)
}
hashes[arm.side] = createHash('sha256').update(text).digest('hex')
+ arm.components = componentFacts(text)
}
return hashes
}
@@ -478,12 +480,12 @@ export async function main(argv, io = {}) {
// fingerprint, and a sweep that cannot be sealed is not evidence. Refusing here costs
// nothing; refusing after the sweep would have spent the tokens first.
let hashes = io.profileHashes ?? null
- if (hashes === null && seal !== undefined) {
+ if (hashes === null) {
try {
hashes = profileFingerprints(arms, { entry, dump: io.dumpProfileConfig, env: io.env })
} catch (error) {
err(`bench ab: ${String(error?.message ?? error)}`)
- err('nothing was spawned and no token was spent; run "node bench/cli.mjs setup" if the profiles are not prepared yet, or pass --no-seal to sweep without sealing.')
+ err('nothing was spawned and no token was spent; run "node bench/cli.mjs setup" if the profiles are not prepared yet. Configuration facts are required even without --seal.')
return EXIT.failure
}
}
diff --git a/bench/run.schema.json b/bench/run.schema.json
index 494ee664..81538a7c 100644
--- a/bench/run.schema.json
+++ b/bench/run.schema.json
@@ -73,6 +73,7 @@
"$defs": {
"arm": { "description": "One arm of the comparison: what it was called, which profile contents it ran, and how it was launched.", "type": "object", "additionalProperties": false, "required": ["label", "profile", "profileHash"],
"properties": {
+ "components": { "description": "Component states read from composed configuration, not a publication/readiness receipt. Null for older or injected records.", "type": ["object", "null"], "additionalProperties": { "type": "string", "enum": ["enabled", "disabled", "absent", "unknown"] } },
"label": { "description": "Arm label, also the profile name it ran, e.g. bench-base.", "type": "string" },
"profile": { "description": "Profile name whose resolved configuration was measured.", "type": "string" },
"profileHash": { "description": "SHA-256 in hex of the arm's dumped configuration, the single-variable fingerprint.", "type": "string" },
diff --git a/bench/run/schedule.mjs b/bench/run/schedule.mjs
index bc821598..94ae9d5c 100644
--- a/bench/run/schedule.mjs
+++ b/bench/run/schedule.mjs
@@ -204,6 +204,9 @@ function sessionOptions(arm, pair, spec, options, ctx) {
/** One arm's summary: the shape `bench/compare.mjs` reads as a side. */
function buildSummary(arm, rows, ctx) {
return {
+ meta: { platform: ctx.spec.meta?.platform ?? `${process.platform}-${process.arch}` },
+ profileHash: arm.profileHash ?? null,
+ components: arm.components ?? null,
format: SUMMARY_FORMAT,
label: arm.label,
profile: arm.profile,
@@ -237,6 +240,7 @@ function noiseFor(spec, summaries, arms) {
/** The arm descriptor the seal step (P8a) lifts into `run.json`'s `arms`. */
function descriptor(arm) {
return {
+ components: arm.components ?? null,
label: arm.label,
profile: arm.profile,
profileHash: arm.profileHash ?? null,
diff --git a/bench/setup.mjs b/bench/setup.mjs
index fcadabc8..705fcf36 100644
--- a/bench/setup.mjs
+++ b/bench/setup.mjs
@@ -70,7 +70,7 @@ export async function main(argv, io = {}) {
const out = io.out ?? ((line) => console.log(line))
const err = io.err ?? ((line) => console.error(line))
let options
- try { options = parseArgs(argv, EXTRA_FLAGS) } catch (error) {
+ try { options = parseArgs(argv, { ...EXTRA_FLAGS, platform: io.platform }) } catch (error) {
err(`${error.message}\n\n${USAGE}`)
return EXIT.usage
}
diff --git a/bench/setup/preflight.mjs b/bench/setup/preflight.mjs
index a77486da..0f516df5 100644
--- a/bench/setup/preflight.mjs
+++ b/bench/setup/preflight.mjs
@@ -434,12 +434,15 @@ export function preflight(options, context = {}) {
else if (permissions.ok) log(` both sides will pin ${pairText(permissions.expected)}`)
log('\n— preflight: PSModulePath —')
- const modulePath = inspectModulePath({ modulePath: env.PSModulePath, windows: env.SystemRoot, isDirectory: context.isDirectory })
- outcomes.modulePath = modulePath ? 'poisoned' : 'clean'
+ const modulePath = platform === 'win32' ? inspectModulePath({ modulePath: env.PSModulePath, windows: env.SystemRoot, isDirectory: context.isDirectory }) : null
+ outcomes.modulePath = platform !== 'win32' ? 'skipped' : modulePath ? 'poisoned' : 'clean'
if (modulePath) {
log(` ✗ ${modulePath.entry} is ahead of the in-box 5.1 module directory`)
log(` search order: ${describeModulePath(env.PSModulePath, modulePath.entry)}`)
warnings.push(modulePath.warning)
+ } else if (platform !== 'win32') {
+ log(' skipped: Windows PSModulePath ordering does not apply on this platform')
+ log(' skipped: Windows Bash IPC proxy and bundled Windows shell payload checks do not apply')
} else {
log(' clean: no Core module root ahead of the in-box Windows PowerShell 5.1 directory')
}
diff --git a/bench/setup/profile.mjs b/bench/setup/profile.mjs
index ddb6ca75..da447a8f 100644
--- a/bench/setup/profile.mjs
+++ b/bench/setup/profile.mjs
@@ -152,12 +152,13 @@ export function defaultShellPath() {
/** The defaults, before any flag is read.
* @returns {Options} the defaults. */
-function defaultOptions() {
+function defaultOptions(platform = process.platform) {
return {
baseProfile: 'bench-base', candProfile: 'bench-ops', template: 'headless',
// There is no Windows PowerShell to pin off Windows, and `dsh-pwsh-sandbox` is
// Windows-gated in the composition anyway, so the default is "no pin" there.
- pinShell: process.platform === 'win32' ? defaultShellPath() : null,
+ pinShell: platform === 'win32' ? defaultShellPath() : null,
+ platform,
permissionMode: 'danger-full-access',
// The candidate measures the working tree: the default install is this checkout, so a
// sweep prepares itself from the code under edit without a publish or a version bump.
@@ -175,7 +176,7 @@ function defaultOptions() {
* @returns {Options} the options.
*/
export function parseArgs(argv, extra = {}) {
- const options = defaultOptions()
+ const options = defaultOptions(extra.platform)
const values = { ...VALUE_FLAGS, ...extra.value }
const booleans = { ...BOOLEAN_FLAGS, ...extra.boolean }
for (let i = 0; i < argv.length; i += 1) {
@@ -200,7 +201,7 @@ export function parseArgs(argv, extra = {}) {
}
}
if (options.baseProfile === options.candProfile) throw new Error('--base-profile and --cand-profile must differ')
- if (options.pinShell && process.platform === 'win32' && !path.isAbsolute(options.pinShell)) {
+ if (options.pinShell && options.platform === 'win32' && !path.isAbsolute(options.pinShell)) {
throw new Error(`--pin-shell must be an absolute path, got "${options.pinShell}"`)
}
if (!PERMISSION_MODES.includes(options.permissionMode)) {
@@ -590,7 +591,9 @@ function ensureProfile(name, context) {
*/
function pinBaselineShell(options, context) {
if (!options.pinShell) {
- context.log(' --no-pin: nothing was touched. The baseline then hits the PATH pwsh alias, so both sides run PowerShell 7')
+ context.log(options.platform !== 'win32'
+ ? ' skipped: Windows PowerShell pin is inapplicable on this platform; the host selects its configured shell'
+ : ' --no-pin: nothing was touched; the host selects its configured shell')
return 'skipped'
}
const file = path.join(context.home, 'profiles', options.baseProfile, 'cordis.patch.yml')
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 24618fe8..d4d7fede 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -7,6 +7,8 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
## [Unreleased]
+- Make benchmark setup skip Windows-only checks explicitly on POSIX, preserve platform/component configuration in summaries and sealed arms, reject cross-platform comparisons by default, and expand the optional tools smoke to Linux, Windows and macOS (#295).
+- 基准准备在 POSIX 上明确跳过 Windows 专用检查,普通摘要和封存数据保留平台及组件配置,默认拒绝跨平台混配,可选 tools 自检覆盖 Linux、Windows、macOS(#295)。
- Document the owned Bash restriction boundary and publication switch (#380),
provider tool-change error ownership (#367), and scoped discovery troubleshooting (#346).
- 说明 Bash 自有工具的掩码边界与发布开关、提供商工具变更错误的职责边界及 discovery 作用域排查方法。
diff --git a/test/bench-ci.test.mjs b/test/bench-ci.test.mjs
index 9811100d..b0640c6f 100644
--- a/test/bench-ci.test.mjs
+++ b/test/bench-ci.test.mjs
@@ -65,7 +65,13 @@ await test('"CI required" neither waits on nor judges the smoke', () => {
await test('the smoke drives the one CLI entry point with the same binary on both sides', () => {
assert.match(benchRuns, /node bench\/cli\.mjs tools\b/u, 'the smoke goes through the single cross-platform entry')
assert.match(benchRuns, /--baseline "\$worker" --candidate "\$worker"/u, 'both sides are the same staged worker')
- assert.match(benchRuns, /runtime\/linux-x64\/dsh-ops-worker/u, 'it uses the worker this job just built and staged')
+ assert.ok(benchRuns.includes('runtime/${{ matrix.target }}/${{ matrix.worker }}'), 'each runner uses its own staged worker')
+ assert.equal(benchJob['runs-on'], '${{ matrix.os }}')
+ assert.deepEqual(benchJob.strategy.matrix.include, [
+ { os: 'ubuntu-24.04', target: 'linux-x64', worker: 'dsh-ops-worker' },
+ { os: 'windows-latest', target: 'win32-x64', worker: 'dsh-ops-worker.exe' },
+ { os: 'macos-latest', target: 'darwin-arm64', worker: 'dsh-ops-worker' },
+ ])
assert.doesNotMatch(benchRuns, /bench\/cli\.mjs ab\b/u, 'the model-driven sweep is never run in CI')
for (const flag of ['--max-ms', '--budget', '--threshold']) {
assert.equal(benchRuns.includes(flag), false, `no timing gate in the smoke (${flag})`)
@@ -81,6 +87,7 @@ await test('the smoke uploads report.md / run.json / panel.html when they exist'
assert.equal(uploads.length, 1, 'the smoke uploads exactly one artifact')
const upload = uploads[0]
assert.match(String(upload.with?.name ?? ''), /-report$/u, 'the name follows the tools-trial-report convention')
+ assert.ok(upload.with.name.includes('${{ matrix.target }}'), 'parallel platforms never overwrite one another’s artifact')
assert.equal(upload.with?.['if-no-files-found'], 'warn', 'a tools-only smoke makes no run.json and must not fail on that')
assert.match(String(upload.if ?? ''), /always\(\)/u, 'evidence is uploaded even when the comparison is red')
const paths = String(upload.with?.path ?? '')
diff --git a/test/bench-compare.test.mjs b/test/bench-compare.test.mjs
index b23779ff..e3f5d0ae 100644
--- a/test/bench-compare.test.mjs
+++ b/test/bench-compare.test.mjs
@@ -167,7 +167,8 @@ await test('the same comparison renders byte-identical text twice: tables first,
const text = renderReport(beyond)
assert.equal(renderReport(beyond), text, 'rendering is a pure function of its inputs')
assertEnglish(text)
- assert.match(text, /^# DSH-OPS Benchmark Report \(beta\)\n\n## Per-task pairs$/m, 'the title leads straight into the data — no summary bar')
+ assert.match(text, /^# DSH-OPS Benchmark Report \(beta\)\n\nPlatform: base=unknown; cand=unknown\./, 'the title states environment evidence before the data')
+ assert.match(text, /^## Per-task pairs$/m)
assert.match(text, /^\| task \| base \| ops \(Δ%\) \| steps: base→ops \(Δ%\) \| tool calls: base→ops \(Δ%\) \| time: base→ops \(Δ%\) \| tool errors \| pass \|$/m, 'the first table carries cost, behaviour, wall-clock evidence and pass: no pairs, exclusion or direction column')
assert.ok(text.includes(`| csv-sum | 1,000 | 926 (${green('\u22127.4%')}) | 4 → 4 (${green('0.0%')}) | 6 → 6 (${green('0.0%')}) | 60.0s → 55.0s (${green('\u22128.3%')}) | 0 → 0 | 2/2→2/2 |`), 'each cell keeps its own Δ% in parentheses, painted like every other percent — time included')
assert.doesNotMatch(text, /geomean/, 'the table ends at TOTAL: the geomean row is gone')
diff --git a/test/bench-platform.test.mjs b/test/bench-platform.test.mjs
new file mode 100644
index 00000000..9f549d51
--- /dev/null
+++ b/test/bench-platform.test.mjs
@@ -0,0 +1,95 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { assert, report, test, withTempDir } from './lib/harness.mjs'
+import { parseArgs as setupArgs } from '../bench/setup/profile.mjs'
+import { preflight } from '../bench/setup/preflight.mjs'
+import { compareSummaries } from '../bench/lib/compare-core.mjs'
+import { main as compare, renderReport } from '../bench/compare.mjs'
+import { componentFacts } from '../bench/lib/environment.mjs'
+import { dumpProfileConfig, profileFingerprints } from '../bench/run.mjs'
+import { main as setup } from '../bench/setup.mjs'
+
+await test('POSIX setup never probes Windows module directories or pins PowerShell', () => {
+ for (const platform of ['linux', 'darwin']) {
+ const options = setupArgs(['--plugin', 'none', '--dry-run'], { platform })
+ assert.equal(options.pinShell, null)
+ const lines = []
+ const result = preflight(options, { platform, log: line => lines.push(line),
+ env: { PSModulePath: 'C:\\Core;C:\\Windows', SystemRoot: 'C:\\Windows' },
+ isDirectory: () => { throw new Error('Windows probe on POSIX') } })
+ assert.equal(result.outcomes.modulePath, 'skipped')
+ assert.equal(result.problems.length, 0)
+ assert.match(lines.join('\n'), /skipped.*Windows/)
+ }
+})
+
+await test('platform mismatch refuses pairing before statistics, including A/A inputs', () => {
+ const base = { meta: { platform: 'linux-x64' }, results: [] }
+ const cand = { meta: { platform: 'darwin-arm64' }, results: [] }
+ assert.throws(() => compareSummaries(base, cand), /linux-x64.*darwin-arm64/)
+ assert.throws(() => compareSummaries(base, base, { noise: { base, cand } }), /platform/)
+ const diagnostic = compareSummaries(base, cand, { allowCrossPlatform: true })
+ assert.match(renderReport(diagnostic), /not a plugin effect/)
+ assert.equal(diagnostic.environment.crossPlatform, true)
+ assert.doesNotThrow(() => compareSummaries({ results: [] }, { results: [] }))
+})
+
+await test('compare CLI returns 2 for different platforms and requires an explicit override', async () => {
+ await withTempDir('bench-platform-', async dir => {
+ const files = ['base', 'cand'].map(name => path.join(dir, `${name}.json`))
+ for (const [i, file] of files.entries()) fs.writeFileSync(file, JSON.stringify({ format: 'dsh-ops-bench-summary-v1', meta: { platform: ['linux-x64', 'win32-x64'][i] }, results: [] }))
+ const lines = []
+ const io = { out: line => lines.push(line), err: line => lines.push(line) }
+ assert.equal(await compare([...files, '--out', '-'], io), 2)
+ assert.match(lines.join('\n'), /linux-x64.*win32-x64/)
+ lines.length = 0
+ assert.equal(await compare([...files, '--out', '-', '--allow-cross-platform'], io), 0)
+ assert.match(lines.join('\n'), /not a plugin effect/)
+ })
+})
+
+await test('composed component facts distinguish disabled, absent and unevaluated rows', () => {
+ const dump = '- id: dsh-ops-shell\n disabled: false\n- id: dsh-ops-discovery\n config:\n enabled: false\n- id: dsh-ops-panel\n disabled: process.platform !== "win32"\n'
+ const facts = componentFacts(dump)
+ assert.equal(facts.shell, 'enabled')
+ assert.equal(facts.discovery, 'disabled')
+ assert.equal(facts.panel, 'unknown')
+ assert.equal(facts.ptc, 'absent')
+ const arms = [{ side: 'base', profile: 'base' }, { side: 'cand', profile: 'cand' }]
+ profileFingerprints(arms, { dump: () => dump })
+ assert.deepEqual(arms[0].components, facts)
+ assert.deepEqual(arms[1].components, facts)
+ assert.equal(componentFacts('bad: [').shell, 'unknown')
+ assert.equal(componentFacts('- id: dsh-ops-shell\n- id: dsh-ops-shell\n').shell, 'unknown')
+})
+
+await test('Linux and macOS dry-run traverse setup without executing or writing profiles', async () => {
+ await withTempDir('bench-posix-setup-', async dir => {
+ const example = path.join(dir, 'example.yml')
+ fs.writeFileSync(example, '[]\n')
+ for (const platform of ['linux', 'darwin']) {
+ const home = path.join(dir, platform)
+ const lines = []
+ const code = await setup(['--dsh-home', home, '--plugin', 'none', '--dry-run'], {
+ platform, arch: 'x64', env: {}, entry: { kind: 'exe', file: 'fixture-dsh' },
+ overlay: path.join(dir, 'overlay.yml'), overlayExample: example,
+ invoke: () => { throw new Error('dry-run spawned the host') },
+ out: line => lines.push(line), err: line => lines.push(line),
+ })
+ assert.equal(code, 0, lines.join('\n'))
+ assert.match(lines.join('\n'), /skipped: Windows PowerShell pin/)
+ assert.match(lines.join('\n'), /skipped: Windows PSModulePath/)
+ assert.equal(fs.existsSync(home), false)
+ }
+ })
+})
+
+await test('host diagnostics cannot contaminate component configuration parsing', () => {
+ const text = dumpProfileConfig({ kind: 'exe', file: 'fixture-dsh' }, 'bench', { run: () => ({
+ status: 0, stdout: '- id: dsh-ops-shell\n', stderr: 'Warning: diagnostic, not configuration\n',
+ }) })
+ assert.equal(componentFacts(text).shell, 'enabled')
+ assert.doesNotMatch(text, /Warning/)
+})
+
+report('bench-platform')
diff --git a/test/bench-schedule.test.mjs b/test/bench-schedule.test.mjs
index 4aa37e0d..4c356952 100644
--- a/test/bench-schedule.test.mjs
+++ b/test/bench-schedule.test.mjs
@@ -524,7 +524,7 @@ await test('main: a sweep is unsealed by default and --seal opts in', async () =
resolveEntry: () => ENTRY,
installSignals: false,
now: NOW,
- dumpProfileConfig: () => { throw new Error('the default must not fingerprint anything') },
+ dumpProfileConfig: () => '- id: dsh-ops-shell\n disabled: false\n',
seal: () => { sealed += 1; return {} },
})
assert.equal(code, EXIT.ok)
@@ -534,6 +534,9 @@ await test('main: a sweep is unsealed by default and --seal opts in', async () =
const group = path.join(root, 'runs', fs.readdirSync(path.join(root, 'runs'))[0])
const armDir = fs.readdirSync(group).find((name) => name.endsWith('bench-base'))
assert.ok(fs.existsSync(path.join(group, armDir, 'summary.json')), 'the per-arm summary is the artifact the report reads')
+ const summary = JSON.parse(fs.readFileSync(path.join(group, armDir, 'summary.json'), 'utf8'))
+ assert.equal(summary.components.shell, 'enabled', 'unsealed summaries still preserve component configuration')
+ assert.equal(summary.meta.platform, `${process.platform}-${process.arch}`)
assert.equal(fs.existsSync(path.join(group, 'run.json')), false, 'nothing sealed this sweep')
const reports = fs.readdirSync(group).filter((name) => name.startsWith('DSH-OPS Benchmark Report') && name.endsWith('.md'))
assert.equal(reports.length, 1, 'a finished sweep writes its own report into the group folder')
@@ -555,6 +558,7 @@ await test('main: a sweep is unsealed by default and --seal opts in', async () =
installSignals: false,
now: NOW,
seal: () => { throw new Error('--no-seal must never seal') },
+ profileHashes: HASHES,
}), EXIT.ok)
assert.match(explicit.text(), /no — not requested/)
})
@@ -563,7 +567,7 @@ await test('main: a sweep is unsealed by default and --seal opts in', async () =
await test('main: a finished sweep writes the report by default and --no-report skips it', async () => {
await withTempDir('bench-main-report', async (root) => {
const runsRoot = path.join(root, 'runs')
- const base = { tasksDir: tasksDir(root, 1), runsDir: runsRoot, llmOverlay: overlayIn(root), resolveEntry: () => ENTRY, installSignals: false, now: NOW }
+ const base = { tasksDir: tasksDir(root, 1), runsDir: runsRoot, llmOverlay: overlayIn(root), resolveEntry: () => ENTRY, installSignals: false, now: NOW, profileHashes: HASHES }
const groups = () => fs.readdirSync(runsRoot)
const out = sink()
diff --git a/test/bench-setup-preflight.test.mjs b/test/bench-setup-preflight.test.mjs
index ebaf7e49..5a48a006 100644
--- a/test/bench-setup-preflight.test.mjs
+++ b/test/bench-setup-preflight.test.mjs
@@ -220,7 +220,7 @@ await test('the preflight reports the poisoned PSModulePath as a warning with th
const isDirectory = (file) => file === path.win32.join(CORE, 'Microsoft.PowerShell.Security')
const options = { baseProfile: 'bench-base', candProfile: 'bench-ops', permissionMode: 'danger-full-access', plugin: 'local', noBuildCheck: true }
const lines = []
- const dirty = preflight(options, { home: path.join(dir, 'home'), env: poisoned, platform: 'linux', arch: 'x64', log: (line) => lines.push(line), isDirectory })
+ const dirty = preflight(options, { home: path.join(dir, 'home'), env: poisoned, platform: 'win32', arch: 'x64', log: (line) => lines.push(line), isDirectory })
assert.equal(dirty.ok, true, 'a poisoned module path is a warning, not a refusal')
assert.equal(dirty.outcomes.modulePath, 'poisoned')
assert.equal(dirty.warnings.length, 1)
@@ -228,7 +228,7 @@ await test('the preflight reports the poisoned PSModulePath as a warning with th
assert.match(lines.join('\n'), /search order: 1\. C:\\Program Files\\PowerShell\\7\\Modules/)
assert.match(lines.join('\n'), /preflight: PSModulePath/)
- const clean = preflight(options, { home: path.join(dir, 'home'), env: { SystemRoot: 'C:\\Windows' }, platform: 'linux', arch: 'x64' })
+ const clean = preflight(options, { home: path.join(dir, 'home'), env: { SystemRoot: 'C:\\Windows' }, platform: 'win32', arch: 'x64' })
assert.equal(clean.outcomes.modulePath, 'clean')
assert.deepEqual(clean.warnings, [])
})
@@ -466,10 +466,11 @@ await test('the poisoned PSModulePath reaches main as a warning and never blocks
await withTempDir('bench-pre-main-module-', async (dir) => {
const fx = fixture(dir)
const io = fx.io({
+ platform: 'win32',
env: { SystemRoot: 'C:\\Windows', PSModulePath: `${CORE};${IN_BOX}` },
isDirectory: (file) => file === path.win32.join(CORE, 'Microsoft.PowerShell.Security'),
})
- const code = await main(['--dsh-home', fx.home, '--no-pin', '--dry-run'], io)
+ const code = await main(['--dsh-home', fx.home, '--no-pin', '--dry-run', '--no-build-check'], io)
assert.equal(code, 0, fx.err())
assert.match(fx.err(), /cmd\.exe/)
assert.match(fx.out(), /preflight: PSModulePath/)