From 0cffe5f8bba96d07d0af740c87242f016c4e597c Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 6 Oct 2026 08:48:25 +0900 Subject: [PATCH 1/3] fix: Decide the generated skill copy exclusion from the path relative to the scan root The C# complexity scan skipped every file whose absolute path contained /.claude/ or /.agents/. A checkout that itself sits below such a directory, such as a git worktree under .claude/worktrees/, had all of its files skipped and was reported as having no complexity issues. The exclusion now looks at the path relative to the scan root, so only generated skill copies inside the scanned tree are skipped. --- .../CodeComplexityAnalyzerRunnerTests.cs | 79 +++++++++++++++++++ .../SourceFileCollector.cs | 22 +++--- 2 files changed, 92 insertions(+), 9 deletions(-) diff --git a/tests/UnityCliLoop.CodeComplexity.Tests/CodeComplexityAnalyzerRunnerTests.cs b/tests/UnityCliLoop.CodeComplexity.Tests/CodeComplexityAnalyzerRunnerTests.cs index e7160aeb5e..a70f28861d 100644 --- a/tests/UnityCliLoop.CodeComplexity.Tests/CodeComplexityAnalyzerRunnerTests.cs +++ b/tests/UnityCliLoop.CodeComplexity.Tests/CodeComplexityAnalyzerRunnerTests.cs @@ -109,6 +109,85 @@ 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 advisory mode keeps the command successful when CA1502 diagnostics are present. [Test] public void Main_WhenFailOnExceededIsFalse_ShouldReturnSuccessForFindings() 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); } } } From c2f781d4e6f7f7d8031c278ffbc4bbf42063fe15 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 6 Oct 2026 08:50:55 +0900 Subject: [PATCH 2/3] fix: Fail the complexity scan when no production source is found A scan that found no C# files under Packages/src returned an empty result, so a wrong --root or an exclusion that matched everything was reported as "No CA1502 complexity issues found" and exited 0. The scan now stops with a message on stderr and exit code 2, the same code used for invalid arguments, instead of turning the check green without analyzing anything. --- .../CodeComplexityAnalyzerRunnerTests.cs | 43 +++++++++++++++++++ .../CodeComplexityAnalyzerRunner.cs | 9 ++-- tools/UnityCliLoop.CodeComplexity/Program.cs | 12 +++++- 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/tests/UnityCliLoop.CodeComplexity.Tests/CodeComplexityAnalyzerRunnerTests.cs b/tests/UnityCliLoop.CodeComplexity.Tests/CodeComplexityAnalyzerRunnerTests.cs index a70f28861d..60c624915e 100644 --- a/tests/UnityCliLoop.CodeComplexity.Tests/CodeComplexityAnalyzerRunnerTests.cs +++ b/tests/UnityCliLoop.CodeComplexity.Tests/CodeComplexityAnalyzerRunnerTests.cs @@ -188,6 +188,25 @@ public int GeneratedBranch(bool condition) 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); + + InvalidOperationException? 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() @@ -275,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"); @@ -334,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..e5ad722740 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 InvalidOperationException( + $"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/Program.cs b/tools/UnityCliLoop.CodeComplexity/Program.cs index 274775d770..97475e2cfa 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 (InvalidOperationException exception) + { + Console.Error.WriteLine(exception.Message); + return 2; + } + CodeComplexityReporter.Write(issues, options); if (options.FailOnExceeded && issues.Any()) From e4bb50ce5301ce67b8d45ca1f60dee5e1d1ff867 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 6 Oct 2026 09:08:02 +0900 Subject: [PATCH 3/3] fix: Map only a missing production source to exit code 2 Exit code 2 means the arguments were invalid. Catching every InvalidOperationException turned any unexpected failure of that type, such as one raised inside Roslyn, into exit 2 with no stack trace. A dedicated NoProductionSourceException now carries the missing-source case, and it is the only exception the entry point maps to exit 2. --- .../CodeComplexityAnalyzerRunnerTests.cs | 2 +- .../CodeComplexityAnalyzerRunner.cs | 2 +- .../NoProductionSourceException.cs | 15 +++++++++++++++ tools/UnityCliLoop.CodeComplexity/Program.cs | 2 +- 4 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 tools/UnityCliLoop.CodeComplexity/NoProductionSourceException.cs diff --git a/tests/UnityCliLoop.CodeComplexity.Tests/CodeComplexityAnalyzerRunnerTests.cs b/tests/UnityCliLoop.CodeComplexity.Tests/CodeComplexityAnalyzerRunnerTests.cs index 60c624915e..49c268b9d6 100644 --- a/tests/UnityCliLoop.CodeComplexity.Tests/CodeComplexityAnalyzerRunnerTests.cs +++ b/tests/UnityCliLoop.CodeComplexity.Tests/CodeComplexityAnalyzerRunnerTests.cs @@ -201,7 +201,7 @@ public void AnalyzeAsync_WhenNoProductionSourceExists_ShouldThrow() ReportFormat.Table, failOnExceeded: false); - InvalidOperationException? exception = Assert.ThrowsAsync( + NoProductionSourceException? exception = Assert.ThrowsAsync( async () => await runner.AnalyzeAsync(options, CancellationToken.None)); Assert.That(exception?.Message, Does.Contain(Path.Combine(_rootPath, "Packages", "src"))); diff --git a/tools/UnityCliLoop.CodeComplexity/CodeComplexityAnalyzerRunner.cs b/tools/UnityCliLoop.CodeComplexity/CodeComplexityAnalyzerRunner.cs index e5ad722740..4200aa70e5 100644 --- a/tools/UnityCliLoop.CodeComplexity/CodeComplexityAnalyzerRunner.cs +++ b/tools/UnityCliLoop.CodeComplexity/CodeComplexityAnalyzerRunner.cs @@ -43,7 +43,7 @@ public async Task> AnalyzeAsync(CodeComplexit // would otherwise turn the check green without analyzing anything. if (fileSet.ProductionFiles.Count == 0) { - throw new InvalidOperationException( + throw new NoProductionSourceException( $"No C# source files were found below {Path.Combine(options.RootPath, "Packages", "src")}. Check --root."); } 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 97475e2cfa..e62519648f 100644 --- a/tools/UnityCliLoop.CodeComplexity/Program.cs +++ b/tools/UnityCliLoop.CodeComplexity/Program.cs @@ -39,7 +39,7 @@ private static async Task RunAsync(string[] args, CancellationToken ct) { issues = await runner.AnalyzeAsync(options, ct); } - catch (InvalidOperationException exception) + catch (NoProductionSourceException exception) { Console.Error.WriteLine(exception.Message); return 2;