From ff4e6396d5e6e4aca5e8599fd168172f85a93098 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 10 Aug 2026 23:22:07 +0900 Subject: [PATCH 1/3] Fix logical partial-family impact traversal (#5060) --- DEVELOPER_GUIDE.md | 4 + TESTING_GUIDE.md | 2 + USER_GUIDE.md | 25 ++ changelog.d/unreleased/5060.fixed.md | 27 ++ .../Cli/QueryCommandRunner.Dependencies.cs | 17 + .../Database/DbReader.GraphQueries.cs | 418 +++++++++++++++--- .../Mcp/McpToolHandlers.ImpactAnalysis.cs | 16 + src/CodeIndex/Models/QueryResults.cs | 15 + tests/CodeIndex.Tests/DbReaderImpactTests.cs | 188 +++++++- tests/CodeIndex.Tests/McpServerTests.cs | 9 +- .../McpServerToolsCallTests.cs | 48 ++ .../QueryCommandRunnerImpactTests.cs | 84 +++- .../QueryCommandRunnerTests.cs | 6 +- tests/CodeIndex.Tests/golden/impact.json | 1 + 14 files changed, 780 insertions(+), 80 deletions(-) create mode 100644 changelog.d/unreleased/5060.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 12d047a62..79af6c5c6 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -2407,6 +2407,8 @@ the query token. Practical consequence: `impact ` on a class-like symbol returns the heuristic file-dependency-hint fallback (with metadata edges) when no member-level callers exist, whereas default `callers ` returns only executable edges. Both are correct under their own contracts; counts will not match. To reconcile, run `references --kind attribute` (or `annotation`), or pass an explicitly supported non-default kind to `callers` / `callees`, to surface edges that the default call graph intentionally drops. +For a single C# logical partial family, impact resolution precedes the physical-file ambiguity guard. `ResolveImpactDefinitions` retains the representative output plus a separately bounded set of every physical family symbol ID and path. `GetTransitiveCallers` queries that ID set as one SQL union root, so candidate rows, callers, cycle nodes, and converging paths are deduplicated before BFS accounting; file-hint fallback resolves member names across every retained family path and excludes all declaration files from dependents. Physical family expansion uses `DefaultImpactPartialFamilyMemberBudget`, reported through `partial_family_member_*`, and never sets the ordinary result `truncated` / `truncated_reason` fields. Only equal logical partial keys may enter this path; multiple logical keys, including unrelated same-name namespace or language symbols, retain the established ambiguity result. + `impact --json` and MCP `impact_analysis` expose zero-result diagnostics as structured routing fields. `zero_result_reason` remains the compact terminal reason; `impact_failure_chain` lists failed preconditions or traversal states in order, using values such as `definition_not_found`, `callable_filter_fails`, `multiple_definitions`, `multiple_definition_files`, `graph_unavailable`, `depth_requested_zero`, and `no_callers`. `suggestion_type` classifies the prose `suggestion` as `resolution`, `traversal`, or `precondition`. CLI `impact --strict` exits with `FeatureUnavailable` when the chain contains a resolution or precondition failure, but still treats a genuine `no_callers` traversal result as success. `definition --json` and MCP `definition` results may include `disambiguator` for C# definitions when existing symbol metadata can distinguish otherwise identical names. Current values include `overload(...)` for method signatures, `partial-class` / `partial-struct` / `partial-interface`, and `extension-method-on()`. Languages without overload or receiver metadata omit the field. @@ -6105,6 +6107,8 @@ before/after 合計と理由別の affected / removed 件数を加える。汎 実運用上の帰結: クラスのようなシンボルに対する `impact ` は、member-level の caller が存在しない場合 heuristic file-dependency-hint fallback (metadata エッジを含む) を返し、一方の既定 `callers ` は実行可能 edge だけを返す。両方とも個々の契約上は正しいが、件数は一致しない。差分を埋めるには `references --kind attribute`(または `annotation`)を使うか、`callers` / `callees` に明示的に対応する非既定 kind を渡し、既定 call graph が意図的に落としている edge を確認する。 +単一の C# 論理 partial family では、impact resolution を物理ファイルの ambiguity guard より先に行います。`ResolveImpactDefinitions` は代表出力に加え、全物理 family の symbol ID / path を独立した上限付き集合として保持します。`GetTransitiveCallers` はその ID 集合を単一 SQL union root として問い合わせるため、candidate row、caller、cycle node、収束 path を BFS の計数前に重複排除します。file-hint fallback は保持した全 family path から member 名を解決し、全 declaration file を依存先候補から除外します。物理 family 展開には `DefaultImpactPartialFamilyMemberBudget` を使い、`partial_family_member_*` で報告し、通常の result 用 `truncated` / `truncated_reason` fields は立てません。この経路へ入るのは論理 partial key が同一の場合だけで、無関係な同名 namespace / language symbol を含む複数 logical key は従来どおり ambiguity result を維持します。 + `impact --json` と MCP `impact_analysis` は、0 件診断を structured routing field として返します。`zero_result_reason` は端末向けの短い理由のまま残し、`impact_failure_chain` は `definition_not_found`、`callable_filter_fails`、`multiple_definitions`、`multiple_definition_files`、`graph_unavailable`、`depth_requested_zero`、`no_callers` などの失敗前提や traversal 状態を順序付きで列挙します。`suggestion_type` は prose の `suggestion` を `resolution`、`traversal`、`precondition` に分類します。CLI `impact --strict` は chain に resolution / precondition failure が含まれる場合は `FeatureUnavailable` で終了しますが、真正な `no_callers` traversal 結果は成功として扱います。 `definition --json` と MCP `definition` の結果は、既存の symbol metadata で同名定義を区別できる C# 定義に対して `disambiguator` を含む場合があります。現行値は method signature 用の `overload(...)`、`partial-class` / `partial-struct` / `partial-interface`、`extension-method-on()` です。overload や receiver metadata を持たない言語ではこの field を省略します。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 01c009182..fca26f097 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -267,6 +267,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Deps JSON summary output and json-graph summary rejection share one SQL graph fixture. Dependency-cycle coverage must prove that the graph budget is independent of the display limit, SCC ranking remains stable when the page size grows, opaque cursors return the next ranked component, mismatched cursor filters fail closed, and graph-budget exhaustion marks totals as non-authoritative. Impact-cycle coverage must distinguish consecutive same-display-name symbols by canonical source/target IDs, verify their structured shortest-path identities, keep unresolved upstream callers and non-unique resolved overload groups out of only the canonical cycle graph, avoid guessed IDs for ambiguous path roots, aggregate mixed target identities without undercounting, and retain controls for direct singleton recursion and multi-node cycles. + Partial-family impact coverage must keep declarations, implementations, duplicate downstream callers, a member-to-member cycle, an unrelated same-name symbol, and a small instance-scoped family budget in one fixture family. Assert logical-root identity and physical evidence separately, and prove family-member truncation does not set ordinary traversal truncation. MCP fixtures that need production-equivalent partial grouping must pass `familyScopeKey` to `InsertIndexedFile` and stamp the C# hotspot-family readiness marker. Exact-symbol dependency coverage reuses the C# source-candidate fixture to prove that a `--limit 1` query can select a symbol beyond the unfiltered candidate window. The repository-scale guard builds 10,001 candidate symbols, runs on non-Windows .NET 8, and keeps query-only work within 2 seconds and 32 MiB of current-thread allocation; fixture construction stays outside the measured interval. Windows retains the lightweight functional pushdown coverage but omits this scale fixture because its bounded full-suite session already runs close to the one-hour timeout. References stale-SQL-contract count and result envelopes share one downgraded graph fixture. Callers and callees stale-SQL-contract result envelopes share one downgraded graph fixture. @@ -1355,6 +1356,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" filterなしの`languages --json` catalog coverageはcommandを1回だけ実行し、canonical language辞書を1つ構築して、extension、alias、extraction、graph、gap、guidance、exact-filenameの各contractをまとめて検証してください。言語coverageの拡張でcatalog discoveryとserializationを繰り返さないようにします。 option風literalのnamed-query escapingは、definition、graph、symbols、files、inspect、impact command全体で1つのindexed Probe fixtureを再利用してください。 impact cycle の回帰 coverage では、同じ表示名が連続する別 symbol を正規 source/target ID で区別し、構造化 shortest-path identity を検証し、未解決の上流 caller と一意でない resolved overload group を正規 cycle graph からだけ除外し、曖昧な path root に推測 ID を付けず、複数 target identity を過少計上せず集約するとともに、直接 singleton 再帰と複数 node cycle の control を維持してください。 + partial-family impact coverage は declaration、implementation、重複する downstream caller、member 間 cycle、無関係な同名 symbol、小さい instance-scoped family budget を 1 つの fixture family にまとめてください。論理 root identity と物理 evidence を別々に assert し、family-member truncation が通常の traversal truncation を立てないことを確認します。本番同等の partial grouping が必要な MCP fixture は `InsertIndexedFile` に `familyScopeKey` を渡し、C# hotspot-family readiness marker を stamp してください。 複数 named-query の output coverage は、compact projection、rich JSON 互換性、query ごとの limit / truncation、UTF-8 byte cap に1つの indexed fixture を再利用し、serializer mode を直接比較できるようにしてください。 共通 bounded-response coverage は、definition、find、status、hotspots、references、callers / callees、impact、map 全体で1つの graph-ready databaseを再利用してください。cursor と UTF-8 byte-budget の境界 case は別の最小 multi-row fixture にまとめ、family parity のために indexing setup を重複させないでください。outline は、長い signature と Unicode を含む深い階層の focused fixture 1つを使い、最後の改行を含む正確な byte 境界、欠落や重複のない cursor 全 page 走査、最小 budget の diagnostic、上限なし出力の非変更を確認してください。regression coverage では alias と read-only batch dispatch、明示的な definition body projection、inactive な impact collection、authoritative な総件数を持つ map section の row 単位 pagination も確認してください。 adversarial な bounded-response coverage では、parser failure の byte cap、impact definition page の offset、既存 map compact section、map shape control の競合、compact と明示 body の組み合わせ、profile / verbose control record の抽出も固定してください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 5c6d57a0c..527e705e4 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -823,6 +823,20 @@ 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. +When 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 +reached through more than one member are deduplicated; the representative +definition still carries `family_members` as physical evidence. An unrelated +same-name type or member remains ambiguous and is never folded into that family. +With `--with-paths`, the logical root's `path_details` node carries the +representative `definition_path` plus `partial_family_id`. +JSON and MCP responses identify this mode with +`traversal_root_scope: "logical_partial_family"` and +`traversal_partial_family_id`. The `partial_family_member_*` fields report the +eligible physical root count, limit, omitted count, and truncation independently +from the normal result/traversal `truncated` fields. + On a current index, cycle detection follows the resolved source/target symbol IDs on real directed edges. Two distinct methods with the same display name are not a cycle, while direct recursion is reported as a singleton cycle. JSON caller rows @@ -4314,6 +4328,17 @@ member / value read を `member_read` として保存し、callers / callees / i `call` として保存しているため、引き続き読み取り可能で、再 index するまでは従来の inclusive な挙動を維持します。 +一致する C# declaration がすべて 1 つの論理 partial family に属する場合、`impact` は +安定した `partial_family_id` を 1 つの traversal root として使い、全物理 member identity +の和集合を辿ります。複数 member から到達する caller、file hint、shortest path は重複排除し、 +代表 definition の `family_members` には物理 evidence を残します。無関係な同名 type / member +は曖昧なままで、この family へ統合しません。JSON / MCP response はこの mode を +`traversal_root_scope: "logical_partial_family"` と `traversal_partial_family_id` で示します。 +`--with-paths` では論理 root の `path_details` node に代表 `definition_path` と +`partial_family_id` も含めます。 +`partial_family_member_*` fields は物理 root 候補の件数、上限、省略件数、truncation +を通常の result / traversal 用 `truncated` fields とは独立して報告します。 + current index では、cycle 判定は実在する有向辺の解決済み source/target symbol ID を 辿ります。表示名が同じ別 method は cycle にせず、直接再帰は singleton cycle として 報告します。JSON の caller row は `caller_symbol_id` と、callee を一意に解決できる場合は diff --git a/changelog.d/unreleased/5060.fixed.md b/changelog.d/unreleased/5060.fixed.md new file mode 100644 index 000000000..142677e6b --- /dev/null +++ b/changelog.d/unreleased/5060.fixed.md @@ -0,0 +1,27 @@ +--- +category: fixed +issues: + - 5060 +affected: + - src/CodeIndex/Database/DbReader.GraphQueries.cs + - src/CodeIndex/Models/QueryResults.cs + - src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs + - src/CodeIndex/Mcp/McpToolHandlers.ImpactAnalysis.cs + - tests/CodeIndex.Tests/DbReaderImpactTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerImpactTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerTests.cs + - tests/CodeIndex.Tests/golden/impact.json + - tests/CodeIndex.Tests/McpServerTests.cs + - tests/CodeIndex.Tests/McpServerToolsCallTests.cs + - USER_GUIDE.md + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Impact analysis now traverses one logical partial family as a single root (#5060; follow-up to #4309, #4566, and #3932)** — C# partial declarations and implementations contribute their union of physical graph identities without triggering `multiple_definition_files`; callers, file hints, cycles, and converging paths are deduplicated while definitions retain physical-member evidence. Unrelated same-name symbols remain ambiguous, and machine-readable output reports the separately bounded family-member expansion without conflating it with ordinary traversal truncation. + +## 日本語 + +- **impact analysis が 1 つの論理 partial family を単一 root として traversal するよう修正しました (#5060、#4309、#4566、#3932 の follow-up)** — C# の partial declaration / implementation は物理 graph identity の和集合を提供し、`multiple_definition_files` で停止しません。definition には物理 member evidence を残しつつ、caller、file hint、cycle、収束 path を重複排除します。無関係な同名 symbol は曖昧なまま維持し、machine-readable output は上限付き family-member 展開を通常の traversal truncation と混同せず個別に報告します。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs index b891f3766..798df3050 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs @@ -182,6 +182,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) ["graph_table_available"] = analysis.GraphTableAvailable, ["degraded"] = !analysis.GraphTableAvailable, }; + AddImpactTraversalRootJsonFields(payload, analysis); AddImpactTerminationJsonFields(payload, analysis, jsonOptions); if (analysis.ZeroResultReason != null) payload["zero_result_reason"] = analysis.ZeroResultReason; @@ -287,6 +288,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) ["hint_file_count"] = hintFileCount, ["truncated"] = analysis.Truncated, }; + AddImpactTraversalRootJsonFields(payload, analysis); AddImpactTerminationJsonFields(payload, analysis, jsonOptions); if (analysis.TruncatedReason != null) payload["truncated_reason"] = analysis.TruncatedReason; @@ -401,6 +403,7 @@ private static void AddImpactFailureJsonFields(JsonObject payload, ImpactAnalysi private static void AddImpactDefinitionsJsonFields(JsonObject payload, ImpactAnalysisResult analysis, QueryCommandOptions options, JsonSerializerOptions jsonOptions) { + AddImpactTraversalRootJsonFields(payload, analysis); var definitions = BuildImpactDefinitionJsonResults(analysis.Definitions); var definitionLimit = Math.Max(1, options.Limit); var visibleDefinitions = definitions.Take(definitionLimit).ToList(); @@ -425,6 +428,20 @@ private static void AddImpactDefinitionsJsonFields(JsonObject payload, ImpactAna payload["definitions_hint"] = "Raise --limit or narrow with --lang, --kind, --path, or --exclude-path to inspect additional matching definitions."; } + private static void AddImpactTraversalRootJsonFields(JsonObject payload, ImpactAnalysisResult analysis) + { + payload["traversal_root_scope"] = analysis.TraversalRootScope; + if (analysis.TraversalPartialFamilyId == null) + return; + + payload["traversal_partial_family_id"] = analysis.TraversalPartialFamilyId; + payload["partial_family_member_count"] = analysis.PartialFamilyMemberCount; + payload["partial_family_member_root_count"] = analysis.PartialFamilyMemberRootCount; + payload["partial_family_member_root_limit"] = analysis.PartialFamilyMemberRootLimit; + payload["partial_family_member_root_truncated"] = analysis.PartialFamilyMemberRootTruncated; + payload["partial_family_member_root_omitted"] = analysis.PartialFamilyMemberRootOmitted; + } + private static List BuildImpactDefinitionJsonResults(IReadOnlyList definitions) => LogicalPartialSymbolGrouper.Group(definitions); diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index bfdf3d824..cbf73d916 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -1141,12 +1141,15 @@ ELSE 5 /// materialize しないようにする。 /// private List GetCallersExact(string symbolName, int limit, int offset = 0, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool includeAmbiguousMSource = false, bool includeMemberReads = false) - => GetCallersExactCore(symbolName, limit, offset, lang, pathPatterns, excludePathPatterns, excludeTests, targetSymbolId: null, includeAmbiguousMSource, includeMemberReads); + => GetCallersExactCore(symbolName, limit, offset, lang, pathPatterns, excludePathPatterns, excludeTests, targetSymbolIds: null, includeAmbiguousMSource, includeMemberReads); private List GetCallersExactForTarget(string symbolName, long targetSymbolId, int limit, int offset, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, bool includeAmbiguousMSource = false, bool includeMemberReads = false) - => GetCallersExactCore(symbolName, limit, offset, lang, pathPatterns, excludePathPatterns, excludeTests, targetSymbolId, includeAmbiguousMSource, includeMemberReads); + => GetCallersExactCore(symbolName, limit, offset, lang, pathPatterns, excludePathPatterns, excludeTests, [targetSymbolId], includeAmbiguousMSource, includeMemberReads); - private List GetCallersExactCore(string symbolName, int limit, int offset, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, long? targetSymbolId, bool includeAmbiguousMSource, bool includeMemberReads) + private List GetCallersExactForTargets(string symbolName, IReadOnlyList targetSymbolIds, int limit, int offset, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, bool includeAmbiguousMSource = false, bool includeMemberReads = false) + => GetCallersExactCore(symbolName, limit, offset, lang, pathPatterns, excludePathPatterns, excludeTests, targetSymbolIds, includeAmbiguousMSource, includeMemberReads); + + private List GetCallersExactCore(string symbolName, int limit, int offset, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? targetSymbolIds, bool includeAmbiguousMSource, bool includeMemberReads) { if (!_hasReferencesTable) return new List(); using var cmd = _conn.CreateCommand(); @@ -1155,20 +1158,21 @@ private List GetCallersExactCore(string symbolName, int limit, int var selfReferenceSql = _referenceColumns.Contains("is_self_reference") ? "r.is_self_reference" : "0"; var mutualRecursionSql = _referenceColumns.Contains("is_mutual_recursion") ? "r.is_mutual_recursion" : "0"; var sourceSymbolIdSql = _referenceColumns.Contains("source_symbol_id") ? "r.source_symbol_id" : "NULL"; - var hasIdentityTargetScope = targetSymbolId != null + var hasIdentityTargetScope = targetSymbolIds is { Count: > 0 } && _referenceColumns.Contains("target_symbol_id") && _referenceColumns.Contains("resolution_state") && HasTable("symbol_reference_candidates"); + const string targetSymbolIdsSql = "SELECT CAST(value AS INTEGER) FROM json_each(@targetSymbolIdsJson)"; var targetSymbolIdSql = hasIdentityTargetScope - ? @"CASE + ? $@"CASE WHEN r.resolution_state = 'resolved' AND EXISTS ( SELECT 1 FROM symbol_reference_candidates projected_identity_candidate WHERE projected_identity_candidate.reference_id = r.id - AND projected_identity_candidate.symbol_id = @targetSymbolId + AND projected_identity_candidate.symbol_id IN ({targetSymbolIdsSql}) ) - THEN @targetSymbolId + THEN r.target_symbol_id ELSE NULL END" : _referenceColumns.Contains("target_symbol_id") ? "r.target_symbol_id" : "NULL"; @@ -1215,13 +1219,13 @@ ELSE NULL // の候補にはできるが、一意に resolved した行だけが実際の cycle edge として候補 ID を // 公開する。unresolved/ambiguous 行も従来の名前ベース探索に残し、target ID は null にする。 var targetCondition = hasIdentityTargetScope - ? @" + ? $@" AND ( EXISTS ( SELECT 1 FROM symbol_reference_candidates identity_candidate WHERE identity_candidate.reference_id = r.id - AND identity_candidate.symbol_id = @targetSymbolId + AND identity_candidate.symbol_id IN ({targetSymbolIdsSql}) AND r.resolution_state IN ('resolved', 'resolved_group') ) OR ( @@ -1301,8 +1305,13 @@ FROM logical_references r } if (lang != null) SqliteCommandPolicy.Add(cmd, "@lang", lang); - if (targetSymbolId != null && HasTable("symbol_reference_candidates")) - SqliteCommandPolicy.Add(cmd, "@targetSymbolId", targetSymbolId.Value); + if (hasIdentityTargetScope) + { + var targetSymbolIdValues = targetSymbolIds! + .Select(static symbolId => symbolId.ToString(System.Globalization.CultureInfo.InvariantCulture)) + .ToList(); + SqliteCommandPolicy.Add(cmd, "@targetSymbolIdsJson", JsonStringListCodec.Serialize(targetSymbolIdValues)); + } AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns); SqliteCommandPolicy.Add(cmd, "@limit", limit); SqliteCommandPolicy.Add(cmd, "@offset", offset); @@ -1342,10 +1351,17 @@ FROM logical_references r return results; } - private static string BuildImpactVisitedKey(CallerResult caller, string callerName, bool useCanonicalIdentity) - => useCanonicalIdentity && caller.CallerSymbolId is long callerSymbolId - ? $"id:{callerSymbolId}:{caller.ReferenceKind}" - : $"{caller.Path}:{callerName}:{caller.ReferenceKind}"; + private static string BuildImpactVisitedKey( + CallerResult caller, + string callerName, + bool useCanonicalIdentity, + bool deduplicateLogicalNodes = false) + { + var identity = useCanonicalIdentity && caller.CallerSymbolId is long callerSymbolId + ? $"id:{callerSymbolId}" + : $"{caller.Path}:{callerName}"; + return deduplicateLogicalNodes ? identity : $"{identity}:{caller.ReferenceKind}"; + } private static string BuildImpactTraversalNodeKey(long? symbolId, string name) => symbolId is long canonicalSymbolId ? $"id:{canonicalSymbolId}" : $"name:{name}"; @@ -1358,6 +1374,8 @@ private static string BuildImpactTraversalNodeKey(long? symbolId, string name) // 収束する場合に JSON 膨張を抑える役割があり、超過時は PathsTruncated で通知する。 private const int DefaultImpactPathsPerResult = 10; internal const int DefaultImpactGraphStateEntryBudget = 10_000; + internal const int DefaultImpactPartialFamilyMemberBudget = 10_000; + internal int ImpactPartialFamilyMemberBudget { get; set; } = DefaultImpactPartialFamilyMemberBudget; internal const int ImpactBoundaryCallerProbeBudget = 512; private const int ImpactBoundaryCallerProbePageSize = 64; @@ -1406,12 +1424,19 @@ private static string BuildImpactTraversalNodeKey(long? symbolId, string name) var rootDefinitionPaths = rootDefinitions .Select(definition => definition.Path) .ToHashSet(StringComparer.OrdinalIgnoreCase); + var isLogicalPartialFamilyRoot = + hasResolvedIdentityGraph + && rootDefinitionResolution.LogicalCount == 1 + && rootDefinitions.Count == 1 + && rootDefinitions[0].Lang == "csharp" + && rootDefinitions[0].PartialFamilyId != null + && rootDefinitionResolution.PhysicalSymbolIds.Count > 0; var qualifiedCSharpRootSymbolIds = - canResolveQualifiedCSharpIdentity + (canResolveQualifiedCSharpIdentity || isLogicalPartialFamilyRoot) && rootDefinitions.Count > 0 && rootDefinitions.All(definition => definition.Lang == "csharp") && rootDefinitions.All(definition => definition.SymbolId != null) - && rootDefinitionResolution.LogicalCount == rootDefinitions.Count + && (isLogicalPartialFamilyRoot || rootDefinitionResolution.LogicalCount == rootDefinitions.Count) ? rootDefinitionResolution.PhysicalSymbolIds.ToHashSet() : []; if (hasResolvedIdentityGraph @@ -1444,18 +1469,29 @@ private static string BuildImpactTraversalNodeKey(long? symbolId, string name) var rootTraversalNodeKey = identityRootSymbolIds.Count > 1 ? $"identity:{NameFold.Fold(symbolName) ?? symbolName}" : BuildImpactTraversalNodeKey(singleIdentityRootSymbolId, resolvedName); - var queue = new Queue<(string Symbol, long? SymbolId, string NodeKey, int Depth)>(); - if (identityRootSymbolIds.Count > 0) + var queue = new Queue<(string Symbol, long? SymbolId, IReadOnlyList? TargetSymbolIds, string NodeKey, int Depth)>(); + if (isLogicalPartialFamilyRoot) + { + queue.Enqueue((resolvedName, null, identityRootSymbolIds.Order().ToArray(), rootTraversalNodeKey, 0)); + } + else if (identityRootSymbolIds.Count > 0) { foreach (var identityRootSymbolId in identityRootSymbolIds.Order()) - queue.Enqueue((resolvedName, identityRootSymbolId, rootTraversalNodeKey, 0)); + queue.Enqueue((resolvedName, identityRootSymbolId, null, rootTraversalNodeKey, 0)); } else { - queue.Enqueue((resolvedName, null, rootTraversalNodeKey, 0)); + queue.Enqueue((resolvedName, null, null, rootTraversalNodeKey, 0)); } visited.Add(resolvedName); - var truncated = qualifiedCSharpRootSymbolIds.Count > 0 + // A partial-family root cap is reported independently on ImpactAnalysisResult. + // It must not masquerade as a traversal/result cap, because raising --limit does + // not expand the family root and the BFS may otherwise have completed normally. + // partial family の root 上限は ImpactAnalysisResult で独立して報告する。 + // --limit 由来の traversal truncation と混同せず、通常完了した BFS を + // safety_cap 扱いしない。 + var truncated = !isLogicalPartialFamilyRoot + && qualifiedCSharpRootSymbolIds.Count > 0 && rootDefinitionResolution.PhysicalSymbolIdsTruncated; var maxDepthReached = false; var cycles = new List(); @@ -1487,15 +1523,40 @@ private static string BuildImpactTraversalNodeKey(long? symbolId, string name) var resultIndicesByNodeKey = withPaths ? new Dictionary>(StringComparer.OrdinalIgnoreCase) : null; + var resultIndexByVisitedKey = isLogicalPartialFamilyRoot + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : null; var pathNodesByKey = withPaths ? new Dictionary(StringComparer.OrdinalIgnoreCase) : null; if (withPaths) - pathNodesByKey![rootTraversalNodeKey] = ResolveImpactPathNode(resolvedName, singleIdentityRootSymbolId, kind: null, lang, referencePath: null, referenceLine: null); + { + var rootPathNode = ResolveImpactPathNode( + resolvedName, + singleIdentityRootSymbolId, + kind: null, + lang, + referencePath: null, + referenceLine: null); + if (isLogicalPartialFamilyRoot) + { + var representative = rootDefinitions[0]; + rootPathNode.SymbolId = null; + rootPathNode.Name = resolvedName; + rootPathNode.Kind = representative.Kind; + rootPathNode.Lang = representative.Lang; + rootPathNode.DefinitionPath = representative.Path; + rootPathNode.DefinitionLine = representative.Line; + rootPathNode.Container = representative.ContainerQualifiedName ?? representative.ContainerName; + rootPathNode.PartialFamilyId = representative.PartialFamilyId; + rootPathNode.LogicalTargetKey = $"partial|{representative.PartialFamilyId}"; + } + pathNodesByKey![rootTraversalNodeKey] = rootPathNode; + } while (queue.Count > 0 && discoveredResultCount < resultWindowEnd && !graphStateBudgetHit && !boundaryProbeBudgetHit) { - var (currentSymbol, currentSymbolId, currentNodeKey, depth) = queue.Dequeue(); + var (currentSymbol, currentSymbolId, currentTargetSymbolIds, currentNodeKey, depth) = queue.Dequeue(); // Fetch callers in pages, filtering out already-visited before counting toward limit. // This prevents diamond graphs from hiding reachable callers behind visited duplicates. @@ -1509,9 +1570,11 @@ private static string BuildImpactTraversalNodeKey(long? symbolId, string name) while (discoveredResultCount < resultWindowEnd && fetchIterations < maxFetchIterations && !graphStateBudgetHit && !boundaryProbeBudgetHit) { fetchIterations++; - var page = currentSymbolId is long targetSymbolId - ? GetCallersExactForTarget(currentSymbol, targetSymbolId, pageSize, pageOffset, lang, pathPatterns, excludePathPatterns, excludeTests, includeAmbiguousMSource, includeMemberReads) - : GetCallersExact(currentSymbol, pageSize, pageOffset, lang, pathPatterns, excludePathPatterns, excludeTests, includeAmbiguousMSource, includeMemberReads); + var page = currentTargetSymbolIds is { Count: > 0 } + ? GetCallersExactForTargets(currentSymbol, currentTargetSymbolIds, pageSize, pageOffset, lang, pathPatterns, excludePathPatterns, excludeTests, includeAmbiguousMSource, includeMemberReads) + : currentSymbolId is long targetSymbolId + ? GetCallersExactForTarget(currentSymbol, targetSymbolId, pageSize, pageOffset, lang, pathPatterns, excludePathPatterns, excludeTests, includeAmbiguousMSource, includeMemberReads) + : GetCallersExact(currentSymbol, pageSize, pageOffset, lang, pathPatterns, excludePathPatterns, excludeTests, includeAmbiguousMSource, includeMemberReads); if (page.Count == 0) break; // No more callers for this symbol / このシンボルの caller は尽きた @@ -1528,7 +1591,14 @@ private static string BuildImpactTraversalNodeKey(long? symbolId, string name) var callerName = caller.CallerName ?? SyntheticTopLevelCallerName; var callerSymbolId = hasResolvedIdentityGraph ? caller.CallerSymbolId : null; var calleeSymbolId = hasResolvedIdentityGraph ? caller.CalleeSymbolId : null; - var cycleEdges = BuildImpactCycleEdges(caller, callerName, currentSymbol, hasResolvedIdentityGraph); + var cycleEdges = BuildImpactCycleEdges( + caller, + callerName, + currentSymbol, + hasResolvedIdentityGraph, + isLogicalPartialFamilyRoot ? identityRootSymbolIds : null, + rootTraversalNodeKey, + resolvedName); foreach (var cycleEdge in cycleEdges) { RegisterImpactCycleNode(cycleNodesByKey, cycleEdge.Caller); @@ -1539,7 +1609,11 @@ private static string BuildImpactTraversalNodeKey(long? symbolId, string name) if (IsImpactRootCaller(caller, callerName, resolvedName, rootDefinitionPaths, identityRootSymbolIds)) continue; var callerNodeKey = BuildImpactTraversalNodeKey(callerSymbolId, callerName); - var key = BuildImpactVisitedKey(caller, callerName, hasResolvedIdentityGraph); + var key = BuildImpactVisitedKey( + caller, + callerName, + hasResolvedIdentityGraph, + deduplicateLogicalNodes: isLogicalPartialFamilyRoot); foreach (var cycleEdge in cycleEdges) { if (!cycleParentsByKey.TryGetValue(cycleEdge.Caller.Key, out var cycleParentSet)) @@ -1559,6 +1633,11 @@ private static string BuildImpactTraversalNodeKey(long? symbolId, string name) if (!visited.Add(key)) { + if (resultIndexByVisitedKey != null + && resultIndexByVisitedKey.TryGetValue(key, out var existingResultIndex)) + { + MergeImpactReferenceEvidence(results[existingResultIndex], caller); + } // Same-depth convergence: record the additional parent so path // enumeration can discover this alternate route. Other-depth re-arrivals // are intentionally dropped — BFS already keeps the shortest route. @@ -1601,6 +1680,7 @@ private static string BuildImpactTraversalNodeKey(long? symbolId, string name) ReferenceKindCounts = caller.ReferenceKindCounts, }); resultIndex = results.Count - 1; + resultIndexByVisitedKey?.Add(key, resultIndex); } discoveredResultCount++; @@ -1655,13 +1735,13 @@ private static string BuildImpactTraversalNodeKey(long? symbolId, string name) && caller.CallerName != SyntheticTopLevelCallerName && depth + 1 < maxDepth) { - queue.Enqueue((caller.CallerName, callerSymbolId, callerNodeKey, depth + 1)); + queue.Enqueue((caller.CallerName, callerSymbolId, null, callerNodeKey, depth + 1)); } else if (caller.CallerName != null && caller.CallerName != SyntheticTopLevelCallerName && depth + 1 == maxDepth) { - var boundaryInspection = InspectBoundaryCallers( + var boundaryInspection = InspectBoundaryCallersCore( caller.CallerName, callerSymbolId, resolvedName, @@ -1678,7 +1758,10 @@ private static string BuildImpactTraversalNodeKey(long? symbolId, string name) excludePathPatterns, excludeTests, includeAmbiguousMSource, - includeMemberReads); + includeMemberReads, + isLogicalPartialFamilyRoot ? identityRootSymbolIds : null, + rootTraversalNodeKey, + resolvedName); maxDepthReached |= boundaryInspection.HasUnvisitedCaller; if (boundaryInspection.ProbeBudgetHit) { @@ -1750,6 +1833,25 @@ private static int GetImpactGraphStateEntryBudget(int limit) return Math.Max(1024, Math.Min(DefaultImpactGraphStateEntryBudget, limitScaled)); } + private static void MergeImpactReferenceEvidence(ImpactResult result, CallerResult caller) + { + var counts = result.ReferenceKindCounts.ToDictionary( + pair => pair.Key, + pair => pair.Value, + StringComparer.Ordinal); + foreach (var (kind, count) in caller.ReferenceKindCounts) + { + counts[kind] = counts.TryGetValue(kind, out var existingCount) + ? Math.Max(existingCount, count) + : count; + } + + result.ReferenceKindCounts = counts; + result.ReferenceKinds = counts.Keys.Order(StringComparer.Ordinal).ToArray(); + result.ReferenceCount = counts.Values.Sum(); + result.FirstLine = Math.Min(result.FirstLine, caller.FirstLine); + } + private static int ImpactGraphStateEntryCount( Dictionary> parentsByNodeKey, Dictionary> cycleParentsByNodeKey, @@ -1793,9 +1895,16 @@ private static List BuildImpactCycleEdges( CallerResult caller, string callerName, string calleeName, - bool hasResolvedIdentityGraph) + bool hasResolvedIdentityGraph, + IReadOnlySet? logicalRootSymbolIds = null, + string? logicalRootKey = null, + string? logicalRootName = null) { - var callerNode = BuildImpactCycleNode(caller.CallerSymbolId, callerName, hasResolvedIdentityGraph); + var callerNode = NormalizeImpactCycleRootNode( + BuildImpactCycleNode(caller.CallerSymbolId, callerName, hasResolvedIdentityGraph), + logicalRootSymbolIds, + logicalRootKey, + logicalRootName); if (callerNode is not { } canonicalCaller) return []; @@ -1815,10 +1924,33 @@ private static List BuildImpactCycleEdges( .Order() .Select(calleeSymbolId => new ImpactCycleEdge( canonicalCaller, - new ImpactCycleNode($"id:{calleeSymbolId}", calleeSymbolId, calleeName))) + NormalizeImpactCycleRootNode( + new ImpactCycleNode($"id:{calleeSymbolId}", calleeSymbolId, calleeName), + logicalRootSymbolIds, + logicalRootKey, + logicalRootName)!.Value)) .ToList(); } + private static ImpactCycleNode? NormalizeImpactCycleRootNode( + ImpactCycleNode? node, + IReadOnlySet? logicalRootSymbolIds, + string? logicalRootKey, + string? logicalRootName) + { + if (node is not { SymbolId: long symbolId } + || logicalRootSymbolIds is not { Count: > 0 } + || !logicalRootSymbolIds.Contains(symbolId)) + { + return node; + } + + return new ImpactCycleNode( + logicalRootKey ?? "logical-partial-root", + SymbolId: null, + logicalRootName ?? node.Value.Name); + } + private static bool IsImpactRootCaller( CallerResult caller, string callerName, @@ -1853,6 +1985,49 @@ private ImpactBoundaryInspection InspectBoundaryCallers( bool excludeTests, bool includeAmbiguousMSource, bool includeMemberReads) + => InspectBoundaryCallersCore( + symbolName, + symbolId, + resolvedName, + rootDefinitionPaths, + identityRootSymbolIds, + visited, + cycleParentsByKey, + cycleNodesByKey, + cycles, + cycleKeys, + hasResolvedIdentityGraph, + lang, + pathPatterns, + excludePathPatterns, + excludeTests, + includeAmbiguousMSource, + includeMemberReads, + logicalRootSymbolIds: null, + logicalRootKey: null, + logicalRootName: null); + + private ImpactBoundaryInspection InspectBoundaryCallersCore( + string symbolName, + long? symbolId, + string resolvedName, + HashSet rootDefinitionPaths, + IReadOnlySet? identityRootSymbolIds, + HashSet visited, + Dictionary> cycleParentsByKey, + Dictionary cycleNodesByKey, + List cycles, + HashSet cycleKeys, + bool hasResolvedIdentityGraph, + string? lang, + IReadOnlyList? pathPatterns, + IReadOnlyList? excludePathPatterns, + bool excludeTests, + bool includeAmbiguousMSource, + bool includeMemberReads, + IReadOnlySet? logicalRootSymbolIds, + string? logicalRootKey, + string? logicalRootName) { var offset = 0; var probes = 0; @@ -1872,7 +2047,14 @@ private ImpactBoundaryInspection InspectBoundaryCallers( foreach (var caller in page) { var callerName = caller.CallerName ?? SyntheticTopLevelCallerName; - var cycleEdges = BuildImpactCycleEdges(caller, callerName, symbolName, hasResolvedIdentityGraph); + var cycleEdges = BuildImpactCycleEdges( + caller, + callerName, + symbolName, + hasResolvedIdentityGraph, + logicalRootSymbolIds, + logicalRootKey, + logicalRootName); foreach (var cycleEdge in cycleEdges) { RegisterImpactCycleNode(cycleNodesByKey, cycleEdge.Caller); @@ -1894,7 +2076,11 @@ private ImpactBoundaryInspection InspectBoundaryCallers( cycleParentSet.Add(cycleEdge.Callee.Key); } - var key = BuildImpactVisitedKey(caller, callerName, hasResolvedIdentityGraph); + var key = BuildImpactVisitedKey( + caller, + callerName, + hasResolvedIdentityGraph, + deduplicateLogicalNodes: logicalRootSymbolIds is not null); if (!visited.Contains(key)) return new ImpactBoundaryInspection(HasUnvisitedCaller: true, ProbeBudgetHit: false); } @@ -2213,6 +2399,7 @@ private static ImpactPathNode CloneImpactPathNode(ImpactPathNode node) DefinitionLine = node.DefinitionLine, Container = node.Container, FamilyKey = node.FamilyKey, + PartialFamilyId = node.PartialFamilyId, LogicalTargetKey = node.LogicalTargetKey, ReferencePath = node.ReferencePath, ReferenceLine = node.ReferenceLine, @@ -2252,9 +2439,33 @@ public ImpactAnalysisResult AnalyzeImpact(string symbolName, int maxDepth = 5, i .Select(d => d.Path) .Distinct(indexedPathComparer) .ToList(); - var hasMultipleFallbackDefinitions = definitionResolution.PreciseDefinitionCount > 1; + var hasMultipleFallbackDefinitions = definitionResolution.PreciseLogicalDefinitionCount > 1; var hasMultipleFallbackDefinitionFiles = definitionResolution.PreciseDefinitionFileCount > 1; var hasClassLikeDefinitions = definitionResolution.PreciseDefinitionCount > 0; + var logicalPartialFamilyDefinition = definitionResolution.LogicalCount == 1 + && definitions.Count == 1 + && definitions[0].Lang == "csharp" + && definitions[0].PartialFamilyId != null + ? definitions[0] + : null; + var traversalRootScope = logicalPartialFamilyDefinition != null + ? "logical_partial_family" + : "symbol"; + var partialFamilyMemberCount = logicalPartialFamilyDefinition != null + ? definitionResolution.PhysicalCount + : (int?)null; + var partialFamilyMemberRootCount = logicalPartialFamilyDefinition != null + ? definitionResolution.PhysicalSymbolIds.Count + : (int?)null; + var partialFamilyMemberRootLimit = logicalPartialFamilyDefinition != null + ? Math.Max(1, ImpactPartialFamilyMemberBudget) + : (int?)null; + var partialFamilyMemberRootTruncated = logicalPartialFamilyDefinition != null + ? definitionResolution.PhysicalSymbolIdsTruncated + : (bool?)null; + var partialFamilyMemberRootOmitted = logicalPartialFamilyDefinition != null + ? Math.Max(0, definitionResolution.PhysicalCount - definitionResolution.PhysicalSymbolIds.Count) + : (int?)null; if (maxDepth <= 0) { @@ -2272,6 +2483,13 @@ public ImpactAnalysisResult AnalyzeImpact(string symbolName, int maxDepth = 5, i HasClassLikeDefinitions = hasClassLikeDefinitions, HasMultipleDefinitions = hasMultipleDefinitions, HasMultipleDefinitionFiles = definitionResolution.PhysicalFileCount > 1, + TraversalRootScope = traversalRootScope, + TraversalPartialFamilyId = logicalPartialFamilyDefinition?.PartialFamilyId, + PartialFamilyMemberCount = partialFamilyMemberCount, + PartialFamilyMemberRootCount = partialFamilyMemberRootCount, + PartialFamilyMemberRootLimit = partialFamilyMemberRootLimit, + PartialFamilyMemberRootTruncated = partialFamilyMemberRootTruncated, + PartialFamilyMemberRootOmitted = partialFamilyMemberRootOmitted, Definitions = definitions, Callers = [], FileImpacts = [], @@ -2342,16 +2560,42 @@ public ImpactAnalysisResult AnalyzeImpact(string symbolName, int maxDepth = 5, i } else if (fallbackDefinitions.Count == 1) { - var fallbackNames = ResolveImpactFallbackNames(fallbackDefinitions[0]); + var fallbackNames = ResolveImpactFallbackNames( + fallbackDefinitions[0], + logicalPartialFamilyDefinition != null + ? definitionResolution.PhysicalDefinitionPaths + : null); var fileImpactOffset = responseCollection is null || string.Equals(responseCollection, "file_impacts", StringComparison.Ordinal) ? offset : 0; - var (hintResults, hintTruncated) = GetFileDependencyHintsToResolvedType(fallbackDefinitions[0], fallbackNames, limit, lang, pathPatterns, excludePathPatterns, excludeTests, fileImpactOffset); + var (hintResults, hintTruncated) = GetFileDependencyHintsToResolvedType( + fallbackDefinitions[0], + fallbackNames, + limit, + lang, + pathPatterns, + excludePathPatterns, + excludeTests, + fileImpactOffset, + logicalPartialFamilyDefinition != null + ? definitionResolution.PhysicalDefinitionPaths + : null); fileImpacts = hintResults; var hintExistsBeforeOffset = false; if (fileImpacts.Count == 0 && fileImpactOffset > 0) { - var hintProbe = GetFileDependencyHintsToResolvedType(fallbackDefinitions[0], fallbackNames, 1, lang, pathPatterns, excludePathPatterns, excludeTests, 0); + var hintProbe = GetFileDependencyHintsToResolvedType( + fallbackDefinitions[0], + fallbackNames, + 1, + lang, + pathPatterns, + excludePathPatterns, + excludeTests, + 0, + logicalPartialFamilyDefinition != null + ? definitionResolution.PhysicalDefinitionPaths + : null); hintExistsBeforeOffset = hintProbe.Results.Count > 0; } if (hintTruncated) @@ -2415,6 +2659,13 @@ public ImpactAnalysisResult AnalyzeImpact(string symbolName, int maxDepth = 5, i HasClassLikeDefinitions = hasClassLikeDefinitions, HasMultipleDefinitions = hasMultipleDefinitions, HasMultipleDefinitionFiles = definitionResolution.PhysicalFileCount > 1, + TraversalRootScope = traversalRootScope, + TraversalPartialFamilyId = logicalPartialFamilyDefinition?.PartialFamilyId, + PartialFamilyMemberCount = partialFamilyMemberCount, + PartialFamilyMemberRootCount = partialFamilyMemberRootCount, + PartialFamilyMemberRootLimit = partialFamilyMemberRootLimit, + PartialFamilyMemberRootTruncated = partialFamilyMemberRootTruncated, + PartialFamilyMemberRootOmitted = partialFamilyMemberRootOmitted, Definitions = definitions, Callers = callers, FileImpacts = fileImpacts, @@ -2437,10 +2688,12 @@ private sealed record ImpactDefinitionResolution( int PhysicalFileCount, int LogicalCount, int PreciseDefinitionCount, + int PreciseLogicalDefinitionCount, int PreciseDefinitionFileCount, int NonCallableDefinitionCount, SymbolResult? SinglePreciseDefinition, HashSet PhysicalSymbolIds, + HashSet PhysicalDefinitionPaths, bool PhysicalSymbolIdsTruncated); private ImpactDefinitionResolution ResolveImpactDefinitions( @@ -2655,6 +2908,7 @@ SELECT COUNT(*) AS physical_count, {pathDistinctSql} AS physical_file_count, COUNT(DISTINCT logical_partial_key) AS logical_count, SUM(is_precise) AS precise_count, + COUNT(DISTINCT CASE WHEN is_precise = 1 THEN logical_partial_key END) AS precise_logical_count, {precisePathDistinctSql} AS precise_file_count, SUM(is_non_callable) AS non_callable_count FROM matching_definitions @@ -2677,7 +2931,8 @@ WHEN logical.logical_declaration_identity_min <> logical.logical_declaration_ide END AS representative_reason, logical.logical_family_members_json, CASE WHEN logical.logical_definition_sites > {LogicalPartialSymbolGrouper.FamilyMemberLimit} THEN 1 ELSE 0 END AS family_members_truncated, - logical.identifier_start_column + logical.identifier_start_column, + stats.precise_logical_count FROM selected_definition_keys selected JOIN logical_definitions logical ON logical.logical_partial_key = selected.logical_partial_key @@ -2712,6 +2967,7 @@ CROSS JOIN definition_stats stats var physicalFileCount = 0; var logicalCount = 0; var preciseCount = 0; + var preciseLogicalCount = 0; var preciseFileCount = 0; var nonCallableCount = 0; using var reader = cmd.ExecuteTrackedReader(); @@ -2759,24 +3015,26 @@ CROSS JOIN definition_stats stats preciseCount = reader.GetInt32(23); preciseFileCount = reader.GetInt32(24); nonCallableCount = reader.GetInt32(25); + preciseLogicalCount = reader.GetInt32(30); if (IsPreciseImpactFallbackKind(result.Kind)) preciseDefinition ??= result; if (reader.GetInt32(19) == 1) results.Add(result); } - // Qualified C# graph traversal is identity-scoped. Keep the representative-only + // Identity-scoped C# graph traversal keeps the representative-only // definition payload, but retain every selected physical family ID internally so a // call resolved to a partial declaration reaches the same graph as its implementation. - // qualified C# のグラフ探索は identity 単位で行う。definition の出力は代表1件のまま + // identity-scoped C# のグラフ探索では definition の出力は代表1件のまま // としつつ、partial 宣言側へ解決された call も実装側と同じグラフへ到達できるよう、 // 選択された family の全 physical ID を内部的に保持する。 reader.Dispose(); var physicalSymbolIds = new HashSet(); + var physicalDefinitionPaths = new HashSet(GetIndexedPathComparer()); var physicalSymbolIdsTruncated = false; foreach (var definition in results) { - AddPhysicalSymbolId(definition.SymbolId!.Value); + AddPhysicalDefinition(definition.SymbolId!.Value, definition.Path); if (physicalSymbolIdsTruncated || definition.DefinitionSites is not > 1) continue; @@ -2785,23 +3043,23 @@ CROSS JOIN definition_stats stats foreach (var member in definition.FamilyMembers ?? []) { if (member.SymbolId is long memberSymbolId) - AddPhysicalSymbolId(memberSymbolId); + AddPhysicalDefinition(memberSymbolId, member.Path); if (physicalSymbolIdsTruncated) break; } continue; } - var (familySymbolIds, familyIdsTruncated) = ResolveImpactPhysicalFamilySymbolIds( + var (familyMembers, familyIdsTruncated) = ResolveImpactPhysicalFamilyMembers( definition, logicalPartialKeySql, lang, pathPatterns, excludePathPatterns, excludeTests); - foreach (var familySymbolId in familySymbolIds) + foreach (var familyMember in familyMembers) { - AddPhysicalSymbolId(familySymbolId); + AddPhysicalDefinition(familyMember.SymbolId, familyMember.Path); if (physicalSymbolIdsTruncated) break; } @@ -2814,26 +3072,29 @@ CROSS JOIN definition_stats stats physicalFileCount, logicalCount, preciseCount, + preciseLogicalCount, preciseFileCount, nonCallableCount, - preciseCount == 1 ? preciseDefinition : null, + preciseLogicalCount == 1 ? preciseDefinition : null, physicalSymbolIds, + physicalDefinitionPaths, physicalSymbolIdsTruncated); - void AddPhysicalSymbolId(long symbolId) + void AddPhysicalDefinition(long symbolId, string path) { if (physicalSymbolIds.Contains(symbolId)) return; - if (physicalSymbolIds.Count >= DefaultImpactGraphStateEntryBudget) + if (physicalSymbolIds.Count >= Math.Max(1, ImpactPartialFamilyMemberBudget)) { physicalSymbolIdsTruncated = true; return; } physicalSymbolIds.Add(symbolId); + physicalDefinitionPaths.Add(path); } } - private (List SymbolIds, bool Truncated) ResolveImpactPhysicalFamilySymbolIds( + private (List<(long SymbolId, string Path)> Members, bool Truncated) ResolveImpactPhysicalFamilyMembers( SymbolResult definition, string logicalPartialKeySql, string? lang, @@ -2847,7 +3108,7 @@ void AddPhysicalSymbolId(long symbolId) ? "s.kind IN ('function', 'test.method')" : "s.kind = @familyKind"; var sql = $@" - SELECT s.id + SELECT s.id, f.path FROM symbols s JOIN files f ON s.file_id = f.id WHERE f.lang = @familyLang @@ -2868,18 +3129,19 @@ FROM symbols s SqliteCommandPolicy.Add(cmd, "@logicalPartialKey", definition.LogicalPartialKey!); if (lang != null) SqliteCommandPolicy.Add(cmd, "@lang", lang); - SqliteCommandPolicy.Add(cmd, "@familyMemberLimit", DefaultImpactGraphStateEntryBudget + 1); + var familyMemberBudget = Math.Max(1, ImpactPartialFamilyMemberBudget); + SqliteCommandPolicy.Add(cmd, "@familyMemberLimit", familyMemberBudget + 1); AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns); - var symbolIds = new List(); + var members = new List<(long SymbolId, string Path)>(); using var reader = cmd.ExecuteTrackedReader(); while (reader.TrackedRead()) { - if (symbolIds.Count >= DefaultImpactGraphStateEntryBudget) - return (symbolIds, true); - symbolIds.Add(reader.GetInt64(0)); + if (members.Count >= familyMemberBudget) + return (members, true); + members.Add((reader.GetInt64(0), reader.GetString(1))); } - return (symbolIds, false); + return (members, false); } // C# convention: a class `FooAttribute` is used in source as `[Foo]`, so the reference @@ -2928,25 +3190,30 @@ FROM symbols s return query[1..]; } - private List ResolveImpactFallbackNames(SymbolResult definition) + private List ResolveImpactFallbackNames( + SymbolResult definition, + IReadOnlySet? physicalDefinitionPaths = null) { if (string.IsNullOrWhiteSpace(definition.Path) || string.IsNullOrWhiteSpace(definition.Name)) return new List(); + var definitionPaths = physicalDefinitionPaths is { Count: > 0 } + ? physicalDefinitionPaths.Order(StringComparer.Ordinal).ToList() + : [definition.Path]; using var cmd = _conn.CreateCommand(); var supportedLangFilter = BuildGraphSupportedLanguagePredicate(cmd, "f", "impactSafeNameLang"); cmd.CommandText = @" SELECT DISTINCT s.name FROM symbols s JOIN files f ON s.file_id = f.id - WHERE f.path = @targetPath + WHERE f.path IN (SELECT value FROM json_each(@targetPathsJson)) AND " + supportedLangFilter + @" AND ( (s.name = @containerName AND s.kind = @containerKind) OR s.container_name = @containerName ) ORDER BY s.name"; - SqliteCommandPolicy.Add(cmd, "@targetPath", definition.Path); + SqliteCommandPolicy.Add(cmd, "@targetPathsJson", JsonStringListCodec.Serialize(definitionPaths)); SqliteCommandPolicy.Add(cmd, "@containerName", definition.Name); SqliteCommandPolicy.Add(cmd, "@containerKind", definition.Kind); @@ -2981,11 +3248,23 @@ FROM symbols s return results; } - private (List Results, bool Truncated) GetFileDependencyHintsToResolvedType(SymbolResult definition, IReadOnlyList fallbackNames, int limit, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, int offset = 0) + private (List Results, bool Truncated) GetFileDependencyHintsToResolvedType( + SymbolResult definition, + IReadOnlyList fallbackNames, + int limit, + string? lang = null, + IReadOnlyList? pathPatterns = null, + IReadOnlyList? excludePathPatterns = null, + bool excludeTests = false, + int offset = 0, + IReadOnlySet? physicalDefinitionPaths = null) { if (!_hasReferencesTable || string.IsNullOrWhiteSpace(definition.Path) || fallbackNames.Count == 0) return (new List(), false); + var definitionPaths = physicalDefinitionPaths is { Count: > 0 } + ? physicalDefinitionPaths.Order(StringComparer.Ordinal).ToList() + : [definition.Path]; using var cmd = _conn.CreateCommand(); var innerSql = @" SELECT src.id AS source_file_id, src.path AS source_path, @impactTargetPath AS target_path, @@ -2995,7 +3274,7 @@ FROM symbols s " + GetLogicalReferenceKindSql("r.reference_kind") + @" AS logical_reference_kind FROM symbol_references r JOIN files src ON r.file_id = src.id - WHERE src.path != @impactTargetPath"; + WHERE src.path NOT IN (SELECT value FROM json_each(@impactTargetPathsJson))"; // `impact` heuristic file hints intentionally include metadata-only reference // kinds (`attribute` / `annotation`). A rename or removal of `User` breaks // `[JsonConverter(typeof(User))]` / `@Inject(User.class)` at compile time just @@ -3012,10 +3291,7 @@ FROM symbol_references r innerSql += $" AND {BuildGraphSupportedLanguagePredicate(cmd, "src", "impactDepsLang")}"; if (lang != null) innerSql += " AND src.lang = @lang"; - var nameClauses = new List(fallbackNames.Count); - for (int i = 0; i < fallbackNames.Count; i++) - nameClauses.Add($"r.symbol_name = @impactFallbackName{i}"); - innerSql += " AND (" + string.Join(" OR ", nameClauses) + ")"; + innerSql += " AND r.symbol_name IN (SELECT value FROM json_each(@impactFallbackNamesJson))"; if (pathPatterns is { Count: > 0 }) { @@ -3044,8 +3320,8 @@ FROM symbol_references r if (lang != null) SqliteCommandPolicy.Add(cmd, "@lang", lang); SqliteCommandPolicy.Add(cmd, "@impactTargetPath", definition.Path); - for (int i = 0; i < fallbackNames.Count; i++) - SqliteCommandPolicy.Add(cmd, $"@impactFallbackName{i}", fallbackNames[i]); + SqliteCommandPolicy.Add(cmd, "@impactTargetPathsJson", JsonStringListCodec.Serialize(definitionPaths)); + SqliteCommandPolicy.Add(cmd, "@impactFallbackNamesJson", JsonStringListCodec.Serialize(fallbackNames)); AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns); var candidates = new List<(long SourceFileId, bool HasMetadataRef, FileDependencyResult Edge)>(); diff --git a/src/CodeIndex/Mcp/McpToolHandlers.ImpactAnalysis.cs b/src/CodeIndex/Mcp/McpToolHandlers.ImpactAnalysis.cs index fa6c48a43..824100e27 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.ImpactAnalysis.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.ImpactAnalysis.cs @@ -89,6 +89,7 @@ private JsonNode ExecuteImpactAnalysis(JsonNode? id, JsonNode? args) ["top_files"] = topFiles, ["results"] = new JsonArray(), }; + AddImpactTraversalRootFields(countOnlyPayload, analysis); AddImpactFailureFields(countOnlyPayload, analysis); AddSqlGraphContractSignal(countOnlyPayload, sqlGraphSignal); AddReferenceGraphCompletenessSignal( @@ -133,6 +134,7 @@ private JsonNode ExecuteImpactAnalysis(JsonNode? id, JsonNode? args) ["definitions"] = ToJsonArray(analysis.Definitions), ["graph_table_available"] = analysis.GraphTableAvailable, }; + AddImpactTraversalRootFields(payload, analysis); if (analysis.TruncatedReason != null) payload["truncated_reason"] = analysis.TruncatedReason; if (analysis.Cycles is { Count: > 0 }) @@ -195,5 +197,19 @@ private JsonNode ExecuteImpactAnalysis(JsonNode? id, JsonNode? args) }); } + private static void AddImpactTraversalRootFields(JsonObject payload, ImpactAnalysisResult analysis) + { + payload["traversal_root_scope"] = analysis.TraversalRootScope; + if (analysis.TraversalPartialFamilyId == null) + return; + + payload["traversal_partial_family_id"] = analysis.TraversalPartialFamilyId; + payload["partial_family_member_count"] = analysis.PartialFamilyMemberCount; + payload["partial_family_member_root_count"] = analysis.PartialFamilyMemberRootCount; + payload["partial_family_member_root_limit"] = analysis.PartialFamilyMemberRootLimit; + payload["partial_family_member_root_truncated"] = analysis.PartialFamilyMemberRootTruncated; + payload["partial_family_member_root_omitted"] = analysis.PartialFamilyMemberRootOmitted; + } + } diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index a0003c041..31af7cae9 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -1039,6 +1039,8 @@ public class ImpactPathNode public int? DefinitionLine { get; set; } public string? Container { get; set; } public string? FamilyKey { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? PartialFamilyId { get; set; } public string? LogicalTargetKey { get; set; } public string? ReferencePath { get; set; } public int? ReferenceLine { get; set; } @@ -1067,6 +1069,19 @@ public class ImpactAnalysisResult public bool HasClassLikeDefinitions { get; set; } public bool HasMultipleDefinitions { get; set; } public bool HasMultipleDefinitionFiles { get; set; } + public string TraversalRootScope { get; set; } = "symbol"; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TraversalPartialFamilyId { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? PartialFamilyMemberCount { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? PartialFamilyMemberRootCount { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? PartialFamilyMemberRootLimit { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? PartialFamilyMemberRootTruncated { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? PartialFamilyMemberRootOmitted { get; set; } public List Definitions { get; set; } = []; public List Callers { get; set; } = []; public List FileImpacts { get; set; } = []; diff --git a/tests/CodeIndex.Tests/DbReaderImpactTests.cs b/tests/CodeIndex.Tests/DbReaderImpactTests.cs index 32312379d..d57b46bed 100644 --- a/tests/CodeIndex.Tests/DbReaderImpactTests.cs +++ b/tests/CodeIndex.Tests/DbReaderImpactTests.cs @@ -784,7 +784,7 @@ public void Boot(FooService service) } [Fact] - public void AnalyzeImpact_PartialClassWithoutReverseEdges_ExplainsMultipleDefinitions() + public void AnalyzeImpact_PartialClassWithoutReverseEdges_UsesSingleLogicalRoot() { InsertIndexedFile("src/Worker.Part1.cs", "csharp", """ @@ -809,8 +809,192 @@ public void Stop() { } Assert.True(analysis.HasClassLikeDefinitions); Assert.True(analysis.HasMultipleDefinitions); Assert.True(analysis.HasMultipleDefinitionFiles); + Assert.Equal("logical_partial_family", analysis.TraversalRootScope); + Assert.NotNull(analysis.TraversalPartialFamilyId); + Assert.Equal(2, analysis.PartialFamilyMemberCount); + Assert.Equal(2, analysis.PartialFamilyMemberRootCount); + Assert.False(analysis.PartialFamilyMemberRootTruncated); + Assert.Equal(0, analysis.PartialFamilyMemberRootOmitted); + Assert.Equal("class_symbol_no_symbol_callers", analysis.ZeroResultReason); + Assert.Contains("deps --path", analysis.Suggestion); + Assert.Contains("--reverse", analysis.Suggestion); + } + + [Fact] + public void AnalyzeImpact_PartialClassFileHintsUnionAllMemberFilesAndDeduplicateConsumers() + { + InsertIndexedFile("src/Worker.Start.cs", "csharp", + """ + namespace Demo; + public partial class Worker + { + public void Start() { } + } + """); + InsertIndexedFile("src/Worker.Stop.cs", "csharp", + """ + namespace Demo; + public partial class Worker + { + public void Stop() { } + } + """); + InsertIndexedFile("src/StartConsumer.cs", "csharp", + """ + namespace Demo; + public class StartConsumer + { + public void Run(Worker worker) => worker.Start(); + } + """); + InsertIndexedFile("src/StopConsumer.cs", "csharp", + """ + namespace Demo; + public class StopConsumer + { + public void Run(Worker worker) => worker.Stop(); + } + """); + InsertIndexedFile("src/BothConsumer.cs", "csharp", + """ + namespace Demo; + public class BothConsumer + { + public void Run(Worker worker) + { + worker.Start(); + worker.Stop(); + } + } + """); + + var analysis = _reader.AnalyzeImpact("Demo.Worker", maxDepth: 3, limit: 10); + + Assert.Equal("file_dependency_hints", analysis.ImpactMode); + Assert.True(analysis.Heuristic); + Assert.Empty(analysis.Callers); + Assert.Equal(3, analysis.FileImpacts.Count); + Assert.Equal( + ["src/BothConsumer.cs", "src/StartConsumer.cs", "src/StopConsumer.cs"], + analysis.FileImpacts.Select(impact => impact.SourcePath).Order(StringComparer.Ordinal)); + Assert.DoesNotContain(analysis.FileImpacts, impact => impact.SourcePath.StartsWith("src/Worker.", StringComparison.Ordinal)); + Assert.Equal(analysis.FileImpacts.Count, analysis.FileImpacts.Select(impact => impact.SourcePath).Distinct().Count()); + } + + [Fact] + public void AnalyzeImpact_PartialMethodFamilyTraversesUnionDeduplicatesAndDetectsLogicalCycle() + { + InsertIndexedFile("src/Worker.Sync.Declaration.cs", "csharp", + """ + namespace Demo; + public partial class Worker + { + partial void Sync(); + public void Start() => Sync(); + } + """); + InsertIndexedFile("src/Worker.Sync.Implementation.cs", "csharp", + """ + namespace Demo; + public partial class Worker + { + partial void Sync() => Finish(); + public void Finish() => Sync(); + } + """); + InsertIndexedFile("src/Entry.cs", "csharp", + """ + namespace Demo; + public class Entry + { + public void Run(Worker worker) => worker.Start(); + } + """); + InsertIndexedFile("src/Other.Sync.cs", "csharp", + """ + namespace Other; + public class Worker + { + public void Sync() { } + } + """); + + var bounded = _reader.AnalyzeImpact("Demo.Worker.Sync", maxDepth: 1, limit: 10, withPaths: true); + var expanded = _reader.AnalyzeImpact("Demo.Worker.Sync", maxDepth: 2, limit: 10, withPaths: true); + + Assert.Equal(2, bounded.DefinitionCount); + Assert.Equal(1, bounded.LogicalDefinitionCount); + Assert.Equal("logical_partial_family", bounded.TraversalRootScope); + Assert.Equal(2, bounded.PartialFamilyMemberRootCount); + Assert.Equal(["Finish", "Start"], bounded.Callers.Select(caller => caller.CallerName).Order(StringComparer.Ordinal)); + Assert.All(bounded.Callers, caller => Assert.Equal(1, caller.Depth)); + Assert.Equal(bounded.Callers.Count, bounded.Callers.Select(caller => caller.CallerSymbolId).Distinct().Count()); + Assert.True(bounded.CycleDetected); + Assert.Contains(bounded.Cycles!, cycle => cycle.Members.Any(member => member.EndsWith(".Sync", StringComparison.Ordinal)) && cycle.Members.Contains("Finish")); + Assert.All( + bounded.Callers.SelectMany(caller => caller.PathDetails ?? []), + path => + { + Assert.EndsWith(".Sync", path[0].Name, StringComparison.Ordinal); + Assert.StartsWith("partial:", path[0].PartialFamilyId); + Assert.StartsWith("src/Worker.Sync.", path[0].DefinitionPath); + }); + + Assert.Contains(expanded.Callers, caller => caller.CallerName == "Run" && caller.Depth == 2); + Assert.DoesNotContain(expanded.Callers, caller => caller.Path == "src/Other.Sync.cs"); + Assert.Equal(expanded.Callers.Count, expanded.Callers.Select(caller => caller.CallerSymbolId).Distinct().Count()); + Assert.True(expanded.CycleDetected); + Assert.Contains(expanded.Cycles!, cycle => cycle.Members.Any(member => member.EndsWith(".Sync", StringComparison.Ordinal)) && cycle.Members.Contains("Finish")); + } + + [Fact] + public void AnalyzeImpact_UnrelatedSameNameTypeRemainsAmbiguousBesidePartialFamily() + { + InsertIndexedFile("src/A.Worker.One.cs", "csharp", + """ + namespace A; + public partial class Worker { } + """); + InsertIndexedFile("src/A.Worker.Two.cs", "csharp", + """ + namespace A; + public partial class Worker { } + """); + InsertIndexedFile("src/B.Worker.cs", "csharp", + """ + namespace B; + public class Worker { } + """); + + var analysis = _reader.AnalyzeImpact("Worker", maxDepth: 3, limit: 10); + + Assert.Equal(3, analysis.DefinitionCount); + Assert.Equal(2, analysis.LogicalDefinitionCount); + Assert.Equal("symbol", analysis.TraversalRootScope); + Assert.Null(analysis.TraversalPartialFamilyId); Assert.Equal("multiple_definition_files", analysis.ZeroResultReason); - Assert.Contains("deps --path --reverse", analysis.Suggestion); + Assert.Empty(analysis.Callers); + Assert.Empty(analysis.FileImpacts); + } + + [Fact] + public void AnalyzeImpact_PartialFamilyBudgetIsIndependentFromTraversalTruncation() + { + InsertIndexedFile("src/Budgeted.One.cs", "csharp", "public partial class Budgeted { }"); + InsertIndexedFile("src/Budgeted.Two.cs", "csharp", "public partial class Budgeted { }"); + InsertIndexedFile("src/Budgeted.Three.cs", "csharp", "public partial class Budgeted { }"); + _reader.ImpactPartialFamilyMemberBudget = 2; + + var analysis = _reader.AnalyzeImpact("Budgeted", maxDepth: 2, limit: 10); + + Assert.Equal("logical_partial_family", analysis.TraversalRootScope); + Assert.Equal(3, analysis.PartialFamilyMemberCount); + Assert.Equal(2, analysis.PartialFamilyMemberRootCount); + Assert.Equal(2, analysis.PartialFamilyMemberRootLimit); + Assert.True(analysis.PartialFamilyMemberRootTruncated); + Assert.Equal(1, analysis.PartialFamilyMemberRootOmitted); + Assert.False(analysis.Truncated); + Assert.Null(analysis.TruncatedReason); } [Fact] diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 19db3d4e2..d0ef1f1cd 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -796,7 +796,8 @@ private long InsertIndexedFile( string content, bool generated = false, bool splitIntoProductionChunks = false, - int? lineCountOverride = null) + int? lineCountOverride = null, + string? familyScopeKey = null) { var normalized = content.Replace("\r\n", "\n"); var lines = normalized.Split('\n'); @@ -828,9 +829,11 @@ private long InsertIndexedFile( }, ]); - var symbols = SymbolExtractor.Extract(fileId, lang, normalized); + var symbols = SymbolExtractor.Extract(fileId, lang, normalized, filePath: familyScopeKey != null ? path : null); + if (familyScopeKey != null) + SymbolExtractor.ApplyFamilyScope(symbols, familyScopeKey, lang); writer.InsertSymbols(symbols); - writer.InsertReferences(ReferenceExtractor.Extract(fileId, lang, normalized, symbols)); + writer.InsertReferences(ReferenceExtractor.Extract(fileId, lang, normalized, symbols, path: familyScopeKey != null ? path : null)); return fileId; } diff --git a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs index ba7fb4536..228656a46 100644 --- a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs @@ -2561,6 +2561,54 @@ public void Run(FolderDiffService service) Assert.Contains("heuristic only", response["result"]!["content"]![0]!["text"]!.GetValue()); } + [Fact] + public void ToolsCall_ImpactAnalysis_LogicalPartialFamilyReportsRootScope_Issue5060() + { + InsertIndexedFile("src/Worker.Start.cs", "csharp", + """ + namespace Demo; + public partial class Worker + { + public void Start() { } + } + """, familyScopeKey: "src"); + InsertIndexedFile("src/Worker.Stop.cs", "csharp", + """ + namespace Demo; + public partial class Worker + { + public void Stop() { } + } + """, familyScopeKey: "src"); + InsertIndexedFile("src/Consumer.cs", "csharp", + """ + namespace Demo; + public class Consumer + { + public void Run(Worker worker) + { + worker.Start(); + worker.Stop(); + } + } + """, familyScopeKey: "src"); + new DbWriter(_db.Connection).MarkHotspotFamilyReady("csharp", "issue-5060-fixture"); + + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"impact_analysis","arguments":{"query":"Demo.Worker"}}}""")!; + var response = _server.HandleMessage(request)!; + var structured = response["result"]!["structuredContent"]!; + + Assert.Equal("logical_partial_family", structured["traversal_root_scope"]!.GetValue()); + Assert.StartsWith("partial:", structured["traversal_partial_family_id"]!.GetValue()); + Assert.Equal(2, structured["partial_family_member_count"]!.GetValue()); + Assert.Equal(2, structured["partial_family_member_root_count"]!.GetValue()); + Assert.Equal(DbReader.DefaultImpactPartialFamilyMemberBudget, structured["partial_family_member_root_limit"]!.GetValue()); + Assert.False(structured["partial_family_member_root_truncated"]!.GetValue()); + Assert.Equal(0, structured["partial_family_member_root_omitted"]!.GetValue()); + Assert.Equal("file_dependency_hints", structured["impact_mode"]!.GetValue()); + Assert.Single(structured["file_impacts"]!.AsArray()); + } + [Fact] public void ToolsCall_ImpactAnalysis_ClassAndNamespaceWithSameNameStillReturnsHeuristicHints() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerImpactTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerImpactTests.cs index d181a43cc..3e3838ffb 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerImpactTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerImpactTests.cs @@ -9,6 +9,78 @@ namespace CodeIndex.Tests; public partial class QueryCommandRunnerTests { + [Fact] + public void RunImpact_LogicalPartialFamilyReportsIndependentRootBudget_Issue5060() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_impact_partial_family_issue5060"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Worker.Start.cs", + "csharp", + """ + namespace Demo; + public partial class Worker + { + public void Start() { } + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Worker.Stop.cs", + "csharp", + """ + namespace Demo; + public partial class Worker + { + public void Stop() { } + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Consumer.cs", + "csharp", + """ + namespace Demo; + public class Consumer + { + public void Run(Worker worker) + { + worker.Start(); + worker.Stop(); + } + } + """); + MarkGraphAndFoldReady(dbPath); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunImpact( + ["Demo.Worker", "--db", dbPath, "--json", "--lang", "csharp", "--max-hops", "3", "--limit", "10"], + _jsonOptions)); + using var document = ParseJsonOutput(stdout); + var root = document.RootElement; + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal("logical_partial_family", root.GetProperty("traversal_root_scope").GetString()); + Assert.StartsWith("partial:", root.GetProperty("traversal_partial_family_id").GetString()); + Assert.Equal(2, root.GetProperty("partial_family_member_count").GetInt32()); + Assert.Equal(2, root.GetProperty("partial_family_member_root_count").GetInt32()); + Assert.Equal(DbReader.DefaultImpactPartialFamilyMemberBudget, root.GetProperty("partial_family_member_root_limit").GetInt32()); + Assert.False(root.GetProperty("partial_family_member_root_truncated").GetBoolean()); + Assert.Equal(0, root.GetProperty("partial_family_member_root_omitted").GetInt32()); + Assert.Equal("file_dependency_hints", root.GetProperty("impact_mode").GetString()); + Assert.Single(root.GetProperty("file_impacts").EnumerateArray()); + Assert.Equal("src/Consumer.cs", root.GetProperty("file_impacts")[0].GetProperty("source_path").GetString()); + Assert.False(root.TryGetProperty("zero_result_reason", out _)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunImpact_MissingDepthValueShowsPerFlagHint_Issue1507() { @@ -709,7 +781,7 @@ public void Boot(FooService service) } [Fact] - public void RunImpact_PartialClassJsonReturnsResolutionHintPayload() + public void RunImpact_PartialClassJsonReturnsLogicalFamilyPayload() { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_runner_impact_partial_hint"); try @@ -744,8 +816,14 @@ public void Stop() { } Assert.Equal(0, json.GetProperty("count").GetInt32()); Assert.True(json.GetProperty("has_multiple_definitions").GetBoolean()); Assert.True(json.GetProperty("has_multiple_definition_files").GetBoolean()); - Assert.Equal("multiple_definition_files", json.GetProperty("zero_result_reason").GetString()); - Assert.Contains("deps --path --reverse", json.GetProperty("suggestion").GetString()); + Assert.Equal("class_symbol_no_symbol_callers", json.GetProperty("zero_result_reason").GetString()); + Assert.Equal("logical_partial_family", json.GetProperty("traversal_root_scope").GetString()); + Assert.StartsWith("partial:", json.GetProperty("traversal_partial_family_id").GetString()); + Assert.Equal(2, json.GetProperty("partial_family_member_count").GetInt32()); + Assert.Equal(2, json.GetProperty("partial_family_member_root_count").GetInt32()); + Assert.False(json.GetProperty("partial_family_member_root_truncated").GetBoolean()); + Assert.Contains("deps --path", json.GetProperty("suggestion").GetString()); + Assert.Contains("--reverse", json.GetProperty("suggestion").GetString()); Assert.Equal(2, json.GetProperty("definition_file_count").GetInt32()); } finally diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 55ef66ccf..6c9a28df5 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -9415,7 +9415,11 @@ public void Stop() { } Assert.Equal("none", json.GetProperty("impact_mode").GetString()); Assert.True(json.GetProperty("has_multiple_definitions").GetBoolean()); Assert.True(json.GetProperty("has_multiple_definition_files").GetBoolean()); - Assert.Equal("multiple_definition_files", json.GetProperty("zero_result_reason").GetString()); + Assert.Equal("class_symbol_no_symbol_callers", json.GetProperty("zero_result_reason").GetString()); + Assert.Equal("logical_partial_family", json.GetProperty("traversal_root_scope").GetString()); + Assert.Equal(2, json.GetProperty("partial_family_member_count").GetInt32()); + Assert.Equal(2, json.GetProperty("partial_family_member_root_count").GetInt32()); + Assert.False(json.GetProperty("partial_family_member_root_truncated").GetBoolean()); } finally { diff --git a/tests/CodeIndex.Tests/golden/impact.json b/tests/CodeIndex.Tests/golden/impact.json index e7928c197..eb0b82cd9 100644 --- a/tests/CodeIndex.Tests/golden/impact.json +++ b/tests/CodeIndex.Tests/golden/impact.json @@ -23,6 +23,7 @@ "symbols": "ExecuteFolderDiffAsync,FolderDiffService" } ], + "traversal_root_scope": "symbol", "definition_count": 1, "definition_file_count": 1, "has_multiple_definitions": false, From 50c0854e8c163d6150d7d2f56874e18d11f42c11 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 11 Aug 2026 00:00:00 +0900 Subject: [PATCH 2/3] Address adversarial review findings for #5060 --- DEVELOPER_GUIDE.md | 4 +- TESTING_GUIDE.md | 4 +- USER_GUIDE.md | 18 +++- changelog.d/unreleased/5060.fixed.md | 4 +- .../Cli/QueryCommandRunner.Dependencies.cs | 11 +++ .../Database/DbReader.GraphQueries.cs | 5 +- .../Mcp/McpToolHandlers.ImpactAnalysis.cs | 2 +- src/CodeIndex/Models/QueryResults.cs | 2 + tests/CodeIndex.Tests/DbReaderImpactTests.cs | 72 ++++++++++++++-- .../McpServerToolsCallTests.cs | 4 +- .../QueryCommandRunnerImpactTests.cs | 84 ++++++++++++++++++- .../QueryCommandRunnerTests.cs | 2 +- 12 files changed, 188 insertions(+), 24 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 79af6c5c6..07daf4115 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -2407,7 +2407,7 @@ the query token. Practical consequence: `impact ` on a class-like symbol returns the heuristic file-dependency-hint fallback (with metadata edges) when no member-level callers exist, whereas default `callers ` returns only executable edges. Both are correct under their own contracts; counts will not match. To reconcile, run `references --kind attribute` (or `annotation`), or pass an explicitly supported non-default kind to `callers` / `callees`, to surface edges that the default call graph intentionally drops. -For a single C# logical partial family, impact resolution precedes the physical-file ambiguity guard. `ResolveImpactDefinitions` retains the representative output plus a separately bounded set of every physical family symbol ID and path. `GetTransitiveCallers` queries that ID set as one SQL union root, so candidate rows, callers, cycle nodes, and converging paths are deduplicated before BFS accounting; file-hint fallback resolves member names across every retained family path and excludes all declaration files from dependents. Physical family expansion uses `DefaultImpactPartialFamilyMemberBudget`, reported through `partial_family_member_*`, and never sets the ordinary result `truncated` / `truncated_reason` fields. Only equal logical partial keys may enter this path; multiple logical keys, including unrelated same-name namespace or language symbols, retain the established ambiguity result. +For a single C# logical partial family on a current reference-identity contract, impact resolution precedes the physical-file ambiguity guard. `ResolveImpactDefinitions` retains the representative output plus a separately bounded set of every physical family symbol ID and path. `GetTransitiveCallers` queries that ID set as one SQL union root, so candidate rows, callers, cycle nodes, and converging paths are deduplicated before BFS accounting; file-hint fallback resolves member names across every retained family path and excludes all declaration files from dependents. Physical family expansion uses `DefaultImpactPartialFamilyMemberBudget`, reported through `partial_family_member_*`, and never sets the ordinary result `truncated` / `truncated_reason` fields. A stale reference-identity contract keeps symbol-root metadata because traversal remains on the legacy path. When the family expansion is capped, CLI count JSON marks the count degraded and non-authoritative, and MCP count-only returns `total: null`. Only equal logical partial keys may enter this path; multiple logical keys, including unrelated same-name namespace or language symbols, retain the established ambiguity result. `impact --json` and MCP `impact_analysis` expose zero-result diagnostics as structured routing fields. `zero_result_reason` remains the compact terminal reason; `impact_failure_chain` lists failed preconditions or traversal states in order, using values such as `definition_not_found`, `callable_filter_fails`, `multiple_definitions`, `multiple_definition_files`, `graph_unavailable`, `depth_requested_zero`, and `no_callers`. `suggestion_type` classifies the prose `suggestion` as `resolution`, `traversal`, or `precondition`. CLI `impact --strict` exits with `FeatureUnavailable` when the chain contains a resolution or precondition failure, but still treats a genuine `no_callers` traversal result as success. @@ -6107,7 +6107,7 @@ before/after 合計と理由別の affected / removed 件数を加える。汎 実運用上の帰結: クラスのようなシンボルに対する `impact ` は、member-level の caller が存在しない場合 heuristic file-dependency-hint fallback (metadata エッジを含む) を返し、一方の既定 `callers ` は実行可能 edge だけを返す。両方とも個々の契約上は正しいが、件数は一致しない。差分を埋めるには `references --kind attribute`(または `annotation`)を使うか、`callers` / `callees` に明示的に対応する非既定 kind を渡し、既定 call graph が意図的に落としている edge を確認する。 -単一の C# 論理 partial family では、impact resolution を物理ファイルの ambiguity guard より先に行います。`ResolveImpactDefinitions` は代表出力に加え、全物理 family の symbol ID / path を独立した上限付き集合として保持します。`GetTransitiveCallers` はその ID 集合を単一 SQL union root として問い合わせるため、candidate row、caller、cycle node、収束 path を BFS の計数前に重複排除します。file-hint fallback は保持した全 family path から member 名を解決し、全 declaration file を依存先候補から除外します。物理 family 展開には `DefaultImpactPartialFamilyMemberBudget` を使い、`partial_family_member_*` で報告し、通常の result 用 `truncated` / `truncated_reason` fields は立てません。この経路へ入るのは論理 partial key が同一の場合だけで、無関係な同名 namespace / language symbol を含む複数 logical key は従来どおり ambiguity result を維持します。 +reference-identity contract が current な単一の C# 論理 partial family では、impact resolution を物理ファイルの ambiguity guard より先に行います。`ResolveImpactDefinitions` は代表出力に加え、全物理 family の symbol ID / path を独立した上限付き集合として保持します。`GetTransitiveCallers` はその ID 集合を単一 SQL union root として問い合わせるため、candidate row、caller、cycle node、収束 path を BFS の計数前に重複排除します。file-hint fallback は保持した全 family path から member 名を解決し、全 declaration file を依存先候補から除外します。物理 family 展開には `DefaultImpactPartialFamilyMemberBudget` を使い、`partial_family_member_*` で報告し、通常の result 用 `truncated` / `truncated_reason` fields は立てません。reference-identity contract が stale の場合は legacy traversal のため symbol-root metadata を維持します。family 展開が上限に達した count は CLI JSON で degraded / non-authoritative とし、MCP count-only では `total: null` を返します。この経路へ入るのは論理 partial key が同一の場合だけで、無関係な同名 namespace / language symbol を含む複数 logical key は従来どおり ambiguity result を維持します。 `impact --json` と MCP `impact_analysis` は、0 件診断を structured routing field として返します。`zero_result_reason` は端末向けの短い理由のまま残し、`impact_failure_chain` は `definition_not_found`、`callable_filter_fails`、`multiple_definitions`、`multiple_definition_files`、`graph_unavailable`、`depth_requested_zero`、`no_callers` などの失敗前提や traversal 状態を順序付きで列挙します。`suggestion_type` は prose の `suggestion` を `resolution`、`traversal`、`precondition` に分類します。CLI `impact --strict` は chain に resolution / precondition failure が含まれる場合は `FeatureUnavailable` で終了しますが、真正な `no_callers` traversal 結果は成功として扱います。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index fca26f097..b1dece874 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -267,7 +267,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Deps JSON summary output and json-graph summary rejection share one SQL graph fixture. Dependency-cycle coverage must prove that the graph budget is independent of the display limit, SCC ranking remains stable when the page size grows, opaque cursors return the next ranked component, mismatched cursor filters fail closed, and graph-budget exhaustion marks totals as non-authoritative. Impact-cycle coverage must distinguish consecutive same-display-name symbols by canonical source/target IDs, verify their structured shortest-path identities, keep unresolved upstream callers and non-unique resolved overload groups out of only the canonical cycle graph, avoid guessed IDs for ambiguous path roots, aggregate mixed target identities without undercounting, and retain controls for direct singleton recursion and multi-node cycles. - Partial-family impact coverage must keep declarations, implementations, duplicate downstream callers, a member-to-member cycle, an unrelated same-name symbol, and a small instance-scoped family budget in one fixture family. Assert logical-root identity and physical evidence separately, and prove family-member truncation does not set ordinary traversal truncation. MCP fixtures that need production-equivalent partial grouping must pass `familyScopeKey` to `InsertIndexedFile` and stamp the C# hotspot-family readiness marker. + Partial-family impact coverage must keep declarations, implementations, duplicate downstream callers, a member-to-member cycle, an unrelated same-name symbol, and a small instance-scoped family budget in one fixture family. Assert logical-root identity and physical evidence separately, prove stale reference identity does not claim logical traversal, and prove family-member truncation leaves ordinary traversal truncation unset while making count output non-authoritative (`total: null` for MCP). Fixtures that need production-equivalent partial grouping must pass `familyScopeKey` where applicable and stamp both C# hotspot-family and reference-identity readiness. Exact-symbol dependency coverage reuses the C# source-candidate fixture to prove that a `--limit 1` query can select a symbol beyond the unfiltered candidate window. The repository-scale guard builds 10,001 candidate symbols, runs on non-Windows .NET 8, and keeps query-only work within 2 seconds and 32 MiB of current-thread allocation; fixture construction stays outside the measured interval. Windows retains the lightweight functional pushdown coverage but omits this scale fixture because its bounded full-suite session already runs close to the one-hour timeout. References stale-SQL-contract count and result envelopes share one downgraded graph fixture. Callers and callees stale-SQL-contract result envelopes share one downgraded graph fixture. @@ -1356,7 +1356,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" filterなしの`languages --json` catalog coverageはcommandを1回だけ実行し、canonical language辞書を1つ構築して、extension、alias、extraction、graph、gap、guidance、exact-filenameの各contractをまとめて検証してください。言語coverageの拡張でcatalog discoveryとserializationを繰り返さないようにします。 option風literalのnamed-query escapingは、definition、graph、symbols、files、inspect、impact command全体で1つのindexed Probe fixtureを再利用してください。 impact cycle の回帰 coverage では、同じ表示名が連続する別 symbol を正規 source/target ID で区別し、構造化 shortest-path identity を検証し、未解決の上流 caller と一意でない resolved overload group を正規 cycle graph からだけ除外し、曖昧な path root に推測 ID を付けず、複数 target identity を過少計上せず集約するとともに、直接 singleton 再帰と複数 node cycle の control を維持してください。 - partial-family impact coverage は declaration、implementation、重複する downstream caller、member 間 cycle、無関係な同名 symbol、小さい instance-scoped family budget を 1 つの fixture family にまとめてください。論理 root identity と物理 evidence を別々に assert し、family-member truncation が通常の traversal truncation を立てないことを確認します。本番同等の partial grouping が必要な MCP fixture は `InsertIndexedFile` に `familyScopeKey` を渡し、C# hotspot-family readiness marker を stamp してください。 + partial-family impact coverage は declaration、implementation、重複する downstream caller、member 間 cycle、無関係な同名 symbol、小さい instance-scoped family budget を 1 つの fixture family にまとめてください。論理 root identity と物理 evidence を別々に assert し、stale な reference identity が logical traversal を名乗らないこと、family-member truncation が通常の traversal truncation を立てず count output を non-authoritative(MCP は `total: null`)にすることを確認します。本番同等の partial grouping が必要な fixture は必要に応じて `InsertIndexedFile` に `familyScopeKey` を渡し、C# hotspot-family と reference-identity の readiness を両方 stamp してください。 複数 named-query の output coverage は、compact projection、rich JSON 互換性、query ごとの limit / truncation、UTF-8 byte cap に1つの indexed fixture を再利用し、serializer mode を直接比較できるようにしてください。 共通 bounded-response coverage は、definition、find、status、hotspots、references、callers / callees、impact、map 全体で1つの graph-ready databaseを再利用してください。cursor と UTF-8 byte-budget の境界 case は別の最小 multi-row fixture にまとめ、family parity のために indexing setup を重複させないでください。outline は、長い signature と Unicode を含む深い階層の focused fixture 1つを使い、最後の改行を含む正確な byte 境界、欠落や重複のない cursor 全 page 走査、最小 budget の diagnostic、上限なし出力の非変更を確認してください。regression coverage では alias と read-only batch dispatch、明示的な definition body projection、inactive な impact collection、authoritative な総件数を持つ map section の row 単位 pagination も確認してください。 adversarial な bounded-response coverage では、parser failure の byte cap、impact definition page の offset、既存 map compact section、map shape control の競合、compact と明示 body の組み合わせ、profile / verbose control record の抽出も固定してください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 527e705e4..1ffb985c6 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -823,7 +823,7 @@ 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. -When every matching C# declaration belongs to one logical partial family, `impact` +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 reached through more than one member are deduplicated; the representative @@ -835,7 +835,12 @@ JSON and MCP responses identify this mode with `traversal_root_scope: "logical_partial_family"` and `traversal_partial_family_id`. The `partial_family_member_*` fields report the eligible physical root count, limit, omitted count, and truncation independently -from the normal result/traversal `truncated` fields. +from the normal result/traversal `truncated` fields. A stale identity contract keeps +`traversal_root_scope: "symbol"` and omits the family-root metadata because the +legacy traversal cannot guarantee the physical-ID union. When the separate family +root limit is reached, CLI count JSON also reports `degraded: true` and +`authoritative_count: false`, while MCP count-only output reports `total: null`; +ordinary traversal `truncated` remains unchanged. On a current index, cycle detection follows the resolved source/target symbol IDs on real directed edges. Two distinct methods with the same display name are not a @@ -4328,7 +4333,7 @@ member / value read を `member_read` として保存し、callers / callees / i `call` として保存しているため、引き続き読み取り可能で、再 index するまでは従来の inclusive な挙動を維持します。 -一致する C# declaration がすべて 1 つの論理 partial family に属する場合、`impact` は +reference-identity contract が current で、一致する C# declaration がすべて 1 つの論理 partial family に属する場合、`impact` は 安定した `partial_family_id` を 1 つの traversal root として使い、全物理 member identity の和集合を辿ります。複数 member から到達する caller、file hint、shortest path は重複排除し、 代表 definition の `family_members` には物理 evidence を残します。無関係な同名 type / member @@ -4337,7 +4342,12 @@ inclusive な挙動を維持します。 `--with-paths` では論理 root の `path_details` node に代表 `definition_path` と `partial_family_id` も含めます。 `partial_family_member_*` fields は物理 root 候補の件数、上限、省略件数、truncation -を通常の result / traversal 用 `truncated` fields とは独立して報告します。 +を通常の result / traversal 用 `truncated` fields とは独立して報告します。identity contract +が stale の場合、legacy traversal は物理 ID の和集合を保証できないため +`traversal_root_scope: "symbol"` のままとし、family-root metadata を省略します。独立した +family root 上限に達した count JSON は CLI で `degraded: true` と +`authoritative_count: false`、MCP count-only で `total: null` を返しますが、通常の traversal +用 `truncated` は変更しません。 current index では、cycle 判定は実在する有向辺の解決済み source/target symbol ID を 辿ります。表示名が同じ別 method は cycle にせず、直接再帰は singleton cycle として diff --git a/changelog.d/unreleased/5060.fixed.md b/changelog.d/unreleased/5060.fixed.md index 142677e6b..6c22a85c5 100644 --- a/changelog.d/unreleased/5060.fixed.md +++ b/changelog.d/unreleased/5060.fixed.md @@ -20,8 +20,8 @@ affected: ## English -- **Impact analysis now traverses one logical partial family as a single root (#5060; follow-up to #4309, #4566, and #3932)** — C# partial declarations and implementations contribute their union of physical graph identities without triggering `multiple_definition_files`; callers, file hints, cycles, and converging paths are deduplicated while definitions retain physical-member evidence. Unrelated same-name symbols remain ambiguous, and machine-readable output reports the separately bounded family-member expansion without conflating it with ordinary traversal truncation. +- **Impact analysis now traverses one logical partial family as a single root (#5060; follow-up to #4309, #4566, and #3932)** — On current reference identity, C# partial declarations and implementations contribute their union of physical graph identities without triggering `multiple_definition_files`, including genuine caller-free partial methods; callers, file hints, cycles, and converging paths are deduplicated while definitions retain physical-member evidence. Stale identity and unrelated same-name symbols remain on safe symbol/ambiguity paths. Machine-readable output reports the separately bounded family-member expansion without conflating it with ordinary traversal truncation, and capped count output is explicitly non-authoritative. ## 日本語 -- **impact analysis が 1 つの論理 partial family を単一 root として traversal するよう修正しました (#5060、#4309、#4566、#3932 の follow-up)** — C# の partial declaration / implementation は物理 graph identity の和集合を提供し、`multiple_definition_files` で停止しません。definition には物理 member evidence を残しつつ、caller、file hint、cycle、収束 path を重複排除します。無関係な同名 symbol は曖昧なまま維持し、machine-readable output は上限付き family-member 展開を通常の traversal truncation と混同せず個別に報告します。 +- **impact analysis が 1 つの論理 partial family を単一 root として traversal するよう修正しました (#5060、#4309、#4566、#3932 の follow-up)** — current な reference identity では、caller が存在しない真正な partial method を含め、C# の partial declaration / implementation が物理 graph identity の和集合を提供し、`multiple_definition_files` で停止しません。definition に物理 member evidence を残しつつ、caller、file hint、cycle、収束 path を重複排除します。stale identity と無関係な同名 symbol は安全な symbol / ambiguity 経路を維持します。machine-readable output は上限付き family-member 展開を通常の traversal truncation と混同せず個別に報告し、上限に達した count output は明示的に non-authoritative とします。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs index 798df3050..5338e1ddb 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs @@ -194,6 +194,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) AddGraphContractJsonFields(payload, reader, jsonOptions, sqlGraphSignal, hdlGraphSignal); AddImpactOptionWarnings(payload, options); AddCountEnvelopeJsonFields(payload, reader, jsonOptions, options); + ApplyImpactCountAuthority(payload, analysis); var writeExitCode = WriteJsonPayloadWithOptionalByteLimit( payload, options, @@ -295,6 +296,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) AddGraphContractJsonFields(payload, reader, jsonOptions, sqlGraphSignal, hdlGraphSignal); AddImpactOptionWarnings(payload, options); AddCountEnvelopeJsonFields(payload, reader, jsonOptions, options); + ApplyImpactCountAuthority(payload, analysis); AddActiveSqliteDiagnostics(payload); Console.WriteLine(payload.ToJsonString(jsonOptions)); } @@ -442,6 +444,15 @@ private static void AddImpactTraversalRootJsonFields(JsonObject payload, ImpactA payload["partial_family_member_root_omitted"] = analysis.PartialFamilyMemberRootOmitted; } + internal static void ApplyImpactCountAuthority(JsonObject payload, ImpactAnalysisResult analysis) + { + if (analysis.CountIsAuthoritative) + return; + + payload["degraded"] = true; + payload["authoritative_count"] = false; + } + private static List BuildImpactDefinitionJsonResults(IReadOnlyList definitions) => LogicalPartialSymbolGrouper.Group(definitions); diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index cbf73d916..66a95e1fe 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -2442,7 +2442,8 @@ public ImpactAnalysisResult AnalyzeImpact(string symbolName, int maxDepth = 5, i var hasMultipleFallbackDefinitions = definitionResolution.PreciseLogicalDefinitionCount > 1; var hasMultipleFallbackDefinitionFiles = definitionResolution.PreciseDefinitionFileCount > 1; var hasClassLikeDefinitions = definitionResolution.PreciseDefinitionCount > 0; - var logicalPartialFamilyDefinition = definitionResolution.LogicalCount == 1 + var logicalPartialFamilyDefinition = _referenceIdentityContractCurrent + && definitionResolution.LogicalCount == 1 && definitions.Count == 1 && definitions[0].Lang == "csharp" && definitions[0].PartialFamilyId != null @@ -2623,7 +2624,7 @@ public ImpactAnalysisResult AnalyzeImpact(string symbolName, int maxDepth = 5, i suggestion = BuildImpactSuggestion(definitionPaths, hasClassLikeDefinitions, hasMultipleDefinitions: false, hasMultipleDefinitionFiles: false, lang); } } - else if (hasMultipleDefinitions) + else if (hasMultipleDefinitions && logicalPartialFamilyDefinition == null) { zeroResultReason = definitionResolution.PhysicalFileCount > 1 ? "multiple_definition_files" : "multiple_definitions"; impactFailureChain.Add(zeroResultReason); diff --git a/src/CodeIndex/Mcp/McpToolHandlers.ImpactAnalysis.cs b/src/CodeIndex/Mcp/McpToolHandlers.ImpactAnalysis.cs index 824100e27..f9cb18ce6 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.ImpactAnalysis.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.ImpactAnalysis.cs @@ -81,7 +81,7 @@ private JsonNode ExecuteImpactAnalysis(JsonNode? id, JsonNode? args) ["max_hops"] = maxDepth, ["actual_depth"] = maxActualDepth, ["truncated"] = analysis.Truncated, - ["total"] = analysis.Truncated ? null : JsonValue.Create(count), + ["total"] = analysis.CountIsAuthoritative ? JsonValue.Create(count) : null, ["termination_reason"] = analysis.TerminationReason, ["impact_mode"] = analysis.ImpactMode, ["heuristic"] = analysis.Heuristic, diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index 31af7cae9..6fca1eebe 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -1082,6 +1082,8 @@ public class ImpactAnalysisResult public bool? PartialFamilyMemberRootTruncated { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? PartialFamilyMemberRootOmitted { get; set; } + [JsonIgnore] + internal bool CountIsAuthoritative => !Truncated && PartialFamilyMemberRootTruncated != true; public List Definitions { get; set; } = []; public List Callers { get; set; } = []; public List FileImpacts { get; set; } = []; diff --git a/tests/CodeIndex.Tests/DbReaderImpactTests.cs b/tests/CodeIndex.Tests/DbReaderImpactTests.cs index d57b46bed..dd7db215d 100644 --- a/tests/CodeIndex.Tests/DbReaderImpactTests.cs +++ b/tests/CodeIndex.Tests/DbReaderImpactTests.cs @@ -801,7 +801,8 @@ public void Stop() { } } """); - var analysis = _reader.AnalyzeImpact("Worker", maxDepth: 3, limit: 10); + var reader = CreateReferenceIdentityReadyImpactReader(); + var analysis = reader.AnalyzeImpact("Worker", maxDepth: 3, limit: 10); Assert.Equal("none", analysis.ImpactMode); Assert.Empty(analysis.Callers); @@ -820,6 +821,23 @@ public void Stop() { } Assert.Contains("--reverse", analysis.Suggestion); } + [Fact] + public void AnalyzeImpact_StaleReferenceIdentityDoesNotClaimLogicalPartialTraversal() + { + InsertIndexedFile("src/Stale.Part1.cs", "csharp", "public partial class Stale { }"); + InsertIndexedFile("src/Stale.Part2.cs", "csharp", "public partial class Stale { }"); + _writer.ClearReferenceIdentityContractReady(); + var reader = new DbReader(_db.Connection); + + var analysis = reader.AnalyzeImpact("Stale", maxDepth: 2, limit: 10); + + Assert.Equal("symbol", analysis.TraversalRootScope); + Assert.Null(analysis.TraversalPartialFamilyId); + Assert.Null(analysis.PartialFamilyMemberCount); + Assert.Null(analysis.PartialFamilyMemberRootCount); + Assert.Null(analysis.PartialFamilyMemberRootTruncated); + } + [Fact] public void AnalyzeImpact_PartialClassFileHintsUnionAllMemberFilesAndDeduplicateConsumers() { @@ -868,7 +886,8 @@ public void Run(Worker worker) } """); - var analysis = _reader.AnalyzeImpact("Demo.Worker", maxDepth: 3, limit: 10); + var reader = CreateReferenceIdentityReadyImpactReader(); + var analysis = reader.AnalyzeImpact("Demo.Worker", maxDepth: 3, limit: 10); Assert.Equal("file_dependency_hints", analysis.ImpactMode); Assert.True(analysis.Heuristic); @@ -919,8 +938,9 @@ public void Sync() { } } """); - var bounded = _reader.AnalyzeImpact("Demo.Worker.Sync", maxDepth: 1, limit: 10, withPaths: true); - var expanded = _reader.AnalyzeImpact("Demo.Worker.Sync", maxDepth: 2, limit: 10, withPaths: true); + var reader = CreateReferenceIdentityReadyImpactReader(); + var bounded = reader.AnalyzeImpact("Demo.Worker.Sync", maxDepth: 1, limit: 10, withPaths: true); + var expanded = reader.AnalyzeImpact("Demo.Worker.Sync", maxDepth: 2, limit: 10, withPaths: true); Assert.Equal(2, bounded.DefinitionCount); Assert.Equal(1, bounded.LogicalDefinitionCount); @@ -947,6 +967,38 @@ public void Sync() { } Assert.Contains(expanded.Cycles!, cycle => cycle.Members.Any(member => member.EndsWith(".Sync", StringComparison.Ordinal)) && cycle.Members.Contains("Finish")); } + [Fact] + public void AnalyzeImpact_CallerFreePartialMethodIsOneLogicalNoCallersResult() + { + InsertIndexedFile("src/Worker.Sync.Declaration.cs", "csharp", + """ + namespace Demo; + public partial class Worker + { + partial void Sync(); + } + """); + InsertIndexedFile("src/Worker.Sync.Implementation.cs", "csharp", + """ + namespace Demo; + public partial class Worker + { + partial void Sync() { } + } + """); + + var reader = CreateReferenceIdentityReadyImpactReader(); + var analysis = reader.AnalyzeImpact("Demo.Worker.Sync", maxDepth: 2, limit: 10); + + Assert.Equal(2, analysis.DefinitionCount); + Assert.Equal(1, analysis.LogicalDefinitionCount); + Assert.Equal("logical_partial_family", analysis.TraversalRootScope); + Assert.Equal("none", analysis.ImpactMode); + Assert.Empty(analysis.Callers); + Assert.Null(analysis.ZeroResultReason); + Assert.Equal(["no_callers"], analysis.ImpactFailureChain); + } + [Fact] public void AnalyzeImpact_UnrelatedSameNameTypeRemainsAmbiguousBesidePartialFamily() { @@ -983,9 +1035,10 @@ public void AnalyzeImpact_PartialFamilyBudgetIsIndependentFromTraversalTruncatio InsertIndexedFile("src/Budgeted.One.cs", "csharp", "public partial class Budgeted { }"); InsertIndexedFile("src/Budgeted.Two.cs", "csharp", "public partial class Budgeted { }"); InsertIndexedFile("src/Budgeted.Three.cs", "csharp", "public partial class Budgeted { }"); - _reader.ImpactPartialFamilyMemberBudget = 2; + var reader = CreateReferenceIdentityReadyImpactReader(); + reader.ImpactPartialFamilyMemberBudget = 2; - var analysis = _reader.AnalyzeImpact("Budgeted", maxDepth: 2, limit: 10); + var analysis = reader.AnalyzeImpact("Budgeted", maxDepth: 2, limit: 10); Assert.Equal("logical_partial_family", analysis.TraversalRootScope); Assert.Equal(3, analysis.PartialFamilyMemberCount); @@ -995,6 +1048,7 @@ public void AnalyzeImpact_PartialFamilyBudgetIsIndependentFromTraversalTruncatio Assert.Equal(1, analysis.PartialFamilyMemberRootOmitted); Assert.False(analysis.Truncated); Assert.Null(analysis.TruncatedReason); + Assert.False(analysis.CountIsAuthoritative); } [Fact] @@ -1533,4 +1587,10 @@ public void Stop() { } Assert.True(analysis.HasMultipleDefinitions); Assert.False(analysis.HasMultipleDefinitionFiles); } + + private DbReader CreateReferenceIdentityReadyImpactReader() + { + _writer.RefreshMutualRecursionFlags(stampReferenceIdentityContractReady: true); + return new DbReader(_db.Connection); + } } diff --git a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs index 228656a46..0a563a65e 100644 --- a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs @@ -2592,7 +2592,9 @@ public void Run(Worker worker) } } """, familyScopeKey: "src"); - new DbWriter(_db.Connection).MarkHotspotFamilyReady("csharp", "issue-5060-fixture"); + var writer = new DbWriter(_db.Connection); + writer.MarkHotspotFamilyReady("csharp", "issue-5060-fixture"); + writer.MarkReferenceIdentityContractReady(); var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"impact_analysis","arguments":{"query":"Demo.Worker"}}}""")!; var response = _server.HandleMessage(request)!; diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerImpactTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerImpactTests.cs index 3e3838ffb..c468b4d97 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerImpactTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerImpactTests.cs @@ -53,7 +53,7 @@ public void Run(Worker worker) } } """); - MarkGraphAndFoldReady(dbPath); + MarkImpactLogicalFamilyReady(dbPath); var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunImpact( ["Demo.Worker", "--db", dbPath, "--json", "--lang", "csharp", "--max-hops", "3", "--limit", "10"], @@ -81,6 +81,77 @@ public void Run(Worker worker) } } + [Fact] + public void RunImpact_CallerFreePartialMethodStrictTreatsFamilyAsOneNoCallersResult_Issue5060() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_impact_partial_method_no_callers_issue5060"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Worker.Sync.Declaration.cs", + "csharp", + """ + namespace Demo; + public partial class Worker + { + partial void Sync(); + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Worker.Sync.Implementation.cs", + "csharp", + """ + namespace Demo; + public partial class Worker + { + partial void Sync() { } + } + """); + MarkImpactLogicalFamilyReady(dbPath); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunImpact( + ["Demo.Worker.Sync", "--db", dbPath, "--json", "--lang", "csharp", "--strict"], + _jsonOptions)); + using var document = ParseJsonOutput(stdout); + var root = document.RootElement; + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal("logical_partial_family", root.GetProperty("traversal_root_scope").GetString()); + Assert.False(root.TryGetProperty("zero_result_reason", out _)); + Assert.Equal("no_callers", Assert.Single(root.GetProperty("impact_failure_chain").EnumerateArray()).GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void ApplyImpactCountAuthority_CappedPartialFamilyIsDegradedAndNonAuthoritative_Issue5060() + { + var payload = new System.Text.Json.Nodes.JsonObject + { + ["degraded"] = false, + ["authoritative_count"] = true, + }; + var analysis = new ImpactAnalysisResult + { + Truncated = false, + PartialFamilyMemberRootTruncated = true, + }; + + QueryCommandRunner.ApplyImpactCountAuthority(payload, analysis); + + Assert.True(payload["degraded"]!.GetValue()); + Assert.False(payload["authoritative_count"]!.GetValue()); + Assert.False(analysis.Truncated); + Assert.False(analysis.CountIsAuthoritative); + } + [Fact] public void RunImpact_MissingDepthValueShowsPerFlagHint_Issue1507() { @@ -350,7 +421,7 @@ public partial class QueryCommandRunner { } """); - MarkGraphAndFoldReady(dbPath); + MarkImpactLogicalFamilyReady(dbPath); var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunImpact( ["QueryCommandRunner", "--db", dbPath, "--json", "--max-hops", "0"], @@ -801,7 +872,7 @@ public partial class Worker public void Stop() { } } """); - MarkGraphAndFoldReady(dbPath); + MarkImpactLogicalFamilyReady(dbPath); var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunImpact( ["Worker", "--db", dbPath, "--json"], @@ -1671,4 +1742,11 @@ public class @class TestProjectHelper.DeleteDirectory(projectRoot); } } + + private static void MarkImpactLogicalFamilyReady(string dbPath) + { + MarkGraphAndFoldReady(dbPath); + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + new DbWriter(db.Connection).MarkReferenceIdentityContractReady(); + } } diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 6c9a28df5..5398c173e 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -9400,7 +9400,7 @@ public partial class Worker public void Stop() { } } """); - MarkGraphAndFoldReady(dbPath); + MarkImpactLogicalFamilyReady(dbPath); var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunImpact( ["Worker", "--db", dbPath, "--json"], From 703a1d37bee8bf39462cd2d461d4ae21bd030ec3 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 11 Aug 2026 00:23:59 +0900 Subject: [PATCH 3/3] Preserve stale partial-family ambiguity (#5060) --- DEVELOPER_GUIDE.md | 2 +- changelog.d/unreleased/5060.fixed.md | 4 ++-- src/CodeIndex/Database/DbReader.GraphQueries.cs | 10 +++++++++- tests/CodeIndex.Tests/DbReaderImpactTests.cs | 12 ++++++++++-- 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 07daf4115..19e61b142 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -2407,7 +2407,7 @@ the query token. Practical consequence: `impact ` on a class-like symbol returns the heuristic file-dependency-hint fallback (with metadata edges) when no member-level callers exist, whereas default `callers ` returns only executable edges. Both are correct under their own contracts; counts will not match. To reconcile, run `references --kind attribute` (or `annotation`), or pass an explicitly supported non-default kind to `callers` / `callees`, to surface edges that the default call graph intentionally drops. -For a single C# logical partial family on a current reference-identity contract, impact resolution precedes the physical-file ambiguity guard. `ResolveImpactDefinitions` retains the representative output plus a separately bounded set of every physical family symbol ID and path. `GetTransitiveCallers` queries that ID set as one SQL union root, so candidate rows, callers, cycle nodes, and converging paths are deduplicated before BFS accounting; file-hint fallback resolves member names across every retained family path and excludes all declaration files from dependents. Physical family expansion uses `DefaultImpactPartialFamilyMemberBudget`, reported through `partial_family_member_*`, and never sets the ordinary result `truncated` / `truncated_reason` fields. A stale reference-identity contract keeps symbol-root metadata because traversal remains on the legacy path. When the family expansion is capped, CLI count JSON marks the count degraded and non-authoritative, and MCP count-only returns `total: null`. Only equal logical partial keys may enter this path; multiple logical keys, including unrelated same-name namespace or language symbols, retain the established ambiguity result. +For a single C# logical partial family on a current reference-identity contract, impact resolution precedes the physical-file ambiguity guard. `ResolveImpactDefinitions` retains the representative output plus a separately bounded set of every physical family symbol ID and path. `GetTransitiveCallers` queries that ID set as one SQL union root, so candidate rows, callers, cycle nodes, and converging paths are deduplicated before BFS accounting; file-hint fallback resolves member names across every retained family path and excludes all declaration files from dependents. Physical family expansion uses `DefaultImpactPartialFamilyMemberBudget`, reported through `partial_family_member_*`, and never sets the ordinary result `truncated` / `truncated_reason` fields. A stale reference-identity contract keeps symbol-root scope and preserves physical multi-file ambiguity because legacy traversal cannot guarantee the family ID/path union. When the family expansion is capped, CLI count JSON marks the count degraded and non-authoritative, and MCP count-only returns `total: null`. Only equal logical partial keys may enter this path; multiple logical keys, including unrelated same-name namespace or language symbols, retain the established ambiguity result. `impact --json` and MCP `impact_analysis` expose zero-result diagnostics as structured routing fields. `zero_result_reason` remains the compact terminal reason; `impact_failure_chain` lists failed preconditions or traversal states in order, using values such as `definition_not_found`, `callable_filter_fails`, `multiple_definitions`, `multiple_definition_files`, `graph_unavailable`, `depth_requested_zero`, and `no_callers`. `suggestion_type` classifies the prose `suggestion` as `resolution`, `traversal`, or `precondition`. CLI `impact --strict` exits with `FeatureUnavailable` when the chain contains a resolution or precondition failure, but still treats a genuine `no_callers` traversal result as success. diff --git a/changelog.d/unreleased/5060.fixed.md b/changelog.d/unreleased/5060.fixed.md index 6c22a85c5..ba50a7506 100644 --- a/changelog.d/unreleased/5060.fixed.md +++ b/changelog.d/unreleased/5060.fixed.md @@ -20,8 +20,8 @@ affected: ## English -- **Impact analysis now traverses one logical partial family as a single root (#5060; follow-up to #4309, #4566, and #3932)** — On current reference identity, C# partial declarations and implementations contribute their union of physical graph identities without triggering `multiple_definition_files`, including genuine caller-free partial methods; callers, file hints, cycles, and converging paths are deduplicated while definitions retain physical-member evidence. Stale identity and unrelated same-name symbols remain on safe symbol/ambiguity paths. Machine-readable output reports the separately bounded family-member expansion without conflating it with ordinary traversal truncation, and capped count output is explicitly non-authoritative. +- **Impact analysis now traverses one logical partial family as a single root (#5060; follow-up to #4309, #4566, and #3932)** — On current reference identity, C# partial declarations and implementations contribute their union of physical graph identities without triggering `multiple_definition_files`, including genuine caller-free partial methods; callers, file hints, cycles, and converging paths are deduplicated while definitions retain physical-member evidence. Stale identity preserves physical multi-file ambiguity, and unrelated same-name symbols remain on safe ambiguity paths. Machine-readable output reports the separately bounded family-member expansion without conflating it with ordinary traversal truncation, and capped count output is explicitly non-authoritative. ## 日本語 -- **impact analysis が 1 つの論理 partial family を単一 root として traversal するよう修正しました (#5060、#4309、#4566、#3932 の follow-up)** — current な reference identity では、caller が存在しない真正な partial method を含め、C# の partial declaration / implementation が物理 graph identity の和集合を提供し、`multiple_definition_files` で停止しません。definition に物理 member evidence を残しつつ、caller、file hint、cycle、収束 path を重複排除します。stale identity と無関係な同名 symbol は安全な symbol / ambiguity 経路を維持します。machine-readable output は上限付き family-member 展開を通常の traversal truncation と混同せず個別に報告し、上限に達した count output は明示的に non-authoritative とします。 +- **impact analysis が 1 つの論理 partial family を単一 root として traversal するよう修正しました (#5060、#4309、#4566、#3932 の follow-up)** — current な reference identity では、caller が存在しない真正な partial method を含め、C# の partial declaration / implementation が物理 graph identity の和集合を提供し、`multiple_definition_files` で停止しません。definition に物理 member evidence を残しつつ、caller、file hint、cycle、収束 path を重複排除します。stale identity は物理的な multi-file ambiguity を維持し、無関係な同名 symbol も安全な ambiguity 経路を保ちます。machine-readable output は上限付き family-member 展開を通常の traversal truncation と混同せず個別に報告し、上限に達した count output は明示的に non-authoritative とします。 diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index 66a95e1fe..0023cda06 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -2439,7 +2439,15 @@ public ImpactAnalysisResult AnalyzeImpact(string symbolName, int maxDepth = 5, i .Select(d => d.Path) .Distinct(indexedPathComparer) .ToList(); - var hasMultipleFallbackDefinitions = definitionResolution.PreciseLogicalDefinitionCount > 1; + // Logical partial-family collapse is only safe when reference identity is current. + // A stale graph cannot union every physical family path, so retain the physical + // ambiguity guard instead of producing hints from only the representative file. + // 論理 partial-family の集約は reference identity が current の場合だけ安全。 + // stale graph では全物理 family path を統合できないため、代表 file だけの hint を + // 返さず、物理 definition の ambiguity guard を維持する。 + var hasMultipleFallbackDefinitions = _referenceIdentityContractCurrent + ? definitionResolution.PreciseLogicalDefinitionCount > 1 + : definitionResolution.PreciseDefinitionCount > 1; var hasMultipleFallbackDefinitionFiles = definitionResolution.PreciseDefinitionFileCount > 1; var hasClassLikeDefinitions = definitionResolution.PreciseDefinitionCount > 0; var logicalPartialFamilyDefinition = _referenceIdentityContractCurrent diff --git a/tests/CodeIndex.Tests/DbReaderImpactTests.cs b/tests/CodeIndex.Tests/DbReaderImpactTests.cs index dd7db215d..018cfad7b 100644 --- a/tests/CodeIndex.Tests/DbReaderImpactTests.cs +++ b/tests/CodeIndex.Tests/DbReaderImpactTests.cs @@ -824,8 +824,12 @@ public void Stop() { } [Fact] public void AnalyzeImpact_StaleReferenceIdentityDoesNotClaimLogicalPartialTraversal() { - InsertIndexedFile("src/Stale.Part1.cs", "csharp", "public partial class Stale { }"); - InsertIndexedFile("src/Stale.Part2.cs", "csharp", "public partial class Stale { }"); + InsertIndexedFile("src/Stale.Part1.cs", "csharp", "public partial class Stale { public void Start() { } }"); + InsertIndexedFile("src/Stale.Part2.cs", "csharp", "public partial class Stale { public void Stop() { } }"); + InsertIndexedFile( + "src/StaleConsumer.cs", + "csharp", + "public class StaleConsumer { public void Run(Stale value) { value.Start(); value.Stop(); } }"); _writer.ClearReferenceIdentityContractReady(); var reader = new DbReader(_db.Connection); @@ -836,6 +840,10 @@ public void AnalyzeImpact_StaleReferenceIdentityDoesNotClaimLogicalPartialTraver Assert.Null(analysis.PartialFamilyMemberCount); Assert.Null(analysis.PartialFamilyMemberRootCount); Assert.Null(analysis.PartialFamilyMemberRootTruncated); + Assert.Equal("none", analysis.ImpactMode); + Assert.Empty(analysis.FileImpacts); + Assert.Equal("multiple_definition_files", analysis.ZeroResultReason); + Assert.Equal(["multiple_definition_files"], analysis.ImpactFailureChain); } [Fact]