diff --git a/.changesharp/unreleased/20260822194832497-featsafety-gates-and-api-surface-dogfooding-changelist.md b/.changesharp/unreleased/20260822194832497-featsafety-gates-and-api-surface-dogfooding-changelist.md new file mode 100644 index 0000000..bd4be65 --- /dev/null +++ b/.changesharp/unreleased/20260822194832497-featsafety-gates-and-api-surface-dogfooding-changelist.md @@ -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) \ No newline at end of file diff --git a/.changesharp/unreleased/20260822194943762-featfragment-changelist-ux-changelist.md b/.changesharp/unreleased/20260822194943762-featfragment-changelist-ux-changelist.md new file mode 100644 index 0000000..2da7b78 --- /dev/null +++ b/.changesharp/unreleased/20260822194943762-featfragment-changelist-ux-changelist.md @@ -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 diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 4dbc3d7..2dd97af 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -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 diff --git a/ChangeSharp.Cli/ChangeSharp.Cli.csproj b/ChangeSharp.Cli/ChangeSharp.Cli.csproj index 31dbb54..f88360d 100644 --- a/ChangeSharp.Cli/ChangeSharp.Cli.csproj +++ b/ChangeSharp.Cli/ChangeSharp.Cli.csproj @@ -5,6 +5,7 @@ + Exe diff --git a/ChangeSharp.Cli/Program.cs b/ChangeSharp.Cli/Program.cs index a3ba4f1..32dc6f6 100644 --- a/ChangeSharp.Cli/Program.cs +++ b/ChangeSharp.Cli/Program.cs @@ -1,5 +1,6 @@ using System.CommandLine; using System.CommandLine.Parsing; +using System.Globalization; using System.Text.Json; namespace ChangeSharp.Cli; @@ -12,16 +13,55 @@ class Program internal const int ExitCodeValidationError = 3; internal const int ExitCodeConflict = 4; + private static readonly Option JsonOption = new("--json") { Description = "Output in JSON format for machine consumption." }; + private static readonly Argument MessageArgument = new("message") + { + Description = "Description of the changes.", + Arity = ArgumentArity.ZeroOrOne + }; + private static readonly Option AddedOption = new("--added") { Description = "Mark change as Added." }; + private static readonly Option ChangedOption = new("--changed") { Description = "Mark change as Changed." }; + private static readonly Option FixedOption = new("--fixed") { Description = "Mark change as Fixed." }; + private static readonly Option RemovedOption = new("--removed") { Description = "Mark change as Removed." }; + private static readonly Option DeprecatedOption = new("--deprecated") { Description = "Mark change as Deprecated." }; + private static readonly Option SecurityOption = new("--security") { Description = "Mark change as Security." }; + private static readonly Option BreakingOption = new("--breaking") { Description = "Mark change as Breaking Changes." }; + private static readonly Option FileOption = new("--file") { Description = "Read the change description from a file instead of the message argument, stdin, or a prompt." }; + private static readonly Option AllowMajorOption = new("--allow-major") { Description = "Allow a fragment whose impact exceeds SemverPolicy.MaxImpact." }; + private static readonly Option ApiMinLevelOption = new("--api-min-level") { Description = "Minimum API impact level (patch, minor, major). Fails if fragments are below this level." }; + private static readonly Option ApiMinLevelWarnOption = new("--api-min-level-warn") { Description = "Only warn if --api-min-level is not met, do not fail." }; + private static readonly Option DryRunOption = new("--dry-run") { Description = "Display what would happen without making any changes." }; + private static readonly Option SeparateOption = new("--separate") { Description = "Create a new fragment file instead of appending to the open changelist." }; + private static readonly Option FragmentTargetOption = new("--fragment") { Description = "Append to a specific fragment file in the unreleased directory." }; + private static readonly Option ChangelistNameOption = new("--changelist") { Description = "Append to (or create) a deterministically named changelist file." }; + static async Task Main(string[] args) { - var rootCommand = new RootCommand("ChangeSharp - Keep a Changelog. Derive the version."); + ParseResult parseResult = CreateRootCommand().Parse(args); + return await parseResult.InvokeAsync(); + } - var jsonOption = new Option("--json") { Description = "Output in JSON format for machine consumption." }; + static RootCommand CreateRootCommand() + { + var rootCommand = new RootCommand("ChangeSharp - Keep a Changelog. Derive the version."); + rootCommand.Add(BuildInitCommand()); + rootCommand.Add(BuildNewCommand()); + rootCommand.Add(BuildAddCommand()); + rootCommand.Add(BuildStatusCommand()); + rootCommand.Add(BuildValidateCommand()); + rootCommand.Add(BuildReleaseCommand()); + rootCommand.Add(BuildPublishCommand()); + rootCommand.Add(BuildPrereleaseCommand()); + rootCommand.Add(BuildRemoveCommand()); + return rootCommand; + } - var initCommand = new Command("init", "Initialize ChangeSharp configuration and directory structure.") { jsonOption }; + private static Command BuildInitCommand() + { + var initCommand = new Command("init", "Initialize ChangeSharp configuration and directory structure.") { JsonOption }; initCommand.SetAction(parseResult => { - var o = Out(parseResult, jsonOption); + var o = Out(parseResult); try { var manager = new WorkspaceManager(); @@ -66,106 +106,152 @@ static async Task Main(string[] args) } catch (Exception ex) { return o.Err(ex.Message); } }); - rootCommand.Add(initCommand); + return initCommand; + } - var messageArgument = new Argument("message") + private static Command BuildNewCommand() + { + var newCommand = new Command("new", "Create a new unreleased changelog fragment.") { - Description = "Description of the changes.", - Arity = ArgumentArity.ZeroOrOne + MessageArgument, AddedOption, ChangedOption, FixedOption, + RemovedOption, DeprecatedOption, SecurityOption, BreakingOption, + FileOption, AllowMajorOption, JsonOption, }; + newCommand.SetAction(parseResult => RunFragmentAction(parseResult, Out(parseResult), forceSeparate: true)); + return newCommand; + } + + private static Command BuildAddCommand() + { + var addCommand = new Command("add", "Add a change to the open changelist, or create a new fragment file.") + { + MessageArgument, AddedOption, ChangedOption, FixedOption, + RemovedOption, DeprecatedOption, SecurityOption, BreakingOption, + FileOption, AllowMajorOption, SeparateOption, FragmentTargetOption, ChangelistNameOption, JsonOption, + }; + addCommand.SetAction(parseResult => RunFragmentAction(parseResult, Out(parseResult), forceSeparate: false)); + return addCommand; + } - var addedOption = new Option("--added") { Description = "Mark change as Added." }; - var changedOption = new Option("--changed") { Description = "Mark change as Changed." }; - var fixedOption = new Option("--fixed") { Description = "Mark change as Fixed." }; - var removedOption = new Option("--removed") { Description = "Mark change as Removed." }; - var deprecatedOption = new Option("--deprecated") { Description = "Mark change as Deprecated." }; - var securityOption = new Option("--security") { Description = "Mark change as Security." }; - var breakingOption = new Option("--breaking") { Description = "Mark change as Breaking Changes." }; + private static int RunFragmentAction(ParseResult parseResult, Output o, bool forceSeparate) + { + var (message, messageError) = ResolveFragmentMessage(parseResult, o); + if (messageError.HasValue) return messageError.Value; - var fileOption = new Option("--file") { Description = "Read the change description from a file instead of the message argument, stdin, or a prompt." }; + string? mappedCategory = CategoryFromFlags( + parseResult.GetValue(BreakingOption), parseResult.GetValue(RemovedOption), + parseResult.GetValue(ChangedOption), parseResult.GetValue(DeprecatedOption), + parseResult.GetValue(FixedOption), parseResult.GetValue(SecurityOption)); - var newCommand = new Command("new", "Create a new unreleased changelog fragment.") + bool anyFlagCategory = parseResult.GetValue(AddedOption) || mappedCategory != null; + bool allowMajor = parseResult.GetValue(AllowMajorOption); + bool separate = forceSeparate || parseResult.GetValue(SeparateOption); + string? fragmentTarget = forceSeparate ? null : parseResult.GetValue(FragmentTargetOption); + string? changelistName = forceSeparate ? null : parseResult.GetValue(ChangelistNameOption); + + while (true) { - messageArgument, addedOption, changedOption, fixedOption, - removedOption, deprecatedOption, securityOption, breakingOption, - fileOption, jsonOption, - }; + string category = ChooseCategory(parseResult, o, allowMajor, anyFlagCategory, mappedCategory, out int? categoryError); + if (categoryError.HasValue) return categoryError.Value; - newCommand.SetAction(parseResult => + int result = TryAppendFragment(o, message, category, allowMajor, anyFlagCategory, separate, fragmentTarget, changelistName); + if (result != RetryInteractive) + return result; + } + } + + private const int RetryInteractive = -1; + + private static (string? Message, int? Error) ResolveFragmentMessage(ParseResult parseResult, Output o) + { + string? message = parseResult.GetValue(MessageArgument); + string? messageFile = parseResult.GetValue(FileOption); + + if (messageFile != null) { - var o = Out(parseResult, jsonOption); - string? message = parseResult.GetValue(messageArgument); + if (!File.Exists(messageFile)) + return (null, o.Err($"File not found: {messageFile}", ExitCodeGenericError)); + return (File.ReadAllText(messageFile).Trim(), null); + } - string? messageFile = parseResult.GetValue(fileOption); - if (messageFile != null) - { - if (!File.Exists(messageFile)) - return o.Err($"File not found: {messageFile}", ExitCodeGenericError); - message = File.ReadAllText(messageFile).Trim(); - } - else if (string.IsNullOrWhiteSpace(message)) - { - message = Console.IsInputRedirected - ? Console.In.ReadToEnd().Trim() - : PromptForMessage(); - } + if (string.IsNullOrWhiteSpace(message)) + { + message = Console.IsInputRedirected + ? Console.In.ReadToEnd().Trim() + : PromptForMessage(); + } - if (string.IsNullOrWhiteSpace(message)) - return o.Err("Description is required.", ExitCodeValidationError); + if (string.IsNullOrWhiteSpace(message)) + return (null, o.Err("Description is required.", ExitCodeValidationError)); - string category; - bool added = parseResult.GetValue(addedOption); - bool changed = parseResult.GetValue(changedOption); - bool fixedOpt = parseResult.GetValue(fixedOption); - bool removed = parseResult.GetValue(removedOption); - bool deprecated = parseResult.GetValue(deprecatedOption); - bool security = parseResult.GetValue(securityOption); - bool breaking = parseResult.GetValue(breakingOption); + return (message, null); + } - bool anyCategoryOptionProvided = added || changed || fixedOpt || removed || deprecated || security || breaking; + private static string ChooseCategory(ParseResult parseResult, Output o, bool allowMajor, bool anyFlagCategory, string? mappedCategory, out int? error) + { + error = null; - if (anyCategoryOptionProvided) - { - category = breaking ? "Breaking Changes" - : removed ? "Removed" - : changed ? "Changed" - : deprecated ? "Deprecated" - : fixedOpt ? "Fixed" - : security ? "Security" - : "Added"; - } - else if (Console.IsInputRedirected) - { - return o.Err("Category is required when non-interactive. Use one of --added, --changed, --fixed, --removed, --deprecated, --security, --breaking.", ExitCodeValidationError); - } - else + if (anyFlagCategory) + return mappedCategory ?? "Added"; + + if (Console.IsInputRedirected) + { + error = o.Err("Category is required when non-interactive. Use one of --added, --changed, --fixed, --removed, --deprecated, --security, --breaking.", ExitCodeValidationError); + return ""; + } + + return PromptForCategory(allowMajor); + } + + private static int TryAppendFragment(Output o, string message, string category, bool allowMajor, bool anyFlagCategory, bool separate, string? fragmentTarget, string? changelistName) + { + try + { + var manager = new WorkspaceManager(); + string? blockReason = manager.GetCreateFragmentError(category, allowMajor); + if (blockReason != null) { - category = PromptForCategory(); + if (anyFlagCategory || Console.IsInputRedirected) + return o.Err(blockReason, ExitCodeValidationError); + Console.WriteLine(); + Console.WriteLine($" {blockReason}"); + Console.WriteLine(" Choose another category, or rerun with --allow-major."); + return RetryInteractive; } - try + var (fragmentPath, appended, formattedCategory) = manager.AppendFragment(message, category, separate, fragmentTarget, changelistName); + return o.Ok(new { - var manager = new WorkspaceManager(); - string filePath = manager.CreateFragment(message, category); - return o.Ok(new - { - filename = Path.GetFileName(filePath), - category, - path = filePath - }, () => Console.WriteLine($"Created fragment: {Path.GetFileName(filePath)} under category '{category}'")); - } - catch (Exception ex) { return o.Err(ex.Message); } - }); - rootCommand.Add(newCommand); + filename = Path.GetFileName(fragmentPath), + category = formattedCategory, + appended, + path = fragmentPath + }, () => Console.WriteLine(appended + ? $"Added to {Path.GetFileName(fragmentPath)} under '{formattedCategory}'" + : $"Created fragment: {Path.GetFileName(fragmentPath)} under category '{formattedCategory}'")); + } + catch (Exception ex) { return o.Err(ex.Message); } + } + private static string? CategoryFromFlags(bool breaking, bool removed, bool changed, bool deprecated, bool fixedOpt, bool security) => + breaking ? "Breaking Changes" + : removed ? "Removed" + : changed ? "Changed" + : deprecated ? "Deprecated" + : fixedOpt ? "Fixed" + : security ? "Security" + : null; + + private static Command BuildStatusCommand() + { var nextOnlyOption = new Option("--next-only") { Description = "Only output the next version number." }; var statusCommand = new Command("status", "Show the status of unreleased fragments and computed version bump.") { - nextOnlyOption, jsonOption + nextOnlyOption, JsonOption }; statusCommand.SetAction(parseResult => { - var o = Out(parseResult, jsonOption); + var o = Out(parseResult); bool nextOnly = parseResult.GetValue(nextOnlyOption); try { @@ -209,18 +295,20 @@ static async Task Main(string[] args) } catch (Exception ex) { return o.Err(ex.Message); } }); - rootCommand.Add(statusCommand); + return statusCommand; + } + private static Command BuildValidateCommand() + { var requireFragmentsOption = new Option("--require-fragments") { Description = "Fail if no unreleased fragments are found." }; - var apiMinLevelOption = new Option("--api-min-level") { Description = "Minimum API impact level (patch, minor, major). Fails if fragments are below this level." }; - var apiMinLevelWarnOption = new Option("--api-min-level-warn") { Description = "Only warn if --api-min-level is not met, do not fail." }; + var apiSurfaceOption = new Option("--api-surface") { Description = "Validate committed public-surface baselines and gate fragments against the derived impact." }; var validateCommand = new Command("validate", "Validate unreleased fragments for correct format.") { - requireFragmentsOption, apiMinLevelOption, apiMinLevelWarnOption, jsonOption + requireFragmentsOption, ApiMinLevelOption, ApiMinLevelWarnOption, apiSurfaceOption, JsonOption }; validateCommand.SetAction(parseResult => { - var o = Out(parseResult, jsonOption); + var o = Out(parseResult); bool requireFragments = parseResult.GetValue(requireFragmentsOption); try { @@ -238,86 +326,76 @@ static async Task Main(string[] args) if (!hasErrors) { - int? apiResult = CheckApiMinLevel(parseResult, manager, apiMinLevelOption, apiMinLevelWarnOption, o); - if (apiResult.HasValue) return apiResult.Value; + if (parseResult.GetValue(apiSurfaceOption)) + { + int surfaceResult = CheckApiSurface(parseResult, manager, o); + if (surfaceResult != 0) return surfaceResult; + } + else + { + int? gateResult = CheckApiMinLevelGate(parseResult, manager, o); + if (gateResult.HasValue) return gateResult.Value; + } } var jsonResults = results.Select(r => new { file = r.FilePath, valid = r.IsValid, errors = r.Errors }).ToList(); - if (hasErrors) - { - return o.Err("Validation failed.", ExitCodeValidationError, new - { - fragmentsValidated = results.Count, - results = jsonResults - }, () => - { - foreach (var r in results) - { - if (r.IsValid) - Console.WriteLine($"\u2713 {r.FilePath}: Valid"); - else - { - Console.WriteLine($"\u2717 {r.FilePath}: Invalid"); - foreach (var e in r.Errors) - Console.WriteLine($" - {e}"); - } - } - Console.WriteLine($"\n{results.Count(r => !r.IsValid)} fragment(s) failed validation."); - }); - } + return hasErrors + ? ReportValidationErrors(o, results, jsonResults) + : ReportValidationSuccess(o, results, jsonResults); + } + catch (Exception ex) { return o.Err(ex.Message); } + }); + return validateCommand; + } - return o.Ok(new - { - fragmentsValidated = results.Count, - results = jsonResults - }, () => + private static int ReportValidationErrors(Output o, List results, object jsonResults) => + o.Err("Validation failed.", ExitCodeValidationError, new + { + fragmentsValidated = results.Count, + results = jsonResults + }, () => + { + foreach (var r in results) + { + if (r.IsValid) + Console.WriteLine($"\u2713 {r.FilePath}: Valid"); + else { - Console.WriteLine("All fragments are valid."); - }); + Console.WriteLine($"\u2717 {r.FilePath}: Invalid"); + foreach (var e in r.Errors) + Console.WriteLine($" - {e}"); + } } - catch (Exception ex) { return o.Err(ex.Message); } + Console.WriteLine($"\n{results.Count(r => !r.IsValid)} fragment(s) failed validation."); }); - rootCommand.Add(validateCommand); - var dryRunOption = new Option("--dry-run") { Description = "Display what would happen without making any changes." }; + private static int ReportValidationSuccess(Output o, List results, object jsonResults) => + o.Ok(new + { + fragmentsValidated = results.Count, + results = jsonResults + }, () => Console.WriteLine("All fragments are valid.")); + + private static Command BuildReleaseCommand() + { var allowEmptyOption = new Option("--allow-empty") { Description = "Exit with success even if no unreleased fragments are found." }; var requireApprovalOption = new Option("--require-approval") { Description = "Require explicit approval (CHANGESHARP_ALLOW_UNSAFE_RELEASE) to proceed." }; + var allowMajorReleaseOption = new Option("--allow-major") { Description = "Allow a release whose impact exceeds SemverPolicy.MaxImpact." }; var releaseCommand = new Command("release", "Aggregate fragments, bump version, update CHANGELOG.md, and clean up.") { - dryRunOption, allowEmptyOption, requireApprovalOption, - apiMinLevelOption, apiMinLevelWarnOption, jsonOption + DryRunOption, allowEmptyOption, requireApprovalOption, + ApiMinLevelOption, ApiMinLevelWarnOption, allowMajorReleaseOption, JsonOption }; releaseCommand.SetAction(parseResult => { - var o = Out(parseResult, jsonOption); - bool dryRun = parseResult.GetValue(dryRunOption); + var o = Out(parseResult); + bool dryRun = ResolveDryRun(parseResult, o); bool allowEmpty = parseResult.GetValue(allowEmptyOption); bool requireApproval = parseResult.GetValue(requireApprovalOption); - if (!dryRun) - { - try - { - var manager = new WorkspaceManager(); - if (manager.ShouldDryRunByDefault()) - { - dryRun = true; - o.Warn("Security.DryRunByDefault is enabled; running in dry-run mode."); - } - } - catch (InvalidOperationException ex) - { - o.Warn($"Could not read config to check Security.DryRunByDefault: {ex.Message}"); - } - } - - if (requireApproval && !dryRun) - { - string? envAllow = Environment.GetEnvironmentVariable("CHANGESHARP_ALLOW_UNSAFE_RELEASE"); - if (envAllow != "true") - return o.Err("Release blocked by --require-approval. Set CHANGESHARP_ALLOW_UNSAFE_RELEASE=true to proceed.", ExitCodeGenericError, new { blockedBy = "approval_gate" }); - } + int? approvalError = CheckApproval(requireApproval, dryRun, o); + if (approvalError.HasValue) return approvalError.Value; try { @@ -336,46 +414,21 @@ static async Task Main(string[] args) if (dryRun) { - return o.Ok(new - { - dryRun = true, - currentVersion = current, - nextVersion = next, - changes = merged.ToChangelogString(), - sections = merged.Sections.ToDictionary(kv => kv.Key, kv => kv.Value), - fragmentCount = count, - versionTargets = targets - }, () => - { - Console.WriteLine("[Dry Run] Release would perform the following actions:"); - Console.WriteLine($"- Update CHANGELOG.md with a new version section: [{next}]"); - Console.WriteLine($"- Add the following changes to CHANGELOG.md:"); - Console.WriteLine(merged.ToChangelogString()); - Console.WriteLine($"- Delete {count} fragment(s) from the unreleased directory."); - - if (targets.Any()) - { - Console.WriteLine($"- Propagate version {next} to the following files:"); - foreach (var target in targets) - Console.WriteLine($" * {target}"); - } - else - { - Console.WriteLine("- No version propagation targets configured."); - } - Console.WriteLine(); - Console.WriteLine("[Dry Run] No files were actually modified."); - }); + return ShowDryRun(o, current, next, merged, count, targets); } - int? apiResult = CheckApiMinLevel(parseResult, manager, apiMinLevelOption, apiMinLevelWarnOption, o); - if (apiResult.HasValue) return apiResult.Value; + var gate = manager.GetReleaseGateResult( + parseResult.GetValue(ApiMinLevelOption), parseResult.GetValue(allowMajorReleaseOption)); - var (nextVersion, releaseWarnings) = manager.Release(DateTime.Today, dryRun); - foreach (var w in releaseWarnings) - Console.Error.WriteLine($"Warning: {w}"); - return o.Ok(new { releasedVersion = nextVersion, warnings = releaseWarnings }, - () => Console.WriteLine($"Release successful! New version: {nextVersion}")); + if (gate.Blocked) + { + bool warnOnly = parseResult.GetValue(ApiMinLevelWarnOption); + if (gate.CapExceeded || !warnOnly) + return o.Err(gate.Message, ExitCodeValidationError); + o.Warn(gate.Message); + } + + return FinishRelease(manager, o, dryRun, gate); } catch (InvalidOperationException ex) when (ex.Message.Contains("Conflict")) { @@ -383,16 +436,64 @@ static async Task Main(string[] args) } catch (Exception ex) { return o.Err(ex.Message); } }); - rootCommand.Add(releaseCommand); + return releaseCommand; + } + + private static bool ResolveDryRun(ParseResult parseResult, Output o) + { + bool dryRun = parseResult.GetValue(DryRunOption); + if (dryRun) return true; + try + { + var manager = new WorkspaceManager(); + if (manager.ShouldDryRunByDefault()) + { + o.Warn("Security.DryRunByDefault is enabled; running in dry-run mode."); + return true; + } + } + catch (InvalidOperationException ex) + { + o.Warn($"Could not read config to check Security.DryRunByDefault: {ex.Message}"); + } + + return false; + } + + private static int? CheckApproval(bool requireApproval, bool dryRun, Output o) + { + if (!requireApproval || dryRun) return null; + + string? envAllow = Environment.GetEnvironmentVariable("CHANGESHARP_ALLOW_UNSAFE_RELEASE"); + if (envAllow != "true") + return o.Err("Release blocked by --require-approval. Set CHANGESHARP_ALLOW_UNSAFE_RELEASE=true to proceed.", ExitCodeGenericError, new { blockedBy = "approval_gate" }); + return null; + } + + private static int FinishRelease(WorkspaceManager manager, Output o, bool dryRun, (bool Blocked, string Message, bool CapExceeded) gate) + { + var (nextVersion, releaseWarnings) = manager.Release(DateTime.Today, dryRun); + var allWarnings = releaseWarnings.Append(gate.CapExceeded ? "Major bump explicitly allowed via --allow-major." : null) + .Where(w => w != null) + .Cast() + .ToList(); + foreach (var w in allWarnings) + Console.Error.WriteLine($"Warning: {w}"); + return o.Ok(new { releasedVersion = nextVersion, warnings = allWarnings }, + () => Console.WriteLine($"Release successful! New version: {nextVersion}")); + } + + private static Command BuildPublishCommand() + { var versionOption = new Option("--version") { Description = "Specific released version to output (default: latest)." }; var publishCommand = new Command("publish", "Output a released version and its changelog segment (for creating a forge release).") { - versionOption, jsonOption + versionOption, JsonOption }; publishCommand.SetAction(parseResult => { - var o = Out(parseResult, jsonOption); + var o = Out(parseResult); try { var manager = new WorkspaceManager(); @@ -411,26 +512,29 @@ static async Task Main(string[] args) } catch (Exception ex) { return o.Err(ex.Message); } }); - rootCommand.Add(publishCommand); + return publishCommand; + } - var branchOption = new Option("--branch") { Description = "Specific branch name to use for pre-release." }; - var listOption = new Option("--list") { Description = "List all active pre-releases." }; + private static Command BuildPrereleaseCommand() + { + var branchOption = new Option("--branch") { Description = "Specific branch name to use for pre-release." }; + var listOption = new Option("--list") { Description = "List all active pre-releases." }; var promoteOption = new Option("--promote") { Description = "Promote the latest pre-release to a final release." }; var channelOption = new Option("--channel") { Description = "Optional release channel (e.g. alpha, beta, rc)." }; var prereleaseCommand = new Command("prerelease", "Handle pre-release versions based on branches.") { - branchOption, listOption, promoteOption, channelOption, dryRunOption, jsonOption + branchOption, listOption, promoteOption, channelOption, DryRunOption, JsonOption }; prereleaseCommand.SetAction(parseResult => { - var o = Out(parseResult, jsonOption); + var o = Out(parseResult); string? branch = parseResult.GetValue(branchOption); - bool list = parseResult.GetValue(listOption); - bool promote = parseResult.GetValue(promoteOption); + bool list = parseResult.GetValue(listOption); + bool promote = parseResult.GetValue(promoteOption); string? channel = parseResult.GetValue(channelOption); - bool dryRun = parseResult.GetValue(dryRunOption); + bool dryRun = parseResult.GetValue(DryRunOption); try { @@ -478,9 +582,12 @@ static async Task Main(string[] args) } catch (Exception ex) { return o.Err(ex.Message); } }); - rootCommand.Add(prereleaseCommand); + return prereleaseCommand; + } - var listOption2 = new Option("--list") { Description = "List all unreleased fragments." }; + private static Command BuildRemoveCommand() + { + var listOption = new Option("--list") { Description = "List all unreleased fragments." }; var allOption = new Option("--all") { Description = "Remove all unreleased fragments." }; var yesOption = new Option("--yes") { Description = "Skip confirmation for --all." }; var fragmentArgument = new Argument("fragment") @@ -490,12 +597,12 @@ static async Task Main(string[] args) }; var removeCommand = new Command("remove", "Remove an unreleased changelog fragment.") { - fragmentArgument, listOption2, allOption, yesOption, jsonOption + fragmentArgument, listOption, allOption, yesOption, JsonOption }; removeCommand.SetAction(parseResult => { - var o = Out(parseResult, jsonOption); - bool list = parseResult.GetValue(listOption2); + var o = Out(parseResult); + bool list = parseResult.GetValue(listOption); bool all = parseResult.GetValue(allOption); bool yes = parseResult.GetValue(yesOption); string? fragment = parseResult.GetValue(fragmentArgument); @@ -503,104 +610,288 @@ static async Task Main(string[] args) try { var manager = new WorkspaceManager(); - var files = manager.ListFragmentFiles(); - var shortNames = files.Select(Path.GetFileName).ToArray(); + var shortNames = manager.ListFragmentFiles().Select(Path.GetFileName).ToArray(); if (list) - { - return o.Ok(new { fragments = shortNames }, () => - { - if (shortNames.Length == 0) - Console.WriteLine("No unreleased fragments found."); - else - { - Console.WriteLine("Unreleased fragments:"); - foreach (var f in shortNames) - Console.WriteLine($" {f}"); - } - }); - } + return ShowFragmentList(o, shortNames); if (all) - { - if (shortNames.Length == 0) - return o.Ok(new { removed = 0 }, () => Console.WriteLine("No unreleased fragments found.")); + return RemoveAllFragments(manager, o, shortNames, yes); - if (!yes) - { - Console.Error.WriteLine($"This will remove {shortNames.Length} fragment(s):"); - foreach (var f in shortNames) - Console.Error.WriteLine($" {f}"); - Console.Error.Write("Are you sure? (y/N): "); - var response = Console.ReadLine()?.Trim().ToLowerInvariant(); - if (response != "y" && response != "yes") - return o.Ok(new { removed = 0 }, () => Console.WriteLine("Removal cancelled.")); - } + if (fragment == null) + return ShowRemoveUsage(o, shortNames); - int count = manager.RemoveAllFragments(); - return o.Ok(new { removed = count }, - () => Console.WriteLine($"Removed {count} fragment(s).")); - } + return RemoveSingleFragment(manager, o, fragment); + } + catch (Exception ex) { return o.Err(ex.Message); } + }); + return removeCommand; + } - if (fragment == null) - { - if (shortNames.Length == 0) - return o.Ok(new { fragments = Array.Empty() }, - () => Console.WriteLine("No unreleased fragments found.")); + private static int ShowFragmentList(Output o, string[] shortNames) => + o.Ok(new { fragments = shortNames }, () => + { + if (shortNames.Length == 0) + Console.WriteLine("No unreleased fragments found."); + else + { + Console.WriteLine("Unreleased fragments:"); + foreach (var f in shortNames) + Console.WriteLine($" {f}"); + } + }); - return o.Ok(new { fragments = shortNames }, () => - { - Console.WriteLine("Usage: changesharp remove "); - Console.WriteLine(" changesharp remove --list"); - Console.WriteLine(" changesharp remove --all"); - Console.WriteLine(); - Console.WriteLine("Available fragments:"); - foreach (var f in shortNames) - Console.WriteLine($" {f}"); - }); - } + private static int RemoveAllFragments(WorkspaceManager manager, Output o, string[] shortNames, bool yes) + { + if (shortNames.Length == 0) + return o.Ok(new { removed = 0 }, () => Console.WriteLine("No unreleased fragments found.")); - string fullPath = files.FirstOrDefault(f => - Path.GetFileName(f).Equals(fragment, StringComparison.OrdinalIgnoreCase) || - f.EndsWith(fragment, StringComparison.OrdinalIgnoreCase)) ?? ""; + if (!yes) + { + Console.Error.WriteLine($"This will remove {shortNames.Length} fragment(s):"); + foreach (var f in shortNames) + Console.Error.WriteLine($" {f}"); + Console.Error.Write("Are you sure? (y/N): "); + var response = Console.ReadLine()?.Trim().ToLowerInvariant(); + if (response != "y" && response != "yes") + return o.Ok(new { removed = 0 }, () => Console.WriteLine("Removal cancelled.")); + } - if (string.IsNullOrEmpty(fullPath) || !manager.RemoveFragment(fullPath)) - return o.Err($"Fragment '{fragment}' not found.", ExitCodeGenericError); + int count = manager.RemoveAllFragments(); + return o.Ok(new { removed = count }, + () => Console.WriteLine($"Removed {count} fragment(s).")); + } - return o.Ok(new { removed = true, fragment }, - () => Console.WriteLine($"Removed fragment: {fragment}")); + private static int ShowRemoveUsage(Output o, string[] shortNames) => + o.Ok(new { fragments = shortNames }, () => + { + if (shortNames.Length == 0) + { + Console.WriteLine("No unreleased fragments found."); + return; } - catch (Exception ex) { return o.Err(ex.Message); } + + Console.WriteLine("Usage: changesharp remove "); + Console.WriteLine(" changesharp remove --list"); + Console.WriteLine(" changesharp remove --all"); + Console.WriteLine(); + Console.WriteLine("Available fragments:"); + foreach (var f in shortNames) + Console.WriteLine($" {f}"); }); - rootCommand.Add(removeCommand); - ParseResult parseResult = rootCommand.Parse(args); - return await parseResult.InvokeAsync(); + private static int RemoveSingleFragment(WorkspaceManager manager, Output o, string fragment) + { + var files = manager.ListFragmentFiles(); + string fullPath = files.FirstOrDefault(f => + Path.GetFileName(f).Equals(fragment, StringComparison.OrdinalIgnoreCase) || + f.EndsWith(fragment, StringComparison.OrdinalIgnoreCase)) ?? ""; + + if (string.IsNullOrEmpty(fullPath) || !manager.RemoveFragment(fullPath)) + return o.Err($"Fragment '{fragment}' not found.", ExitCodeGenericError); + + return o.Ok(new { removed = true, fragment }, + () => Console.WriteLine($"Removed fragment: {fragment}")); } - private static Output Out(ParseResult pr, Option jsonOption) => - new(pr.GetValue(jsonOption)); + private static Output Out(ParseResult pr) => + new(pr.GetValue(JsonOption)); - private static int? CheckApiMinLevel(ParseResult parseResult, WorkspaceManager manager, - Option apiMinLevelOption, Option apiMinLevelWarnOption, Output o) + private static int? CheckApiMinLevelGate(ParseResult parseResult, WorkspaceManager manager, Output o) { - string? minLevel = parseResult.GetValue(apiMinLevelOption); - if (minLevel == null) return null; - - bool warnOnly = parseResult.GetValue(apiMinLevelWarnOption); - var (pass, maxImpact, maxLevelName) = manager.CheckApiMinLevel(minLevel); + string? apiMinLevelValue = parseResult.GetValue(ApiMinLevelOption); + if (apiMinLevelValue == null) return null; + bool warnOnly = parseResult.GetValue(ApiMinLevelWarnOption); + var (pass, maxImpact, maxLevelName) = manager.CheckApiMinLevel(apiMinLevelValue); if (pass) return null; - string message = $"API surface requires at least a '{minLevel}' bump, but fragments only reach '{maxLevelName}' (level {maxImpact})."; - + string message = $"API surface requires at least a '{apiMinLevelValue}' bump, but fragments only reach '{maxLevelName}' (level {maxImpact})."; if (warnOnly) - { o.Warn(message); - return null; + else + return o.Err(message, ExitCodeValidationError); + return null; + } + + private static int ShowDryRun(Output o, string current, string next, ChangeSet merged, int count, IEnumerable targets) + { + var targetsList = targets.ToList(); + return o.Ok(new + { + dryRun = true, + currentVersion = current, + nextVersion = next, + changes = merged.ToChangelogString(), + sections = merged.Sections.ToDictionary(kv => kv.Key, kv => kv.Value), + fragmentCount = count, + versionTargets = targetsList + }, () => + { + Console.WriteLine("[Dry Run] Release would perform the following actions:"); + Console.WriteLine($"- Update CHANGELOG.md with a new version section: [{next}]"); + Console.WriteLine("- Add the following changes to CHANGELOG.md:"); + Console.WriteLine(merged.ToChangelogString()); + Console.WriteLine($"- Delete {count} fragment(s) from the unreleased directory."); + + if (targetsList.Any()) + { + Console.WriteLine($"- Propagate version {next} to the following files:"); + foreach (var target in targetsList) + Console.WriteLine($" * {target}"); + } + else + { + Console.WriteLine("- No version propagation targets configured."); + } + Console.WriteLine(); + Console.WriteLine("[Dry Run] No files were actually modified."); + }); + } + + private static int CheckApiSurface(ParseResult parseResult, WorkspaceManager manager, Output o) + { + string baselineDir = Path.Combine(Directory.GetCurrentDirectory(), "tests", "public-api"); + if (!Directory.Exists(baselineDir)) + return o.Err($"No public-surface baselines found in '{baselineDir}'. Run scripts/update-public-api.sh first.", ExitCodeGenericError); + + var generated = GenerateSurfaces(); + var committed = ReadBaselines(baselineDir); + + var (match, message) = PublicSurfaceValidator.BaselinesMatch(generated, committed); + if (!match) + return o.Err(message ?? "Public-surface baselines are out of date.", ExitCodeValidationError); + + var baseSurface = ReadBaselinesFromGit("origin/main"); + string level = PublicSurfaceValidator.DeriveImpact(baseSurface, committed); + + if (level != "patch") + { + manager.GetStatus(out int count, out _, out _, out _); + if (count == 0) + return o.Err($"API surface changed (impact '{level}'), but no fragments are recorded. Add a fragment at or above this level.", ExitCodeValidationError); + + var (pass, maxImpact, maxLevelName) = manager.CheckApiMinLevel(level); + if (!pass) + return o.Err($"API surface changed (impact '{level}'), but fragments only reach '{maxLevelName}' (level {maxImpact}).", ExitCodeValidationError); + } + + return o.Ok(new { impact = level, baselines = generated.Keys.OrderBy(k => k).ToList() }, + () => Console.WriteLine($"Public-surface baselines up to date (impact '{level}').")); + } + + private static Dictionary GenerateSurfaces() + { + var originalCulture = CultureInfo.CurrentCulture; + var originalUiCulture = CultureInfo.CurrentUICulture; + + try + { + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture; + + var surfaces = new Dictionary(StringComparer.Ordinal) + { + ["cli-help.txt"] = GenerateCliHelp(CreateRootCommand()), + ["mcp-tools.json"] = JsonSerializer.Serialize(McpToolCatalog.Tools, new JsonSerializerOptions { WriteIndented = true }), + ["public-api.txt"] = GeneratePublicApi() + }; + + return surfaces; } + finally + { + CultureInfo.CurrentCulture = originalCulture; + CultureInfo.CurrentUICulture = originalUiCulture; + } + } + + private static string GenerateCliHelp(RootCommand root) + { + var originalOut = Console.Out; + var originalCulture = CultureInfo.CurrentCulture; + var originalUiCulture = CultureInfo.CurrentUICulture; + + try + { + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture; - return o.Err(message, ExitCodeValidationError); + using var sw = new StringWriter(); + Console.SetOut(sw); + + foreach (var cmd in new[] { root }.Concat(root.Subcommands)) + { + if (cmd != root) + { + sw.WriteLine(); + sw.WriteLine($"##### {cmd.Name} #####"); + sw.WriteLine(); + } + + string[] args = cmd == root ? new[] { "--help" } : new[] { cmd.Name, "--help" }; + root.Parse(args).InvokeAsync().GetAwaiter().GetResult(); + } + + return sw.ToString(); + } + finally + { + Console.SetOut(originalOut); + CultureInfo.CurrentCulture = originalCulture; + CultureInfo.CurrentUICulture = originalUiCulture; + } + } + + private static string GeneratePublicApi() + { + string api = PublicApiGenerator.ApiGenerator.GeneratePublicApi(typeof(WorkspaceManager).Assembly); + return api.Replace("\r\n", "\n").TrimEnd() + "\n"; + } + + private static Dictionary ReadBaselines(string dir) + { + var files = new Dictionary(StringComparer.Ordinal); + foreach (var file in Directory.GetFiles(dir)) + files[Path.GetFileName(file)] = File.ReadAllText(file); + return files; + } + + private static Dictionary? ReadBaselinesFromGit(string rev) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var name in new[] { "cli-help.txt", "mcp-tools.json", "public-api.txt" }) + { + string? content = TryGitShow($"{rev}:tests/public-api/{name}"); + if (content != null) + result[name] = content; + } + return result.Count > 0 ? result : null; + } + + private static string? TryGitShow(string spec) + { + try + { + var psi = new System.Diagnostics.ProcessStartInfo("git") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + psi.ArgumentList.Add("show"); + psi.ArgumentList.Add(spec); + using var process = System.Diagnostics.Process.Start(psi); + if (process == null) return null; + string output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + return process.ExitCode == 0 ? output : null; + } + catch + { + return null; + } } private static string? PromptForMessage() @@ -609,7 +900,7 @@ private static Output Out(ParseResult pr, Option jsonOption) => return Console.ReadLine(); } - private static string PromptForCategory() + private static string PromptForCategory(bool allowMajor) { var categories = new (string Name, string Description)[] { @@ -622,67 +913,76 @@ private static string PromptForCategory() ("Breaking Changes", "Backward-incompatible change") }; - Dictionary? impacts = null; + SemverPolicyConfig? policy = null; try { - impacts = new WorkspaceManager().LoadConfig().SemverPolicy.Mappings; + policy = new WorkspaceManager().LoadConfig().SemverPolicy; } catch { // impact display is best-effort } + int maxAllowed = policy == null ? 3 : NextVersionComputer.ParseImpact(policy.MaxImpact); + + string? impactOf(string name) => + policy?.Mappings.TryGetValue(name, out var v) == true ? v : null; + + bool isBlocked(string name) => + !allowMajor && impactOf(name) is { } impact && NextVersionComputer.ParseImpact(impact) > maxAllowed; + int selected = 0; Console.WriteLine("Select a category (↑/↓ to navigate, Enter to confirm, Esc to cancel, 1-7 to jump):"); while (true) { - for (int i = 0; i < categories.Length; i++) - { - Console.CursorLeft = 0; - string impact = impacts != null && impacts.TryGetValue(categories[i].Name, out var v) ? $" ({v})" : ""; - if (i == selected) - { - Console.Write("> "); - Console.BackgroundColor = ConsoleColor.DarkBlue; - Console.ForegroundColor = ConsoleColor.White; - Console.Write(categories[i].Name.PadRight(18)); - Console.ResetColor(); - Console.WriteLine($"{impact} — {categories[i].Description}"); - } - else - { - Console.WriteLine($" {categories[i].Name.PadRight(18)}{impact} — {categories[i].Description}"); - } - } - + RenderCategoryMenu(categories, selected, impactOf, isBlocked); var key = Console.ReadKey(true); - if (key.Key == ConsoleKey.UpArrow && selected > 0) - { - selected--; - } - else if (key.Key == ConsoleKey.DownArrow && selected < categories.Length - 1) - { - selected++; - } - else if (key.Key == ConsoleKey.Enter) + var next = ApplyCategoryKey(key, categories.Length, selected); + if (next.IsFinal) { + selected = next.Selected; break; } - else if (key.Key == ConsoleKey.Escape) + selected = next.Selected; + Console.CursorTop -= categories.Length; + } + + return categories[selected].Name; + } + + private static void RenderCategoryMenu( + (string Name, string Description)[] categories, int selected, + Func impactOf, Func isBlocked) + { + for (int i = 0; i < categories.Length; i++) + { + Console.CursorLeft = 0; + string impact = impactOf(categories[i].Name) is { } v ? $" ({v})" : ""; + string blocked = isBlocked(categories[i].Name) ? " ⚠ blocked (MaxImpact)" : ""; + if (i == selected) { - selected = 0; - break; + Console.Write("> "); + Console.BackgroundColor = ConsoleColor.DarkBlue; + Console.ForegroundColor = ConsoleColor.White; + Console.Write(categories[i].Name.PadRight(18)); + Console.ResetColor(); + Console.WriteLine($"{impact}{blocked} — {categories[i].Description}"); } - else if (key.Key >= ConsoleKey.D1 && key.Key <= ConsoleKey.D7) + else { - selected = key.Key - ConsoleKey.D1; - break; + Console.WriteLine($" {categories[i].Name.PadRight(18)}{impact}{blocked} — {categories[i].Description}"); } - - Console.CursorTop -= categories.Length; } + } - return categories[selected].Name; + private static (bool IsFinal, int Selected) ApplyCategoryKey(ConsoleKeyInfo key, int count, int selected) + { + if (key.Key == ConsoleKey.UpArrow && selected > 0) return (false, selected - 1); + if (key.Key == ConsoleKey.DownArrow && selected < count - 1) return (false, selected + 1); + if (key.Key == ConsoleKey.Escape) return (true, 0); + if (key.Key >= ConsoleKey.D1 && key.Key <= ConsoleKey.D7) return (true, key.Key - ConsoleKey.D1); + if (key.Key == ConsoleKey.Enter) return (true, selected); + return (false, selected); } } @@ -728,4 +1028,4 @@ public void Warn(string message) else Console.WriteLine($"Warning: {message}"); } -} +} \ No newline at end of file diff --git a/ChangeSharp.Mcp/Program.cs b/ChangeSharp.Mcp/Program.cs index 8bae741..ca5e204 100644 --- a/ChangeSharp.Mcp/Program.cs +++ b/ChangeSharp.Mcp/Program.cs @@ -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") @@ -147,7 +97,20 @@ private static async Task 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() ?? false; + bool separateFragment = args?["separate"]?.GetValue() ?? 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[] @@ -155,7 +118,9 @@ private static async Task HandleToolCall(string? name, JsonNode? args) new { type = "text", - text = $"Fragment created: {Path.GetFileName(path)}" + text = appended + ? $"Added to {Path.GetFileName(fragmentPath)} under '{formattedCategory}'" + : $"Fragment created: {Path.GetFileName(fragmentPath)}" } } }; @@ -164,6 +129,26 @@ private static async Task HandleToolCall(string? name, JsonNode? args) var validationResults = Manager.Validate(); if (validationResults.Count == 0 || validationResults.All(r => r.IsValid)) { + string? apiMinLevel = args?["apiMinLevel"]?.ToString(); + if (apiMinLevel != null) + { + var (pass, maxImpact, maxLevelName) = Manager.CheckApiMinLevel(apiMinLevel); + if (!pass) + { + return new + { + content = new[] + { + new + { + type = "text", + text = $"API surface requires at least a '{apiMinLevel}' bump, but fragments only reach '{maxLevelName}' (level {maxImpact})." + } + }, + isError = true + }; + } + } return new { content = new[] { new { type = "text", text = "All fragments are valid." } } }; } var errors = string.Join("\n", validationResults.Where(r => !r.IsValid).Select(r => $"- {r.FilePath}: {string.Join(", ", r.Errors)}")); @@ -182,9 +167,22 @@ private static async Task HandleToolCall(string? name, JsonNode? args) case "perform_release": bool dryRun = args?["dryRun"]?.GetValue() ?? false; + bool allowMajor = args?["allowMajor"]?.GetValue() ?? 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) @@ -211,8 +209,11 @@ private static async Task 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 { diff --git a/ChangeSharp.Tests/ChangeSharp.Tests.csproj b/ChangeSharp.Tests/ChangeSharp.Tests.csproj index 60c4d1c..8dcef57 100644 --- a/ChangeSharp.Tests/ChangeSharp.Tests.csproj +++ b/ChangeSharp.Tests/ChangeSharp.Tests.csproj @@ -19,6 +19,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/ChangeSharp.Tests/PublicApiBaselineTests.cs b/ChangeSharp.Tests/PublicApiBaselineTests.cs new file mode 100644 index 0000000..78a29b2 --- /dev/null +++ b/ChangeSharp.Tests/PublicApiBaselineTests.cs @@ -0,0 +1,88 @@ +using NUnit.Framework; + +namespace ChangeSharp.Tests; + +public class PublicApiBaselineTests +{ + private const string BaselineRelativePath = "tests/public-api/public-api.txt"; + private const string UpdateEnvVar = "CHANGESHARP_UPDATE_API_BASELINE"; + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + if (File.Exists(Path.Combine(dir.FullName, "ChangeSharp.sln"))) + return dir.FullName; + dir = dir.Parent; + } + throw new DirectoryNotFoundException("Could not locate the repository root (ChangeSharp.sln not found)."); + } + + private static string GeneratePublicApi() + { + string api = PublicApiGenerator.ApiGenerator.GeneratePublicApi(typeof(WorkspaceManager).Assembly); + return api.Replace("\r\n", "\n").TrimEnd() + "\n"; + } + + [Test] + public void LibraryPublicApi_MatchesCommittedBaseline() + { + string api = GeneratePublicApi(); + string baselinePath = Path.Combine(FindRepoRoot(), BaselineRelativePath); + + if (Environment.GetEnvironmentVariable(UpdateEnvVar) == "1") + { + Directory.CreateDirectory(Path.GetDirectoryName(baselinePath)!); + File.WriteAllText(baselinePath, api); + return; + } + + if (!File.Exists(baselinePath)) + { + Assert.Fail( + $"Public API baseline missing at '{baselinePath}'. " + + "Regenerate it with CHANGESHARP_UPDATE_API_BASELINE=1 (or scripts/update-public-api.sh) and commit the result."); + } + + string baseline = File.ReadAllText(baselinePath).Replace("\r\n", "\n"); + + if (api != baseline) + { + string? diff = null; + try + { + diff = Diff(api, baseline); + } + catch + { + // best-effort diff display + } + Assert.Fail( + "The library public API changed but the committed baseline is not up to date.\n" + + "If the change is intentional, regenerate the baseline with CHANGESHARP_UPDATE_API_BASELINE=1 " + + "(or scripts/update-public-api.sh) and commit it.\n" + + (diff != null ? $"Diff:\n{diff}" : "")); + } + } + + private static string Diff(string current, string baseline) + { + string currentPath = Path.GetTempFileName(); + string baselinePath = Path.GetTempFileName(); + File.WriteAllText(currentPath, current); + File.WriteAllText(baselinePath, baseline); + var psi = new System.Diagnostics.ProcessStartInfo("git", $"diff --no-index --unified=3 {baselinePath} {currentPath}") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + using var process = System.Diagnostics.Process.Start(psi)!; + string output = process.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd(); + process.WaitForExit(); + File.Delete(currentPath); + File.Delete(baselinePath); + return output; + } +} \ No newline at end of file diff --git a/ChangeSharp.Tests/PublicSurfaceValidatorTests.cs b/ChangeSharp.Tests/PublicSurfaceValidatorTests.cs new file mode 100644 index 0000000..e8aa857 --- /dev/null +++ b/ChangeSharp.Tests/PublicSurfaceValidatorTests.cs @@ -0,0 +1,137 @@ +using NUnit.Framework; + +namespace ChangeSharp.Tests; + +public class PublicSurfaceValidatorTests +{ + private static Dictionary Files(params (string Name, string Content)[] files) => + files.ToDictionary(f => f.Name, f => f.Content, StringComparer.Ordinal); + + [Test] + public void DeriveImpact_NoBase_ReturnsPatch() + { + Assert.That(PublicSurfaceValidator.DeriveImpact(null, Files(("a.txt", "x"))), Is.EqualTo("patch")); + } + + [Test] + public void DeriveImpact_Unchanged_ReturnsPatch() + { + var baseSurface = Files(("a.txt", "line1\nline2")); + var current = Files(("a.txt", "line1\nline2")); + Assert.That(PublicSurfaceValidator.DeriveImpact(baseSurface, current), Is.EqualTo("patch")); + } + + [Test] + public void DeriveImpact_AddedLines_ReturnsMinor() + { + var baseSurface = Files(("a.txt", "line1")); + var current = Files(("a.txt", "line1\nline2\nline3")); + Assert.That(PublicSurfaceValidator.DeriveImpact(baseSurface, current), Is.EqualTo("minor")); + } + + [Test] + public void DeriveImpact_RemovedLines_ReturnsMajor() + { + var baseSurface = Files(("a.txt", "line1\nline2")); + var current = Files(("a.txt", "line1")); + Assert.That(PublicSurfaceValidator.DeriveImpact(baseSurface, current), Is.EqualTo("major")); + } + + [Test] + public void DeriveImpact_NewFile_ReturnsMinor() + { + var baseSurface = Files(("a.txt", "x")); + var current = Files(("a.txt", "x"), ("b.txt", "y")); + Assert.That(PublicSurfaceValidator.DeriveImpact(baseSurface, current), Is.EqualTo("minor")); + } + + [Test] + public void DeriveImpact_RemovedFile_ReturnsMajor() + { + var baseSurface = Files(("a.txt", "x"), ("b.txt", "y")); + var current = Files(("a.txt", "x")); + Assert.That(PublicSurfaceValidator.DeriveImpact(baseSurface, current), Is.EqualTo("major")); + } + + [Test] + public void BaselinesMatch_Identical_ReturnsMatch() + { + var (match, message) = PublicSurfaceValidator.BaselinesMatch(Files(("a.txt", "x")), Files(("a.txt", "x"))); + Assert.That(match, Is.True); + Assert.That(message, Is.Null); + } + + [Test] + public void BaselinesMatch_TextMismatch_ReturnsError() + { + var (match, message) = PublicSurfaceValidator.BaselinesMatch(Files(("a.txt", "x")), Files(("a.txt", "y"))); + Assert.That(match, Is.False); + Assert.That(message, Does.Contain("a.txt")); + } + + [Test] + public void BaselinesMatch_MissingFile_ReturnsError() + { + var (match, _) = PublicSurfaceValidator.BaselinesMatch(Files(("a.txt", "x")), Files()); + Assert.That(match, Is.False); + } + + [Test] + public void BaselinesMatch_JsonFormattingInsensitive() + { + var generated = Files(("t.json", """{"a":[1,2]}""")); + var committed = Files(("t.json", "{\n \"a\": [1, 2]\n}")); + var (match, _) = PublicSurfaceValidator.BaselinesMatch(generated, committed); + Assert.That(match, Is.True); + } + + [Test] + public void BaselinesMatch_JsonSemanticDifference_Fails() + { + var generated = Files(("t.json", """{"a":[1,2]}""")); + var committed = Files(("t.json", """{"a":[1,3]}""")); + var (match, _) = PublicSurfaceValidator.BaselinesMatch(generated, committed); + Assert.That(match, Is.False); + } +} + +public class McpToolCatalogTests +{ + [Test] + public void Tools_ExposeTheFourExpectedTools() + { + var names = McpToolCatalog.Tools.Select(t => t!["name"]!.GetValue()).ToList(); + Assert.That(names, Is.EqualTo(new[] { "get_status", "create_fragment", "validate_fragments", "perform_release" })); + } + + [Test] + public void CreateFragment_HasExpectedInputSchema() + { + var tool = McpToolCatalog.Tools.Single(t => t!["name"]!.GetValue() == "create_fragment"); + var props = tool!["inputSchema"]!["properties"]!.AsObject(); + Assert.That(props.ContainsKey("message"), Is.True); + Assert.That(props.ContainsKey("category"), Is.True); + Assert.That(props.ContainsKey("allowMajor"), Is.True); + Assert.That(props.ContainsKey("separate"), Is.True); + Assert.That(props.ContainsKey("fragment"), Is.True); + Assert.That(props.ContainsKey("changelist"), Is.True); + } + + [Test] + public void PerformRelease_HasExpectedInputSchema() + { + var tool = McpToolCatalog.Tools.Single(t => t!["name"]!.GetValue() == "perform_release"); + var props = tool!["inputSchema"]!["properties"]!.AsObject(); + Assert.That(props.ContainsKey("dryRun"), Is.True); + Assert.That(props.ContainsKey("allowMajor"), Is.True); + Assert.That(props.ContainsKey("apiMinLevel"), Is.True); + } + + [Test] + public void ValidateFragments_HasApiMinLevel() + { + var tool = McpToolCatalog.Tools.Single(t => t!["name"]!.GetValue() == "validate_fragments"); + var props = tool!["inputSchema"]!["properties"]!.AsObject(); + Assert.That(props.ContainsKey("apiMinLevel"), Is.True); + } +} \ No newline at end of file diff --git a/ChangeSharp.Tests/WorkspaceManagerTests.cs b/ChangeSharp.Tests/WorkspaceManagerTests.cs index 514eb0a..5db4e14 100644 --- a/ChangeSharp.Tests/WorkspaceManagerTests.cs +++ b/ChangeSharp.Tests/WorkspaceManagerTests.cs @@ -24,6 +24,72 @@ public void TearDown() } } + private void WriteConfig(string maxImpact) + { + string config = $$""" + { + "SemverPolicy": { + "MaxImpact": "{{maxImpact}}", + "Mappings": { + "Breaking Changes": "Major", + "Removed": "Major", + "Changed": "Minor", + "Added": "Minor", + "Deprecated": "Minor", + "Fixed": "Patch", + "Security": "Patch" + } + } + } + """; + File.WriteAllText(Path.Combine(_testDir, "changesharp.json"), config); + } + + private void WriteConfigWithBranchCap(string globalMaxImpact, string branchPattern, string branchCap) + { + string config = $$""" + { + "SemverPolicy": { + "MaxImpact": "{{globalMaxImpact}}", + "BranchMaxImpact": { + "{{branchPattern}}": "{{branchCap}}" + }, + "Mappings": { + "Breaking Changes": "Major", + "Removed": "Major", + "Changed": "Minor", + "Added": "Minor", + "Deprecated": "Minor", + "Fixed": "Patch", + "Security": "Patch" + } + } + } + """; + File.WriteAllText(Path.Combine(_testDir, "changesharp.json"), config); + } + + private void InitGit(string branch) + { + RunGit("init", "-b", branch); + RunGit("-c", "user.name=ChangeSharp Tests", "-c", "user.email=test@changesharp", "commit", "--allow-empty", "-m", "init"); + } + + private void RunGit(params string[] args) + { + var psi = new System.Diagnostics.ProcessStartInfo("git") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + WorkingDirectory = _testDir + }; + foreach (var arg in args) + psi.ArgumentList.Add(arg); + using var process = System.Diagnostics.Process.Start(psi)!; + process.WaitForExit(); + } + [Test] public void Release_NormalWorkflow_Works() { @@ -548,6 +614,470 @@ public void CheckApiMinLevel_InvalidLevel_ThrowsEvenWithoutFragments() Assert.Throws(() => manager.CheckApiMinLevel("foo")); } + [Test] + public void CheckApiMaxLevel_NoFragments_AlwaysPasses() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + WriteConfig("minor"); + + var (pass, impact, name, maxAllowed, offending) = manager.CheckApiMaxLevel(); + + Assert.That(pass, Is.True); + Assert.That(impact, Is.EqualTo(0)); + Assert.That(name, Is.EqualTo("none")); + Assert.That(maxAllowed, Is.EqualTo(2)); + Assert.That(offending, Is.Empty); + } + + [Test] + public void CheckApiMaxLevel_DefaultMajor_AllowsEverything() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + manager.CreateFragment("Breaking API change", "Breaking Changes"); + + var (pass, impact, name, maxAllowed, offending) = manager.CheckApiMaxLevel(); + + Assert.That(pass, Is.True); + Assert.That(impact, Is.EqualTo(3)); + Assert.That(name, Is.EqualTo("major")); + Assert.That(maxAllowed, Is.EqualTo(3)); + Assert.That(offending, Is.Empty); + } + + [Test] + public void CheckApiMaxLevel_FixedFragment_PassesMinorCap() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + WriteConfig("minor"); + manager.CreateFragment("Fix a bug", "Fixed"); + + var (pass, _, _, maxAllowed, offending) = manager.CheckApiMaxLevel(); + + Assert.That(pass, Is.True); + Assert.That(maxAllowed, Is.EqualTo(2)); + Assert.That(offending, Is.Empty); + } + + [Test] + public void CheckApiMaxLevel_BreakingFragment_FailsMinorCap() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + WriteConfig("minor"); + manager.CreateFragment("Breaking API change", "Breaking Changes"); + + var (pass, impact, name, maxAllowed, offending) = manager.CheckApiMaxLevel(); + + Assert.That(pass, Is.False); + Assert.That(impact, Is.EqualTo(3)); + Assert.That(name, Is.EqualTo("major")); + Assert.That(maxAllowed, Is.EqualTo(2)); + Assert.That(offending, Is.EqualTo(new[] { "Breaking Changes" })); + } + + [Test] + public void CheckApiMaxLevel_MixedFragments_ReportsOnlyOffendingCategories() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + WriteConfig("minor"); + manager.CreateFragment("Fix a bug", "Fixed"); + manager.CreateFragment("New feature", "Added"); + manager.CreateFragment("Breaking API change", "Breaking Changes"); + + var (pass, _, _, _, offending) = manager.CheckApiMaxLevel(); + + Assert.That(pass, Is.False); + Assert.That(offending, Is.EqualTo(new[] { "Breaking Changes" })); + } + + [Test] + public void CheckApiMaxLevel_InvalidMaxImpact_Throws() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + WriteConfig("galactic"); + + Assert.Throws(() => manager.CheckApiMaxLevel()); + } + + [Test] + public void IsCategoryWithinMaxImpact_DefaultMajor_AllowsMajorCategories() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + + var (allowed, impact, maxAllowed) = manager.IsCategoryWithinMaxImpact("Breaking Changes"); + + Assert.That(allowed, Is.True); + Assert.That(impact, Is.EqualTo(3)); + Assert.That(maxAllowed, Is.EqualTo(3)); + } + + [Test] + public void IsCategoryWithinMaxImpact_MinorCap_BlocksMajorCategories() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + WriteConfig("minor"); + + var (breakingAllowed, breakingImpact, maxAllowed) = manager.IsCategoryWithinMaxImpact("Breaking Changes"); + Assert.That(breakingAllowed, Is.False); + Assert.That(breakingImpact, Is.EqualTo(3)); + Assert.That(maxAllowed, Is.EqualTo(2)); + + var (removedAllowed, _, _) = manager.IsCategoryWithinMaxImpact("Removed"); + Assert.That(removedAllowed, Is.False); + + var (addedAllowed, _, _) = manager.IsCategoryWithinMaxImpact("Added"); + Assert.That(addedAllowed, Is.True); + } + + [Test] + public void IsCategoryWithinMaxImpact_UnknownCategory_Allowed() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + WriteConfig("minor"); + + var (allowed, impact, maxAllowed) = manager.IsCategoryWithinMaxImpact("Nonsense"); + + Assert.That(allowed, Is.True); + Assert.That(impact, Is.EqualTo(0)); + Assert.That(maxAllowed, Is.EqualTo(2)); + } + + [Test] + public void IsCategoryWithinMaxImpact_InvalidMaxImpact_Throws() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + WriteConfig("galactic"); + + Assert.Throws(() => manager.IsCategoryWithinMaxImpact("Added")); + } + + [Test] + public void GetCreateFragmentError_BlockedCategory_ReturnsMessage() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + WriteConfig("minor"); + + string? error = manager.GetCreateFragmentError("Breaking Changes", allowMajor: false); + + Assert.That(error, Is.Not.Null); + Assert.That(error, Does.Contain("major")); + Assert.That(error, Does.Contain("--allow-major")); + } + + [Test] + public void GetCreateFragmentError_AllowMajor_ReturnsNull() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + WriteConfig("minor"); + + string? error = manager.GetCreateFragmentError("Breaking Changes", allowMajor: true); + + Assert.That(error, Is.Null); + } + + [Test] + public void GetCreateFragmentError_WithinCap_ReturnsNull() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + WriteConfig("minor"); + + string? error = manager.GetCreateFragmentError("Added", allowMajor: false); + + Assert.That(error, Is.Null); + } + + [Test] + public void GetReleaseGateResult_CapExceededWithoutAllowMajor_Blocks() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + WriteConfig("minor"); + manager.CreateFragment("Breaking API change", "Breaking Changes"); + + var (blocked, message, capExceeded) = manager.GetReleaseGateResult(null, allowMajor: false); + + Assert.That(blocked, Is.True); + Assert.That(capExceeded, Is.True); + Assert.That(message, Does.Contain("major")); + Assert.That(message, Does.Contain("--allow-major")); + } + + [Test] + public void GetReleaseGateResult_AllowMajor_ProceedsButFlagsCapExceeded() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + WriteConfig("minor"); + manager.CreateFragment("Breaking API change", "Breaking Changes"); + + var (blocked, _, capExceeded) = manager.GetReleaseGateResult(null, allowMajor: true); + + Assert.That(blocked, Is.False); + Assert.That(capExceeded, Is.True); + } + + [Test] + public void GetReleaseGateResult_FloorBelowRequired_Blocks() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + manager.CreateFragment("Fix a bug", "Fixed"); + + var (blocked, message, capExceeded) = manager.GetReleaseGateResult("minor", allowMajor: false); + + Assert.That(blocked, Is.True); + Assert.That(capExceeded, Is.False); + Assert.That(message, Does.Contain("API surface requires")); + } + + [Test] + public void GetReleaseGateResult_AllGood_ReturnsNotBlocked() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + WriteConfig("minor"); + manager.CreateFragment("New feature", "Added"); + + var (blocked, _, capExceeded) = manager.GetReleaseGateResult(null, allowMajor: false); + + Assert.That(blocked, Is.False); + Assert.That(capExceeded, Is.False); + } + + [Test] + public void IsDefaultBranch_NoGit_ReturnsFalse() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + + Assert.That(manager.IsDefaultBranch(), Is.False); + } + + [Test] + public void IsDefaultBranch_OnMain_ReturnsTrue() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + InitGit("main"); + + Assert.That(manager.IsDefaultBranch(), Is.True); + } + + [Test] + public void AppendFragment_NoFragments_CreatesNewFile() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + + var (path, appended, category) = manager.AppendFragment("Add search", "Added"); + + Assert.That(appended, Is.False); + Assert.That(category, Is.EqualTo("Added")); + Assert.That(File.Exists(path), Is.True); + Assert.That(manager.ListFragmentFiles().Length, Is.EqualTo(1)); + } + + [Test] + public void AppendFragment_SameSection_AddsBullet() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + manager.AppendFragment("Add search", "Added"); + string[] files = manager.ListFragmentFiles(); + + var (path, appended, _) = manager.AppendFragment("Add sorting", "Added"); + + Assert.That(appended, Is.True); + Assert.That(path, Is.EqualTo(Path.Combine(_testDir, files[0]))); + Assert.That(manager.ListFragmentFiles().Length, Is.EqualTo(1)); + string content = File.ReadAllText(path); + Assert.That(content, Does.Contain("- Add search")); + Assert.That(content, Does.Contain("- Add sorting")); + } + + [Test] + public void AppendFragment_NewSection_AddsSection() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + manager.AppendFragment("Add search", "Added"); + + var (path, appended, category) = manager.AppendFragment("Fix crash", "Fixed"); + + Assert.That(appended, Is.True); + Assert.That(category, Is.EqualTo("Fixed")); + string content = File.ReadAllText(path); + Assert.That(content, Does.Contain("### Added")); + Assert.That(content, Does.Contain("### Fixed")); + Assert.That(content, Does.Contain("- Fix crash")); + } + + [Test] + public void AppendFragment_DuplicateEntry_Throws() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + manager.AppendFragment("Add search", "Added"); + + Assert.Throws(() => manager.AppendFragment("Add search", "Added")); + } + + [Test] + public void AppendFragment_ChangelistName_CreatesAndAppends() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + + var (path1, appended1, _) = manager.AppendFragment("Add search", "Added", changelistName: "my-feature"); + var (path2, appended2, _) = manager.AppendFragment("Add sorting", "Added", changelistName: "my-feature"); + + Assert.That(appended1, Is.False); + Assert.That(appended2, Is.True); + Assert.That(Path.GetFileName(path1), Is.EqualTo("my-feature.md")); + Assert.That(path2, Is.EqualTo(path1)); + Assert.That(manager.ListFragmentFiles().Length, Is.EqualTo(1)); + } + + [Test] + public void AppendFragment_TargetFile_AppendsToSpecific() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + var (pathA, _, _) = manager.AppendFragment("Add search", "Added", separate: true); + manager.AppendFragment("Add sorting", "Added", separate: true); + + var (path, appended, _) = manager.AppendFragment("Fix crash", "Fixed", targetFile: Path.GetFileName(pathA)); + + Assert.That(appended, Is.True); + Assert.That(path, Is.EqualTo(pathA)); + Assert.That(File.ReadAllText(pathA), Does.Contain("### Fixed")); + } + + [Test] + public void AppendFragment_TargetFile_Missing_Throws() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + + Assert.Throws(() => manager.AppendFragment("x", "Added", targetFile: "nope.md")); + } + + [Test] + public void AppendFragment_Separate_CreatesNewFile() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + var (path1, _, _) = manager.AppendFragment("Add search", "Added"); + + var (path, appended, _) = manager.AppendFragment("Add sorting", "Added", separate: true); + + Assert.That(appended, Is.False); + Assert.That(manager.ListFragmentFiles().Length, Is.EqualTo(2)); + Assert.That(Path.GetFileName(path), Is.Not.EqualTo(Path.GetFileName(path1))); + } + + [Test] + public void AppendFragment_OnDefaultBranch_CreatesNewFile() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + InitGit("main"); + manager.AppendFragment("Add search", "Added"); + + var (path, appended, _) = manager.AppendFragment("Add sorting", "Added"); + + Assert.That(appended, Is.False); + Assert.That(manager.ListFragmentFiles().Length, Is.EqualTo(2)); + } + + [Test] + public void AppendFragment_OnFeatureBranch_Appends() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + InitGit("feature/ux"); + manager.AppendFragment("Add search", "Added"); + + var (path, appended, _) = manager.AppendFragment("Add sorting", "Added"); + + Assert.That(appended, Is.True); + Assert.That(manager.ListFragmentFiles().Length, Is.EqualTo(1)); + } + + [Test] + public void BranchMaxImpact_PatchCap_BlocksMinorOnMatchingBranch() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + InitGit("release/1.2"); + WriteConfigWithBranchCap("minor", "release/*", "patch"); + + var (addedAllowed, addedImpact, maxAllowed) = manager.IsCategoryWithinMaxImpact("Added"); + Assert.That(addedAllowed, Is.False); + Assert.That(addedImpact, Is.EqualTo(2)); + Assert.That(maxAllowed, Is.EqualTo(1)); + + var (fixedAllowed, _, _) = manager.IsCategoryWithinMaxImpact("Fixed"); + Assert.That(fixedAllowed, Is.True); + } + + [Test] + public void BranchMaxImpact_NoMatchingBranch_UsesGlobalCap() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + InitGit("feature/x"); + WriteConfigWithBranchCap("minor", "release/*", "patch"); + + var (allowed, _, maxAllowed) = manager.IsCategoryWithinMaxImpact("Added"); + + Assert.That(allowed, Is.True); + Assert.That(maxAllowed, Is.EqualTo(2)); + } + + [Test] + public void CheckApiMaxLevel_BranchCap_BlocksMinorFragment() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + InitGit("release/1.2"); + WriteConfigWithBranchCap("major", "release/*", "patch"); + manager.CreateFragment("New feature", "Added"); + + var (pass, _, _, maxAllowed, offending) = manager.CheckApiMaxLevel(); + + Assert.That(pass, Is.False); + Assert.That(maxAllowed, Is.EqualTo(1)); + Assert.That(offending, Is.EqualTo(new[] { "Added" })); + } + + [Test] + public void GetCreateFragmentError_BranchCap_BlocksMinor() + { + var manager = new WorkspaceManager(_testDir); + manager.Initialize(); + InitGit("release/1.2"); + WriteConfigWithBranchCap("major", "release/*", "patch"); + + string? error = manager.GetCreateFragmentError("Added", allowMajor: false); + + Assert.That(error, Is.Not.Null); + Assert.That(error, Does.Contain("minor")); + Assert.That(error, Does.Contain("patch")); + } + [Test] public void ListFragmentFiles_EmptyDir_ReturnsEmpty() { diff --git a/ChangeSharp/ChangeSharpConfig.cs b/ChangeSharp/ChangeSharpConfig.cs index d6e3304..53dd937 100644 --- a/ChangeSharp/ChangeSharpConfig.cs +++ b/ChangeSharp/ChangeSharpConfig.cs @@ -37,6 +37,10 @@ public class SemverPolicyConfig { "Fixed", "Patch" }, { "Security", "Patch" } }; + + public string MaxImpact { get; set; } = "major"; + + public Dictionary BranchMaxImpact { get; set; } = new(StringComparer.OrdinalIgnoreCase); } public class PreReleaseConfig diff --git a/ChangeSharp/McpToolCatalog.cs b/ChangeSharp/McpToolCatalog.cs new file mode 100644 index 0000000..f8b7a66 --- /dev/null +++ b/ChangeSharp/McpToolCatalog.cs @@ -0,0 +1,56 @@ +using System.Text.Json.Nodes; + +namespace ChangeSharp; + +public static class McpToolCatalog +{ + public static JsonArray Tools { get; } = BuildTools(); + + private static JsonArray BuildTools() => JsonNode.Parse(""" + [ + { + "name": "get_status", + "description": "Get the status of unreleased fragments and the next computed version.", + "inputSchema": { "type": "object", "properties": { } } + }, + { + "name": "create_fragment", + "description": "Create a new change fragment or add to the open changelist.", + "inputSchema": { + "type": "object", + "properties": { + "message": { "type": "string", "description": "The description of the change." }, + "category": { "type": "string", "description": "The category of the change (e.g., Added, Fixed, Changed, Removed)." }, + "allowMajor": { "type": "boolean", "description": "Allow a fragment whose impact exceeds the allowed impact cap." }, + "separate": { "type": "boolean", "description": "Create a new fragment file instead of appending to the open changelist." }, + "fragment": { "type": "string", "description": "Append to a specific fragment file in the unreleased directory." }, + "changelist": { "type": "string", "description": "Append to (or create) a deterministically named changelist file." } + }, + "required": [ "message", "category" ] + } + }, + { + "name": "validate_fragments", + "description": "Validate all unreleased fragments.", + "inputSchema": { + "type": "object", + "properties": { + "apiMinLevel": { "type": "string", "description": "Optional minimum API impact level (patch, minor, major). Fails if fragments are below this level." } + } + } + }, + { + "name": "perform_release", + "description": "Perform a release by aggregating fragments and bumping versions.", + "inputSchema": { + "type": "object", + "properties": { + "dryRun": { "type": "boolean", "description": "If true, only preview the changes without applying them." }, + "allowMajor": { "type": "boolean", "description": "Allow a release whose impact exceeds SemverPolicy.MaxImpact." }, + "apiMinLevel": { "type": "string", "description": "Optional minimum API impact level (patch, minor, major). Fails if fragments are below this level." } + } + } + } + ] + """)!.AsArray(); +} \ No newline at end of file diff --git a/ChangeSharp/NextVersionComputer.cs b/ChangeSharp/NextVersionComputer.cs index e14c94f..e1ea2fb 100644 --- a/ChangeSharp/NextVersionComputer.cs +++ b/ChangeSharp/NextVersionComputer.cs @@ -38,48 +38,44 @@ public static (string Version, string? Warning) ComputeVersionWithWarning(string version = new SemVersion(0); } - SemVersion nextVersion; - int maxImpact = 0; - foreach (var pair in changeSet.Sections) { - if (pair.Value.Count > 0) + if (pair.Value.Count > 0 && policy.Mappings.TryGetValue(pair.Key, out var impact)) { - if (policy.Mappings.TryGetValue(pair.Key, out var impact)) - { - maxImpact = Math.Max(maxImpact, ParseImpact(impact)); - } + maxImpact = Math.Max(maxImpact, ParseImpact(impact)); } } - if (maxImpact == 3) + SemVersion nextVersion = BumpForImpact(version, maxImpact); + + return ($"{prefix}{nextVersion}", warning); + } + + private static SemVersion BumpForImpact(SemVersion version, int maxImpact) + { + if (maxImpact >= 3) { if (version.IsPrerelease && version.Minor == 0 && version.Patch == 0) - nextVersion = new SemVersion(version.Major, 0, 0); - else - nextVersion = new SemVersion(version.Major + 1, 0, 0); + return new SemVersion(version.Major, 0, 0); + return new SemVersion(version.Major + 1, 0, 0); } - else if (maxImpact == 2) + + if (maxImpact >= 2) { if (version.IsPrerelease && version.Patch == 0) - nextVersion = new SemVersion(version.Major, version.Minor, 0); - else - nextVersion = new SemVersion(version.Major, version.Minor + 1, 0); + return new SemVersion(version.Major, version.Minor, 0); + return new SemVersion(version.Major, version.Minor + 1, 0); } - else if (maxImpact == 1) + + if (maxImpact >= 1) { if (version.IsPrerelease) - nextVersion = new SemVersion(version.Major, version.Minor, version.Patch); - else - nextVersion = new SemVersion(version.Major, version.Minor, version.Patch + 1); - } - else - { - nextVersion = version; + return new SemVersion(version.Major, version.Minor, version.Patch); + return new SemVersion(version.Major, version.Minor, version.Patch + 1); } - return ($"{prefix}{nextVersion}", warning); + return version; } public static int ParseImpact(string impact) @@ -93,6 +89,14 @@ public static int ParseImpact(string impact) }; } + public static string ImpactName(int impact) => impact switch + { + 3 => "major", + 2 => "minor", + 1 => "patch", + _ => "none" + }; + public static string ComputePrereleaseVersion(string currentVersion, ChangeSet changeSet, string identifier, int counter, SemverPolicyConfig? policy = null) { string prefix = ""; diff --git a/ChangeSharp/PublicSurfaceValidator.cs b/ChangeSharp/PublicSurfaceValidator.cs new file mode 100644 index 0000000..0481f75 --- /dev/null +++ b/ChangeSharp/PublicSurfaceValidator.cs @@ -0,0 +1,78 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ChangeSharp; + +public static class PublicSurfaceValidator +{ + public static (bool Match, string? Message) BaselinesMatch( + IReadOnlyDictionary generated, + IReadOnlyDictionary committed) + { + foreach (var kv in generated) + { + if (!committed.TryGetValue(kv.Key, out var committedContent)) + return (false, $"Public-surface baseline '{kv.Key}' is missing. Run scripts/update-public-api.sh and commit it."); + + bool equal = TryParseJson(kv.Value, out var generatedJson) && TryParseJson(committedContent, out var committedJson) + ? JsonNode.DeepEquals(generatedJson, committedJson) + : string.Equals(Normalize(kv.Value), Normalize(committedContent), StringComparison.Ordinal); + + if (!equal) + return (false, $"Public-surface baseline '{kv.Key}' is out of date. Run scripts/update-public-api.sh and commit the result."); + } + + return (true, null); + } + + private static bool TryParseJson(string content, out JsonNode? node) + { + try + { + node = JsonNode.Parse(content); + return node != null; + } + catch (JsonException) + { + node = null; + return false; + } + } + + public static string DeriveImpact( + IReadOnlyDictionary? baseSurface, + IReadOnlyDictionary currentSurface) + { + if (baseSurface == null) + return "patch"; + + int added = 0; + int removed = 0; + + foreach (var file in currentSurface) + { + var baseLines = baseSurface.TryGetValue(file.Key, out var baseContent) + ? SplitLines(baseContent) + : Array.Empty(); + var currentLines = SplitLines(file.Value); + var currentSet = currentLines.ToHashSet(StringComparer.Ordinal); + + removed += baseLines.Count(l => !currentSet.Contains(l)); + added += currentLines.Count(l => !baseLines.Contains(l)); + } + + foreach (var file in baseSurface) + { + if (!currentSurface.ContainsKey(file.Key)) + removed += SplitLines(file.Value).Length; + } + + return removed > 0 ? "major" : added > 0 ? "minor" : "patch"; + } + + public static string Normalize(string content) => + content.Replace("\r\n", "\n").TrimEnd('\n', ' ', '\t'); + + private static string[] SplitLines(string content) => + content.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries); +} \ No newline at end of file diff --git a/ChangeSharp/WorkspaceManager.cs b/ChangeSharp/WorkspaceManager.cs index 6bd1873..5e6002f 100644 --- a/ChangeSharp/WorkspaceManager.cs +++ b/ChangeSharp/WorkspaceManager.cs @@ -248,12 +248,89 @@ public List Validate() if (count == 0) return (true, 0, "none"); + var (maxImpact, maxLevelName) = ComputeMaxImpact(merged, config.SemverPolicy); + return (maxImpact >= required, maxImpact, maxLevelName); + } + + public (bool Pass, int MaxImpact, string MaxLevelName, int MaxAllowed, string[] OffendingCategories) CheckApiMaxLevel() + { + var config = LoadConfig(); + var (maxAllowed, _) = ResolveMaxImpact(config); + if (maxAllowed == 0) + throw new ArgumentException($"Invalid SemverPolicy.MaxImpact '{config.SemverPolicy.MaxImpact}'. Valid values: patch, minor, major.", "MaxImpact"); + + GetStatus(out int count, out ChangeSet merged, out _, out _); + + // No fragments → nothing to check, always pass + if (count == 0) + return (true, 0, "none", maxAllowed, Array.Empty()); + + var (maxImpact, maxLevelName) = ComputeMaxImpact(merged, config.SemverPolicy); + + var offendingCategories = merged.Sections + .Where(pair => pair.Value.Count > 0 + && config.SemverPolicy.Mappings.TryGetValue(pair.Key, out var impact) + && NextVersionComputer.ParseImpact(impact) > maxAllowed) + .Select(pair => pair.Key) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return (maxImpact <= maxAllowed, maxImpact, maxLevelName, maxAllowed, offendingCategories); + } + + public (bool Allowed, int CategoryImpact, int MaxAllowed) IsCategoryWithinMaxImpact(string category) + { + var config = LoadConfig(); + var (maxAllowed, _) = ResolveMaxImpact(config); + if (maxAllowed == 0) + throw new ArgumentException($"Invalid SemverPolicy.MaxImpact '{config.SemverPolicy.MaxImpact}'. Valid values: patch, minor, major.", "MaxImpact"); + + if (!config.SemverPolicy.Mappings.TryGetValue(category, out var impact)) + return (true, 0, maxAllowed); + + int categoryImpact = NextVersionComputer.ParseImpact(impact); + return (categoryImpact <= maxAllowed, categoryImpact, maxAllowed); + } + + public string? GetCreateFragmentError(string category, bool allowMajor) + { + if (allowMajor) return null; + + var (allowed, categoryImpact, maxAllowed) = IsCategoryWithinMaxImpact(category); + if (allowed) return null; + + return $"Category '{category}' requires a {NextVersionComputer.ImpactName(categoryImpact)} bump, above the allowed impact ({NextVersionComputer.ImpactName(maxAllowed)}). Use --allow-major to override."; + } + + public (bool Blocked, string Message, bool CapExceeded) GetReleaseGateResult(string? apiMinLevel, bool allowMajor) + { + if (apiMinLevel != null) + { + var (pass, maxImpact, maxLevelName) = CheckApiMinLevel(apiMinLevel); + if (!pass) + return (true, $"API surface requires at least a '{apiMinLevel}' bump, but fragments only reach '{maxLevelName}' (level {maxImpact}).", false); + } + + var (withinCap, maxImpact2, maxLevelName2, maxAllowed, offendingCategories) = CheckApiMaxLevel(); + bool capExceeded = !withinCap; + if (capExceeded && !allowMajor) + { + string categories = offendingCategories.Length > 0 ? $" ({string.Join(", ", offendingCategories)})" : ""; + string message = $"Release would bump to '{maxLevelName2}' (level {maxImpact2}), above the allowed impact ({NextVersionComputer.ImpactName(maxAllowed)}){categories}. Use --allow-major to proceed."; + return (true, message, true); + } + + return (false, "", capExceeded); + } + + private static (int MaxImpact, string MaxLevelName) ComputeMaxImpact(ChangeSet merged, SemverPolicyConfig policy) + { int maxImpact = 0; string maxLevelName = "none"; foreach (var pair in merged.Sections) { - if (pair.Value.Count > 0 && config.SemverPolicy.Mappings.TryGetValue(pair.Key, out var impact)) + if (pair.Value.Count > 0 && policy.Mappings.TryGetValue(pair.Key, out var impact)) { int impactValue = NextVersionComputer.ParseImpact(impact); if (impactValue > maxImpact) @@ -264,7 +341,7 @@ public List Validate() } } - return (maxImpact >= required, maxImpact, maxLevelName); + return (maxImpact, maxLevelName); } public string[] ListFragmentFiles() @@ -338,6 +415,108 @@ public string CreateFragment(string message, string category) return filePath; } + public (string FragmentPath, bool Appended, string Category) AppendFragment( + string message, string category, bool separate = false, + string? targetFile = null, string? changelistName = null) + { + var config = LoadConfig(); + string unreleasedPath = Path.Combine(_basePath, config.UnreleasedDir); + if (!Directory.Exists(unreleasedPath)) + { + Directory.CreateDirectory(unreleasedPath); + } + + string formattedCategory = NormalizeCategory(category); + string? targetPath = null; + + if (targetFile != null) + { + targetPath = Path.Combine(unreleasedPath, Path.GetFileName(targetFile)); + if (!File.Exists(targetPath)) + throw new InvalidOperationException($"Fragment '{targetFile}' not found in the unreleased directory."); + } + else if (changelistName != null) + { + targetPath = Path.Combine(unreleasedPath, $"{Slugify(changelistName)}.md"); + if (!File.Exists(targetPath)) + { + string newContent = $"### {formattedCategory}{Environment.NewLine}- {message}{Environment.NewLine}"; + File.WriteAllText(targetPath, newContent, Encoding.UTF8); + return (targetPath, false, formattedCategory); + } + } + else if (!separate && !IsDefaultBranch()) + { + // Append to the most recent fragment (the open changelist). On the + // default branch (trunk) a new file is created instead to stay + // conflict-free. + var mostRecent = Directory.GetFiles(unreleasedPath, "*.md") + .OrderByDescending(f => Path.GetFileName(f)) + .FirstOrDefault(); + if (mostRecent != null) + targetPath = mostRecent; + } + + if (targetPath == null) + { + string path = CreateFragment(message, category); + return (path, false, formattedCategory); + } + + string content = File.ReadAllText(targetPath, Encoding.UTF8); + string bullet = $"- {message}"; + + var existing = new ChangelogParser().Parse(content).GetSection(formattedCategory); + if (existing.Any(item => item.Trim() == bullet.Trim())) + throw new InvalidOperationException($"'{message}' already exists in the '{formattedCategory}' section of {Path.GetFileName(targetPath)}."); + + File.WriteAllText(targetPath, AddBulletToSection(content, formattedCategory, bullet), Encoding.UTF8); + return (targetPath, true, formattedCategory); + } + + private static string AddBulletToSection(string content, string category, string bullet) + { + string[] lines = content.Replace("\r\n", "\n").Split('\n'); + string headingPrefix = $"### {category}"; + int headingIndex = -1; + + for (int i = lines.Length - 1; i >= 0; i--) + { + string trimmed = lines[i].TrimStart(); + if (trimmed.StartsWith(headingPrefix, StringComparison.OrdinalIgnoreCase) + && (trimmed.Length == headingPrefix.Length || char.IsWhiteSpace(trimmed[headingPrefix.Length]))) + { + headingIndex = i; + break; + } + } + + if (headingIndex < 0) + { + string tail = content.TrimEnd('\n', ' ', '\t'); + string separator = string.IsNullOrEmpty(tail) ? "" : "\n\n"; + return tail + separator + $"### {category}{Environment.NewLine}{bullet}{Environment.NewLine}"; + } + + int sectionEnd = lines.Length; + for (int i = headingIndex + 1; i < lines.Length; i++) + { + if (lines[i].TrimStart().StartsWith("### ")) + { + sectionEnd = i; + break; + } + } + + int insertAt = sectionEnd; + while (insertAt > headingIndex + 1 && string.IsNullOrWhiteSpace(lines[insertAt - 1])) + insertAt--; + + var list = new List(lines); + list.Insert(insertAt, bullet); + return string.Join('\n', list) + "\n"; + } + private string? GetGitBranchName() { try @@ -366,6 +545,37 @@ public string CreateFragment(string message, string category) } } + public bool IsDefaultBranch() + { + return GetGitBranchName() is "main" or "master"; + } + + private (int Cap, bool FromBranch) ResolveMaxImpact(ChangeSharpConfig config) + { + int globalCap = NextVersionComputer.ParseImpact(config.SemverPolicy.MaxImpact); + string? branch = GetGitBranchName(); + + foreach (var kv in config.SemverPolicy.BranchMaxImpact) + { + if (branch != null && BranchMatches(branch, kv.Key)) + { + int branchCap = NextVersionComputer.ParseImpact(kv.Value); + if (branchCap > 0) + return (Math.Min(globalCap, branchCap), true); + } + } + + return (globalCap, false); + } + + private static bool BranchMatches(string branch, string pattern) + { + if (pattern == "*") return true; + if (pattern.EndsWith('*')) + return branch.StartsWith(pattern[..^1], StringComparison.OrdinalIgnoreCase); + return string.Equals(branch, pattern, StringComparison.OrdinalIgnoreCase); + } + public void GetStatus( out int fragmentCount, out ChangeSet mergedChangeSet, diff --git a/README.md b/README.md index 3ab49f2..523ba15 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,8 @@ See the [documentation site](https://manuc66.github.io/ChangeSharp/) — quick s - **AI-ready** — built-in MCP server for AI agent integration - **Version propagation** — MSBuild, JSON, and regex target handlers - **Pre-release channels** — branch-based pre-release workflows -- **Safety gates** — `--api-min-level`, `--require-approval`, `--dry-run` +- **Safety gates** — `--api-min-level` (floor), `SemverPolicy.MaxImpact` + `--allow-major` (cap), `--require-approval`, `--dry-run` +- **Dogfooded API surface gate** — ChangeSharp gates its own CLI/MCP/library surfaces via committed baselines in `tests/public-api/`; see [API Surface Gate](docs/features/ApiSurfaceGate.md) ## License diff --git a/changesharp.json b/changesharp.json index e012db1..891b893 100644 --- a/changesharp.json +++ b/changesharp.json @@ -36,6 +36,7 @@ "MaxIdentifierLength": 30 }, "SemverPolicy": { + "MaxImpact": "major", "Mappings": { "Breaking Changes": "Major", "Removed": "Major", diff --git a/docs/ChangeSharp.md b/docs/ChangeSharp.md index bdc7c72..56c9fe7 100644 --- a/docs/ChangeSharp.md +++ b/docs/ChangeSharp.md @@ -32,7 +32,7 @@ In the .NET ecosystem, release versioning is heavily dominated by tools like **G ChangeSharp distinguishes itself as the **changelog-driven versioning for .NET with CI-ready gates and AI-native integration**: * **Natively Integrated in .NET**: First-class support for MSBuild, .NET Global Tools, and C# library usage. -* **Safety Gates (The Differentiator)**: Unlike simple fragment managers, ChangeSharp enforces a **minimum API impact level** (`--api-min-level`): the CI computes the actual API surface impact with its own diff tool, and ChangeSharp fails the build if the declared fragments are below that level. +* **Safety Gates (The Differentiator)**: Unlike simple fragment managers, ChangeSharp enforces a **minimum API impact level** (`--api-min-level`): the CI computes the actual API surface impact with its own diff tool, and ChangeSharp fails the build if the declared fragments are below that level. The symmetric **max-impact cap** (`SemverPolicy.MaxImpact` + `--allow-major`) blocks fragments/releases that would force a Major bump the team does not want. ChangeSharp dogfoods the API surface gate on its own CLI, MCP, and library surfaces — see [API Surface Gate](features/ApiSurfaceGate.md). * **Separation of Concerns**: Git history is for developers; changelogs are for users. * **Conflict-Free Workflows**: Independent Markdown fragments eliminate merge conflicts on `CHANGELOG.md`. * **AI-Native (MCP Layer)**: First tool to expose changelog management to AI agents via the **Model Context Protocol (MCP)**, with built-in security approval gates. diff --git a/docs/McpIntegration.md b/docs/McpIntegration.md index 7398582..bf8cb8d 100644 --- a/docs/McpIntegration.md +++ b/docs/McpIntegration.md @@ -7,9 +7,11 @@ ChangeSharp provides a built-in Model Context Protocol (MCP) server, allowing AI The MCP server exposes the following tools: - `get_status`: Get the count of pending fragments, the current version, and the next calculated version. -- `create_fragment`: Create a new change fragment with a message and a category. -- `validate_fragments`: Ensure all pending fragments follow the correct format. -- `perform_release`: Execute a release, aggregate changes into the changelog, and bump project versions. +- `create_fragment`: Create a new change fragment or add to the open changelist. Mirrors the CLI `add` command: optional `allowMajor` bypasses the allowed impact cap, and optional `separate` / `fragment` / `changelist` control the target (new file, specific file, named changelist). +- `validate_fragments`: Ensure all pending fragments follow the correct format. Optional `apiMinLevel` (`patch` | `minor` | `major`) mirrors the CLI `--api-min-level` gate: fails if the fragments' declared impact is below this level. +- `perform_release`: Execute a release, aggregate changes into the changelog, and bump project versions. Mirrors the CLI safety gates: optional `allowMajor` (bypass `SemverPolicy.MaxImpact` cap) and optional `apiMinLevel`. + +The safety gates exposed by the CLI (`--api-min-level`, `--allow-major` / `SemverPolicy.MaxImpact`) behave identically through the MCP tools. > **Security Warning**: In enterprise environments, AI agents should NOT be allowed to perform a release without human approval. It is highly recommended to use the `--dry-run` flag or implement a mandatory approval gate in your CI/CD pipeline before the final release is pushed. diff --git a/docs/Roadmap.md b/docs/Roadmap.md index 072d35d..8f828ef 100644 --- a/docs/Roadmap.md +++ b/docs/Roadmap.md @@ -137,6 +137,9 @@ ChangeSharp.MCP → AI/tooling adapter (Interface only) changesharp release --api-min-level major # release gate ``` * **Full decoupling**: ChangeSharp does not perform the diff — the team owns the diff tool. ChangeSharp only enforces the policy. +* **Max impact cap**: the symmetric guard `SemverPolicy.MaxImpact` (+ `--allow-major`) blocks fragments/releases that would force a Major bump the team does not want. Enforced at `new` and `release`. Per-branch caps (`SemverPolicy.BranchMaxImpact`, e.g. `release/*` → `patch`) restrict branches to a lower impact. See [SemVer Rules](SemVer%20Rules.md). +* **`add` command**: appends a change to the open changelist (separate file on the default branch), with `--separate` / `--fragment` / `--changelist` escape hatches; `new` is equivalent to `add --separate`. +* **Dogfooding**: ChangeSharp gates its own CLI, MCP, and library surfaces via committed baselines (`tests/public-api/`) regenerated by `scripts/update-public-api.sh`, enforced by the `api-surface` CI job and the `PublicApiBaselineTests` build-time test. * See [API Surface Gate Specification](features/ApiSurfaceGate.md). ### Step 17: Multi-Team Monorepo Scoping (Priority 2) diff --git a/docs/SemVer Rules.md b/docs/SemVer Rules.md index 7093f87..2b2635b 100644 --- a/docs/SemVer Rules.md +++ b/docs/SemVer Rules.md @@ -56,3 +56,81 @@ ChangeSharp compares the required level against the fragments' declared categori * **CLI Tools**: Compare help output or command schemas. ChangeSharp does **not** perform the API diff itself — it only enforces the policy. See [ApiSurfaceGate](features/ApiSurfaceGate.md) for details. + +## ⛔ Maximum Impact Cap (`SemverPolicy.MaxImpact`) + +`--api-min-level` is a **floor**: it guarantees fragments are not lower than the real API impact. `SemverPolicy.MaxImpact` is the symmetric **cap**: it guarantees no fragment silently forces a Major bump when the team does not want one. + +```json +{ + "SemverPolicy": { + "MaxImpact": "minor" + } +} +``` + +`MaxImpact` accepts `patch`, `minor`, or `major`. The default is `major`, which disables the cap entirely — existing projects are unaffected until they opt in. + +### Per-branch caps (`SemverPolicy.BranchMaxImpact`) + +Restrict the allowed impact on specific branches (e.g. hotfix or release branches +that should only accept fixes). The effective cap is the most restrictive of the +global `MaxImpact` and the matching branch entry: + +```json +{ + "SemverPolicy": { + "MaxImpact": "minor", + "BranchMaxImpact": { + "release/*": "patch", + "hotfix/*": "patch" + } + } +} +``` + +On a `release/1.2` branch, an `Added` fragment (Minor) is refused at creation +and at release (exit 3) unless `--allow-major` is passed; a `Fixed` fragment +(Patch) is fine. Branch patterns support `*` as a suffix wildcard. + +### The `add` command + +`changesharp add` is the everyday way to record a change: + +```bash +changesharp add "Add search" # append to the open changelist (category prompted) +changesharp add --added "Add search" # category via flag +changesharp add --separate "doc only" # force a new fragment file +changesharp add --fragment "x" # append to a specific fragment +changesharp add --changelist "x" # append to (or create) a named changelist +``` + +`add` appends to the most recent fragment (the open changelist). On the default +branch (`main`/`master`) it always creates a separate file so concurrent pushes +stay conflict-free; `--separate`, `--fragment`, and `--changelist` override the +target. `changesharp new` is equivalent to `add --separate`. The `MaxImpact` +cap applies to `add` exactly as it does to `new`. + +> **Note on custom mappings**: the cap applies to the *impact level* your `Mappings` declare, not to whether a change is actually breaking. If you map `Changed → Major` (like this repository does) and set `MaxImpact: minor`, then `changesharp add --changed` is blocked too — even for harmless changes. With such a mapping, choose `MaxImpact: major` (or accept that every `Changed` needs `--allow-major`). + +### Where it is enforced + +| Step | Behavior | +| --- | --- | +| `changesharp add` / `new` (flags) | Category above the cap → refused (exit 3) unless `--allow-major` | +| `changesharp add` / `new` (interactive) | Blocked categories are marked `⚠ blocked (MaxImpact)` in the menu and re-prompted | +| `changesharp validate` | Does **not** enforce the cap (format check only) | +| `changesharp release` | Refused (exit 3) unless `--allow-major` — the production gate (human-in-the-loop) | + +```bash +changesharp add --breaking # ❌ refused above the cap +changesharp add --breaking --allow-major # ✅ deliberate +changesharp release # ❌ refused above the cap +changesharp release --allow-major # ✅ deliberate +``` + +The `--allow-major` flag is the explicit opt-in at both creation and release, so a Major requires two deliberate decisions. A runnable demo lives in `samples/maximpact-gate/`. + +### Known limits + +The gates are enforced per command: the `--api-min-level` floor on `validate` and `release`, the `MaxImpact` cap on `new` and `release`. The `prerelease` create/promote path bypasses them: `changesharp prerelease --promote` promotes a pre-release to a final release without any gate check. This matches the pre-existing behavior of `--api-min-level` on that path; if you rely on the gates, run `changesharp validate --api-min-level ` (or `changesharp release --allow-major`) as an explicit check before promoting. diff --git a/docs/features/ApiSurfaceGate.md b/docs/features/ApiSurfaceGate.md index 8010dfa..cfc4ef1 100644 --- a/docs/features/ApiSurfaceGate.md +++ b/docs/features/ApiSurfaceGate.md @@ -97,3 +97,48 @@ if (parseResult.GetValue(apiMinLevelOption) is string minLevel) ``` ~20 lines of code. No provider, no JSON parsing, no external integration. + +## ChangeSharp dogfoods it + +ChangeSharp applies the API Surface Gate to **its own** public surfaces. Committed baselines in `tests/public-api/` snapshot: + +| Baseline | Covers | Generated by | +| --- | --- | --- | +| `cli-help.txt` | CLI commands and options (root + subcommands) | the tool + `scripts/update-public-api.sh` | +| `mcp-tools.json` | MCP `tools/list` (names + input schema) | the tool (`McpToolCatalog`) + `scripts/update-public-api.sh` | +| `public-api.txt` | Library public API | the tool (`PublicApiGenerator`) + `PublicApiBaselineTests` | + +Everything lives in one command: + +```bash +changesharp validate --api-surface +``` + +It (a) regenerates the three surfaces in memory, (b) **fails** if the committed baselines are out of date (message points to `scripts/update-public-api.sh`), (c) diffs them against `origin/main` and derives the minimum impact — additions → `minor`, removals/renames → `major`, none → `patch` (a help-*reword* shows up as one removed + one added line and is conservatively treated as `major`) — and (d) gates the fragments: an API surface change with no fragment, or with fragments below the derived level, exits 3. + +The CI `api-surface` job is therefore a single step: + +```yaml +- name: Validate the public API surface + run: changesharp validate --api-surface +``` + +When you change a public surface, update the baselines and add a fragment that matches the impact: + +```bash +scripts/update-public-api.sh +changesharp add --added "Add the new --foo option to status" +``` + +## Symmetric guard: the max-impact cap + +The API Surface Gate is a **floor** (fragments must not be lower than the real +impact). ChangeSharp also provides the symmetric **cap** so a fragment can never +silently force a Major bump the team does not want: + +* `SemverPolicy.MaxImpact` in `changesharp.json` (default `major` = cap off). +* Enforced at `changesharp new` (creation) and `changesharp release` (production), + both requiring the explicit `--allow-major` flag to proceed. + +See [SemVer Rules](../SemVer%20Rules.md) for details, and +`samples/maximpact-gate/` for a runnable demo. diff --git a/docs/index.md b/docs/index.md index bdc7c72..56c9fe7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,7 +32,7 @@ In the .NET ecosystem, release versioning is heavily dominated by tools like **G ChangeSharp distinguishes itself as the **changelog-driven versioning for .NET with CI-ready gates and AI-native integration**: * **Natively Integrated in .NET**: First-class support for MSBuild, .NET Global Tools, and C# library usage. -* **Safety Gates (The Differentiator)**: Unlike simple fragment managers, ChangeSharp enforces a **minimum API impact level** (`--api-min-level`): the CI computes the actual API surface impact with its own diff tool, and ChangeSharp fails the build if the declared fragments are below that level. +* **Safety Gates (The Differentiator)**: Unlike simple fragment managers, ChangeSharp enforces a **minimum API impact level** (`--api-min-level`): the CI computes the actual API surface impact with its own diff tool, and ChangeSharp fails the build if the declared fragments are below that level. The symmetric **max-impact cap** (`SemverPolicy.MaxImpact` + `--allow-major`) blocks fragments/releases that would force a Major bump the team does not want. ChangeSharp dogfoods the API surface gate on its own CLI, MCP, and library surfaces — see [API Surface Gate](features/ApiSurfaceGate.md). * **Separation of Concerns**: Git history is for developers; changelogs are for users. * **Conflict-Free Workflows**: Independent Markdown fragments eliminate merge conflicts on `CHANGELOG.md`. * **AI-Native (MCP Layer)**: First tool to expose changelog management to AI agents via the **Model Context Protocol (MCP)**, with built-in security approval gates. diff --git a/samples/maximpact-gate/.gitignore b/samples/maximpact-gate/.gitignore new file mode 100644 index 0000000..0b1f4e3 --- /dev/null +++ b/samples/maximpact-gate/.gitignore @@ -0,0 +1,2 @@ +CHANGELOG.md +.changesharp/ \ No newline at end of file diff --git a/samples/maximpact-gate/README.md b/samples/maximpact-gate/README.md new file mode 100644 index 0000000..3648992 --- /dev/null +++ b/samples/maximpact-gate/README.md @@ -0,0 +1,50 @@ +# MaxImpact Gate — Sample Workspace + +This sample shows ChangeSharp's **max-impact cap**: a team policy that refuses +fragments (and releases) whose declared impact would force a **Major** bump +unless an author explicitly opts in. + +It is the mirror of the `--api-min-level` floor gate: `--api-min-level` says +*fragments must not be lower than the real API impact*; `MaxImpact` says *fragments +must not bump higher than the team allows*. + +## Configuration + +`changesharp.json` in this folder sets `SemverPolicy.MaxImpact` to `minor`: + +```json +{ + "SemverPolicy": { + "MaxImpact": "minor" + } +} +``` + +Impact levels supported: `major` (default, no cap), `minor`, `patch`. + +## What is enforced, and where + +| Step | Behavior | +| --- | --- | +| `changesharp new --breaking` | Refused at creation (exit 3) — the author must use `--allow-major` | +| `changesharp new` (interactive) | Blocked categories are marked `⚠ blocked (MaxImpact)` in the menu and re-prompted | +| `changesharp validate` | Does **not** enforce the cap (format check only) | +| `changesharp release` | Refused without `--allow-major` — the production gate (human-in-the-loop) | +| `changesharp release --allow-major` | Proceeds, deliberate Major recorded | + +## Run the demo + +```bash +samples/maximpact-gate/run-demo.sh +``` + +The script builds the ChangeSharp CLI and MCP server from the repo and asserts the +exit codes for every scenario above, including the same gate through the MCP tools +(`create_fragment` / `perform_release`). It is self-cleaning (no files are left behind). + +## Notes + +- With the default `MaxImpact: major` the cap is disabled and behavior is + unchanged, so existing projects are unaffected until they opt in. +- The gate is enforced by ChangeSharp itself: no external API-diff tool is + involved, unlike `--api-min-level` which receives its level from CI. \ No newline at end of file diff --git a/samples/maximpact-gate/changesharp.json b/samples/maximpact-gate/changesharp.json new file mode 100644 index 0000000..0f554ed --- /dev/null +++ b/samples/maximpact-gate/changesharp.json @@ -0,0 +1,14 @@ +{ + "SemverPolicy": { + "MaxImpact": "minor", + "Mappings": { + "Breaking Changes": "Major", + "Removed": "Major", + "Changed": "Minor", + "Added": "Minor", + "Deprecated": "Minor", + "Fixed": "Patch", + "Security": "Patch" + } + } +} \ No newline at end of file diff --git a/samples/maximpact-gate/run-demo.sh b/samples/maximpact-gate/run-demo.sh new file mode 100755 index 0000000..7891b38 --- /dev/null +++ b/samples/maximpact-gate/run-demo.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# +# Demo of the SemverPolicy.MaxImpact gate (the "max-impact cap"). +# +# Run from anywhere: +# samples/maximpact-gate/run-demo.sh +# +# It builds the ChangeSharp CLI from the repo, then exercises the gate inside +# this sample workspace. Exit code 0 = every check passed. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$ROOT/../.." && pwd)" +CLI_DLL="$REPO/ChangeSharp.Cli/bin/Release/net10.0/ChangeSharp.Cli.dll" +MCP_DLL="$REPO/ChangeSharp.Mcp/bin/Release/net10.0/ChangeSharp.Mcp.dll" + +echo ">>> Building ChangeSharp CLI + MCP (Release) ..." +dotnet build "$REPO/ChangeSharp.Cli/ChangeSharp.Cli.csproj" -c Release --nologo >/dev/null || { echo "CLI build failed." >&2; exit 1; } +dotnet build "$REPO/ChangeSharp.Mcp/ChangeSharp.Mcp.csproj" -c Release --nologo >/dev/null || { echo "MCP build failed." >&2; exit 1; } + +cd "$ROOT" + +PASS=0 +FAIL=0 + +# check