Skip to content

Commit 526e4e5

Browse files
OgeonX-AiAitomatesclaude
authored
fix: audit sweep (#11)
* fix: audit sweep to fix linting errors and test failures * test(contracts): add consumer-side contract-compatibility gate Validates CasSdlcV1 against the pinned cas-contracts v1.1.0 schema in CI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Kim Harjamäki <kim.harjamaki@prosimo.fi> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d476f11 commit 526e4e5

26 files changed

Lines changed: 677 additions & 86 deletions

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ jobs:
3030
- name: Build tests
3131
run: dotnet build src/GsdOrchestrator.Tests/GsdOrchestrator.Tests.csproj --no-restore --configuration Release
3232

33+
- name: Contract compatibility (pinned cas-contracts v1.1)
34+
# Consumer-side gate: fails red if SdlcProfile.CasSdlcV1 or the vendored v1.1
35+
# schema release drifts from the pinned cas-contracts contract. See
36+
# src/GsdOrchestrator.Tests/ContractCompatibilityTests.cs.
37+
run: dotnet test src/GsdOrchestrator.Tests/GsdOrchestrator.Tests.csproj --configuration Release --no-build --filter "FullyQualifiedName~ContractCompatibilityTests"
38+
3339
- name: Test
3440
run: dotnet test src/GsdOrchestrator.Tests/GsdOrchestrator.Tests.csproj --configuration Release --logger trx --no-build --collect:"XPlat Code Coverage" --results-directory ./TestResults
3541

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://schemas.coding-autopilot.dev/v1.1/common.schema.json",
4+
"title": "CAS Common Definitions v1.1",
5+
"$defs": {
6+
"actor": {
7+
"type": "object",
8+
"additionalProperties": false,
9+
"required": ["id", "type"],
10+
"properties": {
11+
"id": { "type": "string", "minLength": 1, "maxLength": 256 },
12+
"type": {
13+
"type": "string",
14+
"enum": ["human", "agent", "service", "workflow"]
15+
},
16+
"displayName": { "type": "string", "minLength": 1, "maxLength": 256 }
17+
}
18+
},
19+
"traceContext": {
20+
"type": "object",
21+
"additionalProperties": false,
22+
"required": ["traceparent"],
23+
"properties": {
24+
"traceparent": {
25+
"type": "string",
26+
"pattern": "^[\\da-f]{2}-[\\da-f]{32}-[\\da-f]{16}-[\\da-f]{2}$"
27+
},
28+
"tracestate": { "type": "string", "maxLength": 512 }
29+
}
30+
},
31+
"lifecycleMetadata": {
32+
"type": "object",
33+
"required": [
34+
"correlationId",
35+
"promptId",
36+
"runId",
37+
"repo",
38+
"actor",
39+
"timestamp",
40+
"schemaVersion",
41+
"traceContext"
42+
],
43+
"properties": {
44+
"correlationId": { "type": "string", "minLength": 1, "maxLength": 128 },
45+
"promptId": { "type": "string", "minLength": 1, "maxLength": 128 },
46+
"runId": { "type": "string", "minLength": 1, "maxLength": 128 },
47+
"repo": {
48+
"type": "string",
49+
"pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"
50+
},
51+
"actor": { "$ref": "#/$defs/actor" },
52+
"timestamp": { "type": "string", "format": "date-time" },
53+
"schemaVersion": { "const": "1.1.0" },
54+
"traceContext": { "$ref": "#/$defs/traceContext" }
55+
}
56+
},
57+
"evidence": {
58+
"type": "object",
59+
"additionalProperties": false,
60+
"required": ["kind", "uri"],
61+
"properties": {
62+
"kind": { "type": "string", "minLength": 1, "maxLength": 64 },
63+
"uri": { "type": "string", "format": "uri" },
64+
"sha256": { "type": "string", "pattern": "^[\\da-f]{64}$" }
65+
}
66+
},
67+
"phaseId": {
68+
"type": "string",
69+
"enum": [
70+
"understand",
71+
"research",
72+
"analyze",
73+
"plan",
74+
"risk-assessment",
75+
"implement",
76+
"verify",
77+
"review",
78+
"improve",
79+
"document",
80+
"update-memory",
81+
"finished"
82+
]
83+
},
84+
"executionBatch": {
85+
"type": "string",
86+
"enum": ["discovery", "design", "change", "assurance", "closure"]
87+
},
88+
"phaseStatus": {
89+
"type": "string",
90+
"enum": [
91+
"pending",
92+
"ready",
93+
"running",
94+
"passed",
95+
"failed",
96+
"invalidated",
97+
"rolled-back",
98+
"waiting",
99+
"terminal"
100+
]
101+
}
102+
}
103+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"version": "1.1.0",
3+
"schemas": [
4+
{
5+
"id": "https://schemas.coding-autopilot.dev/v1.1/common.schema.json",
6+
"path": "common.schema.json",
7+
"sha256": "078026d60c219658fe0fc5cf7c4f786acdcf0c492121f7d688d5078b33ca25af"
8+
},
9+
{
10+
"id": "https://schemas.coding-autopilot.dev/v1.1/phase-execution-request.schema.json",
11+
"path": "phase-execution-request.schema.json",
12+
"sha256": "927ffda5ba809e58627ba25b3275355644b510b0ae1c76f446aa7b7f291f08fd"
13+
},
14+
{
15+
"id": "https://schemas.coding-autopilot.dev/v1.1/phase-execution-result.schema.json",
16+
"path": "phase-execution-result.schema.json",
17+
"sha256": "1d353e20cf6338c2de4a9bec0bad83ca0dec6c9feb5434c5e6264fdc075e7c25"
18+
},
19+
{
20+
"id": "https://schemas.coding-autopilot.dev/v1.1/phase-verification-result.schema.json",
21+
"path": "phase-verification-result.schema.json",
22+
"sha256": "ec5b97a2bf75892530442f666d23a8b6dbb8f13bda0efe49553d3f64cd41b995"
23+
},
24+
{
25+
"id": "https://schemas.coding-autopilot.dev/v1.1/sdlc-lifecycle-event.schema.json",
26+
"path": "sdlc-lifecycle-event.schema.json",
27+
"sha256": "bd955637761679d3b6e8ee09fb7b693b1d0362615b0865204c5ae3bacb3809dc"
28+
},
29+
{
30+
"id": "https://schemas.coding-autopilot.dev/v1.1/sdlc-profile.schema.json",
31+
"path": "sdlc-profile.schema.json",
32+
"sha256": "f4974e911e7cb88191c1a331470c8cd7a79d1c7311c963dd70cd9c8f524ebe45"
33+
}
34+
]
35+
}

0 commit comments

Comments
 (0)