diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 201d055b9..e6673f998 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1052,6 +1052,24 @@ 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. 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 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 +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 resolution fields on the row-value assignment path; separate scalar subqueries multiply the @@ -4176,6 +4194,21 @@ 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 と +一致する必要があります。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 の +別ファイルまたは index 済み base class で宣言されている場合も inheritance chain 上で最も近い property を +ordinal 名一致で選び、型限定 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 は row-value assignment のまま維持してください。scalar subquery を分けると、大規模 graph で diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 89cd9e80f..1d1cdbbdb 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 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. @@ -1382,6 +1384,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 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 new file mode 100644 index 000000000..24ab5a205 --- /dev/null +++ b/changelog.d/unreleased/4825.fixed.md @@ -0,0 +1,27 @@ +--- +category: fixed +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 + - 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/DbReaderSymbolIdentityTests.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 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、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 fdb2a7712..0aeb6aaa7 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; @@ -70,6 +71,42 @@ 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( + "csharp_base_identifiers_json", + (string? signature) => + JsonSerializer.Serialize( + DbWriter.ParseCSharpBaseIdentifiers(signature), + CliJsonSerializerContext.Default.ListString)); + 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)); @@ -170,6 +207,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/DbContext.SchemaMetadata.cs b/src/CodeIndex/Database/DbContext.SchemaMetadata.cs index 6343f8225..447cf8092 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 bbc65bc9d..1f206d70d 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,14 @@ internal static IReadOnlyList ScopedReferenceGraphUpdateStatementsForTes => [ RefreshScopedReferenceSourceSymbolsSql, + .. NormalizeCSharpPropertyReceiverReferencesScopedSql + .Split( + ';', + StringSplitOptions.RemoveEmptyEntries + | StringSplitOptions.TrimEntries) + .Where(static statement => statement.StartsWith( + "UPDATE symbol_references", + StringComparison.Ordinal)), RefreshScopedReferenceResolutionValuesSql, RefreshScopedSelfReferenceSql, RefreshScopedMutualRecursionFlagsSql, @@ -143,13 +155,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 int expectedReferenceSourceCount = 10; + 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."); @@ -171,6 +187,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 2a0cecdfa..c3726fc1e 100644 --- a/src/CodeIndex/Database/DbWriter.References.cs +++ b/src/CodeIndex/Database/DbWriter.References.cs @@ -107,9 +107,270 @@ 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 """; + 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 s.name <> r.symbol_name COLLATE BINARY 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) => + $""" + 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 + 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.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 + ) + 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 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' + 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.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 + ) + ); + """; + + private static string NormalizeCSharpPropertyReceiverReferencesFullSql => + BuildCSharpPropertyReceiverNormalizationSql("1 = 1"); + private const string RefreshReferenceUniqueFamiliesSql = """ DELETE FROM temp.reference_unique_symbol_families; @@ -148,9 +409,24 @@ 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 s.name = r.symbol_name COLLATE BINARY + AND r.target_qualifier = + char(31) || 'property_receiver:' || s.container_qualified_name + COLLATE BINARY + 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 +443,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 +495,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 +531,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 +562,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 +588,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 +613,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 +623,60 @@ 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, + type_symbol.name, + 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_symbol.name, 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 + 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' + 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 +735,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 +866,7 @@ internal static void RebuildRetainedReferenceGraph( command.CommandText = CreateReferenceUniqueFamiliesSql + ";\n" + RefreshReferenceSourceSymbolsFullSql + ";\n" + + NormalizeCSharpPropertyReceiverReferencesFullSql + "\n" + RefreshReferenceUniqueFamiliesSql + "\n" + RefreshReferenceCandidatesSql + "\n" + RefreshReferenceResolutionFullSql + "\n" + @@ -1165,10 +1502,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 +1516,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 000000000..d400a9bf7 --- /dev/null +++ b/src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs @@ -0,0 +1,353 @@ +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 + : int.MaxValue; + 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)) + { + // 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; + bestDistance = distance; + } + } + + searchAt = occurrence + Math.Max(1, identifier.Length); + } + + 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( + string signature, + string symbolName, + int searchStart, + string? symbolKind) + { + var isDelegate = string.Equals(symbolKind, "delegate", StringComparison.Ordinal); + 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)) + { + if (!isDelegate + || IsDelegateDeclarationName(signature, occurrence, symbolName.Length)) + { + return occurrence; + } + } + + searchAt = occurrence + Math.Max(1, symbolName.Length); + } + + return -1; + } + + 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; + if (!SkipCSharpTrivia(text, ref cursor)) + return null; + if (cursor >= text.Length || text[cursor] != '<') + return 0; + + return TryCountTopLevelTypeArguments(text, cursor, out var arity, out _) ? arity : null; + } + + 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; + 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) + { + closeAngleIndex = i; + 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 IsDelegateDeclarationName( + string signature, + int occurrence, + int identifierLength) + { + var cursor = occurrence + identifierLength; + if (!SkipCSharpTrivia(signature, ref cursor)) + return false; + if (cursor < signature.Length && signature[cursor] == '<') + { + if (!TryCountTopLevelTypeArguments( + signature, + cursor, + out _, + out var closeAngleIndex)) + { + return false; + } + + cursor = closeAngleIndex + 1; + if (!SkipCSharpTrivia(signature, ref cursor)) + return false; + } + + return cursor < signature.Length && signature[cursor] == '('; + } + + 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++; + } + + 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/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallClassification.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallClassification.cs index 30a8e838c..55c320d7c 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 0de0673e3..4e3f2c257 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 21a499a9f..cda679460 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) { 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 33058c941..6077cea8c 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", @@ -455,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 bfdc01db1..6c8186049 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 new file mode 100644 index 000000000..1bb7bfd3d --- /dev/null +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue4825Tests.cs @@ -0,0 +1,490 @@ +using System.Text.Json.Nodes; +using CodeIndex.Cli; +using CodeIndex.Indexer; +using CodeIndex.Mcp; +using Microsoft.Data.Sqlite; + +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() + { + 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 + { + } + + public static class NAME + { + public static string Run() => ""; + } + + public class Handler + { + } + + public delegate Handler Handler(T input); + + public class Ordinal + { + } + + public class ordinal + { + } + + public class Commented /* valid trivia */ + { + } + """); + 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; } + public Handler? Handler { get; } + public Commented /* valid trivia */ ? Trivia { get; } + public Ordinal? WrongCaseArity { get; } + public Actual? SameLineOne { get; } public Actual? SameLineTwo { 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( + projectRoot, + "src/PartialState.cs", + """ + namespace Fixture.Partials; + + public partial class Service + { + public string Name { get; } = ""; + public string NAME { get; } = ""; + } + """); + TestProjectHelper.WriteTextFile( + projectRoot, + "src/PartialUse.cs", + """ + namespace Fixture.Partials; + + 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"], + _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); + }, + reference => + { + Assert.Contains("SameLineOne", reference.Context); + Assert.Equal("resolved", reference.State); + 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); + }); + 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( + () => 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"); + 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" + && 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( + """{"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"); + 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" + && edge["targetPath"]!.GetValue() == "src/Types.cs"); + Assert.DoesNotContain( + mcpDependencyEdges, + edge => edge!["sourcePath"]!.GetValue() == "src/CaseUse.cs" + && edge["targetPath"]!.GetValue() == "src/CaseProperty.cs"); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } +}