// ContractsSurfaceTests.cs.template (Constitution kit, item A3) // ----------------------------------------------------------------------------- // Copy into tests/.ArchitectureTests/ContractsSurfaceTests.cs and rebind // from CLAUDE.md. Requires the PublicApiGenerator NuGet package: // dotnet add tests/.ArchitectureTests package PublicApiGenerator // // THE RULE (Law 8, compile-adjacent): the public surface of .Contracts may // only ever GROW. This test asserts approved SUBSET-OF generated - every line // of the committed snapshot must still exist verbatim in the freshly generated // surface. Additions pass silently; a deletion or mutation of a shipped // event/DTO member is a red test citing the exact missing line. // // SNAPSHOT REGENERATION IS SINGLE-WRITER: only the orchestrator runs // tools/Update-ContractsSnapshot.ps1 (at integration / wave closure, when // backend-builder reported appended contracts). Agents NEVER edit the snapshot // (backend-builder hard rule) - so a mutation can never be laundered by // "helpfully" updating the file. Deprecation = add [Obsolete] (an addition, // always allowed - the V2-event pattern). Physical removal = a human, // foundation-gated event (Law 7): approval-inbox entry + approved snapshot edit // + a contracts-changelog note. // // DETERMINISM (or snapshot tests die by a thousand cuts): generator options are // pinned below; the comparison normalizes line endings on BOTH sides; commit // the snapshot with `.gitattributes`: Contracts.approved.txt text eol=lf // ----------------------------------------------------------------------------- using System; using System.Collections.Generic; using System.IO; using System.Linq; using PublicApiGenerator; using Xunit; namespace .ArchitectureTests; public class ContractsSurfaceTests { private const string SnapshotFileName = "Contracts.approved.txt"; // Pinned generator options - never change these casually: any change // re-baselines the snapshot and must go through the same foundation gate // as a removal. private static readonly ApiGeneratorOptions PinnedOptions = new() { IncludeAssemblyAttributes = false, ExcludeAttributes = new[] { "System.Runtime.CompilerServices.CompilerGeneratedAttribute", "System.Runtime.CompilerServices.NullableAttribute", "System.Runtime.CompilerServices.NullableContextAttribute", }, }; [Fact] public void Contracts_public_surface_is_append_only() { // Anchor on any public type in the Contracts assembly; rebind . var assembly = typeof(.Contracts.AssemblyMarker).Assembly; var generated = Normalize(assembly.GeneratePublicApi(PinnedOptions)); var snapshotPath = Path.Combine(FindRepoRoot(), "tests", ".ArchitectureTests", SnapshotFileName); // Env-gated UPDATE mode - the mechanism Update-ContractsSnapshot.ps1 // drives. Interactive runs never hit this branch. if (Environment.GetEnvironmentVariable("UPDATE_CONTRACTS_SNAPSHOT") == "1") { File.WriteAllText(snapshotPath, string.Join("\n", generated) + "\n"); return; // snapshot rewritten; the assertion run is the next plain `dotnet test` } Assert.True(File.Exists(snapshotPath), $"Missing {SnapshotFileName}. Commit the initial surface snapshot at the foundation phase: " + "powershell -NoProfile -File .claude/tools/Update-ContractsSnapshot.ps1"); var approved = Normalize(File.ReadAllText(snapshotPath)); var generatedSet = new HashSet(generated, StringComparer.Ordinal); var missing = approved.Where(line => !generatedSet.Contains(line)).ToList(); Assert.True(missing.Count == 0, "Contracts surface REWRITE detected - the following approved public-API lines were removed or " + "mutated (Law 8: .Contracts is append-only). Deprecate with [Obsolete] + a V2 type instead; " + "a genuine removal is a human foundation-gated event (approval inbox), never an agent edit.\n - " + string.Join("\n - ", missing)); } private static IReadOnlyList Normalize(string api) => api.Replace("\r\n", "\n") .Split('\n') .Select(l => l.TrimEnd()) .Where(l => l.Length > 0) .ToList(); private static string FindRepoRoot() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, ".git"))) dir = dir.Parent; return dir?.FullName ?? throw new InvalidOperationException("Repo root (.git) not found above test base directory."); } } // If .Contracts has no natural anchor type, add this one-liner to it: // namespace .Contracts; public static class AssemblyMarker { }