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
32 changes: 19 additions & 13 deletions CodeSmellAuditor.Cli/CliArgs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public enum CliMode

public sealed record CliArgs(
CliMode Mode,
string? SniffPath,
IReadOnlyList<string> SniffPaths,
string? Model = null,
string? Storage = null,
bool NonInteractive = false)
Expand Down Expand Up @@ -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<string>(), 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 <path-to-file.cs>");
$"Unknown command '{positional[0]}'. Use no args for Targets batch, or: sniff <path-to-file.cs> [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<string>();
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)
Expand Down
49 changes: 34 additions & 15 deletions CodeSmellAuditor.Cli/CliAuditHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,35 +51,54 @@ public async Task<AuditRunResult> RunAsync(string rulesPath, string targetsPath)
}

/// <summary>
/// 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.
/// </summary>
public async Task<AuditRunResult> RunSniffAsync(string rulesPath, string filePath)
public async Task<AuditRunResult> RunSniffAsync(string rulesPath, IReadOnlyList<string> 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<string>(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<AuditRule> rules = await _engine.LoadRulesAsync(rulesPath);
PrintRulesLoaded(rules);

string reportsDirectoryPath = AuditEngine.ResolveReportsDirectory(rulesPath);
string fileName = Path.GetFileName(absolutePath);
var fileResults = new List<FileAuditResult>(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<FileAuditResult> AuditFileWithStatusAsync(
Expand Down
2 changes: 1 addition & 1 deletion CodeSmellAuditor.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 19 additions & 9 deletions CodeSmellAuditor.Core.Tests/CliArgsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public void Parse_NoArgs_ReturnsBatchMode()
CliArgs result = CliArgs.Parse(Array.Empty<string>());

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);
Expand All @@ -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]
Expand All @@ -34,15 +34,24 @@ 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]
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]
Expand All @@ -60,7 +69,7 @@ public void Parse_SniffMissingPath_Throws()
ArgumentException ex = Assert.Throws<ArgumentException>(
() => CliArgs.Parse(new[] { "sniff" }));

Assert.Contains("requires a path", ex.Message);
Assert.Contains("at least one path", ex.Message);
}

[Fact]
Expand All @@ -73,12 +82,12 @@ public void Parse_SniffNonCsExtension_Throws()
}

[Fact]
public void Parse_SniffExtraPaths_Throws()
public void Parse_SniffMixedExtensions_ThrowsOnNonCs()
{
ArgumentException ex = Assert.Throws<ArgumentException>(
() => 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]
Expand All @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand All @@ -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.
22 changes: 15 additions & 7 deletions scripts/sniff.ps1
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
param(
[Parameter(Mandatory = $true, Position = 0)]
[string]$Target,
[Parameter(Mandatory = $true, Position = 0, ValueFromRemainingArguments = $true)]
[string[]]$Target,

[string]$Model,

Expand All @@ -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)
}
Expand Down
Loading