From 007787211016db41e0d5d885be0b01b3a822aa06 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 16:14:47 +0900 Subject: [PATCH 1/4] Fix automatic solution candidate cap (#3065) --- DEVELOPER_GUIDE.md | 4 +- changelog.d/unreleased/3065.fixed.md | 17 ++++++ src/CodeIndex/Cli/SolutionProjectResolver.cs | 52 +++++++++++++++---- .../IndexCommandRunnerTests.cs | 23 ++++++++ 4 files changed, 85 insertions(+), 11 deletions(-) create mode 100644 changelog.d/unreleased/3065.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index d8b56bf77a..21d1ad16a7 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -213,7 +213,7 @@ Interactive terminal controls are allowed only when stdout is not redirected or ### C# / .NET integration -`SolutionProjectResolver` parses the plain-text `.sln` `Project(...) = "...", "...csproj"` entries with a non-regex parser and resolves C# / F# / VB project files. Project entries that normalize outside the active workspace root are ignored before filesystem probing or path-filter evaluation. Solution parsing rejects `.sln` files above 8 MiB, lines above 16,384 characters, and more than 4096 .NET project references with clear diagnostics. When exactly one `.sln` exists at the workspace root, `--project ` uses it automatically; otherwise callers can pass `--solution `. +`SolutionProjectResolver` parses the plain-text `.sln` `Project(...) = "...", "...csproj"` entries with a non-regex parser and resolves C# / F# / VB project files. Project entries that normalize outside the active workspace root are ignored before filesystem probing or path-filter evaluation. Solution parsing rejects `.sln` files above 8 MiB, lines above 16,384 characters, and more than 4096 .NET project references with clear diagnostics. Automatic root-level `.sln` discovery samples at most 128 candidates before sorting and reports a clear error when that cap is exceeded, so callers should pass `--solution ` in solution-heavy workspaces. When exactly one `.sln` exists at the workspace root within that cap, `--project ` uses it automatically; otherwise callers can pass `--solution `. Query commands that accept path filters (`search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `deps`, `impact`, `unused`, `hotspots`, and `validate`) expand `--project` into the matching project directory glob before hitting `DbReader`, so all existing SQL path predicates keep working. `index --project` expands to the files under the selected project directory and reuses the existing `--files` update path. @@ -2344,7 +2344,7 @@ override が文書化されていない限り ANSI/progress control を抑止す ### C# / .NET 連携 -`SolutionProjectResolver` は plain-text の `.sln` に含まれる `Project(...) = "...", "...csproj"` 行を non-regex parser で読み、C# / F# / VB の project file を解決する。active workspace root の外側へ正規化される project entry は、filesystem probe や path-filter 評価の前に無視する。solution parsing は 8 MiB を超える `.sln`、16,384 文字を超える行、4096 件を超える .NET project reference を明確な diagnostic とともに拒否する。workspace root に `.sln` が 1 つだけある場合、`--project ` は自動でそれを使う。複数ある場合は caller が `--solution ` を渡せる。 +`SolutionProjectResolver` は plain-text の `.sln` に含まれる `Project(...) = "...", "...csproj"` 行を non-regex parser で読み、C# / F# / VB の project file を解決する。active workspace root の外側へ正規化される project entry は、filesystem probe や path-filter 評価の前に無視する。solution parsing は 8 MiB を超える `.sln`、16,384 文字を超える行、4096 件を超える .NET project reference を明確な diagnostic とともに拒否する。root 直下の `.sln` 自動検出は sort 前に最大 128 candidates で打ち切り、その上限を超えた場合は明確な error を返すため、solution が多い workspace では `--solution ` を渡す。上限内で workspace root に `.sln` が 1 つだけある場合、`--project ` は自動でそれを使う。複数ある場合は caller が `--solution ` を渡せる。 path filter を受け付ける query コマンド(`search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `deps`, `impact`, `unused`, `hotspots`, `validate`)は、`--project` を対応する project directory glob に展開してから `DbReader` に渡す。これにより既存の SQL path predicate をそのまま利用できる。`index --project` は選択された project directory 配下のファイルに展開し、既存の `--files` 更新経路を再利用する。 diff --git a/changelog.d/unreleased/3065.fixed.md b/changelog.d/unreleased/3065.fixed.md new file mode 100644 index 0000000000..3a66462b48 --- /dev/null +++ b/changelog.d/unreleased/3065.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3065 +affected: + - src/CodeIndex/Cli/SolutionProjectResolver.cs + - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Automatic solution discovery now caps root `.sln` candidates before sorting (#3065)** — workspaces with more than 128 root-level solution files now get a clear `--solution ` diagnostic instead of materializing and sorting an unbounded candidate list. + +## 日本語 + +- **自動 solution 検出が root 直下の `.sln` candidate を sort 前に上限管理するようになりました (#3065)** — root 直下の solution file が 128 件を超える workspace では、無制限に materialize / sort せず、`--solution ` を促す明確な diagnostic を返します。 diff --git a/src/CodeIndex/Cli/SolutionProjectResolver.cs b/src/CodeIndex/Cli/SolutionProjectResolver.cs index d29feb3083..425e359798 100644 --- a/src/CodeIndex/Cli/SolutionProjectResolver.cs +++ b/src/CodeIndex/Cli/SolutionProjectResolver.cs @@ -4,6 +4,19 @@ namespace CodeIndex.Cli; internal sealed record DotNetProjectInfo(string Name, string ProjectPath, string DirectoryPath); +internal readonly record struct SolutionProjectResolverLimits(int MaxAutomaticSolutionCandidates) +{ + internal const int DefaultMaxAutomaticSolutionCandidates = 128; + + public static SolutionProjectResolverLimits Default { get; } = new(DefaultMaxAutomaticSolutionCandidates); + + public void Validate() + { + if (MaxAutomaticSolutionCandidates <= 0) + throw new ArgumentOutOfRangeException(nameof(MaxAutomaticSolutionCandidates), MaxAutomaticSolutionCandidates, "Limit must be positive."); + } +} + internal static class SolutionProjectResolver { internal const long MaxSolutionFileBytes = 8L * 1024 * 1024; @@ -11,18 +24,26 @@ internal static class SolutionProjectResolver internal const int MaxSolutionProjectReferences = 4096; public static IReadOnlyList ResolveProjects(string workspaceRoot, string? solutionPath = null) + => ResolveProjects(workspaceRoot, solutionPath, SolutionProjectResolverLimits.Default); + + internal static IReadOnlyList ResolveProjects( + string workspaceRoot, + string? solutionPath, + SolutionProjectResolverLimits limits) { + limits.Validate(); var root = Path.GetFullPath(workspaceRoot); var indexer = CreateIndexerWithWorkspacePolicy(root); - return ResolveProjects(root, solutionPath, indexer); + return ResolveProjects(root, solutionPath, indexer, limits); } private static IReadOnlyList ResolveProjects( string workspaceRoot, string? solutionPath, - FileIndexer indexer) + FileIndexer indexer, + SolutionProjectResolverLimits limits) { - var solution = ResolveSolutionPath(workspaceRoot, solutionPath); + var solution = ResolveSolutionPath(workspaceRoot, solutionPath, limits); if (solution != null) return ParseSolution(solution, workspaceRoot, indexer); @@ -42,7 +63,7 @@ public static IReadOnlyList ResolveProjectDirectoryGlobs( if (requestedProjects.Count == 0) return []; - var projects = ResolveProjects(workspaceRoot, solutionPath); + var projects = ResolveProjects(workspaceRoot, solutionPath, SolutionProjectResolverLimits.Default); var globs = new List(); foreach (var requested in requestedProjects) { @@ -69,7 +90,7 @@ public static IReadOnlyList ResolveProjectFiles( var root = Path.GetFullPath(workspaceRoot); var indexer = CreateIndexerWithWorkspacePolicy(root); - var projects = ResolveProjects(root, solutionPath, indexer); + var projects = ResolveProjects(root, solutionPath, indexer, SolutionProjectResolverLimits.Default); var files = new SortedSet(StringComparer.Ordinal); foreach (var requested in requestedProjects) { @@ -97,7 +118,10 @@ private static FileIndexer CreateIndexerWithWorkspacePolicy(string workspaceRoot return new FileIndexer(root, ignoreCase, ignoreRuleRoot); } - private static string? ResolveSolutionPath(string workspaceRoot, string? solutionPath) + private static string? ResolveSolutionPath( + string workspaceRoot, + string? solutionPath, + SolutionProjectResolverLimits limits) { if (!string.IsNullOrWhiteSpace(solutionPath)) { @@ -107,9 +131,19 @@ private static FileIndexer CreateIndexerWithWorkspacePolicy(string workspaceRoot return File.Exists(path) ? Path.GetFullPath(path) : throw new FileNotFoundException($"solution not found: {solutionPath}", path); } - var solutions = Directory.EnumerateFiles(workspaceRoot, "*.sln", SearchOption.TopDirectoryOnly) - .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) - .ToList(); + var solutions = new List(); + foreach (var solution in Directory.EnumerateFiles(workspaceRoot, "*.sln", SearchOption.TopDirectoryOnly)) + { + if (solutions.Count >= limits.MaxAutomaticSolutionCandidates) + { + throw new InvalidOperationException( + $"automatic solution discovery found more than {limits.MaxAutomaticSolutionCandidates} .sln files at {workspaceRoot}; pass --solution to select a solution explicitly."); + } + + solutions.Add(solution); + } + + solutions.Sort(StringComparer.OrdinalIgnoreCase); return solutions.Count == 1 ? solutions[0] : null; } diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 6c3251ebdf..2698ffde74 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -1216,6 +1216,29 @@ public void ResolveProjects_RejectsTooManySolutionProjectReferences_Issue3064() } } + [Fact] + public void ResolveProjects_RejectsTooManyAutomaticSolutionCandidates_Issue3065() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_solution_candidate_limit"); + try + { + File.WriteAllText(Path.Combine(projectRoot, "A.sln"), string.Empty); + File.WriteAllText(Path.Combine(projectRoot, "B.sln"), string.Empty); + File.WriteAllText(Path.Combine(projectRoot, "C.sln"), string.Empty); + var limits = SolutionProjectResolverLimits.Default with { MaxAutomaticSolutionCandidates = 2 }; + + var ex = Assert.Throws( + () => SolutionProjectResolver.ResolveProjects(projectRoot, solutionPath: null, limits)); + + Assert.Contains("automatic solution discovery found more than 2 .sln files", ex.Message); + Assert.Contains("pass --solution ", ex.Message); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void ResolveProjectFiles_HonorsGitRootIgnoreRulesForNestedWorkspace_Issue2862() { From 28f3ed7d8f90450846ba7b780b38cb25927bf80e Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 16:19:37 +0900 Subject: [PATCH 2/4] Fix solution fallback traversal errors (#3214) --- DEVELOPER_GUIDE.md | 4 +- changelog.d/unreleased/3214.fixed.md | 17 ++ src/CodeIndex/Cli/SolutionProjectResolver.cs | 217 +++++++++++++++--- .../IndexCommandRunnerTests.cs | 54 +++++ 4 files changed, 264 insertions(+), 28 deletions(-) create mode 100644 changelog.d/unreleased/3214.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 21d1ad16a7..dcd6096c75 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -213,7 +213,7 @@ Interactive terminal controls are allowed only when stdout is not redirected or ### C# / .NET integration -`SolutionProjectResolver` parses the plain-text `.sln` `Project(...) = "...", "...csproj"` entries with a non-regex parser and resolves C# / F# / VB project files. Project entries that normalize outside the active workspace root are ignored before filesystem probing or path-filter evaluation. Solution parsing rejects `.sln` files above 8 MiB, lines above 16,384 characters, and more than 4096 .NET project references with clear diagnostics. Automatic root-level `.sln` discovery samples at most 128 candidates before sorting and reports a clear error when that cap is exceeded, so callers should pass `--solution ` in solution-heavy workspaces. When exactly one `.sln` exists at the workspace root within that cap, `--project ` uses it automatically; otherwise callers can pass `--solution `. +`SolutionProjectResolver` parses the plain-text `.sln` `Project(...) = "...", "...csproj"` entries with a non-regex parser and resolves C# / F# / VB project files. Project entries that normalize outside the active workspace root are ignored before filesystem probing or path-filter evaluation. Solution parsing rejects `.sln` files above 8 MiB, lines above 16,384 characters, and more than 4096 .NET project references with clear diagnostics. Automatic root-level `.sln` discovery samples at most 128 candidates before sorting and reports a clear error when that cap is exceeded, so callers should pass `--solution ` in solution-heavy workspaces. When exactly one `.sln` exists at the workspace root within that cap, `--project ` uses it automatically; otherwise callers can pass `--solution `. Fallback project discovery and project-file expansion use long-path-safe per-directory enumeration, skip unreadable subtrees, and include bounded traversal diagnostics when a project filter cannot be resolved. Query commands that accept path filters (`search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `deps`, `impact`, `unused`, `hotspots`, and `validate`) expand `--project` into the matching project directory glob before hitting `DbReader`, so all existing SQL path predicates keep working. `index --project` expands to the files under the selected project directory and reuses the existing `--files` update path. @@ -2344,7 +2344,7 @@ override が文書化されていない限り ANSI/progress control を抑止す ### C# / .NET 連携 -`SolutionProjectResolver` は plain-text の `.sln` に含まれる `Project(...) = "...", "...csproj"` 行を non-regex parser で読み、C# / F# / VB の project file を解決する。active workspace root の外側へ正規化される project entry は、filesystem probe や path-filter 評価の前に無視する。solution parsing は 8 MiB を超える `.sln`、16,384 文字を超える行、4096 件を超える .NET project reference を明確な diagnostic とともに拒否する。root 直下の `.sln` 自動検出は sort 前に最大 128 candidates で打ち切り、その上限を超えた場合は明確な error を返すため、solution が多い workspace では `--solution ` を渡す。上限内で workspace root に `.sln` が 1 つだけある場合、`--project ` は自動でそれを使う。複数ある場合は caller が `--solution ` を渡せる。 +`SolutionProjectResolver` は plain-text の `.sln` に含まれる `Project(...) = "...", "...csproj"` 行を non-regex parser で読み、C# / F# / VB の project file を解決する。active workspace root の外側へ正規化される project entry は、filesystem probe や path-filter 評価の前に無視する。solution parsing は 8 MiB を超える `.sln`、16,384 文字を超える行、4096 件を超える .NET project reference を明確な diagnostic とともに拒否する。root 直下の `.sln` 自動検出は sort 前に最大 128 candidates で打ち切り、その上限を超えた場合は明確な error を返すため、solution が多い workspace では `--solution ` を渡す。上限内で workspace root に `.sln` が 1 つだけある場合、`--project ` は自動でそれを使う。複数ある場合は caller が `--solution ` を渡せる。fallback project discovery と project-file expansion は long-path-safe な per-directory 列挙を使い、読めない subtree を skip し、project filter を解決できない場合は bounded traversal diagnostics を含める。 path filter を受け付ける query コマンド(`search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `deps`, `impact`, `unused`, `hotspots`, `validate`)は、`--project` を対応する project directory glob に展開してから `DbReader` に渡す。これにより既存の SQL path predicate をそのまま利用できる。`index --project` は選択された project directory 配下のファイルに展開し、既存の `--files` 更新経路を再利用する。 diff --git a/changelog.d/unreleased/3214.fixed.md b/changelog.d/unreleased/3214.fixed.md new file mode 100644 index 0000000000..d333ca2b96 --- /dev/null +++ b/changelog.d/unreleased/3214.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3214 +affected: + - src/CodeIndex/Cli/SolutionProjectResolver.cs + - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Solution project fallback traversal now handles long paths and per-directory failures (#3214)** — project discovery and expansion use long-path-safe enumeration, skip unreadable subtrees, and report bounded traversal diagnostics when a project filter cannot be resolved. + +## 日本語 + +- **solution project fallback traversal が long path と directory 単位の失敗を扱うようになりました (#3214)** — project discovery / expansion は long-path-safe な列挙を使い、読めない subtree を skip し、project filter を解決できない場合は bounded traversal diagnostics を返します。 diff --git a/src/CodeIndex/Cli/SolutionProjectResolver.cs b/src/CodeIndex/Cli/SolutionProjectResolver.cs index 425e359798..2a448248bf 100644 --- a/src/CodeIndex/Cli/SolutionProjectResolver.cs +++ b/src/CodeIndex/Cli/SolutionProjectResolver.cs @@ -4,16 +4,23 @@ namespace CodeIndex.Cli; internal sealed record DotNetProjectInfo(string Name, string ProjectPath, string DirectoryPath); -internal readonly record struct SolutionProjectResolverLimits(int MaxAutomaticSolutionCandidates) +internal readonly record struct SolutionProjectResolverLimits( + int MaxAutomaticSolutionCandidates, + int MaxTraversalDiagnostics) { internal const int DefaultMaxAutomaticSolutionCandidates = 128; + internal const int DefaultMaxTraversalDiagnostics = 8; - public static SolutionProjectResolverLimits Default { get; } = new(DefaultMaxAutomaticSolutionCandidates); + public static SolutionProjectResolverLimits Default { get; } = new( + DefaultMaxAutomaticSolutionCandidates, + DefaultMaxTraversalDiagnostics); public void Validate() { if (MaxAutomaticSolutionCandidates <= 0) throw new ArgumentOutOfRangeException(nameof(MaxAutomaticSolutionCandidates), MaxAutomaticSolutionCandidates, "Limit must be positive."); + if (MaxTraversalDiagnostics <= 0) + throw new ArgumentOutOfRangeException(nameof(MaxTraversalDiagnostics), MaxTraversalDiagnostics, "Limit must be positive."); } } @@ -30,24 +37,32 @@ internal static IReadOnlyList ResolveProjects( string workspaceRoot, string? solutionPath, SolutionProjectResolverLimits limits) + => ResolveProjects(workspaceRoot, solutionPath, limits, traversalDiagnostics: null); + + internal static IReadOnlyList ResolveProjects( + string workspaceRoot, + string? solutionPath, + SolutionProjectResolverLimits limits, + IList? traversalDiagnostics) { limits.Validate(); var root = Path.GetFullPath(workspaceRoot); var indexer = CreateIndexerWithWorkspacePolicy(root); - return ResolveProjects(root, solutionPath, indexer, limits); + return ResolveProjects(root, solutionPath, indexer, limits, traversalDiagnostics); } private static IReadOnlyList ResolveProjects( string workspaceRoot, string? solutionPath, FileIndexer indexer, - SolutionProjectResolverLimits limits) + SolutionProjectResolverLimits limits, + IList? traversalDiagnostics) { var solution = ResolveSolutionPath(workspaceRoot, solutionPath, limits); if (solution != null) return ParseSolution(solution, workspaceRoot, indexer); - return EnumerateFilesUsingIndexerPolicy(workspaceRoot, workspaceRoot, indexer) + return EnumerateFilesUsingIndexerPolicy(workspaceRoot, workspaceRoot, indexer, limits, traversalDiagnostics) .Where(IsDotNetProjectFile) .Select(path => BuildProjectInfo(path, workspaceRoot)) .OrderBy(project => project.Name, StringComparer.OrdinalIgnoreCase) @@ -63,13 +78,18 @@ public static IReadOnlyList ResolveProjectDirectoryGlobs( if (requestedProjects.Count == 0) return []; - var projects = ResolveProjects(workspaceRoot, solutionPath, SolutionProjectResolverLimits.Default); + var traversalDiagnostics = new List(); + var projects = ResolveProjects(workspaceRoot, solutionPath, SolutionProjectResolverLimits.Default, traversalDiagnostics); var globs = new List(); foreach (var requested in requestedProjects) { var match = MatchProject(projects, requested); if (match == null) - throw new InvalidOperationException($"project not found in solution/workspace: {requested}"); + { + throw new InvalidOperationException(AppendTraversalDiagnostics( + $"project not found in solution/workspace: {requested}", + traversalDiagnostics)); + } var relativeDir = Path.GetRelativePath(Path.GetFullPath(workspaceRoot), match.DirectoryPath) .Replace(Path.DirectorySeparatorChar, '/') @@ -90,15 +110,20 @@ public static IReadOnlyList ResolveProjectFiles( var root = Path.GetFullPath(workspaceRoot); var indexer = CreateIndexerWithWorkspacePolicy(root); - var projects = ResolveProjects(root, solutionPath, indexer, SolutionProjectResolverLimits.Default); + var traversalDiagnostics = new List(); + var projects = ResolveProjects(root, solutionPath, indexer, SolutionProjectResolverLimits.Default, traversalDiagnostics); var files = new SortedSet(StringComparer.Ordinal); foreach (var requested in requestedProjects) { var match = MatchProject(projects, requested); if (match == null) - throw new InvalidOperationException($"project not found in solution/workspace: {requested}"); + { + throw new InvalidOperationException(AppendTraversalDiagnostics( + $"project not found in solution/workspace: {requested}", + traversalDiagnostics)); + } - foreach (var file in EnumerateFilesUsingIndexerPolicy(root, match.DirectoryPath, indexer)) + foreach (var file in EnumerateFilesUsingIndexerPolicy(root, match.DirectoryPath, indexer, SolutionProjectResolverLimits.Default, traversalDiagnostics)) { var relative = Path.GetRelativePath(root, file) .Replace(Path.DirectorySeparatorChar, '/') @@ -128,11 +153,11 @@ private static FileIndexer CreateIndexerWithWorkspacePolicy(string workspaceRoot var path = Path.IsPathRooted(solutionPath) ? solutionPath : Path.Combine(workspaceRoot, solutionPath); - return File.Exists(path) ? Path.GetFullPath(path) : throw new FileNotFoundException($"solution not found: {solutionPath}", path); + return File.Exists(LongPath.EnsureWindowsPrefix(path)) ? Path.GetFullPath(path) : throw new FileNotFoundException($"solution not found: {solutionPath}", path); } var solutions = new List(); - foreach (var solution in Directory.EnumerateFiles(workspaceRoot, "*.sln", SearchOption.TopDirectoryOnly)) + foreach (var solution in Directory.EnumerateFiles(LongPath.EnsureWindowsPrefix(workspaceRoot), "*.sln", SearchOption.TopDirectoryOnly)) { if (solutions.Count >= limits.MaxAutomaticSolutionCandidates) { @@ -140,7 +165,7 @@ private static FileIndexer CreateIndexerWithWorkspacePolicy(string workspaceRoot $"automatic solution discovery found more than {limits.MaxAutomaticSolutionCandidates} .sln files at {workspaceRoot}; pass --solution to select a solution explicitly."); } - solutions.Add(solution); + solutions.Add(LongPath.RemoveWindowsPrefix(solution)); } solutions.Sort(StringComparer.OrdinalIgnoreCase); @@ -158,7 +183,7 @@ private static IReadOnlyList ParseSolution( var projects = new List(); var lineNumber = 0; var projectReferenceCount = 0; - foreach (var line in File.ReadLines(solutionPath)) + foreach (var line in File.ReadLines(LongPath.EnsureWindowsPrefix(solutionPath))) { lineNumber++; if (line.Length > MaxSolutionLineChars) @@ -185,7 +210,7 @@ private static IReadOnlyList ParseSolution( if (!IsPathEqualOrParent(root, fullPath)) continue; - if (File.Exists(fullPath) && !indexer.EvaluatePathFilter(fullPath).ShouldSkip) + if (File.Exists(LongPath.EnsureWindowsPrefix(fullPath)) && !indexer.EvaluatePathFilter(fullPath).ShouldSkip) projects.Add(BuildProjectInfo(fullPath, root, name)); } @@ -218,13 +243,13 @@ private static DotNetProjectInfo BuildProjectInfo(string fullProjectPath, string private static IEnumerable EnumerateFilesUsingIndexerPolicy( string workspaceRoot, string startDirectory, - FileIndexer indexer) + FileIndexer indexer, + SolutionProjectResolverLimits limits, + IList? traversalDiagnostics) { var root = Path.GetFullPath(workspaceRoot); var start = Path.GetFullPath(startDirectory); - if (!IsPathEqualOrParent(root, start) || indexer.EvaluatePathFilter(start, isDirectory: true).ShouldSkip) - yield break; - if (!PathCasing.PathsEqual(root, start) && indexer.ShouldSkipDirectoryTraversal(start)) + if (!IsPathEqualOrParent(root, start) || ShouldSkipDirectoryForTraversal(root, start, indexer, limits, traversalDiagnostics)) yield break; var pending = new Stack(); @@ -232,22 +257,162 @@ private static IEnumerable EnumerateFilesUsingIndexerPolicy( while (pending.Count > 0) { var directory = pending.Pop(); - foreach (var childDirectory in Directory.EnumerateDirectories(directory)) + foreach (var childDirectory in EnumerateChildDirectories(root, directory, limits, traversalDiagnostics)) { - if (indexer.ShouldSkipDirectoryTraversal(childDirectory)) - continue; - if (!indexer.EvaluatePathFilter(childDirectory, isDirectory: true).ShouldSkip) + if (!ShouldSkipDirectoryForTraversal(root, childDirectory, indexer, limits, traversalDiagnostics)) pending.Push(childDirectory); } - foreach (var file in Directory.EnumerateFiles(directory)) + foreach (var file in EnumerateDirectoryFiles(root, directory, limits, traversalDiagnostics)) { - if (!indexer.EvaluatePathFilter(file).ShouldSkip) + if (ShouldIncludeFileForTraversal(root, file, indexer, limits, traversalDiagnostics)) yield return file; } } } + private static bool ShouldSkipDirectoryForTraversal( + string workspaceRoot, + string directory, + FileIndexer indexer, + SolutionProjectResolverLimits limits, + IList? traversalDiagnostics) + { + try + { + if (!PathCasing.PathsEqual(Path.GetFullPath(workspaceRoot), Path.GetFullPath(directory)) + && indexer.ShouldSkipDirectoryTraversal(directory)) + { + return true; + } + + return indexer.EvaluatePathFilter(directory, isDirectory: true).ShouldSkip; + } + catch (UnauthorizedAccessException) + { + AddTraversalDiagnostic(workspaceRoot, directory, "directory filters", "permissions", limits, traversalDiagnostics); + } + catch (IOException) + { + AddTraversalDiagnostic(workspaceRoot, directory, "directory filters", "an I/O error", limits, traversalDiagnostics); + } + + return true; + } + + private static bool ShouldIncludeFileForTraversal( + string workspaceRoot, + string file, + FileIndexer indexer, + SolutionProjectResolverLimits limits, + IList? traversalDiagnostics) + { + try + { + return !indexer.EvaluatePathFilter(file).ShouldSkip; + } + catch (UnauthorizedAccessException) + { + AddTraversalDiagnostic(workspaceRoot, file, "file filters", "permissions", limits, traversalDiagnostics); + } + catch (IOException) + { + AddTraversalDiagnostic(workspaceRoot, file, "file filters", "an I/O error", limits, traversalDiagnostics); + } + + return false; + } + + private static IReadOnlyList EnumerateChildDirectories( + string workspaceRoot, + string directory, + SolutionProjectResolverLimits limits, + IList? traversalDiagnostics) + => EnumerateDirectoryEntries( + workspaceRoot, + directory, + "subdirectories", + Directory.EnumerateDirectories, + limits, + traversalDiagnostics); + + private static IReadOnlyList EnumerateDirectoryFiles( + string workspaceRoot, + string directory, + SolutionProjectResolverLimits limits, + IList? traversalDiagnostics) + => EnumerateDirectoryEntries( + workspaceRoot, + directory, + "files", + Directory.EnumerateFiles, + limits, + traversalDiagnostics); + + private static IReadOnlyList EnumerateDirectoryEntries( + string workspaceRoot, + string directory, + string entryKind, + Func> enumerate, + SolutionProjectResolverLimits limits, + IList? traversalDiagnostics) + { + try + { + return enumerate(LongPath.EnsureWindowsPrefix(directory)) + .Select(LongPath.RemoveWindowsPrefix) + .ToList(); + } + catch (UnauthorizedAccessException) + { + AddTraversalDiagnostic(workspaceRoot, directory, entryKind, "permissions", limits, traversalDiagnostics); + } + catch (IOException) + { + AddTraversalDiagnostic(workspaceRoot, directory, entryKind, "an I/O error", limits, traversalDiagnostics); + } + + return []; + } + + private static void AddTraversalDiagnostic( + string workspaceRoot, + string directory, + string entryKind, + string reason, + SolutionProjectResolverLimits limits, + IList? traversalDiagnostics) + { + if (traversalDiagnostics == null) + return; + + if (traversalDiagnostics.Count < limits.MaxTraversalDiagnostics) + { + traversalDiagnostics.Add( + $"Could not enumerate {entryKind} in {FormatRelativePathForDiagnostic(workspaceRoot, directory)} due to {reason}."); + } + else if (traversalDiagnostics.Count == limits.MaxTraversalDiagnostics) + { + traversalDiagnostics.Add($"Additional traversal diagnostics omitted after {limits.MaxTraversalDiagnostics} entries."); + } + } + + private static string AppendTraversalDiagnostics(string message, IReadOnlyList traversalDiagnostics) + { + if (traversalDiagnostics.Count == 0) + return message; + + return $"{message}. Traversal diagnostics: {string.Join(" ", traversalDiagnostics)}"; + } + + private static string FormatRelativePathForDiagnostic(string workspaceRoot, string path) + { + var relative = Path.GetRelativePath(Path.GetFullPath(workspaceRoot), Path.GetFullPath(path)) + .Replace(Path.DirectorySeparatorChar, '/') + .Replace(Path.AltDirectorySeparatorChar, '/'); + return relative == "." ? "." : relative; + } + private static bool IsPathEqualOrParent(string parentPath, string childPath) { var parent = Path.GetFullPath(parentPath) @@ -268,7 +433,7 @@ private static bool IsDotNetProjectFile(string path) private static void RejectOversizedSolutionFile(string solutionPath) { - var length = new FileInfo(solutionPath).Length; + var length = new FileInfo(LongPath.EnsureWindowsPrefix(solutionPath)).Length; if (length > MaxSolutionFileBytes) { throw new InvalidOperationException( diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 2698ffde74..48b41d4704 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -1115,6 +1115,60 @@ public void ResolveProjects_SkipsIgnoredAndDefaultExcludedProjectDirectories_Iss } } + [Fact] + public void ResolveProjects_SkipsFallbackTraversalDirectoryErrors_Issue3214() + { + if (OperatingSystem.IsWindows()) + return; + + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_solution_fallback_directory_error"); + var lockedDirectory = Path.Combine(projectRoot, "locked"); + var restoreLockedDirectory = false; + try + { + Directory.CreateDirectory(Path.Combine(projectRoot, "src", "App")); + Directory.CreateDirectory(lockedDirectory); + File.WriteAllText(Path.Combine(projectRoot, "src", "App", "App.csproj"), ""); + + try + { + File.SetUnixFileMode(lockedDirectory, UnixFileMode.None); + restoreLockedDirectory = true; + _ = Directory.EnumerateFiles(lockedDirectory).ToList(); + return; + } + catch (Exception permissionEx) when (permissionEx is UnauthorizedAccessException or IOException) + { + } + catch (PlatformNotSupportedException) + { + return; + } + + var diagnostics = new List(); + var projects = SolutionProjectResolver.ResolveProjects( + projectRoot, + solutionPath: null, + SolutionProjectResolverLimits.Default, + diagnostics); + + Assert.Contains(projects, project => project.ProjectPath == "src/App/App.csproj"); + Assert.Contains(diagnostics, diagnostic => diagnostic.Contains("locked", StringComparison.Ordinal) + && diagnostic.Contains("permissions", StringComparison.Ordinal)); + + var ex = Assert.Throws( + () => SolutionProjectResolver.ResolveProjectFiles(projectRoot, ["Missing"])); + Assert.Contains("Traversal diagnostics:", ex.Message); + Assert.Contains("locked", ex.Message); + } + finally + { + if (restoreLockedDirectory) + File.SetUnixFileMode(lockedDirectory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void ResolveProjects_SkipsSolutionProjectsOutsideWorkspaceRoot_Issue3063() { From c48bcbf622cbb0023794639e85a52f9398dfd642 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 16:22:49 +0900 Subject: [PATCH 3/4] Cap fallback project discovery traversal (#3213) --- DEVELOPER_GUIDE.md | 4 +- changelog.d/unreleased/3213.fixed.md | 17 +++ src/CodeIndex/Cli/SolutionProjectResolver.cs | 124 ++++++++++++++++-- .../IndexCommandRunnerTests.cs | 43 ++++++ 4 files changed, 175 insertions(+), 13 deletions(-) create mode 100644 changelog.d/unreleased/3213.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index dcd6096c75..5163376fb8 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -213,7 +213,7 @@ Interactive terminal controls are allowed only when stdout is not redirected or ### C# / .NET integration -`SolutionProjectResolver` parses the plain-text `.sln` `Project(...) = "...", "...csproj"` entries with a non-regex parser and resolves C# / F# / VB project files. Project entries that normalize outside the active workspace root are ignored before filesystem probing or path-filter evaluation. Solution parsing rejects `.sln` files above 8 MiB, lines above 16,384 characters, and more than 4096 .NET project references with clear diagnostics. Automatic root-level `.sln` discovery samples at most 128 candidates before sorting and reports a clear error when that cap is exceeded, so callers should pass `--solution ` in solution-heavy workspaces. When exactly one `.sln` exists at the workspace root within that cap, `--project ` uses it automatically; otherwise callers can pass `--solution `. Fallback project discovery and project-file expansion use long-path-safe per-directory enumeration, skip unreadable subtrees, and include bounded traversal diagnostics when a project filter cannot be resolved. +`SolutionProjectResolver` parses the plain-text `.sln` `Project(...) = "...", "...csproj"` entries with a non-regex parser and resolves C# / F# / VB project files. Project entries that normalize outside the active workspace root are ignored before filesystem probing or path-filter evaluation. Solution parsing rejects `.sln` files above 8 MiB, lines above 16,384 characters, and more than 4096 .NET project references with clear diagnostics. Automatic root-level `.sln` discovery samples at most 128 candidates before sorting and reports a clear error when that cap is exceeded, so callers should pass `--solution ` in solution-heavy workspaces. When exactly one `.sln` exists at the workspace root within that cap, `--project ` uses it automatically; otherwise callers can pass `--solution `. Fallback project discovery caps traversal at 4096 directories and 65,536 files with a clear `--solution ` recovery hint. Fallback project discovery and project-file expansion use long-path-safe per-directory enumeration, skip unreadable subtrees, and include bounded traversal diagnostics when a project filter cannot be resolved. Query commands that accept path filters (`search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `deps`, `impact`, `unused`, `hotspots`, and `validate`) expand `--project` into the matching project directory glob before hitting `DbReader`, so all existing SQL path predicates keep working. `index --project` expands to the files under the selected project directory and reuses the existing `--files` update path. @@ -2344,7 +2344,7 @@ override が文書化されていない限り ANSI/progress control を抑止す ### C# / .NET 連携 -`SolutionProjectResolver` は plain-text の `.sln` に含まれる `Project(...) = "...", "...csproj"` 行を non-regex parser で読み、C# / F# / VB の project file を解決する。active workspace root の外側へ正規化される project entry は、filesystem probe や path-filter 評価の前に無視する。solution parsing は 8 MiB を超える `.sln`、16,384 文字を超える行、4096 件を超える .NET project reference を明確な diagnostic とともに拒否する。root 直下の `.sln` 自動検出は sort 前に最大 128 candidates で打ち切り、その上限を超えた場合は明確な error を返すため、solution が多い workspace では `--solution ` を渡す。上限内で workspace root に `.sln` が 1 つだけある場合、`--project ` は自動でそれを使う。複数ある場合は caller が `--solution ` を渡せる。fallback project discovery と project-file expansion は long-path-safe な per-directory 列挙を使い、読めない subtree を skip し、project filter を解決できない場合は bounded traversal diagnostics を含める。 +`SolutionProjectResolver` は plain-text の `.sln` に含まれる `Project(...) = "...", "...csproj"` 行を non-regex parser で読み、C# / F# / VB の project file を解決する。active workspace root の外側へ正規化される project entry は、filesystem probe や path-filter 評価の前に無視する。solution parsing は 8 MiB を超える `.sln`、16,384 文字を超える行、4096 件を超える .NET project reference を明確な diagnostic とともに拒否する。root 直下の `.sln` 自動検出は sort 前に最大 128 candidates で打ち切り、その上限を超えた場合は明確な error を返すため、solution が多い workspace では `--solution ` を渡す。上限内で workspace root に `.sln` が 1 つだけある場合、`--project ` は自動でそれを使う。複数ある場合は caller が `--solution ` を渡せる。fallback project discovery は 4096 directories / 65,536 files で traversal を打ち切り、`--solution ` を示す明確な recovery hint を返す。fallback project discovery と project-file expansion は long-path-safe な per-directory 列挙を使い、読めない subtree を skip し、project filter を解決できない場合は bounded traversal diagnostics を含める。 path filter を受け付ける query コマンド(`search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `deps`, `impact`, `unused`, `hotspots`, `validate`)は、`--project` を対応する project directory glob に展開してから `DbReader` に渡す。これにより既存の SQL path predicate をそのまま利用できる。`index --project` は選択された project directory 配下のファイルに展開し、既存の `--files` 更新経路を再利用する。 diff --git a/changelog.d/unreleased/3213.fixed.md b/changelog.d/unreleased/3213.fixed.md new file mode 100644 index 0000000000..9b118be167 --- /dev/null +++ b/changelog.d/unreleased/3213.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3213 +affected: + - src/CodeIndex/Cli/SolutionProjectResolver.cs + - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **SolutionProjectResolver fallback discovery now caps traversal work (#3213)** — fallback project discovery stops after 4096 directories or 65,536 files and reports a clear `--solution ` recovery hint instead of walking unbounded workspace trees. + +## 日本語 + +- **SolutionProjectResolver の fallback discovery が traversal 作業を上限管理するようになりました (#3213)** — fallback project discovery は 4096 directories または 65,536 files で停止し、workspace tree を無制限に走査せず `--solution ` を示す明確な recovery hint を返します。 diff --git a/src/CodeIndex/Cli/SolutionProjectResolver.cs b/src/CodeIndex/Cli/SolutionProjectResolver.cs index 2a448248bf..0b13ba8f24 100644 --- a/src/CodeIndex/Cli/SolutionProjectResolver.cs +++ b/src/CodeIndex/Cli/SolutionProjectResolver.cs @@ -6,19 +6,29 @@ internal sealed record DotNetProjectInfo(string Name, string ProjectPath, string internal readonly record struct SolutionProjectResolverLimits( int MaxAutomaticSolutionCandidates, + int MaxFallbackDiscoveryDirectories, + int MaxFallbackDiscoveryFiles, int MaxTraversalDiagnostics) { internal const int DefaultMaxAutomaticSolutionCandidates = 128; + internal const int DefaultMaxFallbackDiscoveryDirectories = 4096; + internal const int DefaultMaxFallbackDiscoveryFiles = 65536; internal const int DefaultMaxTraversalDiagnostics = 8; public static SolutionProjectResolverLimits Default { get; } = new( DefaultMaxAutomaticSolutionCandidates, + DefaultMaxFallbackDiscoveryDirectories, + DefaultMaxFallbackDiscoveryFiles, DefaultMaxTraversalDiagnostics); public void Validate() { if (MaxAutomaticSolutionCandidates <= 0) throw new ArgumentOutOfRangeException(nameof(MaxAutomaticSolutionCandidates), MaxAutomaticSolutionCandidates, "Limit must be positive."); + if (MaxFallbackDiscoveryDirectories <= 0) + throw new ArgumentOutOfRangeException(nameof(MaxFallbackDiscoveryDirectories), MaxFallbackDiscoveryDirectories, "Limit must be positive."); + if (MaxFallbackDiscoveryFiles <= 0) + throw new ArgumentOutOfRangeException(nameof(MaxFallbackDiscoveryFiles), MaxFallbackDiscoveryFiles, "Limit must be positive."); if (MaxTraversalDiagnostics <= 0) throw new ArgumentOutOfRangeException(nameof(MaxTraversalDiagnostics), MaxTraversalDiagnostics, "Limit must be positive."); } @@ -62,7 +72,8 @@ private static IReadOnlyList ResolveProjects( if (solution != null) return ParseSolution(solution, workspaceRoot, indexer); - return EnumerateFilesUsingIndexerPolicy(workspaceRoot, workspaceRoot, indexer, limits, traversalDiagnostics) + var budget = ProjectTraversalBudget.ForFallbackDiscovery(limits); + return EnumerateFilesUsingIndexerPolicy(workspaceRoot, workspaceRoot, indexer, limits, budget, traversalDiagnostics) .Where(IsDotNetProjectFile) .Select(path => BuildProjectInfo(path, workspaceRoot)) .OrderBy(project => project.Name, StringComparer.OrdinalIgnoreCase) @@ -123,7 +134,7 @@ public static IReadOnlyList ResolveProjectFiles( traversalDiagnostics)); } - foreach (var file in EnumerateFilesUsingIndexerPolicy(root, match.DirectoryPath, indexer, SolutionProjectResolverLimits.Default, traversalDiagnostics)) + foreach (var file in EnumerateFilesUsingIndexerPolicy(root, match.DirectoryPath, indexer, SolutionProjectResolverLimits.Default, budget: null, traversalDiagnostics)) { var relative = Path.GetRelativePath(root, file) .Replace(Path.DirectorySeparatorChar, '/') @@ -245,6 +256,7 @@ private static IEnumerable EnumerateFilesUsingIndexerPolicy( string startDirectory, FileIndexer indexer, SolutionProjectResolverLimits limits, + ProjectTraversalBudget? budget, IList? traversalDiagnostics) { var root = Path.GetFullPath(workspaceRoot); @@ -253,17 +265,18 @@ private static IEnumerable EnumerateFilesUsingIndexerPolicy( yield break; var pending = new Stack(); + budget?.RecordDirectory(root, start); pending.Push(start); while (pending.Count > 0) { var directory = pending.Pop(); - foreach (var childDirectory in EnumerateChildDirectories(root, directory, limits, traversalDiagnostics)) + foreach (var childDirectory in EnumerateChildDirectories(root, directory, limits, budget, traversalDiagnostics)) { if (!ShouldSkipDirectoryForTraversal(root, childDirectory, indexer, limits, traversalDiagnostics)) pending.Push(childDirectory); } - foreach (var file in EnumerateDirectoryFiles(root, directory, limits, traversalDiagnostics)) + foreach (var file in EnumerateDirectoryFiles(root, directory, limits, budget, traversalDiagnostics)) { if (ShouldIncludeFileForTraversal(root, file, indexer, limits, traversalDiagnostics)) yield return file; @@ -323,56 +336,145 @@ private static bool ShouldIncludeFileForTraversal( return false; } - private static IReadOnlyList EnumerateChildDirectories( + private static IEnumerable EnumerateChildDirectories( string workspaceRoot, string directory, SolutionProjectResolverLimits limits, + ProjectTraversalBudget? budget, IList? traversalDiagnostics) => EnumerateDirectoryEntries( workspaceRoot, directory, "subdirectories", + ProjectTraversalEntryKind.Directory, Directory.EnumerateDirectories, limits, + budget, traversalDiagnostics); - private static IReadOnlyList EnumerateDirectoryFiles( + private static IEnumerable EnumerateDirectoryFiles( string workspaceRoot, string directory, SolutionProjectResolverLimits limits, + ProjectTraversalBudget? budget, IList? traversalDiagnostics) => EnumerateDirectoryEntries( workspaceRoot, directory, "files", + ProjectTraversalEntryKind.File, Directory.EnumerateFiles, limits, + budget, traversalDiagnostics); - private static IReadOnlyList EnumerateDirectoryEntries( + private static IEnumerable EnumerateDirectoryEntries( string workspaceRoot, string directory, string entryKind, + ProjectTraversalEntryKind budgetKind, Func> enumerate, SolutionProjectResolverLimits limits, + ProjectTraversalBudget? budget, IList? traversalDiagnostics) { + IEnumerable entries; try { - return enumerate(LongPath.EnsureWindowsPrefix(directory)) - .Select(LongPath.RemoveWindowsPrefix) - .ToList(); + entries = enumerate(LongPath.EnsureWindowsPrefix(directory)); } catch (UnauthorizedAccessException) { AddTraversalDiagnostic(workspaceRoot, directory, entryKind, "permissions", limits, traversalDiagnostics); + yield break; } catch (IOException) { AddTraversalDiagnostic(workspaceRoot, directory, entryKind, "an I/O error", limits, traversalDiagnostics); + yield break; } - return []; + using var enumerator = entries.GetEnumerator(); + while (true) + { + string entry; + try + { + if (!enumerator.MoveNext()) + yield break; + entry = LongPath.RemoveWindowsPrefix(enumerator.Current); + } + catch (UnauthorizedAccessException) + { + AddTraversalDiagnostic(workspaceRoot, directory, entryKind, "permissions", limits, traversalDiagnostics); + yield break; + } + catch (IOException) + { + AddTraversalDiagnostic(workspaceRoot, directory, entryKind, "an I/O error", limits, traversalDiagnostics); + yield break; + } + + budget?.RecordEntry(workspaceRoot, entry, budgetKind); + yield return entry; + } + } + + private enum ProjectTraversalEntryKind + { + Directory, + File, + } + + private sealed class ProjectTraversalBudget + { + private readonly int _maxDirectories; + private readonly int _maxFiles; + private readonly string _context; + private readonly string _recoveryHint; + private int _directoriesTraversed; + private int _filesTraversed; + + private ProjectTraversalBudget(int maxDirectories, int maxFiles, string context, string recoveryHint) + { + _maxDirectories = maxDirectories; + _maxFiles = maxFiles; + _context = context; + _recoveryHint = recoveryHint; + } + + public static ProjectTraversalBudget ForFallbackDiscovery(SolutionProjectResolverLimits limits) + => new( + limits.MaxFallbackDiscoveryDirectories, + limits.MaxFallbackDiscoveryFiles, + "fallback project discovery", + "pass --solution to avoid fallback workspace discovery"); + + public void RecordDirectory(string workspaceRoot, string directory) + { + _directoriesTraversed++; + if (_directoriesTraversed > _maxDirectories) + ThrowExceeded(workspaceRoot, directory, "directories", _maxDirectories); + } + + public void RecordEntry(string workspaceRoot, string path, ProjectTraversalEntryKind kind) + { + if (kind == ProjectTraversalEntryKind.Directory) + { + RecordDirectory(workspaceRoot, path); + return; + } + + _filesTraversed++; + if (_filesTraversed > _maxFiles) + ThrowExceeded(workspaceRoot, path, "files", _maxFiles); + } + + private void ThrowExceeded(string workspaceRoot, string path, string unit, int limit) + { + throw new InvalidOperationException( + $"{_context} traversed more than {limit} {unit} under {workspaceRoot}; last path: {FormatRelativePathForDiagnostic(workspaceRoot, path)}; {_recoveryHint}."); + } } private static void AddTraversalDiagnostic( diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 48b41d4704..49c529ce7b 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -1293,6 +1293,49 @@ public void ResolveProjects_RejectsTooManyAutomaticSolutionCandidates_Issue3065( } } + [Fact] + public void ResolveProjects_RejectsFallbackDiscoveryDirectoryTraversalLimit_Issue3213() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_solution_fallback_directory_limit"); + try + { + Directory.CreateDirectory(Path.Combine(projectRoot, "src")); + var limits = SolutionProjectResolverLimits.Default with { MaxFallbackDiscoveryDirectories = 1 }; + + var ex = Assert.Throws( + () => SolutionProjectResolver.ResolveProjects(projectRoot, solutionPath: null, limits)); + + Assert.Contains("fallback project discovery traversed more than 1 directories", ex.Message); + Assert.Contains("pass --solution ", ex.Message); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void ResolveProjects_RejectsFallbackDiscoveryFileTraversalLimit_Issue3213() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_solution_fallback_file_limit"); + try + { + File.WriteAllText(Path.Combine(projectRoot, "A.txt"), "a"); + File.WriteAllText(Path.Combine(projectRoot, "B.txt"), "b"); + var limits = SolutionProjectResolverLimits.Default with { MaxFallbackDiscoveryFiles = 1 }; + + var ex = Assert.Throws( + () => SolutionProjectResolver.ResolveProjects(projectRoot, solutionPath: null, limits)); + + Assert.Contains("fallback project discovery traversed more than 1 files", ex.Message); + Assert.Contains("pass --solution ", ex.Message); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void ResolveProjectFiles_HonorsGitRootIgnoreRulesForNestedWorkspace_Issue2862() { From f5fb6a6543af49b21acebb1ba8d068df55d0de23 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 16:25:41 +0900 Subject: [PATCH 4/4] Cap project filter expansion targets (#3066) --- DEVELOPER_GUIDE.md | 4 +- changelog.d/unreleased/3066.fixed.md | 17 +++++ src/CodeIndex/Cli/SolutionProjectResolver.cs | 50 ++++++++++++++- .../IndexCommandRunnerTests.cs | 64 +++++++++++++++++++ 4 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 changelog.d/unreleased/3066.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 5163376fb8..5f528990c2 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -215,7 +215,7 @@ Interactive terminal controls are allowed only when stdout is not redirected or `SolutionProjectResolver` parses the plain-text `.sln` `Project(...) = "...", "...csproj"` entries with a non-regex parser and resolves C# / F# / VB project files. Project entries that normalize outside the active workspace root are ignored before filesystem probing or path-filter evaluation. Solution parsing rejects `.sln` files above 8 MiB, lines above 16,384 characters, and more than 4096 .NET project references with clear diagnostics. Automatic root-level `.sln` discovery samples at most 128 candidates before sorting and reports a clear error when that cap is exceeded, so callers should pass `--solution ` in solution-heavy workspaces. When exactly one `.sln` exists at the workspace root within that cap, `--project ` uses it automatically; otherwise callers can pass `--solution `. Fallback project discovery caps traversal at 4096 directories and 65,536 files with a clear `--solution ` recovery hint. Fallback project discovery and project-file expansion use long-path-safe per-directory enumeration, skip unreadable subtrees, and include bounded traversal diagnostics when a project filter cannot be resolved. -Query commands that accept path filters (`search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `deps`, `impact`, `unused`, `hotspots`, and `validate`) expand `--project` into the matching project directory glob before hitting `DbReader`, so all existing SQL path predicates keep working. `index --project` expands to the files under the selected project directory and reuses the existing `--files` update path. +Query commands that accept path filters (`search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `deps`, `impact`, `unused`, `hotspots`, and `validate`) expand `--project` into the matching project directory glob before hitting `DbReader`, so all existing SQL path predicates keep working. `index --project` expands to the files under the selected project directory and reuses the existing `--files` update path, but rejects expansions above 65,536 files for one project or 131,072 unique files across all requested projects with an explicit-files recovery hint. `cdidx batch` is a CLI-side query loop for editor integrations and scripts that need several query commands against the same DB without spawning `cdidx` repeatedly. It opens one `DbContext` / `DbReader`, reads newline-delimited JSON string arrays from stdin, and dispatches only query commands through the existing `QueryCommandRunner` paths so output and validation stay identical to the standalone command shape. @@ -2346,7 +2346,7 @@ override が文書化されていない限り ANSI/progress control を抑止す `SolutionProjectResolver` は plain-text の `.sln` に含まれる `Project(...) = "...", "...csproj"` 行を non-regex parser で読み、C# / F# / VB の project file を解決する。active workspace root の外側へ正規化される project entry は、filesystem probe や path-filter 評価の前に無視する。solution parsing は 8 MiB を超える `.sln`、16,384 文字を超える行、4096 件を超える .NET project reference を明確な diagnostic とともに拒否する。root 直下の `.sln` 自動検出は sort 前に最大 128 candidates で打ち切り、その上限を超えた場合は明確な error を返すため、solution が多い workspace では `--solution ` を渡す。上限内で workspace root に `.sln` が 1 つだけある場合、`--project ` は自動でそれを使う。複数ある場合は caller が `--solution ` を渡せる。fallback project discovery は 4096 directories / 65,536 files で traversal を打ち切り、`--solution ` を示す明確な recovery hint を返す。fallback project discovery と project-file expansion は long-path-safe な per-directory 列挙を使い、読めない subtree を skip し、project filter を解決できない場合は bounded traversal diagnostics を含める。 -path filter を受け付ける query コマンド(`search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `deps`, `impact`, `unused`, `hotspots`, `validate`)は、`--project` を対応する project directory glob に展開してから `DbReader` に渡す。これにより既存の SQL path predicate をそのまま利用できる。`index --project` は選択された project directory 配下のファイルに展開し、既存の `--files` 更新経路を再利用する。 +path filter を受け付ける query コマンド(`search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `deps`, `impact`, `unused`, `hotspots`, `validate`)は、`--project` を対応する project directory glob に展開してから `DbReader` に渡す。これにより既存の SQL path predicate をそのまま利用できる。`index --project` は選択された project directory 配下のファイルに展開し、既存の `--files` 更新経路を再利用する。ただし 1 project で 65,536 files、requested projects 全体で 131,072 unique files を超える展開は拒否し、明示的な `--files` を使う recovery hint を返す。 `cdidx batch` は、同じ DB に複数の query command を投げる editor integration や script 向けの CLI 側 query loop である。1 つの `DbContext` / `DbReader` を開き、stdin から newline-delimited JSON 文字列配列を読み、query command だけを既存の `QueryCommandRunner` 経路へ dispatch するため、出力と validation は単発コマンドと同じ形を保つ。 diff --git a/changelog.d/unreleased/3066.fixed.md b/changelog.d/unreleased/3066.fixed.md new file mode 100644 index 0000000000..7f0bc5ae9d --- /dev/null +++ b/changelog.d/unreleased/3066.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3066 +affected: + - src/CodeIndex/Cli/SolutionProjectResolver.cs + - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **`index --project` now caps materialized project-file targets (#3066)** — project expansion rejects more than 65,536 files for one project or 131,072 unique files across requested projects and points users to explicit `--files` input. + +## 日本語 + +- **`index --project` が materialize する project-file targets を上限管理するようになりました (#3066)** — project expansion は 1 project で 65,536 files、requested projects 全体で 131,072 unique files を超える場合に拒否し、明示的な `--files` 入力を案内します。 diff --git a/src/CodeIndex/Cli/SolutionProjectResolver.cs b/src/CodeIndex/Cli/SolutionProjectResolver.cs index 0b13ba8f24..cd974b4385 100644 --- a/src/CodeIndex/Cli/SolutionProjectResolver.cs +++ b/src/CodeIndex/Cli/SolutionProjectResolver.cs @@ -8,17 +8,23 @@ internal readonly record struct SolutionProjectResolverLimits( int MaxAutomaticSolutionCandidates, int MaxFallbackDiscoveryDirectories, int MaxFallbackDiscoveryFiles, + int MaxProjectExpansionFilesPerProject, + int MaxProjectExpansionFilesTotal, int MaxTraversalDiagnostics) { internal const int DefaultMaxAutomaticSolutionCandidates = 128; internal const int DefaultMaxFallbackDiscoveryDirectories = 4096; internal const int DefaultMaxFallbackDiscoveryFiles = 65536; + internal const int DefaultMaxProjectExpansionFilesPerProject = 65536; + internal const int DefaultMaxProjectExpansionFilesTotal = 131072; internal const int DefaultMaxTraversalDiagnostics = 8; public static SolutionProjectResolverLimits Default { get; } = new( DefaultMaxAutomaticSolutionCandidates, DefaultMaxFallbackDiscoveryDirectories, DefaultMaxFallbackDiscoveryFiles, + DefaultMaxProjectExpansionFilesPerProject, + DefaultMaxProjectExpansionFilesTotal, DefaultMaxTraversalDiagnostics); public void Validate() @@ -29,6 +35,10 @@ public void Validate() throw new ArgumentOutOfRangeException(nameof(MaxFallbackDiscoveryDirectories), MaxFallbackDiscoveryDirectories, "Limit must be positive."); if (MaxFallbackDiscoveryFiles <= 0) throw new ArgumentOutOfRangeException(nameof(MaxFallbackDiscoveryFiles), MaxFallbackDiscoveryFiles, "Limit must be positive."); + if (MaxProjectExpansionFilesPerProject <= 0) + throw new ArgumentOutOfRangeException(nameof(MaxProjectExpansionFilesPerProject), MaxProjectExpansionFilesPerProject, "Limit must be positive."); + if (MaxProjectExpansionFilesTotal <= 0) + throw new ArgumentOutOfRangeException(nameof(MaxProjectExpansionFilesTotal), MaxProjectExpansionFilesTotal, "Limit must be positive."); if (MaxTraversalDiagnostics <= 0) throw new ArgumentOutOfRangeException(nameof(MaxTraversalDiagnostics), MaxTraversalDiagnostics, "Limit must be positive."); } @@ -115,15 +125,24 @@ public static IReadOnlyList ResolveProjectFiles( string workspaceRoot, IReadOnlyList requestedProjects, string? solutionPath = null) + => ResolveProjectFiles(workspaceRoot, requestedProjects, solutionPath, SolutionProjectResolverLimits.Default); + + internal static IReadOnlyList ResolveProjectFiles( + string workspaceRoot, + IReadOnlyList requestedProjects, + string? solutionPath, + SolutionProjectResolverLimits limits) { if (requestedProjects.Count == 0) return []; + limits.Validate(); var root = Path.GetFullPath(workspaceRoot); var indexer = CreateIndexerWithWorkspacePolicy(root); var traversalDiagnostics = new List(); - var projects = ResolveProjects(root, solutionPath, indexer, SolutionProjectResolverLimits.Default, traversalDiagnostics); + var projects = ResolveProjects(root, solutionPath, indexer, limits, traversalDiagnostics); var files = new SortedSet(StringComparer.Ordinal); + var totalExpandedFiles = 0; foreach (var requested in requestedProjects) { var match = MatchProject(projects, requested); @@ -134,18 +153,43 @@ public static IReadOnlyList ResolveProjectFiles( traversalDiagnostics)); } - foreach (var file in EnumerateFilesUsingIndexerPolicy(root, match.DirectoryPath, indexer, SolutionProjectResolverLimits.Default, budget: null, traversalDiagnostics)) + var projectExpandedFiles = 0; + foreach (var file in EnumerateFilesUsingIndexerPolicy(root, match.DirectoryPath, indexer, limits, budget: null, traversalDiagnostics)) { var relative = Path.GetRelativePath(root, file) .Replace(Path.DirectorySeparatorChar, '/') .Replace(Path.AltDirectorySeparatorChar, '/'); - files.Add(relative); + projectExpandedFiles++; + if (projectExpandedFiles > limits.MaxProjectExpansionFilesPerProject) + ThrowProjectExpansionPerProjectLimitExceeded(limits, requested, match); + + if (files.Add(relative)) + { + totalExpandedFiles++; + if (totalExpandedFiles > limits.MaxProjectExpansionFilesTotal) + ThrowProjectExpansionTotalLimitExceeded(limits); + } } } return files.ToList(); } + private static void ThrowProjectExpansionPerProjectLimitExceeded( + SolutionProjectResolverLimits limits, + string requested, + DotNetProjectInfo match) + { + throw new InvalidOperationException( + $"project filter expansion for {requested} ({match.ProjectPath}) materialized more than {limits.MaxProjectExpansionFilesPerProject} files; narrow --project/--solution or pass explicit --files."); + } + + private static void ThrowProjectExpansionTotalLimitExceeded(SolutionProjectResolverLimits limits) + { + throw new InvalidOperationException( + $"project filter expansion materialized more than {limits.MaxProjectExpansionFilesTotal} unique files across requested projects; narrow --project/--solution or pass explicit --files."); + } + private static FileIndexer CreateIndexerWithWorkspacePolicy(string workspaceRoot) { var root = Path.GetFullPath(workspaceRoot); diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 49c529ce7b..46acc8ea1b 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -1367,6 +1367,70 @@ public void ResolveProjectFiles_HonorsGitRootIgnoreRulesForNestedWorkspace_Issue } } + [Fact] + public void ResolveProjectFiles_RejectsPerProjectExpansionFileLimit_Issue3066() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_index_project_filter_per_project_limit"); + try + { + var libDir = Path.Combine(projectRoot, "src", "Lib"); + Directory.CreateDirectory(libDir); + File.WriteAllText(Path.Combine(projectRoot, "Repo.sln"), """ + Microsoft Visual Studio Solution File, Format Version 12.00 + Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lib", "src\Lib\Lib.csproj", "{11111111-1111-1111-1111-111111111111}" + EndProject + """); + File.WriteAllText(Path.Combine(libDir, "Lib.csproj"), ""); + File.WriteAllText(Path.Combine(libDir, "Class1.cs"), "class Class1 {}"); + var limits = SolutionProjectResolverLimits.Default with { MaxProjectExpansionFilesPerProject = 1 }; + + var ex = Assert.Throws( + () => SolutionProjectResolver.ResolveProjectFiles(projectRoot, ["Lib"], "Repo.sln", limits)); + + Assert.Contains("project filter expansion for Lib (src/Lib/Lib.csproj) materialized more than 1 files", ex.Message); + Assert.Contains("explicit --files", ex.Message); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void ResolveProjectFiles_RejectsTotalExpansionFileLimit_Issue3066() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_index_project_filter_total_limit"); + try + { + var libDir = Path.Combine(projectRoot, "src", "Lib"); + var appDir = Path.Combine(projectRoot, "src", "App"); + Directory.CreateDirectory(libDir); + Directory.CreateDirectory(appDir); + File.WriteAllText(Path.Combine(projectRoot, "Repo.sln"), """ + Microsoft Visual Studio Solution File, Format Version 12.00 + Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lib", "src\Lib\Lib.csproj", "{11111111-1111-1111-1111-111111111111}" + EndProject + Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App", "src\App\App.csproj", "{22222222-2222-2222-2222-222222222222}" + EndProject + """); + File.WriteAllText(Path.Combine(libDir, "Lib.csproj"), ""); + File.WriteAllText(Path.Combine(libDir, "Class1.cs"), "class Class1 {}"); + File.WriteAllText(Path.Combine(appDir, "App.csproj"), ""); + File.WriteAllText(Path.Combine(appDir, "Class2.cs"), "class Class2 {}"); + var limits = SolutionProjectResolverLimits.Default with { MaxProjectExpansionFilesTotal = 3 }; + + var ex = Assert.Throws( + () => SolutionProjectResolver.ResolveProjectFiles(projectRoot, ["Lib", "App"], "Repo.sln", limits)); + + Assert.Contains("project filter expansion materialized more than 3 unique files across requested projects", ex.Message); + Assert.Contains("explicit --files", ex.Message); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void ResolveProjectFiles_SkipsDirectorySymlinkLoops_Issue2862() {