From a1e517c9f48f82e185a48d4b2693c2df0fb7b699 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 01:08:48 +0900 Subject: [PATCH 1/7] Fix visibility-filtered count queries --- changelog.d/unreleased/3111.fixed.md | 16 ++++++++++++ src/CodeIndex/Database/DbSymbolReader.cs | 23 ++++++------------ tests/CodeIndex.Tests/DbReaderTests.cs | 31 ++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 16 deletions(-) create mode 100644 changelog.d/unreleased/3111.fixed.md diff --git a/changelog.d/unreleased/3111.fixed.md b/changelog.d/unreleased/3111.fixed.md new file mode 100644 index 0000000000..de1ce0b00a --- /dev/null +++ b/changelog.d/unreleased/3111.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3111 +affected: + - src/CodeIndex/Database/DbSymbolReader.cs + - tests/CodeIndex.Tests/DbReaderTests.cs +--- + +## English + +- **Visibility-filtered symbol and definition count queries no longer materialize full result lists (#3111)** — count-only paths apply visibility filters directly in SQL while preserving the existing result counts. + +## 日本語 + +- **visibility filter 付きの symbol / definition count query が全結果リストを materialize しなくなりました (#3111)** — count-only 経路で visibility filter を SQL に直接適用し、既存の件数結果を維持します。 diff --git a/src/CodeIndex/Database/DbSymbolReader.cs b/src/CodeIndex/Database/DbSymbolReader.cs index 3b6e0dab0f..d8496cb9a4 100644 --- a/src/CodeIndex/Database/DbSymbolReader.cs +++ b/src/CodeIndex/Database/DbSymbolReader.cs @@ -356,9 +356,6 @@ private static void AddQualifiedGraphQueryParameters(SqliteCommand cmd, string q public int CountSearchSymbols(IReadOnlyList? queries, int limit = 20, string? kind = null, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, DateTime? since = null, bool exact = false, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) { - if (HasVisibilityFilters(visibilityFilters, excludeVisibilityFilters)) - return SearchSymbols(queries, limit, kind, lang, pathPatterns, excludePathPatterns, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters).Count; - var validQueries = NormalizeSymbolSearchQueries(queries, lang, exact); if (validQueries != null && validQueries.Count > 1) return SearchSymbols(validQueries, limit, kind, lang, pathPatterns, excludePathPatterns, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters).Count; @@ -400,6 +397,7 @@ FROM symbols s if (since != null && _fileColumns.Contains("modified")) innerSql += " AND f.modified >= @since"; AppendPathFilters(ref innerSql, pathPatterns, excludePathPatterns, excludeTests); + AppendVisibilityFilters(ref innerSql, visibilityFilters, excludeVisibilityFilters); innerSql += " LIMIT @limit"; cmd.CommandText = $"SELECT COUNT(*) FROM ({innerSql})"; @@ -436,7 +434,8 @@ FROM symbols s if (since != null && _fileColumns.Contains("modified")) cmd.Parameters.AddWithValue("@since", since.Value); AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns); - cmd.Parameters.AddWithValue("@limit", HasVisibilityFilters(visibilityFilters, excludeVisibilityFilters) ? int.MaxValue : limit); + AddVisibilityFilterParameters(cmd, visibilityFilters, excludeVisibilityFilters); + cmd.Parameters.AddWithValue("@limit", limit); var raw = cmd.ExecuteScalar(); return raw is long l ? (int)l : Convert.ToInt32(raw); @@ -449,12 +448,6 @@ public QueryCountResult CountSearchSymbolsTotal(string? query = null, string? ki public QueryCountResult CountSearchSymbolsTotal(IReadOnlyList? queries, string? kind = null, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, DateTime? since = null, bool exact = false, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) { - if (HasVisibilityFilters(visibilityFilters, excludeVisibilityFilters)) - { - var results = SearchSymbols(queries, int.MaxValue, kind, lang, pathPatterns, excludePathPatterns, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters); - return new QueryCountResult(results.Count, results.Select(result => result.Path).Distinct(StringComparer.Ordinal).Count()); - } - lang = DbReader.NormalizeQueryLanguage(lang); using var cmd = _conn.CreateCommand(); @@ -512,6 +505,7 @@ FROM symbols s if (since != null && _fileColumns.Contains("modified")) sql += " AND f.modified >= @since"; AppendPathFilters(ref sql, pathPatterns, excludePathPatterns, excludeTests); + AppendVisibilityFilters(ref sql, visibilityFilters, excludeVisibilityFilters); sql += ")"; cmd.CommandText = sql; @@ -550,6 +544,7 @@ FROM symbols s if (since != null && _fileColumns.Contains("modified")) cmd.Parameters.AddWithValue("@since", since.Value); AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns); + AddVisibilityFilterParameters(cmd, visibilityFilters, excludeVisibilityFilters); using var reader = cmd.ExecuteTrackedReader(); return reader.TrackedRead() @@ -907,12 +902,6 @@ private static string ExtractParameterType(string parameter) public QueryCountResult CountDefinitionsTotal(string query, string? kind = null, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, DateTime? since = null, bool exact = false, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) { - if (HasVisibilityFilters(visibilityFilters, excludeVisibilityFilters)) - { - var results = GetDefinitions(query, int.MaxValue, kind, lang, includeBody: false, pathPatterns, excludePathPatterns, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters); - return new QueryCountResult(results.Count, results.Select(result => result.Path).Distinct(StringComparer.Ordinal).Count()); - } - var normalizedQuery = NormalizeSymbolSearchQueryForSymbolSearch(query, lang, exact); using var cmd = _conn.CreateCommand(); @@ -953,6 +942,7 @@ FROM symbols s if (since != null && _fileColumns.Contains("modified")) sql += " AND f.modified >= @since"; AppendPathFilters(ref sql, pathPatterns, excludePathPatterns, excludeTests); + AppendVisibilityFilters(ref sql, visibilityFilters, excludeVisibilityFilters); sql += $@" AND EXISTS ( SELECT 1 @@ -996,6 +986,7 @@ FROM chunks c if (since != null && _fileColumns.Contains("modified")) cmd.Parameters.AddWithValue("@since", since.Value); AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns); + AddVisibilityFilterParameters(cmd, visibilityFilters, excludeVisibilityFilters); using var reader = cmd.ExecuteTrackedReader(); return reader.TrackedRead() diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index b4bae49362..eba875d618 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -998,6 +998,37 @@ fn private_fn() {} Assert.DoesNotContain(nonPublicResults, result => result.Name == "exported_fn"); } + [Fact] + public void CountSymbolsAndDefinitions_VisibilityFiltersStayInCountQueries() + { + InsertIndexedFile( + "src/count_visibility.rs", + "rust", + """ + pub fn counted_public() {} + fn counted_private() {} + """); + + Assert.Equal(1, _reader.CountSearchSymbols("counted_public", lang: "rust", exact: true, visibilityFilters: ["public"])); + Assert.Equal(0, _reader.CountSearchSymbols("counted_public", lang: "rust", exact: true, excludeVisibilityFilters: ["public"])); + + var symbolTotal = _reader.CountSearchSymbolsTotal("counted_public", lang: "rust", exact: true, visibilityFilters: ["public"]); + Assert.Equal(1, symbolTotal.Count); + Assert.Equal(1, symbolTotal.FileCount); + + var excludedSymbolTotal = _reader.CountSearchSymbolsTotal("counted_public", lang: "rust", exact: true, excludeVisibilityFilters: ["public"]); + Assert.Equal(0, excludedSymbolTotal.Count); + Assert.Equal(0, excludedSymbolTotal.FileCount); + + var definitionTotal = _reader.CountDefinitionsTotal("counted_public", lang: "rust", exact: true, visibilityFilters: ["public"]); + Assert.Equal(1, definitionTotal.Count); + Assert.Equal(1, definitionTotal.FileCount); + + var excludedDefinitionTotal = _reader.CountDefinitionsTotal("counted_public", lang: "rust", exact: true, excludeVisibilityFilters: ["public"]); + Assert.Equal(0, excludedDefinitionTotal.Count); + Assert.Equal(0, excludedDefinitionTotal.FileCount); + } + [Fact] public void SearchSymbols_JavaScriptCommonJsExportQueriesResolveToLeafNames() { From aa5f5d999734d44ccc34cdffab0fdfd937078cc1 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 01:11:32 +0900 Subject: [PATCH 2/7] Avoid loading referenced names for unused fallback --- changelog.d/unreleased/3150.fixed.md | 15 +++++++ src/CodeIndex/Database/DbSymbolReader.cs | 52 +++++++----------------- 2 files changed, 30 insertions(+), 37 deletions(-) create mode 100644 changelog.d/unreleased/3150.fixed.md diff --git a/changelog.d/unreleased/3150.fixed.md b/changelog.d/unreleased/3150.fixed.md new file mode 100644 index 0000000000..42359438f3 --- /dev/null +++ b/changelog.d/unreleased/3150.fixed.md @@ -0,0 +1,15 @@ +--- +category: fixed +issues: + - 3150 +affected: + - src/CodeIndex/Database/DbSymbolReader.cs +--- + +## English + +- **`unused` fallback queries no longer load every referenced symbol name into memory (#3150)** — non-SQL resolver paths now exclude referenced symbols in the candidate SQL before applying their bounded result loops. + +## 日本語 + +- **`unused` fallback query が参照済み symbol 名を全件メモリへ読み込まなくなりました (#3150)** — 非 SQL resolver 経路では、bounded result loop の前に候補 SQL 側で参照済み symbol を除外します。 diff --git a/src/CodeIndex/Database/DbSymbolReader.cs b/src/CodeIndex/Database/DbSymbolReader.cs index d8496cb9a4..2921f59e73 100644 --- a/src/CodeIndex/Database/DbSymbolReader.cs +++ b/src/CodeIndex/Database/DbSymbolReader.cs @@ -2892,7 +2892,6 @@ private List GetUnusedSymbolsWithoutSqlResolver(int limit, s IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null, string? bucketFilter = null, string? minConfidence = null) { - var referencedNames = LoadReferencedSymbolNames(); var targetCount = Math.Max(limit, 1); var publicFetchBudget = Math.Max( targetCount, @@ -2906,13 +2905,13 @@ private List GetUnusedSymbolsWithoutSqlResolver(int limit, s UnusedPublicOverfetchMaximum); var publicOrExported = new List(targetCount); var chunksByFileId = new Dictionary>(); - var privateLike = CollectUnusedCandidateBucket(targetCount, batchSize, 0, referencedNames, chunksByFileId, + var privateLike = CollectUnusedCandidateBucket(targetCount, batchSize, 0, chunksByFileId, kind, lang, pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters); - var maybeNonPublic = CollectUnusedCandidateBucket(targetCount, batchSize, 1, referencedNames, chunksByFileId, + var maybeNonPublic = CollectUnusedCandidateBucket(targetCount, batchSize, 1, chunksByFileId, kind, lang, pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters); - var reflectionOrConfig = CollectUnusedCandidateBucket(targetCount, batchSize, 3, referencedNames, chunksByFileId, + var reflectionOrConfig = CollectUnusedCandidateBucket(targetCount, batchSize, 3, chunksByFileId, kind, lang, pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters); - CollectPublicUnusedCandidateBucket(targetCount, batchSize, publicFetchBudget, referencedNames, chunksByFileId, + CollectPublicUnusedCandidateBucket(targetCount, batchSize, publicFetchBudget, chunksByFileId, publicOrExported, reflectionOrConfig, kind, lang, pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters); var merged = new List(privateLike.Count + maybeNonPublic.Count + publicOrExported.Count + reflectionOrConfig.Count); @@ -2933,7 +2932,6 @@ private List GetFilteredUnusedSymbolsWithoutSqlResolver(int if (targetBuckets.Count == 0) return []; - var referencedNames = LoadReferencedSymbolNames(); var chunksByFileId = new Dictionary>(); var resultsByBucket = CreateUnusedBucketResultLists(); const int batchSize = UnusedPublicOverfetchMaximum; @@ -2950,8 +2948,6 @@ private List GetFilteredUnusedSymbolsWithoutSqlResolver(int offset += batch.Count; foreach (var candidate in batch) { - if (referencedNames.Contains(candidate.Name)) - continue; if (HasSameFilePrivateUse(candidate, chunksByFileId)) continue; @@ -2971,7 +2967,7 @@ private List GetFilteredUnusedSymbolsWithoutSqlResolver(int } private List CollectUnusedCandidateBucket(int targetCount, int batchSize, int provisionalBucketOrder, - HashSet referencedNames, Dictionary> chunksByFileId, string? kind, string? lang, + Dictionary> chunksByFileId, string? kind, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? visibilityFilters, IReadOnlyList? excludeVisibilityFilters) { @@ -2987,8 +2983,6 @@ private List CollectUnusedCandidateBucket(int targetCount, i offset += batch.Count; foreach (var candidate in batch) { - if (referencedNames.Contains(candidate.Name)) - continue; if (HasSameFilePrivateUse(candidate, chunksByFileId)) continue; @@ -3005,7 +2999,7 @@ private List CollectUnusedCandidateBucket(int targetCount, i } private void CollectPublicUnusedCandidateBucket(int targetCount, int batchSize, int candidateBudget, - HashSet referencedNames, Dictionary> chunksByFileId, + Dictionary> chunksByFileId, List publicOrExported, List reflectionOrConfig, string? kind, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? visibilityFilters, IReadOnlyList? excludeVisibilityFilters) @@ -3023,8 +3017,6 @@ private void CollectPublicUnusedCandidateBucket(int targetCount, int batchSize, offset += batch.Count; foreach (var candidate in batch) { - if (referencedNames.Contains(candidate.Name)) - continue; if (HasSameFilePrivateUse(candidate, chunksByFileId)) continue; @@ -3102,23 +3094,6 @@ FROM chunks return chunks; } - private HashSet LoadReferencedSymbolNames() - { - using var cmd = _conn.CreateCommand(); - cmd.CommandText = """ - SELECT DISTINCT symbol_name - FROM symbol_references - WHERE symbol_name IS NOT NULL - AND symbol_name <> '' - """; - - var names = new HashSet(StringComparer.Ordinal); - using var reader = cmd.ExecuteTrackedReader(); - while (reader.TrackedRead()) - names.Add(reader.GetString(0)); - return names; - } - private IEnumerable FetchUnusedCandidateSymbols(int fetchLimit, int offset, int provisionalBucketOrder, string? kind, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) { @@ -3170,6 +3145,15 @@ FROM symbols s JOIN files f ON s.file_id = f.id WHERE s.kind NOT IN ('import', 'namespace')"; sql += $"\n AND {BuildAmbiguousCSharpEnumMemberExclusionSql("s", "f", pathPatterns, excludePathPatterns, excludeTests)}"; + sql += """ + AND NOT EXISTS ( + SELECT 1 + FROM symbol_references sr + WHERE sr.symbol_name IS NOT NULL + AND sr.symbol_name <> '' + AND sr.symbol_name = s.name + ) + """; if (lang != null) sql += SymbolLanguageFileIdFilter; @@ -3663,7 +3647,6 @@ private QueryCountResult CountFilteredUnusedSymbolsWithoutSqlResolver(string? ki IReadOnlyList? visibilityFilters, IReadOnlyList? excludeVisibilityFilters, string? bucketFilter, string? minConfidence) { - var referencedNames = LoadReferencedSymbolNames(); var count = 0; var paths = new HashSet(StringComparer.Ordinal); var chunksByFileId = new Dictionary>(); @@ -3681,8 +3664,6 @@ private QueryCountResult CountFilteredUnusedSymbolsWithoutSqlResolver(string? ki offset += batch.Count; foreach (var candidate in batch) { - if (referencedNames.Contains(candidate.Name)) - continue; if (HasSameFilePrivateUse(candidate, chunksByFileId)) continue; @@ -3705,7 +3686,6 @@ private QueryCountResult CountFilteredUnusedSymbolsWithoutSqlResolver(string? ki private QueryCountResult CountUnusedSymbolsWithoutSqlResolver(string? kind, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) { - var referencedNames = LoadReferencedSymbolNames(); var count = 0; var paths = new HashSet(StringComparer.Ordinal); var chunksByFileId = new Dictionary>(); @@ -3723,8 +3703,6 @@ private QueryCountResult CountUnusedSymbolsWithoutSqlResolver(string? kind, stri offset += batch.Count; foreach (var candidate in batch) { - if (referencedNames.Contains(candidate.Name)) - continue; if (HasSameFilePrivateUse(candidate, chunksByFileId)) continue; From 91405d9eeb8adec2e3b7712a0e9c2cb7e263f8b8 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 01:13:31 +0900 Subject: [PATCH 3/7] Use total counts for MCP graph count-only --- changelog.d/unreleased/3115.fixed.md | 16 ++++++++++++++++ src/CodeIndex/Mcp/McpToolHandlers.cs | 12 ++++++------ tests/CodeIndex.Tests/McpServerTests.cs | 22 ++++++++++++++++++++++ 3 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 changelog.d/unreleased/3115.fixed.md diff --git a/changelog.d/unreleased/3115.fixed.md b/changelog.d/unreleased/3115.fixed.md new file mode 100644 index 0000000000..be1b1ff4ba --- /dev/null +++ b/changelog.d/unreleased/3115.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3115 +affected: + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP graph count-only handlers no longer use `int.MaxValue` row probes (#3115)** — references, callers, and callees now use total-count queries for count-only and paginated-total metadata. + +## 日本語 + +- **MCP graph の count-only handler が `int.MaxValue` の行取得 probe を使わなくなりました (#3115)** — references / callers / callees は count-only と paginated total metadata に total-count query を使います。 diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index d95aacb6d6..a306da028a 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -1467,7 +1467,7 @@ private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) { if (countOnly) { - var countOnlyTotal = reader.CountSearchReferences(query, int.MaxValue, lang, kind, pathPatterns, excludePaths, excludeTests, exact); + var countOnlyTotal = reader.CountSearchReferencesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact).Count; var histogramResults = countOnlyTotal > 0 ? reader.SearchReferences(query, Math.Min(countOnlyTotal, MaxLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, maxLineWidth) : []; @@ -1483,7 +1483,7 @@ private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) var results = reader.SearchReferences(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, maxLineWidth, offset: offset); var truncated = TrimToRequestedLimit(results, limit); var total = truncated || offset > 0 - ? reader.CountSearchReferences(query, int.MaxValue, lang, kind, pathPatterns, excludePaths, excludeTests, exact) + ? reader.CountSearchReferencesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact).Count : results.Count; if (lspCompatible) QueryCommandRunner.AttachLspLocations(results); @@ -1571,7 +1571,7 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) { if (countOnly) { - var countOnlyTotal = reader.CountCallers(query, int.MaxValue, lang, kind, pathPatterns, excludePaths, excludeTests, exact); + var countOnlyTotal = reader.CountCallersTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact).Count; var histogramResults = countOnlyTotal > 0 ? reader.GetCallers(query, Math.Min(countOnlyTotal, MaxLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rankMode: rankMode) : []; @@ -1587,7 +1587,7 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) var results = reader.GetCallers(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rankMode: rankMode, offset: offset); var truncated = TrimToRequestedLimit(results, limit); var total = truncated || offset > 0 - ? reader.CountCallers(query, int.MaxValue, lang, kind, pathPatterns, excludePaths, excludeTests, exact) + ? reader.CountCallersTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact).Count : results.Count; var graphSupport = ResolveGraphSupport(reader, exact, query, lang, pathPatterns, excludePaths, excludeTests); var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignalByLanguages( @@ -1665,7 +1665,7 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) { if (countOnly) { - var countOnlyTotal = reader.CountCallees(query, int.MaxValue, lang, kind, pathPatterns, excludePaths, excludeTests, exact); + var countOnlyTotal = reader.CountCalleesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact).Count; var histogramResults = countOnlyTotal > 0 ? reader.GetCallees(query, Math.Min(countOnlyTotal, MaxLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rankMode: rankMode) : []; @@ -1681,7 +1681,7 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) var results = reader.GetCallees(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rankMode: rankMode, offset: offset); var truncated = TrimToRequestedLimit(results, limit); var total = truncated || offset > 0 - ? reader.CountCallees(query, int.MaxValue, lang, kind, pathPatterns, excludePaths, excludeTests, exact) + ? reader.CountCalleesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact).Count : results.Count; var graphSupport = ResolveGraphSupport(reader, exact, query, lang, pathPatterns, excludePaths, excludeTests); var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignalByLanguages( diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 2ad039817d..0b77ab224f 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -5965,6 +5965,28 @@ public class CountOnlyCaller { public void Run(App app) { app.Run(); app.Run(); Assert.NotEmpty(structured["top_files"]!.AsArray()); } + [Fact] + public void ToolsCall_CallersAndCallees_CountOnly_OmitsRowsAndReturnsHistogram() + { + InsertIndexedFile("src/count-only-graph.py", "python", "def login(user):\n return Run(user)\n"); + + var callersRequest = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"callers","arguments":{"query":"Run","lang":"python","countOnly":true}}}""")!; + var callersResponse = _server.HandleMessage(callersRequest)!; + var callersStructured = callersResponse["result"]!["structuredContent"]!; + Assert.True(callersStructured["count_only"]!.GetValue()); + Assert.True(callersStructured["count"]!.GetValue() >= 1); + Assert.Empty(callersStructured["results"]!.AsArray()); + Assert.NotEmpty(callersStructured["top_files"]!.AsArray()); + + var calleesRequest = JsonNode.Parse("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"callees","arguments":{"query":"login","lang":"python","countOnly":true}}}""")!; + var calleesResponse = _server.HandleMessage(calleesRequest)!; + var calleesStructured = calleesResponse["result"]!["structuredContent"]!; + Assert.True(calleesStructured["count_only"]!.GetValue()); + Assert.True(calleesStructured["count"]!.GetValue() >= 1); + Assert.Empty(calleesStructured["results"]!.AsArray()); + Assert.NotEmpty(calleesStructured["top_files"]!.AsArray()); + } + [Fact] public void ToolsCall_ImpactAnalysis_CountOnly_OmitsCallerRows() { From 32c2c4f952a5e47b65f3157b3e30cf29d217bd3b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 01:17:26 +0900 Subject: [PATCH 4/7] Stream dependency JSON graph output --- changelog.d/unreleased/3097.fixed.md | 18 +++++ src/CodeIndex/Cli/QueryCommandRunner.cs | 77 ++++++++++++++++++- src/CodeIndex/Mcp/McpToolHandlers.cs | 28 ++++--- tests/CodeIndex.Tests/McpServerTests.cs | 23 ++++++ .../QueryCommandRunnerTests.cs | 27 +++++++ 5 files changed, 161 insertions(+), 12 deletions(-) create mode 100644 changelog.d/unreleased/3097.fixed.md diff --git a/changelog.d/unreleased/3097.fixed.md b/changelog.d/unreleased/3097.fixed.md new file mode 100644 index 0000000000..d1c8fc1b0c --- /dev/null +++ b/changelog.d/unreleased/3097.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 3097 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/QueryCommandRunnerTests.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **Dependency JSON graph output avoids duplicate full graph array materialization (#3097)** — CLI graph output now writes JSON incrementally, and MCP graph payload construction fills bounded arrays without LINQ `ToArray` copies. + +## 日本語 + +- **dependency JSON graph 出力が graph 配列全体の重複 materialization を避けるようになりました (#3097)** — CLI graph 出力は JSON を逐次書き込み、MCP graph payload は LINQ `ToArray` のコピーを作らず bounded array を構築します。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 06dc8734e9..4d937b77d8 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -4652,13 +4652,84 @@ private static void WriteDependencyGraph(IReadOnlyList edg Console.WriteLine(""); break; case OutputFormatJsonGraph: - var nodes = edges.SelectMany(edge => new[] { edge.SourcePath, edge.TargetPath }).Distinct(StringComparer.Ordinal).Select(path => new JsonObject { ["id"] = path }).ToArray(); - var graphEdges = edges.Select(edge => new JsonObject { ["source"] = edge.SourcePath, ["target"] = edge.TargetPath, ["reference_count"] = edge.ReferenceCount }).ToArray(); - Console.WriteLine(new JsonObject { ["nodes"] = new JsonArray(nodes), ["edges"] = new JsonArray(graphEdges) }.ToJsonString(jsonOptions)); + WriteDependencyJsonGraph(edges, jsonOptions); break; } } + private static void WriteDependencyJsonGraph(IReadOnlyList edges, JsonSerializerOptions jsonOptions) + { + var seenNodes = new HashSet(StringComparer.Ordinal); + var nodes = new List(); + foreach (var edge in edges) + { + if (seenNodes.Add(edge.SourcePath)) + nodes.Add(edge.SourcePath); + if (seenNodes.Add(edge.TargetPath)) + nodes.Add(edge.TargetPath); + } + + var writer = Console.Out; + if (!jsonOptions.WriteIndented) + { + writer.Write("{\"nodes\":["); + for (var i = 0; i < nodes.Count; i++) + { + if (i > 0) + writer.Write(','); + writer.Write("{\"id\":"); + writer.Write(JsonSerializer.Serialize(nodes[i], jsonOptions)); + writer.Write('}'); + } + + writer.Write("],\"edges\":["); + for (var i = 0; i < edges.Count; i++) + { + if (i > 0) + writer.Write(','); + var edge = edges[i]; + writer.Write("{\"source\":"); + writer.Write(JsonSerializer.Serialize(edge.SourcePath, jsonOptions)); + writer.Write(",\"target\":"); + writer.Write(JsonSerializer.Serialize(edge.TargetPath, jsonOptions)); + writer.Write(",\"reference_count\":"); + writer.Write(edge.ReferenceCount.ToString(CultureInfo.InvariantCulture)); + writer.Write('}'); + } + + writer.WriteLine("]}"); + return; + } + + writer.WriteLine("{"); + writer.WriteLine(" \"nodes\": ["); + for (var i = 0; i < nodes.Count; i++) + { + writer.Write(" { \"id\": "); + writer.Write(JsonSerializer.Serialize(nodes[i], jsonOptions)); + writer.Write(" }"); + writer.WriteLine(i + 1 < nodes.Count ? "," : string.Empty); + } + + writer.WriteLine(" ],"); + writer.WriteLine(" \"edges\": ["); + for (var i = 0; i < edges.Count; i++) + { + var edge = edges[i]; + writer.Write(" { \"source\": "); + writer.Write(JsonSerializer.Serialize(edge.SourcePath, jsonOptions)); + writer.Write(", \"target\": "); + writer.Write(JsonSerializer.Serialize(edge.TargetPath, jsonOptions)); + writer.Write(", \"reference_count\": "); + writer.Write(edge.ReferenceCount.ToString(CultureInfo.InvariantCulture)); + writer.Write(" }"); + writer.WriteLine(i + 1 < edges.Count ? "," : string.Empty); + } + + writer.WriteLine(" ]"); + writer.WriteLine("}"); + } + private static string EscapeDot(string value) => value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal); private static List GetWorkspaceFileDependencies(DbReader primaryReader, QueryCommandOptions options, bool reverse) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index a306da028a..e7284b7845 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -3098,15 +3098,25 @@ private JsonNode ExecuteDeps(JsonNode? id, JsonNode? args) private static JsonObject BuildJsonGraphPayload(IReadOnlyList edges) { - var nodes = edges - .SelectMany(edge => new[] { edge.SourcePath, edge.TargetPath }) - .Distinct(StringComparer.Ordinal) - .Select(path => new JsonObject { ["id"] = path }) - .ToArray(); - var graphEdges = edges - .Select(edge => new JsonObject { ["source"] = edge.SourcePath, ["target"] = edge.TargetPath, ["reference_count"] = edge.ReferenceCount }) - .ToArray(); - return new JsonObject { ["nodes"] = new JsonArray(nodes), ["edges"] = new JsonArray(graphEdges) }; + var nodes = new JsonArray(); + var seenNodes = new HashSet(StringComparer.Ordinal); + var graphEdges = new JsonArray(); + foreach (var edge in edges) + { + if (seenNodes.Add(edge.SourcePath)) + nodes.Add(new JsonObject { ["id"] = edge.SourcePath }); + if (seenNodes.Add(edge.TargetPath)) + nodes.Add(new JsonObject { ["id"] = edge.TargetPath }); + + graphEdges.Add(new JsonObject + { + ["source"] = edge.SourcePath, + ["target"] = edge.TargetPath, + ["reference_count"] = edge.ReferenceCount, + }); + } + + return new JsonObject { ["nodes"] = nodes, ["edges"] = graphEdges }; } private JsonNode ExecuteImpactAnalysis(JsonNode? id, JsonNode? args) diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 0b77ab224f..0e0f2ce381 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -6448,6 +6448,29 @@ public void ToolsCall_Deps_ZeroResultSqlScopeStillIncludesDegradedState() } } + [Fact] + public void ToolsCall_Deps_JsonGraph_ReturnsGraphPayload() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_mcp_deps_json_graph"); + try + { + var dbPath = CreateSqlGraphContractFixtureDb(projectRoot); + using var server = new McpServer(dbPath, ConsoleUi.LoadVersion()); + + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"deps","arguments":{"format":"json-graph","lang":"sql"}}}""")!; + var response = server.HandleMessage(request)!; + var graph = response["result"]!["structuredContent"]!["graph"]!; + + Assert.NotEmpty(graph["nodes"]!.AsArray()); + Assert.NotEmpty(graph["edges"]!.AsArray()); + Assert.NotNull(graph["edges"]![0]!["reference_count"]); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void ToolsCall_Hotspots_ZeroResultSqlScopeStillIncludesDegradedState() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 1f80a51e2b..9d558774bc 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -3455,6 +3455,33 @@ public void RunDeps_Json_StaleSqlGraphContractIncludesDegradedState() } } + [Fact] + public void RunDeps_JsonGraph_WritesValidGraphPayload() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_deps_json_graph"); + try + { + var dbPath = CreateSqlGraphContractFixtureDb(projectRoot); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--format", "json-graph", "--lang", "sql"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.True(json.GetProperty("nodes").GetArrayLength() >= 2); + Assert.True(json.GetProperty("edges").GetArrayLength() >= 1); + Assert.True(json.GetProperty("edges")[0].TryGetProperty("reference_count", out _)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunDeps_WorkspaceDbJson_AggregatesAndTagsMemberDatabaseEdges() { From 2c92ef79b9f4f17f47d4d0d4f5d854f8c20fe363 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 01:23:36 +0900 Subject: [PATCH 5/7] Stream repo map file stat aggregation --- changelog.d/unreleased/3149.fixed.md | 15 ++ src/CodeIndex/Database/RepoMapBuilder.cs | 228 +++++++++++++---------- 2 files changed, 149 insertions(+), 94 deletions(-) create mode 100644 changelog.d/unreleased/3149.fixed.md diff --git a/changelog.d/unreleased/3149.fixed.md b/changelog.d/unreleased/3149.fixed.md new file mode 100644 index 0000000000..21ec2f9bc1 --- /dev/null +++ b/changelog.d/unreleased/3149.fixed.md @@ -0,0 +1,15 @@ +--- +category: fixed +issues: + - 3149 +affected: + - src/CodeIndex/Database/RepoMapBuilder.cs +--- + +## English + +- **Repo map now keeps bounded file-summary rankings instead of materializing every file stat before limits (#3149)** — map aggregation streams file stats, maintains top-N lists as it scans, and resolves Java module grouping without mutating a full file list. + +## 日本語 + +- **repo map が limit 適用前に全 file stat を materialize せず、bounded な file-summary ranking を保持するようになりました (#3149)** — map 集約は file stat を streaming しながら top-N を更新し、Java module grouping も全 file list を mutate せずに解決します。 diff --git a/src/CodeIndex/Database/RepoMapBuilder.cs b/src/CodeIndex/Database/RepoMapBuilder.cs index eeeb43cfe8..424b2b8347 100644 --- a/src/CodeIndex/Database/RepoMapBuilder.cs +++ b/src/CodeIndex/Database/RepoMapBuilder.cs @@ -96,9 +96,11 @@ public RepoMapResult Build(int limit, string? lang, IReadOnlyList? pathP // file stats / workspace freshness / entrypoint 取得が同じ WAL snapshot から返る // ようにする。 using var txn = _conn.BeginTransaction(deferred: true); - var fileStats = GetFileStats(lang, pathPatterns, excludePathPatterns, excludeTests); - ApplyJavaModuleGrouping(fileStats, LoadJavaModuleDescriptors()); - var aggregate = BuildAggregate(fileStats); + var javaModuleDescriptors = LoadJavaModuleDescriptors(); + var aggregate = BuildAggregate( + EnumerateFileStats(lang, pathPatterns, excludePathPatterns, excludeTests), + Math.Max(limit, 0), + javaModuleDescriptors); var freshness = getFreshness(); var result = new RepoMapResult { @@ -112,18 +114,18 @@ public RepoMapResult Build(int limit, string? lang, IReadOnlyList? pathP WorkspaceLatestModified = freshness.LatestModified, Languages = BuildLanguageResults(aggregate.Languages, limit), Modules = BuildModuleResults(aggregate.Modules, limit), - TopFiles = BuildTopFileResults(aggregate.FileSummaries, limit), - LargestFiles = BuildLargestFileResults(aggregate.FileSummaries, limit), - SymbolRichFiles = BuildSymbolRichFileResults(aggregate.FileSummaries, limit), - ReferenceRichFiles = BuildReferenceRichFileResults(aggregate.FileSummaries, limit), - Entrypoints = GetEntrypoints(fileStats, limit, lang, pathPatterns, excludePathPatterns, excludeTests, minEntrypointConfidence), + TopFiles = aggregate.TopFiles, + LargestFiles = BuildLargestFileResults(aggregate.LargestFiles), + SymbolRichFiles = BuildSymbolRichFileResults(aggregate.SymbolRichFiles), + ReferenceRichFiles = BuildReferenceRichFileResults(aggregate.ReferenceRichFiles), + Entrypoints = GetEntrypoints(aggregate.EntrypointFallbacks, limit, lang, pathPatterns, excludePathPatterns, excludeTests, minEntrypointConfidence), GraphTableAvailable = _hasReferencesTable, }; txn.Commit(); return result; } - private List GetFileStats(string? lang, IReadOnlyList? pathPatterns, + private IEnumerable EnumerateFileStats(string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests) { using var cmd = _conn.CreateCommand(); @@ -150,11 +152,10 @@ FROM files f cmd.Parameters.AddWithValue("@lang", lang); DbReader.AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns); - var results = new List(); using var reader = cmd.ExecuteTrackedReader(); while (reader.TrackedRead()) { - results.Add(new RepoFileStat + yield return new RepoFileStat { Path = reader.GetString(0), Lang = DbReader.GetNullableString(reader, 1), @@ -165,27 +166,38 @@ FROM files f Checksum = DbReader.GetNullableString(reader, 6), Modified = DbReader.GetNullableDateTime(reader, 7), IndexedAt = DbReader.GetNullableDateTime(reader, 8), - }); + }; } - - return results; } - private static RepoMapAggregate BuildAggregate(IReadOnlyList fileStats) + private static RepoMapAggregate BuildAggregate( + IEnumerable fileStats, + int limit, + IReadOnlyDictionary moduleByDescriptorPath) { var languages = new Dictionary(StringComparer.Ordinal); var modules = new Dictionary(StringComparer.Ordinal); - var fileSummaries = new List(fileStats.Count); var aggregate = new RepoMapAggregate { - FileCount = fileStats.Count, Languages = languages, Modules = modules, - FileSummaries = fileSummaries, + TopFiles = [], + LargestFiles = [], + SymbolRichFiles = [], + ReferenceRichFiles = [], + EntrypointFallbacks = [], }; foreach (var file in fileStats) { + aggregate.FileCount++; + if (moduleByDescriptorPath.Count > 0 && string.Equals(file.Lang, "java", StringComparison.OrdinalIgnoreCase)) + { + var owningModuleName = ResolveOwningJavaModuleName(file.Path, moduleByDescriptorPath); + if (!string.IsNullOrWhiteSpace(owningModuleName)) + file.ModuleName = owningModuleName; + } + aggregate.TotalLines += file.Lines; aggregate.TotalSymbols += file.SymbolCount; aggregate.TotalReferences += file.ReferenceCount; @@ -209,7 +221,29 @@ private static RepoMapAggregate BuildAggregate(IReadOnlyList fileS } AddFileStats(module, file); - fileSummaries.Add(CreateScoredFileSummary(file)); + + var scoredSummary = CreateScoredFileSummary(file); + AddBounded(aggregate.TopFiles, scoredSummary, limit, CompareTopFiles); + AddBounded(aggregate.LargestFiles, scoredSummary, limit, CompareLargestFiles); + AddBounded(aggregate.SymbolRichFiles, scoredSummary, limit, CompareSymbolRichFiles); + AddBounded(aggregate.ReferenceRichFiles, scoredSummary, limit, CompareReferenceRichFiles); + + var fallback = ScoreEntrypointFileFallback(file.Path, file.Lang, file.SymbolCount, file.ReferenceCount); + if (fallback.Score > 0) + { + aggregate.EntrypointFallbacks.Add(new RepoEntrypointResult + { + Path = file.Path, + Lang = file.Lang, + Kind = "file", + Name = Path.GetFileName(file.Path), + Line = 1, + Score = fallback.Score, + MatchType = fallback.MatchType, + Confidence = fallback.Confidence, + HintRank = fallback.HintRank, + }); + } } return aggregate; @@ -235,51 +269,85 @@ private static List BuildModuleResults(IReadOnlyDictionary BuildTopFileResults(IReadOnlyList fileSummaries, int limit) + private static List BuildLargestFileResults(IReadOnlyList fileSummaries) + => fileSummaries.Select(CopyUnscoredFileSummary).ToList(); + + private static List BuildSymbolRichFileResults(IReadOnlyList fileSummaries) + => fileSummaries.Select(CopyUnscoredFileSummary).ToList(); + + private static List BuildReferenceRichFileResults(IReadOnlyList fileSummaries) + => fileSummaries.Select(CopyUnscoredFileSummary).ToList(); + + private static void AddBounded(List items, T candidate, int limit, Comparison comparison) { - return fileSummaries - .OrderByDescending(file => file.Score) - .ThenByDescending(file => file.ReferenceCount) - .ThenByDescending(file => file.SymbolCount) - .ThenByDescending(file => file.Lines) - .ThenBy(file => file.Path) - .Take(limit) - .ToList(); + if (limit <= 0) + return; + + var index = items.BinarySearch(candidate, Comparer.Create(comparison)); + if (index < 0) + index = ~index; + if (index >= limit) + return; + + items.Insert(index, candidate); + if (items.Count > limit) + items.RemoveAt(items.Count - 1); } - private static List BuildLargestFileResults(IReadOnlyList fileSummaries, int limit) - { - return fileSummaries - .OrderByDescending(file => file.Lines) - .ThenByDescending(file => file.Size) - .ThenBy(file => file.Path) - .Take(limit) - .Select(CopyUnscoredFileSummary) - .ToList(); + private static int CompareTopFiles(RepoFileSummaryResult left, RepoFileSummaryResult right) + { + var score = (right.Score ?? 0).CompareTo(left.Score ?? 0); + if (score != 0) + return score; + var references = right.ReferenceCount.CompareTo(left.ReferenceCount); + if (references != 0) + return references; + var symbols = right.SymbolCount.CompareTo(left.SymbolCount); + if (symbols != 0) + return symbols; + var lines = right.Lines.CompareTo(left.Lines); + if (lines != 0) + return lines; + return string.Compare(left.Path, right.Path, StringComparison.Ordinal); } - private static List BuildSymbolRichFileResults(IReadOnlyList fileSummaries, int limit) + private static int CompareLargestFiles(RepoFileSummaryResult left, RepoFileSummaryResult right) { - return fileSummaries - .OrderByDescending(file => file.SymbolCount) - .ThenByDescending(file => file.ReferenceCount) - .ThenByDescending(file => file.Lines) - .ThenBy(file => file.Path) - .Take(limit) - .Select(CopyUnscoredFileSummary) - .ToList(); + var lines = right.Lines.CompareTo(left.Lines); + if (lines != 0) + return lines; + var size = right.Size.CompareTo(left.Size); + if (size != 0) + return size; + return string.Compare(left.Path, right.Path, StringComparison.Ordinal); } - private static List BuildReferenceRichFileResults(IReadOnlyList fileSummaries, int limit) - { - return fileSummaries - .OrderByDescending(file => file.ReferenceCount) - .ThenByDescending(file => file.SymbolCount) - .ThenByDescending(file => file.Lines) - .ThenBy(file => file.Path) - .Take(limit) - .Select(CopyUnscoredFileSummary) - .ToList(); + private static int CompareSymbolRichFiles(RepoFileSummaryResult left, RepoFileSummaryResult right) + { + var symbols = right.SymbolCount.CompareTo(left.SymbolCount); + if (symbols != 0) + return symbols; + var references = right.ReferenceCount.CompareTo(left.ReferenceCount); + if (references != 0) + return references; + var lines = right.Lines.CompareTo(left.Lines); + if (lines != 0) + return lines; + return string.Compare(left.Path, right.Path, StringComparison.Ordinal); + } + + private static int CompareReferenceRichFiles(RepoFileSummaryResult left, RepoFileSummaryResult right) + { + var references = right.ReferenceCount.CompareTo(left.ReferenceCount); + if (references != 0) + return references; + var symbols = right.SymbolCount.CompareTo(left.SymbolCount); + if (symbols != 0) + return symbols; + var lines = right.Lines.CompareTo(left.Lines); + if (lines != 0) + return lines; + return string.Compare(left.Path, right.Path, StringComparison.Ordinal); } private static void AddFileStats(RepoLanguageResult target, RepoFileStat file) @@ -338,23 +406,7 @@ FROM files f return moduleByDescriptorPath; } - private static void ApplyJavaModuleGrouping(List fileStats, IReadOnlyDictionary moduleByDescriptorPath) - { - if (moduleByDescriptorPath.Count == 0) - return; - - foreach (var file in fileStats) - { - if (!string.Equals(file.Lang, "java", StringComparison.OrdinalIgnoreCase)) - continue; - - var owningModuleName = ResolveOwningJavaModuleName(file.Path, moduleByDescriptorPath); - if (!string.IsNullOrWhiteSpace(owningModuleName)) - file.ModuleName = owningModuleName; - } - } - - private List GetEntrypoints(IReadOnlyList fileStats, int limit, + private List GetEntrypoints(IReadOnlyList fallbackEntrypoints, int limit, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, double minConfidence) { @@ -408,27 +460,11 @@ FROM symbols s .Select(result => result.Path) .ToHashSet(StringComparer.OrdinalIgnoreCase); - foreach (var file in fileStats) + foreach (var fallback in fallbackEntrypoints) { - if (filesWithEntrypoints.Contains(file.Path)) - continue; - - var match = ScoreEntrypointFileFallback(file.Path, file.Lang, file.SymbolCount, file.ReferenceCount); - if (match.Score <= 0) + if (filesWithEntrypoints.Contains(fallback.Path)) continue; - - results.Add(new RepoEntrypointResult - { - Path = file.Path, - Lang = file.Lang, - Kind = "file", - Name = Path.GetFileName(file.Path), - Line = 1, - Score = match.Score, - MatchType = match.MatchType, - Confidence = match.Confidence, - HintRank = match.HintRank, - }); + results.Add(fallback); } ApplyEntrypointAmbiguityPenalty(results); @@ -661,7 +697,7 @@ private readonly record struct EntrypointScore(int Score, string MatchType, doub private sealed class RepoMapAggregate { - public int FileCount { get; init; } + public int FileCount { get; set; } public long TotalLines { get; set; } public long TotalSymbols { get; set; } public long TotalReferences { get; set; } @@ -669,6 +705,10 @@ private sealed class RepoMapAggregate public DateTime? LatestModified { get; set; } public required Dictionary Languages { get; init; } public required Dictionary Modules { get; init; } - public required List FileSummaries { get; init; } + public required List TopFiles { get; init; } + public required List LargestFiles { get; init; } + public required List SymbolRichFiles { get; init; } + public required List ReferenceRichFiles { get; init; } + public required List EntrypointFallbacks { get; init; } } } From 154dbba27a342e8b20e353f1184c57eddb8ee3e3 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 01:27:44 +0900 Subject: [PATCH 6/7] Stream status freshness comparison --- changelog.d/unreleased/3139.fixed.md | 16 +++ src/CodeIndex/Cli/IndexFreshnessChecker.cs | 130 ++++++++++-------- src/CodeIndex/Database/DbReader.cs | 7 +- .../QueryCommandRunnerFilesTests.cs | 40 ++++++ 4 files changed, 132 insertions(+), 61 deletions(-) create mode 100644 changelog.d/unreleased/3139.fixed.md diff --git a/changelog.d/unreleased/3139.fixed.md b/changelog.d/unreleased/3139.fixed.md new file mode 100644 index 0000000000..e59780b0d0 --- /dev/null +++ b/changelog.d/unreleased/3139.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3139 +affected: + - src/CodeIndex/Cli/IndexFreshnessChecker.cs + - src/CodeIndex/Database/DbReader.cs +--- + +## English + +- **`status --check` now compares freshness without materializing the full workspace snapshot (#3139)** — indexed snapshots stream from the database, and workspace files are checked as they are scanned while unmatched indexed rows remain for missing-file classification. + +## 日本語 + +- **`status --check` が workspace snapshot 全体を materialize せずに freshness を比較するようになりました (#3139)** — indexed snapshot は DB から streaming し、workspace file は scan しながら照合し、未照合の indexed row だけを missing file 分類に回します。 diff --git a/src/CodeIndex/Cli/IndexFreshnessChecker.cs b/src/CodeIndex/Cli/IndexFreshnessChecker.cs index 82eb38be8d..ce0095afa8 100644 --- a/src/CodeIndex/Cli/IndexFreshnessChecker.cs +++ b/src/CodeIndex/Cli/IndexFreshnessChecker.cs @@ -19,9 +19,6 @@ internal static IndexFreshnessCheckResult Check(DbReader reader, string? project }; } - var indexed = reader.GetIndexedFileSnapshots() - .ToDictionary(file => file.Path, StringComparer.Ordinal); - var workspace = new Dictionary(StringComparer.Ordinal); var indexedHeadCommit = reader.GetMetaString(DbContext.IndexedHeadCommitMetaKey); var indexedHeadSha = reader.GetMetaString(DbContext.IndexedHeadShaMetaKey); var commitScopedFreshHeadSha = reader.GetMetaString(DbContext.CommitScopedFreshHeadShaMetaKey); @@ -43,7 +40,6 @@ internal static IndexFreshnessCheckResult Check(DbReader reader, string? project && !currentHeadCoveredByCommitRefresh; var result = new IndexFreshnessCheckResult { - IndexedFileCount = indexed.Count, IndexedHeadCommit = string.IsNullOrWhiteSpace(indexedHeadCommit) ? null : indexedHeadCommit, WorkspaceHeadCommit = string.IsNullOrWhiteSpace(workspaceHeadCommit) ? null : workspaceHeadCommit, HeadChanged = headChanged, @@ -62,12 +58,50 @@ internal static IndexFreshnessCheckResult Check(DbReader reader, string? project AddSample(result.ScanErrors, $"{error.Path}: {error.Message}"); } - foreach (var absolutePath in scan.Files.OrderBy(path => path, StringComparer.Ordinal)) + using var indexedEnumerator = reader.EnumerateIndexedFileSnapshots().GetEnumerator(); + var hasIndexed = MoveNextIndexed(); + var skipWorktreePathsLoaded = false; + HashSet? skipWorktreePaths = null; + + foreach (var absolutePath in scan.Files.OrderBy(path => FileIndexer.NormalizeIndexPath(Path.GetRelativePath(projectRoot, path)), StringComparer.Ordinal)) { try { var (record, _, _, _) = indexer.BuildRecordWithRawBytes(absolutePath); - workspace[record.Path] = new WorkspaceFileSnapshot(record.Checksum ?? string.Empty, record.Lines); + result.WorkspaceFileCount++; + while (hasIndexed && string.Compare(indexedEnumerator.Current.Path, record.Path, StringComparison.Ordinal) < 0) + { + AddMissingIndexedPath(indexedEnumerator.Current.Path); + hasIndexed = MoveNextIndexed(); + } + + if (!hasIndexed || string.Compare(indexedEnumerator.Current.Path, record.Path, StringComparison.Ordinal) > 0) + { + result.UnindexedFileCount++; + AddSample(result.UnindexedFiles, record.Path); + continue; + } + + var indexedFile = indexedEnumerator.Current; + if (string.IsNullOrWhiteSpace(indexedFile.Checksum)) + { + result.UnverifiableFileCount++; + AddSample(result.UnverifiableFiles, record.Path); + hasIndexed = MoveNextIndexed(); + continue; + } + + if (!string.Equals(indexedFile.Checksum, record.Checksum ?? string.Empty, StringComparison.OrdinalIgnoreCase) + || (indexedFile.Lines.HasValue && indexedFile.Lines.Value != record.Lines)) + { + result.ChangedFileCount++; + AddSample(result.ChangedFiles, record.Path); + hasIndexed = MoveNextIndexed(); + continue; + } + + result.MatchedFileCount++; + hasIndexed = MoveNextIndexed(); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) { @@ -77,52 +111,44 @@ internal static IndexFreshnessCheckResult Check(DbReader reader, string? project } } - result.WorkspaceFileCount = workspace.Count; - - foreach (var (path, workspaceFile) in workspace.OrderBy(kv => kv.Key, StringComparer.Ordinal)) + while (hasIndexed) { - if (!indexed.TryGetValue(path, out var indexedFile)) - { - result.UnindexedFileCount++; - AddSample(result.UnindexedFiles, path); - continue; - } - - if (string.IsNullOrWhiteSpace(indexedFile.Checksum)) - { - result.UnverifiableFileCount++; - AddSample(result.UnverifiableFiles, path); - continue; - } + AddMissingIndexedPath(indexedEnumerator.Current.Path); + hasIndexed = MoveNextIndexed(); + } - if (!string.Equals(indexedFile.Checksum, workspaceFile.Checksum, StringComparison.OrdinalIgnoreCase) - || (indexedFile.Lines.HasValue && indexedFile.Lines.Value != workspaceFile.Lines)) - { - result.ChangedFileCount++; - AddSample(result.ChangedFiles, path); - continue; - } + result.Checked = result.ScanErrorCount == 0; + result.MatchesWorkspace = result.Checked + && !result.HeadChanged + && result.ChangedFileCount == 0 + && result.MissingFileCount == 0 + && result.UnindexedFileCount == 0 + && result.UnverifiableFileCount == 0; + result.Reason = BuildReason(result); + return result; - result.MatchedFileCount++; + bool MoveNextIndexed() + { + var moved = indexedEnumerator.MoveNext(); + if (moved) + result.IndexedFileCount++; + return moved; } - var missingCandidates = indexed.Keys - .Except(workspace.Keys, StringComparer.Ordinal) - .OrderBy(path => path, StringComparer.Ordinal) - .ToList(); - - // Skip-worktree paths are intentionally absent from disk (sparse-checkout cone/non-cone, - // partial clone, or manual update-index --skip-worktree). Reclassify them so the freshness - // gate stops flagging them as "missing" and rebuilds. - // skip-worktree のパスは意図的に worktree から外されている(sparse-checkout cone/non-cone、 - // partial clone、手動の update-index --skip-worktree)。これらを "missing" から切り分け、 - // 不要な rebuild トリガーを止める。 - HashSet? skipWorktreePaths = null; - if (missingCandidates.Count > 0) - skipWorktreePaths = GitHelper.TryGetSkipWorktreePaths(projectRoot); - - foreach (var path in missingCandidates) + void AddMissingIndexedPath(string path) { + // Skip-worktree paths are intentionally absent from disk (sparse-checkout cone/non-cone, + // partial clone, or manual update-index --skip-worktree). Reclassify them so the freshness + // gate stops flagging them as "missing" and rebuilds. + // skip-worktree のパスは意図的に worktree から外されている(sparse-checkout cone/non-cone、 + // partial clone、手動の update-index --skip-worktree)。これらを "missing" から切り分け、 + // 不要な rebuild トリガーを止める。 + if (!skipWorktreePathsLoaded) + { + skipWorktreePaths = GitHelper.TryGetSkipWorktreePaths(projectRoot); + skipWorktreePathsLoaded = true; + } + if (skipWorktreePaths != null && skipWorktreePaths.Contains(path)) { result.OutsideSparseConeFileCount++; @@ -134,16 +160,6 @@ internal static IndexFreshnessCheckResult Check(DbReader reader, string? project AddSample(result.MissingFiles, path); } } - - result.Checked = result.ScanErrorCount == 0; - result.MatchesWorkspace = result.Checked - && !result.HeadChanged - && result.ChangedFileCount == 0 - && result.MissingFileCount == 0 - && result.UnindexedFileCount == 0 - && result.UnverifiableFileCount == 0; - result.Reason = BuildReason(result); - return result; } private static string BuildReason(IndexFreshnessCheckResult result) @@ -175,6 +191,4 @@ private static void AddSample(List samples, string value) if (samples.Count < SampleLimit) samples.Add(value); } - - private readonly record struct WorkspaceFileSnapshot(string Checksum, int Lines); } diff --git a/src/CodeIndex/Database/DbReader.cs b/src/CodeIndex/Database/DbReader.cs index 4728280b11..df8f64243f 100644 --- a/src/CodeIndex/Database/DbReader.cs +++ b/src/CodeIndex/Database/DbReader.cs @@ -1577,6 +1577,9 @@ GROUP BY s.file_id } internal List GetIndexedFileSnapshots() + => EnumerateIndexedFileSnapshots().ToList(); + + internal IEnumerable EnumerateIndexedFileSnapshots() { using var cmd = _conn.CreateCommand(); cmd.CommandText = $""" @@ -1585,11 +1588,9 @@ FROM files f ORDER BY f.path """; - var results = new List(); using var reader = cmd.ExecuteTrackedReader(); while (reader.TrackedRead()) - results.Add(new IndexedFileSnapshot(reader.GetString(0), GetNullableString(reader, 1), GetNullableInt32(reader, 2))); - return results; + yield return new IndexedFileSnapshot(reader.GetString(0), GetNullableString(reader, 1), GetNullableInt32(reader, 2)); } public QueryCountResult CountListFiles(string? query = null, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, DateTime? since = null) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs index f436ccb042..0bd36f497a 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs @@ -1354,6 +1354,46 @@ public void RunStatus_CheckJson_DetectsMissingAndUnindexedFiles() } } + [Fact] + public void RunStatus_CheckJson_MatchesNfcIndexedPathsAfterNfdWorkspaceSort() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_runner_status_check_nfd_path"); + try + { + var accentContent = "def accent():\n return 1\n"; + var asciiContent = "def ascii_neighbor():\n return 2\n"; + var nfdFileName = "e\u0301.py"; + var nfcFileName = "\u00e9.py"; + File.WriteAllText(Path.Combine(projectRoot, nfdFileName), accentContent); + File.WriteAllText(Path.Combine(projectRoot, "f.py"), asciiContent); + + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, nfcFileName, "python", accentContent); + TestProjectHelper.InsertIndexedFile(dbPath, "f.py", "python", asciiContent); + MarkStatusReadinessReady(dbPath); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunStatus( + ["--db", dbPath, "--check", "--json"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var check = document.RootElement.GetProperty("workspace_check"); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.True(document.RootElement.GetProperty("index_matches_workspace").GetBoolean()); + Assert.Equal("matched", check.GetProperty("reason").GetString()); + Assert.Equal(2, check.GetProperty("indexed_file_count").GetInt32()); + Assert.Equal(2, check.GetProperty("workspace_file_count").GetInt32()); + Assert.Equal(0, check.GetProperty("missing_file_count").GetInt32()); + Assert.Equal(0, check.GetProperty("unindexed_file_count").GetInt32()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunStatus_CheckJson_ReclassifiesSkipWorktreePathsAsOutsideSparseCone() { From 2274dfb64050b14be2df06af518eebfaeb88827c Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 01:31:16 +0900 Subject: [PATCH 7/7] Stream CLI structured format arrays --- changelog.d/unreleased/3152.fixed.md | 16 ++ src/CodeIndex/Cli/QueryCommandRunner.cs | 142 +++++++++++------- .../QueryCommandRunnerSearchTests.cs | 62 ++++++++ 3 files changed, 168 insertions(+), 52 deletions(-) create mode 100644 changelog.d/unreleased/3152.fixed.md diff --git a/changelog.d/unreleased/3152.fixed.md b/changelog.d/unreleased/3152.fixed.md new file mode 100644 index 0000000000..e326802314 --- /dev/null +++ b/changelog.d/unreleased/3152.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3152 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +--- + +## English + +- **CLI structured format helpers no longer duplicate full result arrays for LSP, compact, and SARIF output (#3152)** — formatted writers now stream array items to stdout while preserving their JSON schemas. + +## 日本語 + +- **CLI structured format helper が LSP / compact / SARIF 出力で全 result array を重複保持しなくなりました (#3152)** — formatted writer は JSON schema を保ったまま array item を stdout へ逐次書き込みます。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 4d937b77d8..a21daf99e8 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -1191,7 +1191,14 @@ private static LspLocation ToLspLocation(CalleeResult result) => BuildLspLocation(result.Path, result.FirstLine, 1, result.FirstLine, 1); private static void WriteLspLocations(IEnumerable locations, JsonSerializerOptions jsonOptions) - => Console.WriteLine(JsonSerializer.Serialize(locations.ToList(), CliJsonSerializerContextFactory.Create(jsonOptions).ListLspLocation)); + { + var itemOptions = GetCompactJsonOptions(jsonOptions); + var context = CliJsonSerializerContextFactory.Create(itemOptions); + WriteJsonArray( + locations, + (writer, location) => writer.Write(JsonSerializer.Serialize(location, context.LspLocation)), + jsonOptions); + } private static bool TryWriteEmptyFormattedResult(QueryCommandOptions options, JsonSerializerOptions jsonOptions) { @@ -1256,19 +1263,57 @@ private static void WriteFormattedCount(int count, JsonSerializerOptions jsonOpt private static void WriteCompactLocations(IEnumerable locations, JsonSerializerOptions jsonOptions) { - var rows = new JsonArray(); - foreach (var location in locations) + var itemOptions = GetCompactJsonOptions(jsonOptions); + WriteJsonArray( + locations, + (writer, location) => + { + writer.Write("{\"file\":"); + writer.Write(JsonSerializer.Serialize(location.File, itemOptions)); + writer.Write(",\"line\":"); + writer.Write(location.Line.ToString(CultureInfo.InvariantCulture)); + if (location.Column.HasValue) + { + writer.Write(",\"column\":"); + writer.Write(location.Column.Value.ToString(CultureInfo.InvariantCulture)); + } + writer.Write('}'); + }, + jsonOptions); + } + + private static void WriteJsonArray(IEnumerable items, Action writeItem, JsonSerializerOptions jsonOptions) + { + var writer = Console.Out; + if (!jsonOptions.WriteIndented) { - var row = new JsonObject + writer.Write('['); + var first = true; + foreach (var item in items) { - ["file"] = location.File, - ["line"] = location.Line, - }; - if (location.Column.HasValue) - row["column"] = location.Column.Value; - rows.Add(row); + if (!first) + writer.Write(','); + writeItem(writer, item); + first = false; + } + writer.WriteLine(']'); + return; } - Console.WriteLine(rows.ToJsonString(jsonOptions)); + + writer.WriteLine("["); + var wroteAny = false; + foreach (var item in items) + { + if (wroteAny) + writer.WriteLine(","); + writer.Write(" "); + writeItem(writer, item); + wroteAny = true; + } + + if (wroteAny) + writer.WriteLine(); + writer.WriteLine("]"); } private static void WriteDelimitedLocations(IEnumerable locations, string outputFormat) @@ -1308,51 +1353,44 @@ private static void WriteQuickfix(IEnumerable<(string Path, int Line, int Column private static void WriteSarif(IEnumerable<(string Path, int Line, int Column, string Message, string RuleId)> items, JsonSerializerOptions jsonOptions) { - var results = new JsonArray(); + var writer = Console.Out; + var itemOptions = GetCompactJsonOptions(jsonOptions); + writer.Write("{\"version\":\"2.1.0\",\"runs\":[{\"tool\":{\"driver\":{\"name\":\"cdidx\",\"informationUri\":\"https://github.com/Widthdom/CodeIndex\"}},\"results\":"); + WriteJsonArrayInline( + items, + (resultWriter, item) => WriteSarifResult(resultWriter, item, itemOptions), + separator: ","); + writer.WriteLine("}]}"); + } + + private static void WriteJsonArrayInline(IEnumerable items, Action writeItem, string separator) + { + var writer = Console.Out; + writer.Write('['); + var first = true; foreach (var item in items) { - results.Add(new JsonObject - { - ["ruleId"] = item.RuleId, - ["message"] = new JsonObject { ["text"] = item.Message }, - ["locations"] = new JsonArray - { - new JsonObject - { - ["physicalLocation"] = new JsonObject - { - ["artifactLocation"] = new JsonObject { ["uri"] = item.Path }, - ["region"] = new JsonObject - { - ["startLine"] = Math.Max(1, item.Line), - ["startColumn"] = Math.Max(1, item.Column), - }, - }, - }, - }, - }); + if (!first) + writer.Write(separator); + writeItem(writer, item); + first = false; } + writer.Write(']'); + } - var payload = new JsonObject - { - ["version"] = "2.1.0", - ["runs"] = new JsonArray - { - new JsonObject - { - ["tool"] = new JsonObject - { - ["driver"] = new JsonObject - { - ["name"] = "cdidx", - ["informationUri"] = "https://github.com/Widthdom/CodeIndex", - }, - }, - ["results"] = results, - }, - }, - }; - Console.WriteLine(payload.ToJsonString(jsonOptions)); + private static void WriteSarifResult(TextWriter writer, (string Path, int Line, int Column, string Message, string RuleId) item, JsonSerializerOptions jsonOptions) + { + writer.Write("{\"ruleId\":"); + writer.Write(JsonSerializer.Serialize(item.RuleId, jsonOptions)); + writer.Write(",\"message\":{\"text\":"); + writer.Write(JsonSerializer.Serialize(item.Message, jsonOptions)); + writer.Write("},\"locations\":[{\"physicalLocation\":{\"artifactLocation\":{\"uri\":"); + writer.Write(JsonSerializer.Serialize(item.Path, jsonOptions)); + writer.Write("},\"region\":{\"startLine\":"); + writer.Write(Math.Max(1, item.Line).ToString(CultureInfo.InvariantCulture)); + writer.Write(",\"startColumn\":"); + writer.Write(Math.Max(1, item.Column).ToString(CultureInfo.InvariantCulture)); + writer.Write("}}}]}"); } public static int RunDefinition(string[] cmdArgs, JsonSerializerOptions jsonOptions) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 69dcabf1ed..8c06f2b9b7 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -40,6 +40,68 @@ public void RunSearch_FormatCompactEmitsFileLineOnly_Issue1642() } } + [Fact] + public void RunSearch_FormatLspEmitsLocationArray() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_format_lsp"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/app.cs", + "csharp", + "public class App { void Run() { Authenticate(); } }"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["Authenticate", "--db", dbPath, "--format", "lsp"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var row = Assert.Single(document.RootElement.EnumerateArray()); + Assert.EndsWith("/src/app.cs", row.GetProperty("uri").GetString(), StringComparison.Ordinal); + Assert.True(row.GetProperty("range").TryGetProperty("start", out _)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSearch_FormatSarifEmitsResultsArray() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_format_sarif"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/app.cs", + "csharp", + "public class App { void Run() { Authenticate(); } }"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["Authenticate", "--db", dbPath, "--format", "sarif"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var root = document.RootElement; + Assert.Equal("2.1.0", root.GetProperty("version").GetString()); + var result = Assert.Single(root.GetProperty("runs")[0].GetProperty("results").EnumerateArray()); + Assert.Equal("search", result.GetProperty("ruleId").GetString()); + Assert.Equal("src/app.cs", result.GetProperty("locations")[0].GetProperty("physicalLocation").GetProperty("artifactLocation").GetProperty("uri").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunSearch_FormatCsvEmitsDelimitedRows_Issue1941() {