Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <codes>` | `BLOCKER` | ✅ yes |
| `--critical <codes>` | `CRITICAL` | ✅ yes |
| `--major <codes>` | `MAJOR` | ✅ yes (default for all codes) |
| `--minor <codes>` | `MINOR` | ❌ no — shown with `(warning)` label |
| `--info <codes>` | `INFO` | ❌ no — shown with `(info)` label |
| `--ignore <codes>` | *(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
Expand Down
7 changes: 5 additions & 2 deletions src/SLNX-validator.Core/SonarQubeReporting/ISonarReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ namespace JulianVerdurmen.SlnxValidator.Core.SonarQubeReporting;

public interface ISonarReporter
{
Task WriteReportAsync(IReadOnlyList<FileValidationResult> results, string outputPath);
Task WriteReportAsync(IReadOnlyList<FileValidationResult> results, Stream outputStream);
Task WriteReportAsync(IReadOnlyList<FileValidationResult> results, string outputPath,
IReadOnlyDictionary<ValidationErrorCode, SonarRuleSeverity?>? severityOverrides = null);

Task WriteReportAsync(IReadOnlyList<FileValidationResult> results, Stream outputStream,
IReadOnlyDictionary<ValidationErrorCode, SonarRuleSeverity?>? severityOverrides = null);
}
26 changes: 21 additions & 5 deletions src/SLNX-validator.Core/SonarQubeReporting/SonarReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,29 +17,34 @@ public sealed class SonarReporter(IFileSystem fileSystem) : ISonarReporter
Converters = { new JsonStringEnumConverter() }
};

public async Task WriteReportAsync(IReadOnlyList<FileValidationResult> results, string outputPath)
public async Task WriteReportAsync(IReadOnlyList<FileValidationResult> results, string outputPath,
IReadOnlyDictionary<ValidationErrorCode, SonarRuleSeverity?>? 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<FileValidationResult> results, Stream outputStream)
public async Task WriteReportAsync(IReadOnlyList<FileValidationResult> results, Stream outputStream,
IReadOnlyDictionary<ValidationErrorCode, SonarRuleSeverity?>? 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 };
Expand All @@ -58,6 +63,17 @@ public async Task WriteReportAsync(IReadOnlyList<FileValidationResult> results,
}
};

private static bool IsIgnored(ValidationErrorCode code, IReadOnlyDictionary<ValidationErrorCode, SonarRuleSeverity?>? overrides) =>
overrides is not null && overrides.TryGetValue(code, out var severity) && severity is null;

private static SonarRule ApplyOverride(SonarRule rule, ValidationErrorCode code,
IReadOnlyDictionary<ValidationErrorCode, SonarRuleSeverity?>? 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,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
namespace JulianVerdurmen.SlnxValidator.Core.SonarQubeReporting;

internal enum SonarRuleSeverity
public enum SonarRuleSeverity
{
BLOCKER,
CRITICAL,
Expand Down
112 changes: 110 additions & 2 deletions src/SLNX-validator/Program.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -28,12 +30,48 @@ public static async Task<int> Main(string[] args)
Description = "Semicolon-separated glob patterns for files that must exist on disk and be referenced as <File> elements in the solution."
};

var blockerOption = new Option<string?>("--blocker")
{
Description = "Comma-separated codes (or * for all) to treat as BLOCKER severity (causes exit code 1)."
};

var criticalOption = new Option<string?>("--critical")
{
Description = "Comma-separated codes (or * for all) to treat as CRITICAL severity (causes exit code 1)."
};

var majorOption = new Option<string?>("--major")
{
Description = "Comma-separated codes (or * for all) to treat as MAJOR severity (causes exit code 1)."
};

var minorOption = new Option<string?>("--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<string?>("--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<string?>("--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()
Expand All @@ -49,11 +87,81 @@ public static async Task<int> 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<ValidatorRunner>().RunAsync(options, cancellationToken);
});

return await rootCommand.Parse(args).InvokeAsync();
}

internal static IReadOnlyDictionary<ValidationErrorCode, SonarRuleSeverity?> ParseSeverityOverrides(
string? blocker, string? critical, string? major, string? minor, string? info, string? ignore)
{
var result = new Dictionary<ValidationErrorCode, SonarRuleSeverity?>();

// 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<ValidationErrorCode, SonarRuleSeverity?> target, bool wildcardOnly)
{
if (input is null) return;

if (input.Trim() == "*")
{
if (wildcardOnly)
{
foreach (var code in Enum.GetValues<ValidationErrorCode>())
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<ValidationErrorCode>(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).");
}
}
49 changes: 40 additions & 9 deletions src/SLNX-validator/ValidationReporter.cs
Original file line number Diff line number Diff line change
@@ -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<FileValidationResult> results)
public static async Task Report(IReadOnlyList<FileValidationResult> results,
IReadOnlyDictionary<ValidationErrorCode, SonarRuleSeverity?>? 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<ValidationErrorCode, SonarRuleSeverity?>? overrides) =>
overrides is null || !overrides.TryGetValue(code, out var severity) || severity is not null;

private static bool IsFailingError(ValidationErrorCode code,
IReadOnlyDictionary<ValidationErrorCode, SonarRuleSeverity?>? 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<ValidationErrorCode, SonarRuleSeverity?>? 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<ValidationErrorCode, SonarRuleSeverity?>? overrides)
{
if (overrides is null || !overrides.TryGetValue(code, out var severity)) return "";
return severity switch
{
SonarRuleSeverity.MINOR => " (warning)",
SonarRuleSeverity.INFO => " (info)",
_ => ""
};
}
}
16 changes: 13 additions & 3 deletions src/SLNX-validator/ValidatorRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -27,12 +28,21 @@ public async Task<int> 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<ValidationErrorCode, SonarRuleSeverity?>? 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
}
}
Loading
Loading