diff --git a/src/Dolphin/Cli/CheckCommand.cs b/src/Dolphin/Cli/CheckCommand.cs index f46a42a..04ae633 100644 --- a/src/Dolphin/Cli/CheckCommand.cs +++ b/src/Dolphin/Cli/CheckCommand.cs @@ -31,23 +31,32 @@ public static Command Build() description: "Scan only this file instead of the entire project" ); + var failOnOption = new Option( + "--fail-on", + description: "Lowest severity that causes a non-zero exit: error, warning, or info", + getDefaultValue: () => "error" + ); + failOnOption.FromAmong("error", "warning", "info"); + var cmd = new Command("check", "Run static analysis rules against the codebase") { cwdOption, ruleOption, formatOption, - fileOption + fileOption, + failOnOption }; cmd.SetHandler( - async (cwd, ruleId, format, file) => - Environment.Exit(await HandleAsync(cwd, ruleId, format, file)), - cwdOption, ruleOption, formatOption, fileOption); + async (cwd, ruleId, format, file, failOn) => + Environment.Exit(await HandleAsync(cwd, ruleId, format, file, failOn)), + cwdOption, ruleOption, formatOption, fileOption, failOnOption); return cmd; } - internal static async Task HandleAsync(string cwd, string? ruleId, string format, string? file) + internal static async Task HandleAsync( + string cwd, string? ruleId, string format, string? file, string failOn = "error") { cwd = Path.GetFullPath(cwd); if (!Directory.Exists(cwd)) @@ -113,6 +122,18 @@ internal static async Task HandleAsync(string cwd, string? ruleId, string f Formatter.Print(result.Findings, format); - return result.Findings.Any(f => f.Severity == Severity.Error) ? 1 : 0; + var threshold = failOn.ToLowerInvariant(); + return result.Findings.Any(f => Fails(f.Severity, threshold)) ? 1 : 0; } + + // Whether a finding of the given severity should cause a non-zero exit under the + // configured threshold (which must already be lower-cased). Listed explicitly so + // behavior does not depend on the Severity enum's declaration order. + private static bool Fails(Severity severity, string threshold) => + threshold switch + { + "info" => true, + "warning" => severity is Severity.Error or Severity.Warning, + _ => severity == Severity.Error + }; } diff --git a/tests/Dolphin.Tests/CheckCommandTests.cs b/tests/Dolphin.Tests/CheckCommandTests.cs index f3024ec..f0a6a1f 100644 --- a/tests/Dolphin.Tests/CheckCommandTests.cs +++ b/tests/Dolphin.Tests/CheckCommandTests.cs @@ -1,3 +1,4 @@ +using System.CommandLine; using System.Diagnostics; using System.Text.Json; using Dolphin.Cli; @@ -10,6 +11,25 @@ public class CheckCommandTests { // ── Unit tests ───────────────────────────────────────────────────────────── + [TestMethod] + public void Build_FailOnOption_DefaultsToError() + { + var cmd = CheckCommand.Build(); + var result = cmd.Parse(""); + var option = (Option)cmd.Options.Single(o => o.Name == "fail-on"); + Assert.AreEqual("error", result.GetValueForOption(option)); + } + + [TestMethod] + public void Build_FailOnOption_AcceptsValidValuesAndRejectsInvalid() + { + var cmd = CheckCommand.Build(); + var option = (Option)cmd.Options.Single(o => o.Name == "fail-on"); + + Assert.AreEqual("warning", cmd.Parse("--fail-on warning").GetValueForOption(option)); + Assert.IsTrue(cmd.Parse("--fail-on bogus").Errors.Count > 0); + } + [TestMethod] public async Task HandleAsync_NonExistentCwd_Returns2() { @@ -53,6 +73,72 @@ public async Task HandleAsync_FakeScanner_NoFindings_Returns0() } } + private const string WarningFindingJson = + """{"results":[{"check_id":"r","path":"f.ts","start":{"line":1,"col":1},"end":{"line":1,"col":1},"extra":{"message":"m","severity":"WARNING","lines":"x"}}]}"""; + + [TestMethod] + public async Task HandleAsync_WarningFinding_DefaultFailOn_Returns0() + { + if (OperatingSystem.IsWindows()) Assert.Inconclusive("Fake scanner uses a shell script; Unix-only"); + var (tmpDir, fakeBinDir) = CreateFakeOpengrepEnv(exitCode: 1, stdout: WarningFindingJson); + var savedPath = Environment.GetEnvironmentVariable("PATH"); + Environment.SetEnvironmentVariable("PATH", fakeBinDir + ":" + savedPath); + try + { + var result = await CheckCommand.HandleAsync(tmpDir, null, "text", null); + Assert.AreEqual(0, result); + } + finally + { + Environment.SetEnvironmentVariable("PATH", savedPath); + Directory.Delete(tmpDir, recursive: true); + Directory.Delete(fakeBinDir, recursive: true); + } + } + + [TestMethod] + public async Task HandleAsync_WarningFinding_FailOnWarning_Returns1() + { + if (OperatingSystem.IsWindows()) Assert.Inconclusive("Fake scanner uses a shell script; Unix-only"); + var (tmpDir, fakeBinDir) = CreateFakeOpengrepEnv(exitCode: 1, stdout: WarningFindingJson); + var savedPath = Environment.GetEnvironmentVariable("PATH"); + Environment.SetEnvironmentVariable("PATH", fakeBinDir + ":" + savedPath); + try + { + var result = await CheckCommand.HandleAsync(tmpDir, null, "text", null, "warning"); + Assert.AreEqual(1, result); + } + finally + { + Environment.SetEnvironmentVariable("PATH", savedPath); + Directory.Delete(tmpDir, recursive: true); + Directory.Delete(fakeBinDir, recursive: true); + } + } + + private const string InfoFindingJson = + """{"results":[{"check_id":"r","path":"f.ts","start":{"line":1,"col":1},"end":{"line":1,"col":1},"extra":{"message":"m","severity":"INFO","lines":"x"}}]}"""; + + [TestMethod] + public async Task HandleAsync_InfoFinding_FailOnInfo_Returns1() + { + if (OperatingSystem.IsWindows()) Assert.Inconclusive("Fake scanner uses a shell script; Unix-only"); + var (tmpDir, fakeBinDir) = CreateFakeOpengrepEnv(exitCode: 1, stdout: InfoFindingJson); + var savedPath = Environment.GetEnvironmentVariable("PATH"); + Environment.SetEnvironmentVariable("PATH", fakeBinDir + ":" + savedPath); + try + { + var result = await CheckCommand.HandleAsync(tmpDir, null, "text", null, "info"); + Assert.AreEqual(1, result); + } + finally + { + Environment.SetEnvironmentVariable("PATH", savedPath); + Directory.Delete(tmpDir, recursive: true); + Directory.Delete(fakeBinDir, recursive: true); + } + } + [TestMethod] public async Task HandleAsync_FakeScanner_ScannerWarning_Returns0() {