From 7e685a5c30fa54c61b8e01358dc57a9a8e79228f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 30 May 2026 17:36:02 +0900 Subject: [PATCH 1/3] Fix deterministic definition symbol ordering (#2015) --- changelog.d/unreleased/2015.fixed.md | 15 +++++++++++++++ src/CodeIndex/Database/DbSymbolReader.cs | 3 ++- tests/CodeIndex.Tests/DbReaderTests.cs | 22 ++++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/2015.fixed.md diff --git a/changelog.d/unreleased/2015.fixed.md b/changelog.d/unreleased/2015.fixed.md new file mode 100644 index 0000000000..e5f94f8252 --- /dev/null +++ b/changelog.d/unreleased/2015.fixed.md @@ -0,0 +1,15 @@ +--- +category: fixed +issues: + - 2015 +affected: + - src/CodeIndex/Database/DbSymbolReader.cs +--- + +## English + +- **Definition symbol ordering now has a stable column tie-breaker (#2015)** — symbol searches now order same-name, same-path, same-line results by `start_column` and symbol id so definition output stays deterministic. + +## 日本語 + +- **definition のシンボル順序に安定した column tie-breaker を追加しました (#2015)** — symbol search は同じ name / path / line の結果を `start_column` と symbol id で並べるため、definition 出力が決定的になりました。 diff --git a/src/CodeIndex/Database/DbSymbolReader.cs b/src/CodeIndex/Database/DbSymbolReader.cs index 120c606f19..d60eee85e4 100644 --- a/src/CodeIndex/Database/DbSymbolReader.cs +++ b/src/CodeIndex/Database/DbSymbolReader.cs @@ -518,7 +518,8 @@ FROM symbols s "WHEN @preferCaseInsensitiveNormalizedSqlMatch = 1 AND f.lang = 'sql' AND sql_segment_count(s.name) = @rawQuerySegmentCount AND sql_normalize_name_folded(s.name) = @rawQueryNormalizedFolded THEN 3 " + "WHEN @preferCaseInsensitiveSqlLeafMatch = 1 AND f.lang = 'sql' AND sql_leaf_name_folded(s.name) = @rawQueryLeafFolded THEN 4 " + "ELSE 5 END, " + - $"{PathBucketOrder}, {VisibilityOrder}, s.name, f.path, s.line LIMIT @limit"; + $"{PathBucketOrder}, {VisibilityOrder}, s.name, f.path, s.line, " + + $"{GetSymbolColumnSql("start_column", "CAST(2147483647 AS INTEGER)")} ASC, s.id ASC LIMIT @limit"; cmd.CommandText = sql; if (effectiveQueries != null) diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index 67eb4a942d..f964ff5014 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -2055,6 +2055,28 @@ public void SearchSymbols_FindsByName() Assert.Equal("src/auth.py", results[0].Path); } + [Fact] + public void SearchSymbols_BreaksSameLineTiesByStartColumn() + { + var fileId = _writer.UpsertFile(new FileRecord + { + Path = "src/same_line_symbols.cs", + Lang = "csharp", + Size = 100, + Lines = 1, + Modified = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc), + }); + _writer.InsertSymbols( + [ + new SymbolRecord { FileId = fileId, Kind = "function", Name = "SameLine", Line = 1, StartLine = 1, StartColumn = 30, EndLine = 1, Signature = "late SameLine()" }, + new SymbolRecord { FileId = fileId, Kind = "function", Name = "SameLine", Line = 1, StartLine = 1, StartColumn = 10, EndLine = 1, Signature = "early SameLine()" }, + ]); + + var results = _reader.SearchSymbols("SameLine", kind: "function", lang: "csharp", exact: true, pathPatterns: ["src/same_line_symbols.cs"]); + + Assert.Equal(["early SameLine()", "late SameLine()"], results.Select(result => result.Signature).ToArray()); + } + [Fact] public void SearchSymbols_CSharpOperatorsConversionsAndIndexersUseNavigableNames() { From f030600e9cc64ef0b96fd45accfb36e920710054 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 30 May 2026 17:36:34 +0900 Subject: [PATCH 2/3] Stabilize symbol hotspot tie ordering (#1934) --- changelog.d/unreleased/1934.fixed.md | 16 ++++++++++ src/CodeIndex/Database/DbSymbolReader.cs | 8 ++++- src/CodeIndex/Mcp/McpToolDefinitions.cs | 4 +-- tests/CodeIndex.Tests/DbReaderTests.cs | 40 ++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 changelog.d/unreleased/1934.fixed.md diff --git a/changelog.d/unreleased/1934.fixed.md b/changelog.d/unreleased/1934.fixed.md new file mode 100644 index 0000000000..61d623a270 --- /dev/null +++ b/changelog.d/unreleased/1934.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 1934 +affected: + - src/CodeIndex/Database/DbSymbolReader.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs +--- + +## English + +- **Symbol hotspots now break ranking ties deterministically (#1934)** — hotspot results now sort equal scores and counts by path, line, name, kind, and symbol id, and the MCP tool description documents that order. + +## 日本語 + +- **symbol hotspots の同点順位を決定的にしました (#1934)** — hotspot 結果は同じ score / count の行を path、line、name、kind、symbol id で並べ、MCP tool description でもその順序を明記しました。 diff --git a/src/CodeIndex/Database/DbSymbolReader.cs b/src/CodeIndex/Database/DbSymbolReader.cs index d60eee85e4..22319c74e7 100644 --- a/src/CodeIndex/Database/DbSymbolReader.cs +++ b/src/CodeIndex/Database/DbSymbolReader.cs @@ -2040,7 +2040,13 @@ LEFT JOIN conservative_reference_counts crc FROM grouped_rows gr JOIN reference_counts rc ON rc.symbol_id = gr.symbol_id WHERE rc.ref_count > 0 - ORDER BY rc.ref_score DESC, rc.ref_count DESC + ORDER BY rc.ref_score DESC, + rc.ref_count DESC, + gr.path COLLATE BINARY ASC, + gr.line ASC, + gr.name COLLATE BINARY ASC, + gr.kind COLLATE BINARY ASC, + gr.symbol_id ASC LIMIT @limit"; using var cmd = _conn.CreateCommand(); diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 3db42960fd..9a1bfb02f9 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -420,9 +420,9 @@ private JsonNode HandleToolsList(JsonNode? id) CreateToolDefinition( "symbol_hotspots", "Find the most-referenced symbols in the codebase (hotspot analysis). " - + "Returns symbols ordered by reference count. `groupBy` can be `symbol`, `file`, or `statement`; the default is symbol grouping for non-SQL scopes and statement grouping for SQL scopes to preserve existing SQL behavior. Names that are unique within the active language/kind candidate set use codebase-wide totals; duplicate-name families fall back to conservative same-file counts, and same-file duplicate rows may be grouped when the DB cannot disambiguate targets. Cross-file grouping of duplicate families is trusted only on indexes stamped with the current authoritative hotspot-family version. Useful for identifying central, high-impact code. " + + "Returns symbols ordered by reference score, reference count, then deterministic ties by path, line, name, kind, and symbol id. `groupBy` can be `symbol`, `file`, or `statement`; the default is symbol grouping for non-SQL scopes and statement grouping for SQL scopes to preserve existing SQL behavior. Names that are unique within the active language/kind candidate set use codebase-wide totals; duplicate-name families fall back to conservative same-file counts, and same-file duplicate rows may be grouped when the DB cannot disambiguate targets. Cross-file grouping of duplicate families is trusted only on indexes stamped with the current authoritative hotspot-family version. Useful for identifying central, high-impact code. " + "/ コードベースで最も参照されるシンボルを検索する(ホットスポット分析)。" - + "参照回数順にシンボルを返す。`groupBy` は `symbol` / `file` / `statement` を指定でき、既存 SQL 挙動を保つため既定は非 SQL scope では symbol、SQL scope では statement。active な言語/種別候補集合で一意な名前は codebase 全体の件数を使い、同名ファミリーは保守的な same-file 件数へフォールバックし、DB が対象を曖昧なく結べない同一ファイル重複行は集約される。duplicate family の cross-file 集約は current の authoritative hotspot-family version で stamp された index でのみ信頼する。中心的で影響の大きいコードの特定に有用。", + + "参照スコア、参照回数の順にシンボルを返し、同点は path、line、name、kind、symbol id で決定的に並べる。`groupBy` は `symbol` / `file` / `statement` を指定でき、既存 SQL 挙動を保つため既定は非 SQL scope では symbol、SQL scope では statement。active な言語/種別候補集合で一意な名前は codebase 全体の件数を使い、同名ファミリーは保守的な same-file 件数へフォールバックし、DB が対象を曖昧なく結べない同一ファイル重複行は集約される。duplicate family の cross-file 集約は current の authoritative hotspot-family version で stamp された index でのみ信頼する。中心的で影響の大きいコードの特定に有用。", new JsonObject { ["type"] = "object", diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index f964ff5014..590c40b71f 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -599,6 +599,46 @@ public void GetSymbolHotspots_RanksRealCallsAboveManyLowerWeightSubscribeEdges() Assert.Equal(1.5, hotspots[1].ReferenceScore, precision: 6); } + [Fact] + public void GetSymbolHotspots_BreaksEqualCountsByPathLineNameKind() + { + var betaFileId = _writer.UpsertFile(new FileRecord + { + Path = "src/z_hotspot_tie.cs", + Lang = "csharp", + Size = 100, + Lines = 10, + Modified = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc), + }); + var alphaFileId = _writer.UpsertFile(new FileRecord + { + Path = "src/a_hotspot_tie.cs", + Lang = "csharp", + Size = 100, + Lines = 10, + Modified = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc), + }); + + _writer.InsertSymbols( + [ + new SymbolRecord { FileId = betaFileId, Kind = "function", Name = "BetaTieTarget", Line = 5, StartLine = 5, EndLine = 7, BodyStartLine = 6, BodyEndLine = 7, Signature = "void BetaTieTarget()" }, + new SymbolRecord { FileId = alphaFileId, Kind = "function", Name = "AlphaTieTarget", Line = 5, StartLine = 5, EndLine = 7, BodyStartLine = 6, BodyEndLine = 7, Signature = "void AlphaTieTarget()" }, + ]); + _writer.InsertReferences( + [ + new ReferenceRecord { FileId = betaFileId, SymbolName = "BetaTieTarget", ReferenceKind = "call", Line = 8, Column = 9, Context = "BetaTieTarget();" }, + new ReferenceRecord { FileId = alphaFileId, SymbolName = "AlphaTieTarget", ReferenceKind = "call", Line = 8, Column = 9, Context = "AlphaTieTarget();" }, + ]); + + var hotspots = _reader.GetSymbolHotspots(limit: 10, kind: "function", lang: "csharp", pathPatterns: ["src/a_hotspot_tie.cs", "src/z_hotspot_tie.cs"], excludePathPatterns: null, excludeTests: false) + .Where(item => item.Symbol.Name.EndsWith("TieTarget", StringComparison.Ordinal)) + .ToList(); + + Assert.Collection(hotspots, + first => Assert.Equal("src/a_hotspot_tie.cs", first.Symbol.Path), + second => Assert.Equal("src/z_hotspot_tie.cs", second.Symbol.Path)); + } + [Fact] public void GetCallers_DefaultWeightedRankingPrioritizesInstantiateOverNoisySubscriptions() { From 32a34de433a57606a18a49553e9061f747265f92 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 30 May 2026 17:36:46 +0900 Subject: [PATCH 3/3] Keep reference dedupe container-specific (#1974) --- changelog.d/unreleased/1974.fixed.md | 17 +++++++++++++++++ .../ReferenceExtractor.TypeReferences.cs | 4 ++-- .../Indexer/References/ReferenceExtractor.cs | 11 +++++++---- .../Support/JvmMethodReferenceExtractor.cs | 2 +- .../CodeIndex.Tests/ReferenceExtractorTests.cs | 16 ++++++++++++++-- 5 files changed, 41 insertions(+), 9 deletions(-) create mode 100644 changelog.d/unreleased/1974.fixed.md diff --git a/changelog.d/unreleased/1974.fixed.md b/changelog.d/unreleased/1974.fixed.md new file mode 100644 index 0000000000..ef4b3d1170 --- /dev/null +++ b/changelog.d/unreleased/1974.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 1974 +affected: + - src/CodeIndex/Indexer/References/ReferenceExtractor.cs + - src/CodeIndex/Indexer/References/ReferenceExtractor.TypeReferences.cs + - src/CodeIndex/Indexer/References/Support/JvmMethodReferenceExtractor.cs +--- + +## English + +- **Reference deduplication now keeps distinct containers (#1974)** — reference extraction now includes container kind and name in the in-memory dedupe key so same-position references from different enclosing symbols are preserved. + +## 日本語 + +- **reference dedupe が異なる container を保持するようになりました (#1974)** — reference extraction はメモリ上の dedupe key に container kind / name を含めるため、異なる enclosing symbol からの同一位置 reference が保持されます。 diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.TypeReferences.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.TypeReferences.cs index 57c8bf27d9..7c59b79428 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.TypeReferences.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.TypeReferences.cs @@ -574,7 +574,7 @@ internal static void AddTypeReferenceSegment( return; int column = startInLine + 1; // 1-based / 1始まり - var dedupeKey = BuildReferenceDedupeKey(fileId, language, lineNumber, column, referenceKind, segment); + var dedupeKey = BuildReferenceDedupeKey(fileId, language, lineNumber, column, referenceKind, segment, container); if (!seen.Add(dedupeKey)) return; @@ -1791,7 +1791,7 @@ private static void AddChainReference( int lineNumber, SymbolRecord? container) { - var dedupeKey = BuildReferenceDedupeKey(fileId, null, lineNumber, column, referenceKind, name); + var dedupeKey = BuildReferenceDedupeKey(fileId, null, lineNumber, column, referenceKind, name, container); if (!seen.Add(dedupeKey)) return; diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.cs index e06ad488b2..26b75a8525 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.cs @@ -1220,7 +1220,7 @@ internal static void AddReference( string? language = null) { var column = nameIndex + 1; - var dedupeKey = BuildReferenceDedupeKey(fileId, language, lineNumber, column, referenceKind, name); + var dedupeKey = BuildReferenceDedupeKey(fileId, language, lineNumber, column, referenceKind, name, container); if (!seen.Add(dedupeKey)) return; @@ -1244,10 +1244,13 @@ internal static string BuildReferenceDedupeKey( int lineNumber, int column, string referenceKind, - string name) + string name, + SymbolRecord? container) { var languageSegment = string.IsNullOrWhiteSpace(language) ? "-" : language; - return $"{fileId}:{languageSegment}:{lineNumber}:{column}:{referenceKind}:{name}"; + var containerKindSegment = string.IsNullOrWhiteSpace(container?.Kind) ? "-" : container.Kind; + var containerNameSegment = string.IsNullOrWhiteSpace(container?.Name) ? "-" : container.Name; + return $"{fileId}:{languageSegment}:{lineNumber}:{column}:{referenceKind}:{containerKindSegment}:{containerNameSegment}:{name}"; } private static void EmitCSharpLambdaCaptureReferences( @@ -1761,7 +1764,7 @@ internal static void AddTypeReferenceSegments( if (!IsIgnoredTypeReferenceSegment(language, normalizedSegment, isEscapedCSharpIdentifier)) { int column = argStartInLine + offset + 1; // 1-based / 1始まり - var dedupeKey = BuildReferenceDedupeKey(fileId, language, lineNumber, column, "type_reference", normalizedSegment); + var dedupeKey = BuildReferenceDedupeKey(fileId, language, lineNumber, column, "type_reference", normalizedSegment, container); if (seen.Add(dedupeKey)) { references.Add(new ReferenceRecord diff --git a/src/CodeIndex/Indexer/References/Support/JvmMethodReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Support/JvmMethodReferenceExtractor.cs index 5c23821c1e..2e4aa83f14 100644 --- a/src/CodeIndex/Indexer/References/Support/JvmMethodReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Support/JvmMethodReferenceExtractor.cs @@ -222,7 +222,7 @@ private static void AddChainReference( SymbolRecord? container) { name = NormalizeMethodReferenceName(language, name); - var dedupeKey = ReferenceExtractor.BuildReferenceDedupeKey(fileId, language, lineNumber, column, "call", name); + var dedupeKey = ReferenceExtractor.BuildReferenceDedupeKey(fileId, language, lineNumber, column, "call", name, container); if (!seen.Add(dedupeKey)) return; diff --git a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs index 132df7f6ec..73ba753b46 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs @@ -114,14 +114,26 @@ def inspect_job(): [Fact] public void BuildReferenceDedupeKey_IncludesFileIdAndLanguage() { - var javaKey = ReferenceExtractor.BuildReferenceDedupeKey(1, "java", 3, 5, "type_reference", "Runner"); - var rustKey = ReferenceExtractor.BuildReferenceDedupeKey(2, "rust", 3, 5, "type_reference", "Runner"); + var javaKey = ReferenceExtractor.BuildReferenceDedupeKey(1, "java", 3, 5, "type_reference", "Runner", null); + var rustKey = ReferenceExtractor.BuildReferenceDedupeKey(2, "rust", 3, 5, "type_reference", "Runner", null); Assert.NotEqual(javaKey, rustKey); Assert.StartsWith("1:java:", javaKey, StringComparison.Ordinal); Assert.StartsWith("2:rust:", rustKey, StringComparison.Ordinal); } + [Fact] + public void BuildReferenceDedupeKey_IncludesContainerContext() + { + var leftContainer = new SymbolRecord { Kind = "function", Name = "A.foo" }; + var rightContainer = new SymbolRecord { Kind = "function", Name = "B.foo" }; + + var leftKey = ReferenceExtractor.BuildReferenceDedupeKey(1, "csharp", 3, 5, "call", "Bar", leftContainer); + var rightKey = ReferenceExtractor.BuildReferenceDedupeKey(1, "csharp", 3, 5, "call", "Bar", rightContainer); + + Assert.NotEqual(leftKey, rightKey); + } + [Fact] public void AddTypeReferenceSegment_DedupesWithinFileAndLanguageOnly() {