Skip to content
Merged
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
33 changes: 27 additions & 6 deletions src/Dolphin/Cli/CheckCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,23 +31,32 @@ public static Command Build()
description: "Scan only this file instead of the entire project"
);

var failOnOption = new Option<string>(
"--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<int> HandleAsync(string cwd, string? ruleId, string format, string? file)
internal static async Task<int> HandleAsync(
string cwd, string? ruleId, string format, string? file, string failOn = "error")
{
cwd = Path.GetFullPath(cwd);
if (!Directory.Exists(cwd))
Expand Down Expand Up @@ -113,6 +122,18 @@ internal static async Task<int> 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
};
}
86 changes: 86 additions & 0 deletions tests/Dolphin.Tests/CheckCommandTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.CommandLine;
using System.Diagnostics;
Comment thread
Copilot marked this conversation as resolved.
using System.Text.Json;
using Dolphin.Cli;
Expand All @@ -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<string>)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<string>)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()
{
Expand Down Expand Up @@ -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);
Comment thread
Tim-Pohlmann marked this conversation as resolved.
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);
Comment thread
Tim-Pohlmann marked this conversation as resolved.
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);
}
}

Comment thread
Tim-Pohlmann marked this conversation as resolved.
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);
Comment thread
Tim-Pohlmann marked this conversation as resolved.
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()
{
Expand Down
Loading