diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 34a09d6a0..fdc0e07b8 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1623,6 +1623,17 @@ unqualified name receive a global candidate only when that name is unique in the symbol set. Otherwise they remain `ambiguous` or `unresolved`, and dependency queries do not fall back to a same-name edge. +C# type-reference resolution uses `LogicalPartialSymbolGrouper` for declarations that have a +valid logical partial-family identity. Full and scoped refreshes persist the same stable +`family:` target key used by grouped symbol discovery, so multiple physical declarations in one +language/kind/namespace-and-container/generic-arity family produce `resolved_group` rather than +semantic ambiguity. `resolution_candidate_count` deliberately remains the physical declaration +count, and `symbol_reference_candidates` retains every physical symbol row. Search ranking, +grouped hotspots, inspect, dependencies, and impact consume the logical identity while APIs that +list definitions remain deterministic and physical. The reference-identity contract version +must be advanced when this key changes; version 9 forces full, scoped, no-op, and deletion-only +index paths to replace older physical-path target keys before identity-aware reads become ready. + C# common member names are never discarded during extraction. The writer persists their receiver/type evidence in `target_qualifier`, and reference finalization records `resolution_state`. Bare-name `references` and unqualified graph discovery remain broad and @@ -5579,6 +5590,17 @@ resolution を再構築し、同じ transaction で marker を設定します。 対象となる symbol 集合で名前が一意の場合だけ global candidate を持ちます。それ以外は `ambiguous` または `unresolved` のままとし、dependency query は同名 edge へ fallback しません。 +C# の type-reference resolution は、有効な論理 partial-family identity を持つ declaration に +`LogicalPartialSymbolGrouper` を使用します。full / scoped refresh は grouped symbol discovery と同じ +安定した `family:` target key を永続化するため、language、kind、namespace / container、generic arity が +同じ 1 family 内の複数物理 declaration は semantic ambiguity ではなく `resolved_group` になります。 +`resolution_candidate_count` は意図的に物理 declaration 数のままとし、 +`symbol_reference_candidates` はすべての物理 symbol row を保持します。search ranking、grouped hotspot、 +inspect、dependency、impact は論理 identity を使用し、definition 一覧 API は決定的な物理定義を維持します。 +この key を変更するときは reference-identity contract version を進める必要があります。version 9 は +identity-aware read を ready にする前に、full、scoped、no-op、削除のみの index path で旧来の +物理 path target key を置き換えます。 + C# の一般的な member 名は extraction 時に破棄しません。writer は receiver / 型の evidence を `target_qualifier` に永続化し、reference finalization は `resolution_state` を記録します。bare-name の `references` と無修飾 graph discovery は広い検索結果と保存済み resolution label を維持します。一方、 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 5bc3248ca..c1cb0565a 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -48,6 +48,7 @@ Use the full suite by default. Use targeted filters only while iterating locally - Reference-identity refresh coverage treats a stable graph rebuild as a physical-write performance contract. Keep NULL-safe changed-row predicates for source identity, the four-column target-resolution tuple, self-reference, and mutual-recursion updates; trigger audits must remain at zero on a stable rerun, repair each corrupted phase once, and prove a later-phase failure rolls back earlier identity writes. SQLite `changes()` must continue to report the final mutual-recursion phase. - Fresh-reference resolution coverage belongs in `FreshReferenceResolutionTests`. Keep the empty/rebuild/symbols-only policy truth table, the unchanged 14-parameter insert shape with distinct fresh/ordinary SQL cache entries, canonical provisional values, the materialized candidate-side aggregation shape, and exact fresh-versus-full semantic parity for `unresolved`, `resolved`, `resolved_group`, `ambiguous`, and self-reference rows across C# and a non-C# language. The fresh insert must preserve literal input order, choose same-file nested source symbols with the ordinary containment tie-break across C# and Python, leave out-of-range sources NULL, and omit the final source UPDATE; ordinary full and differential repair paths remain covered separately. Failure tests must prove that fresh defaults remain pending after graph rollback and clear only after a successful commit. Pair these database tests with the full-scan bulk-load theory that observes `unresolved` provisional rows on a fresh CLI scan and NULL resolution state on rebuild. MCP remains outside this opt-in because its per-file durable transactions require the existing graph-failure retry contract. A full-scan barrier regression must commit a candidate-free row from a second connection after the early empty observation but before the outer write transaction, then prove transaction-local revalidation switches to ordinary full resolution and normalizes that row. +- Logical C# partial-family reference coverage is split between `FreshReferenceResolutionTests` and `IndexCommandRunnerUpdateTests`. Keep direct database fixtures for partial class, struct, record, and generic families, plus namespace and arity separation, and assert that one logical family persists `resolved_group` with a stable family target while `resolution_candidate_count` and candidate rows retain every physical declaration. The CLI lifecycle fixture must cover member references across family files, add/remove/rename/move updates, prior-contract restamping, grouped reference ranking, hotspots, inspect, dependencies, impact, and deterministic ungrouped definitions. Full and scoped refreshes must remain semantically identical, and unrelated same-leaf families must stay ambiguous. - C# reference-graph fact coverage is an SQL-shape performance contract. Each of the six managed arity/receiver/value-type functions must occur once only in its reference or symbol materialization statement, and full, scoped, and retained refresh chains must populate reference, symbol, type-identity, and constructor-identity facts in that order before normalization and candidate consumers. Candidate SQL must use the WITHOUT ROWID fact primary keys without retaining project/file-local identity construction or constructor-owner range scans. Keep full/scoped/retained semantic snapshots across partial generic and file-local constructors, primary constructors with same-leaf generic arities, scoped target-definition mutations, fallback owners, legacy NULL identities, stale-row cleanup, and refresh rollback/retry alongside the focused property-receiver regressions. - Rank-5 C# instantiation fallback coverage must keep the set-based SQL shape and its semantic oracle together. Require exactly four materialized stages for type members, unique families, family-driven constructor members, and constructor summaries; at most the one lower-rank correlated subquery; and an indexed `constructor` search rather than a physical constructor scan. The shared behavior fixture must retain raw-case fold collisions, exact and NULL-wildcard arity, deterministic partial representatives, constructor overloads, class/record implicit defaults, value-type zero-argument rules, enum/delegate handling, ambiguous families, and lower-rank suppression. - C# metadata-target resolver coverage treats propagation work and stable reruns as performance contracts. Keep the reverse-ordered 8,000-class chain at exactly `n - 1` dependency edges and `n` queue visits instead of using a wall-clock threshold; retain cross-file partial fan-in, an unseeded cycle, pre-cancellation, rollback of an earlier row after an injected later update failure, zero trigger-audited writes on a stable rerun, and exactly one write when repairing a corrupted derived row. @@ -1163,6 +1164,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - reference identity refresh coverage は、安定graphの再構築を物理writeのperformance contractとします。source identity、target resolutionの4列tuple、self reference、mutual recursionの更新にはNULL-safeなchanged-row predicateを維持し、安定rerunのtrigger auditは0、各corrupt phaseのrepairは1回、後段phaseの失敗で先行identity writeもrollbackされることを検証してください。SQLite `changes()` は引き続き最後のmutual-recursion phaseを表します。 - fresh reference resolutionのcoverageは`FreshReferenceResolutionTests`が担当します。empty/rebuild/symbols-onlyのpolicy truth table、fresh/ordinaryでSQL cache entryを分けても14 parameterのinsert shapeが不変であること、canonicalな暫定値、candidate側materialized aggregationのSQL shape、およびC#と非C#言語を横断する`unresolved`、`resolved`、`resolved_group`、`ambiguous`、self-referenceのfresh/full完全同値を維持してください。fresh insertはliteral input順序を保ち、C# / Pythonの同一file nested source symbolを通常の包含tie-breakで選び、range外sourceをNULLのままにして、最終source UPDATEを省略する必要があります。通常fullとdifferentialのrepair経路は別途coverageを維持します。failure testはgraph rollback後もfresh defaultsがpendingのままで、成功commit後にだけ解除されることを証明します。database testは、fresh CLI scanで暫定`unresolved`、rebuildでNULL resolution stateを観測するfull-scan bulk-load theoryと対にしてください。MCPはfile単位のdurable transactionが既存graph失敗再試行契約を必要とするため、このopt-inの対象外です。 full-scan barrier回帰では、早期empty確認後からouter write transaction開始前の間に別connectionからcandidate-free rowをcommitし、transaction内の再検証が通常のfull resolutionへ切り替わってそのrowを正規化することを必須とします。 +- C# の論理 partial-family reference coverage は `FreshReferenceResolutionTests` と `IndexCommandRunnerUpdateTests` が分担します。partial class、struct、record、generic family、および namespace / arity の分離を直接 database fixture で維持し、1つの論理 family が安定した family target の `resolved_group` になりつつ、`resolution_candidate_count` と candidate row が全物理 declaration を保持することを検証してください。CLI lifecycle fixture は family file をまたぐ member reference、add / remove / rename / move update、旧 contract の restamp、grouped reference ranking、hotspot、inspect、dependency、impact、決定的な ungrouped definition を網羅します。full / scoped refresh の意味を同一に保ち、無関係な same-leaf family は曖昧なままにしてください。 - C# reference-graph fact coverage は SQL shape の performance contract です。arity / receiver / value-type を求める6つの managed function は reference または symbol の materialization statement 内にそれぞれ1回だけ置き、full / scoped / retained refresh chain は reference、symbol、type identity、constructor identity の fact をこの順で normalization と candidate consumer より前に投入してください。candidate SQL は WITHOUT ROWID fact の主キーを使い、project / file-local identity の再構築や constructor-owner range の再走査を残してはいけません。partial generic / file-local constructor、同名別 generic arity を持つ primary constructor、scoped target 定義変更、fallback owner、legacy NULL identity、stale row cleanup、refresh rollback / retry の full / scoped / retained semantic snapshot と、focused property-receiver regression を維持します。 - rank 5 の C# instantiation fallback coverage は、集合処理 SQL shape と semantic oracle を同じ契約として維持してください。type member、一意 family、family 起点の constructor member、constructor summary の materialized stage は厳密に4つ、相関 subquery は lower-rank 用の最大1つとし、物理 constructor scan ではなく indexed `constructor` search を要求します。共有 behavior fixture では raw-case の fold collision、exact / NULL-wildcard arity、partial type の決定的な代表、constructor overload、class / record の implicit default、value type の zero-argument 規則、enum / delegate、曖昧 family、lower-rank suppression を残してください。 - C# metadata-target resolver coverage は propagation work と安定 rerun を performance contract とします。逆順に保存した 8,000 class の chain では wall-clock threshold を使わず、dependency edge が厳密に `n - 1`、queue visit が `n` であることを維持してください。cross-file partial fan-in、seed を持たない cycle、事前 cancel、後段 update の注入失敗時に先行 row も rollback されること、安定 rerun の trigger audit が write 0 回、破損した derived row の修復が厳密に 1 write であることも残します。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 24929fa9c..3a808736a 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -320,7 +320,12 @@ exit `1` with a bounded correction or nearest-command/subcommand suggestion. Fresh indexes resolve reference edges against symbol identity instead of joining folded names alone. `references --json` reports `target_symbol_id`, `target_symbol_key`, `resolution_state`, and `resolution_candidate_count` when available. `resolved` identifies -one definition, `resolved_group` identifies one overload family, and `ambiguous` / +one definition, while `resolved_group` identifies one overload or logical C# partial-type family. +For a partial family, `target_symbol_key` is the stable logical-family identity and +`resolution_candidate_count` remains the number of physical candidate declarations. Those +physical definitions remain available through definition and inspection results. Candidates +from different namespaces/containers, declaration kinds, generic arities, or languages do not +collapse into one family and remain safely ambiguous. `ambiguous` / `unresolved` keep the edge explicit without letting C# `callers`, `callees`, or `deps` silently connect it to an unrelated same-named definition. Legacy databases keep the name-based read fallback until an indexing run refreshes this metadata and stamps its @@ -3974,7 +3979,11 @@ release changelog を source of truth とします。全 command の完全な sy 新しい index は、folded name だけを結合せず symbol identity に対して reference edge を 解決します。`references --json` は利用可能な場合に `target_symbol_id`、 `target_symbol_key`、`resolution_state`、`resolution_candidate_count` を返します。 -`resolved` は単一定義、`resolved_group` は単一 overload family を示し、`ambiguous` / +`resolved` は単一定義、`resolved_group` は単一 overload family または C# の論理 partial 型 family を +示します。partial family では `target_symbol_key` が安定した論理 family identity となり、 +`resolution_candidate_count` は物理 candidate declaration 数のままです。物理定義は definition と +inspect の結果から引き続き参照できます。namespace / container、declaration kind、generic arity、 +language が異なる candidate は 1 family に統合せず、安全に曖昧なままにします。`ambiguous` / `unresolved` は edge を明示したまま、C# の `callers`、`callees`、`deps` が無関係な 同名定義へ暗黙に接続することを防ぎます。legacy DB は、次回の index 実行でこの metadata を再構築して contract version を記録するまで name-based read fallback を維持します。 diff --git a/changelog.d/unreleased/5158.fixed.md b/changelog.d/unreleased/5158.fixed.md new file mode 100644 index 000000000..ef50743cf --- /dev/null +++ b/changelog.d/unreleased/5158.fixed.md @@ -0,0 +1,25 @@ +--- +category: fixed +issues: + - 5158 +affected: + - src/CodeIndex/Database/DbContext.SchemaMetadata.cs + - src/CodeIndex/Database/DbReader.SymbolSearchListQueryBuilder.cs + - src/CodeIndex/Database/DbSymbolReader.Hotspots.cs + - src/CodeIndex/Database/DbWriter.ReferenceGraphRefreshScope.cs + - src/CodeIndex/Database/DbWriter.References.cs + - src/CodeIndex/Database/LogicalPartialSymbolGrouper.cs + - tests/CodeIndex.Tests/FreshReferenceResolutionTests.cs + - tests/CodeIndex.Tests/IndexCommandRunnerUpdateTests.cs + - USER_GUIDE.md + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **C# references now resolve one logical partial-type family (#5158)** — references whose physical candidates all belong to one partial class, struct, record, interface, or generic family persist a stable logical target as `resolved_group`, retain the physical candidate count and definitions, and feed grouped symbol ranking, hotspots, inspect, dependencies, and impact without merging unrelated same-name families. + +## 日本語 + +- **C# reference が単一の論理 partial 型 family へ解決されるようになりました (#5158)** — 物理 candidate がすべて同じ partial class、struct、record、interface、または generic family に属する reference は、安定した論理 target を `resolved_group` として永続化します。物理 candidate 数と定義を保持し、無関係な同名 family を統合せず、grouped symbol ranking、hotspot、inspect、dependency、impact へ参照を反映します。 diff --git a/src/CodeIndex/Database/DbContext.SchemaMetadata.cs b/src/CodeIndex/Database/DbContext.SchemaMetadata.cs index 344b19bc2..4b73399e7 100644 --- a/src/CodeIndex/Database/DbContext.SchemaMetadata.cs +++ b/src/CodeIndex/Database/DbContext.SchemaMetadata.cs @@ -67,18 +67,22 @@ public static string BuildIncompleteHotspotFamilyMarkerFingerprint(string? finge public const string SqlGraphContractVersionMetaKey = "sql_graph_contract_version"; public const int HdlGraphContractVersion = 1; public const string HdlGraphContractVersionMetaKey = "hdl_graph_contract_version"; - // Version 8 (#4914) invalidates C# candidates whose partial family identity did not + // Version 9 (#5158) invalidates references resolved with physical declaration paths + // instead of authoritative logical C# partial-family identities. Version 8 (#4914) + // invalidates C# candidates whose partial family identity did not // distinguish namespace boundaries from nested-type boundaries. Version 7 (#4914) // invalidated candidates written before file-local partial families carried source-file // identity. Version 6 (#4850) previously separated constructor // callables from logical partial-type families; version 5 (#4846) made Markdown fragment // resolution document/path-scoped. - // バージョン 8 (#4914) では namespace 境界と nested-type 境界を区別しない partial family + // バージョン 9 (#5158) では、正式な C# logical partial-family identity ではなく + // physical declaration path で解決した reference を無効化する。バージョン 8 (#4914) では + // namespace 境界と nested-type 境界を区別しない partial family // identity を持つ C# candidate を無効化する。バージョン 7 (#4914) では、file-local partial // family が source-file identity を持つ前の C# candidate を無効化する。バージョン 6 (#4850) は constructor callable と logical // partial type family を分離し、バージョン 5 (#4846) は Markdown fragment 解決を // document/path 内に限定した。 - public const int ReferenceIdentityContractVersion = 8; + public const int ReferenceIdentityContractVersion = 9; public const string ReferenceIdentityContractVersionMetaKey = "reference_identity_contract_version"; public static string GetDynamicReferenceGraphContractVersionMetaKey(string lang) => $"dynamic_reference_graph_contract_version_{lang}"; diff --git a/src/CodeIndex/Database/DbReader.SymbolSearchListQueryBuilder.cs b/src/CodeIndex/Database/DbReader.SymbolSearchListQueryBuilder.cs index 6ba75d2e2..afa956fea 100644 --- a/src/CodeIndex/Database/DbReader.SymbolSearchListQueryBuilder.cs +++ b/src/CodeIndex/Database/DbReader.SymbolSearchListQueryBuilder.cs @@ -34,7 +34,6 @@ private string BuildSymbolSearchListSql(SymbolSearchQueryPlan plan) var columns = BuildSymbolSearchColumnSql(); var includeRankSignals = plan.SortMode != SymbolSortMode.Name && _hasReferencesTable; - var ranking = BuildSymbolSearchRankingSql(columns, includeRankSignals); var canonical = LogicalPartialQuerySql.Build( this, columns.Signature, @@ -44,6 +43,10 @@ private string BuildSymbolSearchListSql(SymbolSearchQueryPlan plan) columns.ReturnType, columns.BodyStartLine, columns.BodyEndLine); + var ranking = BuildSymbolSearchRankingSql( + columns, + canonical.LogicalPartialKey, + includeRankSignals); var sql = BuildSymbolSearchSelectSql(columns, ranking, canonical); sql += SymbolSearchQueryPredicateBuilder.BuildFull(this, plan); SymbolSearchQueryPredicateBuilder.AppendFilters( @@ -94,6 +97,7 @@ private SymbolSearchColumnSql BuildSymbolSearchColumnSql() private SymbolSearchRankingSql BuildSymbolSearchRankingSql( SymbolSearchColumnSql columns, + string logicalPartialKeySql, bool includeRankSignals) { var genericPenalty = includeRankSignals @@ -105,11 +109,14 @@ private SymbolSearchRankingSql BuildSymbolSearchRankingSql( var conservativeSignal = includeRankSignals ? $"(f.lang = 'csharp' AND (s.kind = 'property' OR ({definitionSites}) > 1 OR lower(s.name) IN {GenericSymbolRankNamesSql}))" : "0"; + var csharpPartialIdentitySignal = includeRankSignals && CanUseCSharpIdentityHotspotCounts() + ? $"(f.lang = 'csharp' AND ({logicalPartialKeySql}) LIKE 'family:%')" + : "0"; var referenceCount = includeRankSignals - ? $"CASE WHEN {conservativeSignal} THEN COALESCE(symbol_file_rank.reference_count, 0) ELSE COALESCE(symbol_rank.reference_count, 0) END" + ? $"CASE WHEN {csharpPartialIdentitySignal} THEN COALESCE(symbol_identity_rank.reference_count, 0) WHEN {conservativeSignal} THEN COALESCE(symbol_file_rank.reference_count, 0) ELSE COALESCE(symbol_rank.reference_count, 0) END" : "CAST(0 AS INTEGER)"; var hotspotScore = includeRankSignals - ? $"CASE WHEN {conservativeSignal} THEN COALESCE(symbol_file_rank.hotspot_score, 0.0) ELSE COALESCE(symbol_rank.hotspot_score, 0.0) END" + ? $"CASE WHEN {csharpPartialIdentitySignal} THEN COALESCE(symbol_identity_rank.hotspot_score, 0.0) WHEN {conservativeSignal} THEN COALESCE(symbol_file_rank.hotspot_score, 0.0) ELSE COALESCE(symbol_rank.hotspot_score, 0.0) END" : "CAST(0.0 AS REAL)"; var dilution = $"CASE WHEN ({definitionSites}) > 1 THEN CAST(({definitionSites}) * ({definitionSites}) AS REAL) ELSE 1.0 END"; var structuralPenalty = includeRankSignals @@ -129,7 +136,7 @@ private SymbolSearchRankingSql BuildSymbolSearchRankingSql( ELSE 0.0 END)"; return new SymbolSearchRankingSql( - BuildSymbolRankJoin(includeRankSignals), + BuildSymbolRankJoin(includeRankSignals, logicalPartialKeySql), genericPenalty, definitionSites, referenceCount, @@ -141,10 +148,46 @@ ELSE 0.0 BuildExactSymbolNameOrderSql()); } - private static string BuildSymbolRankJoin(bool includeRankSignals) + private string BuildSymbolRankJoin(bool includeRankSignals, string logicalPartialKeySql) { - return includeRankSignals + if (!includeRankSignals) + return string.Empty; + + var identityJoin = CanUseCSharpIdentityHotspotCounts() ? $@" + LEFT JOIN ( + SELECT identity_site.lang, + identity_site.target_symbol_key, + COUNT(*) AS reference_count, + SUM(identity_site.reference_score) AS hotspot_score + FROM ( + SELECT rf.lang, + sr.target_symbol_key, + sr.file_id, + sr.line, + sr.column_number, + {GetLogicalReferenceKindSql("sr.reference_kind")} AS logical_reference_kind, + MAX({GetHotspotReferenceWeightSql("sr.reference_kind")}) AS reference_score + FROM symbol_references sr + JOIN files rf ON rf.id = sr.file_id + WHERE rf.lang = 'csharp' + AND sr.target_symbol_key IS NOT NULL + AND sr.resolution_state IN ('resolved', 'resolved_group') + AND (sr.reference_kind IN {CallGraphReferenceKindsSql} + OR sr.reference_kind = 'type_reference') + GROUP BY rf.lang, + sr.target_symbol_key, + sr.file_id, + sr.line, + sr.column_number, + logical_reference_kind + ) identity_site + GROUP BY identity_site.lang, identity_site.target_symbol_key + ) symbol_identity_rank + ON symbol_identity_rank.lang = f.lang + AND symbol_identity_rank.target_symbol_key = ({logicalPartialKeySql})" + : string.Empty; + return $@" LEFT JOIN ( SELECT rf.lang AS lang, sr.symbol_name AS symbol_name, @@ -183,8 +226,8 @@ AND ds.name <> '' GROUP BY df.lang, ds.name COLLATE NOCASE ) symbol_defs ON symbol_defs.lang = f.lang - AND symbol_defs.symbol_name = s.name COLLATE NOCASE" - : string.Empty; + AND symbol_defs.symbol_name = s.name COLLATE NOCASE + {identityJoin}"; } private string BuildSymbolSearchSelectSql( diff --git a/src/CodeIndex/Database/DbSymbolReader.Hotspots.cs b/src/CodeIndex/Database/DbSymbolReader.Hotspots.cs index 2b53d53fc..5e6160815 100644 --- a/src/CodeIndex/Database/DbSymbolReader.Hotspots.cs +++ b/src/CodeIndex/Database/DbSymbolReader.Hotspots.cs @@ -77,7 +77,8 @@ JOIN files identity_source_file JOIN csharp_identity_reference_targets identity_target ON identity_target.reference_id = identity_reference.id WHERE identity_source_file.lang = 'csharp' - AND identity_reference.reference_kind IN {CallGraphReferenceKindsSql} + AND (identity_reference.reference_kind IN {CallGraphReferenceKindsSql} + OR identity_reference.reference_kind = 'type_reference') GROUP BY identity_target.logical_target_key, identity_target.target_name, identity_target.target_kind, @@ -109,10 +110,10 @@ private SymbolHotspotRowsQuery BuildGroupedSymbolHotspotRowsQuery(int? resultLim visibilityFilters, excludeVisibilityFilters); var csharpIdentityCountSql = CanUseCSharpIdentityHotspotCounts() - ? "WHEN fc.lang = 'csharp' AND fc.kind IN ('function', 'test.method', 'property') THEN COALESCE(circ.ref_count, 0)" + ? "WHEN fc.lang = 'csharp' AND (fc.kind IN ('function', 'test.method', 'property') OR (fc.kind IN ('class', 'struct', 'interface', 'record') AND fc.logical_target_key LIKE 'family|csharp|%')) THEN COALESCE(circ.ref_count, 0)" : string.Empty; var csharpIdentityScoreSql = CanUseCSharpIdentityHotspotCounts() - ? "WHEN fc.lang = 'csharp' AND fc.kind IN ('function', 'test.method', 'property') THEN COALESCE(circ.ref_score, 0.0)" + ? "WHEN fc.lang = 'csharp' AND (fc.kind IN ('function', 'test.method', 'property') OR (fc.kind IN ('class', 'struct', 'interface', 'record') AND fc.logical_target_key LIKE 'family|csharp|%')) THEN COALESCE(circ.ref_score, 0.0)" : string.Empty; var sql = candidatePlan.Sql + @" logical_references AS MATERIALIZED ( @@ -548,7 +549,13 @@ private static string BuildBoundedHotspotSymbolPredicate( AND ( ( f.lang = 'csharp' - AND s.kind IN ('function', 'test.method', 'property') + AND ( + s.kind IN ('function', 'test.method', 'property') + OR ( + s.kind IN ('class', 'struct', 'interface', 'record') + AND {logicalTargetKeySql} LIKE 'family|csharp|%' + ) + ) AND EXISTS ( SELECT 1 FROM bounded_csharp_identity_targets identity_frontier @@ -558,7 +565,16 @@ FROM bounded_csharp_identity_targets identity_frontier ) ) OR ( - (f.lang != 'csharp' OR s.kind NOT IN ('function', 'test.method', 'property')) + ( + f.lang != 'csharp' + OR ( + s.kind NOT IN ('function', 'test.method', 'property') + AND ( + s.kind NOT IN ('class', 'struct', 'interface', 'record') + OR {logicalTargetKeySql} NOT LIKE 'family|csharp|%' + ) + ) + ) AND EXISTS ( SELECT 1 FROM bounded_reference_names brn @@ -949,10 +965,10 @@ private SymbolHotspotRowsQuery BuildSymbolHotspotRowsQuery(int? resultLimit, str visibilityFilters, excludeVisibilityFilters); var csharpIdentityCountSql = CanUseCSharpIdentityHotspotCounts() - ? "WHEN gr.lang = 'csharp' AND gr.kind IN ('function', 'test.method', 'property') THEN COALESCE(circ.ref_count, 0)" + ? "WHEN gr.lang = 'csharp' AND (gr.kind IN ('function', 'test.method', 'property') OR (gr.kind IN ('class', 'struct', 'interface', 'record') AND gr.logical_target_key LIKE 'family|csharp|%')) THEN COALESCE(circ.ref_count, 0)" : string.Empty; var csharpIdentityScoreSql = CanUseCSharpIdentityHotspotCounts() - ? "WHEN gr.lang = 'csharp' AND gr.kind IN ('function', 'test.method', 'property') THEN COALESCE(circ.ref_score, 0.0)" + ? "WHEN gr.lang = 'csharp' AND (gr.kind IN ('function', 'test.method', 'property') OR (gr.kind IN ('class', 'struct', 'interface', 'record') AND gr.logical_target_key LIKE 'family|csharp|%')) THEN COALESCE(circ.ref_score, 0.0)" : string.Empty; var sql = candidatePlan.Sql + @" grouped_candidates AS ( diff --git a/src/CodeIndex/Database/DbWriter.ReferenceGraphRefreshScope.cs b/src/CodeIndex/Database/DbWriter.ReferenceGraphRefreshScope.cs index 7fff5842f..147d1794f 100644 --- a/src/CodeIndex/Database/DbWriter.ReferenceGraphRefreshScope.cs +++ b/src/CodeIndex/Database/DbWriter.ReferenceGraphRefreshScope.cs @@ -128,7 +128,7 @@ GROUP BY candidate.symbol_id ) INSERT INTO temp.{ReferenceResolutionSymbolFactsTable}(symbol_id, target_key) SELECT target.id, - {ReferenceResolutionTargetKeySql} + {BuildReferenceResolutionTargetKeySql()} FROM dirty_target_symbols AS dirty_target JOIN symbols AS target ON target.id = dirty_target.symbol_id JOIN files AS target_file ON target_file.id = target.file_id; diff --git a/src/CodeIndex/Database/DbWriter.References.cs b/src/CodeIndex/Database/DbWriter.References.cs index 078919a89..16d9c03a9 100644 --- a/src/CodeIndex/Database/DbWriter.References.cs +++ b/src/CodeIndex/Database/DbWriter.References.cs @@ -15,12 +15,27 @@ public partial class DbWriter private const string ReferenceResolutionSymbolFactsTable = "reference_resolution_symbol_facts"; - private const string ReferenceResolutionTargetKeySql = """ + private const string ReferenceResolutionPhysicalTargetKeySql = """ target_file.lang || char(31) || target_file.path || char(31) || COALESCE(target.container_qualified_name, target.container_name, '') || char(31) || COALESCE(target.name, '') """; + private static string BuildReferenceResolutionTargetKeySql() + => LogicalPartialSymbolGrouper.BuildSqlKeyExpression( + languageSql: "target_file.lang", + kindSql: "target.kind", + nameSql: "target.name", + symbolIdSql: "target.id", + fileIdentitySql: "target_file.path", + signatureSql: "target.signature", + containerNameSql: "target.container_name", + containerQualifiedNameSql: "target.container_qualified_name", + familyKeySql: "target.family_key", + returnTypeSql: "target.return_type", + isPartialDeclarationSql: "target.is_partial_declaration", + fallbackKeySql: ReferenceResolutionPhysicalTargetKeySql); + private static readonly string CreateReferenceResolutionSymbolFactsTableSql = $""" CREATE TEMP TABLE IF NOT EXISTS {ReferenceResolutionSymbolFactsTable} ( symbol_id INTEGER NOT NULL PRIMARY KEY, @@ -218,7 +233,7 @@ ON csharp_reference_facts(reference_id) INSERT INTO temp.{ReferenceResolutionSymbolFactsTable}(symbol_id, target_key) SELECT target.id, - {ReferenceResolutionTargetKeySql} + {BuildReferenceResolutionTargetKeySql()} FROM symbols AS target JOIN files AS target_file ON target_file.id = target.file_id; """; diff --git a/src/CodeIndex/Database/LogicalPartialSymbolGrouper.cs b/src/CodeIndex/Database/LogicalPartialSymbolGrouper.cs index 899dc3e2b..37a6087f5 100644 --- a/src/CodeIndex/Database/LogicalPartialSymbolGrouper.cs +++ b/src/CodeIndex/Database/LogicalPartialSymbolGrouper.cs @@ -62,10 +62,11 @@ internal static string BuildSqlKeyExpression( string familyKeySql, string? returnTypeSql = null, string? isPartialDeclarationSql = null, - bool csharpFamilyContractReady = true) + bool csharpFamilyContractReady = true, + string? fallbackKeySql = null) { if (!csharpFamilyContractReady) - return $"'symbol:' || {symbolIdSql}"; + return fallbackKeySql ?? $"'symbol:' || {symbolIdSql}"; var persistedFamilySql = $"NULLIF(TRIM({familyKeySql}), '')"; var scopedPersistedFamilySql = persistedFamilySql; @@ -104,7 +105,7 @@ internal static string BuildSqlKeyExpression( AND {callableIdentitySql} IS NOT NULL THEN 'family:' || {languageSql} || CHAR(31) || 'function' || CHAR(31) || {callableContainerSql} || CHAR(31) || {callableIdentitySql} - ELSE 'symbol:' || {symbolIdSql} + ELSE {fallbackKeySql ?? $"'symbol:' || {symbolIdSql}"} END"; } diff --git a/tests/CodeIndex.Tests/FreshReferenceResolutionTests.cs b/tests/CodeIndex.Tests/FreshReferenceResolutionTests.cs index aaa1be249..aa6cf444b 100644 --- a/tests/CodeIndex.Tests/FreshReferenceResolutionTests.cs +++ b/tests/CodeIndex.Tests/FreshReferenceResolutionTests.cs @@ -244,17 +244,17 @@ public void ReferenceResolutionFacts_ConstructTargetKeysOnceAcrossEveryRefreshSc foreach (var (scope, materializationSql, resolutionSql) in DbWriter.ReferenceResolutionFactSqlForTesting) { - Assert.Equal( - 1, - CountOccurrences( - materializationSql, - "target_file.lang || char(31) || target_file.path")); + Assert.True( + materializationSql.Contains("target_file.path", StringComparison.Ordinal), + $"{scope} target-key materialization omitted the physical fallback: {materializationSql}"); Assert.Contains( "INSERT INTO temp.reference_resolution_symbol_facts", materializationSql, StringComparison.Ordinal); + Assert.Contains("THEN 'family:'", materializationSql, StringComparison.Ordinal); + Assert.Contains("target.family_key", materializationSql, StringComparison.Ordinal); Assert.DoesNotContain( - "target_file.lang || char(31) || target_file.path", + "target_file.path", resolutionSql, StringComparison.Ordinal); Assert.Contains( @@ -289,6 +289,111 @@ public void ReferenceResolutionFacts_ConstructTargetKeysOnceAcrossEveryRefreshSc } } + [Fact] + public void ReferenceResolutionFacts_CollapseOnlyOneLogicalCSharpPartialFamily_Issue5158() + { + var callerFileId = InsertFile("src/Caller.cs", "csharp"); + var demoAFileId = InsertFile("src/Demo.Widget.A.cs", "csharp"); + var demoBFileId = InsertFile("src/Demo.Widget.B.cs", "csharp"); + var otherAFileId = InsertFile("src/Other.Widget.A.cs", "csharp"); + var otherBFileId = InsertFile("src/Other.Widget.B.cs", "csharp"); + var structAFileId = InsertFile("src/Demo.Packet.A.cs", "csharp"); + var structBFileId = InsertFile("src/Demo.Packet.B.cs", "csharp"); + var recordAFileId = InsertFile("src/Demo.Receipt.A.cs", "csharp"); + var recordBFileId = InsertFile("src/Demo.Receipt.B.cs", "csharp"); + var genericAFileId = InsertFile("src/Demo.Box.Generic.A.cs", "csharp"); + var genericBFileId = InsertFile("src/Demo.Box.Generic.B.cs", "csharp"); + var plainAFileId = InsertFile("src/Demo.Box.Plain.A.cs", "csharp"); + var plainBFileId = InsertFile("src/Demo.Box.Plain.B.cs", "csharp"); + var classShapeFileId = InsertFile("src/Demo.Shape.Class.cs", "csharp"); + var structShapeFileId = InsertFile("src/Demo.Shape.Struct.cs", "csharp"); + var csharpCrossLangAFileId = InsertFile("src/CrossLang.A.cs", "csharp"); + var csharpCrossLangBFileId = InsertFile("src/CrossLang.B.cs", "csharp"); + var javaCrossLangFileId = InsertFile("src/CrossLang.java", "java"); + _writer.InsertSymbols([ + CreatePartialTypeSymbol(demoAFileId, "Widget", "class", "Demo", "fixture|Demo.Widget", "public partial class Widget"), + CreatePartialTypeSymbol(demoBFileId, "Widget", "class", "Demo", "fixture|Demo.Widget", "public partial class Widget"), + CreatePartialTypeSymbol(otherAFileId, "Widget", "class", "Other", "fixture|Other.Widget", "public partial class Widget"), + CreatePartialTypeSymbol(otherBFileId, "Widget", "class", "Other", "fixture|Other.Widget", "public partial class Widget"), + CreatePartialTypeSymbol(structAFileId, "Packet", "struct", "Demo", "fixture|Demo.Packet", "public partial struct Packet"), + CreatePartialTypeSymbol(structBFileId, "Packet", "struct", "Demo", "fixture|Demo.Packet", "public partial struct Packet"), + CreatePartialTypeSymbol(recordAFileId, "Receipt", "record", "Demo", "fixture|Demo.Receipt", "public partial record Receipt"), + CreatePartialTypeSymbol(recordBFileId, "Receipt", "record", "Demo", "fixture|Demo.Receipt", "public partial record Receipt"), + CreatePartialTypeSymbol(genericAFileId, "Box", "class", "Demo", "fixture|Demo.Box`1", "public partial class Box"), + CreatePartialTypeSymbol(genericBFileId, "Box", "class", "Demo", "fixture|Demo.Box`1", "public partial class Box"), + CreatePartialTypeSymbol(plainAFileId, "Box", "class", "Demo", "fixture|Demo.Box", "public partial class Box"), + CreatePartialTypeSymbol(plainBFileId, "Box", "class", "Demo", "fixture|Demo.Box", "public partial class Box"), + CreatePartialTypeSymbol(classShapeFileId, "Shape", "class", "Demo", "fixture|Demo.Shape", "public partial class Shape"), + CreatePartialTypeSymbol(structShapeFileId, "Shape", "struct", "Demo", "fixture|Demo.Shape", "public partial struct Shape"), + CreatePartialTypeSymbol(csharpCrossLangAFileId, "CrossLang", "class", "Demo", "fixture|Demo.CrossLang", "public partial class CrossLang"), + CreatePartialTypeSymbol(csharpCrossLangBFileId, "CrossLang", "class", "Demo", "fixture|Demo.CrossLang", "public partial class CrossLang"), + CreatePartialTypeSymbol(javaCrossLangFileId, "CrossLang", "class", "Demo", "fixture|Demo.CrossLang", "public class CrossLang"), + ]); + _writer.InsertReferences([ + CreateTypeReference(callerFileId, "Widget", line: 10), + CreateTypeReference(callerFileId, "Packet", line: 11), + CreateTypeReference(callerFileId, "Receipt", line: 12), + CreateTypeReference(callerFileId, "Box", line: 13), + CreateTypeReference(callerFileId, "Widget", line: 20), + CreateTypeReference(callerFileId, "Box", line: 21), + CreateTypeReference(callerFileId, "Shape", line: 22), + CreateTypeReference(callerFileId, "CrossLang", line: 23), + ], refreshMutualRecursionFlags: false); + + Execute(""" + DELETE FROM symbol_reference_candidates; + + INSERT INTO symbol_reference_candidates(reference_id, symbol_id, scope_rank) + SELECT reference.id, target.id, 0 + FROM symbol_references AS reference + JOIN symbols AS target ON target.name = reference.symbol_name + WHERE (reference.line = 10 AND target.container_qualified_name = 'Demo') + OR reference.line IN (11, 12) + OR (reference.line = 13 AND target.family_key = 'fixture|Demo.Box`1') + OR reference.line IN (20, 21, 22, 23); + """); + + Execute(DbWriter.RefreshReferenceResolutionFullSqlForTesting); + + foreach (var line in new[] { 10, 11, 12, 13 }) + { + var row = ReadResolutionRow("src/Caller.cs", line); + Assert.Equal("resolved_group", row.ResolutionState); + Assert.Equal(2, row.CandidateCount); + Assert.False(row.HasTargetId); + Assert.True(row.HasTargetKey); + } + Assert.Equal( + "family:csharp\u001fclass\u001ffixture|Demo.Widget", + ScalarString("SELECT target_symbol_key FROM symbol_references WHERE line = 10")); + Assert.Equal( + "family:csharp\u001fstruct\u001ffixture|Demo.Packet", + ScalarString("SELECT target_symbol_key FROM symbol_references WHERE line = 11")); + Assert.Equal( + "family:csharp\u001frecord\u001ffixture|Demo.Receipt", + ScalarString("SELECT target_symbol_key FROM symbol_references WHERE line = 12")); + Assert.Equal( + "family:csharp\u001fclass\u001ffixture|Demo.Box`1", + ScalarString("SELECT target_symbol_key FROM symbol_references WHERE line = 13")); + + var namespaceAmbiguity = ReadResolutionRow("src/Caller.cs", 20); + Assert.Equal("ambiguous", namespaceAmbiguity.ResolutionState); + Assert.Equal(4, namespaceAmbiguity.CandidateCount); + Assert.False(namespaceAmbiguity.HasTargetKey); + var arityAmbiguity = ReadResolutionRow("src/Caller.cs", 21); + Assert.Equal("ambiguous", arityAmbiguity.ResolutionState); + Assert.Equal(4, arityAmbiguity.CandidateCount); + Assert.False(arityAmbiguity.HasTargetKey); + var kindAmbiguity = ReadResolutionRow("src/Caller.cs", 22); + Assert.Equal("ambiguous", kindAmbiguity.ResolutionState); + Assert.Equal(2, kindAmbiguity.CandidateCount); + Assert.False(kindAmbiguity.HasTargetKey); + var languageAmbiguity = ReadResolutionRow("src/Caller.cs", 23); + Assert.Equal("ambiguous", languageAmbiguity.ResolutionState); + Assert.Equal(3, languageAmbiguity.CandidateCount); + Assert.False(languageAmbiguity.HasTargetKey); + } + [Fact] public void ReferenceResolutionFacts_PreserveResolvedLegacyCandidateWithNullTargetKey() { @@ -550,6 +655,45 @@ private static SymbolRecord CreateRangedSymbol( Signature = $"function {name}()", }; + private static SymbolRecord CreatePartialTypeSymbol( + long fileId, + string name, + string kind, + string container, + string familyKey, + string signature) + => new() + { + FileId = fileId, + Kind = kind, + Name = name, + Line = 1, + StartLine = 1, + EndLine = 3, + Signature = signature, + ContainerKind = "namespace", + ContainerName = container, + ContainerQualifiedName = container, + FamilyKey = familyKey, + IsPartialDeclaration = true, + }; + + private static ReferenceRecord CreateTypeReference( + long fileId, + string symbolName, + int line) + => new() + { + FileId = fileId, + SymbolName = symbolName, + ReferenceKind = "type_reference", + Line = line, + Column = 1, + Context = $"{symbolName} value;", + ContainerKind = "function", + ContainerName = "Caller", + }; + private static ReferenceRecord CreateReference( long fileId, string symbolName, diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerUpdateTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerUpdateTests.cs index f2b80dc41..b12898e8f 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerUpdateTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerUpdateTests.cs @@ -265,6 +265,298 @@ WHERE reference.resolution_state IN ('resolved', 'resolved_group') } } + [Fact] + public void Run_UpdateMode_PartialFamilyIdentitySurvivesMemberLifecycleAndFeedsReaders_Issue5158() + { + var projectRoot = CreateTempProject(); + try + { + var partAPath = Path.Combine(projectRoot, "Runner5158.A.cs"); + var partBPath = Path.Combine(projectRoot, "Runner5158.B.cs"); + var movedPartPath = Path.Combine(projectRoot, "Runner5158.Moved.cs"); + var partCPath = Path.Combine(projectRoot, "Runner5158.C.cs"); + var callerPath = Path.Combine(projectRoot, "Consumer5158.cs"); + File.WriteAllText(partAPath, """ + namespace Demo; + public partial class Runner5158 + { + public void FromA() { } + } + """); + File.WriteAllText(partBPath, """ + namespace Demo; + public partial class Runner5158 + { + public void FromB() { } + } + """); + File.WriteAllText(callerPath, """ + namespace Demo; + public sealed class Consumer5158 + { + private Runner5158 _runner = new(); + public void Use() + { + _runner.FromA(); + _runner.FromB(); + } + } + """); + + var (initialExitCode, _) = RunAndCaptureJson([projectRoot, "--json"]); + Assert.Equal(CommandExitCodes.Success, initialExitCode); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + var initial = ReadPartialReference(); + Assert.Equal("resolved_group", initial.State); + Assert.Equal(2, initial.CandidateCount); + Assert.StartsWith("family:csharp\u001fclass\u001f", initial.TargetKey); + Assert.Equal(["Runner5158.A.cs", "Runner5158.B.cs"], initial.CandidatePaths); + AssertReaderConsumers(expectedDefinitionSites: 2, expectedReferenceCount: 1); + + File.WriteAllText(partCPath, """ + namespace Demo; + public partial class Runner5158 + { + public void FromC() { } + } + """); + var (addExitCode, _) = RunAndCaptureJson( + [projectRoot, "--files", "Runner5158.C.cs", "--json"]); + Assert.Equal(CommandExitCodes.Success, addExitCode); + var afterAdd = ReadPartialReference(); + Assert.Equal("resolved_group", afterAdd.State); + Assert.Equal(3, afterAdd.CandidateCount); + Assert.Equal(initial.TargetKey, afterAdd.TargetKey); + + File.Delete(partCPath); + var (removeExitCode, _) = RunAndCaptureJson( + [projectRoot, "--files", "Runner5158.C.cs", "--json"]); + Assert.Equal(CommandExitCodes.Success, removeExitCode); + var afterRemove = ReadPartialReference(); + Assert.Equal("resolved_group", afterRemove.State); + Assert.Equal(2, afterRemove.CandidateCount); + Assert.Equal(initial.TargetKey, afterRemove.TargetKey); + + File.Move(partBPath, movedPartPath); + var (moveExitCode, _) = RunAndCaptureJson( + [projectRoot, "--files", "Runner5158.B.cs", "Runner5158.Moved.cs", "--json"]); + Assert.Equal(CommandExitCodes.Success, moveExitCode); + var afterMove = ReadPartialReference(); + Assert.Equal("resolved_group", afterMove.State); + Assert.Equal(2, afterMove.CandidateCount); + Assert.Equal(initial.TargetKey, afterMove.TargetKey); + Assert.Equal(["Runner5158.A.cs", "Runner5158.Moved.cs"], afterMove.CandidatePaths); + + File.WriteAllText(movedPartPath, """ + namespace Demo; + public partial class RenamedRunner5158 + { + public void FromB() { } + } + """); + var (renameExitCode, _) = RunAndCaptureJson( + [projectRoot, "--files", "Runner5158.Moved.cs", "--json"]); + Assert.Equal(CommandExitCodes.Success, renameExitCode); + var afterRename = ReadPartialReference(); + Assert.Equal("resolved", afterRename.State); + Assert.Equal(1, afterRename.CandidateCount); + Assert.Equal(initial.TargetKey, afterRename.TargetKey); + + File.WriteAllText(movedPartPath, """ + namespace Demo; + public partial class Runner5158 + { + public void FromB() { } + } + """); + var (restoreExitCode, _) = RunAndCaptureJson( + [projectRoot, "--files", "Runner5158.Moved.cs", "--json"]); + Assert.Equal(CommandExitCodes.Success, restoreExitCode); + var afterRestore = ReadPartialReference(); + Assert.Equal("resolved_group", afterRestore.State); + Assert.Equal(2, afterRestore.CandidateCount); + Assert.Equal(initial.TargetKey, afterRestore.TargetKey); + + using (var connection = new SqliteConnection($"Data Source={dbPath}")) + { + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = """ + UPDATE codeindex_meta + SET value = @prior_version + WHERE key = @contract_key; + + UPDATE symbol_references + SET target_symbol_id = NULL, + target_symbol_key = NULL, + resolution_state = 'ambiguous', + resolution_candidate_count = 2 + WHERE id = ( + SELECT reference.id + FROM symbol_references AS reference + JOIN files AS source_file ON source_file.id = reference.file_id + WHERE source_file.path = 'Consumer5158.cs' + AND reference.symbol_name = 'Runner5158' + AND reference.reference_kind = 'type_reference' + LIMIT 1 + ); + """; + command.Parameters.AddWithValue( + "@prior_version", + (DbContext.ReferenceIdentityContractVersion - 1).ToString(CultureInfo.InvariantCulture)); + command.Parameters.AddWithValue( + "@contract_key", + DbContext.ReferenceIdentityContractVersionMetaKey); + command.ExecuteNonQuery(); + } + + var (restampExitCode, restampJson) = RunAndCaptureJson( + [projectRoot, "--files", "Consumer5158.cs", "--json"]); + Assert.Equal(CommandExitCodes.Success, restampExitCode); + Assert.Equal(0, restampJson.GetProperty("summary").GetProperty("updated").GetInt32()); + Assert.Equal(1, restampJson.GetProperty("summary").GetProperty("skipped").GetInt32()); + var afterRestamp = ReadPartialReference(); + Assert.Equal("resolved_group", afterRestamp.State); + Assert.Equal(2, afterRestamp.CandidateCount); + Assert.Equal(initial.TargetKey, afterRestamp.TargetKey); + AssertReaderConsumers(expectedDefinitionSites: 2, expectedReferenceCount: 1); + + (string State, int CandidateCount, string? TargetKey, string[] CandidatePaths) + ReadPartialReference() + { + SqliteConnection.ClearAllPools(); + using var connection = new SqliteConnection($"Data Source={dbPath};Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT reference.resolution_state, + reference.resolution_candidate_count, + reference.target_symbol_key + FROM symbol_references AS reference + JOIN files AS source_file ON source_file.id = reference.file_id + WHERE source_file.path = 'Consumer5158.cs' + AND reference.symbol_name = 'Runner5158' + AND reference.reference_kind = 'type_reference' + ORDER BY reference.line, reference.column_number + LIMIT 1 + """; + using var reader = command.ExecuteReader(); + Assert.True(reader.Read()); + var state = reader.GetString(0); + var candidateCount = reader.GetInt32(1); + var targetKey = reader.IsDBNull(2) ? null : reader.GetString(2); + reader.Close(); + + command.CommandText = """ + SELECT target_file.path + FROM symbol_reference_candidates AS candidate + JOIN symbol_references AS reference ON reference.id = candidate.reference_id + JOIN files AS source_file ON source_file.id = reference.file_id + JOIN symbols AS target ON target.id = candidate.symbol_id + JOIN files AS target_file ON target_file.id = target.file_id + WHERE source_file.path = 'Consumer5158.cs' + AND reference.symbol_name = 'Runner5158' + AND reference.reference_kind = 'type_reference' + ORDER BY target_file.path + """; + using var candidateReader = command.ExecuteReader(); + var candidatePaths = new List(); + while (candidateReader.Read()) + candidatePaths.Add(candidateReader.GetString(0)); + return (state, candidateCount, targetKey, candidatePaths.ToArray()); + } + + void AssertReaderConsumers(int expectedDefinitionSites, int expectedReferenceCount) + { + SqliteConnection.ClearAllPools(); + using var db = new DbContext(DbOpenIntent.QueryOnly, dbPath); + db.TryMigrateForRead(); + using var reader = new DbReader(db.Connection, db.IsReadOnly); + + var groupedSymbol = Assert.Single(reader.SearchSymbols( + "Runner5158", + limit: 10, + kind: "class", + lang: "csharp", + exact: true, + sortMode: SymbolSortMode.References, + groupPartials: true)); + Assert.Equal(expectedDefinitionSites, groupedSymbol.DefinitionSites); + Assert.Equal(expectedReferenceCount, groupedSymbol.ReferenceCount); + Assert.StartsWith("partial:", groupedSymbol.PartialFamilyId); + + var physicalDefinitions = reader.SearchSymbols( + "Runner5158", + limit: 10, + kind: "class", + lang: "csharp", + exact: true, + sortMode: SymbolSortMode.Path, + groupPartials: false); + Assert.Equal(expectedDefinitionSites, physicalDefinitions.Count); + Assert.Equal( + physicalDefinitions.Select(definition => definition.Path).OrderBy(path => path, StringComparer.Ordinal), + physicalDefinitions.Select(definition => definition.Path)); + + var hotspot = Assert.Single( + reader.GetGroupedSymbolHotspots( + limit: 20, + kind: "class", + lang: "csharp", + pathPatterns: ["*.cs"], + excludePathPatterns: null, + excludeTests: false), + result => result.Symbol.Name == "Runner5158"); + Assert.Equal(expectedDefinitionSites, hotspot.DefinitionSites); + Assert.Equal(expectedReferenceCount, hotspot.ReferenceCount); + + var defaultHotspot = Assert.Single( + reader.GetSymbolHotspots( + limit: 20, + kind: "class", + lang: "csharp", + pathPatterns: ["*.cs"], + excludePathPatterns: null, + excludeTests: false), + result => result.Symbol.Name == "Runner5158"); + Assert.Equal(expectedReferenceCount, defaultHotspot.ReferenceCount); + + var analysis = reader.AnalyzeSymbol( + "Runner5158", + limit: 20, + lang: "csharp", + exact: true); + Assert.Equal(expectedDefinitionSites, analysis.Definitions.Count); + Assert.Contains( + analysis.CandidateBundles!.SelectMany(bundle => bundle.References), + reference => reference.Path == "Consumer5158.cs" + && reference.ResolutionState == "resolved_group"); + + var dependencies = reader.GetFileDependencies( + limit: 20, + lang: "csharp", + pathPatterns: ["Consumer5158.cs"]); + Assert.Contains(dependencies, edge => edge.TargetPath == "Runner5158.A.cs"); + Assert.Contains( + dependencies, + edge => edge.TargetPath is "Runner5158.B.cs" or "Runner5158.Moved.cs"); + + var impact = reader.AnalyzeImpact( + "Demo.Runner5158", + maxDepth: 1, + limit: 20, + lang: "csharp"); + Assert.Contains( + impact.FileImpacts, + result => result.SourcePath == "Consumer5158.cs"); + } + } + finally + { + DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_UpdateMode_NoOpRepairsVersion4MarkdownCandidates_Issue4846() { @@ -283,7 +575,7 @@ public void Run_UpdateMode_NoOpRepairsVersion4MarkdownCandidates_Issue4846() var (initialExitCode, _) = RunAndCaptureJson([projectRoot, "--json"]); Assert.Equal(CommandExitCodes.Success, initialExitCode); - Assert.Equal(8, DbContext.ReferenceIdentityContractVersion); + Assert.Equal(9, DbContext.ReferenceIdentityContractVersion); var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); using (var connection = new SqliteConnection($"Data Source={dbPath}")) @@ -348,7 +640,9 @@ UPDATE codeindex_meta using var markerCommand = verification.CreateCommand(); markerCommand.CommandText = "SELECT value FROM codeindex_meta WHERE key = @key"; markerCommand.Parameters.AddWithValue("@key", DbContext.ReferenceIdentityContractVersionMetaKey); - Assert.Equal("8", Convert.ToString(markerCommand.ExecuteScalar(), CultureInfo.InvariantCulture)); + Assert.Equal( + DbContext.ReferenceIdentityContractVersion.ToString(CultureInfo.InvariantCulture), + Convert.ToString(markerCommand.ExecuteScalar(), CultureInfo.InvariantCulture)); using var resolutionCommand = verification.CreateCommand(); resolutionCommand.CommandText = """