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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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