diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bba7e90..a442e61 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -40,7 +40,7 @@ jobs:
run: dotnet test src/GsdOrchestrator.Tests/GsdOrchestrator.Tests.csproj --configuration Release --no-build --filter "FullyQualifiedName~ContractCompatibilityTests"
- name: Test
- run: dotnet test src/GsdOrchestrator.Tests/GsdOrchestrator.Tests.csproj --configuration Release --logger trx --no-build --collect:"XPlat Code Coverage" --results-directory ./TestResults
+ run: dotnet test src/GsdOrchestrator.Tests/GsdOrchestrator.Tests.csproj --configuration Release --logger trx --no-build --settings coverlet.runsettings --collect:"XPlat Code Coverage" --results-directory ./TestResults
- name: Upload coverage
uses: actions/upload-artifact@v7
@@ -48,3 +48,32 @@ jobs:
with:
name: coverage-results
path: TestResults/
+
+ - name: Enforce branch coverage (ratchet)
+ shell: pwsh
+ run: |
+ # Ratcheted branch-coverage gate (Phase 26-01). Reads branch-rate (NOT
+ # line-rate) from the cobertura report produced by coverlet's "XPlat
+ # Code Coverage" collector and fails if it regresses below the last
+ # measured baseline. Baseline is raised only when new tests push
+ # coverage strictly higher (see 26-01-SUMMARY.md for history).
+ $Baseline = 0.7314
+
+ $coverageFile = Get-ChildItem -Path TestResults -Recurse -Filter "coverage.cobertura.xml" | Select-Object -First 1
+
+ if (-not $coverageFile) {
+ Write-Output '{"event":"ci_failure","error":"Coverage file not found"}'
+ exit 1
+ }
+
+ [xml]$xml = Get-Content $coverageFile.FullName
+ $rate = [double]$xml.coverage.'branch-rate'
+ $percent = [math]::Round($rate * 100, 2)
+ $requiredPercent = [math]::Round($Baseline * 100, 2)
+
+ if ($rate -lt $Baseline) {
+ Write-Output "{`"event`":`"ci_failure`",`"metric`":`"branch-rate`",`"coverage_percent`":$percent,`"required`":$requiredPercent}"
+ exit 1
+ }
+
+ Write-Output "{`"event`":`"ci_success`",`"metric`":`"branch-rate`",`"coverage_percent`":$percent,`"required`":$requiredPercent}"
diff --git a/.gitignore b/.gitignore
index 9ee38a0..3780374 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,9 @@ obj/
.vs/
*.suo
+# Coverage / test run output
+TestResults/
+
# NuGet
*.nupkg
packages/
diff --git a/coverlet.runsettings b/coverlet.runsettings
new file mode 100644
index 0000000..f7aee15
--- /dev/null
+++ b/coverlet.runsettings
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+ cobertura
+ true
+ Obsolete,GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute
+ **/obj/**,**/*.g.cs,**/Program.cs
+
+
+
+
+
diff --git a/src/GsdOrchestrator.Tests/CoverageGapClosingTests.cs b/src/GsdOrchestrator.Tests/CoverageGapClosingTests.cs
new file mode 100644
index 0000000..87159af
--- /dev/null
+++ b/src/GsdOrchestrator.Tests/CoverageGapClosingTests.cs
@@ -0,0 +1,275 @@
+using System.Text.Json.Nodes;
+using GsdOrchestrator.Loop;
+using GsdOrchestrator.Mcp;
+using GsdOrchestrator.Scheduling;
+using GsdOrchestrator.Verification;
+using GsdOrchestrator.Workflows.Models;
+using Microsoft.Extensions.Logging.Abstractions;
+using NSubstitute;
+using Polly.Registry;
+using Xunit;
+
+namespace GsdOrchestrator.Tests;
+
+///
+/// Phase 26-01: targeted tests for previously-uncovered branches identified from the
+/// Task-1 cobertura baseline (branch-rate 0.6913). Each test asserts observable
+/// behavior (return value, thrown exception type, or published side-effect) per the
+/// plan's threat model (T-26-01: no assertion-free coverage padding).
+///
+public class CoverageGapClosingTests
+{
+ // ---- SdlcWorkflowMap.PhaseIdForState: 0.1428 branch-rate (2/14) before this test ----
+ [Theory]
+ [InlineData(WorkflowState.Idle, "understand")]
+ [InlineData(WorkflowState.Triaging, "understand")]
+ [InlineData(WorkflowState.Analyzing, "research")]
+ [InlineData(WorkflowState.Branching, "analyze")]
+ [InlineData(WorkflowState.Editing, "implement")]
+ [InlineData(WorkflowState.TestGenerating, "verify")]
+ [InlineData(WorkflowState.Validating, "verify")]
+ [InlineData(WorkflowState.Committing, "document")]
+ [InlineData(WorkflowState.PrCreating, "review")]
+ [InlineData(WorkflowState.Reviewing, "review")]
+ [InlineData(WorkflowState.Documenting, "update-memory")]
+ [InlineData(WorkflowState.Done, "finished")]
+ [InlineData(WorkflowState.Failed, "finished")]
+ public void PhaseIdForState_MapsEveryWorkflowStateToExpectedSdlcPhase(WorkflowState state, string expectedPhaseId)
+ {
+ Assert.Equal(expectedPhaseId, SdlcWorkflowMap.PhaseIdForState(state));
+ }
+
+ [Fact]
+ public void PhaseIdForState_UnknownEnumValue_FallsBackToUnderstand()
+ {
+ // Cast an out-of-range int to exercise the switch expression's default arm.
+ var unknown = (WorkflowState)999;
+ Assert.Equal("understand", SdlcWorkflowMap.PhaseIdForState(unknown));
+ }
+
+ // ---- SdlcProfile.ResolveRollbackOrigin: only the "invalidated phase provided" branch was covered ----
+ [Fact]
+ public void ResolveRollbackOrigin_NoInvalidatedPhases_FallsBackToPhaseRollbackTo()
+ {
+ var profile = SdlcProfile.CasSdlcV1;
+
+ var origin = profile.ResolveRollbackOrigin("verify", []);
+
+ Assert.Equal("implement", origin);
+ }
+
+ [Fact]
+ public void ResolveRollbackOrigin_UnknownPhaseAndNoInvalidated_ReturnsPhaseIdItself()
+ {
+ var profile = SdlcProfile.CasSdlcV1;
+
+ var origin = profile.ResolveRollbackOrigin("no-such-phase", []);
+
+ Assert.Equal("no-such-phase", origin);
+ }
+
+ [Fact]
+ public void GetPhase_KnownPhaseId_ReturnsDefinition()
+ {
+ var profile = SdlcProfile.CasSdlcV1;
+
+ var phase = profile.GetPhase("implement");
+
+ Assert.Equal("Implement", phase.Name);
+ Assert.Equal(SdlcBatch.Change, phase.Batch);
+ }
+
+ [Fact]
+ public void TryGetPhase_UnknownPhaseId_ReturnsNull()
+ {
+ var profile = SdlcProfile.CasSdlcV1;
+
+ Assert.Null(profile.TryGetPhase("no-such-phase"));
+ }
+
+ // ---- GsdWorkflowContext.Transition: Failed-state branch was uncovered ----
+ [Fact]
+ public void Transition_ToFailed_RecordsFailedStateAndFailureReason()
+ {
+ var ctx = new GsdWorkflowContext { FailureReason = "boom" };
+
+ var next = ctx.Transition(WorkflowState.Failed, "verification failed");
+
+ Assert.Equal(WorkflowState.Failed, next.CurrentState);
+ Assert.Equal(WorkflowState.Idle, next.FailedState);
+ Assert.Equal("boom", next.FailureReason);
+ Assert.Single(next.History);
+ Assert.Equal(WorkflowState.Failed, next.History[0].To);
+ }
+
+ [Fact]
+ public void Transition_ToFailed_PreservesExistingFailedStateAcrossRepeatedFailures()
+ {
+ var ctx = new GsdWorkflowContext().Transition(WorkflowState.Failed, "first failure");
+
+ var again = ctx.Transition(WorkflowState.Failed, "second failure");
+
+ // FailedState should stick to the first-recorded failure origin (Idle), not be overwritten.
+ Assert.Equal(WorkflowState.Idle, again.FailedState);
+ }
+
+ [Fact]
+ public void Transition_ToNonFailedState_ClearsFailureMetadataAndResetsRetryCount()
+ {
+ var ctx = new GsdWorkflowContext { RetryCount = 3, FailedState = WorkflowState.Editing, FailureReason = "boom" };
+
+ var next = ctx.Transition(WorkflowState.Branching);
+
+ Assert.Equal(0, next.RetryCount);
+ Assert.Null(next.FailedState);
+ Assert.Null(next.FailureReason);
+ }
+
+ // ---- GsdWorkflowContext.WithSdlcVerification: only the "failed" branch was covered ----
+ [Fact]
+ public void WithSdlcVerification_Passed_ClearsPendingRollbackAndResetsNoProgressCount()
+ {
+ var ctx = new GsdWorkflowContext().WithSdlcRun("Improve the loop");
+
+ var next = ctx.WithSdlcVerification(new SdlcVerificationOutcome(
+ "research", "repo-verifier", Passed: true, InvalidatedPhaseIds: [], Reason: null));
+
+ Assert.Null(next.PendingRollback);
+ Assert.Equal(SdlcPhaseStatus.Passed, next.SdlcRun!.CurrentPhaseStatus);
+ Assert.Equal(0, next.SdlcRun.NoProgressCount);
+ }
+
+ [Fact]
+ public void WithSdlcVerification_NoSdlcRun_ReturnsContextUnchanged()
+ {
+ var ctx = new GsdWorkflowContext();
+
+ var next = ctx.WithSdlcVerification(new SdlcVerificationOutcome(
+ "research", "repo-verifier", Passed: false, InvalidatedPhaseIds: [], Reason: "n/a"));
+
+ Assert.Null(next.SdlcRun);
+ Assert.Null(next.PendingRollback);
+ }
+
+ [Fact]
+ public void WithSdlcPhaseExecution_NoSdlcRun_ReturnsContextUnchanged()
+ {
+ var ctx = new GsdWorkflowContext();
+
+ var next = ctx.WithSdlcPhaseExecution(new SdlcPhaseExecutionRecord(
+ "understand", SdlcPhaseStatus.Passed, "repo-verifier", "digest-1", "prompt text"));
+
+ Assert.Null(next.SdlcRun);
+ }
+
+ // ---- LoopPolicyGuard: happy-path and unknown-action branches were uncovered ----
+ [Fact]
+ public void RequireReadablePath_NoEnvSegment_DoesNotThrow()
+ {
+ var exception = Record.Exception(() => LoopPolicyGuard.RequireReadablePath("repo/src/Program.cs"));
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public void EvaluateExternalAction_ApprovedKnownAction_ReturnsAuthorized()
+ {
+ Assert.Equal(ExternalActionDecision.Authorized, LoopPolicyGuard.EvaluateExternalAction("push", approved: true));
+ }
+
+ [Fact]
+ public void EvaluateExternalAction_UnknownAction_ThrowsArgumentOutOfRangeException()
+ {
+ Assert.Throws(
+ () => LoopPolicyGuard.EvaluateExternalAction("unknown_action", approved: true));
+ }
+
+ // ---- NativeProcessCommandExecutor: 0% branch-rate before this test (only exercised via FakeExecutor elsewhere) ----
+ [Fact]
+ public async Task NativeProcessCommandExecutor_EmptyCommand_ReturnsToolMissingWithoutSpawningProcess()
+ {
+ var executor = new NativeProcessCommandExecutor(Environment.CurrentDirectory);
+ var check = new VerificationCheck("empty", VerificationCategory.Build, [], true, TimeSpan.FromSeconds(5));
+
+ var execution = await executor.ExecuteAsync(check, CancellationToken.None);
+
+ Assert.Equal(-1, execution.ExitCode);
+ Assert.True(execution.ToolMissing);
+ Assert.False(execution.TimedOut);
+ }
+
+ [Fact]
+ public async Task NativeProcessCommandExecutor_SuccessfulCommand_ReturnsZeroExitCode()
+ {
+ var executor = new NativeProcessCommandExecutor(Environment.CurrentDirectory);
+ var command = OperatingSystem.IsWindows()
+ ? new List { "cmd.exe", "/c", "exit 0" }
+ : new List { "/bin/sh", "-c", "exit 0" };
+ var check = new VerificationCheck("success", VerificationCategory.Build, command, true, TimeSpan.FromSeconds(15));
+
+ var execution = await executor.ExecuteAsync(check, CancellationToken.None);
+
+ Assert.Equal(0, execution.ExitCode);
+ Assert.False(execution.TimedOut);
+ Assert.False(execution.ToolMissing);
+ }
+
+ [Fact]
+ public async Task NativeProcessCommandExecutor_NonZeroExitCommand_ReturnsThatExitCode()
+ {
+ var executor = new NativeProcessCommandExecutor(Environment.CurrentDirectory);
+ var command = OperatingSystem.IsWindows()
+ ? new List { "cmd.exe", "/c", "exit 7" }
+ : new List { "/bin/sh", "-c", "exit 7" };
+ var check = new VerificationCheck("nonzero", VerificationCategory.Build, command, true, TimeSpan.FromSeconds(15));
+
+ var execution = await executor.ExecuteAsync(check, CancellationToken.None);
+
+ Assert.Equal(7, execution.ExitCode);
+ Assert.False(execution.ToolMissing);
+ }
+
+ [Fact]
+ public async Task NativeProcessCommandExecutor_MissingExecutable_ReturnsToolMissing()
+ {
+ var executor = new NativeProcessCommandExecutor(Environment.CurrentDirectory);
+ var check = new VerificationCheck(
+ "missing", VerificationCategory.Build,
+ [$"definitely-not-a-real-executable-{Guid.NewGuid():N}.exe"], true, TimeSpan.FromSeconds(5));
+
+ var execution = await executor.ExecuteAsync(check, CancellationToken.None);
+
+ Assert.Equal(-1, execution.ExitCode);
+ Assert.True(execution.ToolMissing);
+ Assert.False(execution.TimedOut);
+ }
+
+ // ---- McpTerminalOutcomePublisher: 0% branch-rate before this test (status switch never exercised) ----
+ [Theory]
+ [InlineData(GoalStatus.Completed, "completed")]
+ [InlineData(GoalStatus.Cancelled, "cancelled")]
+ [InlineData(GoalStatus.Blocked, "blocked")]
+ [InlineData(GoalStatus.BudgetExhausted, "budget_exhausted")]
+ [InlineData(GoalStatus.Failed, "failed")]
+ [InlineData(GoalStatus.Draft, "failed")] // default arm of the switch
+ public async Task McpTerminalOutcomePublisher_PublishAsync_MapsGoalStatusToWireStatus(GoalStatus status, string expectedWireStatus)
+ {
+ var client = Substitute.For();
+ client.CallToolAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(new McpToolResult("ok", false)));
+ var registry = new ResiliencePipelineRegistry();
+ registry.TryAddBuilder("mcp-tools", (b, _) => { });
+ var dispatcher = new McpToolDispatcher(client, registry, NullLogger.Instance);
+ var publisher = new McpTerminalOutcomePublisher(dispatcher);
+ var outcome = new TerminalLoopOutcome("goal-1", "corr-1", status, "done", ["cas://evidence/1"]);
+
+ await publisher.PublishAsync(outcome, CancellationToken.None);
+
+ await client.Received(1).CallToolAsync(
+ "record_terminal_outcome",
+ Arg.Is(obj =>
+ obj["goal_id"]!.GetValue() == "goal-1" &&
+ obj["status"]!.GetValue() == expectedWireStatus &&
+ obj["summary"]!.GetValue() == "done"),
+ Arg.Any());
+ }
+}
diff --git a/src/GsdOrchestrator/Checkpointing/FileCheckpointStore.cs b/src/GsdOrchestrator/Checkpointing/FileCheckpointStore.cs
index a0ac659..ff72a83 100644
--- a/src/GsdOrchestrator/Checkpointing/FileCheckpointStore.cs
+++ b/src/GsdOrchestrator/Checkpointing/FileCheckpointStore.cs
@@ -26,7 +26,8 @@ public sealed class FileCheckpointStore : ICheckpointStore
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = true,
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
};
///
@@ -53,7 +54,10 @@ public async Task SaveAsync(GsdWorkflowContext ctx, CancellationToken ct = defau
var tmp = path + ".tmp";
await using (var fs = new FileStream(tmp, FileMode.Create, FileAccess.Write, FileShare.None))
+ {
await JsonSerializer.SerializeAsync(fs, ctx, JsonOpts, ct);
+ fs.Flush(flushToDisk: true);
+ }
// Atomic rename — prevents partial writes leaving corrupt checkpoints
File.Move(tmp, path, overwrite: true);