From 9c20642cea88c7fdc9f27ec963aab0c07cf71a62 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 24 May 2026 22:45:33 +0900 Subject: [PATCH] Fix reference kind histograms (#1777) --- changelog.d/unreleased/1777.fixed.md | 17 ++++ src/CodeIndex/Cli/QueryCommandRunner.cs | 38 +++++---- .../Database/DbReader.GraphQueries.cs | 77 ++++++++++--------- tests/CodeIndex.Tests/DbReaderTests.cs | 54 ++++++++++++- .../QueryCommandRunnerTests.cs | 8 +- 5 files changed, 137 insertions(+), 57 deletions(-) create mode 100644 changelog.d/unreleased/1777.fixed.md diff --git a/changelog.d/unreleased/1777.fixed.md b/changelog.d/unreleased/1777.fixed.md new file mode 100644 index 0000000000..379c865b6f --- /dev/null +++ b/changelog.d/unreleased/1777.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 1777 +affected: + - src/CodeIndex/Database/DbReader.GraphQueries.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - tests/CodeIndex.Tests/DbReaderTests.cs +--- + +## English + +- **Caller/callee reference kind counts now preserve per-kind histograms (#1777)** - grouped `callers` / `callees` rows now populate `reference_kind_counts` / `referenceKindCounts` from the actual kind distribution, so a row with five `call` edges and one `instantiate` edge no longer collapses to an indistinguishable distinct-kind list. + +## 日本語 + +- **caller/callee の reference kind count が kind ごとのヒストグラムを保持するようになりました (#1777)** - grouped `callers` / `callees` 行は実際の kind 分布から `reference_kind_counts` / `referenceKindCounts` を埋めるため、5 件の `call` と 1 件の `instantiate` を持つ行が distinct kind の一覧だけに潰れなくなりました。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 9710ca3b14..2c16fa89a2 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -849,10 +849,10 @@ public static int RunCallers(string[] cmdArgs, JsonSerializerOptions jsonOptions } else { - var kindColumnWidth = ComputeReferenceKindColumnWidth(results, r => FormatReferenceKindLabel(r.ReferenceKind, r.ReferenceKinds, r.HasMixedReferenceKinds)); + var kindColumnWidth = ComputeReferenceKindColumnWidth(results, r => FormatReferenceKindLabel(r.ReferenceKind, r.ReferenceKinds, r.HasMixedReferenceKinds, r.ReferenceKindCounts)); foreach (var r in results) { - var kindLabel = FormatReferenceKindLabel(r.ReferenceKind, r.ReferenceKinds, r.HasMixedReferenceKinds); + var kindLabel = FormatReferenceKindLabel(r.ReferenceKind, r.ReferenceKinds, r.HasMixedReferenceKinds, r.ReferenceKindCounts); Console.WriteLine($"{kindLabel.PadRight(kindColumnWidth)} {r.CallerKind ?? "?",-10} {r.CallerName ?? "",-32} {r.Path}:{r.FirstLine} -> {r.CalleeName} ({r.ReferenceCount} refs)"); } var callerFileCount = results.Select(r => r.Path).Distinct().Count(); @@ -975,10 +975,10 @@ public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions } else { - var kindColumnWidth = ComputeReferenceKindColumnWidth(results, r => FormatReferenceKindLabel(r.ReferenceKind, r.ReferenceKinds, r.HasMixedReferenceKinds)); + var kindColumnWidth = ComputeReferenceKindColumnWidth(results, r => FormatReferenceKindLabel(r.ReferenceKind, r.ReferenceKinds, r.HasMixedReferenceKinds, r.ReferenceKindCounts)); foreach (var r in results) { - var kindLabel = FormatReferenceKindLabel(r.ReferenceKind, r.ReferenceKinds, r.HasMixedReferenceKinds); + var kindLabel = FormatReferenceKindLabel(r.ReferenceKind, r.ReferenceKinds, r.HasMixedReferenceKinds, r.ReferenceKindCounts); Console.WriteLine($"{kindLabel.PadRight(kindColumnWidth)} {r.CalleeName,-32} {r.Path}:{r.FirstLine} <- {r.CallerName ?? ""} ({r.ReferenceCount} refs)"); } var calleeFileCount = results.Select(r => r.Path).Distinct().Count(); @@ -5006,17 +5006,25 @@ private static string GetUsageLineOrThrow(string commandName) => ConsoleUi.GetUsageLine(commandName) ?? throw new InvalidOperationException($"Missing usage line for command '{commandName}'."); - // Human-readable reference_kind label for a grouped caller/callee row. When the - // group spans multiple kinds (e.g. `call` + `subscribe`), render them joined with - // `+` so the operator sees that the grouped row hides mixed semantics (issue #501). - // 単一ラベルに畳まれた reference_kind を人間向けに整形する。複数 kind が混在する - // 行 (`call` + `subscribe` など) は `+` 区切りで並べ、畳まれて見えなくなる意味の - // 違いを運用者が気付けるようにする (issue #501)。 - private static string FormatReferenceKindLabel(string primary, IReadOnlyList kinds, bool hasMixed) - { - if (!hasMixed || kinds == null || kinds.Count <= 1) - return primary ?? string.Empty; - return string.Join("+", kinds); + // Human-readable reference_kind label for a grouped caller/callee row. Counts + // keep high-volume relationships visible without requiring JSON re-querying. + // grouped caller/callee 行の人間向け reference_kind ラベル。count を併記して、 + // JSON で再取得しなくても高頻度の関係が見えるようにする。 + private static string FormatReferenceKindLabel(string primary, IReadOnlyList kinds, bool hasMixed, IReadOnlyDictionary? counts) + { + if (counts == null || counts.Count == 0) + { + if (!hasMixed || kinds == null || kinds.Count <= 1) + return primary ?? string.Empty; + return string.Join("+", kinds); + } + + var orderedKinds = kinds is { Count: > 0 } && kinds.Any(kind => counts.TryGetValue(kind, out var count) && count > 0) + ? kinds + : counts.Keys.Where(kind => counts[kind] > 0).OrderBy(kind => kind, StringComparer.Ordinal).ToArray(); + return string.Join(", ", orderedKinds + .Where(kind => counts.TryGetValue(kind, out var count) && count > 0) + .Select(kind => counts[kind] == 1 ? kind : $"{kind} x{counts[kind]}")); } // Pick a column width that fits every label in the current batch so mixed-kind diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index 792735651a..16789e8fb1 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -37,9 +37,8 @@ public List GetCallers(string query, int limit = 20, string? lang WITH logical_references AS ( SELECT f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, " + groupedReferenceKindSql + @" AS reference_kind, - " + ReferenceKindCountSql("r.reference_kind", "call") + @" AS call_count, - " + ReferenceKindCountSql("r.reference_kind", "instantiate") + @" AS instantiate_count, - " + ReferenceKindCountSql("r.reference_kind", "subscribe") + @" AS subscribe_count, + r.reference_kind AS raw_reference_kind, + COUNT(*) AS reference_count, " + ReferenceWeightedScoreSql("r.reference_kind") + @" AS weighted_score, r.line, MAX(" + selfReferenceSql + @") AS is_self_reference, @@ -53,9 +52,7 @@ AND r.reference_kind IN " + CallGraphReferenceKindsSql + @" SELECT f.path, f.lang, " + BuildCallerKindProjectionSql("r") + @" AS container_kind, " + BuildCallerNameProjectionSql("r") + @" AS container_name, r.symbol_name, r.reference_kind, MIN(r.line) AS first_line, COUNT(*) AS reference_count, GROUP_CONCAT(DISTINCT r.reference_kind) AS reference_kinds, - " + ReferenceKindCountSql("r.reference_kind", "call") + @" AS call_count, - " + ReferenceKindCountSql("r.reference_kind", "instantiate") + @" AS instantiate_count, - " + ReferenceKindCountSql("r.reference_kind", "subscribe") + @" AS subscribe_count, + r.reference_kind || ':' || COUNT(*) AS reference_kind_counts, " + ReferenceWeightedScoreSql("r.reference_kind") + @" AS weighted_score, MAX(" + selfReferenceSql + @") AS is_self_reference, MAX(" + mutualRecursionSql + @") AS is_mutual_recursion @@ -107,15 +104,13 @@ FROM symbol_references r if (referenceKind == null) { sql += @" - GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.file_id, r.line, r.column_number, " + groupedReferenceKindGroupSql + @" + GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.file_id, r.line, r.column_number, " + groupedReferenceKindGroupSql + @", r.reference_kind ) SELECT path, lang, " + BuildCallerKindProjectionSql("r") + @" AS container_kind, " + BuildCallerNameProjectionSql("r") + @" AS container_name, symbol_name, " + (rawKinds ? GetGroupedCallerReferenceKindSql("r.reference_kind") : "MIN(r.reference_kind)") + @" AS reference_kind, - MIN(line) AS first_line, COUNT(*) AS reference_count, + MIN(line) AS first_line, SUM(r.reference_count) AS reference_count, GROUP_CONCAT(DISTINCT r.reference_kind) AS reference_kinds, - SUM(r.call_count) AS call_count, - SUM(r.instantiate_count) AS instantiate_count, - SUM(r.subscribe_count) AS subscribe_count, + GROUP_CONCAT(r.raw_reference_kind || ':' || r.reference_count) AS reference_kind_counts, SUM(r.weighted_score) AS weighted_score, MAX(r.is_self_reference) AS is_self_reference, MAX(r.is_mutual_recursion) AS is_mutual_recursion @@ -162,7 +157,7 @@ FROM logical_references r { var primaryKind = reader.GetString(5); var kinds = ParseDistinctReferenceKinds(GetNullableString(reader, 8), primaryKind); - var counts = BuildReferenceKindCounts(reader.GetInt32(9), reader.GetInt32(10), reader.GetInt32(11)); + var counts = ParseReferenceKindCounts(GetNullableString(reader, 9), primaryKind, reader.GetInt32(7)); results.Add(new CallerResult { Path = reader.GetString(0), @@ -174,11 +169,11 @@ FROM logical_references r ReferenceKinds = kinds, HasMixedReferenceKinds = kinds.Count > 1, ReferenceKindCounts = counts, - ReferenceWeightScore = reader.GetDouble(12), + ReferenceWeightScore = reader.GetDouble(10), FirstLine = reader.GetInt32(6), ReferenceCount = reader.GetInt32(7), - HasSelfReference = reader.GetInt32(13) != 0, - HasMutualRecursion = reader.GetInt32(14) != 0, + HasSelfReference = reader.GetInt32(11) != 0, + HasMutualRecursion = reader.GetInt32(12) != 0, }); } return results; @@ -380,9 +375,8 @@ public List GetCallees(string query, int limit = 20, string? lang WITH logical_references AS ( SELECT f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, {preferredCalleeKindSql} AS reference_kind, - {ReferenceKindCountSql("r.reference_kind", "call")} AS call_count, - {ReferenceKindCountSql("r.reference_kind", "instantiate")} AS instantiate_count, - {ReferenceKindCountSql("r.reference_kind", "subscribe")} AS subscribe_count, + r.reference_kind AS raw_reference_kind, + COUNT(*) AS reference_count, {ReferenceWeightedScoreSql("r.reference_kind")} AS weighted_score, r.line FROM symbol_references r @@ -394,9 +388,7 @@ AND r.reference_kind IN {CallGraphReferenceKindsSql} SELECT f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.reference_kind, MIN(r.line) AS first_line, COUNT(*) AS reference_count, GROUP_CONCAT(DISTINCT r.reference_kind) AS reference_kinds, - " + ReferenceKindCountSql("r.reference_kind", "call") + @" AS call_count, - " + ReferenceKindCountSql("r.reference_kind", "instantiate") + @" AS instantiate_count, - " + ReferenceKindCountSql("r.reference_kind", "subscribe") + @" AS subscribe_count, + r.reference_kind || ':' || COUNT(*) AS reference_kind_counts, " + ReferenceWeightedScoreSql("r.reference_kind") + @" AS weighted_score FROM symbol_references r JOIN files f ON r.file_id = f.id @@ -440,14 +432,12 @@ FROM symbol_references r if (referenceKind == null) { sql += @" - GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.file_id, r.line, r.column_number + GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.file_id, r.line, r.column_number, r.reference_kind ) SELECT path, lang, container_kind, container_name, symbol_name, - reference_kind, MIN(line) AS first_line, COUNT(*) AS reference_count, + reference_kind, MIN(line) AS first_line, SUM(r.reference_count) AS reference_count, GROUP_CONCAT(DISTINCT reference_kind) AS reference_kinds, - SUM(r.call_count) AS call_count, - SUM(r.instantiate_count) AS instantiate_count, - SUM(r.subscribe_count) AS subscribe_count, + GROUP_CONCAT(r.raw_reference_kind || ':' || r.reference_count) AS reference_kind_counts, SUM(r.weighted_score) AS weighted_score FROM logical_references r GROUP BY path, lang, container_kind, container_name, symbol_name, reference_kind"; @@ -495,7 +485,7 @@ FROM logical_references r { var primaryKind = reader.GetString(5); var kinds = ParseDistinctReferenceKinds(GetNullableString(reader, 8), primaryKind); - var counts = BuildReferenceKindCounts(reader.GetInt32(9), reader.GetInt32(10), reader.GetInt32(11)); + var counts = ParseReferenceKindCounts(GetNullableString(reader, 9), primaryKind, reader.GetInt32(7)); results.Add(new CalleeResult { Path = reader.GetString(0), @@ -507,7 +497,7 @@ FROM logical_references r ReferenceKinds = kinds, HasMixedReferenceKinds = kinds.Count > 1, ReferenceKindCounts = counts, - ReferenceWeightScore = reader.GetDouble(12), + ReferenceWeightScore = reader.GetDouble(10), FirstLine = reader.GetInt32(6), ReferenceCount = reader.GetInt32(7), }); @@ -688,9 +678,6 @@ FROM symbol_references r return ExecuteCountSummary(cmd); } - private static string ReferenceKindCountSql(string columnSql, string kind) => - $"SUM(CASE WHEN {columnSql} = '{kind}' THEN 1 ELSE 0 END)"; - private static string ReferenceWeightedScoreSql(string columnSql) => $@" SUM(CASE {columnSql} WHEN 'instantiate' THEN 3.0 @@ -706,14 +693,30 @@ ELSE 0.0 _ => "weighted_score DESC, reference_count DESC", }; - private static IReadOnlyDictionary BuildReferenceKindCounts(int callCount, int instantiateCount, int subscribeCount) + private static IReadOnlyDictionary ParseReferenceKindCounts(string? aggregate, string primaryKind, int fallbackCount) { - return new Dictionary(StringComparer.Ordinal) + var counts = new Dictionary(StringComparer.Ordinal); + counts["call"] = 0; + counts["instantiate"] = 0; + counts["subscribe"] = 0; + if (!string.IsNullOrWhiteSpace(aggregate)) { - ["call"] = callCount, - ["instantiate"] = instantiateCount, - ["subscribe"] = subscribeCount, - }; + foreach (var entry in aggregate.Split(',')) + { + var separator = entry.LastIndexOf(':'); + if (separator <= 0 || separator == entry.Length - 1) + continue; + var kind = entry[..separator].Trim(); + if (kind.Length == 0 || !int.TryParse(entry[(separator + 1)..], out var count)) + continue; + counts[kind] = counts.TryGetValue(kind, out var existing) + ? existing + count + : count; + } + } + if (counts.Count == 0 && !string.IsNullOrEmpty(primaryKind)) + counts[primaryKind] = fallbackCount; + return counts; } /// diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index eb783a00fa..da97cc0550 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -10410,7 +10410,7 @@ public void GetCallers_ExposesDistinctReferenceKindsForMixedGroups() ChunkIndex = 0, StartLine = 1, EndLine = 12, - Content = "public class MixedOwner { public void Setup() { Changed += Handler; Changed(); } }\n", + Content = "public class MixedOwner { public void Setup() { Changed += Handler; Changed(); Changed(); Changed(); Changed(); Changed(); } }\n", } ]); _writer.InsertReferences([ @@ -10436,14 +10436,60 @@ public void GetCallers_ExposesDistinctReferenceKindsForMixedGroups() ContainerKind = "function", ContainerName = "Setup", }, + new ReferenceRecord + { + FileId = fileId, + SymbolName = "Changed", + ReferenceKind = "call", + Line = 1, + Column = 73, + Context = "Changed();", + ContainerKind = "function", + ContainerName = "Setup", + }, + new ReferenceRecord + { + FileId = fileId, + SymbolName = "Changed", + ReferenceKind = "call", + Line = 1, + Column = 84, + Context = "Changed();", + ContainerKind = "function", + ContainerName = "Setup", + }, + new ReferenceRecord + { + FileId = fileId, + SymbolName = "Changed", + ReferenceKind = "call", + Line = 1, + Column = 95, + Context = "Changed();", + ContainerKind = "function", + ContainerName = "Setup", + }, + new ReferenceRecord + { + FileId = fileId, + SymbolName = "Changed", + ReferenceKind = "call", + Line = 1, + Column = 106, + Context = "Changed();", + ContainerKind = "function", + ContainerName = "Setup", + }, ]); var caller = Assert.Single(_reader.GetCallers("Changed", lang: "csharp", exact: true, pathPatterns: ["mixed_kind_caller"])); Assert.Equal("Setup", caller.CallerName); Assert.Equal("Changed", caller.CalleeName); - Assert.Equal(2, caller.ReferenceCount); + Assert.Equal(6, caller.ReferenceCount); Assert.True(caller.HasMixedReferenceKinds); Assert.Equal(new[] { "event", "invoke" }, caller.ReferenceKinds); + Assert.Equal(5, caller.ReferenceKindCounts["call"]); + Assert.Equal(1, caller.ReferenceKindCounts["subscribe"]); Assert.Equal("event", caller.ReferenceKind); // `callees` rows are already split per kind, so each grouped row stays @@ -10456,12 +10502,16 @@ public void GetCallers_ExposesDistinctReferenceKindsForMixedGroups() Assert.Equal("event", callees[0].ReferenceKind); Assert.False(callees[0].HasMixedReferenceKinds); Assert.Equal(new[] { "event" }, callees[0].ReferenceKinds); + Assert.Equal(1, callees[0].ReferenceKindCounts["subscribe"]); Assert.Equal("invoke", callees[1].ReferenceKind); Assert.False(callees[1].HasMixedReferenceKinds); Assert.Equal(new[] { "invoke" }, callees[1].ReferenceKinds); + Assert.Equal(5, callees[1].ReferenceKindCounts["call"]); var rawCaller = Assert.Single(_reader.GetCallers("Changed", lang: "csharp", exact: true, pathPatterns: ["mixed_kind_caller"], rawKinds: true)); Assert.Equal(new[] { "call", "subscribe" }, rawCaller.ReferenceKinds); + Assert.Equal(5, rawCaller.ReferenceKindCounts["call"]); + Assert.Equal(1, rawCaller.ReferenceKindCounts["subscribe"]); Assert.Equal("subscribe", rawCaller.ReferenceKind); } diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 66508426e3..6fda4c3802 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -13120,6 +13120,8 @@ private void OnChanged(object? sender, EventArgs e) { } Assert.True(json.GetProperty("has_mixed_reference_kinds").GetBoolean()); var kinds = json.GetProperty("reference_kinds").EnumerateArray().Select(k => k.GetString()).ToArray(); Assert.Equal(new[] { "event", "invoke" }, kinds); + Assert.Equal(1, json.GetProperty("reference_kind_counts").GetProperty("call").GetInt32()); + Assert.Equal(1, json.GetProperty("reference_kind_counts").GetProperty("subscribe").GetInt32()); Assert.Equal("event", json.GetProperty("reference_kind").GetString()); var (humanExitCode, humanStdout, humanStderr) = CaptureConsole(() => QueryCommandRunner.RunCallers( @@ -13127,7 +13129,7 @@ private void OnChanged(object? sender, EventArgs e) { } _jsonOptions)); Assert.Equal(CommandExitCodes.Success, humanExitCode); - Assert.Contains("event+invoke", humanStdout); + Assert.Contains("call, subscribe", humanStdout); Assert.Contains("SetupAndFire", humanStdout); Assert.Contains("-> Changed (2 refs)", humanStdout); Assert.Contains("(1 callers in 1 files)", humanStderr); @@ -13272,9 +13274,9 @@ public class Factory _jsonOptions)); Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Contains("invoke function DerivedWidget", stdout); + Assert.Contains("call function DerivedWidget", stdout); Assert.Contains("src/DerivedWidget.cs:3 -> BaseWidget (1 refs)", stdout); - Assert.Contains("invoke function Make", stdout); + Assert.Contains("instantiate function Make", stdout); Assert.Contains("src/Factory.cs:3 -> BaseWidget (1 refs)", stdout); Assert.Contains("(2 callers in 2 files)", stderr); }