diff --git a/CodeSmellAuditor.Cli/CliArgs.cs b/CodeSmellAuditor.Cli/CliArgs.cs index 6ceb20d..fc8321a 100644 --- a/CodeSmellAuditor.Cli/CliArgs.cs +++ b/CodeSmellAuditor.Cli/CliArgs.cs @@ -8,7 +8,7 @@ public enum CliMode public sealed record CliArgs( CliMode Mode, - string? SniffPath, + IReadOnlyList SniffPaths, string? Model = null, string? Storage = null, bool NonInteractive = false) @@ -53,32 +53,38 @@ public static CliArgs Parse(string[] args) if (positional.Count == 0) { - return new CliArgs(CliMode.Batch, SniffPath: null, model, storage, nonInteractive); + return new CliArgs(CliMode.Batch, Array.Empty(), model, storage, nonInteractive); } if (!string.Equals(positional[0], "sniff", StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException( - $"Unknown command '{positional[0]}'. Use no args for Targets batch, or: sniff "); + $"Unknown command '{positional[0]}'. Use no args for Targets batch, or: sniff [more.cs...]"); } - if (positional.Count < 2 || string.IsNullOrWhiteSpace(positional[1])) + if (positional.Count < 2) { - throw new ArgumentException("sniff requires a path to a .cs file."); + throw new ArgumentException("sniff requires at least one path to a .cs file."); } - if (positional.Count > 2) + var sniffPaths = new List(); + for (int i = 1; i < positional.Count; i++) { - throw new ArgumentException("sniff accepts exactly one path argument."); - } + string path = positional[i].Trim().Trim('"'); + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentException("sniff path arguments must not be empty."); + } - string path = positional[1].Trim().Trim('"'); - if (!path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) - { - throw new ArgumentException($"sniff path must be a .cs file: {path}"); + if (!path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException($"sniff path must be a .cs file: {path}"); + } + + sniffPaths.Add(path); } - return new CliArgs(CliMode.Sniff, path, model, storage, nonInteractive); + return new CliArgs(CliMode.Sniff, sniffPaths, model, storage, nonInteractive); } private static string RequireOptionValue(string[] args, ref int index, string optionName) diff --git a/CodeSmellAuditor.Cli/CliAuditHost.cs b/CodeSmellAuditor.Cli/CliAuditHost.cs index 9adef68..26bdfcb 100644 --- a/CodeSmellAuditor.Cli/CliAuditHost.cs +++ b/CodeSmellAuditor.Cli/CliAuditHost.cs @@ -51,35 +51,54 @@ public async Task RunAsync(string rulesPath, string targetsPath) } /// - /// Audits one external .cs file in place. Rules and reports stay under auditor storage. + /// Audits one or more external .cs files in place. Rules load once; reports stay under auditor storage. /// - public async Task RunSniffAsync(string rulesPath, string filePath) + public async Task RunSniffAsync(string rulesPath, IReadOnlyList filePaths) { - string absolutePath = Path.GetFullPath(filePath); - if (!File.Exists(absolutePath)) + if (filePaths.Count == 0) { - throw new FileNotFoundException($"sniff target not found: {absolutePath}", absolutePath); + throw new ArgumentException("sniff requires at least one path to a .cs file."); } - if (!absolutePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + var absolutePaths = new List(filePaths.Count); + foreach (string filePath in filePaths) { - throw new ArgumentException($"sniff path must be a .cs file: {absolutePath}"); + string absolutePath = Path.GetFullPath(filePath); + if (!File.Exists(absolutePath)) + { + throw new FileNotFoundException($"sniff target not found: {absolutePath}", absolutePath); + } + + if (!absolutePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException($"sniff path must be a .cs file: {absolutePath}"); + } + + absolutePaths.Add(absolutePath); } IReadOnlyList rules = await _engine.LoadRulesAsync(rulesPath); PrintRulesLoaded(rules); string reportsDirectoryPath = AuditEngine.ResolveReportsDirectory(rulesPath); - string fileName = Path.GetFileName(absolutePath); + var fileResults = new List(absolutePaths.Count); + + foreach (string absolutePath in absolutePaths) + { + string fileName = Path.GetFileName(absolutePath); + FileAuditResult result = await AuditFileWithStatusAsync( + absolutePath, + fileName, + rules, + reportsDirectoryPath); - FileAuditResult result = await AuditFileWithStatusAsync( - absolutePath, - fileName, - rules, - reportsDirectoryPath); + AnsiConsole.MarkupLine($"[grey]└── Report saved:[/] [underline cyan]{result.ReportPath}[/]\n"); + fileResults.Add(result); + } - AnsiConsole.MarkupLine($"[grey]└── Report saved:[/] [underline cyan]{result.ReportPath}[/]\n"); - return new AuditRunResult(new[] { result }); + AuditRunResult runResult = new(fileResults); + PrintBatchSummary(runResult); + return runResult; } private async Task AuditFileWithStatusAsync( diff --git a/CodeSmellAuditor.Cli/Program.cs b/CodeSmellAuditor.Cli/Program.cs index 286dde4..bde55d5 100644 --- a/CodeSmellAuditor.Cli/Program.cs +++ b/CodeSmellAuditor.Cli/Program.cs @@ -124,7 +124,7 @@ static void WaitForEnterIfInteractive(CliArgs parsed) try { - AuditRunResult sniffResult = await host.RunSniffAsync(rulesPath, cliArgs.SniffPath!); + AuditRunResult sniffResult = await host.RunSniffAsync(rulesPath, cliArgs.SniffPaths); Environment.ExitCode = sniffResult.ExitCode; } catch (Exception ex) when (ex is FileNotFoundException or ArgumentException) diff --git a/CodeSmellAuditor.Core.Tests/CliArgsTests.cs b/CodeSmellAuditor.Core.Tests/CliArgsTests.cs index a53714f..373a16f 100644 --- a/CodeSmellAuditor.Core.Tests/CliArgsTests.cs +++ b/CodeSmellAuditor.Core.Tests/CliArgsTests.cs @@ -11,7 +11,7 @@ public void Parse_NoArgs_ReturnsBatchMode() CliArgs result = CliArgs.Parse(Array.Empty()); Assert.Equal(CliMode.Batch, result.Mode); - Assert.Null(result.SniffPath); + Assert.Empty(result.SniffPaths); Assert.False(result.NonInteractive); Assert.Null(result.Model); Assert.Null(result.Storage); @@ -23,7 +23,7 @@ public void Parse_SniffWithCsPath_ReturnsSniffMode() CliArgs result = CliArgs.Parse(new[] { "sniff", @"D:\Repos\Sample.cs" }); Assert.Equal(CliMode.Sniff, result.Mode); - Assert.Equal(@"D:\Repos\Sample.cs", result.SniffPath); + Assert.Equal(new[] { @"D:\Repos\Sample.cs" }, result.SniffPaths); } [Theory] @@ -34,7 +34,7 @@ public void Parse_SniffIsCaseInsensitive(string command) CliArgs result = CliArgs.Parse(new[] { command, "Program.cs" }); Assert.Equal(CliMode.Sniff, result.Mode); - Assert.Equal("Program.cs", result.SniffPath); + Assert.Equal(new[] { "Program.cs" }, result.SniffPaths); } [Fact] @@ -42,7 +42,16 @@ public void Parse_SniffStripsSurroundingQuotes() { CliArgs result = CliArgs.Parse(new[] { "sniff", "\"C:\\Work\\Foo.cs\"" }); - Assert.Equal(@"C:\Work\Foo.cs", result.SniffPath); + Assert.Equal(new[] { @"C:\Work\Foo.cs" }, result.SniffPaths); + } + + [Fact] + public void Parse_SniffMultipleCsPaths_ReturnsAll() + { + CliArgs result = CliArgs.Parse(new[] { "sniff", "A.cs", "B.cs", @"D:\C.cs" }); + + Assert.Equal(CliMode.Sniff, result.Mode); + Assert.Equal(new[] { "A.cs", "B.cs", @"D:\C.cs" }, result.SniffPaths); } [Fact] @@ -60,7 +69,7 @@ public void Parse_SniffMissingPath_Throws() ArgumentException ex = Assert.Throws( () => CliArgs.Parse(new[] { "sniff" })); - Assert.Contains("requires a path", ex.Message); + Assert.Contains("at least one path", ex.Message); } [Fact] @@ -73,12 +82,12 @@ public void Parse_SniffNonCsExtension_Throws() } [Fact] - public void Parse_SniffExtraPaths_Throws() + public void Parse_SniffMixedExtensions_ThrowsOnNonCs() { ArgumentException ex = Assert.Throws( - () => CliArgs.Parse(new[] { "sniff", "A.cs", "B.cs" })); + () => CliArgs.Parse(new[] { "sniff", "A.cs", "readme.md" })); - Assert.Contains("exactly one path", ex.Message); + Assert.Contains(".cs file", ex.Message); } [Fact] @@ -105,12 +114,13 @@ public void Parse_SniffWithFlagsBeforeAndAfter_ParsesAll() "--model", "llama3", "sniff", "Program.cs", + "Helper.cs", "--storage", @"C:\WorkstationStorage", "--non-interactive" }); Assert.Equal(CliMode.Sniff, result.Mode); - Assert.Equal("Program.cs", result.SniffPath); + Assert.Equal(new[] { "Program.cs", "Helper.cs" }, result.SniffPaths); Assert.Equal("llama3", result.Model); Assert.Equal(@"C:\WorkstationStorage", result.Storage); Assert.True(result.NonInteractive); diff --git a/README.md b/README.md index f8cdda6..26a8b07 100644 --- a/README.md +++ b/README.md @@ -12,16 +12,16 @@ This is an **AI-assisted semantic code reviewer**, not a deterministic static an ## Highlights - Single-file `.cs` audits against local markdown rule packs (`.mdc`) -- Batch mode via `WorkstationStorage/Targets/` and path-based `sniff` for files in place +- Batch mode via `WorkstationStorage/Targets/` and path-based `sniff` for one or more `.cs` files in place - Local Ollama only — source stays on your machine - Streaming terminal output with Spectre Status spinner until first token - Timestamped markdown reports under `WorkstationStorage/Reports/` (`{File}_{yyyyMMdd_HHmmss}_Critique.md`) - 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; waits for Enter unless `--non-interactive` +- Batch and multi-file sniff print a pass/fail summary after all files; batch waits for Enter unless `--non-interactive` - CLI flags: `--model`, `--storage`, `--non-interactive` (env vars `CODESMELL_MODEL` / `CODESMELL_STORAGE` override flags when set) - Layered Core / Cli / Tests solution with constructor injection at the composition root - Compact rule excerpts with character-budget validation before each audit -- 34 xUnit tests for rule loading, prompt building, pass/fail parsing, CLI flags, summaries, and engine orchestration (fakes; no Ollama in CI) +- 40 xUnit tests for rule loading, prompt building, pass/fail parsing, CLI flags, 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 c83c7ec..96656b8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -83,7 +83,7 @@ Owns the interactive batch loop. For each target it wraps `AuditFileAsync` in `A |----------|-----------| | Interfaces for rules and AI | Swap rule packs or cloud API without changing engine | | Cli owns Status wrapping | Spectre Status must wrap the await; presentation stays out of Core | -| File-level Core API | Keeps Core modular; Cli (or future sniff CLI) can drive one file at a time | +| File-level Core API | Keeps Core modular; Cli drives one or more files via sniff while Core stays per-file | | Streaming token callback | Immediate feedback without Core knowing about the console | | Compact `.mdc` rules at runtime | Keeps prompt size within local model context | | Bounded report template | Prevents reasoning models from consuming output budget | @@ -103,7 +103,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` | Pass/fail summary; exit `0`/`1`; Enter-to-exit unless `--non-interactive` | -| Sniff | `sniff path\to\File.cs` | external path in place | no Enter wait; exit `0`/`1` from pass/fail | +| Sniff | `sniff path\to\File.cs [more.cs...]` | one or more external paths in place | no Enter wait; pass/fail summary; exit `0`/`1` from aggregate pass/fail | ### Shared options @@ -115,4 +115,4 @@ Core does not reference Cli. External AI and file I/O are behind interfaces or i Flags may appear before or after `sniff`. Example: `dotnet run --project CodeSmellAuditor.Cli -- --model qwen3.5:4b --non-interactive`. -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`. `scripts/sniff.ps1` forwards optional `-Model` / `-Storage` to the CLI. +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`. Multi-path sniff loads rules once and audits files sequentially. `scripts/sniff.ps1` forwards one or more `-Target` paths plus optional `-Model` / `-Storage` to the CLI. diff --git a/scripts/sniff.ps1 b/scripts/sniff.ps1 index 1102867..89b05c1 100644 --- a/scripts/sniff.ps1 +++ b/scripts/sniff.ps1 @@ -1,6 +1,6 @@ param( - [Parameter(Mandatory = $true, Position = 0)] - [string]$Target, + [Parameter(Mandatory = $true, Position = 0, ValueFromRemainingArguments = $true)] + [string[]]$Target, [string]$Model, @@ -17,15 +17,23 @@ if (-not (Test-Path -LiteralPath $cliProject)) { exit 1 } -$absoluteFile = $Target -if (-not [System.IO.Path]::IsPathRooted($absoluteFile)) { - $absoluteFile = Join-Path (Get-Location) $Target +if ($null -eq $Target -or $Target.Count -eq 0) { + Write-Error "At least one -Target .cs path is required." + exit 1 +} + +$absoluteFiles = @() +foreach ($item in $Target) { + $absoluteFile = $item + if (-not [System.IO.Path]::IsPathRooted($absoluteFile)) { + $absoluteFile = Join-Path (Get-Location) $item + } + $absoluteFiles += [System.IO.Path]::GetFullPath($absoluteFile) } -$absoluteFile = [System.IO.Path]::GetFullPath($absoluteFile) Set-Location -LiteralPath $repoRoot -$cliArgs = @("sniff", $absoluteFile) +$cliArgs = @("sniff") + $absoluteFiles if (-not [string]::IsNullOrWhiteSpace($Model)) { $cliArgs += @("--model", $Model) }