From ce5ce72f2c448f64aefafb4af7ebf09956c7b91c Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sat, 22 Aug 2026 08:43:46 +0200 Subject: [PATCH 01/13] feat(gates): MaxImpact cap, --allow-major, and dogfood the API surface gate - SemverPolicy.MaxImpact (default major = cap off): fragments/releases that would force a Major bump are refused at new and release unless --allow-major is passed. Interactive menu marks blocked categories and re-prompts. - MCP tools expose the gates: validate_fragments apiMinLevel, perform_release allowMajor/apiMinLevel. - Dogfood the API Surface Gate on ChangeSharp itself: committed baselines (tests/public-api: cli-help, mcp-tools, public-api via PublicApiGenerator), scripts/update-public-api.sh, api-surface CI job, PublicApiBaselineTests. - Demo workspace samples/maximpact-gate with self-verifying run-demo.sh. - Docs updated (SemVer Rules, ApiSurfaceGate, McpIntegration, Roadmap). --- ...cymaximpact-cap-block-fragmentsreleases.md | 2 + ...space-samplesmaximpact-gate-demonstrati.md | 2 + ...-api-surface-gate-on-changesharp-itself.md | 2 + ...safety-gates-on-mcp-tools-validatefragm.md | 2 + .github/workflows/dotnet.yml | 52 +++++ ChangeSharp.Cli/Program.cs | 124 ++++++++--- ChangeSharp.Mcp/Program.cs | 72 +++++- ChangeSharp.Tests/ChangeSharp.Tests.csproj | 1 + ChangeSharp.Tests/PublicApiBaselineTests.cs | 88 ++++++++ ChangeSharp.Tests/WorkspaceManagerTests.cs | 167 ++++++++++++++ ChangeSharp/ChangeSharpConfig.cs | 2 + ChangeSharp/WorkspaceManager.cs | 50 ++++- README.md | 3 +- changesharp.json | 1 + docs/ChangeSharp.md | 2 +- docs/McpIntegration.md | 6 +- docs/Roadmap.md | 2 + docs/SemVer Rules.md | 32 +++ docs/features/ApiSurfaceGate.md | 38 ++++ docs/index.md | 2 +- samples/maximpact-gate/.gitignore | 2 + samples/maximpact-gate/README.md | 49 ++++ samples/maximpact-gate/changesharp.json | 14 ++ samples/maximpact-gate/run-demo.sh | 79 +++++++ scripts/update-public-api.sh | 47 ++++ tests/public-api/cli-help.txt | 158 +++++++++++++ tests/public-api/mcp-tools.json | 65 ++++++ tests/public-api/public-api.txt | 209 ++++++++++++++++++ 28 files changed, 1229 insertions(+), 44 deletions(-) create mode 100644 .changesharp/unreleased/20260822063756081-main-semverpolicymaximpact-cap-block-fragmentsreleases.md create mode 100644 .changesharp/unreleased/20260822063756276-main-sample-workspace-samplesmaximpact-gate-demonstrati.md create mode 100644 .changesharp/unreleased/20260822064152527-main-dogfood-the-api-surface-gate-on-changesharp-itself.md create mode 100644 .changesharp/unreleased/20260822064152723-main-expose-the-safety-gates-on-mcp-tools-validatefragm.md create mode 100644 ChangeSharp.Tests/PublicApiBaselineTests.cs create mode 100644 samples/maximpact-gate/.gitignore create mode 100644 samples/maximpact-gate/README.md create mode 100644 samples/maximpact-gate/changesharp.json create mode 100755 samples/maximpact-gate/run-demo.sh create mode 100755 scripts/update-public-api.sh create mode 100644 tests/public-api/cli-help.txt create mode 100644 tests/public-api/mcp-tools.json create mode 100644 tests/public-api/public-api.txt diff --git a/.changesharp/unreleased/20260822063756081-main-semverpolicymaximpact-cap-block-fragmentsreleases.md b/.changesharp/unreleased/20260822063756081-main-semverpolicymaximpact-cap-block-fragmentsreleases.md new file mode 100644 index 0000000..b913f37 --- /dev/null +++ b/.changesharp/unreleased/20260822063756081-main-semverpolicymaximpact-cap-block-fragmentsreleases.md @@ -0,0 +1,2 @@ +### Added +- SemverPolicy.MaxImpact cap: block fragments/releases that would force a Major bump unless --allow-major is passed (new + release) diff --git a/.changesharp/unreleased/20260822063756276-main-sample-workspace-samplesmaximpact-gate-demonstrati.md b/.changesharp/unreleased/20260822063756276-main-sample-workspace-samplesmaximpact-gate-demonstrati.md new file mode 100644 index 0000000..9219d20 --- /dev/null +++ b/.changesharp/unreleased/20260822063756276-main-sample-workspace-samplesmaximpact-gate-demonstrati.md @@ -0,0 +1,2 @@ +### Added +- Sample workspace samples/maximpact-gate demonstrating the MaxImpact cap (run-demo.sh) diff --git a/.changesharp/unreleased/20260822064152527-main-dogfood-the-api-surface-gate-on-changesharp-itself.md b/.changesharp/unreleased/20260822064152527-main-dogfood-the-api-surface-gate-on-changesharp-itself.md new file mode 100644 index 0000000..954ab71 --- /dev/null +++ b/.changesharp/unreleased/20260822064152527-main-dogfood-the-api-surface-gate-on-changesharp-itself.md @@ -0,0 +1,2 @@ +### Added +- Dogfood the API Surface Gate on ChangeSharp itself: committed baselines (CLI help, MCP tools, library public API) + update script + api-surface CI job + PublicApiBaselineTests diff --git a/.changesharp/unreleased/20260822064152723-main-expose-the-safety-gates-on-mcp-tools-validatefragm.md b/.changesharp/unreleased/20260822064152723-main-expose-the-safety-gates-on-mcp-tools-validatefragm.md new file mode 100644 index 0000000..d648dc1 --- /dev/null +++ b/.changesharp/unreleased/20260822064152723-main-expose-the-safety-gates-on-mcp-tools-validatefragm.md @@ -0,0 +1,2 @@ +### Added +- Expose the safety gates on MCP tools: validate_fragments apiMinLevel, perform_release allowMajor/apiMinLevel diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 4dbc3d7..35f47c7 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -53,6 +53,58 @@ jobs: if: github.event_name == 'push' run: changesharp validate + api-surface: + # Dogfoods the API Surface Gate on ChangeSharp's own public surfaces + # (CLI help, MCP tools, library public API). See docs/features/ApiSurfaceGate.md. + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Regenerate public-surface baselines + run: scripts/update-public-api.sh + + - name: Committed baselines must match the regenerated surface + run: | + if ! git diff --exit-code -- tests/public-api/; then + echo "::error::Public-surface baselines are out of date. Run scripts/update-public-api.sh and commit the result." + exit 1 + fi + + - name: Derive API impact from the baseline diff vs main + id: surface + run: | + git fetch origin main --depth=1 + DIFF=$(git diff --unified=0 origin/main -- tests/public-api/ || true) + if [ -z "$DIFF" ]; then + LEVEL=patch + else + ADDED=$(echo "$DIFF" | grep -c '^+[^+]' || true) + REMOVED=$(echo "$DIFF" | grep -c '^-[^-]' || true) + if [ "$REMOVED" -gt 0 ]; then LEVEL=major; else LEVEL=minor; fi + fi + echo "Detected API impact level: $LEVEL" + echo "level=$LEVEL" >> "$GITHUB_OUTPUT" + + - name: Pack ChangeSharp CLI + run: dotnet pack ChangeSharp.Cli/ChangeSharp.Cli.csproj -o nupkg --nologo + + - name: Install ChangeSharp CLI + run: dotnet tool install --global --add-source ./nupkg ChangeSharp.Cli + + - name: Enforce fragments against the API surface impact + run: | + export PATH="$PATH:$HOME/.dotnet/tools" + changesharp validate --api-min-level ${{ steps.surface.outputs.level }} + analysis: # SonarCloud analyzes both main pushes and pull requests. For PRs the scanner # needs the pull-request parameters plus GITHUB_TOKEN (used to decorate the PR diff --git a/ChangeSharp.Cli/Program.cs b/ChangeSharp.Cli/Program.cs index a3ba4f1..fa6e7ef 100644 --- a/ChangeSharp.Cli/Program.cs +++ b/ChangeSharp.Cli/Program.cs @@ -83,12 +83,13 @@ static async Task Main(string[] args) var breakingOption = new Option("--breaking") { Description = "Mark change as Breaking Changes." }; var fileOption = new Option("--file") { Description = "Read the change description from a file instead of the message argument, stdin, or a prompt." }; + var allowMajorOption = new Option("--allow-major") { Description = "Allow a fragment whose impact exceeds SemverPolicy.MaxImpact." }; var newCommand = new Command("new", "Create a new unreleased changelog fragment.") { messageArgument, addedOption, changedOption, fixedOption, removedOption, deprecatedOption, securityOption, breakingOption, - fileOption, jsonOption, + fileOption, allowMajorOption, jsonOption, }; newCommand.SetAction(parseResult => @@ -121,40 +122,58 @@ static async Task Main(string[] args) bool deprecated = parseResult.GetValue(deprecatedOption); bool security = parseResult.GetValue(securityOption); bool breaking = parseResult.GetValue(breakingOption); + bool allowMajor = parseResult.GetValue(allowMajorOption); bool anyCategoryOptionProvided = added || changed || fixedOpt || removed || deprecated || security || breaking; - if (anyCategoryOptionProvided) + while (true) { - category = breaking ? "Breaking Changes" - : removed ? "Removed" - : changed ? "Changed" - : deprecated ? "Deprecated" - : fixedOpt ? "Fixed" - : security ? "Security" - : "Added"; - } - else if (Console.IsInputRedirected) - { - return o.Err("Category is required when non-interactive. Use one of --added, --changed, --fixed, --removed, --deprecated, --security, --breaking.", ExitCodeValidationError); - } - else - { - category = PromptForCategory(); - } + if (anyCategoryOptionProvided) + { + category = breaking ? "Breaking Changes" + : removed ? "Removed" + : changed ? "Changed" + : deprecated ? "Deprecated" + : fixedOpt ? "Fixed" + : security ? "Security" + : "Added"; + } + else if (Console.IsInputRedirected) + { + return o.Err("Category is required when non-interactive. Use one of --added, --changed, --fixed, --removed, --deprecated, --security, --breaking.", ExitCodeValidationError); + } + else + { + category = PromptForCategory(allowMajor); + } - try - { - var manager = new WorkspaceManager(); - string filePath = manager.CreateFragment(message, category); - return o.Ok(new + try { - filename = Path.GetFileName(filePath), - category, - path = filePath - }, () => Console.WriteLine($"Created fragment: {Path.GetFileName(filePath)} under category '{category}'")); + var manager = new WorkspaceManager(); + if (!allowMajor) + { + var (allowed, categoryImpact, maxAllowed) = manager.IsCategoryWithinMaxImpact(category); + if (!allowed) + { + string blockReason = $"Category '{category}' requires a {ImpactLevelName(categoryImpact)} bump, above the configured SemverPolicy.MaxImpact ({ImpactLevelName(maxAllowed)})."; + if (anyCategoryOptionProvided || Console.IsInputRedirected) + return o.Err($"{blockReason} Use --allow-major to override.", ExitCodeValidationError); + Console.WriteLine(); + Console.WriteLine($" {blockReason}"); + Console.WriteLine(" Choose another category, or rerun with --allow-major."); + continue; + } + } + string filePath = manager.CreateFragment(message, category); + return o.Ok(new + { + filename = Path.GetFileName(filePath), + category, + path = filePath + }, () => Console.WriteLine($"Created fragment: {Path.GetFileName(filePath)} under category '{category}'")); + } + catch (Exception ex) { return o.Err(ex.Message); } } - catch (Exception ex) { return o.Err(ex.Message); } }); rootCommand.Add(newCommand); @@ -283,10 +302,11 @@ static async Task Main(string[] args) var dryRunOption = new Option("--dry-run") { Description = "Display what would happen without making any changes." }; var allowEmptyOption = new Option("--allow-empty") { Description = "Exit with success even if no unreleased fragments are found." }; var requireApprovalOption = new Option("--require-approval") { Description = "Require explicit approval (CHANGESHARP_ALLOW_UNSAFE_RELEASE) to proceed." }; + var allowMajorReleaseOption = new Option("--allow-major") { Description = "Allow a release whose impact exceeds SemverPolicy.MaxImpact." }; var releaseCommand = new Command("release", "Aggregate fragments, bump version, update CHANGELOG.md, and clean up.") { dryRunOption, allowEmptyOption, requireApprovalOption, - apiMinLevelOption, apiMinLevelWarnOption, jsonOption + apiMinLevelOption, apiMinLevelWarnOption, allowMajorReleaseOption, jsonOption }; releaseCommand.SetAction(parseResult => { @@ -371,6 +391,9 @@ static async Task Main(string[] args) int? apiResult = CheckApiMinLevel(parseResult, manager, apiMinLevelOption, apiMinLevelWarnOption, o); if (apiResult.HasValue) return apiResult.Value; + int? maxResult = CheckApiMaxLevel(parseResult, manager, allowMajorReleaseOption, o); + if (maxResult.HasValue) return maxResult.Value; + var (nextVersion, releaseWarnings) = manager.Release(DateTime.Today, dryRun); foreach (var w in releaseWarnings) Console.Error.WriteLine($"Warning: {w}"); @@ -603,13 +626,27 @@ private static Output Out(ParseResult pr, Option jsonOption) => return o.Err(message, ExitCodeValidationError); } + private static int? CheckApiMaxLevel(ParseResult parseResult, WorkspaceManager manager, + Option allowMajorOption, Output o) + { + bool allowMajor = parseResult.GetValue(allowMajorOption); + if (allowMajor) return null; + + var (pass, maxImpact, maxLevelName, maxAllowed, offendingCategories) = manager.CheckApiMaxLevel(); + if (pass) return null; + + string categories = offendingCategories.Length > 0 ? $" ({string.Join(", ", offendingCategories)})" : ""; + string message = $"Release would bump to '{maxLevelName}' (level {maxImpact}), above the configured SemverPolicy.MaxImpact ({ImpactLevelName(maxAllowed)}){categories}. Use --allow-major to proceed."; + return o.Err(message, ExitCodeValidationError); + } + private static string? PromptForMessage() { Console.Write("Enter a description for the change: "); return Console.ReadLine(); } - private static string PromptForCategory() + private static string PromptForCategory(bool allowMajor) { var categories = new (string Name, string Description)[] { @@ -622,16 +659,24 @@ private static string PromptForCategory() ("Breaking Changes", "Backward-incompatible change") }; - Dictionary? impacts = null; + SemverPolicyConfig? policy = null; try { - impacts = new WorkspaceManager().LoadConfig().SemverPolicy.Mappings; + policy = new WorkspaceManager().LoadConfig().SemverPolicy; } catch { // impact display is best-effort } + int maxAllowed = policy == null ? 3 : NextVersionComputer.ParseImpact(policy.MaxImpact); + + string? impactOf(string name) => + policy?.Mappings.TryGetValue(name, out var v) == true ? v : null; + + bool isBlocked(string name) => + !allowMajor && impactOf(name) is { } impact && NextVersionComputer.ParseImpact(impact) > maxAllowed; + int selected = 0; Console.WriteLine("Select a category (↑/↓ to navigate, Enter to confirm, Esc to cancel, 1-7 to jump):"); while (true) @@ -639,7 +684,8 @@ private static string PromptForCategory() for (int i = 0; i < categories.Length; i++) { Console.CursorLeft = 0; - string impact = impacts != null && impacts.TryGetValue(categories[i].Name, out var v) ? $" ({v})" : ""; + string impact = impactOf(categories[i].Name) is { } v ? $" ({v})" : ""; + string blocked = isBlocked(categories[i].Name) ? " ⚠ blocked (MaxImpact)" : ""; if (i == selected) { Console.Write("> "); @@ -647,11 +693,11 @@ private static string PromptForCategory() Console.ForegroundColor = ConsoleColor.White; Console.Write(categories[i].Name.PadRight(18)); Console.ResetColor(); - Console.WriteLine($"{impact} — {categories[i].Description}"); + Console.WriteLine($"{impact}{blocked} — {categories[i].Description}"); } else { - Console.WriteLine($" {categories[i].Name.PadRight(18)}{impact} — {categories[i].Description}"); + Console.WriteLine($" {categories[i].Name.PadRight(18)}{impact}{blocked} — {categories[i].Description}"); } } @@ -684,6 +730,14 @@ private static string PromptForCategory() return categories[selected].Name; } + + private static string ImpactLevelName(int impact) => impact switch + { + 3 => "major", + 2 => "minor", + 1 => "patch", + _ => "none" + }; } readonly struct Output diff --git a/ChangeSharp.Mcp/Program.cs b/ChangeSharp.Mcp/Program.cs index 8bae741..e3286d8 100644 --- a/ChangeSharp.Mcp/Program.cs +++ b/ChangeSharp.Mcp/Program.cs @@ -84,7 +84,10 @@ static async Task Main(string[] args) inputSchema = new { type = "object", - properties = new { } + properties = new + { + apiMinLevel = new { type = "string", description = "Optional minimum API impact level (patch, minor, major). Fails if fragments are below this level." } + } } }, new @@ -96,7 +99,9 @@ static async Task Main(string[] args) type = "object", properties = new { - dryRun = new { type = "boolean", description = "If true, only preview the changes without applying them." } + dryRun = new { type = "boolean", description = "If true, only preview the changes without applying them." }, + allowMajor = new { type = "boolean", description = "Allow a release whose impact exceeds SemverPolicy.MaxImpact." }, + apiMinLevel = new { type = "string", description = "Optional minimum API impact level (patch, minor, major). Fails if fragments are below this level." } } } } @@ -164,6 +169,26 @@ private static async Task HandleToolCall(string? name, JsonNode? args) var validationResults = Manager.Validate(); if (validationResults.Count == 0 || validationResults.All(r => r.IsValid)) { + string? apiMinLevel = args?["apiMinLevel"]?.ToString(); + if (apiMinLevel != null) + { + var (pass, maxImpact, maxLevelName) = Manager.CheckApiMinLevel(apiMinLevel); + if (!pass) + { + return new + { + content = new[] + { + new + { + type = "text", + text = $"API surface requires at least a '{apiMinLevel}' bump, but fragments only reach '{maxLevelName}' (level {maxImpact})." + } + }, + isError = true + }; + } + } return new { content = new[] { new { type = "text", text = "All fragments are valid." } } }; } var errors = string.Join("\n", validationResults.Where(r => !r.IsValid).Select(r => $"- {r.FilePath}: {string.Join(", ", r.Errors)}")); @@ -182,9 +207,52 @@ private static async Task HandleToolCall(string? name, JsonNode? args) case "perform_release": bool dryRun = args?["dryRun"]?.GetValue() ?? false; + bool allowMajor = args?["allowMajor"]?.GetValue() ?? false; if (!dryRun) { + string? apiMinLevel = args?["apiMinLevel"]?.ToString(); + if (apiMinLevel != null) + { + var (pass, _, _) = Manager.CheckApiMinLevel(apiMinLevel); + if (!pass) + { + return new + { + content = new[] + { + new + { + type = "text", + text = $"API surface requires at least a '{apiMinLevel}' bump, but fragments are below this level." + } + }, + isError = true + }; + } + } + + if (!allowMajor) + { + var (withinCap, maxImpact, maxLevelName, _, offendingCategories) = Manager.CheckApiMaxLevel(); + if (!withinCap) + { + string cats = offendingCategories.Length > 0 ? $" ({string.Join(", ", offendingCategories)})" : ""; + return new + { + content = new[] + { + new + { + type = "text", + text = $"Release would bump to '{maxLevelName}' (level {maxImpact}), above the configured SemverPolicy.MaxImpact{cats}. Set allowMajor: true to proceed." + } + }, + isError = true + }; + } + } + // Check Security config from changesharp.json var config = Manager.LoadConfig(); if (config.Security.RequireApproval || config.Security.AllowAgentRelease == false) diff --git a/ChangeSharp.Tests/ChangeSharp.Tests.csproj b/ChangeSharp.Tests/ChangeSharp.Tests.csproj index 60c4d1c..8dcef57 100644 --- a/ChangeSharp.Tests/ChangeSharp.Tests.csproj +++ b/ChangeSharp.Tests/ChangeSharp.Tests.csproj @@ -19,6 +19,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/ChangeSharp.Tests/PublicApiBaselineTests.cs b/ChangeSharp.Tests/PublicApiBaselineTests.cs new file mode 100644 index 0000000..78a29b2 --- /dev/null +++ b/ChangeSharp.Tests/PublicApiBaselineTests.cs @@ -0,0 +1,88 @@ +using NUnit.Framework; + +namespace ChangeSharp.Tests; + +public class PublicApiBaselineTests +{ + private const string BaselineRelativePath = "tests/public-api/public-api.txt"; + private const string UpdateEnvVar = "CHANGESHARP_UPDATE_API_BASELINE"; + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + if (File.Exists(Path.Combine(dir.FullName, "ChangeSharp.sln"))) + return dir.FullName; + dir = dir.Parent; + } + throw new DirectoryNotFoundException("Could not locate the repository root (ChangeSharp.sln not found)."); + } + + private static string GeneratePublicApi() + { + string api = PublicApiGenerator.ApiGenerator.GeneratePublicApi(typeof(WorkspaceManager).Assembly); + return api.Replace("\r\n", "\n").TrimEnd() + "\n"; + } + + [Test] + public void LibraryPublicApi_MatchesCommittedBaseline() + { + string api = GeneratePublicApi(); + string baselinePath = Path.Combine(FindRepoRoot(), BaselineRelativePath); + + if (Environment.GetEnvironmentVariable(UpdateEnvVar) == "1") + { + Directory.CreateDirectory(Path.GetDirectoryName(baselinePath)!); + File.WriteAllText(baselinePath, api); + return; + } + + if (!File.Exists(baselinePath)) + { + Assert.Fail( + $"Public API baseline missing at '{baselinePath}'. " + + "Regenerate it with CHANGESHARP_UPDATE_API_BASELINE=1 (or scripts/update-public-api.sh) and commit the result."); + } + + string baseline = File.ReadAllText(baselinePath).Replace("\r\n", "\n"); + + if (api != baseline) + { + string? diff = null; + try + { + diff = Diff(api, baseline); + } + catch + { + // best-effort diff display + } + Assert.Fail( + "The library public API changed but the committed baseline is not up to date.\n" + + "If the change is intentional, regenerate the baseline with CHANGESHARP_UPDATE_API_BASELINE=1 " + + "(or scripts/update-public-api.sh) and commit it.\n" + + (diff != null ? $"Diff:\n{diff}" : "")); + } + } + + private static string Diff(string current, string baseline) + { + string currentPath = Path.GetTempFileName(); + string baselinePath = Path.GetTempFileName(); + File.WriteAllText(currentPath, current); + File.WriteAllText(baselinePath, baseline); + var psi = new System.Diagnostics.ProcessStartInfo("git", $"diff --no-index --unified=3 {baselinePath} {currentPath}") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + using var process = System.Diagnostics.Process.Start(psi)!; + string output = process.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd(); + process.WaitForExit(); + File.Delete(currentPath); + File.Delete(baselinePath); + return output; + } +} \ No newline at end of file diff --git a/ChangeSharp.Tests/WorkspaceManagerTests.cs b/ChangeSharp.Tests/WorkspaceManagerTests.cs index 514eb0a..bde163e 100644 --- a/ChangeSharp.Tests/WorkspaceManagerTests.cs +++ b/ChangeSharp.Tests/WorkspaceManagerTests.cs @@ -24,6 +24,27 @@ public void TearDown() } } + private void WriteConfig(string maxImpact) + { + string config = $$""" + { + "SemverPolicy": { + "MaxImpact": "{{maxImpact}}", + "Mappings": { + "Breaking Changes": "Major", + "Removed": "Major", + "Changed": "Minor", + "Added": "Minor", + "Deprecated": "Minor", + "Fixed": "Patch", + "Security": "Patch" + } + } + } + """; + File.WriteAllText(Path.Combine(_testDir, "changesharp.json"), config); + } + [Test] public void Release_NormalWorkflow_Works() { @@ -548,6 +569,152 @@ public void CheckApiMinLevel_InvalidLevel_ThrowsEvenWithoutFragments() Assert.Throws(() => manager.CheckApiMinLevel("foo")); } + [Test] + public void CheckApiMaxLevel_NoFragments_AlwaysPasses() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + WriteConfig("minor"); + + var (pass, impact, name, maxAllowed, offending) = manager.CheckApiMaxLevel(); + + Assert.That(pass, Is.True); + Assert.That(impact, Is.EqualTo(0)); + Assert.That(name, Is.EqualTo("none")); + Assert.That(maxAllowed, Is.EqualTo(2)); + Assert.That(offending, Is.Empty); + } + + [Test] + public void CheckApiMaxLevel_DefaultMajor_AllowsEverything() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + manager.CreateFragment("Breaking API change", "Breaking Changes"); + + var (pass, impact, name, maxAllowed, offending) = manager.CheckApiMaxLevel(); + + Assert.That(pass, Is.True); + Assert.That(impact, Is.EqualTo(3)); + Assert.That(name, Is.EqualTo("major")); + Assert.That(maxAllowed, Is.EqualTo(3)); + Assert.That(offending, Is.Empty); + } + + [Test] + public void CheckApiMaxLevel_FixedFragment_PassesMinorCap() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + WriteConfig("minor"); + manager.CreateFragment("Fix a bug", "Fixed"); + + var (pass, _, _, maxAllowed, offending) = manager.CheckApiMaxLevel(); + + Assert.That(pass, Is.True); + Assert.That(maxAllowed, Is.EqualTo(2)); + Assert.That(offending, Is.Empty); + } + + [Test] + public void CheckApiMaxLevel_BreakingFragment_FailsMinorCap() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + WriteConfig("minor"); + manager.CreateFragment("Breaking API change", "Breaking Changes"); + + var (pass, impact, name, maxAllowed, offending) = manager.CheckApiMaxLevel(); + + Assert.That(pass, Is.False); + Assert.That(impact, Is.EqualTo(3)); + Assert.That(name, Is.EqualTo("major")); + Assert.That(maxAllowed, Is.EqualTo(2)); + Assert.That(offending, Is.EqualTo(new[] { "Breaking Changes" })); + } + + [Test] + public void CheckApiMaxLevel_MixedFragments_ReportsOnlyOffendingCategories() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + WriteConfig("minor"); + manager.CreateFragment("Fix a bug", "Fixed"); + manager.CreateFragment("New feature", "Added"); + manager.CreateFragment("Breaking API change", "Breaking Changes"); + + var (pass, _, _, _, offending) = manager.CheckApiMaxLevel(); + + Assert.That(pass, Is.False); + Assert.That(offending, Is.EqualTo(new[] { "Breaking Changes" })); + } + + [Test] + public void CheckApiMaxLevel_InvalidMaxImpact_Throws() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + WriteConfig("galactic"); + + Assert.Throws(() => manager.CheckApiMaxLevel()); + } + + [Test] + public void IsCategoryWithinMaxImpact_DefaultMajor_AllowsMajorCategories() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + + var (allowed, impact, maxAllowed) = manager.IsCategoryWithinMaxImpact("Breaking Changes"); + + Assert.That(allowed, Is.True); + Assert.That(impact, Is.EqualTo(3)); + Assert.That(maxAllowed, Is.EqualTo(3)); + } + + [Test] + public void IsCategoryWithinMaxImpact_MinorCap_BlocksMajorCategories() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + WriteConfig("minor"); + + var (breakingAllowed, breakingImpact, maxAllowed) = manager.IsCategoryWithinMaxImpact("Breaking Changes"); + Assert.That(breakingAllowed, Is.False); + Assert.That(breakingImpact, Is.EqualTo(3)); + Assert.That(maxAllowed, Is.EqualTo(2)); + + var (removedAllowed, _, _) = manager.IsCategoryWithinMaxImpact("Removed"); + Assert.That(removedAllowed, Is.False); + + var (addedAllowed, _, _) = manager.IsCategoryWithinMaxImpact("Added"); + Assert.That(addedAllowed, Is.True); + } + + [Test] + public void IsCategoryWithinMaxImpact_UnknownCategory_Allowed() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + WriteConfig("minor"); + + var (allowed, impact, maxAllowed) = manager.IsCategoryWithinMaxImpact("Nonsense"); + + Assert.That(allowed, Is.True); + Assert.That(impact, Is.EqualTo(0)); + Assert.That(maxAllowed, Is.EqualTo(2)); + } + + [Test] + public void IsCategoryWithinMaxImpact_InvalidMaxImpact_Throws() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + WriteConfig("galactic"); + + Assert.Throws(() => manager.IsCategoryWithinMaxImpact("Added")); + } + [Test] public void ListFragmentFiles_EmptyDir_ReturnsEmpty() { diff --git a/ChangeSharp/ChangeSharpConfig.cs b/ChangeSharp/ChangeSharpConfig.cs index d6e3304..679714d 100644 --- a/ChangeSharp/ChangeSharpConfig.cs +++ b/ChangeSharp/ChangeSharpConfig.cs @@ -37,6 +37,8 @@ public class SemverPolicyConfig { "Fixed", "Patch" }, { "Security", "Patch" } }; + + public string MaxImpact { get; set; } = "major"; } public class PreReleaseConfig diff --git a/ChangeSharp/WorkspaceManager.cs b/ChangeSharp/WorkspaceManager.cs index 6bd1873..2b87ef3 100644 --- a/ChangeSharp/WorkspaceManager.cs +++ b/ChangeSharp/WorkspaceManager.cs @@ -248,12 +248,58 @@ public List Validate() if (count == 0) return (true, 0, "none"); + var (maxImpact, maxLevelName) = ComputeMaxImpact(merged, config.SemverPolicy); + return (maxImpact >= required, maxImpact, maxLevelName); + } + + public (bool Pass, int MaxImpact, string MaxLevelName, int MaxAllowed, string[] OffendingCategories) CheckApiMaxLevel() + { + var config = LoadConfig(); + int maxAllowed = NextVersionComputer.ParseImpact(config.SemverPolicy.MaxImpact); + if (maxAllowed == 0) + throw new ArgumentException($"Invalid SemverPolicy.MaxImpact '{config.SemverPolicy.MaxImpact}'. Valid values: patch, minor, major.", "MaxImpact"); + + GetStatus(out int count, out ChangeSet merged, out _, out _); + + // No fragments → nothing to check, always pass + if (count == 0) + return (true, 0, "none", maxAllowed, Array.Empty()); + + var (maxImpact, maxLevelName) = ComputeMaxImpact(merged, config.SemverPolicy); + + var offendingCategories = merged.Sections + .Where(pair => pair.Value.Count > 0 + && config.SemverPolicy.Mappings.TryGetValue(pair.Key, out var impact) + && NextVersionComputer.ParseImpact(impact) > maxAllowed) + .Select(pair => pair.Key) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return (maxImpact <= maxAllowed, maxImpact, maxLevelName, maxAllowed, offendingCategories); + } + + public (bool Allowed, int CategoryImpact, int MaxAllowed) IsCategoryWithinMaxImpact(string category) + { + var config = LoadConfig(); + int maxAllowed = NextVersionComputer.ParseImpact(config.SemverPolicy.MaxImpact); + if (maxAllowed == 0) + throw new ArgumentException($"Invalid SemverPolicy.MaxImpact '{config.SemverPolicy.MaxImpact}'. Valid values: patch, minor, major.", "MaxImpact"); + + if (!config.SemverPolicy.Mappings.TryGetValue(category, out var impact)) + return (true, 0, maxAllowed); + + int categoryImpact = NextVersionComputer.ParseImpact(impact); + return (categoryImpact <= maxAllowed, categoryImpact, maxAllowed); + } + + private static (int MaxImpact, string MaxLevelName) ComputeMaxImpact(ChangeSet merged, SemverPolicyConfig policy) + { int maxImpact = 0; string maxLevelName = "none"; foreach (var pair in merged.Sections) { - if (pair.Value.Count > 0 && config.SemverPolicy.Mappings.TryGetValue(pair.Key, out var impact)) + if (pair.Value.Count > 0 && policy.Mappings.TryGetValue(pair.Key, out var impact)) { int impactValue = NextVersionComputer.ParseImpact(impact); if (impactValue > maxImpact) @@ -264,7 +310,7 @@ public List Validate() } } - return (maxImpact >= required, maxImpact, maxLevelName); + return (maxImpact, maxLevelName); } public string[] ListFragmentFiles() diff --git a/README.md b/README.md index 3ab49f2..523ba15 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,8 @@ See the [documentation site](https://manuc66.github.io/ChangeSharp/) — quick s - **AI-ready** — built-in MCP server for AI agent integration - **Version propagation** — MSBuild, JSON, and regex target handlers - **Pre-release channels** — branch-based pre-release workflows -- **Safety gates** — `--api-min-level`, `--require-approval`, `--dry-run` +- **Safety gates** — `--api-min-level` (floor), `SemverPolicy.MaxImpact` + `--allow-major` (cap), `--require-approval`, `--dry-run` +- **Dogfooded API surface gate** — ChangeSharp gates its own CLI/MCP/library surfaces via committed baselines in `tests/public-api/`; see [API Surface Gate](docs/features/ApiSurfaceGate.md) ## License diff --git a/changesharp.json b/changesharp.json index e012db1..891b893 100644 --- a/changesharp.json +++ b/changesharp.json @@ -36,6 +36,7 @@ "MaxIdentifierLength": 30 }, "SemverPolicy": { + "MaxImpact": "major", "Mappings": { "Breaking Changes": "Major", "Removed": "Major", diff --git a/docs/ChangeSharp.md b/docs/ChangeSharp.md index bdc7c72..c505549 100644 --- a/docs/ChangeSharp.md +++ b/docs/ChangeSharp.md @@ -32,7 +32,7 @@ In the .NET ecosystem, release versioning is heavily dominated by tools like **G ChangeSharp distinguishes itself as the **changelog-driven versioning for .NET with CI-ready gates and AI-native integration**: * **Natively Integrated in .NET**: First-class support for MSBuild, .NET Global Tools, and C# library usage. -* **Safety Gates (The Differentiator)**: Unlike simple fragment managers, ChangeSharp enforces a **minimum API impact level** (`--api-min-level`): the CI computes the actual API surface impact with its own diff tool, and ChangeSharp fails the build if the declared fragments are below that level. +* **Safety Gates (The Differentiator)**: Unlike simple fragment managers, ChangeSharp enforces a **minimum API impact level** (`--api-min-level`): the CI computes the actual API surface impact with its own diff tool, and ChangeSharp fails the build if the declared fragments are below that level. The symmetric **max-impact cap** (`SemverPolicy.MaxImpact` + `--allow-major`) blocks fragments/releases that would force a Major bump the team does not want. ChangeSharp dogfoods both gates on its own CLI, MCP, and library surfaces — see [API Surface Gate](features/ApiSurfaceGate.md). * **Separation of Concerns**: Git history is for developers; changelogs are for users. * **Conflict-Free Workflows**: Independent Markdown fragments eliminate merge conflicts on `CHANGELOG.md`. * **AI-Native (MCP Layer)**: First tool to expose changelog management to AI agents via the **Model Context Protocol (MCP)**, with built-in security approval gates. diff --git a/docs/McpIntegration.md b/docs/McpIntegration.md index 7398582..536089f 100644 --- a/docs/McpIntegration.md +++ b/docs/McpIntegration.md @@ -8,8 +8,10 @@ The MCP server exposes the following tools: - `get_status`: Get the count of pending fragments, the current version, and the next calculated version. - `create_fragment`: Create a new change fragment with a message and a category. -- `validate_fragments`: Ensure all pending fragments follow the correct format. -- `perform_release`: Execute a release, aggregate changes into the changelog, and bump project versions. +- `validate_fragments`: Ensure all pending fragments follow the correct format. Optional `apiMinLevel` (`patch` | `minor` | `major`) mirrors the CLI `--api-min-level` gate: fails if the fragments' declared impact is below this level. +- `perform_release`: Execute a release, aggregate changes into the changelog, and bump project versions. Mirrors the CLI safety gates: optional `allowMajor` (bypass `SemverPolicy.MaxImpact` cap) and optional `apiMinLevel`. + +The safety gates exposed by the CLI (`--api-min-level`, `--allow-major` / `SemverPolicy.MaxImpact`) behave identically through the MCP tools. > **Security Warning**: In enterprise environments, AI agents should NOT be allowed to perform a release without human approval. It is highly recommended to use the `--dry-run` flag or implement a mandatory approval gate in your CI/CD pipeline before the final release is pushed. diff --git a/docs/Roadmap.md b/docs/Roadmap.md index 072d35d..462ce0e 100644 --- a/docs/Roadmap.md +++ b/docs/Roadmap.md @@ -137,6 +137,8 @@ ChangeSharp.MCP → AI/tooling adapter (Interface only) changesharp release --api-min-level major # release gate ``` * **Full decoupling**: ChangeSharp does not perform the diff — the team owns the diff tool. ChangeSharp only enforces the policy. +* **Max impact cap**: the symmetric guard `SemverPolicy.MaxImpact` (+ `--allow-major`) blocks fragments/releases that would force a Major bump the team does not want. Enforced at `new` and `release`. See [SemVer Rules](SemVer%20Rules.md). +* **Dogfooding**: ChangeSharp gates its own CLI, MCP, and library surfaces via committed baselines (`tests/public-api/`) regenerated by `scripts/update-public-api.sh`, enforced by the `api-surface` CI job and the `PublicApiBaselineTests` build-time test. * See [API Surface Gate Specification](features/ApiSurfaceGate.md). ### Step 17: Multi-Team Monorepo Scoping (Priority 2) diff --git a/docs/SemVer Rules.md b/docs/SemVer Rules.md index 7093f87..090d4ed 100644 --- a/docs/SemVer Rules.md +++ b/docs/SemVer Rules.md @@ -56,3 +56,35 @@ ChangeSharp compares the required level against the fragments' declared categori * **CLI Tools**: Compare help output or command schemas. ChangeSharp does **not** perform the API diff itself — it only enforces the policy. See [ApiSurfaceGate](features/ApiSurfaceGate.md) for details. + +## ⛔ Maximum Impact Cap (`SemverPolicy.MaxImpact`) + +`--api-min-level` is a **floor**: it guarantees fragments are not lower than the real API impact. `SemverPolicy.MaxImpact` is the symmetric **cap**: it guarantees no fragment silently forces a Major bump when the team does not want one. + +```json +{ + "SemverPolicy": { + "MaxImpact": "minor" + } +} +``` + +`MaxImpact` accepts `patch`, `minor`, or `major`. The default is `major`, which disables the cap entirely — existing projects are unaffected until they opt in. + +### Where it is enforced + +| Step | Behavior | +| --- | --- | +| `changesharp new` (flags) | Category above the cap → refused (exit 3) unless `--allow-major` | +| `changesharp new` (interactive) | Blocked categories are marked `⚠ blocked (MaxImpact)` in the menu and re-prompted | +| `changesharp validate` | Does **not** enforce the cap (format check only) | +| `changesharp release` | Refused (exit 3) unless `--allow-major` — the production gate (human-in-the-loop) | + +```bash +changesharp new --breaking # ❌ refused above the cap +changesharp new --breaking --allow-major # ✅ deliberate +changesharp release # ❌ refused above the cap +changesharp release --allow-major # ✅ deliberate +``` + +The `--allow-major` flag is the explicit opt-in at both creation and release, so a Major requires two deliberate decisions. A runnable demo lives in `samples/maximpact-gate/`. diff --git a/docs/features/ApiSurfaceGate.md b/docs/features/ApiSurfaceGate.md index 8010dfa..ce8d5a4 100644 --- a/docs/features/ApiSurfaceGate.md +++ b/docs/features/ApiSurfaceGate.md @@ -97,3 +97,41 @@ if (parseResult.GetValue(apiMinLevelOption) is string minLevel) ``` ~20 lines of code. No provider, no JSON parsing, no external integration. + +## ChangeSharp dogfoods it + +ChangeSharp applies the API Surface Gate to **its own** public surfaces. Committed baselines in `tests/public-api/` snapshot: + +| Baseline | Covers | Generated by | +| --- | --- | --- | +| `cli-help.txt` | CLI commands and options (root + subcommands) | `scripts/update-public-api.sh` | +| `mcp-tools.json` | MCP `tools/list` (names + input schema) | `scripts/update-public-api.sh` | +| `public-api.txt` | Library public API | `PublicApiGenerator` (test `PublicApiBaselineTests`) | + +CI job `api-surface` (`.github/workflows/dotnet.yml`): + +1. Regenerates the baselines and **fails** if the committed ones are out of date (`run scripts/update-public-api.sh`). +2. Diffs them against `origin/main` and derives the minimum impact: additions → `minor`, removals/renames → `major`, none → `patch`. +3. Runs `changesharp validate --api-min-level ` so the PR's fragments cannot be lower than the real surface change. + +The library baseline is also enforced at build time by `PublicApiBaselineTests` (part of `dotnet test`). + +When you change a public surface, update the baselines and add a fragment that matches the impact: + +```bash +scripts/update-public-api.sh +changesharp new --added "Add the new --foo option to status" +``` + +## Symmetric guard: the max-impact cap + +The API Surface Gate is a **floor** (fragments must not be lower than the real +impact). ChangeSharp also provides the symmetric **cap** so a fragment can never +silently force a Major bump the team does not want: + +* `SemverPolicy.MaxImpact` in `changesharp.json` (default `major` = cap off). +* Enforced at `changesharp new` (creation) and `changesharp release` (production), + both requiring the explicit `--allow-major` flag to proceed. + +See [SemVer Rules](../SemVer%20Rules.md) for details, and +`samples/maximpact-gate/` for a runnable demo. diff --git a/docs/index.md b/docs/index.md index bdc7c72..c505549 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,7 +32,7 @@ In the .NET ecosystem, release versioning is heavily dominated by tools like **G ChangeSharp distinguishes itself as the **changelog-driven versioning for .NET with CI-ready gates and AI-native integration**: * **Natively Integrated in .NET**: First-class support for MSBuild, .NET Global Tools, and C# library usage. -* **Safety Gates (The Differentiator)**: Unlike simple fragment managers, ChangeSharp enforces a **minimum API impact level** (`--api-min-level`): the CI computes the actual API surface impact with its own diff tool, and ChangeSharp fails the build if the declared fragments are below that level. +* **Safety Gates (The Differentiator)**: Unlike simple fragment managers, ChangeSharp enforces a **minimum API impact level** (`--api-min-level`): the CI computes the actual API surface impact with its own diff tool, and ChangeSharp fails the build if the declared fragments are below that level. The symmetric **max-impact cap** (`SemverPolicy.MaxImpact` + `--allow-major`) blocks fragments/releases that would force a Major bump the team does not want. ChangeSharp dogfoods both gates on its own CLI, MCP, and library surfaces — see [API Surface Gate](features/ApiSurfaceGate.md). * **Separation of Concerns**: Git history is for developers; changelogs are for users. * **Conflict-Free Workflows**: Independent Markdown fragments eliminate merge conflicts on `CHANGELOG.md`. * **AI-Native (MCP Layer)**: First tool to expose changelog management to AI agents via the **Model Context Protocol (MCP)**, with built-in security approval gates. diff --git a/samples/maximpact-gate/.gitignore b/samples/maximpact-gate/.gitignore new file mode 100644 index 0000000..0b1f4e3 --- /dev/null +++ b/samples/maximpact-gate/.gitignore @@ -0,0 +1,2 @@ +CHANGELOG.md +.changesharp/ \ No newline at end of file diff --git a/samples/maximpact-gate/README.md b/samples/maximpact-gate/README.md new file mode 100644 index 0000000..9a75f3f --- /dev/null +++ b/samples/maximpact-gate/README.md @@ -0,0 +1,49 @@ +# MaxImpact Gate — Sample Workspace + +This sample shows ChangeSharp's **max-impact cap**: a team policy that refuses +fragments (and releases) whose declared impact would force a **Major** bump +unless an author explicitly opts in. + +It is the mirror of the `--api-min-level` floor gate: `--api-min-level` says +*fragments must not be lower than the real API impact*; `MaxImpact` says *fragments +must not bump higher than the team allows*. + +## Configuration + +`changesharp.json` in this folder sets `SemverPolicy.MaxImpact` to `minor`: + +```json +{ + "SemverPolicy": { + "MaxImpact": "minor" + } +} +``` + +Impact levels supported: `major` (default, no cap), `minor`, `patch`. + +## What is enforced, and where + +| Step | Behavior | +| --- | --- | +| `changesharp new --breaking` | Refused at creation (exit 3) — the author must use `--allow-major` | +| `changesharp new` (interactive) | Blocked categories are marked `⚠ blocked (MaxImpact)` in the menu and re-prompted | +| `changesharp validate` | Does **not** enforce the cap (format check only) | +| `changesharp release` | Refused without `--allow-major` — the production gate (human-in-the-loop) | +| `changesharp release --allow-major` | Proceeds, deliberate Major recorded | + +## Run the demo + +```bash +samples/maximpact-gate/run-demo.sh +``` + +The script builds the ChangeSharp CLI from the repo and asserts the exit codes +for every scenario above. It is self-cleaning (no files are left behind). + +## Notes + +- With the default `MaxImpact: major` the cap is disabled and behavior is + unchanged, so existing projects are unaffected until they opt in. +- The gate is enforced by ChangeSharp itself: no external API-diff tool is + involved, unlike `--api-min-level` which receives its level from CI. \ No newline at end of file diff --git a/samples/maximpact-gate/changesharp.json b/samples/maximpact-gate/changesharp.json new file mode 100644 index 0000000..0f554ed --- /dev/null +++ b/samples/maximpact-gate/changesharp.json @@ -0,0 +1,14 @@ +{ + "SemverPolicy": { + "MaxImpact": "minor", + "Mappings": { + "Breaking Changes": "Major", + "Removed": "Major", + "Changed": "Minor", + "Added": "Minor", + "Deprecated": "Minor", + "Fixed": "Patch", + "Security": "Patch" + } + } +} \ No newline at end of file diff --git a/samples/maximpact-gate/run-demo.sh b/samples/maximpact-gate/run-demo.sh new file mode 100755 index 0000000..d546f66 --- /dev/null +++ b/samples/maximpact-gate/run-demo.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# +# Demo of the SemverPolicy.MaxImpact gate (the "max-impact cap"). +# +# Run from anywhere: +# samples/maximpact-gate/run-demo.sh +# +# It builds the ChangeSharp CLI from the repo, then exercises the gate inside +# this sample workspace. Exit code 0 = every check passed. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$ROOT/../.." && pwd)" +CLI_DLL="$REPO/ChangeSharp.Cli/bin/Release/net10.0/ChangeSharp.Cli.dll" + +echo ">>> Building ChangeSharp CLI (Release) ..." +dotnet build "$REPO/ChangeSharp.Cli/ChangeSharp.Cli.csproj" -c Release --nologo >/dev/null || { echo "Build failed." >&2; exit 1; } + +cd "$ROOT" + +PASS=0 +FAIL=0 + +# check