返回 DeepSeek-Reasonix
evidence_test.go
根目录 / internal / evidence / evidence_test.go
1 package evidence
2
3 import (
4 "context"
5 "encoding/json"
6 "reflect"
7 "strings"
8 "testing"
9 )
10
11 func TestLedgerRecordsSuccessAndFailureReceipts(t *testing.T) {
12 ledger := NewLedger()
13 ledger.Record(Receipt{
14 ToolName: "bash",
15 Args: json.RawMessage(`{"command":"go test ./..."}`),
16 Success: true,
17 Command: "go test ./...",
18 })
19 ledger.Record(Receipt{
20 ToolName: "bash",
21 Args: json.RawMessage(`{"command":"go test ./internal/..."}`),
22 Success: false,
23 Command: "go test ./internal/...",
24 })
25
26 if !ledger.HasSuccessfulCommand("go test ./...") {
27 t.Fatal("successful bash command should verify")
28 }
29 if ledger.HasSuccessfulCommand("go test ./internal/...") {
30 t.Fatal("failed bash command must not verify")
31 }
32 }
33
34 func TestLedgerDistinguishesWorkflowSpecificMutation(t *testing.T) {
35 ledger := NewLedger()
36 ledger.Record(ReceiptFromToolCall("remember", json.RawMessage(`{"body":"ORBIT-42"}`), true, false))
37 if !ledger.HasSuccessfulToolReceipt("remember") {
38 t.Fatal("successful remember receipt was not found")
39 }
40 if ledger.HasSuccessfulMutationOtherThan("remember") {
41 t.Fatal("remember-only mutation was treated as an unrelated workspace mutation")
42 }
43 ledger.Record(ReceiptFromToolCall("edit_file", json.RawMessage(`{"path":"internal/a.go"}`), true, false))
44 if !ledger.HasSuccessfulMutationOtherThan("remember") {
45 t.Fatal("workspace mutation was hidden by the remember allowance")
46 }
47 }
48
49 func TestLedgerMatchesFreshSuccessfulReviewReceipt(t *testing.T) {
50 ledger := NewLedger()
51 ledger.Record(ReceiptFromToolCall("review", json.RawMessage(`{"task":"review changes"}`), true, true))
52 if !ledger.HasCompletedReview() {
53 t.Fatal("successful dedicated review tool should verify review evidence")
54 }
55 legacy := NewLedger()
56 legacy.Record(ReceiptFromToolCall("task", json.RawMessage(`{"profile":"review"}`), true, true))
57 if !legacy.HasCompletedReview() {
58 t.Fatal("successful task(profile=review) should verify review evidence")
59 }
60 failed := NewLedger()
61 failed.Record(ReceiptFromToolCall("task", json.RawMessage(`{"profile":"review"}`), false, true))
62 if failed.HasCompletedReview() {
63 t.Fatal("failed review task must not verify review evidence")
64 }
65 background := NewLedger()
66 background.Record(ReceiptFromToolCall("task", json.RawMessage(`{"profile":"review","run_in_background":true}`), true, true))
67 if background.HasCompletedReview() {
68 t.Fatal("starting a background review must not count as a completed review")
69 }
70 }
71
72 func TestLedgerRejectsReviewBeforeLatestMutation(t *testing.T) {
73 ledger := NewLedger()
74 ledger.Record(ReceiptFromToolCall("review", json.RawMessage(`{"task":"review changes"}`), true, true))
75 ledger.Record(ReceiptFromToolCall("edit_file", json.RawMessage(`{"path":"changed.go"}`), true, false))
76 if ledger.HasCompletedReview() {
77 t.Fatal("review evidence before the latest mutation must be stale")
78 }
79 }
80
81 func TestLedgerRequiresReviewCoverageAfterMutation(t *testing.T) {
82 fresh := NewLedger()
83 fresh.Record(ReceiptFromToolCall("edit_file", json.RawMessage(`{"path":"changed.go"}`), true, false))
84 fresh.Record(ReceiptFromToolCall("read_file", json.RawMessage(`{"path":"changed.go"}`), true, true))
85 fresh.Record(ReceiptFromToolCall("review", json.RawMessage(`{"task":"review changes"}`), true, true))
86 if !fresh.HasCompletedReview() {
87 t.Fatal("review after reading the latest changed path should count")
88 }
89
90 unrelated := NewLedger()
91 unrelated.Record(ReceiptFromToolCall("edit_file", json.RawMessage(`{"path":"changed.go"}`), true, false))
92 unrelated.Record(ReceiptFromToolCall("read_file", json.RawMessage(`{"path":"other.go"}`), true, true))
93 unrelated.Record(ReceiptFromToolCall("review", json.RawMessage(`{"task":"review something else"}`), true, true))
94 if unrelated.HasCompletedReview() {
95 t.Fatal("review that did not inspect the latest changed path must not count")
96 }
97 }
98
99 func TestLedgerHostReviewCoverageRequiresContentForEveryPath(t *testing.T) {
100 ledger := NewLedger()
101 ledger.Record(ReceiptFromToolCall("edit_file", json.RawMessage(`{"path":"internal/a.go"}`), true, false))
102 ledger.Record(ReceiptFromToolCall("edit_file", json.RawMessage(`{"path":"internal/b.go"}`), true, false))
103 mutation, ok := ledger.LatestSuccessfulMutationIndex()
104 if !ok {
105 t.Fatal("missing mutation index")
106 }
107 ledger.Record(ReceiptFromToolCall("read_file", json.RawMessage(`{"path":"internal/a.go"}`), true, true))
108 if ledger.HasHostReviewCoverageAfter(mutation, []string{"internal/a.go", "internal/b.go"}) {
109 t.Fatal("one path read must not cover a two-path change set")
110 }
111 ledger.Record(ReceiptFromToolCall("read_file", json.RawMessage(`{"path":"internal/b.go"}`), true, true))
112 if !ledger.HasHostReviewCoverageAfter(mutation, []string{"internal/a.go", "internal/b.go"}) {
113 t.Fatal("fresh reads of every changed path should prove host review coverage")
114 }
115
116 diff := NewLedger()
117 diff.Record(ReceiptFromToolCall("edit_file", json.RawMessage(`{"path":"internal/a.go"}`), true, false))
118 diffMutation, ok := diff.LatestSuccessfulMutationIndex()
119 if !ok {
120 t.Fatal("missing diff mutation index")
121 }
122 diff.Record(Receipt{ToolName: "bash", Success: true, Command: "git diff", OutputBytes: 200})
123 if !diff.HasHostReviewCoverageAfter(diffMutation, []string{"internal/a.go", "internal/b.go"}) {
124 t.Fatal("an output-producing whole git diff should cover the current change set")
125 }
126 for _, command := range []string{"git status --short", "git diff --check"} {
127 insufficient := NewLedger()
128 insufficient.Record(ReceiptFromToolCall("edit_file", json.RawMessage(`{"path":"internal/a.go"}`), true, false))
129 idx, ok := insufficient.LatestSuccessfulMutationIndex()
130 if !ok {
131 t.Fatal("missing insufficient mutation index")
132 }
133 insufficient.Record(Receipt{ToolName: "bash", Success: true, Command: command, OutputBytes: 200})
134 if insufficient.HasHostReviewCoverageAfter(idx, []string{"internal/a.go"}) {
135 t.Fatalf("%q must not count as content review", command)
136 }
137 }
138 }
139
140 func TestLedgerAcceptsCollectedStructuredReviewReport(t *testing.T) {
141 ledger := NewLedger()
142 ledger.Record(ReceiptFromToolCall("edit_file", json.RawMessage(`{"path":"changed.go"}`), true, false))
143 ledger.Record(Receipt{ToolName: "review_report", Success: true, Args: json.RawMessage(`{
144 "kind":"review",
145 "verdict":"block",
146 "reviewed_paths":["changed.go"],
147 "findings":[{"severity":"critical","summary":"must fix","path":"changed.go"}]
148 }`)})
149 if !ledger.HasCompletedReview() {
150 t.Fatal("a structured report completes the review activity even when its verdict requires follow-up")
151 }
152 }
153
154 func TestLedgerHasWriteOrCommandSince(t *testing.T) {
155 ledger := NewLedger()
156 ledger.Record(Receipt{ToolName: "todo_write", Success: true, Todos: []TodoItem{{Content: "edit", Status: "in_progress"}}})
157 ledger.Record(Receipt{ToolName: "write_file", Success: true, Write: true, Paths: []string{"a.go"}})
158 ledger.Record(Receipt{ToolName: "bash", Success: false, Command: "go test ./..."})
159 ledger.Record(Receipt{ToolName: "complete_step", Success: true, Step: "edit"})
160
161 if got := ledger.Len(); got != 4 {
162 t.Fatalf("Len() = %d, want 4", got)
163 }
164 if !ledger.HasWriteOrCommandSince(0) {
165 t.Fatal("write receipt at index 1 should count from index 0")
166 }
167 if !ledger.HasWriteOrCommandSince(-1) {
168 t.Fatal("negative index should behave like 0")
169 }
170 if ledger.HasWriteOrCommandSince(2) {
171 t.Fatal("failed command and bookkeeping receipts must not count as progress")
172 }
173 ledger.Record(Receipt{ToolName: "bash", Success: true, Command: "go test ./..."})
174 if !ledger.HasWriteOrCommandSince(2) {
175 t.Fatal("successful command receipt after index should count as progress")
176 }
177 var nilLedger *Ledger
178 if nilLedger.HasWriteOrCommandSince(0) {
179 t.Fatal("nil ledger must report no progress")
180 }
181 }
182
183 func TestLedgerMatchesFileReadAndWriteReceipts(t *testing.T) {
184 ledger := NewLedger()
185 ledger.Record(Receipt{ToolName: "read_file", Success: true, Paths: []string{`internal/tool/builtin/completestep.go`}, Read: true})
186 ledger.Record(Receipt{ToolName: "write_file", Success: true, Paths: []string{`internal/evidence/evidence.go`}, Write: true})
187 ledger.Record(Receipt{ToolName: "edit_file", Success: false, Paths: []string{`failed.go`}, Write: true})
188
189 if !ledger.HasSuccessfulReadOrWrite([]string{`internal\tool\builtin\completestep.go`}) {
190 t.Fatal("read receipt should verify the same path across separators")
191 }
192 if !ledger.HasSuccessfulWrite([]string{`internal/evidence/evidence.go`}) {
193 t.Fatal("write receipt should verify written path")
194 }
195 if ledger.HasSuccessfulWrite([]string{`failed.go`}) {
196 t.Fatal("failed write receipt must not verify")
197 }
198 }
199
200 func TestLedgerReportsAnchorRefreshReadsAfterWrites(t *testing.T) {
201 ledger := NewLedger()
202 ledger.Record(Receipt{ToolName: "write_file", Success: true, Paths: []string{`src\a.go`}, Write: true})
203 writeIndex, ok := ledger.LatestSuccessfulWriteIndex([]string{`src/a.go`})
204 if !ok {
205 t.Fatal("expected latest write index")
206 }
207 if ledger.HasSuccessfulAnchorRefreshReadAfter([]string{`src/a.go`}, writeIndex) {
208 t.Fatal("read-after-write should be false before a read")
209 }
210
211 ledger.Record(Receipt{ToolName: "grep", Success: true, Paths: []string{`src/a.go`}, Read: true, Args: json.RawMessage(`{"path":"src/a.go","pattern":"func"}`)})
212 if ledger.HasSuccessfulAnchorRefreshReadAfter([]string{`src/a.go`}, writeIndex) {
213 t.Fatal("grep should not refresh anchor edit state")
214 }
215 ledger.Record(Receipt{ToolName: "read_file", Success: true, Paths: []string{`src/a.go`}, Read: true, Args: json.RawMessage(`{"path":"src/a.go","offset":100,"limit":20}`)})
216 if ledger.HasSuccessfulAnchorRefreshReadAfter([]string{`src/a.go`}, writeIndex) {
217 t.Fatal("windowed read_file should not refresh anchor edit state")
218 }
219 ledger.Record(Receipt{ToolName: "read_file", Success: true, Paths: []string{`src/a.go`}, Read: true, Args: json.RawMessage(`{"path":"src/a.go"}`)})
220 if !ledger.HasSuccessfulAnchorRefreshReadAfter([]string{`src/a.go`}, writeIndex) {
221 t.Fatal("read-after-write should be true after a successful read")
222 }
223 }
224
225 func TestLedgerReportsFinalReadinessReceiptsAfterWriter(t *testing.T) {
226 ledger := NewLedger()
227 ledger.Record(Receipt{ToolName: "bash", Success: true, Command: "go test ./..."})
228 ledger.Record(Receipt{ToolName: "write_file", Success: true, Paths: []string{"changed.go"}, Write: true})
229 ledger.Record(Receipt{ToolName: "bash", Success: false, Command: "git diff --check"})
230 ledger.Record(Receipt{ToolName: "todo_write", Success: true, Todos: []TodoItem{{Content: "Edit code", Status: "in_progress"}}})
231
232 writer, ok := ledger.LatestSuccessfulWriterIndex()
233 if !ok {
234 t.Fatal("expected latest successful writer")
235 }
236 if ledger.HasSuccessfulCommandAfter("go test ./...", writer) {
237 t.Fatal("command before latest writer must not satisfy final readiness")
238 }
239 if ledger.HasSuccessfulCommandAfter("git diff --check", writer) {
240 t.Fatal("failed command must not satisfy final readiness")
241 }
242 if ledger.HasSuccessfulCompleteStepAfter(writer) {
243 t.Fatal("missing complete_step must not satisfy final readiness")
244 }
245 if !ledger.HasSuccessfulTodoWrite() {
246 t.Fatal("successful todo_write receipt should be reported")
247 }
248
249 ledger.Record(Receipt{ToolName: "bash", Success: true, Command: "git diff --check"})
250 ledger.Record(Receipt{ToolName: "complete_step", Success: true, Step: "Edit code"})
251 if !ledger.HasSuccessfulCommandAfter("git diff --check", writer) {
252 t.Fatal("command after latest writer should satisfy final readiness")
253 }
254 if !ledger.HasSuccessfulCompleteStepAfter(writer) {
255 t.Fatal("complete_step after latest writer should satisfy final readiness")
256 }
257 }
258
259 func TestLedgerResetClearsTurnReceipts(t *testing.T) {
260 ledger := NewLedger()
261 ledger.Record(Receipt{ToolName: "bash", Success: true, Command: "go test ./..."})
262
263 ledger.Reset()
264
265 if ledger.HasSuccessfulCommand("go test ./...") {
266 t.Fatal("reset should clear prior-turn evidence")
267 }
268 }
269
270 func TestContextCarriesLedger(t *testing.T) {
271 ledger := NewLedger()
272 ctx := WithLedger(context.Background(), ledger)
273
274 got, ok := FromContext(ctx)
275 if !ok {
276 t.Fatal("ledger missing from context")
277 }
278 if got != ledger {
279 t.Fatal("context returned a different ledger")
280 }
281 }
282
283 func TestReceiptFromToolCallExtractsEvidenceFields(t *testing.T) {
284 bash := ReceiptFromToolCall("bash", json.RawMessage(`{"command":"git diff --check"}`), true, false)
285 if bash.Command != "git diff --check" {
286 t.Fatalf("bash command = %q", bash.Command)
287 }
288 if bash.Write {
289 t.Fatal("bash should not be treated as a verified file writer")
290 }
291
292 write := ReceiptFromToolCall("write_file", json.RawMessage(`{"path":"internal/evidence/evidence.go","content":"x"}`), true, false)
293 if !write.Write || len(write.Paths) != 1 || write.Paths[0] != `internal/evidence/evidence.go` {
294 t.Fatalf("write receipt not extracted: %+v", write)
295 }
296
297 read := ReceiptFromToolCall("read_file", json.RawMessage(`{"path":"internal/tool/builtin/completestep.go"}`), true, true)
298 if !read.Read || len(read.Paths) != 1 {
299 t.Fatalf("read receipt not extracted: %+v", read)
300 }
301
302 glob := ReceiptFromToolCall("glob", json.RawMessage(`{"pattern":"**/*.go"}`), true, true)
303 if !glob.Read {
304 t.Fatalf("generic read-only tool should be treated as read context: %+v", glob)
305 }
306 }
307
308 func TestReceiptFromToolCallExtractsTodoWriteItems(t *testing.T) {
309 receipt := ReceiptFromToolCall("todo_write", json.RawMessage(`{"todos":[
310 {"content":"Add parser","status":"in_progress","activeForm":"Adding parser"},
311 {"content":"Wire parser","status":"pending","level":1}
312 ]}`), true, true)
313
314 if len(receipt.Todos) != 2 {
315 t.Fatalf("todos not extracted: %+v", receipt)
316 }
317 if receipt.Todos[0].Content != "Add parser" || receipt.Todos[0].Status != "in_progress" || receipt.Todos[0].ActiveForm != "Adding parser" {
318 t.Fatalf("first todo not extracted: %+v", receipt.Todos[0])
319 }
320 if receipt.Todos[1].Level != 1 {
321 t.Fatalf("todo level not extracted: %+v", receipt.Todos[1])
322 }
323 }
324
325 func TestReceiptFromToolCallExtractsCompleteStep(t *testing.T) {
326 receipt := ReceiptFromToolCall("complete_step", json.RawMessage(`{
327 "step":"Add parser",
328 "result":"parser added",
329 "evidence":[{"kind":"manual","summary":"checked manually"}]
330 }`), true, true)
331
332 if receipt.Step != "Add parser" {
333 t.Fatalf("complete_step step = %q", receipt.Step)
334 }
335 if !receipt.StepProof {
336 t.Fatalf("complete_step evidence proof not extracted: %+v", receipt)
337 }
338 if receipt.Read {
339 t.Fatalf("complete_step should not be treated as read-only context: %+v", receipt)
340 }
341
342 missingResult := ReceiptFromToolCall("complete_step", json.RawMessage(`{
343 "step":"Add parser",
344 "evidence":[{"kind":"manual","summary":"checked manually"}]
345 }`), false, true)
346 if missingResult.StepProof {
347 t.Fatalf("complete_step without result should not count as proof: %+v", missingResult)
348 }
349
350 missingCommand := ReceiptFromToolCall("complete_step", json.RawMessage(`{
351 "step":"Add parser",
352 "result":"parser added",
353 "evidence":[{"kind":"verification","summary":"checked manually"}]
354 }`), false, true)
355 if missingCommand.StepProof {
356 t.Fatalf("verification evidence without command should not count as proof: %+v", missingCommand)
357 }
358
359 emptyProof := ReceiptFromToolCall("complete_step", json.RawMessage(`{
360 "step":"Add parser",
361 "result":"parser added",
362 "evidence":[]
363 }`), false, true)
364 if emptyProof.StepProof {
365 t.Fatalf("empty complete_step evidence should not count as proof: %+v", emptyProof)
366 }
367 }
368
369 func TestReceiptFromToolCallExtractsCompleteStepIndex(t *testing.T) {
370 receipt := ReceiptFromToolCall("complete_step", json.RawMessage(`{
371 "step_index":2,
372 "result":"done",
373 "evidence":[{"kind":"manual","summary":"checked"}]
374 }`), true, true)
375
376 if receipt.Step != "2" {
377 t.Fatalf("step index not extracted as step identity: %+v", receipt)
378 }
379 if !receipt.StepProof {
380 t.Fatalf("step proof not detected: %+v", receipt)
381 }
382 }
383
384 func TestLedgerMatchesLatestSuccessfulTodoStep(t *testing.T) {
385 ledger := NewLedger()
386 ledger.Record(Receipt{
387 ToolName: "todo_write",
388 Success: false,
389 Todos: []TodoItem{{Content: "Failed only", Status: "in_progress"}},
390 })
391 ledger.Record(Receipt{
392 ToolName: "todo_write",
393 Success: true,
394 Todos: []TodoItem{
395 {Content: "Add parser", Status: "in_progress", ActiveForm: "Adding parser"},
396 {Content: "Wire parser", Status: "completed"},
397 {Content: "Document parser", Status: "pending"},
398 },
399 })
400
401 for _, step := range []string{"Add parser", "Adding parser", "2"} {
402 match, ok := ledger.MatchLatestTodoStep(step)
403 if !ok {
404 t.Fatalf("latest todo receipt missing for %q", step)
405 }
406 if !match.Found {
407 t.Fatalf("step %q did not match latest todo list", step)
408 }
409 if step == "2" && match.Content != "Wire parser" {
410 t.Fatalf("numeric step matched %q, want Wire parser", match.Content)
411 }
412 }
413
414 match, ok := ledger.MatchLatestTodoStep("Failed only")
415 if !ok {
416 t.Fatal("successful todo receipt should exist")
417 }
418 if match.Found {
419 t.Fatal("failed todo_write receipt must not match")
420 }
421 }
422
423 func TestMatchTodoStepToleratesCitationDrift(t *testing.T) {
424 // Verbatim shape from discussion #3970: todo authored with a fullwidth
425 // colon, cited back with a halfwidth one — and stuck forever.
426 ledger := NewLedger()
427 ledger.Record(Receipt{
428 ToolName: "todo_write",
429 Success: true,
430 Todos: []TodoItem{
431 {Content: "Phase 4:环境准备", Status: "completed"},
432 {Content: "Phase 5:脚本编辑与执行代码", Status: "in_progress"},
433 {Content: "Review notes", Status: "pending"},
434 },
435 })
436
437 matches := map[string]int{
438 "Phase 5: 脚本编辑与执行代码": 2,
439 "phase 5:脚本编辑与执行代码": 2,
440 " Phase 5:脚本编辑与执行代码": 2,
441 "脚本编辑与执行代码": 2,
442 "Phase 4:环境": 1,
443 "REVIEW NOTES": 3,
444 "2": 2,
445 }
446 for step, want := range matches {
447 match, ok := ledger.MatchLatestTodoStep(step)
448 if !ok || !match.Found {
449 t.Fatalf("step %q should match todo %d, got found=%v", step, want, match.Found)
450 }
451 if match.Index != want {
452 t.Errorf("step %q matched todo %d, want %d", step, match.Index, want)
453 }
454 }
455
456 for _, step := range []string{"deploy backend", "代码", "Phase 9:不存在的阶段"} {
457 if match, _ := ledger.MatchLatestTodoStep(step); match.Found {
458 t.Errorf("step %q should not match, got todo %d (%q)", step, match.Index, match.Content)
459 }
460 }
461 }
462
463 func TestMatchTodoStepAmbiguousContainmentStaysUnmatched(t *testing.T) {
464 ledger := NewLedger()
465 ledger.Record(Receipt{
466 ToolName: "todo_write",
467 Success: true,
468 Todos: []TodoItem{
469 {Content: "Deploy backend service", Status: "in_progress"},
470 {Content: "Deploy backend worker", Status: "pending"},
471 },
472 })
473 if match, _ := ledger.MatchLatestTodoStep("Deploy backend"); match.Found {
474 t.Fatalf("ambiguous citation should stay unmatched, got todo %d (%q)", match.Index, match.Content)
475 }
476 if match, _ := ledger.MatchLatestTodoStep("Deploy backend worker"); !match.Found || match.Index != 2 {
477 t.Fatal("exact citation must still resolve despite shared prefix")
478 }
479 }
480
481 func TestLedgerRequiresCompleteStepForNewCompletedTodos(t *testing.T) {
482 ledger := NewLedger()
483 ledger.Record(Receipt{
484 ToolName: "todo_write",
485 Success: true,
486 Todos: []TodoItem{
487 {Content: "Add parser", Status: "in_progress"},
488 {Content: "Already done", Status: "completed"},
489 },
490 })
491
492 current := []TodoItem{
493 {Content: "Add parser", Status: "completed"},
494 {Content: "Already done", Status: "completed"},
495 }
496 missing, hasBaseline := ledger.UnverifiedCompletedTodos(current)
497 if !hasBaseline {
498 t.Fatal("expected prior todo_write baseline")
499 }
500 if len(missing) != 1 || missing[0].Content != "Add parser" {
501 t.Fatalf("missing = %+v, want only Add parser", missing)
502 }
503
504 ledger.Record(Receipt{ToolName: "complete_step", Success: false, Step: "Add parser"})
505 missing, hasBaseline = ledger.UnverifiedCompletedTodos(current)
506 if !hasBaseline {
507 t.Fatal("expected prior todo_write baseline after failed complete_step")
508 }
509 if len(missing) != 1 || missing[0].Content != "Add parser" {
510 t.Fatalf("failed complete_step without proof-bearing recovery should not authorize completion, missing = %+v", missing)
511 }
512
513 ledger.Record(Receipt{ToolName: "complete_step", Success: true, Step: "Add parser"})
514 missing, hasBaseline = ledger.UnverifiedCompletedTodos(current)
515 if !hasBaseline {
516 t.Fatal("expected prior todo_write baseline after successful complete_step")
517 }
518 if len(missing) != 0 {
519 t.Fatalf("successful complete_step should authorize completion, missing = %+v", missing)
520 }
521 }
522
523 func TestLedgerMatchesCompletionByActiveFormAndNumber(t *testing.T) {
524 ledger := NewLedger()
525 ledger.Record(Receipt{
526 ToolName: "todo_write",
527 Success: true,
528 Todos: []TodoItem{
529 {Content: "Add parser", Status: "in_progress", ActiveForm: "Adding parser"},
530 {Content: "Wire parser", Status: "in_progress"},
531 },
532 })
533
534 current := []TodoItem{
535 {Content: "Add parser", Status: "completed", ActiveForm: "Adding parser"},
536 {Content: "Wire parser", Status: "in_progress"},
537 }
538 ledger.Record(Receipt{ToolName: "complete_step", Success: true, Step: "Adding parser"})
539 missing, hasBaseline := ledger.UnverifiedCompletedTodos(current)
540 if !hasBaseline {
541 t.Fatal("expected prior todo_write baseline")
542 }
543 if len(missing) != 0 {
544 t.Fatalf("activeForm complete_step should authorize completion, missing = %+v", missing)
545 }
546
547 current = []TodoItem{
548 {Content: "Add parser", Status: "completed", ActiveForm: "Adding parser"},
549 {Content: "Wire parser", Status: "completed"},
550 }
551 ledger.Record(Receipt{ToolName: "complete_step", Success: true, Step: "2"})
552 missing, hasBaseline = ledger.UnverifiedCompletedTodos(current)
553 if !hasBaseline {
554 t.Fatal("expected prior todo_write baseline")
555 }
556 if len(missing) != 0 {
557 t.Fatalf("numeric complete_step should authorize completion, missing = %+v", missing)
558 }
559 }
560
561 func TestLedgerNumericCompleteStepDoesNotAuthorizeReplacedTodo(t *testing.T) {
562 ledger := NewLedger()
563 ledger.Record(Receipt{
564 ToolName: "todo_write",
565 Success: true,
566 Todos: []TodoItem{{Content: "Add parser", Status: "in_progress"}},
567 })
568 ledger.Record(Receipt{ToolName: "complete_step", Success: true, Step: "1"})
569
570 missing, hasBaseline := ledger.UnverifiedCompletedTodos([]TodoItem{
571 {Content: "Ship parser", Status: "completed"},
572 })
573 if !hasBaseline {
574 t.Fatal("expected prior todo_write baseline")
575 }
576 if len(missing) != 1 || missing[0].Content != "Ship parser" {
577 t.Fatalf("numeric complete_step should not authorize a replaced todo, missing = %+v", missing)
578 }
579 }
580
581 func TestLedgerNumericCompleteStepFollowsReorderedSignedTodo(t *testing.T) {
582 ledger := NewLedger()
583 ledger.Record(Receipt{
584 ToolName: "todo_write",
585 Success: true,
586 Todos: []TodoItem{
587 {Content: "Add parser", Status: "in_progress"},
588 {Content: "Write tests", Status: "pending"},
589 },
590 })
591 ledger.Record(Receipt{ToolName: "complete_step", Success: true, Step: "1"})
592
593 missing, hasBaseline := ledger.UnverifiedCompletedTodos([]TodoItem{
594 {Content: "Write tests", Status: "pending"},
595 {Content: "Add parser", Status: "completed"},
596 })
597 if !hasBaseline {
598 t.Fatal("expected prior todo_write baseline")
599 }
600 if len(missing) != 0 {
601 t.Fatalf("numeric complete_step should follow the signed todo identity after reorder, missing = %+v", missing)
602 }
603 }
604
605 func TestLedgerNoBaselineDoesNotConstrainCompletedTodos(t *testing.T) {
606 ledger := NewLedger()
607 missing, hasBaseline := ledger.UnverifiedCompletedTodos([]TodoItem{
608 {Content: "Add parser", Status: "completed"},
609 })
610
611 if hasBaseline {
612 t.Fatal("empty ledger should not report a prior todo_write baseline")
613 }
614 if len(missing) != 0 {
615 t.Fatalf("no baseline should not report missing completions, got %+v", missing)
616 }
617 }
618
619 func TestValidateSerialTodosRejectsInvalidOrdering(t *testing.T) {
620 tests := []struct {
621 name string
622 todos []TodoItem
623 want string
624 }{
625 {
626 name: "completed after current",
627 todos: []TodoItem{
628 {Content: "first", Status: "in_progress"},
629 {Content: "second", Status: "completed"},
630 },
631 want: "completed after unfinished",
632 },
633 {
634 name: "multiple current items",
635 todos: []TodoItem{
636 {Content: "first", Status: "in_progress"},
637 {Content: "second", Status: "in_progress"},
638 },
639 want: "second in_progress",
640 },
641 {
642 name: "pending without current",
643 todos: []TodoItem{{Content: "first", Status: "pending"}},
644 want: "no in_progress",
645 },
646 }
647 for _, tc := range tests {
648 t.Run(tc.name, func(t *testing.T) {
649 if err := ValidateSerialTodos(tc.todos); err == nil || !strings.Contains(err.Error(), tc.want) {
650 t.Fatalf("ValidateSerialTodos() error = %v, want %q", err, tc.want)
651 }
652 })
653 }
654
655 valid := []TodoItem{
656 {Content: "done", Status: "completed"},
657 {Content: "current", Status: "in_progress"},
658 {Content: "later", Status: "pending"},
659 }
660 if err := ValidateSerialTodos(valid); err != nil {
661 t.Fatalf("valid serial list rejected: %v", err)
662 }
663 }
664
665 func TestNormalizeSerialTodosRepairsLegacyOutOfOrderState(t *testing.T) {
666 got := NormalizeSerialTodos([]TodoItem{
667 {Content: "first", Status: "in_progress"},
668 {Content: "second", Status: "completed"},
669 {Content: "third", Status: "in_progress"},
670 })
671 want := []string{"in_progress", "pending", "pending"}
672 for i := range want {
673 if got[i].Status != want[i] {
674 t.Fatalf("todo %d status = %q, want %q: %+v", i+1, got[i].Status, want[i], got)
675 }
676 }
677 }
678
679 func TestValidateSerialTodosAcceptsPhaseChains(t *testing.T) {
680 tests := []struct {
681 name string
682 todos []TodoItem
683 }{
684 {
685 name: "entered phase with an active sub-step",
686 todos: []TodoItem{
687 {Content: "Phase", Status: "pending"},
688 {Content: "sub one", Status: "in_progress", Level: 1},
689 },
690 },
691 {
692 name: "single current item mid-chain",
693 todos: []TodoItem{
694 {Content: "Phase", Status: "pending"},
695 {Content: "sub one", Status: "completed", Level: 1},
696 {Content: "sub two", Status: "in_progress", Level: 1},
697 {Content: "sub three", Status: "pending", Level: 1},
698 {Content: "Later phase", Status: "pending"},
699 {Content: "later sub", Status: "pending", Level: 1},
700 },
701 },
702 {
703 name: "phase awaiting sign-off after its sub-steps",
704 todos: []TodoItem{
705 {Content: "Phase", Status: "in_progress"},
706 {Content: "sub one", Status: "completed", Level: 1},
707 {Content: "sub two", Status: "completed", Level: 1},
708 {Content: "next", Status: "pending"},
709 },
710 },
711 {
712 name: "completed phase segment before the current one",
713 todos: []TodoItem{
714 {Content: "Phase", Status: "completed"},
715 {Content: "sub one", Status: "completed", Level: 1},
716 {Content: "Second phase", Status: "pending"},
717 {Content: "sub two", Status: "in_progress", Level: 1},
718 },
719 },
720 }
721 for _, tc := range tests {
722 t.Run(tc.name, func(t *testing.T) {
723 if err := ValidateSerialTodos(tc.todos); err != nil {
724 t.Fatalf("valid phase chain rejected: %v", err)
725 }
726 })
727 }
728 }
729
730 func TestValidateSerialTodosRejectsInvalidPhaseChains(t *testing.T) {
731 tests := []struct {
732 name string
733 todos []TodoItem
734 want string
735 }{
736 {
737 name: "phase completed before its sub-steps",
738 todos: []TodoItem{
739 {Content: "Phase", Status: "completed"},
740 {Content: "sub one", Status: "in_progress", Level: 1},
741 },
742 want: "sub-step 2 \"sub one\" is unfinished",
743 },
744 {
745 name: "phase in_progress while sub-steps are unfinished",
746 todos: []TodoItem{
747 {Content: "Phase", Status: "in_progress"},
748 {Content: "sub one", Status: "pending", Level: 1},
749 },
750 want: "cannot be in_progress while sub-step 2",
751 },
752 {
753 name: "phase and sub-step both in_progress",
754 todos: []TodoItem{
755 {Content: "Phase", Status: "in_progress"},
756 {Content: "sub one", Status: "in_progress", Level: 1},
757 },
758 want: "second in_progress item",
759 },
760 {
761 name: "orphan sub-step with no phase above it",
762 todos: []TodoItem{
763 {Content: "sub one", Status: "in_progress", Level: 1},
764 {Content: "Next", Status: "pending"},
765 },
766 want: "no phase above it",
767 },
768 {
769 name: "completed segment after the current chain",
770 todos: []TodoItem{
771 {Content: "Phase", Status: "pending"},
772 {Content: "sub one", Status: "in_progress", Level: 1},
773 {Content: "Second phase", Status: "completed"},
774 {Content: "sub two", Status: "completed", Level: 1},
775 },
776 want: "completed after unfinished",
777 },
778 {
779 name: "stale sub-step progress before the current item",
780 todos: []TodoItem{
781 {Content: "Phase", Status: "pending"},
782 {Content: "sub one", Status: "completed", Level: 1},
783 {Content: "sub two", Status: "pending", Level: 1},
784 {Content: "Second phase", Status: "pending"},
785 {Content: "sub three", Status: "in_progress", Level: 1},
786 },
787 want: "in_progress after pending work",
788 },
789 }
790 for _, tc := range tests {
791 t.Run(tc.name, func(t *testing.T) {
792 if err := ValidateSerialTodos(tc.todos); err == nil || !strings.Contains(err.Error(), tc.want) {
793 t.Fatalf("ValidateSerialTodos() error = %v, want %q", err, tc.want)
794 }
795 })
796 }
797 }
798
799 func TestNormalizeSerialTodosRepairsPhaseChains(t *testing.T) {
800 got := NormalizeSerialTodos([]TodoItem{
801 {Content: "Phase", Status: "completed"},
802 {Content: "sub one", Status: "completed", Level: 1},
803 {Content: "sub two", Status: "pending", Level: 1},
804 {Content: "Second phase", Status: "completed"},
805 {Content: "sub three", Status: "completed", Level: 1},
806 })
807 want := []string{"pending", "completed", "in_progress", "pending", "pending"}
808 for i := range want {
809 if got[i].Status != want[i] {
810 t.Fatalf("todo %d status = %q, want %q: %+v", i+1, got[i].Status, want[i], got)
811 }
812 }
813
814 signable := NormalizeSerialTodos([]TodoItem{
815 {Content: "Phase", Status: "pending"},
816 {Content: "sub one", Status: "completed", Level: 1},
817 {Content: "sub two", Status: "completed", Level: 1},
818 })
819 if signable[0].Status != "in_progress" {
820 t.Fatalf("phase with completed sub-steps should normalize to in_progress for sign-off: %+v", signable)
821 }
822 }
823
824 func TestAdvanceSerialTodoWalksPhaseChain(t *testing.T) {
825 todos := []TodoItem{
826 {Content: "Phase", Status: "pending"},
827 {Content: "sub one", Status: "in_progress", Level: 1},
828 {Content: "sub two", Status: "pending", Level: 1},
829 {Content: "Second phase", Status: "pending"},
830 {Content: "sub three", Status: "pending", Level: 1},
831 }
832 statuses := func() []string {
833 out := make([]string, len(todos))
834 for i, todo := range todos {
835 out[i] = todo.Status
836 }
837 return out
838 }
839
840 if AdvanceSerialTodo(todos, 0) {
841 t.Fatalf("pending phase completed ahead of its sub-steps: %v", statuses())
842 }
843 if !AdvanceSerialTodo(todos, 1) {
844 t.Fatal("current sub-step did not complete")
845 }
846 if got, want := statuses(), []string{"pending", "completed", "in_progress", "pending", "pending"}; !reflect.DeepEqual(got, want) {
847 t.Fatalf("after first sub-step statuses = %v, want %v", got, want)
848 }
849 if !AdvanceSerialTodo(todos, 2) {
850 t.Fatal("last sub-step did not complete")
851 }
852 if got := todos[0].Status; got != "in_progress" {
853 t.Fatalf("phase status after its sub-steps = %q, want in_progress for sign-off", got)
854 }
855 if !AdvanceSerialTodo(todos, 0) {
856 t.Fatal("phase with completed sub-steps did not complete")
857 }
858 if got, want := statuses(), []string{"completed", "completed", "completed", "pending", "in_progress"}; !reflect.DeepEqual(got, want) {
859 t.Fatalf("after phase sign-off statuses = %v, want next sub-step promoted under its pending phase: %v", got, want)
860 }
861 if !AdvanceSerialTodo(todos, 4) {
862 t.Fatal("second chain sub-step did not complete")
863 }
864 if got := todos[3].Status; got != "in_progress" {
865 t.Fatalf("second phase after its sub-step = %q, want in_progress", got)
866 }
867 if !AdvanceSerialTodo(todos, 3) {
868 t.Fatal("second phase did not sign off")
869 }
870 for i, todo := range todos {
871 if todo.Status != "completed" {
872 t.Fatalf("todo %d = %+v, want completed", i+1, todo)
873 }
874 }
875 }
876
877 func TestAdvanceSerialTodoAdvancesOrphanSubStep(t *testing.T) {
878 todos := []TodoItem{
879 {Content: "orphan sub", Status: "in_progress", Level: 1},
880 {Content: "Next step", Status: "pending"},
881 }
882 if !AdvanceSerialTodo(todos, 0) {
883 t.Fatal("orphan sub-step did not complete")
884 }
885 if todos[0].Status != "completed" || todos[1].Status != "in_progress" {
886 t.Fatalf("orphan completion must promote the next pending unit: %+v", todos)
887 }
888
889 chained := []TodoItem{
890 {Content: "orphan sub", Status: "in_progress", Level: 1},
891 {Content: "Phase", Status: "pending"},
892 {Content: "sub one", Status: "pending", Level: 1},
893 }
894 if !AdvanceSerialTodo(chained, 0) {
895 t.Fatal("orphan sub-step before a phase did not complete")
896 }
897 if chained[1].Status != "pending" || chained[2].Status != "in_progress" {
898 t.Fatalf("orphan completion before a phase must promote the phase's first sub-step: %+v", chained)
899 }
900 }
901
902 func TestSuccessfulProgressSignaturesIgnoreExactRepeatsAndBookkeeping(t *testing.T) {
903 ledger := NewLedger()
904 read := ReceiptFromToolCall("read_file", json.RawMessage(`{"path":"a.go"}`), true, true)
905 read.OutputBytes = 10
906 ledger.Record(read)
907 ledger.Record(read)
908 ledger.Record(ReceiptFromToolCall("todo_write", json.RawMessage(`{"todos":[]}`), true, true))
909 ledger.Record(ReceiptFromToolCall("edit_file", json.RawMessage(`{"path":"a.go","old_string":"a","new_string":"b"}`), true, false))
910 ledger.Record(ReceiptFromToolCall("bash", json.RawMessage(`{"command":"go test ./..."}`), true, false))
911
912 sigs := ledger.SuccessfulProgressSignaturesSince(0)
913 if len(sigs) != 4 {
914 t.Fatalf("progress signatures = %d, want two reads plus mutation and command", len(sigs))
915 }
916 if sigs[0] != sigs[1] {
917 t.Fatalf("exact repeated reads should have the same signature: %q != %q", sigs[0], sigs[1])
918 }
919 if sigs[1] == sigs[2] || sigs[2] == sigs[3] {
920 t.Fatalf("distinct host work collapsed to one signature: %v", sigs)
921 }
922 }
923
924 func TestLedgerNumericCompleteStepAuthorizesRephrasedTodo(t *testing.T) {
925 ledger := NewLedger()
926 ledger.Record(Receipt{
927 ToolName: "todo_write",
928 Success: true,
929 Todos: []TodoItem{
930 {Content: "Add parser", Status: "in_progress"},
931 {Content: "Write tests", Status: "pending"},
932 },
933 })
934 ledger.Record(Receipt{ToolName: "complete_step", Success: true, Step: "1"})
935
936 // The model rephrased item 1 (added detail) but it's the same task.
937 missing, hasBaseline := ledger.UnverifiedCompletedTodos([]TodoItem{
938 {Content: "Add parser with streaming support", Status: "completed"},
939 {Content: "Write tests", Status: "pending"},
940 })
941 if !hasBaseline {
942 t.Fatal("expected prior todo_write baseline")
943 }
944 if len(missing) != 0 {
945 t.Fatalf("rephrased todo at same index should be authorized by content overlap, missing = %+v", missing)
946 }
947
948 // The model also rephrased item 2; still ok because the new text contains the old.
949 missing, hasBaseline = ledger.UnverifiedCompletedTodos([]TodoItem{
950 {Content: "Add parser with streaming support", Status: "completed"},
951 {Content: "Write tests and benchmarks", Status: "completed"},
952 })
953 if !hasBaseline {
954 t.Fatal("expected prior todo_write baseline for second rephrase")
955 }
956 // Item 1 is already authorized; item 2 is also rephrased but lacks a
957 // complete_step — so it should still be flagged.
958 if len(missing) != 1 || missing[0].Content != "Write tests and benchmarks" {
959 t.Fatalf("rephrased todo without complete_step should still be missing, got %+v", missing)
960 }
961 }
962
963 func TestToolCallMutatesForDeliveryProfile(t *testing.T) {
964 tests := []struct {
965 name string
966 toolName string
967 args string
968 readOnly bool
969 want bool
970 }{
971 {name: "trusted reader", toolName: "read_file", args: `{"path":"a.go"}`, readOnly: true},
972 {name: "file writer", toolName: "edit_file", args: `{"path":"a.go"}`, want: true},
973 {name: "delegated task meta", toolName: "task", args: `{"prompt":"fix it"}`},
974 {name: "run_skill meta", toolName: "run_skill", args: `{"name":"review"}`},
975 {name: "review meta", toolName: "review", args: `{"task":"review changes"}`},
976 {name: "security_review meta", toolName: "security_review", args: `{"task":"security"}`},
977 {name: "use_capability meta", toolName: "use_capability", args: `{"action":"inspect","capability_id":"mcp-server:github"}`},
978 {name: "test command", toolName: "bash", args: `{"command":"go test ./..."}`},
979 {name: "npx vitest pipeline", toolName: "bash", args: `{"command":"npx vitest run src/lib/foo.test.ts 2>&1 | tail -40"}`},
980 {name: "node syntax check", toolName: "bash", args: `{"command":"node --check app.js"}`},
981 {name: "node syntax check pipeline", toolName: "bash", args: `{"command":"tail -n +2 app.html | head -n 20 | node --check"}`},
982 {name: "node eval stays opaque", toolName: "bash", args: `{"command":"node -e 'console.log(1)'"}`, want: true},
983 {name: "node conditions flag stays opaque", toolName: "bash", args: `{"command":"node -C production server.js"}`, want: true},
984 {name: "node test runner", toolName: "bash", args: `{"command":"node --test"}`},
985 {name: "node test snapshot update stays opaque", toolName: "bash", args: `{"command":"node --test --test-update-snapshots"}`, want: true},
986 {name: "node test reporter file stays opaque", toolName: "bash", args: `{"command":"node --test --test-reporter=junit --test-reporter-destination=result.txt"}`, want: true},
987 {name: "node test rerun state stays opaque", toolName: "bash", args: `{"command":"node --test --test-rerun-failures=state.json"}`, want: true},
988 {name: "node test cpu profile stays opaque", toolName: "bash", args: `{"command":"node --test --cpu-prof"}`, want: true},
989 {name: "diff review", toolName: "bash", args: `{"command":"git diff --check"}`},
990 {name: "PowerShell network probe does not write workspace", toolName: "bash", args: `{"command":"Test-NetConnection -ComputerName example.com -Port 443"}`},
991 {name: "resolved read-only substitution", toolName: "bash", args: `{"command":"basename \"$(pwd)\""}`},
992 {name: "writer in substitution", toolName: "bash", args: `{"command":"basename \"$(touch out)\""}`, want: true},
993 {name: "formatter write", toolName: "bash", args: `{"command":"gofmt -w internal/a.go"}`, want: true},
994 {name: "file redirect", toolName: "bash", args: `{"command":"printf x > generated.txt"}`, want: true},
995 {name: "compound verification", toolName: "bash", args: `{"command":"go test ./... 2>&1 | tail -20"}`},
996 {name: "pytest snapshot update stays opaque", toolName: "bash", args: `{"command":"pytest --snapshot-update"}`, want: true},
997 {name: "pytest junitxml report stays opaque", toolName: "bash", args: `{"command":"pytest --junitxml=report.xml"}`, want: true},
998 {name: "gotestsum junitfile stays opaque", toolName: "bash", args: `{"command":"gotestsum --junitfile out.xml ./..."}`, want: true},
999 {name: "go test coverprofile stays opaque", toolName: "bash", args: `{"command":"go test -coverprofile=cover.out ./..."}`, want: true},
1000 {name: "go test blockprofile stays opaque", toolName: "bash", args: `{"command":"go test -blockprofile=block.out ./..."}`, want: true},
1001 {name: "go test trace stays opaque", toolName: "bash", args: `{"command":"go test -trace trace.out ./..."}`, want: true},
1002 {name: "go test compile binary stays opaque", toolName: "bash", args: `{"command":"go test -c ./internal/evidence"}`, want: true},
1003 {name: "go test dotted cpuprofile stays opaque", toolName: "bash", args: `{"command":"go test ./internal/evidence -test.cpuprofile=cpu.out -count=1"}`, want: true},
1004 {name: "go test artifacts stays opaque", toolName: "bash", args: `{"command":"go test -artifacts ./..."}`, want: true},
1005 {name: "jest output file stays opaque", toolName: "bash", args: `{"command":"npm test -- --json --outputFile=result.json"}`, want: true},
1006 {name: "mypy report stays opaque", toolName: "bash", args: `{"command":"mypy --txt-report reports src/"}`, want: true},
1007 {name: "plain pytest", toolName: "bash", args: `{"command":"pytest"}`},
1008 }
1009 for _, tt := range tests {
1010 t.Run(tt.name, func(t *testing.T) {
1011 if got := ToolCallMutates(tt.toolName, json.RawMessage(tt.args), tt.readOnly); got != tt.want {
1012 t.Fatalf("ToolCallMutates(%q, %s) = %v, want %v", tt.toolName, tt.args, got, tt.want)
1013 }
1014 })
1015 }
1016 }
1017
1018 func TestRunnerWriteOutputFlagsCannotMasqueradeAsVerification(t *testing.T) {
1019 // Snapshot flags rewrite checked-in fixtures and report/profile flags
1020 // write explicit output paths; both must stay opaque mutations so the
1021 // files they produce still require review and sign-off.
1022 for _, command := range []string{
1023 "pytest --snapshot-update",
1024 "pytest --junitxml=report.xml",
1025 "mypy --junit-xml report.xml src/",
1026 "gotestsum --junitfile out.xml ./...",
1027 "go test -coverprofile=cover.out ./...",
1028 "go test --coverprofile cover.out ./...",
1029 "go test -blockprofile=block.out ./...",
1030 "go test -mutexprofile mutex.out ./...",
1031 "go test -trace trace.out ./...",
1032 "go test -c ./internal/evidence",
1033 "go test -o evidence.test -c ./internal/evidence",
1034 "go test ./internal/evidence -test.cpuprofile=cpu.out -count=1",
1035 "go test -test.trace trace.out ./...",
1036 "go test -artifacts ./...",
1037 "go test ./... -args -test.testlogfile=log.txt",
1038 "go test -test.gocoverdir=covdir ./...",
1039 "gotestsum -- -test.coverprofile=cover.out ./...",
1040 "npm test -- --updateSnapshot",
1041 "npx vitest run --update",
1042 "npx vitest run --update=true",
1043 "npx vitest run -u=true",
1044 "npx jest --updateSnapshot=true",
1045 "npx vitest run --coverage",
1046 "npx vitest run --outputFile=result.json",
1047 "npx --yes vitest run",
1048 "npm test -- --json --outputFile=result.json",
1049 "yarn test --outputFile.json=result.json",
1050 "pytest --report-log=log.jsonl",
1051 "mypy --txt-report reports src/",
1052 "mypy --html-report html src/",
1053 "mypy --xml-report=reports src/",
1054 "mypy --cobertura-xml-report reports src/",
1055 } {
1056 if bashCommandIsVerification(command) {
1057 t.Fatalf("%q writes files and must not be classified as verification", command)
1058 }
1059 if !ToolCallMutates("bash", json.RawMessage(`{"command":"`+command+`"}`), false) {
1060 t.Fatalf("%q must remain an opaque mutation", command)
1061 }
1062 }
1063 for _, command := range []string{
1064 "pytest",
1065 "gotestsum ./...",
1066 "go test -cover ./...",
1067 "go test -count=1 ./...",
1068 "go test -test.v -test.run TestFoo ./...",
1069 "pytest --trace",
1070 "npm test -- --json",
1071 "npx vitest run src/lib/foo.test.ts 2>&1 | tail -40",
1072 "npx jest src/lib/foo.test.ts",
1073 "mypy src/",
1074 "mypy --strict src/",
1075 } {
1076 if !bashCommandIsVerification(command) {
1077 t.Fatalf("%q should remain a verification command", command)
1078 }
1079 }
1080 }
1081
1082 func TestToolCallRequiresDeliveryCriteriaForExecutionCommands(t *testing.T) {
1083 if !ToolCallRequiresDeliveryCriteria("bash", json.RawMessage(`{"command":"go test ./..."}`), false) {
1084 t.Fatal("verification command should require delivery acceptance criteria")
1085 }
1086 if !ToolCallRequiresDeliveryCriteria("bash", json.RawMessage(`{"command":"npm run test"}`), false) {
1087 t.Fatal("npm run test should require delivery acceptance criteria")
1088 }
1089 if !ToolCallRequiresDeliveryCriteria("bash", json.RawMessage(`{"command":"git diff --check"}`), false) {
1090 t.Fatal("git diff --check is a verification command and should require acceptance criteria")
1091 }
1092 if !ToolCallRequiresDeliveryCriteria("bash", json.RawMessage(`{"command":"node --check app.js"}`), false) {
1093 t.Fatal("node --check is a verification command and should require acceptance criteria")
1094 }
1095 }
1096
1097 func TestBashToolCallMixesMutationAndVerification(t *testing.T) {
1098 tests := []struct {
1099 name string
1100 command string
1101 want bool
1102 }{
1103 {
1104 name: "temporary JavaScript extraction",
1105 command: `python3 -c 'open("/tmp/snake_check.js","w").write("x")' && node --check /tmp/snake_check.js`,
1106 want: true,
1107 },
1108 {name: "generated code before tests", command: "go generate ./... && go test ./...", want: true},
1109 {name: "read-only extraction pipeline", command: "tail -n +2 snake.js | head -n 20 | node --check -"},
1110 {name: "plain verifier", command: "go test ./..."},
1111 {name: "plain mutation", command: "gofmt -w main.go"},
1112 }
1113 for _, tt := range tests {
1114 t.Run(tt.name, func(t *testing.T) {
1115 args, err := json.Marshal(map[string]string{"command": tt.command})
1116 if err != nil {
1117 t.Fatal(err)
1118 }
1119 if got := BashToolCallMixesMutationAndVerification(args); got != tt.want {
1120 t.Fatalf("BashToolCallMixesMutationAndVerification(%q) = %v, want %v", tt.command, got, tt.want)
1121 }
1122 })
1123 }
1124 }
1125
1126 func TestBashToolCallMasksVerificationExit(t *testing.T) {
1127 tests := []struct {
1128 command string
1129 want bool
1130 }{
1131 {command: `tail -n +2 snake.html | head -n 20 | node --check -; echo "EXIT: $?"`, want: true},
1132 {command: `go test ./...; printf 'status=%s\n' "$?"`, want: true},
1133 {command: `tail -n +2 snake.html | head -n 20 | node --check -`},
1134 {command: `go test ./...`},
1135 {command: `echo "$?"`},
1136 {command: `echo done; go test ./...`},
1137 }
1138 for _, tt := range tests {
1139 args, err := json.Marshal(map[string]string{"command": tt.command})
1140 if err != nil {
1141 t.Fatal(err)
1142 }
1143 if got := BashToolCallMasksVerificationExit(args); got != tt.want {
1144 t.Errorf("BashToolCallMasksVerificationExit(%q) = %v, want %v", tt.command, got, tt.want)
1145 }
1146 }
1147 }
1148
1149 func TestBashToolCallUsesOpaqueInlineInterpreter(t *testing.T) {
1150 tests := []struct {
1151 command string
1152 want bool
1153 }{
1154 {command: `node -e 'require("fs").readFileSync("snake.html")'`, want: true},
1155 {command: `node --input-type=module --eval 'console.log(1)'`, want: true},
1156 {command: `python3 -c 'print(open("snake.html").read())'`, want: true},
1157 {command: `ruby -e 'puts 1'`, want: true},
1158 {command: `php -r 'echo 1;'`, want: true},
1159 {command: `deno eval 'console.log(1)'`, want: true},
1160 {command: "tail -n +2 snake.html | node --check -"},
1161 {command: "node --test"},
1162 {command: "python3 -m pytest"},
1163 {command: "node scripts/check.js"},
1164 }
1165 for _, tt := range tests {
1166 args, err := json.Marshal(map[string]string{"command": tt.command})
1167 if err != nil {
1168 t.Fatal(err)
1169 }
1170 if got := BashToolCallUsesOpaqueInlineInterpreter(args); got != tt.want {
1171 t.Errorf("BashToolCallUsesOpaqueInlineInterpreter(%q) = %v, want %v", tt.command, got, tt.want)
1172 }
1173 }
1174 }
1175
1176 func TestLedgerDeliverySignoffAcceptsNodeSyntaxCheckAfterMutation(t *testing.T) {
1177 ledger := NewLedger()
1178 ledger.Record(ReceiptFromToolCall("edit_file", json.RawMessage(`{"path":"app.js"}`), true, false))
1179 mutation, ok := ledger.LatestSuccessfulMutationIndex()
1180 if !ok {
1181 t.Fatal("expected mutation receipt")
1182 }
1183 ledger.Record(ReceiptFromToolCall("read_file", json.RawMessage(`{"path":"app.js"}`), true, true))
1184 command := "node --check app.js"
1185 ledger.Record(ReceiptFromToolCall("bash", json.RawMessage(`{"command":"node --check app.js"}`), true, false))
1186 ledger.Record(ReceiptFromToolCall("complete_step", json.RawMessage(`{
1187 "step":"Check JavaScript",
1188 "result":"syntax valid",
1189 "evidence":[{"kind":"verification","summary":"syntax valid","command":"node --check app.js"}]
1190 }`), true, true))
1191
1192 if !IsDeliveryVerificationCommand(command) {
1193 t.Fatal("node --check should be recognized as a delivery verification")
1194 }
1195 if latest, ok := ledger.LatestSuccessfulMutationIndex(); !ok || latest != mutation {
1196 t.Fatalf("node --check moved latest mutation from %d to %d (ok=%v)", mutation, latest, ok)
1197 }
1198 if !ledger.HasSuccessfulReviewAfter(mutation) {
1199 t.Fatal("expected post-mutation read to satisfy review")
1200 }
1201 if !ledger.HasSuccessfulDeliverySignoffAfter(mutation) {
1202 t.Fatal("expected node --check to satisfy delivery sign-off")
1203 }
1204 }
1205
1206 func TestNodeEvalCannotMasqueradeAsDeliveryVerification(t *testing.T) {
1207 command := `node -e 'require("fs").readFileSync("app.js")'`
1208 if IsDeliveryVerificationCommand(command) {
1209 t.Fatal("arbitrary node eval must not be recognized as delivery verification")
1210 }
1211 if !ToolCallMutates("bash", json.RawMessage(`{"command":"node -e 'require(\"fs\").readFileSync(\"app.js\")'"}`), false) {
1212 t.Fatal("arbitrary node eval must remain an opaque mutation")
1213 }
1214 }
1215
1216 func TestNodeConditionsFlagCannotMasqueradeAsDeliveryVerification(t *testing.T) {
1217 // Node CLI flags are case-sensitive: -C is --conditions and executes the
1218 // target script, unlike the syntax-only -c/--check.
1219 command := "node -C production server.js"
1220 if IsDeliveryVerificationCommand(command) {
1221 t.Fatal("node -C (--conditions) executes the script and must not be recognized as delivery verification")
1222 }
1223 if !ToolCallMutates("bash", json.RawMessage(`{"command":"node -C production server.js"}`), false) {
1224 t.Fatal("node -C (--conditions) must remain an opaque mutation")
1225 }
1226 }
1227
1228 func TestNodeTestRunnerWriteFlagsCannotMasqueradeAsDeliveryVerification(t *testing.T) {
1229 if !IsDeliveryVerificationCommand("node --test") {
1230 t.Fatal("plain node --test should be recognized as a delivery verification")
1231 }
1232 // Test-runner state/report flags and Node runtime profiling/tracing flags
1233 // create or update files. They must stay opaque mutations so those files
1234 // still require review and sign-off.
1235 for _, command := range []string{
1236 "node --test --test-update-snapshots",
1237 "node --test --test-reporter=junit --test-reporter-destination=result.txt",
1238 "node --test --test-reporter junit --test-reporter-destination result.txt",
1239 "node --test --test-rerun-failures=state.json",
1240 "node --test --test-rerun-failures state.json",
1241 "node --test --cpu-prof",
1242 "node --test --heap-prof",
1243 "node --test --heapsnapshot-near-heap-limit=1",
1244 "node --test --heapsnapshot-signal=SIGUSR2",
1245 "node --test --localstorage-file=localstorage.json",
1246 "node --test --perf-basic-prof",
1247 "node --test --perf-basic-prof-only-functions",
1248 "node --test --perf-prof",
1249 "node --test --prof",
1250 "node --test --redirect-warnings=warnings.log",
1251 "node --test --report-on-fatalerror",
1252 "node --test --report-on-signal",
1253 "node --test --report-uncaught-exception",
1254 "node --test --tls-keylog=tls.log",
1255 "node --test --trace-events-enabled",
1256 } {
1257 if IsDeliveryVerificationCommand(command) {
1258 t.Fatalf("%q writes files and must not be recognized as delivery verification", command)
1259 }
1260 }
1261 }
1262
1263 func TestLedgerReviewAfterRestoredCheckpointBaseline(t *testing.T) {
1264 // A negative index is the restored-checkpoint baseline: the mutation
1265 // happened before a controller rebuild or cold resume, so its receipt (and
1266 // touched paths) are not in this ledger. Fresh review-shaped receipts must
1267 // still be able to satisfy the review gate.
1268 if NewLedger().HasSuccessfulReviewAfter(-1) {
1269 t.Fatal("an empty ledger must not satisfy the checkpoint-baseline review")
1270 }
1271
1272 read := NewLedger()
1273 read.Record(ReceiptFromToolCall("read_file", json.RawMessage(`{"path":"internal/parser.go"}`), true, true))
1274 if !read.HasSuccessfulReviewAfter(-1) {
1275 t.Fatal("a successful read must satisfy review for a restored mutation baseline")
1276 }
1277
1278 diff := NewLedger()
1279 diff.Record(ReceiptFromToolCall("bash", json.RawMessage(`{"command":"git diff"}`), true, false))
1280 if !diff.HasSuccessfulReviewAfter(-1) {
1281 t.Fatal("a git diff inspection must satisfy review for a restored mutation baseline")
1282 }
1283
1284 failed := NewLedger()
1285 failed.Record(ReceiptFromToolCall("read_file", json.RawMessage(`{"path":"internal/parser.go"}`), false, true))
1286 if failed.HasSuccessfulReviewAfter(-1) {
1287 t.Fatal("a failed read must not satisfy the checkpoint-baseline review")
1288 }
1289
1290 opaque := NewLedger()
1291 opaque.Record(ReceiptFromToolCall("bash", json.RawMessage(`{"command":"echo done"}`), true, false))
1292 if opaque.HasSuccessfulReviewAfter(-1) {
1293 t.Fatal("a non-review command must not satisfy the checkpoint-baseline review")
1294 }
1295 }
1296
1297 func TestLedgerDeliverySignoffRequiresPostMutationVerificationAndReview(t *testing.T) {
1298 ledger := NewLedger()
1299 ledger.Record(ReceiptFromToolCall("todo_write", json.RawMessage(`{"todos":[{"content":"Ship parser","status":"in_progress"}]}`), true, true))
1300 ledger.Record(ReceiptFromToolCall("edit_file", json.RawMessage(`{"path":"internal/parser.go"}`), true, false))
1301 mutation, ok := ledger.LatestSuccessfulMutationIndex()
1302 if !ok {
1303 t.Fatal("expected mutation receipt")
1304 }
1305 ledger.Record(ReceiptFromToolCall("read_file", json.RawMessage(`{"path":"internal/parser.go"}`), true, true))
1306 ledger.Record(ReceiptFromToolCall("bash", json.RawMessage(`{"command":"go test ./internal/..."}`), true, false))
1307 ledger.Record(ReceiptFromToolCall("complete_step", json.RawMessage(`{
1308 "step":"Ship parser",
1309 "result":"parser shipped",
1310 "evidence":[{"kind":"verification","summary":"tests passed","command":"go test ./internal/..."}]
1311 }`), true, true))
1312
1313 if !ledger.HasSuccessfulAcceptanceCriteria() {
1314 t.Fatal("expected non-empty todo_write to establish acceptance criteria")
1315 }
1316 if !ledger.HasSuccessfulReviewAfter(mutation) {
1317 t.Fatal("expected post-mutation read to satisfy review")
1318 }
1319 if !ledger.HasSuccessfulDeliverySignoffAfter(mutation) {
1320 t.Fatal("expected post-mutation verification cited by complete_step")
1321 }
1322 }
1323
1324 func TestLedgerDeliverySignoffRejectsPreMutationVerification(t *testing.T) {
1325 ledger := NewLedger()
1326 ledger.Record(ReceiptFromToolCall("bash", json.RawMessage(`{"command":"go test ./..."}`), true, false))
1327 ledger.Record(ReceiptFromToolCall("edit_file", json.RawMessage(`{"path":"main.go"}`), true, false))
1328 mutation, ok := ledger.LatestSuccessfulMutationIndex()
1329 if !ok {
1330 t.Fatal("expected mutation receipt")
1331 }
1332 ledger.Record(ReceiptFromToolCall("complete_step", json.RawMessage(`{
1333 "step":"change",
1334 "result":"changed",
1335 "evidence":[{"kind":"verification","summary":"tests passed before edit","command":"go test ./..."}]
1336 }`), true, true))
1337 if ledger.HasSuccessfulDeliverySignoffAfter(mutation) {
1338 t.Fatal("pre-mutation verification must not sign off changed work")
1339 }
1340 }
1341
1342 func TestLedgerDeliverySignoffRejectsInspectionCommandMasqueradingAsVerification(t *testing.T) {
1343 ledger := NewLedger()
1344 ledger.Record(ReceiptFromToolCall("edit_file", json.RawMessage(`{"path":"main.go"}`), true, false))
1345 mutation, ok := ledger.LatestSuccessfulMutationIndex()
1346 if !ok {
1347 t.Fatal("expected mutation receipt")
1348 }
1349 ledger.Record(ReceiptFromToolCall("bash", json.RawMessage(`{"command":"git status --short"}`), true, false))
1350 ledger.Record(ReceiptFromToolCall("complete_step", json.RawMessage(`{
1351 "step":"change",
1352 "result":"changed",
1353 "evidence":[{"kind":"verification","summary":"claimed verification","command":"git status --short"}]
1354 }`), true, true))
1355 if ledger.HasSuccessfulDeliverySignoffAfter(mutation) {
1356 t.Fatal("inspection-only git status must not count as delivery verification")
1357 }
1358 }
1359
1359 lines GO