From 2b951d1c7cd1f7f2a4920e4406663d2c8fc6a1ba Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 27 Jul 2026 05:44:50 +0900 Subject: [PATCH 1/4] Fix C# type reference target resolution (#4825) --- DEVELOPER_GUIDE.md | 21 ++ TESTING_GUIDE.md | 4 + changelog.d/unreleased/4825.fixed.md | 25 ++ .../Database/DbContext.ConnectionFunctions.cs | 12 + .../DbWriter.ReferenceGraphRefreshScope.cs | 7 +- src/CodeIndex/Database/DbWriter.References.cs | 171 +++++++++++- .../Indexer/CSharpTypeReferenceArity.cs | 263 ++++++++++++++++++ ...ferenceExtractor.CoreCallClassification.cs | 2 +- .../ReferenceExtractor.CoreExtraction.cs | 6 +- .../ReferenceExtractor.CoreLookups.cs | 19 +- tests/CodeIndex.Tests/DatabaseTests.cs | 8 +- .../QueryCommandRunnerIssue4825Tests.cs | 240 ++++++++++++++++ 12 files changed, 758 insertions(+), 20 deletions(-) create mode 100644 changelog.d/unreleased/4825.fixed.md create mode 100644 src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs create mode 100644 tests/CodeIndex.Tests/QueryCommandRunnerIssue4825Tests.cs diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 3303e8c2e3..a285913e66 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1052,6 +1052,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` candidates are filtered before qualifier and namespace ranking. A candidate +must be a type-like symbol (`class`, `struct`, `record`, `interface`, `enum`, or `delegate`), and +when the reference's generic arity can be recovered from its normalized line context, that arity +must match the declaration. Non-type name collisions and mismatched generic declarations never +participate in resolution; if no compatible candidate remains, the reference stays `unresolved`. +An uppercase property receiver such as `Name.Trim()` is rewritten to a qualified member reference +before graph resolution, including when the property is declared in another partial-class file, so +the type-only filter does not discard a real member dependency. +This is a general candidate-compatibility rule rather than a framework-type blacklist, and it +does not change Java reference resolution. + Reference finalization computes candidate count, minimum symbol ID, distinct target-family count, and stable target key in one correlated aggregate per reference. Keep these four resolution fields on the row-value assignment path; separate scalar subqueries multiply the @@ -4170,6 +4181,16 @@ resolution を再構築し、同じ transaction で marker を設定します。 対象となる symbol 集合で名前が一意の場合だけ global candidate を持ちます。それ以外は `ambiguous` または `unresolved` のままとし、dependency query は同名 edge へ fallback しません。 +C# の `type_reference` candidate は qualifier / namespace の順位付け前に絞り込みます。candidate は +型相当の symbol(`class`、`struct`、`record`、`interface`、`enum`、`delegate`)でなければならず、 +正規化済み行 context から reference の generic arity を復元できる場合は declaration の arity と +一致する必要があります。型ではない同名 symbol と arity 不一致の generic declaration は解決候補に +含めず、適合 candidate が残らなければ reference は `unresolved` のままです。これは framework 型の +blacklist ではなく一般的な candidate compatibility rule です。`Name.Trim()` のように大文字で始まる +property receiver は graph 解決前に修飾済み member reference へ書き換え、property が partial class の +別ファイルで宣言されている場合も、型限定 filter によって実在する member dependency を失わないようにします。 +Java の reference resolution は変更しません。 + reference finalization は、candidate count、最小 symbol ID、distinct target-family count、安定 target key を reference ごとに1回の correlated aggregate で計算します。この4つの resolution field は row-value assignment のまま維持してください。scalar subquery を分けると、大規模 graph で diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index cabd5c32b4..651924c587 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -503,6 +503,8 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding keeps SCSS variable/mixin/extend references beside SQL multiline MERGE hints, non-ASCII identifiers, quoted-string masking, and temporary-table body boundaries in one multi-file workspace with one CLI index. Use language-specific queries and unique per-file sentinels so count, kind, and line assertions remain independently diagnostic. - `QueryCommandRunnerTests.RunReferences_ExactJson_CSharpQueryBoundariesAndVisualBasicQuerySyntaxShareIndexedWorkspace` keeps C# LINQ range-member, parenthesized group-by, awaited keyword-local, nullable suffix, ternary, and keyword-local boundaries beside a Visual Basic query-syntax call in one multi-language workspace with one CLI index. Keep unique per-file sentinels and language-specific queries so zero-reference negatives and positive context, kind, container, and line contracts remain independently diagnostic. +- `QueryCommandRunnerTests.CSharpTypeReferences_ResolveOnlyToTypeLikeSymbolsWithMatchingArity_Issue4825` + indexes one real C# fixture containing user-defined generic types, unrelated same-name properties, `Action` / `Stream` references, and an uppercase property receiver split across partial-class files. Keep CLI `references`, `inspect`, and `deps` assertions together with the MCP `references`, `analyze_symbol`, and `deps` wrappers so unresolved diagnostics, generic-arity selection, false dependency-edge suppression, and preservation of real member dependencies cannot drift across surfaces. - `LspServerTests.cs` LSP JSON-RPC behavior and indexed editor semantics. Reference parity coverage must compare LSP locations with the matching CLI candidate bundle, including overload identity and both `includeDeclaration` states. Keep document/workspace identifier ranges and explicit-versus-inferred inlay hints in one source-semantics fixture so persisted-column anchoring, source confirmation, and hint suppression cannot drift independently. Document-symbol hierarchy coverage keeps positional and body record members in one same-line fixture, asserts the complete root-to-member shape and deterministic sibling order, and retains the separate same-range top-level controls so order-independent parent resolution cannot over-nest unrelated symbols. Keep a same-line duplicate-container fixture with distinct positional members so selection-column disambiguation cannot move an earlier member beneath a later same-named container. @@ -1381,6 +1383,8 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" は SCSS の variable / mixin / extend 参照と、SQL の multiline MERGE hint、非 ASCII identifier、quoted-string masking、temporary-table body boundary を、CLI index 1回の multi-file workspace で共有します。言語別 query と file ごとの固有 sentinel を使い、件数・kind・line assertion の診断性を独立に保ってください。 - `QueryCommandRunnerTests.RunReferences_ExactJson_CSharpQueryBoundariesAndVisualBasicQuerySyntaxShareIndexedWorkspace` は C# LINQ の range-member、parenthesized group-by、awaited keyword-local、nullable suffix、ternary、keyword-local boundary と Visual Basic query syntax 内の call を、CLI index 1回の multi-language workspace で共有します。file ごとの固有 sentinel と言語別 query を使い、zero-reference negative と positive の context、kind、container、line 契約の診断性を独立に保ってください。 +- `QueryCommandRunnerTests.CSharpTypeReferences_ResolveOnlyToTypeLikeSymbolsWithMatchingArity_Issue4825` + は user-defined generic type、無関係な同名 property、`Action` / `Stream` reference、partial class の別ファイルに分かれた大文字始まりの property receiver を含む実 C# fixture を1回 index します。CLI の `references`、`inspect`、`deps` と MCP wrapper の `references`、`analyze_symbol`、`deps` を同じ fixture で検証し、unresolved 診断、generic arity による選択、偽 dependency edge の抑制、実在する member dependency の維持が surface 間でずれないよう維持してください。 - `LspServerTests.cs` LSP の JSON-RPC 挙動と indexed editor semantics のテスト。reference parity coverage では、overload identity と `includeDeclaration` の両状態を含め、LSP location と対応する CLI candidate bundle を比較してください。document/workspace の identifier range と explicit/inferred inlay hint は 1 つの source-semantics fixture にまとめ、保存済み column の anchoring、source 上の確認、hint 抑制が別々に drift しないようにします。 document-symbol hierarchy coverage は positional member と body member を同一行の record fixture にまとめ、root から member までの完全な形状と決定的な sibling 順序を assert します。順序非依存の親解決が無関係な symbol を過剰に nest しないよう、same-range top-level の control は独立したまま維持してください。selection column による曖昧性解消で前の member が行内で後にある同名 container の配下へ移動しないよう、異なる positional member を持つ同一行の duplicate-container fixture も維持してください。 diff --git a/changelog.d/unreleased/4825.fixed.md b/changelog.d/unreleased/4825.fixed.md new file mode 100644 index 0000000000..369cbcc958 --- /dev/null +++ b/changelog.d/unreleased/4825.fixed.md @@ -0,0 +1,25 @@ +--- +category: fixed +issues: + - 4825 +affected: + - src/CodeIndex/Database/DbContext.ConnectionFunctions.cs + - src/CodeIndex/Database/DbWriter.ReferenceGraphRefreshScope.cs + - src/CodeIndex/Database/DbWriter.References.cs + - src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs + - src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallClassification.cs + - src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs + - src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs + - tests/CodeIndex.Tests/DatabaseTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerIssue4825Tests.cs + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **C# type references now resolve only to compatible type-like symbols (#4825)** — C# `type_reference` rows now require a type-like target with matching generic arity, so framework types such as `Action` and `Stream` no longer bind to unrelated same-name properties or create false dependency edges. References without a compatible indexed type remain unresolved, while uppercase property receivers retain their real member dependencies across partial-class files. + +## 日本語 + +- **C# の型参照を互換性のある型相当 symbol だけに解決するようにしました (#4825)** — C# の `type_reference` row は型相当の target と一致する generic arity を必須とするようになり、`Action` や `Stream` などの framework 型が無関係な同名 property に結び付いて偽の dependency edge を作らなくなりました。適合する index 済み型がない reference は unresolved のままとしつつ、大文字で始まる property receiver の実在する member dependency は partial class の別ファイル間でも維持します。 diff --git a/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs b/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs index fdb2a7712d..643a49e81e 100644 --- a/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs +++ b/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs @@ -70,6 +70,18 @@ internal static void RegisterConnectionFunctions(SqliteConnection connection) connection.CreateFunction( "csharp_identifier_occurrence_count", (string? text, string? identifier) => CountCSharpIdentifierOccurrences(text, identifier)); + connection.CreateFunction( + "csharp_reference_type_arity", + (string? context, string? identifier, long? columnNumber) => + CSharpTypeReferenceArity.GetReferenceArity(context, identifier, columnNumber)); + connection.CreateFunction( + "csharp_reference_is_member_receiver", + (string? context, string? identifier, long? columnNumber) => + CSharpTypeReferenceArity.IsMemberReceiver(context, identifier, columnNumber)); + connection.CreateFunction( + "csharp_definition_type_arity", + (string? signature, string? identifier, string? symbolKind) => + CSharpTypeReferenceArity.GetDefinitionArity(signature, identifier, symbolKind)); connection.CreateFunction( "sql_normalize_exact_source_name", (string? text, string? lang) => string.IsNullOrWhiteSpace(text) ? null : ExactSourceSearchNormalizer.Normalize(text, lang)); diff --git a/src/CodeIndex/Database/DbWriter.ReferenceGraphRefreshScope.cs b/src/CodeIndex/Database/DbWriter.ReferenceGraphRefreshScope.cs index bbc65bc9d7..f508d87384 100644 --- a/src/CodeIndex/Database/DbWriter.ReferenceGraphRefreshScope.cs +++ b/src/CodeIndex/Database/DbWriter.ReferenceGraphRefreshScope.cs @@ -47,6 +47,10 @@ FROM temp.{ReferenceGraphDirtyReferencesTable} AND r.source_symbol_id IS NOT {ReferenceSourceSymbolValueSql}; """; + private static string NormalizeCSharpPropertyReceiverReferencesScopedSql => + BuildCSharpPropertyReceiverNormalizationSql( + $"r.id IN (SELECT reference_id FROM temp.{ReferenceGraphDirtyReferencesTable})"); + private const string RefreshScopedReferenceUniqueFamiliesSql = $""" DELETE FROM temp.reference_unique_symbol_families; @@ -132,6 +136,7 @@ internal static IReadOnlyList ScopedReferenceGraphUpdateStatementsForTes => [ RefreshScopedReferenceSourceSymbolsSql, + NormalizeCSharpPropertyReceiverReferencesScopedSql, RefreshScopedReferenceResolutionValuesSql, RefreshScopedSelfReferenceSql, RefreshScopedMutualRecursionFlagsSql, @@ -143,7 +148,7 @@ private static string BuildScopedReferenceCandidatesSql() const string fullReferenceSourceSql = "FROM symbol_references AS r"; const string fullInstantiateSymbolSourceSql = "FROM symbols AS s"; const string fullInstantiateNamePredicateSql = "AND s.name_folded IS NOT NULL"; - const int expectedReferenceSourceCount = 10; + const int expectedReferenceSourceCount = 12; if (CountOrdinalOccurrences(RefreshReferenceCandidatesSql, fullDeleteSql) != 1 || CountOrdinalOccurrences(RefreshReferenceCandidatesSql, fullReferenceSourceSql) diff --git a/src/CodeIndex/Database/DbWriter.References.cs b/src/CodeIndex/Database/DbWriter.References.cs index 2a0cecdfaf..156ab70f01 100644 --- a/src/CodeIndex/Database/DbWriter.References.cs +++ b/src/CodeIndex/Database/DbWriter.References.cs @@ -110,6 +110,97 @@ PRIMARY KEY(lang, name_folded) ) WITHOUT ROWID """; + private const string CSharpTypeReferenceCandidatePredicateSql = """ + ( + source_file.lang <> 'csharp' + OR r.reference_kind <> 'type_reference' + OR CASE + WHEN s.kind NOT IN ('class', 'struct', 'record', 'interface', 'enum', 'delegate') THEN 0 + WHEN csharp_reference_type_arity( + COALESCE( + r.context, + (SELECT reference_line.context + FROM reference_lines AS reference_line + WHERE reference_line.id = r.reference_line_id)), + r.symbol_name, + r.column_number) IS NULL THEN 1 + WHEN csharp_definition_type_arity(s.signature, s.name, s.kind) + = csharp_reference_type_arity( + COALESCE( + r.context, + (SELECT reference_line.context + FROM reference_lines AS reference_line + WHERE reference_line.id = r.reference_line_id)), + r.symbol_name, + r.column_number) THEN 1 + ELSE 0 + END = 1 + ) + """; + + private static string BuildCSharpPropertyReceiverNormalizationSql(string scopePredicate) => + $""" + UPDATE symbol_references AS r + SET reference_kind = 'type_reference', + target_qualifier = NULL + WHERE {scopePredicate} + AND r.reference_kind = 'reference' + AND r.target_qualifier LIKE char(31) || 'property_receiver:%' + AND NOT EXISTS ( + SELECT 1 + FROM symbols AS source + JOIN files AS source_file ON source_file.id = source.file_id + JOIN symbols AS target + ON target.name_folded = r.symbol_name_folded + AND target.container_qualified_name = + source.container_qualified_name COLLATE NOCASE + JOIN files AS target_file ON target_file.id = target.file_id + WHERE source.id = r.source_symbol_id + AND source_file.lang = 'csharp' + AND target_file.lang = 'csharp' + AND target.kind = 'property' + ); + + UPDATE symbol_references AS r + SET reference_kind = 'reference', + target_qualifier = char(31) || 'property_receiver:' || ( + SELECT source.container_qualified_name + FROM symbols AS source + WHERE source.id = r.source_symbol_id + ) + WHERE {scopePredicate} + AND r.reference_kind = 'type_reference' + AND r.target_qualifier IS NULL + AND csharp_reference_is_member_receiver( + COALESCE( + r.context, + ( + SELECT reference_line.context + FROM reference_lines AS reference_line + WHERE reference_line.id = r.reference_line_id + ) + ), + r.symbol_name, + r.column_number) = 1 + AND EXISTS ( + SELECT 1 + FROM symbols AS source + JOIN files AS source_file ON source_file.id = source.file_id + JOIN symbols AS target + ON target.name_folded = r.symbol_name_folded + AND target.container_qualified_name = + source.container_qualified_name COLLATE NOCASE + JOIN files AS target_file ON target_file.id = target.file_id + WHERE source.id = r.source_symbol_id + AND source_file.lang = 'csharp' + AND target_file.lang = 'csharp' + AND target.kind = 'property' + ); + """; + + private static string NormalizeCSharpPropertyReceiverReferencesFullSql => + BuildCSharpPropertyReceiverNormalizationSql("1 = 1"); + private const string RefreshReferenceUniqueFamiliesSql = """ DELETE FROM temp.reference_unique_symbol_families; @@ -148,9 +239,23 @@ HAVING COUNT(DISTINCT target_file.path || char(31) || COALESCE(s.name, '')) = 1; """; - private const string RefreshReferenceCandidatesSql = """ + private static string RefreshReferenceCandidatesSql => $""" DELETE FROM symbol_reference_candidates; + INSERT INTO symbol_reference_candidates(reference_id, symbol_id, scope_rank) + SELECT r.id, s.id, 0 + FROM symbol_references AS r + JOIN files AS source_file ON source_file.id = r.file_id + JOIN symbols AS s ON s.name_folded = r.symbol_name_folded + JOIN files AS target_file ON target_file.id = s.file_id + WHERE source_file.lang = 'csharp' + AND target_file.lang = 'csharp' + AND r.reference_kind = 'reference' + AND r.target_qualifier = + char(31) || 'property_receiver:' || s.container_qualified_name + COLLATE NOCASE + AND s.kind = 'property'; + INSERT INTO symbol_reference_candidates(reference_id, symbol_id, scope_rank) SELECT r.id, s.id, 0 FROM symbol_references AS r @@ -167,6 +272,7 @@ THEN r.symbol_name_folded || 'attribute' END AND (source_file.lang <> 'ambiguous_m' OR source_file.id = target_file.id)) OR (source_file.lang = 'ambiguous_m' AND target_file.lang IN ('matlab', 'objc')) ) + AND {CSharpTypeReferenceCandidatePredicateSql} AND r.target_qualifier IS NOT NULL AND r.target_qualifier NOT LIKE char(31) || 'receiver:%' AND ( @@ -218,6 +324,7 @@ THEN r.symbol_name_folded || 'attribute' END JOIN files AS target_file ON target_file.id = s.file_id WHERE source_file.lang = 'csharp' AND target_file.lang = 'csharp' + AND {CSharpTypeReferenceCandidatePredicateSql} AND r.target_qualifier LIKE char(31) || 'receiver:%' AND source.signature IS NOT NULL AND source.signature <> '' @@ -253,6 +360,7 @@ THEN r.symbol_name_folded || 'attribute' END AND (source_file.lang <> 'ambiguous_m' OR source_file.id = target_file.id)) OR (source_file.lang = 'ambiguous_m' AND target_file.lang IN ('matlab', 'objc')) ) + AND {CSharpTypeReferenceCandidatePredicateSql} AND r.target_qualifier IS NULL AND s.file_id = r.file_id AND source.container_name IS NOT NULL @@ -283,6 +391,7 @@ THEN r.symbol_name_folded || 'attribute' END AND (source_file.lang <> 'ambiguous_m' OR source_file.id = target_file.id)) OR (source_file.lang = 'ambiguous_m' AND target_file.lang IN ('matlab', 'objc')) ) + AND {CSharpTypeReferenceCandidatePredicateSql} AND r.target_qualifier IS NULL AND source.container_qualified_name IS NOT NULL AND source.container_qualified_name <> '' @@ -308,6 +417,7 @@ THEN r.symbol_name_folded || 'attribute' END AND (source_file.lang <> 'ambiguous_m' OR source_file.id = target_file.id)) OR (source_file.lang = 'ambiguous_m' AND target_file.lang IN ('matlab', 'objc')) ) + AND {CSharpTypeReferenceCandidatePredicateSql} AND r.target_qualifier IS NULL AND s.file_id = r.file_id AND NOT EXISTS ( @@ -332,6 +442,7 @@ THEN r.symbol_name_folded || 'attribute' END AND (source_file.lang <> 'ambiguous_m' OR source_file.id = target_file.id)) OR (source_file.lang = 'ambiguous_m' AND target_file.lang IN ('matlab', 'objc')) ) + AND {CSharpTypeReferenceCandidatePredicateSql} AND r.target_qualifier IS NULL AND source.container_name IS NOT NULL AND source.container_name <> '' @@ -341,6 +452,58 @@ SELECT 1 FROM symbol_reference_candidates AS existing WHERE existing.reference_id = r.id ); + INSERT INTO symbol_reference_candidates(reference_id, symbol_id, scope_rank) + SELECT r.id, unique_target.symbol_id, 5 + FROM symbol_references AS r + JOIN files AS source_file ON source_file.id = r.file_id + JOIN ( + SELECT MIN(type_symbol.id) AS symbol_id, + type_symbol.name_folded, + csharp_definition_type_arity( + type_symbol.signature, + type_symbol.name, + type_symbol.kind) AS type_arity + FROM symbols AS type_symbol + JOIN files AS target_file ON target_file.id = type_symbol.file_id + WHERE target_file.lang = 'csharp' + AND type_symbol.name_folded IS NOT NULL + AND type_symbol.kind IN ('class', 'struct', 'record', 'interface', 'enum', 'delegate') + GROUP BY type_symbol.name_folded, type_arity + HAVING type_arity IS NOT NULL + AND COUNT(DISTINCT target_file.path || char(31) || + COALESCE( + type_symbol.container_qualified_name, + type_symbol.container_name, + '') || char(31) || + COALESCE(type_symbol.name, '')) = 1 + ) AS unique_target ON unique_target.name_folded = r.symbol_name_folded + WHERE source_file.lang = 'csharp' + AND r.target_qualifier IS NULL + AND r.reference_kind = 'type_reference' + AND ( + csharp_reference_type_arity( + COALESCE( + r.context, + (SELECT reference_line.context + FROM reference_lines AS reference_line + WHERE reference_line.id = r.reference_line_id)), + r.symbol_name, + r.column_number) IS NULL + OR unique_target.type_arity + = csharp_reference_type_arity( + COALESCE( + r.context, + (SELECT reference_line.context + FROM reference_lines AS reference_line + WHERE reference_line.id = r.reference_line_id)), + r.symbol_name, + r.column_number) + ) + AND NOT EXISTS ( + SELECT 1 FROM symbol_reference_candidates AS existing + WHERE existing.reference_id = r.id + ); + INSERT INTO symbol_reference_candidates(reference_id, symbol_id, scope_rank) SELECT r.id, target.id, 5 FROM symbol_references AS r @@ -399,7 +562,7 @@ JOIN files AS target_file COALESCE(target.name, '') = unique_family.family_key WHERE source_file.lang = 'csharp' AND r.target_qualifier IS NULL - AND r.reference_kind <> 'instantiate' + AND r.reference_kind NOT IN ('instantiate', 'type_reference') AND NOT EXISTS ( SELECT 1 FROM symbol_reference_candidates AS existing WHERE existing.reference_id = r.id @@ -530,6 +693,7 @@ internal static void RebuildRetainedReferenceGraph( command.CommandText = CreateReferenceUniqueFamiliesSql + ";\n" + RefreshReferenceSourceSymbolsFullSql + ";\n" + + NormalizeCSharpPropertyReceiverReferencesFullSql + "\n" + RefreshReferenceUniqueFamiliesSql + "\n" + RefreshReferenceCandidatesSql + "\n" + RefreshReferenceResolutionFullSql + "\n" + @@ -1165,10 +1329,12 @@ internal void RefreshMutualRecursionFlags(CancellationToken cancellationToken = { refreshIdentitySql = HasPersistedReferenceResolutionState(cancellationToken) ? RefreshReferenceSourceSymbolsDifferentialSql + ";\n" + + NormalizeCSharpPropertyReceiverReferencesFullSql + "\n" + RefreshReferenceUniqueFamiliesSql + "\n" + RefreshReferenceCandidatesSql + "\n" + RefreshReferenceResolutionDifferentialSql + "\n" : RefreshReferenceSourceSymbolsFullSql + ";\n" + + NormalizeCSharpPropertyReceiverReferencesFullSql + "\n" + RefreshReferenceUniqueFamiliesSql + "\n" + RefreshReferenceCandidatesSql + "\n" + RefreshReferenceResolutionFullSql + "\n"; @@ -1177,6 +1343,7 @@ internal void RefreshMutualRecursionFlags(CancellationToken cancellationToken = { DeleteRemovedReferenceCandidates(cancellationToken); refreshIdentitySql = RefreshScopedReferenceSourceSymbolsSql + "\n" + + NormalizeCSharpPropertyReceiverReferencesScopedSql + "\n" + RefreshScopedReferenceUniqueFamiliesSql + "\n" + RefreshScopedReferenceCandidatesSql + "\n" + RefreshScopedReferenceResolutionSql + "\n" + diff --git a/src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs b/src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs new file mode 100644 index 0000000000..c8a4817dc1 --- /dev/null +++ b/src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs @@ -0,0 +1,263 @@ +namespace CodeIndex.Indexer; + +internal static class CSharpTypeReferenceArity +{ + internal static int? GetReferenceArity(string? context, string? symbolName, long? columnNumber) + { + if (string.IsNullOrWhiteSpace(context) || string.IsNullOrWhiteSpace(symbolName)) + return null; + + var occurrence = FindClosestIdentifierOccurrence(context, symbolName, columnNumber); + return occurrence < 0 ? null : ReadArityAfterIdentifier(context, occurrence, symbolName.Length); + } + + internal static bool IsMemberReceiver(string? context, string? symbolName, long? columnNumber) + { + if (string.IsNullOrWhiteSpace(context) || string.IsNullOrWhiteSpace(symbolName)) + return false; + + var occurrence = FindClosestIdentifierOccurrence(context, symbolName, columnNumber); + if (occurrence < 0) + return false; + + var cursor = occurrence + symbolName.Length; + SkipWhitespace(context, ref cursor); + return cursor < context.Length && context[cursor] == '.'; + } + + internal static int? GetDefinitionArity(string? signature, string? symbolName, string? symbolKind) + { + if (string.IsNullOrWhiteSpace(symbolName)) + return null; + if (string.IsNullOrWhiteSpace(signature)) + return 0; + + var searchStart = FindDeclarationKeywordEnd(signature, symbolKind); + var occurrence = FindDefinitionIdentifierOccurrence(signature, symbolName, searchStart, symbolKind); + return occurrence < 0 ? null : ReadArityAfterIdentifier(signature, occurrence, symbolName.Length); + } + + private static int FindClosestIdentifierOccurrence(string text, string identifier, long? columnNumber) + { + var expectedIndex = columnNumber is > 0 and <= int.MaxValue + ? (int)columnNumber.Value - 1 + : 0; + var bestIndex = -1; + var bestDistance = int.MaxValue; + for (var searchAt = 0; searchAt <= text.Length - identifier.Length;) + { + var occurrence = text.IndexOf(identifier, searchAt, StringComparison.Ordinal); + if (occurrence < 0) + break; + + if (IsIdentifierOccurrence(text, occurrence, identifier.Length)) + { + var distance = Math.Abs(occurrence - expectedIndex); + if (distance < bestDistance) + { + bestIndex = occurrence; + bestDistance = distance; + } + } + + searchAt = occurrence + Math.Max(1, identifier.Length); + } + + return bestIndex; + } + + private static int FindDefinitionIdentifierOccurrence( + string signature, + string symbolName, + int searchStart, + string? symbolKind) + { + var fallback = -1; + for (var searchAt = Math.Clamp(searchStart, 0, signature.Length); + searchAt <= signature.Length - symbolName.Length;) + { + var occurrence = signature.IndexOf(symbolName, searchAt, StringComparison.Ordinal); + if (occurrence < 0) + break; + + if (IsIdentifierOccurrence(signature, occurrence, symbolName.Length)) + { + fallback = occurrence; + if (!string.Equals(symbolKind, "delegate", StringComparison.Ordinal) + || IsDelegateNameContinuation(signature, occurrence + symbolName.Length)) + { + return occurrence; + } + } + + searchAt = occurrence + Math.Max(1, symbolName.Length); + } + + return fallback; + } + + private static int FindDeclarationKeywordEnd(string signature, string? symbolKind) + { + if (string.IsNullOrWhiteSpace(symbolKind)) + return 0; + + var keyword = symbolKind switch + { + "class" => "class", + "struct" => "struct", + "record" => "record", + "interface" => "interface", + "enum" => "enum", + "delegate" => "delegate", + _ => null, + }; + if (keyword == null) + return 0; + + for (var searchAt = 0; searchAt <= signature.Length - keyword.Length;) + { + var occurrence = signature.IndexOf(keyword, searchAt, StringComparison.Ordinal); + if (occurrence < 0) + return 0; + if (IsIdentifierOccurrence(signature, occurrence, keyword.Length)) + return occurrence + keyword.Length; + searchAt = occurrence + keyword.Length; + } + + return 0; + } + + private static int? ReadArityAfterIdentifier(string text, int occurrence, int identifierLength) + { + var cursor = occurrence + identifierLength; + SkipWhitespace(text, ref cursor); + if (cursor >= text.Length || text[cursor] != '<') + return 0; + + return TryCountTopLevelTypeArguments(text, cursor, out var arity) ? arity : null; + } + + private static bool TryCountTopLevelTypeArguments(string text, int openAngleIndex, out int arity) + { + arity = 1; + var angleDepth = 1; + var parenthesisDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; + for (var i = openAngleIndex + 1; i < text.Length; i++) + { + var c = text[i]; + if (c == '"' || c == '\'') + { + i = SkipQuotedLiteral(text, i, c); + continue; + } + if (c == '/' && i + 1 < text.Length) + { + if (text[i + 1] == '/') + return false; + if (text[i + 1] == '*') + { + i = SkipBlockComment(text, i); + if (i >= text.Length) + return false; + continue; + } + } + + switch (c) + { + case '<': + angleDepth++; + break; + case '>': + angleDepth--; + if (angleDepth == 0) + return true; + break; + case '(': + parenthesisDepth++; + break; + case ')': + if (parenthesisDepth > 0) + parenthesisDepth--; + break; + case '[': + bracketDepth++; + break; + case ']': + if (bracketDepth > 0) + bracketDepth--; + break; + case '{': + braceDepth++; + break; + case '}': + if (braceDepth > 0) + braceDepth--; + break; + case ',' when angleDepth == 1 + && parenthesisDepth == 0 + && bracketDepth == 0 + && braceDepth == 0: + arity++; + break; + } + } + + return false; + } + + private static int SkipQuotedLiteral(string text, int quoteIndex, char quote) + { + var verbatim = quote == '"' && quoteIndex > 0 && text[quoteIndex - 1] == '@'; + for (var i = quoteIndex + 1; i < text.Length; i++) + { + if (!verbatim && text[i] == '\\' && i + 1 < text.Length) + { + i++; + continue; + } + if (text[i] != quote) + continue; + if (verbatim && i + 1 < text.Length && text[i + 1] == quote) + { + i++; + continue; + } + + return i; + } + + return text.Length; + } + + private static int SkipBlockComment(string text, int slashIndex) + { + var closeIndex = text.IndexOf("*/", slashIndex + 2, StringComparison.Ordinal); + return closeIndex < 0 ? text.Length : closeIndex + 1; + } + + private static bool IsDelegateNameContinuation(string signature, int cursor) + { + SkipWhitespace(signature, ref cursor); + return cursor < signature.Length && signature[cursor] is '<' or '('; + } + + private static bool IsIdentifierOccurrence(string text, int occurrence, int length) + { + var before = occurrence - 1; + var after = occurrence + length; + return (before < 0 || !IsIdentifierPart(text[before])) + && (after >= text.Length || !IsIdentifierPart(text[after])); + } + + private static bool IsIdentifierPart(char c) + => char.IsLetterOrDigit(c) || c == '_'; + + private static void SkipWhitespace(string text, ref int cursor) + { + while (cursor < text.Length && char.IsWhiteSpace(text[cursor])) + cursor++; + } +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallClassification.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallClassification.cs index 30a8e838c1..55c320d7c1 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallClassification.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallClassification.cs @@ -124,7 +124,7 @@ private static bool TryAddCoreCallLikeReference( out var receiverNames) && (receiverNames.InstanceNames.Contains(normalizedName) || receiverNames.StaticNames.Contains(normalizedName)) - && call.Lookups.HasCSharpPrivateProperty( + && call.Lookups.HasCSharpProperty( containingType, normalizedName)) { diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs index 0de0673e3b..4e3f2c2575 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs @@ -343,7 +343,7 @@ internal static List ExtractCore( if (language == "csharp") { - RewriteCSharpPrivatePropertyReceiverReferences( + RewriteCSharpPropertyReceiverReferences( preparedLines, references, lookups); @@ -360,7 +360,7 @@ internal static List ExtractCore( return references; } - private static void RewriteCSharpPrivatePropertyReceiverReferences( + private static void RewriteCSharpPropertyReceiverReferences( IReadOnlyList preparedLines, List references, CoreExtractionLookups lookups) @@ -391,7 +391,7 @@ private static void RewriteCSharpPrivatePropertyReceiverReferences( reference.Line); var containingType = GetContainingTypeQualifiedName(owner); if (containingType == null - || !lookups.HasCSharpPrivateProperty( + || !lookups.HasCSharpProperty( containingType, reference.SymbolName)) { diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs index 21a499a9f5..ee60ce0425 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs @@ -33,8 +33,8 @@ private sealed class CoreExtractionLookups private HashSet? pythonClassNames; private bool pythonClassNamesResolved; private PythonImportBindingResolver.ImportedTypeCallLookup? pythonImportedTypeCallLookup; - private HashSet<(string Container, string Name)>? csharpPrivateProperties; - private bool csharpPrivatePropertiesResolved; + private HashSet<(string Container, string Name)>? csharpProperties; + private bool csharpPropertiesResolved; private Dictionary>? csharpContainerCandidatesByName; private List<(int StartLine, int StartColumn, int EndLine, int EndColumn, SymbolRecord Container)>? recordPrimaryCtorRanges; private bool recordPrimaryCtorRangesResolved; @@ -90,23 +90,22 @@ internal bool HasSameFilePythonClass(string candidate, string leaf) internal PythonImportBindingResolver.ImportedTypeCallLookup GetPythonImportedTypeCallLookup() => pythonImportedTypeCallLookup ??= PythonImportBindingResolver.BuildImportedTypeCallLookup(symbols); - internal bool HasCSharpPrivateProperty(string containingType, string propertyName) + internal bool HasCSharpProperty(string containingType, string propertyName) { - if (!csharpPrivatePropertiesResolved) + if (!csharpPropertiesResolved) { - foreach (var symbol in symbols) + foreach (var symbol in request.WorkspaceSymbols ?? symbols) { if (symbol.Kind == "property" - && symbol.ContainerQualifiedName != null - && string.Equals(symbol.Visibility, "private", StringComparison.OrdinalIgnoreCase)) + && symbol.ContainerQualifiedName != null) { - (csharpPrivateProperties ??= []).Add((symbol.ContainerQualifiedName, symbol.Name)); + (csharpProperties ??= []).Add((symbol.ContainerQualifiedName, symbol.Name)); } } - csharpPrivatePropertiesResolved = true; + csharpPropertiesResolved = true; } - return csharpPrivateProperties?.Contains((containingType, propertyName)) == true; + return csharpProperties?.Contains((containingType, propertyName)) == true; } internal SymbolRecord? FindCSharpContainerCandidate(string? containerName, int lineNumber) diff --git a/tests/CodeIndex.Tests/DatabaseTests.cs b/tests/CodeIndex.Tests/DatabaseTests.cs index 33058c941e..c036917249 100644 --- a/tests/CodeIndex.Tests/DatabaseTests.cs +++ b/tests/CodeIndex.Tests/DatabaseTests.cs @@ -422,7 +422,7 @@ public void ReferenceGraphDirtyScope_GeneratedSqlUsesDirtyPrimaryKeySeeks() candidateSql, StringComparison.Ordinal); Assert.Equal( - 10, + 12, candidateSql.Split( "FROM temp.reference_graph_dirty_references AS dirty_reference", StringSplitOptions.None).Length - 1); @@ -433,7 +433,7 @@ public void ReferenceGraphDirtyScope_GeneratedSqlUsesDirtyPrimaryKeySeeks() "INSERT INTO symbol_reference_candidates", StringComparison.Ordinal)) .ToArray(); - Assert.Equal(10, candidateInserts.Length); + Assert.Equal(12, candidateInserts.Length); foreach (var statement in candidateInserts) { var plan = ReadQueryPlanDetails(_db.Connection, statement); @@ -446,7 +446,9 @@ public void ReferenceGraphDirtyScope_GeneratedSqlUsesDirtyPrimaryKeySeeks() } var instantiateStatement = Assert.Single(candidateInserts.Where(static statement => - statement.Contains("AS unique_target", StringComparison.Ordinal))); + statement.Contains( + "FROM temp.reference_graph_lookup_names AS lookup_name", + StringComparison.Ordinal))); var instantiatePlan = ReadQueryPlanDetails(_db.Connection, instantiateStatement); Assert.Contains(instantiatePlan, static detail => detail.Contains( "idx_symbols_name_folded", diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerIssue4825Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue4825Tests.cs new file mode 100644 index 0000000000..c9cbe534f4 --- /dev/null +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue4825Tests.cs @@ -0,0 +1,240 @@ +using System.Text.Json.Nodes; +using CodeIndex.Cli; +using CodeIndex.Mcp; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Tests; + +public partial class QueryCommandRunnerTests +{ + [Fact] + public void CSharpTypeReferences_ResolveOnlyToTypeLikeSymbolsWithMatchingArity_Issue4825() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_csharp_type_reference_issue4825"); + try + { + TestProjectHelper.WriteTextFile( + projectRoot, + "src/Types.cs", + """ + namespace Fixture.Types; + + public class Actual + { + } + + public class Actual + { + } + """); + TestProjectHelper.WriteTextFile( + projectRoot, + "src/Impostors.cs", + """ + namespace Fixture.Impostors; + + public sealed class CollisionHolder + { + public string Action { get; } = ""; + public string Stream { get; } = ""; + } + """); + TestProjectHelper.WriteTextFile( + projectRoot, + "src/Consumer.cs", + """ + using System; + using System.IO; + using Fixture.Types; + + namespace Fixture.Consumer; + + public sealed class Consumer + { + public Action? Callback { get; } + public Stream? Body { get; } + public Actual? One { get; } + public Fixture.Types.Actual? Two { get; } + } + """); + TestProjectHelper.WriteTextFile( + projectRoot, + "src/PartialState.cs", + """ + namespace Fixture.Partials; + + public partial class Service + { + public string Name { get; } = ""; + } + """); + TestProjectHelper.WriteTextFile( + projectRoot, + "src/PartialUse.cs", + """ + namespace Fixture.Partials; + + public partial class Service + { + public string Normalize() => Name.Trim(); + } + """); + + var (indexExitCode, _, indexStderr) = CaptureConsole(() => IndexCommandRunner.Run( + [projectRoot, "--json", "--quiet"], + _jsonOptions)); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + + Assert.Equal(CommandExitCodes.Success, indexExitCode); + Assert.Equal(string.Empty, indexStderr); + + foreach (var frameworkTypeName in new[] { "Action", "Stream" }) + { + var (referenceExitCode, referenceStdout, referenceStderr) = CaptureConsole( + () => QueryCommandRunner.RunReferences( + [ + frameworkTypeName, + "--db", dbPath, + "--json", + "--exact-name", + "--kind", "type_reference", + "--lang", "csharp", + ], + _jsonOptions)); + using var referenceDocument = ParseJsonOutput(referenceStdout); + var reference = referenceDocument.RootElement; + + Assert.Equal(CommandExitCodes.Success, referenceExitCode); + Assert.Equal(string.Empty, referenceStderr); + Assert.Equal("unresolved", reference.GetProperty("resolution_state").GetString()); + Assert.False(reference.TryGetProperty("resolution_candidate_count", out _)); + Assert.False(reference.TryGetProperty("target_symbol_id", out _)); + Assert.Equal("src/Consumer.cs", reference.GetProperty("path").GetString()); + } + + using (var connection = new SqliteConnection( + new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Mode = SqliteOpenMode.ReadOnly, + }.ToString())) + { + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT COALESCE(r.context, reference_line.context), + r.resolution_state, + s.kind, + s.signature + FROM symbol_references AS r + JOIN files AS source_file ON source_file.id = r.file_id + LEFT JOIN reference_lines AS reference_line ON reference_line.id = r.reference_line_id + LEFT JOIN symbols AS s ON s.id = r.target_symbol_id + WHERE source_file.path = 'src/Consumer.cs' + AND r.reference_kind = 'type_reference' + AND r.symbol_name = 'Actual' + ORDER BY r.line, r.column_number + """; + using var reader = command.ExecuteReader(); + var actualReferences = new List<(string Context, string? State, string? Kind, string? Signature)>(); + while (reader.Read()) + { + actualReferences.Add(( + reader.GetString(0), + reader.IsDBNull(1) ? null : reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetString(2), + reader.IsDBNull(3) ? null : reader.GetString(3))); + } + + Assert.Collection( + actualReferences, + reference => + { + Assert.Contains("Actual", reference.Context); + Assert.Equal("resolved", reference.State); + Assert.Equal("class", reference.Kind); + Assert.Contains("Actual", reference.Signature); + }, + reference => + { + Assert.Contains("Actual", reference.Context); + Assert.Equal("resolved", reference.State); + Assert.Equal("class", reference.Kind); + Assert.Contains("Actual", reference.Signature); + }); + } + + var (inspectExitCode, inspectStdout, inspectStderr) = CaptureConsole( + () => QueryCommandRunner.RunInspect( + ["Action", "--db", dbPath, "--json", "--exact-name", "--lang", "csharp"], + _jsonOptions)); + using var inspectDocument = ParseJsonOutput(inspectStdout); + + Assert.Equal(CommandExitCodes.Success, inspectExitCode); + Assert.Equal(string.Empty, inspectStderr); + Assert.Empty(inspectDocument.RootElement.GetProperty("references").EnumerateArray()); + + var (depsExitCode, depsStdout, depsStderr) = CaptureConsole( + () => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--json", "--lang", "csharp", "--limit", "100"], + _jsonOptions)); + using var depsDocument = ParseJsonOutput(depsStdout); + var dependencyEdges = depsDocument.RootElement.GetProperty("edges").EnumerateArray().ToArray(); + + Assert.Equal(CommandExitCodes.Success, depsExitCode); + Assert.Equal(string.Empty, depsStderr); + Assert.Contains( + dependencyEdges, + edge => edge.GetProperty("source_path").GetString() == "src/Consumer.cs" + && edge.GetProperty("target_path").GetString() == "src/Types.cs"); + Assert.DoesNotContain( + dependencyEdges, + edge => edge.GetProperty("source_path").GetString() == "src/Consumer.cs" + && edge.GetProperty("target_path").GetString() == "src/Impostors.cs"); + Assert.Contains( + dependencyEdges, + edge => edge.GetProperty("source_path").GetString() == "src/PartialUse.cs" + && edge.GetProperty("target_path").GetString() == "src/PartialState.cs"); + + using var server = new McpServer(dbPath, ConsoleUi.LoadVersion()); + var referencesRequest = JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"references","arguments":{"query":"Action","kind":"type_reference","lang":"csharp","exactName":true}}}""")!; + var referencesResponse = server.HandleMessage(referencesRequest)!; + var mcpReference = Assert.Single( + referencesResponse["result"]!["structuredContent"]!["results"]!.AsArray()); + + Assert.Equal("unresolved", mcpReference!["resolutionState"]!.GetValue()); + Assert.Null(mcpReference["targetSymbolId"]); + + var analyzeRequest = JsonNode.Parse( + """{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"analyze_symbol","arguments":{"query":"Action","lang":"csharp","exact":true}}}""")!; + var analyzeResponse = server.HandleMessage(analyzeRequest)!; + + Assert.Empty( + analyzeResponse["result"]!["structuredContent"]!["references"]!.AsArray()); + + var depsRequest = JsonNode.Parse( + """{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"deps","arguments":{"lang":"csharp","limit":100}}}""")!; + var depsResponse = server.HandleMessage(depsRequest)!; + var mcpDependencyEdges = + depsResponse["result"]!["structuredContent"]!["edges"]!.AsArray(); + + Assert.Contains( + mcpDependencyEdges, + edge => edge!["sourcePath"]!.GetValue() == "src/Consumer.cs" + && edge["targetPath"]!.GetValue() == "src/Types.cs"); + Assert.DoesNotContain( + mcpDependencyEdges, + edge => edge!["sourcePath"]!.GetValue() == "src/Consumer.cs" + && edge["targetPath"]!.GetValue() == "src/Impostors.cs"); + Assert.Contains( + mcpDependencyEdges, + edge => edge!["sourcePath"]!.GetValue() == "src/PartialUse.cs" + && edge["targetPath"]!.GetValue() == "src/PartialState.cs"); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } +} From fe05e1f4bccdf68adda7bfc1f0e514e8b785c983 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 27 Jul 2026 06:34:57 +0900 Subject: [PATCH 2/4] Address C# type reference review findings (#4825) --- DEVELOPER_GUIDE.md | 5 + TESTING_GUIDE.md | 4 +- changelog.d/unreleased/4825.fixed.md | 6 +- .../Database/DbContext.SchemaMetadata.cs | 9 +- .../DbWriter.ReferenceGraphRefreshScope.cs | 14 ++- src/CodeIndex/Database/DbWriter.References.cs | 6 +- .../Indexer/CSharpTypeReferenceArity.cs | 44 ++++++-- .../ReferenceExtractor.CoreLookups.cs | 2 +- tests/CodeIndex.Tests/DatabaseTests.cs | 15 +++ .../DbReaderSymbolIdentityTests.cs | 11 +- .../QueryCommandRunnerIssue4825Tests.cs | 105 ++++++++++++++++++ 11 files changed, 200 insertions(+), 21 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index a285913e66..5ada02087d 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1062,6 +1062,9 @@ before graph resolution, including when the property is declared in another part the type-only filter does not discard a real member dependency. This is a general candidate-compatibility rule rather than a framework-type blacklist, and it does not change Java reference resolution. +The persisted reference-identity contract is versioned for this rule, so indexes written before +the compatibility filter are treated as non-authoritative until a normal index refresh rebuilds +their candidates. Reference finalization computes candidate count, minimum symbol ID, distinct target-family count, and stable target key in one correlated aggregate per reference. Keep these four @@ -4190,6 +4193,8 @@ blacklist ではなく一般的な candidate compatibility rule です。`Name.T property receiver は graph 解決前に修飾済み member reference へ書き換え、property が partial class の 別ファイルで宣言されている場合も、型限定 filter によって実在する member dependency を失わないようにします。 Java の reference resolution は変更しません。 +この規則は persisted reference-identity contract の version 対象であり、compatibility filter 導入前に +作成された index は、通常の index 更新で candidate を再構築するまで非 authoritative として扱います。 reference finalization は、candidate count、最小 symbol ID、distinct target-family count、安定 target key を reference ごとに1回の correlated aggregate で計算します。この4つの resolution field は diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 651924c587..0b8c6781f0 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -504,7 +504,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding - `QueryCommandRunnerTests.RunReferences_ExactJson_CSharpQueryBoundariesAndVisualBasicQuerySyntaxShareIndexedWorkspace` keeps C# LINQ range-member, parenthesized group-by, awaited keyword-local, nullable suffix, ternary, and keyword-local boundaries beside a Visual Basic query-syntax call in one multi-language workspace with one CLI index. Keep unique per-file sentinels and language-specific queries so zero-reference negatives and positive context, kind, container, and line contracts remain independently diagnostic. - `QueryCommandRunnerTests.CSharpTypeReferences_ResolveOnlyToTypeLikeSymbolsWithMatchingArity_Issue4825` - indexes one real C# fixture containing user-defined generic types, unrelated same-name properties, `Action` / `Stream` references, and an uppercase property receiver split across partial-class files. Keep CLI `references`, `inspect`, and `deps` assertions together with the MCP `references`, `analyze_symbol`, and `deps` wrappers so unresolved diagnostics, generic-arity selection, false dependency-edge suppression, and preservation of real member dependencies cannot drift across surfaces. + indexes one real C# fixture containing user-defined generic classes and delegates, unrelated same-name properties, exact-case type/property collisions, `Action` / `Stream` references, and an uppercase property receiver split across partial-class files. Keep CLI `references`, `inspect`, and `deps` assertions together with the MCP `references`, `analyze_symbol`, and `deps` wrappers so unresolved diagnostics, generic-arity selection, case-sensitive receiver handling, false dependency-edge suppression, and preservation of real member dependencies cannot drift across surfaces. - `LspServerTests.cs` LSP JSON-RPC behavior and indexed editor semantics. Reference parity coverage must compare LSP locations with the matching CLI candidate bundle, including overload identity and both `includeDeclaration` states. Keep document/workspace identifier ranges and explicit-versus-inferred inlay hints in one source-semantics fixture so persisted-column anchoring, source confirmation, and hint suppression cannot drift independently. Document-symbol hierarchy coverage keeps positional and body record members in one same-line fixture, asserts the complete root-to-member shape and deterministic sibling order, and retains the separate same-range top-level controls so order-independent parent resolution cannot over-nest unrelated symbols. Keep a same-line duplicate-container fixture with distinct positional members so selection-column disambiguation cannot move an earlier member beneath a later same-named container. @@ -1384,7 +1384,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - `QueryCommandRunnerTests.RunReferences_ExactJson_CSharpQueryBoundariesAndVisualBasicQuerySyntaxShareIndexedWorkspace` は C# LINQ の range-member、parenthesized group-by、awaited keyword-local、nullable suffix、ternary、keyword-local boundary と Visual Basic query syntax 内の call を、CLI index 1回の multi-language workspace で共有します。file ごとの固有 sentinel と言語別 query を使い、zero-reference negative と positive の context、kind、container、line 契約の診断性を独立に保ってください。 - `QueryCommandRunnerTests.CSharpTypeReferences_ResolveOnlyToTypeLikeSymbolsWithMatchingArity_Issue4825` - は user-defined generic type、無関係な同名 property、`Action` / `Stream` reference、partial class の別ファイルに分かれた大文字始まりの property receiver を含む実 C# fixture を1回 index します。CLI の `references`、`inspect`、`deps` と MCP wrapper の `references`、`analyze_symbol`、`deps` を同じ fixture で検証し、unresolved 診断、generic arity による選択、偽 dependency edge の抑制、実在する member dependency の維持が surface 間でずれないよう維持してください。 + は user-defined generic class / delegate、無関係な同名 property、大文字小文字だけ異なる type / property collision、`Action` / `Stream` reference、partial class の別ファイルに分かれた大文字始まりの property receiver を含む実 C# fixture を1回 index します。CLI の `references`、`inspect`、`deps` と MCP wrapper の `references`、`analyze_symbol`、`deps` を同じ fixture で検証し、unresolved 診断、generic arity による選択、case-sensitive receiver 判定、偽 dependency edge の抑制、実在する member dependency の維持が surface 間でずれないよう維持してください。 - `LspServerTests.cs` LSP の JSON-RPC 挙動と indexed editor semantics のテスト。reference parity coverage では、overload identity と `includeDeclaration` の両状態を含め、LSP location と対応する CLI candidate bundle を比較してください。document/workspace の identifier range と explicit/inferred inlay hint は 1 つの source-semantics fixture にまとめ、保存済み column の anchoring、source 上の確認、hint 抑制が別々に drift しないようにします。 document-symbol hierarchy coverage は positional member と body member を同一行の record fixture にまとめ、root から member までの完全な形状と決定的な sibling 順序を assert します。順序非依存の親解決が無関係な symbol を過剰に nest しないよう、same-range top-level の control は独立したまま維持してください。selection column による曖昧性解消で前の member が行内で後にある同名 container の配下へ移動しないよう、異なる positional member を持つ同一行の duplicate-container fixture も維持してください。 diff --git a/changelog.d/unreleased/4825.fixed.md b/changelog.d/unreleased/4825.fixed.md index 369cbcc958..5cef54c99a 100644 --- a/changelog.d/unreleased/4825.fixed.md +++ b/changelog.d/unreleased/4825.fixed.md @@ -4,6 +4,7 @@ issues: - 4825 affected: - src/CodeIndex/Database/DbContext.ConnectionFunctions.cs + - src/CodeIndex/Database/DbContext.SchemaMetadata.cs - src/CodeIndex/Database/DbWriter.ReferenceGraphRefreshScope.cs - src/CodeIndex/Database/DbWriter.References.cs - src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs @@ -11,6 +12,7 @@ affected: - src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs - src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs - tests/CodeIndex.Tests/DatabaseTests.cs + - tests/CodeIndex.Tests/DbReaderSymbolIdentityTests.cs - tests/CodeIndex.Tests/QueryCommandRunnerIssue4825Tests.cs - DEVELOPER_GUIDE.md - TESTING_GUIDE.md @@ -18,8 +20,8 @@ affected: ## English -- **C# type references now resolve only to compatible type-like symbols (#4825)** — C# `type_reference` rows now require a type-like target with matching generic arity, so framework types such as `Action` and `Stream` no longer bind to unrelated same-name properties or create false dependency edges. References without a compatible indexed type remain unresolved, while uppercase property receivers retain their real member dependencies across partial-class files. +- **C# type references now resolve only to compatible type-like symbols (#4825)** — C# `type_reference` rows now require an exact-case type-like target with matching generic arity, including generic delegates whose return type shares the delegate name, so framework types such as `Action` and `Stream` no longer bind to unrelated same-name properties or create false dependency edges. References without a compatible indexed type remain unresolved, uppercase property receivers retain their real member dependencies across partial-class files, and indexes written under the prior reference-identity contract are treated as non-authoritative until refreshed. ## 日本語 -- **C# の型参照を互換性のある型相当 symbol だけに解決するようにしました (#4825)** — C# の `type_reference` row は型相当の target と一致する generic arity を必須とするようになり、`Action` や `Stream` などの framework 型が無関係な同名 property に結び付いて偽の dependency edge を作らなくなりました。適合する index 済み型がない reference は unresolved のままとしつつ、大文字で始まる property receiver の実在する member dependency は partial class の別ファイル間でも維持します。 +- **C# の型参照を互換性のある型相当 symbol だけに解決するようにしました (#4825)** — C# の `type_reference` row は大文字小文字が正確に一致する型相当 target と一致する generic arity を必須とし、戻り値型が delegate 名と同名の generic delegate も正しく扱うようになりました。`Action` や `Stream` などの framework 型が無関係な同名 property に結び付いて偽の dependency edge を作らなくなり、適合する index 済み型がない reference は unresolved のままです。大文字で始まる property receiver の実在する member dependency は partial class の別ファイル間でも維持し、以前の reference-identity contract で作成された index は更新されるまで非 authoritative として扱います。 diff --git a/src/CodeIndex/Database/DbContext.SchemaMetadata.cs b/src/CodeIndex/Database/DbContext.SchemaMetadata.cs index 6343f8225e..447cf80923 100644 --- a/src/CodeIndex/Database/DbContext.SchemaMetadata.cs +++ b/src/CodeIndex/Database/DbContext.SchemaMetadata.cs @@ -62,7 +62,14 @@ 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"; - public const int ReferenceIdentityContractVersion = 2; + // Version 3 (#4825) invalidates persisted reference candidates written before C# + // type references were constrained to type-like symbols with compatible generic arity. + // Version 2 DBs can otherwise keep authoritative-looking property bindings and false + // dependency edges until an explicit index refresh. + // バージョン 3 (#4825) では、C# の型参照を型相当 symbol と互換 generic arity に限定する + // 前に永続化された reference candidate を無効化する。バージョン 2 の DB をそのまま信頼すると、 + // 明示的な index 更新まで property への誤結合と偽 dependency edge が残るためである。 + public const int ReferenceIdentityContractVersion = 3; 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/DbWriter.ReferenceGraphRefreshScope.cs b/src/CodeIndex/Database/DbWriter.ReferenceGraphRefreshScope.cs index f508d87384..37e76edc5f 100644 --- a/src/CodeIndex/Database/DbWriter.ReferenceGraphRefreshScope.cs +++ b/src/CodeIndex/Database/DbWriter.ReferenceGraphRefreshScope.cs @@ -148,13 +148,17 @@ private static string BuildScopedReferenceCandidatesSql() const string fullReferenceSourceSql = "FROM symbol_references AS r"; const string fullInstantiateSymbolSourceSql = "FROM symbols AS s"; const string fullInstantiateNamePredicateSql = "AND s.name_folded IS NOT NULL"; + const string fullCSharpTypeSymbolSourceSql = "FROM symbols AS type_symbol"; + const string fullCSharpTypeNamePredicateSql = "AND type_symbol.name_folded IS NOT NULL"; const int expectedReferenceSourceCount = 12; if (CountOrdinalOccurrences(RefreshReferenceCandidatesSql, fullDeleteSql) != 1 || CountOrdinalOccurrences(RefreshReferenceCandidatesSql, fullReferenceSourceSql) != expectedReferenceSourceCount || CountOrdinalOccurrences(RefreshReferenceCandidatesSql, fullInstantiateSymbolSourceSql) != 1 - || CountOrdinalOccurrences(RefreshReferenceCandidatesSql, fullInstantiateNamePredicateSql) != 1) + || CountOrdinalOccurrences(RefreshReferenceCandidatesSql, fullInstantiateNamePredicateSql) != 1 + || CountOrdinalOccurrences(RefreshReferenceCandidatesSql, fullCSharpTypeSymbolSourceSql) != 1 + || CountOrdinalOccurrences(RefreshReferenceCandidatesSql, fullCSharpTypeNamePredicateSql) != 1) { throw new InvalidOperationException( "The reference-candidate SQL shape changed without updating the dirty-scope projection."); @@ -176,6 +180,14 @@ private static string BuildScopedReferenceCandidatesSql() .Replace( fullInstantiateNamePredicateSql, "AND lookup_name.lang = 'csharp'\n AND s.name_folded = lookup_name.name_folded", + StringComparison.Ordinal) + .Replace( + fullCSharpTypeSymbolSourceSql, + $"FROM temp.{ReferenceGraphLookupNamesTable} AS type_lookup_name\n CROSS JOIN symbols AS type_symbol INDEXED BY idx_symbols_name_folded", + StringComparison.Ordinal) + .Replace( + fullCSharpTypeNamePredicateSql, + "AND type_lookup_name.lang = 'csharp'\n AND type_symbol.name_folded = type_lookup_name.name_folded", StringComparison.Ordinal); } diff --git a/src/CodeIndex/Database/DbWriter.References.cs b/src/CodeIndex/Database/DbWriter.References.cs index 156ab70f01..684c7e6139 100644 --- a/src/CodeIndex/Database/DbWriter.References.cs +++ b/src/CodeIndex/Database/DbWriter.References.cs @@ -152,8 +152,9 @@ FROM symbols AS source JOIN files AS source_file ON source_file.id = source.file_id JOIN symbols AS target ON target.name_folded = r.symbol_name_folded + AND target.name = r.symbol_name COLLATE BINARY AND target.container_qualified_name = - source.container_qualified_name COLLATE NOCASE + source.container_qualified_name COLLATE BINARY JOIN files AS target_file ON target_file.id = target.file_id WHERE source.id = r.source_symbol_id AND source_file.lang = 'csharp' @@ -188,8 +189,9 @@ FROM symbols AS source JOIN files AS source_file ON source_file.id = source.file_id JOIN symbols AS target ON target.name_folded = r.symbol_name_folded + AND target.name = r.symbol_name COLLATE BINARY AND target.container_qualified_name = - source.container_qualified_name COLLATE NOCASE + source.container_qualified_name COLLATE BINARY JOIN files AS target_file ON target_file.id = target.file_id WHERE source.id = r.source_symbol_id AND source_file.lang = 'csharp' diff --git a/src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs b/src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs index c8a4817dc1..43099ab261 100644 --- a/src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs +++ b/src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs @@ -72,7 +72,7 @@ private static int FindDefinitionIdentifierOccurrence( int searchStart, string? symbolKind) { - var fallback = -1; + var isDelegate = string.Equals(symbolKind, "delegate", StringComparison.Ordinal); for (var searchAt = Math.Clamp(searchStart, 0, signature.Length); searchAt <= signature.Length - symbolName.Length;) { @@ -82,9 +82,8 @@ private static int FindDefinitionIdentifierOccurrence( if (IsIdentifierOccurrence(signature, occurrence, symbolName.Length)) { - fallback = occurrence; - if (!string.Equals(symbolKind, "delegate", StringComparison.Ordinal) - || IsDelegateNameContinuation(signature, occurrence + symbolName.Length)) + if (!isDelegate + || IsDelegateDeclarationName(signature, occurrence, symbolName.Length)) { return occurrence; } @@ -93,7 +92,7 @@ private static int FindDefinitionIdentifierOccurrence( searchAt = occurrence + Math.Max(1, symbolName.Length); } - return fallback; + return -1; } private static int FindDeclarationKeywordEnd(string signature, string? symbolKind) @@ -134,12 +133,17 @@ private static int FindDeclarationKeywordEnd(string signature, string? symbolKin if (cursor >= text.Length || text[cursor] != '<') return 0; - return TryCountTopLevelTypeArguments(text, cursor, out var arity) ? arity : null; + return TryCountTopLevelTypeArguments(text, cursor, out var arity, out _) ? arity : null; } - private static bool TryCountTopLevelTypeArguments(string text, int openAngleIndex, out int arity) + private static bool TryCountTopLevelTypeArguments( + string text, + int openAngleIndex, + out int arity, + out int closeAngleIndex) { arity = 1; + closeAngleIndex = -1; var angleDepth = 1; var parenthesisDepth = 0; var bracketDepth = 0; @@ -173,7 +177,10 @@ private static bool TryCountTopLevelTypeArguments(string text, int openAngleInde case '>': angleDepth--; if (angleDepth == 0) + { + closeAngleIndex = i; return true; + } break; case '(': parenthesisDepth++; @@ -238,10 +245,29 @@ private static int SkipBlockComment(string text, int slashIndex) return closeIndex < 0 ? text.Length : closeIndex + 1; } - private static bool IsDelegateNameContinuation(string signature, int cursor) + private static bool IsDelegateDeclarationName( + string signature, + int occurrence, + int identifierLength) { + var cursor = occurrence + identifierLength; SkipWhitespace(signature, ref cursor); - return cursor < signature.Length && signature[cursor] is '<' or '('; + if (cursor < signature.Length && signature[cursor] == '<') + { + if (!TryCountTopLevelTypeArguments( + signature, + cursor, + out _, + out var closeAngleIndex)) + { + return false; + } + + cursor = closeAngleIndex + 1; + SkipWhitespace(signature, ref cursor); + } + + return cursor < signature.Length && signature[cursor] == '('; } private static bool IsIdentifierOccurrence(string text, int occurrence, int length) diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs index ee60ce0425..cda6794600 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs @@ -94,7 +94,7 @@ internal bool HasCSharpProperty(string containingType, string propertyName) { if (!csharpPropertiesResolved) { - foreach (var symbol in request.WorkspaceSymbols ?? symbols) + foreach (var symbol in symbols) { if (symbol.Kind == "property" && symbol.ContainerQualifiedName != null) diff --git a/tests/CodeIndex.Tests/DatabaseTests.cs b/tests/CodeIndex.Tests/DatabaseTests.cs index c036917249..6077cea8c8 100644 --- a/tests/CodeIndex.Tests/DatabaseTests.cs +++ b/tests/CodeIndex.Tests/DatabaseTests.cs @@ -457,6 +457,21 @@ public void ReferenceGraphDirtyScope_GeneratedSqlUsesDirtyPrimaryKeySeeks() "SEARCH lookup_name USING PRIMARY KEY", StringComparison.OrdinalIgnoreCase)); + var csharpTypeStatement = Assert.Single(candidateInserts.Where(static statement => + statement.Contains( + "FROM temp.reference_graph_lookup_names AS type_lookup_name", + StringComparison.Ordinal))); + var csharpTypePlan = ReadQueryPlanDetails(_db.Connection, csharpTypeStatement); + Assert.Contains(csharpTypePlan, static detail => detail.Contains( + "idx_symbols_name_folded", + StringComparison.OrdinalIgnoreCase)); + Assert.Contains(csharpTypePlan, static detail => detail.Contains( + "SEARCH type_lookup_name USING PRIMARY KEY", + StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(csharpTypePlan, static detail => + detail.Equals("SCAN type_symbol", StringComparison.OrdinalIgnoreCase) + || detail.StartsWith("SCAN type_symbol ", StringComparison.OrdinalIgnoreCase)); + foreach (var statement in DbWriter.ScopedReferenceGraphUpdateStatementsForTesting) { var plan = ReadQueryPlanDetails(_db.Connection, statement); diff --git a/tests/CodeIndex.Tests/DbReaderSymbolIdentityTests.cs b/tests/CodeIndex.Tests/DbReaderSymbolIdentityTests.cs index bfdc01db14..6c8186049b 100644 --- a/tests/CodeIndex.Tests/DbReaderSymbolIdentityTests.cs +++ b/tests/CodeIndex.Tests/DbReaderSymbolIdentityTests.cs @@ -5,8 +5,11 @@ namespace CodeIndex.Tests; public partial class DbReaderTests { - [Fact] - public void SymbolIdentity_LegacySchemaColumnsWithoutReadyMarkerKeepNameFallbackUntilRefresh() + [Theory] + [InlineData(null)] + [InlineData("2")] + public void SymbolIdentity_MissingOrPriorReadyMarkerKeepsNameFallbackUntilRefresh( + string? persistedContractVersion) { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_reference_identity_legacy_marker"); try @@ -30,7 +33,9 @@ public static class Caller using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); var writer = new DbWriter(db.Connection); writer.MarkGraphReady(); - writer.SetMeta(DbContext.ReferenceIdentityContractVersionMetaKey, null); + writer.SetMeta( + DbContext.ReferenceIdentityContractVersionMetaKey, + persistedContractVersion); using (var clearIdentity = db.Connection.CreateCommand()) { clearIdentity.CommandText = """ diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerIssue4825Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue4825Tests.cs index c9cbe534f4..d5e642b586 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerIssue4825Tests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue4825Tests.cs @@ -26,6 +26,17 @@ public class Actual public class Actual { } + + public static class NAME + { + public static string Run() => ""; + } + + public class Handler + { + } + + public delegate Handler Handler(T input); """); TestProjectHelper.WriteTextFile( projectRoot, @@ -55,6 +66,31 @@ public sealed class Consumer public Stream? Body { get; } public Actual? One { get; } public Fixture.Types.Actual? Two { get; } + public Handler? Handler { get; } + } + """); + TestProjectHelper.WriteTextFile( + projectRoot, + "src/CaseProperty.cs", + """ + namespace Fixture.CaseSensitive; + + public partial class CaseConsumer + { + public string Name { get; } = ""; + } + """); + TestProjectHelper.WriteTextFile( + projectRoot, + "src/CaseUse.cs", + """ + using Fixture.Types; + + namespace Fixture.CaseSensitive; + + public partial class CaseConsumer + { + public string Invoke() => NAME.Run(); } """); TestProjectHelper.WriteTextFile( @@ -162,6 +198,59 @@ FROM symbol_references AS r Assert.Equal("class", reference.Kind); Assert.Contains("Actual", reference.Signature); }); + reader.Close(); + + command.CommandText = """ + SELECT r.symbol_name, + r.reference_kind, + r.resolution_state, + s.kind, + s.signature + FROM symbol_references AS r + JOIN files AS source_file ON source_file.id = r.file_id + LEFT JOIN symbols AS s ON s.id = r.target_symbol_id + WHERE ( + source_file.path = 'src/CaseUse.cs' + AND r.symbol_name = 'NAME' + ) + OR ( + source_file.path = 'src/Consumer.cs' + AND r.symbol_name = 'Handler' + AND r.reference_kind = 'type_reference' + ) + ORDER BY source_file.path, r.line, r.column_number + """; + using var compatibilityReader = command.ExecuteReader(); + var compatibilityReferences = + new List<(string Name, string Kind, string? State, string? TargetKind, string? Signature)>(); + while (compatibilityReader.Read()) + { + compatibilityReferences.Add(( + compatibilityReader.GetString(0), + compatibilityReader.GetString(1), + compatibilityReader.IsDBNull(2) ? null : compatibilityReader.GetString(2), + compatibilityReader.IsDBNull(3) ? null : compatibilityReader.GetString(3), + compatibilityReader.IsDBNull(4) ? null : compatibilityReader.GetString(4))); + } + + Assert.Collection( + compatibilityReferences, + reference => + { + Assert.Equal("NAME", reference.Name); + Assert.Equal("type_reference", reference.Kind); + Assert.Equal("resolved", reference.State); + Assert.Equal("class", reference.TargetKind); + Assert.Contains("class NAME", reference.Signature); + }, + reference => + { + Assert.Equal("Handler", reference.Name); + Assert.Equal("type_reference", reference.Kind); + Assert.Equal("resolved", reference.State); + Assert.Equal("delegate", reference.TargetKind); + Assert.Contains("Handler", reference.Signature); + }); } var (inspectExitCode, inspectStdout, inspectStderr) = CaptureConsole( @@ -195,6 +284,14 @@ FROM symbol_references AS r dependencyEdges, edge => edge.GetProperty("source_path").GetString() == "src/PartialUse.cs" && edge.GetProperty("target_path").GetString() == "src/PartialState.cs"); + Assert.Contains( + dependencyEdges, + edge => edge.GetProperty("source_path").GetString() == "src/CaseUse.cs" + && edge.GetProperty("target_path").GetString() == "src/Types.cs"); + Assert.DoesNotContain( + dependencyEdges, + edge => edge.GetProperty("source_path").GetString() == "src/CaseUse.cs" + && edge.GetProperty("target_path").GetString() == "src/CaseProperty.cs"); using var server = new McpServer(dbPath, ConsoleUi.LoadVersion()); var referencesRequest = JsonNode.Parse( @@ -231,6 +328,14 @@ FROM symbol_references AS r mcpDependencyEdges, edge => edge!["sourcePath"]!.GetValue() == "src/PartialUse.cs" && edge["targetPath"]!.GetValue() == "src/PartialState.cs"); + Assert.Contains( + mcpDependencyEdges, + edge => edge!["sourcePath"]!.GetValue() == "src/CaseUse.cs" + && edge["targetPath"]!.GetValue() == "src/Types.cs"); + Assert.DoesNotContain( + mcpDependencyEdges, + edge => edge!["sourcePath"]!.GetValue() == "src/CaseUse.cs" + && edge["targetPath"]!.GetValue() == "src/CaseProperty.cs"); } finally { From 288b2eae9e4816a683b74c341f40460ceef334f0 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 27 Jul 2026 07:36:11 +0900 Subject: [PATCH 3/4] Address final C# type reference review findings (#4825) --- DEVELOPER_GUIDE.md | 17 +- TESTING_GUIDE.md | 4 +- changelog.d/unreleased/4825.fixed.md | 4 +- .../Database/DbContext.ConnectionFunctions.cs | 102 ++++++++++ .../DbWriter.ReferenceGraphRefreshScope.cs | 9 +- src/CodeIndex/Database/DbWriter.References.cs | 185 +++++++++++++++++- .../Indexer/CSharpTypeReferenceArity.cs | 76 ++++++- .../QueryCommandRunnerIssue4825Tests.cs | 145 ++++++++++++++ 8 files changed, 519 insertions(+), 23 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 5ada02087d..400a87cb2a 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1055,11 +1055,15 @@ fall back to a same-name edge. C# `type_reference` candidates are filtered before qualifier and namespace ranking. A candidate must be a type-like symbol (`class`, `struct`, `record`, `interface`, `enum`, or `delegate`), and when the reference's generic arity can be recovered from its normalized line context, that arity -must match the declaration. Non-type name collisions and mismatched generic declarations never +must match the declaration. Arity recovery skips valid block-comment trivia and treats the original +source column as an upper bound after the persisted context has been trimmed, so a later same-name +generic on the line cannot steal the reference. Non-type name collisions, ordinal-case mismatches, +and mismatched generic declarations never participate in resolution; if no compatible candidate remains, the reference stays `unresolved`. An uppercase property receiver such as `Name.Trim()` is rewritten to a qualified member reference -before graph resolution, including when the property is declared in another partial-class file, so -the type-only filter does not discard a real member dependency. +before graph resolution, including when the property is declared in another partial-class file or +an indexed base class. The closest property in the inheritance chain is selected with ordinal name +matching, so the type-only filter does not discard or ambiguously retarget a real member dependency. This is a general candidate-compatibility rule rather than a framework-type blacklist, and it does not change Java reference resolution. The persisted reference-identity contract is versioned for this rule, so indexes written before @@ -4187,11 +4191,14 @@ resolution を再構築し、同じ transaction で marker を設定します。 C# の `type_reference` candidate は qualifier / namespace の順位付け前に絞り込みます。candidate は 型相当の symbol(`class`、`struct`、`record`、`interface`、`enum`、`delegate`)でなければならず、 正規化済み行 context から reference の generic arity を復元できる場合は declaration の arity と -一致する必要があります。型ではない同名 symbol と arity 不一致の generic declaration は解決候補に +一致する必要があります。arity の復元では有効な block comment trivia を読み飛ばし、永続化 context の +trim 後は元の source column を上限として扱うため、同じ行の後続同名 generic を誤って選びません。 +型ではない同名 symbol、大文字小文字が ordinal 一致しない symbol、arity 不一致の generic declaration は解決候補に 含めず、適合 candidate が残らなければ reference は `unresolved` のままです。これは framework 型の blacklist ではなく一般的な candidate compatibility rule です。`Name.Trim()` のように大文字で始まる property receiver は graph 解決前に修飾済み member reference へ書き換え、property が partial class の -別ファイルで宣言されている場合も、型限定 filter によって実在する member dependency を失わないようにします。 +別ファイルまたは index 済み base class で宣言されている場合も inheritance chain 上で最も近い property を +ordinal 名一致で選び、型限定 filter によって実在する member dependency を失ったり曖昧化したりしないようにします。 Java の reference resolution は変更しません。 この規則は persisted reference-identity contract の version 対象であり、compatibility filter 導入前に 作成された index は、通常の index 更新で candidate を再構築するまで非 authoritative として扱います。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 0b8c6781f0..8ff9dafb34 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -504,7 +504,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding - `QueryCommandRunnerTests.RunReferences_ExactJson_CSharpQueryBoundariesAndVisualBasicQuerySyntaxShareIndexedWorkspace` keeps C# LINQ range-member, parenthesized group-by, awaited keyword-local, nullable suffix, ternary, and keyword-local boundaries beside a Visual Basic query-syntax call in one multi-language workspace with one CLI index. Keep unique per-file sentinels and language-specific queries so zero-reference negatives and positive context, kind, container, and line contracts remain independently diagnostic. - `QueryCommandRunnerTests.CSharpTypeReferences_ResolveOnlyToTypeLikeSymbolsWithMatchingArity_Issue4825` - indexes one real C# fixture containing user-defined generic classes and delegates, unrelated same-name properties, exact-case type/property collisions, `Action` / `Stream` references, and an uppercase property receiver split across partial-class files. Keep CLI `references`, `inspect`, and `deps` assertions together with the MCP `references`, `analyze_symbol`, and `deps` wrappers so unresolved diagnostics, generic-arity selection, case-sensitive receiver handling, false dependency-edge suppression, and preservation of real member dependencies cannot drift across surfaces. + indexes one real C# fixture containing user-defined generic classes and delegates, block-comment generic trivia, repeated same-line generic names under indentation, unrelated same-name properties, ordinal-case type/property collisions, `Action` / `Stream` references, and uppercase property receivers split across partial-class and inherited-base files. Keep CLI `references`, `inspect`, and `deps` assertions together with the MCP `references`, `analyze_symbol`, and `deps` wrappers so unresolved diagnostics, generic-arity selection, case-sensitive receiver handling, false dependency-edge suppression, and preservation of real member dependencies cannot drift across surfaces. - `LspServerTests.cs` LSP JSON-RPC behavior and indexed editor semantics. Reference parity coverage must compare LSP locations with the matching CLI candidate bundle, including overload identity and both `includeDeclaration` states. Keep document/workspace identifier ranges and explicit-versus-inferred inlay hints in one source-semantics fixture so persisted-column anchoring, source confirmation, and hint suppression cannot drift independently. Document-symbol hierarchy coverage keeps positional and body record members in one same-line fixture, asserts the complete root-to-member shape and deterministic sibling order, and retains the separate same-range top-level controls so order-independent parent resolution cannot over-nest unrelated symbols. Keep a same-line duplicate-container fixture with distinct positional members so selection-column disambiguation cannot move an earlier member beneath a later same-named container. @@ -1384,7 +1384,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - `QueryCommandRunnerTests.RunReferences_ExactJson_CSharpQueryBoundariesAndVisualBasicQuerySyntaxShareIndexedWorkspace` は C# LINQ の range-member、parenthesized group-by、awaited keyword-local、nullable suffix、ternary、keyword-local boundary と Visual Basic query syntax 内の call を、CLI index 1回の multi-language workspace で共有します。file ごとの固有 sentinel と言語別 query を使い、zero-reference negative と positive の context、kind、container、line 契約の診断性を独立に保ってください。 - `QueryCommandRunnerTests.CSharpTypeReferences_ResolveOnlyToTypeLikeSymbolsWithMatchingArity_Issue4825` - は user-defined generic class / delegate、無関係な同名 property、大文字小文字だけ異なる type / property collision、`Action` / `Stream` reference、partial class の別ファイルに分かれた大文字始まりの property receiver を含む実 C# fixture を1回 index します。CLI の `references`、`inspect`、`deps` と MCP wrapper の `references`、`analyze_symbol`、`deps` を同じ fixture で検証し、unresolved 診断、generic arity による選択、case-sensitive receiver 判定、偽 dependency edge の抑制、実在する member dependency の維持が surface 間でずれないよう維持してください。 + は user-defined generic class / delegate、generic 間の block comment trivia、indent 下で同一行に反復する同名 generic、無関係な同名 property、大文字小文字だけ異なる type / property collision、`Action` / `Stream` reference、partial class の別ファイルおよび継承 base file に分かれた大文字始まりの property receiver を含む実 C# fixture を1回 index します。CLI の `references`、`inspect`、`deps` と MCP wrapper の `references`、`analyze_symbol`、`deps` を同じ fixture で検証し、unresolved 診断、generic arity による選択、case-sensitive receiver 判定、偽 dependency edge の抑制、実在する member dependency の維持が surface 間でずれないよう維持してください。 - `LspServerTests.cs` LSP の JSON-RPC 挙動と indexed editor semantics のテスト。reference parity coverage では、overload identity と `includeDeclaration` の両状態を含め、LSP location と対応する CLI candidate bundle を比較してください。document/workspace の identifier range と explicit/inferred inlay hint は 1 つの source-semantics fixture にまとめ、保存済み column の anchoring、source 上の確認、hint 抑制が別々に drift しないようにします。 document-symbol hierarchy coverage は positional member と body member を同一行の record fixture にまとめ、root から member までの完全な形状と決定的な sibling 順序を assert します。順序非依存の親解決が無関係な symbol を過剰に nest しないよう、same-range top-level の control は独立したまま維持してください。selection column による曖昧性解消で前の member が行内で後にある同名 container の配下へ移動しないよう、異なる positional member を持つ同一行の duplicate-container fixture も維持してください。 diff --git a/changelog.d/unreleased/4825.fixed.md b/changelog.d/unreleased/4825.fixed.md index 5cef54c99a..24ab5a205f 100644 --- a/changelog.d/unreleased/4825.fixed.md +++ b/changelog.d/unreleased/4825.fixed.md @@ -20,8 +20,8 @@ affected: ## English -- **C# type references now resolve only to compatible type-like symbols (#4825)** — C# `type_reference` rows now require an exact-case type-like target with matching generic arity, including generic delegates whose return type shares the delegate name, so framework types such as `Action` and `Stream` no longer bind to unrelated same-name properties or create false dependency edges. References without a compatible indexed type remain unresolved, uppercase property receivers retain their real member dependencies across partial-class files, and indexes written under the prior reference-identity contract are treated as non-authoritative until refreshed. +- **C# type references now resolve only to compatible type-like symbols (#4825)** — C# `type_reference` rows now require an exact-case type-like target with matching generic arity, including generic delegates whose return type shares the delegate name, block-comment generic trivia, and repeated same-line names in trimmed contexts. Framework types such as `Action` and `Stream` no longer bind to unrelated same-name properties or create false dependency edges. References without a compatible indexed type remain unresolved, uppercase property receivers retain exact member dependencies across partial-class and inherited-base files, and indexes written under the prior reference-identity contract are treated as non-authoritative until refreshed. ## 日本語 -- **C# の型参照を互換性のある型相当 symbol だけに解決するようにしました (#4825)** — C# の `type_reference` row は大文字小文字が正確に一致する型相当 target と一致する generic arity を必須とし、戻り値型が delegate 名と同名の generic delegate も正しく扱うようになりました。`Action` や `Stream` などの framework 型が無関係な同名 property に結び付いて偽の dependency edge を作らなくなり、適合する index 済み型がない reference は unresolved のままです。大文字で始まる property receiver の実在する member dependency は partial class の別ファイル間でも維持し、以前の reference-identity contract で作成された index は更新されるまで非 authoritative として扱います。 +- **C# の型参照を互換性のある型相当 symbol だけに解決するようにしました (#4825)** — C# の `type_reference` row は大文字小文字が正確に一致する型相当 target と一致する generic arity を必須とし、戻り値型が delegate 名と同名の generic delegate、generic 間の block comment trivia、trim 済み context 内で同一行に反復する名前も正しく扱うようになりました。`Action` や `Stream` などの framework 型が無関係な同名 property に結び付いて偽の dependency edge を作らなくなり、適合する index 済み型がない reference は unresolved のままです。大文字で始まる property receiver の実在する member dependency は partial class の別ファイル間と継承 base file の双方で ordinal 一致を維持し、以前の reference-identity contract で作成された index は更新されるまで非 authoritative として扱います。 diff --git a/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs b/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs index 643a49e81e..39d27b1b3c 100644 --- a/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs +++ b/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs @@ -5,6 +5,7 @@ using Microsoft.Data.Sqlite; using System.Globalization; using System.Runtime.ExceptionServices; +using System.Text.Json; namespace CodeIndex.Database; @@ -82,6 +83,28 @@ internal static void RegisterConnectionFunctions(SqliteConnection connection) "csharp_definition_type_arity", (string? signature, string? identifier, string? symbolKind) => CSharpTypeReferenceArity.GetDefinitionArity(signature, identifier, symbolKind)); + connection.CreateFunction( + "csharp_base_identifiers_json", + (string? signature) => + JsonSerializer.Serialize(DbWriter.ParseCSharpBaseIdentifiers(signature))); + connection.CreateFunction( + "csharp_base_name_folded", + (string? baseReference) => + { + var leaf = GetCSharpBaseReferenceLeaf(baseReference); + return leaf == null ? null : NameFold.Fold(leaf) ?? leaf; + }); + connection.CreateFunction( + "csharp_base_name", + (string? baseReference) => GetCSharpBaseReferenceLeaf(baseReference)); + connection.CreateFunction( + "csharp_base_reference_matches", + (string? baseReference, string? candidateName, string? candidateQualifiedName, string? derivingQualifiedName) => + CSharpBaseReferenceMatches( + baseReference, + candidateName, + candidateQualifiedName, + derivingQualifiedName) ? 1 : 0); connection.CreateFunction( "sql_normalize_exact_source_name", (string? text, string? lang) => string.IsNullOrWhiteSpace(text) ? null : ExactSourceSearchNormalizer.Normalize(text, lang)); @@ -182,6 +205,85 @@ internal static int CountCSharpIdentifierOccurrences(string? text, string? ident return count; } + private static bool CSharpBaseReferenceMatches( + string? baseReference, + string? candidateName, + string? candidateQualifiedName, + string? derivingQualifiedName) + { + if (string.IsNullOrWhiteSpace(baseReference) + || string.IsNullOrWhiteSpace(candidateName) + || string.IsNullOrWhiteSpace(candidateQualifiedName) + || string.IsNullOrWhiteSpace(derivingQualifiedName)) + { + return false; + } + + var normalizedReference = + CSharpVerbatimNameNormalizer.Normalize(baseReference.Trim()); + if (normalizedReference.StartsWith("global::", StringComparison.Ordinal)) + normalizedReference = normalizedReference["global::".Length..]; + + var normalizedCandidateName = + CSharpVerbatimNameNormalizer.Normalize(candidateName.Trim()); + var normalizedCandidateQualifiedName = + CSharpVerbatimNameNormalizer.Normalize(candidateQualifiedName.Trim()); + if (normalizedReference.Contains('.', StringComparison.Ordinal) + || normalizedReference.Contains("::", StringComparison.Ordinal)) + { + return string.Equals( + normalizedReference.Replace("::", ".", StringComparison.Ordinal), + normalizedCandidateQualifiedName, + StringComparison.Ordinal); + } + + if (!string.Equals( + normalizedReference, + normalizedCandidateName, + StringComparison.Ordinal)) + { + return false; + } + + var derivingScope = GetQualifiedNameScope( + CSharpVerbatimNameNormalizer.Normalize(derivingQualifiedName.Trim())); + var candidateScope = GetQualifiedNameScope(normalizedCandidateQualifiedName); + while (true) + { + if (string.Equals( + derivingScope, + candidateScope, + StringComparison.Ordinal)) + { + return true; + } + + if (derivingScope.Length == 0) + return false; + derivingScope = GetQualifiedNameScope(derivingScope); + } + } + + private static string? GetCSharpBaseReferenceLeaf(string? baseReference) + { + if (string.IsNullOrWhiteSpace(baseReference)) + return null; + + var normalized = + CSharpVerbatimNameNormalizer.Normalize(baseReference.Trim()); + if (normalized.StartsWith("global::", StringComparison.Ordinal)) + normalized = normalized["global::".Length..]; + normalized = normalized.Replace("::", ".", StringComparison.Ordinal); + var lastDot = normalized.LastIndexOf('.'); + return lastDot < 0 ? normalized : normalized[(lastDot + 1)..]; + } + + private static string GetQualifiedNameScope(string qualifiedName) + { + var lastDot = qualifiedName.LastIndexOf('.'); + return lastDot < 0 ? string.Empty : qualifiedName[..lastDot]; + } + internal static bool HasCSharpIdentifierOccurrenceOutsideLineRange(string? text, string? identifier, int startLine, int endLine) { if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(identifier)) diff --git a/src/CodeIndex/Database/DbWriter.ReferenceGraphRefreshScope.cs b/src/CodeIndex/Database/DbWriter.ReferenceGraphRefreshScope.cs index 37e76edc5f..1f206d70d0 100644 --- a/src/CodeIndex/Database/DbWriter.ReferenceGraphRefreshScope.cs +++ b/src/CodeIndex/Database/DbWriter.ReferenceGraphRefreshScope.cs @@ -136,7 +136,14 @@ internal static IReadOnlyList ScopedReferenceGraphUpdateStatementsForTes => [ RefreshScopedReferenceSourceSymbolsSql, - NormalizeCSharpPropertyReceiverReferencesScopedSql, + .. NormalizeCSharpPropertyReceiverReferencesScopedSql + .Split( + ';', + StringSplitOptions.RemoveEmptyEntries + | StringSplitOptions.TrimEntries) + .Where(static statement => statement.StartsWith( + "UPDATE symbol_references", + StringComparison.Ordinal)), RefreshScopedReferenceResolutionValuesSql, RefreshScopedSelfReferenceSql, RefreshScopedMutualRecursionFlagsSql, diff --git a/src/CodeIndex/Database/DbWriter.References.cs b/src/CodeIndex/Database/DbWriter.References.cs index 684c7e6139..c3726fc1ee 100644 --- a/src/CodeIndex/Database/DbWriter.References.cs +++ b/src/CodeIndex/Database/DbWriter.References.cs @@ -107,6 +107,19 @@ CREATE TEMP TABLE IF NOT EXISTS reference_unique_symbol_families ( name_folded TEXT NOT NULL, family_key TEXT NOT NULL, PRIMARY KEY(lang, name_folded) + ) WITHOUT ROWID; + + CREATE TEMP TABLE IF NOT EXISTS csharp_type_direct_bases ( + derived_qualified_name TEXT NOT NULL, + base_qualified_name TEXT NOT NULL, + PRIMARY KEY(derived_qualified_name, base_qualified_name) + ) WITHOUT ROWID; + + CREATE TEMP TABLE IF NOT EXISTS csharp_type_inheritance ( + derived_qualified_name TEXT NOT NULL, + base_qualified_name TEXT NOT NULL, + distance INTEGER NOT NULL, + PRIMARY KEY(derived_qualified_name, base_qualified_name) ) WITHOUT ROWID """; @@ -116,6 +129,7 @@ PRIMARY KEY(lang, name_folded) OR r.reference_kind <> 'type_reference' OR CASE WHEN s.kind NOT IN ('class', 'struct', 'record', 'interface', 'enum', 'delegate') THEN 0 + WHEN s.name <> r.symbol_name COLLATE BINARY THEN 0 WHEN csharp_reference_type_arity( COALESCE( r.context, @@ -140,6 +154,114 @@ ELSE 0 private static string BuildCSharpPropertyReceiverNormalizationSql(string scopePredicate) => $""" + DELETE FROM temp.csharp_type_direct_bases; + DELETE FROM temp.csharp_type_inheritance; + + INSERT OR IGNORE INTO temp.csharp_type_direct_bases( + derived_qualified_name, + base_qualified_name) + SELECT + CASE + WHEN COALESCE(derived.container_qualified_name, '') = '' + THEN derived.name + WHEN derived.container_qualified_name = derived.name COLLATE BINARY + OR substr( + derived.container_qualified_name, + -length(derived.name) - 1 + ) = ('.' || derived.name) COLLATE BINARY + THEN derived.container_qualified_name + ELSE derived.container_qualified_name || '.' || derived.name + END, + CASE + WHEN COALESCE(base_type.container_qualified_name, '') = '' + THEN base_type.name + WHEN base_type.container_qualified_name = base_type.name COLLATE BINARY + OR substr( + base_type.container_qualified_name, + -length(base_type.name) - 1 + ) = ('.' || base_type.name) COLLATE BINARY + THEN base_type.container_qualified_name + ELSE base_type.container_qualified_name || '.' || base_type.name + END + FROM symbols AS derived + JOIN files AS derived_file ON derived_file.id = derived.file_id + JOIN json_each( + csharp_base_identifiers_json(derived.signature) + ) AS base_reference + JOIN symbols AS base_type INDEXED BY idx_symbols_name_folded + ON base_type.name_folded = + csharp_base_name_folded(base_reference.value) + AND base_type.name = + csharp_base_name(base_reference.value) COLLATE BINARY + JOIN files AS base_file ON base_file.id = base_type.file_id + WHERE derived_file.lang = 'csharp' + AND base_file.lang = 'csharp' + AND derived.kind IN ('class', 'record') + AND base_type.kind IN ('class', 'record') + AND csharp_base_reference_matches( + base_reference.value, + base_type.name, + CASE + WHEN COALESCE(base_type.container_qualified_name, '') = '' + THEN base_type.name + WHEN base_type.container_qualified_name = + base_type.name COLLATE BINARY + OR substr( + base_type.container_qualified_name, + -length(base_type.name) - 1 + ) = ('.' || base_type.name) COLLATE BINARY + THEN base_type.container_qualified_name + ELSE base_type.container_qualified_name || '.' || base_type.name + END, + CASE + WHEN COALESCE(derived.container_qualified_name, '') = '' + THEN derived.name + WHEN derived.container_qualified_name = + derived.name COLLATE BINARY + OR substr( + derived.container_qualified_name, + -length(derived.name) - 1 + ) = ('.' || derived.name) COLLATE BINARY + THEN derived.container_qualified_name + ELSE derived.container_qualified_name || '.' || derived.name + END) = 1; + + INSERT OR IGNORE INTO temp.csharp_type_inheritance( + derived_qualified_name, + base_qualified_name, + distance) + WITH RECURSIVE inheritance( + derived_qualified_name, + base_qualified_name, + distance, + path) AS ( + SELECT direct.derived_qualified_name, + direct.base_qualified_name, + 1, + char(31) || direct.derived_qualified_name || + char(31) || direct.base_qualified_name || char(31) + FROM temp.csharp_type_direct_bases AS direct + UNION ALL + SELECT inheritance.derived_qualified_name, + direct.base_qualified_name, + inheritance.distance + 1, + inheritance.path || direct.base_qualified_name || char(31) + FROM inheritance + JOIN temp.csharp_type_direct_bases AS direct + ON direct.derived_qualified_name = + inheritance.base_qualified_name COLLATE BINARY + WHERE inheritance.distance < 32 + AND instr( + inheritance.path, + char(31) || direct.base_qualified_name || char(31) + ) = 0 + ) + SELECT derived_qualified_name, + base_qualified_name, + MIN(distance) + FROM inheritance + GROUP BY derived_qualified_name, base_qualified_name; + UPDATE symbol_references AS r SET reference_kind = 'type_reference', target_qualifier = NULL @@ -153,21 +275,61 @@ FROM symbols AS source JOIN symbols AS target ON target.name_folded = r.symbol_name_folded AND target.name = r.symbol_name COLLATE BINARY - AND target.container_qualified_name = - source.container_qualified_name COLLATE BINARY JOIN files AS target_file ON target_file.id = target.file_id WHERE source.id = r.source_symbol_id AND source_file.lang = 'csharp' AND target_file.lang = 'csharp' AND target.kind = 'property' + AND target.container_qualified_name IN ( + SELECT source.container_qualified_name + UNION + SELECT inheritance.base_qualified_name + FROM temp.csharp_type_inheritance AS inheritance + WHERE inheritance.derived_qualified_name = + source.container_qualified_name COLLATE BINARY + ) + AND r.target_qualifier = + char(31) || 'property_receiver:' || + target.container_qualified_name COLLATE BINARY ); UPDATE symbol_references AS r SET reference_kind = 'reference', target_qualifier = char(31) || 'property_receiver:' || ( - SELECT source.container_qualified_name + SELECT target.container_qualified_name FROM symbols AS source + JOIN files AS source_file ON source_file.id = source.file_id + JOIN symbols AS target + ON target.name_folded = r.symbol_name_folded + AND target.name = r.symbol_name COLLATE BINARY + JOIN files AS target_file ON target_file.id = target.file_id WHERE source.id = r.source_symbol_id + AND source_file.lang = 'csharp' + AND target_file.lang = 'csharp' + AND target.kind = 'property' + AND target.container_qualified_name IN ( + SELECT source.container_qualified_name + UNION + SELECT inheritance.base_qualified_name + FROM temp.csharp_type_inheritance AS inheritance + WHERE inheritance.derived_qualified_name = + source.container_qualified_name COLLATE BINARY + ) + ORDER BY CASE + WHEN target.container_qualified_name = + source.container_qualified_name COLLATE BINARY + THEN 0 + ELSE COALESCE(( + SELECT inheritance.distance + FROM temp.csharp_type_inheritance AS inheritance + WHERE inheritance.derived_qualified_name = + source.container_qualified_name COLLATE BINARY + AND inheritance.base_qualified_name = + target.container_qualified_name COLLATE BINARY + ), 33) + END, + target.id + LIMIT 1 ) WHERE {scopePredicate} AND r.reference_kind = 'type_reference' @@ -190,13 +352,19 @@ FROM symbols AS source JOIN symbols AS target ON target.name_folded = r.symbol_name_folded AND target.name = r.symbol_name COLLATE BINARY - AND target.container_qualified_name = - source.container_qualified_name COLLATE BINARY JOIN files AS target_file ON target_file.id = target.file_id WHERE source.id = r.source_symbol_id AND source_file.lang = 'csharp' AND target_file.lang = 'csharp' AND target.kind = 'property' + AND target.container_qualified_name IN ( + SELECT source.container_qualified_name + UNION + SELECT inheritance.base_qualified_name + FROM temp.csharp_type_inheritance AS inheritance + WHERE inheritance.derived_qualified_name = + source.container_qualified_name COLLATE BINARY + ) ); """; @@ -253,9 +421,10 @@ FROM symbol_references AS r WHERE source_file.lang = 'csharp' AND target_file.lang = 'csharp' AND r.reference_kind = 'reference' + AND s.name = r.symbol_name COLLATE BINARY AND r.target_qualifier = char(31) || 'property_receiver:' || s.container_qualified_name - COLLATE NOCASE + COLLATE BINARY AND s.kind = 'property'; INSERT INTO symbol_reference_candidates(reference_id, symbol_id, scope_rank) @@ -461,6 +630,7 @@ FROM symbol_references AS r JOIN ( SELECT MIN(type_symbol.id) AS symbol_id, type_symbol.name_folded, + type_symbol.name, csharp_definition_type_arity( type_symbol.signature, type_symbol.name, @@ -470,7 +640,7 @@ FROM symbols AS type_symbol WHERE target_file.lang = 'csharp' AND type_symbol.name_folded IS NOT NULL AND type_symbol.kind IN ('class', 'struct', 'record', 'interface', 'enum', 'delegate') - GROUP BY type_symbol.name_folded, type_arity + GROUP BY type_symbol.name_folded, type_symbol.name, type_arity HAVING type_arity IS NOT NULL AND COUNT(DISTINCT target_file.path || char(31) || COALESCE( @@ -479,6 +649,7 @@ AND COUNT(DISTINCT target_file.path || char(31) || '') || char(31) || COALESCE(type_symbol.name, '')) = 1 ) AS unique_target ON unique_target.name_folded = r.symbol_name_folded + AND unique_target.name = r.symbol_name COLLATE BINARY WHERE source_file.lang = 'csharp' AND r.target_qualifier IS NULL AND r.reference_kind = 'type_reference' diff --git a/src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs b/src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs index 43099ab261..d400a9bf72 100644 --- a/src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs +++ b/src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs @@ -41,7 +41,7 @@ private static int FindClosestIdentifierOccurrence(string text, string identifie { var expectedIndex = columnNumber is > 0 and <= int.MaxValue ? (int)columnNumber.Value - 1 - : 0; + : int.MaxValue; var bestIndex = -1; var bestDistance = int.MaxValue; for (var searchAt = 0; searchAt <= text.Length - identifier.Length;) @@ -52,7 +52,23 @@ private static int FindClosestIdentifierOccurrence(string text, string identifie if (IsIdentifierOccurrence(text, occurrence, identifier.Length)) { - var distance = Math.Abs(occurrence - expectedIndex); + // Reference columns are measured against the original line while context is + // trimmed. Trimming can only move the matching token to the left, so an + // occurrence to the right of the original column cannot be the referenced + // token. This upper bound prevents a later same-name generic from stealing + // the first reference on an indented line. + // reference column は元の行基準だが context は trim 済みである。trim により + // 対象 token は左へしか動かないため、元 column より右の occurrence は候補外。 + // これにより indent された同一行の後続同名 generic への誤対応を防ぐ。 + if (occurrence > expectedIndex) + { + searchAt = occurrence + Math.Max(1, identifier.Length); + continue; + } + + var distance = expectedIndex == int.MaxValue + ? occurrence + : expectedIndex - occurrence; if (distance < bestDistance) { bestIndex = occurrence; @@ -63,7 +79,22 @@ private static int FindClosestIdentifierOccurrence(string text, string identifie searchAt = occurrence + Math.Max(1, identifier.Length); } - return bestIndex; + if (bestIndex >= 0) + return bestIndex; + + // Legacy/plugin rows can carry a column that is already context-relative or no + // usable column at all. In that case retain a deterministic exact-case fallback. + for (var searchAt = 0; searchAt <= text.Length - identifier.Length;) + { + var occurrence = text.IndexOf(identifier, searchAt, StringComparison.Ordinal); + if (occurrence < 0) + break; + if (IsIdentifierOccurrence(text, occurrence, identifier.Length)) + return occurrence; + searchAt = occurrence + Math.Max(1, identifier.Length); + } + + return -1; } private static int FindDefinitionIdentifierOccurrence( @@ -129,7 +160,8 @@ private static int FindDeclarationKeywordEnd(string signature, string? symbolKin private static int? ReadArityAfterIdentifier(string text, int occurrence, int identifierLength) { var cursor = occurrence + identifierLength; - SkipWhitespace(text, ref cursor); + if (!SkipCSharpTrivia(text, ref cursor)) + return null; if (cursor >= text.Length || text[cursor] != '<') return 0; @@ -251,7 +283,8 @@ private static bool IsDelegateDeclarationName( int identifierLength) { var cursor = occurrence + identifierLength; - SkipWhitespace(signature, ref cursor); + if (!SkipCSharpTrivia(signature, ref cursor)) + return false; if (cursor < signature.Length && signature[cursor] == '<') { if (!TryCountTopLevelTypeArguments( @@ -264,7 +297,8 @@ private static bool IsDelegateDeclarationName( } cursor = closeAngleIndex + 1; - SkipWhitespace(signature, ref cursor); + if (!SkipCSharpTrivia(signature, ref cursor)) + return false; } return cursor < signature.Length && signature[cursor] == '('; @@ -286,4 +320,34 @@ private static void SkipWhitespace(string text, ref int cursor) while (cursor < text.Length && char.IsWhiteSpace(text[cursor])) cursor++; } + + private static bool SkipCSharpTrivia(string text, ref int cursor) + { + while (cursor < text.Length) + { + SkipWhitespace(text, ref cursor); + if (cursor + 1 >= text.Length || text[cursor] != '/') + return true; + + if (text[cursor + 1] == '/') + { + cursor = text.Length; + return false; + } + + if (text[cursor + 1] != '*') + return true; + + var closeIndex = text.IndexOf("*/", cursor + 2, StringComparison.Ordinal); + if (closeIndex < 0) + { + cursor = text.Length; + return false; + } + + cursor = closeIndex + 2; + } + + return true; + } } diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerIssue4825Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue4825Tests.cs index d5e642b586..1bb7bfd3db 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerIssue4825Tests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue4825Tests.cs @@ -1,5 +1,6 @@ using System.Text.Json.Nodes; using CodeIndex.Cli; +using CodeIndex.Indexer; using CodeIndex.Mcp; using Microsoft.Data.Sqlite; @@ -7,6 +8,23 @@ namespace CodeIndex.Tests; public partial class QueryCommandRunnerTests { + [Fact] + public void CSharpTypeReferenceArity_SkipsBlockCommentTrivia_Issue4825() + { + Assert.Equal( + 1, + CSharpTypeReferenceArity.GetReferenceArity( + "public Commented /* valid trivia */ ? Value { get; }", + "Commented", + 8)); + Assert.Equal( + 1, + CSharpTypeReferenceArity.GetDefinitionArity( + "public class Commented /* valid trivia */ ", + "Commented", + "class")); + } + [Fact] public void CSharpTypeReferences_ResolveOnlyToTypeLikeSymbolsWithMatchingArity_Issue4825() { @@ -37,6 +55,18 @@ public class Handler } public delegate Handler Handler(T input); + + public class Ordinal + { + } + + public class ordinal + { + } + + public class Commented /* valid trivia */ + { + } """); TestProjectHelper.WriteTextFile( projectRoot, @@ -67,6 +97,9 @@ public sealed class Consumer public Actual? One { get; } public Fixture.Types.Actual? Two { get; } public Handler? Handler { get; } + public Commented /* valid trivia */ ? Trivia { get; } + public Ordinal? WrongCaseArity { get; } + public Actual? SameLineOne { get; } public Actual? SameLineTwo { get; } } """); TestProjectHelper.WriteTextFile( @@ -102,6 +135,7 @@ namespace Fixture.Partials; public partial class Service { public string Name { get; } = ""; + public string NAME { get; } = ""; } """); TestProjectHelper.WriteTextFile( @@ -115,6 +149,38 @@ public partial class Service public string Normalize() => Name.Trim(); } """); + TestProjectHelper.WriteTextFile( + projectRoot, + "src/BaseState.cs", + """ + namespace Fixture.Inheritance; + + public class Base + { + protected string Name { get; } = ""; + } + """); + TestProjectHelper.WriteTextFile( + projectRoot, + "src/Middle.cs", + """ + namespace Fixture.Inheritance; + + public class Middle : Base + { + } + """); + TestProjectHelper.WriteTextFile( + projectRoot, + "src/DerivedUse.cs", + """ + namespace Fixture.Inheritance; + + public class Derived : Middle + { + public string Normalize() => Name.Trim(); + } + """); var (indexExitCode, _, indexStderr) = CaptureConsole(() => IndexCommandRunner.Run( [projectRoot, "--json", "--quiet"], @@ -197,6 +263,13 @@ FROM symbol_references AS r Assert.Equal("resolved", reference.State); Assert.Equal("class", reference.Kind); Assert.Contains("Actual", reference.Signature); + }, + reference => + { + Assert.Contains("SameLineOne", reference.Context); + Assert.Equal("resolved", reference.State); + Assert.Equal("class", reference.Kind); + Assert.Contains("Actual", reference.Signature); }); reader.Close(); @@ -251,6 +324,70 @@ FROM symbol_references AS r Assert.Equal("delegate", reference.TargetKind); Assert.Contains("Handler", reference.Signature); }); + compatibilityReader.Close(); + + command.CommandText = """ + SELECT r.resolution_state, + s.signature + FROM symbol_references AS r + JOIN files AS source_file ON source_file.id = r.file_id + LEFT JOIN symbols AS s ON s.id = r.target_symbol_id + WHERE source_file.path = 'src/Consumer.cs' + AND r.reference_kind = 'type_reference' + AND r.symbol_name = @name + """; + var nameParameter = command.Parameters.Add("@name", SqliteType.Text); + + nameParameter.Value = "Commented"; + using (var triviaReader = command.ExecuteReader()) + { + Assert.True(triviaReader.Read()); + Assert.Equal("resolved", triviaReader.GetString(0)); + Assert.Contains( + "class Commented ", + triviaReader.GetString(1)); + } + + nameParameter.Value = "Ordinal"; + using (var ordinalReader = command.ExecuteReader()) + { + Assert.True(ordinalReader.Read()); + Assert.Equal("unresolved", ordinalReader.GetString(0)); + Assert.True(ordinalReader.IsDBNull(1)); + } + + command.Parameters.Clear(); + command.CommandText = """ + SELECT source_file.path, + r.resolution_state, + r.resolution_candidate_count, + s.name, + target_file.path + FROM symbol_references AS r + JOIN files AS source_file ON source_file.id = r.file_id + LEFT JOIN symbols AS s ON s.id = r.target_symbol_id + LEFT JOIN files AS target_file ON target_file.id = s.file_id + WHERE r.symbol_name = 'Name' + AND source_file.path IN ( + 'src/PartialUse.cs', + 'src/DerivedUse.cs' + ) + ORDER BY source_file.path + """; + using var propertyReader = command.ExecuteReader(); + Assert.True(propertyReader.Read()); + Assert.Equal("src/DerivedUse.cs", propertyReader.GetString(0)); + Assert.Equal("resolved", propertyReader.GetString(1)); + Assert.Equal(1, propertyReader.GetInt32(2)); + Assert.Equal("Name", propertyReader.GetString(3)); + Assert.Equal("src/BaseState.cs", propertyReader.GetString(4)); + Assert.True(propertyReader.Read()); + Assert.Equal("src/PartialUse.cs", propertyReader.GetString(0)); + Assert.Equal("resolved", propertyReader.GetString(1)); + Assert.Equal(1, propertyReader.GetInt32(2)); + Assert.Equal("Name", propertyReader.GetString(3)); + Assert.Equal("src/PartialState.cs", propertyReader.GetString(4)); + Assert.False(propertyReader.Read()); } var (inspectExitCode, inspectStdout, inspectStderr) = CaptureConsole( @@ -284,6 +421,10 @@ FROM symbol_references AS r dependencyEdges, edge => edge.GetProperty("source_path").GetString() == "src/PartialUse.cs" && edge.GetProperty("target_path").GetString() == "src/PartialState.cs"); + Assert.Contains( + dependencyEdges, + edge => edge.GetProperty("source_path").GetString() == "src/DerivedUse.cs" + && edge.GetProperty("target_path").GetString() == "src/BaseState.cs"); Assert.Contains( dependencyEdges, edge => edge.GetProperty("source_path").GetString() == "src/CaseUse.cs" @@ -328,6 +469,10 @@ FROM symbol_references AS r mcpDependencyEdges, edge => edge!["sourcePath"]!.GetValue() == "src/PartialUse.cs" && edge["targetPath"]!.GetValue() == "src/PartialState.cs"); + Assert.Contains( + mcpDependencyEdges, + edge => edge!["sourcePath"]!.GetValue() == "src/DerivedUse.cs" + && edge["targetPath"]!.GetValue() == "src/BaseState.cs"); Assert.Contains( mcpDependencyEdges, edge => edge!["sourcePath"]!.GetValue() == "src/CaseUse.cs" From b15a84d06881dc7e5f1fc900d4aa99b27a35511d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 27 Jul 2026 09:21:17 +0900 Subject: [PATCH 4/4] Make C# base serialization trim-safe (#4825) --- src/CodeIndex/Database/DbContext.ConnectionFunctions.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs b/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs index 39d27b1b3c..0aeb6aaa74 100644 --- a/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs +++ b/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs @@ -86,7 +86,9 @@ internal static void RegisterConnectionFunctions(SqliteConnection connection) connection.CreateFunction( "csharp_base_identifiers_json", (string? signature) => - JsonSerializer.Serialize(DbWriter.ParseCSharpBaseIdentifiers(signature))); + JsonSerializer.Serialize( + DbWriter.ParseCSharpBaseIdentifiers(signature), + CliJsonSerializerContext.Default.ListString)); connection.CreateFunction( "csharp_base_name_folded", (string? baseReference) =>