From ef11daaa99c88c0fb7fae9ec50b016bb169c38f8 Mon Sep 17 00:00:00 2001 From: TimoKuronen Date: Wed, 9 Sep 2026 10:31:01 +0300 Subject: [PATCH] Add batch pass/fail summary and exit codes - Map AuditRunResult to process exit 0/1 for Targets batch - Print per-file PASS/FAIL summary after batch runs - Cover ExitCode and summary formatting with unit tests - Document batch exit behavior in README and architecture --- CodeSmellAuditor.Cli/BatchRunSummary.cs | 41 +++++++++++ CodeSmellAuditor.Cli/CliAuditHost.cs | 16 ++++- CodeSmellAuditor.Cli/Program.cs | 10 ++- .../AuditRunResultTests.cs | 70 +++++++++++++++++++ CodeSmellAuditor.Core/AuditRunResult.cs | 6 ++ README.md | 5 +- docs/architecture.md | 4 +- 7 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 CodeSmellAuditor.Cli/BatchRunSummary.cs create mode 100644 CodeSmellAuditor.Core.Tests/AuditRunResultTests.cs diff --git a/CodeSmellAuditor.Cli/BatchRunSummary.cs b/CodeSmellAuditor.Cli/BatchRunSummary.cs new file mode 100644 index 0000000..a99f8e7 --- /dev/null +++ b/CodeSmellAuditor.Cli/BatchRunSummary.cs @@ -0,0 +1,41 @@ +using System.Text; +using CodeSmellAuditor.Core; + +namespace CodeSmellAuditor.Cli; + +/// +/// Formats a plain-text batch pass/fail summary for the console host. +/// +public static class BatchRunSummary +{ + public static string Format(AuditRunResult result) + { + if (result.FileResults.Count == 0) + { + return "Batch summary: 0 files audited."; + } + + int passed = result.FileResults.Count(file => file.HasPassed); + int failed = result.FileResults.Count - passed; + + var builder = new StringBuilder(); + builder.Append("Batch summary: "); + builder.Append(result.FileResults.Count); + builder.Append(" files - "); + builder.Append(passed); + builder.Append(" passed, "); + builder.Append(failed); + builder.Append(" failed"); + + foreach (FileAuditResult file in result.FileResults) + { + builder.AppendLine(); + builder.Append(" "); + builder.Append(file.HasPassed ? "PASS" : "FAIL"); + builder.Append(" "); + builder.Append(file.FileName); + } + + return builder.ToString(); + } +} diff --git a/CodeSmellAuditor.Cli/CliAuditHost.cs b/CodeSmellAuditor.Cli/CliAuditHost.cs index 8104089..9adef68 100644 --- a/CodeSmellAuditor.Cli/CliAuditHost.cs +++ b/CodeSmellAuditor.Cli/CliAuditHost.cs @@ -24,7 +24,9 @@ public async Task RunAsync(string rulesPath, string targetsPath) if (targetFiles.Count == 0) { AnsiConsole.MarkupLine("[bold yellow]WARNING:[/] No C# target files found."); - return new AuditRunResult(Array.Empty()); + AuditRunResult emptyResult = new(Array.Empty()); + PrintBatchSummary(emptyResult); + return emptyResult; } string reportsDirectoryPath = AuditEngine.ResolveReportsDirectory(rulesPath); @@ -43,7 +45,9 @@ public async Task RunAsync(string rulesPath, string targetsPath) fileResults.Add(result); } - return new AuditRunResult(fileResults); + AuditRunResult runResult = new(fileResults); + PrintBatchSummary(runResult); + return runResult; } /// @@ -129,4 +133,12 @@ private static void PrintRulesLoaded(IReadOnlyList rules) AnsiConsole.WriteLine(); } + + private static void PrintBatchSummary(AuditRunResult result) + { + AnsiConsole.WriteLine(); + AnsiConsole.Write(new Rule("[bold]Batch summary[/]").RuleStyle("grey")); + AnsiConsole.WriteLine(BatchRunSummary.Format(result)); + AnsiConsole.WriteLine(); + } } diff --git a/CodeSmellAuditor.Cli/Program.cs b/CodeSmellAuditor.Cli/Program.cs index 181fc70..8f48ce3 100644 --- a/CodeSmellAuditor.Cli/Program.cs +++ b/CodeSmellAuditor.Cli/Program.cs @@ -91,8 +91,12 @@ static void WaitForExit() if (cliArgs.Mode == CliMode.Batch) { - await host.RunAsync(rulesPath, targetsPath); - AnsiConsole.MarkupLine("[bold green]Batch processing complete.[/]"); + AuditRunResult batchResult = await host.RunAsync(rulesPath, targetsPath); + Environment.ExitCode = batchResult.ExitCode; + AnsiConsole.MarkupLine( + batchResult.AllPassed + ? "[bold green]Batch processing complete.[/]" + : "[bold yellow]Batch processing complete with failures.[/]"); WaitForExit(); return; } @@ -100,7 +104,7 @@ static void WaitForExit() try { AuditRunResult sniffResult = await host.RunSniffAsync(rulesPath, cliArgs.SniffPath!); - Environment.ExitCode = sniffResult.AllPassed ? 0 : 1; + Environment.ExitCode = sniffResult.ExitCode; } catch (Exception ex) when (ex is FileNotFoundException or ArgumentException) { diff --git a/CodeSmellAuditor.Core.Tests/AuditRunResultTests.cs b/CodeSmellAuditor.Core.Tests/AuditRunResultTests.cs new file mode 100644 index 0000000..b641667 --- /dev/null +++ b/CodeSmellAuditor.Core.Tests/AuditRunResultTests.cs @@ -0,0 +1,70 @@ +using CodeSmellAuditor.Cli; +using CodeSmellAuditor.Core; +using Xunit; + +namespace CodeSmellAuditor.Core.Tests; + +public class AuditRunResultTests +{ + [Fact] + public void ExitCode_AllPassed_IsZero() + { + var result = new AuditRunResult(new[] + { + new FileAuditResult("A.cs", "a.md", HasPassed: true), + new FileAuditResult("B.cs", "b.md", HasPassed: true) + }); + + Assert.True(result.AllPassed); + Assert.Equal(0, result.ExitCode); + } + + [Fact] + public void ExitCode_AnyFailure_IsOne() + { + var result = new AuditRunResult(new[] + { + new FileAuditResult("A.cs", "a.md", HasPassed: true), + new FileAuditResult("B.cs", "b.md", HasPassed: false) + }); + + Assert.False(result.AllPassed); + Assert.Equal(1, result.ExitCode); + } + + [Fact] + public void ExitCode_EmptyRun_IsOne() + { + var result = new AuditRunResult(Array.Empty()); + + Assert.False(result.AllPassed); + Assert.Equal(1, result.ExitCode); + } +} + +public class BatchRunSummaryTests +{ + [Fact] + public void Format_EmptyRun_ReportsZeroFiles() + { + string text = BatchRunSummary.Format(new AuditRunResult(Array.Empty())); + + Assert.Equal("Batch summary: 0 files audited.", text); + } + + [Fact] + public void Format_MixedResults_ListsPassFailPerFile() + { + var result = new AuditRunResult(new[] + { + new FileAuditResult("Good.cs", "g.md", HasPassed: true), + new FileAuditResult("Bad.cs", "b.md", HasPassed: false) + }); + + string text = BatchRunSummary.Format(result); + + Assert.Contains("Batch summary: 2 files - 1 passed, 1 failed", text); + Assert.Contains("PASS Good.cs", text); + Assert.Contains("FAIL Bad.cs", text); + } +} diff --git a/CodeSmellAuditor.Core/AuditRunResult.cs b/CodeSmellAuditor.Core/AuditRunResult.cs index 4aa1168..d513a1b 100644 --- a/CodeSmellAuditor.Core/AuditRunResult.cs +++ b/CodeSmellAuditor.Core/AuditRunResult.cs @@ -5,4 +5,10 @@ public record FileAuditResult(string FileName, string ReportPath, bool HasPassed public record AuditRunResult(IReadOnlyList FileResults) { public bool AllPassed => FileResults.Count > 0 && FileResults.All(result => result.HasPassed); + + /// + /// Process exit code for scriptable hosts: 0 when every audited file passed, otherwise 1. + /// Empty runs are treated as failure (nothing to pass). + /// + public int ExitCode => AllPassed ? 0 : 1; } diff --git a/README.md b/README.md index 9ffeabd..ac9f652 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,11 @@ This is an **AI-assisted semantic code reviewer**, not a deterministic static an - Local Ollama only — source stays on your machine - Streaming terminal output with Spectre Status spinner until first token - Timestamped markdown reports under `WorkstationStorage/Reports/` -- Pass/fail parsed from a `Status:` line in the report; `sniff` sets process exit codes +- Pass/fail parsed from a `Status:` line in the report; batch and sniff set process exit codes (`0`/`1`) +- Batch mode prints a pass/fail summary after all Targets files; still waits for Enter (desk UX) - Layered Core / Cli / Tests solution with constructor injection at the composition root - Compact rule excerpts with character-budget validation before each audit -- 25 xUnit tests for rule loading, prompt building, pass/fail parsing, and engine orchestration (fakes; no Ollama in CI) +- 30 xUnit tests for rule loading, prompt building, pass/fail parsing, summaries, and engine orchestration (fakes; no Ollama in CI) - Ubuntu CI via GitHub Actions ## Architecture diff --git a/docs/architecture.md b/docs/architecture.md index 2c01f91..a479ae5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -102,7 +102,7 @@ Core does not reference Cli. External AI and file I/O are behind interfaces or i | Mode | Invocation | Source | Exit behavior | |------|------------|--------|---------------| -| Batch | no args | `WorkstationStorage/Targets/*.cs` | Enter-to-exit; no exit code yet | +| Batch | no args | `WorkstationStorage/Targets/*.cs` | Pass/fail summary; exit `0`/`1` from `AuditRunResult`; still Enter-to-exit for desk UX | | Sniff | `sniff path\to\File.cs` | external path in place | no Enter wait; exit `0`/`1` from pass/fail | -Both modes reuse `CliAuditHost` Status wrapping and `AuditEngine.AuditFileAsync`. Rules and reports always come from auditor storage; sniff never copies into Targets. +Both modes reuse `CliAuditHost` Status wrapping and `AuditEngine.AuditFileAsync`. Rules and reports always come from auditor storage; sniff never copies into Targets. Empty batch runs (no `*.cs` targets) exit `1`.