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
41 changes: 41 additions & 0 deletions CodeSmellAuditor.Cli/BatchRunSummary.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System.Text;
using CodeSmellAuditor.Core;

namespace CodeSmellAuditor.Cli;

/// <summary>
/// Formats a plain-text batch pass/fail summary for the console host.
/// </summary>
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();
}
}
16 changes: 14 additions & 2 deletions CodeSmellAuditor.Cli/CliAuditHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ public async Task<AuditRunResult> RunAsync(string rulesPath, string targetsPath)
if (targetFiles.Count == 0)
{
AnsiConsole.MarkupLine("[bold yellow]WARNING:[/] No C# target files found.");
return new AuditRunResult(Array.Empty<FileAuditResult>());
AuditRunResult emptyResult = new(Array.Empty<FileAuditResult>());
PrintBatchSummary(emptyResult);
return emptyResult;
}

string reportsDirectoryPath = AuditEngine.ResolveReportsDirectory(rulesPath);
Expand All @@ -43,7 +45,9 @@ public async Task<AuditRunResult> RunAsync(string rulesPath, string targetsPath)
fileResults.Add(result);
}

return new AuditRunResult(fileResults);
AuditRunResult runResult = new(fileResults);
PrintBatchSummary(runResult);
return runResult;
}

/// <summary>
Expand Down Expand Up @@ -129,4 +133,12 @@ private static void PrintRulesLoaded(IReadOnlyList<AuditRule> 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();
}
}
10 changes: 7 additions & 3 deletions CodeSmellAuditor.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,16 +91,20 @@ 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;
}

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)
{
Expand Down
70 changes: 70 additions & 0 deletions CodeSmellAuditor.Core.Tests/AuditRunResultTests.cs
Original file line number Diff line number Diff line change
@@ -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<FileAuditResult>());

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<FileAuditResult>()));

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);
}
}
6 changes: 6 additions & 0 deletions CodeSmellAuditor.Core/AuditRunResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,10 @@ public record FileAuditResult(string FileName, string ReportPath, bool HasPassed
public record AuditRunResult(IReadOnlyList<FileAuditResult> FileResults)
{
public bool AllPassed => FileResults.Count > 0 && FileResults.All(result => result.HasPassed);

/// <summary>
/// Process exit code for scriptable hosts: 0 when every audited file passed, otherwise 1.
/// Empty runs are treated as failure (nothing to pass).
/// </summary>
public int ExitCode => AllPassed ? 0 : 1;
}
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Loading