Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/adr/53123-purelock-automated-pure-function-test-coverage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# ADR-53123: PureLock — Automated Pure-Function Test Coverage

**Date**: 2026-08-16
**Status**: Draft
**Deciders**: Unknown (automation-generated PR by PureLock)

---

### Context

Several pure Go functions in `pkg/cli/` and `pkg/workflow/` had statement coverage in the 46–61% range (`recommendAuditComparisonAction` at 46.7%, `gatewayEntryToTimelineEvent` at 48.5%, `prepareNestedMapValueForYAML` at 60.9%). These functions contain non-trivial branching logic — priority ordering, type switches, and fallback chains — that create regression risk. No existing workflow systematically targeted pure functions for comprehensive branch coverage; coverage improvements happened reactively during feature work, leaving gaps that were difficult to close without dedicated effort.

### Decision

We will use PureLock, an automated precompute pass that ranks pure Go functions by coverage deficit and cyclomatic complexity, and generates comprehensive testify table-driven test suites for the top-ranked candidates. PureLock targets only functions with no observable side effects (no I/O, no global mutation), enabling full branch coverage via pure input/output assertions. Each generated test file is reviewed and committed to the PR branch before merge.

### Alternatives Considered

#### Alternative 1: Manual test authoring by feature owners

Tests are written by the engineer implementing a feature, as part of normal PR workflow. This is the existing practice for most of the codebase. Why not chosen: coverage improvements for pre-existing low-coverage functions depend entirely on whether an engineer proactively adds tests outside their feature scope. In practice, low-coverage pure functions persist indefinitely because no one has an incentive to write tests for code they did not change.

#### Alternative 2: Integration or end-to-end tests

Cover the target functions indirectly by exercising the higher-level workflows that call them. Why not chosen: integration tests cannot guarantee 100% branch-level coverage of a specific function, take significantly longer to run, require network-dependent infrastructure (e.g., `httptest` servers that are blocked in the sandbox), and obscure which branches were actually exercised.

### Consequences

#### Positive
- Achieves 100% function-level statement coverage for each targeted pure function, closing coverage gaps that would otherwise persist indefinitely.
- Scales automatically: PureLock can identify and generate tests for many low-coverage functions without requiring human initiative or scope creep into feature work.
- Table-driven tests with explicit subtest names serve as living documentation of expected function behavior and branch semantics.

#### Negative
- Generated test files can drift from production code as function signatures or branch logic evolve; reviewers must ensure tests remain meaningful after refactors.
- Automation-generated PRs consume merge queue capacity and require reviewer bandwidth to inspect generated test cases for correctness, even when no production code changes.

#### Neutral
- PureLock PRs are generated by a bot (`github-actions`) rather than a human author; they carry `automation`, `testing`, and `coverage` labels for filtering.
- Full-package test runs may be blocked in the sandbox by network-dependent tests in unrelated files; targeted `-run` flags are required to validate the generated tests in CI.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
143 changes: 143 additions & 0 deletions pkg/cli/audit_comparison_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,146 @@ func TestScoreAuditComparisonCandidateFallsBackToLatestSuccess(t *testing.T) {
assert.Equal(t, "latest_success", candidate.Selection)
assert.Nil(t, candidate.MatchedOn)
}

func TestRecommendAuditComparisonAction(t *testing.T) {
t.Parallel()

newlyPresentMCPFailure := &AuditComparisonMCPFailureDelta{NewlyPresent: true}

tests := []struct {
name string
label string
currentConclusion string
delta *AuditComparisonDelta
expectedContains string
}{
{
name: "non-success failure conclusion",
label: "changed",
currentConclusion: "failure",
delta: &AuditComparisonDelta{},
expectedContains: "Investigate failure; run concluded with errors",
},
{
name: "non-success non-failure conclusion",
label: "changed",
currentConclusion: "action_required",
delta: &AuditComparisonDelta{},
expectedContains: "Investigate the action required conclusion",
},
{
name: "nil delta with success conclusion",
label: "changed",
currentConclusion: "success",
delta: nil,
expectedContains: "No action needed",
},
{
name: "stable label with success conclusion",
label: "stable",
currentConclusion: "success",
delta: &AuditComparisonDelta{},
expectedContains: "No action needed",
},
{
name: "empty conclusion treated as success-equivalent, stable label",
label: "stable",
currentConclusion: "",
delta: &AuditComparisonDelta{},
expectedContains: "No action needed",
},
{
name: "posture read_only to write_capable",
label: "risky",
currentConclusion: "success",
delta: &AuditComparisonDelta{
Posture: AuditComparisonStringDelta{Before: "read_only", After: "write_capable"},
},
expectedContains: "Review first-time write-capable behavior",
},
{
name: "newly present MCP failure",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The fallback default review message subtest (line ~221) verifies that decreased blocked requests and turns fall through to the default message. But there is no test case for all delta fields being zero-valued (AuditComparisonDelta{}) with a changed label and success conclusion — it is ambiguous whether that path also lands on the fallback or takes a different route.

💡 Suggested addition
{
    name:              "empty delta with changed label falls back to default",
    label:             "changed",
    currentConclusion: "success",
    delta:             &AuditComparisonDelta{},
    expectedContains:  "Review the behavior change against the selected successful baseline",
},

@copilot please address this.

label: "risky",
currentConclusion: "success",
delta: &AuditComparisonDelta{
MCPFailure: newlyPresentMCPFailure,
},
expectedContains: "Inspect the new MCP failure",
},
{
name: "MCP failure present but not newly present falls through",
label: "changed",
currentConclusion: "success",
delta: &AuditComparisonDelta{
MCPFailure: &AuditComparisonMCPFailureDelta{NewlyPresent: false},
},
expectedContains: "Review the behavior change",
},
{
name: "blocked requests increased",
label: "risky",
currentConclusion: "success",
delta: &AuditComparisonDelta{
BlockedRequests: AuditComparisonIntDelta{Before: 1, After: 3},
},
expectedContains: "Review network policy changes",
},
{
name: "turns increased",
label: "changed",
currentConclusion: "success",
delta: &AuditComparisonDelta{
Turns: AuditComparisonIntDelta{Before: 2, After: 5},
},
expectedContains: "Compare prompt or task-shape changes",
},
{
name: "fallback default review message",
label: "changed",
currentConclusion: "success",
delta: &AuditComparisonDelta{
Turns: AuditComparisonIntDelta{Before: 5, After: 3},
BlockedRequests: AuditComparisonIntDelta{Before: 3, After: 1},
},
expectedContains: "Review the behavior change against the selected successful baseline",
},
{
name: "priority order: posture change wins over MCP failure",
label: "risky",
currentConclusion: "success",
delta: &AuditComparisonDelta{
Posture: AuditComparisonStringDelta{Before: "read_only", After: "write_capable"},
MCPFailure: newlyPresentMCPFailure,
},
expectedContains: "Review first-time write-capable behavior",
},
{
name: "priority order: MCP failure wins over blocked requests",
label: "risky",
currentConclusion: "success",
delta: &AuditComparisonDelta{
MCPFailure: newlyPresentMCPFailure,
BlockedRequests: AuditComparisonIntDelta{Before: 1, After: 5},
},
expectedContains: "Inspect the new MCP failure",
},
{
name: "priority order: blocked requests wins over turns",
label: "risky",
currentConclusion: "success",
delta: &AuditComparisonDelta{
BlockedRequests: AuditComparisonIntDelta{Before: 1, After: 5},
Turns: AuditComparisonIntDelta{Before: 2, After: 8},
},
expectedContains: "Review network policy changes",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := recommendAuditComparisonAction(tt.label, tt.currentConclusion, tt.delta)
assert.Contains(t, result, tt.expectedContains)
})
}
}
188 changes: 188 additions & 0 deletions pkg/cli/gateway_logs_timeline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -990,3 +990,191 @@ func TestRenderUnifiedTimelineStream_SteeringEvent(t *testing.T) {
t.Errorf("output missing steering message; got:\n%s", out)
}
}

// ─── gatewayEntryToTimelineEvent ───────────────────────────────────────────────

func TestGatewayEntryToTimelineEvent(t *testing.T) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] TestGatewayEntryToTimelineEvent is missing t.Parallel() on the parent test and subtests — the other two new test functions both call it. Omitting it slows the test suite when running in parallel with other tests.

💡 Suggested fix

Add t.Parallel() immediately after t.Run(tt.name, func(t *testing.T) { for each subtest, and at the top of the parent function.

@copilot please address this.

baseTS := "2024-01-15T10:00:00Z"
baseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)

tests := []struct {
name string
entry GatewayLogEntry
wantOK bool
wantKind TimelineEventKind
wantServer string
wantTool string
wantStatus string
wantError string
wantReason string
wantAuthor string
}{
{
name: "unparseable timestamp returns false",
entry: GatewayLogEntry{Timestamp: "not-a-timestamp", Event: "tool_call"},
wantOK: false,
},
{
name: "empty timestamp returns false",
entry: GatewayLogEntry{Timestamp: "", Event: "tool_call"},
wantOK: false,
},
{
name: "DIFC_FILTERED uses ServerID when present",
entry: GatewayLogEntry{
Timestamp: baseTS,
Type: "DIFC_FILTERED",
ServerID: "srv-1",
ServerName: "srv-fallback",
ToolName: "tool-a",
Reason: "blocked secrecy",
AuthorLogin: "octocat",
},
wantOK: true,
wantKind: TimelineKindDIFCFiltered,
wantServer: "srv-1",
wantTool: "tool-a",
wantReason: "blocked secrecy",
wantAuthor: "octocat",
},
{
name: "DIFC_FILTERED falls back to ServerName when ServerID empty",
entry: GatewayLogEntry{
Timestamp: baseTS,
Type: "DIFC_FILTERED",
ServerName: "srv-fallback",
},
wantOK: true,
wantKind: TimelineKindDIFCFiltered,
wantServer: "srv-fallback",
},
{
name: "GUARD_POLICY_BLOCKED uses ServerID when present",
entry: GatewayLogEntry{
Timestamp: baseTS,
Type: "GUARD_POLICY_BLOCKED",
ServerID: "srv-2",
ToolName: "tool-b",
Reason: "policy violation",
Message: "guard rejected the call",
},
wantOK: true,
wantKind: TimelineKindGuardPolicyBlocked,
wantServer: "srv-2",
wantTool: "tool-b",
wantReason: "policy violation",
wantError: "guard rejected the call",
},
{
name: "GUARD_POLICY_BLOCKED falls back to ServerName when ServerID empty",
entry: GatewayLogEntry{
Timestamp: baseTS,
Type: "GUARD_POLICY_BLOCKED",
ServerName: "srv-fallback-guard",
},
wantOK: true,
wantKind: TimelineKindGuardPolicyBlocked,
wantServer: "srv-fallback-guard",
},
{
name: "tool_call event with explicit status is preserved",
entry: GatewayLogEntry{
Timestamp: baseTS,
Event: "tool_call",
ServerName: "srv-3",
ToolName: "tool-c",
Method: "call",
Duration: 12.5,
Status: "success",
},
wantOK: true,
wantKind: TimelineKindToolCall,
wantServer: "srv-3",
wantTool: "tool-c",
wantStatus: "success",
},
{
name: "rpc_call event with error sets status to error",
entry: GatewayLogEntry{
Timestamp: baseTS,
Event: "rpc_call",
ToolName: "tool-d",
Error: "boom",
},
wantOK: true,
wantKind: TimelineKindToolCall,
wantTool: "tool-d",
wantStatus: "error",
wantError: "boom",
},
{
name: "request event with error level sets status to error",
entry: GatewayLogEntry{
Timestamp: baseTS,
Event: "request",
Level: "error",
},
wantOK: true,
wantKind: TimelineKindToolCall,
wantStatus: "error",
},
{
name: "request event with no error defaults to success",
entry: GatewayLogEntry{
Timestamp: baseTS,
Event: "request",
},
wantOK: true,
wantKind: TimelineKindToolCall,
wantStatus: "success",
},
{
name: "unknown type and unknown event returns false",
entry: GatewayLogEntry{
Timestamp: baseTS,
Type: "SOMETHING_ELSE",
Event: "unknown_event",
},
wantOK: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
evt, ok := gatewayEntryToTimelineEvent(tt.entry)
if ok != tt.wantOK {
t.Fatalf("gatewayEntryToTimelineEvent() ok = %v, want %v", ok, tt.wantOK)
}
if !tt.wantOK {
return
}
Comment on lines +1148 to +1150
if !evt.Time.Equal(baseTime) {
t.Errorf("Time = %v, want %v", evt.Time, baseTime)
}
if evt.Source != TimelineSourceGateway {
t.Errorf("Source = %v, want %v", evt.Source, TimelineSourceGateway)
}
if evt.Kind != tt.wantKind {
t.Errorf("Kind = %v, want %v", evt.Kind, tt.wantKind)
}
if tt.wantServer != "" && evt.ServerName != tt.wantServer {
t.Errorf("ServerName = %q, want %q", evt.ServerName, tt.wantServer)
}
if tt.wantTool != "" && evt.ToolName != tt.wantTool {
t.Errorf("ToolName = %q, want %q", evt.ToolName, tt.wantTool)
}
if tt.wantStatus != "" && evt.Status != tt.wantStatus {
t.Errorf("Status = %q, want %q", evt.Status, tt.wantStatus)
}
if tt.wantError != "" && evt.Error != tt.wantError {
t.Errorf("Error = %q, want %q", evt.Error, tt.wantError)
}
if tt.wantReason != "" && evt.Reason != tt.wantReason {
t.Errorf("Reason = %q, want %q", evt.Reason, tt.wantReason)
}
if tt.wantAuthor != "" && evt.AuthorLogin != tt.wantAuthor {
t.Errorf("AuthorLogin = %q, want %q", evt.AuthorLogin, tt.wantAuthor)
}
})
}
}
Loading
Loading