Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
### Added
- SemverPolicy.MaxImpact cap: block fragments/releases that would force a Major bump unless --allow-major is passed (new + release)
- Sample workspace samples/maximpact-gate demonstrating the MaxImpact cap (run-demo.sh)
- Dogfood the API Surface Gate on ChangeSharp itself: committed baselines (CLI help, MCP tools, library public API) + update script + api-surface CI job + PublicApiBaselineTests
- Expose the safety gates on MCP tools: validate_fragments apiMinLevel, perform_release allowMajor/apiMinLevel
- Unify safety-gate orchestration in the library (GetCreateFragmentError, GetReleaseGateResult) so the CLI and MCP share the same gate sequence
- Record explicit --allow-major decisions in release output (audit trail, CLI + MCP)

### Fixed
- Reduce CodeFactor cognitive-complexity findings in the interactive category menu and version-bump computation (behavior-preserving refactor)
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Added
- Add command: append a change to the open changelist (--separate/--fragment/--changelist), trunk-safe on the default branch
- SemverPolicy.BranchMaxImpact: per-branch impact caps (e.g. release/* only accepts fixes)
- Validate the public API surface with a single command (changesharp validate --api-surface): regenerate surfaces, check baselines, derive impact vs origin/main, gate fragments - CI job reduced to one step
45 changes: 45 additions & 0 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,51 @@ jobs:
if: github.event_name == 'push'
run: changesharp validate

api-surface:
# Dogfoods the API Surface Gate on ChangeSharp's own public surfaces.
# One command does it all: regenerates the surfaces in memory, checks the
# committed baselines, derives the impact vs origin/main, and gates the
# fragments. 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: 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: Validate the public API surface
run: changesharp validate --api-surface

demo:
# Dogfoods the MaxImpact gate end-to-end through the CLI and MCP server:
# add/new at creation, release gate, --allow-major, branch caps, MCP
# create_fragment/perform_release. See samples/maximpact-gate/README.md.
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v5

- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x

- name: Run the max-impact-gate demo (CLI + MCP)
run: samples/maximpact-gate/run-demo.sh

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
Expand Down
1 change: 1 addition & 0 deletions ChangeSharp.Cli/ChangeSharp.Cli.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.CommandLine" Version="2.0.9" />
<PackageReference Include="PublicApiGenerator" Version="11.5.4" />
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
Expand Down
924 changes: 612 additions & 312 deletions ChangeSharp.Cli/Program.cs

Large diffs are not rendered by default.

111 changes: 56 additions & 55 deletions ChangeSharp.Mcp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,57 +50,7 @@ static async Task Main(string[] args)
{
SendResponse(id, new
{
tools = new object[]
{
new
{
name = "get_status",
description = "Get the status of unreleased fragments and the next computed version.",
inputSchema = new
{
type = "object",
properties = new { }
}
},
new
{
name = "create_fragment",
description = "Create a new unreleased change fragment.",
inputSchema = new
{
type = "object",
properties = new
{
message = new { type = "string", description = "The description of the change." },
category = new { type = "string", description = "The category of the change (e.g., Added, Fixed, Changed, Removed)." }
},
required = new[] { "message", "category" }
}
},
new
{
name = "validate_fragments",
description = "Validate all unreleased fragments.",
inputSchema = new
{
type = "object",
properties = new { }
}
},
new
{
name = "perform_release",
description = "Perform a release by aggregating fragments and bumping versions.",
inputSchema = new
{
type = "object",
properties = new
{
dryRun = new { type = "boolean", description = "If true, only preview the changes without applying them." }
}
}
}
}
tools = McpToolCatalog.Tools
});
}
else if (method == "tools/call")
Expand Down Expand Up @@ -147,15 +97,30 @@ private static async Task<object> HandleToolCall(string? name, JsonNode? args)
case "create_fragment":
var message = args?["message"]?.ToString() ?? "";
var category = args?["category"]?.ToString() ?? "Added";
string path = Manager.CreateFragment(message, category);
bool allowFragmentMajor = args?["allowMajor"]?.GetValue<bool>() ?? false;
bool separateFragment = args?["separate"]?.GetValue<bool>() ?? false;
string? fragmentTarget = args?["fragment"]?.ToString();
string? changelistName = args?["changelist"]?.ToString();
string? fragmentError = Manager.GetCreateFragmentError(category, allowFragmentMajor);
if (fragmentError != null)
{
return new
{
content = new[] { new { type = "text", text = fragmentError } },
isError = true
};
}
var (fragmentPath, appended, formattedCategory) = Manager.AppendFragment(message, category, separateFragment, fragmentTarget, changelistName);
return new
{
content = new[]
{
new
{
type = "text",
text = $"Fragment created: {Path.GetFileName(path)}"
text = appended
? $"Added to {Path.GetFileName(fragmentPath)} under '{formattedCategory}'"
: $"Fragment created: {Path.GetFileName(fragmentPath)}"
}
}
};
Expand All @@ -164,6 +129,26 @@ private static async Task<object> 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)}"));
Expand All @@ -182,9 +167,22 @@ private static async Task<object> HandleToolCall(string? name, JsonNode? args)

case "perform_release":
bool dryRun = args?["dryRun"]?.GetValue<bool>() ?? false;
bool allowMajor = args?["allowMajor"]?.GetValue<bool>() ?? false;
var gate = (Blocked: false, Message: "", CapExceeded: false);

if (!dryRun)
{
string? apiMinLevel = args?["apiMinLevel"]?.ToString();
gate = Manager.GetReleaseGateResult(apiMinLevel, allowMajor);
if (gate.Blocked)
{
return new
{
content = new[] { new { type = "text", text = gate.Message } },
isError = true
};
}

// Check Security config from changesharp.json
var config = Manager.LoadConfig();
if (config.Security.RequireApproval || config.Security.AllowAgentRelease == false)
Expand All @@ -211,8 +209,11 @@ private static async Task<object> HandleToolCall(string? name, JsonNode? args)
try
{
var (version, releaseWarnings) = Manager.Release(DateTime.Today, dryRun);
string warningText = releaseWarnings.Length > 0
? "\nWarnings:\n" + string.Join("\n", releaseWarnings.Select(w => $" - {w}"))
var allWarnings = releaseWarnings.ToList();
if (!dryRun && gate.CapExceeded)
allWarnings.Add("Major bump explicitly allowed via allowMajor.");
string warningText = allWarnings.Count > 0
? "\nWarnings:\n" + string.Join("\n", allWarnings.Select(w => $" - {w}"))
: "";
return new
{
Expand Down
1 change: 1 addition & 0 deletions ChangeSharp.Tests/ChangeSharp.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="PublicApiGenerator" Version="11.5.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ChangeSharp\ChangeSharp.csproj" />
Expand Down
88 changes: 88 additions & 0 deletions ChangeSharp.Tests/PublicApiBaselineTests.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading