@catheadowl/dsh-eval 0.1.0

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.
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Trace matchers for dsh agent eval. Every factory returns a matcher:
3
+ * `{ describe, check(trace) -> { ok, message } }` — a pure function over an
4
+ * `EvalTrace` (see trace.mjs), so matchers unit-test without any dsh run.
5
+ * Intent tests assert tool SELECTION over final text: model wording varies,
6
+ * tool choice is the contract under test.
7
+ */
8
+
9
+ /** Render a name matcher for diagnostics. */
10
+ function describeMatcher(matcher) {
11
+ return matcher instanceof RegExp ? String(matcher) : `'${matcher}'`
12
+ }
13
+
14
+ /** Whether a tool name satisfies a matcher (exact string or RegExp). */
15
+ function nameMatches(matcher, name) {
16
+ return matcher instanceof RegExp ? matcher.test(name) : name === matcher
17
+ }
18
+
19
+ /**
20
+ * Whether a message `source` satisfies a source matcher. A string or RegExp
21
+ * matches `source.plugin` (the producer name — e.g. `'gates'` for steer);
22
+ * a function receives the full `source` object (for `kind`-based matching).
23
+ */
24
+ function sourceMatches(matcher, source) {
25
+ if (typeof matcher === 'function') return matcher(source) === true
26
+ const plugin = source?.plugin
27
+ if (matcher instanceof RegExp) return typeof plugin === 'string' && matcher.test(plugin)
28
+ return plugin === matcher
29
+ }
30
+
31
+ /** Render a source matcher for diagnostics. */
32
+ function describeSource(matcher) {
33
+ if (typeof matcher === 'function') return '<source predicate>'
34
+ return describeMatcher(matcher)
35
+ }
36
+
37
+ /** Render the trace's call sequence for failure messages. */
38
+ function callList(trace) {
39
+ const names = trace.toolCalls.map(call => call.name)
40
+ return names.length === 0 ? '(no tool calls)' : `[${names.join(', ')}]`
41
+ }
42
+
43
+ /**
44
+ * Collect the tool results paired with calls matching `matcher`.
45
+ * @returns {{ callIds: Set<string>, results: object[] }}
46
+ * `callIds` is empty when no call satisfies `matcher`;
47
+ * `results` is the subset of `trace.toolResults` paired with those calls.
48
+ */
49
+ function resultsForMatcher(matcher, trace) {
50
+ const callIds = new Set(
51
+ trace.toolCalls.filter(call => nameMatches(matcher, call.name)).map(call => call.callId),
52
+ )
53
+ const results = callIds.size === 0
54
+ ? []
55
+ : trace.toolResults.filter(r => callIds.has(r.callId))
56
+ return { callIds, results }
57
+ }
58
+
59
+ /** Truncate a string for diagnostics. */
60
+ function truncate(text, max = 200) {
61
+ return text.length > max ? `${text.slice(0, max)}…` : text
62
+ }
63
+
64
+ /** A tool matching `matcher` was called at least once. */
65
+ export function toolCalled(matcher) {
66
+ return {
67
+ describe: `tool called: ${describeMatcher(matcher)}`,
68
+ check(trace) {
69
+ const hit = trace.toolCalls.some(call => nameMatches(matcher, call.name))
70
+ return hit
71
+ ? { ok: true, message: '' }
72
+ : { ok: false, message: `expected a ${describeMatcher(matcher)} call; saw ${callList(trace)}` }
73
+ },
74
+ }
75
+ }
76
+
77
+ /** No tool matching `matcher` was ever called. */
78
+ export function toolNotCalled(matcher) {
79
+ return {
80
+ describe: `tool not called: ${describeMatcher(matcher)}`,
81
+ check(trace) {
82
+ const hit = trace.toolCalls.find(call => nameMatches(matcher, call.name))
83
+ return hit === undefined
84
+ ? { ok: true, message: '' }
85
+ : { ok: false, message: `expected no ${describeMatcher(matcher)} call; saw one at seq ${hit.seq}` }
86
+ },
87
+ }
88
+ }
89
+
90
+ /** The FIRST tool call matches `matcher`. */
91
+ export function firstTool(matcher) {
92
+ return {
93
+ describe: `first tool is: ${describeMatcher(matcher)}`,
94
+ check(trace) {
95
+ const first = trace.toolCalls[0]
96
+ if (first === undefined) {
97
+ return { ok: false, message: `expected first tool ${describeMatcher(matcher)}; the run made no tool calls` }
98
+ }
99
+ return nameMatches(matcher, first.name)
100
+ ? { ok: true, message: '' }
101
+ : { ok: false, message: `expected first tool ${describeMatcher(matcher)}; first was '${first.name}'` }
102
+ },
103
+ }
104
+ }
105
+
106
+ /**
107
+ * The expected names appear as an ORDERED SUBSEQUENCE of the call sequence
108
+ * (other calls may interleave). `names` entries are matchers.
109
+ */
110
+ export function toolSequence(names) {
111
+ return {
112
+ describe: `tool sequence: ${names.map(describeMatcher).join(' -> ')}`,
113
+ check(trace) {
114
+ let cursor = 0
115
+ for (const call of trace.toolCalls) {
116
+ const expected = names[cursor]
117
+ if (expected !== undefined && nameMatches(expected, call.name)) cursor += 1
118
+ }
119
+ return cursor === names.length
120
+ ? { ok: true, message: '' }
121
+ : {
122
+ ok: false,
123
+ message: `expected subsequence ${names.map(describeMatcher).join(' -> ')}; `
124
+ + `stalled at ${describeMatcher(names[cursor])}; saw ${callList(trace)}`,
125
+ }
126
+ },
127
+ }
128
+ }
129
+
130
+ /**
131
+ * One call of `matcher` satisfies `match` on its arguments: an object checks
132
+ * a shallow subset of the parsed JSON arguments; a function receives
133
+ * `(parsedArguments, rawArguments)` and returns a boolean.
134
+ */
135
+ export function toolCallArgs(matcher, match) {
136
+ return {
137
+ describe: `tool ${describeMatcher(matcher)} arguments match`,
138
+ check(trace) {
139
+ const calls = trace.toolCalls.filter(call => nameMatches(matcher, call.name))
140
+ if (calls.length === 0) {
141
+ return { ok: false, message: `expected a ${describeMatcher(matcher)} call to inspect; saw ${callList(trace)}` }
142
+ }
143
+ const satisfied = calls.some(call => {
144
+ if (typeof match === 'function') return match(call.parsedArguments, call.arguments) === true
145
+ const parsed = call.parsedArguments
146
+ if (parsed === null || typeof parsed !== 'object') return false
147
+ return Object.entries(match).every(
148
+ ([key, value]) => JSON.stringify(parsed[key]) === JSON.stringify(value),
149
+ )
150
+ })
151
+ return satisfied
152
+ ? { ok: true, message: '' }
153
+ : {
154
+ ok: false,
155
+ message: `no ${describeMatcher(matcher)} call matched the argument predicate; `
156
+ + `arguments seen: ${calls.map(call => call.arguments).join(' | ')}`,
157
+ }
158
+ },
159
+ }
160
+ }
161
+
162
+ /** The call matching `matcher` has a paired `tool/result` in the trace. */
163
+ export function toolResultFor(matcher) {
164
+ return {
165
+ describe: `tool result present for: ${describeMatcher(matcher)}`,
166
+ check(trace) {
167
+ const { callIds } = resultsForMatcher(matcher, trace)
168
+ if (callIds.size === 0) {
169
+ return { ok: false, message: `expected a ${describeMatcher(matcher)} call; saw ${callList(trace)}` }
170
+ }
171
+ const hit = trace.toolResults.some(result => callIds.has(result.callId))
172
+ return hit
173
+ ? { ok: true, message: '' }
174
+ : { ok: false, message: `${describeMatcher(matcher)} was called but no tool/result arrived for it` }
175
+ },
176
+ }
177
+ }
178
+
179
+ /**
180
+ * A call matching `matcher` produced a tool result with `isError === true`.
181
+ * Fails when the tool was never called, never received a result, or every
182
+ * result was a success.
183
+ */
184
+ export function toolResultIsError(matcher) {
185
+ return {
186
+ describe: `tool result isError: ${describeMatcher(matcher)}`,
187
+ check(trace) {
188
+ const { callIds, results } = resultsForMatcher(matcher, trace)
189
+ if (callIds.size === 0) {
190
+ return { ok: false, message: `expected a ${describeMatcher(matcher)} call; saw ${callList(trace)}` }
191
+ }
192
+ if (results.length === 0) {
193
+ return { ok: false, message: `${describeMatcher(matcher)} was called but no tool/result arrived for it` }
194
+ }
195
+ const hit = results.some(r => r.isError === true)
196
+ return hit
197
+ ? { ok: true, message: '' }
198
+ : {
199
+ ok: false,
200
+ message: `expected ${describeMatcher(matcher)} to produce an error result; `
201
+ + `saw isError: [${results.map(r => String(r.isError)).join(', ')}]`,
202
+ }
203
+ },
204
+ }
205
+ }
206
+
207
+ /**
208
+ * A call matching `matcher` produced a tool result with `isError` NOT true
209
+ * (i.e. `false` or `undefined` — treated as success).
210
+ */
211
+ export function toolResultSucceeded(matcher) {
212
+ return {
213
+ describe: `tool result succeeded: ${describeMatcher(matcher)}`,
214
+ check(trace) {
215
+ const { callIds, results } = resultsForMatcher(matcher, trace)
216
+ if (callIds.size === 0) {
217
+ return { ok: false, message: `expected a ${describeMatcher(matcher)} call; saw ${callList(trace)}` }
218
+ }
219
+ if (results.length === 0) {
220
+ return { ok: false, message: `${describeMatcher(matcher)} was called but no tool/result arrived for it` }
221
+ }
222
+ const hit = results.some(r => r.isError !== true)
223
+ return hit
224
+ ? { ok: true, message: '' }
225
+ : {
226
+ ok: false,
227
+ message: `expected ${describeMatcher(matcher)} to produce a success result; `
228
+ + `all ${results.length} result(s) had isError: true`,
229
+ }
230
+ },
231
+ }
232
+ }
233
+
234
+ /**
235
+ * A call matching `matcher` produced a tool result whose text contains
236
+ * `substring`. The text is the same projection used by `toolResultFor`
237
+ * (concatenated inner text blocks of the tool-result wrapper).
238
+ */
239
+ export function toolResultTextIncludes(matcher, substring) {
240
+ return {
241
+ describe: `tool result text includes: ${describeMatcher(matcher)} → '${substring}'`,
242
+ check(trace) {
243
+ const { callIds, results } = resultsForMatcher(matcher, trace)
244
+ if (callIds.size === 0) {
245
+ return { ok: false, message: `expected a ${describeMatcher(matcher)} call; saw ${callList(trace)}` }
246
+ }
247
+ if (results.length === 0) {
248
+ return { ok: false, message: `${describeMatcher(matcher)} was called but no tool/result arrived for it` }
249
+ }
250
+ const hit = results.some(r => r.text.includes(substring))
251
+ return hit
252
+ ? { ok: true, message: '' }
253
+ : {
254
+ ok: false,
255
+ message: `no ${describeMatcher(matcher)} result text includes '${substring}'; `
256
+ + `texts seen: [${results.map(r => JSON.stringify(truncate(r.text))).join(', ')}]`,
257
+ }
258
+ },
259
+ }
260
+ }
261
+
262
+ /** The final assistant text contains `substring`. */
263
+ export function finalTextIncludes(substring) {
264
+ return {
265
+ describe: `final text includes: '${substring}'`,
266
+ check(trace) {
267
+ const hit = trace.finalText.includes(substring)
268
+ return hit
269
+ ? { ok: true, message: '' }
270
+ : { ok: false, message: `final text does not include '${substring}'; final text: ${JSON.stringify(trace.finalText.slice(0, 400))}` }
271
+ },
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Any assembled assistant text contains `substring`. Unlike
277
+ * `finalTextIncludes`, later turns cannot invalidate the assertion — blocking
278
+ * gates that splice feedback after the script ends (turn-close hooks) push
279
+ * their own trailing steps, so a scripted closing line may no longer be the
280
+ * FINAL text even though the script delivered it.
281
+ */
282
+ export function assistantTextIncludes(substring) {
283
+ return {
284
+ describe: `assistant text includes: '${substring}'`,
285
+ check(trace) {
286
+ const hit = trace.assistantTexts.some(text => text.includes(substring))
287
+ return hit
288
+ ? { ok: true, message: '' }
289
+ : { ok: false, message: `no assistant text includes '${substring}'; texts seen: [${trace.assistantTexts.map(t => JSON.stringify(t.slice(0, 120))).join(', ')}]` }
290
+ },
291
+ }
292
+ }
293
+
294
+ /** The final assistant text matches `regex`. */
295
+ export function finalTextMatches(regex) {
296
+ return {
297
+ describe: `final text matches: ${String(regex)}`,
298
+ check(trace) {
299
+ const hit = regex.test(trace.finalText)
300
+ return hit
301
+ ? { ok: true, message: '' }
302
+ : { ok: false, message: `final text does not match ${String(regex)}; final text: ${JSON.stringify(trace.finalText.slice(0, 400))}` }
303
+ },
304
+ }
305
+ }
306
+
307
+ /** The assembled system prompt of a request contains `substring`. */
308
+ export function systemPromptIncludes(substring) {
309
+ return {
310
+ describe: `system prompt includes: '${substring}'`,
311
+ check(trace) {
312
+ const headers = trace.requestHeaders
313
+ if (headers.length === 0) {
314
+ return { ok: false, message: 'expected a request/header event; the run produced none' }
315
+ }
316
+ const hit = headers.some(header => header.system.includes(substring))
317
+ return hit
318
+ ? { ok: true, message: '' }
319
+ : { ok: false, message: `no request/header system prompt contains '${substring}' (${headers.length} header(s) seen)` }
320
+ },
321
+ }
322
+ }
323
+
324
+ /** A tool named `matcher` is mounted in some request header (not merely called). */
325
+ export function toolMounted(matcher) {
326
+ return {
327
+ describe: `tool mounted: ${describeMatcher(matcher)}`,
328
+ check(trace) {
329
+ const headers = trace.requestHeaders
330
+ if (headers.length === 0) {
331
+ return { ok: false, message: 'expected a request/header event; the run produced none' }
332
+ }
333
+ const names = [...new Set(headers.flatMap(header => header.toolNames))]
334
+ const hit = names.some(name => nameMatches(matcher, name))
335
+ return hit
336
+ ? { ok: true, message: '' }
337
+ : { ok: false, message: `expected ${describeMatcher(matcher)} among mounted tools; saw [${names.join(', ')}]` }
338
+ },
339
+ }
340
+ }
341
+
342
+ /**
343
+ * A user message from a source matching `sourceMatcher` contains `substring`.
344
+ * Source matcher: string/RegExp against `source.plugin`, or a predicate over
345
+ * the full `source`. This is how a case asserts plugin steer — a `user/message`
346
+ * with a plugin source — separately from the task prompt (`kind: 'user'`).
347
+ */
348
+ export function userMessageTextIncludes(sourceMatcher, substring) {
349
+ return {
350
+ describe: `user message from ${describeSource(sourceMatcher)} includes: '${substring}'`,
351
+ check(trace) {
352
+ const messages = trace.userMessages.filter(message => sourceMatches(sourceMatcher, message.source))
353
+ if (messages.length === 0) {
354
+ return { ok: false, message: `expected a user message from ${describeSource(sourceMatcher)}; the run produced none` }
355
+ }
356
+ const hit = messages.some(message => message.text.includes(substring))
357
+ return hit
358
+ ? { ok: true, message: '' }
359
+ : {
360
+ ok: false,
361
+ message: `no ${describeSource(sourceMatcher)} user message includes '${substring}'; `
362
+ + `texts seen: [${messages.map(message => JSON.stringify(truncate(message.text))).join(', ')}]`,
363
+ }
364
+ },
365
+ }
366
+ }
367
+
368
+ /**
369
+ * No user message from a source matching `sourceMatcher` contains `substring`.
370
+ * Passes vacuously when no such message exists — pair it with
371
+ * `userMessageTextIncludes` to also prove the message arrived. This is the
372
+ * "not steered on someone else's file" half of an isolation assertion.
373
+ */
374
+ export function userMessageTextExcludes(sourceMatcher, substring) {
375
+ return {
376
+ describe: `user message from ${describeSource(sourceMatcher)} excludes: '${substring}'`,
377
+ check(trace) {
378
+ const messages = trace.userMessages.filter(message => sourceMatches(sourceMatcher, message.source))
379
+ const hit = messages.find(message => message.text.includes(substring))
380
+ return hit === undefined
381
+ ? { ok: true, message: '' }
382
+ : {
383
+ ok: false,
384
+ message: `a ${describeSource(sourceMatcher)} user message includes '${substring}': `
385
+ + `${JSON.stringify(truncate(hit.text))}`,
386
+ }
387
+ },
388
+ }
389
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Host CLI resolution chain (release-plan C6 / spec host-checkout-resolution).
3
+ *
4
+ * Locating the compiled dsh CLI follows the same two-layer model as package
5
+ * imports: committed files carry no real host-checkout path — the machine's
6
+ * resolution layer (node_modules, junction-built by the relink anchor tool)
7
+ * absorbs it. Precedence, first hit wins:
8
+ *
9
+ * 1. explicit `--repo <dir>` flag — the documented escape hatch;
10
+ * 2. resolution layer — `node_modules/@deepseek-ai/dsh/lib/bin.js`
11
+ * (the CLI package's own bin target) reachable upward from startDir;
12
+ * 3. config `repo` key (legacy) — kept working for existing checked-in
13
+ * `dsh-eval.config.mjs` files until the repo split retires them.
14
+ *
15
+ * Every miss fails loud with a fingerprint and placeholder-only guidance —
16
+ * no machine-specific example paths, no silent fallback to guessing.
17
+ */
18
+
19
+ import { existsSync, realpathSync } from 'node:fs'
20
+ import { dirname, join, resolve } from 'node:path'
21
+
22
+ /** Relative location of the compiled CLI entry inside a dsh checkout. */
23
+ export const CLI_RELATIVE_PATH = join('apps', 'cli', 'lib', 'bin.js')
24
+
25
+ /** Package-relative location of the CLI entry inside `@deepseek-ai/dsh`. */
26
+ const PACKAGED_CLI_PATH = join('node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js')
27
+
28
+ /** The fail-loud message when no chain segment can produce a CLI. */
29
+ export const NO_CLI_GUIDANCE = [
30
+ 'no dsh CLI found. In order:',
31
+ " 1) pass --repo <host-checkout> explicitly;",
32
+ ' 2) or make the resolution layer provide it: node_modules/@deepseek-ai/dsh/lib/bin.js',
33
+ ' (run the repo relink script to (re)build the junction tree from DSH_REPO,',
34
+ ' then build the host checkout if lib/ is missing);',
35
+ ' 3) or set repo in dsh-eval.config.mjs (legacy, retired at repo split).',
36
+ ].join('\n')
37
+
38
+ /** Validate a repo-style candidate: return the CLI path or undefined. */
39
+ function cliFromRepoDir(repoDir) {
40
+ const dir = resolve(repoDir)
41
+ return existsSync(join(dir, CLI_RELATIVE_PATH)) ? join(dir, CLI_RELATIVE_PATH) : undefined
42
+ }
43
+
44
+ /** Walk up from startDir looking for the packaged CLI on the resolution layer. */
45
+ function cliFromNodeModules(startDir) {
46
+ let dir = resolve(startDir)
47
+ for (;;) {
48
+ const candidate = join(dir, PACKAGED_CLI_PATH)
49
+ if (existsSync(candidate)) return candidate
50
+ const parent = dirname(dir)
51
+ if (parent === dir) return undefined
52
+ dir = parent
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Derive the host checkout dir from a resolved CLI path (realpath, then up
58
+ * four levels from `apps/cli/lib/bin.js`). Falls back to undefined when the
59
+ * layout does not look like a checkout (e.g. an installed CLI tree) — repo
60
+ * is report metadata only, never a runtime requirement.
61
+ */
62
+ function repoFromCli(cli) {
63
+ try {
64
+ const repo = resolve(realpathSync(cli), '..', '..', '..', '..')
65
+ return existsSync(join(repo, CLI_RELATIVE_PATH)) ? repo : undefined
66
+ } catch {
67
+ return undefined
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Resolve the compiled dsh CLI through the three-segment chain.
73
+ *
74
+ * @param {object} options
75
+ * @param {string} [options.repoFlag] - explicit `--repo <dir>` value (highest).
76
+ * @param {string} [options.configRepo] - config-file `repo` value (legacy, lowest).
77
+ * @param {string} [options.startDir] - where resolution-layer lookup starts
78
+ * (default: process.cwd()).
79
+ * @returns {{ cli: string, repo: string | undefined, source: 'flag' | 'node_modules' | 'config' }}
80
+ * @throws {Error} with placeholder-only guidance when a flag/config repo has
81
+ * no compiled CLI, or when the whole chain misses.
82
+ */
83
+ export function resolveDshCliChain(options = {}) {
84
+ if (options.repoFlag !== undefined) {
85
+ const cli = cliFromRepoDir(options.repoFlag)
86
+ if (cli === undefined) {
87
+ throw new Error(
88
+ `repo '${resolve(options.repoFlag)}' has no ${CLI_RELATIVE_PATH.replaceAll('\\', '/')} — pass a built host checkout, or build it first (pnpm build)`
89
+ )
90
+ }
91
+ return { cli, repo: resolve(options.repoFlag), source: 'flag' }
92
+ }
93
+ const fromLayer = cliFromNodeModules(options.startDir ?? process.cwd())
94
+ if (fromLayer !== undefined) {
95
+ return { cli: fromLayer, repo: repoFromCli(fromLayer), source: 'node_modules' }
96
+ }
97
+ if (options.configRepo !== undefined) {
98
+ const cli = cliFromRepoDir(options.configRepo)
99
+ if (cli !== undefined) {
100
+ return { cli, repo: resolve(options.configRepo), source: 'config' }
101
+ }
102
+ }
103
+ throw new Error(NO_CLI_GUIDANCE)
104
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Shared `dsh-eval.config.mjs` discovery and loading (EVAL-008).
3
+ *
4
+ * Both CLIs (`dsh-eval`, `dsh-review`) repeat `--profile/--repo` wiring in
5
+ * every consumer's package scripts. A per-package config file removes that
6
+ * repetition: discovery walks UP from the working directory (never into
7
+ * `node_modules`), the first `dsh-eval.config.mjs` wins, and CLI flags
8
+ * always override config values — flags stay the escape hatch, config is
9
+ * the default.
10
+ *
11
+ * Config shape (default export):
12
+ * {
13
+ * profile?: string, // dsh profile to boot
14
+ * repo?: string, // deepseek-harness checkout, RELATIVE paths
15
+ * // resolve against the config file's dir
16
+ * mode?: 'real'|'mock'|'all' // behavior CLI --mode default
17
+ * failOnSkip?: boolean, // behavior CLI default
18
+ * report?: string, // behavior CLI --report default (resolved
19
+ * // against the config file's dir)
20
+ * disableRows?: string[], // loader rows disabled in EVERY case's run
21
+ * // unless the case itself declares
22
+ * // `disableRows` (an explicit case-level
23
+ * // `[]` re-enables everything)
24
+ * }
25
+ * Unknown keys are rejected: a typo'd `profle` must fail loud, not silently
26
+ * fall back to CLI-required mode.
27
+ */
28
+
29
+ import { existsSync } from 'node:fs'
30
+ import { join, resolve } from 'node:path'
31
+ import { pathToFileURL } from 'node:url'
32
+
33
+ /** The config file name both CLIs look for. */
34
+ export const CONFIG_FILE_NAME = 'dsh-eval.config.mjs'
35
+
36
+ const ALLOWED_KEYS = new Set(['profile', 'repo', 'mode', 'failOnSkip', 'report', 'disableRows'])
37
+
38
+ /**
39
+ * Walk up from `startDir` looking for the config file. Never crosses into
40
+ * (or searches within) `node_modules`; the first hit wins.
41
+ * @param {string} startDir - absolute directory to search from.
42
+ * @returns {string | undefined} absolute config path, or undefined.
43
+ */
44
+ export function findEvalConfigFile(startDir) {
45
+ let dir = resolve(startDir)
46
+ for (;;) {
47
+ if (!dir.split(/[\\/]/).includes('node_modules')) {
48
+ const candidate = join(dir, CONFIG_FILE_NAME)
49
+ if (existsSync(candidate)) return candidate
50
+ }
51
+ const parent = resolve(dir, '..')
52
+ if (parent === dir) return undefined
53
+ dir = parent
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Load and validate the config reachable from `startDir`.
59
+ * @param {string} startDir - absolute directory to search from (usually cwd).
60
+ * @returns {Promise<{ file: string, config: {
61
+ * profile?: string, repo?: string, mode?: 'real'|'mock'|'all',
62
+ * failOnSkip?: boolean, report?: string,
63
+ * } }>} `config` is `{}` when no file exists. `repo`/`report` come back
64
+ * absolute (resolved against the config file's directory).
65
+ * @throws {Error} on a malformed config (unknown key, wrong value shape).
66
+ */
67
+ export async function loadEvalConfig(startDir) {
68
+ const file = findEvalConfigFile(startDir)
69
+ if (file === undefined) return { file: undefined, config: {} }
70
+ const module = await import(pathToFileURL(file).href)
71
+ const config = module.default ?? {}
72
+ if (config === null || typeof config !== 'object' || Array.isArray(config)) {
73
+ throw new Error(`${CONFIG_FILE_NAME}: default export must be a config object`)
74
+ }
75
+ for (const key of Object.keys(config)) {
76
+ if (!ALLOWED_KEYS.has(key)) {
77
+ throw new Error(`${file}: unknown config key '${key}' (allowed: ${[...ALLOWED_KEYS].join(', ')})`)
78
+ }
79
+ }
80
+ const out = {}
81
+ if (config.profile !== undefined) {
82
+ if (typeof config.profile !== 'string' || config.profile === '') {
83
+ throw new Error(`${file}: profile must be a non-empty string`)
84
+ }
85
+ out.profile = config.profile
86
+ }
87
+ if (config.repo !== undefined) {
88
+ if (typeof config.repo !== 'string' || config.repo === '') {
89
+ throw new Error(`${file}: repo must be a non-empty string (relative to the config file)`)
90
+ }
91
+ out.repo = resolve(file, '..', config.repo)
92
+ }
93
+ if (config.mode !== undefined) {
94
+ if (!['real', 'mock', 'all'].includes(config.mode)) {
95
+ throw new Error(`${file}: mode must be 'real', 'mock', or 'all (got '${config.mode}')`)
96
+ }
97
+ out.mode = config.mode
98
+ }
99
+ if (config.failOnSkip !== undefined) {
100
+ if (typeof config.failOnSkip !== 'boolean') {
101
+ throw new Error(`${file}: failOnSkip must be a boolean`)
102
+ }
103
+ out.failOnSkip = config.failOnSkip
104
+ }
105
+ if (config.report !== undefined) {
106
+ if (typeof config.report !== 'string' || config.report === '') {
107
+ throw new Error(`${file}: report must be a non-empty string (relative to the config file)`)
108
+ }
109
+ out.report = resolve(file, '..', config.report)
110
+ }
111
+ if (config.disableRows !== undefined) {
112
+ if (!Array.isArray(config.disableRows) || config.disableRows.length === 0
113
+ || config.disableRows.some(row => typeof row !== 'string' || row === '')) {
114
+ throw new Error(`${file}: disableRows must be a non-empty string[] of loader row ids (got '${JSON.stringify(config.disableRows)}')`)
115
+ }
116
+ out.disableRows = config.disableRows
117
+ }
118
+ return { file, config: out }
119
+ }