|
| 1 | +using System.Security.Cryptography; |
| 2 | +using System.Text.Json; |
| 3 | +using System.Text.Json.Nodes; |
| 4 | +using GsdOrchestrator.Workflows.Models; |
| 5 | +using Xunit; |
| 6 | + |
| 7 | +namespace GsdOrchestrator.Tests; |
| 8 | + |
| 9 | +/// <summary> |
| 10 | +/// Consumer-side contract-compatibility gate against the pinned cas-contracts schema |
| 11 | +/// version. gsd-orchestrator produces the CAS v1.1 SdlcProfile contract via |
| 12 | +/// <see cref="SdlcProfile.CasSdlcV1"/>; these tests fail red in CI when that in-code |
| 13 | +/// profile drifts from the vendored cas-contracts v1.1 schema. |
| 14 | +/// |
| 15 | +/// The invariants asserted here are read out of the vendored schema file itself |
| 16 | +/// (required fields, enums, consts, phase count), so an upstream schema change is |
| 17 | +/// what makes the check react — rather than re-hardcoding the rules. This keeps the |
| 18 | +/// check self-contained and offline (no JSON-Schema validator NuGet dependency). |
| 19 | +/// |
| 20 | +/// Cross-repo reference note: cas-contracts is a separate repo. In isolated CI only |
| 21 | +/// this repo is checked out, so the pinned v1.1.0 release is vendored under |
| 22 | +/// GsdOrchestrator.Tests/Contracts/cas-contracts/v1.1.0/ (same convention as the |
| 23 | +/// Python consumers). When the sibling ../cas-contracts checkout is present |
| 24 | +/// (local dev), an extra test asserts the vendored copy has not drifted upstream. |
| 25 | +/// </summary> |
| 26 | +public class ContractCompatibilityTests |
| 27 | +{ |
| 28 | + // The cas-contracts version this consumer targets. Kept in lockstep with the |
| 29 | + // vendored release and with SdlcProfile.CasSdlcV1.ProfileVersion. |
| 30 | + private const string PinnedSchemaVersion = "1.1.0"; |
| 31 | + private const string PinnedProfileVersion = "v1.1"; |
| 32 | + |
| 33 | + private static string ContractRoot { get; } = Path.Combine( |
| 34 | + AppContext.BaseDirectory, "Contracts", "cas-contracts", $"v{PinnedSchemaVersion}"); |
| 35 | + |
| 36 | + private static JsonNode LoadSchema(string name) => |
| 37 | + JsonNode.Parse(File.ReadAllText(Path.Combine(ContractRoot, name)))!; |
| 38 | + |
| 39 | + /// <summary>Walk up from the test output directory to find a sibling |
| 40 | + /// cas-contracts/registry/releases/v{version} checkout, or null if absent.</summary> |
| 41 | + private static string? ResolveUpstreamRoot() |
| 42 | + { |
| 43 | + var dir = new DirectoryInfo(AppContext.BaseDirectory); |
| 44 | + while (dir is not null) |
| 45 | + { |
| 46 | + var candidate = Path.Combine( |
| 47 | + dir.FullName, "cas-contracts", "registry", "releases", $"v{PinnedSchemaVersion}"); |
| 48 | + if (Directory.Exists(candidate)) |
| 49 | + { |
| 50 | + return candidate; |
| 51 | + } |
| 52 | + |
| 53 | + dir = dir.Parent; |
| 54 | + } |
| 55 | + |
| 56 | + return null; |
| 57 | + } |
| 58 | + |
| 59 | + /// <summary>Serialize CasSdlcV1 into the camelCase v1.1 wire shape, including the |
| 60 | + /// mandatory lifecycle metadata that every v1.1 contract requires.</summary> |
| 61 | + private static JsonObject BuildProfileWirePayload() |
| 62 | + { |
| 63 | + var profile = SdlcProfile.CasSdlcV1; |
| 64 | + var phases = new JsonArray(); |
| 65 | + foreach (var phase in profile.Phases) |
| 66 | + { |
| 67 | + var phaseNode = new JsonObject |
| 68 | + { |
| 69 | + ["id"] = phase.Id, |
| 70 | + ["name"] = phase.Name, |
| 71 | + ["batch"] = phase.Batch.ToString().ToLowerInvariant(), |
| 72 | + ["dependencies"] = new JsonArray(phase.Dependencies.Select(d => (JsonNode)d!).ToArray()), |
| 73 | + ["verifier"] = phase.Verifier, |
| 74 | + ["requiredArtifacts"] = new JsonArray(phase.RequiredArtifacts.Select(a => (JsonNode)a!).ToArray()), |
| 75 | + ["successCriteria"] = new JsonArray(phase.SuccessCriteria.Select(c => (JsonNode)c!).ToArray()), |
| 76 | + ["maxAttempts"] = phase.MaxAttempts, |
| 77 | + }; |
| 78 | + if (phase.RollbackTo is not null) |
| 79 | + { |
| 80 | + phaseNode["rollbackTo"] = phase.RollbackTo; |
| 81 | + } |
| 82 | + |
| 83 | + phases.Add(phaseNode); |
| 84 | + } |
| 85 | + |
| 86 | + return new JsonObject |
| 87 | + { |
| 88 | + // lifecycleMetadata (common.schema.json#/$defs/lifecycleMetadata) |
| 89 | + ["correlationId"] = "corr-001", |
| 90 | + ["promptId"] = "prompt-001", |
| 91 | + ["runId"] = "run-001", |
| 92 | + ["repo"] = "Coding-Autopilot-System/gsd-orchestrator", |
| 93 | + ["actor"] = new JsonObject { ["id"] = "gsd-orchestrator", ["type"] = "service" }, |
| 94 | + ["timestamp"] = "2026-07-03T00:00:00Z", |
| 95 | + ["schemaVersion"] = PinnedSchemaVersion, |
| 96 | + ["traceContext"] = new JsonObject |
| 97 | + { |
| 98 | + ["traceparent"] = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", |
| 99 | + }, |
| 100 | + // SdlcProfile body |
| 101 | + ["kind"] = "SdlcProfile", |
| 102 | + ["profileId"] = profile.ProfileId, |
| 103 | + ["profileVersion"] = profile.ProfileVersion, |
| 104 | + ["goalBudget"] = profile.GoalBudget, |
| 105 | + ["phases"] = phases, |
| 106 | + ["goalAttempts"] = profile.GoalAttempts, |
| 107 | + ["iterations"] = profile.Iterations, |
| 108 | + ["runtimeMinutes"] = profile.RuntimeMinutes, |
| 109 | + ["modelCalls"] = profile.ModelCalls, |
| 110 | + ["noProgressLimit"] = profile.NoProgressLimit, |
| 111 | + }; |
| 112 | + } |
| 113 | + |
| 114 | + [Fact] |
| 115 | + public void VendoredRelease_MatchesManifestHashes() |
| 116 | + { |
| 117 | + var manifest = LoadSchema("manifest.json"); |
| 118 | + Assert.Equal(PinnedSchemaVersion, (string?)manifest["version"]); |
| 119 | + |
| 120 | + foreach (var entry in manifest["schemas"]!.AsArray()) |
| 121 | + { |
| 122 | + var path = (string)entry!["path"]!; |
| 123 | + var expected = (string)entry["sha256"]!; |
| 124 | + var bytes = File.ReadAllBytes(Path.Combine(ContractRoot, path)); |
| 125 | + var actual = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); |
| 126 | + Assert.Equal(expected, actual); |
| 127 | + } |
| 128 | + } |
| 129 | + |
| 130 | + [Fact] |
| 131 | + public void PinnedVersion_IsTheVersionThisConsumerEmits() |
| 132 | + { |
| 133 | + // schemaVersion const enforced by the vendored schema must equal the pin... |
| 134 | + var common = LoadSchema("common.schema.json"); |
| 135 | + var schemaConst = (string?)common["$defs"]!["lifecycleMetadata"]!["properties"]! |
| 136 | + ["schemaVersion"]!["const"]; |
| 137 | + Assert.Equal(PinnedSchemaVersion, schemaConst); |
| 138 | + |
| 139 | + // ...and the in-code profile must emit the pinned profileVersion. |
| 140 | + Assert.Equal(PinnedProfileVersion, SdlcProfile.CasSdlcV1.ProfileVersion); |
| 141 | + |
| 142 | + // The vendored profile schema pins the same profileVersion via a regex pattern. |
| 143 | + var profileSchema = LoadSchema("sdlc-profile.schema.json"); |
| 144 | + var pattern = (string?)profileSchema["allOf"]![1]!["properties"]! |
| 145 | + ["profileVersion"]!["pattern"]; |
| 146 | + Assert.Equal("^v1\\.1$", pattern); |
| 147 | + } |
| 148 | + |
| 149 | + [Fact] |
| 150 | + public void CasSdlcV1_ConformsToVendoredProfileSchema() |
| 151 | + { |
| 152 | + var payload = BuildProfileWirePayload(); |
| 153 | + var schema = LoadSchema("sdlc-profile.schema.json"); |
| 154 | + var body = schema["allOf"]![1]!; |
| 155 | + var props = body["properties"]!; |
| 156 | + |
| 157 | + // Required top-level SdlcProfile fields are present. |
| 158 | + foreach (var required in body["required"]!.AsArray()) |
| 159 | + { |
| 160 | + Assert.True(payload.ContainsKey((string)required!), |
| 161 | + $"payload missing required field '{required}'"); |
| 162 | + } |
| 163 | + |
| 164 | + // profileVersion matches the schema const, and kind matches. |
| 165 | + Assert.Equal("SdlcProfile", (string?)payload["kind"]); |
| 166 | + Assert.Equal(PinnedProfileVersion, (string?)payload["profileVersion"]); |
| 167 | + |
| 168 | + // Phase count matches the schema bounds (v1.1: exactly 12 phases). |
| 169 | + var minItems = (int)props["phases"]!["minItems"]!; |
| 170 | + var maxItems = (int)props["phases"]!["maxItems"]!; |
| 171 | + var phases = payload["phases"]!.AsArray(); |
| 172 | + Assert.InRange(phases.Count, minItems, maxItems); |
| 173 | + |
| 174 | + // Every phase id / batch value is a member of the schema enums. |
| 175 | + var common = LoadSchema("common.schema.json"); |
| 176 | + var phaseIdEnum = common["$defs"]!["phaseId"]!["enum"]!.AsArray() |
| 177 | + .Select(n => (string)n!).ToHashSet(); |
| 178 | + var batchEnum = common["$defs"]!["executionBatch"]!["enum"]!.AsArray() |
| 179 | + .Select(n => (string)n!).ToHashSet(); |
| 180 | + |
| 181 | + foreach (var phase in phases) |
| 182 | + { |
| 183 | + var id = (string)phase!["id"]!; |
| 184 | + var batch = (string)phase["batch"]!; |
| 185 | + Assert.Contains(id, phaseIdEnum); |
| 186 | + Assert.Contains(batch, batchEnum); |
| 187 | + Assert.False(string.IsNullOrWhiteSpace((string?)phase["verifier"])); |
| 188 | + } |
| 189 | + } |
| 190 | + |
| 191 | + [Fact] |
| 192 | + public void WorkflowContextSchemaVersion_TracksPinnedMajorMinor() |
| 193 | + { |
| 194 | + // GsdWorkflowContext.SchemaVersion is "1.1"; guard it stays aligned with the |
| 195 | + // pinned contract major.minor so checkpoints and contracts do not diverge. |
| 196 | + var context = new GsdWorkflowContext(); |
| 197 | + var pinnedMajorMinor = string.Join('.', PinnedSchemaVersion.Split('.').Take(2)); |
| 198 | + Assert.Equal(pinnedMajorMinor, context.SchemaVersion); |
| 199 | + } |
| 200 | + |
| 201 | + [Fact] |
| 202 | + public void VendoredRelease_MatchesUpstreamSourceOfTruth() |
| 203 | + { |
| 204 | + // Local-only drift guard against the sibling cas-contracts repo. In isolated CI |
| 205 | + // the sibling is not checked out, so this test passes vacuously (no NuGet skip |
| 206 | + // dependency). Locally it fails red if the vendored copy drifts from upstream. |
| 207 | + var upstreamRoot = ResolveUpstreamRoot(); |
| 208 | + if (upstreamRoot is null) |
| 209 | + { |
| 210 | + return; |
| 211 | + } |
| 212 | + |
| 213 | + foreach (var file in Directory.EnumerateFiles(ContractRoot, "*.json")) |
| 214 | + { |
| 215 | + var upstream = Path.Combine(upstreamRoot, Path.GetFileName(file)); |
| 216 | + Assert.True(File.Exists(upstream), $"upstream missing {Path.GetFileName(file)}"); |
| 217 | + var local = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(file))); |
| 218 | + var remote = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(upstream))); |
| 219 | + Assert.Equal(remote, local); |
| 220 | + } |
| 221 | + } |
| 222 | +} |
0 commit comments