From 5f766872c12d7bdd7ff5b54d9d775490a7777138 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 03:54:02 +0900 Subject: [PATCH 01/18] Preserve C# explicit-interface symbol identity (#4866) --- DEVELOPER_GUIDE.md | 22 ++ README.md | 17 + TESTING_GUIDE.md | 2 + changelog.d/unreleased/4866.fixed.md | 20 ++ .../Database/DbContext.SchemaMetadata.cs | 2 +- .../Database/DbSymbolReader.Definitions.cs | 10 +- .../Database/DbSymbolReader.Search.cs | 64 +++- .../Database/DbWriter.FoldBackfill.cs | 22 +- .../Symbols/CSharpSymbolNameNormalizer.cs | 319 ++++++++++++++++++ .../SymbolExtractor.PatternEmission.cs | 12 +- .../Symbols/SymbolExtractor.Patterns.cs | 13 +- tests/CodeIndex.Tests/DbReaderSearchTests.cs | 178 ++++++++++ tests/CodeIndex.Tests/LspServerTests.cs | 83 +++++ .../SymbolExtractorCSharpTests.cs | 72 ++++ 14 files changed, 812 insertions(+), 24 deletions(-) create mode 100644 changelog.d/unreleased/4866.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 7ce6df3c39..fdc8f18715 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1143,6 +1143,17 @@ are globally unique are aggregated once into the connection-local fallbacks. Create that temp table in a separate prepared command before preparing the refresh; SQLite resolves referenced tables while preparing every statement in a command batch. +For C# explicit-interface members, `symbols.name` remains the short display/discovery alias, +while `symbols.name_folded` stores the normalized interface qualifier plus terminal method +generic arity. Kind and normalized signature remain independent columns in the canonical symbol +row, so identity comparisons retain qualifier, member kind, arity, and signature without +changing outline or LSP display names. Exact qualified queries normalize generic parameter names +to arity and map indexer spellings `this` and `Item` together; unqualified exact queries use the +short-name alias for discovery and therefore may return explicit and public members. Fold +validation/backfill reconstructs the qualified identity from the persisted signature, and a +`CSharpSymbolNameContractVersion` change forces unchanged C# files to be reindexed before that +identity is trusted. + `inspect` / MCP `analyze_symbol` treats each returned definition as a separate identity bundle. Candidate selectors expose the persisted symbol ID plus qualified/container name, signature, language, kind, path, and line. Identity-scoped reference/caller/callee queries @@ -4408,6 +4419,17 @@ C# attribute fallback で共有します。この temp table は refresh command prepared command で作成してください。SQLite は command batch の全statementをprepareする時点で 参照tableを解決します。 +C# の明示的 interface member では、`symbols.name` は短い表示用 / discovery alias のままにし、 +`symbols.name_folded` に正規化した interface qualifier と末尾 method の generic arity を +保存します。kind と正規化済み signature は canonical symbol row の独立した列に保持するため、 +outline や LSP の表示名を変えずに qualifier、member kind、arity、signature を identity 比較へ +残せます。修飾した完全一致 query は generic parameter 名を arity に正規化し、indexer の +`this` と `Item` を同じ表記として扱います。非修飾の完全一致 query は短い名前の discovery +alias を使うため、明示的実装と public member の両方を返す場合があります。fold の検証 / +backfill は永続化済み signature から修飾 identity を復元し、 +`CSharpSymbolNameContractVersion` の変更時には、その identity を信頼する前に未変更の C# file +も再 index します。 + `inspect` / MCP `analyze_symbol` は返された各定義を別々の identity bundle として 扱います。candidate selector は永続化した symbol ID に加え、qualified/container name、 signature、language、kind、path、line を公開します。identity-scoped な diff --git a/README.md b/README.md index 6e6e1f895b..d7a8e963ba 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,15 @@ cdidx license --json cdidx validate ``` +For C# explicit-interface implementations, symbol output and outlines keep the +short display name (`Run`, `Value`, `Changed`, or `Item`). Qualified exact-name +queries use the interface spelling, such as `IFoo.Run`, `IFoo.Value`, +`IFoo.Changed`, and `IFoo.Item` (`IFoo.this` is accepted as an indexer alias). +Generic parameter spelling is normalized to arity, so `IFoo.Run` has the +same exact identity as `IFoo.Run`. An unqualified exact query such as `Run` +remains a discovery alias and may return explicit implementations alongside +same-named public members; use the qualified spelling to select one identity. + `doctor --env-inventory=full` accepts exact `--env-domain`, `--env-category`, and `--env-sensitivity` filters that compose with AND. Its JSON form accepts `--max-json-bytes` as a whole-document UTF-8 byte cap; @@ -519,6 +528,14 @@ cdidx license --json cdidx validate ``` +C# の明示的 interface 実装では、symbol 出力と outline は短い表示名 +(`Run`、`Value`、`Changed`、`Item`)を維持します。修飾した exact-name query には +`IFoo.Run`、`IFoo.Value`、`IFoo.Changed`、`IFoo.Item` のような interface 表記を使い、 +indexer では `IFoo.this` も alias として受け付けます。generic parameter の表記は arity に +正規化されるため、`IFoo.Run` と `IFoo.Run` は同じ完全一致 identity です。 +`Run` のような非修飾の完全一致 query は discovery alias のままで、同名の public member と +明示的実装をともに返す場合があります。1つの identity を選ぶには修飾表記を使ってください。 + `doctor --env-inventory=full` は、AND で合成される完全一致の `--env-domain`、`--env-category`、`--env-sensitivity` filter を受け付けます。 JSON 形式では `--max-json-bytes` を文書全体の UTF-8 byte cap として利用できるため、 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 1650ee7f41..273f14e6e0 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -79,6 +79,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding - `SymbolExtractor*Tests.cs` and `ReferenceExtractor*Tests.cs` Extractor coverage is split by language or feature area with partial test classes, while shared helpers remain on the root `SymbolExtractorTests` / `ReferenceExtractorTests` parts. C# declaration-boundary regressions should pair a direct extractor fixture with a real-index `symbols --exact-name` query. Keep invocation and parameter continuations beside valid multi-line methods, constructors, delegates, and local functions so both false-positive rejection and declaration ranges remain observable. + C# explicit-interface identity coverage pairs extractor assertions for methods, properties, events, and indexers with persisted exact qualified/unqualified queries, fold rewrite validation, inspect/outline checks, and LSP definition/reference scoping. Include multiple and inherited interfaces, generic arity, a same-named public member, and a qualified return type that must not be mistaken for an explicit-interface qualifier. C# callable-containment fixtures should cover block-bodied test methods, local and nested local functions, named lambdas, expression-bodied members, and nested types together, asserting both symbol parents and call-reference containers. Repository-metadata coverage lives in `SymbolExtractorRepositoryMetadataTests.cs` and `ReferenceExtractorRepositoryMetadataTests.cs`; keep TOML, JSON Lines, ignore/attributes, EditorConfig, `.rules`, and application-manifest capability assertions coordinated with conservative local-path and malformed-record controls. Capability-regression fixtures that require an unsupported language use the explicit `text` placeholder or an ambiguity bucket; do not use a recognized repository-metadata format as the unsupported control. @@ -991,6 +992,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - `SymbolExtractor*Tests.cs` と `ReferenceExtractor*Tests.cs` extractor のカバレッジは言語または機能領域ごとの partial test class に分割し、共有 helper は root 側の `SymbolExtractorTests` / `ReferenceExtractorTests` に残します。 C# の declaration boundary に関する regression では、extractor を直接呼ぶ fixture と、実 index に対する `symbols --exact-name` query を組み合わせてください。呼び出し・parameter の continuation と、正当な複数行 method、constructor、delegate、local function を同居させ、false positive の拒否と宣言 range の両方を観測可能にします。 + C# の明示的 interface identity coverage では、method、property、event、indexer の extractor assertion と、永続化後の修飾 / 非修飾の完全一致 query、fold rewrite 検証、inspect / outline、LSP の definition / reference scope を組み合わせます。複数および継承 interface、generic arity、同名 public member に加え、明示的 interface qualifier と誤認してはならない修飾 return type を含めてください。 C# の callable containment fixture では、block body の test method、local / nested local function、named lambda、expression-bodied member、nested type を同居させ、symbol の親と call reference の container の両方を検証してください。 repository metadata の coverage は `SymbolExtractorRepositoryMetadataTests.cs` と `ReferenceExtractorRepositoryMetadataTests.cs` に置きます。TOML、JSON Lines、ignore / attributes、EditorConfig、`.rules`、application manifest の capability assertion を、保守的な local-path 抽出と malformed-record control に同期させてください。 未対応言語を必要とする capability regression fixture には明示的な `text` placeholder または ambiguity bucket を使い、認識済み repository metadata 形式を未対応 control に使わないでください。 diff --git a/changelog.d/unreleased/4866.fixed.md b/changelog.d/unreleased/4866.fixed.md new file mode 100644 index 0000000000..b03c592719 --- /dev/null +++ b/changelog.d/unreleased/4866.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 4866 +affected: + - src/CodeIndex/Database + - src/CodeIndex/Indexer + - tests/CodeIndex.Tests + - README.md + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **C# explicit-interface members now retain distinct symbol identities (#4866)** — Exact qualified queries preserve the interface qualifier and generic arity for methods, properties, events, and indexers without merging them with same-named public members. Short display names remain available as unqualified discovery aliases across CLI, inspect, outline, and LSP navigation. + +## 日本語 + +- **C# の明示的 interface member が個別の symbol identity を保持するようになりました (#4866)** — method、property、event、indexer の完全一致 query で interface qualifier と generic arity を保持し、同名の public member と統合しません。CLI、inspect、outline、LSP navigation では、短い表示名を非修飾の discovery alias として引き続き利用できます。 diff --git a/src/CodeIndex/Database/DbContext.SchemaMetadata.cs b/src/CodeIndex/Database/DbContext.SchemaMetadata.cs index ce3f081c0d..de1351fc32 100644 --- a/src/CodeIndex/Database/DbContext.SchemaMetadata.cs +++ b/src/CodeIndex/Database/DbContext.SchemaMetadata.cs @@ -55,7 +55,7 @@ public static bool IsIncompleteHotspotFamilyMarkerFingerprint(string? fingerprin && fingerprint.StartsWith(HotspotFamilyIncompleteMarkerFingerprintPrefix, StringComparison.Ordinal); public static string BuildIncompleteHotspotFamilyMarkerFingerprint(string? fingerprint) => HotspotFamilyIncompleteMarkerFingerprintPrefix + (string.IsNullOrWhiteSpace(fingerprint) ? "unknown" : fingerprint); - public const int CSharpSymbolNameContractVersion = 2; + public const int CSharpSymbolNameContractVersion = 3; public const string CSharpSymbolNameContractVersionMetaKey = "csharp_symbol_name_contract_version"; public const string CSharpStaticInterfaceSourceEvidenceMetaKey = "csharp_static_interface_source_evidence"; public const int SqlGraphContractVersion = 1; diff --git a/src/CodeIndex/Database/DbSymbolReader.Definitions.cs b/src/CodeIndex/Database/DbSymbolReader.Definitions.cs index 188d8cb686..342aa356d4 100644 --- a/src/CodeIndex/Database/DbSymbolReader.Definitions.cs +++ b/src/CodeIndex/Database/DbSymbolReader.Definitions.cs @@ -328,6 +328,9 @@ FROM symbols s var qualifiedSymbolClause = SqlNameResolver.HasQualifier(normalizedQuery) ? BuildQualifiedSymbolMatchSql("query", _foldReady) : null; + var csharpExplicitInterfaceClause = allowLeafFallback + ? BuildCSharpExplicitInterfaceShortAliasMatchSql("query") + : BuildCSharpExplicitInterfaceIdentityMatchSql("query"); var markdownAnchorExactClause = _symbolColumns.Contains("name_folded") ? "(f.lang = 'markdown' AND ((s.kind = 'heading' AND s.name_folded = @queryMarkdownHeading) OR (s.kind = 'anchor' AND s.name_folded = @queryMarkdownExplicitAnchor COLLATE BINARY)))" : "0"; @@ -341,12 +344,12 @@ FROM symbols s : " AND ((s.container_qualified_name = @queryRustContainer COLLATE NOCASE OR s.container_name = @queryRustContainer COLLATE NOCASE) AND s.name = @queryRustLeaf COLLATE NOCASE)" : _foldReady ? allowLeafFallback - ? $" AND ({BuildPersistedFoldedNameMatchSql("s.name_folded", "@query")} OR {markdownAnchorExactClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name_folded(s.name) = @queryNormalizedFolded) OR sql_leaf_name_folded(s.name) = @queryLeafFolded)))" - : $" AND ({BuildPersistedFoldedNameMatchSql("s.name_folded", "@query")} OR {markdownAnchorExactClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name_folded(s.name) = @queryNormalizedFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" + ? $" AND ({BuildPersistedFoldedNameMatchSql("s.name_folded", "@query")} OR {csharpExplicitInterfaceClause} OR {markdownAnchorExactClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name_folded(s.name) = @queryNormalizedFolded) OR sql_leaf_name_folded(s.name) = @queryLeafFolded)))" + : $" AND ({BuildPersistedFoldedNameMatchSql("s.name_folded", "@query")} OR {csharpExplicitInterfaceClause} OR {markdownAnchorExactClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name_folded(s.name) = @queryNormalizedFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" : allowLeafFallback ? $" AND (s.name = @query COLLATE NOCASE OR {markdownAnchorExactClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name(s.name) = @queryNormalized COLLATE NOCASE) OR sql_leaf_name(s.name) = @queryLeaf COLLATE NOCASE)))" : $" AND (s.name = @query COLLATE NOCASE OR {markdownAnchorExactClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name(s.name) = @queryNormalized COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" - : $" AND (s.name LIKE @query ESCAPE '\\'{markdownAnchorLikeClause} OR (f.lang = 'sql' AND sql_normalize_name(s.name) LIKE @queryNormalizedLike ESCAPE '\\'){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})"; + : $" AND (s.name LIKE @query ESCAPE '\\'{markdownAnchorLikeClause} OR (f.lang = 'sql' AND sql_normalize_name(s.name) LIKE @queryNormalizedLike ESCAPE '\\'){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause} OR {csharpExplicitInterfaceClause}" : string.Empty)})"; } if (kind != null) sql += " AND s.kind = @kind"; @@ -385,6 +388,7 @@ FROM chunks c SqliteCommandPolicy.Add(cmd, "@queryLeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(normalizedQuery)) ?? SqlNameResolver.GetLeafName(normalizedQuery)); SqliteCommandPolicy.Add(cmd, "@querySegmentCount", SqlNameResolver.GetSegmentCount(normalizedQuery)); SqliteCommandPolicy.Add(cmd, "@queryNormalizedLike", $"%{EscapeLikeQuery(SqlNameResolver.NormalizeQualifiedName(normalizedQuery))}%"); + AddCSharpExplicitInterfaceIdentityQueryParameter(cmd, "query", normalizedQuery); if (_symbolColumns.Contains("name_folded")) { var markdownHeadingIdentity = MarkdownAnchorIdentity.NormalizeHeadingFragment(normalizedQuery); diff --git a/src/CodeIndex/Database/DbSymbolReader.Search.cs b/src/CodeIndex/Database/DbSymbolReader.Search.cs index 5139a3eaa9..d31229d19e 100644 --- a/src/CodeIndex/Database/DbSymbolReader.Search.cs +++ b/src/CodeIndex/Database/DbSymbolReader.Search.cs @@ -118,6 +118,34 @@ private string BuildQualifiedSymbolMatchSql(string parameterStem, bool useFolded OR {containerQualifiedNameSql} COLLATE NOCASE LIKE @{parameterStem}ContainerSuffixLike ESCAPE '\'))"; } + private string BuildCSharpExplicitInterfaceIdentityMatchSql( + string parameterStem, + string symbolAlias = "s", + string fileAlias = "f") + { + if (!_csharpSymbolNameContractCurrent || !_foldReady || !_symbolColumns.Contains("name_folded")) + return "0"; + + return $"({fileAlias}.lang = 'csharp' AND {symbolAlias}.name_folded = @{parameterStem}CSharpExplicitInterfaceIdentityFolded)"; + } + + private static string BuildCSharpExplicitInterfaceShortAliasMatchSql( + string parameterStem, + string symbolAlias = "s", + string fileAlias = "f") + => $"({fileAlias}.lang = 'csharp' AND {symbolAlias}.name = @{parameterStem}Leaf COLLATE NOCASE)"; + + private static void AddCSharpExplicitInterfaceIdentityQueryParameter( + SqliteCommand cmd, + string parameterStem, + string query) + { + SqliteCommandPolicy.Add( + cmd, + $"@{parameterStem}CSharpExplicitInterfaceIdentityFolded", + CSharpSymbolNameNormalizer.NormalizeExplicitInterfaceQueryIdentityNameFolded(query)); + } + private static string GetQualifiedQueryContainer(string query) { var normalized = SqlNameResolver.NormalizeQualifiedName(query); @@ -213,6 +241,9 @@ FROM symbols s var qualifiedSymbolClause = SqlNameResolver.HasQualifier(validQueries[0]) ? BuildQualifiedSymbolMatchSql("query0", _foldReady) : null; + var csharpExplicitInterfaceClause = allowLeafFallback + ? BuildCSharpExplicitInterfaceShortAliasMatchSql("query0") + : BuildCSharpExplicitInterfaceIdentityMatchSql("query0"); var rustQualifiedExact = ShouldPreserveRustQualifiedExactQuery(validQueries[0], lang, exact); var rustQualifiedParts = rustQualifiedExact ? NormalizeRustQualifiedExactQueryParts(validQueries[0]) : default; innerSql += exact @@ -222,12 +253,12 @@ FROM symbols s : " AND ((s.container_qualified_name = @query0RustContainer COLLATE NOCASE OR s.container_name = @query0RustContainer COLLATE NOCASE) AND s.name = @query0RustLeaf COLLATE NOCASE)" : _foldReady ? allowLeafFallback - ? $" AND ({BuildPersistedFoldedNameMatchSql("s.name_folded", "@query0")} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name_folded(s.name) = @query0NormalizedFolded) OR sql_leaf_name_folded(s.name) = @query0LeafFolded)))" - : $" AND ({BuildPersistedFoldedNameMatchSql("s.name_folded", "@query0")} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name_folded(s.name) = @query0NormalizedFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" + ? $" AND ({BuildPersistedFoldedNameMatchSql("s.name_folded", "@query0")} OR {csharpExplicitInterfaceClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name_folded(s.name) = @query0NormalizedFolded) OR sql_leaf_name_folded(s.name) = @query0LeafFolded)))" + : $" AND ({BuildPersistedFoldedNameMatchSql("s.name_folded", "@query0")} OR {csharpExplicitInterfaceClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name_folded(s.name) = @query0NormalizedFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" : allowLeafFallback ? " AND (s.name = @query0 COLLATE NOCASE OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name(s.name) = @query0Normalized COLLATE NOCASE) OR sql_leaf_name(s.name) = @query0Leaf COLLATE NOCASE)))" : $" AND (s.name = @query0 COLLATE NOCASE OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name(s.name) = @query0Normalized COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" - : $" AND (s.name LIKE @query0 ESCAPE '\\' OR (f.lang = 'sql' AND sql_normalize_name(s.name) LIKE @query0NormalizedLike ESCAPE '\\'){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})"; + : $" AND (s.name LIKE @query0 ESCAPE '\\' OR (f.lang = 'sql' AND sql_normalize_name(s.name) LIKE @query0NormalizedLike ESCAPE '\\'){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause} OR {csharpExplicitInterfaceClause}" : string.Empty)})"; } if (kind != null) innerSql += " AND s.kind = @kind"; @@ -260,6 +291,7 @@ FROM symbols s SqliteCommandPolicy.Add(cmd, "@query0LeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(value)) ?? SqlNameResolver.GetLeafName(value)); SqliteCommandPolicy.Add(cmd, "@query0SegmentCount", SqlNameResolver.GetSegmentCount(value)); SqliteCommandPolicy.Add(cmd, "@query0NormalizedLike", $"%{EscapeLikeQuery(SqlNameResolver.NormalizeQualifiedName(value))}%"); + AddCSharpExplicitInterfaceIdentityQueryParameter(cmd, "query0", value); if (SqlNameResolver.HasQualifier(value)) AddQualifiedSymbolQueryParameters(cmd, "query0", value); if (rustQualifiedParts.QualifiedPath != null) @@ -326,6 +358,9 @@ FROM symbols s var qualifiedSymbolClause = SqlNameResolver.HasQualifier(queryValue) ? BuildQualifiedSymbolMatchSql($"query{idx}", _foldReady) : null; + var csharpExplicitInterfaceClause = allowLeafFallback + ? BuildCSharpExplicitInterfaceShortAliasMatchSql($"query{idx}") + : BuildCSharpExplicitInterfaceIdentityMatchSql($"query{idx}"); var swiftBacktickAlias = ComputeSwiftBacktickAlias(queryValue, lang); var swiftBacktickClause = swiftBacktickAlias != null ? _foldReady @@ -338,8 +373,8 @@ FROM symbols s : $"((s.container_qualified_name = @query{idx}RustContainer COLLATE NOCASE OR s.container_name = @query{idx}RustContainer COLLATE NOCASE) AND s.name = @query{idx}RustLeaf COLLATE NOCASE)"; return _foldReady ? allowLeafFallback - ? $"({BuildPersistedFoldedNameMatchSql("s.name_folded", $"@query{idx}")}{swiftBacktickClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded) OR sql_leaf_name_folded(s.name) = @query{idx}LeafFolded)))" - : $"({BuildPersistedFoldedNameMatchSql("s.name_folded", $"@query{idx}")}{swiftBacktickClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" + ? $"({BuildPersistedFoldedNameMatchSql("s.name_folded", $"@query{idx}")}{swiftBacktickClause} OR {csharpExplicitInterfaceClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded) OR sql_leaf_name_folded(s.name) = @query{idx}LeafFolded)))" + : $"({BuildPersistedFoldedNameMatchSql("s.name_folded", $"@query{idx}")}{swiftBacktickClause} OR {csharpExplicitInterfaceClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" : allowLeafFallback ? $"(s.name = @query{idx} COLLATE NOCASE{swiftBacktickClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE) OR sql_leaf_name(s.name) = @query{idx}Leaf COLLATE NOCASE)))" : $"(s.name = @query{idx} COLLATE NOCASE{swiftBacktickClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})"; @@ -349,10 +384,13 @@ FROM symbols s var qualifiedSymbolClause = SqlNameResolver.HasQualifier(queryValue) ? BuildQualifiedSymbolMatchSql($"query{idx}", _foldReady) : null; + var csharpExplicitInterfaceClause = SqlNameResolver.HasQualifier(queryValue) + ? BuildCSharpExplicitInterfaceIdentityMatchSql($"query{idx}") + : null; var markdownAnchorLikeClause = _symbolColumns.Contains("name_folded") ? $" OR (f.lang = 'markdown' AND ((s.kind = 'heading' AND s.name_folded LIKE @query{idx}MarkdownHeadingLike ESCAPE '\\') OR (s.kind = 'anchor' AND instr(s.name_folded, @query{idx}MarkdownExplicitAnchor) > 0)))" : string.Empty; - return $"(s.name LIKE @query{idx} ESCAPE '\\'{markdownAnchorLikeClause} OR (f.lang = 'sql' AND sql_normalize_name(s.name) LIKE @query{idx}NormalizedLike ESCAPE '\\'){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})"; + return $"(s.name LIKE @query{idx} ESCAPE '\\'{markdownAnchorLikeClause} OR (f.lang = 'sql' AND sql_normalize_name(s.name) LIKE @query{idx}NormalizedLike ESCAPE '\\'){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause} OR {csharpExplicitInterfaceClause}" : string.Empty)})"; })); sql += $" AND ({orClauses})"; } @@ -386,6 +424,7 @@ FROM symbols s SqliteCommandPolicy.Add(cmd, $"@query{i}LeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(value)) ?? SqlNameResolver.GetLeafName(value)); SqliteCommandPolicy.Add(cmd, $"@query{i}SegmentCount", SqlNameResolver.GetSegmentCount(value)); SqliteCommandPolicy.Add(cmd, $"@query{i}NormalizedLike", $"%{EscapeLikeQuery(SqlNameResolver.NormalizeQualifiedName(value))}%"); + AddCSharpExplicitInterfaceIdentityQueryParameter(cmd, $"query{i}", value); if (_symbolColumns.Contains("name_folded")) { var markdownHeadingIdentity = MarkdownAnchorIdentity.NormalizeHeadingFragment(value); @@ -634,6 +673,9 @@ FROM symbols s var qualifiedSymbolClause = SqlNameResolver.HasQualifier(queryValue) ? BuildQualifiedSymbolMatchSql($"query{idx}", _foldReady) : null; + var csharpExplicitInterfaceClause = allowLeafFallback + ? BuildCSharpExplicitInterfaceShortAliasMatchSql($"query{idx}") + : BuildCSharpExplicitInterfaceIdentityMatchSql($"query{idx}"); var swiftBacktickAlias = ComputeSwiftBacktickAlias(queryValue, lang); var swiftBacktickClause = swiftBacktickAlias != null ? _foldReady @@ -646,8 +688,8 @@ FROM symbols s : $"((s.container_qualified_name = @query{idx}RustContainer COLLATE NOCASE OR s.container_name = @query{idx}RustContainer COLLATE NOCASE) AND s.name = @query{idx}RustLeaf COLLATE NOCASE)"; return _foldReady ? allowLeafFallback - ? $"({BuildPersistedFoldedNameMatchSql("s.name_folded", $"@query{idx}")}{swiftBacktickClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded) OR sql_leaf_name_folded(s.name) = @query{idx}LeafFolded)))" - : $"({BuildPersistedFoldedNameMatchSql("s.name_folded", $"@query{idx}")}{swiftBacktickClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" + ? $"({BuildPersistedFoldedNameMatchSql("s.name_folded", $"@query{idx}")}{swiftBacktickClause} OR {csharpExplicitInterfaceClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded) OR sql_leaf_name_folded(s.name) = @query{idx}LeafFolded)))" + : $"({BuildPersistedFoldedNameMatchSql("s.name_folded", $"@query{idx}")}{swiftBacktickClause} OR {csharpExplicitInterfaceClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" : allowLeafFallback ? $"(s.name = @query{idx} COLLATE NOCASE{swiftBacktickClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE) OR sql_leaf_name(s.name) = @query{idx}Leaf COLLATE NOCASE)))" : $"(s.name = @query{idx} COLLATE NOCASE{swiftBacktickClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})"; @@ -657,10 +699,13 @@ FROM symbols s var qualifiedSymbolClause = SqlNameResolver.HasQualifier(queryValue) ? BuildQualifiedSymbolMatchSql($"query{idx}", _foldReady) : null; + var csharpExplicitInterfaceClause = SqlNameResolver.HasQualifier(queryValue) + ? BuildCSharpExplicitInterfaceIdentityMatchSql($"query{idx}") + : null; var markdownAnchorLikeClause = _symbolColumns.Contains("name_folded") ? $" OR (f.lang = 'markdown' AND ((s.kind = 'heading' AND s.name_folded LIKE @query{idx}MarkdownHeadingLike ESCAPE '\\') OR (s.kind = 'anchor' AND instr(s.name_folded, @query{idx}MarkdownExplicitAnchor) > 0)))" : string.Empty; - return $"(s.name LIKE @query{idx} ESCAPE '\\'{markdownAnchorLikeClause} OR (f.lang = 'sql' AND sql_normalize_name(s.name) LIKE @query{idx}NormalizedLike ESCAPE '\\'){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})"; + return $"(s.name LIKE @query{idx} ESCAPE '\\'{markdownAnchorLikeClause} OR (f.lang = 'sql' AND sql_normalize_name(s.name) LIKE @query{idx}NormalizedLike ESCAPE '\\'){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause} OR {csharpExplicitInterfaceClause}" : string.Empty)})"; })); sql += $" AND ({orClauses})"; } @@ -714,6 +759,7 @@ FROM symbols s SqliteCommandPolicy.Add(cmd, $"@query{idx}LeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(effectiveQueries[idx])) ?? SqlNameResolver.GetLeafName(effectiveQueries[idx])); SqliteCommandPolicy.Add(cmd, $"@query{idx}SegmentCount", SqlNameResolver.GetSegmentCount(effectiveQueries[idx])); SqliteCommandPolicy.Add(cmd, $"@query{idx}NormalizedLike", $"%{EscapeLikeQuery(SqlNameResolver.NormalizeQualifiedName(effectiveQueries[idx]))}%"); + AddCSharpExplicitInterfaceIdentityQueryParameter(cmd, $"query{idx}", effectiveQueries[idx]); if (_symbolColumns.Contains("name_folded")) { var markdownHeadingIdentity = MarkdownAnchorIdentity.NormalizeHeadingFragment(effectiveQueries[idx]); diff --git a/src/CodeIndex/Database/DbWriter.FoldBackfill.cs b/src/CodeIndex/Database/DbWriter.FoldBackfill.cs index db4e4e06fa..7dc0386663 100644 --- a/src/CodeIndex/Database/DbWriter.FoldBackfill.cs +++ b/src/CodeIndex/Database/DbWriter.FoldBackfill.cs @@ -97,7 +97,7 @@ public bool AllFoldedColumnValuesMatchCurrentFold() var markdownSymbolIdentityFolds = BuildMarkdownSymbolIdentityFoldMap(); var symbols = RentCommand( """ - SELECT s.id, s.name, s.name_folded, f.lang, s.kind + SELECT s.id, s.name, s.name_folded, f.lang, s.kind, s.signature FROM symbols s JOIN files f ON f.id = s.file_id WHERE s.name IS NOT NULL @@ -113,6 +113,7 @@ WHERE s.name IS NOT NULL reader.GetString(1), reader.IsDBNull(3) ? null : reader.GetString(3), reader.GetString(4), + reader.IsDBNull(5) ? null : reader.GetString(5), markdownSymbolIdentityFolds); var actual = reader.IsDBNull(2) ? null : reader.GetString(2); if (!string.Equals(actual, expected, StringComparison.Ordinal)) @@ -379,17 +380,17 @@ private int BackfillSymbolFoldedRows(bool rewriteAll, CancellationToken cancella var markdownSymbolIdentityFolds = BuildMarkdownSymbolIdentityFoldMap(); var lastSymbolId = rewriteAll ? GetFoldBackfillCheckpoint(FoldBackfillLastSymbolIdMetaKey) : 0; - var rows = new List<(long Id, string Name, string? Lang, string Kind)>(); + var rows = new List<(long Id, string Name, string? Lang, string Kind, string? Signature)>(); var selectSql = rewriteAll ? """ - SELECT s.id, s.name, f.lang, s.kind + SELECT s.id, s.name, f.lang, s.kind, s.signature FROM symbols s JOIN files f ON f.id = s.file_id WHERE s.name IS NOT NULL AND s.id > @lastSymbolId ORDER BY s.id """ : """ - SELECT s.id, s.name, f.lang, s.kind + SELECT s.id, s.name, f.lang, s.kind, s.signature FROM symbols s JOIN files f ON f.id = s.file_id WHERE s.name IS NOT NULL AND s.name_folded IS NULL @@ -411,7 +412,8 @@ WHERE s.name IS NOT NULL AND s.name_folded IS NULL reader.GetInt64(0), reader.GetString(1), reader.IsDBNull(2) ? null : reader.GetString(2), - reader.GetString(3))); + reader.GetString(3), + reader.IsDBNull(4) ? null : reader.GetString(4))); } } finally @@ -441,6 +443,7 @@ WHERE s.name IS NOT NULL AND s.name_folded IS NULL row.Name, row.Lang, row.Kind, + row.Signature, markdownSymbolIdentityFolds); pId.Value = row.Id; update.ExecuteNonQuery(); @@ -512,6 +515,7 @@ private static string FoldPersistedSymbolName( string name, string? lang, string kind, + string? signature, IReadOnlyDictionary markdownSymbolIdentityFolds) { if (lang == "markdown" @@ -521,6 +525,14 @@ private static string FoldPersistedSymbolName( return identity; } + if (lang == "csharp") + { + var explicitInterfaceIdentity = + CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded(name, signature); + if (explicitInterfaceIdentity != null) + return explicitInterfaceIdentity; + } + return DbReader.FoldNameForLanguage(name, lang); } diff --git a/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs b/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs index 3466c081e7..e8a43687c5 100644 --- a/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs +++ b/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs @@ -30,6 +30,153 @@ public static string Normalize(string name, Match match, string matchLine) return NormalizeVerbatimIdentifiers(name); } + /// + /// Build the persisted folded identity for an explicit-interface implementation. The + /// user-facing symbol name remains the short member name, while this key preserves the + /// normalized interface qualifier and method generic arity. Member kind and normalized + /// signature remain separate columns in the canonical symbol row. + /// + /// 明示的インターフェース実装の永続 folded identity を構築する。ユーザー向け表示名は + /// 短いメンバー名のままにし、この key には正規化した interface qualifier と method の + /// generic arity を保持する。member kind と正規化済み signature は canonical symbol row + /// の別列として保持される。 + /// + internal static string? BuildExplicitInterfaceIdentityNameFolded(string name, Match match) + { + var qualifierGroup = match.Groups["explicitInterface"]; + if (!qualifierGroup.Success || string.IsNullOrWhiteSpace(qualifierGroup.Value)) + return null; + + var qualifier = NormalizeTypeDisplayName(qualifierGroup.Value); + var typeParameters = match.Groups["explicitTypeParameters"]; + var arity = typeParameters.Success + && TryCountTopLevelTypeArguments(typeParameters.Value, out var parsedArity) + ? parsedArity + : 0; + + return BuildExplicitInterfaceIdentityNameFolded(qualifier, name, arity); + } + + /// + /// Reconstruct an explicit-interface identity from persisted display name + signature. + /// Fold validation and maintenance use this so a rewrite never replaces the qualified + /// language identity with the short discovery alias. + /// + /// 永続化済みの表示名と signature から明示的インターフェース identity を再構築する。 + /// fold 検証・maintenance が、修飾済み language identity を短い discovery alias で + /// 上書きしないために使用する。 + /// + internal static string? BuildExplicitInterfaceIdentityNameFolded(string name, string? signature) + { + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(signature)) + return null; + + var sourceName = string.Equals(name, "Item", StringComparison.Ordinal) ? "this" : name; + var memberMarker = "." + sourceName; + var searchStart = 0; + while (searchStart < signature.Length) + { + var memberIndex = signature.IndexOf( + memberMarker, + searchStart, + StringComparison.Ordinal); + if (memberIndex <= 0) + return null; + + var cursor = memberIndex + memberMarker.Length; + while (cursor < signature.Length && char.IsWhiteSpace(signature[cursor])) + cursor++; + if (TryReadExplicitInterfaceMemberArity(signature, cursor, out var arity)) + { + var qualifierEnd = memberIndex; + var qualifierStart = FindExplicitInterfaceQualifierStart(signature, qualifierEnd); + if (qualifierStart < qualifierEnd) + { + var qualifier = NormalizeTypeDisplayName(signature[qualifierStart..qualifierEnd]); + return BuildExplicitInterfaceIdentityNameFolded(qualifier, name, arity); + } + } + + searchStart = memberIndex + memberMarker.Length; + } + + return null; + } + + private static bool TryReadExplicitInterfaceMemberArity( + string signature, + int cursor, + out int arity) + { + arity = 0; + if (cursor >= signature.Length) + return false; + + if (signature[cursor] != '<') + { + // Reject a matching qualified return/parameter type such as `Models.Run Run()`. + // A non-generic explicit member name is followed immediately by its + // parameter/indexer list, accessor body, expression body, or terminator. + return signature[cursor] is '(' or '[' or '{' or '=' or ';'; + } + + var typeParameterEnd = FindBalancedTypeArgumentListEnd(signature, cursor); + if (typeParameterEnd <= cursor + || !TryCountTopLevelTypeArguments( + signature[cursor..(typeParameterEnd + 1)], + out arity)) + { + arity = 0; + return false; + } + + cursor = typeParameterEnd + 1; + while (cursor < signature.Length && char.IsWhiteSpace(signature[cursor])) + cursor++; + + // A generic explicit-interface member is a method. Requiring its parameter list + // prevents a same-named qualified generic return/parameter type from being mistaken + // for the member declaration. + if (cursor < signature.Length && signature[cursor] == '(') + return true; + + arity = 0; + return false; + } + + /// + /// Normalize the exact-query spelling documented for C# explicit-interface members into + /// the same folded identity used by extraction. A terminal generic argument list is reduced + /// to arity so `IFoo.Run<T>` and an implementation declared as `IFoo.Run<TValue>` + /// share the language identity without collapsing into an unqualified `Run`. + /// + /// C# 明示的インターフェースメンバー向けに文書化した完全一致 query 表記を、抽出時と + /// 同じ folded identity へ正規化する。末尾の generic 引数リストは arity に変換し、 + /// `IFoo.Run<T>` と `IFoo.Run<TValue>` を同一 identity としつつ、非修飾の + /// `Run` へは統合しない。 + /// + internal static string NormalizeExplicitInterfaceQueryIdentityNameFolded(string query) + { + var normalized = NormalizeTypeDisplayName(query); + var lastDot = FindLastTopLevelDot(normalized); + if (lastDot < 0) + return NameFold.Fold(normalized) ?? normalized; + + var leafStart = lastDot + 1; + var genericStart = normalized.IndexOf('<', leafStart); + if (genericStart >= 0 + && normalized.EndsWith('>') + && TryCountTopLevelTypeArguments(normalized[genericStart..], out var arity)) + { + normalized = normalized[..genericStart] + $"`{arity}"; + } + + if (string.Equals(normalized[(lastDot + 1)..], "this", StringComparison.Ordinal)) + normalized = normalized[..(lastDot + 1)] + "Item"; + + return NameFold.Fold(normalized) ?? normalized; + } + private static bool TryReadConversionOperatorName(Match match, string matchLine, out string name) { name = string.Empty; @@ -154,6 +301,178 @@ private static string NormalizeTypeDisplayName(string typeName) return NormalizeVerbatimIdentifiers(normalized); } + private static int FindLastTopLevelDot(string value) + { + var angleDepth = 0; + var bracketDepth = 0; + var lastDot = -1; + for (var index = 0; index < value.Length; index++) + { + switch (value[index]) + { + case '<': + angleDepth++; + break; + case '>': + if (angleDepth > 0) + angleDepth--; + break; + case '[': + bracketDepth++; + break; + case ']': + if (bracketDepth > 0) + bracketDepth--; + break; + case '.': + if (angleDepth == 0 && bracketDepth == 0) + lastDot = index; + break; + } + } + + return lastDot; + } + + private static string BuildExplicitInterfaceIdentityNameFolded( + string qualifier, + string name, + int arity) + { + var identityName = $"{qualifier}.{name}"; + if (arity > 0) + identityName += $"`{arity}"; + + return NameFold.Fold(identityName) ?? identityName; + } + + private static int FindExplicitInterfaceQualifierStart(string signature, int qualifierEnd) + { + var angleDepth = 0; + var bracketDepth = 0; + for (var index = qualifierEnd - 1; index >= 0; index--) + { + switch (signature[index]) + { + case '>': + angleDepth++; + break; + case '<': + if (angleDepth > 0) + angleDepth--; + break; + case ']': + bracketDepth++; + break; + case '[': + if (bracketDepth > 0) + bracketDepth--; + break; + default: + if (char.IsWhiteSpace(signature[index]) + && angleDepth == 0 + && bracketDepth == 0) + { + return index + 1; + } + break; + } + } + + return 0; + } + + private static int FindBalancedTypeArgumentListEnd(string value, int startIndex) + { + var angleDepth = 0; + var bracketDepth = 0; + var parenDepth = 0; + for (var index = startIndex; index < value.Length; index++) + { + switch (value[index]) + { + case '<': + angleDepth++; + break; + case '>': + angleDepth--; + if (angleDepth == 0 && bracketDepth == 0 && parenDepth == 0) + return index; + if (angleDepth < 0) + return -1; + break; + case '[': + bracketDepth++; + break; + case ']': + if (bracketDepth > 0) + bracketDepth--; + break; + case '(': + parenDepth++; + break; + case ')': + if (parenDepth > 0) + parenDepth--; + break; + } + } + + return -1; + } + + private static bool TryCountTopLevelTypeArguments(string value, out int arity) + { + arity = 0; + var trimmed = value.AsSpan().Trim(); + if (trimmed.Length < 3 || trimmed[0] != '<' || trimmed[^1] != '>') + return false; + + var angleDepth = 0; + var bracketDepth = 0; + var parenDepth = 0; + var sawToken = false; + arity = 1; + foreach (var ch in trimmed) + { + switch (ch) + { + case '<': + angleDepth++; + break; + case '>': + angleDepth--; + if (angleDepth < 0) + return false; + break; + case '[': + bracketDepth++; + break; + case ']': + if (bracketDepth > 0) + bracketDepth--; + break; + case '(': + parenDepth++; + break; + case ')': + if (parenDepth > 0) + parenDepth--; + break; + case ',': + if (angleDepth == 1 && bracketDepth == 0 && parenDepth == 0) + arity++; + break; + default: + if (!char.IsWhiteSpace(ch) && ch is not '<' and not '>') + sawToken = true; + break; + } + } + + return angleDepth == 0 && sawToken; + } + private static string NormalizeVerbatimIdentifiers(string value) { if (string.IsNullOrEmpty(value) diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmission.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmission.cs index d700e94a03..7782cd44ae 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmission.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmission.cs @@ -225,6 +225,11 @@ private static void AddDefaultPatternSymbol( PatternSymbolEmissionContext context, string kind) { + var csharpExplicitInterfaceIdentityNameFolded = context.Language == "csharp" + ? CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( + context.Name, + context.Match) + : null; var csharpMetadataTarget = TryClassifyCSharpExtractorMetadataTarget( context.Language, context.Pattern.Kind, @@ -242,7 +247,8 @@ private static void AddDefaultPatternSymbol( kind, context.Signature, context.PatternMatchLine), - csharpMetadataTarget); + csharpMetadataTarget, + csharpExplicitInterfaceIdentityNameFolded); if (context.DockerfileStageNames != null && kind == "stage") context.DockerfileStageNames.Add(context.Name); @@ -287,7 +293,8 @@ private static void AddEmittedPatternSymbol( string? returnType, string? familyKey = null, string? subKind = null, - bool? isMetadataTarget = null) + bool? isMetadataTarget = null, + string? identityNameFolded = null) { var startLine = context.LineIndex + 1; AddSymbolRecord( @@ -300,6 +307,7 @@ private static void AddEmittedPatternSymbol( FileId = context.FileId, Kind = kind, Name = name, + IdentityNameFolded = identityNameFolded, Line = startLine, StartLine = startLine, StartColumn = startColumn, diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Patterns.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Patterns.cs index 55ecd9e837..f2d911e77f 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Patterns.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Patterns.cs @@ -1185,7 +1185,7 @@ private enum JavaScriptTypeScriptFunctionHeaderConsumeResult // qualifier 側ではなく末尾のメンバー名を event 名として捕捉しなければならない。 // BodyStyle.Brace を使い、同一行/次行どちらの accessor block も通常の brace-range // 経路で扱う。 - new("event", new Regex($@"^\s*(?:(?{CSharpVisibilityPattern})\s+|(?:static|unsafe|extern|virtual|override|abstract|sealed|new|partial)\s+)*event\s+(?{CSharpTypePattern})\s+{CSharpExplicitInterfaceQualifierPattern}\s*\.\s*(?{CSharpIdentifierPattern})\b", RegexOptions.Compiled), BodyStyle.Brace, "visibility", "returnType"), + new("event", new Regex($@"^\s*(?:(?{CSharpVisibilityPattern})\s+|(?:static|unsafe|extern|virtual|override|abstract|sealed|new|partial)\s+)*event\s+(?{CSharpTypePattern})\s+(?{CSharpExplicitInterfaceQualifierPattern})\s*\.\s*(?{CSharpIdentifierPattern})\b", RegexOptions.Compiled), BodyStyle.Brace, "visibility", "returnType"), // Explicit interface implementation (e.g. void IDisposable.Dispose()) // Requires a valid return type (not a statement keyword) and interface name before the dot. // Reject named-argument labels only when they are followed by a qualified call site, @@ -1218,7 +1218,7 @@ private enum JavaScriptTypeScriptFunctionHeaderConsumeResult // / `Inner`。正規表現は最初の `(` で止まるので、末尾の `.Append(...)` / // `.Consume()` チェーンはキャプチャされない)として // 明示的インターフェースメソッドに化けないようにする。 - new("function", new Regex($@"^\s*(?![?:])(?!(?:await|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|using|case|else|when|break|continue|goto|new|from|where|select|orderby|group|join|let|into|on|equals|ascending|descending|by)\b)(?!\w+\s*:\s*(?:global::)?[\w@.<>:]+\.\w+\s*{CSharpMethodTypeParameterListPattern}[\(\[])(?:(?ref(?:\s+readonly)?)\s+)?(?{CSharpTypePattern})\s+{CSharpExplicitInterfaceQualifierPattern}\.(?{CSharpIdentifierPattern})\s*{CSharpMethodTypeParameterListPattern}[\(\[]", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), + new("function", new Regex($@"^\s*(?![?:])(?!(?:await|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|using|case|else|when|break|continue|goto|new|from|where|select|orderby|group|join|let|into|on|equals|ascending|descending|by)\b)(?!\w+\s*:\s*(?:global::)?[\w@.<>:]+\.\w+\s*{CSharpMethodTypeParameterListPattern}[\(\[])(?:(?ref(?:\s+readonly)?)\s+)?(?{CSharpTypePattern})\s+(?{CSharpExplicitInterfaceQualifierPattern})\.(?{CSharpIdentifierPattern})\s*(?{CSharpMethodTypeParameterListPattern})[\(\[]", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), // Explicit interface property implementation (brace body), e.g. int IThing.Value { get; set; } // Mirrors the explicit-interface method row above: the qualifier is non-capturing so the // short property name (Value) is recorded as name, consistent with how the method row @@ -1227,10 +1227,15 @@ private enum JavaScriptTypeScriptFunctionHeaderConsumeResult // 上の明示的インターフェースメソッド行と同じ構造で、修飾子は非キャプチャにしてショート名 // (Value) のみを name として記録する。メソッド側が Dispose / CompareTo を返すのと揃える。 // Closes #333. - new("property", new Regex($@"^\s*(?![?:])(?!(?:class|struct|interface|enum|record|namespace|delegate|event|const|using|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|case|else|when|break|continue|goto|await)\b)(?:(?ref(?:\s+readonly)?)\s+)?(?{CSharpTypePattern})\s+{CSharpExplicitInterfaceQualifierPattern}\.(?{CSharpIdentifierPattern})\s*\{{", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), + new("property", new Regex($@"^\s*(?![?:])(?!(?:class|struct|interface|enum|record|namespace|delegate|event|const|using|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|case|else|when|break|continue|goto|await)\b)(?:(?ref(?:\s+readonly)?)\s+)?(?{CSharpTypePattern})\s+(?{CSharpExplicitInterfaceQualifierPattern})\.(?{CSharpIdentifierPattern})\s*\{{", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), // Explicit interface property implementation (expression body), e.g. string IThing.Name => "x"; // 明示的インターフェースプロパティ実装(式本体)。例: string IThing.Name => "x"; - new("property", new Regex($@"^\s*(?![?:])(?!(?:class|struct|interface|enum|record|namespace|delegate|event|const|using|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|case|else|when|break|continue|goto|await)\b)(?:(?ref(?:\s+readonly)?)\s+)?(?{CSharpTypePattern})\s+{CSharpExplicitInterfaceQualifierPattern}\.(?{CSharpIdentifierPattern})\s*=>\s*", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), + new("property", new Regex($@"^\s*(?![?:])(?!(?:class|struct|interface|enum|record|namespace|delegate|event|const|using|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|case|else|when|break|continue|goto|await)\b)(?:(?ref(?:\s+readonly)?)\s+)?(?{CSharpTypePattern})\s+(?{CSharpExplicitInterfaceQualifierPattern})\.(?{CSharpIdentifierPattern})\s*=>\s*", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), + // Explicit interface indexer implementation. The display name stays `Item`, while + // the captured qualifier becomes part of the persisted exact-query identity. + // 明示的インターフェース indexer 実装。表示名は `Item` のままにし、捕捉した + // qualifier は永続化する完全一致 query identity に含める。 + new("function", new Regex($@"^\s*(?![?:])(?:(?ref(?:\s+readonly)?)\s+)?(?{CSharpTypePattern})\s+(?{CSharpExplicitInterfaceQualifierPattern})\.(?this)\s*\[", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), // Indexer (this[...]) — `partial` is legal on indexers since C# 13 (extended partial // member support), so accept it alongside the other modifiers. Otherwise every // `partial` indexer declaration would be silently dropped from symbols / definition / diff --git a/tests/CodeIndex.Tests/DbReaderSearchTests.cs b/tests/CodeIndex.Tests/DbReaderSearchTests.cs index aa300887eb..b694a63e99 100644 --- a/tests/CodeIndex.Tests/DbReaderSearchTests.cs +++ b/tests/CodeIndex.Tests/DbReaderSearchTests.cs @@ -1870,6 +1870,184 @@ public class Impl : IFoo Assert.Contains(createResults, s => s.Kind == "function" && s.Name == "Create" && s.ReturnType == "Alias::Type"); } + [Fact] + public void SearchSymbols_ExplicitInterfaceExactIdentityAndShortAliasStayDistinct_Issue4866() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_explicit_interface_identity_4866"); + var dbPath = Path.Combine(project.Root, "codeindex.db"); + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + db.InitializeSchema(); + var writer = new DbWriter(db.Connection); + const string path = "src/ExplicitMembers.cs"; + const string content = """ + namespace Demo; + + public interface IFoo + { + void Run(T value); + int Value { get; } + event System.EventHandler Changed; + string this[int index] { get; } + } + + public interface IBar + { + void Run(TLeft left, TRight right); + } + + public sealed class Service : IFoo, IBar + { + void IFoo.Run(TValue value) { } + void IBar.Run(TLeft left, TRight right) { } + int IFoo.Value => 1; + event System.EventHandler IFoo.Changed { add { } remove { } } + string IFoo.this[int index] => index.ToString(); + public void Run(T value) { } + public void CallPublicRun() { Run(1); } + } + """; + var fileId = writer.UpsertFile(new FileRecord + { + Path = path, + Lang = "csharp", + Size = content.Length, + Lines = content.Count(ch => ch == '\n') + 1, + Modified = new DateTime(2026, 7, 29, 0, 0, 0, DateTimeKind.Utc), + }); + writer.InsertChunks([ + new ChunkRecord + { + FileId = fileId, + ChunkIndex = 0, + StartLine = 1, + EndLine = content.Count(ch => ch == '\n') + 1, + Content = content, + }, + ]); + var symbols = SymbolExtractor.Extract(fileId, "csharp", content, filePath: path); + SymbolExtractor.ApplyFamilyScope(symbols, FileIndexer.DeriveFallbackFamilyScopeKey(path)); + writer.InsertSymbols(symbols); + writer.InsertReferences(ReferenceExtractor.Extract(fileId, "csharp", content, symbols, path: path)); + var rewritten = writer.BackfillFoldedColumns(rewriteAll: true); + Assert.True(rewritten.Symbols > 0); + Assert.True(writer.MarkFoldReady()); + writer.MarkCSharpSymbolNameContractReady(); + writer.MarkGraphReady(); + + using var reader = new DbReader(db.Connection); + Assert.Equal( + DbContext.CSharpSymbolNameContractVersion.ToString(System.Globalization.CultureInfo.InvariantCulture), + reader.GetMetaString(DbContext.CSharpSymbolNameContractVersionMetaKey)); + Assert.Equal( + "ifoo.run`1", + CSharpSymbolNameNormalizer.NormalizeExplicitInterfaceQueryIdentityNameFolded("IFoo.Run")); + Assert.Null(CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( + "Run", + "Models.Run Run()")); + Assert.Null(CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( + "Run", + "Models.Run Run()")); + Assert.Equal( + "ifoo.run`1", + CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( + "Run", + "Models.Run IFoo.Run(TValue value)")); + Assert.True(SqlNameResolver.HasQualifier("IFoo.Run")); + using (var identityCommand = db.Connection.CreateCommand()) + { + identityCommand.CommandText = """ + SELECT name_folded + FROM symbols + WHERE signature LIKE 'void IFoo.Run%' + """; + Assert.Equal("ifoo.run`1", identityCommand.ExecuteScalar()); + } + + var fooRun = Assert.Single(reader.SearchSymbols( + "IFoo.Run", + lang: "csharp", + exact: true)); + Assert.Equal("Run", fooRun.Name); + Assert.StartsWith("void IFoo.Run", fooRun.Signature, StringComparison.Ordinal); + + var sameArityAlias = Assert.Single(reader.SearchSymbols( + "IFoo.Run", + lang: "csharp", + exact: true)); + Assert.Equal(fooRun.SymbolId, sameArityAlias.SymbolId); + + var barRun = Assert.Single(reader.SearchSymbols( + "IBar.Run", + lang: "csharp", + exact: true)); + Assert.StartsWith("void IBar.Run", barRun.Signature, StringComparison.Ordinal); + Assert.NotEqual(fooRun.SymbolId, barRun.SymbolId); + + var interfaceDeclaration = Assert.Single(reader.SearchSymbols( + "IFoo.Run", + lang: "csharp", + exact: true)); + Assert.Equal("interface", interfaceDeclaration.ContainerKind); + Assert.NotEqual(fooRun.SymbolId, interfaceDeclaration.SymbolId); + Assert.Contains( + reader.SearchSymbols("Run", limit: 20, lang: "csharp", exact: true), + result => result.SymbolId == fooRun.SymbolId); + + var valueResults = reader.SearchSymbols("IFoo.Value", lang: "csharp", exact: true); + Assert.Equal(2, valueResults.Count); + Assert.Equal(2, valueResults.Select(result => result.SymbolId).Distinct().Count()); + Assert.Contains(valueResults, result => result.ContainerKind == "interface"); + Assert.Contains(valueResults, result => result.ContainerKind == "class"); + + var eventResults = reader.SearchSymbols("IFoo.Changed", lang: "csharp", exact: true); + Assert.Equal(2, eventResults.Count); + Assert.Equal(2, eventResults.Select(result => result.SymbolId).Distinct().Count()); + + var itemResults = reader.SearchSymbols("IFoo.Item", lang: "csharp", exact: true); + Assert.Equal(2, itemResults.Count); + Assert.Equal(2, itemResults.Select(result => result.SymbolId).Distinct().Count()); + Assert.Single(reader.SearchSymbols("IFoo.this", lang: "csharp", exact: true)); + Assert.Single(reader.SearchSymbols("IFoo.Run", lang: "csharp", exact: false)); + + var definitions = reader.GetDefinitions( + "IFoo.Run", + lang: "csharp", + exact: true); + Assert.Single(definitions); + Assert.Equal(fooRun.SymbolId, definitions[0].SymbolId); + Assert.Equal(1, reader.CountDefinitionsTotal( + "IFoo.Run", + lang: "csharp", + exact: true).Count); + + var analysis = reader.AnalyzeSymbol( + "IFoo.Run", + lang: "csharp", + exact: true); + Assert.Single(analysis.Definitions); + Assert.Equal(fooRun.SymbolId, analysis.Definitions[0].SymbolId); + Assert.Empty(analysis.References); + Assert.Empty(reader.SearchReferences( + "IFoo.Run", + lang: "csharp", + exact: true)); + Assert.NotEmpty(reader.SearchReferences( + "Run", + lang: "csharp", + exact: true)); + + var outline = reader.GetOutline(path); + Assert.NotNull(outline); + Assert.Contains( + outline!.Symbols, + symbol => symbol.Name == "Run" + && symbol.Signature?.StartsWith("void IFoo.Run", StringComparison.Ordinal) == true); + Assert.Contains( + outline.Symbols, + symbol => symbol.Name == "Item" + && symbol.Signature?.Contains("IFoo.this", StringComparison.Ordinal) == true); + } + [Fact] public void SearchSymbols_ReturnsRichMetadataWhenAvailable() { diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 14c627f9e9..d3dbaaca30 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -3960,6 +3960,89 @@ public void HandleMessage_Definition_ReturnsMultipleWorkspaceCandidates_Issue353 } } + [Fact] + public void HandleMessage_ExplicitInterfaceIdentityKeepsDefinitionAndReferencesScoped_Issue4866() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_explicit_interface_identity_4866"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "service.cs"); + var source = """ + interface IFoo + { + void Run(T value); + } + class Service : IFoo + { + void IFoo.Run(TValue value) { } + public void Run(T value) { } + void Call() { Run(1); } + } + """; + File.WriteAllText(sourcePath, source); + TestProjectHelper.InsertIndexedFile(dbPath, "service.cs", "csharp", source); + using (var readinessDb = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + var writer = new DbWriter(readinessDb.Connection); + Assert.True(writer.MarkFoldReady()); + writer.MarkCSharpSymbolNameContractReady(); + writer.MarkGraphReady(); + } + + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + using var server = new LspServer( + new DbReader(db), + "1.2.3", + ProgramRunner.CreateDefaultJsonOptions(), + projectRoot); + var explicitRunCharacter = CharacterOf(source, 6, "Run"); + + var definitionResponse = HandleInitializedMessage( + server, + CreateDefinitionRequest(sourcePath, 4866, 6, explicitRunCharacter)); + Assert.NotNull(definitionResponse); + var definitionLocation = Assert.Single(definitionResponse!["result"]!.AsArray()); + Assert.Equal( + 6, + definitionLocation!["range"]!["start"]!["line"]!.GetValue()); + + var referencesResponse = server.HandleMessage( + CreateReferencesRequest( + sourcePath, + 4867, + 6, + explicitRunCharacter, + includeDeclaration: true)); + Assert.NotNull(referencesResponse); + var referenceLocations = referencesResponse!["result"]!.AsArray(); + var referenceLocation = Assert.Single(referenceLocations); + Assert.Equal( + 6, + referenceLocation!["range"]!["start"]!["line"]!.GetValue()); + Assert.DoesNotContain( + referenceLocations, + location => location!["range"]!["start"]!["line"]!.GetValue() == 8); + + var documentSymbolsResponse = server.HandleMessage( + CreateTextDocumentRequest("textDocument/documentSymbol", sourcePath, 4868)); + Assert.NotNull(documentSymbolsResponse); + var roots = documentSymbolsResponse!["result"]!.AsArray(); + var service = Assert.Single( + roots, + symbol => symbol?["name"]?.GetValue() == "Service"); + var serviceChildren = service!["children"]!.AsArray(); + Assert.Equal( + 2, + serviceChildren.Count( + symbol => symbol?["name"]?.GetValue().StartsWith("Run", StringComparison.Ordinal) == true)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_References_PrefersCurrentIndexedDocumentForCommonToken() { diff --git a/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs b/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs index 72952edc0a..0054f8857c 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs @@ -4375,6 +4375,78 @@ public void Extract_CSharp_DetectsExplicitInterfaceImpl() Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "ArrayArg" && s.ReturnType == "string"); } + [Fact] + public void Extract_CSharp_PreservesExplicitInterfaceIdentity_Issue4866() + { + var content = """ + namespace Demo; + + public interface IBase + { + void Run(T value); + } + + public interface IFoo : IBase + { + int Value { get; } + event System.EventHandler Changed; + string this[int index] { get; } + } + + public interface IBar + { + void Run(TLeft left, TRight right); + } + + public sealed class Service : IFoo, IBar + { + void IBase.Run(TValue value) { } + void IBar.Run(TLeft left, TRight right) { } + int IFoo.Value => 1; + event System.EventHandler IFoo.Changed { add { } remove { } } + string IFoo.this[int index] => index.ToString(); + public void Run(T value) { } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var serviceMembers = symbols.Where(symbol => symbol.ContainerName == "Service").ToList(); + + var baseRun = Assert.Single( + serviceMembers, + symbol => symbol.Kind == "function" + && symbol.Name == "Run" + && symbol.Signature?.StartsWith("void IBase.", StringComparison.Ordinal) == true); + Assert.Equal("ibase.run`1", baseRun.IdentityNameFolded); + + var barRun = Assert.Single( + serviceMembers, + symbol => symbol.Kind == "function" + && symbol.Name == "Run" + && symbol.Signature?.StartsWith("void IBar.", StringComparison.Ordinal) == true); + Assert.Equal("ibar.run`2", barRun.IdentityNameFolded); + + var ordinaryRun = Assert.Single( + serviceMembers, + symbol => symbol.Kind == "function" + && symbol.Name == "Run" + && symbol.Signature?.StartsWith("public void Run", StringComparison.Ordinal) == true); + Assert.Null(ordinaryRun.IdentityNameFolded); + + Assert.Equal( + "ifoo.value", + Assert.Single(serviceMembers, symbol => symbol.Kind == "property" && symbol.Name == "Value") + .IdentityNameFolded); + Assert.Equal( + "ifoo.changed", + Assert.Single(serviceMembers, symbol => symbol.Kind == "event" && symbol.Name == "Changed") + .IdentityNameFolded); + Assert.Equal( + "ifoo.item", + Assert.Single(serviceMembers, symbol => symbol.Kind == "function" && symbol.Name == "Item") + .IdentityNameFolded); + } + [Fact] public void Extract_CSharp_DetectsGenericOverTupleReturnTypes() { From 66882fc4bfd6f76b2a85b2644238bea5ae9df253 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 04:26:50 +0900 Subject: [PATCH 02/18] Harden explicit-interface fold reconstruction (#4866) --- .../Symbols/CSharpSymbolNameNormalizer.cs | 19 +++++++++++++++++++ tests/CodeIndex.Tests/DbReaderSearchTests.cs | 12 ++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs b/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs index e8a43687c5..6c307df0c0 100644 --- a/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs +++ b/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs @@ -73,6 +73,7 @@ public static string Normalize(string name, Match match, string matchLine) var sourceName = string.Equals(name, "Item", StringComparison.Ordinal) ? "this" : name; var memberMarker = "." + sourceName; + var declarationBodyStart = FindDeclarationBodyStart(signature); var searchStart = 0; while (searchStart < signature.Length) { @@ -82,6 +83,8 @@ public static string Normalize(string name, Match match, string matchLine) StringComparison.Ordinal); if (memberIndex <= 0) return null; + if (memberIndex >= declarationBodyStart) + return null; var cursor = memberIndex + memberMarker.Length; while (cursor < signature.Length && char.IsWhiteSpace(signature[cursor])) @@ -103,6 +106,22 @@ public static string Normalize(string name, Match match, string matchLine) return null; } + private static int FindDeclarationBodyStart(string signature) + { + var expressionBodyStart = signature.IndexOf("=>", StringComparison.Ordinal); + var blockBodyStart = signature.IndexOf('{'); + var initializerStart = signature.IndexOf('='); + var bodyStart = signature.Length; + if (expressionBodyStart >= 0) + bodyStart = Math.Min(bodyStart, expressionBodyStart); + if (blockBodyStart >= 0) + bodyStart = Math.Min(bodyStart, blockBodyStart); + if (initializerStart >= 0) + bodyStart = Math.Min(bodyStart, initializerStart); + + return bodyStart; + } + private static bool TryReadExplicitInterfaceMemberArity( string signature, int cursor, diff --git a/tests/CodeIndex.Tests/DbReaderSearchTests.cs b/tests/CodeIndex.Tests/DbReaderSearchTests.cs index b694a63e99..0275a08511 100644 --- a/tests/CodeIndex.Tests/DbReaderSearchTests.cs +++ b/tests/CodeIndex.Tests/DbReaderSearchTests.cs @@ -1947,6 +1947,18 @@ public void Run(T value) { } Assert.Null(CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( "Run", "Models.Run Run()")); + Assert.Null(CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( + "Count", + "public int Count => inner.Count;")); + Assert.Null(CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( + "Add", + "public void Add(Item item) => inner.Add(item);")); + Assert.Null(CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( + "MaxSize", + "internal const int MaxSize = Limits.MaxSize;")); + Assert.Null(CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( + "Registry", + "using Registry = CodeIndex.Indexer.Registry;")); Assert.Equal( "ifoo.run`1", CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( From 7ec83389788a6256f105aafe660f21908f5d59bf Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 09:02:45 +0900 Subject: [PATCH 03/18] Address explicit-interface identity review findings (#4866) --- .../Database/DbContext.ConnectionFunctions.cs | 4 + .../Database/DbSymbolReader.Definitions.cs | 5 +- .../Database/DbSymbolReader.Search.cs | 57 ++++++++++-- .../PostExtractionHookMutationMaterializer.cs | 18 +++- .../Indexer/Hooks/PostExtractionHooks.cs | 10 +- .../Symbols/CSharpSymbolNameNormalizer.cs | 92 ++++++++++++++++--- tests/CodeIndex.Tests/DbReaderSearchTests.cs | 83 ++++++++++++++++- .../PostExtractionHookContractTests.cs | 35 +++++++ 8 files changed, 273 insertions(+), 31 deletions(-) diff --git a/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs b/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs index 76af876a38..131abec647 100644 --- a/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs +++ b/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs @@ -57,6 +57,10 @@ internal static void RegisterConnectionFunctions(SqliteConnection connection) var leafName = SqlNameResolver.GetLeafName(name); return leafName.Length == 0 ? null : NameFold.Fold(leafName) ?? leafName; }); + connection.CreateFunction( + "codeindex_name_fold", + (string? name) => NameFold.Fold(name), + isDeterministic: true); connection.CreateFunction( "sql_normalize_name", (string? name) => string.IsNullOrWhiteSpace(name) ? null : SqlNameResolver.NormalizeQualifiedName(name)); diff --git a/src/CodeIndex/Database/DbSymbolReader.Definitions.cs b/src/CodeIndex/Database/DbSymbolReader.Definitions.cs index 342aa356d4..5002295ea3 100644 --- a/src/CodeIndex/Database/DbSymbolReader.Definitions.cs +++ b/src/CodeIndex/Database/DbSymbolReader.Definitions.cs @@ -384,8 +384,9 @@ FROM chunks c SqliteCommandPolicy.Add(cmd, "@query", paramValue); SqliteCommandPolicy.Add(cmd, "@queryNormalized", SqlNameResolver.NormalizeQualifiedName(normalizedQuery)); SqliteCommandPolicy.Add(cmd, "@queryNormalizedFolded", NameFold.Fold(SqlNameResolver.NormalizeQualifiedName(normalizedQuery)) ?? SqlNameResolver.NormalizeQualifiedName(normalizedQuery)); - SqliteCommandPolicy.Add(cmd, "@queryLeaf", SqlNameResolver.GetLeafName(normalizedQuery)); - SqliteCommandPolicy.Add(cmd, "@queryLeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(normalizedQuery)) ?? SqlNameResolver.GetLeafName(normalizedQuery)); + var queryLeaf = GetQualifiedQueryLeaf(normalizedQuery, lang); + SqliteCommandPolicy.Add(cmd, "@queryLeaf", queryLeaf); + SqliteCommandPolicy.Add(cmd, "@queryLeafFolded", NameFold.Fold(queryLeaf) ?? queryLeaf); SqliteCommandPolicy.Add(cmd, "@querySegmentCount", SqlNameResolver.GetSegmentCount(normalizedQuery)); SqliteCommandPolicy.Add(cmd, "@queryNormalizedLike", $"%{EscapeLikeQuery(SqlNameResolver.NormalizeQualifiedName(normalizedQuery))}%"); AddCSharpExplicitInterfaceIdentityQueryParameter(cmd, "query", normalizedQuery); diff --git a/src/CodeIndex/Database/DbSymbolReader.Search.cs b/src/CodeIndex/Database/DbSymbolReader.Search.cs index d31229d19e..70916527f6 100644 --- a/src/CodeIndex/Database/DbSymbolReader.Search.cs +++ b/src/CodeIndex/Database/DbSymbolReader.Search.cs @@ -80,13 +80,24 @@ public List GetDistinctKinds() public List SearchSymbols(string? query = null, int limit = 20, string? kind = null, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, DateTime? since = null, bool exact = false, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null, SymbolSortMode sortMode = SymbolSortMode.Name, int? startLine = null, int? endLine = null, bool groupPartials = false, int offset = 0) { var normalizedQuery = NormalizeSymbolSearchQueryForSymbolSearch(query, lang, exact); - return SearchSymbols(normalizedQuery == null ? null : new[] { normalizedQuery }, limit, kind, lang, pathPatterns, excludePathPatterns, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters, sortMode, startLine, endLine, groupPartials, offset); + return SearchSymbols( + normalizedQuery == null + ? null + : new NormalizedSymbolSearchQueryList([normalizedQuery]), + limit, kind, lang, pathPatterns, excludePathPatterns, excludeTests, since, exact, + visibilityFilters, excludeVisibilityFilters, sortMode, startLine, endLine, + groupPartials, offset); } public int CountSearchSymbols(string? query = null, int limit = 20, string? kind = null, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, DateTime? since = null, bool exact = false, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) { var normalizedQuery = NormalizeSymbolSearchQueryForSymbolSearch(query, lang, exact); - return CountSearchSymbols(normalizedQuery == null ? null : new[] { normalizedQuery }, limit, kind, lang, pathPatterns, excludePathPatterns, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters); + return CountSearchSymbols( + normalizedQuery == null + ? null + : new NormalizedSymbolSearchQueryList([normalizedQuery]), + limit, kind, lang, pathPatterns, excludePathPatterns, excludeTests, since, exact, + visibilityFilters, excludeVisibilityFilters); } public bool AnySearchSymbols(IReadOnlyList? queries, string? kind = null, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, DateTime? since = null, bool exact = false, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) @@ -97,8 +108,19 @@ public bool AnySearchSymbols(IReadOnlyList? queries, string? kind = null foreach (var query in validQueries) { - if (CountSearchSymbols([query], 1, kind, lang, pathPatterns, excludePathPatterns, excludeTests, since, exact) > 0) + if (CountSearchSymbols( + new NormalizedSymbolSearchQueryList([query]), + 1, + kind, + lang, + pathPatterns, + excludePathPatterns, + excludeTests, + since, + exact) > 0) + { return true; + } } return false; @@ -129,11 +151,13 @@ private string BuildCSharpExplicitInterfaceIdentityMatchSql( return $"({fileAlias}.lang = 'csharp' AND {symbolAlias}.name_folded = @{parameterStem}CSharpExplicitInterfaceIdentityFolded)"; } - private static string BuildCSharpExplicitInterfaceShortAliasMatchSql( + private string BuildCSharpExplicitInterfaceShortAliasMatchSql( string parameterStem, string symbolAlias = "s", string fileAlias = "f") - => $"({fileAlias}.lang = 'csharp' AND {symbolAlias}.name = @{parameterStem}Leaf COLLATE NOCASE)"; + => _foldReady + ? $"({fileAlias}.lang = 'csharp' AND instr({symbolAlias}.name_folded, '.') > 0 AND codeindex_name_fold({symbolAlias}.name) = @{parameterStem}LeafFolded)" + : $"({fileAlias}.lang = 'csharp' AND {symbolAlias}.name = @{parameterStem}Leaf COLLATE NOCASE)"; private static void AddCSharpExplicitInterfaceIdentityQueryParameter( SqliteCommand cmd, @@ -163,6 +187,12 @@ private static string GetQualifiedQuerySuffix(string query) return previousDot >= 0 ? normalized[(previousDot + 1)..] : normalized; } + private static string GetQualifiedQueryLeaf(string query, string? lang) + { + var leaf = SqlNameResolver.GetLeafName(query); + return NormalizeCSharpVerbatimQuery(leaf, lang) ?? leaf; + } + private static void AddQualifiedSymbolQueryParameters(SqliteCommand cmd, string parameterStem, string query) { var container = GetQualifiedQueryContainer(query); @@ -287,8 +317,9 @@ FROM symbols s SqliteCommandPolicy.Add(cmd, "@query0", paramValue); SqliteCommandPolicy.Add(cmd, "@query0Normalized", SqlNameResolver.NormalizeQualifiedName(value)); SqliteCommandPolicy.Add(cmd, "@query0NormalizedFolded", NameFold.Fold(SqlNameResolver.NormalizeQualifiedName(value)) ?? SqlNameResolver.NormalizeQualifiedName(value)); - SqliteCommandPolicy.Add(cmd, "@query0Leaf", SqlNameResolver.GetLeafName(value)); - SqliteCommandPolicy.Add(cmd, "@query0LeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(value)) ?? SqlNameResolver.GetLeafName(value)); + var queryLeaf = GetQualifiedQueryLeaf(value, lang); + SqliteCommandPolicy.Add(cmd, "@query0Leaf", queryLeaf); + SqliteCommandPolicy.Add(cmd, "@query0LeafFolded", NameFold.Fold(queryLeaf) ?? queryLeaf); SqliteCommandPolicy.Add(cmd, "@query0SegmentCount", SqlNameResolver.GetSegmentCount(value)); SqliteCommandPolicy.Add(cmd, "@query0NormalizedLike", $"%{EscapeLikeQuery(SqlNameResolver.NormalizeQualifiedName(value))}%"); AddCSharpExplicitInterfaceIdentityQueryParameter(cmd, "query0", value); @@ -755,8 +786,9 @@ FROM symbols s } SqliteCommandPolicy.Add(cmd, $"@query{idx}Normalized", SqlNameResolver.NormalizeQualifiedName(effectiveQueries[idx])); SqliteCommandPolicy.Add(cmd, $"@query{idx}NormalizedFolded", NameFold.Fold(SqlNameResolver.NormalizeQualifiedName(effectiveQueries[idx])) ?? SqlNameResolver.NormalizeQualifiedName(effectiveQueries[idx])); - SqliteCommandPolicy.Add(cmd, $"@query{idx}Leaf", SqlNameResolver.GetLeafName(effectiveQueries[idx])); - SqliteCommandPolicy.Add(cmd, $"@query{idx}LeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(effectiveQueries[idx])) ?? SqlNameResolver.GetLeafName(effectiveQueries[idx])); + var queryLeaf = GetQualifiedQueryLeaf(effectiveQueries[idx], lang); + SqliteCommandPolicy.Add(cmd, $"@query{idx}Leaf", queryLeaf); + SqliteCommandPolicy.Add(cmd, $"@query{idx}LeafFolded", NameFold.Fold(queryLeaf) ?? queryLeaf); SqliteCommandPolicy.Add(cmd, $"@query{idx}SegmentCount", SqlNameResolver.GetSegmentCount(effectiveQueries[idx])); SqliteCommandPolicy.Add(cmd, $"@query{idx}NormalizedLike", $"%{EscapeLikeQuery(SqlNameResolver.NormalizeQualifiedName(effectiveQueries[idx]))}%"); AddCSharpExplicitInterfaceIdentityQueryParameter(cmd, $"query{idx}", effectiveQueries[idx]); @@ -1156,6 +1188,13 @@ private static bool ShouldPreserveRustQualifiedExactQuery(string? query, string? { if (ShouldPreserveRustQualifiedExactQuery(query, lang, exact)) return query?.Trim(); + if (exact + && !string.IsNullOrWhiteSpace(query) + && string.Equals(NormalizeQueryLanguage(lang), "csharp", StringComparison.Ordinal) + && SqlNameResolver.HasQualifier(query)) + { + return CSharpSymbolNameNormalizer.NormalizeExplicitInterfaceQueryDisplayName(query); + } return NormalizeSymbolSearchQuery(query, lang, exact) ?? query; } diff --git a/src/CodeIndex/Indexer/Hooks/PostExtractionHookMutationMaterializer.cs b/src/CodeIndex/Indexer/Hooks/PostExtractionHookMutationMaterializer.cs index 940fc6ade8..af51962088 100644 --- a/src/CodeIndex/Indexer/Hooks/PostExtractionHookMutationMaterializer.cs +++ b/src/CodeIndex/Indexer/Hooks/PostExtractionHookMutationMaterializer.cs @@ -61,11 +61,23 @@ internal static bool TrimToLimit(List? items, int? maxCount) internal static void RefreshLanguageIdentity(string? language, IEnumerable symbols) { - if (!string.Equals(language, "nim", StringComparison.Ordinal)) + if (string.Equals(language, "nim", StringComparison.Ordinal)) + { + foreach (var symbol in symbols) + symbol.IdentityNameFolded = NimIdentifierIdentity.Fold(symbol.Name); return; + } - foreach (var symbol in symbols) - symbol.IdentityNameFolded = NimIdentifierIdentity.Fold(symbol.Name); + if (string.Equals(language, "csharp", StringComparison.Ordinal)) + { + foreach (var symbol in symbols) + { + symbol.IdentityNameFolded = + CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( + symbol.Name, + symbol.Signature); + } + } } internal static void RefreshLanguageIdentity(string? language, IEnumerable references) diff --git a/src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs b/src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs index 4eda16fe5e..6a0def1ee4 100644 --- a/src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs +++ b/src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs @@ -373,6 +373,7 @@ private void OnSymbolsExtractedCore( if (!sourceSymbolsAlreadyObserved) ObserveCSharpStaticInterfaceSourceSymbols(context, symbols); + var acceptedHookMutation = false; foreach (var hook in hooks) { cancellationToken.ThrowIfCancellationRequested(); @@ -398,6 +399,7 @@ private void OnSymbolsExtractedCore( cancellationToken)) { PostExtractionHookMutationMaterializer.ReplaceList(symbols, workingSymbols); + acceptedHookMutation = true; } } @@ -405,7 +407,8 @@ private void OnSymbolsExtractedCore( // Re-derive it from the accepted public name after all mutations. // hook は record の rename/add はできるが内部の永続化 identity key は設定できないため、 // 全 mutation 受理後の公開名から再導出する。 - PostExtractionHookMutationMaterializer.RefreshLanguageIdentity(context.Language, symbols); + if (acceptedHookMutation) + PostExtractionHookMutationMaterializer.RefreshLanguageIdentity(context.Language, symbols); } public void OnReferencesExtracted(FileContext context, IList references, CancellationToken cancellationToken = default) @@ -413,6 +416,7 @@ public void OnReferencesExtracted(FileContext context, IList re ObjectDisposedException.ThrowIf(disposed, this); cancellationToken.ThrowIfCancellationRequested(); + var acceptedHookMutation = false; foreach (var hook in hooks) { cancellationToken.ThrowIfCancellationRequested(); @@ -438,10 +442,12 @@ public void OnReferencesExtracted(FileContext context, IList re cancellationToken)) { PostExtractionHookMutationMaterializer.ReplaceList(references, workingReferences); + acceptedHookMutation = true; } } - PostExtractionHookMutationMaterializer.RefreshLanguageIdentity(context.Language, references); + if (acceptedHookMutation) + PostExtractionHookMutationMaterializer.RefreshLanguageIdentity(context.Language, references); } private bool InvokeHookWithBudget( diff --git a/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs b/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs index 6c307df0c0..f44c2d4c54 100644 --- a/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs +++ b/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs @@ -71,27 +71,48 @@ public static string Normalize(string name, Match match, string matchLine) if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(signature)) return null; - var sourceName = string.Equals(name, "Item", StringComparison.Ordinal) ? "this" : name; - var memberMarker = "." + sourceName; + var isIndexer = string.Equals(name, "Item", StringComparison.Ordinal); + var sourceName = isIndexer ? "this" : name; var declarationBodyStart = FindDeclarationBodyStart(signature); var searchStart = 0; while (searchStart < signature.Length) { var memberIndex = signature.IndexOf( - memberMarker, + sourceName, searchStart, StringComparison.Ordinal); - if (memberIndex <= 0) + if (memberIndex < 0) return null; if (memberIndex >= declarationBodyStart) return null; - var cursor = memberIndex + memberMarker.Length; + var memberTokenStart = memberIndex; + if (memberTokenStart > 0 && signature[memberTokenStart - 1] == '@') + memberTokenStart--; + var memberTokenEnd = memberIndex + sourceName.Length; + var hasIdentifierBoundary = + (memberTokenStart == 0 || !IsIdentifierChar(signature[memberTokenStart - 1])) + && (memberTokenEnd >= signature.Length || !IsIdentifierChar(signature[memberTokenEnd])); + var cursorBeforeMember = memberTokenStart - 1; + while (cursorBeforeMember >= 0 && char.IsWhiteSpace(signature[cursorBeforeMember])) + cursorBeforeMember--; + var hasQualifierDot = cursorBeforeMember >= 0 && signature[cursorBeforeMember] == '.'; + var isVerbatimIndexerSpelling = + isIndexer && memberTokenStart < memberIndex && signature[memberTokenStart] == '@'; + if (!hasIdentifierBoundary || !hasQualifierDot || isVerbatimIndexerSpelling) + { + searchStart = memberTokenEnd; + continue; + } + + var cursor = memberTokenEnd; while (cursor < signature.Length && char.IsWhiteSpace(signature[cursor])) cursor++; - if (TryReadExplicitInterfaceMemberArity(signature, cursor, out var arity)) + if (TryReadExplicitInterfaceMemberArity(signature, cursor, isIndexer, out var arity)) { - var qualifierEnd = memberIndex; + var qualifierEnd = cursorBeforeMember; + while (qualifierEnd > 0 && char.IsWhiteSpace(signature[qualifierEnd - 1])) + qualifierEnd--; var qualifierStart = FindExplicitInterfaceQualifierStart(signature, qualifierEnd); if (qualifierStart < qualifierEnd) { @@ -100,7 +121,7 @@ public static string Normalize(string name, Match match, string matchLine) } } - searchStart = memberIndex + memberMarker.Length; + searchStart = memberTokenEnd; } return null; @@ -125,6 +146,7 @@ private static int FindDeclarationBodyStart(string signature) private static bool TryReadExplicitInterfaceMemberArity( string signature, int cursor, + bool isIndexer, out int arity) { arity = 0; @@ -136,7 +158,8 @@ private static bool TryReadExplicitInterfaceMemberArity( // Reject a matching qualified return/parameter type such as `Models.Run Run()`. // A non-generic explicit member name is followed immediately by its // parameter/indexer list, accessor body, expression body, or terminator. - return signature[cursor] is '(' or '[' or '{' or '=' or ';'; + return signature[cursor] is '(' or '{' or '=' or ';' + || (isIndexer && signature[cursor] == '['); } var typeParameterEnd = FindBalancedTypeArgumentListEnd(signature, cursor); @@ -174,10 +197,44 @@ private static bool TryReadExplicitInterfaceMemberArity( /// `IFoo.Run<T>` と `IFoo.Run<TValue>` を同一 identity としつつ、非修飾の /// `Run` へは統合しない。 /// - internal static string NormalizeExplicitInterfaceQueryIdentityNameFolded(string query) + internal static string NormalizeExplicitInterfaceQueryDisplayName(string query) { + var rawLastDot = FindLastTopLevelDot(query); + var rawTerminalToken = rawLastDot >= 0 + ? query[(rawLastDot + 1)..].Trim() + : string.Empty; + var isIndexerSpelling = string.Equals( + rawTerminalToken, + "this", + StringComparison.Ordinal); + var isVerbatimThisSpelling = string.Equals( + rawTerminalToken, + "@this", + StringComparison.Ordinal); var normalized = NormalizeTypeDisplayName(query); var lastDot = FindLastTopLevelDot(normalized); + if (lastDot < 0) + return normalized; + + if (isIndexerSpelling + && string.Equals(normalized[(lastDot + 1)..], "this", StringComparison.Ordinal)) + { + normalized = normalized[..(lastDot + 1)] + "Item"; + } + else if (isVerbatimThisSpelling + && string.Equals(normalized[(lastDot + 1)..], "this", StringComparison.Ordinal)) + { + normalized = normalized[..(lastDot + 1)] + "@this"; + } + + return normalized; + } + + internal static string NormalizeExplicitInterfaceQueryIdentityNameFolded(string query) + { + var normalized = NormalizeTypeDisplayName( + NormalizeExplicitInterfaceQueryDisplayName(query)); + var lastDot = FindLastTopLevelDot(normalized); if (lastDot < 0) return NameFold.Fold(normalized) ?? normalized; @@ -190,9 +247,6 @@ internal static string NormalizeExplicitInterfaceQueryIdentityNameFolded(string normalized = normalized[..genericStart] + $"`{arity}"; } - if (string.Equals(normalized[(lastDot + 1)..], "this", StringComparison.Ordinal)) - normalized = normalized[..(lastDot + 1)] + "Item"; - return NameFold.Fold(normalized) ?? normalized; } @@ -392,6 +446,18 @@ private static int FindExplicitInterfaceQualifierStart(string signature, int qua && angleDepth == 0 && bracketDepth == 0) { + var previous = index - 1; + while (previous >= 0 && char.IsWhiteSpace(signature[previous])) + previous--; + var next = index + 1; + while (next < qualifierEnd && char.IsWhiteSpace(signature[next])) + next++; + if ((previous >= 0 && signature[previous] is '.' or ':' or '<') + || (next < qualifierEnd && signature[next] is '.' or ':' or '<')) + { + break; + } + return index + 1; } break; diff --git a/tests/CodeIndex.Tests/DbReaderSearchTests.cs b/tests/CodeIndex.Tests/DbReaderSearchTests.cs index 0275a08511..905beceafb 100644 --- a/tests/CodeIndex.Tests/DbReaderSearchTests.cs +++ b/tests/CodeIndex.Tests/DbReaderSearchTests.cs @@ -1888,6 +1888,8 @@ public interface IFoo int Value { get; } event System.EventHandler Changed; string this[int index] { get; } + void Ä(); + void @this(); } public interface IBar @@ -1900,11 +1902,18 @@ public sealed class Service : IFoo, IBar void IFoo.Run(TValue value) { } void IBar.Run(TLeft left, TRight right) { } int IFoo.Value => 1; - event System.EventHandler IFoo.Changed { add { } remove { } } + event System.EventHandler IFoo . Changed { add { } remove { } } string IFoo.this[int index] => index.ToString(); + void IFoo.Ä() { } + void IFoo.@this() { } public void Run(T value) { } public void CallPublicRun() { Run(1); } } + + public sealed class ArrayFactory + { + public Demo.Service[] Service() => []; + } """; var fileId = writer.UpsertFile(new FileRecord { @@ -1930,6 +1939,7 @@ public void Run(T value) { } writer.InsertReferences(ReferenceExtractor.Extract(fileId, "csharp", content, symbols, path: path)); var rewritten = writer.BackfillFoldedColumns(rewriteAll: true); Assert.True(rewritten.Symbols > 0); + Assert.True(writer.AllFoldedColumnValuesMatchCurrentFold()); Assert.True(writer.MarkFoldReady()); writer.MarkCSharpSymbolNameContractReady(); writer.MarkGraphReady(); @@ -1947,6 +1957,9 @@ public void Run(T value) { } Assert.Null(CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( "Run", "Models.Run Run()")); + Assert.Null(CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( + "Run", + "Models.Run[] Run()")); Assert.Null(CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( "Count", "public int Count => inner.Count;")); @@ -1964,6 +1977,29 @@ public void Run(T value) { } CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( "Run", "Models.Run IFoo.Run(TValue value)")); + Assert.Equal( + "ifoo.changed", + CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( + "Changed", + "event System.EventHandler IFoo . Changed { add { } remove { } }")); + Assert.Equal( + "ifoo.run", + CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( + "Run", + "void IFoo.@Run()")); + Assert.Equal( + "ifoo.this", + CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( + "this", + "void IFoo.@this()")); + Assert.Equal( + "ifoo.item", + CSharpSymbolNameNormalizer.NormalizeExplicitInterfaceQueryIdentityNameFolded( + "IFoo.this")); + Assert.Equal( + "ifoo.this", + CSharpSymbolNameNormalizer.NormalizeExplicitInterfaceQueryIdentityNameFolded( + "IFoo.@this")); Assert.True(SqlNameResolver.HasQualifier("IFoo.Run")); using (var identityCommand = db.Connection.CreateCommand()) { @@ -2018,7 +2054,50 @@ WHERE signature LIKE 'void IFoo.Run%' var itemResults = reader.SearchSymbols("IFoo.Item", lang: "csharp", exact: true); Assert.Equal(2, itemResults.Count); Assert.Equal(2, itemResults.Select(result => result.SymbolId).Distinct().Count()); - Assert.Single(reader.SearchSymbols("IFoo.this", lang: "csharp", exact: true)); + var sourceSpelledItemResults = reader.SearchSymbols( + "IFoo.this", + lang: "csharp", + exact: true); + Assert.Equal( + itemResults.Select(result => result.SymbolId).Order().ToArray(), + sourceSpelledItemResults.Select(result => result.SymbolId).Order().ToArray()); + + var unicodeShortAliasResults = reader.SearchSymbols( + "ä", + lang: "csharp", + exact: true); + Assert.Equal(2, unicodeShortAliasResults.Count); + Assert.Contains( + unicodeShortAliasResults, + result => result.Signature?.Contains("IFoo.Ä", StringComparison.Ordinal) == true); + Assert.Equal( + 2, + reader.SearchSymbols("IFoo.Ä", lang: "csharp", exact: true).Count); + + var verbatimThisResults = reader.SearchSymbols( + "IFoo.@this", + lang: "csharp", + exact: true); + Assert.Equal(2, verbatimThisResults.Count); + Assert.All( + verbatimThisResults, + result => Assert.DoesNotContain("IFoo.this[", result.Signature, StringComparison.Ordinal)); + Assert.Equal( + 2, + reader.CountSearchSymbols("IFoo.@this", lang: "csharp", exact: true)); + var verbatimThisDefinitions = reader.GetDefinitions( + "IFoo.@this", + lang: "csharp", + exact: true); + Assert.Equal(2, verbatimThisDefinitions.Count); + Assert.All( + verbatimThisDefinitions, + result => Assert.DoesNotContain("IFoo.this[", result.Signature, StringComparison.Ordinal)); + var qualifiedService = Assert.Single(reader.SearchSymbols( + "Demo.Service", + lang: "csharp", + exact: true)); + Assert.Equal("class", qualifiedService.Kind); Assert.Single(reader.SearchSymbols("IFoo.Run", lang: "csharp", exact: false)); var definitions = reader.GetDefinitions( diff --git a/tests/CodeIndex.Tests/PostExtractionHookContractTests.cs b/tests/CodeIndex.Tests/PostExtractionHookContractTests.cs index 70b2edc87b..d10c291031 100644 --- a/tests/CodeIndex.Tests/PostExtractionHookContractTests.cs +++ b/tests/CodeIndex.Tests/PostExtractionHookContractTests.cs @@ -179,4 +179,39 @@ public void MutationMaterializer_RecomputesNimIdentityAfterHookMutation_Issue473 Assert.Equal("pkg", reference.TargetQualifier); Assert.True(reference.SuppressInferredTargetQualifier); } + + [Fact] + public void MutationMaterializer_RecomputesCSharpExplicitInterfaceIdentityAfterHookMutation_Issue4866() + { + var symbols = new List + { + new() + { + FileId = 7, + Kind = "function", + Name = "Run", + Signature = "void IFoo.@Run()", + IdentityNameFolded = "stale", + Line = 1, + StartLine = 1, + EndLine = 1, + }, + new() + { + FileId = 7, + Kind = "function", + Name = "Plain", + Signature = "void Plain()", + IdentityNameFolded = "stale", + Line = 2, + StartLine = 2, + EndLine = 2, + }, + }; + + PostExtractionHookMutationMaterializer.RefreshLanguageIdentity("csharp", symbols); + + Assert.Equal("ifoo.run", symbols[0].IdentityNameFolded); + Assert.Null(symbols[1].IdentityNameFolded); + } } From 0d47bd3fe45da709b722221829e5a9df214b11fb Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 09:23:50 +0900 Subject: [PATCH 04/18] Fix explicit-interface review edge cases (#4866) --- .../Database/DbSymbolReader.Search.cs | 5 +-- .../Symbols/CSharpSymbolNameNormalizer.cs | 32 +++++++++++++++++-- .../Symbols/SymbolExtractor.Patterns.cs | 8 ++--- tests/CodeIndex.Tests/DbReaderSearchTests.cs | 27 ++++++++++++++-- .../SymbolExtractorCSharpTests.cs | 10 +++--- 5 files changed, 66 insertions(+), 16 deletions(-) diff --git a/src/CodeIndex/Database/DbSymbolReader.Search.cs b/src/CodeIndex/Database/DbSymbolReader.Search.cs index 70916527f6..41c400a93a 100644 --- a/src/CodeIndex/Database/DbSymbolReader.Search.cs +++ b/src/CodeIndex/Database/DbSymbolReader.Search.cs @@ -451,8 +451,9 @@ FROM symbols s SqliteCommandPolicy.Add(cmd, $"@query{i}", paramValue); SqliteCommandPolicy.Add(cmd, $"@query{i}Normalized", SqlNameResolver.NormalizeQualifiedName(value)); SqliteCommandPolicy.Add(cmd, $"@query{i}NormalizedFolded", NameFold.Fold(SqlNameResolver.NormalizeQualifiedName(value)) ?? SqlNameResolver.NormalizeQualifiedName(value)); - SqliteCommandPolicy.Add(cmd, $"@query{i}Leaf", SqlNameResolver.GetLeafName(value)); - SqliteCommandPolicy.Add(cmd, $"@query{i}LeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(value)) ?? SqlNameResolver.GetLeafName(value)); + var queryLeaf = GetQualifiedQueryLeaf(value, lang); + SqliteCommandPolicy.Add(cmd, $"@query{i}Leaf", queryLeaf); + SqliteCommandPolicy.Add(cmd, $"@query{i}LeafFolded", NameFold.Fold(queryLeaf) ?? queryLeaf); SqliteCommandPolicy.Add(cmd, $"@query{i}SegmentCount", SqlNameResolver.GetSegmentCount(value)); SqliteCommandPolicy.Add(cmd, $"@query{i}NormalizedLike", $"%{EscapeLikeQuery(SqlNameResolver.NormalizeQualifiedName(value))}%"); AddCSharpExplicitInterfaceIdentityQueryParameter(cmd, $"query{i}", value); diff --git a/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs b/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs index f44c2d4c54..b2ba3e3991 100644 --- a/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs +++ b/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs @@ -71,8 +71,36 @@ public static string Normalize(string name, Match match, string matchLine) if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(signature)) return null; - var isIndexer = string.Equals(name, "Item", StringComparison.Ordinal); - var sourceName = isIndexer ? "this" : name; + // `Item` is only the display alias for an indexer when the declaration itself uses + // `this[...]`. A legal method/property/event may also be named `Item`, so try the + // indexer source spelling first and then fall back to the literal member name. + // `Item` は宣言が `this[...]` の場合だけ indexer の表示 alias になる。 + // method/property/event の実名にも使えるため、まず indexer 表記を試し、 + // 一致しなければ通常の member 名として再構築する。 + if (string.Equals(name, "Item", StringComparison.Ordinal)) + { + var indexerIdentity = TryBuildExplicitInterfaceIdentityNameFolded( + name, + signature, + sourceName: "this", + isIndexer: true); + if (indexerIdentity != null) + return indexerIdentity; + } + + return TryBuildExplicitInterfaceIdentityNameFolded( + name, + signature, + sourceName: name, + isIndexer: false); + } + + private static string? TryBuildExplicitInterfaceIdentityNameFolded( + string name, + string signature, + string sourceName, + bool isIndexer) + { var declarationBodyStart = FindDeclarationBodyStart(signature); var searchStart = 0; while (searchStart < signature.Length) diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Patterns.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Patterns.cs index f2d911e77f..a84bfa481c 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Patterns.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Patterns.cs @@ -1218,7 +1218,7 @@ private enum JavaScriptTypeScriptFunctionHeaderConsumeResult // / `Inner`。正規表現は最初の `(` で止まるので、末尾の `.Append(...)` / // `.Consume()` チェーンはキャプチャされない)として // 明示的インターフェースメソッドに化けないようにする。 - new("function", new Regex($@"^\s*(?![?:])(?!(?:await|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|using|case|else|when|break|continue|goto|new|from|where|select|orderby|group|join|let|into|on|equals|ascending|descending|by)\b)(?!\w+\s*:\s*(?:global::)?[\w@.<>:]+\.\w+\s*{CSharpMethodTypeParameterListPattern}[\(\[])(?:(?ref(?:\s+readonly)?)\s+)?(?{CSharpTypePattern})\s+(?{CSharpExplicitInterfaceQualifierPattern})\.(?{CSharpIdentifierPattern})\s*(?{CSharpMethodTypeParameterListPattern})[\(\[]", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), + new("function", new Regex($@"^\s*(?![?:])(?!(?:await|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|using|case|else|when|break|continue|goto|new|from|where|select|orderby|group|join|let|into|on|equals|ascending|descending|by)\b)(?!\w+\s*:\s*(?:global::)?[\w@.<>:]+\.\w+\s*{CSharpMethodTypeParameterListPattern}[\(\[])(?:(?ref(?:\s+readonly)?)\s+)?(?{CSharpTypePattern})\s+(?{CSharpExplicitInterfaceQualifierPattern})\s*\.\s*(?{CSharpIdentifierPattern})\s*(?{CSharpMethodTypeParameterListPattern})[\(\[]", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), // Explicit interface property implementation (brace body), e.g. int IThing.Value { get; set; } // Mirrors the explicit-interface method row above: the qualifier is non-capturing so the // short property name (Value) is recorded as name, consistent with how the method row @@ -1227,15 +1227,15 @@ private enum JavaScriptTypeScriptFunctionHeaderConsumeResult // 上の明示的インターフェースメソッド行と同じ構造で、修飾子は非キャプチャにしてショート名 // (Value) のみを name として記録する。メソッド側が Dispose / CompareTo を返すのと揃える。 // Closes #333. - new("property", new Regex($@"^\s*(?![?:])(?!(?:class|struct|interface|enum|record|namespace|delegate|event|const|using|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|case|else|when|break|continue|goto|await)\b)(?:(?ref(?:\s+readonly)?)\s+)?(?{CSharpTypePattern})\s+(?{CSharpExplicitInterfaceQualifierPattern})\.(?{CSharpIdentifierPattern})\s*\{{", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), + new("property", new Regex($@"^\s*(?![?:])(?!(?:class|struct|interface|enum|record|namespace|delegate|event|const|using|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|case|else|when|break|continue|goto|await)\b)(?:(?ref(?:\s+readonly)?)\s+)?(?{CSharpTypePattern})\s+(?{CSharpExplicitInterfaceQualifierPattern})\s*\.\s*(?{CSharpIdentifierPattern})\s*\{{", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), // Explicit interface property implementation (expression body), e.g. string IThing.Name => "x"; // 明示的インターフェースプロパティ実装(式本体)。例: string IThing.Name => "x"; - new("property", new Regex($@"^\s*(?![?:])(?!(?:class|struct|interface|enum|record|namespace|delegate|event|const|using|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|case|else|when|break|continue|goto|await)\b)(?:(?ref(?:\s+readonly)?)\s+)?(?{CSharpTypePattern})\s+(?{CSharpExplicitInterfaceQualifierPattern})\.(?{CSharpIdentifierPattern})\s*=>\s*", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), + new("property", new Regex($@"^\s*(?![?:])(?!(?:class|struct|interface|enum|record|namespace|delegate|event|const|using|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|case|else|when|break|continue|goto|await)\b)(?:(?ref(?:\s+readonly)?)\s+)?(?{CSharpTypePattern})\s+(?{CSharpExplicitInterfaceQualifierPattern})\s*\.\s*(?{CSharpIdentifierPattern})\s*=>\s*", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), // Explicit interface indexer implementation. The display name stays `Item`, while // the captured qualifier becomes part of the persisted exact-query identity. // 明示的インターフェース indexer 実装。表示名は `Item` のままにし、捕捉した // qualifier は永続化する完全一致 query identity に含める。 - new("function", new Regex($@"^\s*(?![?:])(?:(?ref(?:\s+readonly)?)\s+)?(?{CSharpTypePattern})\s+(?{CSharpExplicitInterfaceQualifierPattern})\.(?this)\s*\[", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), + new("function", new Regex($@"^\s*(?![?:])(?:(?ref(?:\s+readonly)?)\s+)?(?{CSharpTypePattern})\s+(?{CSharpExplicitInterfaceQualifierPattern})\s*\.\s*(?this)\s*\[", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), // Indexer (this[...]) — `partial` is legal on indexers since C# 13 (extended partial // member support), so accept it alongside the other modifiers. Otherwise every // `partial` indexer declaration would be silently dropped from symbols / definition / diff --git a/tests/CodeIndex.Tests/DbReaderSearchTests.cs b/tests/CodeIndex.Tests/DbReaderSearchTests.cs index 905beceafb..7f7f5fdecb 100644 --- a/tests/CodeIndex.Tests/DbReaderSearchTests.cs +++ b/tests/CodeIndex.Tests/DbReaderSearchTests.cs @@ -1897,13 +1897,19 @@ public interface IBar void Run(TLeft left, TRight right); } - public sealed class Service : IFoo, IBar + public interface IItemContract + { + int Item { get; } + } + + public sealed class Service : IFoo, IBar, IItemContract { void IFoo.Run(TValue value) { } void IBar.Run(TLeft left, TRight right) { } int IFoo.Value => 1; event System.EventHandler IFoo . Changed { add { } remove { } } - string IFoo.this[int index] => index.ToString(); + string IFoo . this[int index] => index.ToString(); + int IItemContract . Item => 2; void IFoo.Ä() { } void IFoo.@this() { } public void Run(T value) { } @@ -1996,6 +2002,11 @@ public sealed class ArrayFactory "ifoo.item", CSharpSymbolNameNormalizer.NormalizeExplicitInterfaceQueryIdentityNameFolded( "IFoo.this")); + Assert.Equal( + "iitemcontract.item", + CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( + "Item", + "int IItemContract . Item => 2;")); Assert.Equal( "ifoo.this", CSharpSymbolNameNormalizer.NormalizeExplicitInterfaceQueryIdentityNameFolded( @@ -2062,6 +2073,13 @@ WHERE signature LIKE 'void IFoo.Run%' itemResults.Select(result => result.SymbolId).Order().ToArray(), sourceSpelledItemResults.Select(result => result.SymbolId).Order().ToArray()); + var namedItemPropertyResults = reader.SearchSymbols( + "IItemContract.Item", + lang: "csharp", + exact: true); + Assert.Equal(2, namedItemPropertyResults.Count); + Assert.All(namedItemPropertyResults, result => Assert.Equal("property", result.Kind)); + var unicodeShortAliasResults = reader.SearchSymbols( "ä", lang: "csharp", @@ -2085,6 +2103,9 @@ WHERE signature LIKE 'void IFoo.Run%' Assert.Equal( 2, reader.CountSearchSymbols("IFoo.@this", lang: "csharp", exact: true)); + Assert.Equal( + 2, + reader.CountSearchSymbolsTotal("IFoo.@this", lang: "csharp", exact: true).Count); var verbatimThisDefinitions = reader.GetDefinitions( "IFoo.@this", lang: "csharp", @@ -2136,7 +2157,7 @@ WHERE signature LIKE 'void IFoo.Run%' Assert.Contains( outline.Symbols, symbol => symbol.Name == "Item" - && symbol.Signature?.Contains("IFoo.this", StringComparison.Ordinal) == true); + && symbol.Signature?.Contains("IFoo . this", StringComparison.Ordinal) == true); } [Fact] diff --git a/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs b/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs index 0054f8857c..24d3e73fa7 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs @@ -4400,11 +4400,11 @@ public interface IBar public sealed class Service : IFoo, IBar { - void IBase.Run(TValue value) { } + void IBase . Run(TValue value) { } void IBar.Run(TLeft left, TRight right) { } - int IFoo.Value => 1; - event System.EventHandler IFoo.Changed { add { } remove { } } - string IFoo.this[int index] => index.ToString(); + int IFoo . Value => 1; + event System.EventHandler IFoo . Changed { add { } remove { } } + string IFoo . this[int index] => index.ToString(); public void Run(T value) { } } """; @@ -4416,7 +4416,7 @@ public void Run(T value) { } serviceMembers, symbol => symbol.Kind == "function" && symbol.Name == "Run" - && symbol.Signature?.StartsWith("void IBase.", StringComparison.Ordinal) == true); + && symbol.Signature?.Contains("IBase . Run", StringComparison.Ordinal) == true); Assert.Equal("ibase.run`1", baseRun.IdentityNameFolded); var barRun = Assert.Single( From 54de221ff9cdaff58f4efcb2b9ce5592c8e37d36 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 10:15:58 +0900 Subject: [PATCH 05/18] Keep explicit-interface aliases indexed (#4866) --- DEVELOPER_GUIDE.md | 35 ++++--- changelog.d/unreleased/4866.fixed.md | 4 +- src/CodeIndex/Cli/DiffCommandRunner.cs | 6 ++ .../Cli/IndexCommandRunner.Maintenance.cs | 3 +- .../Database/DbContext.ReadMigrations.cs | 3 + .../DbContext.SchemaInitialization.cs | 20 +++- src/CodeIndex/Database/DbContext.cs | 2 + src/CodeIndex/Database/DbReader.cs | 14 ++- .../Database/DbSymbolReader.Search.cs | 5 +- src/CodeIndex/Database/DbWriter.BatchSql.cs | 4 + .../Database/DbWriter.ChunkSymbolBatches.cs | 9 +- .../Database/DbWriter.FoldBackfill.cs | 82 ++++++++++++++-- .../PostExtractionHookMutationMaterializer.cs | 8 ++ .../SymbolExtractor.PatternEmission.cs | 4 + src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs | 5 +- src/CodeIndex/Models/SymbolRecord.cs | 4 + tests/CodeIndex.Tests/DatabaseTests.cs | 19 ++++ tests/CodeIndex.Tests/DbReaderSearchTests.cs | 21 +++++ .../IndexCommandRunnerTests.cs | 93 +++++++++++++++++++ .../PostExtractionHookContractTests.cs | 4 + 20 files changed, 307 insertions(+), 38 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 80b87bbc5b..cbf8cfa8d7 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1051,6 +1051,7 @@ idx_files_modified ON files(modified) -- idx_files_path is not needed: the UNIQUE constraint on path creates an implicit index idx_chunks_file ON chunks(file_id) idx_symbols_name ON symbols(name) +idx_symbols_display_name_folded ON symbols(display_name_folded) WHERE display_name_folded IS NOT NULL idx_symbols_file ON symbols(file_id) idx_symbols_file_kind ON symbols(file_id, kind) idx_files_lang_modified ON files(lang, modified) @@ -1148,14 +1149,16 @@ SQLite resolves referenced tables while preparing every statement in a command b For C# explicit-interface members, `symbols.name` remains the short display/discovery alias, while `symbols.name_folded` stores the normalized interface qualifier plus terminal method -generic arity. Kind and normalized signature remain independent columns in the canonical symbol -row, so identity comparisons retain qualifier, member kind, arity, and signature without +generic arity. When that identity differs, `symbols.display_name_folded` stores the short +Unicode-folded discovery alias and `idx_symbols_display_name_folded` keeps unqualified exact +queries indexed. Kind and normalized signature remain independent columns in the canonical +symbol row, so identity comparisons retain qualifier, member kind, arity, and signature without changing outline or LSP display names. Exact qualified queries normalize generic parameter names to arity and map indexer spellings `this` and `Item` together; unqualified exact queries use the short-name alias for discovery and therefore may return explicit and public members. Fold -validation/backfill reconstructs the qualified identity from the persisted signature, and a -`CSharpSymbolNameContractVersion` change forces unchanged C# files to be reindexed before that -identity is trusted. +validation/backfill reconstructs both folds from the persisted signature. A +`CSharpSymbolNameContractVersion` change either refreshes unchanged C# files during indexing or +forces `backfill-fold` / MCP `backfill_fold` into full rewrite mode before v3 is stamped. `inspect` / MCP `analyze_symbol` treats each returned definition as a separate identity bundle. Candidate selectors expose the persisted symbol ID plus qualified/container name, @@ -1892,7 +1895,7 @@ Process exit codes are coarse (`0` success including valid zero-row queries, `1` - **Compact search snippets for AI** — `search --json` and MCP `search` return match-centered snippets with explicit snippet ranges, match lines, highlights, context counts, `truncated_line_count`, and `truncation_context` instead of whole chunks. `truncation_context.char_counts` and `truncation_context.total_chars` expose the omitted character counts behind each clamped snippet line, while truncated highlights also carry `truncated_char_counts`. `--snippet-lines` lets clients trade recall for smaller payloads, and `--max-line-width` (CLI) / `maxLineWidth` (MCP) routes each snippet line through the same `LineWidthFormatter.ClampLine` contract used by `find` / `references` / `excerpt` / `inspect` so hits inside minified / transpiled / generated single-line files no longer return hundreds of KB per result unless the caller explicitly sets `0`; clamped lines carry `...(+N)...` markers and `highlights[].truncated` / `highlights[].original_line_length`. - **Repo map for first-pass orientation** — `map` aggregates languages, modules, top files, file hot spots, and likely entrypoints from indexed data so AI clients can decide where to look before issuing precise queries. Entrypoint inference now falls back to known top-level entry files when symbol extraction does not produce an explicit `Main`-style symbol. - **Freshness metadata for trust decisions** — `status` exposes whole-workspace freshness and git state, plus trust metadata such as `sql_graph_contract_ready` / `sql_graph_contract_degraded_reason`, `hotspot_family_ready` / `hotspot_family_degraded_reason`, forward-compatibility audit fields (`index_writer_version`, `index_newer_than_reader`, `index_newer_than_reader_reason` — see "Forward-compatibility readiness audit"), and fold remediation fields (`fold_ready_reason`, `degraded_reason`, `recommended_action`, `alternative_action`) so AI clients can tell up front whether SQL graph/dependency/impact answers, duplicate-name hotspot families, and Unicode `--exact` are authoritative. CLI `status --json` and MCP `status` both populate those fold remediation fields when `fold_ready=false`. It also carries `unknown_extension_file_count`, a capped `unknown_extension_files` path sample, `unknown_extension_files_truncated`, and `unknown_extension_file_path_limit` after a current full-repository scan so extension-table coverage gaps are visible and actionable even when those files were excluded from indexing. When those fold remediation fields are derived from an explicit read-only `file:` DB URI, they are normalized back to a writable filesystem path for both absolute (`file:///...?...`) and relative (`file:codeindex.db?...`) forms instead of echoing the read-only URI into commands that would fail. `cdidx index` JSON/human readiness output also surfaces the same trust bits, keeping the post-index readiness summary aligned with `status`. `impact` / MCP `impact_analysis` also mirror the SQL graph-contract signal in JSON so stale SQL rows do not masquerade as authoritative zero-impact answers. `inspect` / MCP `analyze_symbol` and `references` / MCP `references` now mirror that same SQL graph-contract signal whenever SQL-backed graph reads contribute to their payloads, so stale SQL rows do not look like authoritative hits or zero-result answers there either. `map` keeps `indexed_at` / `latest_modified` scoped to the filtered result set and also exposes `workspace_indexed_at` / `workspace_latest_modified` for whole-workspace freshness. `inspect` mirrors those whole-workspace timestamps and git fields so symbol-oriented AI flows can make trust decisions without a separate `status` call. `files` exposes per-file checksum plus modified/indexed timestamps. File-column migrations are applied opportunistically for older DBs, and read paths are designed to avoid crashing if in-place migration is unavailable. CLI and MCP zero-result JSON responses for `search`, `files`, `symbols`, `definition`, `references`, `callers`, `callees`, `deps`, `unused`, `hotspots`, and `impact` include `indexed_file_count`, `indexed_at`, and `freshness_available`. `indexed_at:null` with `freshness_available=true` means the index is empty, while `freshness_available=false` means a legacy/read-only DB could not expose freshness timestamps and `freshness_degraded_reason` explains why. **HEAD-aware staleness signal**: every successful `cdidx index` full scan now stamps the captured `git HEAD` into `codeindex_meta` so subsequent runs can compare it against the workspace HEAD. When they differ and the user did not pass `--rebuild`, the CLI emits a `head_changed` warning recommending `cdidx index --rebuild` and exposes `head_changed` / `prior_indexed_head_commit` / `current_head_commit` / `head_change_notice` in `index --json`. `status --check` mirrors the same comparison through `workspace_check.head_changed` (alongside `indexed_head_commit` / `workspace_head_commit` when they differ), so AI clients that already gate on freshness can refuse to trust a default incremental scan after `git switch ` without a separate query. `--commits` / `--files` partial updates deliberately preserve the captured HEAD so the staleness signal survives until a real full scan reindexes the worktree. Non-git workspaces and legacy DBs that never captured a HEAD skip the comparison instead of false-positive flagging. -- **Folded-key upgrade without reparse** — `backfill-fold` and MCP `backfill_fold` recompute `name_folded` / `*_folded` directly from existing DB rows, then stamp `FoldReadyFlag` once verification confirms no required folded values remain NULL. This gives AI clients and users a low-cost upgrade path from pre-#86 DBs without re-reading every source file, and it also rewrites all folded rows when `fold_key_version` is missing or mismatched so future `NameFold.Version` bumps cannot silently restamp stale keys. +- **Folded-key upgrade without reparse** — `backfill-fold` and MCP `backfill_fold` recompute `name_folded`, explicit-interface `display_name_folded`, and reference `*_folded` values directly from existing DB rows, then stamp `FoldReadyFlag` once verification confirms no required folded values remain NULL. This gives AI clients and users a low-cost upgrade path from pre-#86 DBs without re-reading every source file. It rewrites all folded rows when fold metadata is stale or the C# symbol-name contract changed, and stamps the current C# contract only after verification succeeds. - **Bundled symbol analysis** — `inspect` and MCP `analyze_symbol` return definition, nearby symbols, references, callers, callees, file metadata, workspace trust metadata, and graph-support metadata in one request so AI clients can answer common symbol questions with fewer round-trips. - **Language-aware reference extraction** — `references`, `callers`, `callees`, and `impact` are backed by an indexed reference table built only for languages where regex-based call/reference extraction is meaningful. Unsupported languages intentionally fall back to text search instead of returning low-confidence pseudo-graph data. When a language is removed from graph support, `PurgeUnsupportedReferences` deletes its stale `symbol_references` rows on the next indexing run, and graph read paths additionally filter by supported languages to prevent stale edges from surviving between index runs. Shell is intentionally excluded because its command-style invocations (`foo arg1 arg2`) cannot be detected by the parenthesized-call regex. **Nested generic call sites**: C#/Java constructor calls like `new Dictionary>()` and C# generic method calls like `Helper.DoWork>()` are recovered by a depth-aware fallback scanner so the outer target still reaches the reference table even though the flat regex fast-path cannot balance `>>`. **JS/TS no-paren constructors**: JavaScript / TypeScript zero-argument constructor calls that legally omit `()` — for example `new Foo;`, `new Date;`, qualified targets like `new Demo.Provider;`, and one-level generic TypeScript forms like `new Box;` — are emitted as `instantiate` edges via a dedicated language-gated path, while next-line `.bar()` / `[0]` continuations are suppressed so a line-ended `new Foo` does not become a phantom standalone instantiation. **Constructor chain calls**: C# `: this(...)` / `: base(...)` initializers and Java `this(...)` / `super(...)` first-statement calls are detected separately from the generic call regex and rewritten so the reference target is the real constructor (enclosing class/record for `this`, the parsed base type from the class signature for `base` / `super`). Cross-line C# initializers are attributed to the owning constructor rather than the enclosing class. Base-type parsing strips generics, record primary-ctor args, `where` constraints, and `global::` / dotted namespace qualifiers; Java `super.method()` stays a normal method call. **Type-position dependency edges**: C#/Java base lists, declaration types, generic constraints, `throws`, `is`/`as`/`instanceof`, and real C# XML-doc `cref` sites are indexed as `type_reference` rows so `references` / `impact` can see compile-time rename dependencies without polluting the default dynamic call graph exposed by `callers` / `callees`. C# XML-doc `cref` extraction accepts declaration-attached XML-doc comments from both `///` lines and delimited `/** ... */` blocks, including declarations that begin later on the same physical line after the closing `*/` only when no unrelated same-line code or declaration intervenes, while ordinary `//` / `////` comments, non-documenting block comments, method-body XML-doc comments that merely precede a later declaration, brace-free field/property initializer continuations, brace-free expression lambdas, intervening top-level executable statements, same-line non-target code after `*/`, other nested executable continuations, and multiline raw/verbatim string content whose line happens to start with `/**` stay excluded. Non-doc code or string content after the closing `*/` on the same physical line is still outside the doc-comment slice. Even though the regex now runs against that narrower slice, the extractor preserves `symbol_references.column` relative to the original physical source line. On the C# read path, `using static` constant-pattern suppression is token-aware around `is` / `case`, reconstructs an anchor-aware indexed multi-line window when the anchor lives on a previous line, and keeps trivia-bearing forms such as `value is/*comment*/Red`, `value is\n Red or Blue`, `value is\n // comment\n Red`, `case\n // comment\n Point:`, long `case` / `or` chains, and `case\tRed:` filtered or rescued correctly. Qualified constant/member patterns stay qualifier-driven on that exact-name read path, so an unrelated same-name type such as `class Red {}` no longer cancels suppression for `case Color.Red or Color.Blue:` just because the leaf name matches. The extractor-side pending type-pattern carry now also survives trivia-only separator lines, standalone continuation-line `not`, and multiline `case` heads/logical continuations, so comment-only or `not`-only continuation lines no longer drop the later type head before the real token arrives. Non-type `case` labels such as `case > 0:` and `case not > 0:` do not arm that pending carry, so the next-line call/identifier token stays out of `type_reference`. Same-name type rescue also honors `file` visibility so file-local types only rescue references from the same physical file; inherited protected/public/internal nested types from real base classes rescue derived-class pattern heads only after the base reference is normalized through active type and namespace aliases, and alias-expanded constructed generic bases are canonicalized again before containing-type lookup so `AliasBase = Probe.Base` resolves the same way as `Probe.Base`; implemented interfaces do not contribute inherited nested-type rescue; and same-file `using Namespace;`, project-wide `global using Namespace;`, and active type aliases all participate in the rescue set. The extractor deliberately leaves ambiguous unqualified `using static` heads such as `value is Red` in the DB, because file-local parsing alone cannot know whether another file in the same namespace declares the real `Red` type; the workspace-aware read path is responsible for suppressing the pure constant-only cases. **SQL qualified-name alignment**: SQL definitions still persist their schema-qualified symbol name (`dbo.fn_X`), but graph/`deps`/unused/hotspot readers now resolve each SQL reference row through its stored source-line context, recorded call column, and enclosing container before they compare it to definitions, so qualified `references` / `callers` / `impact` queries stay schema-scoped even when one line contains multiple qualified calls or the lookup is non-exact. Those readers fall back to the bare leaf only when the source site itself is genuinely unqualified, which keeps `deps`, `unused`, and `hotspots` aligned with qualified SQL calls without regressing bare-call support or double-counting `EXEC dbo.fn_Target; EXEC sales.fn_Target;`. Once a row already has a recorded call column, those downstream readers no longer whole-line-upgrade that row to a later qualified token, so trailing comments, string literals, or a second qualified call cannot steal the earlier unqualified edge. Exact SQL graph/dependency readers also preserve the resolved segment count, so a quoted single identifier containing a dot such as `"sales.fn_Target"` stays distinct from the real qualified name `sales.fn_Target` across exact `references` / `callers` / `impact` and aggregate `deps` / `unused` / `hotspots`. SQL CTE body source rows use the raw `cte_body_reference` kind, so `references --kind cte_body_reference` can distinguish anchor/recursive-member internals from outer-query table references. Qualified SQL `callees` queries also keep leaf fallback disabled unless the caller query itself is unqualified, so `callees sales.Caller` no longer widens to `dbo.Caller`. SQL extractors also accept optional whitespace around qualified-name dots, so definitions/calls such as `[sales] . [fn_Target]` and `[dbo] . [fn_Target]` keep their full qualified identity instead of truncating at the first segment. The same SQL no-parens extractor now preserves ANSI / PostgreSQL double-quoted call targets such as `CALL "sales"."proc_name"` and `EXEC "dbo"."fn_Target"` instead of stripping them as string literals, while true single-quoted SQL string literals remain masked. Definition-oriented readers also canonicalize quoted qualified SQL names (`[dbo].[fn_X]` → `dbo.fn_X`) before matching, and they only fall back to the leaf identifier for unqualified queries so exact qualified lookups do not widen to sibling schemas that merely share the same leaf name. Exact SQL definition matching also preserves segment count, so a quoted single identifier that contains a dot (`"sales.fn_Target"`) does not collide with a real qualified name (`sales.fn_Target`). SQL exact graph leaf fallback also stays on the Unicode folded exact path, and both quoted qualified and unqualified Unicode exact definition lookups now use the folded normalized path, so queries such as `dbo.Äpfel` / `dbo.äpfel` and bare `Äpfel` / `äpfel` keep matching leaf call/reference rows such as `äpfel` plus stored definitions such as `[dbo].[Äpfel]` or `dbo.Äpfel` instead of silently degrading to ASCII-only `NOCASE`. Exact multi-name SQL `symbols --count` lookups also bind the folded leaf parameters on that same `_foldReady` path, so Unicode leaf query sets no longer fail with missing-parameter database errors. - **Transitive impact analysis** — `impact` and MCP `impact_analysis` compute the transitive caller chain of a symbol using BFS. Design constraints refined through adversarial review: caller matching uses case-insensitive exact match (`lower() = lower()`) to avoid both substring expansion and case-sensitivity brittleness; symbol names are pre-resolved through definitions with exact-case preference; the read path filters to graph-supported languages to prevent stale edges from removed languages; the definition set used for heuristic fallback must also respect active `--lang` / `--path` / `--exclude-path` / `--exclude-tests` filters and graph-supported languages so out-of-scope or unsupported duplicates do not suppress in-scope hints; fallback eligibility is keyed off class-like definitions only, so same-name namespace/import siblings do not block a single resolved class / struct / interface target, while pure non-callable `namespace` / `import` queries surface `non_callable_symbol_kind` guidance; heuristic file-level hints still return a successful result and encode their non-authoritative status via `impact_mode`, `heuristic`, `hint_count`, and `truncated`; caller rows include `result_kind: "graph"` and heuristic `file_impacts` rows include `result_kind: "file_heuristic"` so clients can distinguish authoritative hop-depth graph results from boundary fallback hints without inferring from list position or depth values; when `truncated` is `true`, the JSON / MCP payload also exposes `truncated_reason` so callers can distinguish actionable cases from runaway-graph cases — `user_limit` means the caller-supplied `--limit` was reached and raising `--limit` will return more results, while `safety_cap` means an internal per-symbol BFS fetch-iteration cap fired (the graph is likely pathological / cyclic and raising `--limit` alone will not help). `impact` / MCP `impact_analysis` also expose `termination_reason` (`completed`, `max_depth_reached`, `cycle_detected`, `row_limit_truncated`, `safety_cap`, or `cancelled`), `cycle_detected`, and `cycles` so caller cycles are distinguishable from natural traversal completion or limit/depth termination (#1883). `safety_cap` outranks `user_limit` whenever both are encountered, and the heuristic file-level hints path is `user_limit`-only because hint truncation is always driven by the caller's `--limit`. The field is omitted whenever `truncated` is `false`. (#1533) `count` / `file_count` now describe the visible returned set while `confirmed_count` / `confirmed_file_count` preserve symbol-level caller totals for heuristic-success payloads, and `impact --json --count` uses the same `*_count` field names as the full payload; to reduce general-name collisions, a file only qualifies for type fallback if it both references one of the candidate member names and also exposes same-file evidence anchoring the source/target pair — either a `call` / `instantiate` reference to the resolved target name (the call-graph itself authoritatively pins the relationship, so this path runs before the metadata-attribute bypass and does not depend on the looser ambiguity guard) or structured type evidence through indexed symbol metadata such as signatures or return types — rather than raw comment/string text matches. The call/instantiate anchor matches the resolved name exactly with no suffix-strip alias, because callable references already carry the authoritative identifier and applying the C# `[Foo]` → `FooAttribute` alias there would let unrelated `Foo()` method calls falsely anchor `impact FooAttribute` (#1881); the metadata bypass keeps the C# `Attribute` suffix alias because attribute use sites legitimately abbreviate the target name. The signature evidence path is Unicode-aware so fullwidth/accented identifiers are tokenized consistently with exact-name resolution; hint `reference_count` reflects the real number of matching reference rows while the symbol list stays deduplicated; only multiple class-like definitions are treated as fallback ambiguity, even when they share one file; and `PurgeUnsupportedReferences` runs in all three indexing paths (CLI full scan, CLI update mode, MCP index). @@ -4431,14 +4434,16 @@ prepared command で作成してください。SQLite は command batch の全st C# の明示的 interface member では、`symbols.name` は短い表示用 / discovery alias のままにし、 `symbols.name_folded` に正規化した interface qualifier と末尾 method の generic arity を -保存します。kind と正規化済み signature は canonical symbol row の独立した列に保持するため、 -outline や LSP の表示名を変えずに qualifier、member kind、arity、signature を identity 比較へ -残せます。修飾した完全一致 query は generic parameter 名を arity に正規化し、indexer の -`this` と `Item` を同じ表記として扱います。非修飾の完全一致 query は短い名前の discovery -alias を使うため、明示的実装と public member の両方を返す場合があります。fold の検証 / -backfill は永続化済み signature から修飾 identity を復元し、 -`CSharpSymbolNameContractVersion` の変更時には、その identity を信頼する前に未変更の C# file -も再 index します。 +保存します。identity が異なる場合は `symbols.display_name_folded` に短い Unicode-folded +discovery alias を保存し、`idx_symbols_display_name_folded` によって非修飾の完全一致 query +も index 対応に保ちます。kind と正規化済み signature は canonical symbol row の独立した列に +保持するため、outline や LSP の表示名を変えずに qualifier、member kind、arity、signature を +identity 比較へ残せます。修飾した完全一致 query は generic parameter 名を arity に正規化し、 +indexer の `this` と `Item` を同じ表記として扱います。非修飾の完全一致 query は短い名前の +discovery alias を使うため、明示的実装と public member の両方を返す場合があります。fold の +検証 / backfill は永続化済み signature から両方の fold を復元します。 +`CSharpSymbolNameContractVersion` の変更時は、indexing が未変更の C# file を refresh するか、 +`backfill-fold` / MCP `backfill_fold` が v3 stamp 前に full rewrite mode へ切り替わります。 `inspect` / MCP `analyze_symbol` は返された各定義を別々の identity bundle として 扱います。candidate selector は永続化した symbol ID に加え、qualified/container name、 @@ -5187,7 +5192,7 @@ USER_GUIDEの[終了コード](USER_GUIDE.md#終了コード)セクションを `hooks[]` は `callback_budget_ms` を含み、`CDIDX_HOOK_CALLBACK_BUDGET_MS`(既定値: 5000 ms)で強制される post-extraction callback 予算を反映します。hook は結果反映前の scratch copy 上で実行されるため、timeout した callback の変更は破棄されます。 文書化された `status --json` trust contract は `fold_ready`、`fold_ready_reason`、`graph_table_available`、`issues_table_available`、`file_issues_data_current`、`migration_in_progress`、`sql_graph_contract_ready`、`sql_graph_contract_degraded_reason`、`hotspot_family_ready`、`hotspot_family_degraded_reason`、`csharp_symbol_name_ready`、`csharp_metadata_target_ready`、`csharp_metadata_target_degraded_reason`、`indexed_head_commit`、`worktree_head_changed`、`indexed_head_sha`、`indexed_head_branch`、`indexed_head_timestamp`、`commits_ahead_of_indexed_head`、`index_writer_version`、`index_newer_than_reader`、`index_newer_than_reader_reason`、`unknown_extension_file_count`、`unknown_extension_files`、`unknown_extension_files_truncated`、`unknown_extension_file_path_limit`、`extractors`、`path_case_sensitive`、`stale_after_seconds`、`index_age_seconds`、remediation field の `degraded_root_cause`、`degraded_reason`、`recommended_action`、`alternative_action`、`readiness_degradations`、および MCP 専用の `mcp_session` を対象にします。MCP `mcp_session` は永続化された DB 状態ではなく、セッション単位の診断情報で、`log_level`、`roots`、任意の `client_info`、任意の `client_capabilities` を含みます。この一覧は `README.md` と `AGENT_GUIDE.md` に同期してください。いずれかの必須 field がこれらの docs から漏れると `DocumentationStatusContractTests` が失敗します。 -- **再解析不要の folded-key アップグレード** — `backfill-fold` と MCP `backfill_fold` は、既存 DB 行から `name_folded` / `*_folded` を直接再計算し、必要な folded 値に NULL が残っていないことを検証してから `FoldReadyFlag` を stamp する。これにより、pre-#86 DB から AI クライアントやユーザーが低コストで Unicode `--exact` へ上がれる。さらに `fold_key_version` が未記録または不一致なら全 folded 行を再生成するため、将来の `NameFold.Version` 変更後に古い key を silent に再 stamp してしまうことも防ぐ。 +- **再解析不要の folded-key アップグレード** — `backfill-fold` と MCP `backfill_fold` は、既存 DB 行から `name_folded`、明示的 interface 用 `display_name_folded`、reference の `*_folded` を直接再計算し、必要な folded 値に NULL が残っていないことを検証してから `FoldReadyFlag` を stamp する。これにより、pre-#86 DB から AI クライアントやユーザーが低コストで Unicode `--exact` へ上がれる。fold metadata が stale、または C# symbol-name contract が変わった場合は全 folded 行を再生成し、検証成功後にだけ現在の C# contract を stamp する。 - **まとめて取るシンボル分析** — `inspect` と MCP の `analyze_symbol` は、定義、近傍シンボル、参照、caller、callee、ファイルメタデータ、ワークスペース信頼メタデータ、graph 対応メタデータを1回で返し、AIクライアントが一般的なシンボル調査を少ない往復で終えやすくする。 - **言語考慮の参照抽出** — `references`、`callers`、`callees` は、正規表現ベースの call/reference 抽出が意味を持つ言語だけに対してインデックス化された参照テーブルで支える。未対応言語では、低信頼な疑似グラフ結果を返す代わりにテキスト検索へ戻る前提で設計する。**nested generic 呼び出し**: `new Dictionary>()` のような C#/Java のコンストラクタ呼び出しと、`Helper.DoWork>()` のような C# generic method call は、平坦な regex fast-path で `>>` を釣り合わせられなくても depth-aware fallback scanner で拾い直し、外側 target を参照テーブルへ残す。**コンストラクタ連鎖呼び出し**: C# の `: this(...)` / `: base(...)` イニシャライザと、Java のコンストラクタ本体冒頭文 `this(...)` / `super(...)` は、汎用 call regex とは別に検出し、呼び先が実際のコンストラクタとなるように書き換える(`this` は外側の class/record、`base` / `super` は外側クラスのシグネチャから解析した基底型)。C# のクロス行イニシャライザは外側クラスではなく、そのコンストラクタに紐付ける。基底型の解析は generic 引数、record のプライマリコンストラクタ引数、`where` 制約、`global::` やドット付きの namespace 修飾を剥がす。Java の `super.method()` は通常のメソッド呼び出しのまま扱う。**型位置の依存エッジ**: C#/Java の継承リスト、宣言型、generic 制約、`throws`、`is` / `as` / `instanceof`、および実際の C# XML doc `///` `cref` は `type_reference` 行として索引し、既定の `callers` / `callees` が見せる動的 call graph を汚さずに、`references` / `impact` から compile-time rename 依存を辿れるようにする。**SQL qualified-name alignment**: SQL の graph/dependency reader は、各 reference 行の source-line context、記録済み call 列位置、enclosing container から SQL 参照名を復元して定義と照合するため、qualified な `references` / `callers` / `impact` query は exact / non-exact を問わず sibling schema へ widen しない。source 側が genuinely unqualified な場合にだけ bare leaf fallback を許可するので、qualified call を含む `deps` / `unused` / `hotspots` も schema 単位で整合し、`EXEC dbo.fn_Target; EXEC sales.fn_Target;` のような同一行 multi-call も二重計上しない。列位置が記録されている row は、その列に qualified token が見つからなければ whole-line の別 qualified token へ昇格させないため、行末コメント・文字列リテラル・後続の別 call が先頭の unqualified edge を横取りすることもない。qualified な `callees` query でも caller query 自体が unqualified なとき以外は leaf fallback を無効化したため、`callees sales.Caller` が `dbo.Caller` へ広がらない。SQL extractor は qualified-name の `.` 前後空白も許容し、definition 系 reader は quoted qualified SQL name (`[dbo].[fn_X]` → `dbo.fn_X`) を正規化してから照合する。さらに exact SQL 定義照合は segment 数を保持し、SQL の exact graph leaf fallback は Unicode folded exact path を維持する。SQL CTE 本体内の source 行は raw `cte_body_reference` kind を使うため、`references --kind cte_body_reference` で anchor/recursive member 内部を outer query の table reference と区別できる。そのため、quoted single identifier の衝突や Unicode exact lookup の ASCII-only `NOCASE` 退行も防ぐ。 exact な SQL の graph/dependency reader は解決済み segment 数も保持するため、`"sales.fn_Target"` のようなドット入り quoted single identifier が、本物の qualified name `sales.fn_Target` と exact `references` / `callers` / `impact` や集計系の `deps` / `unused` / `hotspots` で衝突しない。 diff --git a/changelog.d/unreleased/4866.fixed.md b/changelog.d/unreleased/4866.fixed.md index b03c592719..c29ca19b45 100644 --- a/changelog.d/unreleased/4866.fixed.md +++ b/changelog.d/unreleased/4866.fixed.md @@ -13,8 +13,8 @@ affected: ## English -- **C# explicit-interface members now retain distinct symbol identities (#4866)** — Exact qualified queries preserve the interface qualifier and generic arity for methods, properties, events, and indexers without merging them with same-named public members. Short display names remain available as unqualified discovery aliases across CLI, inspect, outline, and LSP navigation. +- **C# explicit-interface members now retain distinct symbol identities (#4866)** — Exact qualified queries preserve the interface qualifier and generic arity for methods, properties, events, and indexers without merging them with same-named public members. Short display names remain available through a separately indexed Unicode-folded discovery alias across CLI, inspect, outline, and LSP navigation, and `backfill-fold` upgrades the previous C# naming contract without reparsing source. ## 日本語 -- **C# の明示的 interface member が個別の symbol identity を保持するようになりました (#4866)** — method、property、event、indexer の完全一致 query で interface qualifier と generic arity を保持し、同名の public member と統合しません。CLI、inspect、outline、LSP navigation では、短い表示名を非修飾の discovery alias として引き続き利用できます。 +- **C# の明示的 interface member が個別の symbol identity を保持するようになりました (#4866)** — method、property、event、indexer の完全一致 query で interface qualifier と generic arity を保持し、同名の public member と統合しません。CLI、inspect、outline、LSP navigation では、短い表示名を別途 index 化した Unicode-folded discovery alias として引き続き利用でき、`backfill-fold` は source の再解析なしで以前の C# naming contract を更新します。 diff --git a/src/CodeIndex/Cli/DiffCommandRunner.cs b/src/CodeIndex/Cli/DiffCommandRunner.cs index 79f0a7b457..acf75333a1 100644 --- a/src/CodeIndex/Cli/DiffCommandRunner.cs +++ b/src/CodeIndex/Cli/DiffCommandRunner.cs @@ -222,6 +222,7 @@ private static DiffJsonResult CompareDatabasesCore( "sub_kind", "name", "name_folded", + "display_name_folded", "line", "start_line", "start_column", @@ -1301,6 +1302,9 @@ private static string BuildSymbolRowsSql(SqliteConnection connection) var metadataTargetSourceExpr = ColumnExists(connection, "symbols", "metadata_target_source") ? "symbols.metadata_target_source" : "NULL"; + var displayNameFoldedExpr = ColumnExists(connection, "symbols", "display_name_folded") + ? "symbols.display_name_folded" + : "NULL"; return $$""" SELECT @@ -1309,6 +1313,7 @@ private static string BuildSymbolRowsSql(SqliteConnection connection) symbols.sub_kind, symbols.name, symbols.name_folded, + {{displayNameFoldedExpr}}, symbols.line, symbols.start_line, symbols.start_column, @@ -1332,6 +1337,7 @@ ORDER BY symbols.sub_kind, symbols.name, symbols.name_folded, + {{displayNameFoldedExpr}}, symbols.line, symbols.start_line, symbols.start_column, diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs b/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs index b517b19299..687d73399c 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs @@ -583,7 +583,7 @@ internal static int RunBackfillFold( // Missing or mismatched fold metadata means persisted keys may have been generated // by a different fold algorithm/runtime, so refresh every row from source names. // fold metadata 未記録 / 不一致時は全行再計算して version/runtime skew を解消する。 - var rewriteAll = !foldMetadataCurrentBefore; + var rewriteAll = writer.ResolveFoldBackfillRewriteAll(!foldMetadataCurrentBefore); var symbols = 0; var symbolReferences = 0; @@ -617,6 +617,7 @@ internal static int RunBackfillFold( "Retry `cdidx backfill-fold`. If the DB still does not verify, rebuild it with `cdidx index --rebuild`.", CommandErrorCodes.DbError); } + writer.MarkCSharpSymbolNameContractReady(); transaction.Commit(); userVersionAfter = db.GetUserVersion(); diff --git a/src/CodeIndex/Database/DbContext.ReadMigrations.cs b/src/CodeIndex/Database/DbContext.ReadMigrations.cs index 56a50f8317..157b22ba74 100644 --- a/src/CodeIndex/Database/DbContext.ReadMigrations.cs +++ b/src/CodeIndex/Database/DbContext.ReadMigrations.cs @@ -255,10 +255,13 @@ PRIMARY KEY(reference_id, symbol_id) // not fail on legacy DBs where the column did not exist yet. // #86: folded 列を追加してから folded index を作らないと legacy DB でクラッシュする。 yield return ("EnsureColumn symbols.name_folded", () => EnsureColumn("symbols", "name_folded", "TEXT")); + yield return ("EnsureColumn symbols.display_name_folded", () => EnsureColumn("symbols", "display_name_folded", "TEXT")); yield return ("EnsureColumn symbol_references.symbol_name_folded", () => EnsureColumn("symbol_references", "symbol_name_folded", "TEXT")); yield return ("EnsureColumn symbol_references.container_name_folded", () => EnsureColumn("symbol_references", "container_name_folded", "TEXT")); yield return ("CREATE INDEX idx_symbols_name_folded", () => Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_folded ON symbols(name_folded)")); + yield return ("CREATE INDEX idx_symbols_display_name_folded", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbols_display_name_folded ON symbols(display_name_folded) WHERE display_name_folded IS NOT NULL")); yield return ("CREATE INDEX idx_symbols_file_name_folded", () => Execute("CREATE INDEX IF NOT EXISTS idx_symbols_file_name_folded ON symbols(file_id, name_folded)")); yield return ("CREATE INDEX idx_symbols_file_name_nocase", diff --git a/src/CodeIndex/Database/DbContext.SchemaInitialization.cs b/src/CodeIndex/Database/DbContext.SchemaInitialization.cs index 8323b0bb57..b840200083 100644 --- a/src/CodeIndex/Database/DbContext.SchemaInitialization.cs +++ b/src/CodeIndex/Database/DbContext.SchemaInitialization.cs @@ -137,7 +137,9 @@ container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN (" + sym visibility TEXT, return_type TEXT, is_metadata_target INTEGER, - metadata_target_source TEXT + metadata_target_source TEXT, + name_folded TEXT, + display_name_folded TEXT )"); // Indexed references table / 参照インデックステーブル @@ -228,6 +230,7 @@ private void MigrateCoreTableColumns() // COLLATE NOCASE path (correct for ASCII, misses non-ASCII casing — #86 fix). // #86: --exact 用の Unicode 折り畳み列。レガシー行は NULL のまま、再 index で埋まる。 EnsureColumn("symbols", "name_folded", "TEXT"); + EnsureColumn("symbols", "display_name_folded", "TEXT"); EnsureColumn("symbol_references", "symbol_name_folded", "TEXT"); EnsureColumn("symbol_references", "container_name_folded", "TEXT"); EnsureColumn("symbol_references", "is_self_reference", "INTEGER NOT NULL DEFAULT 0"); @@ -315,6 +318,11 @@ private void CreateCoreSchemaIndexes() // the NOCASE indexes above. Both sets coexist so mixed-state DBs cannot regress. // #86: 折り畳み列のインデックス。FoldReadyFlag が立っている DB でだけ使う。 Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_folded ON symbols(name_folded)"); + // Explicit-interface identities occupy name_folded, while unqualified discovery uses + // the separately persisted display-name fold. Both predicates stay indexed. + // 明示的 interface identity は name_folded、非修飾 discovery は別途永続化した + // display-name fold を使い、両方の predicate を index 対応に保つ。 + Execute("CREATE INDEX IF NOT EXISTS idx_symbols_display_name_folded ON symbols(display_name_folded) WHERE display_name_folded IS NOT NULL"); // Reference-source and ranked-candidate resolution repeatedly combines the folded // symbol name with file or container scope. Keep those probes bounded for every // indexed language, including the NOCASE fallback used by partially migrated DBs. @@ -432,10 +440,11 @@ container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbol return_type TEXT, is_metadata_target INTEGER, metadata_target_source TEXT, - name_folded TEXT + name_folded TEXT, + display_name_folded TEXT ) """, - "id, file_id, kind, sub_kind, name, line, start_line, start_column, end_line, body_start_line, body_end_line, signature, container_kind, container_name, container_qualified_name, family_key, visibility, return_type, is_metadata_target, metadata_target_source, name_folded"); + "id, file_id, kind, sub_kind, name, line, start_line, start_column, end_line, body_start_line, body_end_line, signature, container_kind, container_name, container_qualified_name, family_key, visibility, return_type, is_metadata_target, metadata_target_source, name_folded, display_name_folded"); RebuildReferenceLineTablesWithRequiredFileId(); RebuildTableWithRequiredFileId( "file_issues", @@ -726,10 +735,11 @@ container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbol return_type TEXT, is_metadata_target INTEGER, metadata_target_source TEXT, - name_folded TEXT + name_folded TEXT, + display_name_folded TEXT ) """; - const string symbolsColumns = "id, file_id, kind, sub_kind, name, line, start_line, start_column, end_line, body_start_line, body_end_line, signature, container_kind, container_name, container_qualified_name, family_key, visibility, return_type, is_metadata_target, metadata_target_source, name_folded"; + const string symbolsColumns = "id, file_id, kind, sub_kind, name, line, start_line, start_column, end_line, body_start_line, body_end_line, signature, container_kind, container_name, container_qualified_name, family_key, visibility, return_type, is_metadata_target, metadata_target_source, name_folded, display_name_folded"; var symbolReferencesCreateSql = $""" CREATE TABLE symbol_references ( diff --git a/src/CodeIndex/Database/DbContext.cs b/src/CodeIndex/Database/DbContext.cs index 0c0f41b9fb..73df6b132a 100644 --- a/src/CodeIndex/Database/DbContext.cs +++ b/src/CodeIndex/Database/DbContext.cs @@ -197,6 +197,7 @@ private static readonly (string Table, string Column)[] ReadMigrationRequiredCol ("symbols", "is_metadata_target"), ("symbols", "metadata_target_source"), ("symbols", "name_folded"), + ("symbols", "display_name_folded"), ]; private static readonly string[] ReadMigrationRequiredIndexes = [ @@ -217,6 +218,7 @@ private static readonly (string Table, string Column)[] ReadMigrationRequiredCol "idx_symbol_refs_container_nocase_kind", "idx_symbols_name_nocase", "idx_symbols_name_folded", + "idx_symbols_display_name_folded", "idx_symbols_file_name_folded", "idx_symbols_file_name_nocase", "idx_symbols_name_folded_container_name_nocase", diff --git a/src/CodeIndex/Database/DbReader.cs b/src/CodeIndex/Database/DbReader.cs index 296a2ac3e5..9454b2786f 100644 --- a/src/CodeIndex/Database/DbReader.cs +++ b/src/CodeIndex/Database/DbReader.cs @@ -1274,7 +1274,19 @@ private ExactQuerySignal BuildExactSymbolSignal(bool available, params string[] DateTime? since = null) { if (_csharpSymbolNameContractCurrent) - return null; + { + if (!_foldReady + || (_symbolColumns.Contains("display_name_folded") + && HasSymbolIndex("idx_symbols_display_name_folded")) + || !ScopeMayIncludeCSharpFiles(lang, pathPatterns, excludePathPatterns, excludeTests, since)) + { + return null; + } + + return BuildExactSymbolSignal( + available: false, + "idx_symbols_display_name_folded"); + } if (!ScopeMayIncludeCSharpFiles(lang, pathPatterns, excludePathPatterns, excludeTests, since)) return null; diff --git a/src/CodeIndex/Database/DbSymbolReader.Search.cs b/src/CodeIndex/Database/DbSymbolReader.Search.cs index 41c400a93a..93e2f3ddf5 100644 --- a/src/CodeIndex/Database/DbSymbolReader.Search.cs +++ b/src/CodeIndex/Database/DbSymbolReader.Search.cs @@ -156,7 +156,10 @@ private string BuildCSharpExplicitInterfaceShortAliasMatchSql( string symbolAlias = "s", string fileAlias = "f") => _foldReady - ? $"({fileAlias}.lang = 'csharp' AND instr({symbolAlias}.name_folded, '.') > 0 AND codeindex_name_fold({symbolAlias}.name) = @{parameterStem}LeafFolded)" + && _csharpSymbolNameContractCurrent + && _symbolColumns.Contains("display_name_folded") + && HasSymbolIndex("idx_symbols_display_name_folded") + ? $"({fileAlias}.lang = 'csharp' AND {symbolAlias}.display_name_folded = @{parameterStem}LeafFolded)" : $"({fileAlias}.lang = 'csharp' AND {symbolAlias}.name = @{parameterStem}Leaf COLLATE NOCASE)"; private static void AddCSharpExplicitInterfaceIdentityQueryParameter( diff --git a/src/CodeIndex/Database/DbWriter.BatchSql.cs b/src/CodeIndex/Database/DbWriter.BatchSql.cs index cb1f3eb0d4..900709c938 100644 --- a/src/CodeIndex/Database/DbWriter.BatchSql.cs +++ b/src/CodeIndex/Database/DbWriter.BatchSql.cs @@ -38,6 +38,10 @@ private static object FoldedNameDbValue( ? identityNameFolded : FoldedNameDbValue(name, cache); + private static object DisplayFoldedNameDbValue( + string? displayNameFolded) => + (object?)displayNameFolded ?? DBNull.Value; + private static Dictionary CreateFoldedNameCache(int rowCount, int namesPerRow) { if (rowCount <= 0 || namesPerRow <= 0) diff --git a/src/CodeIndex/Database/DbWriter.ChunkSymbolBatches.cs b/src/CodeIndex/Database/DbWriter.ChunkSymbolBatches.cs index b0ff53bec6..472ae68e83 100644 --- a/src/CodeIndex/Database/DbWriter.ChunkSymbolBatches.cs +++ b/src/CodeIndex/Database/DbWriter.ChunkSymbolBatches.cs @@ -58,7 +58,7 @@ public void InsertSymbols(IReadOnlyList symbols, CancellationToken TrackReferenceGraphInsertedSymbols(symbols); InvalidateReferenceIdentityContractForMutation(); - int rowsPerStatement = GetRowsPerInsertStatement(columnCount: 20); + int rowsPerStatement = GetRowsPerInsertStatement(columnCount: 21); var foldedNameCache = CreateFoldedNameCache( Math.Min(symbols.Count, rowsPerStatement), namesPerRow: 1); @@ -248,6 +248,8 @@ private void InsertSymbolBatch(IReadOnlyList symbols, int start, i symbol.Name, symbol.IdentityNameFolded, foldedNameCache); + cmd.Parameters[parameterIndex++].Value = DisplayFoldedNameDbValue( + symbol.DisplayNameFolded); } cmd.ExecuteNonQuery(); @@ -295,7 +297,7 @@ INSERT INTO symbols ( container_kind, container_name, container_qualified_name, family_key, visibility, return_type, is_metadata_target, metadata_target_source, - name_folded + name_folded, display_name_folded ) VALUES "); var parameterIndex = 0; @@ -303,7 +305,7 @@ INSERT INTO symbols ( { if (row > 0) sql.Append(", "); - AppendBatchParameterTuple(sql, ref parameterIndex, columnCount: 20); + AppendBatchParameterTuple(sql, ref parameterIndex, columnCount: 21); } return sql.ToString(); } @@ -333,6 +335,7 @@ private static void AddSymbolInsertParameters(SqliteCommand cmd, int rowCount) AddBatchParameter(cmd, ref parameterIndex, SqliteType.Integer); AddBatchParameter(cmd, ref parameterIndex, SqliteType.Text); AddBatchParameter(cmd, ref parameterIndex, SqliteType.Text); + AddBatchParameter(cmd, ref parameterIndex, SqliteType.Text); } } } diff --git a/src/CodeIndex/Database/DbWriter.FoldBackfill.cs b/src/CodeIndex/Database/DbWriter.FoldBackfill.cs index 7dc0386663..de67d0d77b 100644 --- a/src/CodeIndex/Database/DbWriter.FoldBackfill.cs +++ b/src/CodeIndex/Database/DbWriter.FoldBackfill.cs @@ -74,6 +74,12 @@ private bool AllFoldedColumnsBackfilledCore( @" SELECT (SELECT COUNT(*) FROM symbols WHERE name_folded IS NULL) + + (SELECT COUNT(*) + FROM symbols s + JOIN files f ON f.id = s.file_id + WHERE f.lang = 'csharp' + AND instr(s.name_folded, '.') > 0 + AND s.display_name_folded IS NULL) + (SELECT COUNT(*) FROM symbol_references WHERE symbol_name IS NOT NULL AND symbol_name_folded IS NULL) + (SELECT COUNT(*) FROM symbol_references WHERE container_name IS NOT NULL AND container_name_folded IS NULL)", static _ => { }); @@ -97,7 +103,8 @@ public bool AllFoldedColumnValuesMatchCurrentFold() var markdownSymbolIdentityFolds = BuildMarkdownSymbolIdentityFoldMap(); var symbols = RentCommand( """ - SELECT s.id, s.name, s.name_folded, f.lang, s.kind, s.signature + SELECT s.id, s.name, s.name_folded, s.display_name_folded, + f.lang, s.kind, s.signature FROM symbols s JOIN files f ON f.id = s.file_id WHERE s.name IS NOT NULL @@ -111,13 +118,27 @@ WHERE s.name IS NOT NULL var expected = FoldPersistedSymbolName( reader.GetInt64(0), reader.GetString(1), - reader.IsDBNull(3) ? null : reader.GetString(3), - reader.GetString(4), - reader.IsDBNull(5) ? null : reader.GetString(5), + reader.IsDBNull(4) ? null : reader.GetString(4), + reader.GetString(5), + reader.IsDBNull(6) ? null : reader.GetString(6), markdownSymbolIdentityFolds); var actual = reader.IsDBNull(2) ? null : reader.GetString(2); if (!string.Equals(actual, expected, StringComparison.Ordinal)) return false; + var foldedDisplay = DbReader.FoldNameForLanguage( + reader.GetString(1), + reader.IsDBNull(4) ? null : reader.GetString(4)); + var expectedDisplay = + string.Equals( + reader.IsDBNull(4) ? null : reader.GetString(4), + "csharp", + StringComparison.Ordinal) + && !string.Equals(expected, foldedDisplay, StringComparison.Ordinal) + ? foldedDisplay + : null; + var actualDisplay = reader.IsDBNull(3) ? null : reader.GetString(3); + if (!string.Equals(actualDisplay, expectedDisplay, StringComparison.Ordinal)) + return false; } } finally @@ -276,6 +297,7 @@ private bool ExtractorContractsMatchCurrentForReuse(string? lang) bool rewriteAll = false, CancellationToken cancellationToken = default) { + rewriteAll = ResolveFoldBackfillRewriteAll(rewriteAll); cancellationToken.ThrowIfCancellationRequested(); var graphRefreshPending = string.Equals( GetMetaString(FoldBackfillGraphRefreshPendingMetaKey), @@ -319,6 +341,7 @@ private bool ExtractorContractsMatchCurrentForReuse(string? lang) public (int Symbols, int SymbolReferences) CountBackfillFoldedColumns(bool rewriteAll = false) { + rewriteAll = ResolveFoldBackfillRewriteAll(rewriteAll); var phase = rewriteAll ? GetMetaString(FoldBackfillPhaseMetaKey) : null; var lastSymbolId = rewriteAll ? GetFoldBackfillCheckpoint(FoldBackfillLastSymbolIdMetaKey) : 0; var lastReferenceId = rewriteAll ? GetFoldBackfillCheckpoint(FoldBackfillLastReferenceIdMetaKey) : 0; @@ -327,7 +350,16 @@ private bool ExtractorContractsMatchCurrentForReuse(string? lang) ? "SELECT COUNT(*) FROM symbols WHERE name IS NOT NULL AND id > @lastSymbolId" : rewriteAll ? "SELECT 0" - : "SELECT COUNT(*) FROM symbols WHERE name IS NOT NULL AND name_folded IS NULL"; + : """ + SELECT COUNT(*) + FROM symbols s + JOIN files f ON f.id = s.file_id + WHERE s.name IS NOT NULL + AND (s.name_folded IS NULL + OR (f.lang = 'csharp' + AND instr(s.name_folded, '.') > 0 + AND s.display_name_folded IS NULL)) + """; var symbolsUsesCheckpoint = rewriteAll && phase != "references"; var symbols = RentCommand( symbolsSql, @@ -372,6 +404,19 @@ private static int ToInt32Count(object? value) return count > int.MaxValue ? int.MaxValue : (int)count; } + internal bool ResolveFoldBackfillRewriteAll(bool rewriteAll) + { + if (rewriteAll) + return true; + + var currentCSharpContract = DbContext.CSharpSymbolNameContractVersion.ToString( + System.Globalization.CultureInfo.InvariantCulture); + return !string.Equals( + GetMetaString(DbContext.CSharpSymbolNameContractVersionMetaKey), + currentCSharpContract, + StringComparison.Ordinal); + } + private int BackfillSymbolFoldedRows(bool rewriteAll, CancellationToken cancellationToken) { var phase = rewriteAll ? GetMetaString(FoldBackfillPhaseMetaKey) : null; @@ -393,7 +438,11 @@ ORDER BY s.id SELECT s.id, s.name, f.lang, s.kind, s.signature FROM symbols s JOIN files f ON f.id = s.file_id - WHERE s.name IS NOT NULL AND s.name_folded IS NULL + WHERE s.name IS NOT NULL + AND (s.name_folded IS NULL + OR (f.lang = 'csharp' + AND instr(s.name_folded, '.') > 0 + AND s.display_name_folded IS NULL)) """; var select = RentCommand( selectSql, @@ -425,26 +474,43 @@ WHERE s.name IS NOT NULL AND s.name_folded IS NULL return 0; var update = RentCommand( - "UPDATE symbols SET name_folded = @folded WHERE id = @id", + """ + UPDATE symbols + SET name_folded = @folded, + display_name_folded = @displayFolded + WHERE id = @id + """, static c => { c.Parameters.Add("@folded", SqliteType.Text); + c.Parameters.Add("@displayFolded", SqliteType.Text); c.Parameters.Add("@id", SqliteType.Integer); }); try { var pFolded = update.Parameters["@folded"]; + var pDisplayFolded = update.Parameters["@displayFolded"]; var pId = update.Parameters["@id"]; foreach (var row in rows) { cancellationToken.ThrowIfCancellationRequested(); - pFolded.Value = FoldPersistedSymbolName( + var foldedIdentity = FoldPersistedSymbolName( row.Id, row.Name, row.Lang, row.Kind, row.Signature, markdownSymbolIdentityFolds); + var foldedDisplay = DbReader.FoldNameForLanguage(row.Name, row.Lang); + pFolded.Value = foldedIdentity; + pDisplayFolded.Value = + string.Equals(row.Lang, "csharp", StringComparison.Ordinal) + && !string.Equals( + foldedIdentity, + foldedDisplay, + StringComparison.Ordinal) + ? foldedDisplay + : DBNull.Value; pId.Value = row.Id; update.ExecuteNonQuery(); if (rewriteAll) diff --git a/src/CodeIndex/Indexer/Hooks/PostExtractionHookMutationMaterializer.cs b/src/CodeIndex/Indexer/Hooks/PostExtractionHookMutationMaterializer.cs index af51962088..1b930c540a 100644 --- a/src/CodeIndex/Indexer/Hooks/PostExtractionHookMutationMaterializer.cs +++ b/src/CodeIndex/Indexer/Hooks/PostExtractionHookMutationMaterializer.cs @@ -1,3 +1,4 @@ +using CodeIndex.Database; using CodeIndex.Models; namespace CodeIndex.Indexer.Hooks; @@ -64,7 +65,10 @@ internal static void RefreshLanguageIdentity(string? language, IEnumerable ExecuteBackfillFoldAsync(JsonNode? id, JsonNode? ar && storedFoldFingerprint == currentFoldFingerprint; foldReadyBefore = foldReadyBefore && foldMetadataCurrentBefore; var force = args?["force"]?.GetValue() ?? false; - var rewriteAll = force - || !foldMetadataCurrentBefore; + var rewriteAll = writer.ResolveFoldBackfillRewriteAll( + force || !foldMetadataCurrentBefore); var symbols = 0; var symbolReferences = 0; var totalSymbols = 0; @@ -74,6 +74,7 @@ private async Task ExecuteBackfillFoldAsync(JsonNode? id, JsonNode? ar verified = writer.MarkFoldReady(); if (!verified) return CreateToolErrorResponse(id, "Folded-name backfill verification failed: some rows still have NULL folded values. Re-run backfill_fold."); + writer.MarkCSharpSymbolNameContractReady(); transaction.Commit(); userVersionAfter = db.GetUserVersion(); diff --git a/src/CodeIndex/Models/SymbolRecord.cs b/src/CodeIndex/Models/SymbolRecord.cs index 0f78478544..40987e249e 100644 --- a/src/CodeIndex/Models/SymbolRecord.cs +++ b/src/CodeIndex/Models/SymbolRecord.cs @@ -27,6 +27,10 @@ public class SymbolRecord [JsonInclude] internal string? IdentityNameFolded { get; set; } + /// Folded display alias when persisted identity differs / 永続 identity と異なる場合の表示名 fold alias + [JsonInclude] + internal string? DisplayNameFolded { get; set; } + /// Line number (1-based) / 行番号(1始まり) public int Line { get; set; } diff --git a/tests/CodeIndex.Tests/DatabaseTests.cs b/tests/CodeIndex.Tests/DatabaseTests.cs index 6ec6601b03..d91ac678f2 100644 --- a/tests/CodeIndex.Tests/DatabaseTests.cs +++ b/tests/CodeIndex.Tests/DatabaseTests.cs @@ -4103,6 +4103,7 @@ public void InitializeSchema_CreatesReferenceCompositeIndexesForGraphLookups() Assert.Contains("idx_symbols_file_name_folded", symbolIndexes); Assert.Contains("idx_symbols_file_name_nocase", symbolIndexes); + Assert.Contains("idx_symbols_display_name_folded", symbolIndexes); Assert.Contains("idx_symbols_name_folded_container_name_nocase", symbolIndexes); Assert.Contains("idx_symbols_name_folded_container_qualified_name_nocase", symbolIndexes); Assert.Contains("idx_symbol_refs_name_kind", indexes); @@ -4117,6 +4118,11 @@ public void InitializeSchema_CreatesReferenceCompositeIndexesForGraphLookups() AssertIndexColumns(_db.Connection, "idx_symbols_file_name_folded", [("file_id", "BINARY"), ("name_folded", "BINARY")]); AssertIndexColumns(_db.Connection, "idx_symbols_file_name_nocase", [("file_id", "BINARY"), ("name", "NOCASE")]); + AssertIndexColumns(_db.Connection, "idx_symbols_display_name_folded", [("display_name_folded", "BINARY")]); + AssertIndexSqlContains( + _db.Connection, + "idx_symbols_display_name_folded", + "WHERE display_name_folded IS NOT NULL"); AssertIndexColumns(_db.Connection, "idx_symbols_name_folded_container_name_nocase", [("name_folded", "BINARY"), ("container_name", "NOCASE")]); AssertIndexColumns(_db.Connection, "idx_symbols_name_folded_container_qualified_name_nocase", [("name_folded", "BINARY"), ("container_qualified_name", "NOCASE")]); AssertIndexColumns(_db.Connection, "idx_symbol_refs_name_nocase_kind", [("symbol_name", "NOCASE"), ("reference_kind", "BINARY")]); @@ -4151,6 +4157,13 @@ public void ReferenceResolutionLookupQueries_UseCompositeSymbolIndexes() ("@name", "Worker")), "idx_symbols_file_name_nocase"); + AssertSearchesWithIndex( + ReadQueryPlanDetails( + _db.Connection, + "SELECT id FROM symbols WHERE display_name_folded = @display_name_folded", + ("@display_name_folded", "run")), + "idx_symbols_display_name_folded"); + AssertSearchesWithIndex( ReadQueryPlanDetails( _db.Connection, @@ -4503,6 +4516,7 @@ container_name TEXT Assert.DoesNotContain("idx_files_path_nocase", fileIndexes); Assert.Contains("idx_symbols_file_name_folded", symbolIndexes); Assert.Contains("idx_symbols_file_name_nocase", symbolIndexes); + Assert.Contains("idx_symbols_display_name_folded", symbolIndexes); Assert.Contains("idx_symbols_name_folded_container_name_nocase", symbolIndexes); Assert.Contains("idx_symbols_name_folded_container_qualified_name_nocase", symbolIndexes); Assert.Contains("idx_symbol_refs_name_kind", indexes); @@ -4517,6 +4531,11 @@ container_name TEXT AssertIndexColumns(db.Connection, "idx_symbols_file_name_folded", [("file_id", "BINARY"), ("name_folded", "BINARY")]); AssertIndexColumns(db.Connection, "idx_symbols_file_name_nocase", [("file_id", "BINARY"), ("name", "NOCASE")]); + AssertIndexColumns(db.Connection, "idx_symbols_display_name_folded", [("display_name_folded", "BINARY")]); + AssertIndexSqlContains( + db.Connection, + "idx_symbols_display_name_folded", + "WHERE display_name_folded IS NOT NULL"); AssertIndexColumns(db.Connection, "idx_symbols_name_folded_container_name_nocase", [("name_folded", "BINARY"), ("container_name", "NOCASE")]); AssertIndexColumns(db.Connection, "idx_symbols_name_folded_container_qualified_name_nocase", [("name_folded", "BINARY"), ("container_qualified_name", "NOCASE")]); AssertIndexColumns(db.Connection, "idx_symbol_refs_container_nocase_kind", [("container_name", "NOCASE"), ("reference_kind", "BINARY")]); diff --git a/tests/CodeIndex.Tests/DbReaderSearchTests.cs b/tests/CodeIndex.Tests/DbReaderSearchTests.cs index 7f7f5fdecb..5feb4b0593 100644 --- a/tests/CodeIndex.Tests/DbReaderSearchTests.cs +++ b/tests/CodeIndex.Tests/DbReaderSearchTests.cs @@ -1951,6 +1951,27 @@ public sealed class ArrayFactory writer.MarkGraphReady(); using var reader = new DbReader(db.Connection); + using (var planCommand = db.Connection.CreateCommand()) + { + planCommand.CommandText = """ + EXPLAIN QUERY PLAN + SELECT s.id + FROM symbols s + JOIN files f ON f.id = s.file_id + WHERE s.name_folded = @identity + OR (f.lang = 'csharp' AND s.display_name_folded = @display) + """; + planCommand.Parameters.AddWithValue("@identity", "run"); + planCommand.Parameters.AddWithValue("@display", "run"); + using var planReader = planCommand.ExecuteReader(); + var plan = new System.Text.StringBuilder(); + while (planReader.Read()) + plan.AppendLine(planReader.GetString(3)); + var planText = plan.ToString(); + Assert.Contains("idx_symbols_name_folded", planText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("idx_symbols_display_name_folded", planText, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("SCAN s", planText, StringComparison.OrdinalIgnoreCase); + } Assert.Equal( DbContext.CSharpSymbolNameContractVersion.ToString(System.Globalization.CultureInfo.InvariantCulture), reader.GetMetaString(DbContext.CSharpSymbolNameContractVersionMetaKey)); diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index a96dcb2725..95938c040b 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -6329,6 +6329,99 @@ public void RunBackfillFold_DryRunReportsRowsWithoutWriting() } } + [Fact] + public void RunBackfillFold_RewritesPreviousCSharpExplicitInterfaceIdentityContract_Issue4866() + { + var dbPath = CreateTempDbPath("cdidx_backfill_fold_csharp_v2"); + try + { + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + db.InitializeSchema(); + var writer = new DbWriter(db.Connection); + Assert.True(writer.MarkFoldReady()); + var fileId = writer.UpsertFile(new FileRecord + { + Path = "src/Explicit.cs", + Lang = "csharp", + Size = 64, + Lines = 1, + Modified = new DateTime(2026, 7, 29, 0, 0, 0, DateTimeKind.Utc), + }); + writer.InsertSymbols([ + new SymbolRecord + { + FileId = fileId, + Kind = "function", + Name = "Run", + Signature = "void IFoo.Run() { }", + Line = 1, + StartLine = 1, + EndLine = 1, + }, + ]); + writer.SetMeta(DbContext.CSharpSymbolNameContractVersionMetaKey, "2"); + } + + JsonElement json; + int exitCode; + lock (TestConsoleLock.Gate) + { + var originalOut = Console.Out; + using var output = new StringWriter(); + try + { + Console.SetOut(output); + exitCode = IndexCommandRunner.RunBackfillFold( + ["--db", dbPath, "--json"], + _jsonOptions); + using var document = JsonDocument.Parse(output.ToString()); + json = document.RootElement.Clone(); + } + finally + { + Console.SetOut(originalOut); + } + } + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.True(json.GetProperty("rewrite_all").GetBoolean()); + Assert.Equal(1, json.GetProperty("symbols").GetInt32()); + Assert.True(json.GetProperty("verified").GetBoolean()); + + using var verifyDb = new DbContext(DbOpenIntent.WriteIndex, dbPath); + Assert.Equal( + DbContext.CSharpSymbolNameContractVersion.ToString( + System.Globalization.CultureInfo.InvariantCulture), + verifyDb.GetMetaString(DbContext.CSharpSymbolNameContractVersionMetaKey)); + using var identity = verifyDb.Connection.CreateCommand(); + identity.CommandText = """ + SELECT name_folded, display_name_folded + FROM symbols + WHERE name = 'Run' + """; + using var identityReader = identity.ExecuteReader(); + Assert.True(identityReader.Read()); + Assert.Equal("ifoo.run", identityReader.GetString(0)); + Assert.Equal("run", identityReader.GetString(1)); + + using var reader = new DbReader(verifyDb.Connection); + Assert.Single(reader.SearchSymbols( + "IFoo.Run", + lang: "csharp", + exact: true)); + Assert.Single(reader.SearchSymbols( + "Run", + lang: "csharp", + exact: true)); + } + finally + { + SqliteConnection.ClearAllPools(); + DeleteFile(dbPath); + } + } + [Fact] public void RunBackfillFold_DryRunReportsEffectiveFoldReadyWhenMetadataStale() { diff --git a/tests/CodeIndex.Tests/PostExtractionHookContractTests.cs b/tests/CodeIndex.Tests/PostExtractionHookContractTests.cs index d10c291031..e42f0fb769 100644 --- a/tests/CodeIndex.Tests/PostExtractionHookContractTests.cs +++ b/tests/CodeIndex.Tests/PostExtractionHookContractTests.cs @@ -136,6 +136,7 @@ public void MutationMaterializer_RecomputesNimIdentityAfterHookMutation_Issue473 Kind = "function", Name = "renamed_proc", IdentityNameFolded = "stale", + DisplayNameFolded = "stale", Line = 1, StartLine = 1, EndLine = 1, @@ -173,6 +174,7 @@ public void MutationMaterializer_RecomputesNimIdentityAfterHookMutation_Issue473 PostExtractionHookMutationMaterializer.RefreshLanguageIdentity("nim", clonedReferences); Assert.Equal("renamedproc", Assert.Single(symbols).IdentityNameFolded); + Assert.Null(Assert.Single(symbols).DisplayNameFolded); var reference = Assert.Single(clonedReferences); Assert.Equal("renamedproc", reference.IdentitySymbolNameFolded); Assert.Equal("Rungraph", reference.IdentityContainerNameFolded); @@ -212,6 +214,8 @@ public void MutationMaterializer_RecomputesCSharpExplicitInterfaceIdentityAfterH PostExtractionHookMutationMaterializer.RefreshLanguageIdentity("csharp", symbols); Assert.Equal("ifoo.run", symbols[0].IdentityNameFolded); + Assert.Equal("run", symbols[0].DisplayNameFolded); Assert.Null(symbols[1].IdentityNameFolded); + Assert.Null(symbols[1].DisplayNameFolded); } } From 5857c094acd35b309845344fdbf3fb61caeb31bd Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 10:24:45 +0900 Subject: [PATCH 06/18] Scope C# alias backfill to identity rows (#4866) --- .../Database/DbWriter.FoldBackfill.cs | 6 +++--- .../IndexCommandRunnerTests.cs | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/CodeIndex/Database/DbWriter.FoldBackfill.cs b/src/CodeIndex/Database/DbWriter.FoldBackfill.cs index de67d0d77b..051643faf7 100644 --- a/src/CodeIndex/Database/DbWriter.FoldBackfill.cs +++ b/src/CodeIndex/Database/DbWriter.FoldBackfill.cs @@ -78,7 +78,7 @@ private bool AllFoldedColumnsBackfilledCore( FROM symbols s JOIN files f ON f.id = s.file_id WHERE f.lang = 'csharp' - AND instr(s.name_folded, '.') > 0 + AND s.name_folded <> codeindex_name_fold(s.name) AND s.display_name_folded IS NULL) + (SELECT COUNT(*) FROM symbol_references WHERE symbol_name IS NOT NULL AND symbol_name_folded IS NULL) + (SELECT COUNT(*) FROM symbol_references WHERE container_name IS NOT NULL AND container_name_folded IS NULL)", @@ -357,7 +357,7 @@ FROM symbols s WHERE s.name IS NOT NULL AND (s.name_folded IS NULL OR (f.lang = 'csharp' - AND instr(s.name_folded, '.') > 0 + AND s.name_folded <> codeindex_name_fold(s.name) AND s.display_name_folded IS NULL)) """; var symbolsUsesCheckpoint = rewriteAll && phase != "references"; @@ -441,7 +441,7 @@ FROM symbols s WHERE s.name IS NOT NULL AND (s.name_folded IS NULL OR (f.lang = 'csharp' - AND instr(s.name_folded, '.') > 0 + AND s.name_folded <> codeindex_name_fold(s.name) AND s.display_name_folded IS NULL)) """; var select = RentCommand( diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 95938c040b..6b242ff153 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -6359,6 +6359,16 @@ public void RunBackfillFold_RewritesPreviousCSharpExplicitInterfaceIdentityContr StartLine = 1, EndLine = 1, }, + new SymbolRecord + { + FileId = fileId, + Kind = "namespace", + Name = "CodeIndex.Tests", + Signature = "namespace CodeIndex.Tests;", + Line = 1, + StartLine = 1, + EndLine = 1, + }, ]); writer.SetMeta(DbContext.CSharpSymbolNameContractVersionMetaKey, "2"); } @@ -6386,7 +6396,7 @@ public void RunBackfillFold_RewritesPreviousCSharpExplicitInterfaceIdentityContr Assert.Equal(CommandExitCodes.Success, exitCode); Assert.True(json.GetProperty("rewrite_all").GetBoolean()); - Assert.Equal(1, json.GetProperty("symbols").GetInt32()); + Assert.Equal(2, json.GetProperty("symbols").GetInt32()); Assert.True(json.GetProperty("verified").GetBoolean()); using var verifyDb = new DbContext(DbOpenIntent.WriteIndex, dbPath); @@ -6404,6 +6414,13 @@ FROM symbols Assert.True(identityReader.Read()); Assert.Equal("ifoo.run", identityReader.GetString(0)); Assert.Equal("run", identityReader.GetString(1)); + using var namespaceAlias = verifyDb.Connection.CreateCommand(); + namespaceAlias.CommandText = """ + SELECT display_name_folded + FROM symbols + WHERE kind = 'namespace' + """; + Assert.Equal(DBNull.Value, namespaceAlias.ExecuteScalar()); using var reader = new DbReader(verifyDb.Connection); Assert.Single(reader.SearchSymbols( From 22ac98b7ee2333af45b13615cda05acf65d2d88d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 11:09:02 +0900 Subject: [PATCH 07/18] Constrain explicit-interface fold reconstruction (#4866) --- .../Database/DbWriter.FoldBackfill.cs | 5 ++- .../PostExtractionHookMutationMaterializer.cs | 3 +- .../Symbols/CSharpSymbolNameNormalizer.cs | 45 ++++++++++++++----- tests/CodeIndex.Tests/DbReaderSearchTests.cs | 44 +++++++++++++----- .../IndexCommandRunnerTests.cs | 22 ++++++++- 5 files changed, 93 insertions(+), 26 deletions(-) diff --git a/src/CodeIndex/Database/DbWriter.FoldBackfill.cs b/src/CodeIndex/Database/DbWriter.FoldBackfill.cs index 051643faf7..e353420769 100644 --- a/src/CodeIndex/Database/DbWriter.FoldBackfill.cs +++ b/src/CodeIndex/Database/DbWriter.FoldBackfill.cs @@ -594,7 +594,10 @@ private static string FoldPersistedSymbolName( if (lang == "csharp") { var explicitInterfaceIdentity = - CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded(name, signature); + CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( + name, + signature, + kind); if (explicitInterfaceIdentity != null) return explicitInterfaceIdentity; } diff --git a/src/CodeIndex/Indexer/Hooks/PostExtractionHookMutationMaterializer.cs b/src/CodeIndex/Indexer/Hooks/PostExtractionHookMutationMaterializer.cs index 1b930c540a..f2b16a55c0 100644 --- a/src/CodeIndex/Indexer/Hooks/PostExtractionHookMutationMaterializer.cs +++ b/src/CodeIndex/Indexer/Hooks/PostExtractionHookMutationMaterializer.cs @@ -79,7 +79,8 @@ internal static void RefreshLanguageIdentity(string? language, IEnumerable - internal static string? BuildExplicitInterfaceIdentityNameFolded(string name, string? signature) + internal static string? BuildExplicitInterfaceIdentityNameFolded( + string name, + string? signature, + string kind) { - if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(signature)) + if (string.IsNullOrWhiteSpace(name) + || string.IsNullOrWhiteSpace(signature) + || kind is not ("function" or "property" or "event")) + { return null; + } // `Item` is only the display alias for an indexer when the declaration itself uses // `this[...]`. A legal method/property/event may also be named `Item`, so try the @@ -83,7 +90,8 @@ public static string Normalize(string name, Match match, string matchLine) name, signature, sourceName: "this", - isIndexer: true); + isIndexer: true, + kind: kind); if (indexerIdentity != null) return indexerIdentity; } @@ -92,14 +100,16 @@ public static string Normalize(string name, Match match, string matchLine) name, signature, sourceName: name, - isIndexer: false); + isIndexer: false, + kind: kind); } private static string? TryBuildExplicitInterfaceIdentityNameFolded( string name, string signature, string sourceName, - bool isIndexer) + bool isIndexer, + string kind) { var declarationBodyStart = FindDeclarationBodyStart(signature); var searchStart = 0; @@ -136,7 +146,12 @@ public static string Normalize(string name, Match match, string matchLine) var cursor = memberTokenEnd; while (cursor < signature.Length && char.IsWhiteSpace(signature[cursor])) cursor++; - if (TryReadExplicitInterfaceMemberArity(signature, cursor, isIndexer, out var arity)) + if (TryReadExplicitInterfaceMemberArity( + signature, + cursor, + isIndexer, + kind, + out var arity)) { var qualifierEnd = cursorBeforeMember; while (qualifierEnd > 0 && char.IsWhiteSpace(signature[qualifierEnd - 1])) @@ -175,6 +190,7 @@ private static bool TryReadExplicitInterfaceMemberArity( string signature, int cursor, bool isIndexer, + string kind, out int arity) { arity = 0; @@ -183,11 +199,18 @@ private static bool TryReadExplicitInterfaceMemberArity( if (signature[cursor] != '<') { - // Reject a matching qualified return/parameter type such as `Models.Run Run()`. - // A non-generic explicit member name is followed immediately by its - // parameter/indexer list, accessor body, expression body, or terminator. - return signature[cursor] is '(' or '{' or '=' or ';' - || (isIndexer && signature[cursor] == '['); + // Match the suffix required by the persisted row's member kind. In particular, + // a function token must lead into its parameter list; accepting `{` here lets a + // later base/constraint type such as `class Runner : IFoo.Runner { }` masquerade + // as the declaration token. + // 永続 row の member kind に対応する suffix だけを受理する。function token は + // parameter list へ続く必要があり、`{` を許すと `class Runner : IFoo.Runner { }` + // のような後続 base/constraint 型を declaration token と誤認してしまう。 + if (isIndexer) + return kind == "function" && signature[cursor] == '['; + if (kind == "function") + return signature[cursor] == '('; + return signature[cursor] is '{' or '=' or ';'; } var typeParameterEnd = FindBalancedTypeArgumentListEnd(signature, cursor); diff --git a/tests/CodeIndex.Tests/DbReaderSearchTests.cs b/tests/CodeIndex.Tests/DbReaderSearchTests.cs index 5feb4b0593..b6245c64f9 100644 --- a/tests/CodeIndex.Tests/DbReaderSearchTests.cs +++ b/tests/CodeIndex.Tests/DbReaderSearchTests.cs @@ -1980,45 +1980,64 @@ FROM symbols s CSharpSymbolNameNormalizer.NormalizeExplicitInterfaceQueryIdentityNameFolded("IFoo.Run")); Assert.Null(CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( "Run", - "Models.Run Run()")); + "Models.Run Run()", + "function")); Assert.Null(CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( "Run", - "Models.Run Run()")); + "Models.Run Run()", + "function")); Assert.Null(CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( "Run", - "Models.Run[] Run()")); + "Models.Run[] Run()", + "function")); Assert.Null(CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( "Count", - "public int Count => inner.Count;")); + "public int Count => inner.Count;", + "property")); Assert.Null(CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( "Add", - "public void Add(Item item) => inner.Add(item);")); + "public void Add(Item item) => inner.Add(item);", + "function")); Assert.Null(CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( "MaxSize", - "internal const int MaxSize = Limits.MaxSize;")); + "internal const int MaxSize = Limits.MaxSize;", + "field")); Assert.Null(CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( "Registry", - "using Registry = CodeIndex.Indexer.Registry;")); + "using Registry = CodeIndex.Indexer.Registry;", + "import")); + Assert.Null(CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( + "Runner", + "public class Runner : IFoo.Runner { }", + "class")); + Assert.Null(CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( + "Run", + "void Run() where T : IFoo.Run { }", + "function")); Assert.Equal( "ifoo.run`1", CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( "Run", - "Models.Run IFoo.Run(TValue value)")); + "Models.Run IFoo.Run(TValue value)", + "function")); Assert.Equal( "ifoo.changed", CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( "Changed", - "event System.EventHandler IFoo . Changed { add { } remove { } }")); + "event System.EventHandler IFoo . Changed { add { } remove { } }", + "event")); Assert.Equal( "ifoo.run", CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( "Run", - "void IFoo.@Run()")); + "void IFoo.@Run()", + "function")); Assert.Equal( "ifoo.this", CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( "this", - "void IFoo.@this()")); + "void IFoo.@this()", + "function")); Assert.Equal( "ifoo.item", CSharpSymbolNameNormalizer.NormalizeExplicitInterfaceQueryIdentityNameFolded( @@ -2027,7 +2046,8 @@ FROM symbols s "iitemcontract.item", CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( "Item", - "int IItemContract . Item => 2;")); + "int IItemContract . Item => 2;", + "property")); Assert.Equal( "ifoo.this", CSharpSymbolNameNormalizer.NormalizeExplicitInterfaceQueryIdentityNameFolded( diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 6b242ff153..0839055546 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -6369,6 +6369,16 @@ public void RunBackfillFold_RewritesPreviousCSharpExplicitInterfaceIdentityContr StartLine = 1, EndLine = 1, }, + new SymbolRecord + { + FileId = fileId, + Kind = "class", + Name = "Runner", + Signature = "public class Runner : IFoo.Runner { }", + Line = 1, + StartLine = 1, + EndLine = 1, + }, ]); writer.SetMeta(DbContext.CSharpSymbolNameContractVersionMetaKey, "2"); } @@ -6396,7 +6406,7 @@ public void RunBackfillFold_RewritesPreviousCSharpExplicitInterfaceIdentityContr Assert.Equal(CommandExitCodes.Success, exitCode); Assert.True(json.GetProperty("rewrite_all").GetBoolean()); - Assert.Equal(2, json.GetProperty("symbols").GetInt32()); + Assert.Equal(3, json.GetProperty("symbols").GetInt32()); Assert.True(json.GetProperty("verified").GetBoolean()); using var verifyDb = new DbContext(DbOpenIntent.WriteIndex, dbPath); @@ -6421,6 +6431,16 @@ FROM symbols WHERE kind = 'namespace' """; Assert.Equal(DBNull.Value, namespaceAlias.ExecuteScalar()); + using var ordinaryTypeIdentity = verifyDb.Connection.CreateCommand(); + ordinaryTypeIdentity.CommandText = """ + SELECT name_folded, display_name_folded + FROM symbols + WHERE kind = 'class' + """; + using var ordinaryTypeIdentityReader = ordinaryTypeIdentity.ExecuteReader(); + Assert.True(ordinaryTypeIdentityReader.Read()); + Assert.Equal("runner", ordinaryTypeIdentityReader.GetString(0)); + Assert.True(ordinaryTypeIdentityReader.IsDBNull(1)); using var reader = new DbReader(verifyDb.Connection); Assert.Single(reader.SearchSymbols( From e28ddaae2e697e542d47a5e861eb1c8b419c33bf Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 11:47:24 +0900 Subject: [PATCH 08/18] Harden explicit-interface upgrade readiness (#4866) --- DEVELOPER_GUIDE.md | 4 +- changelog.d/unreleased/4866.fixed.md | 4 +- .../Cli/IndexCommandRunner.Maintenance.cs | 16 ++++ .../Database/DbSymbolReader.Search.cs | 3 +- .../Database/DbWriter.FoldBackfill.cs | 34 +++++++++ src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs | 12 +++ tests/CodeIndex.Tests/DbReaderSearchTests.cs | 24 ++++++ .../IndexCommandRunnerTests.cs | 73 +++++++++++++++++++ .../McpServerToolsCallTests.cs | 40 ++++++++++ 9 files changed, 205 insertions(+), 5 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 1c50faac51..90aa83dbf7 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1927,7 +1927,7 @@ Process exit codes are coarse (`0` success including valid zero-row queries, `1` - **Compact search snippets for AI** — `search --json` and MCP `search` return match-centered snippets with explicit snippet ranges, match lines, highlights, context counts, `truncated_line_count`, and `truncation_context` instead of whole chunks. `truncation_context.char_counts` and `truncation_context.total_chars` expose the omitted character counts behind each clamped snippet line, while truncated highlights also carry `truncated_char_counts`. `--snippet-lines` lets clients trade recall for smaller payloads, and `--max-line-width` (CLI) / `maxLineWidth` (MCP) routes each snippet line through the same `LineWidthFormatter.ClampLine` contract used by `find` / `references` / `excerpt` / `inspect` so hits inside minified / transpiled / generated single-line files no longer return hundreds of KB per result unless the caller explicitly sets `0`; clamped lines carry `...(+N)...` markers and `highlights[].truncated` / `highlights[].original_line_length`. - **Repo map for first-pass orientation** — `map` aggregates languages, modules, top files, file hot spots, and likely entrypoints from indexed data so AI clients can decide where to look before issuing precise queries. Entrypoint inference now falls back to known top-level entry files when symbol extraction does not produce an explicit `Main`-style symbol. - **Freshness metadata for trust decisions** — `status` exposes whole-workspace freshness and git state, plus trust metadata such as `sql_graph_contract_ready` / `sql_graph_contract_degraded_reason`, `hotspot_family_ready` / `hotspot_family_degraded_reason`, forward-compatibility audit fields (`index_writer_version`, `index_newer_than_reader`, `index_newer_than_reader_reason` — see "Forward-compatibility readiness audit"), and fold remediation fields (`fold_ready_reason`, `degraded_reason`, `recommended_action`, `alternative_action`) so AI clients can tell up front whether SQL graph/dependency/impact answers, duplicate-name hotspot families, and Unicode `--exact` are authoritative. CLI `status --json` and MCP `status` both populate those fold remediation fields when `fold_ready=false`. It also carries `unknown_extension_file_count`, a capped `unknown_extension_files` path sample, `unknown_extension_files_truncated`, and `unknown_extension_file_path_limit` after a current full-repository scan so extension-table coverage gaps are visible and actionable even when those files were excluded from indexing. When those fold remediation fields are derived from an explicit read-only `file:` DB URI, they are normalized back to a writable filesystem path for both absolute (`file:///...?...`) and relative (`file:codeindex.db?...`) forms instead of echoing the read-only URI into commands that would fail. `cdidx index` JSON/human readiness output also surfaces the same trust bits, keeping the post-index readiness summary aligned with `status`. `impact` / MCP `impact_analysis` also mirror the SQL graph-contract signal in JSON so stale SQL rows do not masquerade as authoritative zero-impact answers. `inspect` / MCP `analyze_symbol` and `references` / MCP `references` now mirror that same SQL graph-contract signal whenever SQL-backed graph reads contribute to their payloads, so stale SQL rows do not look like authoritative hits or zero-result answers there either. `map` keeps `indexed_at` / `latest_modified` scoped to the filtered result set and also exposes `workspace_indexed_at` / `workspace_latest_modified` for whole-workspace freshness. `inspect` mirrors those whole-workspace timestamps and git fields so symbol-oriented AI flows can make trust decisions without a separate `status` call. `files` exposes per-file checksum plus modified/indexed timestamps. File-column migrations are applied opportunistically for older DBs, and read paths are designed to avoid crashing if in-place migration is unavailable. CLI and MCP zero-result JSON responses for `search`, `files`, `symbols`, `definition`, `references`, `callers`, `callees`, `deps`, `unused`, `hotspots`, and `impact` include `indexed_file_count`, `indexed_at`, and `freshness_available`. `indexed_at:null` with `freshness_available=true` means the index is empty, while `freshness_available=false` means a legacy/read-only DB could not expose freshness timestamps and `freshness_degraded_reason` explains why. **HEAD-aware staleness signal**: every successful `cdidx index` full scan now stamps the captured `git HEAD` into `codeindex_meta` so subsequent runs can compare it against the workspace HEAD. When they differ and the user did not pass `--rebuild`, the CLI emits a `head_changed` warning recommending `cdidx index --rebuild` and exposes `head_changed` / `prior_indexed_head_commit` / `current_head_commit` / `head_change_notice` in `index --json`. `status --check` mirrors the same comparison through `workspace_check.head_changed` (alongside `indexed_head_commit` / `workspace_head_commit` when they differ), so AI clients that already gate on freshness can refuse to trust a default incremental scan after `git switch ` without a separate query. `--commits` / `--files` partial updates deliberately preserve the captured HEAD so the staleness signal survives until a real full scan reindexes the worktree. Non-git workspaces and legacy DBs that never captured a HEAD skip the comparison instead of false-positive flagging. -- **Folded-key upgrade without reparse** — `backfill-fold` and MCP `backfill_fold` recompute `name_folded`, explicit-interface `display_name_folded`, and reference `*_folded` values directly from existing DB rows, then stamp `FoldReadyFlag` once verification confirms no required folded values remain NULL. This gives AI clients and users a low-cost upgrade path from pre-#86 DBs without re-reading every source file. It rewrites all folded rows when fold metadata is stale or the C# symbol-name contract changed, and stamps the current C# contract only after verification succeeds. +- **Folded-key upgrade without reparse** — `backfill-fold` and MCP `backfill_fold` recompute `name_folded`, explicit-interface `display_name_folded`, and reference `*_folded` values directly from existing DB rows, then stamp `FoldReadyFlag` once verification confirms no required folded values remain NULL. This gives AI clients and users a low-cost upgrade path from pre-#86 DBs without re-reading every source file. It rewrites all folded rows when fold metadata is stale or the C# symbol-name contract changed, and stamps the current C# contract only after verification succeeds. Upgrading the explicit-interface v3 contract also requires persisted signatures for every C# method/property/event row; older databases without that reconstruction evidence must refresh C# files or rebuild rather than receiving a false-ready stamp. - **Bundled symbol analysis** — `inspect` and MCP `analyze_symbol` return definition, nearby symbols, references, callers, callees, file metadata, workspace trust metadata, and graph-support metadata in one request so AI clients can answer common symbol questions with fewer round-trips. - **Language-aware reference extraction** — `references`, `callers`, `callees`, and `impact` are backed by an indexed reference table built only for languages where regex-based call/reference extraction is meaningful. Unsupported languages intentionally fall back to text search instead of returning low-confidence pseudo-graph data. When a language is removed from graph support, `PurgeUnsupportedReferences` deletes its stale `symbol_references` rows on the next indexing run, and graph read paths additionally filter by supported languages to prevent stale edges from surviving between index runs. Shell is intentionally excluded because its command-style invocations (`foo arg1 arg2`) cannot be detected by the parenthesized-call regex. **Nested generic call sites**: C#/Java constructor calls like `new Dictionary>()` and C# generic method calls like `Helper.DoWork>()` are recovered by a depth-aware fallback scanner so the outer target still reaches the reference table even though the flat regex fast-path cannot balance `>>`. **JS/TS no-paren constructors**: JavaScript / TypeScript zero-argument constructor calls that legally omit `()` — for example `new Foo;`, `new Date;`, qualified targets like `new Demo.Provider;`, and one-level generic TypeScript forms like `new Box;` — are emitted as `instantiate` edges via a dedicated language-gated path, while next-line `.bar()` / `[0]` continuations are suppressed so a line-ended `new Foo` does not become a phantom standalone instantiation. **Constructor chain calls**: C# `: this(...)` / `: base(...)` initializers and Java `this(...)` / `super(...)` first-statement calls are detected separately from the generic call regex and rewritten so the reference target is the real constructor (enclosing class/record for `this`, the parsed base type from the class signature for `base` / `super`). Cross-line C# initializers are attributed to the owning constructor rather than the enclosing class. Base-type parsing strips generics, record primary-ctor args, `where` constraints, and `global::` / dotted namespace qualifiers; Java `super.method()` stays a normal method call. **Type-position dependency edges**: C#/Java base lists, declaration types, generic constraints, `throws`, `is`/`as`/`instanceof`, and real C# XML-doc `cref` sites are indexed as `type_reference` rows so `references` / `impact` can see compile-time rename dependencies without polluting the default dynamic call graph exposed by `callers` / `callees`. C# XML-doc `cref` extraction accepts declaration-attached XML-doc comments from both `///` lines and delimited `/** ... */` blocks, including declarations that begin later on the same physical line after the closing `*/` only when no unrelated same-line code or declaration intervenes, while ordinary `//` / `////` comments, non-documenting block comments, method-body XML-doc comments that merely precede a later declaration, brace-free field/property initializer continuations, brace-free expression lambdas, intervening top-level executable statements, same-line non-target code after `*/`, other nested executable continuations, and multiline raw/verbatim string content whose line happens to start with `/**` stay excluded. Non-doc code or string content after the closing `*/` on the same physical line is still outside the doc-comment slice. Even though the regex now runs against that narrower slice, the extractor preserves `symbol_references.column` relative to the original physical source line. On the C# read path, `using static` constant-pattern suppression is token-aware around `is` / `case`, reconstructs an anchor-aware indexed multi-line window when the anchor lives on a previous line, and keeps trivia-bearing forms such as `value is/*comment*/Red`, `value is\n Red or Blue`, `value is\n // comment\n Red`, `case\n // comment\n Point:`, long `case` / `or` chains, and `case\tRed:` filtered or rescued correctly. Qualified constant/member patterns stay qualifier-driven on that exact-name read path, so an unrelated same-name type such as `class Red {}` no longer cancels suppression for `case Color.Red or Color.Blue:` just because the leaf name matches. The extractor-side pending type-pattern carry now also survives trivia-only separator lines, standalone continuation-line `not`, and multiline `case` heads/logical continuations, so comment-only or `not`-only continuation lines no longer drop the later type head before the real token arrives. Non-type `case` labels such as `case > 0:` and `case not > 0:` do not arm that pending carry, so the next-line call/identifier token stays out of `type_reference`. Same-name type rescue also honors `file` visibility so file-local types only rescue references from the same physical file; inherited protected/public/internal nested types from real base classes rescue derived-class pattern heads only after the base reference is normalized through active type and namespace aliases, and alias-expanded constructed generic bases are canonicalized again before containing-type lookup so `AliasBase = Probe.Base` resolves the same way as `Probe.Base`; implemented interfaces do not contribute inherited nested-type rescue; and same-file `using Namespace;`, project-wide `global using Namespace;`, and active type aliases all participate in the rescue set. The extractor deliberately leaves ambiguous unqualified `using static` heads such as `value is Red` in the DB, because file-local parsing alone cannot know whether another file in the same namespace declares the real `Red` type; the workspace-aware read path is responsible for suppressing the pure constant-only cases. **SQL qualified-name alignment**: SQL definitions still persist their schema-qualified symbol name (`dbo.fn_X`), but graph/`deps`/unused/hotspot readers now resolve each SQL reference row through its stored source-line context, recorded call column, and enclosing container before they compare it to definitions, so qualified `references` / `callers` / `impact` queries stay schema-scoped even when one line contains multiple qualified calls or the lookup is non-exact. Those readers fall back to the bare leaf only when the source site itself is genuinely unqualified, which keeps `deps`, `unused`, and `hotspots` aligned with qualified SQL calls without regressing bare-call support or double-counting `EXEC dbo.fn_Target; EXEC sales.fn_Target;`. Once a row already has a recorded call column, those downstream readers no longer whole-line-upgrade that row to a later qualified token, so trailing comments, string literals, or a second qualified call cannot steal the earlier unqualified edge. Exact SQL graph/dependency readers also preserve the resolved segment count, so a quoted single identifier containing a dot such as `"sales.fn_Target"` stays distinct from the real qualified name `sales.fn_Target` across exact `references` / `callers` / `impact` and aggregate `deps` / `unused` / `hotspots`. SQL CTE body source rows use the raw `cte_body_reference` kind, so `references --kind cte_body_reference` can distinguish anchor/recursive-member internals from outer-query table references. Qualified SQL `callees` queries also keep leaf fallback disabled unless the caller query itself is unqualified, so `callees sales.Caller` no longer widens to `dbo.Caller`. SQL extractors also accept optional whitespace around qualified-name dots, so definitions/calls such as `[sales] . [fn_Target]` and `[dbo] . [fn_Target]` keep their full qualified identity instead of truncating at the first segment. The same SQL no-parens extractor now preserves ANSI / PostgreSQL double-quoted call targets such as `CALL "sales"."proc_name"` and `EXEC "dbo"."fn_Target"` instead of stripping them as string literals, while true single-quoted SQL string literals remain masked. Definition-oriented readers also canonicalize quoted qualified SQL names (`[dbo].[fn_X]` → `dbo.fn_X`) before matching, and they only fall back to the leaf identifier for unqualified queries so exact qualified lookups do not widen to sibling schemas that merely share the same leaf name. Exact SQL definition matching also preserves segment count, so a quoted single identifier that contains a dot (`"sales.fn_Target"`) does not collide with a real qualified name (`sales.fn_Target`). SQL exact graph leaf fallback also stays on the Unicode folded exact path, and both quoted qualified and unqualified Unicode exact definition lookups now use the folded normalized path, so queries such as `dbo.Äpfel` / `dbo.äpfel` and bare `Äpfel` / `äpfel` keep matching leaf call/reference rows such as `äpfel` plus stored definitions such as `[dbo].[Äpfel]` or `dbo.Äpfel` instead of silently degrading to ASCII-only `NOCASE`. Exact multi-name SQL `symbols --count` lookups also bind the folded leaf parameters on that same `_foldReady` path, so Unicode leaf query sets no longer fail with missing-parameter database errors. - **Transitive impact analysis** — `impact` and MCP `impact_analysis` compute the transitive caller chain of a symbol using BFS. Design constraints refined through adversarial review: caller matching uses case-insensitive exact match (`lower() = lower()`) to avoid both substring expansion and case-sensitivity brittleness; symbol names are pre-resolved through definitions with exact-case preference; the read path filters to graph-supported languages to prevent stale edges from removed languages; the definition set used for heuristic fallback must also respect active `--lang` / `--path` / `--exclude-path` / `--exclude-tests` filters and graph-supported languages so out-of-scope or unsupported duplicates do not suppress in-scope hints; fallback eligibility is keyed off class-like definitions only, so same-name namespace/import siblings do not block a single resolved class / struct / interface target, while pure non-callable `namespace` / `import` queries surface `non_callable_symbol_kind` guidance; heuristic file-level hints still return a successful result and encode their non-authoritative status via `impact_mode`, `heuristic`, `hint_count`, and `truncated`; caller rows include `result_kind: "graph"` and heuristic `file_impacts` rows include `result_kind: "file_heuristic"` so clients can distinguish authoritative hop-depth graph results from boundary fallback hints without inferring from list position or depth values; when `truncated` is `true`, the JSON / MCP payload also exposes `truncated_reason` so callers can distinguish actionable cases from runaway-graph cases — `user_limit` means the caller-supplied `--limit` was reached and raising `--limit` will return more results, while `safety_cap` means an internal per-symbol BFS fetch-iteration cap fired (the graph is likely pathological / cyclic and raising `--limit` alone will not help). `impact` / MCP `impact_analysis` also expose `termination_reason` (`completed`, `max_depth_reached`, `cycle_detected`, `row_limit_truncated`, `safety_cap`, or `cancelled`), `cycle_detected`, and `cycles` so caller cycles are distinguishable from natural traversal completion or limit/depth termination (#1883). `safety_cap` outranks `user_limit` whenever both are encountered, and the heuristic file-level hints path is `user_limit`-only because hint truncation is always driven by the caller's `--limit`. The field is omitted whenever `truncated` is `false`. (#1533) `count` / `file_count` now describe the visible returned set while `confirmed_count` / `confirmed_file_count` preserve symbol-level caller totals for heuristic-success payloads, and `impact --json --count` uses the same `*_count` field names as the full payload; to reduce general-name collisions, a file only qualifies for type fallback if it both references one of the candidate member names and also exposes same-file evidence anchoring the source/target pair — either a `call` / `instantiate` reference to the resolved target name (the call-graph itself authoritatively pins the relationship, so this path runs before the metadata-attribute bypass and does not depend on the looser ambiguity guard) or structured type evidence through indexed symbol metadata such as signatures or return types — rather than raw comment/string text matches. The call/instantiate anchor matches the resolved name exactly with no suffix-strip alias, because callable references already carry the authoritative identifier and applying the C# `[Foo]` → `FooAttribute` alias there would let unrelated `Foo()` method calls falsely anchor `impact FooAttribute` (#1881); the metadata bypass keeps the C# `Attribute` suffix alias because attribute use sites legitimately abbreviate the target name. The signature evidence path is Unicode-aware so fullwidth/accented identifiers are tokenized consistently with exact-name resolution; hint `reference_count` reflects the real number of matching reference rows while the symbol list stays deduplicated; only multiple class-like definitions are treated as fallback ambiguity, even when they share one file; and `PurgeUnsupportedReferences` runs in all three indexing paths (CLI full scan, CLI update mode, MCP index). @@ -5265,7 +5265,7 @@ USER_GUIDEの[終了コード](USER_GUIDE.md#終了コード)セクションを `hooks[]` は `callback_budget_ms` を含み、`CDIDX_HOOK_CALLBACK_BUDGET_MS`(既定値: 5000 ms)で強制される post-extraction callback 予算を反映します。hook は結果反映前の scratch copy 上で実行されるため、timeout した callback の変更は破棄されます。 文書化された `status --json` trust contract は `fold_ready`、`fold_ready_reason`、`graph_table_available`、`issues_table_available`、`file_issues_data_current`、`migration_in_progress`、`sql_graph_contract_ready`、`sql_graph_contract_degraded_reason`、`hotspot_family_ready`、`hotspot_family_degraded_reason`、`csharp_symbol_name_ready`、`csharp_metadata_target_ready`、`csharp_metadata_target_degraded_reason`、`indexed_head_commit`、`worktree_head_changed`、`indexed_head_sha`、`indexed_head_branch`、`indexed_head_timestamp`、`commits_ahead_of_indexed_head`、`index_writer_version`、`index_newer_than_reader`、`index_newer_than_reader_reason`、`unknown_extension_file_count`、`unknown_extension_files`、`unknown_extension_files_truncated`、`unknown_extension_file_path_limit`、`extractors`、`path_case_sensitive`、`stale_after_seconds`、`index_age_seconds`、remediation field の `degraded_root_cause`、`degraded_reason`、`recommended_action`、`alternative_action`、`readiness_degradations`、および MCP 専用の `mcp_session` を対象にします。MCP `mcp_session` は永続化された DB 状態ではなく、セッション単位の診断情報で、`log_level`、`roots`、任意の `client_info`、任意の `client_capabilities` を含みます。この一覧は `README.md` と `AGENT_GUIDE.md` に同期してください。いずれかの必須 field がこれらの docs から漏れると `DocumentationStatusContractTests` が失敗します。 -- **再解析不要の folded-key アップグレード** — `backfill-fold` と MCP `backfill_fold` は、既存 DB 行から `name_folded`、明示的 interface 用 `display_name_folded`、reference の `*_folded` を直接再計算し、必要な folded 値に NULL が残っていないことを検証してから `FoldReadyFlag` を stamp する。これにより、pre-#86 DB から AI クライアントやユーザーが低コストで Unicode `--exact` へ上がれる。fold metadata が stale、または C# symbol-name contract が変わった場合は全 folded 行を再生成し、検証成功後にだけ現在の C# contract を stamp する。 +- **再解析不要の folded-key アップグレード** — `backfill-fold` と MCP `backfill_fold` は、既存 DB 行から `name_folded`、明示的 interface 用 `display_name_folded`、reference の `*_folded` を直接再計算し、必要な folded 値に NULL が残っていないことを検証してから `FoldReadyFlag` を stamp する。これにより、pre-#86 DB から AI クライアントやユーザーが低コストで Unicode `--exact` へ上がれる。fold metadata が stale、または C# symbol-name contract が変わった場合は全 folded 行を再生成し、検証成功後にだけ現在の C# contract を stamp する。明示的 interface の v3 contract 更新では、C# の method/property/event 全行に永続 signature が必要であり、復元根拠がない古い DB は false-ready stamp を付けず C# file refresh または rebuild を要求する。 - **まとめて取るシンボル分析** — `inspect` と MCP の `analyze_symbol` は、定義、近傍シンボル、参照、caller、callee、ファイルメタデータ、ワークスペース信頼メタデータ、graph 対応メタデータを1回で返し、AIクライアントが一般的なシンボル調査を少ない往復で終えやすくする。 - **言語考慮の参照抽出** — `references`、`callers`、`callees` は、正規表現ベースの call/reference 抽出が意味を持つ言語だけに対してインデックス化された参照テーブルで支える。未対応言語では、低信頼な疑似グラフ結果を返す代わりにテキスト検索へ戻る前提で設計する。**nested generic 呼び出し**: `new Dictionary>()` のような C#/Java のコンストラクタ呼び出しと、`Helper.DoWork>()` のような C# generic method call は、平坦な regex fast-path で `>>` を釣り合わせられなくても depth-aware fallback scanner で拾い直し、外側 target を参照テーブルへ残す。**コンストラクタ連鎖呼び出し**: C# の `: this(...)` / `: base(...)` イニシャライザと、Java のコンストラクタ本体冒頭文 `this(...)` / `super(...)` は、汎用 call regex とは別に検出し、呼び先が実際のコンストラクタとなるように書き換える(`this` は外側の class/record、`base` / `super` は外側クラスのシグネチャから解析した基底型)。C# のクロス行イニシャライザは外側クラスではなく、そのコンストラクタに紐付ける。基底型の解析は generic 引数、record のプライマリコンストラクタ引数、`where` 制約、`global::` やドット付きの namespace 修飾を剥がす。Java の `super.method()` は通常のメソッド呼び出しのまま扱う。**型位置の依存エッジ**: C#/Java の継承リスト、宣言型、generic 制約、`throws`、`is` / `as` / `instanceof`、および実際の C# XML doc `///` `cref` は `type_reference` 行として索引し、既定の `callers` / `callees` が見せる動的 call graph を汚さずに、`references` / `impact` から compile-time rename 依存を辿れるようにする。**SQL qualified-name alignment**: SQL の graph/dependency reader は、各 reference 行の source-line context、記録済み call 列位置、enclosing container から SQL 参照名を復元して定義と照合するため、qualified な `references` / `callers` / `impact` query は exact / non-exact を問わず sibling schema へ widen しない。source 側が genuinely unqualified な場合にだけ bare leaf fallback を許可するので、qualified call を含む `deps` / `unused` / `hotspots` も schema 単位で整合し、`EXEC dbo.fn_Target; EXEC sales.fn_Target;` のような同一行 multi-call も二重計上しない。列位置が記録されている row は、その列に qualified token が見つからなければ whole-line の別 qualified token へ昇格させないため、行末コメント・文字列リテラル・後続の別 call が先頭の unqualified edge を横取りすることもない。qualified な `callees` query でも caller query 自体が unqualified なとき以外は leaf fallback を無効化したため、`callees sales.Caller` が `dbo.Caller` へ広がらない。SQL extractor は qualified-name の `.` 前後空白も許容し、definition 系 reader は quoted qualified SQL name (`[dbo].[fn_X]` → `dbo.fn_X`) を正規化してから照合する。さらに exact SQL 定義照合は segment 数を保持し、SQL の exact graph leaf fallback は Unicode folded exact path を維持する。SQL CTE 本体内の source 行は raw `cte_body_reference` kind を使うため、`references --kind cte_body_reference` で anchor/recursive member 内部を outer query の table reference と区別できる。そのため、quoted single identifier の衝突や Unicode exact lookup の ASCII-only `NOCASE` 退行も防ぐ。 exact な SQL の graph/dependency reader は解決済み segment 数も保持するため、`"sales.fn_Target"` のようなドット入り quoted single identifier が、本物の qualified name `sales.fn_Target` と exact `references` / `callers` / `impact` や集計系の `deps` / `unused` / `hotspots` で衝突しない。 diff --git a/changelog.d/unreleased/4866.fixed.md b/changelog.d/unreleased/4866.fixed.md index c29ca19b45..cdc77276b0 100644 --- a/changelog.d/unreleased/4866.fixed.md +++ b/changelog.d/unreleased/4866.fixed.md @@ -13,8 +13,8 @@ affected: ## English -- **C# explicit-interface members now retain distinct symbol identities (#4866)** — Exact qualified queries preserve the interface qualifier and generic arity for methods, properties, events, and indexers without merging them with same-named public members. Short display names remain available through a separately indexed Unicode-folded discovery alias across CLI, inspect, outline, and LSP navigation, and `backfill-fold` upgrades the previous C# naming contract without reparsing source. +- **C# explicit-interface members now retain distinct symbol identities (#4866)** — Exact qualified queries preserve the interface qualifier and generic arity for methods, properties, events, and indexers without merging them with same-named public members, including indexer aliases when no language filter is supplied. Short display names remain available through a separately indexed Unicode-folded discovery alias across CLI, inspect, outline, and LSP navigation. `backfill-fold` upgrades the previous C# naming contract without reparsing when persisted signatures are available; legacy databases without that reconstruction evidence remain unready and request a C# refresh instead of receiving a false v3 readiness stamp. ## 日本語 -- **C# の明示的 interface member が個別の symbol identity を保持するようになりました (#4866)** — method、property、event、indexer の完全一致 query で interface qualifier と generic arity を保持し、同名の public member と統合しません。CLI、inspect、outline、LSP navigation では、短い表示名を別途 index 化した Unicode-folded discovery alias として引き続き利用でき、`backfill-fold` は source の再解析なしで以前の C# naming contract を更新します。 +- **C# の明示的 interface member が個別の symbol identity を保持するようになりました (#4866)** — method、property、event、indexer の完全一致 query で interface qualifier と generic arity を保持し、言語 filter 未指定時の indexer alias を含めて同名の public member と統合しません。CLI、inspect、outline、LSP navigation では、短い表示名を別途 index 化した Unicode-folded discovery alias として引き続き利用できます。`backfill-fold` は永続 signature が残る場合に source の再解析なしで以前の C# naming contract を更新し、復元根拠がない legacy DB には誤った v3 readiness stamp を付けず C# refresh を案内します。 diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs b/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs index 687d73399c..e40bf21ed7 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs @@ -579,6 +579,11 @@ internal static int RunBackfillFold( var storedFoldFingerprint = db.GetMetaString("fold_key_fingerprint"); var foldMetadataCurrentBefore = storedFoldVersion == currentFoldVersion && storedFoldFingerprint == currentFoldFingerprint; + var csharpSymbolNameContractUpgradeRequired = !string.Equals( + db.GetMetaString(DbContext.CSharpSymbolNameContractVersionMetaKey), + DbContext.CSharpSymbolNameContractVersion.ToString( + System.Globalization.CultureInfo.InvariantCulture), + StringComparison.Ordinal); foldReadyBefore = foldReadyBefore && foldMetadataCurrentBefore; // Missing or mismatched fold metadata means persisted keys may have been generated // by a different fold algorithm/runtime, so refresh every row from source names. @@ -617,6 +622,17 @@ internal static int RunBackfillFold( "Retry `cdidx backfill-fold`. If the DB still does not verify, rebuild it with `cdidx index --rebuild`.", CommandErrorCodes.DbError); } + if (csharpSymbolNameContractUpgradeRequired + && !writer.CanReconstructCSharpExplicitInterfaceIdentitiesFromPersistedRows()) + { + return WriteCommandError( + options.Json, + jsonOptions, + "C# explicit-interface identities cannot be reconstructed because legacy symbol signatures are missing", + CommandExitCodes.DatabaseError, + "Refresh the C# files with `cdidx index ` (or rebuild the index) before retrying `cdidx backfill-fold`.", + CommandErrorCodes.DbError); + } writer.MarkCSharpSymbolNameContractReady(); transaction.Commit(); diff --git a/src/CodeIndex/Database/DbSymbolReader.Search.cs b/src/CodeIndex/Database/DbSymbolReader.Search.cs index 93e2f3ddf5..e4558cf307 100644 --- a/src/CodeIndex/Database/DbSymbolReader.Search.cs +++ b/src/CodeIndex/Database/DbSymbolReader.Search.cs @@ -1194,7 +1194,8 @@ private static bool ShouldPreserveRustQualifiedExactQuery(string? query, string? return query?.Trim(); if (exact && !string.IsNullOrWhiteSpace(query) - && string.Equals(NormalizeQueryLanguage(lang), "csharp", StringComparison.Ordinal) + && (string.IsNullOrWhiteSpace(lang) + || string.Equals(NormalizeQueryLanguage(lang), "csharp", StringComparison.Ordinal)) && SqlNameResolver.HasQualifier(query)) { return CSharpSymbolNameNormalizer.NormalizeExplicitInterfaceQueryDisplayName(query); diff --git a/src/CodeIndex/Database/DbWriter.FoldBackfill.cs b/src/CodeIndex/Database/DbWriter.FoldBackfill.cs index e353420769..a3473cf324 100644 --- a/src/CodeIndex/Database/DbWriter.FoldBackfill.cs +++ b/src/CodeIndex/Database/DbWriter.FoldBackfill.cs @@ -25,6 +25,40 @@ internal static Action? FoldBackfillVerificationForTesting set => ScopedFoldBackfillVerificationForTesting.Value = value; } + /// + /// A pre-v3 C# naming contract can be upgraded without reparsing only when every symbol kind + /// that may represent an explicit-interface member still has its declaration signature. + /// Without that source evidence, a short legacy name cannot be distinguished from a qualified + /// explicit implementation, so stamping v3 would make the readiness signal untrustworthy. + /// + /// v3 より前の C# naming contract を再解析なしで更新できるのは、明示的 interface member + /// になり得る全 symbol kind に宣言 signature が残っている場合だけである。source evidence + /// がなければ短い legacy 名と修飾済み実装を区別できず、v3 stamp が不正確になる。 + /// + public bool CanReconstructCSharpExplicitInterfaceIdentitiesFromPersistedRows() + { + var command = RentCommand( + """ + SELECT COUNT(*) + FROM symbols s + JOIN files f ON f.id = s.file_id + WHERE f.lang = 'csharp' + AND s.kind IN ('function', 'property', 'event') + AND (s.signature IS NULL OR trim(s.signature) = '') + """, + static _ => { }); + try + { + var raw = command.ExecuteScalar(); + var missing = raw is long value ? value : Convert.ToInt64(raw ?? 0); + return missing == 0; + } + finally + { + ReleaseCommand(command); + } + } + /// /// True only when every existing row in symbols / symbol_references has a populated folded /// value for each source name that is itself non-NULL. Callers use this before stamping diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs b/src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs index 55d95c373c..c888de7bb3 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs @@ -45,6 +45,11 @@ private async Task ExecuteBackfillFoldAsync(JsonNode? id, JsonNode? ar var storedFoldFingerprint = db.GetMetaString("fold_key_fingerprint"); var foldMetadataCurrentBefore = storedFoldVersion == currentFoldVersion && storedFoldFingerprint == currentFoldFingerprint; + var csharpSymbolNameContractUpgradeRequired = !string.Equals( + db.GetMetaString(DbContext.CSharpSymbolNameContractVersionMetaKey), + DbContext.CSharpSymbolNameContractVersion.ToString( + System.Globalization.CultureInfo.InvariantCulture), + StringComparison.Ordinal); foldReadyBefore = foldReadyBefore && foldMetadataCurrentBefore; var force = args?["force"]?.GetValue() ?? false; var rewriteAll = writer.ResolveFoldBackfillRewriteAll( @@ -74,6 +79,13 @@ private async Task ExecuteBackfillFoldAsync(JsonNode? id, JsonNode? ar verified = writer.MarkFoldReady(); if (!verified) return CreateToolErrorResponse(id, "Folded-name backfill verification failed: some rows still have NULL folded values. Re-run backfill_fold."); + if (csharpSymbolNameContractUpgradeRequired + && !writer.CanReconstructCSharpExplicitInterfaceIdentitiesFromPersistedRows()) + { + return CreateToolErrorResponse( + id, + "C# explicit-interface identities cannot be reconstructed because legacy symbol signatures are missing. Refresh the C# files with the index tool (or rebuild the index), then retry backfill_fold."); + } writer.MarkCSharpSymbolNameContractReady(); transaction.Commit(); diff --git a/tests/CodeIndex.Tests/DbReaderSearchTests.cs b/tests/CodeIndex.Tests/DbReaderSearchTests.cs index b6245c64f9..692e1333b9 100644 --- a/tests/CodeIndex.Tests/DbReaderSearchTests.cs +++ b/tests/CodeIndex.Tests/DbReaderSearchTests.cs @@ -2113,6 +2113,24 @@ WHERE signature LIKE 'void IFoo.Run%' Assert.Equal( itemResults.Select(result => result.SymbolId).Order().ToArray(), sourceSpelledItemResults.Select(result => result.SymbolId).Order().ToArray()); + var sourceSpelledItemResultsWithoutLanguage = reader.SearchSymbols( + "IFoo.this", + exact: true); + Assert.Equal( + itemResults.Select(result => result.SymbolId).Order().ToArray(), + sourceSpelledItemResultsWithoutLanguage.Select(result => result.SymbolId).Order().ToArray()); + Assert.Equal( + itemResults.Count, + reader.CountSearchSymbols("IFoo.this", exact: true)); + Assert.Equal( + itemResults.Count, + reader.CountSearchSymbolsTotal("IFoo.this", exact: true).Count); + Assert.Equal( + itemResults.Select(result => result.SymbolId).Order().ToArray(), + reader.GetDefinitions("IFoo.this", exact: true) + .Select(result => result.SymbolId) + .Order() + .ToArray()); var namedItemPropertyResults = reader.SearchSymbols( "IItemContract.Item", @@ -2155,6 +2173,12 @@ WHERE signature LIKE 'void IFoo.Run%' Assert.All( verbatimThisDefinitions, result => Assert.DoesNotContain("IFoo.this[", result.Signature, StringComparison.Ordinal)); + var verbatimThisResultsWithoutLanguage = reader.SearchSymbols( + "IFoo.@this", + exact: true); + Assert.Equal( + verbatimThisResults.Select(result => result.SymbolId).Order().ToArray(), + verbatimThisResultsWithoutLanguage.Select(result => result.SymbolId).Order().ToArray()); var qualifiedService = Assert.Single(reader.SearchSymbols( "Demo.Service", lang: "csharp", diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 0839055546..9a39538805 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -6459,6 +6459,79 @@ FROM symbols } } + [Fact] + public void RunBackfillFold_RefusesCSharpV3StampWhenLegacySignaturesAreMissing_Issue4866() + { + var dbPath = CreateTempDbPath("cdidx_backfill_fold_csharp_v2_missing_signature"); + try + { + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + db.InitializeSchema(); + var writer = new DbWriter(db.Connection); + var fileId = writer.UpsertFile(new FileRecord + { + Path = "src/LegacyExplicit.cs", + Lang = "csharp", + Size = 32, + Lines = 1, + Modified = new DateTime(2026, 7, 29, 0, 0, 0, DateTimeKind.Utc), + }); + writer.InsertSymbols([ + new SymbolRecord + { + FileId = fileId, + Kind = "function", + Name = "Run", + Signature = null, + Line = 1, + StartLine = 1, + EndLine = 1, + }, + ]); + writer.SetMeta(DbContext.CSharpSymbolNameContractVersionMetaKey, "2"); + Assert.False( + writer.CanReconstructCSharpExplicitInterfaceIdentitiesFromPersistedRows()); + } + + string outputText; + int exitCode; + lock (TestConsoleLock.Gate) + { + var originalOut = Console.Out; + using var output = new StringWriter(); + try + { + Console.SetOut(output); + exitCode = IndexCommandRunner.RunBackfillFold( + ["--db", dbPath, "--json"], + _jsonOptions); + outputText = output.ToString(); + } + finally + { + Console.SetOut(originalOut); + } + } + + Assert.Equal(CommandExitCodes.DatabaseError, exitCode); + Assert.Contains( + "C# explicit-interface identities cannot be reconstructed", + outputText, + StringComparison.Ordinal); + + using var verifyDb = new DbContext(DbOpenIntent.WriteIndex, dbPath); + Assert.Equal( + "2", + verifyDb.GetMetaString(DbContext.CSharpSymbolNameContractVersionMetaKey)); + } + finally + { + SqliteConnection.ClearAllPools(); + DeleteFile(dbPath); + } + } + [Fact] public void RunBackfillFold_DryRunReportsEffectiveFoldReadyWhenMetadataStale() { diff --git a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs index dfb08855dd..6f32f6afaf 100644 --- a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs @@ -11787,6 +11787,46 @@ public void ToolsCall_BackfillFold_StampsFoldReady() Assert.True(reader._foldReady); } + [Fact] + public void ToolsCall_BackfillFold_RefusesCSharpV3StampWhenLegacySignaturesAreMissing_Issue4866() + { + var writer = new DbWriter(_db.Connection); + var fileId = writer.UpsertFile(new FileRecord + { + Path = "src/legacy-explicit-4866.cs", + Lang = "csharp", + Size = 32, + Lines = 1, + Modified = new DateTime(2026, 7, 29, 0, 0, 0, DateTimeKind.Utc), + }); + writer.InsertSymbols([ + new SymbolRecord + { + FileId = fileId, + Kind = "function", + Name = "Run", + Signature = null, + Line = 1, + StartLine = 1, + EndLine = 1, + }, + ]); + writer.SetMeta(DbContext.CSharpSymbolNameContractVersionMetaKey, "2"); + + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":4866,"method":"tools/call","params":{"name":"backfill_fold","arguments":{}}}""")!; + var response = _server.HandleMessage(request)!; + + Assert.True(response["result"]!["isError"]?.GetValue() ?? false); + var text = response["result"]!["content"]![0]!["text"]!.GetValue(); + Assert.Contains( + "C# explicit-interface identities cannot be reconstructed", + text, + StringComparison.Ordinal); + Assert.Equal( + "2", + _db.GetMetaString(DbContext.CSharpSymbolNameContractVersionMetaKey)); + } + [Fact] public void ToolsCall_BackfillFold_ExceptionUsesSanitizedToolError_Issue3201() { From 2b629eb4c3db89020f4b89f8ba9d62bc617c41b6 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 13:07:40 +0900 Subject: [PATCH 09/18] Fix final explicit-interface review regressions (#4866) --- .../Database/DbSymbolReader.Definitions.cs | 8 +- .../Database/DbSymbolReader.Search.cs | 103 ++++++++--- src/CodeIndex/Database/DbWriter.References.cs | 1 + .../Symbols/CSharpSymbolNameNormalizer.cs | 63 +++++-- tests/CodeIndex.Tests/DbReaderSearchTests.cs | 160 +++++++++++++++++- .../IndexCommandRunnerTests.cs | 22 ++- 6 files changed, 318 insertions(+), 39 deletions(-) diff --git a/src/CodeIndex/Database/DbSymbolReader.Definitions.cs b/src/CodeIndex/Database/DbSymbolReader.Definitions.cs index 5002295ea3..6f17add2a1 100644 --- a/src/CodeIndex/Database/DbSymbolReader.Definitions.cs +++ b/src/CodeIndex/Database/DbSymbolReader.Definitions.cs @@ -344,11 +344,11 @@ FROM symbols s : " AND ((s.container_qualified_name = @queryRustContainer COLLATE NOCASE OR s.container_name = @queryRustContainer COLLATE NOCASE) AND s.name = @queryRustLeaf COLLATE NOCASE)" : _foldReady ? allowLeafFallback - ? $" AND ({BuildPersistedFoldedNameMatchSql("s.name_folded", "@query")} OR {csharpExplicitInterfaceClause} OR {markdownAnchorExactClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name_folded(s.name) = @queryNormalizedFolded) OR sql_leaf_name_folded(s.name) = @queryLeafFolded)))" - : $" AND ({BuildPersistedFoldedNameMatchSql("s.name_folded", "@query")} OR {csharpExplicitInterfaceClause} OR {markdownAnchorExactClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name_folded(s.name) = @queryNormalizedFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" + ? $" AND ({BuildExactPrimarySymbolNameMatchSql("@query", true, normalizedQuery, lang)} OR {csharpExplicitInterfaceClause} OR {markdownAnchorExactClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name_folded(s.name) = @queryNormalizedFolded) OR sql_leaf_name_folded(s.name) = @queryLeafFolded)))" + : $" AND ({BuildExactPrimarySymbolNameMatchSql("@query", true, normalizedQuery, lang)} OR {csharpExplicitInterfaceClause} OR {markdownAnchorExactClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name_folded(s.name) = @queryNormalizedFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" : allowLeafFallback - ? $" AND (s.name = @query COLLATE NOCASE OR {markdownAnchorExactClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name(s.name) = @queryNormalized COLLATE NOCASE) OR sql_leaf_name(s.name) = @queryLeaf COLLATE NOCASE)))" - : $" AND (s.name = @query COLLATE NOCASE OR {markdownAnchorExactClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name(s.name) = @queryNormalized COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" + ? $" AND ({BuildExactPrimarySymbolNameMatchSql("@query", false, normalizedQuery, lang)} OR {markdownAnchorExactClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name(s.name) = @queryNormalized COLLATE NOCASE) OR sql_leaf_name(s.name) = @queryLeaf COLLATE NOCASE)))" + : $" AND ({BuildExactPrimarySymbolNameMatchSql("@query", false, normalizedQuery, lang)} OR {markdownAnchorExactClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name(s.name) = @queryNormalized COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" : $" AND (s.name LIKE @query ESCAPE '\\'{markdownAnchorLikeClause} OR (f.lang = 'sql' AND sql_normalize_name(s.name) LIKE @queryNormalizedLike ESCAPE '\\'){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause} OR {csharpExplicitInterfaceClause}" : string.Empty)})"; } if (kind != null) diff --git a/src/CodeIndex/Database/DbSymbolReader.Search.cs b/src/CodeIndex/Database/DbSymbolReader.Search.cs index e4558cf307..50f882ba8e 100644 --- a/src/CodeIndex/Database/DbSymbolReader.Search.cs +++ b/src/CodeIndex/Database/DbSymbolReader.Search.cs @@ -131,13 +131,39 @@ private string BuildQualifiedSymbolMatchSql(string parameterStem, bool useFolded var containerNameSql = GetSymbolColumnSql("container_name", "''", symbolAlias); var containerQualifiedNameSql = GetSymbolColumnSql("container_qualified_name", containerNameSql, symbolAlias); var nameMatchSql = useFoldedName - ? $"{symbolAlias}.name_folded = @{parameterStem}LeafFolded" - : $"{symbolAlias}.name = @{parameterStem}Leaf COLLATE NOCASE"; + ? $"{symbolAlias}.name_folded = @{parameterStem}CSharpLeafFolded" + : $"{symbolAlias}.name = @{parameterStem}CSharpLeaf COLLATE NOCASE"; + var qualifiedNameMatchSql = useFoldedName + ? $"{symbolAlias}.name_folded = @{parameterStem}CSharpQualifiedFolded" + : $"{symbolAlias}.name = @{parameterStem}CSharpQualified COLLATE NOCASE"; return $@"({fileAlias}.lang = 'csharp' - AND {nameMatchSql} - AND ({containerNameSql} = @{parameterStem}Container COLLATE NOCASE - OR {containerQualifiedNameSql} = @{parameterStem}Container COLLATE NOCASE - OR {containerQualifiedNameSql} COLLATE NOCASE LIKE @{parameterStem}ContainerSuffixLike ESCAPE '\'))"; + AND ({qualifiedNameMatchSql} + OR ({nameMatchSql} + AND ({containerNameSql} = @{parameterStem}Container COLLATE NOCASE + OR {containerQualifiedNameSql} = @{parameterStem}Container COLLATE NOCASE + OR {containerQualifiedNameSql} COLLATE NOCASE LIKE @{parameterStem}ContainerSuffixLike ESCAPE '\'))))"; + } + + private string BuildExactPrimarySymbolNameMatchSql( + string parameterSql, + bool useFoldedName, + string query, + string? lang) + { + var matchSql = useFoldedName + ? BuildPersistedFoldedNameMatchSql("s.name_folded", parameterSql) + : $"s.name = {parameterSql} COLLATE NOCASE"; + if (!string.IsNullOrWhiteSpace(lang) || !SqlNameResolver.HasQualifier(query)) + return matchSql; + + // Preserve direct qualified matching for ordinary and legacy C# rows. Only v3 + // explicit-interface rows have a display alias; those rows must use the C# identity + // clause so `IFoo.this` cannot also match a distinct `IFoo.@this` implementation. + // 通常および legacy C# row の修飾直接一致は維持する。表示 alias を持つ v3 の + // 明示的 interface row だけを C# identity 条件へ限定し、`IFoo.this` が別の + // `IFoo.@this` 実装にも一致しないようにする。 + var displayNameFoldedSql = GetSymbolColumnSql("display_name_folded", "NULL"); + return $"((f.lang <> 'csharp' OR {displayNameFoldedSql} IS NULL) AND {matchSql})"; } private string BuildCSharpExplicitInterfaceIdentityMatchSql( @@ -198,9 +224,25 @@ private static string GetQualifiedQueryLeaf(string query, string? lang) private static void AddQualifiedSymbolQueryParameters(SqliteCommand cmd, string parameterStem, string query) { - var container = GetQualifiedQueryContainer(query); + var csharpDisplayQuery = + CSharpSymbolNameNormalizer.NormalizeExplicitInterfaceQueryDisplayName(query); + var csharpQualifiedQuery = + NormalizeCSharpVerbatimQuery(csharpDisplayQuery, "csharp") + ?? csharpDisplayQuery; + var container = GetQualifiedQueryContainer(csharpQualifiedQuery); + var csharpLeaf = GetQualifiedQueryLeaf(csharpQualifiedQuery, "csharp"); SqliteCommandPolicy.Add(cmd, $"@{parameterStem}Container", container); SqliteCommandPolicy.Add(cmd, $"@{parameterStem}ContainerSuffixLike", $"%.{EscapeLikeQuery(container)}"); + SqliteCommandPolicy.Add(cmd, $"@{parameterStem}CSharpQualified", csharpQualifiedQuery); + SqliteCommandPolicy.Add( + cmd, + $"@{parameterStem}CSharpQualifiedFolded", + NameFold.Fold(csharpQualifiedQuery) ?? csharpQualifiedQuery); + SqliteCommandPolicy.Add(cmd, $"@{parameterStem}CSharpLeaf", csharpLeaf); + SqliteCommandPolicy.Add( + cmd, + $"@{parameterStem}CSharpLeafFolded", + NameFold.Fold(csharpLeaf) ?? csharpLeaf); } private bool HasSingleQualifiedSymbolDefinition(string query, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests) @@ -286,11 +328,11 @@ FROM symbols s : " AND ((s.container_qualified_name = @query0RustContainer COLLATE NOCASE OR s.container_name = @query0RustContainer COLLATE NOCASE) AND s.name = @query0RustLeaf COLLATE NOCASE)" : _foldReady ? allowLeafFallback - ? $" AND ({BuildPersistedFoldedNameMatchSql("s.name_folded", "@query0")} OR {csharpExplicitInterfaceClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name_folded(s.name) = @query0NormalizedFolded) OR sql_leaf_name_folded(s.name) = @query0LeafFolded)))" - : $" AND ({BuildPersistedFoldedNameMatchSql("s.name_folded", "@query0")} OR {csharpExplicitInterfaceClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name_folded(s.name) = @query0NormalizedFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" + ? $" AND ({BuildExactPrimarySymbolNameMatchSql("@query0", true, validQueries[0], lang)} OR {csharpExplicitInterfaceClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name_folded(s.name) = @query0NormalizedFolded) OR sql_leaf_name_folded(s.name) = @query0LeafFolded)))" + : $" AND ({BuildExactPrimarySymbolNameMatchSql("@query0", true, validQueries[0], lang)} OR {csharpExplicitInterfaceClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name_folded(s.name) = @query0NormalizedFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" : allowLeafFallback - ? " AND (s.name = @query0 COLLATE NOCASE OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name(s.name) = @query0Normalized COLLATE NOCASE) OR sql_leaf_name(s.name) = @query0Leaf COLLATE NOCASE)))" - : $" AND (s.name = @query0 COLLATE NOCASE OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name(s.name) = @query0Normalized COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" + ? $" AND ({BuildExactPrimarySymbolNameMatchSql("@query0", false, validQueries[0], lang)} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name(s.name) = @query0Normalized COLLATE NOCASE) OR sql_leaf_name(s.name) = @query0Leaf COLLATE NOCASE)))" + : $" AND ({BuildExactPrimarySymbolNameMatchSql("@query0", false, validQueries[0], lang)} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name(s.name) = @query0Normalized COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" : $" AND (s.name LIKE @query0 ESCAPE '\\' OR (f.lang = 'sql' AND sql_normalize_name(s.name) LIKE @query0NormalizedLike ESCAPE '\\'){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause} OR {csharpExplicitInterfaceClause}" : string.Empty)})"; } if (kind != null) @@ -407,11 +449,11 @@ FROM symbols s : $"((s.container_qualified_name = @query{idx}RustContainer COLLATE NOCASE OR s.container_name = @query{idx}RustContainer COLLATE NOCASE) AND s.name = @query{idx}RustLeaf COLLATE NOCASE)"; return _foldReady ? allowLeafFallback - ? $"({BuildPersistedFoldedNameMatchSql("s.name_folded", $"@query{idx}")}{swiftBacktickClause} OR {csharpExplicitInterfaceClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded) OR sql_leaf_name_folded(s.name) = @query{idx}LeafFolded)))" - : $"({BuildPersistedFoldedNameMatchSql("s.name_folded", $"@query{idx}")}{swiftBacktickClause} OR {csharpExplicitInterfaceClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" + ? $"({BuildExactPrimarySymbolNameMatchSql($"@query{idx}", true, queryValue, lang)}{swiftBacktickClause} OR {csharpExplicitInterfaceClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded) OR sql_leaf_name_folded(s.name) = @query{idx}LeafFolded)))" + : $"({BuildExactPrimarySymbolNameMatchSql($"@query{idx}", true, queryValue, lang)}{swiftBacktickClause} OR {csharpExplicitInterfaceClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" : allowLeafFallback - ? $"(s.name = @query{idx} COLLATE NOCASE{swiftBacktickClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE) OR sql_leaf_name(s.name) = @query{idx}Leaf COLLATE NOCASE)))" - : $"(s.name = @query{idx} COLLATE NOCASE{swiftBacktickClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})"; + ? $"({BuildExactPrimarySymbolNameMatchSql($"@query{idx}", false, queryValue, lang)}{swiftBacktickClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE) OR sql_leaf_name(s.name) = @query{idx}Leaf COLLATE NOCASE)))" + : $"({BuildExactPrimarySymbolNameMatchSql($"@query{idx}", false, queryValue, lang)}{swiftBacktickClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})"; })) : string.Join(" OR ", effectiveQueries.Select((queryValue, idx) => { @@ -723,11 +765,11 @@ FROM symbols s : $"((s.container_qualified_name = @query{idx}RustContainer COLLATE NOCASE OR s.container_name = @query{idx}RustContainer COLLATE NOCASE) AND s.name = @query{idx}RustLeaf COLLATE NOCASE)"; return _foldReady ? allowLeafFallback - ? $"({BuildPersistedFoldedNameMatchSql("s.name_folded", $"@query{idx}")}{swiftBacktickClause} OR {csharpExplicitInterfaceClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded) OR sql_leaf_name_folded(s.name) = @query{idx}LeafFolded)))" - : $"({BuildPersistedFoldedNameMatchSql("s.name_folded", $"@query{idx}")}{swiftBacktickClause} OR {csharpExplicitInterfaceClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" + ? $"({BuildExactPrimarySymbolNameMatchSql($"@query{idx}", true, queryValue, lang)}{swiftBacktickClause} OR {csharpExplicitInterfaceClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded) OR sql_leaf_name_folded(s.name) = @query{idx}LeafFolded)))" + : $"({BuildExactPrimarySymbolNameMatchSql($"@query{idx}", true, queryValue, lang)}{swiftBacktickClause} OR {csharpExplicitInterfaceClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" : allowLeafFallback - ? $"(s.name = @query{idx} COLLATE NOCASE{swiftBacktickClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE) OR sql_leaf_name(s.name) = @query{idx}Leaf COLLATE NOCASE)))" - : $"(s.name = @query{idx} COLLATE NOCASE{swiftBacktickClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})"; + ? $"({BuildExactPrimarySymbolNameMatchSql($"@query{idx}", false, queryValue, lang)}{swiftBacktickClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE) OR sql_leaf_name(s.name) = @query{idx}Leaf COLLATE NOCASE)))" + : $"({BuildExactPrimarySymbolNameMatchSql($"@query{idx}", false, queryValue, lang)}{swiftBacktickClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})"; })) : string.Join(" OR ", effectiveQueries.Select((queryValue, idx) => { @@ -1194,11 +1236,28 @@ private static bool ShouldPreserveRustQualifiedExactQuery(string? query, string? return query?.Trim(); if (exact && !string.IsNullOrWhiteSpace(query) - && (string.IsNullOrWhiteSpace(lang) - || string.Equals(NormalizeQueryLanguage(lang), "csharp", StringComparison.Ordinal)) && SqlNameResolver.HasQualifier(query)) { - return CSharpSymbolNameNormalizer.NormalizeExplicitInterfaceQueryDisplayName(query); + if (string.Equals( + NormalizeQueryLanguage(lang), + "csharp", + StringComparison.Ordinal)) + { + return CSharpSymbolNameNormalizer.NormalizeExplicitInterfaceQueryDisplayName(query); + } + + // Without a language filter the query must retain its original spelling for + // non-C# exact matching. C#-specific display and identity aliases are supplied + // through their own SQL parameters. + // 言語フィルターがない場合、C# 以外の完全一致を保つため query の元表記を + // 維持する。C# 専用の表示名・identity alias は個別の SQL parameter で渡す。 + if (string.IsNullOrWhiteSpace(lang)) + { + var terraformNormalized = NormalizeTerraformDottedQuery(query, lang); + if (terraformNormalized != null) + return terraformNormalized; + return query.Trim(); + } } return NormalizeSymbolSearchQuery(query, lang, exact) ?? query; diff --git a/src/CodeIndex/Database/DbWriter.References.cs b/src/CodeIndex/Database/DbWriter.References.cs index e6e1c87511..14ad08c903 100644 --- a/src/CodeIndex/Database/DbWriter.References.cs +++ b/src/CodeIndex/Database/DbWriter.References.cs @@ -81,6 +81,7 @@ FROM symbols AS s AND r.container_name IS NOT NULL AND r.container_name <> '' AND (s.name_folded = r.container_name_folded + OR s.display_name_folded = r.container_name_folded OR (s.name_folded IS NULL AND s.name = r.container_name COLLATE NOCASE)) AND r.line BETWEEN COALESCE(s.start_line, s.line) AND COALESCE(s.end_line, s.line) ORDER BY (COALESCE(s.end_line, s.line) - COALESCE(s.start_line, s.line)), diff --git a/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs b/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs index 9c5418dff3..b7c9974e73 100644 --- a/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs +++ b/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs @@ -137,7 +137,9 @@ public static string Normalize(string name, Match match, string matchLine) var hasQualifierDot = cursorBeforeMember >= 0 && signature[cursorBeforeMember] == '.'; var isVerbatimIndexerSpelling = isIndexer && memberTokenStart < memberIndex && signature[memberTokenStart] == '@'; - if (!hasIdentifierBoundary || !hasQualifierDot || isVerbatimIndexerSpelling) + if (!hasIdentifierBoundary + || isVerbatimIndexerSpelling + || !IsDeclarationHeaderTopLevel(signature, memberTokenStart)) { searchStart = memberTokenEnd; continue; @@ -146,22 +148,33 @@ public static string Normalize(string name, Match match, string matchLine) var cursor = memberTokenEnd; while (cursor < signature.Length && char.IsWhiteSpace(signature[cursor])) cursor++; - if (TryReadExplicitInterfaceMemberArity( + if (!TryReadExplicitInterfaceMemberArity( signature, cursor, isIndexer, kind, out var arity)) { - var qualifierEnd = cursorBeforeMember; - while (qualifierEnd > 0 && char.IsWhiteSpace(signature[qualifierEnd - 1])) - qualifierEnd--; - var qualifierStart = FindExplicitInterfaceQualifierStart(signature, qualifierEnd); - if (qualifierStart < qualifierEnd) - { - var qualifier = NormalizeTypeDisplayName(signature[qualifierStart..qualifierEnd]); - return BuildExplicitInterfaceIdentityNameFolded(qualifier, name, arity); - } + searchStart = memberTokenEnd; + continue; + } + + // A valid unqualified declaration token is the persisted row's real member. + // Do not scan into its parameter attributes/defaults or constraints for a later + // same-named qualified invocation or type. + // 有効な非修飾 declaration token は永続 row の実メンバーである。引数の + // attribute/default や制約内にある同名の修飾呼び出し・型まで探索しない。 + if (!hasQualifierDot) + return null; + + var qualifierEnd = cursorBeforeMember; + while (qualifierEnd > 0 && char.IsWhiteSpace(signature[qualifierEnd - 1])) + qualifierEnd--; + var qualifierStart = FindExplicitInterfaceQualifierStart(signature, qualifierEnd); + if (qualifierStart < qualifierEnd) + { + var qualifier = NormalizeTypeDisplayName(signature[qualifierStart..qualifierEnd]); + return BuildExplicitInterfaceIdentityNameFolded(qualifier, name, arity); } searchStart = memberTokenEnd; @@ -170,6 +183,34 @@ public static string Normalize(string name, Match match, string matchLine) return null; } + private static bool IsDeclarationHeaderTopLevel(string signature, int tokenStart) + { + var parenthesisDepth = 0; + var bracketDepth = 0; + for (var i = 0; i < tokenStart; i++) + { + switch (signature[i]) + { + case '(': + parenthesisDepth++; + break; + case ')': + if (parenthesisDepth > 0) + parenthesisDepth--; + break; + case '[': + bracketDepth++; + break; + case ']': + if (bracketDepth > 0) + bracketDepth--; + break; + } + } + + return parenthesisDepth == 0 && bracketDepth == 0; + } + private static int FindDeclarationBodyStart(string signature) { var expressionBodyStart = signature.IndexOf("=>", StringComparison.Ordinal); diff --git a/tests/CodeIndex.Tests/DbReaderSearchTests.cs b/tests/CodeIndex.Tests/DbReaderSearchTests.cs index 692e1333b9..2e8d79e99b 100644 --- a/tests/CodeIndex.Tests/DbReaderSearchTests.cs +++ b/tests/CodeIndex.Tests/DbReaderSearchTests.cs @@ -1904,7 +1904,7 @@ public interface IItemContract public sealed class Service : IFoo, IBar, IItemContract { - void IFoo.Run(TValue value) { } + void IFoo.Run(TValue value) { ExplicitHelper(); } void IBar.Run(TLeft left, TRight right) { } int IFoo.Value => 1; event System.EventHandler IFoo . Changed { add { } remove { } } @@ -1913,6 +1913,7 @@ event System.EventHandler IFoo . Changed { add { } remove { } } void IFoo.Ä() { } void IFoo.@this() { } public void Run(T value) { } + public void ExplicitHelper() { } public void CallPublicRun() { Run(1); } } @@ -2014,6 +2015,16 @@ FROM symbols s "Run", "void Run() where T : IFoo.Run { }", "function")); + Assert.Null(CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( + "Run", + "public void Run([Foo.Run()] int value) { }", + "function")); + Assert.Equal( + "ifoo.run", + CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( + "Run", + "[Run()] void IFoo.Run()", + "function")); Assert.Equal( "ifoo.run`1", CSharpSymbolNameNormalizer.BuildExplicitInterfaceIdentityNameFolded( @@ -2075,6 +2086,15 @@ WHERE signature LIKE 'void IFoo.Run%' lang: "csharp", exact: true)); Assert.Equal(fooRun.SymbolId, sameArityAlias.SymbolId); + using (var sourceIdentity = db.Connection.CreateCommand()) + { + sourceIdentity.CommandText = """ + SELECT source_symbol_id + FROM symbol_references + WHERE symbol_name = 'ExplicitHelper' + """; + Assert.Equal(fooRun.SymbolId, sourceIdentity.ExecuteScalar()); + } var barRun = Assert.Single(reader.SearchSymbols( "IBar.Run", @@ -2179,6 +2199,45 @@ WHERE signature LIKE 'void IFoo.Run%' Assert.Equal( verbatimThisResults.Select(result => result.SymbolId).Order().ToArray(), verbatimThisResultsWithoutLanguage.Select(result => result.SymbolId).Order().ToArray()); + + const string sqlPath = "src/qualified-function.sql"; + var sqlFileId = writer.UpsertFile(new FileRecord + { + Path = sqlPath, + Lang = "sql", + Size = 32, + Lines = 1, + Modified = new DateTime(2026, 7, 29, 0, 0, 0, DateTimeKind.Utc), + }); + writer.InsertChunks([ + new ChunkRecord + { + FileId = sqlFileId, + ChunkIndex = 0, + StartLine = 1, + EndLine = 1, + Content = "CREATE FUNCTION foo.this();", + }, + ]); + writer.InsertSymbols([ + new SymbolRecord + { + FileId = sqlFileId, + Kind = "function", + Name = "foo.this", + Line = 1, + StartLine = 1, + EndLine = 1, + Signature = "CREATE FUNCTION foo.this();", + }, + ]); + writer.BackfillFoldedColumns(rewriteAll: true); + + var sqlQualified = Assert.Single(reader.SearchSymbols("foo.this", exact: true)); + Assert.Equal(sqlPath, sqlQualified.Path); + Assert.Equal(1, reader.CountSearchSymbolsTotal("foo.this", exact: true).Count); + Assert.Equal(sqlPath, Assert.Single(reader.GetDefinitions("foo.this", exact: true)).Path); + var qualifiedService = Assert.Single(reader.SearchSymbols( "Demo.Service", lang: "csharp", @@ -2204,6 +2263,8 @@ WHERE signature LIKE 'void IFoo.Run%' Assert.Single(analysis.Definitions); Assert.Equal(fooRun.SymbolId, analysis.Definitions[0].SymbolId); Assert.Empty(analysis.References); + var explicitCallee = Assert.Single(analysis.Callees); + Assert.Equal("ExplicitHelper", explicitCallee.CalleeName); Assert.Empty(reader.SearchReferences( "IFoo.Run", lang: "csharp", @@ -2225,6 +2286,103 @@ WHERE signature LIKE 'void IFoo.Run%' && symbol.Signature?.Contains("IFoo . this", StringComparison.Ordinal) == true); } + [Fact] + public void SearchSymbols_QualifiedExactWithoutLanguagePreservesLegacyCSharpAndTerraform_Issue4866Review() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_explicit_interface_cross_language_4866"); + var dbPath = Path.Combine(project.Root, "codeindex.db"); + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + db.InitializeSchema(); + var writer = new DbWriter(db.Connection); + var csharpFileId = writer.UpsertFile(new FileRecord + { + Path = "src/Using.cs", + Lang = "csharp", + Size = 25, + Lines = 1, + Modified = new DateTime(2026, 7, 29, 0, 0, 0, DateTimeKind.Utc), + }); + writer.InsertChunks([ + new ChunkRecord + { + FileId = csharpFileId, + ChunkIndex = 0, + StartLine = 1, + EndLine = 1, + Content = "using CodeIndex.Database; public sealed class Service { }", + }, + ]); + var terraformFileId = writer.UpsertFile(new FileRecord + { + Path = "infra/main.tf", + Lang = "terraform", + Size = 20, + Lines = 1, + Modified = new DateTime(2026, 7, 29, 0, 0, 0, DateTimeKind.Utc), + }); + writer.InsertSymbols([ + new SymbolRecord + { + FileId = csharpFileId, + Kind = "import", + Name = "CodeIndex.Database", + Signature = "using CodeIndex.Database;", + Line = 1, + StartLine = 1, + EndLine = 1, + }, + new SymbolRecord + { + FileId = csharpFileId, + Kind = "class", + Name = "Service", + Signature = "public sealed class Service", + ContainerKind = "namespace", + ContainerName = "Demo", + ContainerQualifiedName = "Demo", + Line = 1, + StartLine = 1, + EndLine = 1, + }, + new SymbolRecord + { + FileId = terraformFileId, + Kind = "function", + Name = "region", + Signature = """variable "region" {}""", + Line = 1, + StartLine = 1, + EndLine = 1, + }, + ]); + writer.BackfillFoldedColumns(rewriteAll: true); + Assert.True(writer.MarkFoldReady()); + writer.SetMeta(DbContext.CSharpSymbolNameContractVersionMetaKey, "2"); + + using var reader = new DbReader(db.Connection); + Assert.Equal( + "src/Using.cs", + Assert.Single(reader.SearchSymbols("CodeIndex.Database", exact: true)).Path); + Assert.Equal( + "src/Using.cs", + Assert.Single(reader.SearchSymbols("global::CodeIndex.Database", exact: true)).Path); + Assert.Equal( + "src/Using.cs", + Assert.Single(reader.SearchSymbols("@CodeIndex.@Database", exact: true)).Path); + Assert.Equal( + "src/Using.cs", + Assert.Single(reader.SearchSymbols("global::Demo.Service", exact: true)).Path); + Assert.Equal( + "src/Using.cs", + Assert.Single(reader.SearchSymbols("@Demo.@Service", exact: true)).Path); + Assert.Equal( + "src/Using.cs", + Assert.Single(reader.GetDefinitions("global::Demo.Service", exact: true)).Path); + Assert.Equal( + "infra/main.tf", + Assert.Single(reader.SearchSymbols("var.region", exact: true)).Path); + } + [Fact] public void SearchSymbols_ReturnsRichMetadataWhenAvailable() { diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 9a39538805..a050d176bb 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -6379,6 +6379,16 @@ public void RunBackfillFold_RewritesPreviousCSharpExplicitInterfaceIdentityContr StartLine = 1, EndLine = 1, }, + new SymbolRecord + { + FileId = fileId, + Kind = "function", + Name = "Execute", + Signature = "public void Execute([Foo.Execute()] int value) { }", + Line = 1, + StartLine = 1, + EndLine = 1, + }, ]); writer.SetMeta(DbContext.CSharpSymbolNameContractVersionMetaKey, "2"); } @@ -6406,7 +6416,7 @@ public void RunBackfillFold_RewritesPreviousCSharpExplicitInterfaceIdentityContr Assert.Equal(CommandExitCodes.Success, exitCode); Assert.True(json.GetProperty("rewrite_all").GetBoolean()); - Assert.Equal(3, json.GetProperty("symbols").GetInt32()); + Assert.Equal(4, json.GetProperty("symbols").GetInt32()); Assert.True(json.GetProperty("verified").GetBoolean()); using var verifyDb = new DbContext(DbOpenIntent.WriteIndex, dbPath); @@ -6441,6 +6451,16 @@ FROM symbols Assert.True(ordinaryTypeIdentityReader.Read()); Assert.Equal("runner", ordinaryTypeIdentityReader.GetString(0)); Assert.True(ordinaryTypeIdentityReader.IsDBNull(1)); + using var ordinaryMethodIdentity = verifyDb.Connection.CreateCommand(); + ordinaryMethodIdentity.CommandText = """ + SELECT name_folded, display_name_folded + FROM symbols + WHERE name = 'Execute' + """; + using var ordinaryMethodIdentityReader = ordinaryMethodIdentity.ExecuteReader(); + Assert.True(ordinaryMethodIdentityReader.Read()); + Assert.Equal("execute", ordinaryMethodIdentityReader.GetString(0)); + Assert.True(ordinaryMethodIdentityReader.IsDBNull(1)); using var reader = new DbReader(verifyDb.Connection); Assert.Single(reader.SearchSymbols( From bb94b600fe202ccc00d0b5e066c2d71473c2baf7 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 13:53:48 +0900 Subject: [PATCH 10/18] Harden explicit-interface backfill compatibility (#4866) --- .../Cli/IndexCommandRunner.Maintenance.cs | 14 +- .../Database/DbWriter.FoldBackfill.cs | 27 +++- .../Symbols/CSharpSymbolNameNormalizer.cs | 7 +- src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs | 10 +- tests/CodeIndex.Tests/DbReaderSearchTests.cs | 7 + .../IndexCommandRunnerTests.cs | 151 ++++++++++++++++++ .../McpServerToolsCallTests.cs | 49 ++++++ .../SymbolExtractorCSharpTests.cs | 7 + 8 files changed, 266 insertions(+), 6 deletions(-) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs b/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs index e40bf21ed7..67f1af8490 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs @@ -570,6 +570,16 @@ internal static int RunBackfillFold( if (!options.DryRun) db.InitializeSchema(); var writer = new DbWriter(db); + if (writer.TryGetNewerCSharpSymbolNameContractVersion(out var newerCSharpContract)) + { + return WriteCommandError( + options.Json, + jsonOptions, + $"C# symbol-name contract version {newerCSharpContract} is newer than supported version {DbContext.CSharpSymbolNameContractVersion}", + CommandExitCodes.DatabaseError, + "Use the same or a newer CodeIndex version that wrote this database; this version will not rewrite or downgrade its C# identities.", + CommandErrorCodes.DbError); + } var userVersionBefore = db.GetUserVersion(); var foldReadyBefore = (userVersionBefore & DbContext.FoldReadyFlag) != 0; @@ -579,7 +589,9 @@ internal static int RunBackfillFold( var storedFoldFingerprint = db.GetMetaString("fold_key_fingerprint"); var foldMetadataCurrentBefore = storedFoldVersion == currentFoldVersion && storedFoldFingerprint == currentFoldFingerprint; - var csharpSymbolNameContractUpgradeRequired = !string.Equals( + var csharpSymbolNameContractUpgradeRequired = + writer.HasAnyFilesWithLanguage("csharp") + && !string.Equals( db.GetMetaString(DbContext.CSharpSymbolNameContractVersionMetaKey), DbContext.CSharpSymbolNameContractVersion.ToString( System.Globalization.CultureInfo.InvariantCulture), diff --git a/src/CodeIndex/Database/DbWriter.FoldBackfill.cs b/src/CodeIndex/Database/DbWriter.FoldBackfill.cs index a3473cf324..60ddf4f89f 100644 --- a/src/CodeIndex/Database/DbWriter.FoldBackfill.cs +++ b/src/CodeIndex/Database/DbWriter.FoldBackfill.cs @@ -43,7 +43,7 @@ SELECT COUNT(*) FROM symbols s JOIN files f ON f.id = s.file_id WHERE f.lang = 'csharp' - AND s.kind IN ('function', 'property', 'event') + AND s.kind IN ('function', 'test.method', 'property', 'event') AND (s.signature IS NULL OR trim(s.signature) = '') """, static _ => { }); @@ -440,9 +440,19 @@ private static int ToInt32Count(object? value) internal bool ResolveFoldBackfillRewriteAll(bool rewriteAll) { + if (TryGetNewerCSharpSymbolNameContractVersion(out var storedVersion)) + { + throw new InvalidOperationException( + $"C# symbol-name contract version {storedVersion} is newer than supported version " + + $"{DbContext.CSharpSymbolNameContractVersion}."); + } + if (rewriteAll) return true; + if (!HasAnyFilesWithLanguage("csharp")) + return false; + var currentCSharpContract = DbContext.CSharpSymbolNameContractVersion.ToString( System.Globalization.CultureInfo.InvariantCulture); return !string.Equals( @@ -451,6 +461,21 @@ internal bool ResolveFoldBackfillRewriteAll(bool rewriteAll) StringComparison.Ordinal); } + public bool TryGetNewerCSharpSymbolNameContractVersion(out int storedVersion) + { + storedVersion = 0; + if (!HasAnyFilesWithLanguage("csharp")) + return false; + + var stored = GetMetaString(DbContext.CSharpSymbolNameContractVersionMetaKey); + return int.TryParse( + stored, + System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, + out storedVersion) + && storedVersion > DbContext.CSharpSymbolNameContractVersion; + } + private int BackfillSymbolFoldedRows(bool rewriteAll, CancellationToken cancellationToken) { var phase = rewriteAll ? GetMetaString(FoldBackfillPhaseMetaKey) : null; diff --git a/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs b/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs index b7c9974e73..4afec4d9c0 100644 --- a/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs +++ b/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs @@ -73,7 +73,7 @@ public static string Normalize(string name, Match match, string matchLine) { if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(signature) - || kind is not ("function" or "property" or "event")) + || kind is not ("function" or "test.method" or "property" or "event")) { return null; } @@ -238,6 +238,7 @@ private static bool TryReadExplicitInterfaceMemberArity( if (cursor >= signature.Length) return false; + var isFunction = kind is "function" or "test.method"; if (signature[cursor] != '<') { // Match the suffix required by the persisted row's member kind. In particular, @@ -248,8 +249,8 @@ private static bool TryReadExplicitInterfaceMemberArity( // parameter list へ続く必要があり、`{` を許すと `class Runner : IFoo.Runner { }` // のような後続 base/constraint 型を declaration token と誤認してしまう。 if (isIndexer) - return kind == "function" && signature[cursor] == '['; - if (kind == "function") + return isFunction && signature[cursor] == '['; + if (isFunction) return signature[cursor] == '('; return signature[cursor] is '{' or '=' or ';'; } diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs b/src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs index c888de7bb3..588061b2d4 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs @@ -37,6 +37,12 @@ private async Task ExecuteBackfillFoldAsync(JsonNode? id, JsonNode? ar if (!dryRun) db.InitializeSchema(); var writer = new DbWriter(db); + if (writer.TryGetNewerCSharpSymbolNameContractVersion(out var newerCSharpContract)) + { + return CreateToolErrorResponse( + id, + $"C# symbol-name contract version {newerCSharpContract} is newer than supported version {DbContext.CSharpSymbolNameContractVersion}. Use the same or a newer CodeIndex version that wrote this database; this version will not rewrite or downgrade its C# identities."); + } var userVersionBefore = db.GetUserVersion(); var foldReadyBefore = (userVersionBefore & DbContext.FoldReadyFlag) != 0; var currentFoldVersion = NameFold.Version.ToString(System.Globalization.CultureInfo.InvariantCulture); @@ -45,7 +51,9 @@ private async Task ExecuteBackfillFoldAsync(JsonNode? id, JsonNode? ar var storedFoldFingerprint = db.GetMetaString("fold_key_fingerprint"); var foldMetadataCurrentBefore = storedFoldVersion == currentFoldVersion && storedFoldFingerprint == currentFoldFingerprint; - var csharpSymbolNameContractUpgradeRequired = !string.Equals( + var csharpSymbolNameContractUpgradeRequired = + writer.HasAnyFilesWithLanguage("csharp") + && !string.Equals( db.GetMetaString(DbContext.CSharpSymbolNameContractVersionMetaKey), DbContext.CSharpSymbolNameContractVersion.ToString( System.Globalization.CultureInfo.InvariantCulture), diff --git a/tests/CodeIndex.Tests/DbReaderSearchTests.cs b/tests/CodeIndex.Tests/DbReaderSearchTests.cs index 2e8d79e99b..45e678a063 100644 --- a/tests/CodeIndex.Tests/DbReaderSearchTests.cs +++ b/tests/CodeIndex.Tests/DbReaderSearchTests.cs @@ -1890,6 +1890,7 @@ public interface IFoo string this[int index] { get; } void Ä(); void @this(); + void Verify(); } public interface IBar @@ -1912,6 +1913,8 @@ event System.EventHandler IFoo . Changed { add { } remove { } } int IItemContract . Item => 2; void IFoo.Ä() { } void IFoo.@this() { } + [Fact] + void IFoo.Verify() { } public void Run(T value) { } public void ExplicitHelper() { } public void CallPublicRun() { Run(1); } @@ -2199,6 +2202,10 @@ FROM symbol_references Assert.Equal( verbatimThisResults.Select(result => result.SymbolId).Order().ToArray(), verbatimThisResultsWithoutLanguage.Select(result => result.SymbolId).Order().ToArray()); + var attributedExplicitMethod = Assert.Single( + reader.SearchSymbols("IFoo.Verify", lang: "csharp", exact: true), + result => result.Kind == "test.method"); + Assert.Contains("IFoo.Verify", attributedExplicitMethod.Signature, StringComparison.Ordinal); const string sqlPath = "src/qualified-function.sql"; var sqlFileId = writer.UpsertFile(new FileRecord diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index a050d176bb..c661a8934b 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -6479,6 +6479,157 @@ FROM symbols } } + [Fact] + public void RunBackfillFold_RejectsNewerCSharpIdentityContractWithoutRewriting_Issue4866Review() + { + var dbPath = CreateTempDbPath("cdidx_backfill_fold_csharp_future"); + var futureVersion = DbContext.CSharpSymbolNameContractVersion + 1; + try + { + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + db.InitializeSchema(); + var writer = new DbWriter(db.Connection); + var fileId = writer.UpsertFile(new FileRecord + { + Path = "src/Future.cs", + Lang = "csharp", + Size = 32, + Lines = 1, + Modified = new DateTime(2026, 7, 29, 0, 0, 0, DateTimeKind.Utc), + }); + writer.InsertSymbols([ + new SymbolRecord + { + FileId = fileId, + Kind = "function", + Name = "Run", + IdentityNameFolded = "future::ifoo.run", + DisplayNameFolded = "run", + Signature = "void IFoo.Run() { }", + Line = 1, + StartLine = 1, + EndLine = 1, + }, + ]); + writer.SetMeta( + DbContext.CSharpSymbolNameContractVersionMetaKey, + futureVersion.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + string outputText; + int exitCode; + lock (TestConsoleLock.Gate) + { + var originalOut = Console.Out; + using var output = new StringWriter(); + try + { + Console.SetOut(output); + exitCode = IndexCommandRunner.RunBackfillFold( + ["--db", dbPath, "--json"], + _jsonOptions); + outputText = output.ToString(); + } + finally + { + Console.SetOut(originalOut); + } + } + + Assert.Equal(CommandExitCodes.DatabaseError, exitCode); + Assert.Contains("newer than supported version", outputText, StringComparison.Ordinal); + + using var verifyDb = new DbContext(DbOpenIntent.WriteIndex, dbPath); + Assert.Equal( + futureVersion.ToString(System.Globalization.CultureInfo.InvariantCulture), + verifyDb.GetMetaString(DbContext.CSharpSymbolNameContractVersionMetaKey)); + using var identity = verifyDb.Connection.CreateCommand(); + identity.CommandText = """ + SELECT name_folded, display_name_folded + FROM symbols + WHERE name = 'Run' + """; + using var identityReader = identity.ExecuteReader(); + Assert.True(identityReader.Read()); + Assert.Equal("future::ifoo.run", identityReader.GetString(0)); + Assert.Equal("run", identityReader.GetString(1)); + } + finally + { + SqliteConnection.ClearAllPools(); + DeleteFile(dbPath); + } + } + + [Fact] + public void RunBackfillFold_DoesNotRewriteCurrentFoldRowsWhenCSharpIsAbsent_Issue4866Review() + { + var dbPath = CreateTempDbPath("cdidx_backfill_fold_without_csharp"); + try + { + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + db.InitializeSchema(); + var writer = new DbWriter(db.Connection); + var fileId = writer.UpsertFile(new FileRecord + { + Path = "src/app.py", + Lang = "python", + Size = 32, + Lines = 1, + Modified = new DateTime(2026, 7, 29, 0, 0, 0, DateTimeKind.Utc), + }); + writer.InsertSymbols([ + new SymbolRecord + { + FileId = fileId, + Kind = "function", + Name = "run", + Line = 1, + StartLine = 1, + EndLine = 1, + }, + ]); + writer.BackfillFoldedColumns(rewriteAll: true); + Assert.True(writer.MarkFoldReady()); + writer.SetMeta(DbContext.CSharpSymbolNameContractVersionMetaKey, null); + } + + JsonElement json; + int exitCode; + lock (TestConsoleLock.Gate) + { + var originalOut = Console.Out; + using var output = new StringWriter(); + try + { + Console.SetOut(output); + exitCode = IndexCommandRunner.RunBackfillFold( + ["--db", dbPath, "--json"], + _jsonOptions); + using var document = JsonDocument.Parse(output.ToString()); + json = document.RootElement.Clone(); + } + finally + { + Console.SetOut(originalOut); + } + } + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.False(json.GetProperty("rewrite_all").GetBoolean()); + Assert.Equal(0, json.GetProperty("symbols").GetInt32()); + Assert.Equal(0, json.GetProperty("symbol_references").GetInt32()); + Assert.True(json.GetProperty("was_already_complete").GetBoolean()); + } + finally + { + SqliteConnection.ClearAllPools(); + DeleteFile(dbPath); + } + } + [Fact] public void RunBackfillFold_RefusesCSharpV3StampWhenLegacySignaturesAreMissing_Issue4866() { diff --git a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs index 6f32f6afaf..31ffe4971f 100644 --- a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs @@ -11787,6 +11787,55 @@ public void ToolsCall_BackfillFold_StampsFoldReady() Assert.True(reader._foldReady); } + [Fact] + public void ToolsCall_BackfillFold_RejectsNewerCSharpIdentityContract_Issue4866Review() + { + var writer = new DbWriter(_db.Connection); + var futureVersion = DbContext.CSharpSymbolNameContractVersion + 1; + var fileId = writer.UpsertFile(new FileRecord + { + Path = "src/future-explicit-4866.cs", + Lang = "csharp", + Size = 32, + Lines = 1, + Modified = new DateTime(2026, 7, 29, 0, 0, 0, DateTimeKind.Utc), + }); + writer.InsertSymbols([ + new SymbolRecord + { + FileId = fileId, + Kind = "function", + Name = "FutureRun", + IdentityNameFolded = "future::ifuture.futurerun", + DisplayNameFolded = "futurerun", + Signature = "void IFuture.FutureRun() { }", + Line = 1, + StartLine = 1, + EndLine = 1, + }, + ]); + writer.SetMeta( + DbContext.CSharpSymbolNameContractVersionMetaKey, + futureVersion.ToString(System.Globalization.CultureInfo.InvariantCulture)); + + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":4866,"method":"tools/call","params":{"name":"backfill_fold","arguments":{}}}""")!; + var response = _server.HandleMessage(request)!; + + Assert.True(response["result"]!["isError"]?.GetValue() ?? false); + var text = response["result"]!["content"]![0]!["text"]!.GetValue(); + Assert.Contains("newer than supported version", text, StringComparison.Ordinal); + Assert.Equal( + futureVersion.ToString(System.Globalization.CultureInfo.InvariantCulture), + _db.GetMetaString(DbContext.CSharpSymbolNameContractVersionMetaKey)); + using var identity = _db.Connection.CreateCommand(); + identity.CommandText = """ + SELECT name_folded + FROM symbols + WHERE name = 'FutureRun' + """; + Assert.Equal("future::ifuture.futurerun", identity.ExecuteScalar()); + } + [Fact] public void ToolsCall_BackfillFold_RefusesCSharpV3StampWhenLegacySignaturesAreMissing_Issue4866() { diff --git a/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs b/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs index ad5e428542..a902c7b634 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs @@ -4396,6 +4396,7 @@ public interface IFoo : IBase int Value { get; } event System.EventHandler Changed; string this[int index] { get; } + void Verify(); } public interface IBar @@ -4410,6 +4411,8 @@ void IBar.Run(TLeft left, TRight right) { } int IFoo . Value => 1; event System.EventHandler IFoo . Changed { add { } remove { } } string IFoo . this[int index] => index.ToString(); + [Fact] + void IFoo.Verify() { } public void Run(T value) { } } """; @@ -4450,6 +4453,10 @@ public void Run(T value) { } "ifoo.item", Assert.Single(serviceMembers, symbol => symbol.Kind == "function" && symbol.Name == "Item") .IdentityNameFolded); + Assert.Equal( + "ifoo.verify", + Assert.Single(serviceMembers, symbol => symbol.Kind == "test.method" && symbol.Name == "Verify") + .IdentityNameFolded); } [Fact] From 265602758ac9e193b8f91eb8620f1332b1e3ffe6 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 14:26:55 +0900 Subject: [PATCH 11/18] Decode explicit-interface Unicode escapes (#4866) --- .../Database/ExactSourceSearchNormalizer.cs | 2 +- .../Indexer/Symbols/CSharpSymbolNameNormalizer.cs | 10 ++++++++++ tests/CodeIndex.Tests/DbReaderSearchTests.cs | 12 ++++++++++++ tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs | 6 ++++++ 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/CodeIndex/Database/ExactSourceSearchNormalizer.cs b/src/CodeIndex/Database/ExactSourceSearchNormalizer.cs index 788556de15..e154838b77 100644 --- a/src/CodeIndex/Database/ExactSourceSearchNormalizer.cs +++ b/src/CodeIndex/Database/ExactSourceSearchNormalizer.cs @@ -67,7 +67,7 @@ private static string NormalizeCSharp(string text, out int[] rawIndexMap) return normalized; } - private static string NormalizeCSharpUnicodeEscapes(string text, out int[] rawIndexMap) + internal static string NormalizeCSharpUnicodeEscapes(string text, out int[] rawIndexMap) { if (text.Length == 0 || text.IndexOf('\\') < 0) return Identity(text, out rawIndexMap); diff --git a/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs b/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs index 4afec4d9c0..56f0ece20a 100644 --- a/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs +++ b/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs @@ -78,6 +78,16 @@ public static string Normalize(string name, Match match, string matchLine) return null; } + // Extraction canonicalizes source-only Unicode escapes in the persisted display name, + // but the signature keeps its source spelling. Decode only those escapes here while + // preserving `@`, whose presence distinguishes an `@this` method from an indexer. + // 抽出時は永続表示名の source-only Unicode escape を正規化する一方、signature は + // source 表記を保持する。indexer と `@this` method の区別に必要な `@` は残し、 + // Unicode escape だけをここで復号する。 + signature = ExactSourceSearchNormalizer.NormalizeCSharpUnicodeEscapes( + signature, + out _); + // `Item` is only the display alias for an indexer when the declaration itself uses // `this[...]`. A legal method/property/event may also be named `Item`, so try the // indexer source spelling first and then fall back to the literal member name. diff --git a/tests/CodeIndex.Tests/DbReaderSearchTests.cs b/tests/CodeIndex.Tests/DbReaderSearchTests.cs index 45e678a063..237b35ffba 100644 --- a/tests/CodeIndex.Tests/DbReaderSearchTests.cs +++ b/tests/CodeIndex.Tests/DbReaderSearchTests.cs @@ -1891,6 +1891,7 @@ public interface IFoo void Ä(); void @this(); void Verify(); + void Escape(); } public interface IBar @@ -1915,6 +1916,7 @@ void IFoo.Ä() { } void IFoo.@this() { } [Fact] void IFoo.Verify() { } + void I\u0046oo.\u0045scape() { } public void Run(T value) { } public void ExplicitHelper() { } public void CallPublicRun() { Run(1); } @@ -2206,6 +2208,16 @@ FROM symbol_references reader.SearchSymbols("IFoo.Verify", lang: "csharp", exact: true), result => result.Kind == "test.method"); Assert.Contains("IFoo.Verify", attributedExplicitMethod.Signature, StringComparison.Ordinal); + var escapedExplicitMethodResults = reader.SearchSymbols( + "IFoo.Escape", + lang: "csharp", + exact: true); + Assert.Equal(2, escapedExplicitMethodResults.Count); + Assert.Contains( + escapedExplicitMethodResults, + result => result.Signature?.Contains( + @"I\u0046oo.\u0045scape", + StringComparison.Ordinal) == true); const string sqlPath = "src/qualified-function.sql"; var sqlFileId = writer.UpsertFile(new FileRecord diff --git a/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs b/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs index a902c7b634..602a013568 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs @@ -4397,6 +4397,7 @@ public interface IFoo : IBase event System.EventHandler Changed; string this[int index] { get; } void Verify(); + void Escape(); } public interface IBar @@ -4413,6 +4414,7 @@ event System.EventHandler IFoo . Changed { add { } remove { } } string IFoo . this[int index] => index.ToString(); [Fact] void IFoo.Verify() { } + void I\u0046oo.\u0045scape() { } public void Run(T value) { } } """; @@ -4457,6 +4459,10 @@ public void Run(T value) { } "ifoo.verify", Assert.Single(serviceMembers, symbol => symbol.Kind == "test.method" && symbol.Name == "Verify") .IdentityNameFolded); + Assert.Equal( + "ifoo.escape", + Assert.Single(serviceMembers, symbol => symbol.Kind == "function" && symbol.Name == "Escape") + .IdentityNameFolded); } [Fact] From c80c0a29e8164bc0203e879b2cc3abf1680efe5c Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 15:47:11 +0900 Subject: [PATCH 12/18] Fix explicit-interface review regressions (#4866) --- .../Database/DbSymbolReader.Analysis.cs | 4 +- .../Database/DbWriter.FoldBackfill.cs | 8 +- tests/CodeIndex.Tests/DbReaderSearchTests.cs | 8 ++ .../IndexCommandRunnerTests.cs | 76 +++++++++++++++++++ 4 files changed, 93 insertions(+), 3 deletions(-) diff --git a/src/CodeIndex/Database/DbSymbolReader.Analysis.cs b/src/CodeIndex/Database/DbSymbolReader.Analysis.cs index dc84376e48..b4ada5d040 100644 --- a/src/CodeIndex/Database/DbSymbolReader.Analysis.cs +++ b/src/CodeIndex/Database/DbSymbolReader.Analysis.cs @@ -263,7 +263,9 @@ public SymbolAnalysisResult AnalyzeSymbol(string query, int limit = 10, string? } lang = DbReader.NormalizeQueryLanguage(lang); - var normalizedQuery = NormalizeSymbolSearchQuery(query, lang) ?? query; + var normalizedQuery = + NormalizeSymbolSearchQueryForSymbolSearch(query, lang, exact) + ?? query; // Propagate `exact` to every bundled sub-query so the one-round-trip AI workflow // (`inspect` / MCP `analyze_symbol`) keeps the same precision contract as the leaf // commands. Without this, `inspect Run --exact` would still pull RunAsync/RunImpact diff --git a/src/CodeIndex/Database/DbWriter.FoldBackfill.cs b/src/CodeIndex/Database/DbWriter.FoldBackfill.cs index 60ddf4f89f..768782debb 100644 --- a/src/CodeIndex/Database/DbWriter.FoldBackfill.cs +++ b/src/CodeIndex/Database/DbWriter.FoldBackfill.cs @@ -380,11 +380,14 @@ private bool ExtractorContractsMatchCurrentForReuse(string? lang) var lastSymbolId = rewriteAll ? GetFoldBackfillCheckpoint(FoldBackfillLastSymbolIdMetaKey) : 0; var lastReferenceId = rewriteAll ? GetFoldBackfillCheckpoint(FoldBackfillLastReferenceIdMetaKey) : 0; + var hasDisplayNameFolded = + DbSchemaCache.LoadColumns(_conn, "symbols").Contains("display_name_folded"); var symbolsSql = rewriteAll && phase != "references" ? "SELECT COUNT(*) FROM symbols WHERE name IS NOT NULL AND id > @lastSymbolId" : rewriteAll ? "SELECT 0" - : """ + : hasDisplayNameFolded + ? """ SELECT COUNT(*) FROM symbols s JOIN files f ON f.id = s.file_id @@ -393,7 +396,8 @@ WHERE s.name IS NOT NULL OR (f.lang = 'csharp' AND s.name_folded <> codeindex_name_fold(s.name) AND s.display_name_folded IS NULL)) - """; + """ + : "SELECT COUNT(*) FROM symbols WHERE name IS NOT NULL AND name_folded IS NULL"; var symbolsUsesCheckpoint = rewriteAll && phase != "references"; var symbols = RentCommand( symbolsSql, diff --git a/tests/CodeIndex.Tests/DbReaderSearchTests.cs b/tests/CodeIndex.Tests/DbReaderSearchTests.cs index 237b35ffba..949a4e625d 100644 --- a/tests/CodeIndex.Tests/DbReaderSearchTests.cs +++ b/tests/CodeIndex.Tests/DbReaderSearchTests.cs @@ -2198,6 +2198,14 @@ FROM symbol_references Assert.All( verbatimThisDefinitions, result => Assert.DoesNotContain("IFoo.this[", result.Signature, StringComparison.Ordinal)); + var verbatimThisAnalysis = reader.AnalyzeSymbol( + "IFoo.@this", + lang: "csharp", + exact: true); + Assert.Equal(2, verbatimThisAnalysis.Definitions.Count); + Assert.All( + verbatimThisAnalysis.Definitions, + result => Assert.DoesNotContain("IFoo.this[", result.Signature, StringComparison.Ordinal)); var verbatimThisResultsWithoutLanguage = reader.SearchSymbols( "IFoo.@this", exact: true); diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index c661a8934b..863d481232 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -6630,6 +6630,82 @@ public void RunBackfillFold_DoesNotRewriteCurrentFoldRowsWhenCSharpIsAbsent_Issu } } + [Fact] + public void RunBackfillFold_DryRunSupportsPreDisplayAliasSchemaWithoutCSharp_Issue4866Review() + { + var dbPath = CreateTempDbPath("cdidx_backfill_fold_without_csharp_legacy_schema"); + try + { + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + db.InitializeSchema(); + var writer = new DbWriter(db.Connection); + var fileId = writer.UpsertFile(new FileRecord + { + Path = "src/app.py", + Lang = "python", + Size = 32, + Lines = 1, + Modified = new DateTime(2026, 7, 29, 0, 0, 0, DateTimeKind.Utc), + }); + writer.InsertSymbols([ + new SymbolRecord + { + FileId = fileId, + Kind = "function", + Name = "run", + Line = 1, + StartLine = 1, + EndLine = 1, + }, + ]); + writer.BackfillFoldedColumns(rewriteAll: true); + Assert.True(writer.MarkFoldReady()); + writer.SetMeta(DbContext.CSharpSymbolNameContractVersionMetaKey, null); + + using var legacySchema = db.Connection.CreateCommand(); + legacySchema.CommandText = """ + DROP INDEX IF EXISTS idx_symbols_display_name_folded; + ALTER TABLE symbols DROP COLUMN display_name_folded; + """; + legacySchema.ExecuteNonQuery(); + } + + JsonElement json; + int exitCode; + lock (TestConsoleLock.Gate) + { + var originalOut = Console.Out; + using var output = new StringWriter(); + try + { + Console.SetOut(output); + exitCode = IndexCommandRunner.RunBackfillFold( + ["--db", dbPath, "--dry-run", "--json"], + _jsonOptions); + using var document = JsonDocument.Parse(output.ToString()); + json = document.RootElement.Clone(); + } + finally + { + Console.SetOut(originalOut); + } + } + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.False(json.GetProperty("rewrite_all").GetBoolean()); + Assert.True(json.GetProperty("dry_run").GetBoolean()); + Assert.Equal(0, json.GetProperty("symbols").GetInt32()); + Assert.Equal(0, json.GetProperty("symbol_references").GetInt32()); + Assert.True(json.GetProperty("was_already_complete").GetBoolean()); + } + finally + { + SqliteConnection.ClearAllPools(); + DeleteFile(dbPath); + } + } + [Fact] public void RunBackfillFold_RefusesCSharpV3StampWhenLegacySignaturesAreMissing_Issue4866() { From b8aad69d516f029a5efd2773069319bb4479ed45 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 16:33:05 +0900 Subject: [PATCH 13/18] Fix explicit-interface final review findings (#4866) --- .../Cli/IndexCommandRunner.Maintenance.cs | 22 +++---- src/CodeIndex/Database/DbReader.cs | 14 +++-- .../Database/DbSymbolReader.Definitions.cs | 4 +- .../Database/DbSymbolReader.Search.cs | 16 ++--- .../Symbols/CSharpSymbolNameNormalizer.cs | 19 +++++- src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs | 14 ++--- tests/CodeIndex.Tests/DbReaderSearchTests.cs | 56 +++++++++++++++++ .../IndexCommandRunnerTests.cs | 61 +++++++++++++------ .../McpServerToolsCallTests.cs | 24 ++++++++ 9 files changed, 179 insertions(+), 51 deletions(-) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs b/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs index 67f1af8490..be0d4ad58f 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs @@ -596,6 +596,17 @@ internal static int RunBackfillFold( DbContext.CSharpSymbolNameContractVersion.ToString( System.Globalization.CultureInfo.InvariantCulture), StringComparison.Ordinal); + if (csharpSymbolNameContractUpgradeRequired + && !writer.CanReconstructCSharpExplicitInterfaceIdentitiesFromPersistedRows()) + { + return WriteCommandError( + options.Json, + jsonOptions, + "C# explicit-interface identities cannot be reconstructed because legacy symbol signatures are missing", + CommandExitCodes.DatabaseError, + "Refresh the C# files with `cdidx index ` (or rebuild the index) before retrying `cdidx backfill-fold`.", + CommandErrorCodes.DbError); + } foldReadyBefore = foldReadyBefore && foldMetadataCurrentBefore; // Missing or mismatched fold metadata means persisted keys may have been generated // by a different fold algorithm/runtime, so refresh every row from source names. @@ -634,17 +645,6 @@ internal static int RunBackfillFold( "Retry `cdidx backfill-fold`. If the DB still does not verify, rebuild it with `cdidx index --rebuild`.", CommandErrorCodes.DbError); } - if (csharpSymbolNameContractUpgradeRequired - && !writer.CanReconstructCSharpExplicitInterfaceIdentitiesFromPersistedRows()) - { - return WriteCommandError( - options.Json, - jsonOptions, - "C# explicit-interface identities cannot be reconstructed because legacy symbol signatures are missing", - CommandExitCodes.DatabaseError, - "Refresh the C# files with `cdidx index ` (or rebuild the index) before retrying `cdidx backfill-fold`.", - CommandErrorCodes.DbError); - } writer.MarkCSharpSymbolNameContractReady(); transaction.Commit(); diff --git a/src/CodeIndex/Database/DbReader.cs b/src/CodeIndex/Database/DbReader.cs index 9454b2786f..f60c69182f 100644 --- a/src/CodeIndex/Database/DbReader.cs +++ b/src/CodeIndex/Database/DbReader.cs @@ -187,6 +187,11 @@ private bool IsSqliteInterruptCancellation(SqliteException exception) // read this as false and fall back to the ASCII-only `COLLATE NOCASE` path. // #86: name_folded 列が全行埋まっているか(fold 経路を使えるか)。 internal readonly bool _foldReady; + // Matching fold metadata remains useful for language-contract identities when a failed + // index run has temporarily cleared only the global FoldReady bit. + // fold metadata が一致していれば、失敗した index run が全体の FoldReady bit だけを一時的に + // clear した場合も language contract の identity を引き続き利用できる。 + internal readonly bool _foldMetadataCurrent; internal readonly bool _csharpSymbolNameContractCurrent; // #3524: True when `symbols.is_metadata_target` has been populated from extractor facts // plus the writer resolver for every C# class-like row and the stamp in `codeindex_meta` @@ -608,11 +613,12 @@ private DbReader( // version mismatch や fingerprint mismatch、未記録は NOCASE fallback に降格させる。 var foldBitSet = (userVersion & DbContext.FoldReadyFlag) != 0 && _symbolColumns.Contains("name_folded"); - var storedFoldVersion = foldBitSet ? ParseFoldVersion(connection) : -1; - var storedFoldFingerprint = foldBitSet ? ParseFoldFingerprint(connection) : null; - _foldReady = foldBitSet - && storedFoldVersion == NameFold.Version + var storedFoldVersion = ParseFoldVersion(connection); + var storedFoldFingerprint = ParseFoldFingerprint(connection); + _foldMetadataCurrent = + storedFoldVersion == NameFold.Version && string.Equals(storedFoldFingerprint, NameFold.Fingerprint(), StringComparison.Ordinal); + _foldReady = foldBitSet && _foldMetadataCurrent; _csharpSymbolNameContractCurrent = string.Equals( TryGetMetaString(_conn, DbContext.CSharpSymbolNameContractVersionMetaKey), DbContext.CSharpSymbolNameContractVersion.ToString(System.Globalization.CultureInfo.InvariantCulture), diff --git a/src/CodeIndex/Database/DbSymbolReader.Definitions.cs b/src/CodeIndex/Database/DbSymbolReader.Definitions.cs index 6f17add2a1..4fb5a9f9f9 100644 --- a/src/CodeIndex/Database/DbSymbolReader.Definitions.cs +++ b/src/CodeIndex/Database/DbSymbolReader.Definitions.cs @@ -347,8 +347,8 @@ FROM symbols s ? $" AND ({BuildExactPrimarySymbolNameMatchSql("@query", true, normalizedQuery, lang)} OR {csharpExplicitInterfaceClause} OR {markdownAnchorExactClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name_folded(s.name) = @queryNormalizedFolded) OR sql_leaf_name_folded(s.name) = @queryLeafFolded)))" : $" AND ({BuildExactPrimarySymbolNameMatchSql("@query", true, normalizedQuery, lang)} OR {csharpExplicitInterfaceClause} OR {markdownAnchorExactClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name_folded(s.name) = @queryNormalizedFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" : allowLeafFallback - ? $" AND ({BuildExactPrimarySymbolNameMatchSql("@query", false, normalizedQuery, lang)} OR {markdownAnchorExactClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name(s.name) = @queryNormalized COLLATE NOCASE) OR sql_leaf_name(s.name) = @queryLeaf COLLATE NOCASE)))" - : $" AND ({BuildExactPrimarySymbolNameMatchSql("@query", false, normalizedQuery, lang)} OR {markdownAnchorExactClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name(s.name) = @queryNormalized COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" + ? $" AND ({BuildExactPrimarySymbolNameMatchSql("@query", false, normalizedQuery, lang)} OR {csharpExplicitInterfaceClause} OR {markdownAnchorExactClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name(s.name) = @queryNormalized COLLATE NOCASE) OR sql_leaf_name(s.name) = @queryLeaf COLLATE NOCASE)))" + : $" AND ({BuildExactPrimarySymbolNameMatchSql("@query", false, normalizedQuery, lang)} OR {csharpExplicitInterfaceClause} OR {markdownAnchorExactClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @querySegmentCount AND sql_normalize_name(s.name) = @queryNormalized COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" : $" AND (s.name LIKE @query ESCAPE '\\'{markdownAnchorLikeClause} OR (f.lang = 'sql' AND sql_normalize_name(s.name) LIKE @queryNormalizedLike ESCAPE '\\'){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause} OR {csharpExplicitInterfaceClause}" : string.Empty)})"; } if (kind != null) diff --git a/src/CodeIndex/Database/DbSymbolReader.Search.cs b/src/CodeIndex/Database/DbSymbolReader.Search.cs index 50f882ba8e..091c5e9332 100644 --- a/src/CodeIndex/Database/DbSymbolReader.Search.cs +++ b/src/CodeIndex/Database/DbSymbolReader.Search.cs @@ -171,7 +171,9 @@ private string BuildCSharpExplicitInterfaceIdentityMatchSql( string symbolAlias = "s", string fileAlias = "f") { - if (!_csharpSymbolNameContractCurrent || !_foldReady || !_symbolColumns.Contains("name_folded")) + if (!_csharpSymbolNameContractCurrent + || !_foldMetadataCurrent + || !_symbolColumns.Contains("name_folded")) return "0"; return $"({fileAlias}.lang = 'csharp' AND {symbolAlias}.name_folded = @{parameterStem}CSharpExplicitInterfaceIdentityFolded)"; @@ -331,8 +333,8 @@ FROM symbols s ? $" AND ({BuildExactPrimarySymbolNameMatchSql("@query0", true, validQueries[0], lang)} OR {csharpExplicitInterfaceClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name_folded(s.name) = @query0NormalizedFolded) OR sql_leaf_name_folded(s.name) = @query0LeafFolded)))" : $" AND ({BuildExactPrimarySymbolNameMatchSql("@query0", true, validQueries[0], lang)} OR {csharpExplicitInterfaceClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name_folded(s.name) = @query0NormalizedFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" : allowLeafFallback - ? $" AND ({BuildExactPrimarySymbolNameMatchSql("@query0", false, validQueries[0], lang)} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name(s.name) = @query0Normalized COLLATE NOCASE) OR sql_leaf_name(s.name) = @query0Leaf COLLATE NOCASE)))" - : $" AND ({BuildExactPrimarySymbolNameMatchSql("@query0", false, validQueries[0], lang)} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name(s.name) = @query0Normalized COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" + ? $" AND ({BuildExactPrimarySymbolNameMatchSql("@query0", false, validQueries[0], lang)} OR {csharpExplicitInterfaceClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name(s.name) = @query0Normalized COLLATE NOCASE) OR sql_leaf_name(s.name) = @query0Leaf COLLATE NOCASE)))" + : $" AND ({BuildExactPrimarySymbolNameMatchSql("@query0", false, validQueries[0], lang)} OR {csharpExplicitInterfaceClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query0SegmentCount AND sql_normalize_name(s.name) = @query0Normalized COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" : $" AND (s.name LIKE @query0 ESCAPE '\\' OR (f.lang = 'sql' AND sql_normalize_name(s.name) LIKE @query0NormalizedLike ESCAPE '\\'){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause} OR {csharpExplicitInterfaceClause}" : string.Empty)})"; } if (kind != null) @@ -452,8 +454,8 @@ FROM symbols s ? $"({BuildExactPrimarySymbolNameMatchSql($"@query{idx}", true, queryValue, lang)}{swiftBacktickClause} OR {csharpExplicitInterfaceClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded) OR sql_leaf_name_folded(s.name) = @query{idx}LeafFolded)))" : $"({BuildExactPrimarySymbolNameMatchSql($"@query{idx}", true, queryValue, lang)}{swiftBacktickClause} OR {csharpExplicitInterfaceClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" : allowLeafFallback - ? $"({BuildExactPrimarySymbolNameMatchSql($"@query{idx}", false, queryValue, lang)}{swiftBacktickClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE) OR sql_leaf_name(s.name) = @query{idx}Leaf COLLATE NOCASE)))" - : $"({BuildExactPrimarySymbolNameMatchSql($"@query{idx}", false, queryValue, lang)}{swiftBacktickClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})"; + ? $"({BuildExactPrimarySymbolNameMatchSql($"@query{idx}", false, queryValue, lang)}{swiftBacktickClause} OR {csharpExplicitInterfaceClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE) OR sql_leaf_name(s.name) = @query{idx}Leaf COLLATE NOCASE)))" + : $"({BuildExactPrimarySymbolNameMatchSql($"@query{idx}", false, queryValue, lang)}{swiftBacktickClause} OR {csharpExplicitInterfaceClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})"; })) : string.Join(" OR ", effectiveQueries.Select((queryValue, idx) => { @@ -768,8 +770,8 @@ FROM symbols s ? $"({BuildExactPrimarySymbolNameMatchSql($"@query{idx}", true, queryValue, lang)}{swiftBacktickClause} OR {csharpExplicitInterfaceClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded) OR sql_leaf_name_folded(s.name) = @query{idx}LeafFolded)))" : $"({BuildExactPrimarySymbolNameMatchSql($"@query{idx}", true, queryValue, lang)}{swiftBacktickClause} OR {csharpExplicitInterfaceClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name_folded(s.name) = @query{idx}NormalizedFolded){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})" : allowLeafFallback - ? $"({BuildExactPrimarySymbolNameMatchSql($"@query{idx}", false, queryValue, lang)}{swiftBacktickClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE) OR sql_leaf_name(s.name) = @query{idx}Leaf COLLATE NOCASE)))" - : $"({BuildExactPrimarySymbolNameMatchSql($"@query{idx}", false, queryValue, lang)}{swiftBacktickClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})"; + ? $"({BuildExactPrimarySymbolNameMatchSql($"@query{idx}", false, queryValue, lang)}{swiftBacktickClause} OR {csharpExplicitInterfaceClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE) OR sql_leaf_name(s.name) = @query{idx}Leaf COLLATE NOCASE)))" + : $"({BuildExactPrimarySymbolNameMatchSql($"@query{idx}", false, queryValue, lang)}{swiftBacktickClause} OR {csharpExplicitInterfaceClause} OR {markdownAnchorClause} OR (f.lang = 'sql' AND sql_segment_count(s.name) = @query{idx}SegmentCount AND sql_normalize_name(s.name) = @query{idx}Normalized COLLATE NOCASE){(qualifiedSymbolClause != null ? $" OR {qualifiedSymbolClause}" : string.Empty)})"; })) : string.Join(" OR ", effectiveQueries.Select((queryValue, idx) => { diff --git a/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs b/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs index 56f0ece20a..af90684cad 100644 --- a/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs +++ b/src/CodeIndex/Indexer/Symbols/CSharpSymbolNameNormalizer.cs @@ -306,14 +306,27 @@ internal static string NormalizeExplicitInterfaceQueryDisplayName(string query) var rawTerminalToken = rawLastDot >= 0 ? query[(rawLastDot + 1)..].Trim() : string.Empty; + var decodedRawTerminalToken = + ExactSourceSearchNormalizer.NormalizeCSharpUnicodeEscapes( + rawTerminalToken, + out _); var isIndexerSpelling = string.Equals( rawTerminalToken, "this", StringComparison.Ordinal); var isVerbatimThisSpelling = string.Equals( - rawTerminalToken, - "@this", - StringComparison.Ordinal); + rawTerminalToken, + "@this", + StringComparison.Ordinal) + || (rawTerminalToken.IndexOf('\\') >= 0 + && (string.Equals( + decodedRawTerminalToken, + "this", + StringComparison.Ordinal) + || string.Equals( + decodedRawTerminalToken, + "@this", + StringComparison.Ordinal))); var normalized = NormalizeTypeDisplayName(query); var lastDot = FindLastTopLevelDot(normalized); if (lastDot < 0) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs b/src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs index 588061b2d4..beb18c5685 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs @@ -58,6 +58,13 @@ private async Task ExecuteBackfillFoldAsync(JsonNode? id, JsonNode? ar DbContext.CSharpSymbolNameContractVersion.ToString( System.Globalization.CultureInfo.InvariantCulture), StringComparison.Ordinal); + if (csharpSymbolNameContractUpgradeRequired + && !writer.CanReconstructCSharpExplicitInterfaceIdentitiesFromPersistedRows()) + { + return CreateToolErrorResponse( + id, + "C# explicit-interface identities cannot be reconstructed because legacy symbol signatures are missing. Refresh the C# files with the index tool (or rebuild the index), then retry backfill_fold."); + } foldReadyBefore = foldReadyBefore && foldMetadataCurrentBefore; var force = args?["force"]?.GetValue() ?? false; var rewriteAll = writer.ResolveFoldBackfillRewriteAll( @@ -87,13 +94,6 @@ private async Task ExecuteBackfillFoldAsync(JsonNode? id, JsonNode? ar verified = writer.MarkFoldReady(); if (!verified) return CreateToolErrorResponse(id, "Folded-name backfill verification failed: some rows still have NULL folded values. Re-run backfill_fold."); - if (csharpSymbolNameContractUpgradeRequired - && !writer.CanReconstructCSharpExplicitInterfaceIdentitiesFromPersistedRows()) - { - return CreateToolErrorResponse( - id, - "C# explicit-interface identities cannot be reconstructed because legacy symbol signatures are missing. Refresh the C# files with the index tool (or rebuild the index), then retry backfill_fold."); - } writer.MarkCSharpSymbolNameContractReady(); transaction.Commit(); diff --git a/tests/CodeIndex.Tests/DbReaderSearchTests.cs b/tests/CodeIndex.Tests/DbReaderSearchTests.cs index 949a4e625d..bdb9890f83 100644 --- a/tests/CodeIndex.Tests/DbReaderSearchTests.cs +++ b/tests/CodeIndex.Tests/DbReaderSearchTests.cs @@ -2068,6 +2068,10 @@ FROM symbols s "ifoo.this", CSharpSymbolNameNormalizer.NormalizeExplicitInterfaceQueryIdentityNameFolded( "IFoo.@this")); + Assert.Equal( + "ifoo.this", + CSharpSymbolNameNormalizer.NormalizeExplicitInterfaceQueryIdentityNameFolded( + @"IFoo.\u0074his")); Assert.True(SqlNameResolver.HasQualifier("IFoo.Run")); using (var identityCommand = db.Connection.CreateCommand()) { @@ -2184,6 +2188,25 @@ FROM symbol_references Assert.All( verbatimThisResults, result => Assert.DoesNotContain("IFoo.this[", result.Signature, StringComparison.Ordinal)); + var escapedThisResults = reader.SearchSymbols( + @"IFoo.\u0074his", + lang: "csharp", + exact: true); + Assert.Equal( + verbatimThisResults.Select(result => result.SymbolId).Order().ToArray(), + escapedThisResults.Select(result => result.SymbolId).Order().ToArray()); + Assert.All( + escapedThisResults, + result => Assert.DoesNotContain("IFoo.this[", result.Signature, StringComparison.Ordinal)); + Assert.Equal( + verbatimThisResults.Count, + reader.CountSearchSymbols(@"IFoo.\u0074his", lang: "csharp", exact: true)); + Assert.Equal( + verbatimThisResults.Select(result => result.SymbolId).Order().ToArray(), + reader.GetDefinitions(@"IFoo.\u0074his", lang: "csharp", exact: true) + .Select(result => result.SymbolId) + .Order() + .ToArray()); Assert.Equal( 2, reader.CountSearchSymbols("IFoo.@this", lang: "csharp", exact: true)); @@ -2311,6 +2334,39 @@ FROM symbol_references outline.Symbols, symbol => symbol.Name == "Item" && symbol.Signature?.Contains("IFoo . this", StringComparison.Ordinal) == true); + + using (var clearFoldReady = db.Connection.CreateCommand()) + { + clearFoldReady.CommandText = + $"PRAGMA user_version = {db.GetUserVersion() & ~DbContext.FoldReadyFlag}"; + clearFoldReady.ExecuteNonQuery(); + } + using var foldDegradedReader = new DbReader(db.Connection); + Assert.False(foldDegradedReader._foldReady); + Assert.True(foldDegradedReader._foldMetadataCurrent); + var foldDegradedQualified = Assert.Single(foldDegradedReader.SearchSymbols( + "IFoo.Run", + lang: "csharp", + exact: true)); + Assert.Equal(fooRun.SymbolId, foldDegradedQualified.SymbolId); + Assert.Equal( + 1, + foldDegradedReader.CountSearchSymbols( + "IFoo.Run", + lang: "csharp", + exact: true)); + Assert.Equal( + 1, + foldDegradedReader.CountSearchSymbolsTotal( + "IFoo.Run", + lang: "csharp", + exact: true).Count); + Assert.Equal( + fooRun.SymbolId, + Assert.Single(foldDegradedReader.GetDefinitions( + "IFoo.Run", + lang: "csharp", + exact: true)).SymbolId); } [Fact] diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 863d481232..92936bc446 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -6735,42 +6735,69 @@ public void RunBackfillFold_RefusesCSharpV3StampWhenLegacySignaturesAreMissing_I StartLine = 1, EndLine = 1, }, + new SymbolRecord + { + FileId = fileId, + Kind = "function", + Name = "Stable", + IdentityNameFolded = "sentinel::stable", + DisplayNameFolded = "stable", + Signature = "void IFoo.Stable() { }", + Line = 1, + StartLine = 1, + EndLine = 1, + }, ]); writer.SetMeta(DbContext.CSharpSymbolNameContractVersionMetaKey, "2"); Assert.False( writer.CanReconstructCSharpExplicitInterfaceIdentitiesFromPersistedRows()); } - string outputText; - int exitCode; - lock (TestConsoleLock.Gate) + (int ExitCode, string Output) RunBackfill(params string[] additionalArguments) { - var originalOut = Console.Out; - using var output = new StringWriter(); - try - { - Console.SetOut(output); - exitCode = IndexCommandRunner.RunBackfillFold( - ["--db", dbPath, "--json"], - _jsonOptions); - outputText = output.ToString(); - } - finally + var arguments = new List { "--db", dbPath }; + arguments.AddRange(additionalArguments); + lock (TestConsoleLock.Gate) { - Console.SetOut(originalOut); + var originalOut = Console.Out; + using var output = new StringWriter(); + try + { + Console.SetOut(output); + var exitCode = IndexCommandRunner.RunBackfillFold( + arguments.ToArray(), + _jsonOptions); + return (exitCode, output.ToString()); + } + finally + { + Console.SetOut(originalOut); + } } } - Assert.Equal(CommandExitCodes.DatabaseError, exitCode); + var dryRun = RunBackfill("--dry-run", "--json"); + Assert.Equal(CommandExitCodes.DatabaseError, dryRun.ExitCode); Assert.Contains( "C# explicit-interface identities cannot be reconstructed", - outputText, + dryRun.Output, + StringComparison.Ordinal); + + var run = RunBackfill("--json"); + Assert.Equal(CommandExitCodes.DatabaseError, run.ExitCode); + Assert.Contains( + "C# explicit-interface identities cannot be reconstructed", + run.Output, StringComparison.Ordinal); using var verifyDb = new DbContext(DbOpenIntent.WriteIndex, dbPath); Assert.Equal( "2", verifyDb.GetMetaString(DbContext.CSharpSymbolNameContractVersionMetaKey)); + using var identity = verifyDb.Connection.CreateCommand(); + identity.CommandText = "SELECT name_folded FROM symbols WHERE name = 'Stable'"; + Assert.Equal("sentinel::stable", identity.ExecuteScalar()); + Assert.Null(verifyDb.GetMetaString(DbWriter.FoldBackfillGraphRefreshPendingMetaKey)); } finally { diff --git a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs index 31ffe4971f..098205b915 100644 --- a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs @@ -11859,9 +11859,29 @@ public void ToolsCall_BackfillFold_RefusesCSharpV3StampWhenLegacySignaturesAreMi StartLine = 1, EndLine = 1, }, + new SymbolRecord + { + FileId = fileId, + Kind = "function", + Name = "Stable", + IdentityNameFolded = "sentinel::stable", + DisplayNameFolded = "stable", + Signature = "void IFoo.Stable() { }", + Line = 1, + StartLine = 1, + EndLine = 1, + }, ]); writer.SetMeta(DbContext.CSharpSymbolNameContractVersionMetaKey, "2"); + var dryRunRequest = JsonNode.Parse("""{"jsonrpc":"2.0","id":4865,"method":"tools/call","params":{"name":"backfill_fold","arguments":{"dry_run":true}}}""")!; + var dryRunResponse = _server.HandleMessage(dryRunRequest)!; + Assert.True(dryRunResponse["result"]!["isError"]?.GetValue() ?? false); + Assert.Contains( + "C# explicit-interface identities cannot be reconstructed", + dryRunResponse["result"]!["content"]![0]!["text"]!.GetValue(), + StringComparison.Ordinal); + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":4866,"method":"tools/call","params":{"name":"backfill_fold","arguments":{}}}""")!; var response = _server.HandleMessage(request)!; @@ -11874,6 +11894,10 @@ public void ToolsCall_BackfillFold_RefusesCSharpV3StampWhenLegacySignaturesAreMi Assert.Equal( "2", _db.GetMetaString(DbContext.CSharpSymbolNameContractVersionMetaKey)); + using var identity = _db.Connection.CreateCommand(); + identity.CommandText = "SELECT name_folded FROM symbols WHERE name = 'Stable'"; + Assert.Equal("sentinel::stable", identity.ExecuteScalar()); + Assert.Null(_db.GetMetaString(DbWriter.FoldBackfillGraphRefreshPendingMetaKey)); } [Fact] From ac7a59f9d68da9b4dbea8654571c5d0c18253a21 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 16:54:49 +0900 Subject: [PATCH 14/18] Preserve future C# contract metadata (#4866) --- .../Database/DbWriter.FoldBackfill.cs | 3 - .../IndexCommandRunnerTests.cs | 82 +++++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/src/CodeIndex/Database/DbWriter.FoldBackfill.cs b/src/CodeIndex/Database/DbWriter.FoldBackfill.cs index 768782debb..2306f1584c 100644 --- a/src/CodeIndex/Database/DbWriter.FoldBackfill.cs +++ b/src/CodeIndex/Database/DbWriter.FoldBackfill.cs @@ -468,9 +468,6 @@ internal bool ResolveFoldBackfillRewriteAll(bool rewriteAll) public bool TryGetNewerCSharpSymbolNameContractVersion(out int storedVersion) { storedVersion = 0; - if (!HasAnyFilesWithLanguage("csharp")) - return false; - var stored = GetMetaString(DbContext.CSharpSymbolNameContractVersionMetaKey); return int.TryParse( stored, diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 92936bc446..61f9cbc265 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -6562,6 +6562,88 @@ FROM symbols } } + [Fact] + public void RunBackfillFold_PreservesNewerCSharpIdentityContractWithoutCSharpFiles_Issue4866Review() + { + var dbPath = CreateTempDbPath("cdidx_backfill_fold_csharp_future_without_csharp"); + var futureVersion = DbContext.CSharpSymbolNameContractVersion + 1; + try + { + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + db.InitializeSchema(); + var writer = new DbWriter(db.Connection); + var fileId = writer.UpsertFile(new FileRecord + { + Path = "src/app.py", + Lang = "python", + Size = 32, + Lines = 1, + Modified = new DateTime(2026, 7, 29, 0, 0, 0, DateTimeKind.Utc), + }); + writer.InsertSymbols([ + new SymbolRecord + { + FileId = fileId, + Kind = "function", + Name = "run", + Line = 1, + StartLine = 1, + EndLine = 1, + }, + ]); + Assert.True(writer.MarkFoldReady()); + writer.SetMeta( + DbContext.CSharpSymbolNameContractVersionMetaKey, + futureVersion.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + (int ExitCode, string Output) RunBackfill(params string[] additionalArguments) + { + var arguments = new List { "--db", dbPath, "--json" }; + arguments.AddRange(additionalArguments); + lock (TestConsoleLock.Gate) + { + var originalOut = Console.Out; + using var output = new StringWriter(); + try + { + Console.SetOut(output); + var exitCode = IndexCommandRunner.RunBackfillFold( + arguments.ToArray(), + _jsonOptions); + return (exitCode, output.ToString()); + } + finally + { + Console.SetOut(originalOut); + } + } + } + + var dryRun = RunBackfill("--dry-run"); + Assert.Equal(CommandExitCodes.DatabaseError, dryRun.ExitCode); + Assert.Contains("newer than supported version", dryRun.Output, StringComparison.Ordinal); + + var run = RunBackfill(); + Assert.Equal(CommandExitCodes.DatabaseError, run.ExitCode); + Assert.Contains("newer than supported version", run.Output, StringComparison.Ordinal); + + using var verifyDb = new DbContext(DbOpenIntent.WriteIndex, dbPath); + Assert.Equal( + futureVersion.ToString(System.Globalization.CultureInfo.InvariantCulture), + verifyDb.GetMetaString(DbContext.CSharpSymbolNameContractVersionMetaKey)); + using var identity = verifyDb.Connection.CreateCommand(); + identity.CommandText = "SELECT name_folded FROM symbols WHERE name = 'run'"; + Assert.Equal("run", identity.ExecuteScalar()); + } + finally + { + SqliteConnection.ClearAllPools(); + DeleteFile(dbPath); + } + } + [Fact] public void RunBackfillFold_DoesNotRewriteCurrentFoldRowsWhenCSharpIsAbsent_Issue4866Review() { From cbe346dd38bfa5ceffa872b87c7a7fec5e0b4c35 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 17:44:12 +0900 Subject: [PATCH 15/18] Distinguish stale C# folds from missing aliases (#4866) --- src/CodeIndex/Database/DbWriter.FoldBackfill.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/CodeIndex/Database/DbWriter.FoldBackfill.cs b/src/CodeIndex/Database/DbWriter.FoldBackfill.cs index 2306f1584c..bb385f2335 100644 --- a/src/CodeIndex/Database/DbWriter.FoldBackfill.cs +++ b/src/CodeIndex/Database/DbWriter.FoldBackfill.cs @@ -108,12 +108,6 @@ private bool AllFoldedColumnsBackfilledCore( @" SELECT (SELECT COUNT(*) FROM symbols WHERE name_folded IS NULL) - + (SELECT COUNT(*) - FROM symbols s - JOIN files f ON f.id = s.file_id - WHERE f.lang = 'csharp' - AND s.name_folded <> codeindex_name_fold(s.name) - AND s.display_name_folded IS NULL) + (SELECT COUNT(*) FROM symbol_references WHERE symbol_name IS NOT NULL AND symbol_name_folded IS NULL) + (SELECT COUNT(*) FROM symbol_references WHERE container_name IS NOT NULL AND container_name_folded IS NULL)", static _ => { }); From 6feeb90c6ba4ab140218f08a7eb246dcf12013ec Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 20:35:11 +0900 Subject: [PATCH 16/18] Resolve explicit C# identities in impact lookup (#4866) --- .../Database/DbReader.GraphQueries.cs | 12 ++++++ tests/CodeIndex.Tests/DbReaderSearchTests.cs | 40 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index 0f3ff62da1..f9857e033a 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -1074,6 +1074,10 @@ private string ResolveSymbolName(string symbolName, string? lang) : allowLeafFallback ? "(s.name = @name COLLATE NOCASE OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @segmentCount AND sql_normalize_name(s.name) = @normalizedName COLLATE NOCASE) OR sql_leaf_name(s.name) = @leafName COLLATE NOCASE)))" : "(s.name = @name COLLATE NOCASE OR (f.lang = 'sql' AND sql_segment_count(s.name) = @segmentCount AND sql_normalize_name(s.name) = @normalizedName COLLATE NOCASE))"; + var csharpExplicitInterfaceClause = allowLeafFallback + ? BuildCSharpExplicitInterfaceShortAliasMatchSql("name") + : BuildCSharpExplicitInterfaceIdentityMatchSql("name"); + nameCondition = $"({nameCondition} OR {csharpExplicitInterfaceClause})"; cmd.CommandText = @"SELECT s.name FROM symbols s JOIN files f ON s.file_id = f.id WHERE " + nameCondition + @" AND " + supportedLangFilter + @" @@ -1090,8 +1094,11 @@ ELSE 5 SqliteCommandPolicy.Add(cmd, "@normalizedNameFolded", NameFold.Fold(normalizedName) ?? normalizedName); SqliteCommandPolicy.Add(cmd, "@leafName", leafName); SqliteCommandPolicy.Add(cmd, "@leafNameFolded", NameFold.Fold(leafName) ?? leafName); + SqliteCommandPolicy.Add(cmd, "@nameLeaf", leafName); + SqliteCommandPolicy.Add(cmd, "@nameLeafFolded", NameFold.Fold(leafName) ?? leafName); SqliteCommandPolicy.Add(cmd, "@segmentCount", segmentCount); SqliteCommandPolicy.Add(cmd, "@allowLeafFallback", allowLeafFallback ? 1 : 0); + AddCSharpExplicitInterfaceIdentityQueryParameter(cmd, "name", normalizedSymbolName); if (_foldReady) SqliteCommandPolicy.Add(cmd, "@nameFolded", NameFold.Fold(normalizedSymbolName) ?? normalizedSymbolName); using var reader = cmd.ExecuteTrackedReader(); @@ -2404,6 +2411,10 @@ private ImpactDefinitionResolution ResolveImpactDefinitions( : allowLeafFallback ? "(s.name = @resolvedName COLLATE NOCASE OR (f.lang = 'sql' AND ((sql_segment_count(s.name) = @resolvedNameSegmentCount AND sql_normalize_name(s.name) = @resolvedNameNormalized COLLATE NOCASE) OR sql_leaf_name(s.name) = @resolvedNameLeaf COLLATE NOCASE)))" : "(s.name = @resolvedName COLLATE NOCASE OR (f.lang = 'sql' AND sql_segment_count(s.name) = @resolvedNameSegmentCount AND sql_normalize_name(s.name) = @resolvedNameNormalized COLLATE NOCASE))"; + var csharpExplicitInterfaceClause = allowLeafFallback + ? BuildCSharpExplicitInterfaceShortAliasMatchSql("resolvedName") + : BuildCSharpExplicitInterfaceIdentityMatchSql("resolvedName"); + nameCondition = $"({nameCondition} OR {csharpExplicitInterfaceClause})"; if (SqlNameResolver.HasQualifier(resolvedName)) { var containerNameSql = GetSymbolColumnSql("container_name", "''"); @@ -2530,6 +2541,7 @@ CROSS JOIN definition_stats stats SqliteCommandPolicy.Add(cmd, "@resolvedNameLeafFolded", FoldNameForLanguage(leafName, lang)); SqliteCommandPolicy.Add(cmd, "@resolvedNameSegmentCount", segmentCount); SqliteCommandPolicy.Add(cmd, "@allowLeafFallback", allowLeafFallback ? 1 : 0); + AddCSharpExplicitInterfaceIdentityQueryParameter(cmd, "resolvedName", resolvedName); if (SqlNameResolver.HasQualifier(resolvedName)) { var container = GetQualifiedQueryContainer(resolvedName); diff --git a/tests/CodeIndex.Tests/DbReaderSearchTests.cs b/tests/CodeIndex.Tests/DbReaderSearchTests.cs index bdb9890f83..279a0b7beb 100644 --- a/tests/CodeIndex.Tests/DbReaderSearchTests.cs +++ b/tests/CodeIndex.Tests/DbReaderSearchTests.cs @@ -1926,6 +1926,12 @@ public sealed class ArrayFactory { public Demo.Service[] Service() => []; } + + public sealed class ExternalService : External.IFoo + { + void External.IFoo.Run(TExternal value) { ExternalHelper(); } + public void ExternalHelper() { } + } """; var fileId = writer.UpsertFile(new FileRecord { @@ -2122,6 +2128,40 @@ FROM symbol_references reader.SearchSymbols("Run", limit: 20, lang: "csharp", exact: true), result => result.SymbolId == fooRun.SymbolId); + var qualifiedExternalImpact = reader.AnalyzeImpact( + "External.IFoo.Run", + maxDepth: 0, + limit: 20, + lang: "csharp", + pathPatterns: [path]); + var qualifiedExternalDefinition = Assert.Single(qualifiedExternalImpact.Definitions); + Assert.Contains( + "External.IFoo.Run", + qualifiedExternalDefinition.Signature, + StringComparison.Ordinal); + Assert.NotEqual("no_matching_definition", qualifiedExternalImpact.ZeroResultReason); + + var shortExplicitImpact = reader.AnalyzeImpact( + "Run", + maxDepth: 0, + limit: 20, + lang: "csharp", + pathPatterns: [path]); + Assert.Contains( + shortExplicitImpact.Definitions, + definition => definition.SymbolId == qualifiedExternalDefinition.SymbolId); + + var shortExternalImpact = reader.AnalyzeImpact( + "ExternalHelper", + maxDepth: 1, + limit: 20, + lang: "csharp", + pathPatterns: [path]); + Assert.Contains( + shortExternalImpact.Callers, + caller => caller.CallerName == "Run" + && caller.CallerSymbolId == qualifiedExternalDefinition.SymbolId); + var valueResults = reader.SearchSymbols("IFoo.Value", lang: "csharp", exact: true); Assert.Equal(2, valueResults.Count); Assert.Equal(2, valueResults.Select(result => result.SymbolId).Distinct().Count()); From f3b454b8a75f6cd28d9b47c2ec3755a0fc350798 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 21:22:46 +0900 Subject: [PATCH 17/18] Fix adversarial C# identity edge cases (#4866) --- .../Database/DbReader.GraphQueries.cs | 77 +++++++---- .../PostExtractionHookMutationMaterializer.cs | 10 +- .../Symbols/CSharpSymbolNameNormalizer.cs | 93 +++++++++++++ tests/CodeIndex.Tests/DbReaderSearchTests.cs | 122 ++++++++++++++++++ .../PostExtractionHookContractTests.cs | 14 ++ 5 files changed, 293 insertions(+), 23 deletions(-) diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index f9857e033a..30b8adf396 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -1169,6 +1169,9 @@ ELSE NULL : _foldReady ? " OR (f.lang = 'csharp' AND r.symbol_name_folded IN (" + string.Join(", ", polymorphicCSharpSymbolNames.Select((_, i) => $"@polymorphicSymbolNameFolded{i}")) + "))" : " OR (f.lang = 'csharp' AND r.symbol_name COLLATE NOCASE IN (" + string.Join(", ", polymorphicCSharpSymbolNames.Select((_, i) => $"@polymorphicSymbolName{i}")) + "))"; + var unscopedPolymorphicNameCondition = hasIdentityTargetScope + ? string.Empty + : polymorphicNameCondition; var nameCondition = _foldReady ? allowSqlLeafFallback ? @" @@ -1200,7 +1203,7 @@ AND r.resolution_state IN ('resolved', 'resolved_group') OR ( COALESCE(r.resolution_state, 'unresolved') NOT IN ('resolved', 'resolved_group') " + nameCondition + @" - )" + polymorphicNameCondition + @" + )" + unscopedPolymorphicNameCondition + @" )" : nameCondition; // impact BFS must share the call-graph contract with `callers`/`callees`/`hotspots`, @@ -1361,34 +1364,54 @@ private static string BuildImpactTraversalNodeKey(long? symbolId, string name) // 定義を通じてシンボル名を解決し、"run" → "Run" のようなケース違いを補正する。 // 見つからなければユーザ入力をフォールバック使用。 var resolvedName = ResolveSymbolName(symbolName, lang); - var rootDefinitionResolution = ResolveImpactDefinitions(symbolName, limit, lang, pathPatterns, excludePathPatterns, excludeTests); + var hasResolvedIdentityGraph = _referenceIdentityContractCurrent; + var canResolveQualifiedCSharpIdentity = + hasResolvedIdentityGraph + && SqlNameResolver.HasQualifier(symbolName) + && lang is null or "csharp"; + var rootDefinitionLimit = canResolveQualifiedCSharpIdentity + ? DefaultImpactGraphStateEntryBudget + : limit; + var rootDefinitionResolution = ResolveImpactDefinitions(symbolName, rootDefinitionLimit, lang, pathPatterns, excludePathPatterns, excludeTests); if (rootDefinitionResolution.Definitions.Count == 0 && !string.Equals(symbolName, resolvedName, StringComparison.Ordinal)) { - rootDefinitionResolution = ResolveImpactDefinitions(resolvedName, limit, lang, pathPatterns, excludePathPatterns, excludeTests); + rootDefinitionResolution = ResolveImpactDefinitions(resolvedName, rootDefinitionLimit, lang, pathPatterns, excludePathPatterns, excludeTests); } var rootDefinitions = rootDefinitionResolution.Definitions; var rootDefinitionPaths = rootDefinitions .Select(definition => definition.Path) .ToHashSet(StringComparer.OrdinalIgnoreCase); - var hasResolvedIdentityGraph = _referenceIdentityContractCurrent; - if (hasResolvedIdentityGraph && rootDefinitionPaths.Count > 1) + var qualifiedCSharpRootSymbolIds = + canResolveQualifiedCSharpIdentity + && rootDefinitions.Count > 0 + && rootDefinitions.All(definition => definition.Lang == "csharp") + && rootDefinitions.All(definition => definition.SymbolId != null) + && rootDefinitionResolution.LogicalCount == rootDefinitions.Count + ? rootDefinitions + .Select(definition => definition.SymbolId!.Value) + .ToHashSet() + : []; + if (hasResolvedIdentityGraph + && rootDefinitionPaths.Count > 1 + && qualifiedCSharpRootSymbolIds.Count == 0) { return ([], false, null, ImpactTerminationReasons.Completed, []); } - var qualifiedRootSymbolId = hasResolvedIdentityGraph - && SqlNameResolver.HasQualifier(symbolName) - && rootDefinitions.Count == 1 - && rootDefinitions[0].Lang == "csharp" - ? rootDefinitions[0].SymbolId - : null; var ambiguousMRootSymbolId = hasResolvedIdentityGraph && rootDefinitions.Count == 1 && lang is "matlab" or "objc" && string.Equals(rootDefinitions[0].Lang, lang, StringComparison.Ordinal) ? rootDefinitions[0].SymbolId : null; - var identityRootSymbolId = qualifiedRootSymbolId ?? ambiguousMRootSymbolId; + var identityRootSymbolIds = qualifiedCSharpRootSymbolIds.Count > 0 + ? qualifiedCSharpRootSymbolIds + : ambiguousMRootSymbolId is long ambiguousRootSymbolId + ? [ambiguousRootSymbolId] + : []; + var singleIdentityRootSymbolId = identityRootSymbolIds.Count == 1 + ? identityRootSymbolIds.Single() + : (long?)null; var includeAmbiguousMSource = ambiguousMRootSymbolId != null; var results = new List(); @@ -1396,9 +1419,19 @@ private static string BuildImpactTraversalNodeKey(long? symbolId, string name) var resultWindowEnd = checked(resultOffset + limit); var discoveredResultCount = 0; var visited = new HashSet(StringComparer.OrdinalIgnoreCase); - var rootTraversalNodeKey = BuildImpactTraversalNodeKey(identityRootSymbolId, resolvedName); + var rootTraversalNodeKey = identityRootSymbolIds.Count > 1 + ? $"identity:{NameFold.Fold(symbolName) ?? symbolName}" + : BuildImpactTraversalNodeKey(singleIdentityRootSymbolId, resolvedName); var queue = new Queue<(string Symbol, long? SymbolId, string NodeKey, int Depth)>(); - queue.Enqueue((resolvedName, identityRootSymbolId, rootTraversalNodeKey, 0)); + if (identityRootSymbolIds.Count > 0) + { + foreach (var identityRootSymbolId in identityRootSymbolIds.Order()) + queue.Enqueue((resolvedName, identityRootSymbolId, rootTraversalNodeKey, 0)); + } + else + { + queue.Enqueue((resolvedName, null, rootTraversalNodeKey, 0)); + } visited.Add(resolvedName); var truncated = false; var maxDepthReached = false; @@ -1435,7 +1468,7 @@ private static string BuildImpactTraversalNodeKey(long? symbolId, string name) ? new Dictionary(StringComparer.OrdinalIgnoreCase) : null; if (withPaths) - pathNodesByKey![rootTraversalNodeKey] = ResolveImpactPathNode(resolvedName, identityRootSymbolId, kind: null, lang, referencePath: null, referenceLine: null); + pathNodesByKey![rootTraversalNodeKey] = ResolveImpactPathNode(resolvedName, singleIdentityRootSymbolId, kind: null, lang, referencePath: null, referenceLine: null); while (queue.Count > 0 && discoveredResultCount < resultWindowEnd && !graphStateBudgetHit && !boundaryProbeBudgetHit) { @@ -1480,7 +1513,7 @@ private static string BuildImpactTraversalNodeKey(long? symbolId, string name) if (IsCycleEdge(cycleEdge.Caller.Key, cycleEdge.Callee.Key, cycleParentsByKey)) AddImpactCycle(cycles, cycleKeys, BuildCycleMembers(cycleEdge.Caller.Key, cycleEdge.Callee.Key, cycleParentsByKey), cycleNodesByKey); } - if (IsImpactRootCaller(caller, callerName, resolvedName, rootDefinitionPaths, identityRootSymbolId)) + if (IsImpactRootCaller(caller, callerName, resolvedName, rootDefinitionPaths, identityRootSymbolIds)) continue; var callerNodeKey = BuildImpactTraversalNodeKey(callerSymbolId, callerName); var key = BuildImpactVisitedKey(caller, callerName, hasResolvedIdentityGraph); @@ -1610,7 +1643,7 @@ private static string BuildImpactTraversalNodeKey(long? symbolId, string name) callerSymbolId, resolvedName, rootDefinitionPaths, - identityRootSymbolId, + identityRootSymbolIds, visited, cycleParentsByKey, cycleNodesByKey, @@ -1767,10 +1800,10 @@ private static bool IsImpactRootCaller( string callerName, string resolvedName, HashSet rootDefinitionPaths, - long? identityRootSymbolId) + IReadOnlySet identityRootSymbolIds) { - if (identityRootSymbolId is long rootSymbolId && caller.CallerSymbolId is long callerSymbolId) - return rootSymbolId == callerSymbolId; + if (identityRootSymbolIds.Count > 0 && caller.CallerSymbolId is long callerSymbolId) + return identityRootSymbolIds.Contains(callerSymbolId); return string.Equals(callerName, resolvedName, StringComparison.OrdinalIgnoreCase) && (rootDefinitionPaths.Count == 0 || rootDefinitionPaths.Contains(caller.Path)); } @@ -1780,7 +1813,7 @@ private ImpactBoundaryInspection InspectBoundaryCallers( long? symbolId, string resolvedName, HashSet rootDefinitionPaths, - long? identityRootSymbolId, + IReadOnlySet identityRootSymbolIds, HashSet visited, Dictionary> cycleParentsByKey, Dictionary cycleNodesByKey, @@ -1819,7 +1852,7 @@ private ImpactBoundaryInspection InspectBoundaryCallers( if (IsCycleEdge(cycleEdge.Caller.Key, cycleEdge.Callee.Key, cycleParentsByKey)) AddImpactCycle(cycles, cycleKeys, BuildCycleMembers(cycleEdge.Caller.Key, cycleEdge.Callee.Key, cycleParentsByKey), cycleNodesByKey); } - var isRoot = IsImpactRootCaller(caller, callerName, resolvedName, rootDefinitionPaths, identityRootSymbolId); + var isRoot = IsImpactRootCaller(caller, callerName, resolvedName, rootDefinitionPaths, identityRootSymbolIds); if (isRoot) continue; diff --git a/src/CodeIndex/Indexer/Hooks/PostExtractionHookMutationMaterializer.cs b/src/CodeIndex/Indexer/Hooks/PostExtractionHookMutationMaterializer.cs index f2b16a55c0..6c36fdeb51 100644 --- a/src/CodeIndex/Indexer/Hooks/PostExtractionHookMutationMaterializer.cs +++ b/src/CodeIndex/Indexer/Hooks/PostExtractionHookMutationMaterializer.cs @@ -76,11 +76,19 @@ internal static void RefreshLanguageIdentity(string? language, IEnumerable + /// Preserve an explicit-interface qualifier when a post-extraction hook changes only the + /// public member name. The hook cannot edit the internal identity fields, and the persisted + /// signature intentionally retains the original source spelling, so rebuilding solely from + /// the new name would otherwise discard the qualifier. + /// + /// post-extraction hook が公開 member 名だけを変更した場合に、明示的 interface 修飾子を + /// 保持する。hook は内部 identity field を編集できず、永続 signature は元の source + /// 表記を意図的に保持するため、新しい名前だけから再構築すると修飾子が失われてしまう。 + /// + internal static string? RebuildExplicitInterfaceIdentityAfterNameMutation( + string name, + string? signature, + string kind, + string? previousIdentityNameFolded, + string? previousDisplayNameFolded) + { + if (string.IsNullOrWhiteSpace(name) + || string.IsNullOrWhiteSpace(signature) + || string.IsNullOrWhiteSpace(previousIdentityNameFolded) + || string.IsNullOrWhiteSpace(previousDisplayNameFolded) + || kind is not ("function" or "test.method" or "property" or "event")) + { + return null; + } + + var normalizedName = NormalizeVerbatimIdentifiers(name); + var newDisplayNameFolded = NameFold.Fold(normalizedName) ?? normalizedName; + if (string.Equals( + newDisplayNameFolded, + previousDisplayNameFolded, + StringComparison.Ordinal)) + { + return null; + } + + var memberSeparator = previousIdentityNameFolded.LastIndexOf('.'); + if (memberSeparator <= 0 + || memberSeparator == previousIdentityNameFolded.Length - 1) + { + return null; + } + + var previousLeaf = previousIdentityNameFolded[(memberSeparator + 1)..]; + var aritySeparator = previousLeaf.LastIndexOf('`'); + var aritySuffix = string.Empty; + if (aritySeparator >= 0) + { + aritySuffix = previousLeaf[aritySeparator..]; + if (aritySeparator == 0 + || aritySuffix.Length == 1 + || !aritySuffix.AsSpan(1).ToString().All(char.IsDigit)) + { + return null; + } + previousLeaf = previousLeaf[..aritySeparator]; + } + if (!string.Equals( + previousLeaf, + previousDisplayNameFolded, + StringComparison.Ordinal)) + { + return null; + } + + // Only preserve the old qualifier when the unchanged declaration header still contains + // that old qualified member. This avoids carrying an explicit identity across a hook + // mutation that converted the record into an ordinary declaration. + // 変更後も declaration header に元の修飾 member が残る場合だけ修飾子を保持し、 + // 通常宣言へ変換した hook mutation に古い explicit identity を持ち越さない。 + var decodedSignature = ExactSourceSearchNormalizer.NormalizeCSharpUnicodeEscapes( + signature, + out _); + var declarationHeader = decodedSignature[..FindDeclarationBodyStart(decodedSignature)]; + declarationHeader = TypeWhitespaceRegex.Replace(declarationHeader, " "); + declarationHeader = TypeDotWhitespaceRegex.Replace(declarationHeader, "."); + declarationHeader = NormalizeVerbatimIdentifiers(declarationHeader); + var declarationHeaderFolded = + NameFold.Fold(declarationHeader) ?? declarationHeader; + var previousQualifiedMember = + previousIdentityNameFolded[..(memberSeparator + 1)] + previousLeaf; + if (!declarationHeaderFolded.Contains( + previousQualifiedMember, + StringComparison.Ordinal)) + { + return null; + } + + return previousIdentityNameFolded[..(memberSeparator + 1)] + + newDisplayNameFolded + + aritySuffix; + } + private static string? TryBuildExplicitInterfaceIdentityNameFolded( string name, string signature, diff --git a/tests/CodeIndex.Tests/DbReaderSearchTests.cs b/tests/CodeIndex.Tests/DbReaderSearchTests.cs index 279a0b7beb..e29518afe8 100644 --- a/tests/CodeIndex.Tests/DbReaderSearchTests.cs +++ b/tests/CodeIndex.Tests/DbReaderSearchTests.cs @@ -2409,6 +2409,128 @@ FROM symbol_references exact: true)).SymbolId); } + [Fact] + public void AnalyzeImpact_QualifiedExplicitInterfaceIdentityTraversesAllDefinitionIds_Issue4866Review() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_explicit_interface_impact_4866"); + var dbPath = Path.Combine(project.Root, "codeindex.db"); + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + db.InitializeSchema(); + var writer = new DbWriter(db.Connection); + const string contractPath = "src/IFoo.cs"; + const string contractContent = """ + namespace Demo; + + public interface IFoo + { + void Run(); + } + """; + const string servicePath = "src/Service.cs"; + const string serviceContent = """ + namespace Demo; + + public sealed class Service : IFoo + { + void IFoo.Run() { } + public void Run() { } + public void CallInterface(IFoo target) { target.Run(); } + public void CallPublic() { Run(); } + } + """; + + var contractFileId = writer.UpsertFile(new FileRecord + { + Path = contractPath, + Lang = "csharp", + Size = contractContent.Length, + Lines = contractContent.Count(ch => ch == '\n') + 1, + Modified = new DateTime(2026, 7, 29, 0, 0, 0, DateTimeKind.Utc), + }); + var serviceFileId = writer.UpsertFile(new FileRecord + { + Path = servicePath, + Lang = "csharp", + Size = serviceContent.Length, + Lines = serviceContent.Count(ch => ch == '\n') + 1, + Modified = new DateTime(2026, 7, 29, 0, 0, 1, DateTimeKind.Utc), + }); + writer.InsertChunks([ + new ChunkRecord + { + FileId = contractFileId, + ChunkIndex = 0, + StartLine = 1, + EndLine = contractContent.Count(ch => ch == '\n') + 1, + Content = contractContent, + }, + new ChunkRecord + { + FileId = serviceFileId, + ChunkIndex = 0, + StartLine = 1, + EndLine = serviceContent.Count(ch => ch == '\n') + 1, + Content = serviceContent, + }, + ]); + var contractSymbols = SymbolExtractor.Extract( + contractFileId, + "csharp", + contractContent, + filePath: contractPath); + var serviceSymbols = SymbolExtractor.Extract( + serviceFileId, + "csharp", + serviceContent, + filePath: servicePath); + SymbolExtractor.ApplyFamilyScope( + contractSymbols, + FileIndexer.DeriveFallbackFamilyScopeKey(contractPath)); + SymbolExtractor.ApplyFamilyScope( + serviceSymbols, + FileIndexer.DeriveFallbackFamilyScopeKey(servicePath)); + writer.InsertSymbols(contractSymbols); + writer.InsertSymbols(serviceSymbols); + writer.InsertReferences(ReferenceExtractor.Extract( + contractFileId, + "csharp", + contractContent, + contractSymbols, + path: contractPath)); + writer.InsertReferences(ReferenceExtractor.Extract( + serviceFileId, + "csharp", + serviceContent, + serviceSymbols, + path: servicePath)); + writer.BackfillFoldedColumns(rewriteAll: true); + Assert.True(writer.MarkFoldReady()); + writer.MarkCSharpSymbolNameContractReady(); + writer.MarkGraphReady(); + + using var reader = new DbReader(db.Connection); + var impact = reader.AnalyzeImpact( + "IFoo.Run", + maxDepth: 1, + limit: 20, + lang: "csharp", + pathPatterns: ["src/**"]); + + Assert.Equal(2, impact.Definitions.Count); + Assert.Equal( + [contractPath, servicePath], + impact.Definitions.Select(definition => definition.Path).Order().ToArray()); + var interfaceCaller = Assert.Single( + impact.Callers, + caller => caller.CallerName == "CallInterface"); + Assert.Contains( + interfaceCaller.CalleeSymbolId, + impact.Definitions.Select(definition => definition.SymbolId)); + Assert.DoesNotContain( + impact.Callers, + caller => caller.CallerName == "CallPublic"); + } + [Fact] public void SearchSymbols_QualifiedExactWithoutLanguagePreservesLegacyCSharpAndTerraform_Issue4866Review() { diff --git a/tests/CodeIndex.Tests/PostExtractionHookContractTests.cs b/tests/CodeIndex.Tests/PostExtractionHookContractTests.cs index e42f0fb769..9a49489fc0 100644 --- a/tests/CodeIndex.Tests/PostExtractionHookContractTests.cs +++ b/tests/CodeIndex.Tests/PostExtractionHookContractTests.cs @@ -209,6 +209,18 @@ public void MutationMaterializer_RecomputesCSharpExplicitInterfaceIdentityAfterH StartLine = 2, EndLine = 2, }, + new() + { + FileId = 7, + Kind = "function", + Name = "Execute", + Signature = "void IFoo.Run()", + IdentityNameFolded = "ifoo.run", + DisplayNameFolded = "run", + Line = 3, + StartLine = 3, + EndLine = 3, + }, }; PostExtractionHookMutationMaterializer.RefreshLanguageIdentity("csharp", symbols); @@ -217,5 +229,7 @@ public void MutationMaterializer_RecomputesCSharpExplicitInterfaceIdentityAfterH Assert.Equal("run", symbols[0].DisplayNameFolded); Assert.Null(symbols[1].IdentityNameFolded); Assert.Null(symbols[1].DisplayNameFolded); + Assert.Equal("ifoo.execute", symbols[2].IdentityNameFolded); + Assert.Equal("execute", symbols[2].DisplayNameFolded); } } From dd7ba8cee67ea14766aa6ace7fcb3f14c37b5f90 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 22:35:41 +0900 Subject: [PATCH 18/18] Handle missing impact root identity in boundary probes (#4866) --- src/CodeIndex/Database/DbReader.GraphQueries.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index 30b8adf396..aeb1b506be 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -1800,10 +1800,13 @@ private static bool IsImpactRootCaller( string callerName, string resolvedName, HashSet rootDefinitionPaths, - IReadOnlySet identityRootSymbolIds) + IReadOnlySet? identityRootSymbolIds) { - if (identityRootSymbolIds.Count > 0 && caller.CallerSymbolId is long callerSymbolId) + if (identityRootSymbolIds is { Count: > 0 } + && caller.CallerSymbolId is long callerSymbolId) + { return identityRootSymbolIds.Contains(callerSymbolId); + } return string.Equals(callerName, resolvedName, StringComparison.OrdinalIgnoreCase) && (rootDefinitionPaths.Count == 0 || rootDefinitionPaths.Contains(caller.Path)); } @@ -1813,7 +1816,7 @@ private ImpactBoundaryInspection InspectBoundaryCallers( long? symbolId, string resolvedName, HashSet rootDefinitionPaths, - IReadOnlySet identityRootSymbolIds, + IReadOnlySet? identityRootSymbolIds, HashSet visited, Dictionary> cycleParentsByKey, Dictionary cycleNodesByKey,