From 16bdc4c5ada859e416df1fc2c22d9d91a6aa58ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 14:56:17 +0000 Subject: [PATCH 1/2] Initial plan From fe5a6b92be0ade660430c73fe4506e9f53197845 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 15:10:33 +0000 Subject: [PATCH 2/2] Add CLI severity override flags (--blocker, --critical, --major, --minor, --info, --ignore) Co-authored-by: 304NotModified <5808377+304NotModified@users.noreply.github.com> Agent-Logs-Url: https://github.com/304NotModified/SLNX-validator/sessions/7f135785-135a-41ee-a6da-3b9afeee686d --- README.md | 52 +++++++ .../SonarQubeReporting/ISonarReporter.cs | 7 +- .../SonarQubeReporting/SonarReporter.cs | 26 +++- .../SonarQubeReporting/SonarRuleSeverity.cs | 2 +- src/SLNX-validator/Program.cs | 112 +++++++++++++- src/SLNX-validator/ValidationReporter.cs | 49 ++++-- src/SLNX-validator/ValidatorRunner.cs | 16 +- src/SLNX-validator/ValidatorRunnerOptions.cs | 6 +- .../SonarReporterTests.cs | 94 ++++++++++- tests/SLNX-validator.Tests/ProgramTests.cs | 146 ++++++++++++++++++ .../ValidatorRunnerTests.cs | 103 ++++++++++++ 11 files changed, 588 insertions(+), 25 deletions(-) create mode 100644 tests/SLNX-validator.Tests/ProgramTests.cs diff --git a/README.md b/README.md index 2b1f402..dca4b5c 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,58 @@ slnx-validator MySolution.slnx --required-files "appsettings.json;docs/" | `0` | All patterns matched and all matched files are referenced in the solution. | | `1` | Any validation error — including required files not existing or not referenced. | +### Severity override flags + +Override the severity of specific validation codes, or suppress them entirely. This controls both the exit code behaviour and the severity written to the SonarQube JSON report. + +| Flag | Severity | Causes exit code `1`? | +|------|----------|-----------------------| +| `--blocker ` | `BLOCKER` | ✅ yes | +| `--critical ` | `CRITICAL` | ✅ yes | +| `--major ` | `MAJOR` | ✅ yes (default for all codes) | +| `--minor ` | `MINOR` | ❌ no — shown with `(warning)` label | +| `--info ` | `INFO` | ❌ no — shown with `(info)` label | +| `--ignore ` | *(suppressed)* | ❌ no — not shown at all, not in SonarQube report | + +Each flag accepts a **comma-separated list of codes** or the **wildcard `*`** to match all codes: + +```powershell +# Suppress a specific code +slnx-validator MySolution.slnx --ignore SLNX011 + +# Downgrade a code to non-failing severity +slnx-validator MySolution.slnx --minor SLNX011,SLNX012 + +# Set everything to INFO, but keep SLNX011 as MAJOR +slnx-validator MySolution.slnx --info * --major SLNX011 + +# Ignore everything except SLNX013 +slnx-validator MySolution.slnx --ignore * --major SLNX013 +``` + +**Wildcard `*` and specific codes** + +When `*` is combined with specific code flags, **specific codes always win over the wildcard**, regardless of flag order: + +```powershell +# SLNX011 remains MAJOR even though --info * is also specified +slnx-validator MySolution.slnx --info * --major SLNX011 +``` + +**Effect on SonarQube report** + +Severity overrides are reflected in the generated rule definition in the JSON report: + +```json +{ + "id": "SLNX011", + "severity": "MINOR", + ... +} +``` + +Codes set to `--ignore` are excluded from both the `rules` and `issues` arrays entirely. + ## SonarQube integration example ```powershell diff --git a/src/SLNX-validator.Core/SonarQubeReporting/ISonarReporter.cs b/src/SLNX-validator.Core/SonarQubeReporting/ISonarReporter.cs index 00e9c3c..cdca9ab 100644 --- a/src/SLNX-validator.Core/SonarQubeReporting/ISonarReporter.cs +++ b/src/SLNX-validator.Core/SonarQubeReporting/ISonarReporter.cs @@ -4,6 +4,9 @@ namespace JulianVerdurmen.SlnxValidator.Core.SonarQubeReporting; public interface ISonarReporter { - Task WriteReportAsync(IReadOnlyList results, string outputPath); - Task WriteReportAsync(IReadOnlyList results, Stream outputStream); + Task WriteReportAsync(IReadOnlyList results, string outputPath, + IReadOnlyDictionary? severityOverrides = null); + + Task WriteReportAsync(IReadOnlyList results, Stream outputStream, + IReadOnlyDictionary? severityOverrides = null); } \ No newline at end of file diff --git a/src/SLNX-validator.Core/SonarQubeReporting/SonarReporter.cs b/src/SLNX-validator.Core/SonarQubeReporting/SonarReporter.cs index 0d9dc4d..a97550a 100644 --- a/src/SLNX-validator.Core/SonarQubeReporting/SonarReporter.cs +++ b/src/SLNX-validator.Core/SonarQubeReporting/SonarReporter.cs @@ -17,29 +17,34 @@ public sealed class SonarReporter(IFileSystem fileSystem) : ISonarReporter Converters = { new JsonStringEnumConverter() } }; - public async Task WriteReportAsync(IReadOnlyList results, string outputPath) + public async Task WriteReportAsync(IReadOnlyList results, string outputPath, + IReadOnlyDictionary? severityOverrides = null) { var directory = Path.GetDirectoryName(outputPath); if (!string.IsNullOrEmpty(directory)) fileSystem.CreateDirectory(directory); await using var stream = fileSystem.CreateFile(outputPath); - await WriteReportAsync(results, stream); + await WriteReportAsync(results, stream, severityOverrides); } - public async Task WriteReportAsync(IReadOnlyList results, Stream outputStream) + public async Task WriteReportAsync(IReadOnlyList results, Stream outputStream, + IReadOnlyDictionary? severityOverrides = null) { var usedCodes = results .SelectMany(r => r.Errors) .Select(e => e.Code) + .Where(c => !IsIgnored(c, severityOverrides)) .Distinct() .OrderBy(c => (int)c) .ToList(); - var rules = usedCodes.Select(GetRuleDefinition).ToList(); + var rules = usedCodes.Select(c => ApplyOverride(GetRuleDefinition(c), c, severityOverrides)).ToList(); var issues = results - .SelectMany(r => r.Errors.Select(e => BuildIssue(r.File, e))) + .SelectMany(r => r.Errors + .Where(e => !IsIgnored(e.Code, severityOverrides)) + .Select(e => BuildIssue(r.File, e))) .ToList(); var report = new SonarReport { Rules = rules, Issues = issues }; @@ -58,6 +63,17 @@ public async Task WriteReportAsync(IReadOnlyList results, } }; + private static bool IsIgnored(ValidationErrorCode code, IReadOnlyDictionary? overrides) => + overrides is not null && overrides.TryGetValue(code, out var severity) && severity is null; + + private static SonarRule ApplyOverride(SonarRule rule, ValidationErrorCode code, + IReadOnlyDictionary? overrides) + { + if (overrides is not null && overrides.TryGetValue(code, out var severity) && severity.HasValue) + return rule with { Severity = severity.Value }; + return rule; + } + private static SonarRule GetRuleDefinition(ValidationErrorCode code) => code switch { ValidationErrorCode.FileNotFound => CreateRule(code, diff --git a/src/SLNX-validator.Core/SonarQubeReporting/SonarRuleSeverity.cs b/src/SLNX-validator.Core/SonarQubeReporting/SonarRuleSeverity.cs index 19f6c41..2fb651c 100644 --- a/src/SLNX-validator.Core/SonarQubeReporting/SonarRuleSeverity.cs +++ b/src/SLNX-validator.Core/SonarQubeReporting/SonarRuleSeverity.cs @@ -1,6 +1,6 @@ namespace JulianVerdurmen.SlnxValidator.Core.SonarQubeReporting; -internal enum SonarRuleSeverity +public enum SonarRuleSeverity { BLOCKER, CRITICAL, diff --git a/src/SLNX-validator/Program.cs b/src/SLNX-validator/Program.cs index c0c820e..257e978 100644 --- a/src/SLNX-validator/Program.cs +++ b/src/SLNX-validator/Program.cs @@ -1,5 +1,7 @@ using System.CommandLine; using JulianVerdurmen.SlnxValidator.Core; +using JulianVerdurmen.SlnxValidator.Core.SonarQubeReporting; +using JulianVerdurmen.SlnxValidator.Core.ValidationResults; using Microsoft.Extensions.DependencyInjection; namespace JulianVerdurmen.SlnxValidator; @@ -28,12 +30,48 @@ public static async Task Main(string[] args) Description = "Semicolon-separated glob patterns for files that must exist on disk and be referenced as elements in the solution." }; + var blockerOption = new Option("--blocker") + { + Description = "Comma-separated codes (or * for all) to treat as BLOCKER severity (causes exit code 1)." + }; + + var criticalOption = new Option("--critical") + { + Description = "Comma-separated codes (or * for all) to treat as CRITICAL severity (causes exit code 1)." + }; + + var majorOption = new Option("--major") + { + Description = "Comma-separated codes (or * for all) to treat as MAJOR severity (causes exit code 1)." + }; + + var minorOption = new Option("--minor") + { + Description = "Comma-separated codes (or * for all) to treat as MINOR severity (shown, but does not cause exit code 1)." + }; + + var infoOption = new Option("--info") + { + Description = "Comma-separated codes (or * for all) to treat as INFO severity (shown, but does not cause exit code 1)." + }; + + var ignoreOption = new Option("--ignore") + { + Description = "Comma-separated codes (or * for all) to suppress entirely (not shown, not in SonarQube report)." + }; + var rootCommand = new RootCommand("Validates .slnx solution files.") { inputArgument, sonarqubeReportOption, continueOnErrorOption, - requiredFilesOption + requiredFilesOption, + blockerOption, + criticalOption, + majorOption, + minorOption, + infoOption, + ignoreOption }; var services = new ServiceCollection() @@ -49,11 +87,81 @@ public static async Task Main(string[] args) SonarqubeReportPath: parseResult.GetValue(sonarqubeReportOption), ContinueOnError: parseResult.GetValue(continueOnErrorOption), RequiredFilesPattern: parseResult.GetValue(requiredFilesOption), - WorkingDirectory: Environment.CurrentDirectory); + WorkingDirectory: Environment.CurrentDirectory, + SeverityOverrides: ParseSeverityOverrides( + parseResult.GetValue(blockerOption), + parseResult.GetValue(criticalOption), + parseResult.GetValue(majorOption), + parseResult.GetValue(minorOption), + parseResult.GetValue(infoOption), + parseResult.GetValue(ignoreOption))); return await services.GetRequiredService().RunAsync(options, cancellationToken); }); return await rootCommand.Parse(args).InvokeAsync(); } + + internal static IReadOnlyDictionary ParseSeverityOverrides( + string? blocker, string? critical, string? major, string? minor, string? info, string? ignore) + { + var result = new Dictionary(); + + // Pass 1: wildcards only (lowest priority — expanded first so specific codes can overwrite) + ParseInto(blocker, SonarRuleSeverity.BLOCKER, result, wildcardOnly: true); + ParseInto(critical, SonarRuleSeverity.CRITICAL, result, wildcardOnly: true); + ParseInto(major, SonarRuleSeverity.MAJOR, result, wildcardOnly: true); + ParseInto(minor, SonarRuleSeverity.MINOR, result, wildcardOnly: true); + ParseInto(info, SonarRuleSeverity.INFO, result, wildcardOnly: true); + ParseInto(ignore, null, result, wildcardOnly: true); + + // Pass 2: specific codes (highest priority — overwrite wildcards from pass 1) + ParseInto(blocker, SonarRuleSeverity.BLOCKER, result, wildcardOnly: false); + ParseInto(critical, SonarRuleSeverity.CRITICAL, result, wildcardOnly: false); + ParseInto(major, SonarRuleSeverity.MAJOR, result, wildcardOnly: false); + ParseInto(minor, SonarRuleSeverity.MINOR, result, wildcardOnly: false); + ParseInto(info, SonarRuleSeverity.INFO, result, wildcardOnly: false); + ParseInto(ignore, null, result, wildcardOnly: false); + + return result; + } + + private static void ParseInto(string? input, SonarRuleSeverity? severity, + Dictionary target, bool wildcardOnly) + { + if (input is null) return; + + if (input.Trim() == "*") + { + if (wildcardOnly) + { + foreach (var code in Enum.GetValues()) + target[code] = severity; + } + return; + } + + if (wildcardOnly) return; + + foreach (var raw in input.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + if (raw == "*") continue; + target[ParseCode(raw)] = severity; + } + } + + private static ValidationErrorCode ParseCode(string raw) + { + if (raw.StartsWith("SLNX", StringComparison.OrdinalIgnoreCase) && + int.TryParse(raw.AsSpan(4), out var num) && + Enum.IsDefined(typeof(ValidationErrorCode), num)) + { + return (ValidationErrorCode)num; + } + + if (Enum.TryParse(raw, ignoreCase: true, out var code)) + return code; + + throw new InvalidOperationException($"Unknown validation code: '{raw}'. Use the SLNX-prefixed code (e.g. SLNX011) or the enum name (e.g. ReferencedFileNotFound)."); + } } diff --git a/src/SLNX-validator/ValidationReporter.cs b/src/SLNX-validator/ValidationReporter.cs index ea323cc..aa2879e 100644 --- a/src/SLNX-validator/ValidationReporter.cs +++ b/src/SLNX-validator/ValidationReporter.cs @@ -1,39 +1,70 @@ +using JulianVerdurmen.SlnxValidator.Core.SonarQubeReporting; using JulianVerdurmen.SlnxValidator.Core.ValidationResults; namespace JulianVerdurmen.SlnxValidator; internal static class ValidationReporter { - public static async Task Report(IReadOnlyList results) + public static async Task Report(IReadOnlyList results, + IReadOnlyDictionary? severityOverrides = null) { foreach (var result in results) { - Console.WriteLine(result.HasErrors ? $"[FAIL] {result.File}" : $"[OK] {result.File}"); + var isFailingResult = result.Errors.Any(e => IsFailingError(e.Code, severityOverrides)); + Console.WriteLine(isFailingResult ? $"[FAIL] {result.File}" : $"[OK] {result.File}"); } - var failedResults = results.Where(r => r.HasErrors).ToList(); + var visibleResults = results + .Where(r => r.Errors.Any(e => IsVisible(e.Code, severityOverrides))) + .ToList(); - if (failedResults.Count == 0) + if (visibleResults.Count == 0) { return; } Console.WriteLine(); - foreach (var result in failedResults) + foreach (var result in visibleResults) { await Console.Error.WriteLineAsync(result.File); - foreach (var error in result.Errors) + foreach (var error in result.Errors.Where(e => IsVisible(e.Code, severityOverrides))) { - await Console.Error.WriteLineAsync(FormatError(error)); + await Console.Error.WriteLineAsync(FormatError(error, severityOverrides)); } } } - private static string FormatError(ValidationError error) + private static bool IsVisible(ValidationErrorCode code, + IReadOnlyDictionary? overrides) => + overrides is null || !overrides.TryGetValue(code, out var severity) || severity is not null; + + private static bool IsFailingError(ValidationErrorCode code, + IReadOnlyDictionary? overrides) + { + if (overrides is not null && overrides.TryGetValue(code, out var severity)) + return severity is SonarRuleSeverity.BLOCKER or SonarRuleSeverity.CRITICAL or SonarRuleSeverity.MAJOR; + return true; // default: all errors are failing + } + + private static string FormatError(ValidationError error, + IReadOnlyDictionary? overrides) { var location = error.Line is null ? "" : $"line {error.Line}: "; - return $" - {location}[{error.Code.ToCode()}] {error.Message}"; + var label = GetSeverityLabel(error.Code, overrides); + return $" - {location}[{error.Code.ToCode()}]{label} {error.Message}"; + } + + private static string GetSeverityLabel(ValidationErrorCode code, + IReadOnlyDictionary? overrides) + { + if (overrides is null || !overrides.TryGetValue(code, out var severity)) return ""; + return severity switch + { + SonarRuleSeverity.MINOR => " (warning)", + SonarRuleSeverity.INFO => " (info)", + _ => "" + }; } } diff --git a/src/SLNX-validator/ValidatorRunner.cs b/src/SLNX-validator/ValidatorRunner.cs index 0e8f39e..829b5cb 100644 --- a/src/SLNX-validator/ValidatorRunner.cs +++ b/src/SLNX-validator/ValidatorRunner.cs @@ -2,6 +2,7 @@ using JulianVerdurmen.SlnxValidator.Core.FileSystem; using JulianVerdurmen.SlnxValidator.Core.SonarQubeReporting; using JulianVerdurmen.SlnxValidator.Core.Validation; +using JulianVerdurmen.SlnxValidator.Core.ValidationResults; namespace JulianVerdurmen.SlnxValidator; @@ -27,12 +28,21 @@ public async Task RunAsync(ValidatorRunnerOptions options, CancellationToke var results = await collector.CollectAsync(files, requiredFilesOptions, cancellationToken); - await ValidationReporter.Report(results); + var overrides = options.SeverityOverrides; + await ValidationReporter.Report(results, overrides); if (options.SonarqubeReportPath is not null) - await sonarReporter.WriteReportAsync(results, options.SonarqubeReportPath); + await sonarReporter.WriteReportAsync(results, options.SonarqubeReportPath, overrides); - var hasErrors = results.Any(r => r.HasErrors); + var hasErrors = results.Any(r => r.Errors.Any(e => IsFailingError(e.Code, overrides))); return !options.ContinueOnError && hasErrors ? 1 : 0; } + + private static bool IsFailingError(ValidationErrorCode code, + IReadOnlyDictionary? overrides) + { + if (overrides is not null && overrides.TryGetValue(code, out var severity)) + return severity is SonarRuleSeverity.BLOCKER or SonarRuleSeverity.CRITICAL or SonarRuleSeverity.MAJOR; + return true; // default: all errors are failing + } } diff --git a/src/SLNX-validator/ValidatorRunnerOptions.cs b/src/SLNX-validator/ValidatorRunnerOptions.cs index f65f5b2..eb26b83 100644 --- a/src/SLNX-validator/ValidatorRunnerOptions.cs +++ b/src/SLNX-validator/ValidatorRunnerOptions.cs @@ -1,3 +1,6 @@ +using JulianVerdurmen.SlnxValidator.Core.SonarQubeReporting; +using JulianVerdurmen.SlnxValidator.Core.ValidationResults; + namespace JulianVerdurmen.SlnxValidator; /// All options forwarded from the CLI to . @@ -6,4 +9,5 @@ internal sealed record ValidatorRunnerOptions( string? SonarqubeReportPath, bool ContinueOnError, string? RequiredFilesPattern, - string WorkingDirectory); + string WorkingDirectory, + IReadOnlyDictionary? SeverityOverrides = null); diff --git a/tests/SLNX-validator.Core.Tests/SonarReporterTests.cs b/tests/SLNX-validator.Core.Tests/SonarReporterTests.cs index 6cfc68c..bbbad91 100644 --- a/tests/SLNX-validator.Core.Tests/SonarReporterTests.cs +++ b/tests/SLNX-validator.Core.Tests/SonarReporterTests.cs @@ -9,10 +9,12 @@ public class SonarReporterTests { private static SonarReporter CreateReporter() => new(new MockFileSystem()); - private static async Task WriteAndReadReportAsync(IReadOnlyList results) + private static async Task WriteAndReadReportAsync( + IReadOnlyList results, + IReadOnlyDictionary? severityOverrides = null) { using var stream = new MemoryStream(); - await CreateReporter().WriteReportAsync(results, stream); + await CreateReporter().WriteReportAsync(results, stream, severityOverrides); return JsonDocument.Parse(stream.ToArray()); } @@ -219,4 +221,92 @@ public async Task WriteReportAsync_WithOutputPathNoSubdirectory_DoesNotCreateDir fileSystem.CreatedDirectories.Should().BeEmpty(); } + + #region WriteReportAsync – severity overrides + + [Test] + public async Task WriteReportAsync_SeverityOverride_ReflectedInRuleDefinition() + { + // Arrange + var results = new List + { + new() + { + File = "test.slnx", + HasErrors = true, + Errors = [new ValidationError(ValidationErrorCode.ReferencedFileNotFound, "File not found")] + } + }; + var overrides = new Dictionary + { + [ValidationErrorCode.ReferencedFileNotFound] = SonarRuleSeverity.MINOR + }; + + // Act + using var doc = await WriteAndReadReportAsync(results, overrides); + + // Assert + doc.RootElement.GetProperty("rules")[0] + .GetProperty("severity").GetString().Should().Be("MINOR"); + } + + [Test] + public async Task WriteReportAsync_IgnoredCode_NotInRulesOrIssues() + { + // Arrange + var results = new List + { + new() + { + File = "test.slnx", + HasErrors = true, + Errors = [new ValidationError(ValidationErrorCode.ReferencedFileNotFound, "File not found")] + } + }; + var overrides = new Dictionary + { + [ValidationErrorCode.ReferencedFileNotFound] = null + }; + + // Act + using var doc = await WriteAndReadReportAsync(results, overrides); + + // Assert + doc.RootElement.GetProperty("rules").GetArrayLength().Should().Be(0); + doc.RootElement.GetProperty("issues").GetArrayLength().Should().Be(0); + } + + [Test] + public async Task WriteReportAsync_IgnoreAllButOne_OnlyVisibleCodeInReport() + { + // Arrange + var results = new List + { + new() + { + File = "test.slnx", + HasErrors = true, + Errors = + [ + new ValidationError(ValidationErrorCode.ReferencedFileNotFound, "File not found"), + new ValidationError(ValidationErrorCode.XsdViolation, "Schema error", Line: 2), + ] + } + }; + // Ignore all codes, but make SLNX013 (XsdViolation) MAJOR + var overrides = Enum.GetValues() + .ToDictionary(c => c, _ => (SonarRuleSeverity?)null); + overrides[ValidationErrorCode.XsdViolation] = SonarRuleSeverity.MAJOR; + + // Act + using var doc = await WriteAndReadReportAsync(results, overrides); + + // Assert + doc.RootElement.GetProperty("rules").GetArrayLength().Should().Be(1); + doc.RootElement.GetProperty("rules")[0].GetProperty("id").GetString().Should().Be("SLNX013"); + doc.RootElement.GetProperty("issues").GetArrayLength().Should().Be(1); + doc.RootElement.GetProperty("issues")[0].GetProperty("ruleId").GetString().Should().Be("SLNX013"); + } + + #endregion } \ No newline at end of file diff --git a/tests/SLNX-validator.Tests/ProgramTests.cs b/tests/SLNX-validator.Tests/ProgramTests.cs new file mode 100644 index 0000000..085901d --- /dev/null +++ b/tests/SLNX-validator.Tests/ProgramTests.cs @@ -0,0 +1,146 @@ +using AwesomeAssertions; +using JulianVerdurmen.SlnxValidator.Core.SonarQubeReporting; +using JulianVerdurmen.SlnxValidator.Core.ValidationResults; + +namespace JulianVerdurmen.SlnxValidator.Tests; + +public class ProgramTests +{ + #region ParseSeverityOverrides – basic parsing + + [Test] + public void ParseSeverityOverrides_NoOverrides_ReturnsEmptyDictionary() + { + // Arrange / Act + var result = Program.ParseSeverityOverrides(null, null, null, null, null, null); + + // Assert + result.Should().BeEmpty(); + } + + [Test] + public void ParseSeverityOverrides_SingleCode_ParsesCorrectly() + { + // Arrange / Act + var result = Program.ParseSeverityOverrides(null, null, null, "SLNX011", null, null); + + // Assert + result.Should().HaveCount(1); + result[ValidationErrorCode.ReferencedFileNotFound].Should().Be(SonarRuleSeverity.MINOR); + } + + [Test] + public void ParseSeverityOverrides_CommaSeparatedCodes_ParsesBoth() + { + // Arrange / Act + var result = Program.ParseSeverityOverrides(null, null, null, "SLNX011,SLNX012", null, null); + + // Assert + result[ValidationErrorCode.ReferencedFileNotFound].Should().Be(SonarRuleSeverity.MINOR); + result[ValidationErrorCode.InvalidWildcardUsage].Should().Be(SonarRuleSeverity.MINOR); + } + + [Test] + public void ParseSeverityOverrides_EnumNameCode_ParsesCorrectly() + { + // Arrange / Act + var result = Program.ParseSeverityOverrides(null, null, null, "ReferencedFileNotFound", null, null); + + // Assert + result[ValidationErrorCode.ReferencedFileNotFound].Should().Be(SonarRuleSeverity.MINOR); + } + + [Test] + public void ParseSeverityOverrides_IgnoreCode_SetsToNull() + { + // Arrange / Act + var result = Program.ParseSeverityOverrides(null, null, null, null, null, "SLNX011"); + + // Assert + result[ValidationErrorCode.ReferencedFileNotFound].Should().BeNull(); + } + + [Test] + public void ParseSeverityOverrides_UnknownCode_ThrowsInvalidOperationException() + { + // Arrange / Act + var act = () => Program.ParseSeverityOverrides(null, null, null, "SLNX999", null, null); + + // Assert + act.Should().Throw().WithMessage("*SLNX999*"); + } + + #endregion + + #region ParseSeverityOverrides – wildcard expansion + + [Test] + public void ParseSeverityOverrides_Wildcard_ExpandsToAllCodes() + { + // Arrange / Act + var result = Program.ParseSeverityOverrides(null, null, null, null, "*", null); + + // Assert + var allCodes = Enum.GetValues(); + result.Should().HaveCount(allCodes.Length); + result.Should().AllSatisfy(kvp => kvp.Value.Should().Be(SonarRuleSeverity.INFO)); + } + + [Test] + public void ParseSeverityOverrides_IgnoreWildcard_SetsAllToNull() + { + // Arrange / Act + var result = Program.ParseSeverityOverrides(null, null, null, null, null, "*"); + + // Assert + var allCodes = Enum.GetValues(); + result.Should().HaveCount(allCodes.Length); + result.Should().AllSatisfy(kvp => kvp.Value.Should().BeNull()); + } + + #endregion + + #region ParseSeverityOverrides – specific codes beat wildcards + + [Test] + public void ParseSeverityOverrides_SpecificCodeOverridesWildcard_InfoAllMajorSLNX011() + { + // Arrange / Act: --info * --major SLNX011 + var result = Program.ParseSeverityOverrides(null, null, "SLNX011", null, "*", null); + + // Assert: SLNX011 (ReferencedFileNotFound) should be MAJOR, everything else INFO + var allCodes = Enum.GetValues(); + foreach (var code in allCodes) + { + if (code == ValidationErrorCode.ReferencedFileNotFound) + result[code].Should().Be(SonarRuleSeverity.MAJOR); + else + result[code].Should().Be(SonarRuleSeverity.INFO); + } + } + + [Test] + public void ParseSeverityOverrides_IgnoreAllMajorSpecificCode_SpecificCodeWins() + { + // Arrange / Act: --ignore * --major SLNX013 + var result = Program.ParseSeverityOverrides(null, null, "SLNX013", null, null, "*"); + + // Assert: SLNX013 (XsdViolation) should be MAJOR; all others should be null (ignored) + result[ValidationErrorCode.XsdViolation].Should().Be(SonarRuleSeverity.MAJOR); + result[ValidationErrorCode.ReferencedFileNotFound].Should().BeNull(); + result[ValidationErrorCode.FileNotFound].Should().BeNull(); + } + + [Test] + public void ParseSeverityOverrides_MinorAllInfoSpecificCode_SpecificCodeWins() + { + // Arrange / Act: --minor * --info SLNX001 + var result = Program.ParseSeverityOverrides(null, null, null, "*", "SLNX001", null); + + // Assert: SLNX001 (FileNotFound) should be INFO; all others should be MINOR + result[ValidationErrorCode.FileNotFound].Should().Be(SonarRuleSeverity.INFO); + result[ValidationErrorCode.XsdViolation].Should().Be(SonarRuleSeverity.MINOR); + } + + #endregion +} diff --git a/tests/SLNX-validator.Tests/ValidatorRunnerTests.cs b/tests/SLNX-validator.Tests/ValidatorRunnerTests.cs index 7c5fa6b..32f9470 100644 --- a/tests/SLNX-validator.Tests/ValidatorRunnerTests.cs +++ b/tests/SLNX-validator.Tests/ValidatorRunnerTests.cs @@ -141,5 +141,108 @@ public async Task RunAsync_RequiredFiles_NoMatchOnDisk_ReturnsOne() } #endregion + + #region RunAsync – severity overrides + + // All severity override tests use a .xml file (not .slnx) to generate SLNX002 (InvalidExtension) errors, + // which allows testing severity override behavior with predictable validation output. + + [Test] + public async Task RunAsync_IgnoreAllCodes_WithErrors_ReturnsZero() + { + // Arrange: file with wrong extension generates SLNX002; --ignore * suppresses all codes + var runner = CreateRunnerWithSlnx("test.xml", ""); + var overrides = Program.ParseSeverityOverrides(null, null, null, null, null, ignore: "*"); + + // Act + var exitCode = await runner.RunAsync( + new ValidatorRunnerOptions("test.xml", null, false, null, ".", overrides), + CancellationToken.None); + + // Assert + exitCode.Should().Be(0); + } + + [Test] + public async Task RunAsync_IgnoreSpecificCode_ThatCodeDoesNotCauseExitOne() + { + // Arrange: --ignore SLNX002 suppresses the InvalidExtension error + var runner = CreateRunnerWithSlnx("test.xml", ""); + var overrides = Program.ParseSeverityOverrides(null, null, null, null, null, ignore: "SLNX002"); + + // Act + var exitCode = await runner.RunAsync( + new ValidatorRunnerOptions("test.xml", null, false, null, ".", overrides), + CancellationToken.None); + + // Assert + exitCode.Should().Be(0); + } + + [Test] + public async Task RunAsync_MinorOverrideForErrorCode_ReturnsZero() + { + // Arrange: --minor SLNX002 downgrades InvalidExtension to non-failing severity + var runner = CreateRunnerWithSlnx("test.xml", ""); + var overrides = Program.ParseSeverityOverrides(null, null, null, minor: "SLNX002", null, null); + + // Act + var exitCode = await runner.RunAsync( + new ValidatorRunnerOptions("test.xml", null, false, null, ".", overrides), + CancellationToken.None); + + // Assert + exitCode.Should().Be(0); + } + + [Test] + public async Task RunAsync_InfoAllCodes_ReturnsZero() + { + // Arrange: --info * downgrades all codes to INFO (non-failing) + var runner = CreateRunnerWithSlnx("test.xml", ""); + var overrides = Program.ParseSeverityOverrides(null, null, null, null, info: "*", null); + + // Act + var exitCode = await runner.RunAsync( + new ValidatorRunnerOptions("test.xml", null, false, null, ".", overrides), + CancellationToken.None); + + // Assert + exitCode.Should().Be(0); + } + + [Test] + public async Task RunAsync_InfoAllCodesMajorSpecificCode_SpecificCodeCausesExitOne() + { + // Arrange: --info * --major SLNX002 → SLNX002 stays MAJOR (specific overrides wildcard) + var runner = CreateRunnerWithSlnx("test.xml", ""); + var overrides = Program.ParseSeverityOverrides(null, null, major: "SLNX002", null, info: "*", null); + + // Act + var exitCode = await runner.RunAsync( + new ValidatorRunnerOptions("test.xml", null, false, null, ".", overrides), + CancellationToken.None); + + // Assert + exitCode.Should().Be(1); + } + + [Test] + public async Task RunAsync_IgnoreAllCodesMajorSpecificCode_SpecificCodeCausesExitOne() + { + // Arrange: --ignore * --major SLNX002 → SLNX002 is MAJOR (specific wins over wildcard ignore) + var runner = CreateRunnerWithSlnx("test.xml", ""); + var overrides = Program.ParseSeverityOverrides(null, null, major: "SLNX002", null, null, ignore: "*"); + + // Act + var exitCode = await runner.RunAsync( + new ValidatorRunnerOptions("test.xml", null, false, null, ".", overrides), + CancellationToken.None); + + // Assert + exitCode.Should().Be(1); + } + + #endregion }