diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index ffd1e31f5..49d13f042 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -19,6 +19,7 @@ Use the full suite by default. Use targeted filters only while iterating locally ## Test Stack +- Issue #5226 impact-count coverage belongs in `DbReaderIssue5226Tests.cs`, `QueryCommandRunnerIssue5226Tests.cs`, `QueryCommandRunnerImpactTests.cs`, and `McpServerIssue5226Tests.cs`. Keep zero, one, above-default, multi-hop, include/exclude-filter, incomplete-graph, dedicated safety-cap, heuristic-fallback, cross-language SQL-readiness, and ordinary row-limited cases together. Human, JSON, compact, and MCP count modes must ignore the presentation limit while preserving authoritative/lower-bound metadata and omitting count-only result rows; capped human output must retain numeric stdout and warn with the reason on stderr. - Issue #5225 scoped C# dry-run coverage belongs in `IndexCommandRunnerDryRunTests.cs`. Keep paired preview/execution fixtures for `--files`, `--commits`, and `--changed-between`, plus static-interface and member-read expansion identities. Preserve non-C# no-expansion, candidate-cap lower-bound metadata, preflight-error and cancellation controls, human/JSON count and truncation output, and database/source non-mutation assertions across both target frameworks. - Issue #5197 dependency-cycle coverage belongs in `QueryCommandRunnerIssue5197Tests.cs`, `McpServerIssue5197Tests.cs`, and the `deps-cycles-summary.json` golden in `JsonOutputSnapshotTests.cs`. Keep a cycle larger than the 50-node presentation limit to verify complete analysis, authoritative counts, bounded default materialization in human, JSON, DOT, GraphML, and JSON graph output, compact summaries, explicit raw expansion, expansion-aware recovery guidance, largest-component/grouping metadata, and CLI/MCP parity. A later JSON graph cursor page must report its own materialized and omitted-node totals even when they differ from the global largest component. Verify that SCC summaries aggregate every advertised evidence dimension and that actual MCP summaries and expanded results validate against the advertised `deps` output schema, including node arrays beyond the ordinary 10,000-item row bound. MCP must reject summary mode combined with `format=json-graph` just as the CLI does. Keep the C# suppression fixture mixed: unresolved qualified calls and resolved same-name decoys are removable evidence only with a current identity contract, stale and absent contracts retain qualified calls with unavailable resolution evidence, and confirmed resolved targets must remain and drive the surviving SCC even when their target file contains same-name overloads. - Issue #5198 CODEOWNERS coverage belongs in `FileIndexerTests.cs`, `SymbolExtractorRepositoryMetadataTests.cs`, `QueryCommandRunnerFilesTests.cs`, `IndexCommandRunnerUpdateTests.cs`, `QueryCommandRunnerTests.cs`, and `McpServerToolsCallTests.cs`. Preserve the three case-sensitive Git-worktree-relative locations and the arbitrary-nested/case-variant/POSIX-literal-backslash negative cases, including scans rooted below the enclosing worktree; parser cases for full-line and inline comments, blank lines, whitespace, CRLF, rejection of unsupported escaped leading `#`, ownerless rules, user/team/email owners, malformed mentions, duplicate and overlapping patterns, invalid input, and persistable bounded diagnostics; ordered rule and owner child symbols; symbol-only capability guidance; full/scoped/delete indexing; branch-switch reconciliation; and removal from unknown-extension status diagnostics. @@ -1172,6 +1173,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" ## テストスタック +- Issue #5226 の impact count coverage は `DbReaderIssue5226Tests.cs`、`QueryCommandRunnerIssue5226Tests.cs`、`QueryCommandRunnerImpactTests.cs`、`McpServerIssue5226Tests.cs` が担当します。0件、1件、既定上限超過、multi-hop、include / exclude filter、不完全 graph、専用 safety cap、heuristic fallback、cross-language の SQL readiness、通常の row 上限制限を一緒に維持してください。human、JSON、compact、MCP の count mode は表示用 limit を無視し、authoritative / lower-bound metadata を保持しながら count-only の result row を生成しないことを検証します。cap 到達時の human 出力は stdout を数値のみのまま保ち、stderr に理由付き warning を出します。 - Issue #5225 の scoped C# dry-run coverage は `IndexCommandRunnerDryRunTests.cs` が担当します。`--files`、`--commits`、`--changed-between` の preview / 実行を対にした fixture と、static-interface / member-read 展開の target identity を維持してください。両 target framework で、非 C# の非展開、candidate cap の lower-bound metadata、preflight error / cancellation control、human / JSON の件数・truncation 出力、database / source の非変更 assertion も保ちます。 - Issue #5197 の dependency-cycle coverage は `QueryCommandRunnerIssue5197Tests.cs`、`McpServerIssue5197Tests.cs`、`JsonOutputSnapshotTests.cs` の `deps-cycles-summary.json` golden が担当します。50 node の表示上限を超える cycle を維持し、完全な解析、authoritative な件数、human、JSON、DOT、GraphML、JSON graph 出力における既定の上限付き materialization、compact summary、明示的な raw 展開、展開状態を考慮した recovery guidance、最大 component / grouping metadata、CLI / MCP parity を検証してください。後続の JSON graph cursor page は global な最大 component と異なる場合も、その page 自身の materialize 済み node 数と省略 node 数を報告します。SCC summary が公開するすべての evidence dimension を集計し、実際の MCP summary と展開結果が通常の 10,000 item の row 上限を超える node array も含めて公開 `deps` output schema に適合することを検証します。また MCP は CLI と同様に summary mode と `format=json-graph` の併用を拒否します。C# 抑制 fixture は、current な identity contract がある場合だけ未解決の修飾 call と解決済みcallの同名decoyを除外可能とし、stale / absent contract では unavailable な resolution evidence として修飾 call を保持し、target file に同名 overload がある場合も確認済みの解決済みtargetが残る SCC を構成する混在状態を維持します。 - Issue #5198 の CODEOWNERS coverage は `FileIndexerTests.cs`、`SymbolExtractorRepositoryMetadataTests.cs`、`QueryCommandRunnerFilesTests.cs`、`IndexCommandRunnerUpdateTests.cs`、`QueryCommandRunnerTests.cs`、`McpServerToolsCallTests.cs` が担当します。case-sensitive な3つの Git worktree-relative location と、enclosing worktree より下を scan root にした場合を含む任意の nested file・大小文字違い file・POSIX の literal backslash file の negative case、full-line / inline comment・blank line・whitespace・CRLF・未対応である先頭 `#` の escape の拒否・ownerless rule・user/team/email owner・malformed mention・duplicate / overlapping pattern・不正 input・永続化可能な上限付き diagnostic の parser case、順序付き rule と owner child symbol、symbol-only capability guidance、full / scoped / delete indexing、branch-switch reconciliation、unknown-extension status diagnostic からの除外を維持してください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 3f5ce3fc9..351c4785d 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -885,6 +885,7 @@ cdidx impact Run --max-hops 2 --exclude-tests cdidx impact Run --max-hops 0 --json cdidx impact FolderDiffService --with-paths --json cdidx impact CurrentValue --include-member-reads --json +cdidx impact Run --count --limit 1 --json ``` `impact` resolves a symbol and walks transitive callers through call-graph edges. @@ -899,6 +900,16 @@ dependencies are intentionally part of the graph. Legacy indexes stored those reads as `call`; they remain readable and keep their historical inclusive behavior until re-indexed. +`--count` (MCP: `countOnly`) treats `--limit` as a row-presentation setting and +therefore ignores it while computing the total. Human, JSON, compact, and MCP +count outputs report the same filtered, multi-hop total without materializing caller +or file-impact rows. The traversal still has dedicated safety budgets. If one is +reached, `truncated` and its safety-cap reason remain visible, CLI JSON reports +`authoritative_count: false`, and MCP reports `total: null`; the returned `count` is +a lower bound. Human count output keeps stdout numeric and writes the cap reason and +lower-bound warning to stderr. Without `--count`, `--limit` continues to cap displayed +rows and a `user_limit` truncation can be retried with a larger value. + When the reference-identity contract is current and every matching C# declaration belongs to one logical partial family, `impact` uses the family's stable `partial_family_id` as one traversal root and walks the union of all physical member identities. Callers, file hints, and shortest paths @@ -4589,6 +4600,7 @@ cdidx impact Run --max-hops 2 --exclude-tests cdidx impact Run --max-hops 0 --json cdidx impact FolderDiffService --with-paths --json cdidx impact CurrentValue --include-member-reads --json +cdidx impact Run --count --limit 1 --json ``` `impact` は symbol を解決し、call-graph edges を通じて transitive callers を探索します。 @@ -4603,6 +4615,15 @@ member / value read を `member_read` として保存し、callers / callees / i `call` として保存しているため、引き続き読み取り可能で、再 index するまでは従来の inclusive な挙動を維持します。 +`--count`(MCP は `countOnly`)では `--limit` を row 表示用の設定として扱い、総数の +算出時には無視します。human、JSON、compact、MCP の count 出力は caller / file-impact +row を materialize せず、同じ filter・multi-hop 条件の総数を一致して返します。ただし +traversal 専用の safety budget は引き続き適用されます。到達時は `truncated` と safety-cap +理由を保持し、CLI JSON は `authoritative_count: false`、MCP は `total: null` を返すため、 +`count` は lower bound です。human count 出力は stdout を数値のみのまま維持し、cap 理由と +lower-bound warning を stderr に出力します。`--count` がない通常表示では、従来どおり +`--limit` が表示 row を制限し、`user_limit` なら値を増やして再試行できます。 + reference-identity contract が current で、一致する C# declaration がすべて 1 つの論理 partial family に属する場合、`impact` は 安定した `partial_family_id` を 1 つの traversal root として使い、全物理 member identity の和集合を辿ります。複数 member から到達する caller、file hint、shortest path は重複排除し、 diff --git a/changelog.d/unreleased/5226.fixed.md b/changelog.d/unreleased/5226.fixed.md new file mode 100644 index 000000000..48c8dd0ce --- /dev/null +++ b/changelog.d/unreleased/5226.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 5226 +affected: + - src/CodeIndex/Database/DbReader.ImpactTraversal.cs + - src/CodeIndex/Database/DbReader.ImpactTraversal.Engine.cs + - src/CodeIndex/Database/DbReader.GraphQueries.cs + - src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs + - src/CodeIndex/Mcp/McpToolHandlers.ImpactAnalysis.cs + - USER_GUIDE.md +--- + +## English + +- **Impact count modes now return limit-independent totals (#5226)** — `impact --count` and MCP `impact_analysis` with `countOnly` no longer let the presentation `limit` change the reported total, restoring the count contract introduced in #159. Filtered and multi-hop counts agree across human, JSON, compact, and MCP output; bounded identity-target batches keep multi-hop MCP counts within the request deadline, supplied MCP limits are reported as ignored, dedicated safety caps still report non-authoritative lower bounds without materializing normal result rows, human output warns on stderr, and count-only summaries retain graph-readiness evidence. + +## 日本語 + +- **impact の count mode が表示上限に依存しない総数を返すようになりました (#5226)** — `impact --count` と MCP `impact_analysis` の `countOnly` では、表示用 `limit` が報告総数を変えなくなり、#159 で導入した count 契約を復元しました。filter・multi-hop 条件の件数は human、JSON、compact、MCP で一致し、bounded な identity-target batch により multi-hop MCP count を request deadline 内に保ち、指定された MCP `limit` は ignored として通知します。専用 safety cap 到達時は通常の result row を生成せず、authoritative でない lower bound として stderr warning も含めて通知し、count-only summary でも graph readiness の evidence を保持します。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs index 03308b0bd..6c34d0d2d 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs @@ -99,7 +99,8 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) JsonEnvelopeWrapper.GetBoundedResponseOffset("impact"), JsonEnvelopeWrapper.GetBoundedImpactCollection(), options.IncludeMemberReads, - selectedDefinition); + selectedDefinition, + countOnly: options.CountOnly); if (options.IncludeBody && !options.CountOnly && options.OutputFormat is (OutputFormatText or OutputFormatJson) @@ -113,12 +114,13 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) DbReader.IsSqlLanguage(options.Lang) || DbReader.ContainsSqlLanguage(analysis.Definitions.Select(definition => definition.Lang)) || DbReader.ContainsSqlLanguage(analysis.Callers.Select(caller => caller.Lang)) - || reader.AnyFilePathHasLanguage(analysis.FileImpacts.SelectMany(impact => new[] { impact.SourcePath, impact.TargetPath }), "sql")); + || reader.AnyFilePathHasLanguage(analysis.FileImpacts.SelectMany(impact => new[] { impact.SourcePath, impact.TargetPath }), "sql") + || reader.AnyFilePathHasLanguage(analysis.CountFileHistogram.Keys, "sql")); var hdlGraphSignal = reader.GetHdlGraphContractSignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests); - var confirmedCount = analysis.Callers.Count; - var confirmedFileCount = analysis.Callers.Select(r => r.Path).Distinct().Count(); - var hintCount = analysis.FileImpacts.Count; - var hintFileCount = analysis.FileImpacts.Select(r => r.SourcePath).Distinct().Count(); + var confirmedCount = analysis.ConfirmedCount; + var confirmedFileCount = analysis.ConfirmedFileCount; + var hintCount = analysis.HintCount; + var hintFileCount = analysis.HintFileCount; var hasHeuristicHints = analysis.ImpactMode == "file_dependency_hints"; var visibleCount = hasHeuristicHints ? hintCount : confirmedCount; var visibleFileCount = hasHeuristicHints ? hintFileCount : confirmedFileCount; @@ -206,6 +208,9 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) ["heuristic"] = analysis.Heuristic, ["hint_count"] = analysis.HintCount, ["hint_file_count"] = 0, + ["max_hops"] = maxDepth, + ["max_depth"] = maxDepth, + ["actual_depth"] = analysis.ActualDepth, ["definition_count"] = analysis.DefinitionCount, ["definition_file_count"] = analysis.DefinitionFileCount, ["has_multiple_definitions"] = analysis.HasMultipleDefinitions, @@ -240,7 +245,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) } else { - Console.WriteLine("0"); + WriteImpactHumanCount(0, analysis); if (!analysis.GraphTableAvailable) CommandErrorWriter.WriteStderr("WARN: symbol_references table missing — this count result is degraded, not authoritative."); } @@ -321,6 +326,9 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) ["heuristic"] = analysis.Heuristic, ["hint_count"] = hintCount, ["hint_file_count"] = hintFileCount, + ["max_hops"] = maxDepth, + ["max_depth"] = maxDepth, + ["actual_depth"] = analysis.ActualDepth, ["truncated"] = analysis.Truncated, }; AddImpactTraversalRootJsonFields(payload, analysis); @@ -337,7 +345,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) } else { - Console.WriteLine($"{visibleCount}"); + WriteImpactHumanCount(visibleCount, analysis); } return CommandExitCodes.Success; } @@ -356,7 +364,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) ["hint_file_count"] = hintFileCount, ["max_hops"] = maxDepth, ["max_depth"] = maxDepth, - ["actual_depth"] = analysis.Callers.Count > 0 ? analysis.Callers.Max(r => r.Depth) : 0, + ["actual_depth"] = analysis.ActualDepth, ["truncated"] = analysis.Truncated, ["impact_mode"] = analysis.ImpactMode, ["heuristic"] = analysis.Heuristic, @@ -516,6 +524,17 @@ private static void WriteImpactIdentityRootWarningIfNeeded(ImpactAnalysisResult $"WARN: impact traversal has no identity-backed root ({reason}); confirmed counts are not authoritative."); } + internal static void WriteImpactHumanCount(int count, ImpactAnalysisResult analysis) + { + Console.WriteLine(count.ToString(CultureInfo.InvariantCulture)); + if (!analysis.Truncated) + return; + + var reason = analysis.TruncatedReason ?? analysis.TerminationReason; + CommandErrorWriter.WriteStderr( + $"WARN: impact count truncated ({reason}); {count.ToString(CultureInfo.InvariantCulture)} is a lower bound, not authoritative."); + } + private static List BuildImpactDefinitionJsonResults(IReadOnlyList definitions) => LogicalPartialSymbolGrouper.Group(definitions); diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index 372edccbf..07f5b87d1 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -724,7 +724,7 @@ ELSE 5 /// SQL 側で要求された LIMIT/OFFSET を適用し、呼び出し側が要求以上の中間ページを /// materialize しないようにする。 /// - private List GetCallersExactCore(string symbolName, int limit, int offset, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? targetSymbolIds, bool requireAuthoritativeIdentity, bool includeAmbiguousMSource, bool includeMemberReads) + private List GetCallersExactCore(string symbolName, int limit, int offset, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? targetSymbolIds, IReadOnlyList? countTargetSymbolNames, bool requireAuthoritativeIdentity, bool includeAmbiguousMSource, bool includeMemberReads, bool countOnly) { if (!_hasReferencesTable) return new List(); using var cmd = _conn.CreateCommand(); @@ -758,11 +758,27 @@ ELSE NULL // caller rows whose stored callee casing differs from the resolved definition. // caller 側も leaf `--exact` と同じく FoldReady なら folded equality、legacy DB では // `COLLATE NOCASE` fallback。definition と caller 行の casing 差もここで吸収する。 - var allowSqlLeafFallback = !SqlNameResolver.HasQualifier(symbolName); - var allowCSharpQualifiedContextMatch = SqlNameResolver.HasQualifier(symbolName) + var normalizedCountTargetNames = countTargetSymbolNames? + .Where(static name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList() ?? []; + var hasCountNameBatch = countOnly + && normalizedCountTargetNames.Count > 1 + && normalizedCountTargetNames.All(static name => !SqlNameResolver.HasQualifier(name)); + var allowSqlLeafFallback = hasCountNameBatch || !SqlNameResolver.HasQualifier(symbolName); + var allowCSharpQualifiedContextMatch = !hasCountNameBatch + && SqlNameResolver.HasQualifier(symbolName) && !HasQualifiedSymbolDefinition(symbolName, lang, pathPatterns, excludePathPatterns, excludeTests); - var allowQualifiedLeafFallback = HasSingleQualifiedSymbolDefinition(symbolName, lang, pathPatterns, excludePathPatterns, excludeTests); - var polymorphicCSharpSymbolNames = lang is null or "csharp" + var allowQualifiedLeafFallback = !hasCountNameBatch + && !allowSqlLeafFallback + && HasSingleQualifiedSymbolDefinition(symbolName, lang, pathPatterns, excludePathPatterns, excludeTests); + // Identity-scoped pages select C# edges by target ID; their name predicate applies only + // to non-C# fallback rows, where a C# polymorphic-name disjunct cannot match. Repeating + // dispatch-name discovery for every BFS frontier node is therefore redundant. + // identity scope の page は C# edge を target ID で選び、name predicate は non-C# + // fallback row だけに適用されるため、C# polymorphic name 条件は一致しない。 + // したがって BFS の各 frontier node で dispatch name を再探索する必要はない。 + var polymorphicCSharpSymbolNames = !hasIdentityTargetScope && (lang is null or "csharp") ? GetCSharpPolymorphicDispatchSymbolNames(symbolName) : []; var polymorphicNameCondition = polymorphicCSharpSymbolNames.Count == 0 @@ -770,13 +786,17 @@ ELSE NULL : _foldReady ? " OR (f.lang = 'csharp' AND r.symbol_name_folded IN (" + string.Join(", ", polymorphicCSharpSymbolNames.Select((_, i) => $"@polymorphicSymbolNameFolded{i}")) + "))" : " OR (f.lang = 'csharp' AND r.symbol_name COLLATE NOCASE IN (" + string.Join(", ", polymorphicCSharpSymbolNames.Select((_, i) => $"@polymorphicSymbolName{i}")) + "))"; - var namePredicate = _foldReady - ? allowSqlLeafFallback - ? "(" + BuildPersistedFoldedNameMatchSql("r.symbol_name_folded", "@symbolNameFolded") + " OR (f.lang = 'sql' AND r.symbol_name_folded = @symbolNameLeafFolded)" + polymorphicNameCondition + " OR (f.lang = 'solution' AND r.reference_kind = 'project_reference' AND r.container_name = @symbolName COLLATE NOCASE))" - : "(((f.lang = 'sql') AND sql_context_has_name_folded_at(" + contextSql + @", @symbolName, r.column_number) = 1) OR ((f.lang != 'sql') AND " + BuildPersistedFoldedNameMatchSql("r.symbol_name_folded", "@symbolNameFolded") + ") OR " + BuildCSharpQualifiedContextFallbackSql(BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: true, like: false)) + " OR " + BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: true) + polymorphicNameCondition + " OR (f.lang = 'solution' AND r.reference_kind = 'project_reference' AND r.container_name = @symbolName COLLATE NOCASE))" - : allowSqlLeafFallback - ? "(r.symbol_name = @symbolName COLLATE NOCASE OR (f.lang = 'sql' AND r.symbol_name = sql_leaf_name(@symbolName) COLLATE NOCASE)" + polymorphicNameCondition + " OR (f.lang = 'solution' AND r.reference_kind = 'project_reference' AND r.container_name = @symbolName COLLATE NOCASE))" - : "(((f.lang = 'sql') AND sql_context_has_name_at(" + contextSql + @", @symbolName, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name = @symbolName COLLATE NOCASE) OR " + BuildCSharpQualifiedContextFallbackSql(BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: false, like: false)) + " OR " + BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: false) + polymorphicNameCondition + " OR (f.lang = 'solution' AND r.reference_kind = 'project_reference' AND r.container_name = @symbolName COLLATE NOCASE))"; + var namePredicate = hasCountNameBatch + ? _foldReady + ? "(r.symbol_name_folded IN (SELECT value FROM json_each(@countTargetNamesFoldedJson)) OR (f.lang = 'solution' AND r.reference_kind = 'project_reference' AND r.container_name COLLATE NOCASE IN (SELECT value FROM json_each(@countTargetNamesJson))))" + : "(r.symbol_name COLLATE NOCASE IN (SELECT value FROM json_each(@countTargetNamesJson)) OR (f.lang = 'solution' AND r.reference_kind = 'project_reference' AND r.container_name COLLATE NOCASE IN (SELECT value FROM json_each(@countTargetNamesJson))))" + : _foldReady + ? allowSqlLeafFallback + ? "(" + BuildPersistedFoldedNameMatchSql("r.symbol_name_folded", "@symbolNameFolded") + " OR (f.lang = 'sql' AND r.symbol_name_folded = @symbolNameLeafFolded)" + polymorphicNameCondition + " OR (f.lang = 'solution' AND r.reference_kind = 'project_reference' AND r.container_name = @symbolName COLLATE NOCASE))" + : "(((f.lang = 'sql') AND sql_context_has_name_folded_at(" + contextSql + @", @symbolName, r.column_number) = 1) OR ((f.lang != 'sql') AND " + BuildPersistedFoldedNameMatchSql("r.symbol_name_folded", "@symbolNameFolded") + ") OR " + BuildCSharpQualifiedContextFallbackSql(BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: true, like: false)) + " OR " + BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: true) + polymorphicNameCondition + " OR (f.lang = 'solution' AND r.reference_kind = 'project_reference' AND r.container_name = @symbolName COLLATE NOCASE))" + : allowSqlLeafFallback + ? "(r.symbol_name = @symbolName COLLATE NOCASE OR (f.lang = 'sql' AND r.symbol_name = sql_leaf_name(@symbolName) COLLATE NOCASE)" + polymorphicNameCondition + " OR (f.lang = 'solution' AND r.reference_kind = 'project_reference' AND r.container_name = @symbolName COLLATE NOCASE))" + : "(((f.lang = 'sql') AND sql_context_has_name_at(" + contextSql + @", @symbolName, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name = @symbolName COLLATE NOCASE) OR " + BuildCSharpQualifiedContextFallbackSql(BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: false, like: false)) + " OR " + BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: false) + polymorphicNameCondition + " OR (f.lang = 'solution' AND r.reference_kind = 'project_reference' AND r.container_name = @symbolName COLLATE NOCASE))"; var nameCondition = "\n AND " + namePredicate; // Selector traversal admits only references authoritatively resolved to that target. // Name-resolved roots, including a partial family that currently has one member, retain @@ -816,7 +836,33 @@ AND r.resolution_state IN ('resolved', 'resolved_group') // `subscribe` エッジ(`Click += OnClick` 等)も推移 caller に含める。`attribute` / // `annotation` のような metadata エッジは引き続き除外する。 var callerContainerPredicate = BuildCallerContainerPredicate("f", "r"); - var sql = $@" + var sql = countOnly + ? $@" + SELECT f.path, f.lang, {BuildCallerKindProjectionSql("r")} AS container_kind, + CASE WHEN f.lang = 'solution' AND r.reference_kind = 'project_reference' THEN f.path + ELSE {BuildCallerNameProjectionSql("r")} END AS container_name, + r.symbol_name, + r.reference_kind, + MIN(r.line) AS first_line, + COALESCE(MIN(r.column_number), 0) AS first_column, + MIN(CASE WHEN {referenceSpanLengthSql} > 0 THEN {referenceSpanLengthSql} ELSE NULL END) AS first_length, + COUNT(*) AS reference_count, + MAX({selfReferenceSql}) AS is_self_reference, + MAX({mutualRecursionSql}) AS is_mutual_recursion, + {sourceSymbolIdSql} AS source_symbol_id, + CASE + WHEN COUNT(DISTINCT COALESCE({targetSymbolIdSql}, -1)) = 1 + THEN MIN({targetSymbolIdSql}) + ELSE NULL + END AS target_symbol_id, + GROUP_CONCAT(DISTINCT {targetSymbolIdSql}) AS target_symbol_ids + FROM symbol_references r + JOIN files f ON r.file_id = f.id{referenceLineJoin} + WHERE {callerContainerPredicate} + AND (r.reference_kind IN {CallGraphReferenceKindsSql}{(includeMemberReads ? " OR r.reference_kind = 'member_read'" : string.Empty)}) + AND {supportedLangFilter} + {targetCondition}" + : $@" WITH logical_references AS ( SELECT f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.reference_kind, r.line, r.column_number, {sourceSymbolIdSql} AS source_symbol_id, @@ -843,46 +889,59 @@ FROM symbol_references r "r", includeQualifiedCommonCalls: false); AppendPathFilters(ref sql, pathPatterns, excludePathPatterns, excludeTests); - sql += @" - GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.reference_kind, r.file_id, r.line, r.column_number, source_symbol_id, target_symbol_id - ), - ranked_references AS ( - SELECT r.*, - ROW_NUMBER() OVER ( - PARTITION BY path, lang, " + BuildCallerKindProjectionSql("r") + @", - CASE WHEN lang = 'solution' AND reference_kind = 'project_reference' THEN path - ELSE " + BuildCallerNameProjectionSql("r") + @" END, - symbol_name, reference_kind, source_symbol_id - ORDER BY line, - CASE WHEN column_number IS NULL THEN 1 ELSE 0 END, - column_number, - CASE WHEN span_length IS NULL OR span_length <= 0 THEN 1 ELSE 0 END, - COALESCE(span_length, 0), - COALESCE(target_symbol_id, -1) - ) AS location_rank - FROM logical_references r - ) - SELECT path, lang, " + BuildCallerKindProjectionSql("r") + @" AS container_kind, - CASE WHEN lang = 'solution' AND reference_kind = 'project_reference' THEN path - ELSE " + BuildCallerNameProjectionSql("r") + @" END AS container_name, - symbol_name, - reference_kind, - MAX(CASE WHEN location_rank = 1 THEN line END) AS first_line, - COALESCE(MAX(CASE WHEN location_rank = 1 THEN column_number END), 0) AS first_column, - MAX(CASE WHEN location_rank = 1 THEN span_length END) AS first_length, - COUNT(*) AS reference_count, - MAX(is_self_reference) AS is_self_reference, - MAX(is_mutual_recursion) AS is_mutual_recursion, - source_symbol_id, - CASE - WHEN COUNT(DISTINCT COALESCE(target_symbol_id, -1)) = 1 - THEN MIN(target_symbol_id) - ELSE NULL - END AS target_symbol_id, - GROUP_CONCAT(DISTINCT target_symbol_id) AS target_symbol_ids - FROM ranked_references r - GROUP BY path, lang, container_kind, container_name, symbol_name, reference_kind, source_symbol_id"; - sql += $" ORDER BY {GetPathBucketOrderSql("r.path")}, reference_count DESC, r.path, COALESCE(r.container_name, ''), COALESCE(r.container_kind, ''), r.symbol_name, reference_kind, first_line, COALESCE(source_symbol_id, -1) LIMIT @limit OFFSET @offset"; + if (countOnly) + { + sql += $@" + GROUP BY f.path, f.lang, {BuildCallerKindProjectionSql("r")}, + CASE WHEN f.lang = 'solution' AND r.reference_kind = 'project_reference' THEN f.path + ELSE {BuildCallerNameProjectionSql("r")} END, + r.symbol_name, r.reference_kind, {sourceSymbolIdSql}"; + } + else + { + sql += @" + GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.reference_kind, r.file_id, r.line, r.column_number, source_symbol_id, target_symbol_id + ), + ranked_references AS ( + SELECT r.*, + ROW_NUMBER() OVER ( + PARTITION BY path, lang, " + BuildCallerKindProjectionSql("r") + @", + CASE WHEN lang = 'solution' AND reference_kind = 'project_reference' THEN path + ELSE " + BuildCallerNameProjectionSql("r") + @" END, + symbol_name, reference_kind, source_symbol_id + ORDER BY line, + CASE WHEN column_number IS NULL THEN 1 ELSE 0 END, + column_number, + CASE WHEN span_length IS NULL OR span_length <= 0 THEN 1 ELSE 0 END, + COALESCE(span_length, 0), + COALESCE(target_symbol_id, -1) + ) AS location_rank + FROM logical_references r + ) + SELECT path, lang, " + BuildCallerKindProjectionSql("r") + @" AS container_kind, + CASE WHEN lang = 'solution' AND reference_kind = 'project_reference' THEN path + ELSE " + BuildCallerNameProjectionSql("r") + @" END AS container_name, + symbol_name, + reference_kind, + MAX(CASE WHEN location_rank = 1 THEN line END) AS first_line, + COALESCE(MAX(CASE WHEN location_rank = 1 THEN column_number END), 0) AS first_column, + MAX(CASE WHEN location_rank = 1 THEN span_length END) AS first_length, + COUNT(*) AS reference_count, + MAX(is_self_reference) AS is_self_reference, + MAX(is_mutual_recursion) AS is_mutual_recursion, + source_symbol_id, + CASE + WHEN COUNT(DISTINCT COALESCE(target_symbol_id, -1)) = 1 + THEN MIN(target_symbol_id) + ELSE NULL + END AS target_symbol_id, + GROUP_CONCAT(DISTINCT target_symbol_id) AS target_symbol_ids + FROM ranked_references r + GROUP BY path, lang, container_kind, container_name, symbol_name, reference_kind, source_symbol_id"; + } + sql += countOnly + ? $" ORDER BY {GetPathBucketOrderSql("f.path")}, reference_count DESC, f.path, COALESCE(container_name, ''), COALESCE(container_kind, ''), r.symbol_name, reference_kind, first_line, COALESCE(source_symbol_id, -1) LIMIT @limit OFFSET @offset" + : $" ORDER BY {GetPathBucketOrderSql("r.path")}, reference_count DESC, r.path, COALESCE(r.container_name, ''), COALESCE(r.container_kind, ''), r.symbol_name, reference_kind, first_line, COALESCE(source_symbol_id, -1) LIMIT @limit OFFSET @offset"; cmd.CommandText = sql; SqliteCommandPolicy.Add(cmd, "@symbolName", symbolName); @@ -892,6 +951,14 @@ FROM ranked_references r SqliteCommandPolicy.Add(cmd, "@symbolNameLeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(symbolName)) ?? SqlNameResolver.GetLeafName(symbolName)); if (_foldReady) AddPersistedFoldedNameQueryParameters(cmd, "@symbolNameFolded", symbolName, lang); + if (hasCountNameBatch) + { + SqliteCommandPolicy.Add(cmd, "@countTargetNamesJson", JsonStringListCodec.Serialize(normalizedCountTargetNames)); + SqliteCommandPolicy.Add( + cmd, + "@countTargetNamesFoldedJson", + JsonStringListCodec.Serialize(normalizedCountTargetNames.Select(static name => NameFold.Fold(name) ?? name).ToList())); + } for (var i = 0; i < polymorphicCSharpSymbolNames.Count; i++) { if (_foldReady) @@ -954,15 +1021,21 @@ FROM ranked_references r /// metadata and a class-like file-dependency fallback when symbol-level callers are absent. /// The bound is inclusive (callers at depth 1..N are returned); /// maxDepth: 0 short-circuits to symbol resolution only. + /// When is true, is ignored as a + /// presentation limit and a dedicated traversal safety budget bounds the count instead. /// impact 用に caller BFS と解決メタデータを束ね、class 系で caller 不在なら /// file dependency をフォールバックとして返す。 は inclusive で /// N 指定時は depth 1〜N の caller を返し、maxDepth: 0 は symbol 解決のみで終了する。 + /// が true の場合、 は表示上限として + /// 無視し、件数は専用 traversal safety budget まで算出する。 /// - public ImpactAnalysisResult AnalyzeImpact(string symbolName, int maxDepth = 5, int limit = 50, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool withPaths = false, int offset = 0, string? responseCollection = null, bool includeMemberReads = false, DefinitionResult? selectedDefinition = null) + public ImpactAnalysisResult AnalyzeImpact(string symbolName, int maxDepth = 5, int limit = 50, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool withPaths = false, int offset = 0, string? responseCollection = null, bool includeMemberReads = false, DefinitionResult? selectedDefinition = null, bool countOnly = false) { lang = NormalizeQueryLanguage(lang); var resolvedName = selectedDefinition?.Name ?? ResolveSymbolName(symbolName, lang); - var definitionOffset = string.Equals(responseCollection, "definitions", StringComparison.Ordinal) ? offset : 0; + var traversalLimit = countOnly ? GetImpactCountTraversalLimit() : limit; + var effectiveOffset = countOnly ? 0 : offset; + var definitionOffset = string.Equals(responseCollection, "definitions", StringComparison.Ordinal) ? effectiveOffset : 0; var selectedDefinitionMatchesFilters = selectedDefinition != null && SelectedDefinitionMatchesImpactFilters( selectedDefinition, @@ -974,12 +1047,12 @@ public ImpactAnalysisResult AnalyzeImpact(string symbolName, int maxDepth = 5, i ? selectedDefinitionMatchesFilters ? ResolveSelectedImpactDefinition(selectedDefinition) : EmptyImpactDefinitionResolution() - : ResolveImpactDefinitions(symbolName, limit, lang, pathPatterns, excludePathPatterns, excludeTests, definitionOffset); + : ResolveImpactDefinitions(symbolName, traversalLimit, lang, pathPatterns, excludePathPatterns, excludeTests, definitionOffset); if (selectedDefinition == null && definitionResolution.Definitions.Count == 0 && !string.Equals(symbolName, resolvedName, StringComparison.Ordinal)) { - definitionResolution = ResolveImpactDefinitions(resolvedName, limit, lang, pathPatterns, excludePathPatterns, excludeTests, definitionOffset); + definitionResolution = ResolveImpactDefinitions(resolvedName, traversalLimit, lang, pathPatterns, excludePathPatterns, excludeTests, definitionOffset); } var definitions = definitionResolution.Definitions; var indexedPathComparer = GetIndexedPathComparer(); @@ -1049,6 +1122,11 @@ public ImpactAnalysisResult AnalyzeImpact(string symbolName, int maxDepth = 5, i DefinitionFileCount = definitionResolution.PhysicalFileCount, LogicalDefinitionCount = definitionResolution.LogicalCount, HintCount = 0, + ConfirmedCount = 0, + ConfirmedFileCount = 0, + HintFileCount = 0, + ActualDepth = 0, + CountFileHistogram = new Dictionary(indexedPathComparer), HasClassLikeDefinitions = hasClassLikeDefinitions, HasMultipleDefinitions = hasMultipleDefinitions, HasMultipleDefinitionFiles = definitionResolution.PhysicalFileCount > 1, @@ -1087,23 +1165,30 @@ public ImpactAnalysisResult AnalyzeImpact(string symbolName, int maxDepth = 5, i } var callerOffset = responseCollection is null || string.Equals(responseCollection, "callers", StringComparison.Ordinal) - ? offset + ? effectiveOffset : 0; - var (callers, truncated, truncatedReason, terminationReason, cycles) = selectedDefinition != null - ? selectedDefinitionMatchesFilters - ? GetTransitiveCallersForCandidate( - selectedDefinition, + var traversal = selectedDefinition != null && !selectedDefinitionMatchesFilters + ? CreateEmptyImpactTraversalExecutionResult() + : AnalyzeTransitiveCallers( + symbolName, maxDepth, - limit, + traversalLimit, lang, pathPatterns, excludePathPatterns, excludeTests, withPaths, callerOffset, - includeMemberReads) - : ([], false, null, ImpactTerminationReasons.Completed, []) - : GetTransitiveCallers(symbolName, maxDepth, limit, lang, pathPatterns, excludePathPatterns, excludeTests, withPaths, resultOffset: callerOffset, includeMemberReads: includeMemberReads); + includeMemberReads, + countOnly, + selectedDefinition); + var callers = traversal.Results; + var confirmedCount = traversal.Count; + var confirmedFileCount = traversal.FileCount; + var truncated = traversal.Truncated; + var truncatedReason = traversal.TruncatedReason; + var terminationReason = traversal.TerminationReason; + var cycles = traversal.Cycles; var callerExistsBeforeOffset = false; if (callers.Count == 0 && callerOffset > 0) { @@ -1127,13 +1212,16 @@ public ImpactAnalysisResult AnalyzeImpact(string symbolName, int maxDepth = 5, i var impactMode = "callers"; var fileImpacts = new List(); + var hintCount = 0; + var hintFileCount = 0; + IReadOnlyDictionary countFileHistogram = traversal.FileCounts; string? zeroResultReason = null; List? impactFailureChain = null; string? suggestionType = null; string? suggestion = null; var heuristic = !identityRootSignal.Available; - if (callers.Count == 0 && !callerExistsBeforeOffset) + if (confirmedCount == 0 && !callerExistsBeforeOffset) { impactMode = "none"; impactFailureChain = []; @@ -1174,12 +1262,12 @@ public ImpactAnalysisResult AnalyzeImpact(string symbolName, int maxDepth = 5, i ? definitionResolution.PhysicalDefinitionPaths : null); var fileImpactOffset = responseCollection is null || string.Equals(responseCollection, "file_impacts", StringComparison.Ordinal) - ? offset + ? effectiveOffset : 0; - var (hintResults, hintTruncated) = GetFileDependencyHintsToResolvedType( + var hintAnalysis = GetFileDependencyHintsToResolvedType( fallbackDefinitions[0], fallbackNames, - limit, + traversalLimit, lang, pathPatterns, excludePathPatterns, @@ -1187,8 +1275,12 @@ public ImpactAnalysisResult AnalyzeImpact(string symbolName, int maxDepth = 5, i fileImpactOffset, logicalPartialFamilyDefinition != null ? definitionResolution.PhysicalDefinitionPaths - : null); - fileImpacts = hintResults; + : null, + countOnly); + fileImpacts = hintAnalysis.Results; + hintCount = hintAnalysis.Count; + hintFileCount = hintAnalysis.FileCounts.Count; + countFileHistogram = hintAnalysis.FileCounts; var hintExistsBeforeOffset = false; if (fileImpacts.Count == 0 && fileImpactOffset > 0) { @@ -1206,18 +1298,24 @@ public ImpactAnalysisResult AnalyzeImpact(string symbolName, int maxDepth = 5, i : null); hintExistsBeforeOffset = hintProbe.Results.Count > 0; } - if (hintTruncated) + if (hintAnalysis.Truncated) { truncated = true; - // Heuristic hints can only be capped by the user-supplied --limit, so this - // path never escalates to safety_cap. Leave any pre-existing reason - // (e.g. safety_cap propagated from the caller BFS above) intact since it - // is the stronger signal. Issue #1533. - // ヒント側の truncation は --limit による cap のみ。caller BFS で - // safety_cap が立っていればそちらを優先する (#1533)。 - truncatedReason ??= ImpactTruncatedReasons.UserLimit; + // Row output uses the user-supplied --limit; count-only uses its dedicated + // traversal cap. Preserve the distinct retry contract from issue #1533. + // 通常の row 出力は --limit、count-only は専用 traversal cap で打ち切る。 + // Issue #1533 の再試行契約を区別したまま維持する。 + if (countOnly) + { + truncatedReason = ImpactTruncatedReasons.SafetyCap; + terminationReason = ImpactTerminationReasons.SafetyCap; + } + else + { + truncatedReason ??= ImpactTruncatedReasons.UserLimit; + } } - if (fileImpacts.Count > 0 || hintExistsBeforeOffset) + if (hintCount > 0 || hintExistsBeforeOffset) { impactMode = "file_dependency_hints"; heuristic = true; @@ -1263,7 +1361,12 @@ public ImpactAnalysisResult AnalyzeImpact(string symbolName, int maxDepth = 5, i DefinitionCount = definitionResolution.PhysicalCount, DefinitionFileCount = definitionResolution.PhysicalFileCount, LogicalDefinitionCount = definitionResolution.LogicalCount, - HintCount = fileImpacts.Count, + HintCount = hintCount, + ConfirmedCount = confirmedCount, + ConfirmedFileCount = confirmedFileCount, + HintFileCount = hintFileCount, + ActualDepth = traversal.ActualDepth, + CountFileHistogram = countFileHistogram, HasClassLikeDefinitions = hasClassLikeDefinitions, HasMultipleDefinitions = hasMultipleDefinitions, HasMultipleDefinitionFiles = definitionResolution.PhysicalFileCount > 1, @@ -1399,7 +1502,13 @@ WHERE f.path IN (SELECT value FROM json_each(@targetPathsJson)) return results; } - private (List Results, bool Truncated) GetFileDependencyHintsToResolvedType( + private sealed record FileDependencyHintQueryResult( + List Results, + int Count, + IReadOnlyDictionary FileCounts, + bool Truncated); + + private FileDependencyHintQueryResult GetFileDependencyHintsToResolvedType( SymbolResult definition, IReadOnlyList fallbackNames, int limit, @@ -1408,10 +1517,15 @@ WHERE f.path IN (SELECT value FROM json_each(@targetPathsJson)) IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, int offset = 0, - IReadOnlySet? physicalDefinitionPaths = null) + IReadOnlySet? physicalDefinitionPaths = null, + bool countOnly = false) { if (!_hasReferencesTable || string.IsNullOrWhiteSpace(definition.Path) || fallbackNames.Count == 0) - return (new List(), false); + return new FileDependencyHintQueryResult( + [], + 0, + new Dictionary(GetIndexedPathComparer()), + false); var definitionPaths = physicalDefinitionPaths is { Count: > 0 } ? physicalDefinitionPaths.Order(StringComparer.Ordinal).ToList() @@ -1550,9 +1664,17 @@ FROM symbol_references r offset = Math.Max(0, offset); var truncated = filtered.Count > checked(offset + limit); - filtered = filtered.Skip(offset).Take(limit).ToList(); - - return (filtered, truncated); + var counted = filtered.Skip(offset).Take(limit).ToList(); + var fileCounts = counted + .GroupBy(static result => result.SourcePath, GetIndexedPathComparer()) + .ToDictionary( + static group => group.Key, + static group => group.Count(), + GetIndexedPathComparer()); + var count = counted.Count; + filtered = countOnly ? [] : counted; + + return new FileDependencyHintQueryResult(filtered, count, fileCounts, truncated); } // Returns true when the metadata target name resolves to at most one class-like diff --git a/src/CodeIndex/Database/DbReader.ImpactTraversal.Engine.cs b/src/CodeIndex/Database/DbReader.ImpactTraversal.Engine.cs index 8cdf8af71..96889b628 100644 --- a/src/CodeIndex/Database/DbReader.ImpactTraversal.Engine.cs +++ b/src/CodeIndex/Database/DbReader.ImpactTraversal.Engine.cs @@ -1,3 +1,4 @@ +using CodeIndex.Indexer; using CodeIndex.Models; namespace CodeIndex.Database; @@ -24,28 +25,113 @@ internal ImpactTraversalEngine( _state = new ImpactTraversalState(owner, request, root); } - internal (List Results, bool Truncated, string? TruncatedReason, string TerminationReason, List Cycles) Run() + internal ImpactTraversalExecutionResult Run() { while (_state.CanTraverse) { + if (_request.CountOnly && TryTraverseCountBatch()) + continue; var node = _state.Queue.Dequeue(); TraverseNode(in node); } _state.CompleteTraversal(); - _state.Paths.Materialize(_state.Results, _request.MaxPathsPerResult); - return ( + if (!_request.CountOnly) + _state.Paths.Materialize(_state.Results, _request.MaxPathsPerResult); + return new ImpactTraversalExecutionResult( _state.Results, + _request.CountOnly ? _state.DiscoveredResultCount : _state.Results.Count, + _state.FileCounts, + _state.ActualDepth, _state.Truncated, _state.TruncatedReason, _state.ResolveTerminationReason(), _state.Cycles.Results); } + private bool TryTraverseCountBatch() + { + var first = _state.Queue.Peek(); + if (!CanBatchCountNode(in first)) + return false; + + var nodes = new List(ImpactCountCallerTargetBatchSize); + while (_state.Queue.Count > 0 + && nodes.Count < ImpactCountCallerTargetBatchSize) + { + var candidate = _state.Queue.Peek(); + if (candidate.Depth != first.Depth || !CanBatchCountNode(in candidate)) + break; + nodes.Add(_state.Queue.Dequeue()); + } + + if (nodes.Count == 1) + { + var node = nodes[0]; + TraverseNode(in node); + } + else + { + TraverseCountBatch(nodes); + } + return true; + } + + private bool CanBatchCountNode(in ImpactTraversalFrontierNode node) + { + var hasIdentity = node.SymbolId != null || node.TargetSymbolIds is { Count: > 0 }; + if (!hasIdentity) + return false; + return _request.Lang == "csharp" || !SqlNameResolver.HasQualifier(node.Symbol); + } + + private void TraverseCountBatch(IReadOnlyList nodes) + { + var pageOffset = 0; + var fetchIterations = 0; + var syntheticNode = new ImpactTraversalFrontierNode( + nodes[0].Symbol, + SymbolId: null, + TargetSymbolIds: null, + NodeKey: string.Empty, + nodes[0].Depth); + + while (_state.CanFetchCurrentNode && fetchIterations < MaxFetchIterations) + { + fetchIterations++; + var pageSize = CountPageSize(); + var page = _owner.ReadImpactCountCallerBatch( + nodes, + _request, + pageSize, + pageOffset, + _root.IncludeAmbiguousMSource); + if (page.Count == 0) + break; + + ProcessPage(in syntheticNode, page); + pageOffset += page.Count; + if (page.Count < pageSize) + break; + } + + if (fetchIterations >= MaxFetchIterations) + _state.MarkSafetyCap(); + } + private void TraverseNode(in ImpactTraversalFrontierNode node) { var needed = _state.ResultWindowEnd - _state.DiscoveredResultCount; - var pageSize = Math.Max(1, needed + 1); + // The count safety budget can be much larger than a presentation window. Feeding + // that budget to every row-oriented caller query makes moderately connected graphs + // exceed MCP's request deadline even when the final total is small. Page count-only + // reads independently while retaining the same global traversal cap. + // count safety budget は表示 window より大きいため、その値を各 caller query の + // LIMIT に流すと中規模 graph でも MCP deadline を超える。count-only の read は + // 独立した小さい page に分け、全体 traversal cap は従来どおり維持する。 + var pageSize = _request.CountOnly + ? CountPageSize() + : Math.Max(1, needed + 1); var pageOffset = 0; var fetchIterations = 0; @@ -71,6 +157,14 @@ private void TraverseNode(in ImpactTraversalFrontierNode node) _state.MarkSafetyCap(); } + private int CountPageSize() + { + var needed = _state.ResultWindowEnd - _state.DiscoveredResultCount; + return needed <= ImpactCountCallerPageSize + ? Math.Max(1, needed + 1) + : ImpactCountCallerPageSize; + } + private void ProcessPage( in ImpactTraversalFrontierNode node, IReadOnlyList page) @@ -79,7 +173,7 @@ private void ProcessPage( { if (_state.DiscoveredResultCount >= _state.ResultWindowEnd) { - _state.MarkUserLimit(); + _state.MarkResultWindowLimit(); break; } if (!ProcessCaller(in node, caller)) @@ -94,7 +188,10 @@ private bool ProcessCaller( var callerName = caller.CallerName ?? SyntheticTopLevelCallerName; var callerSymbolId = _root.HasResolvedIdentityGraph ? caller.CallerSymbolId : null; var calleeSymbolId = _root.HasResolvedIdentityGraph ? caller.CalleeSymbolId : null; - var cycleEdges = _state.Cycles.Observe(caller, callerName, node.Symbol); + var calleeName = _request.CountOnly && node.NodeKey.Length == 0 + ? caller.CalleeName + : node.Symbol; + var cycleEdges = _state.Cycles.Observe(caller, callerName, calleeName); if (IsRootCaller(caller, callerName)) return true; @@ -125,7 +222,7 @@ private bool ProcessCaller( var result = _state.IncludeNextResult ? BuildResult(caller, callerSymbolId, calleeSymbolId, node.Depth + 1) : null; - var resultIndex = _state.AddResult(result, visitedKey); + var resultIndex = _state.AddResult(result, visitedKey, caller.Path, node.Depth + 1); _state.Paths.RecordCaller( callerNodeKey, node.NodeKey, diff --git a/src/CodeIndex/Database/DbReader.ImpactTraversal.State.cs b/src/CodeIndex/Database/DbReader.ImpactTraversal.State.cs index d571558b7..6b1890fea 100644 --- a/src/CodeIndex/Database/DbReader.ImpactTraversal.State.cs +++ b/src/CodeIndex/Database/DbReader.ImpactTraversal.State.cs @@ -41,12 +41,14 @@ private sealed class ImpactTraversalState private readonly int _resultOffset; private readonly int _graphStateEntryBudget; private readonly Dictionary? _resultIndexByVisitedKey; + private readonly bool _countOnly; internal ImpactTraversalState( DbReader owner, ImpactTraversalRequest request, ImpactTraversalRoot root) { + _countOnly = request.CountOnly; _resultOffset = Math.Max(0, request.ResultOffset); ResultWindowEnd = checked(_resultOffset + request.Limit); _graphStateEntryBudget = owner.GetImpactGraphStateEntryBudget(ResultWindowEnd); @@ -55,9 +57,10 @@ internal ImpactTraversalState( Visited = new HashSet(StringComparer.OrdinalIgnoreCase) { root.ResolvedName }; Cycles = new ImpactCycleTracker(root); Paths = new ImpactPathTracker(owner, request.WithPaths, root); - _resultIndexByVisitedKey = root.IsLogicalPartialFamily + _resultIndexByVisitedKey = root.IsLogicalPartialFamily && !request.CountOnly ? new Dictionary(StringComparer.OrdinalIgnoreCase) : null; + FileCounts = new Dictionary(owner.GetIndexedPathComparer()); Truncated = root.InitiallyTruncated; TruncatedReason = root.InitiallyTruncated ? ImpactTruncatedReasons.SafetyCap @@ -69,9 +72,11 @@ internal ImpactTraversalState( internal ImpactCycleTracker Cycles { get; } internal ImpactPathTracker Paths { get; } internal List Results { get; } = []; + internal Dictionary FileCounts { get; } internal int ResultWindowEnd { get; } internal int BoundaryProbeBudget { get; } internal int DiscoveredResultCount { get; private set; } + internal int ActualDepth { get; private set; } internal bool Truncated { get; private set; } internal string? TruncatedReason { get; private set; } internal bool MaxDepthReached { get; set; } @@ -89,7 +94,7 @@ internal bool CanFetchCurrentNode && !GraphStateBudgetHit && !BoundaryProbeBudgetHit; - internal bool IncludeNextResult => DiscoveredResultCount >= _resultOffset; + internal bool IncludeNextResult => !_countOnly && DiscoveredResultCount >= _resultOffset; private static Queue CreateInitialQueue( ImpactTraversalRoot root) @@ -142,15 +147,21 @@ private static Queue CreateInitialQueue( internal bool TryVisit(string key) => Visited.Add(key); - internal int AddResult(ImpactResult? result, string visitedKey) + internal int AddResult(ImpactResult? result, string visitedKey, string path, int depth) { var resultIndex = -1; + var includeInSummary = _countOnly || IncludeNextResult; if (IncludeNextResult) { Results.Add(result!); resultIndex = Results.Count - 1; _resultIndexByVisitedKey?.Add(visitedKey, resultIndex); } + if (includeInSummary) + { + FileCounts[path] = FileCounts.TryGetValue(path, out var count) ? count + 1 : 1; + ActualDepth = Math.Max(ActualDepth, depth); + } DiscoveredResultCount++; return resultIndex; } @@ -181,8 +192,13 @@ internal void MarkBoundaryProbeBudget() TruncatedReason = ImpactTruncatedReasons.BoundaryProbeBudget; } - internal void MarkUserLimit() + internal void MarkResultWindowLimit() { + if (_countOnly) + { + MarkSafetyCap(); + return; + } Truncated = true; TruncatedReason ??= ImpactTruncatedReasons.UserLimit; } @@ -196,7 +212,7 @@ internal void MarkSafetyCap() internal void CompleteTraversal() { if (Queue.Count > 0 && DiscoveredResultCount >= ResultWindowEnd) - MarkUserLimit(); + MarkResultWindowLimit(); } internal string ResolveTerminationReason() diff --git a/src/CodeIndex/Database/DbReader.ImpactTraversal.cs b/src/CodeIndex/Database/DbReader.ImpactTraversal.cs index b8b860b9f..e7f2b4ed5 100644 --- a/src/CodeIndex/Database/DbReader.ImpactTraversal.cs +++ b/src/CodeIndex/Database/DbReader.ImpactTraversal.cs @@ -13,6 +13,13 @@ public partial class DbReader // 収束する場合に JSON 膨張を抑える役割があり、超過時は PathsTruncated で通知する。 private const int DefaultImpactPathsPerResult = 10; internal const int DefaultImpactGraphStateEntryBudget = 10_000; + // Count-only traversal uses a dedicated result budget instead of the presentation limit. + // count-only traversal は表示用 limit ではなく専用の result budget を使う。 + internal const int DefaultImpactCountTraversalLimit = 10_000; + // Keep count traversal SQL pages bounded independently of the overall count budget. + // count traversal の SQL page size は全体の count budget と分離して小さく保つ。 + internal const int ImpactCountCallerPageSize = 64; + internal const int ImpactCountCallerTargetBatchSize = 128; internal const int DefaultImpactPartialFamilyMemberBudget = 10_000; internal int ImpactPartialFamilyMemberBudget { get; set; } = DefaultImpactPartialFamilyMemberBudget; internal const int ImpactBoundaryCallerProbeBudget = 512; @@ -20,6 +27,7 @@ public partial class DbReader internal int? ImpactGraphStateEntryBudgetForTesting { get; set; } internal int? ImpactBoundaryCallerProbeBudgetForTesting { get; set; } + internal int? ImpactCountTraversalLimitForTesting { get; set; } private sealed record ImpactTraversalRequest( string SymbolName, @@ -33,7 +41,21 @@ private sealed record ImpactTraversalRequest( int MaxPathsPerResult, int ResultOffset, bool IncludeMemberReads, - DefinitionResult? SelectedDefinition); + DefinitionResult? SelectedDefinition, + bool CountOnly); + + private sealed record ImpactTraversalExecutionResult( + List Results, + int Count, + IReadOnlyDictionary FileCounts, + int ActualDepth, + bool Truncated, + string? TruncatedReason, + string TerminationReason, + List Cycles) + { + internal int FileCount => FileCounts.Count; + } private sealed record ImpactTraversalRoot( string ResolvedName, @@ -109,12 +131,15 @@ private readonly record struct ImpactBoundaryInspection( maxPathsPerResult, resultOffset, includeMemberReads, - SelectedDefinition: null); - var root = ResolveImpactTraversalRoot(request); - if (root == null) - return ([], false, null, ImpactTerminationReasons.Completed, []); - - return new ImpactTraversalEngine(this, request, root).Run(); + SelectedDefinition: null, + CountOnly: false); + var execution = RunImpactTraversal(request); + return ( + execution.Results, + execution.Truncated, + execution.TruncatedReason, + execution.TerminationReason, + execution.Cycles); } internal (List Results, bool Truncated, string? TruncatedReason, string TerminationReason, List Cycles) GetTransitiveCallersForCandidate( @@ -141,13 +166,67 @@ private readonly record struct ImpactBoundaryInspection( DefaultImpactPathsPerResult, resultOffset, includeMemberReads, - definition); + definition, + CountOnly: false); + var execution = RunImpactTraversal(request); + return ( + execution.Results, + execution.Truncated, + execution.TruncatedReason, + execution.TerminationReason, + execution.Cycles); + } + + private ImpactTraversalExecutionResult AnalyzeTransitiveCallers( + string symbolName, + int maxDepth, + int limit, + string? lang, + IReadOnlyList? pathPatterns, + IReadOnlyList? excludePathPatterns, + bool excludeTests, + bool withPaths, + int resultOffset, + bool includeMemberReads, + bool countOnly, + DefinitionResult? selectedDefinition) + { + var request = new ImpactTraversalRequest( + selectedDefinition?.Name ?? symbolName, + maxDepth, + limit, + selectedDefinition == null ? lang : NormalizeQueryLanguage(lang) ?? selectedDefinition.Lang, + pathPatterns, + excludePathPatterns, + excludeTests, + withPaths && !countOnly, + DefaultImpactPathsPerResult, + countOnly ? 0 : resultOffset, + includeMemberReads, + selectedDefinition, + countOnly); + return RunImpactTraversal(request); + } + + private ImpactTraversalExecutionResult RunImpactTraversal(ImpactTraversalRequest request) + { var root = ResolveImpactTraversalRoot(request); return root == null - ? ([], false, null, ImpactTerminationReasons.Completed, []) + ? CreateEmptyImpactTraversalExecutionResult() : new ImpactTraversalEngine(this, request, root).Run(); } + private ImpactTraversalExecutionResult CreateEmptyImpactTraversalExecutionResult() + => new( + [], + 0, + new Dictionary(GetIndexedPathComparer()), + 0, + false, + null, + ImpactTerminationReasons.Completed, + []); + private ImpactTraversalRoot? ResolveImpactTraversalRoot(ImpactTraversalRequest request) { if (request.SelectedDefinition is { SymbolId: long selectedSymbolId } selectedDefinition) @@ -406,9 +485,47 @@ private List ReadImpactCallerPage( request.ExcludePathPatterns, request.ExcludeTests, targetIds, + countTargetSymbolNames: null, requireAuthoritativeIdentity: request.SelectedDefinition != null, includeAmbiguousMSource: includeAmbiguousMSource, - includeMemberReads: request.IncludeMemberReads); + includeMemberReads: request.IncludeMemberReads, + countOnly: request.CountOnly); + } + + private List ReadImpactCountCallerBatch( + IReadOnlyList nodes, + ImpactTraversalRequest request, + int pageSize, + int pageOffset, + bool includeAmbiguousMSource) + { + var targetIds = nodes + .SelectMany(static node => node.TargetSymbolIds is { Count: > 0 } + ? node.TargetSymbolIds + : node.SymbolId is long symbolId + ? [symbolId] + : Array.Empty()) + .Distinct() + .Order() + .ToArray(); + var targetNames = nodes + .Select(static node => node.Symbol) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + return GetCallersExactCore( + targetNames[0], + pageSize, + pageOffset, + request.Lang, + request.PathPatterns, + request.ExcludePathPatterns, + request.ExcludeTests, + targetIds, + targetNames, + requireAuthoritativeIdentity: request.SelectedDefinition != null, + includeAmbiguousMSource: includeAmbiguousMSource, + includeMemberReads: request.IncludeMemberReads, + countOnly: true); } private int GetImpactGraphStateEntryBudget(int limit) @@ -421,4 +538,7 @@ private int GetImpactGraphStateEntryBudget(int limit) private int GetImpactBoundaryCallerProbeBudget() => ImpactBoundaryCallerProbeBudgetForTesting ?? ImpactBoundaryCallerProbeBudget; + + private int GetImpactCountTraversalLimit() + => Math.Max(1, ImpactCountTraversalLimitForTesting ?? DefaultImpactCountTraversalLimit); } diff --git a/src/CodeIndex/Mcp/McpToolCatalog.cs b/src/CodeIndex/Mcp/McpToolCatalog.cs index 7510962fb..89aedce1d 100644 --- a/src/CodeIndex/Mcp/McpToolCatalog.cs +++ b/src/CodeIndex/Mcp/McpToolCatalog.cs @@ -472,7 +472,7 @@ private static JsonObject[] CreateGraphAndAnalysisTools() ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Symbol name to analyze impact for" }, ["maxHops"] = new JsonObject { ["type"] = "integer", ["description"] = "Max BFS hops, inclusive (default: 5; maxHops: N returns callers at hop 1..N, so a chain A→B→C→D queried against D with maxHops: 2 yields C at hop 1 and B at hop 2; 0 resolves the symbol without traversing callers). Server-side cap: 50; requests above the cap are clamped and a `warnings` entry plus `max_hops_requested` field is added to the response.", ["default"] = 5, ["minimum"] = 0, ["maximum"] = 50 }, ["maxDepth"] = new JsonObject { ["type"] = "integer", ["description"] = "Deprecated alias for `maxHops`; accepted during the compatibility period and reported in `warnings` when used.", ["minimum"] = 0, ["maximum"] = 50 }, - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max total callers or heuristic file-level dependency hints to return (default: 50). Check `truncated` when the limit is reached; `truncated_reason` distinguishes `user_limit` (raise `limit` to get more) from `safety_cap` (pathological graph, raising `limit` will not help).", ["default"] = QueryCommandRunner.DefaultImpactLimit }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max caller rows or heuristic file-level dependency hints to return (default: 50). Ignored as a presentation limit when `countOnly` is true; dedicated traversal safety caps still apply. Check `truncated`; `truncated_reason` distinguishes `user_limit` (raise `limit` for more rows) from safety-cap reasons (raising `limit` will not help).", ["default"] = QueryCommandRunner.DefaultImpactLimit }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), @@ -480,7 +480,7 @@ private static JsonObject[] CreateGraphAndAnalysisTools() ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, ["withPaths"] = new JsonObject { ["type"] = "boolean", ["description"] = "When true, each caller carries a `paths` array of shortest call chains [resolvedRoot, intermediate..., callerName]; diamond convergence surfaces every shortest route (per-row cap; `pathsTruncated` flag indicates overflow).", ["default"] = false }, ["includeMemberReads"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include canonical `member_read` value-read edges in impact traversal. Defaults to false; legacy indexes stored these reads as `call` and cannot separate them.", ["default"] = false }, - ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only count metadata and a small top-file histogram; omit caller and file-impact row payloads.", ["default"] = false } + ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return limit-independent count metadata and a small top-file histogram; omit caller and file-impact row payloads. Counts run to completion or a dedicated safety cap, where `truncated`, `authoritative_count: false`, and `total: null` expose a lower bound.", ["default"] = false } }, ["required"] = new JsonArray { "query" } }, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.ImpactAnalysis.cs b/src/CodeIndex/Mcp/McpToolHandlers.ImpactAnalysis.cs index ad5fde643..7f9a786b1 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.ImpactAnalysis.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.ImpactAnalysis.cs @@ -36,15 +36,24 @@ private JsonNode ExecuteImpactAnalysis(JsonNode? id, JsonNode? args) maxDepthClampWarning = $"maxHops was clamped from {maxDepthRequested} to {maxDepth} (server cap is [0, {MaxImpactDepth}])."; adjustments.AddClamped("maxHops", maxDepthRequested, maxDepth, 0, MaxImpactDepth); } - var limit = ReadLimit(args, QueryCommandRunner.DefaultImpactLimit, adjustments); + var countOnly = ReadCountOnly(args); + var requestedLimit = ReadOptionalIntArgument(args, "limit"); + var limit = countOnly + ? QueryCommandRunner.DefaultImpactLimit + : ReadLimit(args, QueryCommandRunner.DefaultImpactLimit, adjustments); + if (countOnly && requestedLimit is int ignoredLimit) + { + adjustments.AddIgnored( + "limit", + ignoredLimit, + "countOnly uses a dedicated traversal safety cap instead of a presentation limit."); + } var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; var withPaths = args?["withPaths"]?.GetValue() ?? false; var includeMemberReads = args?["includeMemberReads"]?.GetValue() ?? false; - var countOnly = ReadCountOnly(args); - return WithDbReader(id, args, reader => { if (TryResolveMcpGraphSelector(id, reader, selectorValue, out var selectedDefinition) is JsonNode selectorError) @@ -67,26 +76,26 @@ private JsonNode ExecuteImpactAnalysis(JsonNode? id, JsonNode? args) excludeTests, withPaths, includeMemberReads: includeMemberReads, - selectedDefinition: selectedDefinition); + selectedDefinition: selectedDefinition, + countOnly: countOnly); var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignal( reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests), DbReader.IsSqlLanguage(lang) || DbReader.ContainsSqlLanguage(analysis.Definitions.Select(definition => definition.Lang)) || DbReader.ContainsSqlLanguage(analysis.Callers.Select(caller => caller.Lang)) - || reader.AnyFilePathHasLanguage(analysis.FileImpacts.SelectMany(impact => new[] { impact.SourcePath, impact.TargetPath }), "sql")); - var confirmedCount = analysis.Callers.Count; - var confirmedFileCount = analysis.Callers.Select(r => r.Path).Distinct().Count(); - var hintCount = analysis.FileImpacts.Count; - var hintFileCount = analysis.FileImpacts.Select(r => r.SourcePath).Distinct().Count(); + || reader.AnyFilePathHasLanguage(analysis.FileImpacts.SelectMany(impact => new[] { impact.SourcePath, impact.TargetPath }), "sql") + || reader.AnyFilePathHasLanguage(analysis.CountFileHistogram.Keys, "sql")); + var confirmedCount = analysis.ConfirmedCount; + var confirmedFileCount = analysis.ConfirmedFileCount; + var hintCount = analysis.HintCount; + var hintFileCount = analysis.HintFileCount; var hasHeuristicHints = analysis.ImpactMode == "file_dependency_hints" && hintCount > 0; var count = hasHeuristicHints ? hintCount : confirmedCount; var fileCount = hasHeuristicHints ? hintFileCount : confirmedFileCount; - var maxActualDepth = analysis.Callers.Count > 0 ? analysis.Callers.Max(r => r.Depth) : 0; + var maxActualDepth = analysis.ActualDepth; if (countOnly) { - var topFiles = hasHeuristicHints - ? BuildTopFileHistogram(analysis.FileImpacts, impact => impact.SourcePath) - : BuildTopFileHistogram(analysis.Callers, caller => caller.Path); + var topFiles = BuildTopFileHistogramFromCounts(analysis.CountFileHistogram); var countOnlyPayload = new JsonObject { ["query"] = query, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 9a8edc41d..84ec6be69 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -563,6 +563,24 @@ private JsonArray BuildTopFileHistogram(IEnumerable results, Func counts) + { + var histogram = new JsonArray(); + foreach (var (path, count) in counts + .OrderByDescending(static pair => pair.Value) + .ThenBy(static pair => pair.Key, StringComparer.Ordinal) + .Take(5)) + { + histogram.Add(new JsonObject + { + ["path"] = path, + ["count"] = count, + }); + } + + return histogram; + } + private static bool MatchesRecipeFacetMetadata(CompactSearchResult result, SearchAuditRecipeQuery recipeQuery) { if (recipeQuery.MatchOrigins.Count > 0 && diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index 03aebd7df..300bffbcc 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -1231,6 +1231,17 @@ public class ImpactAnalysisResult [JsonIgnore] public int LogicalDefinitionCount { get; set; } public int HintCount { get; set; } + [JsonIgnore] + internal int ConfirmedCount { get; set; } + [JsonIgnore] + internal int ConfirmedFileCount { get; set; } + [JsonIgnore] + internal int HintFileCount { get; set; } + [JsonIgnore] + internal int ActualDepth { get; set; } + [JsonIgnore] + internal IReadOnlyDictionary CountFileHistogram { get; set; } + = new Dictionary(StringComparer.Ordinal); public bool HasClassLikeDefinitions { get; set; } public bool HasMultipleDefinitions { get; set; } public bool HasMultipleDefinitionFiles { get; set; } diff --git a/tests/CodeIndex.Tests/DbReaderIssue5226Tests.cs b/tests/CodeIndex.Tests/DbReaderIssue5226Tests.cs new file mode 100644 index 000000000..2ac64bc4c --- /dev/null +++ b/tests/CodeIndex.Tests/DbReaderIssue5226Tests.cs @@ -0,0 +1,111 @@ +using CodeIndex.Database; + +namespace CodeIndex.Tests; + +public partial class DbReaderTests +{ + [Fact] + public void AnalyzeImpact_CountOnlyPagesAndBatchesMultiHopCallers_Issue5226() + { + InsertIndexedFile( + "src/issue5226-paged/Target.cs", + "csharp", + "public static class Issue5226PagedTarget { public static void Hit() { } }"); + var callerCount = DbReader.ImpactCountCallerPageSize + 6; + for (int i = 0; i < callerCount; i++) + { + InsertIndexedFile( + $"src/issue5226-paged/Caller{i}.cs", + "csharp", + $"public static class Issue5226PagedCaller{i} {{ public static void Step{i}() {{ Issue5226PagedTarget.Hit(); }} }}"); + InsertIndexedFile( + $"src/issue5226-paged/Top{i}.cs", + "csharp", + $"public sealed class Issue5226PagedTop{i} {{ public void Run() {{ Issue5226PagedCaller{i}.Step{i}(); }} }}"); + } + InsertIndexedFile( + "src/issue5226-paged/cross_language.py", + "python", + "def issue5226_cross_language_top():\n return Step0() + Step1()\n"); + + var count = _reader.AnalyzeImpact( + "Issue5226PagedTarget.Hit", + maxDepth: 2, + limit: 1, + pathPatterns: ["src/issue5226-paged/*"], + countOnly: true); + + Assert.Empty(count.Callers); + Assert.Equal((callerCount * 2) + 1, count.ConfirmedCount); + Assert.Equal((callerCount * 2) + 1, count.ConfirmedFileCount); + Assert.Equal(2, count.ActualDepth); + Assert.False(count.Truncated); + Assert.True(count.CountIsAuthoritative); + } + + [Fact] + public void AnalyzeImpact_CountOnlyUsesDedicatedSafetyCapWithoutMaterializingRows_Issue5226() + { + InsertIndexedFile( + "src/issue5226/Target.cs", + "csharp", + "public static class Issue5226Target { public static void Hit() { } }"); + for (int i = 0; i < 4; i++) + { + InsertIndexedFile( + $"src/issue5226/Caller{i}.cs", + "csharp", + $"public sealed class Issue5226Caller{i} {{ public void Run() {{ Issue5226Target.Hit(); }} }}"); + } + + var count = _reader.AnalyzeImpact( + "Issue5226Target.Hit", + maxDepth: 1, + limit: 1, + lang: "csharp", + pathPatterns: ["src/issue5226/*"], + countOnly: true); + + Assert.Empty(count.Callers); + Assert.Equal(4, count.ConfirmedCount); + Assert.Equal(4, count.ConfirmedFileCount); + Assert.Equal(4, count.CountFileHistogram.Count); + Assert.Equal(1, count.ActualDepth); + Assert.False(count.Truncated); + Assert.True(count.CountIsAuthoritative); + + var rows = _reader.AnalyzeImpact( + "Issue5226Target.Hit", + maxDepth: 1, + limit: 1, + lang: "csharp", + pathPatterns: ["src/issue5226/*"]); + Assert.Single(rows.Callers); + Assert.Equal(1, rows.ConfirmedCount); + Assert.True(rows.Truncated); + Assert.Equal(ImpactTruncatedReasons.UserLimit, rows.TruncatedReason); + + _reader.ImpactCountTraversalLimitForTesting = 2; + try + { + var capped = _reader.AnalyzeImpact( + "Issue5226Target.Hit", + maxDepth: 1, + limit: 1, + lang: "csharp", + pathPatterns: ["src/issue5226/*"], + countOnly: true); + + Assert.Empty(capped.Callers); + Assert.Equal(2, capped.ConfirmedCount); + Assert.True(capped.Truncated); + Assert.Equal(ImpactTruncatedReasons.SafetyCap, capped.TruncatedReason); + Assert.Equal(ImpactTerminationReasons.SafetyCap, capped.TerminationReason); + Assert.False(capped.CountIsAuthoritative); + } + finally + { + _reader.ImpactCountTraversalLimitForTesting = null; + } + } +} diff --git a/tests/CodeIndex.Tests/McpServerIssue5226Tests.cs b/tests/CodeIndex.Tests/McpServerIssue5226Tests.cs new file mode 100644 index 000000000..0ac01a682 --- /dev/null +++ b/tests/CodeIndex.Tests/McpServerIssue5226Tests.cs @@ -0,0 +1,130 @@ +using System.Text.Json.Nodes; +using CodeIndex.Cli; +using CodeIndex.Indexer; +using CodeIndex.Mcp; + +namespace CodeIndex.Tests; + +public partial class McpServerTests +{ + [Fact] + public void ToolsCall_ImpactCountOnlyPreservesSqlCallerReadinessWithoutLanguageFilter_Issue5226() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_mcp_impact_count_sql_language_issue5226"); + try + { + var dbPath = CreateSqlGraphContractFixtureDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/issue5226_target.py", + "python", + "def issue5226_cross_language():\n return 0\n"); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/issue5226_caller.sql", + "sql", + """ + CREATE PROCEDURE dbo.issue5226_Caller + AS + BEGIN + SELECT issue5226_cross_language(); + END; + GO + """); + DowngradeSqlGraphContractRows(dbPath); + using var server = new McpServer(dbPath, ConsoleUi.LoadVersion()); + + var request = JsonNode.Parse( + """{"jsonrpc":"2.0","id":5226,"method":"tools/call","params":{"name":"impact_analysis","arguments":{"query":"issue5226_cross_language","countOnly":true,"limit":1,"maxHops":1}}}""")!; + var structured = server.HandleMessage(request)!["result"]!["structuredContent"]!; + + Assert.Equal(1, structured["count"]!.GetValue()); + Assert.False(structured["sql_graph_contract_ready"]!.GetValue()); + Assert.False(structured["authoritative_count"]!.GetValue()); + Assert.Null(structured["total"]); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void ToolsCall_ImpactCountOnlyIgnoresLimitAndPreservesCompletenessSignals_Issue5226() + { + InsertIndexedFile( + "src/issue5226-target.cs", + "csharp", + "public static class McpIssue5226Target { public static void Hit() { } }"); + for (int i = 0; i < 6; i++) + { + InsertIndexedFile( + $"src/issue5226-caller-{i}.cs", + "csharp", + $"public sealed class McpIssue5226Caller{i} {{ public void Run() {{ McpIssue5226Target.Hit(); }} }}"); + } + + JsonNode Call(bool countOnly, int limit) + { + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 5226, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "impact_analysis", + ["arguments"] = new JsonObject + { + ["query"] = "McpIssue5226Target.Hit", + ["lang"] = "csharp", + ["maxHops"] = 1, + ["limit"] = limit, + ["countOnly"] = countOnly, + }, + }, + }; + return _server.HandleMessage(request)!["result"]!["structuredContent"]!; + } + + var count = Call(countOnly: true, limit: 1000); + Assert.True(count["count_only"]!.GetValue()); + Assert.Equal(6, count["count"]!.GetValue()); + Assert.Equal(6, count["file_count"]!.GetValue()); + Assert.Equal(6, count["total"]!.GetValue()); + Assert.False(count["truncated"]!.GetValue()); + Assert.True(count["authoritative_count"]!.GetValue()); + Assert.Empty(count["results"]!.AsArray()); + Assert.Equal(5, count["top_files"]!.AsArray().Count); + var limitAdjustment = Assert.Single(count["argument_adjustments"]!.AsArray()); + Assert.Equal("limit", limitAdjustment!["argument"]!.GetValue()); + Assert.Equal("ignored", limitAdjustment["action"]!.GetValue()); + Assert.Equal(1000, limitAdjustment["requested"]!.GetValue()); + Assert.Null(limitAdjustment["effective"]); + + var rows = Call(countOnly: false, limit: 1); + Assert.Equal(1, rows["count"]!.GetValue()); + Assert.Single(rows["callers"]!.AsArray()); + Assert.True(rows["truncated"]!.GetValue()); + Assert.Equal("user_limit", rows["truncated_reason"]!.GetValue()); + + var capKind = ReferenceExtractor.ReferenceSafetyCapDiagnosticKinds[0]; + using (var command = _db.Connection.CreateCommand()) + { + command.CommandText = """ + INSERT INTO file_issues (file_id, kind, line, message) + SELECT id, @kind, 1, 'reference extraction safety cap reached' + FROM files + WHERE path = 'src/issue5226-target.cs'; + """; + command.Parameters.AddWithValue("@kind", capKind); + Assert.Equal(1, command.ExecuteNonQuery()); + } + + var incomplete = Call(countOnly: true, limit: 1); + Assert.Equal(6, incomplete["count"]!.GetValue()); + Assert.False(incomplete["reference_graph_complete"]!.GetValue()); + Assert.False(incomplete["authoritative_count"]!.GetValue()); + Assert.Null(incomplete["total"]); + } +} diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerImpactTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerImpactTests.cs index 86d2cb625..17b3dfb58 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerImpactTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerImpactTests.cs @@ -727,7 +727,7 @@ public void Boot(FooService service) } [Fact] - public void RunImpact_HeuristicHintsCountOnlyJsonUsesVisibleResultCount() + public void RunImpact_HeuristicHintsCountOnlyIgnoresPresentationLimit_Issue5226() { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_runner_impact_hint_count_only"); try @@ -750,10 +750,30 @@ public void Run(FolderDiffService service) } } """); + TestProjectHelper.InsertIndexedFile(dbPath, "src/App2.cs", "csharp", + """ + public class App2 + { + public void Run(FolderDiffService service) + { + service.ExecuteFolderDiffAsync(); + } + } + """); + TestProjectHelper.InsertIndexedFile(dbPath, "src/App3.cs", "csharp", + """ + public class App3 + { + public void Run(FolderDiffService service) + { + service.ExecuteFolderDiffAsync(); + } + } + """); MarkGraphAndFoldReady(dbPath); var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunImpact( - ["FolderDiffService", "--db", dbPath, "--json", "--count"], + ["FolderDiffService", "--db", dbPath, "--json", "--count", "--limit", "1"], _jsonOptions)); using var document = ParseJsonOutput(stdout); @@ -762,13 +782,13 @@ public void Run(FolderDiffService service) Assert.Equal(CommandExitCodes.Success, exitCode); Assert.Equal(string.Empty, stderr); Assert.Equal("file_dependency_hints", json.GetProperty("impact_mode").GetString()); - Assert.Equal(1, json.GetProperty("count").GetInt32()); - Assert.Equal(1, json.GetProperty("files").GetInt32()); - Assert.Equal(1, json.GetProperty("file_count").GetInt32()); + Assert.Equal(3, json.GetProperty("count").GetInt32()); + Assert.Equal(3, json.GetProperty("files").GetInt32()); + Assert.Equal(3, json.GetProperty("file_count").GetInt32()); Assert.Equal(0, json.GetProperty("confirmed_count").GetInt32()); Assert.Equal(0, json.GetProperty("confirmed_file_count").GetInt32()); - Assert.Equal(1, json.GetProperty("hint_count").GetInt32()); - Assert.Equal(1, json.GetProperty("hint_file_count").GetInt32()); + Assert.Equal(3, json.GetProperty("hint_count").GetInt32()); + Assert.Equal(3, json.GetProperty("hint_file_count").GetInt32()); Assert.False(json.GetProperty("degraded").GetBoolean()); Assert.True(json.GetProperty("authoritative_count").GetBoolean()); } @@ -779,7 +799,7 @@ public void Run(FolderDiffService service) } [Fact] - public void RunImpact_CountOnlyJson_UserLimitTruncationIsNonAuthoritative_Issue3566() + public void RunImpact_CountOnlyJson_IgnoresUserLimitAndRemainsAuthoritative_Issue5226() { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_impact_count_truncated_authority_3566"); try @@ -809,13 +829,13 @@ def target(): Assert.Equal(CommandExitCodes.Success, exitCode); Assert.Equal(string.Empty, stderr); - Assert.Equal(2, json.GetProperty("count").GetInt32()); - Assert.Equal(2, json.GetProperty("files").GetInt32()); - Assert.Equal(2, json.GetProperty("file_count").GetInt32()); - Assert.True(json.GetProperty("truncated").GetBoolean()); - Assert.Equal("user_limit", json.GetProperty("truncated_reason").GetString()); - Assert.True(json.GetProperty("degraded").GetBoolean()); - Assert.False(json.GetProperty("authoritative_count").GetBoolean()); + Assert.Equal(6, json.GetProperty("count").GetInt32()); + Assert.Equal(6, json.GetProperty("files").GetInt32()); + Assert.Equal(6, json.GetProperty("file_count").GetInt32()); + Assert.False(json.GetProperty("truncated").GetBoolean()); + Assert.False(json.TryGetProperty("truncated_reason", out _)); + Assert.False(json.GetProperty("degraded").GetBoolean()); + Assert.True(json.GetProperty("authoritative_count").GetBoolean()); } finally { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5226Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5226Tests.cs new file mode 100644 index 000000000..f7692e8e4 --- /dev/null +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5226Tests.cs @@ -0,0 +1,176 @@ +using CodeIndex.Cli; +using CodeIndex.Database; +using static CodeIndex.Tests.QueryCommandTestSupport; + +namespace CodeIndex.Tests; + +public partial class QueryCommandRunnerTests +{ + [Fact] + public void RunImpact_HumanCountWarnsWhenDedicatedSafetyCapReturnsLowerBound_Issue5226() + { + var analysis = new ImpactAnalysisResult + { + Truncated = true, + TruncatedReason = ImpactTruncatedReasons.SafetyCap, + TerminationReason = ImpactTerminationReasons.SafetyCap, + }; + + var (exitCode, stdout, stderr) = CaptureConsole(() => + { + QueryCommandRunner.WriteImpactHumanCount(2, analysis); + return CommandExitCodes.Success; + }); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal("2", stdout.Trim()); + Assert.Contains("safety_cap", stderr, StringComparison.Ordinal); + Assert.Contains("lower bound", stderr, StringComparison.Ordinal); + Assert.Contains("not authoritative", stderr, StringComparison.Ordinal); + } + + [Fact] + public void RunImpact_CountModesReturnLimitIndependentTotalsAcrossDepthsAndFilters_Issue5226() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_impact_count_issue5226"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/lib.py", + "python", + "def issue5226_target():\n return 0\n"); + for (int i = 0; i < 55; i++) + { + TestProjectHelper.InsertIndexedFile( + dbPath, + $"src/caller_{i:D2}.py", + "python", + $"def issue5226_caller_{i:D2}():\n return issue5226_target()\n"); + } + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/top.py", + "python", + "def issue5226_top():\n return issue5226_caller_00()\n"); + MarkGraphAndFoldReady(dbPath); + + var (humanExitCode, humanStdout, _) = CaptureConsole(() => QueryCommandRunner.RunImpact( + ["issue5226_target", "--db", dbPath, "--count", "--limit", "1", "--max-hops", "1"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, humanExitCode); + Assert.Equal("55", humanStdout.Trim()); + + foreach (var machineFlag in new[] { "--json", "--compact" }) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunImpact( + ["issue5226_target", "--db", dbPath, machineFlag, "--count", "--limit", "1", "--max-hops", "1"], + _jsonOptions)); + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal(55, json.GetProperty("count").GetInt32()); + Assert.Equal(55, json.GetProperty("file_count").GetInt32()); + Assert.False(json.GetProperty("truncated").GetBoolean()); + Assert.True(json.GetProperty("authoritative_count").GetBoolean()); + } + + var one = RunCountJson( + dbPath, + ["--path", "src/lib.py", "--path", "src/caller_00.py", "--limit", "1", "--max-hops", "1"]); + Assert.Equal(1, one.GetProperty("count").GetInt32()); + + var zero = RunCountJson( + dbPath, + ["--path", "src/lib.py", "--limit", "1", "--max-hops", "1"]); + Assert.Equal(0, zero.GetProperty("count").GetInt32()); + + var filtered = RunCountJson( + dbPath, + ["--exclude-path", "src/caller_00.py", "--limit", "1", "--max-hops", "1"]); + Assert.Equal(54, filtered.GetProperty("count").GetInt32()); + + var multiHop = RunCountJson(dbPath, ["--limit", "1", "--max-hops", "2"]); + Assert.Equal(56, multiHop.GetProperty("count").GetInt32()); + Assert.Equal(2, multiHop.GetProperty("actual_depth").GetInt32()); + + var (rowsExitCode, rowsStdout, rowsStderr) = CaptureConsole(() => QueryCommandRunner.RunImpact( + ["issue5226_target", "--db", dbPath, "--json", "--limit", "1", "--max-hops", "1"], + _jsonOptions)); + using var rowsDocument = ParseJsonOutput(rowsStdout); + var rows = rowsDocument.RootElement; + Assert.Equal(CommandExitCodes.Success, rowsExitCode); + Assert.Equal(string.Empty, rowsStderr); + Assert.Equal(1, rows.GetProperty("count").GetInt32()); + Assert.Single(rows.GetProperty("callers").EnumerateArray()); + Assert.True(rows.GetProperty("truncated").GetBoolean()); + Assert.Equal("user_limit", rows.GetProperty("truncated_reason").GetString()); + + System.Text.Json.JsonElement RunCountJson(string path, string[] extraArgs) + { + var args = new List + { + "issue5226_target", "--db", path, "--json", "--count", + }; + args.AddRange(extraArgs); + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunImpact([.. args], _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + return document.RootElement.Clone(); + } + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunImpact_CountOnlyPreservesSqlCallerReadinessWithoutLanguageFilter_Issue5226() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_impact_count_sql_language_issue5226"); + try + { + var dbPath = CreateSqlGraphContractFixtureDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/issue5226_target.py", + "python", + "def issue5226_cross_language():\n return 0\n"); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/issue5226_caller.sql", + "sql", + """ + CREATE PROCEDURE dbo.issue5226_Caller + AS + BEGIN + SELECT issue5226_cross_language(); + END; + GO + """); + DowngradeSqlGraphContractRows(dbPath); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunImpact( + ["issue5226_cross_language", "--db", dbPath, "--json", "--count", "--limit", "1", "--max-hops", "1"], + _jsonOptions)); + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal(1, json.GetProperty("count").GetInt32()); + Assert.False(json.GetProperty("sql_graph_contract_ready").GetBoolean()); + Assert.False(json.GetProperty("authoritative_count").GetBoolean()); + Assert.True(json.GetProperty("degraded").GetBoolean()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } +}