From c5ad59044f29e150fc2432623028c05e24ab5e53 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 16 Sep 2026 10:36:56 +0900 Subject: [PATCH] Fix exact indexed file identity in LSP navigation (#5373) --- DEVELOPER_GUIDE.md | 14 ++ TESTING_GUIDE.md | 16 +++ USER_GUIDE.md | 11 ++ changelog.d/unreleased/5373.fixed.md | 17 +++ .../Database/DbReader.AuditPartitions.cs | 11 +- .../Database/DbReader.ExactFilePath.cs | 18 +++ src/CodeIndex/Database/DbReader.cs | 12 +- .../Lsp/LspServer.PositionResolution.cs | 20 ++- .../CodeIndex.Tests/LspCallHierarchyTests.cs | 123 +++++++++++++++++- 9 files changed, 218 insertions(+), 24 deletions(-) create mode 100644 changelog.d/unreleased/5373.fixed.md create mode 100644 src/CodeIndex/Database/DbReader.ExactFilePath.cs diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index f8dd25b5db..d4c5bf2031 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1,5 +1,12 @@ # Developer Guide +LSP definition/declaration and reference position resolution selects local candidates +by exact indexed path before query limits. `DbReader.BeginExactFilePath` shares the +binary-equality scope used by audit partitions; keep it limited to local lookups and +dispose it before following authoritative targets across files. Position matching +also rejects foreign paths with ordinal equality. Path glob syntax and filesystem +case policy do not change this indexed identity; no schema migration is needed. + Python string masking preserves UTF-16 reference coordinates for persisted CLI columns and LSP ranges. See [Python reference coordinates](docs/python-reference-coordinates.md#english) for examples and the normal index refresh required for unchanged files from older @@ -4630,6 +4637,13 @@ API version 1 の互換性を維持し、新しい guard scope は contract vers # 開発者ガイド +LSP の定義・宣言・参照の位置解決では、候補数の上限を適用する前に索引内のパスを +完全一致で絞ります。`DbReader.BeginExactFilePath` は監査の分割検索と同じバイナリ比較の +スコープを使います。文書内の検索だけに適用し、確定済みの参照先を別ファイルへ辿る前に +必ず破棄してください。位置照合でも順序比較によるパスの一致を確認し、別ファイルを +除外します。パスの glob 構文やファイルシステムの大小文字規則は索引内の識別を変えず、 +スキーマの移行も不要です。 + Python の文字列マスクは UTF-16 の参照座標を保持し、保存済みの CLI 参照列と LSP の範囲を 一致させます。具体例と、古い抽出契約で保存した未変更ファイルに必要な通常の索引更新は [Python の参照座標](docs/python-reference-coordinates.md#日本語)を参照してください。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 9fbdb3697d..5b45cd1d30 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -7,6 +7,14 @@ Compare ordinary and named rows with `--count`, `--format count`, and named `--summary-only` on net8/net9. Keep exact/substring controls, counts above the presentation limit, child/parent file totals, count authority, and human output. +Issue #5373 extends `LspCallHierarchyTests` with initialized, framed definition, +declaration and reference navigation over exact indexed files. Keep case collisions, +literal path characters, directory-prefix decoys, pre-limit selection, local fallback +and cross-file authoritative targets alongside overload/negative-evidence controls. +Rename fixture database paths to model collisions without a case-sensitive filesystem. +Run the LSP suites and audit partition recovery tests on net8/net9 because both use +the shared exact-file query scope. + Python reference-coordinate coverage (#5362) checks empty/ASCII/BMP/astral, escaped, raw, byte, adjacent, triple-quoted and literal f-strings with repeated same-line calls and string/comment decoys. Run `--filter FullyQualifiedName~Issue5362` @@ -1520,6 +1528,14 @@ Issue #5300 のテストは隣接・入れ子の C# callable、対象行の除 `--format count`、名前付きの `--summary-only` を比較してください。`--exact` と `--exact-substring` の対照ケース、表示上限を超える件数、子・親のファイル総数、件数の確実性、人間向け出力を維持します。 +Issue #5373 は `LspCallHierarchyTests` を拡張し、初期化済みでフレーム形式の LSP を通じて、 +索引内のファイルが完全一致する定義・宣言・参照ナビゲーションを検証します。大小文字の +衝突、パス内の特殊文字、ディレクトリの接頭辞による偽候補、上限適用前の選択、文書内の +フォールバック、別ファイルの確定済み参照先を、オーバーロードと否定根拠の対照テストと +併せて維持してください。大小文字を区別しないファイルシステムでも再現できるよう、 +fixture の DB 内のパスを書き換えます。完全一致スコープを監査の分割検索と共有するため、 +LSP 全体と監査の復旧テストを net8/net9 で実行してください。 + Python の参照座標テスト (#5362) は、空・ASCII・BMP・補助平面文字、エスケープ、raw、 bytes、隣接文字列、三重引用符、式のない f-string を、同一行の複数呼び出しと文字列・ コメント内の偽の呼び出しを含めて検証します。`--filter FullyQualifiedName~Issue5362` diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 2feae1e0ef..ddc99181c8 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -3636,6 +3636,12 @@ MCP stdio is line protocol: send one compact UTF-8 JSON-RPC object per LF-delimi ### LSP Server (for LSP-native editors) +Definition/declaration and reference navigation select document-local candidates by +exact indexed file identity before candidate limits. Case-colliding paths, literal +glob characters and directory prefixes cannot turn another file's declaration into +a declaration at the cursor. Authoritative cross-file targets and overload selection +are preserved. This query fix requires no reindex. + Indexed call hierarchy is available through the standard prepare/incoming/outgoing methods. See [supported languages, error behavior and limits](docs/lsp-call-hierarchy.md#english). @@ -7685,6 +7691,11 @@ MCP stdio は line protocol です。LF 区切りの各行に compact な UTF-8 ### LSP サーバー(LSP-native editor 向け) +定義・宣言・参照ナビゲーションでは、候補数の上限を適用する前に、文書内の候補を +索引内のファイルの完全一致で選びます。大小文字だけが異なるパス、glob の特殊文字、 +ディレクトリの接頭辞によって、別ファイルの宣言をカーソル位置の宣言として誤認しません。 +別ファイルの確定済み参照先とオーバーロードの選択は維持され、再索引は不要です。 + `cdidx lsp --db .cdidx/codeindex.db` は read-only の Language Server Protocol サーバーを stdio で起動します。既存の CodeIndex database を再利用し、 任意の LSP command を起動できるが MCP には対応していない editor 向けに diff --git a/changelog.d/unreleased/5373.fixed.md b/changelog.d/unreleased/5373.fixed.md new file mode 100644 index 0000000000..9f80be6f27 --- /dev/null +++ b/changelog.d/unreleased/5373.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 5373 +affected: + - src/CodeIndex/Lsp/LspServer.PositionResolution.cs + - src/CodeIndex/Database/DbReader.ExactFilePath.cs + - tests/CodeIndex.Tests/LspCallHierarchyTests.cs +--- + +## English + +- **LSP navigation selects local candidates by exact indexed file identity (#5373)** — Definition, declaration and reference lookups no longer treat case-colliding paths, literal glob characters or directory prefixes as the requested file. Exact selection happens before candidate limits, while overload resolution and authoritative references across files retain their existing behavior. No reindex is required. + +## 日本語 + +- **LSP のナビゲーションで文書内の候補を索引内のファイルの完全一致で選択します (#5373)** — 定義・宣言・参照の検索で、大小文字だけが異なるパス、glob の特殊文字を含むパス、ディレクトリの接頭辞から別ファイルを誤選択しなくなりました。候補数の上限より前に完全一致で絞り、オーバーロード解決や別ファイルの確定済み参照先への移動は従来の動作を維持します。再索引は不要です。 diff --git a/src/CodeIndex/Database/DbReader.AuditPartitions.cs b/src/CodeIndex/Database/DbReader.AuditPartitions.cs index df8a9a522d..317291a08a 100644 --- a/src/CodeIndex/Database/DbReader.AuditPartitions.cs +++ b/src/CodeIndex/Database/DbReader.AuditPartitions.cs @@ -2,17 +2,8 @@ namespace CodeIndex.Database; public partial class DbReader { - private static readonly AsyncLocal AuditPartitionPath = new(); - internal static IDisposable BeginAuditPartitionPath(string? path) - => new AuditPartitionPathLease(path); - - private sealed class AuditPartitionPathLease : IDisposable - { - private readonly string? _previous = AuditPartitionPath.Value; - internal AuditPartitionPathLease(string? path) => AuditPartitionPath.Value = path; - public void Dispose() => AuditPartitionPath.Value = _previous; - } + => BeginExactFilePath(path); // The caller shares one row budget across all effective child scopes. No counts, // content reads, or unbounded inventory allocation are needed to build the plan. diff --git a/src/CodeIndex/Database/DbReader.ExactFilePath.cs b/src/CodeIndex/Database/DbReader.ExactFilePath.cs new file mode 100644 index 0000000000..6c32731f4a --- /dev/null +++ b/src/CodeIndex/Database/DbReader.ExactFilePath.cs @@ -0,0 +1,18 @@ +namespace CodeIndex.Database; + +public partial class DbReader +{ + private static readonly AsyncLocal ExactFilePath = new(); + + // Apply literal indexed identity before SQL limits, including nested candidate + // queries. Dispose before following a resolved target into another file. + internal static IDisposable BeginExactFilePath(string? path) + => new ExactFilePathLease(path); + + private sealed class ExactFilePathLease : IDisposable + { + private readonly string? _previous = ExactFilePath.Value; + internal ExactFilePathLease(string? path) => ExactFilePath.Value = path; + public void Dispose() => ExactFilePath.Value = _previous; + } +} diff --git a/src/CodeIndex/Database/DbReader.cs b/src/CodeIndex/Database/DbReader.cs index 6493ec0b19..68c5970263 100644 --- a/src/CodeIndex/Database/DbReader.cs +++ b/src/CodeIndex/Database/DbReader.cs @@ -2321,8 +2321,8 @@ private IReadOnlySet LoadColumns(string tableName) internal static void AppendPathFilters(ref string sql, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, bool applyGeneratedFilter = true) { EnsurePathFilterParameterBudget(pathPatterns, excludePathPatterns); - if (AuditPartitionPath.Value != null) - sql += " AND f.path = @auditPartitionPath COLLATE BINARY"; + if (ExactFilePath.Value != null) + sql += " AND f.path = @exactFilePath COLLATE BINARY"; if (applyGeneratedFilter && !IncludeGeneratedScope.Value && GeneratedColumnAvailableScope.Value) sql += " AND COALESCE(f.generated, 0) = 0"; @@ -2349,8 +2349,8 @@ internal static void AppendPathFilters(ref string sql, IReadOnlyList? pa internal static void AddPathFilterParameters(SqliteCommand cmd, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns) { EnsurePathFilterParameterBudget(pathPatterns, excludePathPatterns); - if (AuditPartitionPath.Value is { } partitionPath) - SqliteCommandPolicy.AddText(cmd, "@auditPartitionPath", partitionPath); + if (ExactFilePath.Value is { } exactFilePath) + SqliteCommandPolicy.AddText(cmd, "@exactFilePath", exactFilePath); if (pathPatterns != null) AddPathFilterParameterSet(cmd, "pathPattern", pathPatterns); @@ -2384,8 +2384,8 @@ internal static string BuildPathFiltersSql(string fileAlias, IReadOnlyList ResolveLspDefinitions(PositionTokenContext context) { - var localDefinitions = _reader.GetDefinitions(context.Token, DefaultLimit, exact: true, pathPatterns: [context.IndexedPath]); + var localDefinitions = GetLocalLspDefinitions(context); if (localDefinitions.Count > 0) { var positionDefinitions = FindDefinitionsAtPosition(localDefinitions, context); @@ -49,7 +49,7 @@ private List ResolveLspDefinitions(PositionTokenContext contex private IReadOnlyList ResolveLspReferences(PositionTokenContext context) { - var localDefinitions = _reader.GetDefinitions(context.Token, DefaultLimit, exact: true, pathPatterns: [context.IndexedPath]); + var localDefinitions = GetLocalLspDefinitions(context); if (localDefinitions.Count > 0) { var positionDefinitions = FindDefinitionsAtPosition(localDefinitions, context); @@ -69,10 +69,10 @@ private IReadOnlyList ResolveLspReferences(PositionTokenContext if (authoritativeUnresolved) return []; + using var fileScope = DbReader.BeginExactFilePath(context.IndexedPath); return _reader.SearchReferences( context.Token, DefaultLimit, - pathPatterns: [context.IndexedPath], exact: true); } @@ -93,11 +93,20 @@ private IReadOnlyList ResolveLspReferences(PositionTokenContext } if (workspaceDefinitions.Count == 0 || !HasSingleLspDefinitionTarget(workspaceDefinitions)) - return _reader.AnalyzeSymbol(context.Token, DefaultLimit, pathPatterns: [context.IndexedPath], exact: true).References; + { + using var fileScope = DbReader.BeginExactFilePath(context.IndexedPath); + return _reader.AnalyzeSymbol(context.Token, DefaultLimit, exact: true).References; + } return _reader.AnalyzeSymbol(context.Token, DefaultLimit, exact: true).References; } + private List GetLocalLspDefinitions(PositionTokenContext context) + { + using var fileScope = DbReader.BeginExactFilePath(context.IndexedPath); + return _reader.GetDefinitions(context.Token, DefaultLimit, exact: true); + } + private List ResolveReferenceTargetsAtPosition(PositionTokenContext context) => ResolveReferenceTargetsAtPosition(context, out _); @@ -354,6 +363,9 @@ private List FindDefinitionsAtPosition( var sourceLine = context.Line + 1; return definitions.Where(definition => { + if (!string.Equals(definition.Path, context.IndexedPath, StringComparison.Ordinal)) + return false; + var identifier = GetSymbolIdentifierPosition(definition, context.ResolvedPath); if (identifier.Line != sourceLine) return false; diff --git a/tests/CodeIndex.Tests/LspCallHierarchyTests.cs b/tests/CodeIndex.Tests/LspCallHierarchyTests.cs index 34b303f694..1af8700a4b 100644 --- a/tests/CodeIndex.Tests/LspCallHierarchyTests.cs +++ b/tests/CodeIndex.Tests/LspCallHierarchyTests.cs @@ -76,6 +76,103 @@ public void FramedHierarchy_PreservesIdentityRepeatedSitesAndNavigation_Issue535 Assert.NotEmpty(fixture.Notices); } + [Fact] + public void FramedNavigation_FiltersBeforeLimitsAndPreservesReferenceFallbacks_Issue5373() + { + const string source = "class Calls\n{\n void Leaf() {}\n void Caller(B b)\n {\n Leaf();\n Missing();\n b.Remote();\n }\n}\nclass Decoy\n{\n void Remote() {}\n}"; + const string target = "class B\n{\n public void Remote() {}\n}"; + // Exactly fill the old local-candidate limit with an earlier-sorting foreign file. + var foreign = new StringBuilder(); + for (var index = 0; index < 50; index++) + foreign.AppendLine($"class Foreign{index}\n{{\n void Leaf() {{}}\n void Caller() {{ Leaf(); Missing(); }}\n}}"); + using var fixture = new Fixture(source, "calls.cs", additionalFiles: new Dictionary + { + ["Foreign.cs"] = foreign.ToString(), + ["Target.cs"] = target, + }); + using (var db = new DbContext(DbOpenIntent.WriteIndex, fixture.DbPath)) + using (var command = db.Connection.CreateCommand()) + { + command.CommandText = """ + UPDATE files SET path = 'Calls.cs' WHERE path = 'Foreign.cs'; + DELETE FROM symbol_reference_candidates WHERE reference_id IN + (SELECT id FROM symbol_references WHERE symbol_name IN ('Leaf', 'Missing')); + UPDATE symbol_references SET target_symbol_id = NULL, target_symbol_key = NULL, + resolution_state = 'unresolved', resolution_candidate_count = 0 + WHERE symbol_name IN ('Leaf', 'Missing'); + """; + command.ExecuteNonQuery(); + } + using (var db = new DbContext(DbOpenIntent.QueryOnly, fixture.DbPath)) + { + var reader = new DbReader(db); + var patternCandidates = reader.GetDefinitions("Leaf", 50, exact: true, pathPatterns: ["calls.cs"]); + Assert.Equal(50, patternCandidates.Count); + Assert.All(patternCandidates, candidate => Assert.Equal("Calls.cs", candidate.Path)); + } + foreach (var line in new[] { 2, 5 }) + { + var column = source.Split('\n')[line].IndexOf("Leaf", StringComparison.Ordinal); + var definition = Assert.Single(fixture.Position("textDocument/definition", line, column)["result"]!.AsArray())!; + Assert.Equal(fixture.Uri, definition["uri"]!.GetValue()); + AssertRange(definition["range"]!, 2, 6, 10); + } + // With identity evidence removed, query the invocation to exercise the legacy + // local-name fallback; declarations deliberately require identity-bound references. + var reference = Assert.Single(fixture.Position("textDocument/references", 5, 2)["result"]!.AsArray())!; + Assert.Equal(fixture.Uri, reference["uri"]!.GetValue()); + AssertRange(reference["range"]!, 5, 2, 6); + var missing = Assert.Single(fixture.Position("textDocument/references", 6, 2)["result"]!.AsArray())!; + Assert.Equal(fixture.Uri, missing["uri"]!.GetValue()); + AssertRange(missing["range"]!, 6, 2, 9); + + // Local same-name candidates must not constrain an authoritative cross-file target. + var remote = Assert.Single(fixture.Position("textDocument/definition", 7, 4)["result"]!.AsArray())!; + Assert.Equal(LspServer.PathToUri(Path.Combine(Path.GetDirectoryName(fixture.SourcePath)!, "Target.cs")), remote["uri"]!.GetValue()); + AssertRange(remote["range"]!, 2, 13, 19); + var remoteReference = Assert.Single(fixture.Position("textDocument/references", 7, 4)["result"]!.AsArray())!; + Assert.Equal(fixture.Uri, remoteReference["uri"]!.GetValue()); + AssertRange(remoteReference["range"]!, 7, 4, 10); + } + + [Fact] + public void ExactFileScope_TreatsGlobCharactersLiterallyAndRestoresNestedScopes_Issue5373() + { + const string source = "class Calls\n{\n void Leaf() {}\n void Caller() { Leaf(); }\n}"; + const string indexedPath = "[Calls]*?%_#'日本.cs"; + const string otherPath = "[calls]*?%_#'日本.cs"; + using var fixture = new Fixture(source, additionalFiles: new Dictionary { ["Other.cs"] = source }); + using var db = new DbContext(DbOpenIntent.WriteIndex, fixture.DbPath); + using (var command = db.Connection.CreateCommand()) + { + // These names need not be legal on the host filesystem: only indexed identity matters. + command.CommandText = "UPDATE files SET path = CASE WHEN path = 'Calls.cs' THEN @path ELSE @other END"; + command.Parameters.AddWithValue("@path", indexedPath); + command.Parameters.AddWithValue("@other", otherPath); + command.ExecuteNonQuery(); + } + var reader = new DbReader(db); + using (DbReader.BeginExactFilePath(indexedPath)) + { + AssertPath(indexedPath); + Assert.Throws((Action)(() => + { + using var nested = DbReader.BeginAuditPartitionPath(otherPath); + AssertPath(otherPath); + throw new InvalidOperationException("Scope restoration control"); + })); + AssertPath(indexedPath); + } + Assert.Equal(2, reader.GetDefinitions("Leaf", exact: true).Count); + Assert.Equal(2, reader.SearchReferences("Leaf", exact: true).Count); + + void AssertPath(string path) + { + Assert.Equal(path, Assert.Single(reader.GetDefinitions("Leaf", 1, exact: true)).Path); + Assert.Equal(path, Assert.Single(reader.SearchReferences("Leaf", 1, exact: true)).Path); + } + } + [Theory] [InlineData("cs", "class Café\n{\n void 終了() { }\n void 開始() { var s = \"😀\"; 終了(); }\n}\n", 2, "終了", 3)] [InlineData("py", "def 終了():\n pass\ndef 開始():\n 終了()\n", 0, "終了", 3)] @@ -180,24 +277,42 @@ public void FramedHierarchy_RejectsOversizedResponseAndKeepsSessionUsable_Issue5 Assert.Equal(leaf["data"]!.GetValue(), fixture.Prepare(2, "Leaf")["data"]!.GetValue()); } - [Fact] - public void FramedHierarchy_PreparesWithinExactFileIdentity_Issue5351() + [Theory] + [InlineData("Calls.cs", "calls.cs")] + [InlineData("Calls[0].cs", "Calls0.cs")] + [InlineData("Calls%.cs", "CallsX.cs")] + [InlineData("Calls_.cs", "CallsX.cs")] + [InlineData("Calls.cs", "Calls.cs/Foreign.cs")] + public void FramedNavigation_UsesExactFileIdentity_Issue5351_Issue5373(string fileName, string foreignPath) { const string source = "class B\n{\n public void Leaf() {}\n}\nclass Calls\n{\n void Caller(B b)\n {\n b.Leaf();\n }\n}"; const string foreign = "class Foreign\n{\n\n\n\n\n\n\n void Leaf() {}\n}"; - using var fixture = new Fixture(source, additionalFiles: new Dictionary { ["Foreign.cs"] = foreign }); + using var fixture = new Fixture(source, fileName, additionalFiles: new Dictionary { ["Foreign.cs"] = foreign }); // Model case-colliding indexed files even on a case-insensitive test filesystem. // The foreign file must never be selected or read for this document's position. using (var db = new DbContext(DbOpenIntent.WriteIndex, fixture.DbPath)) using (var command = db.Connection.CreateCommand()) { - command.CommandText = "UPDATE files SET path = 'calls.cs' WHERE path = 'Foreign.cs'"; + command.CommandText = "UPDATE files SET path = @path WHERE path = 'Foreign.cs'"; + command.Parameters.AddWithValue("@path", foreignPath); command.ExecuteNonQuery(); } var declared = fixture.Prepare(2, "Leaf"); var atCall = fixture.Prepare(8, "Leaf"); Assert.Equal(fixture.Uri, atCall["uri"]!.GetValue()); Assert.Equal(declared["data"]!.GetValue(), atCall["data"]!.GetValue()); + foreach (var method in new[] { "textDocument/definition", "textDocument/declaration" }) + { + var location = Assert.Single(fixture.Position(method, 8, 10)["result"]!.AsArray())!; + Assert.Equal(fixture.Uri, location["uri"]!.GetValue()); + AssertRange(location["range"]!, 2, 13, 17); + } + foreach (var position in new[] { (Line: 2, Column: 13), (Line: 8, Column: 10) }) + { + var reference = Assert.Single(fixture.Position("textDocument/references", position.Line, position.Column)["result"]!.AsArray())!; + Assert.Equal(fixture.Uri, reference["uri"]!.GetValue()); + AssertRange(reference["range"]!, 8, 10, 14); + } } [Fact]