@sweny-ai/core 0.1.72 → 0.1.74

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,517 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { checkAllToolsCalled, checkAnyToolCalled, checkNoToolCalled, checkOutputMatches, checkOutputRequired, evaluateVerify, resolvePath, } from "../verify.js";
3
+ const tc = (tool, output) => ({ tool, input: {}, output });
4
+ const errOut = { error: "boom" };
5
+ describe("resolvePath", () => {
6
+ describe("simple dotted paths", () => {
7
+ it("resolves a top-level key", () => {
8
+ expect(resolvePath({ a: 1 }, "a")).toEqual({ ok: true, mode: "all", values: [1] });
9
+ });
10
+ it("resolves a nested key", () => {
11
+ expect(resolvePath({ a: { b: { c: "x" } } }, "a.b.c")).toEqual({
12
+ ok: true,
13
+ mode: "all",
14
+ values: ["x"],
15
+ });
16
+ });
17
+ it("returns the literal null value (does not treat null as missing)", () => {
18
+ expect(resolvePath({ a: null }, "a")).toEqual({ ok: true, mode: "all", values: [null] });
19
+ });
20
+ it("fails when a top-level segment is missing", () => {
21
+ const r = resolvePath({}, "a");
22
+ expect(r.ok).toBe(false);
23
+ if (!r.ok)
24
+ expect(r.reason).toMatch(/missing segment 'a'/);
25
+ });
26
+ it("fails when an intermediate segment is missing", () => {
27
+ const r = resolvePath({ a: {} }, "a.b.c");
28
+ expect(r.ok).toBe(false);
29
+ if (!r.ok)
30
+ expect(r.reason).toMatch(/missing segment 'b'/);
31
+ });
32
+ it("fails when an intermediate segment is null", () => {
33
+ const r = resolvePath({ a: null }, "a.b");
34
+ expect(r.ok).toBe(false);
35
+ if (!r.ok)
36
+ expect(r.reason).toMatch(/null/);
37
+ });
38
+ });
39
+ describe("wildcard expansion", () => {
40
+ it("expands [*] over an array of primitives", () => {
41
+ expect(resolvePath({ xs: [1, 2, 3] }, "xs[*]")).toEqual({
42
+ ok: true,
43
+ mode: "all",
44
+ values: [1, 2, 3],
45
+ });
46
+ });
47
+ it("expands [*] then walks into each element", () => {
48
+ const data = { findings: [{ severity: "critical" }, { severity: "low" }] };
49
+ expect(resolvePath(data, "findings[*].severity")).toEqual({
50
+ ok: true,
51
+ mode: "all",
52
+ values: ["critical", "low"],
53
+ });
54
+ });
55
+ it("returns an empty values array when the array is empty", () => {
56
+ expect(resolvePath({ xs: [] }, "xs[*]")).toEqual({ ok: true, mode: "all", values: [] });
57
+ });
58
+ it("returns an empty values array when expanding deeper into an empty array", () => {
59
+ expect(resolvePath({ findings: [] }, "findings[*].severity")).toEqual({
60
+ ok: true,
61
+ mode: "all",
62
+ values: [],
63
+ });
64
+ });
65
+ it("fails when [*] is applied to a non-array", () => {
66
+ const r = resolvePath({ xs: { not: "array" } }, "xs[*]");
67
+ expect(r.ok).toBe(false);
68
+ if (!r.ok)
69
+ expect(r.reason).toMatch(/expected array at 'xs'/);
70
+ });
71
+ it("fails when an element under [*] is missing the next segment", () => {
72
+ const data = { findings: [{ severity: "critical" }, { title: "x" }] };
73
+ const r = resolvePath(data, "findings[*].severity");
74
+ expect(r.ok).toBe(false);
75
+ if (!r.ok)
76
+ expect(r.reason).toMatch(/missing segment 'severity'/);
77
+ });
78
+ });
79
+ describe("mode prefixes", () => {
80
+ it("parses `all:` prefix", () => {
81
+ const r = resolvePath({ xs: [1, 2] }, "all:xs[*]");
82
+ expect(r).toEqual({ ok: true, mode: "all", values: [1, 2] });
83
+ });
84
+ it("parses `any:` prefix", () => {
85
+ const r = resolvePath({ xs: [1, 2] }, "any:xs[*]");
86
+ expect(r).toEqual({ ok: true, mode: "any", values: [1, 2] });
87
+ });
88
+ it("defaults to `all` when no prefix", () => {
89
+ const r = resolvePath({ xs: [1] }, "xs[*]");
90
+ expect(r).toEqual({ ok: true, mode: "all", values: [1] });
91
+ });
92
+ it("rejects an unknown prefix", () => {
93
+ const r = resolvePath({ xs: [1] }, "every:xs[*]");
94
+ expect(r.ok).toBe(false);
95
+ if (!r.ok)
96
+ expect(r.reason).toMatch(/unknown prefix 'every'/);
97
+ });
98
+ });
99
+ describe("grammar errors", () => {
100
+ it("rejects an empty path", () => {
101
+ const r = resolvePath({}, "");
102
+ expect(r.ok).toBe(false);
103
+ if (!r.ok)
104
+ expect(r.reason).toMatch(/empty path/);
105
+ });
106
+ it("rejects a malformed segment", () => {
107
+ const r = resolvePath({}, "a..b");
108
+ expect(r.ok).toBe(false);
109
+ if (!r.ok)
110
+ expect(r.reason).toMatch(/malformed/);
111
+ });
112
+ });
113
+ });
114
+ describe("checkAnyToolCalled", () => {
115
+ it("passes when one of the named tools succeeded", () => {
116
+ expect(checkAnyToolCalled(["a", "b"], [tc("a", { ok: true })])).toBeNull();
117
+ });
118
+ it("passes when one succeeded among others", () => {
119
+ expect(checkAnyToolCalled(["a"], [tc("x"), tc("a", { ok: true }), tc("y")])).toBeNull();
120
+ });
121
+ it("fails when only an unrelated tool was called", () => {
122
+ const err = checkAnyToolCalled(["a", "b"], [tc("x")]);
123
+ expect(err).toMatch(/any_tool_called.*required one of \[a, b\].*called.*x/);
124
+ });
125
+ it("fails when the required tool errored", () => {
126
+ const err = checkAnyToolCalled(["a"], [tc("a", errOut)]);
127
+ expect(err).toMatch(/any_tool_called/);
128
+ });
129
+ it("reports 'none' when no tools were called", () => {
130
+ const err = checkAnyToolCalled(["a"], []);
131
+ expect(err).toMatch(/called: \[none\]/);
132
+ });
133
+ });
134
+ describe("checkAllToolsCalled", () => {
135
+ it("passes when every named tool succeeded", () => {
136
+ expect(checkAllToolsCalled(["a", "b"], [tc("a", { ok: true }), tc("b", { ok: true })])).toBeNull();
137
+ });
138
+ it("passes when extras were also called", () => {
139
+ expect(checkAllToolsCalled(["a"], [tc("a", { ok: true }), tc("x"), tc("y")])).toBeNull();
140
+ });
141
+ it("fails when a required tool was never called", () => {
142
+ const err = checkAllToolsCalled(["a", "b"], [tc("a", { ok: true })]);
143
+ expect(err).toMatch(/all_tools_called.*missing.*\[b\]/);
144
+ });
145
+ it("fails when a required tool only errored", () => {
146
+ const err = checkAllToolsCalled(["a", "b"], [tc("a", { ok: true }), tc("b", errOut)]);
147
+ expect(err).toMatch(/all_tools_called.*missing.*\[b\]/);
148
+ });
149
+ });
150
+ describe("checkNoToolCalled", () => {
151
+ it("passes when none of the named tools were called", () => {
152
+ expect(checkNoToolCalled(["a", "b"], [tc("x"), tc("y")])).toBeNull();
153
+ });
154
+ it("passes when toolCalls is empty", () => {
155
+ expect(checkNoToolCalled(["a"], [])).toBeNull();
156
+ });
157
+ it("fails when a forbidden tool was called successfully", () => {
158
+ const err = checkNoToolCalled(["force_push"], [tc("force_push", { ok: true })]);
159
+ expect(err).toMatch(/no_tool_called.*forbidden.*\[force_push\]/);
160
+ });
161
+ it("fails when a forbidden tool was called even with error", () => {
162
+ const err = checkNoToolCalled(["force_push"], [tc("force_push", errOut)]);
163
+ expect(err).toMatch(/no_tool_called/);
164
+ });
165
+ });
166
+ describe("checkOutputRequired", () => {
167
+ it("passes when all paths resolve to non-null values", () => {
168
+ expect(checkOutputRequired(["a", "b.c"], { a: 1, b: { c: "x" } })).toBeNull();
169
+ });
170
+ it("fails when a path is missing", () => {
171
+ const err = checkOutputRequired(["a", "missing"], { a: 1 });
172
+ expect(err).toMatch(/output_required.*'missing'.*missing segment/);
173
+ });
174
+ it("fails when a path resolves to null", () => {
175
+ const err = checkOutputRequired(["a"], { a: null });
176
+ expect(err).toMatch(/output_required.*'a'.*null/);
177
+ });
178
+ it("with default (all) wildcard, requires every element to have non-null path", () => {
179
+ const data = { findings: [{ severity: "critical" }, { severity: null }] };
180
+ const err = checkOutputRequired(["findings[*].severity"], data);
181
+ expect(err).toMatch(/output_required/);
182
+ });
183
+ it("with default (all) wildcard, passes when every element has non-null path", () => {
184
+ const data = { findings: [{ severity: "critical" }, { severity: "low" }] };
185
+ expect(checkOutputRequired(["findings[*].severity"], data)).toBeNull();
186
+ });
187
+ it("with default (all) wildcard over empty array, vacuously passes", () => {
188
+ expect(checkOutputRequired(["findings[*].severity"], { findings: [] })).toBeNull();
189
+ });
190
+ it("with `any` wildcard, passes when at least one element has non-null path", () => {
191
+ const data = { findings: [{ severity: null }, { severity: "low" }] };
192
+ expect(checkOutputRequired(["any:findings[*].severity"], data)).toBeNull();
193
+ });
194
+ it("with `any` wildcard over empty array, fails", () => {
195
+ const err = checkOutputRequired(["any:findings[*].severity"], { findings: [] });
196
+ expect(err).toMatch(/output_required.*no elements/);
197
+ });
198
+ it("aggregates multiple required-path failures into one message", () => {
199
+ const err = checkOutputRequired(["a", "b"], {});
200
+ expect(err).toMatch(/'a'/);
201
+ expect(err).toMatch(/'b'/);
202
+ });
203
+ });
204
+ describe("checkOutputMatches", () => {
205
+ it("passes equals on a single value", () => {
206
+ expect(checkOutputMatches([{ path: "a", equals: 1 }], { a: 1 })).toBeNull();
207
+ });
208
+ it("fails equals on a mismatched value", () => {
209
+ const err = checkOutputMatches([{ path: "a", equals: 1 }], { a: 2 });
210
+ expect(err).toMatch(/output_matches.*'a'.*equals.*1.*got.*2/);
211
+ });
212
+ it("passes `in` when the value is in the set", () => {
213
+ expect(checkOutputMatches([{ path: "sev", in: ["high", "low"] }], { sev: "low" })).toBeNull();
214
+ });
215
+ it("fails `in` when the value is not in the set", () => {
216
+ const err = checkOutputMatches([{ path: "sev", in: ["high", "low"] }], { sev: "urgent" });
217
+ expect(err).toMatch(/output_matches.*'sev'.*in \["high", "low"\].*got \["urgent"\]/);
218
+ });
219
+ it("passes `matches` when the regex matches the coerced string", () => {
220
+ expect(checkOutputMatches([{ path: "url", matches: "^https://" }], { url: "https://x" })).toBeNull();
221
+ });
222
+ it("fails `matches` when the regex does not match", () => {
223
+ const err = checkOutputMatches([{ path: "url", matches: "^https://" }], { url: "ftp://x" });
224
+ expect(err).toMatch(/output_matches.*'url'.*matches.*ftp/);
225
+ });
226
+ it("with default (all) wildcard, requires every element to satisfy operator", () => {
227
+ const data = { sevs: [{ s: "high" }, { s: "urgent" }] };
228
+ const err = checkOutputMatches([{ path: "sevs[*].s", in: ["high", "low"] }], data);
229
+ expect(err).toMatch(/output_matches/);
230
+ });
231
+ it("with default (all) wildcard, passes when every element satisfies operator", () => {
232
+ const data = { sevs: [{ s: "high" }, { s: "low" }] };
233
+ expect(checkOutputMatches([{ path: "sevs[*].s", in: ["high", "low"] }], data)).toBeNull();
234
+ });
235
+ it("with default (all) wildcard over empty array, vacuously passes", () => {
236
+ expect(checkOutputMatches([{ path: "sevs[*].s", in: ["high"] }], { sevs: [] })).toBeNull();
237
+ });
238
+ it("with `any` wildcard, passes when at least one element satisfies operator", () => {
239
+ const data = { sevs: [{ s: "low" }, { s: "critical" }] };
240
+ expect(checkOutputMatches([{ path: "any:sevs[*].s", equals: "critical" }], data)).toBeNull();
241
+ });
242
+ it("with `any` wildcard, fails when no element satisfies operator", () => {
243
+ const data = { sevs: [{ s: "low" }, { s: "high" }] };
244
+ const err = checkOutputMatches([{ path: "any:sevs[*].s", equals: "critical" }], data);
245
+ expect(err).toMatch(/output_matches.*no element satisfies/);
246
+ });
247
+ it("with `any` wildcard over empty array, fails", () => {
248
+ const err = checkOutputMatches([{ path: "any:sevs[*].s", equals: "x" }], { sevs: [] });
249
+ expect(err).toMatch(/output_matches/);
250
+ });
251
+ it("fails when the path itself does not resolve", () => {
252
+ const err = checkOutputMatches([{ path: "missing", equals: 1 }], {});
253
+ expect(err).toMatch(/output_matches.*'missing'.*missing segment/);
254
+ });
255
+ it("aggregates multiple match failures into one message", () => {
256
+ const err = checkOutputMatches([
257
+ { path: "a", equals: 1 },
258
+ { path: "b", equals: 2 },
259
+ ], { a: 999, b: 999 });
260
+ expect(err).toMatch(/'a'/);
261
+ expect(err).toMatch(/'b'/);
262
+ });
263
+ });
264
+ const result = (data, toolCalls = []) => ({
265
+ status: "success",
266
+ data,
267
+ toolCalls,
268
+ });
269
+ describe("evaluateVerify", () => {
270
+ it("returns null when verify is undefined", () => {
271
+ expect(evaluateVerify(undefined, result({}))).toBeNull();
272
+ });
273
+ it("returns null when every declared check passes", () => {
274
+ const v = {
275
+ any_tool_called: ["a"],
276
+ output_required: ["x"],
277
+ output_matches: [{ path: "x", equals: 1 }],
278
+ };
279
+ const r = result({ x: 1 }, [tc("a", { ok: true })]);
280
+ expect(evaluateVerify(v, r)).toBeNull();
281
+ });
282
+ it("aggregates failures from multiple checks into one error string", () => {
283
+ const v = {
284
+ any_tool_called: ["create_pr"],
285
+ no_tool_called: ["force_push"],
286
+ output_required: ["prUrl"],
287
+ output_matches: [{ path: "branch", matches: "^sweny/" }],
288
+ };
289
+ const r = result({ branch: "main" }, [tc("force_push", { ok: true })]);
290
+ const err = evaluateVerify(v, r);
291
+ expect(err).not.toBeNull();
292
+ expect(err).toMatch(/^verify failed:/);
293
+ expect(err).toMatch(/any_tool_called/);
294
+ expect(err).toMatch(/no_tool_called/);
295
+ expect(err).toMatch(/output_required/);
296
+ expect(err).toMatch(/output_matches/);
297
+ });
298
+ it("formats failures as a bulleted list", () => {
299
+ const v = { any_tool_called: ["a"], no_tool_called: ["b"] };
300
+ const r = result({}, [tc("b")]);
301
+ const err = evaluateVerify(v, r);
302
+ expect(err).toMatch(/\n {2}- /);
303
+ });
304
+ });
305
+ // ─── Hardening tests added in response to PR #158 review ─────────────
306
+ describe("deepEqual semantics (via output_matches equals)", () => {
307
+ it("treats objects with different key insertion order as equal", () => {
308
+ expect(checkOutputMatches([{ path: "x", equals: { a: 1, b: 2 } }], { x: { b: 2, a: 1 } })).toBeNull();
309
+ });
310
+ it("differentiates objects with different keys", () => {
311
+ expect(checkOutputMatches([{ path: "x", equals: { a: 1, b: 2 } }], { x: { a: 1, c: 2 } })).not.toBeNull();
312
+ });
313
+ it("handles arrays element-wise (order-sensitive)", () => {
314
+ expect(checkOutputMatches([{ path: "x", equals: [1, 2, 3] }], { x: [1, 2, 3] })).toBeNull();
315
+ expect(checkOutputMatches([{ path: "x", equals: [1, 2, 3] }], { x: [3, 2, 1] })).not.toBeNull();
316
+ });
317
+ it("handles nested object/array equality", () => {
318
+ const eq = { items: [{ id: 1 }, { id: 2 }] };
319
+ expect(checkOutputMatches([{ path: "x", equals: eq }], { x: { items: [{ id: 1 }, { id: 2 }] } })).toBeNull();
320
+ expect(checkOutputMatches([{ path: "x", equals: eq }], { x: { items: [{ id: 1 }, { id: 3 }] } })).not.toBeNull();
321
+ });
322
+ it("treats NaN as equal to NaN (Object.is)", () => {
323
+ expect(checkOutputMatches([{ path: "x", equals: NaN }], { x: NaN })).toBeNull();
324
+ });
325
+ it("treats null as equal to null", () => {
326
+ expect(checkOutputMatches([{ path: "x", equals: null }], { x: null })).toBeNull();
327
+ expect(checkOutputMatches([{ path: "x", equals: null }], { x: 0 })).not.toBeNull();
328
+ });
329
+ it("differentiates {a: undefined} from {} (presence of key matters)", () => {
330
+ expect(checkOutputMatches([{ path: "x", equals: { a: undefined } }], { x: { a: undefined } })).toBeNull();
331
+ expect(checkOutputMatches([{ path: "x", equals: { a: undefined } }], { x: {} })).not.toBeNull();
332
+ });
333
+ it("differentiates an array from an object even when both have same length-ish shape", () => {
334
+ expect(checkOutputMatches([{ path: "x", equals: { 0: "a", 1: "b", length: 2 } }], { x: ["a", "b"] })).not.toBeNull();
335
+ });
336
+ });
337
+ describe("regex / matches edge cases", () => {
338
+ it("respects regex special characters when matching strings", () => {
339
+ expect(checkOutputMatches([{ path: "v", matches: "^foo\\.bar$" }], { v: "foo.bar" })).toBeNull();
340
+ // Unescaped `.` is regex any-char — `foo_bar` would still match `foo.bar`,
341
+ // but `foo.baz` should not match `^foo\.bar$`.
342
+ expect(checkOutputMatches([{ path: "v", matches: "^foo\\.bar$" }], { v: "foo.baz" })).not.toBeNull();
343
+ });
344
+ it("coerces numbers to their JSON string form for matching", () => {
345
+ expect(checkOutputMatches([{ path: "v", matches: "^123$" }], { v: 123 })).toBeNull();
346
+ });
347
+ it("coerces objects to their JSON string form for matching", () => {
348
+ expect(checkOutputMatches([{ path: "v", matches: '"x":"abc"' }], { v: { x: "abc" } })).toBeNull();
349
+ });
350
+ it("reports a clear failure when the regex source is invalid", () => {
351
+ const err = checkOutputMatches([{ path: "v", matches: "(unclosed" }], { v: "x" });
352
+ expect(err).toMatch(/output_matches.*invalid regex/);
353
+ });
354
+ });
355
+ describe("nested wildcard chains", () => {
356
+ it("expands a[*].b[*].c", () => {
357
+ const data = {
358
+ a: [{ b: [{ c: 1 }, { c: 2 }] }, { b: [{ c: 3 }] }],
359
+ };
360
+ const r = resolvePath(data, "a[*].b[*].c");
361
+ expect(r).toEqual({ ok: true, mode: "all", values: [1, 2, 3] });
362
+ });
363
+ it("fails when an inner array is missing", () => {
364
+ const data = { a: [{ b: [{ c: 1 }] }, { b: { c: 2 } }] };
365
+ const r = resolvePath(data, "a[*].b[*].c");
366
+ expect(r.ok).toBe(false);
367
+ });
368
+ });
369
+ describe("malformed paths", () => {
370
+ it("rejects [*] at the start (no identifier)", () => {
371
+ const r = resolvePath({ a: [1] }, "[*].x");
372
+ expect(r.ok).toBe(false);
373
+ if (!r.ok)
374
+ expect(r.reason).toMatch(/malformed segment/);
375
+ });
376
+ it("rejects double brackets a[*][*]", () => {
377
+ const r = resolvePath({ a: [[1]] }, "a[*][*]");
378
+ expect(r.ok).toBe(false);
379
+ if (!r.ok)
380
+ expect(r.reason).toMatch(/malformed segment/);
381
+ });
382
+ it("rejects a path with only a prefix", () => {
383
+ const r = resolvePath({ a: 1 }, "all:");
384
+ expect(r.ok).toBe(false);
385
+ if (!r.ok)
386
+ expect(r.reason).toMatch(/empty path after prefix/);
387
+ });
388
+ });
389
+ describe("non-object node data", () => {
390
+ it("checkOutputRequired against null data fails cleanly", () => {
391
+ const err = checkOutputRequired(["x"], null);
392
+ expect(err).toMatch(/output_required.*'x'/);
393
+ });
394
+ it("checkOutputMatches against null data fails cleanly", () => {
395
+ const err = checkOutputMatches([{ path: "x", equals: 1 }], null);
396
+ expect(err).toMatch(/output_matches.*'x'/);
397
+ });
398
+ it("checkOutputRequired against a primitive fails cleanly", () => {
399
+ const err = checkOutputRequired(["x"], "string");
400
+ expect(err).toMatch(/output_required.*'x'/);
401
+ });
402
+ });
403
+ describe("isErrorOutput tolerance for null/false sentinels", () => {
404
+ it("treats {error: null} as success", () => {
405
+ const err = checkAnyToolCalled(["save"], [tc("save", { ok: true, error: null })]);
406
+ expect(err).toBeNull();
407
+ });
408
+ it("treats {error: false} as success", () => {
409
+ const err = checkAnyToolCalled(["save"], [tc("save", { ok: true, error: false })]);
410
+ expect(err).toBeNull();
411
+ });
412
+ it("treats {error: undefined} as success", () => {
413
+ const err = checkAnyToolCalled(["save"], [tc("save", { ok: true, error: undefined })]);
414
+ expect(err).toBeNull();
415
+ });
416
+ it("treats {error: 0} as success (zero is not an error)", () => {
417
+ // Defensive: only truthy/non-null/non-false `error` values should fail.
418
+ // `error: 0` is unusual but the contract is "presence of an error value".
419
+ const err = checkAnyToolCalled(["save"], [tc("save", { ok: true, error: 0 })]);
420
+ // We DO treat `0` as an error because it's neither null, undefined, nor false.
421
+ // Pin this so future readers see it's intentional.
422
+ expect(err).not.toBeNull();
423
+ });
424
+ it("treats {error: 'boom'} as failure", () => {
425
+ const err = checkAnyToolCalled(["save"], [tc("save", { error: "boom" })]);
426
+ expect(err).not.toBeNull();
427
+ });
428
+ });
429
+ describe("output_matches all-mode aggregation", () => {
430
+ it("reports every offending value, not just the first", () => {
431
+ const data = { sevs: [{ s: "urgent" }, { s: "high" }, { s: "yikes" }] };
432
+ const err = checkOutputMatches([{ path: "sevs[*].s", in: ["high", "low"] }], data);
433
+ expect(err).toMatch(/"urgent"/);
434
+ expect(err).toMatch(/"yikes"/);
435
+ // "high" satisfies the operator and must NOT appear as an offender
436
+ // (verify by absence-of-substring within the offenders bracket section).
437
+ const m = err.match(/got \[(.*)\]$/);
438
+ expect(m).not.toBeNull();
439
+ expect(m[1]).not.toContain('"high"');
440
+ });
441
+ });
442
+ describe("path-resolution failures inside output_matches", () => {
443
+ it("missing path produces a clean message naming the missing segment", () => {
444
+ const err = checkOutputMatches([{ path: "missing.x", equals: 1 }], { other: 1 });
445
+ expect(err).toMatch(/output_matches.*'missing\.x'.*missing segment 'missing'/);
446
+ });
447
+ it("[*] against a non-array fails cleanly", () => {
448
+ const err = checkOutputMatches([{ path: "x[*].y", equals: 1 }], { x: { not: "an array" } });
449
+ expect(err).toMatch(/output_matches.*expected array at 'x'/);
450
+ });
451
+ });
452
+ describe("tool-call message parity", () => {
453
+ it("checkAllToolsCalled includes the called: tail", () => {
454
+ const err = checkAllToolsCalled(["save", "send"], [tc("save", { ok: true }), tc("log")]);
455
+ expect(err).toMatch(/missing successful calls to \[send\]/);
456
+ expect(err).toMatch(/called: \[save, log\]/);
457
+ });
458
+ it("checkNoToolCalled includes the called: tail", () => {
459
+ const err = checkNoToolCalled(["force_push"], [tc("force_push"), tc("log")]);
460
+ expect(err).toMatch(/forbidden tools were invoked: \[force_push\]/);
461
+ expect(err).toMatch(/called: \[force_push, log\]/);
462
+ });
463
+ });
464
+ describe("isErrorOutput against non-object output", () => {
465
+ it("treats string output as success (no error key possible)", () => {
466
+ const err = checkAnyToolCalled(["save"], [tc("save", "ok")]);
467
+ expect(err).toBeNull();
468
+ });
469
+ it("treats number output as success", () => {
470
+ const err = checkAnyToolCalled(["save"], [tc("save", 42)]);
471
+ expect(err).toBeNull();
472
+ });
473
+ it("treats undefined output (no result reported) as success", () => {
474
+ const err = checkAnyToolCalled(["save"], [tc("save")]);
475
+ expect(err).toBeNull();
476
+ });
477
+ });
478
+ describe("output_matches matches operator with mixed pass/fail values", () => {
479
+ it("aggregates only the offending values under all-mode matches", () => {
480
+ const data = { urls: ["https://a", "ftp://b", "https://c", "ssh://d"] };
481
+ const err = checkOutputMatches([{ path: "urls[*]", matches: "^https://" }], data);
482
+ expect(err).toMatch(/"ftp:\/\/b"/);
483
+ expect(err).toMatch(/"ssh:\/\/d"/);
484
+ const offenders = err.match(/got \[(.*)\]$/)[1];
485
+ expect(offenders).not.toContain("https://a");
486
+ expect(offenders).not.toContain("https://c");
487
+ });
488
+ });
489
+ describe("applyOperator defensive fallthrough", () => {
490
+ it("returns a clean error when no operator is declared (zod-bypass guard)", () => {
491
+ // Construct an OutputMatch that bypasses zod (cast-as-any) to exercise the
492
+ // applyOperator default branch. In real flow zod prevents this, but we want
493
+ // the runtime to degrade gracefully if a producer ever skips schema parsing.
494
+ const bogus = { path: "x" };
495
+ const err = checkOutputMatches([bogus], { x: 1 });
496
+ expect(err).toMatch(/output_matches.*'x'.*no operator/);
497
+ });
498
+ });
499
+ describe("evaluateVerify exact failure-string format (public contract)", () => {
500
+ it("produces the exact bulleted format", () => {
501
+ const v = {
502
+ any_tool_called: ["save"],
503
+ output_required: ["url"],
504
+ };
505
+ const r = {
506
+ status: "success",
507
+ data: {},
508
+ toolCalls: [tc("read")],
509
+ };
510
+ const err = evaluateVerify(v, r);
511
+ expect(err).toBe([
512
+ "verify failed:",
513
+ " - any_tool_called: required one of [save] to succeed, called: [read]",
514
+ " - output_required: 'url' missing segment 'url'",
515
+ ].join("\n"));
516
+ });
517
+ });
package/dist/executor.js CHANGED
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { consoleLogger } from "./types.js";
16
16
  import { resolveSources } from "./source-resolver.js";
17
+ import { evaluateVerify } from "./verify.js";
17
18
  /**
18
19
  * Execute a workflow from entry to completion.
19
20
  *
@@ -124,11 +125,15 @@ export async function execute(workflow, input, options) {
124
125
  // Machine-checked post-condition. Enforced AFTER the LLM finishes so that
125
126
  // a model claiming success cannot bypass side-effect requirements (e.g.
126
127
  // reporting "issue created" when no `linear_create_issue` call was made).
127
- const verifyError = evaluateVerify(node.verify, result);
128
- if (verifyError && result.status === "success") {
129
- result.status = "failed";
130
- result.data = { ...result.data, error: verifyError };
131
- logger.warn(` verify failed: ${verifyError}`, { node: currentId });
128
+ // Skipped entirely when the node already failed — the node's own error is
129
+ // the root cause and verify would just add noise.
130
+ if (result.status === "success") {
131
+ const verifyError = evaluateVerify(node.verify, result);
132
+ if (verifyError) {
133
+ result.status = "failed";
134
+ result.data = { ...result.data, error: verifyError };
135
+ logger.warn(` verify failed: ${verifyError}`, { node: currentId });
136
+ }
132
137
  }
133
138
  results.set(currentId, result);
134
139
  trace.steps.push({ node: currentId, status: result.status, iteration });
@@ -164,31 +169,6 @@ export async function execute(workflow, input, options) {
164
169
  return { results, trace };
165
170
  }
166
171
  // ─── Internals ───────────────────────────────────────────────────
167
- /**
168
- * Evaluate a node's `verify` post-condition against the executed result.
169
- *
170
- * Returns an error string when the check fails, or null when the node
171
- * passes verification (or has no verify block).
172
- *
173
- * Keep this deterministic and side-effect free — the executor calls it
174
- * synchronously right after the LLM finishes.
175
- */
176
- function evaluateVerify(verify, result) {
177
- if (!verify)
178
- return null;
179
- if (verify.any_tool_called && verify.any_tool_called.length > 0) {
180
- const required = new Set(verify.any_tool_called);
181
- const success = result.toolCalls.some((c) => required.has(c.tool) &&
182
- // Treat explicit error outputs (from coreToolToSdkTool's catch branch)
183
- // as failed calls so a swallowed handler throw doesn't count as success.
184
- !(c.output && typeof c.output === "object" && "error" in c.output));
185
- if (!success) {
186
- const called = result.toolCalls.map((c) => c.tool).join(", ") || "none";
187
- return `verify.any_tool_called failed — required one of [${verify.any_tool_called.join(", ")}] to succeed, but tool calls were: [${called}]`;
188
- }
189
- }
190
- return null;
191
- }
192
172
  /**
193
173
  * Build the full instruction for a node.
194
174
  *