From 8b4b3b94dfc4941b4eefb64969e281e618228d4a Mon Sep 17 00:00:00 2001 From: TimoKuronen Date: Mon, 28 Sep 2026 16:07:33 +0300 Subject: [PATCH] Reduce audit noise with opt-in stack packs and grounded prompts. Keep default rule packs stack-agnostic, load Unity guidance only via --stack, and instruct the model to cite visible symbols and treat Score as qualitative-only. --- CodeSmellAuditor.Cli/CliArgs.cs | 18 ++- CodeSmellAuditor.Cli/CliAuditHost.cs | 27 +++- CodeSmellAuditor.Cli/Program.cs | 12 +- .../AuditEngineTests.cs | 126 ++++++++++++++++++ .../AuditPromptBuilderTests.cs | 4 + CodeSmellAuditor.Core.Tests/CliArgsTests.cs | 46 +++++++ .../SystemAuditPromptBuilderTests.cs | 3 + CodeSmellAuditor.Core/AuditEngine.cs | 60 +++++++++ CodeSmellAuditor.Core/AuditPromptBuilder.cs | 11 ++ .../SystemAuditPromptBuilder.cs | 11 ++ README.md | 3 +- WorkstationStorage/Rules/10-csharp-style.mdc | 4 +- .../Rules/20-project-layout.mdc | 9 +- .../Rules/40-error-handling.mdc | 2 - .../Stacks/Unity/10-unity-csharp-style.mdc | 13 ++ .../Stacks/Unity/20-unity-project-layout.mdc | 14 ++ .../Unity}/30-unity-perf-async.mdc | 3 +- .../Stacks/Unity/40-unity-error-handling.mdc | 12 ++ docs/architecture.md | 18 ++- docs/sample-report-excerpt.md | 4 +- scripts/sniff.ps1 | 5 + 21 files changed, 370 insertions(+), 35 deletions(-) create mode 100644 WorkstationStorage/Rules/Stacks/Unity/10-unity-csharp-style.mdc create mode 100644 WorkstationStorage/Rules/Stacks/Unity/20-unity-project-layout.mdc rename WorkstationStorage/Rules/{ => Stacks/Unity}/30-unity-perf-async.mdc (93%) create mode 100644 WorkstationStorage/Rules/Stacks/Unity/40-unity-error-handling.mdc diff --git a/CodeSmellAuditor.Cli/CliArgs.cs b/CodeSmellAuditor.Cli/CliArgs.cs index 49ac337..0766314 100644 --- a/CodeSmellAuditor.Cli/CliArgs.cs +++ b/CodeSmellAuditor.Cli/CliArgs.cs @@ -13,13 +13,15 @@ public sealed record CliArgs( string? Model = null, string? Storage = null, bool NonInteractive = false, - string? Manifest = null) + string? Manifest = null, + string? Stack = null) { public static CliArgs Parse(string[] args) { string? model = null; string? storage = null; string? manifest = null; + string? stack = null; bool nonInteractive = false; var positional = new List(); @@ -45,6 +47,12 @@ public static CliArgs Parse(string[] args) continue; } + if (string.Equals(token, "--stack", StringComparison.OrdinalIgnoreCase)) + { + stack = RequireOptionValue(args, ref i, "--stack"); + continue; + } + if (string.Equals(token, "--non-interactive", StringComparison.OrdinalIgnoreCase)) { nonInteractive = true; @@ -54,7 +62,7 @@ public static CliArgs Parse(string[] args) if (token.StartsWith('-')) { throw new ArgumentException( - $"Unknown option '{token}'. Supported: --model, --storage, --manifest, --non-interactive."); + $"Unknown option '{token}'. Supported: --model, --storage, --manifest, --stack, --non-interactive."); } positional.Add(token); @@ -67,7 +75,7 @@ public static CliArgs Parse(string[] args) throw new ArgumentException("--manifest is only valid with sniff-system."); } - return new CliArgs(CliMode.Batch, Array.Empty(), model, storage, nonInteractive); + return new CliArgs(CliMode.Batch, Array.Empty(), model, storage, nonInteractive, Stack: stack); } if (string.Equals(positional[0], "sniff", StringComparison.OrdinalIgnoreCase)) @@ -78,13 +86,13 @@ public static CliArgs Parse(string[] args) } IReadOnlyList sniffPaths = ParseCsPaths(positional, minCount: 1, commandName: "sniff"); - return new CliArgs(CliMode.Sniff, sniffPaths, model, storage, nonInteractive); + return new CliArgs(CliMode.Sniff, sniffPaths, model, storage, nonInteractive, Stack: stack); } if (string.Equals(positional[0], "sniff-system", StringComparison.OrdinalIgnoreCase)) { IReadOnlyList systemPaths = ParseCsPaths(positional, minCount: 2, commandName: "sniff-system"); - return new CliArgs(CliMode.SniffSystem, systemPaths, model, storage, nonInteractive, manifest); + return new CliArgs(CliMode.SniffSystem, systemPaths, model, storage, nonInteractive, manifest, stack); } throw new ArgumentException( diff --git a/CodeSmellAuditor.Cli/CliAuditHost.cs b/CodeSmellAuditor.Cli/CliAuditHost.cs index 22dbbdc..9d069f4 100644 --- a/CodeSmellAuditor.Cli/CliAuditHost.cs +++ b/CodeSmellAuditor.Cli/CliAuditHost.cs @@ -15,9 +15,9 @@ public CliAuditHost(AuditEngine engine) _engine = engine; } - public async Task RunAsync(string rulesPath, string targetsPath) + public async Task RunAsync(string rulesPath, string targetsPath, string? stackName = null) { - IReadOnlyList rules = await _engine.LoadRulesAsync(rulesPath); + IReadOnlyList rules = await _engine.LoadRulesAsync(rulesPath, stackName); PrintRulesLoaded(rules); IReadOnlyList targetFiles = AuditEngine.EnumerateTargetFiles(targetsPath); @@ -53,7 +53,10 @@ public async Task RunAsync(string rulesPath, string targetsPath) /// /// Audits one or more external .cs files in place. Rules load once; reports stay under auditor storage. /// - public async Task RunSniffAsync(string rulesPath, IReadOnlyList filePaths) + public async Task RunSniffAsync( + string rulesPath, + IReadOnlyList filePaths, + string? stackName = null) { if (filePaths.Count == 0) { @@ -77,7 +80,7 @@ public async Task RunSniffAsync(string rulesPath, IReadOnlyList< absolutePaths.Add(absolutePath); } - IReadOnlyList rules = await _engine.LoadRulesAsync(rulesPath); + IReadOnlyList rules = await _engine.LoadRulesAsync(rulesPath, stackName); PrintRulesLoaded(rules); string reportsDirectoryPath = AuditEngine.ResolveReportsDirectory(rulesPath); @@ -107,7 +110,8 @@ public async Task RunSniffAsync(string rulesPath, IReadOnlyList< public async Task RunSniffSystemAsync( string rulesPath, IReadOnlyList filePaths, - string? manifestPath) + string? manifestPath, + string? stackName = null) { if (filePaths.Count < 2) { @@ -151,13 +155,22 @@ public async Task RunSniffSystemAsync( $"Architecture rules folder not found: {architectureRulesPath}"); } - IReadOnlyList rules = await _engine.LoadRulesAsync(architectureRulesPath); - if (rules.Count == 0) + // Architecture packs are the primary rules for system audits; optional stack packs + // layer on from the root Rules/Stacks folder (not under Architecture/). + IReadOnlyList architectureRules = await _engine.LoadRulesAsync(architectureRulesPath); + if (architectureRules.Count == 0) { throw new InvalidOperationException( $"No architecture rule packs found under {architectureRulesPath}"); } + IReadOnlyList rules = string.IsNullOrWhiteSpace(stackName) + ? architectureRules + : await _engine.LoadRulesAsync( + architectureRulesPath, + stackName, + stackRootRulesPath: rulesPath); + PrintRulesLoaded(rules); string reportsDirectoryPath = AuditEngine.ResolveReportsDirectory(rulesPath); diff --git a/CodeSmellAuditor.Cli/Program.cs b/CodeSmellAuditor.Cli/Program.cs index 61d254d..636f56a 100644 --- a/CodeSmellAuditor.Cli/Program.cs +++ b/CodeSmellAuditor.Cli/Program.cs @@ -113,9 +113,14 @@ static void WaitForEnterIfInteractive(CliArgs parsed) var engine = new AuditEngine(repository, aiService); var host = new CliAuditHost(engine); +if (!string.IsNullOrWhiteSpace(cliArgs.Stack)) +{ + AnsiConsole.MarkupLine($"[grey]Stack pack:[/] [cyan]{Markup.Escape(cliArgs.Stack)}[/]"); +} + if (cliArgs.Mode == CliMode.Batch) { - AuditRunResult batchResult = await host.RunAsync(rulesPath, targetsPath); + AuditRunResult batchResult = await host.RunAsync(rulesPath, targetsPath, cliArgs.Stack); Environment.ExitCode = batchResult.ExitCode; AnsiConsole.MarkupLine( batchResult.AllPassed @@ -132,12 +137,13 @@ static void WaitForEnterIfInteractive(CliArgs parsed) AuditRunResult systemResult = await host.RunSniffSystemAsync( rulesPath, cliArgs.SniffPaths, - cliArgs.Manifest); + cliArgs.Manifest, + cliArgs.Stack); Environment.ExitCode = systemResult.ExitCode; return; } - AuditRunResult sniffResult = await host.RunSniffAsync(rulesPath, cliArgs.SniffPaths); + AuditRunResult sniffResult = await host.RunSniffAsync(rulesPath, cliArgs.SniffPaths, cliArgs.Stack); Environment.ExitCode = sniffResult.ExitCode; } catch (Exception ex) when (ex is FileNotFoundException or ArgumentException or DirectoryNotFoundException or InvalidOperationException) diff --git a/CodeSmellAuditor.Core.Tests/AuditEngineTests.cs b/CodeSmellAuditor.Core.Tests/AuditEngineTests.cs index 8eacfcb..65f05e6 100644 --- a/CodeSmellAuditor.Core.Tests/AuditEngineTests.cs +++ b/CodeSmellAuditor.Core.Tests/AuditEngineTests.cs @@ -172,6 +172,132 @@ public void ResolveArchitectureRulesPath_AppendsArchitectureFolder() Assert.Equal(Path.Combine(@"D:\Storage\Rules", "Architecture"), resolved); } + [Fact] + public void ResolveStackRulesPath_AppendsStacksFolderAndName() + { + string resolved = AuditEngine.ResolveStackRulesPath(@"D:\Storage\Rules", "Unity"); + + Assert.Equal(Path.Combine(@"D:\Storage\Rules", "Stacks", "Unity"), resolved); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ResolveStackRulesPath_RejectsEmptyName(string stackName) + { + Assert.Throws(() => + AuditEngine.ResolveStackRulesPath(@"D:\Storage\Rules", stackName)); + } + + [Theory] + [InlineData("../Unity")] + [InlineData("Unity/Extra")] + [InlineData(@"Unity\Extra")] + public void ResolveStackRulesPath_RejectsPathTraversal(string stackName) + { + Assert.Throws(() => + AuditEngine.ResolveStackRulesPath(@"D:\Storage\Rules", stackName)); + } + + [Fact] + public async Task LoadRulesAsync_WithStack_ConcatenatesBaseAndStackRules() + { + string workspace = CreateTempWorkspace(); + + try + { + string rulesPath = Path.Combine(workspace, "Rules"); + string stackPath = Path.Combine(rulesPath, "Stacks", "Unity"); + Directory.CreateDirectory(rulesPath); + Directory.CreateDirectory(stackPath); + + await File.WriteAllTextAsync( + Path.Combine(rulesPath, "base.mdc"), + "Base rule body."); + await File.WriteAllTextAsync( + Path.Combine(stackPath, "unity.mdc"), + "Unity rule body."); + + var engine = new AuditEngine( + new MarkdownRuleRepository(), + new FakeAiOrchestrator("Status: COMPLIANT")); + + IReadOnlyList baseOnly = await engine.LoadRulesAsync(rulesPath); + IReadOnlyList withStack = await engine.LoadRulesAsync(rulesPath, "Unity"); + + Assert.Single(baseOnly); + Assert.Equal(2, withStack.Count); + Assert.Contains(withStack, r => r.Name == "base.mdc"); + Assert.Contains(withStack, r => r.Name == "unity.mdc"); + } + finally + { + Directory.Delete(workspace, recursive: true); + } + } + + [Fact] + public async Task LoadRulesAsync_WithStack_UsesStackRootWhenProvided() + { + string workspace = CreateTempWorkspace(); + + try + { + string rulesPath = Path.Combine(workspace, "Rules"); + string architecturePath = Path.Combine(rulesPath, "Architecture"); + string stackPath = Path.Combine(rulesPath, "Stacks", "Unity"); + Directory.CreateDirectory(architecturePath); + Directory.CreateDirectory(stackPath); + + await File.WriteAllTextAsync( + Path.Combine(architecturePath, "arch.mdc"), + "Architecture rule."); + await File.WriteAllTextAsync( + Path.Combine(stackPath, "unity.mdc"), + "Unity rule."); + + var engine = new AuditEngine( + new MarkdownRuleRepository(), + new FakeAiOrchestrator("Status: COMPLIANT")); + + IReadOnlyList rules = await engine.LoadRulesAsync( + architecturePath, + "Unity", + stackRootRulesPath: rulesPath); + + Assert.Equal(2, rules.Count); + Assert.Contains(rules, r => r.Name == "arch.mdc"); + Assert.Contains(rules, r => r.Name == "unity.mdc"); + } + finally + { + Directory.Delete(workspace, recursive: true); + } + } + + [Fact] + public async Task LoadRulesAsync_WithMissingStack_Throws() + { + string workspace = CreateTempWorkspace(); + + try + { + string rulesPath = Path.Combine(workspace, "Rules"); + Directory.CreateDirectory(rulesPath); + + var engine = new AuditEngine( + new MarkdownRuleRepository(), + new FakeAiOrchestrator("Status: COMPLIANT")); + + await Assert.ThrowsAsync(() => + engine.LoadRulesAsync(rulesPath, "Unity")); + } + finally + { + Directory.Delete(workspace, recursive: true); + } + } + private static string CreateTempWorkspace() { return Path.Combine(Path.GetTempPath(), $"codesmell-engine-{Guid.NewGuid():N}"); diff --git a/CodeSmellAuditor.Core.Tests/AuditPromptBuilderTests.cs b/CodeSmellAuditor.Core.Tests/AuditPromptBuilderTests.cs index 687ebb7..6ec6f79 100644 --- a/CodeSmellAuditor.Core.Tests/AuditPromptBuilderTests.cs +++ b/CodeSmellAuditor.Core.Tests/AuditPromptBuilderTests.cs @@ -22,6 +22,10 @@ public void BuildSystemPrompt_IncludesRulesAndOutputTemplate() Assert.Contains("EXACTLY one of", prompt); Assert.Contains("omit Missing Context", prompt); Assert.Contains("No analysis traces", prompt); + Assert.Contains("Grounding rules:", prompt); + Assert.Contains("visible in the provided source text", prompt); + Assert.Contains("Missing Context as an open question", prompt); + Assert.Contains("not a regression metric", prompt); } [Fact] diff --git a/CodeSmellAuditor.Core.Tests/CliArgsTests.cs b/CodeSmellAuditor.Core.Tests/CliArgsTests.cs index 659ab53..3e2b286 100644 --- a/CodeSmellAuditor.Core.Tests/CliArgsTests.cs +++ b/CodeSmellAuditor.Core.Tests/CliArgsTests.cs @@ -15,6 +15,7 @@ public void Parse_NoArgs_ReturnsBatchMode() Assert.False(result.NonInteractive); Assert.Null(result.Model); Assert.Null(result.Storage); + Assert.Null(result.Stack); } [Fact] @@ -97,15 +98,60 @@ public void Parse_BatchWithFlags_ParsesOptions() { "--model", "qwen3.5:4b", "--storage", @"D:\Storage", + "--stack", "Unity", "--non-interactive" }); Assert.Equal(CliMode.Batch, result.Mode); Assert.Equal("qwen3.5:4b", result.Model); Assert.Equal(@"D:\Storage", result.Storage); + Assert.Equal("Unity", result.Stack); Assert.True(result.NonInteractive); } + [Fact] + public void Parse_SniffWithStack_ParsesStack() + { + CliArgs result = CliArgs.Parse(new[] + { + "sniff", + "Program.cs", + "--stack", + "Unity" + }); + + Assert.Equal(CliMode.Sniff, result.Mode); + Assert.Equal("Unity", result.Stack); + } + + [Fact] + public void Parse_SniffSystemWithStack_ParsesStack() + { + CliArgs result = CliArgs.Parse(new[] + { + "sniff-system", + "A.cs", + "B.cs", + "--stack", + "Unity", + "--manifest", + @"D:\manifest.md" + }); + + Assert.Equal(CliMode.SniffSystem, result.Mode); + Assert.Equal("Unity", result.Stack); + Assert.Equal(@"D:\manifest.md", result.Manifest); + } + + [Fact] + public void Parse_StackMissingValue_Throws() + { + ArgumentException ex = Assert.Throws( + () => CliArgs.Parse(new[] { "--stack" })); + + Assert.Contains("--stack requires a value", ex.Message); + } + [Fact] public void Parse_SniffWithFlagsBeforeAndAfter_ParsesAll() { diff --git a/CodeSmellAuditor.Core.Tests/SystemAuditPromptBuilderTests.cs b/CodeSmellAuditor.Core.Tests/SystemAuditPromptBuilderTests.cs index 7487cc3..1602ef8 100644 --- a/CodeSmellAuditor.Core.Tests/SystemAuditPromptBuilderTests.cs +++ b/CodeSmellAuditor.Core.Tests/SystemAuditPromptBuilderTests.cs @@ -21,6 +21,9 @@ public void BuildSystemPrompt_IncludesArchitectureFramingAndTemplate() Assert.Contains("## Manifest Conformance", prompt); Assert.Contains("## Cross-File Findings", prompt); Assert.Contains("REVIEW REQUIRED", prompt); + Assert.Contains("Grounding rules:", prompt); + Assert.Contains("visible in the provided source files", prompt); + Assert.Contains("not a regression metric", prompt); } [Fact] diff --git a/CodeSmellAuditor.Core/AuditEngine.cs b/CodeSmellAuditor.Core/AuditEngine.cs index d60b8ad..21d8c66 100644 --- a/CodeSmellAuditor.Core/AuditEngine.cs +++ b/CodeSmellAuditor.Core/AuditEngine.cs @@ -22,11 +22,71 @@ public async Task> LoadRulesAsync(string rulesPath) return (await _ruleRepository.GetActiveRulesAsync(rulesPath)).ToList(); } + /// + /// Loads base rules from , then optionally concatenates + /// an opt-in stack pack under Rules/Stacks/{stackName}. + /// When is set (system audits), the stack folder + /// is resolved from that root instead of from the architecture rules folder. + /// + public async Task> LoadRulesAsync( + string rulesPath, + string? stackName, + string? stackRootRulesPath = null) + { + var combined = new List(await LoadRulesAsync(rulesPath)); + + if (string.IsNullOrWhiteSpace(stackName)) + { + return combined; + } + + string rootForStack = stackRootRulesPath ?? rulesPath; + string stackRulesPath = ResolveStackRulesPath(rootForStack, stackName); + if (!Directory.Exists(stackRulesPath)) + { + throw new DirectoryNotFoundException( + $"Stack rules folder not found: {stackRulesPath}"); + } + + IReadOnlyList stackRules = + (await _ruleRepository.GetActiveRulesAsync(stackRulesPath)).ToList(); + + if (stackRules.Count == 0) + { + throw new InvalidOperationException( + $"No rule packs found under stack folder: {stackRulesPath}"); + } + + combined.AddRange(stackRules); + return combined; + } + public static string ResolveArchitectureRulesPath(string rulesPath) { return Path.Combine(rulesPath, "Architecture"); } + public static string ResolveStackRulesPath(string rulesPath, string stackName) + { + if (string.IsNullOrWhiteSpace(stackName)) + { + throw new ArgumentException("Stack name must not be empty.", nameof(stackName)); + } + + string trimmed = stackName.Trim(); + if (trimmed.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 + || trimmed.Contains('/') + || trimmed.Contains('\\') + || trimmed.Contains("..", StringComparison.Ordinal)) + { + throw new ArgumentException( + $"Invalid stack name '{stackName}'. Use a single folder name such as Unity.", + nameof(stackName)); + } + + return Path.Combine(rulesPath, "Stacks", trimmed); + } + public static string ResolveReportsDirectory(string rulesPath) { string basePath = Path.GetDirectoryName(rulesPath.TrimEnd(Path.DirectorySeparatorChar)) diff --git a/CodeSmellAuditor.Core/AuditPromptBuilder.cs b/CodeSmellAuditor.Core/AuditPromptBuilder.cs index b2b4b37..7b191b4 100644 --- a/CodeSmellAuditor.Core/AuditPromptBuilder.cs +++ b/CodeSmellAuditor.Core/AuditPromptBuilder.cs @@ -20,6 +20,14 @@ public record AuditConfiguration( public static class AuditPromptBuilder { + private const string GroundingRules = """ + Grounding rules: + - Only report a finding if a concrete symbol, member, or pattern for it is visible in the provided source text. + - Do not assume frameworks, infrastructure, or patterns not referenced in the source (Unity types, UniTask, ScriptableObject, DI containers, service locators) unless the source actually names them. + - A single file may be one part of a larger system already wired elsewhere; claims that depend on code you cannot see (for example "no DI" or "unclear composition") belong under Missing Context as an open question, never under Top Findings as an asserted violation. + - Each Top Findings bullet must name the exact method, property, or field it concerns. + """; + private const string OutputTemplate = """ Output ONLY the final report in this exact format. No analysis traces, no reasoning, no preamble. @@ -40,6 +48,7 @@ One sentence. Contract rules: - The Status line must appear exactly once and use EXACTLY one of: COMPLIANT or REVIEW REQUIRED (nothing else on that line after Status:). - The Score line must appear exactly once as an integer from 0 to 100. + - Score is a rough qualitative signal for this run only, not a regression metric; do not imply meaningful change between runs from a small Score delta; base Score primarily on the count and severity of this run's own Top Findings. - Emit Status, Score, and Top Findings first. If the output budget is tight, omit Missing Context and/or Recommended Next Step rather than truncating or omitting Status. """; @@ -57,6 +66,8 @@ public static string BuildSystemPrompt(IEnumerable rules) builder.AppendLine(); } + builder.AppendLine(GroundingRules); + builder.AppendLine(); builder.AppendLine(OutputTemplate); return builder.ToString(); } diff --git a/CodeSmellAuditor.Core/SystemAuditPromptBuilder.cs b/CodeSmellAuditor.Core/SystemAuditPromptBuilder.cs index ed09d79..fe24d02 100644 --- a/CodeSmellAuditor.Core/SystemAuditPromptBuilder.cs +++ b/CodeSmellAuditor.Core/SystemAuditPromptBuilder.cs @@ -8,6 +8,14 @@ namespace CodeSmellAuditor.Core; /// public static class SystemAuditPromptBuilder { + private const string GroundingRules = """ + Grounding rules: + - Only report a finding if a concrete symbol, member, or pattern for it is visible in the provided source files. + - Do not assume frameworks, infrastructure, or patterns not referenced in the provided sources (Unity types, UniTask, ScriptableObject, DI containers, service locators) unless the sources actually name them. + - Claims that depend on code outside the audited set belong under Per-File Notes or Recommended Next Step as open questions, never as asserted Cross-File Findings. + - Prefer findings that name the files and exact members involved. + """; + private const string OutputTemplate = """ Output ONLY the final report in this exact format. No analysis traces, no reasoning, no preamble. @@ -31,6 +39,7 @@ One sentence. Contract rules: - The Status line must appear exactly once and use EXACTLY one of: COMPLIANT or REVIEW REQUIRED (nothing else on that line after Status:). - The Score line must appear exactly once as an integer from 0 to 100. + - Score is a rough qualitative signal for this run only, not a regression metric; do not imply meaningful change between runs from a small Score delta; base Score primarily on the count and severity of this run's own findings. - Emit Status, Score, Manifest Conformance, and Cross-File Findings first. If the output budget is tight, omit Per-File Notes and/or Recommended Next Step rather than truncating or omitting Status. - Judge the files against the stated architecture contract and the governance rules. Do not invent a different architecture; flag conflicts with the given contract. """; @@ -49,6 +58,8 @@ public static string BuildSystemPrompt(IEnumerable rules) builder.AppendLine(); } + builder.AppendLine(GroundingRules); + builder.AppendLine(); builder.AppendLine(OutputTemplate); return builder.ToString(); } diff --git a/README.md b/README.md index d3e80cc..00d4d36 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,11 @@ This is an **AI-assisted semantic reviewer**, not a Roslyn/static analyzer: the ## Highlights - Single-file and multi-path `sniff` against local markdown rule packs (`.mdc`); batch mode via `WorkstationStorage/Targets/` +- Opt-in stack packs via `--stack` (e.g. `Unity` under `Rules/Stacks/Unity/`); default packs stay stack-agnostic - `sniff-system` for two or more related files (optional `--manifest`) using `Rules/Architecture/` and one combined report - Local Ollama only — source stays on your machine - Streaming console output with Spectre Status until first token; timestamped reports under `WorkstationStorage/Reports/` -- Pass/fail from the report `Status:` line; batch and sniff set process exit codes (`0`/`1`) +- Pass/fail from the report `Status:` line (exit codes `0`/`1`); `Score` is informational only (not a regression metric) - Layered Core / Cli with interfaces for rule loading and AI orchestration - xUnit tests (fakes; no Ollama in CI) and Ubuntu GitHub Actions diff --git a/WorkstationStorage/Rules/10-csharp-style.mdc b/WorkstationStorage/Rules/10-csharp-style.mdc index af68dea..260261f 100644 --- a/WorkstationStorage/Rules/10-csharp-style.mdc +++ b/WorkstationStorage/Rules/10-csharp-style.mdc @@ -16,6 +16,4 @@ RulePackVersion: 1.3.0 - Functions at one abstraction level. - Flag classes with more than five constructor dependencies as possible SRP violations. - Public types and non-obvious public boundary methods should have XML summaries; flag redundant summaries that restate the name. -- Inline comments explain why - not line narration or inspector setup. -- ScriptableObject config assets: prefer auto-properties or public fields; flag passthrough getters with no logic. -- Flag runtime mutation of shared ScriptableObject assets in gameplay code. \ No newline at end of file +- Inline comments explain why - not line narration or inspector setup. \ No newline at end of file diff --git a/WorkstationStorage/Rules/20-project-layout.mdc b/WorkstationStorage/Rules/20-project-layout.mdc index b0d18d6..a438d17 100644 --- a/WorkstationStorage/Rules/20-project-layout.mdc +++ b/WorkstationStorage/Rules/20-project-layout.mdc @@ -1,5 +1,5 @@ --- -description: Unity and .NET project layout for CodeSmellAuditor (EngineeringRulebook 1.3.0) +description: .NET project layout for CodeSmellAuditor (EngineeringRulebook 1.3.0) alwaysApply: true --- @@ -7,13 +7,6 @@ alwaysApply: true RulePackVersion: 1.3.0 -## Unity - -- Flag new types in global Interfaces/, Managers/, or Controllers/ buckets when feature folders exist. -- Flag interfaces split from their implementation across unrelated top-level folders. -- Flag namespace/folder mismatch on new files under Assets/.../Scripts/. -- Prefer feature-first layout per EngineeringRulebook unity-project-layout.md. - ## .NET - Flag Core referencing Infrastructure or Ui. diff --git a/WorkstationStorage/Rules/40-error-handling.mdc b/WorkstationStorage/Rules/40-error-handling.mdc index 16196cd..cb4f877 100644 --- a/WorkstationStorage/Rules/40-error-handling.mdc +++ b/WorkstationStorage/Rules/40-error-handling.mdc @@ -8,6 +8,4 @@ alwaysApply: true RulePackVersion: 1.3.0 - Flag empty catch blocks and swallowed exceptions. -- Flag defensive null checks on every internal private call when serialized refs should be validated at startup. -- Flag exceptions for expected gameplay rejection paths; prefer Try/bool + reason enum. - Validate external/untrusted input at boundaries; do not require blanket null guards on internal helpers. \ No newline at end of file diff --git a/WorkstationStorage/Rules/Stacks/Unity/10-unity-csharp-style.mdc b/WorkstationStorage/Rules/Stacks/Unity/10-unity-csharp-style.mdc new file mode 100644 index 0000000..43b600c --- /dev/null +++ b/WorkstationStorage/Rules/Stacks/Unity/10-unity-csharp-style.mdc @@ -0,0 +1,13 @@ +--- +description: Unity C# style extras for CodeSmellAuditor (EngineeringRulebook 1.3.0) +alwaysApply: false +--- + +# Unity C# style + +RulePackVersion: 1.3.0 +Mode: stack-unity + +- ScriptableObject config assets: prefer auto-properties or public fields; flag passthrough getters with no logic. +- Flag runtime mutation of shared ScriptableObject assets in gameplay code. +- Inline comments explain why - not line narration or inspector setup. \ No newline at end of file diff --git a/WorkstationStorage/Rules/Stacks/Unity/20-unity-project-layout.mdc b/WorkstationStorage/Rules/Stacks/Unity/20-unity-project-layout.mdc new file mode 100644 index 0000000..48a240f --- /dev/null +++ b/WorkstationStorage/Rules/Stacks/Unity/20-unity-project-layout.mdc @@ -0,0 +1,14 @@ +--- +description: Unity project layout for CodeSmellAuditor (EngineeringRulebook 1.3.0) +alwaysApply: false +--- + +# Unity project layout + +RulePackVersion: 1.3.0 +Mode: stack-unity + +- Flag new types in global Interfaces/, Managers/, or Controllers/ buckets when feature folders exist. +- Flag interfaces split from their implementation across unrelated top-level folders. +- Flag namespace/folder mismatch on new files under Assets/.../Scripts/. +- Prefer feature-first layout per EngineeringRulebook unity-project-layout.md. \ No newline at end of file diff --git a/WorkstationStorage/Rules/30-unity-perf-async.mdc b/WorkstationStorage/Rules/Stacks/Unity/30-unity-perf-async.mdc similarity index 93% rename from WorkstationStorage/Rules/30-unity-perf-async.mdc rename to WorkstationStorage/Rules/Stacks/Unity/30-unity-perf-async.mdc index ec094c7..045eb37 100644 --- a/WorkstationStorage/Rules/30-unity-perf-async.mdc +++ b/WorkstationStorage/Rules/Stacks/Unity/30-unity-perf-async.mdc @@ -1,11 +1,12 @@ --- description: Unity performance and async for CodeSmellAuditor (EngineeringRulebook 1.3.0) -alwaysApply: true +alwaysApply: false --- # Unity performance and async RulePackVersion: 1.3.0 +Mode: stack-unity - Flag Vector3.Distance or magnitude used only for threshold comparison in per-frame or network hot paths; suggest sqrMagnitude. - Flag unnecessary UnityEngine to Unity.Mathematics conversion in inner loops. diff --git a/WorkstationStorage/Rules/Stacks/Unity/40-unity-error-handling.mdc b/WorkstationStorage/Rules/Stacks/Unity/40-unity-error-handling.mdc new file mode 100644 index 0000000..8844831 --- /dev/null +++ b/WorkstationStorage/Rules/Stacks/Unity/40-unity-error-handling.mdc @@ -0,0 +1,12 @@ +--- +description: Unity error handling for CodeSmellAuditor (EngineeringRulebook 1.3.0) +alwaysApply: false +--- + +# Unity error handling + +RulePackVersion: 1.3.0 +Mode: stack-unity + +- Flag defensive null checks on every internal private call when serialized refs should be validated at startup. +- Flag exceptions for expected gameplay rejection paths; prefer Try/bool + reason enum. \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md index 139cff9..7413008 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,7 +54,7 @@ flowchart TB ### IRuleRepository -Loads active governance rules from a folder. `MarkdownRuleRepository` reads `.mdc` audit packs by default; reference `.md` manuals are excluded from runtime prompts. Compact excerpts keep prompts within local model context; report shape is owned by `AuditPromptBuilder`. +Loads active governance rules from a folder. `MarkdownRuleRepository` reads `.mdc` audit packs by default; reference `.md` manuals are excluded from runtime prompts. Compact excerpts keep prompts within local model context; report shape is owned by `AuditPromptBuilder`. Root `Rules/*.mdc` are stack-agnostic. Opt-in stack packs live under `Rules/Stacks//` (loaded only when `--stack` is set). Architecture packs under `Rules/Architecture/` are used by sniff-system. ### IAiOrchestrator @@ -68,8 +68,8 @@ Assembles system instructions, rule excerpts, and source text. Enforces a charac Presentation-neutral domain API: -1. `LoadRulesAsync` - load compact rules -2. `EnumerateTargetFiles` / `ResolveReportsDirectory` / `ResolveArchitectureRulesPath` - storage helpers +1. `LoadRulesAsync` - load compact rules (optional stack name concatenates `Rules/Stacks//`) +2. `EnumerateTargetFiles` / `ResolveReportsDirectory` / `ResolveArchitectureRulesPath` / `ResolveStackRulesPath` - storage helpers 3. `AuditFileAsync` - analyze one file, optional token callback, persist markdown report 4. `AuditSystemAsync` - analyze two or more files as one architecture audit (optional manifest text) 5. `RunBatchAsync` - headless multi-file run for tests and non-interactive callers @@ -92,8 +92,10 @@ Owns the interactive batch loop. For each target it wraps `AuditFileAsync` in `A | Streaming token callback | Immediate feedback without Core knowing about the console | | Compact `.mdc` rules at runtime | Keeps prompt size within local model context | | Architecture rules under `Rules/Architecture/` | System audits do not inflate single-file prompts | +| Stack packs under `Rules/Stacks//` | Unity (and future stacks) are opt-in via `--stack`; default packs stay stack-agnostic | | Bounded report template | Prevents reasoning models from consuming output budget | | Status line parsing | Deterministic pass/fail from `Status:` line | +| Score is informational | Model-emitted qualitative signal; not used for exit codes or regression | | Env var for storage and model config | Portable across machines | ## Dependency direction @@ -118,9 +120,17 @@ Core does not reference Cli. External AI and file I/O are behind interfaces or i |------|---------|----------| | `--model ` | Ollama model for this run | `CODESMELL_MODEL` wins when set | | `--storage ` | WorkstationStorage root | `CODESMELL_STORAGE` wins when set | +| `--stack ` | Opt-in stack pack under `Rules/Stacks//` (e.g. `Unity`) | — | | `--manifest ` | Free-text architecture contract (sniff-system only) | — | | `--non-interactive` | Skip batch Enter wait | — | Flags may appear before or after `sniff` / `sniff-system`. Example: `dotnet run --project CodeSmellAuditor.Cli -- --model qwen3.5:4b --non-interactive`. -Batch and sniff reuse `CliAuditHost` Status wrapping and `AuditEngine.AuditFileAsync` with root `Rules/*.mdc`. Sniff-system loads only `Rules/Architecture/*.mdc`, uses `AuditEngine.AuditSystemAsync` / `IAiOrchestrator.AnalyzeSystemAsync` with a larger `AuditConfiguration.ForSystemAudit` budget, and writes one `SystemAudit_*_Critique.md` report. Rules and reports always come from auditor storage; sniff modes never copy into Targets. Empty batch runs (no `*.cs` targets) exit `1`. Multi-path sniff loads rules once and audits files sequentially. `scripts/sniff.ps1` forwards `-Target` paths plus optional `-Model` / `-Storage`; `-System` and `-Manifest` map to `sniff-system`. +Batch and sniff reuse `CliAuditHost` Status wrapping and `AuditEngine.AuditFileAsync` with root `Rules/*.mdc`. Optional `--stack` concatenates `Rules/Stacks//*.mdc` onto that set. Sniff-system loads only `Rules/Architecture/*.mdc` (plus optional stack packs from `Rules/Stacks/`), uses `AuditEngine.AuditSystemAsync` / `IAiOrchestrator.AnalyzeSystemAsync` with a larger `AuditConfiguration.ForSystemAudit` budget, and writes one `SystemAudit_*_Critique.md` report. Rules and reports always come from auditor storage; sniff modes never copy into Targets. Empty batch runs (no `*.cs` targets) exit `1`. Multi-path sniff loads rules once and audits files sequentially. `scripts/sniff.ps1` forwards `-Target` paths plus optional `-Model` / `-Storage` / `-Stack`; `-System` and `-Manifest` map to `sniff-system`. + +### Report contract + +| Field | Role | +|-------|------| +| `Status:` | Sole pass/fail signal (`COMPLIANT` or `REVIEW REQUIRED`). Parsed by the CLI for exit codes. | +| `Score:` | Rough qualitative signal for that run only. Model-emitted, non-deterministic; not a regression metric. Prefer Status and Top Findings over Score deltas between runs. | diff --git a/docs/sample-report-excerpt.md b/docs/sample-report-excerpt.md index 6747710..cb78b00 100644 --- a/docs/sample-report-excerpt.md +++ b/docs/sample-report-excerpt.md @@ -42,6 +42,8 @@ Score: 72 Extract spawn planning into a dedicated planner class and inject it as a single dependency. ``` +`Status:` is the only pass/fail signal used by the CLI. `Score:` is a rough qualitative signal for that run only - not a regression metric; prefer Status and Top Findings over Score deltas between runs. + ## Interrupted reports -If the model exceeds its output budget, the report may be truncated. +If the model exceeds its output budget, the report may be truncated. \ No newline at end of file diff --git a/scripts/sniff.ps1 b/scripts/sniff.ps1 index 5263d59..0e315a6 100644 --- a/scripts/sniff.ps1 +++ b/scripts/sniff.ps1 @@ -6,6 +6,8 @@ param( [string]$Storage, + [string]$Stack, + [switch]$System, [string]$Manifest @@ -65,6 +67,9 @@ if (-not [string]::IsNullOrWhiteSpace($Model)) { if (-not [string]::IsNullOrWhiteSpace($Storage)) { $cliArgs += @("--storage", $Storage) } +if (-not [string]::IsNullOrWhiteSpace($Stack)) { + $cliArgs += @("--stack", $Stack) +} if (-not [string]::IsNullOrWhiteSpace($absoluteManifest)) { $cliArgs += @("--manifest", $absoluteManifest) }