@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/CHANGELOG.md +57 -0
- package/README.i18n.yaml +2 -2
- package/README.md +2 -5
- package/README.zh.md +2 -5
- package/bin/dsh-eval.mjs +335 -335
- package/bin/dsh-review.mjs +166 -154
- package/docs/README.md +3 -2
- package/docs/cross-turn.md +66 -0
- package/docs/host-wiring.md +1 -1
- package/docs/known-issues.md +9 -1
- package/docs/matchers.md +13 -1
- package/docs/review.md +12 -9
- package/package.json +7 -1
- package/src/adapters/dsh/review.mjs +70 -17
- package/src/assertions.mjs +499 -389
- package/src/discovery.mjs +190 -166
- package/src/driver/multi-turn-driver.mjs +163 -0
- package/src/experiment/review.mjs +118 -118
- package/src/index.mjs +4 -0
- package/src/mock/mock-adapter.mjs +73 -73
- package/src/mock/script.mjs +49 -49
- package/src/overlay.mjs +13 -0
- package/src/review-report.mjs +14 -1
- package/src/runner.mjs +21 -1
- package/src/sandbox.mjs +62 -1
- package/src/tool-validation.mjs +77 -77
- package/src/trace.mjs +293 -218
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))
|