@catheadowl/dsh-eval 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/dsh-eval.mjs CHANGED
@@ -1,335 +1,335 @@
1
- #!/usr/bin/env node
2
- /**
3
- * dsh-eval — the plugin agent-eval case executor.
4
- *
5
- * Usage:
6
- * dsh-eval run --profile <name> --repo <deepseek-harness dir>
7
- * [--mode real|mock|all] [--keep-artifacts] [--fail-on-skip]
8
- * [--format text|json] [--report <file>]
9
- * <case paths...>
10
- *
11
- * A case path is a `*.eval.mjs` file or a directory scanned recursively for
12
- * them. Each file default-exports one case object (or an array of them):
13
- * `{ id, task, mode?: 'real'|'mock', expect: Matcher[], script?, persona?,
14
- * prepare?, timeoutMs? }`. Real cases skip when DEEPSEEK_API_KEY is absent;
15
- * the exit code is 1 when any run fails. Failures keep their artifacts under
16
- * `<case file dir>/.runs/<case id>/`.
17
- *
18
- * Output formats:
19
- * - `--format text` (default): unchanged human output on stdout/stderr.
20
- * - `--format json`: all progress and failure chatter moves to stderr;
21
- * stdout receives exactly one JSON report object (see src/report.mjs).
22
- * - `--report <file>`: additionally write that report object to a file,
23
- * in either format — the aggregation/CI consumption path.
24
- */
25
-
26
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
27
- import { homedir } from 'node:os'
28
- import { dirname, isAbsolute, join, resolve } from 'node:path'
29
- import { pathToFileURL } from 'node:url'
30
- import { runEvalCase } from '../src/runner.mjs'
31
- import { discoverFiles, validateEvalCase, detectDuplicateIds } from '../src/discovery.mjs'
32
- import { createCaseRecord, buildRunReport, reportExitCode, mockDeterminismHint } from '../src/report.mjs'
33
- import { loadEvalConfig } from '../src/config.mjs'
34
- import { resolveDshCliChain } from '../src/cli.mjs'
35
-
36
- function usage(error) {
37
- const text = [
38
- 'usage: dsh-eval run [--profile <name>] [--repo <deepseek-harness>] [--mode real|mock|all] [--keep-artifacts] [--fail-on-skip] [--format text|json] [--report <file>] <case paths...>',
39
- ' --profile/--repo/--mode/--fail-on-skip/--report may come from a dsh-eval.config.mjs found upward from cwd; flags override it.',
40
- ].join('\n')
41
- if (error === undefined) {
42
- process.stdout.write(`${text}\n`)
43
- process.exit(0)
44
- }
45
- process.stderr.write(`${error}\n${text}\n`)
46
- process.exit(2)
47
- }
48
-
49
- /** Parse argv: known flags, then case paths. Profile/repo/mode/failOnSkip
50
- * may come from a `dsh-eval.config.mjs` instead of flags (flags win);
51
- * required-ness is checked after config merging, not here. */
52
- function parseArgs(argv) {
53
- const options = {
54
- profile: undefined, repo: undefined, mode: undefined,
55
- keepArtifacts: false, failOnSkip: undefined, format: 'text', report: undefined,
56
- }
57
- const paths = []
58
- for (let i = 0; i < argv.length; i += 1) {
59
- const arg = argv[i]
60
- if (arg === 'run') continue
61
- if (arg === '--profile') { options.profile = argv[++i]; continue }
62
- if (arg === '--repo') { options.repo = argv[++i]; continue }
63
- if (arg === '--mode') { options.mode = argv[++i]; continue }
64
- if (arg === '--keep-artifacts') { options.keepArtifacts = true; continue }
65
- if (arg === '--fail-on-skip') { options.failOnSkip = true; continue }
66
- if (arg === '--format') { options.format = argv[++i]; continue }
67
- if (arg === '--report') { options.report = argv[++i]; continue }
68
- if (arg === '-h' || arg === '--help') usage()
69
- paths.push(arg)
70
- }
71
- if (!['real', 'mock', 'all'].includes(options.mode ?? 'all')) usage(`error: --mode must be real, mock, or all (got '${options.mode}')`)
72
- if (!['text', 'json'].includes(options.format)) usage(`error: --format must be text or json (got '${options.format}')`)
73
- if (paths.length === 0) usage('error: at least one case file or directory is required')
74
- return { options, paths }
75
- }
76
-
77
- /**
78
- * Line output that respects the format: in `json` mode stdout is reserved
79
- * for the single report object, so progress lines go to stderr instead.
80
- */
81
- function say(line) {
82
- if (jsonFormat) process.stderr.write(`${line}\n`)
83
- else process.stdout.write(`${line}\n`)
84
- }
85
-
86
- /** Recursively collect `*.eval.mjs` files from one file or directory path. */
87
- function discoverCaseFiles(path) {
88
- return discoverFiles(path, '.eval.mjs')
89
- }
90
-
91
- /** Import one case file, validate shape, and normalize to a case array. */
92
- async function loadCases(file) {
93
- const module = await import(pathToFileURL(file).href)
94
- const exported = module.default
95
- const list = Array.isArray(exported) ? exported : [exported]
96
- for (const evalCase of list) {
97
- validateEvalCase(evalCase, file)
98
- }
99
- return list.map(evalCase => ({ ...evalCase, __file: file }))
100
- }
101
-
102
- /**
103
- * Whether a model credential is available to a real run: the process
104
- * environment, or the managed `$DSH_HOME/.credentials.yaml` document that
105
- * `dsh-credentials-local` resolves per request. The key's VALUE is never
106
- * read here — presence is the gate.
107
- */
108
- function credentialAvailable() {
109
- if (process.env.DEEPSEEK_API_KEY !== undefined) return true
110
- const home = (process.env.DSH_HOME ?? '').trim() !== '' ? process.env.DSH_HOME : join(homedir(), '.dsh')
111
- return existsSync(join(home, '.credentials.yaml'))
112
- }
113
-
114
- /** Why a case is skipped, or undefined when it should run. */
115
- function skipReason(evalCase, modeFilter) {
116
- const mode = evalCase.mode ?? 'real'
117
- if (modeFilter !== 'all' && mode !== modeFilter) return `--mode ${modeFilter}`
118
- if (mode === 'real' && !credentialAvailable()) {
119
- return 'no credential (DEEPSEEK_API_KEY unset and no $DSH_HOME/.credentials.yaml)'
120
- }
121
- return undefined
122
- }
123
-
124
- /**
125
- * Persist one run's post-mortem artifacts under `.runs/<case id>/` next to
126
- * the case file: the in-memory streams/trace plus the raw session logs
127
- * captured before the run dir cleanup.
128
- */
129
- function writeArtifacts(evalCase, result, mode) {
130
- const artifactsDir = join(dirname(evalCase.__file), '.runs', evalCase.id)
131
- try {
132
- mkdirSync(artifactsDir, { recursive: true })
133
- writeFileSync(join(artifactsDir, 'stdout.txt'), result.stdout)
134
- writeFileSync(join(artifactsDir, 'stderr.txt'), result.stderr)
135
- writeFileSync(join(artifactsDir, 'trace.json'), JSON.stringify({
136
- caseId: evalCase.id, mode, task: evalCase.task,
137
- exitCode: result.exitCode, timedOut: result.timedOut, trace: result.trace,
138
- }, undefined, 2))
139
- result.sessionLogs.forEach((text, index) => {
140
- writeFileSync(join(artifactsDir, `session-${index}.jsonl`), text)
141
- })
142
- } catch { /* artifact persistence is best-effort */ }
143
- return artifactsDir
144
- }
145
-
146
- const startedAt = new Date().toISOString()
147
- const { options, paths } = parseArgs(process.argv.slice(2))
148
- const jsonFormat = options.format === 'json'
149
-
150
- // Config merge: a `dsh-eval.config.mjs` reachable from cwd
151
- // supplies defaults; explicit flags always win. Required-ness is only
152
- // decided after the merge, so config-only invocations work.
153
- const { config } = await loadEvalConfig(process.cwd())
154
- const profile = options.profile ?? config.profile
155
- const modeFilter = options.mode ?? config.mode ?? 'all'
156
- const failOnSkip = options.failOnSkip ?? config.failOnSkip ?? false
157
- if (profile === undefined) usage('error: --profile <name> is required (or set profile in dsh-eval.config.mjs)')
158
- // CLI resolution (C6, spec host-checkout-resolution): `--repo` flag >
159
- // resolution layer (node_modules/@deepseek-ai/dsh) > config repo key (legacy).
160
- // Committed files carry no real host-checkout path.
161
- const { cli: cliPath, repo: repoDir, source: cliSource } = resolveDshCliChain({
162
- repoFlag: options.repo,
163
- configRepo: config.repo,
164
- })
165
- const reportRepo = repoDir ?? cliPath
166
-
167
- const files = paths.flatMap(path => {
168
- const absolute = resolve(path)
169
- if (!existsSync(absolute)) usage(`error: no such case path: ${path}`)
170
- return discoverCaseFiles(absolute)
171
- })
172
- if (files.length === 0) usage('error: no *.eval.mjs case files found')
173
-
174
- const records = []
175
- const seenIds = new Map()
176
-
177
- for (const file of files.sort()) {
178
- let cases
179
- try {
180
- cases = await loadCases(file)
181
- } catch (error) {
182
- records.push(createCaseRecord({
183
- id: file, file, status: 'fail',
184
- failures: [`failed to load cases: ${error.message}`],
185
- }))
186
- process.stderr.write(`FAIL ${file}: failed to load cases: ${error.message}\n`)
187
- continue
188
- }
189
- // Intra-file duplicate check
190
- try {
191
- detectDuplicateIds(cases)
192
- } catch (error) {
193
- records.push(createCaseRecord({
194
- id: file, file, status: 'fail',
195
- failures: [error.message],
196
- }))
197
- process.stderr.write(`FAIL ${file}: ${error.message}\n`)
198
- continue
199
- }
200
- // Cross-file duplicate check (only add to seenIds after all pass)
201
- let hasDuplicate = false
202
- for (const c of cases) {
203
- if (seenIds.has(c.id)) {
204
- const message = `duplicate case id '${c.id}' (also in ${seenIds.get(c.id)})`
205
- records.push(createCaseRecord({
206
- id: c.id, file, mode: c.mode ?? 'real', status: 'fail',
207
- failures: [message],
208
- }))
209
- process.stderr.write(`FAIL ${file}: ${message}\n`)
210
- hasDuplicate = true
211
- break
212
- }
213
- }
214
- if (hasDuplicate) continue
215
- for (const c of cases) seenIds.set(c.id, file)
216
- for (const rawCase of cases) {
217
- // Row-disable precedence: a case's own `disableRows` —
218
- // including an explicit `[]` ("disable nothing") — overrides the
219
- // config-level default; only an undeclared field inherits it.
220
- const evalCase = rawCase.disableRows === undefined && config.disableRows !== undefined
221
- ? { ...rawCase, disableRows: config.disableRows }
222
- : rawCase
223
- const mode = evalCase.mode ?? 'real'
224
- const skip = skipReason(evalCase, modeFilter)
225
- if (skip !== undefined) {
226
- records.push(createCaseRecord({
227
- id: evalCase.id, file, mode, status: 'skip', skipReason: skip,
228
- }))
229
- say(`SKIP ${evalCase.id}: ${skip}`)
230
- continue
231
- }
232
- say(`RUN ${evalCase.id} (${mode})...`)
233
- const runStartedAt = Date.now()
234
- let result
235
- try {
236
- result = await runEvalCase(evalCase, { profile, cliPath, dshRepoDir: repoDir, mode })
237
- } catch (error) {
238
- records.push(createCaseRecord({
239
- id: evalCase.id, file, mode, status: 'fail',
240
- failures: [`runner error: ${error.message}`],
241
- durationMs: Date.now() - runStartedAt,
242
- }))
243
- process.stderr.write(`FAIL ${evalCase.id}: runner error: ${error.message}\n`)
244
- continue
245
- }
246
- const durationMs = Date.now() - runStartedAt
247
-
248
- if (result.trace === undefined) {
249
- const artifactsDir = writeArtifacts(evalCase, result, mode)
250
- records.push(createCaseRecord({
251
- id: evalCase.id, file, mode, status: 'fail',
252
- failures: [`no session trace materialized (exit ${result.exitCode}${result.timedOut ? ', timed out' : ''})`],
253
- exitCode: result.exitCode, timedOut: result.timedOut,
254
- durationMs, artifactsDir,
255
- }))
256
- process.stderr.write(
257
- `FAIL ${evalCase.id}: no session trace materialized (exit ${result.exitCode}${result.timedOut ? ', timed out' : ''})\n`
258
- + ` artifacts: ${artifactsDir}\n--- stderr ---\n${result.stderr}\n`,
259
- )
260
- continue
261
- }
262
-
263
- const failures = []
264
- if (result.exitCode !== 0) {
265
- // Headless SSOT: exit 0 iff the turn completed. A run that errored out
266
- // must not pass on coincidentally satisfied matchers.
267
- failures.push(`dsh CLI exited with code ${result.exitCode} (the turn did not complete)`)
268
- }
269
- for (const matcher of evalCase.expect) {
270
- const outcome = matcher.check(result.trace)
271
- if (!outcome.ok) failures.push(`${matcher.describe}: ${outcome.message}`)
272
- }
273
- if (result.timedOut) failures.push('run timed out')
274
- if (result.inspectError !== undefined) failures.push(`workspace inspect failed: ${result.inspectError}`)
275
-
276
- if (failures.length === 0) {
277
- const artifactsDir = options.keepArtifacts ? writeArtifacts(evalCase, result, mode) : undefined
278
- records.push(createCaseRecord({
279
- id: evalCase.id, file, mode, status: 'pass',
280
- exitCode: result.exitCode, timedOut: result.timedOut,
281
- durationMs, ...(artifactsDir !== undefined ? { artifactsDir } : {}),
282
- }))
283
- say(`PASS ${evalCase.id}`)
284
- } else {
285
- const artifactsDir = writeArtifacts(evalCase, result, mode)
286
- // Self-explaining failure for broken mock determinism: when non-host
287
- // plugin injections are visible in the
288
- // trace, the failure names them and the two framework-native exits —
289
- // consumers stop rediscovering the mechanism from raw traces.
290
- let hint
291
- if (mode === 'mock') hint = mockDeterminismHint({ trace: result.trace, failures })
292
- records.push(createCaseRecord({
293
- id: evalCase.id, file, mode, status: 'fail', failures: hint ? [...failures, hint] : failures,
294
- exitCode: result.exitCode, timedOut: result.timedOut,
295
- durationMs, artifactsDir,
296
- }))
297
- process.stderr.write(`FAIL ${evalCase.id} (exit ${result.exitCode}):\n${failures.map(f => ` - ${f}`).join('\n')}\n`)
298
- if (hint !== undefined) process.stderr.write(` ! ${hint}\n`)
299
- process.stderr.write(` artifacts: ${artifactsDir}\n`)
300
- }
301
- }
302
- }
303
-
304
- const finishedAt = new Date().toISOString()
305
- const report = buildRunReport({
306
- profile,
307
- repo: reportRepo,
308
- cliSource,
309
- modeFilter,
310
- failOnSkip,
311
- startedAt,
312
- finishedAt,
313
- records,
314
- })
315
-
316
- const reportTarget = options.report ?? config.report
317
- if (reportTarget !== undefined) {
318
- const reportPath = isAbsolute(reportTarget) ? reportTarget : resolve(process.cwd(), reportTarget)
319
- try {
320
- mkdirSync(dirname(reportPath), { recursive: true })
321
- writeFileSync(reportPath, JSON.stringify(report, undefined, 2))
322
- process.stderr.write(`report: ${reportPath}\n`)
323
- } catch (error) {
324
- process.stderr.write(`error: failed to write report '${reportPath}': ${error.message}\n`)
325
- process.exit(2)
326
- }
327
- }
328
-
329
- if (options.format === 'json') {
330
- process.stdout.write(`${JSON.stringify(report, undefined, 2)}\n`)
331
- } else {
332
- const { summary } = report
333
- process.stdout.write(`\n${summary.selected} selected, ${summary.passed} passed, ${summary.failed} failed, ${summary.skipped} skipped\n`)
334
- }
335
- process.exit(reportExitCode(records, failOnSkip))
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-eval — the plugin agent-eval case executor.
4
+ *
5
+ * Usage:
6
+ * dsh-eval run --profile <name> --repo <deepseek-harness dir>
7
+ * [--mode real|mock|all] [--keep-artifacts] [--fail-on-skip]
8
+ * [--format text|json] [--report <file>]
9
+ * <case paths...>
10
+ *
11
+ * A case path is a `*.eval.mjs` file or a directory scanned recursively for
12
+ * them. Each file default-exports one case object (or an array of them):
13
+ * `{ id, task, mode?: 'real'|'mock', expect: Matcher[], script?, persona?,
14
+ * prepare?, timeoutMs? }`. Real cases skip when DEEPSEEK_API_KEY is absent;
15
+ * the exit code is 1 when any run fails. Failures keep their artifacts under
16
+ * `<case file dir>/.runs/<case id>/`.
17
+ *
18
+ * Output formats:
19
+ * - `--format text` (default): unchanged human output on stdout/stderr.
20
+ * - `--format json`: all progress and failure chatter moves to stderr;
21
+ * stdout receives exactly one JSON report object (see src/report.mjs).
22
+ * - `--report <file>`: additionally write that report object to a file,
23
+ * in either format — the aggregation/CI consumption path.
24
+ */
25
+
26
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
27
+ import { homedir } from 'node:os'
28
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
29
+ import { pathToFileURL } from 'node:url'
30
+ import { runEvalCase } from '../src/runner.mjs'
31
+ import { discoverFiles, validateEvalCase, detectDuplicateIds } from '../src/discovery.mjs'
32
+ import { createCaseRecord, buildRunReport, reportExitCode, mockDeterminismHint } from '../src/report.mjs'
33
+ import { loadEvalConfig } from '../src/config.mjs'
34
+ import { resolveDshCliChain } from '../src/cli.mjs'
35
+
36
+ function usage(error) {
37
+ const text = [
38
+ 'usage: dsh-eval run [--profile <name>] [--repo <deepseek-harness>] [--mode real|mock|all] [--keep-artifacts] [--fail-on-skip] [--format text|json] [--report <file>] <case paths...>',
39
+ ' --profile/--repo/--mode/--fail-on-skip/--report may come from a dsh-eval.config.mjs found upward from cwd; flags override it.',
40
+ ].join('\n')
41
+ if (error === undefined) {
42
+ process.stdout.write(`${text}\n`)
43
+ process.exit(0)
44
+ }
45
+ process.stderr.write(`${error}\n${text}\n`)
46
+ process.exit(2)
47
+ }
48
+
49
+ /** Parse argv: known flags, then case paths. Profile/repo/mode/failOnSkip
50
+ * may come from a `dsh-eval.config.mjs` instead of flags (flags win);
51
+ * required-ness is checked after config merging, not here. */
52
+ function parseArgs(argv) {
53
+ const options = {
54
+ profile: undefined, repo: undefined, mode: undefined,
55
+ keepArtifacts: false, failOnSkip: undefined, format: 'text', report: undefined,
56
+ }
57
+ const paths = []
58
+ for (let i = 0; i < argv.length; i += 1) {
59
+ const arg = argv[i]
60
+ if (arg === 'run') continue
61
+ if (arg === '--profile') { options.profile = argv[++i]; continue }
62
+ if (arg === '--repo') { options.repo = argv[++i]; continue }
63
+ if (arg === '--mode') { options.mode = argv[++i]; continue }
64
+ if (arg === '--keep-artifacts') { options.keepArtifacts = true; continue }
65
+ if (arg === '--fail-on-skip') { options.failOnSkip = true; continue }
66
+ if (arg === '--format') { options.format = argv[++i]; continue }
67
+ if (arg === '--report') { options.report = argv[++i]; continue }
68
+ if (arg === '-h' || arg === '--help') usage()
69
+ paths.push(arg)
70
+ }
71
+ if (!['real', 'mock', 'all'].includes(options.mode ?? 'all')) usage(`error: --mode must be real, mock, or all (got '${options.mode}')`)
72
+ if (!['text', 'json'].includes(options.format)) usage(`error: --format must be text or json (got '${options.format}')`)
73
+ if (paths.length === 0) usage('error: at least one case file or directory is required')
74
+ return { options, paths }
75
+ }
76
+
77
+ /**
78
+ * Line output that respects the format: in `json` mode stdout is reserved
79
+ * for the single report object, so progress lines go to stderr instead.
80
+ */
81
+ function say(line) {
82
+ if (jsonFormat) process.stderr.write(`${line}\n`)
83
+ else process.stdout.write(`${line}\n`)
84
+ }
85
+
86
+ /** Recursively collect `*.eval.mjs` files from one file or directory path. */
87
+ function discoverCaseFiles(path) {
88
+ return discoverFiles(path, '.eval.mjs')
89
+ }
90
+
91
+ /** Import one case file, validate shape, and normalize to a case array. */
92
+ async function loadCases(file) {
93
+ const module = await import(pathToFileURL(file).href)
94
+ const exported = module.default
95
+ const list = Array.isArray(exported) ? exported : [exported]
96
+ for (const evalCase of list) {
97
+ validateEvalCase(evalCase, file)
98
+ }
99
+ return list.map(evalCase => ({ ...evalCase, __file: file }))
100
+ }
101
+
102
+ /**
103
+ * Whether a model credential is available to a real run: the process
104
+ * environment, or the managed `$DSH_HOME/.credentials.yaml` document that
105
+ * `dsh-credentials-local` resolves per request. The key's VALUE is never
106
+ * read here — presence is the gate.
107
+ */
108
+ function credentialAvailable() {
109
+ if (process.env.DEEPSEEK_API_KEY !== undefined) return true
110
+ const home = (process.env.DSH_HOME ?? '').trim() !== '' ? process.env.DSH_HOME : join(homedir(), '.dsh')
111
+ return existsSync(join(home, '.credentials.yaml'))
112
+ }
113
+
114
+ /** Why a case is skipped, or undefined when it should run. */
115
+ function skipReason(evalCase, modeFilter) {
116
+ const mode = evalCase.mode ?? 'real'
117
+ if (modeFilter !== 'all' && mode !== modeFilter) return `--mode ${modeFilter}`
118
+ if (mode === 'real' && !credentialAvailable()) {
119
+ return 'no credential (DEEPSEEK_API_KEY unset and no $DSH_HOME/.credentials.yaml)'
120
+ }
121
+ return undefined
122
+ }
123
+
124
+ /**
125
+ * Persist one run's post-mortem artifacts under `.runs/<case id>/` next to
126
+ * the case file: the in-memory streams/trace plus the raw session logs
127
+ * captured before the run dir cleanup.
128
+ */
129
+ function writeArtifacts(evalCase, result, mode) {
130
+ const artifactsDir = join(dirname(evalCase.__file), '.runs', evalCase.id)
131
+ try {
132
+ mkdirSync(artifactsDir, { recursive: true })
133
+ writeFileSync(join(artifactsDir, 'stdout.txt'), result.stdout)
134
+ writeFileSync(join(artifactsDir, 'stderr.txt'), result.stderr)
135
+ writeFileSync(join(artifactsDir, 'trace.json'), JSON.stringify({
136
+ caseId: evalCase.id, mode, task: evalCase.task,
137
+ exitCode: result.exitCode, timedOut: result.timedOut, trace: result.trace,
138
+ }, undefined, 2))
139
+ result.sessionLogs.forEach((text, index) => {
140
+ writeFileSync(join(artifactsDir, `session-${index}.jsonl`), text)
141
+ })
142
+ } catch { /* artifact persistence is best-effort */ }
143
+ return artifactsDir
144
+ }
145
+
146
+ const startedAt = new Date().toISOString()
147
+ const { options, paths } = parseArgs(process.argv.slice(2))
148
+ const jsonFormat = options.format === 'json'
149
+
150
+ // Config merge: a `dsh-eval.config.mjs` reachable from cwd
151
+ // supplies defaults; explicit flags always win. Required-ness is only
152
+ // decided after the merge, so config-only invocations work.
153
+ const { config } = await loadEvalConfig(process.cwd())
154
+ const profile = options.profile ?? config.profile
155
+ const modeFilter = options.mode ?? config.mode ?? 'all'
156
+ const failOnSkip = options.failOnSkip ?? config.failOnSkip ?? false
157
+ if (profile === undefined) usage('error: --profile <name> is required (or set profile in dsh-eval.config.mjs)')
158
+ // CLI resolution (C6, spec host-checkout-resolution): `--repo` flag >
159
+ // resolution layer (node_modules/@deepseek-ai/dsh) > config repo key (legacy).
160
+ // Committed files carry no real host-checkout path.
161
+ const { cli: cliPath, repo: repoDir, source: cliSource } = resolveDshCliChain({
162
+ repoFlag: options.repo,
163
+ configRepo: config.repo,
164
+ })
165
+ const reportRepo = repoDir ?? cliPath
166
+
167
+ const files = paths.flatMap(path => {
168
+ const absolute = resolve(path)
169
+ if (!existsSync(absolute)) usage(`error: no such case path: ${path}`)
170
+ return discoverCaseFiles(absolute)
171
+ })
172
+ if (files.length === 0) usage('error: no *.eval.mjs case files found')
173
+
174
+ const records = []
175
+ const seenIds = new Map()
176
+
177
+ for (const file of files.sort()) {
178
+ let cases
179
+ try {
180
+ cases = await loadCases(file)
181
+ } catch (error) {
182
+ records.push(createCaseRecord({
183
+ id: file, file, status: 'fail',
184
+ failures: [`failed to load cases: ${error.message}`],
185
+ }))
186
+ process.stderr.write(`FAIL ${file}: failed to load cases: ${error.message}\n`)
187
+ continue
188
+ }
189
+ // Intra-file duplicate check
190
+ try {
191
+ detectDuplicateIds(cases)
192
+ } catch (error) {
193
+ records.push(createCaseRecord({
194
+ id: file, file, status: 'fail',
195
+ failures: [error.message],
196
+ }))
197
+ process.stderr.write(`FAIL ${file}: ${error.message}\n`)
198
+ continue
199
+ }
200
+ // Cross-file duplicate check (only add to seenIds after all pass)
201
+ let hasDuplicate = false
202
+ for (const c of cases) {
203
+ if (seenIds.has(c.id)) {
204
+ const message = `duplicate case id '${c.id}' (also in ${seenIds.get(c.id)})`
205
+ records.push(createCaseRecord({
206
+ id: c.id, file, mode: c.mode ?? 'real', status: 'fail',
207
+ failures: [message],
208
+ }))
209
+ process.stderr.write(`FAIL ${file}: ${message}\n`)
210
+ hasDuplicate = true
211
+ break
212
+ }
213
+ }
214
+ if (hasDuplicate) continue
215
+ for (const c of cases) seenIds.set(c.id, file)
216
+ for (const rawCase of cases) {
217
+ // Row-disable precedence: a case's own `disableRows` —
218
+ // including an explicit `[]` ("disable nothing") — overrides the
219
+ // config-level default; only an undeclared field inherits it.
220
+ const evalCase = rawCase.disableRows === undefined && config.disableRows !== undefined
221
+ ? { ...rawCase, disableRows: config.disableRows }
222
+ : rawCase
223
+ const mode = evalCase.mode ?? 'real'
224
+ const skip = skipReason(evalCase, modeFilter)
225
+ if (skip !== undefined) {
226
+ records.push(createCaseRecord({
227
+ id: evalCase.id, file, mode, status: 'skip', skipReason: skip,
228
+ }))
229
+ say(`SKIP ${evalCase.id}: ${skip}`)
230
+ continue
231
+ }
232
+ say(`RUN ${evalCase.id} (${mode})...`)
233
+ const runStartedAt = Date.now()
234
+ let result
235
+ try {
236
+ result = await runEvalCase(evalCase, { profile, cliPath, dshRepoDir: repoDir, mode })
237
+ } catch (error) {
238
+ records.push(createCaseRecord({
239
+ id: evalCase.id, file, mode, status: 'fail',
240
+ failures: [`runner error: ${error.message}`],
241
+ durationMs: Date.now() - runStartedAt,
242
+ }))
243
+ process.stderr.write(`FAIL ${evalCase.id}: runner error: ${error.message}\n`)
244
+ continue
245
+ }
246
+ const durationMs = Date.now() - runStartedAt
247
+
248
+ if (result.trace === undefined) {
249
+ const artifactsDir = writeArtifacts(evalCase, result, mode)
250
+ records.push(createCaseRecord({
251
+ id: evalCase.id, file, mode, status: 'fail',
252
+ failures: [`no session trace materialized (exit ${result.exitCode}${result.timedOut ? ', timed out' : ''})`],
253
+ exitCode: result.exitCode, timedOut: result.timedOut,
254
+ durationMs, artifactsDir,
255
+ }))
256
+ process.stderr.write(
257
+ `FAIL ${evalCase.id}: no session trace materialized (exit ${result.exitCode}${result.timedOut ? ', timed out' : ''})\n`
258
+ + ` artifacts: ${artifactsDir}\n--- stderr ---\n${result.stderr}\n`,
259
+ )
260
+ continue
261
+ }
262
+
263
+ const failures = []
264
+ if (result.exitCode !== 0) {
265
+ // Headless SSOT: exit 0 iff the turn completed. A run that errored out
266
+ // must not pass on coincidentally satisfied matchers.
267
+ failures.push(`dsh CLI exited with code ${result.exitCode} (the turn did not complete)`)
268
+ }
269
+ for (const matcher of evalCase.expect) {
270
+ const outcome = matcher.check(result.trace)
271
+ if (!outcome.ok) failures.push(`${matcher.describe}: ${outcome.message}`)
272
+ }
273
+ if (result.timedOut) failures.push('run timed out')
274
+ if (result.inspectError !== undefined) failures.push(`workspace inspect failed: ${result.inspectError}`)
275
+
276
+ if (failures.length === 0) {
277
+ const artifactsDir = options.keepArtifacts ? writeArtifacts(evalCase, result, mode) : undefined
278
+ records.push(createCaseRecord({
279
+ id: evalCase.id, file, mode, status: 'pass',
280
+ exitCode: result.exitCode, timedOut: result.timedOut,
281
+ durationMs, ...(artifactsDir !== undefined ? { artifactsDir } : {}),
282
+ }))
283
+ say(`PASS ${evalCase.id}`)
284
+ } else {
285
+ const artifactsDir = writeArtifacts(evalCase, result, mode)
286
+ // Self-explaining failure for broken mock determinism: when non-host
287
+ // plugin injections are visible in the
288
+ // trace, the failure names them and the two framework-native exits —
289
+ // consumers stop rediscovering the mechanism from raw traces.
290
+ let hint
291
+ if (mode === 'mock') hint = mockDeterminismHint({ trace: result.trace, failures })
292
+ records.push(createCaseRecord({
293
+ id: evalCase.id, file, mode, status: 'fail', failures: hint ? [...failures, hint] : failures,
294
+ exitCode: result.exitCode, timedOut: result.timedOut,
295
+ durationMs, artifactsDir,
296
+ }))
297
+ process.stderr.write(`FAIL ${evalCase.id} (exit ${result.exitCode}):\n${failures.map(f => ` - ${f}`).join('\n')}\n`)
298
+ if (hint !== undefined) process.stderr.write(` ! ${hint}\n`)
299
+ process.stderr.write(` artifacts: ${artifactsDir}\n`)
300
+ }
301
+ }
302
+ }
303
+
304
+ const finishedAt = new Date().toISOString()
305
+ const report = buildRunReport({
306
+ profile,
307
+ repo: reportRepo,
308
+ cliSource,
309
+ modeFilter,
310
+ failOnSkip,
311
+ startedAt,
312
+ finishedAt,
313
+ records,
314
+ })
315
+
316
+ const reportTarget = options.report ?? config.report
317
+ if (reportTarget !== undefined) {
318
+ const reportPath = isAbsolute(reportTarget) ? reportTarget : resolve(process.cwd(), reportTarget)
319
+ try {
320
+ mkdirSync(dirname(reportPath), { recursive: true })
321
+ writeFileSync(reportPath, JSON.stringify(report, undefined, 2))
322
+ process.stderr.write(`report: ${reportPath}\n`)
323
+ } catch (error) {
324
+ process.stderr.write(`error: failed to write report '${reportPath}': ${error.message}\n`)
325
+ process.exit(2)
326
+ }
327
+ }
328
+
329
+ if (options.format === 'json') {
330
+ process.stdout.write(`${JSON.stringify(report, undefined, 2)}\n`)
331
+ } else {
332
+ const { summary } = report
333
+ process.stdout.write(`\n${summary.selected} selected, ${summary.passed} passed, ${summary.failed} failed, ${summary.skipped} skipped\n`)
334
+ }
335
+ process.exit(reportExitCode(records, failOnSkip))