@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.
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/bin/dsh-eval.mjs +335 -0
- package/bin/dsh-review.mjs +154 -0
- package/docs/README.md +15 -0
- package/docs/disablerows.md +25 -0
- package/docs/host-wiring.md +71 -0
- package/docs/intent-cases.md +39 -0
- package/docs/known-issues.md +15 -0
- package/docs/matchers.md +35 -0
- package/docs/report.md +27 -0
- package/docs/review.md +77 -0
- package/package.json +31 -0
- package/src/adapters/dsh/index.mjs +7 -0
- package/src/adapters/dsh/review.mjs +192 -0
- package/src/assertions.mjs +389 -0
- package/src/cli.mjs +104 -0
- package/src/config.mjs +119 -0
- package/src/discovery.mjs +115 -0
- package/src/experiment/review.mjs +118 -0
- package/src/index.mjs +49 -0
- package/src/mock/mock-adapter.mjs +73 -0
- package/src/mock/script.mjs +49 -0
- package/src/report.mjs +142 -0
- package/src/review-report.mjs +101 -0
- package/src/runner.mjs +373 -0
- package/src/tool-validation.mjs +77 -0
- package/src/trace.mjs +218 -0
|
@@ -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
|
+
}
|