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 @@ +