diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 4426a98540..5143461ee2 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1247,9 +1247,9 @@ same source location. | `--kind ` | `definition`, `references`, `callers`, `callees`, `symbols`, `hotspots`, `unused`, `validate` | Filter by kind (case-insensitive; `--kind FUNCTION` is treated as `--kind function`). `definition` / `symbols` / `hotspots` / `unused` use symbol kinds (`function`, `lambda`, `async_function`, `generator`, `async_generator`, `test.method`, `class`, `struct`, `interface`, `protocol`, `enum`, `property`, `event`, `delegate`, `namespace`, `import`); `references` accepts all indexed reference kinds (`call`, `instantiate`, `subscribe`, `attribute`, `annotation`, `type_reference`); `callers` / `callees` accept only the call-graph kinds (`call`, `instantiate`, `subscribe`) and reject non-call-graph kinds (`--kind attribute` / `--kind annotation` / `--kind type_reference`) with a usage error — metadata rows are attributed to the enclosing body-range symbol rather than the annotated target, and `type_reference` rows are compile-time type-position edges (declaration types, generic constraints, `is`/`as`/`instanceof`, XML-doc `cref`) rather than runtime calls, so `callers` / `callees` cannot answer either correctly; use `references --kind attribute` / `references --kind annotation` / `references --kind type_reference` instead. `references` defaults to every indexed reference kind so metadata usages remain visible, while `callers` / `callees` / `hotspots` / `impact` default to the call-graph kinds only (`call`, `instantiate`, `subscribe`) and exclude metadata edges (`attribute`, `annotation`, `type_reference`). Identical constructor `call` + `instantiate` rows at one physical site still collapse; `validate` uses issue kinds such as `bom` | | `--rank-by ` | `callers`, `callees` | Choose the caller/callee ranking model. `weighted` is the default and scores `instantiate=3.0`, `call=1.0`, `subscribe=0.1`; `count` sorts by raw `reference_count`; `kind` groups by reference kind first, then count. | | `--body` | `definition`, `references`, `callers`, `callees`, `impact`, `inspect` | Include reconstructed body content or capped graph-location excerpts | -| `--count` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `impact`, `unused`, `hotspots` | Return only counts. `search` / `definition` / `references` / `callers` / `callees` / `symbols` / `files` / `find` / `unused` ignore `--limit` and return authoritative totals; `impact` and `hotspots` still report the visible page count and may truncate with `--limit` (with `--json`: a single count object; commands that expose file counts add `files`) | +| `--count` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `impact`, `unused`, `hotspots` | Return only counts. `search` / `definition` / `references` / `callers` / `callees` / `symbols` / `files` / `find` / `unused` / `hotspots` ignore `--limit` and return authoritative totals; only `impact` still reports the visible page count and may truncate with `--limit` (with `--json`: a single count object; commands that expose file counts add `files`) | | `--group-by ` | `hotspots` | Choose the hotspot grouping unit. The default is `symbol` for non-SQL scopes and `statement` for `--lang sql`, preserving SQL's statement-oriented grouping; JSON includes `grouped_by` so mixed-language callers can verify the active unit. `file` rolls symbol hotspot volume up to target files. | -| `--group-by-name` | `hotspots` | Collapse rows that share the same `(name, kind)` across files into one representative result while preserving `definition_sites` / `paths` metadata in JSON. The top-level JSON `count` is the number of returned name/kind groups after `--limit`; use `definition_site_total` for the grouped definition-site total, each hotspot's `reference_count` for raw incoming references, and the count-only JSON `files` field for distinct files. Hotspot ordering uses a weighted invocation score (`call` / `instantiate` = 1.0, `subscribe` = 0.3) while still showing the raw reference count; metadata-only edges such as `attribute`, `annotation`, and `type_reference` remain excluded from default hotspots. | +| `--group-by-name` | `hotspots` | Collapse rows that share the same `(name, kind)` across files into one representative result while preserving `definition_sites` / `paths` metadata in JSON. Per-group `paths` samples are capped at 20 entries; `paths_truncated` signals that more definition paths exist. In normal JSON, the top-level `count` is the number of returned name/kind groups after `--limit`; with `--count`, `count`, `files`, and `definition_site_total` are full totals that ignore `--limit`. Use each hotspot's `reference_count` for raw incoming references. Hotspot ordering uses a weighted invocation score (`call` / `instantiate` = 1.0, `subscribe` = 0.3) while still showing the raw reference count; metadata-only edges such as `attribute`, `annotation`, and `type_reference` remain excluded from default hotspots. | | `--with-paths` | `impact` | Emit a `paths` array on each caller listing the shortest call chains `[resolvedRoot, intermediate..., callerName]`. Same-depth diamond convergence (e.g. `A → B → foo` and `A → C → foo`) surfaces both routes that the default dedup collapses. Per-row cap (10) keeps JSON payloads bounded; `paths_truncated` signals overflow. Off by default; default behavior is unchanged. | | `--start ` | `excerpt` | Start line for excerpt reconstruction (max: 10000000) | | `--end ` | `excerpt` | End line for excerpt reconstruction (defaults to `--start`; max: 10000000) | @@ -3537,9 +3537,9 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--lang ` | クエリ系 | 言語でフィルタ(大文字小文字を区別しない。`--lang Python` は `--lang python` と同じ扱い)。`c#`、`cs`、`kt`、`kts` のような一般的な別名も受け付ける。未知の値を指定すると、人間向け出力の 0 件応答に `Available: <言語一覧>` ヒントが付く。 | | `--kind ` | `definition`, `references`, `callers`, `callees`, `symbols`, `hotspots`, `unused`, `validate` | 種別でフィルタ(大文字小文字を区別しない。`--kind FUNCTION` は `--kind function` と同じ扱い)。`definition` / `symbols` / `hotspots` / `unused` は symbol kind(`function`、`lambda`、`async_function`、`generator`、`async_generator`、`test.method`、`class`、`struct`、`interface`、`protocol`、`enum`、`property`、`event`、`delegate`、`namespace`、`import`)、`references` は全ての reference kind(`call`、`instantiate`、`subscribe`、`attribute`、`annotation`、`type_reference`)を受け付ける。`callers` / `callees` は call-graph 種別のみ(`call`、`instantiate`、`subscribe`)を受け付け、非 call-graph 種別(`--kind attribute` / `--kind annotation` / `--kind type_reference`)は usage error で拒否する — metadata 行は注釈対象そのものではなく body-range 上の外側シンボルに帰属し、`type_reference` は宣言型・generic 制約・`is`/`as`/`instanceof`・XML-doc `cref` といった compile-time な型位置エッジであり実行時呼び出しではないため、`callers` / `callees` はいずれの kind にも正しく答えられない。metadata / 型位置参照の列挙は `references --kind attribute` / `references --kind annotation` / `references --kind type_reference` を使う。`references` の既定は全 reference kind を表示して metadata 参照も見えるままにするが、`callers` / `callees` / `hotspots` / `impact` の既定は call-graph kind(`call`、`instantiate`、`subscribe`)のみで、`attribute` / `annotation` / `type_reference` のような metadata edge は除外する。同じ物理位置にある constructor の `call` + `instantiate` 重複行は引き続き集約する。`validate` は `bom` などの issue kind を使う | | `--body` | `definition`, `references`, `callers`, `callees`, `impact`, `inspect` | 再構成した本文、または上限付きの graph 位置抜粋を含める | -| `--count` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `impact`, `unused`, `hotspots` | 件数だけを返す。`search` / `definition` / `references` / `callers` / `callees` / `symbols` / `files` / `find` / `unused` は `--limit` を無視した総件数を返し、`impact` と `hotspots` は visible page count のままで `--limit` によって切り詰められることがある(`--json` 併用時は単一の count オブジェクト。files 件数を出すコマンドは `files` も返す) | +| `--count` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `impact`, `unused`, `hotspots` | 件数だけを返す。`search` / `definition` / `references` / `callers` / `callees` / `symbols` / `files` / `find` / `unused` / `hotspots` は `--limit` を無視した総件数を返し、`impact` だけは visible page count のままで `--limit` によって切り詰められることがある(`--json` 併用時は単一の count オブジェクト。files 件数を出すコマンドは `files` も返す) | | `--group-by ` | `hotspots` | hotspot の集計単位を選ぶ。既定は非 SQL scope では `symbol`、`--lang sql` では既存の statement-oriented grouping を保つため `statement`。JSON には `grouped_by` が入り、mixed-language 呼び出しでも現在の単位を確認できる。`file` は symbol hotspot の参照量を対象ファイル単位にまとめる。 | -| `--group-by-name` | `hotspots` | ファイルをまたいで同じ `(name, kind)` を共有する行を代表1件に集約し、JSON では `definition_sites` / `paths` metadata を保持したまま返す。top-level JSON の `count` は `--limit` 適用後に返された name/kind group 数であり、grouped definition-site 合計は `definition_site_total`、生の incoming reference 数は各 hotspot の `reference_count`、distinct file 数は count-only JSON の `files` field を参照する。hotspot の順位付けは重み付き invocation score(`call` / `instantiate` = 1.0、`subscribe` = 0.3)を使い、生の reference count も引き続き表示する。`attribute` / `annotation` / `type_reference` のような metadata-only edge は既定の hotspots から除外されたまま。 | +| `--group-by-name` | `hotspots` | ファイルをまたいで同じ `(name, kind)` を共有する行を代表1件に集約し、JSON では `definition_sites` / `paths` metadata を保持したまま返す。group ごとの `paths` sample は 20 件に制限し、さらに definition path がある場合は `paths_truncated` を返す。通常 JSON の top-level `count` は `--limit` 適用後に返された name/kind group 数であり、`--count` 併用時の `count`、`files`、`definition_site_total` は `--limit` を無視した総数を返す。生の incoming reference 数は各 hotspot の `reference_count` を参照する。hotspot の順位付けは重み付き invocation score(`call` / `instantiate` = 1.0、`subscribe` = 0.3)を使い、生の reference count も引き続き表示する。`attribute` / `annotation` / `type_reference` のような metadata-only edge は既定の hotspots から除外されたまま。 | | `--with-paths` | `impact` | 各 caller に `paths` 配列を付け、`[resolvedRoot, 中間..., callerName]` の順で最短呼び出し経路を列挙する。同 depth で複数経路が収束するダイヤモンド(例: `A → B → foo` と `A → C → foo`)でも、既定 dedup で潰れる経路をすべて表示する。1 行あたりの保持上限は 10 経路で、超過時は `paths_truncated` を `true` にする。既定では出力しないため、フラグ未指定時の挙動は変更しない。 | | `--start ` | `excerpt` | 抜粋再構成の開始行(最大: 10000000) | | `--end ` | `excerpt` | 抜粋再構成の終了行(省略時は `--start` と同じ、最大: 10000000) | diff --git a/changelog.d/unreleased/2991.fixed.md b/changelog.d/unreleased/2991.fixed.md new file mode 100644 index 0000000000..e9bb42e8b8 --- /dev/null +++ b/changelog.d/unreleased/2991.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 2991 +affected: + - src/CodeIndex/Database/DbSymbolReader.cs + - tests/CodeIndex.Tests/DbReaderTests.cs +--- + +## English + +- **C# property hotspots no longer use repo-wide bare-name counts for common member names (#2991)** — `hotspots` now keeps C# properties such as record component `Equal` on the conservative in-target counting path, so unrelated same-name local references no longer inflate property rankings. + +## 日本語 + +- **C# property hotspot が一般的なメンバー名をリポジトリ全体の bare-name 件数で過集約しないよう修正しました (#2991)** — `hotspots` は record component の `Equal` のような C# property を保守的な in-target counting 経路に保つため、無関係な同名ローカル参照で property ranking が膨らまなくなりました。 diff --git a/changelog.d/unreleased/3153.fixed.md b/changelog.d/unreleased/3153.fixed.md new file mode 100644 index 0000000000..d6ee049568 --- /dev/null +++ b/changelog.d/unreleased/3153.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 3153 +affected: + - src/CodeIndex/Database/DbSymbolReader.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Models/QueryResults.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs + - USER_GUIDE.md +--- + +## English + +- **`hotspots --count` now ignores display limits (#3153)** — count-only hotspots use dedicated total-count queries for symbol, file, and same-name group modes, so `--limit` no longer caps `count`, `files`, or grouped `definition_site_total`. + +## 日本語 + +- **`hotspots --count` が表示上限に制限されないよう修正しました (#3153)** — count-only hotspots は symbol、file、same-name group の各 mode で専用の総件数 query を使うため、`--limit` が `count`、`files`、grouped `definition_site_total` を切り詰めなくなりました。 diff --git a/changelog.d/unreleased/3159.fixed.md b/changelog.d/unreleased/3159.fixed.md new file mode 100644 index 0000000000..b1a42b36d1 --- /dev/null +++ b/changelog.d/unreleased/3159.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 3159 +affected: + - src/CodeIndex/Database/DbSymbolReader.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Models/QueryResults.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs + - USER_GUIDE.md +--- + +## English + +- **Grouped hotspot path samples are now bounded (#3159)** — `hotspots --group-by-name --json` caps each group's `paths` array at 20 entries and reports `paths_truncated` when additional definition paths exist, keeping same-name aggregates from producing very large JSON payloads. + +## 日本語 + +- **Grouped hotspot の path sample に上限を設けました (#3159)** — `hotspots --group-by-name --json` は各 group の `paths` 配列を 20 件に制限し、追加の definition path がある場合は `paths_truncated` を返すため、同名集約で JSON payload が過大にならなくなりました。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 2e926f1631..9593377625 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -316,7 +316,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--with-paths", Description = "Impact: include shortest call chains per caller", Commands = Set("impact") }, new() { Name = "--reverse", Description = "Reverse direction (show dependents)", Commands = Set("deps") }, new() { Name = "--group-by", ValuePlaceholder = "", Description = "Hotspots: choose grouping unit", Commands = Set("hotspots") }, - new() { Name = "--group-by-name", Description = "Hotspots: collapse same-name rows; count is name/kind groups", Commands = Set("hotspots") }, + new() { Name = "--group-by-name", Description = "Hotspots: collapse same-name rows; JSON paths capped per group", Commands = Set("hotspots") }, new() { Name = "--check", Description = "Verify status freshness/readiness", Commands = Set("status") }, new() { Name = "--config", Description = "Print effective configuration with source attribution", Commands = Set("status") }, new() { Name = "--stale-after", ValuePlaceholder = "", Description = "Status: freshness age threshold (e.g. 30m, 2h, 7d)", Commands = Set("status") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index fe66e6528e..576252051e 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -1058,7 +1058,7 @@ private static void PrintFlagReference(Action WriteHelpLine) Console.WriteLine(" --severity validate only: filter issues by severity: info, warning, error"); Console.WriteLine(" --visibility Filter symbols/definitions/unused/hotspots by visibility: public, protected, internal, private"); WriteHelpLine(" --exclude-visibility Exclude symbols/definitions/unused/hotspots by visibility"); - WriteHelpLine(" --count Count only; search/definition/references/callers/callees/symbols/files/find/unused ignore --limit, impact/hotspots still use visible page counts"); + WriteHelpLine(" --count Count only; search/definition/references/callers/callees/symbols/files/find/unused/hotspots ignore --limit, impact still uses visible page counts"); Console.WriteLine(" --since Filter to files modified since this timestamp (ISO 8601)"); Console.WriteLine(" --no-dedup search only: return every raw overlapping chunk hit (debug/density)"); WriteHelpLine($" --require-before/--require-after search only: keep primary matches only when the guard query appears within --guard-window lines before/after the match (default {DbReader.DefaultSearchGuardWindow}, max {DbReader.MaxSearchGuardWindow})"); @@ -1068,7 +1068,7 @@ private static void PrintFlagReference(Action WriteHelpLine) WriteHelpLine(" --max-hops Max BFS hops for impact analysis, inclusive (default: 5; --max-hops 2 returns callers at hop 1 and 2; --max-hops 0 resolves the symbol without traversing callers)"); Console.WriteLine(" --depth Deprecated alias for --max-hops"); Console.WriteLine(" --reverse Reverse direction for deps (show dependents)"); - WriteHelpLine(" --group-by-name hotspots: collapse rows sharing (name, kind) across files; JSON count is the number of name/kind groups, not reference, file, or definition-site count"); + WriteHelpLine(" --group-by-name hotspots: collapse rows sharing (name, kind) across files; JSON paths are capped per group with paths_truncated"); WriteHelpLine(" --with-paths impact: also emit `paths` per caller — the shortest call chains [root, ..., caller] (diamond graphs surface every converging route, capped per row)"); WriteHelpLine(" unused reflection note C# nameof/typeof and direct reflection member-name literals such as GetMethod(\"Foo\") are indexed; dynamically constructed reflection names may need manual review"); WriteHelpLine(" Note: if a query itself starts with '-', pass it with --query or -- ; for option values that start with '--', use --opt=."); diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index b1a0536bca..3d6bd02b00 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -356,7 +356,8 @@ internal sealed record GroupedSymbolHotspotJsonResult( string? Visibility, string? Container, int DefinitionSites, - List Paths); + List Paths, + bool PathsTruncated); internal sealed record VersionInfoJsonResult( [property: JsonPropertyName("name")] string Name, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index fa49fe9ccb..a0098074e8 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -5065,6 +5065,36 @@ public static int RunHotspots(string[] cmdArgs, JsonSerializerOptions jsonOption reader.ScopeMayIncludeSqlSymbols(options.Kind, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests)); if (groupBy == HotspotsGroupedByNameKind) { + if (options.CountOnly) + { + var countSummary = reader.CountGroupedSymbolHotspots(options.Kind, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, visibilityFilters: options.VisibilityFilters, excludeVisibilityFilters: options.ExcludeVisibilityFilters); + var countSqlGraphSignal = countSummary.Count == 0 + ? zeroResultSqlGraphSignal + : NarrowSqlGraphContractSignal( + baseSqlGraphSignal, + reader.ScopeMayIncludeSqlSymbols(options.Kind, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests)); + if (options.Json) + { + var payload = countSummary.Count == 0 + ? BuildGroupedHotspotsZeroJsonPayload(reader, jsonOptions, countOnly: true, graphAvailable: reader._hasReferencesTable, queryOptions: options) + : new JsonObject + { + ["count"] = countSummary.Count, + ["files"] = countSummary.FileCount, + ["definition_site_total"] = countSummary.DefinitionSiteTotal, + ["grouped_by"] = HotspotsGroupedByNameKind, + }; + AddSqlGraphContractJsonFields(payload, countSqlGraphSignal); + Console.WriteLine(payload.ToJsonString(jsonOptions)); + } + else + { + Console.WriteLine($"{countSummary.Count}"); + WriteSqlGraphContractWarningIfNeeded(json: false, countSqlGraphSignal, reader, options); + } + return countSummary.Count == 0 ? ZeroResultExitCode(options) : CommandExitCodes.Success; + } + var groupedResults = reader.GetGroupedSymbolHotspots(options.Limit, options.Kind, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, visibilityFilters: options.VisibilityFilters, excludeVisibilityFilters: options.ExcludeVisibilityFilters); var effectiveSqlGraphSignal = groupedResults.Count == 0 ? zeroResultSqlGraphSignal @@ -5101,32 +5131,6 @@ public static int RunHotspots(string[] cmdArgs, JsonSerializerOptions jsonOption } var definitionSiteTotal = groupedResults.Sum(g => g.DefinitionSites); - var groupedFileCount = groupedResults - .SelectMany(g => g.Paths) - .Distinct(StringComparer.Ordinal) - .Count(); - - if (options.CountOnly) - { - if (options.Json) - { - var payload = new JsonObject - { - ["count"] = groupedResults.Count, - ["files"] = groupedFileCount, - ["definition_site_total"] = definitionSiteTotal, - ["grouped_by"] = HotspotsGroupedByNameKind, - }; - AddSqlGraphContractJsonFields(payload, effectiveSqlGraphSignal); - Console.WriteLine(payload.ToJsonString(jsonOptions)); - } - else - { - Console.WriteLine($"{groupedResults.Count}"); - WriteSqlGraphContractWarningIfNeeded(json: false, effectiveSqlGraphSignal, reader, options); - } - return CommandExitCodes.Success; - } if (options.Json) { @@ -5141,7 +5145,8 @@ public static int RunHotspots(string[] cmdArgs, JsonSerializerOptions jsonOption g.Symbol.Visibility, g.Symbol.ContainerName, g.DefinitionSites, - g.Paths)) + g.Paths, + g.PathsTruncated)) .ToList(); var payload = new JsonObject { @@ -5170,11 +5175,43 @@ public static int RunHotspots(string[] cmdArgs, JsonSerializerOptions jsonOption if (groupBy == HotspotsGroupedByFile) { + var fileHotspotSignal = reader.GetHotspotFamilySignal(options.Lang); + if (options.CountOnly) + { + var countSummary = reader.CountFileSymbolHotspots(options.Kind, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, visibilityFilters: options.VisibilityFilters, excludeVisibilityFilters: options.ExcludeVisibilityFilters); + var countSqlGraphSignal = countSummary.Count == 0 + ? zeroResultSqlGraphSignal + : NarrowSqlGraphContractSignal( + baseSqlGraphSignal, + reader.ScopeMayIncludeSqlSymbols(options.Kind, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests)); + if (options.Json) + { + var payload = new JsonObject + { + ["count"] = countSummary.Count, + ["files"] = countSummary.FileCount, + ["graph_table_available"] = reader._hasReferencesTable, + ["grouped_by"] = groupBy, + }; + AddHotspotFamilyJsonFields(payload, fileHotspotSignal); + AddSqlGraphContractJsonFields(payload, countSqlGraphSignal); + if (countSummary.Count == 0) + AddFreshnessHint(payload, reader); + Console.WriteLine(payload.ToJsonString(jsonOptions)); + } + else + { + Console.WriteLine($"{countSummary.Count}"); + WriteHotspotFamilyWarningIfNeeded(json: false, fileHotspotSignal); + WriteSqlGraphContractWarningIfNeeded(json: false, countSqlGraphSignal, reader, options); + } + return countSummary.Count == 0 ? ZeroResultExitCode(options) : CommandExitCodes.Success; + } + var fileResults = reader.GetFileSymbolHotspots(options.Limit, options.Kind, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, visibilityFilters: options.VisibilityFilters, excludeVisibilityFilters: options.ExcludeVisibilityFilters); var effectiveSqlGraphSignal = fileResults.Count == 0 ? zeroResultSqlGraphSignal : NarrowSqlGraphContractSignalByLanguages(baseSqlGraphSignal, fileResults.Select(result => result.Lang), options.Lang); - var fileHotspotSignal = reader.GetHotspotFamilySignal(options.Lang); if (fileResults.Count == 0) { @@ -5229,30 +5266,6 @@ public static int RunHotspots(string[] cmdArgs, JsonSerializerOptions jsonOption return ZeroResultExitCode(options); } - if (options.CountOnly) - { - if (options.Json) - { - var payload = new JsonObject - { - ["count"] = fileResults.Count, - ["files"] = fileResults.Count, - ["graph_table_available"] = reader._hasReferencesTable, - ["grouped_by"] = groupBy, - }; - AddHotspotFamilyJsonFields(payload, fileHotspotSignal); - AddSqlGraphContractJsonFields(payload, effectiveSqlGraphSignal); - Console.WriteLine(payload.ToJsonString(jsonOptions)); - } - else - { - Console.WriteLine($"{fileResults.Count}"); - WriteHotspotFamilyWarningIfNeeded(json: false, fileHotspotSignal); - WriteSqlGraphContractWarningIfNeeded(json: false, effectiveSqlGraphSignal, reader, options); - } - return CommandExitCodes.Success; - } - if (options.Json) { var hotspots = new JsonArray(); @@ -5290,11 +5303,47 @@ public static int RunHotspots(string[] cmdArgs, JsonSerializerOptions jsonOption return CommandExitCodes.Success; } + var hotspotSignal = reader.GetHotspotFamilySignal(options.Lang); + if (options.CountOnly) + { + var countSummary = reader.CountSymbolHotspots(options.Kind, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, visibilityFilters: options.VisibilityFilters, excludeVisibilityFilters: options.ExcludeVisibilityFilters); + var countSqlGraphSignal = countSummary.Count == 0 + ? zeroResultSqlGraphSignal + : NarrowSqlGraphContractSignal( + baseSqlGraphSignal, + reader.ScopeMayIncludeSqlSymbols(options.Kind, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests)); + if (options.Json) + { + var payload = new JsonObject + { + ["count"] = countSummary.Count, + ["files"] = countSummary.FileCount, + ["graph_table_available"] = reader._hasReferencesTable, + ["grouped_by"] = groupBy, + }; + if (!reader._hasReferencesTable) + payload["degraded"] = true; + AddHotspotFamilyJsonFields(payload, hotspotSignal); + AddSqlGraphContractJsonFields(payload, countSqlGraphSignal); + if (countSummary.Count == 0) + AddFreshnessHint(payload, reader); + Console.WriteLine(payload.ToJsonString(jsonOptions)); + } + else + { + Console.WriteLine($"{countSummary.Count}"); + if (!reader._hasReferencesTable) + Console.Error.WriteLine("WARN: symbol_references table missing — this count result is degraded, not authoritative."); + WriteHotspotFamilyWarningIfNeeded(json: false, hotspotSignal); + WriteSqlGraphContractWarningIfNeeded(json: false, countSqlGraphSignal, reader, options); + } + return countSummary.Count == 0 ? ZeroResultExitCode(options) : CommandExitCodes.Success; + } + var results = reader.GetSymbolHotspots(options.Limit, options.Kind, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, visibilityFilters: options.VisibilityFilters, excludeVisibilityFilters: options.ExcludeVisibilityFilters); var sqlGraphSignal = results.Count == 0 ? zeroResultSqlGraphSignal : NarrowSqlGraphContractSignalByLanguages(baseSqlGraphSignal, results.Select(result => result.Symbol.Lang), options.Lang); - var hotspotSignal = reader.GetHotspotFamilySignal(options.Lang); if (results.Count == 0) { if (options.CountOnly) @@ -5356,31 +5405,6 @@ public static int RunHotspots(string[] cmdArgs, JsonSerializerOptions jsonOption return ZeroResultExitCode(options); } - if (options.CountOnly) - { - var fc = results.Select(r => r.Symbol.Path).Distinct().Count(); - if (options.Json) - { - var payload = new JsonObject - { - ["count"] = results.Count, - ["files"] = fc, - ["graph_table_available"] = reader._hasReferencesTable, - ["grouped_by"] = groupBy, - }; - AddHotspotFamilyJsonFields(payload, hotspotSignal); - AddSqlGraphContractJsonFields(payload, sqlGraphSignal); - Console.WriteLine(payload.ToJsonString(jsonOptions)); - } - else - { - Console.WriteLine($"{results.Count}"); - WriteHotspotFamilyWarningIfNeeded(json: false, hotspotSignal); - WriteSqlGraphContractWarningIfNeeded(json: false, sqlGraphSignal, reader, options); - } - return CommandExitCodes.Success; - } - if (options.Json) { var items = results diff --git a/src/CodeIndex/Database/DbSymbolReader.cs b/src/CodeIndex/Database/DbSymbolReader.cs index 2921f59e73..42d32b499c 100644 --- a/src/CodeIndex/Database/DbSymbolReader.cs +++ b/src/CodeIndex/Database/DbSymbolReader.cs @@ -69,6 +69,7 @@ public partial class DbReader private const int UnusedPublicOverfetchMinimum = 64; private const int UnusedPublicOverfetchMaximum = 1024; private const int UnusedPublicCandidateBudget = 2048; + private const int GroupedHotspotPathSampleLimit = 20; private const string SymbolLanguageFileIdFilter = " AND s.file_id IN (SELECT id FROM files WHERE lang = @lang)"; private sealed class UnusedCandidateSymbol @@ -2051,6 +2052,45 @@ gr.path COLLATE BINARY ASC return results; } + public HotspotCountResult CountSymbolHotspots(string? kind, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) + { + if (!_hasReferencesTable) return new HotspotCountResult(0, 0); + var query = BuildSymbolHotspotRowsQuery(kind, lang, pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters); + var sql = query.Sql + @" + SELECT COUNT(*), + COUNT(DISTINCT gr.path) + FROM grouped_rows gr + JOIN reference_counts rc ON rc.symbol_id = gr.symbol_id + WHERE rc.ref_count > 0"; + + using var cmd = _conn.CreateCommand(); + cmd.CommandText = sql; + AddSymbolHotspotParameters(cmd, query, limit: null, kind, lang, pathPatterns, excludePathPatterns, visibilityFilters, excludeVisibilityFilters); + return ExecuteHotspotCountSummary(cmd); + } + + public HotspotCountResult CountFileSymbolHotspots(string? kind, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) + { + if (!_hasReferencesTable) return new HotspotCountResult(0, 0); + var query = BuildSymbolHotspotRowsQuery(kind, lang, pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters); + var sql = query.Sql + @" + SELECT COUNT(*), + COUNT(*) + FROM ( + SELECT gr.path, + gr.lang + FROM grouped_rows gr + JOIN reference_counts rc ON rc.symbol_id = gr.symbol_id + WHERE rc.ref_count > 0 + GROUP BY gr.path, gr.lang + ) file_groups"; + + using var cmd = _conn.CreateCommand(); + cmd.CommandText = sql; + AddSymbolHotspotParameters(cmd, query, limit: null, kind, lang, pathPatterns, excludePathPatterns, visibilityFilters, excludeVisibilityFilters); + return ExecuteHotspotCountSummary(cmd); + } + private SymbolHotspotRowsQuery BuildSymbolHotspotRowsQuery(string? kind, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? visibilityFilters, IReadOnlyList? excludeVisibilityFilters) { var containerNameSql = GetSymbolColumnSql("container_name"); @@ -2331,14 +2371,20 @@ AND sql_normalize_name(fc_exact.name) = frc_leaf.raw_symbol_name COLLATE NOCASE reference_counts AS ( SELECT gr.symbol_id, CASE - WHEN nc.defs = 1 - OR (nc.count_safe_defs = nc.defs AND nc.count_safe_groups = 1) + WHEN (gr.lang != 'csharp' OR gr.kind != 'property') + AND ( + nc.defs = 1 + OR (nc.count_safe_defs = nc.defs AND nc.count_safe_groups = 1) + ) THEN COALESCE(gerc.ref_count, 0) + COALESCE(glrc.ref_count, 0) ELSE COALESCE(crc.ref_count, 0) END AS ref_count, CASE - WHEN nc.defs = 1 - OR (nc.count_safe_defs = nc.defs AND nc.count_safe_groups = 1) + WHEN (gr.lang != 'csharp' OR gr.kind != 'property') + AND ( + nc.defs = 1 + OR (nc.count_safe_defs = nc.defs AND nc.count_safe_groups = 1) + ) THEN COALESCE(gerc.ref_score, 0.0) + COALESCE(glrc.ref_score, 0.0) ELSE COALESCE(crc.ref_score, 0.0) END AS ref_score @@ -2381,9 +2427,10 @@ LEFT JOIN conservative_reference_counts crc return new SymbolHotspotRowsQuery(sql, graphLangs, hotspotFamilyLangs); } - private static void AddSymbolHotspotParameters(SqliteCommand command, SymbolHotspotRowsQuery query, int limit, string? kind, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, IReadOnlyList? visibilityFilters, IReadOnlyList? excludeVisibilityFilters) + private static void AddSymbolHotspotParameters(SqliteCommand command, SymbolHotspotRowsQuery query, int? limit, string? kind, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, IReadOnlyList? visibilityFilters, IReadOnlyList? excludeVisibilityFilters) { - command.Parameters.AddWithValue("@limit", limit); + if (limit.HasValue) + command.Parameters.AddWithValue("@limit", limit.Value); if (lang != null) command.Parameters.AddWithValue("@lang", lang); else @@ -2401,16 +2448,22 @@ private static void AddSymbolHotspotParameters(SqliteCommand command, SymbolHots private sealed record SymbolHotspotRowsQuery(string Sql, List GraphLanguages, List HotspotFamilyLanguages); - /// - /// Return grouped hotspot rows collapsed by (name, kind) after the full filtered site set - /// has been considered, keeping the representative site deterministic. - /// フィルタ済みの全 definition site を見た上で、(name, kind) 単位に hotspot を集約して返す。 - /// 代表 site は決定的な順序で選ぶ。 - /// - public List GetGroupedSymbolHotspots(int limit, string? kind, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) + private static HotspotCountResult ExecuteHotspotCountSummary(SqliteCommand command) { - if (!_hasReferencesTable) return []; + using var reader = command.ExecuteTrackedReader(); + if (!reader.TrackedRead()) + return new HotspotCountResult(0, 0); + + var count = Convert.ToInt32(reader.GetValue(0)); + var fileCount = Convert.ToInt32(reader.GetValue(1)); + var definitionSiteTotal = reader.FieldCount > 2 && !reader.IsDBNull(2) + ? Convert.ToInt32(reader.GetValue(2)) + : 0; + return new HotspotCountResult(count, fileCount, definitionSiteTotal); + } + private SymbolHotspotRowsQuery BuildGroupedSymbolHotspotRowsQuery(string? kind, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? visibilityFilters, IReadOnlyList? excludeVisibilityFilters) + { var containerNameSql = GetSymbolColumnSql("container_name"); var containerQualifiedNameSql = GetSymbolColumnSql("container_qualified_name"); var familyKeySql = GetSymbolColumnSql("family_key"); @@ -2593,14 +2646,20 @@ LEFT JOIN file_reference_counts frc site_reference_counts AS ( SELECT fc.id AS symbol_id, CASE - WHEN nc.defs = 1 - OR (nc.count_safe_defs = nc.defs AND nc.count_safe_groups = 1) + WHEN (fc.lang != 'csharp' OR fc.kind != 'property') + AND ( + nc.defs = 1 + OR (nc.count_safe_defs = nc.defs AND nc.count_safe_groups = 1) + ) THEN COALESCE(grc.ref_count, 0) ELSE COALESCE(crc.ref_count, 0) END AS ref_count, CASE - WHEN nc.defs = 1 - OR (nc.count_safe_defs = nc.defs AND nc.count_safe_groups = 1) + WHEN (fc.lang != 'csharp' OR fc.kind != 'property') + AND ( + nc.defs = 1 + OR (nc.count_safe_defs = nc.defs AND nc.count_safe_groups = 1) + ) THEN COALESCE(grc.ref_score, 0.0) ELSE COALESCE(crc.ref_score, 0.0) END AS ref_score @@ -2672,7 +2731,39 @@ JOIN grouped_reference_counts grc ON grc.name = hs.name AND grc.kind = hs.kind GROUP BY hs.name, hs.kind - ) + )"; + + return new SymbolHotspotRowsQuery(sql, graphLangs, hotspotFamilyLangs); + } + + public HotspotCountResult CountGroupedSymbolHotspots(string? kind, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) + { + if (!_hasReferencesTable) return new HotspotCountResult(0, 0); + var query = BuildGroupedSymbolHotspotRowsQuery(kind, lang, pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters); + var sql = query.Sql + @" + SELECT COUNT(*), + (SELECT COUNT(DISTINCT path) FROM ranked_sites), + COALESCE(SUM(definition_sites), 0) + FROM grouped"; + + using var cmd = _conn.CreateCommand(); + cmd.CommandText = sql; + AddSymbolHotspotParameters(cmd, query, limit: null, kind, lang, pathPatterns, excludePathPatterns, visibilityFilters, excludeVisibilityFilters); + return ExecuteHotspotCountSummary(cmd); + } + + /// + /// Return grouped hotspot rows collapsed by (name, kind) after the full filtered site set + /// has been considered, keeping the representative site deterministic. + /// フィルタ済みの全 definition site を見た上で、(name, kind) 単位に hotspot を集約して返す。 + /// 代表 site は決定的な順序で選ぶ。 + /// + public List GetGroupedSymbolHotspots(int limit, string? kind, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) + { + if (!_hasReferencesTable) return []; + + var query = BuildGroupedSymbolHotspotRowsQuery(kind, lang, pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters); + var sql = query.Sql + @" SELECT g.name, g.kind, g.ref_count, g.ref_score, g.definition_sites, rep.path, rep.lang, rep.line, rep.visibility, rep.container_name, ( @@ -2683,6 +2774,7 @@ FROM ranked_sites hs2 WHERE hs2.name = g.name AND hs2.kind = g.kind ORDER BY path + LIMIT @groupedPathSampleLimit ) ) AS grouped_paths FROM grouped g @@ -2695,20 +2787,8 @@ JOIN ranked_sites rep using var cmd = _conn.CreateCommand(); cmd.CommandText = sql; - cmd.Parameters.AddWithValue("@limit", limit); - if (lang != null) - cmd.Parameters.AddWithValue("@lang", lang); - else - { - for (int i = 0; i < graphLangs.Count; i++) - cmd.Parameters.AddWithValue($"@gl{i}", graphLangs[i]); - } - if (kind != null) - cmd.Parameters.AddWithValue("@kind", kind); - AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns); - AddVisibilityFilterParameters(cmd, visibilityFilters, excludeVisibilityFilters); - for (int i = 0; i < hotspotFamilyLangs.Count; i++) - cmd.Parameters.AddWithValue($"@hotspotFamilyLang{i}", hotspotFamilyLangs[i]); + AddSymbolHotspotParameters(cmd, query, limit, kind, lang, pathPatterns, excludePathPatterns, visibilityFilters, excludeVisibilityFilters); + cmd.Parameters.AddWithValue("@groupedPathSampleLimit", GroupedHotspotPathSampleLimit + 1); var results = new List(); using var reader = cmd.ExecuteTrackedReader(); @@ -2717,6 +2797,9 @@ JOIN ranked_sites rep var paths = GetNullableString(reader, 10)? .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .ToList() ?? []; + var pathsTruncated = paths.Count > GroupedHotspotPathSampleLimit; + if (pathsTruncated) + paths = paths.Take(GroupedHotspotPathSampleLimit).ToList(); results.Add(new GroupedHotspotResult { Symbol = new SymbolResult @@ -2733,6 +2816,7 @@ JOIN ranked_sites rep ReferenceScore = reader.GetDouble(3), DefinitionSites = reader.GetInt32(4), Paths = paths, + PathsTruncated = pathsTruncated, }); } diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index 1808d958b0..00e8b9de89 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -41,6 +41,8 @@ public class SearchResult public readonly record struct QueryCountResult(int Count, int FileCount, bool IncludesSql = false); +public readonly record struct HotspotCountResult(int Count, int FileCount, int DefinitionSiteTotal = 0); + public enum SearchGuardRole { Require, @@ -115,6 +117,7 @@ public class GroupedHotspotResult public double ReferenceScore { get; set; } public int DefinitionSites { get; set; } public List Paths { get; set; } = []; + public bool PathsTruncated { get; set; } } public class SymbolHotspotResult diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 7d9f7f1bd6..aecbf9c203 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -138,7 +138,7 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains(" run `cdidx backfill-fold` or check fold_ready.", output); Assert.Contains("--kind definition/symbols/hotspots/unused: symbol kind; references: reference kind (call/instantiate/subscribe/attribute/annotation); callers/callees: call-graph kinds only (call/instantiate/subscribe — metadata kinds rejected, use references instead); validate: issue kind", output); Assert.Contains("--severity validate only: filter issues by severity: info, warning, error", output); - Assert.Contains("--count Count only; search/definition/references/callers/callees/symbols/files/find/unused ignore --limit, impact/hotspots still use visible page counts", output); + Assert.Contains("--count Count only; search/definition/references/callers/callees/symbols/files/find/unused/hotspots ignore --limit, impact still uses visible page counts", output); Assert.Contains("--no-dedup search only: return every raw overlapping chunk hit (debug/density)", output); Assert.Contains("--commits [commit-ref ...]", output); Assert.Contains("Update only files changed in the specified git", output); @@ -163,7 +163,7 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("--lang Filter by language (aliases: bat, cmd, cshtml, razor, ts, tsx, cts, mts)", output); Assert.Contains("--bytes files: sort by size and show raw byte counts in human", output); Assert.Contains("map: show raw byte counts; JSON always keeps raw", output); - Assert.Contains("--group-by-name hotspots: collapse rows sharing (name, kind) across files; JSON count is the number of name/kind groups, not reference, file, or definition-site count", output); + Assert.Contains("--group-by-name hotspots: collapse rows sharing (name, kind) across files; JSON paths are capped per group with paths_truncated", output); Assert.Contains("cdidx search \"Run();\" --exact-substring Case-sensitive exact substring search", output); Assert.Contains("cdidx search --query --path --path README.md Search for a literal option token", output); Assert.Contains("cdidx hotspots --group-by-name --exclude-tests", output); @@ -878,7 +878,7 @@ public void PrintCompletions_PowerShellIncludesSharedFlagValues() [Theory] [InlineData("bash", "if [ \"$cmd\" = \"hotspots\" ]", "--group-by-name", "--exact-name")] - [InlineData("zsh", "elif [[ $subcmd == hotspots ]]; then", "--group-by-name[Hotspots: collapse same-name rows; count is name/kind groups]", "--exact-name[Exact symbol-name equality]")] + [InlineData("zsh", "elif [[ $subcmd == hotspots ]]; then", "--group-by-name[Hotspots: collapse same-name rows; JSON paths capped per group]", "--exact-name[Exact symbol-name equality]")] public void PrintCompletions_BashAndZshScopeGroupByNameToHotspots(string shell, string hotspotsBranchMarker, string groupedFlagToken, string genericExactNameToken) { var output = ConsoleUi.GetCompletionScript(shell); diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index d91a5cc2d5..3948ed798c 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -4175,6 +4175,45 @@ public void LocalB() }); } + [Fact] + public void GetSymbolHotspots_CSharpPropertyDoesNotUseRepoWideBareNameCounts() + { + InsertIndexedFile("src/Diff.cs", "csharp", + """ + public sealed record OrderedRowsDiff(bool Equal); + + public class DiffUse + { + public bool Use(OrderedRowsDiff diff) + { + return diff.Equal; + } + } + """); + InsertIndexedFile("src/Unrelated.cs", "csharp", + """ + public class Unrelated + { + public bool Run() + { + var equal = true; + equal = equal && true; + return equal; + } + } + """); + + var results = _reader.GetSymbolHotspots( + limit: 10, + kind: "property", + lang: "csharp", + pathPatterns: ["src/"], + excludePathPatterns: null, + excludeTests: false); + + Assert.DoesNotContain(results, result => result.Symbol.Name == "Equal"); + } + [Fact] public void GetSymbolHotspots_DoesNotMergePartialFamiliesAcrossProjectRoots() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs index ed8d427f86..94a30d8e78 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs @@ -4253,6 +4253,58 @@ public void Use() } } + [Fact] + public void RunHotspots_CountJson_IgnoresLimitForSymbolAndFileGroups() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_hotspots_count_limit"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + for (var i = 0; i < 3; i++) + { + TestProjectHelper.InsertIndexedFile(dbPath, $"src/Hotspot{i}.cs", "csharp", + $$""" + public class HotspotContainer{{i}} + { + private void Hotspot{{i}}() + { + Hotspot{{i}}(); + } + } + """); + } + MarkGraphAndFoldReady(dbPath); + + var (symbolExitCode, symbolStdout, symbolStderr) = CaptureConsole(() => QueryCommandRunner.RunHotspots( + ["--db", dbPath, "--json", "--kind", "function", "--count", "--limit", "1"], + _jsonOptions)); + var (fileExitCode, fileStdout, fileStderr) = CaptureConsole(() => QueryCommandRunner.RunHotspots( + ["--db", dbPath, "--json", "--kind", "function", "--group-by", "file", "--count", "--limit", "1"], + _jsonOptions)); + + using var symbolDocument = ParseJsonOutput(symbolStdout); + using var fileDocument = ParseJsonOutput(fileStdout); + var symbolJson = symbolDocument.RootElement; + var fileJson = fileDocument.RootElement; + + Assert.Equal(CommandExitCodes.Success, symbolExitCode); + Assert.Equal(string.Empty, symbolStderr); + Assert.Equal(3, symbolJson.GetProperty("count").GetInt32()); + Assert.Equal(3, symbolJson.GetProperty("files").GetInt32()); + Assert.Equal("symbol", symbolJson.GetProperty("grouped_by").GetString()); + + Assert.Equal(CommandExitCodes.Success, fileExitCode); + Assert.Equal(string.Empty, fileStderr); + Assert.Equal(3, fileJson.GetProperty("count").GetInt32()); + Assert.Equal(3, fileJson.GetProperty("files").GetInt32()); + Assert.Equal("file", fileJson.GetProperty("grouped_by").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunHotspots_GroupByName_CountJson_UsesGroupedSemantics() { @@ -4284,10 +4336,20 @@ public void Use() } } """); + TestProjectHelper.InsertIndexedFile(dbPath, "src/UniqueHelper.cs", "csharp", + """ + public class UniqueHelper + { + private void UniqueHotspot() + { + UniqueHotspot(); + } + } + """); MarkGraphAndFoldReady(dbPath); var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunHotspots( - ["--db", dbPath, "--json", "--group-by-name", "--count"], + ["--db", dbPath, "--json", "--group-by-name", "--count", "--limit", "1"], _jsonOptions)); using var document = ParseJsonOutput(stdout); @@ -4295,9 +4357,9 @@ public void Use() Assert.Equal(CommandExitCodes.Success, exitCode); Assert.Equal(string.Empty, stderr); - Assert.Equal(1, json.GetProperty("count").GetInt32()); - Assert.Equal(2, json.GetProperty("definition_site_total").GetInt32()); - Assert.Equal(2, json.GetProperty("files").GetInt32()); + Assert.Equal(2, json.GetProperty("count").GetInt32()); + Assert.Equal(3, json.GetProperty("definition_site_total").GetInt32()); + Assert.Equal(3, json.GetProperty("files").GetInt32()); Assert.Equal("name_kind", json.GetProperty("grouped_by").GetString()); } finally @@ -4439,6 +4501,48 @@ private void UniqueHotspot() } } + [Fact] + public void RunHotspots_GroupByNameJson_CapsPathSamples() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_hotspots_group_paths"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + for (var i = 0; i < 25; i++) + { + TestProjectHelper.InsertIndexedFile(dbPath, $"src/Helper{i:D2}.cs", "csharp", + $$""" + public class Helper{{i}} + { + private void SharedHelper() + { + SharedHelper(); + } + } + """); + } + MarkGraphAndFoldReady(dbPath); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunHotspots( + ["--db", dbPath, "--json", "--kind", "function", "--group-by-name", "--limit", "1"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + var hotspot = Assert.Single(json.GetProperty("hotspots").EnumerateArray()); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal(25, hotspot.GetProperty("definition_sites").GetInt32()); + Assert.Equal(20, hotspot.GetProperty("paths").GetArrayLength()); + Assert.True(hotspot.GetProperty("paths_truncated").GetBoolean()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunHotspots_GroupByName_CountsSameFileOverloadsAsSeparateDefinitionSites() {