diff --git a/CodeyBox.slnx b/CodeyBox.slnx
index 048766b5..f83cefac 100644
--- a/CodeyBox.slnx
+++ b/CodeyBox.slnx
@@ -62,6 +62,7 @@
+
diff --git a/plugins/auditors-api-compatibility/CodeyBox.ApiCompatAuditorPlugin/ApiCompatAuditor.cs b/plugins/auditors-api-compatibility/CodeyBox.ApiCompatAuditorPlugin/ApiCompatAuditor.cs
new file mode 100644
index 00000000..b00aeac7
--- /dev/null
+++ b/plugins/auditors-api-compatibility/CodeyBox.ApiCompatAuditorPlugin/ApiCompatAuditor.cs
@@ -0,0 +1,455 @@
+using CodeyBox.Core;
+using CodeyBox.PluginSdk;
+using CodeyBox.PluginSdk.Tools;
+using Microsoft.Extensions.Logging;
+
+namespace CodeyBox.ApiCompatAuditorPlugin;
+
+///
+/// API-compatibility auditor wrapping apicompat (Microsoft.DotNet.ApiCompat.Tool —
+/// .NET public API breaking-change detection) on the shared
+/// : the base supplies sandboxed invocation with a
+/// bounded timeout, per-stream output caps, exit-code classification, severity mapping,
+/// finding identity, and per-auditor configuration. This class adds the text report
+/// parser ( — the tool exposes no JSON mode; its
+/// verdict is CPnnnn: diagnostic lines on stderr plus the pinned tool-version
+/// declaration via ), the assembly /
+/// package mode selection below, and the side-effect guard over operator arguments.
+///
+/// Gate behaviour: blocking. apicompat reports no severity per
+/// diagnostic — every CPnnnn line it emits is a public API compatibility break
+/// (removed type/member, changed signature) — so every finding maps to
+/// and fails the audit. There is no advisory mode;
+/// narrow scope with IncludedRules/ExcludedRules, NoWarn, or an
+/// operator-owned SuppressionFile instead. Note the consequence: raising
+/// MinimumSeverity above error drops every finding.
+///
+/// Exit-code convention (verified against 10.0.401 live runs — not the common
+/// 0/1/2 convention). 0 = the comparison completed and no breaking change
+/// exists (the clean-run marker on stdout); 1 = ambiguous: breaking changes
+/// (diagnostic lines on stderr plus the API breaking changes found. trailer) or
+/// the run failed (missing assembly → Unhandled exception: … with a stack trace;
+/// bad invocation → usage text) — the parser tells them apart by content. A 1
+/// exit whose output carries no parseable CPnnnn line contradicts the output
+/// contract and fails closed as infrastructure through the parser, as does any other
+/// non-declared exit. 126/127 are cannot-execute / not-found —
+/// infrastructure.
+///
+/// Mode selection. Assembly mode is the default:
+/// apicompat -l <baseline> -r <current> from the scoped
+/// LeftAssembly / RightAssembly keys (comma-separated for multi-assembly
+/// comparisons). Package mode — apicompat package <pkg> --baseline-package
+/// <base> — applies when Package or BaselinePackage is
+/// configured or ExtraArguments carries the package subcommand. Both modes
+/// at once (assembly keys plus package keys) is a deterministic configuration failure,
+/// and no resolvable baseline at all is a deterministic infrastructure failure pointing
+/// at the baseline knobs — a compatibility check without a "previous API" is
+/// meaningless, and enabling this auditor without configuring it is a loud
+/// misconfiguration, never a silent skip. An operator-supplied flag in
+/// ExtraArguments takes precedence over the matching scoped key: the auditor
+/// emits nothing for that slot and the base appends the operator's argv verbatim.
+///
+/// Version pin. The diagnostic catalogue and the report shape change between
+/// releases, so findings are only meaningful from the build the auditor was verified
+/// against. The auditor probes apicompat --version before every scan; a missing
+/// binary, an unrecognised version string, or a version other than
+/// ExpectedVersion is an infrastructure failure naming the tool — never a pass,
+/// never a finding.
+///
+/// Repository-controlled suppression. apicompat reads its
+/// compatibility suppressions only from the file SuppressionFile points at (or a
+/// --suppression-file in ExtraArguments); it consults no repository file
+/// implicitly. A suppression file that lives inside the audited repository therefore
+/// lets a change set soften the check applied to it. The auditor does not second-guess
+/// the configured file — but operators who need the gate to be independent of
+/// repo-authored tuning should point SuppressionFile at an operator-owned file
+/// outside the repository. Rule ids in findings (e.g. CP0002) additionally stay
+/// filterable after the fact through the shared IncludedRules/ExcludedRules
+/// knobs, which the audit host applies — outside the subject's reach.
+///
+/// Scope and defaults. The scan compares exactly the configured
+/// assemblies/packages: the tool never walks the repository, so vendored or generated
+/// trees cannot produce findings and the auditor ships no ExcludePaths default.
+/// Findings carry member identities, never file locations, so path exclusion has
+/// nothing to match — rule selection (IncludedRules/ExcludedRules,
+/// NoWarn) is the narrowing mechanism.
+///
+[CodeyBoxPlugin(
+ id: PluginId,
+ displayName: "CodeyBox: ApiCompat .NET API Compatibility",
+ minHostApiVersion: "1.0")]
+[CodeyBoxPluginRequiresTool(
+ "apicompat",
+ InstallHint = "provision the pinned apicompat release (see ExpectedVersion, default "
+ + DefaultExpectedVersion + ") into the sandbox baseline — no distro apt package carries "
+ + "it — with 'dotnet tool install --global Microsoft.DotNet.ApiCompat.Tool --version "
+ + DefaultExpectedVersion + "' (which needs the .NET SDK on PATH) via "
+ + "CodeyBox:MultipassExtraRuncmd / CodeyBox:Incus:ExtraRuncmd or ExecutableProvisions")]
+public sealed class ApiCompatAuditor : ExternalToolAuditorBase, IPluginInitializer
+{
+ /// Plugin id used in Plugins:Enabled and the scoped-config section.
+ public const string PluginId = "codeybox.apicompat";
+
+ ///
+ /// apicompat release the invocation, its exit convention, and its report
+ /// shape were verified against. Operators running a different pinned
+ /// build set ExpectedVersion in the plugin's scoped config to
+ /// match what they provisioned.
+ ///
+ public const string DefaultExpectedVersion = "10.0.401";
+
+ /// Scoped-config key for baseline assemblies (-l/--left-assembly, comma-separated).
+ public const string LeftAssemblyKey = "LeftAssembly";
+
+ /// Scoped-config key for current assemblies (-r/--right-assembly, comma-separated).
+ public const string RightAssemblyKey = "RightAssembly";
+
+ /// Scoped-config key for the current package (package <pkg>).
+ public const string PackageKey = "Package";
+
+ /// Scoped-config key for the baseline package (--baseline-package).
+ public const string BaselinePackageKey = "BaselinePackage";
+
+ ///
+ /// Scoped-config key for compatibility suppression files (--suppression-file,
+ /// comma-separated). Prefer an operator-owned file outside the audited repository.
+ ///
+ public const string SuppressionFileKey = "SuppressionFile";
+
+ /// Scoped-config key for assembly-mode strict checks (--strict-mode).
+ public const string StrictModeKey = "StrictMode";
+
+ /// Scoped-config key for checking internal APIs too (--respect-internals).
+ public const string RespectInternalsKey = "RespectInternals";
+
+ /// Scoped-config key for the parameter-name rule (--enable-rule-cannot-change-parameter-name).
+ public const string EnableRuleCannotChangeParameterNameKey = "EnableRuleCannotChangeParameterName";
+
+ /// Scoped-config key for the attributes rule (--enable-rule-attributes-must-match).
+ public const string EnableRuleAttributesMustMatchKey = "EnableRuleAttributesMustMatch";
+
+ /// Scoped-config key for disabling diagnostics (--noWarn <ids>).
+ public const string NoWarnKey = "NoWarn";
+
+ private const string LeftFlag = "--left-assembly";
+ private const string LeftShortFlag = "-l";
+ private const string RightFlag = "--right-assembly";
+ private const string RightShortFlag = "-r";
+ private const string BaselinePackageFlag = "--baseline-package";
+ private const string SuppressionFileFlag = "--suppression-file";
+ private const string NoWarnFlag = "--noWarn";
+ private const string StrictModeFlag = "--strict-mode";
+ private const string RespectInternalsFlag = "--respect-internals";
+ private const string ParameterNameRuleFlag = "--enable-rule-cannot-change-parameter-name";
+ private const string AttributesRuleFlag = "--enable-rule-attributes-must-match";
+ private const string GenerateSuppressionFileFlag = "--generate-suppression-file";
+ private const string SuppressionOutputFileFlag = "--suppression-output-file";
+ private const string PackageSubcommand = "package";
+
+ private static readonly ExternalToolAuditorOptions AuditorDefaults = new()
+ {
+ // 0 = ran clean (clean-run marker); 1 = breaking changes found OR
+ // could not run — the parser tells them apart by content (CPnnnn
+ // diagnostic lines mean findings, their absence means
+ // infrastructure). Everything else is infrastructure.
+ FindingsExitCodes = new HashSet { 0, 1 },
+ // No default: the tool compares exactly the configured
+ // assemblies/packages and never walks the tree, so vendored or
+ // generated code cannot produce findings to exclude. Findings carry
+ // member identities, never file paths, so path exclusion has
+ // nothing to match — narrow with IncludedRules/ExcludedRules/NoWarn.
+ };
+
+ private Func _optionsAccessor = () => AuditorDefaults;
+ private Func _expectedVersion = static () => DefaultExpectedVersion;
+ private Func _leftAssembly = static () => null;
+ private Func _rightAssembly = static () => null;
+ private Func _package = static () => null;
+ private Func _baselinePackage = static () => null;
+ private Func _suppressionFile = static () => null;
+ private Func _noWarn = static () => null;
+ private Func _strictMode = static () => false;
+ private Func _respectInternals = static () => false;
+ private Func _enableRuleCannotChangeParameterName = static () => false;
+ private Func _enableRuleAttributesMustMatch = static () => false;
+
+ ///
+ public override string Name => "codeybox:apicompat";
+
+ ///
+ protected override string ToolName => "apicompat";
+
+ ///
+ protected override IExternalToolOutputParser OutputParser { get; } =
+ new ApiCompatReportParser();
+
+ ///
+ /// Declared mapping from apicompat's severity vocabulary to
+ /// . The tool prints every diagnostic under its
+ /// "API compatibility errors" header with no per-diagnostic level, so the
+ /// parser labels each finding error and the default resolves
+ /// everything (including any future level the map does not name) to
+ /// . Raw tool levels never reach
+ /// findings.
+ ///
+ protected override ExternalToolSeverityMapping SeverityMapping { get; } =
+ new(new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["error"] = AuditSeverity.Error,
+ ["fail"] = AuditSeverity.Error,
+ ["failure"] = AuditSeverity.Error,
+ ["critical"] = AuditSeverity.Error,
+ ["high"] = AuditSeverity.Error,
+ ["breaking"] = AuditSeverity.Error,
+ ["warning"] = AuditSeverity.Warning,
+ ["warn"] = AuditSeverity.Warning,
+ ["dangerous"] = AuditSeverity.Warning,
+ ["medium"] = AuditSeverity.Warning,
+ ["info"] = AuditSeverity.Info,
+ ["low"] = AuditSeverity.Info,
+ ["non-breaking"] = AuditSeverity.Info,
+ ["safe"] = AuditSeverity.Info,
+ }, AuditSeverity.Error);
+
+ ///
+ protected override Func OptionsAccessor => _optionsAccessor;
+
+ ///
+ protected override ToolVersionPin? VersionPin =>
+ new(PluginId, _expectedVersion, DefaultExpectedVersion, ["--version"]);
+
+ ///
+ /// Builds the scan argv. Exactly one mode wins: package mode when a package-side
+ /// scoped key is set or ExtraArguments carries the package subcommand,
+ /// otherwise assembly mode. Assembly keys combined with package keys are a
+ /// deterministic configuration failure, as is a mode with no resolvable baseline
+ /// (missing key and no operator-supplied flag). For any slot the operator supplies
+ /// via ExtraArguments, the auditor emits nothing — the base appends the
+ /// operator's argv verbatim. Values travel as their own argv entries, never through
+ /// a shell.
+ ///
+ protected override IReadOnlyList BuildToolArguments(ExternalToolAuditorOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var lefts = SplitScopedList(_leftAssembly(), LeftAssemblyKey);
+ var rights = SplitScopedList(_rightAssembly(), RightAssemblyKey);
+ var package = ValidatedScopedValue(_package(), PackageKey);
+ var baselinePackage = ValidatedScopedValue(_baselinePackage(), BaselinePackageKey);
+ var packageMode = package is not null
+ || baselinePackage is not null
+ || OptionsCarrySubcommand(options, PackageSubcommand);
+
+ if (packageMode && (lefts.Count > 0 || rights.Count > 0))
+ throw new AuditUnavailableException(
+ $"could-not-verify: auditor '{Name}' has both assembly baselines "
+ + $"({LeftAssemblyKey}/{RightAssemblyKey}) and package baselines "
+ + $"({PackageKey}/{BaselinePackageKey}) configured — set exactly one comparison mode.")
+ { IsDeterministic = true };
+
+ return packageMode
+ ? BuildPackageArguments(options, package, baselinePackage)
+ : BuildAssemblyArguments(options, lefts, rights);
+ }
+
+ private List BuildPackageArguments(
+ ExternalToolAuditorOptions options, string? package, string? baselinePackage)
+ {
+ if (OptionsCarrySubcommand(options, PackageSubcommand))
+ return [];
+
+ if (package is null)
+ throw new AuditUnavailableException(
+ $"could-not-verify: auditor '{Name}' is in package mode ({BaselinePackageKey} is set) "
+ + $"but scoped '{PackageKey}' names no current package to validate. Configure "
+ + $"CodeyBox:Plugins:{PluginId}:{PackageKey} (and :{BaselinePackageKey} for the "
+ + "baseline), or drive the whole invocation through ExtraArguments.")
+ { IsDeterministic = true };
+
+ var args = new List { PackageSubcommand, package };
+ if (!ExtraArgumentsSupplyFlag(options, BaselinePackageFlag))
+ {
+ if (baselinePackage is null)
+ throw new AuditUnavailableException(
+ $"could-not-verify: auditor '{Name}' has no baseline to compare against: scoped "
+ + $"'{PackageKey}' names the current package but neither scoped '{BaselinePackageKey}' "
+ + $"nor an ExtraArguments '{BaselinePackageFlag}' flag names the baseline package. "
+ + "A compatibility check without a previous API is meaningless — configure one.")
+ { IsDeterministic = true };
+ args.Add(BaselinePackageFlag);
+ args.Add(baselinePackage);
+ }
+
+ AddSharedFlags(args, options);
+ return args;
+ }
+
+ private List BuildAssemblyArguments(
+ ExternalToolAuditorOptions options, IReadOnlyList lefts, IReadOnlyList rights)
+ {
+ var args = new List();
+ var operatorLeft = ExtraArgumentsSupplyFlag(options, LeftFlag, "--left", LeftShortFlag);
+ var operatorRight = ExtraArgumentsSupplyFlag(options, RightFlag, "--right", RightShortFlag);
+
+ if (!operatorLeft)
+ {
+ if (lefts.Count == 0)
+ throw new AuditUnavailableException(
+ $"could-not-verify: auditor '{Name}' has no baseline to compare against: scoped "
+ + $"'{LeftAssemblyKey}' names no baseline assembly and no ExtraArguments "
+ + "'-l/--left-assembly' flag does either. A compatibility check without a "
+ + "previous API is meaningless — configure "
+ + $"CodeyBox:Plugins:{PluginId}:{LeftAssemblyKey} (and :{RightAssemblyKey} for the "
+ + "current assemblies), or drive the whole invocation through ExtraArguments.")
+ { IsDeterministic = true };
+ foreach (var left in lefts)
+ {
+ args.Add(LeftFlag);
+ args.Add(left);
+ }
+ }
+
+ if (!operatorRight)
+ {
+ if (rights.Count == 0)
+ throw new AuditUnavailableException(
+ $"could-not-verify: auditor '{Name}' has no current API to compare: scoped "
+ + $"'{RightAssemblyKey}' names no current assembly and no ExtraArguments "
+ + "'-r/--right-assembly' flag does either. Configure "
+ + $"CodeyBox:Plugins:{PluginId}:{RightAssemblyKey}, or drive the whole invocation "
+ + "through ExtraArguments.")
+ { IsDeterministic = true };
+ foreach (var right in rights)
+ {
+ args.Add(RightFlag);
+ args.Add(right);
+ }
+ }
+
+ if (_strictMode() && !ExtraArgumentsSupplyFlag(options, StrictModeFlag))
+ args.Add(StrictModeFlag);
+
+ AddSharedFlags(args, options);
+ return args;
+ }
+
+ private void AddSharedFlags(List args, ExternalToolAuditorOptions options)
+ {
+ // StrictMode is assembly-mode only (package validation has its own
+ // per-framework strict flags, reachable through ExtraArguments), so
+ // only the callers that accept it emit it.
+
+ if (!ExtraArgumentsSupplyFlag(options, SuppressionFileFlag))
+ {
+ foreach (var suppression in SplitScopedList(_suppressionFile(), SuppressionFileKey))
+ {
+ args.Add(SuppressionFileFlag);
+ args.Add(suppression);
+ }
+ }
+
+ if (!ExtraArgumentsSupplyFlag(options, NoWarnFlag))
+ AddValueFlag(args, NoWarnFlag, ValidatedScopedValue(_noWarn(), NoWarnKey));
+
+ if (_respectInternals() && !ExtraArgumentsSupplyFlag(options, RespectInternalsFlag))
+ args.Add(RespectInternalsFlag);
+ if (_enableRuleCannotChangeParameterName()
+ && !ExtraArgumentsSupplyFlag(options, ParameterNameRuleFlag))
+ args.Add(ParameterNameRuleFlag);
+ if (_enableRuleAttributesMustMatch()
+ && !ExtraArgumentsSupplyFlag(options, AttributesRuleFlag))
+ args.Add(AttributesRuleFlag);
+ }
+
+ ///
+ /// Side-effect guard on the live path: --generate-suppression-file and
+ /// --suppression-output-file make the scan write files into the sandbox —
+ /// the audited worktree — mutating the tree under audit and letting a run mint a
+ /// suppression file a later run could mistake for operator configuration. Both
+ /// spellings (bare and --flag=value) fail closed as deterministic
+ /// infrastructure before the scan runs. Assembly paths themselves need no guard:
+ /// the tool opens them through Roslyn metadata readers — it never executes them —
+ /// and every value travels as its own argv entry, never through a shell.
+ ///
+ protected override Task VerifyToolAsync(
+ ISandbox sandbox,
+ string workingDirectory,
+ string tool,
+ ExternalToolAuditorOptions options,
+ CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ foreach (var extra in options.ExtraArguments)
+ {
+ if (IsSuppressionWriteFlag(extra))
+ throw new AuditUnavailableException(
+ $"could-not-verify: auditor '{Name}' rejects ExtraArguments entry '{extra}': "
+ + "suppression-file generation writes into the audited tree and would let a scan "
+ + "mint the configuration a later scan enforces. Generate suppression files "
+ + "out-of-band instead.")
+ { IsDeterministic = true };
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task InitializeAsync(PluginContext context, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ var scoped = context.ScopedConfig;
+ _optionsAccessor = () => ExternalToolAuditorOptions.Bind(scoped, AuditorDefaults);
+ _expectedVersion = () => scoped[ToolVersionPin.ExpectedVersionKey];
+ _leftAssembly = () => scoped[LeftAssemblyKey];
+ _rightAssembly = () => scoped[RightAssemblyKey];
+ _package = () => scoped[PackageKey];
+ _baselinePackage = () => scoped[BaselinePackageKey];
+ _suppressionFile = () => scoped[SuppressionFileKey];
+ _noWarn = () => scoped[NoWarnKey];
+ _strictMode = () => bool.TryParse(scoped[StrictModeKey], out var strict) && strict;
+ _respectInternals = () => bool.TryParse(scoped[RespectInternalsKey], out var internals) && internals;
+ _enableRuleCannotChangeParameterName = () =>
+ bool.TryParse(scoped[EnableRuleCannotChangeParameterNameKey], out var rule) && rule;
+ _enableRuleAttributesMustMatch = () =>
+ bool.TryParse(scoped[EnableRuleAttributesMustMatchKey], out var rule) && rule;
+ context.Logger.LogInformation(
+ "ApiCompatAuditor initialized: pluginId={PluginId}", context.PluginId);
+ return Task.CompletedTask;
+ }
+
+ private static List SplitScopedList(string? configured, string key)
+ {
+ var values = ExternalToolAuditorOptions.SplitCommaSeparatedList(configured);
+ var validated = new List(values.Count);
+ foreach (var value in values)
+ {
+ var entry = ValidatedScopedValue(value, key);
+ if (entry is not null)
+ validated.Add(entry);
+ }
+
+ return validated;
+ }
+
+ private static bool OptionsCarrySubcommand(ExternalToolAuditorOptions options, string subcommand)
+ {
+ foreach (var arg in options.ExtraArguments)
+ {
+ if (string.Equals(arg?.Trim(), subcommand, StringComparison.Ordinal))
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool IsSuppressionWriteFlag(string? argument)
+ {
+ if (string.IsNullOrWhiteSpace(argument))
+ return false;
+ var value = argument.Trim();
+ return value.Equals(GenerateSuppressionFileFlag, StringComparison.Ordinal)
+ || value.StartsWith(GenerateSuppressionFileFlag + "=", StringComparison.Ordinal)
+ || value.Equals(SuppressionOutputFileFlag, StringComparison.Ordinal)
+ || value.StartsWith(SuppressionOutputFileFlag + "=", StringComparison.Ordinal);
+ }
+}
diff --git a/plugins/auditors-api-compatibility/CodeyBox.ApiCompatAuditorPlugin/ApiCompatReportParser.cs b/plugins/auditors-api-compatibility/CodeyBox.ApiCompatAuditorPlugin/ApiCompatReportParser.cs
new file mode 100644
index 00000000..e4a8ee4e
--- /dev/null
+++ b/plugins/auditors-api-compatibility/CodeyBox.ApiCompatAuditorPlugin/ApiCompatReportParser.cs
@@ -0,0 +1,115 @@
+using System.Text.RegularExpressions;
+using CodeyBox.PluginSdk.Tools;
+
+namespace CodeyBox.ApiCompatAuditorPlugin;
+
+///
+/// Parses the apicompat human-readable report (Microsoft.DotNet.ApiCompat.Tool)
+/// into records. Verified against 10.0.401: the tool
+/// exposes no JSON or SARIF output mode — its verdict is a plain-text report written
+/// to stderr when breaking changes exist (stdout carries only the clean-run
+/// marker), so both streams are scanned for diagnostic lines.
+///
+/// A diagnostic line is CPnnnn: <message> (observed: CP0001 type
+/// removed, CP0002 member removed, CP0017 parameter name changed behind its
+/// opt-in rule). Each becomes one finding carrying the diagnostic id as the rule id and
+/// the remainder as the message. The tool prints no per-diagnostic severity — everything
+/// appears under the API compatibility errors between … header — so findings carry
+/// the canonical token, which the auditor's declared mapping
+/// resolves to ; raw tool levels never
+/// reach findings.
+///
+/// The tool reports member identities (e.g. int TestLib.Widget.Id.get), never
+/// a file/line — findings therefore carry no location. Paths are never reconstructed by
+/// parsing human-readable messages.
+///
+/// The exit code and the report cross-check each other, because exit 1 is
+/// ambiguous: breaking changes exit 1 with diagnostic lines, but a run failure
+/// (missing assembly → Unhandled exception: …FileNotFoundException… plus a stack
+/// trace; bad invocation → Option '…' is required / Unrecognized … usage
+/// text) also exits 1. Exit 1 with diagnostic lines is the findings verdict;
+/// exit 1 with none contradicts the output contract and throws
+/// — infrastructure, never a pass. An empty or
+/// unrecognizable report on exit 0 without the clean-run marker is likewise
+/// infrastructure: a silent tool is never a passing audit.
+///
+internal sealed class ApiCompatReportParser : IExternalToolOutputParser
+{
+ /// Canonical tool level for CPnnnn diagnostics (maps to Error).
+ internal const string ErrorLevel = "error";
+
+ /// Marker printed to stdout when the comparison completed with no breaking changes.
+ internal const string CleanMarker = "APICompat ran successfully without finding any breaking changes.";
+
+ /// Trailer printed after the diagnostics when breaking changes exist.
+ internal const string BreakingTrailer = "API breaking changes found.";
+
+ // Same per-report result bound the shared SARIF parser applies.
+ private const int MaxResults = SarifToolOutputParser.DefaultMaxResults;
+ private const int MessageMaxChars = 512;
+
+ private static readonly Regex DiagnosticLine = new(
+ @"^CP(?\d+)\s*:\s*(?.+?)\s*$",
+ RegexOptions.CultureInvariant | RegexOptions.Compiled);
+
+ public IReadOnlyList Parse(ExternalToolParseInput input)
+ {
+ ArgumentNullException.ThrowIfNull(input);
+
+ var findings = new List();
+ var sawCleanMarker = false;
+
+ foreach (var rawLine in SplitLines(input.Stdout, input.Stderr))
+ {
+ var line = ToolOutputText.SingleLine(rawLine).Trim();
+ if (line.Length == 0)
+ continue;
+
+ if (line.Contains(CleanMarker, StringComparison.Ordinal))
+ {
+ sawCleanMarker = true;
+ continue;
+ }
+
+ var match = DiagnosticLine.Match(line);
+ if (!match.Success || findings.Count >= MaxResults)
+ continue;
+
+ var message = ExternalToolJsonHelpers.Truncate(match.Groups["message"].Value, MessageMaxChars);
+ if (string.IsNullOrWhiteSpace(message))
+ continue;
+
+ findings.Add(new ExternalToolFinding(
+ SeverityLevel: ErrorLevel,
+ RuleId: "CP" + match.Groups["id"].Value,
+ Message: message,
+ Path: null,
+ Line: null));
+ }
+
+ if (findings.Count == 0)
+ {
+ if (input.ExitCode == 0 && sawCleanMarker)
+ return findings;
+
+ throw new ExternalToolParseException(
+ $"Tool '{input.ToolName}' exited {input.ExitCode} without a recognizable "
+ + "apicompat report (no CPnnnn diagnostic lines and no clean-run marker) — "
+ + "the check claims nothing the report shows.");
+ }
+
+ return findings;
+ }
+
+ private static IEnumerable SplitLines(params string[] streams)
+ {
+ foreach (var stream in streams)
+ {
+ if (string.IsNullOrEmpty(stream))
+ continue;
+ using var reader = new StringReader(stream);
+ while (reader.ReadLine() is { } line)
+ yield return line;
+ }
+ }
+}
diff --git a/plugins/auditors-api-compatibility/CodeyBox.ApiCompatAuditorPlugin/CodeyBox.ApiCompatAuditorPlugin.csproj b/plugins/auditors-api-compatibility/CodeyBox.ApiCompatAuditorPlugin/CodeyBox.ApiCompatAuditorPlugin.csproj
new file mode 100644
index 00000000..696fccf9
--- /dev/null
+++ b/plugins/auditors-api-compatibility/CodeyBox.ApiCompatAuditorPlugin/CodeyBox.ApiCompatAuditorPlugin.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/auditors-api-compatibility/CodeyBox.ApiCompatAuditorPlugin/README.md b/plugins/auditors-api-compatibility/CodeyBox.ApiCompatAuditorPlugin/README.md
new file mode 100644
index 00000000..add86f56
--- /dev/null
+++ b/plugins/auditors-api-compatibility/CodeyBox.ApiCompatAuditorPlugin/README.md
@@ -0,0 +1,144 @@
+# CodeyBox: ApiCompat .NET API Compatibility Auditor
+
+Auditor plugin wrapping [`apicompat`](https://learn.microsoft.com/en-us/dotnet/fundamentals/apicompat/global-tool)
+(`Microsoft.DotNet.ApiCompat.Tool`): it compares the public API surface of .NET
+assemblies (or packages) in the audited working tree against a baseline and reports
+compatibility breaks as CodeyBox audit findings.
+
+## What it reports
+
+- One finding per `apicompat` diagnostic, emitted as `CPnnnn:` text lines on stderr
+ (the tool exposes no JSON mode). The title carries the diagnostic identifier
+ (e.g. `CP0001` type removed, `CP0002` member removed, `CP0017` parameter name
+ changed behind its opt-in rule); the description carries the tool, rule,
+ tool-reported severity, and problem message. Diagnostics name API member
+ identities, never files — findings carry no `Location`.
+- **Gate behaviour: blocking.** `apicompat` reports no severity per diagnostic —
+ every emitted line is a public API break — so every finding maps to
+ `AuditSeverity.Error` and **fails the audit** (`Passed = false`). There is no
+ advisory mode; narrow scope with `IncludedRules` / `ExcludedRules`, `NoWarn`, or
+ an operator-owned `SuppressionFile` instead.
+
+## What it cannot see
+
+- **Source that was never built.** The tool compares compiled assemblies/packages,
+ not source. An API break in source that is not covered by the configured
+ `LeftAssembly` / `RightAssembly` (or `Package`) inputs is invisible; keep the
+ configured inputs aligned with the libraries under audit.
+- **Compatible evolution.** Added types and members are API-safe and produce no
+ findings in the default (non-strict) mode — only breaks do. `StrictMode`
+ additionally flags additions.
+- **Path exclusion.** Findings carry member identities, never file paths, so
+ `ExcludePaths` has nothing to match and is inert for this auditor. Narrow with
+ `IncludedRules` / `ExcludedRules` (exact diagnostic ids such as `CP0002`) or
+ `NoWarn` instead.
+- **Suppression tuning in the audited repo.** Compatibility suppressions come only
+ from the file `SuppressionFile` points at (or `--suppression-file` in
+ `ExtraArguments`). A suppression file inside the audited repository lets a change
+ set soften the check applied to it; operators who need the gate independent of
+ repo-authored tuning should point `SuppressionFile` at an operator-owned file
+ outside the repository. Post-hoc rule filtering through `IncludedRules` /
+ `ExcludedRules` is applied by the audit host, outside the subject's reach.
+- **Generated suppression files.** `--generate-suppression-file` /
+ `--suppression-output-file` in `ExtraArguments` are rejected: they make the scan
+ write files into the audited tree. Generate suppression files out-of-band.
+
+## Exit codes and failure classification
+
+`apicompat`'s exit convention (verified against 10.0.401 live runs — it is **not**
+the common 0/1/2 convention; exit `1` is ambiguous):
+
+| Exit | Meaning | Classification |
+|---|---|---|
+| `0` | Ran clean, no breaking changes (clean-run marker on stdout) | Verdict (Pass) |
+| `1` | Ran and found breaking changes (`CPnnnn:` lines on stderr) | Verdict (`Passed = false`, findings reported) |
+| `1` (no `CPnnnn` lines) | Could not run (missing assembly with an `Unhandled exception: …` dump, usage error such as `Option '…' is required`) — contradicts the output contract | Infrastructure (`AuditUnavailableException`) |
+| `0` (no marker) | Silent tool with no recognizable report | Infrastructure (`AuditUnavailableException`) |
+| `126` / `127` | Binary not executable or not found | Infrastructure (`AuditUnavailableException`) |
+| anything else | Unknown convention or signal termination | Infrastructure (`AuditUnavailableException`) |
+
+## Version pinning
+
+The auditor is pinned to **apicompat `10.0.401`** (`DefaultExpectedVersion`).
+A scanner's diagnostic catalogue and report shape evolve between releases, so
+an unpinned tool would change findings across runs. The auditor probes
+`apicompat --version` before scanning; any version mismatch or missing binary
+fails closed as infrastructure. Operators running a different pinned
+build configure `ExpectedVersion` in scoped configuration.
+
+The tool requirement is declared **verify-only** (no `AptPackage`): no
+distro apt package carries a pinned `apicompat`. Provision the pinned release
+in your sandbox baseline from NuGet via the .NET SDK (which must be on PATH):
+
+```sh
+# Baseline provisioning bake step
+dotnet tool install --global Microsoft.DotNet.ApiCompat.Tool --version 10.0.401
+apicompat --version # must print 10.0.401
+```
+
+## Enabling
+
+The plugin is **disabled by default**. It loads only when allowlisted and
+enabled:
+
+```json
+{
+ "CodeyBox": {
+ "Plugins": {
+ "Allowlist": ["codeybox.apicompat"],
+ "Enabled": ["codeybox.apicompat"]
+ }
+ }
+}
+```
+
+and per project under `Audit.Custom`:
+
+```json
+{ "Kind": "plugin", "PluginId": "codeybox.apicompat" }
+```
+
+When disabled, the plugin's `apicompat` tool requirement
+contributes nothing to sandbox baseline provisioning — no presence probe
+and no install step mention `apicompat`.
+
+## Configuration
+
+Scoped under `CodeyBox:Plugins:codeybox.apicompat`, hot-reloadable per run:
+
+| Key | Default | Meaning |
+|---|---|---|
+| `ExpectedVersion` | `10.0.401` | Pinned apicompat release. A different installed version fails closed as infrastructure. |
+| `LeftAssembly` | — (required in assembly mode) | Baseline assembly path(s), comma-separated, passed to `-l/--left-assembly`. Sandbox paths; each value travels as its own argv entry. |
+| `RightAssembly` | — (required in assembly mode) | Current assembly path(s), comma-separated, passed to `-r/--right-assembly`. |
+| `Package` | — (required in package mode) | Current package path for `apicompat package `. Setting `Package` or `BaselinePackage` selects package mode. |
+| `BaselinePackage` | — (required in package mode) | Baseline package path passed to `--baseline-package`. |
+| `SuppressionFile` | `null` | Compatibility suppression file(s), comma-separated, passed to `--suppression-file`. Prefer an operator-owned file outside the repository (see above). |
+| `StrictMode` | `false` | Assembly-mode strict checks (`--strict-mode`): additions become findings. Not emitted in package mode (which has its own per-framework strict flags, reachable through `ExtraArguments`). |
+| `RespectInternals` | `false` | Check internal APIs too (`--respect-internals`). |
+| `EnableRuleCannotChangeParameterName` | `false` | Flag parameter renames (`--enable-rule-cannot-change-parameter-name`, e.g. `CP0017`). |
+| `EnableRuleAttributesMustMatch` | `false` | Flag attribute mismatches (`--enable-rule-attributes-must-match`). |
+| `NoWarn` | `null` | Diagnostic ids to suppress (`--noWarn`, e.g. `CP0017`). |
+| `MinimumSeverity` | `info` | No-op for this auditor: every apicompat finding is `error`, so no threshold can narrow them. Use `IncludedRules` / `ExcludedRules` / `NoWarn` instead. |
+| `IncludedRules` / `ExcludedRules` | — | Exact diagnostic ids to include or drop (e.g. `CP0002`). Applied by the host after the scan. |
+| `ExcludePaths` | — (none) | Inert for this auditor: findings carry no file paths. Setting it changes nothing. |
+| `ExtraArguments` | — | Extra CLI arguments appended to argv (e.g. `--exclude-attributes-file`, package-mode strict flags). `--generate-suppression-file` / `--suppression-output-file` are rejected; a `--suppression-file` / `--noWarn` / side flag here takes precedence over the matching scoped key. |
+| `TimeoutSeconds` | `300` | Per-run execution bound. Exceeding it is an infrastructure failure. |
+| `MaxOutputBytesPerStream` / `MaxFindings` | `1 MiB` / `1000` | Output/findings bounds; excess is reported as truncated. |
+
+Assembly keys combined with package keys are a deterministic configuration
+failure, as is a mode with no resolvable baseline (missing key and no
+operator-supplied flag covering that slot).
+
+## Default scope
+
+By default (assembly mode), the auditor runs
+`apicompat --left --right ` at the repository
+root and compares exactly those binaries: the tool never walks the repository,
+so vendored assemblies and generated trees cannot produce findings and no
+`ExcludePaths` default is needed. Both assembly pointers are required with no
+guessed default: which binaries constitute the public API surface varies per
+repository (build outputs, checked-in reference assemblies), and any invented
+default risks a silent no-op comparison. An unset baseline is a deterministic
+infrastructure failure naming the key — enabling this auditor without
+configuring it is a loud misconfiguration, never a silent skip.
diff --git a/tests/CodeyBox.Tests/ApiCompatAuditorTests.cs b/tests/CodeyBox.Tests/ApiCompatAuditorTests.cs
new file mode 100644
index 00000000..2ecb449d
--- /dev/null
+++ b/tests/CodeyBox.Tests/ApiCompatAuditorTests.cs
@@ -0,0 +1,839 @@
+using System.Diagnostics;
+using System.Text.RegularExpressions;
+using CodeyBox.ApiCompatAuditorPlugin;
+using CodeyBox.Core;
+using CodeyBox.Orchestrator;
+using CodeyBox.PluginSdk;
+using CodeyBox.PluginSdk.Tools;
+using CodeyBox.Sandbox.Process;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace CodeyBox.Tests;
+
+///
+/// Covers the apicompat auditor plugin:
+/// - Missing or wrong-version binary is an infrastructure failure naming apicompat (never a pass or finding).
+/// - Exits 0 and 1 are verdicts (findings-producing); any other exit is infrastructure.
+/// - Exit 1 is ambiguous: CPnnnn diagnostic lines mean findings, while run failures
+/// (unhandled-exception dumps, usage errors) with no diagnostic lines fail closed as infrastructure.
+/// - Text output maps to findings with rule ids and mapped severity; findings carry no file location.
+/// - Raw tool output carries no severity; every finding maps to Error (never passed through).
+/// - Assembly mode (LeftAssembly/RightAssembly) and package mode (Package/BaselinePackage),
+/// with deterministic failures for conflicting or missing baselines and for
+/// suppression-file generation flags.
+/// - Plugin is disabled by default, absent from baseline provisioning until enabled.
+/// - Real binary execution tests under [Trait("requires_apicompat", "true")].
+///
+public sealed class ApiCompatAuditorTests
+{
+ private static readonly string? InstalledApicompatVersion = ProbeInstalledApicompatVersion();
+
+ private const string BreakingStderr = """
+ API compatibility errors between '/left/TestLib.dll' (left) and '/right/TestLib.dll' (right):
+ CP0002: Member 'int TestLib.Widget.Id.get' exists on /left/TestLib.dll but not on /right/TestLib.dll
+ CP0002: Member 'void TestLib.Widget.Id.set' exists on /left/TestLib.dll but not on /right/TestLib.dll
+ API breaking changes found. If those are intentional, the APICompat suppression file can be updated by specifying the '--generate-suppression-file' parameter.
+ """;
+
+ private const string CleanStdout = """
+ APICompat ran successfully without finding any breaking changes.
+ """;
+
+ private const string UnhandledExceptionStderr = """
+ Unhandled exception: System.IO.FileNotFoundException: Could not find the provided path '/missing.dll' to load binaries from.
+ at Microsoft.DotNet.ApiSymbolExtensions.AssemblySymbolLoader.LoadFromPaths(IEnumerable`1 paths, ImmutableHashSet`1 referenceAssemblyNamesToIgnore)
+ at Microsoft.DotNet.ApiCompatibility.Runner.ApiCompatRunner.ExecuteWorkItems()
+ """;
+
+ private const string UsageErrorOutput = """
+ Option '--right-assembly' is required.
+
+ Description:
+ Microsoft.DotNet.ApiCompat v10.0.401+e34a38d2ae1fc26406a317517196e55c68ff83ab
+ """;
+
+ [Fact]
+ public async Task MissingBinary_IsInfrastructureFailure_NamingApicompat_NeverAPass()
+ {
+ var scanExecs = 0;
+ var sandbox = new FakeSandbox((exec, _) =>
+ {
+ if (IsPresenceProbe(exec))
+ return Task.FromResult(new SandboxExecResult(1, "", ""));
+ if (IsVersionProbe(exec))
+ return Task.FromResult(new SandboxExecResult(127, "", "apicompat: command not found"));
+ scanExecs++;
+ return Task.FromResult(new SandboxExecResult(0, "", ""));
+ });
+
+ var auditor = new ApiCompatAuditor();
+ await auditor.InitializeAsync(BuildPluginContext(AssemblyScopedConfig()), CancellationToken.None);
+ var ex = await Assert.ThrowsAsync(
+ () => ((IAuditor)auditor).RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None));
+
+ Assert.Contains("apicompat", ex.Message, StringComparison.Ordinal);
+ Assert.Equal(0, scanExecs);
+ }
+
+ [Fact]
+ public async Task VersionProbeFailed_IsInfrastructureFailure_NamingApicompat()
+ {
+ var scanExecs = 0;
+ var sandbox = new FakeSandbox((exec, _) =>
+ {
+ if (IsPresenceProbe(exec))
+ return Task.FromResult(new SandboxExecResult(0, "", ""));
+ if (IsVersionProbe(exec))
+ return Task.FromResult(new SandboxExecResult(127, "", ""));
+ scanExecs++;
+ return Task.FromResult(new SandboxExecResult(0, "", ""));
+ });
+
+ var auditor = new ApiCompatAuditor();
+ await auditor.InitializeAsync(BuildPluginContext(AssemblyScopedConfig()), CancellationToken.None);
+ var ex = await Assert.ThrowsAsync(
+ () => ((IAuditor)auditor).RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None));
+
+ Assert.Contains("apicompat", ex.Message, StringComparison.Ordinal);
+ Assert.Contains("could not be determined", ex.Message, StringComparison.Ordinal);
+ Assert.Equal(0, scanExecs);
+ }
+
+ [Fact]
+ public async Task WrongVersion_IsInfrastructureFailure()
+ {
+ var scanExecs = 0;
+ var sandbox = new FakeSandbox((exec, _) =>
+ {
+ if (IsPresenceProbe(exec))
+ return Task.FromResult(new SandboxExecResult(0, "", ""));
+ if (IsVersionProbe(exec))
+ return Task.FromResult(new SandboxExecResult(0, "9.0.100-preview-unused\n", ""));
+ scanExecs++;
+ return Task.FromResult(new SandboxExecResult(0, "", ""));
+ });
+
+ var auditor = new ApiCompatAuditor();
+ await auditor.InitializeAsync(BuildPluginContext(AssemblyScopedConfig()), CancellationToken.None);
+ var ex = await Assert.ThrowsAsync(
+ () => ((IAuditor)auditor).RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None));
+
+ Assert.Contains("apicompat", ex.Message, StringComparison.Ordinal);
+ Assert.Contains("9.0.100", ex.Message, StringComparison.Ordinal);
+ Assert.Contains(ApiCompatAuditor.DefaultExpectedVersion, ex.Message, StringComparison.Ordinal);
+ Assert.Equal(0, scanExecs);
+ }
+
+ [Fact]
+ public async Task UnparseableExpectedVersion_IsInfrastructureFailure()
+ {
+ var scanExecs = 0;
+ var sandbox = new FakeSandbox((exec, _) =>
+ {
+ if (IsPresenceProbe(exec) || IsVersionProbe(exec))
+ return Task.FromResult(new SandboxExecResult(0, ApiCompatAuditor.DefaultExpectedVersion + "\n", ""));
+ scanExecs++;
+ return Task.FromResult(new SandboxExecResult(0, "", ""));
+ });
+
+ var auditor = new ApiCompatAuditor();
+ await auditor.InitializeAsync(
+ BuildPluginContext(new Dictionary
+ {
+ ["Scoped:ExpectedVersion"] = "not-a-valid-version-string",
+ ["Scoped:LeftAssembly"] = "/left/TestLib.dll",
+ ["Scoped:RightAssembly"] = "/right/TestLib.dll",
+ }),
+ CancellationToken.None);
+
+ var ex = await Assert.ThrowsAsync(
+ () => ((IAuditor)auditor).RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None));
+
+ Assert.Contains("unparseable ExpectedVersion", ex.Message, StringComparison.Ordinal);
+ Assert.Equal(0, scanExecs);
+ }
+
+ [Fact]
+ public async Task Fixture_WithBreakingChange_YieldsFindings_WithRuleIdAndNoLocation()
+ {
+ SandboxExec? scanExec = null;
+ var sandbox = new FakeSandbox((exec, _) =>
+ {
+ if (IsPresenceProbe(exec) || IsVersionProbe(exec))
+ return Task.FromResult(Ok(exec));
+ scanExec = exec;
+ return Task.FromResult(new SandboxExecResult(1, "", BreakingStderr));
+ });
+
+ var auditor = new ApiCompatAuditor();
+ await auditor.InitializeAsync(BuildPluginContext(AssemblyScopedConfig()), CancellationToken.None);
+ var result = await ((IAuditor)auditor).RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None);
+
+ Assert.False(result.Passed);
+ Assert.Equal(2, result.Findings.Count);
+
+ foreach (var finding in result.Findings)
+ {
+ Assert.Equal("codeybox:apicompat", finding.AuditorName);
+ Assert.Equal(AuditSeverity.Error, finding.Severity);
+ Assert.Null(finding.Location);
+ }
+
+ Assert.All(
+ result.Findings,
+ f => Assert.Contains("CP0002", f.Title, StringComparison.Ordinal));
+
+ Assert.NotNull(scanExec);
+ Assert.Equal("apicompat", scanExec!.Argv[0]);
+ var argv = scanExec.Argv.ToList();
+ var leftIndex = argv.IndexOf("--left-assembly");
+ Assert.True(leftIndex >= 0 && leftIndex + 1 < argv.Count);
+ Assert.Equal("/left/TestLib.dll", argv[leftIndex + 1]);
+ var rightIndex = argv.IndexOf("--right-assembly");
+ Assert.True(rightIndex >= 0 && rightIndex + 1 < argv.Count);
+ Assert.Equal("/right/TestLib.dll", argv[rightIndex + 1]);
+ }
+
+ [Fact]
+ public async Task CleanFixture_YieldsZeroFindings_AndPasses()
+ {
+ var sandbox = new FakeSandbox((exec, _) =>
+ {
+ if (IsPresenceProbe(exec) || IsVersionProbe(exec))
+ return Task.FromResult(Ok(exec));
+ return Task.FromResult(new SandboxExecResult(0, CleanStdout, ""));
+ });
+
+ var auditor = new ApiCompatAuditor();
+ await auditor.InitializeAsync(BuildPluginContext(AssemblyScopedConfig()), CancellationToken.None);
+ var result = await ((IAuditor)auditor).RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None);
+
+ Assert.True(result.Passed);
+ Assert.Empty(result.Findings);
+ }
+
+ [Fact]
+ public async Task FindingsExit_ReportsFindings_AndFails()
+ {
+ var sandbox = new FakeSandbox((exec, _) =>
+ {
+ if (IsPresenceProbe(exec) || IsVersionProbe(exec))
+ return Task.FromResult(Ok(exec));
+ return Task.FromResult(new SandboxExecResult(1, "", BreakingStderr));
+ });
+
+ var auditor = new ApiCompatAuditor();
+ await auditor.InitializeAsync(BuildPluginContext(AssemblyScopedConfig()), CancellationToken.None);
+ var result = await ((IAuditor)auditor).RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None);
+
+ Assert.False(result.Passed);
+ Assert.NotEmpty(result.Findings);
+ }
+
+ [Fact]
+ public async Task ExitCode1_WithoutDiagnostics_IsInfrastructureFailure()
+ {
+ var sandbox = new FakeSandbox((exec, _) =>
+ {
+ if (IsPresenceProbe(exec) || IsVersionProbe(exec))
+ return Task.FromResult(Ok(exec));
+ return Task.FromResult(new SandboxExecResult(1, "", UnhandledExceptionStderr));
+ });
+
+ var auditor = new ApiCompatAuditor();
+ await auditor.InitializeAsync(BuildPluginContext(AssemblyScopedConfig()), CancellationToken.None);
+ var ex = await Assert.ThrowsAsync(
+ () => ((IAuditor)auditor).RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None));
+
+ Assert.Contains("apicompat", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task ExitCode1_WithUsageError_IsInfrastructureFailure()
+ {
+ var sandbox = new FakeSandbox((exec, _) =>
+ {
+ if (IsPresenceProbe(exec) || IsVersionProbe(exec))
+ return Task.FromResult(Ok(exec));
+ return Task.FromResult(new SandboxExecResult(1, UsageErrorOutput, ""));
+ });
+
+ var auditor = new ApiCompatAuditor();
+ await auditor.InitializeAsync(BuildPluginContext(AssemblyScopedConfig()), CancellationToken.None);
+ var ex = await Assert.ThrowsAsync(
+ () => ((IAuditor)auditor).RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None));
+
+ Assert.Contains("apicompat", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task UnknownExitCode_IsInfrastructureFailure()
+ {
+ var sandbox = new FakeSandbox((exec, _) =>
+ {
+ if (IsPresenceProbe(exec) || IsVersionProbe(exec))
+ return Task.FromResult(Ok(exec));
+ return Task.FromResult(new SandboxExecResult(2, "", "boom"));
+ });
+
+ var auditor = new ApiCompatAuditor();
+ await auditor.InitializeAsync(BuildPluginContext(AssemblyScopedConfig()), CancellationToken.None);
+ var ex = await Assert.ThrowsAsync(
+ () => ((IAuditor)auditor).RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None));
+
+ Assert.Contains("apicompat", ex.Message, StringComparison.Ordinal);
+ Assert.Contains("exit 2", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task SeverityMapping_EveryFindingIsError_NoRawSeverityPassthrough()
+ {
+ var sandbox = new FakeSandbox((exec, _) =>
+ {
+ if (IsPresenceProbe(exec) || IsVersionProbe(exec))
+ return Task.FromResult(Ok(exec));
+ return Task.FromResult(new SandboxExecResult(1, "", BreakingStderr));
+ });
+
+ var auditor = new ApiCompatAuditor();
+ await auditor.InitializeAsync(BuildPluginContext(AssemblyScopedConfig()), CancellationToken.None);
+ var result = await ((IAuditor)auditor).RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None);
+
+ Assert.Equal(2, result.Findings.Count);
+ Assert.All(result.Findings, f => Assert.Equal(AuditSeverity.Error, f.Severity));
+ Assert.All(
+ result.Findings,
+ f => Assert.Contains("Rule: CP0002", f.Description, StringComparison.Ordinal));
+ Assert.All(
+ result.Findings,
+ f => Assert.Contains("Severity (tool): error", f.Description, StringComparison.Ordinal));
+ Assert.False(result.Passed);
+ }
+
+ [Fact]
+ public async Task ScopedConfiguration_IncludedRules_FiltersOtherRules()
+ {
+ var sandbox = new FakeSandbox((exec, _) =>
+ {
+ if (IsPresenceProbe(exec) || IsVersionProbe(exec))
+ return Task.FromResult(Ok(exec));
+ return Task.FromResult(new SandboxExecResult(1, "", BreakingStderr));
+ });
+
+ var auditor = new ApiCompatAuditor();
+ await auditor.InitializeAsync(
+ BuildPluginContext(new Dictionary
+ {
+ ["Scoped:LeftAssembly"] = "/left/TestLib.dll",
+ ["Scoped:RightAssembly"] = "/right/TestLib.dll",
+ ["Scoped:IncludedRules"] = "CP0001",
+ }),
+ CancellationToken.None);
+
+ var result = await ((IAuditor)auditor).RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None);
+
+ Assert.True(result.Passed);
+ Assert.Empty(result.Findings);
+ }
+
+ [Fact]
+ public async Task MissingBaseline_IsDeterministicFailure_WithoutScan()
+ {
+ var scanExecs = 0;
+ var sandbox = new FakeSandbox((exec, _) =>
+ {
+ if (IsPresenceProbe(exec) || IsVersionProbe(exec))
+ return Task.FromResult(Ok(exec));
+ scanExecs++;
+ return Task.FromResult(new SandboxExecResult(0, CleanStdout, ""));
+ });
+
+ var auditor = new ApiCompatAuditor();
+ await auditor.InitializeAsync(
+ BuildPluginContext(new Dictionary()),
+ CancellationToken.None);
+ var ex = await Assert.ThrowsAsync(
+ () => ((IAuditor)auditor).RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None));
+
+ Assert.True(ex.IsDeterministic);
+ Assert.Contains("no baseline", ex.Message, StringComparison.Ordinal);
+ Assert.Equal(0, scanExecs);
+ }
+
+ [Fact]
+ public async Task ConflictingModes_AreDeterministicFailure_WithoutScan()
+ {
+ var scanExecs = 0;
+ var sandbox = new FakeSandbox((exec, _) =>
+ {
+ if (IsPresenceProbe(exec) || IsVersionProbe(exec))
+ return Task.FromResult(Ok(exec));
+ scanExecs++;
+ return Task.FromResult(new SandboxExecResult(0, CleanStdout, ""));
+ });
+
+ var auditor = new ApiCompatAuditor();
+ await auditor.InitializeAsync(
+ BuildPluginContext(new Dictionary
+ {
+ ["Scoped:LeftAssembly"] = "/left/TestLib.dll",
+ ["Scoped:RightAssembly"] = "/right/TestLib.dll",
+ ["Scoped:Package"] = "/pkgs/current.nupkg",
+ ["Scoped:BaselinePackage"] = "/pkgs/baseline.nupkg",
+ }),
+ CancellationToken.None);
+ var ex = await Assert.ThrowsAsync(
+ () => ((IAuditor)auditor).RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None));
+
+ Assert.True(ex.IsDeterministic);
+ Assert.Equal(0, scanExecs);
+ }
+
+ [Fact]
+ public async Task GenerateSuppressionFile_InExtraArguments_IsDeterministicFailure()
+ {
+ var scanExecs = 0;
+ var sandbox = new FakeSandbox((exec, _) =>
+ {
+ if (IsPresenceProbe(exec) || IsVersionProbe(exec))
+ return Task.FromResult(Ok(exec));
+ scanExecs++;
+ return Task.FromResult(new SandboxExecResult(0, CleanStdout, ""));
+ });
+
+ var auditor = new ApiCompatAuditor();
+ await auditor.InitializeAsync(
+ BuildPluginContext(new Dictionary
+ {
+ ["Scoped:LeftAssembly"] = "/left/TestLib.dll",
+ ["Scoped:RightAssembly"] = "/right/TestLib.dll",
+ ["Scoped:ExtraArguments"] = "--generate-suppression-file",
+ }),
+ CancellationToken.None);
+ var ex = await Assert.ThrowsAsync(
+ () => ((IAuditor)auditor).RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None));
+
+ Assert.True(ex.IsDeterministic);
+ Assert.Equal(0, scanExecs);
+ }
+
+ [Fact]
+ public async Task PackageMode_BuildsPackageArgv()
+ {
+ SandboxExec? scanExec = null;
+ var sandbox = new FakeSandbox((exec, _) =>
+ {
+ if (IsPresenceProbe(exec) || IsVersionProbe(exec))
+ return Task.FromResult(Ok(exec));
+ scanExec = exec;
+ return Task.FromResult(new SandboxExecResult(0, CleanStdout, ""));
+ });
+
+ var auditor = new ApiCompatAuditor();
+ await auditor.InitializeAsync(
+ BuildPluginContext(new Dictionary
+ {
+ ["Scoped:Package"] = "/pkgs/current.nupkg",
+ ["Scoped:BaselinePackage"] = "/pkgs/baseline.nupkg",
+ }),
+ CancellationToken.None);
+ var result = await ((IAuditor)auditor).RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None);
+
+ Assert.True(result.Passed);
+ Assert.NotNull(scanExec);
+ var argv = scanExec!.Argv.ToList();
+ Assert.Equal("apicompat", argv[0]);
+ Assert.Equal("package", argv[1]);
+ Assert.Equal("/pkgs/current.nupkg", argv[2]);
+ var baselineIndex = argv.IndexOf("--baseline-package");
+ Assert.True(baselineIndex >= 0 && baselineIndex + 1 < argv.Count);
+ Assert.Equal("/pkgs/baseline.nupkg", argv[baselineIndex + 1]);
+ }
+
+ [Fact]
+ public async Task ScopedConfiguration_StrictMode_AppendedToAssemblyArguments()
+ {
+ SandboxExec? scanExec = null;
+ var sandbox = new FakeSandbox((exec, _) =>
+ {
+ if (IsPresenceProbe(exec) || IsVersionProbe(exec))
+ return Task.FromResult(Ok(exec));
+ scanExec = exec;
+ return Task.FromResult(new SandboxExecResult(0, CleanStdout, ""));
+ });
+
+ var auditor = new ApiCompatAuditor();
+ await auditor.InitializeAsync(
+ BuildPluginContext(new Dictionary
+ {
+ ["Scoped:LeftAssembly"] = "/left/TestLib.dll",
+ ["Scoped:RightAssembly"] = "/right/TestLib.dll",
+ ["Scoped:StrictMode"] = "true",
+ ["Scoped:NoWarn"] = "CP0017",
+ ["Scoped:SuppressionFile"] = "/ops/suppressions.xml",
+ }),
+ CancellationToken.None);
+ await ((IAuditor)auditor).RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None);
+
+ Assert.NotNull(scanExec);
+ var argv = scanExec!.Argv.ToList();
+ Assert.Contains("--strict-mode", argv);
+ var noWarnIndex = argv.IndexOf("--noWarn");
+ Assert.True(noWarnIndex >= 0 && noWarnIndex + 1 < argv.Count);
+ Assert.Equal("CP0017", argv[noWarnIndex + 1]);
+ var suppressionIndex = argv.IndexOf("--suppression-file");
+ Assert.True(suppressionIndex >= 0 && suppressionIndex + 1 < argv.Count);
+ Assert.Equal("/ops/suppressions.xml", argv[suppressionIndex + 1]);
+ }
+
+ [Fact]
+ public void Parser_MapsDiagnosticLines_WithRuleId()
+ {
+ var parser = new ApiCompatReportParser();
+ var findings = parser.Parse(new ExternalToolParseInput(
+ "apicompat", "", BreakingStderr, 1));
+
+ Assert.Equal(2, findings.Count);
+ Assert.All(findings, f => Assert.Equal("CP0002", f.RuleId));
+ Assert.All(findings, f => Assert.Equal("error", f.SeverityLevel));
+ Assert.All(findings, f => Assert.Null(f.Path));
+ Assert.Contains(findings, f => f.Message.Contains("Id.get", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void Parser_CleanMarker_YieldsNoFindings()
+ {
+ var parser = new ApiCompatReportParser();
+ var findings = parser.Parse(new ExternalToolParseInput(
+ "apicompat", CleanStdout, "", 0));
+
+ Assert.Empty(findings);
+ }
+
+ [Fact]
+ public void Parser_EmptyReport_ThrowsParseException()
+ {
+ var parser = new ApiCompatReportParser();
+ Assert.Throws(() =>
+ parser.Parse(new ExternalToolParseInput("apicompat", "", "", 1)));
+ }
+
+ [Fact]
+ public void DisabledPlugin_IsNotLoaded_AndToolAbsentFromBaselineProvisioning()
+ {
+ var assemblyPath = PluginAssemblyPath();
+ var loader = new PluginLoader(
+ new PluginOptions
+ {
+ AssemblyPaths = [assemblyPath],
+ Allowlist = ["*"],
+ Enabled = [],
+ },
+ new ConfigurationBuilder().Build(),
+ NullLogger.Instance);
+
+ Assert.Empty(loader.DiscoverPlugins());
+ var status = Assert.Single(
+ loader.GetDiscoveryStatuses(),
+ s => s.PluginId == ApiCompatAuditor.PluginId);
+ Assert.Equal(PluginSkipReason.Disabled, status.SkipReason);
+
+ var tools = loader.GetEnabledPluginTools();
+ Assert.Empty(tools);
+
+ var contributions = PluginBaselineProvisioning.BuildContributions(tools);
+ var flattened = string.Join("\n", contributions.InstallCommands)
+ + "\n" + string.Join("\n", contributions.VerificationCommands.SelectMany(static v => v.Argv));
+ Assert.DoesNotContain("apicompat", flattened, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void EnabledPlugin_DeclaresApicompatRequirement_VerifyOnly()
+ {
+ var assemblyPath = PluginAssemblyPath();
+ var loader = new PluginLoader(
+ new PluginOptions
+ {
+ AssemblyPaths = [assemblyPath],
+ Allowlist = ["*"],
+ Enabled = [ApiCompatAuditor.PluginId],
+ },
+ new ConfigurationBuilder().Build(),
+ NullLogger.Instance);
+
+ var plugins = loader.DiscoverPlugins();
+ Assert.Contains(plugins, p => p.PluginId == ApiCompatAuditor.PluginId);
+
+ var tools = loader.GetEnabledPluginTools();
+ var apicompatTool = Assert.Single(tools, t => t.Binary == "apicompat");
+ // Verify-only by design: apicompat has no distro apt package, so no package is specified
+ Assert.Null(apicompatTool.AptPackage);
+
+ var contributions = PluginBaselineProvisioning.BuildContributions(loader.GetEnabledPluginTools());
+ var flattened = string.Join("\n", contributions.VerificationCommands.SelectMany(static v => v.Argv));
+ Assert.Contains("apicompat", flattened, StringComparison.Ordinal);
+ Assert.Empty(contributions.InstallCommands);
+ }
+
+ [Fact]
+ [Trait("requires_apicompat", "true")]
+ public async Task RealApicompat_BreakingFixture_ReportsRuleId()
+ {
+ var installed = InstalledApicompatVersion;
+ if (installed is null)
+ return;
+
+ var fixtureDir = Path.Combine(Path.GetTempPath(), "codeybox-apicompat-fixture-" + Guid.NewGuid().ToString("N")[..8]);
+ try
+ {
+ var baselineDll = BuildFixtureAssembly(fixtureDir, "base", baseline: true);
+ var currentDll = BuildFixtureAssembly(fixtureDir, "current", baseline: false);
+
+ var provider = new ProcessSandboxProvider(NullLogger.Instance);
+ await using var sandbox = await provider.CreateAsync(
+ new SandboxSpec
+ {
+ ImageReference = "ignored",
+ WorkingDirectory = "/work",
+ Mounts =
+ [
+ new SandboxMount { SandboxPath = "/work", HostPath = fixtureDir },
+ ],
+ },
+ CancellationToken.None);
+
+ var auditor = new ApiCompatAuditor();
+ await auditor.InitializeAsync(
+ BuildPluginContext(new Dictionary
+ {
+ ["Scoped:ExpectedVersion"] = installed,
+ ["Scoped:LeftAssembly"] = "/work/base/TestLib.dll",
+ ["Scoped:RightAssembly"] = "/work/current/TestLib.dll",
+ }),
+ CancellationToken.None);
+
+ Assert.True(File.Exists(baselineDll));
+ Assert.True(File.Exists(currentDll));
+ var result = await ((IAuditor)auditor).RunAsync(
+ sandbox, "/work", FakeContext(), CancellationToken.None);
+
+ Assert.False(result.Passed);
+ Assert.Equal(2, result.Findings.Count);
+ Assert.All(
+ result.Findings,
+ f => Assert.Contains("CP0002", f.Title, StringComparison.Ordinal));
+ Assert.All(result.Findings, f => Assert.Equal(AuditSeverity.Error, f.Severity));
+ Assert.All(result.Findings, f => Assert.Null(f.Location));
+ }
+ finally
+ {
+ TryDeleteDirectory(fixtureDir);
+ }
+ }
+
+ [Fact]
+ [Trait("requires_apicompat", "true")]
+ public async Task RealApicompat_CleanFixture_Passes()
+ {
+ var installed = InstalledApicompatVersion;
+ if (installed is null)
+ return;
+
+ var fixtureDir = Path.Combine(Path.GetTempPath(), "codeybox-apicompat-fixture-" + Guid.NewGuid().ToString("N")[..8]);
+ try
+ {
+ BuildFixtureAssembly(fixtureDir, "base", baseline: true);
+ BuildFixtureAssembly(fixtureDir, "current", baseline: true);
+
+ var provider = new ProcessSandboxProvider(NullLogger.Instance);
+ await using var sandbox = await provider.CreateAsync(
+ new SandboxSpec
+ {
+ ImageReference = "ignored",
+ WorkingDirectory = "/work",
+ Mounts =
+ [
+ new SandboxMount { SandboxPath = "/work", HostPath = fixtureDir },
+ ],
+ },
+ CancellationToken.None);
+
+ var auditor = new ApiCompatAuditor();
+ await auditor.InitializeAsync(
+ BuildPluginContext(new Dictionary
+ {
+ ["Scoped:ExpectedVersion"] = installed,
+ ["Scoped:LeftAssembly"] = "/work/base/TestLib.dll",
+ ["Scoped:RightAssembly"] = "/work/current/TestLib.dll",
+ }),
+ CancellationToken.None);
+
+ var result = await ((IAuditor)auditor).RunAsync(
+ sandbox, "/work", FakeContext(), CancellationToken.None);
+
+ Assert.True(result.Passed);
+ Assert.Empty(result.Findings);
+ }
+ finally
+ {
+ TryDeleteDirectory(fixtureDir);
+ }
+ }
+
+ private static Dictionary AssemblyScopedConfig() => new()
+ {
+ ["Scoped:LeftAssembly"] = "/left/TestLib.dll",
+ ["Scoped:RightAssembly"] = "/right/TestLib.dll",
+ };
+
+ private static string PluginAssemblyPath()
+ {
+ var path = Path.Combine(AppContext.BaseDirectory, "CodeyBox.ApiCompatAuditorPlugin.dll");
+ Assert.True(File.Exists(path), $"Plugin assembly not found at '{path}'.");
+ return path;
+ }
+
+ private static PluginContext BuildPluginContext(IReadOnlyDictionary scopedValues)
+ {
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(scopedValues)
+ .Build();
+ return new PluginContext(
+ HostApiVersion: "1.0",
+ PluginId: ApiCompatAuditor.PluginId,
+ PluginDisplayName: "CodeyBox: ApiCompat .NET API Compatibility",
+ Host: new TestPluginHost(config.GetSection("Scoped")));
+ }
+
+ private static SandboxExecResult Ok(SandboxExec exec)
+ => IsVersionProbe(exec)
+ ? new SandboxExecResult(0, ApiCompatAuditor.DefaultExpectedVersion + "+e34a38d2ae1fc26406a317517196e55c68ff83ab\n", "")
+ : new SandboxExecResult(0, "", "");
+
+ private static bool IsPresenceProbe(SandboxExec exec)
+ => exec.Argv.Count >= 3
+ && exec.Argv[0] == "sh"
+ && exec.Argv[1] == "-c"
+ && exec.Argv[2].Contains("command -v", StringComparison.Ordinal)
+ && exec.Argv.Contains("apicompat", StringComparer.Ordinal);
+
+ private static bool IsVersionProbe(SandboxExec exec)
+ => exec.Argv.Count == 2 && exec.Argv[0] == "apicompat" && exec.Argv[1] == "--version";
+
+ private static string BuildFixtureAssembly(string fixtureDir, string name, bool baseline)
+ {
+ var projectDir = Path.Combine(fixtureDir, "src-" + name);
+ var outputDir = Path.Combine(fixtureDir, name);
+ Directory.CreateDirectory(projectDir);
+ File.WriteAllText(
+ Path.Combine(projectDir, "fixture.csproj"),
+ """
+
+
+ net8.0
+ TestLib
+ disable
+
+
+ """);
+ File.WriteAllText(
+ Path.Combine(projectDir, "Lib.cs"),
+ """
+ namespace TestLib;
+ public class Widget
+ {
+ __ID__
+ public string Name { get; set; } = "";
+ }
+ """.Replace(
+ "__ID__",
+ baseline ? "public int Id { get; set; }" : string.Empty,
+ StringComparison.Ordinal));
+ var psi = new ProcessStartInfo
+ {
+ FileName = "dotnet",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ WorkingDirectory = projectDir,
+ };
+ psi.ArgumentList.Add("build");
+ psi.ArgumentList.Add("--nologo");
+ psi.ArgumentList.Add("-v");
+ psi.ArgumentList.Add("q");
+ psi.ArgumentList.Add("-o");
+ psi.ArgumentList.Add(outputDir);
+ using var process = Process.Start(psi)!;
+ var stdout = process.StandardOutput.ReadToEnd();
+ var stderr = process.StandardError.ReadToEnd();
+ if (!process.WaitForExit(milliseconds: 180_000))
+ {
+ try { process.Kill(); } catch { /* best-effort fixture teardown */ }
+ throw new InvalidOperationException($"dotnet build timed out for fixture '{name}'.");
+ }
+ var dll = Path.Combine(outputDir, "TestLib.dll");
+ Assert.True(
+ process.ExitCode == 0 && File.Exists(dll),
+ $"dotnet build failed for fixture '{name}': {stdout}\n{stderr}");
+ return dll;
+ }
+
+ private static string? ProbeInstalledApicompatVersion()
+ {
+ try
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = "apicompat",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ };
+ psi.ArgumentList.Add("--version");
+ using var process = Process.Start(psi)!;
+ var stdout = process.StandardOutput.ReadToEnd();
+ if (!process.WaitForExit(milliseconds: 10_000))
+ {
+ try { process.Kill(); } catch { /* best-effort probe teardown */ }
+ return null;
+ }
+ var match = Regex.Match(stdout, @"\d+\.\d+\.\d+[\w.\-]*");
+ return process.ExitCode == 0 && match.Success ? match.Value : null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static void TryDeleteDirectory(string path)
+ {
+ try { Directory.Delete(path, recursive: true); }
+ catch { /* best-effort fixture cleanup */ }
+ }
+
+ private static AuditContext FakeContext() =>
+ new(WorkItemId.New(), "feature", "main", 1, "do x");
+
+ private sealed class TestPluginHost(IConfigurationSection scoped) : IPluginHost
+ {
+ public Microsoft.Extensions.Logging.ILogger Logger { get; } = NullLogger.Instance;
+ public IConfigurationSection ScopedConfig { get; } = scoped;
+ }
+
+ private sealed class FakeSandbox(
+ Func> onExec) : ISandbox
+ {
+ public string Id => "fake";
+
+ public async Task ExecAsync(SandboxExec exec, CancellationToken ct = default)
+ {
+ await Task.Yield();
+ ct.ThrowIfCancellationRequested();
+ return await onExec(exec, ct);
+ }
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+ }
+}
diff --git a/tests/CodeyBox.Tests/CodeyBox.Tests.csproj b/tests/CodeyBox.Tests/CodeyBox.Tests.csproj
index 37b8cda9..49447afb 100644
--- a/tests/CodeyBox.Tests/CodeyBox.Tests.csproj
+++ b/tests/CodeyBox.Tests/CodeyBox.Tests.csproj
@@ -53,6 +53,7 @@
+