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`.