From 46f2c5e469bf27ae4ba1bab263543b3cc5b0d840 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 31 Aug 2026 01:36:58 +0900 Subject: [PATCH 1/3] Fix impact count totals independent of limits (#5226) --- TESTING_GUIDE.md | 2 + USER_GUIDE.md | 19 +++ changelog.d/unreleased/5226.fixed.md | 18 +++ .../Cli/QueryCommandRunner.Dependencies.cs | 19 ++- .../Database/DbReader.GraphQueries.cs | 125 +++++++++++++----- .../DbReader.ImpactTraversal.Engine.cs | 14 +- .../DbReader.ImpactTraversal.State.cs | 26 +++- .../Database/DbReader.ImpactTraversal.cs | 96 ++++++++++++-- src/CodeIndex/Mcp/McpToolCatalog.cs | 4 +- .../Mcp/McpToolHandlers.ImpactAnalysis.cs | 17 ++- src/CodeIndex/Mcp/McpToolHandlers.cs | 18 +++ src/CodeIndex/Models/QueryResults.cs | 11 ++ .../CodeIndex.Tests/DbReaderIssue5226Tests.cs | 72 ++++++++++ .../McpServerIssue5226Tests.cs | 81 ++++++++++++ .../QueryCommandRunnerImpactTests.cs | 50 ++++--- .../QueryCommandRunnerIssue5226Tests.cs | 107 +++++++++++++++ 16 files changed, 593 insertions(+), 86 deletions(-) create mode 100644 changelog.d/unreleased/5226.fixed.md create mode 100644 tests/CodeIndex.Tests/DbReaderIssue5226Tests.cs create mode 100644 tests/CodeIndex.Tests/McpServerIssue5226Tests.cs create mode 100644 tests/CodeIndex.Tests/QueryCommandRunnerIssue5226Tests.cs diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 88dcaf2013..beb2788a7e 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, 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. - 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. - Issue #5194 shell-completion inventory coverage belongs in `CliFlagSchemaTests.cs` and `ConsoleUiTests.cs`. Keep exact contextual inventories for lsp, every workspace verb, config show, diff, import, archive export, and ctags export synchronized with authoritative help and safe parser probes. Preserve positive and negative nested cases, value-taking versus boolean metadata, context-specific short aliases, mutually exclusive diff modes, exact schema-to-renderer sets for Bash/zsh/fish/PowerShell, and the available-shell generated-script execution contract. @@ -1171,6 +1172,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、通常の row 上限制限を一緒に維持してください。human、JSON、compact、MCP の count mode は表示用 limit を無視し、authoritative / lower-bound metadata を保持しながら count-only の result row を生成しないことを検証します。 - 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 からの除外を維持してください。 - Issue #5194 の shell completion inventory coverage は `CliFlagSchemaTests.cs` と `ConsoleUiTests.cs` が担当します。lsp、workspace の全 verb、config show、diff、import、archive export、ctags export の正確な context 別 inventory を、authoritative help と安全な parser probe に同期してください。nested context の positive / negative case、value-taking と boolean の metadata、context 固有の short alias、diff の排他的 mode、Bash / zsh / fish / PowerShell における schema-to-renderer の完全一致、および利用可能な shell で生成 script を実行する契約を維持します。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 3f5ce3fc92..12f2a1d2fb 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,15 @@ 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. 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 +4599,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 +4614,14 @@ 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 です。`--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 0000000000..8da29230e5 --- /dev/null +++ b/changelog.d/unreleased/5226.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 5226 +affected: + - src/CodeIndex/Database/DbReader.ImpactTraversal.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; dedicated safety caps still report non-authoritative lower bounds without materializing normal result rows. + +## 日本語 + +- **impact の count mode が表示上限に依存しない総数を返すようになりました (#5226)** — `impact --count` と MCP `impact_analysis` の `countOnly` では、表示用 `limit` が報告総数を変えなくなり、#159 で導入した count 契約を復元しました。filter・multi-hop 条件の件数は human、JSON、compact、MCP で一致し、専用 safety cap 到達時は通常の result row を生成せず、authoritative でない lower bound として通知します。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs index 03308b0bde..cd2aaa6e0e 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) @@ -115,10 +116,10 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) || DbReader.ContainsSqlLanguage(analysis.Callers.Select(caller => caller.Lang)) || reader.AnyFilePathHasLanguage(analysis.FileImpacts.SelectMany(impact => new[] { impact.SourcePath, impact.TargetPath }), "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 +207,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, @@ -321,6 +325,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); @@ -356,7 +363,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, diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index 372edccbfa..d93aa94389 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -954,15 +954,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 +980,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 +1055,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 +1098,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 +1145,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 +1195,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 +1208,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 +1231,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 +1294,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 +1435,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 +1450,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 +1597,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 8cdf8af718..1979a609ac 100644 --- a/src/CodeIndex/Database/DbReader.ImpactTraversal.Engine.cs +++ b/src/CodeIndex/Database/DbReader.ImpactTraversal.Engine.cs @@ -24,7 +24,7 @@ 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) { @@ -33,9 +33,13 @@ internal ImpactTraversalEngine( } _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(), @@ -79,7 +83,7 @@ private void ProcessPage( { if (_state.DiscoveredResultCount >= _state.ResultWindowEnd) { - _state.MarkUserLimit(); + _state.MarkResultWindowLimit(); break; } if (!ProcessCaller(in node, caller)) @@ -125,7 +129,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 d571558b7e..6b1890fea1 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 b8b860b9f3..c2827445ae 100644 --- a/src/CodeIndex/Database/DbReader.ImpactTraversal.cs +++ b/src/CodeIndex/Database/DbReader.ImpactTraversal.cs @@ -13,6 +13,9 @@ 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; internal const int DefaultImpactPartialFamilyMemberBudget = 10_000; internal int ImpactPartialFamilyMemberBudget { get; set; } = DefaultImpactPartialFamilyMemberBudget; internal const int ImpactBoundaryCallerProbeBudget = 512; @@ -20,6 +23,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 +37,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 +127,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 +162,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) @@ -421,4 +496,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 7510962fb2..89aedce1d7 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 ad5fde6439..7e7b9630c9 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.ImpactAnalysis.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.ImpactAnalysis.cs @@ -67,26 +67,25 @@ 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(); + 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 9a8edc41d7..84ec6be698 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 03aebd7df1..300bffbcc9 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 0000000000..13519585fb --- /dev/null +++ b/tests/CodeIndex.Tests/DbReaderIssue5226Tests.cs @@ -0,0 +1,72 @@ +using CodeIndex.Database; + +namespace CodeIndex.Tests; + +public partial class DbReaderTests +{ + [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 0000000000..e72d0e4017 --- /dev/null +++ b/tests/CodeIndex.Tests/McpServerIssue5226Tests.cs @@ -0,0 +1,81 @@ +using System.Text.Json.Nodes; +using CodeIndex.Indexer; + +namespace CodeIndex.Tests; + +public partial class McpServerTests +{ + [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) + { + 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"] = 1, + ["countOnly"] = countOnly, + }, + }, + }; + return _server.HandleMessage(request)!["result"]!["structuredContent"]!; + } + + var count = Call(countOnly: true); + 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 rows = Call(countOnly: false); + 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); + 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 86d2cb625a..17b3dfb587 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 0000000000..601db0a750 --- /dev/null +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5226Tests.cs @@ -0,0 +1,107 @@ +using CodeIndex.Cli; +using static CodeIndex.Tests.QueryCommandTestSupport; + +namespace CodeIndex.Tests; + +public partial class QueryCommandRunnerTests +{ + [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); + } + } +} From e53d74f617a2fdd3033e42f86d4ab67aa71dae42 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 31 Aug 2026 02:57:09 +0900 Subject: [PATCH 2/3] Preserve impact count authority signals (#5226) --- TESTING_GUIDE.md | 4 +- USER_GUIDE.md | 10 +-- changelog.d/unreleased/5226.fixed.md | 4 +- .../Cli/QueryCommandRunner.Dependencies.cs | 18 ++++- .../Mcp/McpToolHandlers.ImpactAnalysis.cs | 3 +- .../McpServerIssue5226Tests.cs | 44 ++++++++++++ .../QueryCommandRunnerIssue5226Tests.cs | 69 +++++++++++++++++++ 7 files changed, 140 insertions(+), 12 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index beb2788a7e..664f17d2be 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -19,7 +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, 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. +- 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 #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. - Issue #5194 shell-completion inventory coverage belongs in `CliFlagSchemaTests.cs` and `ConsoleUiTests.cs`. Keep exact contextual inventories for lsp, every workspace verb, config show, diff, import, archive export, and ctags export synchronized with authoritative help and safe parser probes. Preserve positive and negative nested cases, value-taking versus boolean metadata, context-specific short aliases, mutually exclusive diff modes, exact schema-to-renderer sets for Bash/zsh/fish/PowerShell, and the available-shell generated-script execution contract. @@ -1172,7 +1172,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、通常の row 上限制限を一緒に維持してください。human、JSON、compact、MCP の count mode は表示用 limit を無視し、authoritative / lower-bound metadata を保持しながら count-only の result row を生成しないことを検証します。 +- 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 #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 からの除外を維持してください。 - Issue #5194 の shell completion inventory coverage は `CliFlagSchemaTests.cs` と `ConsoleUiTests.cs` が担当します。lsp、workspace の全 verb、config show、diff、import、archive export、ctags export の正確な context 別 inventory を、authoritative help と安全な parser probe に同期してください。nested context の positive / negative case、value-taking と boolean の metadata、context 固有の short alias、diff の排他的 mode、Bash / zsh / fish / PowerShell における schema-to-renderer の完全一致、および利用可能な shell で生成 script を実行する契約を維持します。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 12f2a1d2fb..351c4785dd 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -906,8 +906,9 @@ count outputs report the same filtered, multi-hop total without materializing ca 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. Without `--count`, `--limit` continues to cap displayed rows and a -`user_limit` truncation can be retried with a larger value. +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 @@ -4619,8 +4620,9 @@ inclusive な挙動を維持します。 row を materialize せず、同じ filter・multi-hop 条件の総数を一致して返します。ただし traversal 専用の safety budget は引き続き適用されます。到達時は `truncated` と safety-cap 理由を保持し、CLI JSON は `authoritative_count: false`、MCP は `total: null` を返すため、 -`count` は lower bound です。`--count` がない通常表示では、従来どおり `--limit` が表示 -row を制限し、`user_limit` なら値を増やして再試行できます。 +`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 diff --git a/changelog.d/unreleased/5226.fixed.md b/changelog.d/unreleased/5226.fixed.md index 8da29230e5..decf99e0d2 100644 --- a/changelog.d/unreleased/5226.fixed.md +++ b/changelog.d/unreleased/5226.fixed.md @@ -11,8 +11,8 @@ affected: ## 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; dedicated safety caps still report non-authoritative lower bounds without materializing normal result rows. +- **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; 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 で一致し、専用 safety cap 到達時は通常の result row を生成せず、authoritative でない lower bound として通知します。 +- **impact の count mode が表示上限に依存しない総数を返すようになりました (#5226)** — `impact --count` と MCP `impact_analysis` の `countOnly` では、表示用 `limit` が報告総数を変えなくなり、#159 で導入した count 契約を復元しました。filter・multi-hop 条件の件数は human、JSON、compact、MCP で一致し、専用 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 cd2aaa6e0e..6c34d0d2df 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs @@ -114,7 +114,8 @@ 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.ConfirmedCount; var confirmedFileCount = analysis.ConfirmedFileCount; @@ -244,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."); } @@ -344,7 +345,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) } else { - Console.WriteLine($"{visibleCount}"); + WriteImpactHumanCount(visibleCount, analysis); } return CommandExitCodes.Success; } @@ -523,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/Mcp/McpToolHandlers.ImpactAnalysis.cs b/src/CodeIndex/Mcp/McpToolHandlers.ImpactAnalysis.cs index 7e7b9630c9..941894639b 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.ImpactAnalysis.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.ImpactAnalysis.cs @@ -74,7 +74,8 @@ private JsonNode ExecuteImpactAnalysis(JsonNode? id, JsonNode? args) 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")); + || 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; diff --git a/tests/CodeIndex.Tests/McpServerIssue5226Tests.cs b/tests/CodeIndex.Tests/McpServerIssue5226Tests.cs index e72d0e4017..df2d677bde 100644 --- a/tests/CodeIndex.Tests/McpServerIssue5226Tests.cs +++ b/tests/CodeIndex.Tests/McpServerIssue5226Tests.cs @@ -1,10 +1,54 @@ 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() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5226Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5226Tests.cs index 601db0a750..f7692e8e44 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5226Tests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5226Tests.cs @@ -1,10 +1,34 @@ 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() { @@ -104,4 +128,49 @@ System.Text.Json.JsonElement RunCountJson(string path, string[] extraArgs) 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); + } + } } From df45627170d14097c81ee3ca3b382de545869fcc Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 31 Aug 2026 04:37:47 +0900 Subject: [PATCH 3/3] Batch impact count traversal for MCP (#5226) --- changelog.d/unreleased/5226.fixed.md | 6 +- .../Database/DbReader.GraphQueries.cs | 173 ++++++++++++------ .../DbReader.ImpactTraversal.Engine.cs | 97 +++++++++- .../Database/DbReader.ImpactTraversal.cs | 44 ++++- .../Mcp/McpToolHandlers.ImpactAnalysis.cs | 15 +- .../CodeIndex.Tests/DbReaderIssue5226Tests.cs | 39 ++++ .../McpServerIssue5226Tests.cs | 15 +- 7 files changed, 323 insertions(+), 66 deletions(-) diff --git a/changelog.d/unreleased/5226.fixed.md b/changelog.d/unreleased/5226.fixed.md index decf99e0d2..48c8dd0cee 100644 --- a/changelog.d/unreleased/5226.fixed.md +++ b/changelog.d/unreleased/5226.fixed.md @@ -4,6 +4,8 @@ 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 @@ -11,8 +13,8 @@ affected: ## 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; 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 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 で一致し、専用 safety cap 到達時は通常の result row を生成せず、authoritative でない lower bound として stderr warning も含めて通知し、count-only summary でも 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/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index d93aa94389..07f5b87d1b 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) diff --git a/src/CodeIndex/Database/DbReader.ImpactTraversal.Engine.cs b/src/CodeIndex/Database/DbReader.ImpactTraversal.Engine.cs index 1979a609ac..96889b6288 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; @@ -28,6 +29,8 @@ internal ImpactTraversalExecutionResult Run() { while (_state.CanTraverse) { + if (_request.CountOnly && TryTraverseCountBatch()) + continue; var node = _state.Queue.Dequeue(); TraverseNode(in node); } @@ -46,10 +49,89 @@ internal ImpactTraversalExecutionResult Run() _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; @@ -75,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) @@ -98,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; diff --git a/src/CodeIndex/Database/DbReader.ImpactTraversal.cs b/src/CodeIndex/Database/DbReader.ImpactTraversal.cs index c2827445ae..e7f2b4ed5f 100644 --- a/src/CodeIndex/Database/DbReader.ImpactTraversal.cs +++ b/src/CodeIndex/Database/DbReader.ImpactTraversal.cs @@ -16,6 +16,10 @@ public partial class DbReader // 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; @@ -481,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) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.ImpactAnalysis.cs b/src/CodeIndex/Mcp/McpToolHandlers.ImpactAnalysis.cs index 941894639b..7f9a786b10 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) diff --git a/tests/CodeIndex.Tests/DbReaderIssue5226Tests.cs b/tests/CodeIndex.Tests/DbReaderIssue5226Tests.cs index 13519585fb..2ac64bc4cd 100644 --- a/tests/CodeIndex.Tests/DbReaderIssue5226Tests.cs +++ b/tests/CodeIndex.Tests/DbReaderIssue5226Tests.cs @@ -4,6 +4,45 @@ 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() { diff --git a/tests/CodeIndex.Tests/McpServerIssue5226Tests.cs b/tests/CodeIndex.Tests/McpServerIssue5226Tests.cs index df2d677bde..0ac01a682f 100644 --- a/tests/CodeIndex.Tests/McpServerIssue5226Tests.cs +++ b/tests/CodeIndex.Tests/McpServerIssue5226Tests.cs @@ -64,7 +64,7 @@ public void ToolsCall_ImpactCountOnlyIgnoresLimitAndPreservesCompletenessSignals $"public sealed class McpIssue5226Caller{i} {{ public void Run() {{ McpIssue5226Target.Hit(); }} }}"); } - JsonNode Call(bool countOnly) + JsonNode Call(bool countOnly, int limit) { var request = new JsonObject { @@ -79,7 +79,7 @@ JsonNode Call(bool countOnly) ["query"] = "McpIssue5226Target.Hit", ["lang"] = "csharp", ["maxHops"] = 1, - ["limit"] = 1, + ["limit"] = limit, ["countOnly"] = countOnly, }, }, @@ -87,7 +87,7 @@ JsonNode Call(bool countOnly) return _server.HandleMessage(request)!["result"]!["structuredContent"]!; } - var count = Call(countOnly: true); + 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()); @@ -96,8 +96,13 @@ JsonNode Call(bool countOnly) 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); + var rows = Call(countOnly: false, limit: 1); Assert.Equal(1, rows["count"]!.GetValue()); Assert.Single(rows["callers"]!.AsArray()); Assert.True(rows["truncated"]!.GetValue()); @@ -116,7 +121,7 @@ FROM files Assert.Equal(1, command.ExecuteNonQuery()); } - var incomplete = Call(countOnly: true); + 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());