diff --git a/tests/UnityCliLoop.CodeComplexity.Tests/CodeComplexityAnalyzerRunnerTests.cs b/tests/UnityCliLoop.CodeComplexity.Tests/CodeComplexityAnalyzerRunnerTests.cs index e7160aeb5e..49c268b9d6 100644 --- a/tests/UnityCliLoop.CodeComplexity.Tests/CodeComplexityAnalyzerRunnerTests.cs +++ b/tests/UnityCliLoop.CodeComplexity.Tests/CodeComplexityAnalyzerRunnerTests.cs @@ -109,6 +109,104 @@ public async Task AnalyzeAsync_WhenRepositorySymbolWrapsCode_ShouldAnalyzeCondit && issue.Message.Contains("ConditionalTestFrameworkBranch", StringComparison.Ordinal)), Is.True); } + // Verifies that a checkout placed below a .claude directory still has its sources analyzed. + [Test] + public async Task AnalyzeAsync_WhenRootSitsBelowClaudeDirectory_ShouldStillAnalyzeSources() + { + string rootPath = Path.Combine( + TestContext.CurrentContext.WorkDirectory, + ".claude", + "worktrees", + $"code-complexity-{Guid.NewGuid():N}"); + try + { + Directory.CreateDirectory(rootPath); + CreateSampleRepository(rootPath); + CodeComplexityAnalyzerRunner runner = new(); + CodeComplexityOptions options = new( + rootPath, + maxComplexity: 1, + includeNonProduction: false, + ReportFormat.Table, + failOnExceeded: false); + + IReadOnlyList issues = await runner.AnalyzeAsync(options, CancellationToken.None); + + Assert.That(issues.Any(issue => + issue.RuleId == "CA1502" + && issue.Message.Contains("ProductionBranch", StringComparison.Ordinal)), Is.True); + } + finally + { + // Only the per-test directory is removed: the work directory may already hold a real + // .claude directory, which a recursive delete of .claude would wipe out. + if (Directory.Exists(rootPath)) + { + Directory.Delete(rootPath, recursive: true); + } + } + } + + // Verifies that a generated skill copy inside the scanned tree is skipped while the rest of the tree is analyzed. + [Test] + public async Task AnalyzeAsync_WhenGeneratedSkillCopyIsInsideScannedTree_ShouldIgnoreIt() + { + string skillCopyDirectory = Path.Combine(_rootPath, "Packages", "src", ".claude", "skills"); + Directory.CreateDirectory(skillCopyDirectory); + WriteFile( + Path.Combine(skillCopyDirectory, "Generated.cs"), + """ + namespace SampleGenerated + { + public sealed class GeneratedCode + { + public int GeneratedBranch(bool condition) + { + if (condition) + { + return 1; + } + + return 0; + } + } + } + """); + CodeComplexityAnalyzerRunner runner = new(); + CodeComplexityOptions options = new( + _rootPath, + maxComplexity: 1, + includeNonProduction: false, + ReportFormat.Table, + failOnExceeded: false); + + IReadOnlyList issues = await runner.AnalyzeAsync(options, CancellationToken.None); + + Assert.That(issues.Any(issue => + issue.Message.Contains("GeneratedBranch", StringComparison.Ordinal)), Is.False); + Assert.That(issues.Any(issue => + issue.Message.Contains("ProductionBranch", StringComparison.Ordinal)), Is.True); + } + + // Verifies that a root without production sources is rejected instead of being reported as clean. + [Test] + public void AnalyzeAsync_WhenNoProductionSourceExists_ShouldThrow() + { + DeleteProductionSources(_rootPath); + CodeComplexityAnalyzerRunner runner = new(); + CodeComplexityOptions options = new( + _rootPath, + maxComplexity: 1, + includeNonProduction: false, + ReportFormat.Table, + failOnExceeded: false); + + NoProductionSourceException? exception = Assert.ThrowsAsync( + async () => await runner.AnalyzeAsync(options, CancellationToken.None)); + + Assert.That(exception?.Message, Does.Contain(Path.Combine(_rootPath, "Packages", "src"))); + } + // Verifies that advisory mode keeps the command successful when CA1502 diagnostics are present. [Test] public void Main_WhenFailOnExceededIsFalse_ShouldReturnSuccessForFindings() @@ -196,6 +294,21 @@ public void Main_WhenRootPathContainsNullCharacter_ShouldReturnValidationFailure Assert.That(exitCode, Is.EqualTo(2)); } + // Verifies that a root without production sources returns the validation failure code. + [Test] + public void Main_WhenNoProductionSourceExists_ShouldReturnValidationFailure() + { + DeleteProductionSources(_rootPath); + + int exitCode = Program.Main(new[] + { + "--root", + _rootPath + }); + + Assert.That(exitCode, Is.EqualTo(2)); + } + private static void CreateSampleRepository(string rootPath) { string packageDirectory = Path.Combine(rootPath, "Packages", "src", "Editor", "Sample"); @@ -255,6 +368,15 @@ public int AssetBranch(bool condition) """); } + private static void DeleteProductionSources(string rootPath) + { + string packageSourcePath = Path.Combine(rootPath, "Packages", "src"); + foreach (string sourceFile in Directory.GetFiles(packageSourcePath, "*.cs", SearchOption.AllDirectories)) + { + File.Delete(sourceFile); + } + } + private static void WriteFile(string path, string content) { File.WriteAllText(path, content.Replace("\r\n", "\n", StringComparison.Ordinal)); diff --git a/tools/UnityCliLoop.CodeComplexity/CodeComplexityAnalyzerRunner.cs b/tools/UnityCliLoop.CodeComplexity/CodeComplexityAnalyzerRunner.cs index 1f62380c2b..4200aa70e5 100644 --- a/tools/UnityCliLoop.CodeComplexity/CodeComplexityAnalyzerRunner.cs +++ b/tools/UnityCliLoop.CodeComplexity/CodeComplexityAnalyzerRunner.cs @@ -39,12 +39,15 @@ public async Task> AnalyzeAsync(CodeComplexit Debug.Assert(options.MaxComplexity > 0, "Command-line parsing must reject non-positive thresholds."); SourceFileSet fileSet = SourceFileCollector.Collect(options.RootPath); - string[] sourceFiles = CreateSourceFileList(fileSet, options.IncludeNonProduction); - if (sourceFiles.Length == 0) + // An empty scan must not pass as "no issues": a wrong --root or an over-broad exclusion + // would otherwise turn the check green without analyzing anything. + if (fileSet.ProductionFiles.Count == 0) { - return Array.Empty(); + throw new NoProductionSourceException( + $"No C# source files were found below {Path.Combine(options.RootPath, "Packages", "src")}. Check --root."); } + string[] sourceFiles = CreateSourceFileList(fileSet, options.IncludeNonProduction); Compilation compilation = CreateCompilation(sourceFiles); ImmutableArray analyzers = ImmutableArray.Create(new CodeMetricsAnalyzer()); AnalyzerOptions analyzerOptions = new(ImmutableArray.Create(CreateCodeMetricsConfig(options.MaxComplexity))); diff --git a/tools/UnityCliLoop.CodeComplexity/NoProductionSourceException.cs b/tools/UnityCliLoop.CodeComplexity/NoProductionSourceException.cs new file mode 100644 index 0000000000..f47aedcb08 --- /dev/null +++ b/tools/UnityCliLoop.CodeComplexity/NoProductionSourceException.cs @@ -0,0 +1,15 @@ +using System; + +namespace UnityCliLoop.CodeComplexity +{ + /// + /// Thrown when the scan root has no production C# source to analyze. + /// + public sealed class NoProductionSourceException : Exception + { + public NoProductionSourceException(string message) + : base(message) + { + } + } +} diff --git a/tools/UnityCliLoop.CodeComplexity/Program.cs b/tools/UnityCliLoop.CodeComplexity/Program.cs index 274775d770..e62519648f 100644 --- a/tools/UnityCliLoop.CodeComplexity/Program.cs +++ b/tools/UnityCliLoop.CodeComplexity/Program.cs @@ -34,7 +34,17 @@ private static async Task RunAsync(string[] args, CancellationToken ct) CodeComplexityOptions options = parseResult.Options ?? throw new InvalidOperationException("Successful command-line parsing must produce options."); CodeComplexityAnalyzerRunner runner = new(); - IReadOnlyList issues = await runner.AnalyzeAsync(options, ct); + IReadOnlyList issues; + try + { + issues = await runner.AnalyzeAsync(options, ct); + } + catch (NoProductionSourceException exception) + { + Console.Error.WriteLine(exception.Message); + return 2; + } + CodeComplexityReporter.Write(issues, options); if (options.FailOnExceeded && issues.Any()) diff --git a/tools/UnityCliLoop.CodeComplexity/SourceFileCollector.cs b/tools/UnityCliLoop.CodeComplexity/SourceFileCollector.cs index b6b3fba77e..0ed68b0ece 100644 --- a/tools/UnityCliLoop.CodeComplexity/SourceFileCollector.cs +++ b/tools/UnityCliLoop.CodeComplexity/SourceFileCollector.cs @@ -16,10 +16,10 @@ public static SourceFileSet Collect(string rootPath) string assetsPath = Path.Combine(rootPath, "Assets"); string testsPath = Path.Combine(rootPath, "tests"); - string[] productionFiles = CollectFiles(packageSourcePath); + string[] productionFiles = CollectFiles(rootPath, packageSourcePath); List nonProductionFiles = new(); - nonProductionFiles.AddRange(CollectFiles(assetsPath)); - nonProductionFiles.AddRange(CollectFiles(testsPath)); + nonProductionFiles.AddRange(CollectFiles(rootPath, assetsPath)); + nonProductionFiles.AddRange(CollectFiles(rootPath, testsPath)); return new SourceFileSet( productionFiles, @@ -29,7 +29,7 @@ public static SourceFileSet Collect(string rootPath) .ToArray()); } - private static string[] CollectFiles(string directoryPath) + private static string[] CollectFiles(string rootPath, string directoryPath) { if (!Directory.Exists(directoryPath)) { @@ -37,16 +37,20 @@ private static string[] CollectFiles(string directoryPath) } return Directory.GetFiles(directoryPath, "*.cs", SearchOption.AllDirectories) - .Where(path => !IsGeneratedSkillCopy(path)) + .Where(path => !IsGeneratedSkillCopy(rootPath, path)) .OrderBy(path => path, StringComparer.Ordinal) .ToArray(); } - private static bool IsGeneratedSkillCopy(string path) + // Judged on the path relative to the scan root: a checkout that itself sits below a .claude or + // .agents directory, such as a git worktree, would otherwise have every file skipped. + private static bool IsGeneratedSkillCopy(string rootPath, string path) { - string normalized = path.Replace(Path.DirectorySeparatorChar, '/'); - return normalized.Contains("/.agents/", StringComparison.Ordinal) - || normalized.Contains("/.claude/", StringComparison.Ordinal); + string relativePath = Path.GetRelativePath(rootPath, path).Replace(Path.DirectorySeparatorChar, '/'); + return relativePath.StartsWith(".agents/", StringComparison.Ordinal) + || relativePath.Contains("/.agents/", StringComparison.Ordinal) + || relativePath.StartsWith(".claude/", StringComparison.Ordinal) + || relativePath.Contains("/.claude/", StringComparison.Ordinal); } } }