diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 476d253..e211f03 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -284,14 +284,17 @@ func Run(ctx context.Context, options Options) (Summary, error) { } // Finalize each evaluator once the loop ends so liveness obligations that - // never discharged (an unbounded eventually that never fired, a strong - // next with no successor) are reported as violations rather than silently - // left pending. Properties already violated mid-run are not re-reported. + // never discharged (an eventually that never fired) are reported as + // violations rather than silently left pending. Properties already + // violated mid-run are not re-reported. The synthetic record gets its own + // step index so no two trace lines share one; witnesses still attribute + // the violation to the step that spawned the obligation. if ended := options.Verifier.Finalize(); len(ended) > 0 { - witnesses := collectWitnesses(options.Verifier, ended, logger, stepIndex) - summary.Violations = append(summary.Violations, violationRecords(ended, witnesses, stepIndex)...) + finalIndex := stepIndex + 1 + witnesses := collectWitnesses(options.Verifier, ended, logger, finalIndex) + summary.Violations = append(summary.Violations, violationRecords(ended, witnesses, finalIndex)...) finalStep := trace.Step{ - Index: stepIndex, + Index: finalIndex, Timestamp: time.Now(), Violations: ended, Witnesses: witnesses, diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 5fc3885..6d28b7f 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -336,6 +336,103 @@ globalThis.actions = actions(() => []); } } +func TestRunner_AlwaysNextLeavesNoEndOfRunViolation(t *testing.T) { + // always(next(p)) ends every run with a pending deferred check. That + // residue is vacuous (no successor state to check), so neither the + // summary nor the trace may report an end-of-run violation. + const spec = ` +import { actions, always, next } from "@sanderling/spec"; +globalThis.properties = { + nextHolds: always(next(() => true)), +}; +globalThis.actions = actions(() => []); +` + state := newHarnessWithSpec(t, spec) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + summary, err := Run(ctx, Options{ + Duration: time.Hour, + IdleTimeout: 20 * time.Millisecond, + MaxSteps: 3, + Driver: state.mock, + Verifier: state.verifier, + TraceWriter: state.writer, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if len(summary.Violations) != 0 { + t.Errorf("expected no violations, got %v", summary.Violations) + } +} + +func TestRunner_FinalizeRecordUsesDistinctStepIndex(t *testing.T) { + // An eventually that never fires is reported at run end through a + // synthetic trace record. That record must carry its own step index so no + // two trace lines share one (duplicate indices made the replay UI select + // two rows at once). + const spec = ` +import { actions, eventually } from "@sanderling/spec"; +globalThis.properties = { + neverFires: eventually(() => false), +}; +globalThis.actions = actions(() => []); +` + state := newHarnessWithSpec(t, spec) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + summary, err := Run(ctx, Options{ + Duration: time.Hour, + IdleTimeout: 20 * time.Millisecond, + MaxSteps: 3, + Driver: state.mock, + Verifier: state.verifier, + TraceWriter: state.writer, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if !containsProperty(summary.Violations, "neverFires") { + t.Fatalf("expected neverFires in violations: %v", summary.Violations) + } + + file, err := os.Open(filepath.Join(state.writer.Directory(), "trace.jsonl")) + if err != nil { + t.Fatal(err) + } + defer file.Close() + + type traceLine struct { + Step int `json:"step"` + Violations []string `json:"violations"` + } + seen := map[int]bool{} + finalizeStep := 0 + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) + for scanner.Scan() { + var line traceLine + if err := json.Unmarshal(scanner.Bytes(), &line); err != nil { + t.Fatalf("trace line decode: %v", err) + } + if seen[line.Step] { + t.Errorf("duplicate step index %d in trace", line.Step) + } + seen[line.Step] = true + if slices.Contains(line.Violations, "neverFires") { + finalizeStep = line.Step + } + } + if err := scanner.Err(); err != nil { + t.Fatalf("scan trace: %v", err) + } + if finalizeStep != summary.Steps+1 { + t.Errorf("finalize record step = %d, want %d (steps+1)", finalizeStep, summary.Steps+1) + } +} + func TestRunner_ThrowingPredicateIsLoggedNotPanic(t *testing.T) { const throwingSpec = ` import { actions, always, Tap } from "@sanderling/spec";