diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 2124777b8..561b4efbf 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1125,6 +1125,8 @@ every non-inspect command must reject that cursor family before execution. `symbol_references.reference_kind` stores raw extractor labels. Default call-graph surfaces (`callers`, `callees`, inspect/analyze caller and callee bundles, and their JSON/MCP fields) expose the canonical public vocabulary `call`, `instantiate`, and `subscribe`. The primary `reference_kind`, `reference_kinds`, and `reference_kind_counts` keys use that same vocabulary. Use `--raw-kinds` on `callers` / `callees`, or `references --kind `, when debugging raw extractor output. +`ReferenceRecord.SpanLength` and `symbol_references.span_length` persist the physical matched-token width rather than deriving it from the resolved symbol name; this matters for constructor-chain tokens such as `base`, `super`, and `this`. `DbReader.GetCallees` preserves that span while aggregating counts: it selects the smallest `(line, column_number)` among rows with a stored column, exposes that 1-based pair as `first_line` / nullable `first_column`, and carries the same row's nullable width as `first_length`; `reference_count` remains the independent aggregate. When every contributing legacy row has `column_number IS NULL`, the reader retains the minimum line and a null column. A migrated row can also retain a column with a null span length. CLI/MCP location adapters degrade either case without fabricating precision. + Reference extraction deduplicates only within the same indexed file and language context. When adding extractor paths, include the file id and language hint in shared `seen` keys so same line/column/name edges from polyglot workspaces do not collapse across Java, Rust, C#, SQL, or other language-specific normalization contexts. | Raw kind | Logical graph kind | Notes | @@ -4307,6 +4309,8 @@ inspect 以外の各 command は実行前にその cursor family を拒否しな `symbol_references.reference_kind` には extractor が出力した raw label を保存する。既定の call-graph 表示(`callers`、`callees`、inspect/analyze の caller / callee bundle、および JSON/MCP フィールド)は、公開 canonical 語彙 `call`、`instantiate`、`subscribe` を返す。primary `reference_kind`、`reference_kinds`、`reference_kind_counts` の key はすべて同じ語彙を使う。raw extractor 出力を調べる場合は、`callers` / `callees` の `--raw-kinds`、または `references --kind ` を使う。 +`ReferenceRecord.SpanLength` と `symbol_references.span_length` は、解決後の symbol 名から導出せず、物理的に一致した token 幅を永続化する。これは `base`、`super`、`this` のような constructor-chain token で重要になる。`DbReader.GetCallees` は count を集約しながらその span を保持し、列が保存された row のうち最小の `(line, column_number)` を選び、その 1-based 座標を `first_line` / nullable な `first_column`、同じ row の nullable な幅を `first_length` として公開し、`reference_count` は独立した集約値のままにする。寄与する legacy row がすべて `column_number IS NULL` の場合は最小行と null 列を保持する。移行済み row では列があっても span 長が null の場合がある。CLI/MCP の location adapter はどちらの場合も精度を捏造せず劣化させる。 + | Raw kind | Logical graph kind | 備考 | |---|---|---| | `call` | `call` | 直接実行される呼び出しエッジ。 | diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 22f56ac23..65e538490 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1658,6 +1658,8 @@ By default, `callers` and `callees` return only executable call, construction, a `callers` and `callees` rank results by weighted structural importance by default: `instantiate` edges count as `3.0`, direct `call` edges as `1.0`, and event `subscribe` edges as `0.1`. This keeps factory or composition-root constructor use from being buried under noisy event subscriptions. Use `--rank-by count` to restore raw `reference_count` ordering, or `--rank-by kind` to group `instantiate`, `call`, then `subscribe` before count. JSON rows keep the raw `reference_count` and add `reference_kind_counts` plus `reference_weight_score` so consumers can re-rank without losing the source counts; MCP structured rows expose the same data as `referenceKindCounts` and `referenceWeightScore`. +Grouped `callees` rows preserve the earliest precise call site separately from the aggregate `reference_count`. CLI JSON exposes its 1-based `first_line`, nullable `first_column`, and persisted physical-token `first_length`; MCP uses the corresponding `firstLine`, `firstColumn`, and `firstLength` fields. This keeps resolved constructor-chain calls on their actual `base`, `super`, or `this` token. Compact and quickfix output use the same 1-based column, SARIF carries the same token-sized region, and LSP converts it to a zero-based, non-empty token range. If every contributing legacy reference lacks a column, the column remains `null`, quickfix reports column `0`, SARIF omits `endColumn`, and LSP returns a zero-width range at the start of the retained source line. If a migrated row retains its column but has no persisted span length, SARIF still omits `endColumn` and LSP returns a zero-width range at that column instead of inventing a token width. + ### Outline a single file ```bash @@ -4769,6 +4771,8 @@ cdidx callees AddToGitExclude --exclude-tests `callers` と `callees` は既定で構造的重要度の weighted 順に並びます。`instantiate` は `3.0`、直接 `call` は `1.0`、event `subscribe` は `0.1` として数えるため、factory や composition root の constructor 利用が大量の event subscription に埋もれにくくなります。従来どおり生の `reference_count` で並べたい場合は `--rank-by count`、reference kind を優先して `instantiate`、`call`、`subscribe` の順でまとめたい場合は `--rank-by kind` を使ってください。JSON の各行は生の `reference_count` を維持し、`reference_kind_counts` と `reference_weight_score` も追加で返します。MCP structured row では同じ情報を `referenceKindCounts` と `referenceWeightScore` として返すため、consumer 側で再ランキングできます。 +集約された `callees` 行は、集約値の `reference_count` とは別に、最初の精密な call site を保持します。CLI JSON は 1-based の `first_line`、nullable な `first_column`、永続化された物理 token 幅の `first_length` を返し、MCP は対応する `firstLine`、`firstColumn`、`firstLength` を使います。そのため、解決後の constructor-chain call も実際の `base`、`super`、`this` token を指します。compact / quickfix も同じ 1-based 列を使い、SARIF は同じ token 幅の region、LSP は 0-based の非空 token range に変換します。寄与する legacy reference の全てで列が欠けている場合は列を `null` のまま保ち、quickfix は列 `0`、SARIF は `endColumn` を省略し、LSP は保持した source line の先頭に zero-width range を返します。移行済み row に列はあるものの永続化 span 長がない場合も、SARIF は `endColumn` を省略し、LSP はその列に zero-width range を返すため、token 幅を捏造しません。 + ### 1ファイルのアウトラインを見る ```bash diff --git a/changelog.d/unreleased/4841.fixed.md b/changelog.d/unreleased/4841.fixed.md new file mode 100644 index 000000000..84ad3aecb --- /dev/null +++ b/changelog.d/unreleased/4841.fixed.md @@ -0,0 +1,28 @@ +--- +category: fixed +issues: + - 4841 +affected: + - src/CodeIndex/Models/ReferenceRecord.cs + - src/CodeIndex/Indexer/References/ReferenceExtractor.ReferenceRecords.cs + - src/CodeIndex/Indexer/References/ReferenceExtractor.PrimaryConstructors.cs + - src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallReferences.cs + - src/CodeIndex/Indexer/References/Support/JvmMethodReferenceExtractor.cs + - src/CodeIndex/Indexer/Hooks/PostExtractionHookMutationMaterializer.cs + - src/CodeIndex/Database/DbContext.SchemaInitialization.cs + - src/CodeIndex/Database/DbWriter.ReferenceSql.cs + - src/CodeIndex/Database/DbWriter.References.cs + - src/CodeIndex/Database/DbReader.GraphQueries.cs + - src/CodeIndex/Models/QueryResults.cs + - src/CodeIndex/Cli/QueryCommandRunner.Graph.cs + - src/CodeIndex/Cli/QueryCommandRunner.Locations.cs + - src/CodeIndex/Mcp/McpToolHandlers.Graph.cs +--- + +## English + +- **`callees` now preserves the first physical call-site span in every location output (#4841)** — following the graph-location fidelity work in #4417, reference rows persist the matched token width and aggregated callee rows expose its deterministic 1-based column and length separately from `reference_count`; quickfix, LSP, SARIF, JSON, compact, and MCP output point at the called token—including resolved `base` / `super` / `this` constructor chains—while legacy rows without complete spans degrade explicitly. + +## 日本語 + +- **`callees` がすべての location 出力で最初の物理 call-site span を保持するようになりました (#4841)** — #4417 の graph location fidelity 対応に続き、reference row は一致した token 幅を永続化し、集約された callee 行は `reference_count` とは別に決定的な 1-based 列と長さを公開します。quickfix、LSP、SARIF、JSON、compact、MCP は、解決済みの `base` / `super` / `this` constructor chain を含めて実際の呼び出し token を指し、完全な span を持たない legacy row は明示的に劣化します。 diff --git a/src/CodeIndex/Cli/ProjectionFieldRegistry.cs b/src/CodeIndex/Cli/ProjectionFieldRegistry.cs index 3f8efa7b0..f4666f6c8 100644 --- a/src/CodeIndex/Cli/ProjectionFieldRegistry.cs +++ b/src/CodeIndex/Cli/ProjectionFieldRegistry.cs @@ -239,9 +239,7 @@ private static ProjectionCommandFieldSchema CreateCallGraphSchema(string command var resultFields = isCallerCommand ? GetJsonFieldNames() : GetJsonFieldNames(); - var compactFields = isCallerCommand - ? new[] { "file", "line", "column" } - : ["file", "line"]; + string[] compactFields = ["file", "line", "column"]; return Create( command, compactFields, @@ -253,9 +251,8 @@ private static ProjectionCommandFieldSchema CreateCallGraphSchema(string command "reference_extraction_limits", "reference_graph_complete", "reference_extraction_cap_hits") .Alias("file", "path") - .Alias("line", "first_line"); - if (isCallerCommand) - builder.Alias("column", "first_column"); + .Alias("line", "first_line") + .Alias("column", "first_column"); }); } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs index 7a9c215ca..e23fbf2a6 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs @@ -369,7 +369,7 @@ public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions { if (TryWriteFormattedLocations( options, - results.Select(r => new FormattedLocation(r.Path, r.FirstLine, null, $"{r.CallerName ?? ""} -> {r.CalleeName}")), + results.Select(r => new FormattedLocation(r.Path, r.FirstLine, r.FirstColumn, $"{r.CallerName ?? ""} -> {r.CalleeName}")), jsonOptions)) return CommandExitCodes.Success; if (options.OutputFormat == OutputFormatLsp) @@ -379,12 +379,22 @@ public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions } if (options.OutputFormat == OutputFormatQf) { - WriteQuickfix(results.Select(r => (r.Path, r.FirstLine, 1, $"{r.CallerName ?? ""} -> {r.CalleeName}"))); + WriteQuickfix(results.Select(r => (r.Path, r.FirstLine, r.FirstColumn ?? 0, $"{r.CallerName ?? ""} -> {r.CalleeName}"))); return CommandExitCodes.Success; } if (options.OutputFormat == OutputFormatSarif) { - WriteSarif(results.Select(r => (r.Path, r.FirstLine, 1, $"{r.CallerName ?? ""} -> {r.CalleeName}", r.ReferenceKind)), jsonOptions); + WriteSarif( + results.Select(r => new SarifLocation( + r.Path, + r.FirstLine, + r.FirstColumn ?? 1, + r.FirstColumn.HasValue && r.FirstLength.HasValue + ? r.FirstColumn.Value + Math.Max(1, r.FirstLength.Value) + : null, + $"{r.CallerName ?? ""} -> {r.CalleeName}", + r.ReferenceKind)), + jsonOptions); return CommandExitCodes.Success; } foreach (var r in results) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Locations.cs b/src/CodeIndex/Cli/QueryCommandRunner.Locations.cs index 5e75539ee..2f9d0ec1d 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Locations.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Locations.cs @@ -92,7 +92,18 @@ private static LspLocation ToLspLocation(CallerResult result) => BuildLspLocation(result.Path, result.FirstLine, Math.Max(1, result.FirstColumn), result.FirstLine, Math.Max(1, result.FirstColumn) + Math.Max(1, result.CalleeName.Length)); private static LspLocation ToLspLocation(CalleeResult result) - => BuildLspLocation(result.Path, result.FirstLine, 1, result.FirstLine, 1); + { + var column = Math.Max(1, result.FirstColumn ?? 1); + var endColumn = result.FirstLength.HasValue + ? column + Math.Max(1, result.FirstLength.Value) + : column; + return BuildLspLocation( + result.Path, + result.FirstLine, + column, + result.FirstLine, + endColumn); + } private static void WriteLspLocations(IEnumerable locations, JsonSerializerOptions jsonOptions) { diff --git a/src/CodeIndex/Database/DbContext.ReadMigrations.cs b/src/CodeIndex/Database/DbContext.ReadMigrations.cs index 759d1e418..56a50f831 100644 --- a/src/CodeIndex/Database/DbContext.ReadMigrations.cs +++ b/src/CodeIndex/Database/DbContext.ReadMigrations.cs @@ -157,6 +157,7 @@ file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, reference_kind TEXT, line INTEGER, column_number INTEGER, + span_length INTEGER, context TEXT, reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, container_kind TEXT, @@ -166,6 +167,8 @@ is_mutual_recursion INTEGER NOT NULL DEFAULT 0 )")); yield return ("EnsureColumn symbol_references.reference_line_id", () => EnsureColumn("symbol_references", "reference_line_id", "INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL")); + yield return ("EnsureColumn symbol_references.span_length", + () => EnsureColumn("symbol_references", "span_length", "INTEGER")); yield return ("EnsureColumn symbol_references.is_self_reference", () => EnsureColumn("symbol_references", "is_self_reference", "INTEGER NOT NULL DEFAULT 0")); yield return ("EnsureColumn symbol_references.is_mutual_recursion", diff --git a/src/CodeIndex/Database/DbContext.SchemaInitialization.cs b/src/CodeIndex/Database/DbContext.SchemaInitialization.cs index a8bc7d198..8323b0bb5 100644 --- a/src/CodeIndex/Database/DbContext.SchemaInitialization.cs +++ b/src/CodeIndex/Database/DbContext.SchemaInitialization.cs @@ -149,6 +149,7 @@ file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, reference_kind TEXT CHECK (reference_kind IN (" + referenceKindCheck + @")), line INTEGER, column_number INTEGER, + span_length INTEGER, context TEXT, reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN (" + symbolKindCheck + @")), @@ -220,6 +221,7 @@ private void MigrateCoreTableColumns() "symbol_references", "reference_line_id", rebuildsSymbolReferences ? "INTEGER" : "INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL"); + EnsureColumn("symbol_references", "span_length", "INTEGER"); // #86: Unicode-aware folded name columns for `--exact` name matching across all // `--exact` command variants. Populated by the writer via NameFold.Fold; NULL on // legacy rows until a full reindex, in which case the reader falls back to the @@ -486,6 +488,7 @@ file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, reference_kind TEXT CHECK (reference_kind IN ({referenceKindCheck})), line INTEGER, column_number INTEGER, + span_length INTEGER, context TEXT, reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbolKindCheck})), @@ -502,7 +505,7 @@ container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbol resolution_candidate_count INTEGER NOT NULL DEFAULT 0 ) """; - const string symbolReferencesColumns = "id, file_id, symbol_name, reference_kind, line, column_number, context, reference_line_id, container_kind, container_name, symbol_name_folded, container_name_folded, is_self_reference, is_mutual_recursion, source_symbol_id, target_symbol_id, target_symbol_key, target_qualifier, resolution_state, resolution_candidate_count"; + const string symbolReferencesColumns = "id, file_id, symbol_name, reference_kind, line, column_number, span_length, context, reference_line_id, container_kind, container_name, symbol_name_folded, container_name_folded, is_self_reference, is_mutual_recursion, source_symbol_id, target_symbol_id, target_symbol_key, target_qualifier, resolution_state, resolution_candidate_count"; const string oldReferenceLines = "_reference_lines_nullable_file_id"; const string oldSymbolReferences = "_symbol_references_nullable_file_id"; @@ -538,6 +541,7 @@ file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, reference_kind TEXT CHECK (reference_kind IN ({referenceKindCheck})), line INTEGER, column_number INTEGER, + span_length INTEGER, context TEXT, reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbolKindCheck})), @@ -554,7 +558,7 @@ container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbol resolution_candidate_count INTEGER NOT NULL DEFAULT 0 ) """; - const string symbolReferencesColumns = "id, file_id, symbol_name, reference_kind, line, column_number, context, reference_line_id, container_kind, container_name, symbol_name_folded, container_name_folded, is_self_reference, is_mutual_recursion, source_symbol_id, target_symbol_id, target_symbol_key, target_qualifier, resolution_state, resolution_candidate_count"; + const string symbolReferencesColumns = "id, file_id, symbol_name, reference_kind, line, column_number, span_length, context, reference_line_id, container_kind, container_name, symbol_name_folded, container_name_folded, is_self_reference, is_mutual_recursion, source_symbol_id, target_symbol_id, target_symbol_key, target_qualifier, resolution_state, resolution_candidate_count"; const string oldSymbolReferences = "_symbol_references_reference_line_delete"; var quotedOldSymbolReferences = SqliteIdentifier.Quote(oldSymbolReferences); @@ -624,6 +628,7 @@ file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, reference_kind TEXT CHECK (reference_kind IN ({referenceKindCheck})), line INTEGER, column_number INTEGER, + span_length INTEGER, context TEXT, reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbolKindCheck})), @@ -640,7 +645,7 @@ container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbol resolution_candidate_count INTEGER NOT NULL DEFAULT 0 ) """; - const string symbolReferencesColumns = "id, file_id, symbol_name, reference_kind, line, column_number, context, reference_line_id, container_kind, container_name, symbol_name_folded, container_name_folded, is_self_reference, is_mutual_recursion, source_symbol_id, target_symbol_id, target_symbol_key, target_qualifier, resolution_state, resolution_candidate_count"; + const string symbolReferencesColumns = "id, file_id, symbol_name, reference_kind, line, column_number, span_length, context, reference_line_id, container_kind, container_name, symbol_name_folded, container_name_folded, is_self_reference, is_mutual_recursion, source_symbol_id, target_symbol_id, target_symbol_key, target_qualifier, resolution_state, resolution_candidate_count"; const string oldReferenceLines = "_reference_lines_file_line_key"; const string oldSymbolReferences = "_symbol_references_file_line_key"; @@ -734,6 +739,7 @@ file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, reference_kind TEXT CHECK (reference_kind IN ({referenceKindCheck})), line INTEGER, column_number INTEGER, + span_length INTEGER, context TEXT, reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbolKindCheck})), @@ -750,7 +756,7 @@ container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbol resolution_candidate_count INTEGER NOT NULL DEFAULT 0 ) """; - const string symbolReferencesColumns = "id, file_id, symbol_name, reference_kind, line, column_number, context, reference_line_id, container_kind, container_name, symbol_name_folded, container_name_folded, is_self_reference, is_mutual_recursion, source_symbol_id, target_symbol_id, target_symbol_key, target_qualifier, resolution_state, resolution_candidate_count"; + const string symbolReferencesColumns = "id, file_id, symbol_name, reference_kind, line, column_number, span_length, context, reference_line_id, container_kind, container_name, symbol_name_folded, container_name_folded, is_self_reference, is_mutual_recursion, source_symbol_id, target_symbol_id, target_symbol_key, target_qualifier, resolution_state, resolution_candidate_count"; var rebuilt = false; RunWithForeignKeysDisabledForMigration("EnsureKindCheckConstraintsCurrent", () => diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index f6ed12466..e66ffed1b 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -576,6 +576,9 @@ private List GetCalleesCore(string query, int limit, string? lang, var calleeGroupKindSql = rawKinds ? GetRawReferenceKindSql("r.reference_kind") : GetLogicalReferenceKindSql("r.reference_kind"); + var referenceSpanLengthSql = _referenceColumns.Contains("span_length") + ? "r.span_length" + : "NULL"; var sql = $@" WITH logical_references AS ( SELECT f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, @@ -584,7 +587,9 @@ WITH logical_references AS ( {calleeGroupKindSql} AS count_reference_kind, COUNT(*) AS reference_count, {ReferenceWeightedScoreSql("r.reference_kind")} AS weighted_score, - r.line + r.line, + r.column_number, + {referenceSpanLengthSql} AS span_length FROM symbol_references r JOIN files f ON r.file_id = f.id WHERE r.container_name IS NOT NULL @@ -638,15 +643,30 @@ WHERE r.container_name IS NOT NULL if (lang != null) sql += " AND f.lang = @lang"; AppendPathFilters(ref sql, pathPatterns, excludePathPatterns, excludeTests); - sql += @" - GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.file_id, r.line, r.column_number, r.reference_kind + sql += $@" + GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.file_id, r.line, r.column_number, {referenceSpanLengthSql}, r.reference_kind + ), + ranked_call_sites AS ( + SELECT logical_references.*, + ROW_NUMBER() OVER ( + PARTITION BY path, lang, container_kind, container_name, symbol_name, reference_kind + ORDER BY CASE WHEN column_number IS NULL THEN 1 ELSE 0 END, + line, + column_number, + COALESCE(span_length, 0) + ) AS location_rank + FROM logical_references ) SELECT path, lang, container_kind, container_name, symbol_name, - reference_kind, MIN(line) AS first_line, SUM(r.reference_count) AS reference_count, + reference_kind, + MAX(CASE WHEN location_rank = 1 THEN line END) AS first_line, + MAX(CASE WHEN location_rank = 1 THEN column_number END) AS first_column, + MAX(CASE WHEN location_rank = 1 THEN span_length END) AS first_length, + SUM(r.reference_count) AS reference_count, GROUP_CONCAT(DISTINCT reference_kind) AS reference_kinds, GROUP_CONCAT(r.count_reference_kind || ':' || r.reference_count) AS reference_kind_counts, SUM(r.weighted_score) AS weighted_score - FROM logical_references r + FROM ranked_call_sites r GROUP BY path, lang, container_kind, container_name, symbol_name, reference_kind"; sql += $" ORDER BY CASE WHEN @preferExactCase = 1 AND r.container_name = @rawQuery THEN 0 ELSE 1 END, {GetPathBucketOrderSql("r.path")}, CASE WHEN lower(r.container_name) = lower(@rankingQuery) THEN 0 ELSE 1 END, {BuildReferenceRankOrderSql(rankMode)}, r.path, first_line, r.lang, r.container_kind, r.container_name, r.symbol_name, r.reference_kind LIMIT @limit OFFSET @offset"; @@ -693,10 +713,10 @@ FROM logical_references r while (reader.TrackedRead()) { var primaryKind = reader.GetString(5); - var kindAggregate = TruncateReferenceKindAggregate(GetNullableString(reader, 8), out var kindsTruncated); - var countAggregate = TruncateReferenceKindAggregate(GetNullableString(reader, 9), out var countsTruncated); + var kindAggregate = TruncateReferenceKindAggregate(GetNullableString(reader, 10), out var kindsTruncated); + var countAggregate = TruncateReferenceKindAggregate(GetNullableString(reader, 11), out var countsTruncated); var kinds = ParseDistinctReferenceKinds(kindAggregate, primaryKind); - var counts = ParseReferenceKindCounts(countAggregate, primaryKind, reader.GetInt32(7)); + var counts = ParseReferenceKindCounts(countAggregate, primaryKind, reader.GetInt32(9)); results.Add(new CalleeResult { Path = reader.GetString(0), @@ -709,9 +729,11 @@ FROM logical_references r HasMixedReferenceKinds = kinds.Count > 1, ReferenceKindCounts = counts, AggregateTruncated = kindsTruncated || countsTruncated, - ReferenceWeightScore = reader.GetDouble(10), + ReferenceWeightScore = reader.GetDouble(12), FirstLine = reader.GetInt32(6), - ReferenceCount = reader.GetInt32(7), + FirstColumn = GetNullableInt32(reader, 7), + FirstLength = GetNullableInt32(reader, 8), + ReferenceCount = reader.GetInt32(9), }); } return results; diff --git a/src/CodeIndex/Database/DbWriter.ReferenceSql.cs b/src/CodeIndex/Database/DbWriter.ReferenceSql.cs index 63990f499..902b9b593 100644 --- a/src/CodeIndex/Database/DbWriter.ReferenceSql.cs +++ b/src/CodeIndex/Database/DbWriter.ReferenceSql.cs @@ -9,7 +9,7 @@ private static string BuildReferenceInsertSql(int rowCount) var sql = CreateBatchSqlBuilder(rowCount, estimatedCharsPerRow: 256); sql.Append(@" INSERT INTO symbol_references ( - file_id, symbol_name, reference_kind, line, column_number, + file_id, symbol_name, reference_kind, line, column_number, span_length, context, reference_line_id, container_kind, container_name, symbol_name_folded, container_name_folded, is_self_reference, is_mutual_recursion, target_qualifier @@ -20,7 +20,7 @@ INSERT INTO symbol_references ( { if (row > 0) sql.Append(", "); - AppendBatchParameterTuple(sql, ref parameterIndex, columnCount: 14); + AppendBatchParameterTuple(sql, ref parameterIndex, columnCount: 15); } return sql.ToString(); } @@ -35,6 +35,7 @@ private static void AddReferenceInsertParameters(SqliteCommand cmd, int rowCount AddBatchParameter(cmd, ref parameterIndex, SqliteType.Text); AddBatchParameter(cmd, ref parameterIndex, SqliteType.Integer); AddBatchParameter(cmd, ref parameterIndex, SqliteType.Integer); + AddBatchParameter(cmd, ref parameterIndex, SqliteType.Integer); AddBatchParameter(cmd, ref parameterIndex, SqliteType.Text); AddBatchParameter(cmd, ref parameterIndex, SqliteType.Integer); AddBatchParameter(cmd, ref parameterIndex, SqliteType.Text); diff --git a/src/CodeIndex/Database/DbWriter.References.cs b/src/CodeIndex/Database/DbWriter.References.cs index c3726fc1e..f039b9412 100644 --- a/src/CodeIndex/Database/DbWriter.References.cs +++ b/src/CodeIndex/Database/DbWriter.References.cs @@ -1169,6 +1169,9 @@ private void InsertReferenceBatch( cmd.Parameters[parameterIndex++].Value = reference.ReferenceKind; cmd.Parameters[parameterIndex++].Value = reference.Line; cmd.Parameters[parameterIndex++].Value = reference.Column; + cmd.Parameters[parameterIndex++].Value = + (object?)(reference.SpanLength > 0 ? reference.SpanLength : null) + ?? DBNull.Value; cmd.Parameters[parameterIndex++].Value = DBNull.Value; cmd.Parameters[parameterIndex++].Value = previousReferenceLineId; cmd.Parameters[parameterIndex++].Value = (object?)reference.ContainerKind ?? DBNull.Value; diff --git a/src/CodeIndex/Indexer/Hooks/PostExtractionHookMutationMaterializer.cs b/src/CodeIndex/Indexer/Hooks/PostExtractionHookMutationMaterializer.cs index 273edd63c..940fc6ade 100644 --- a/src/CodeIndex/Indexer/Hooks/PostExtractionHookMutationMaterializer.cs +++ b/src/CodeIndex/Indexer/Hooks/PostExtractionHookMutationMaterializer.cs @@ -117,6 +117,7 @@ private static ReferenceRecord CloneReference(ReferenceRecord reference) ReferenceKind = reference.ReferenceKind, Line = reference.Line, Column = reference.Column, + SpanLength = reference.SpanLength, Context = reference.Context, ContainerKind = reference.ContainerKind, ContainerName = reference.ContainerName, diff --git a/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.cs index 4ff4930fd..987987637 100644 --- a/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.cs @@ -171,7 +171,8 @@ public static void EmitCtorChainReferences( "call", context, lineNumber, - chainContainer); + chainContainer, + sourceLength: kindToken.Length); } while (chainMatches.MoveNext()); } diff --git a/src/CodeIndex/Indexer/References/Languages/JavaReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/JavaReferenceExtractor.cs index 3263bfc97..712ff2652 100644 --- a/src/CodeIndex/Indexer/References/Languages/JavaReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/JavaReferenceExtractor.cs @@ -125,7 +125,8 @@ public static void EmitCtorChainReferences( "call", context, lineNumber, - ctorContainer); + ctorContainer, + sourceLength: kindToken.Length); } public static (SymbolRecord Synthetic, int NameIndex, int OpenBraceIndex, int CloseBraceIndex)? diff --git a/src/CodeIndex/Indexer/References/Languages/KotlinReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/KotlinReferenceExtractor.cs index b4bfda7a7..639fadf3e 100644 --- a/src/CodeIndex/Indexer/References/Languages/KotlinReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/KotlinReferenceExtractor.cs @@ -441,7 +441,8 @@ public static void EmitCtorDelegationReferences( "call", context, lineNumber, - ctorContainer); + ctorContainer, + sourceLength: kindToken.Length); } while (matches.MoveNext()); } diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallClassification.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallClassification.cs index 55c320d7c..ad7efaee3 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallClassification.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallClassification.cs @@ -147,7 +147,8 @@ private static bool TryAddCoreCallLikeReference( line.Context, line.LineNumber, callContainer, - line.Language); + line.Language, + sourceLength: name.Length); } return false; @@ -169,7 +170,8 @@ private static bool TryAddCoreCallLikeReference( line.LineNumber, callContainer, line.Language, - targetQualifier); + targetQualifier, + name.Length); return true; } if (line.Language == "rust" @@ -189,7 +191,8 @@ private static bool TryAddCoreCallLikeReference( line.Context, line.LineNumber, callContainer, - line.Language); + line.Language, + sourceLength: name.Length); return true; } if (line.Language == "python" @@ -209,7 +212,8 @@ private static bool TryAddCoreCallLikeReference( line.Context, line.LineNumber, callContainer, - line.Language); + line.Language, + sourceLength: name.Length); return true; } if (line.Language == "csharp" @@ -248,7 +252,8 @@ private static bool TryAddCoreCallLikeReference( line.Context, line.LineNumber, callContainer, - line.Language); + line.Language, + sourceLength: name.Length); if (line.Language == "csharp" && metadataKind == "attribute" && CSharpReferenceExtractor.TryGetCallerInfoAttributeTypeName( @@ -266,7 +271,8 @@ private static bool TryAddCoreCallLikeReference( line.Context, line.LineNumber, callContainer, - line.Language); + line.Language, + sourceLength: name.Length); } return true; } @@ -285,7 +291,8 @@ private static bool TryAddCoreCallLikeReference( "instantiate", line.Context, line.LineNumber, - callContainer); + callContainer, + sourceLength: name.Length); return true; } @@ -302,7 +309,8 @@ private static bool TryAddCoreCallLikeReference( "consumes_hook", line.Context, line.LineNumber, - callContainer); + callContainer, + sourceLength: name.Length); return true; } @@ -319,7 +327,8 @@ private static bool TryAddCoreCallLikeReference( ScientificNativeReferenceExtractor.Supports(line.Language) ? line.Language : null, - targetQualifier: targetQualifier); + targetQualifier: targetQualifier, + sourceLength: name.Length); return true; } diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallReferences.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallReferences.cs index 6cbc7795d..574253daf 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallReferences.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallReferences.cs @@ -54,7 +54,8 @@ private static Action CreatePowerShellParameterReferenceEmitter( line.Context, line.LineNumber, callContainer, - line.Language); + line.Language, + sourceLength: name.Length); }; private static Action CreateGradleDslReferenceEmitter( @@ -73,7 +74,8 @@ private static Action CreateGradleDslReferenceEmitter( line.Context, line.LineNumber, callContainer, - line.Language); + line.Language, + sourceLength: name.Length); }; private static void EmitCoreCallReferences(CoreCallReferenceContext call) diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs index b882a08fb..1c4c86117 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs @@ -448,6 +448,7 @@ internal void EmitCSharpBclRegexWithoutTimeoutReferences(List r ReferenceKind = "bcl_regex_without_timeout", Line = reference.Line, Column = reference.Column, + SpanLength = reference.SpanLength, Context = reference.Context, ContainerKind = reference.ContainerKind, ContainerName = reference.ContainerName, diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.PrimaryConstructors.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.PrimaryConstructors.cs index c297c77d8..0cf75a6a1 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.PrimaryConstructors.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.PrimaryConstructors.cs @@ -42,7 +42,8 @@ private static void AddChainReference( string referenceKind, string context, int lineNumber, - SymbolRecord? container) + SymbolRecord? container, + int? sourceLength = null) { var dedupeKey = CreateReferenceDedupeKey(fileId, null, lineNumber, column, referenceKind, name, container); if (!seen.Add(dedupeKey)) @@ -55,6 +56,7 @@ private static void AddChainReference( ReferenceKind = referenceKind, Line = lineNumber, Column = column, + SpanLength = Math.Max(1, sourceLength ?? name.Length), Context = context, ContainerKind = container?.Kind, ContainerName = container?.Name, @@ -96,7 +98,17 @@ private static void EmitMethodGroupReferences( continue; var container = resolveContainerForColumn(nameGroup.Index); - AddChainReference(references, seen, fileId, name, nameGroup.Index, "call", context, lineNumber, container); + AddChainReference( + references, + seen, + fileId, + name, + nameGroup.Index + 1, + "call", + context, + lineNumber, + container, + rawName.Length); } } diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.ReferenceRecords.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.ReferenceRecords.cs index 345d38ea3..77448277b 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.ReferenceRecords.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.ReferenceRecords.cs @@ -233,7 +233,8 @@ internal static void AddReference( lineNumber, container, language, - targetQualifier); + targetQualifier, + match.Groups["name"].Length); } internal static void AddReference( @@ -247,7 +248,8 @@ internal static void AddReference( int lineNumber, SymbolRecord? container, string? language = null, - string? targetQualifier = null) + string? targetQualifier = null, + int? sourceLength = null) { var column = nameIndex + 1; var dedupeKey = CreateReferenceDedupeKey(fileId, language, lineNumber, column, referenceKind, name, container); @@ -268,6 +270,7 @@ internal static void AddReference( ReferenceKind = referenceKind, Line = lineNumber, Column = column, + SpanLength = Math.Max(1, sourceLength ?? name.Length), Context = context, ContainerKind = container?.Kind, ContainerName = container?.Name, diff --git a/src/CodeIndex/Indexer/References/Support/JvmMethodReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Support/JvmMethodReferenceExtractor.cs index 60e2d5ae1..f183e7b11 100644 --- a/src/CodeIndex/Indexer/References/Support/JvmMethodReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Support/JvmMethodReferenceExtractor.cs @@ -225,8 +225,10 @@ private static void AddChainReference( int lineNumber, SymbolRecord? container) { + var sourceLength = name.Length; name = NormalizeMethodReferenceName(language, name); - var dedupeKey = ReferenceExtractor.CreateReferenceDedupeKey(fileId, language, lineNumber, column, "call", name, container); + var oneBasedColumn = column + 1; + var dedupeKey = ReferenceExtractor.CreateReferenceDedupeKey(fileId, language, lineNumber, oneBasedColumn, "call", name, container); if (!seen.Add(dedupeKey)) return; @@ -236,7 +238,8 @@ private static void AddChainReference( SymbolName = name, ReferenceKind = "call", Line = lineNumber, - Column = column, + Column = oneBasedColumn, + SpanLength = sourceLength, Context = context, ContainerKind = container?.Kind, ContainerName = container?.Name, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs b/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs index ce409f929..24d1270ac 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs @@ -342,7 +342,7 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) }; AddPaginatedResultEnvelope(payload, results.Count, total, truncated, offset); if (format == "compact") - ApplyCompactResults(payload, results, result => result.Path, result => result.FirstLine); + ApplyCompactResults(payload, results, result => result.Path, result => result.FirstLine, result => result.FirstColumn); payload["aggregate_truncated"] = results.Any(result => result.AggregateTruncated); if (exact) AddExactGraphSignal(payload, exactSignal); diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 1b4303ae0..0bfbe7b51 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -679,6 +679,8 @@ private static JsonArray BuildCompactCalleeRows(IEnumerable result { ["file"] = result.Path, ["line"] = result.FirstLine, + ["column"] = result.FirstColumn, + ["length"] = result.FirstLength, ["caller_kind"] = result.CallerKind, ["caller"] = result.CallerName, ["callee"] = result.CalleeName, diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index c1e53468b..d57895fa3 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -734,6 +734,12 @@ public class CalleeResult public bool AggregateTruncated { get; set; } public double ReferenceWeightScore { get; set; } public int FirstLine { get; set; } + // The representative call-site span is explicit so location consumers do not + // reconstruct a column from display text. Legacy rows can retain a null column. + // 代表 call-site の span を明示し、location consumer が表示文字列から列を + // 復元しないようにする。legacy row の列は null のまま保持できる。 + public int? FirstColumn { get; set; } + public int? FirstLength { get; set; } public int ReferenceCount { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? BodyContent { get; set; } diff --git a/src/CodeIndex/Models/ReferenceRecord.cs b/src/CodeIndex/Models/ReferenceRecord.cs index f89e34dae..413acd389 100644 --- a/src/CodeIndex/Models/ReferenceRecord.cs +++ b/src/CodeIndex/Models/ReferenceRecord.cs @@ -27,6 +27,9 @@ public class ReferenceRecord /// Column number (1-based) / 列番号(1始まり) public int Column { get; set; } + /// Physical source-token width in UTF-16 code units / 物理 source token の UTF-16 code unit 幅 + public int SpanLength { get; set; } + /// Trimmed source line for quick inspection / すばやい確認用のtrim済みソース行 public string Context { get; set; } = string.Empty; diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index dc2d94044..65871f2ee 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -2075,6 +2075,93 @@ public void GetCallees_ReturnsReferencedSymbolsForCaller() Assert.Equal("call", callee.ReferenceKind); } + [Fact] + public void GetCallees_PreservesFirstPreciseCallSiteAndLegacyLineOnlyFallback_Issue4841() + { + const string targetName = "Issue4841Target"; + const string callerName = "Issue4841Caller"; + const string callLine = " void Issue4841Caller() { var text = \"日本語\";\tIssue4841Target(); Issue4841Target(); }"; + const string methodGroupLine = " void Issue4841MethodGroupCaller() { System.Action callback = @Issue4841Target; }"; + var source = string.Join('\n', + "class Issue4841Probe", + "{", + " void Issue4841Target() { }", + callLine, + methodGroupLine, + "}", + "class Issue4841VeryLongParent { }", + "class Issue4841Child : Issue4841VeryLongParent", + "{", + " Issue4841Child() : base() { }", + "}", + ""); + var expectedColumn = callLine.IndexOf(targetName, StringComparison.Ordinal) + 1; + var expectedMethodGroupColumn = methodGroupLine.IndexOf($"@{targetName}", StringComparison.Ordinal) + 1; + InsertIndexedFile("src/Issue4841Probe.cs", "csharp", source); + + var precise = Assert.Single(_reader.GetCallees(callerName, lang: "csharp", exact: true)); + + Assert.Equal(4, precise.FirstLine); + Assert.Equal(expectedColumn, precise.FirstColumn); + Assert.Equal(targetName.Length, precise.FirstLength); + Assert.Equal(2, precise.ReferenceCount); + + var methodGroup = Assert.Single( + _reader.GetCallees("Issue4841MethodGroupCaller", lang: "csharp", exact: true), + row => row.CalleeName == targetName); + Assert.Equal(5, methodGroup.FirstLine); + Assert.Equal(expectedMethodGroupColumn, methodGroup.FirstColumn); + Assert.Equal(targetName.Length + 1, methodGroup.FirstLength); + Assert.Equal(1, methodGroup.ReferenceCount); + + var constructorChain = Assert.Single( + _reader.GetCallees("Issue4841Child", lang: "csharp", exact: true), + row => row.CalleeName == "Issue4841VeryLongParent"); + Assert.Equal("Issue4841VeryLongParent", constructorChain.CalleeName); + Assert.Equal(10, constructorChain.FirstLine); + Assert.Equal(24, constructorChain.FirstColumn); + Assert.Equal("base".Length, constructorChain.FirstLength); + + using (var command = _db.Connection.CreateCommand()) + { + command.CommandText = """ + UPDATE symbol_references + SET span_length = NULL + WHERE container_name = @caller + AND symbol_name = @target; + """; + command.Parameters.AddWithValue("@caller", callerName); + command.Parameters.AddWithValue("@target", targetName); + Assert.Equal(2, command.ExecuteNonQuery()); + } + + var spanless = Assert.Single(_reader.GetCallees(callerName, lang: "csharp", exact: true)); + + Assert.Equal(expectedColumn, spanless.FirstColumn); + Assert.Null(spanless.FirstLength); + Assert.Equal(2, spanless.ReferenceCount); + + using (var command = _db.Connection.CreateCommand()) + { + command.CommandText = """ + UPDATE symbol_references + SET column_number = NULL + WHERE container_name = @caller + AND symbol_name = @target; + """; + command.Parameters.AddWithValue("@caller", callerName); + command.Parameters.AddWithValue("@target", targetName); + Assert.Equal(2, command.ExecuteNonQuery()); + } + + var legacy = Assert.Single(_reader.GetCallees(callerName, lang: "csharp", exact: true)); + + Assert.Equal(4, legacy.FirstLine); + Assert.Null(legacy.FirstColumn); + Assert.Null(legacy.FirstLength); + Assert.Equal(2, legacy.ReferenceCount); + } + [Fact] public void GetCallers_CppFriendReferenceRequiresExplicitKind() { diff --git a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs index c20930ebf..85a87cd0a 100644 --- a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs @@ -1861,6 +1861,101 @@ private void OnChanged(object? sender, EventArgs e) { } Assert.Equal(new[] { "subscribe" }, kinds); } + [Fact] + public void ToolsCall_Callees_PreservesFirstCallSiteSpanInFullAndCompactRows_Issue4841() + { + const string targetName = "Issue4841McpTarget"; + const string callerName = "Issue4841McpCaller"; + const string callLine = " void Issue4841McpCaller() { var text = \"日本語\";\tIssue4841McpTarget(); Issue4841McpTarget(); }"; + var source = string.Join('\n', + "class Issue4841McpProbe", + "{", + " void Issue4841McpTarget() { }", + callLine, + "}", + "class Issue4841McpVeryLongParent { }", + "class Issue4841McpChild : Issue4841McpVeryLongParent", + "{", + " Issue4841McpChild() : base() { }", + "}", + ""); + var expectedColumn = callLine.IndexOf(targetName, StringComparison.Ordinal) + 1; + InsertIndexedFile("src/Issue4841McpProbe.cs", "csharp", source); + + var fullRequest = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 4841, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "callees", + ["arguments"] = new JsonObject + { + ["query"] = callerName, + ["lang"] = "csharp", + ["exact"] = true, + }, + }, + }; + var fullResponse = _server.HandleMessage(fullRequest)!; + var structured = fullResponse["result"]!["structuredContent"]!; + var fullRow = structured["results"]![0]!; + + Assert.Equal(1, structured["count"]!.GetValue()); + Assert.Equal(4, fullRow["firstLine"]!.GetValue()); + Assert.Equal(expectedColumn, fullRow["firstColumn"]!.GetValue()); + Assert.Equal(targetName.Length, fullRow["firstLength"]!.GetValue()); + Assert.Equal(2, fullRow["referenceCount"]!.GetValue()); + + var compactRequest = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 4842, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "callees", + ["arguments"] = new JsonObject + { + ["query"] = callerName, + ["lang"] = "csharp", + ["exact"] = true, + ["format"] = "compact", + }, + }, + }; + var compactResponse = _server.HandleMessage(compactRequest)!; + var compactRow = compactResponse["result"]!["structuredContent"]!["results"]![0]!; + + Assert.Equal(4, compactRow["line"]!.GetValue()); + Assert.Equal(expectedColumn, compactRow["column"]!.GetValue()); + + var constructorChainRequest = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 4843, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "callees", + ["arguments"] = new JsonObject + { + ["query"] = "Issue4841McpChild", + ["lang"] = "csharp", + ["exact"] = true, + }, + }, + }; + var constructorChainResponse = _server.HandleMessage(constructorChainRequest)!; + var constructorChainRow = constructorChainResponse["result"]!["structuredContent"]!["results"]! + .AsArray() + .Single(row => row!["calleeName"]!.GetValue() == "Issue4841McpVeryLongParent")!; + + Assert.Equal("Issue4841McpVeryLongParent", constructorChainRow["calleeName"]!.GetValue()); + Assert.Equal("base".Length, constructorChainRow["firstLength"]!.GetValue()); + } + [Fact] public void ToolsCall_References_ReturnsIndexedReference() { diff --git a/tests/CodeIndex.Tests/PostExtractionHookContractTests.cs b/tests/CodeIndex.Tests/PostExtractionHookContractTests.cs index 314626ec3..70b2edc87 100644 --- a/tests/CodeIndex.Tests/PostExtractionHookContractTests.cs +++ b/tests/CodeIndex.Tests/PostExtractionHookContractTests.cs @@ -151,6 +151,7 @@ public void MutationMaterializer_RecomputesNimIdentityAfterHookMutation_Issue473 ReferenceKind = "call", Line = 2, Column = 1, + SpanLength = 4, ContainerName = "Run_Graph", IdentityContainerNameFolded = "stale", TargetQualifier = "pkg", @@ -166,6 +167,7 @@ public void MutationMaterializer_RecomputesNimIdentityAfterHookMutation_Issue473 var clonedReference = Assert.Single(clonedReferences); Assert.Equal("pkg", clonedReference.TargetQualifier); Assert.True(clonedReference.SuppressInferredTargetQualifier); + Assert.Equal(4, clonedReference.SpanLength); PostExtractionHookMutationMaterializer.RefreshLanguageIdentity("nim", symbols); PostExtractionHookMutationMaterializer.RefreshLanguageIdentity("nim", clonedReferences); diff --git a/tests/CodeIndex.Tests/ProjectionFieldRegistryIssue4836Tests.cs b/tests/CodeIndex.Tests/ProjectionFieldRegistryIssue4836Tests.cs index e1f3aa2d5..5e2968cdb 100644 --- a/tests/CodeIndex.Tests/ProjectionFieldRegistryIssue4836Tests.cs +++ b/tests/CodeIndex.Tests/ProjectionFieldRegistryIssue4836Tests.cs @@ -190,6 +190,8 @@ public void EveryDiscoveredProjectionField_ValidatesFromTheSameRegistry_Issue483 [InlineData("callers", "aggregate_truncated")] [InlineData("callers", "first_column")] [InlineData("callees", "body_content_recovery")] + [InlineData("callees", "first_column")] + [InlineData("callees", "first_length")] [InlineData("impact", "path_details")] [InlineData("map", "language_count")] [InlineData("map", "module_count")] @@ -224,7 +226,6 @@ public void ExistingConditionalAndModeSpecificFields_RemainValid_Issue4836( [InlineData("symbols", "is_metadata_target")] [InlineData("symbols", "metadata_target_source")] [InlineData("symbols", "same_line_signature_occurrence_index")] - [InlineData("callees", "first_column")] [InlineData("callees", "has_self_reference")] [InlineData("callees", "has_mutual_recursion")] public void NonOutputFields_AreNotAdvertisedOrAccepted_Issue4836( @@ -241,10 +242,10 @@ public void NonOutputFields_AreNotAdvertisedOrAccepted_Issue4836( } [Fact] - public void CallGraphCompactDefaults_AreCommandSpecific_Issue4836() + public void CallGraphCompactDefaults_IncludeCallSiteColumns_Issue4836_Issue4841() { Assert.Contains("column", ProjectionFieldRegistry.GetCompactFields("callers")!); - Assert.DoesNotContain("column", ProjectionFieldRegistry.GetCompactFields("callees")!); + Assert.Contains("column", ProjectionFieldRegistry.GetCompactFields("callees")!); } [Fact] diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerReferencesTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerReferencesTests.cs index f399089b1..a83631cf6 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerReferencesTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerReferencesTests.cs @@ -9,6 +9,249 @@ namespace CodeIndex.Tests; public partial class QueryCommandRunnerTests { + [Fact] + public void RunCallees_LocationFormatsPreserveFirstCallSiteSpanAndLegacyFallback_Issue4841() + { + const string targetName = "Issue4841CliTarget"; + const string callerName = "Issue4841CliCaller"; + const string callLine = " void Issue4841CliCaller() { var text = \"日本語\";\tIssue4841CliTarget(); Issue4841CliTarget(); }"; + var source = string.Join('\n', + "class Issue4841CliProbe", + "{", + " void Issue4841CliTarget() { }", + callLine, + "}", + "class Issue4841CliVeryLongParent { }", + "class Issue4841CliChild : Issue4841CliVeryLongParent", + "{", + " Issue4841CliChild() : base() { }", + "}", + ""); + var expectedColumn = callLine.IndexOf(targetName, StringComparison.Ordinal) + 1; + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_callees_locations_issue4841"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "src/Issue4841CliProbe.cs", "csharp", source); + MarkGraphAndFoldReady(dbPath); + string[] baseArgs = [callerName, "--db", dbPath, "--lang", "csharp", "--exact"]; + + var (jsonExit, jsonStdout, jsonStderr) = CaptureConsole(() => QueryCommandRunner.RunCallees( + [.. baseArgs, "--json"], + _jsonOptions)); + var jsonRow = Assert.Single(ParseJsonLines(jsonStdout)).RootElement; + Assert.Equal(CommandExitCodes.Success, jsonExit); + Assert.Equal(string.Empty, jsonStderr); + Assert.Equal(4, jsonRow.GetProperty("first_line").GetInt32()); + Assert.Equal(expectedColumn, jsonRow.GetProperty("first_column").GetInt32()); + Assert.Equal(targetName.Length, jsonRow.GetProperty("first_length").GetInt32()); + Assert.Equal(2, jsonRow.GetProperty("reference_count").GetInt32()); + + var (compactExit, compactStdout, compactStderr) = CaptureConsole(() => QueryCommandRunner.RunCallees( + [.. baseArgs, "--format", "compact"], + _jsonOptions)); + using var compactDocument = ParseJsonOutput(compactStdout); + var compactRow = compactDocument.RootElement.GetProperty("results")[0]; + Assert.Equal(CommandExitCodes.Success, compactExit); + Assert.Equal(string.Empty, compactStderr); + Assert.Equal(expectedColumn, compactRow.GetProperty("column").GetInt32()); + + var (quickfixExit, quickfixStdout, quickfixStderr) = CaptureConsole(() => QueryCommandRunner.RunCallees( + [.. baseArgs, "--format", "qf"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, quickfixExit); + Assert.Equal(string.Empty, quickfixStderr); + Assert.Equal( + $"src/Issue4841CliProbe.cs:4:{expectedColumn}:{callerName} -> {targetName}", + quickfixStdout.Trim()); + + var (lspExit, lspStdout, lspStderr) = CaptureConsole(() => QueryCommandRunner.RunCallees( + [.. baseArgs, "--format", "lsp"], + _jsonOptions)); + using var lspDocument = ParseJsonOutput(lspStdout); + var preciseRange = lspDocument.RootElement[0].GetProperty("range"); + Assert.Equal(CommandExitCodes.Success, lspExit); + Assert.Equal(string.Empty, lspStderr); + Assert.Equal(3, preciseRange.GetProperty("start").GetProperty("line").GetInt32()); + Assert.Equal(expectedColumn - 1, preciseRange.GetProperty("start").GetProperty("character").GetInt32()); + Assert.Equal(expectedColumn - 1 + targetName.Length, preciseRange.GetProperty("end").GetProperty("character").GetInt32()); + + var (sarifExit, sarifStdout, sarifStderr) = CaptureConsole(() => QueryCommandRunner.RunCallees( + [.. baseArgs, "--format", "sarif"], + _jsonOptions)); + using var sarifDocument = ParseJsonOutput(sarifStdout); + var preciseRegion = sarifDocument.RootElement + .GetProperty("runs")[0] + .GetProperty("results")[0] + .GetProperty("locations")[0] + .GetProperty("physicalLocation") + .GetProperty("region"); + Assert.Equal(CommandExitCodes.Success, sarifExit); + Assert.Equal(string.Empty, sarifStderr); + Assert.Equal(4, preciseRegion.GetProperty("startLine").GetInt32()); + Assert.Equal(expectedColumn, preciseRegion.GetProperty("startColumn").GetInt32()); + Assert.Equal(expectedColumn + targetName.Length, preciseRegion.GetProperty("endColumn").GetInt32()); + + string[] constructorChainArgs = ["Issue4841CliChild", "--db", dbPath, "--lang", "csharp", "--exact"]; + var (chainJsonExit, chainJsonStdout, chainJsonStderr) = CaptureConsole(() => QueryCommandRunner.RunCallees( + [.. constructorChainArgs, "--json"], + _jsonOptions)); + var chainJsonRow = ParseJsonLines(chainJsonStdout) + .Select(document => document.RootElement) + .Single(row => row.GetProperty("callee_name").GetString() == "Issue4841CliVeryLongParent"); + Assert.Equal(CommandExitCodes.Success, chainJsonExit); + Assert.Equal(string.Empty, chainJsonStderr); + Assert.Equal("Issue4841CliVeryLongParent", chainJsonRow.GetProperty("callee_name").GetString()); + Assert.Equal("base".Length, chainJsonRow.GetProperty("first_length").GetInt32()); + + var (chainLspExit, chainLspStdout, chainLspStderr) = CaptureConsole(() => QueryCommandRunner.RunCallees( + [.. constructorChainArgs, "--format", "lsp"], + _jsonOptions)); + using var chainLspDocument = ParseJsonOutput(chainLspStdout); + var chainRange = chainLspDocument.RootElement + .EnumerateArray() + .Select(location => location.GetProperty("range")) + .Single(range => + range.GetProperty("end").GetProperty("character").GetInt32() + - range.GetProperty("start").GetProperty("character").GetInt32() + == "base".Length); + Assert.Equal(CommandExitCodes.Success, chainLspExit); + Assert.Equal(string.Empty, chainLspStderr); + Assert.Equal( + chainRange.GetProperty("start").GetProperty("character").GetInt32() + "base".Length, + chainRange.GetProperty("end").GetProperty("character").GetInt32()); + + var (chainSarifExit, chainSarifStdout, chainSarifStderr) = CaptureConsole(() => QueryCommandRunner.RunCallees( + [.. constructorChainArgs, "--format", "sarif"], + _jsonOptions)); + using var chainSarifDocument = ParseJsonOutput(chainSarifStdout); + var chainRegion = chainSarifDocument.RootElement + .GetProperty("runs")[0] + .GetProperty("results") + .EnumerateArray() + .Select(result => result + .GetProperty("locations")[0] + .GetProperty("physicalLocation") + .GetProperty("region")) + .Single(region => + region.GetProperty("endColumn").GetInt32() + - region.GetProperty("startColumn").GetInt32() + == "base".Length); + Assert.Equal(CommandExitCodes.Success, chainSarifExit); + Assert.Equal(string.Empty, chainSarifStderr); + Assert.Equal( + chainRegion.GetProperty("startColumn").GetInt32() + "base".Length, + chainRegion.GetProperty("endColumn").GetInt32()); + + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + using var command = db.Connection.CreateCommand(); + command.CommandText = """ + UPDATE symbol_references + SET span_length = NULL + WHERE container_name = @caller + AND symbol_name = @target; + """; + command.Parameters.AddWithValue("@caller", callerName); + command.Parameters.AddWithValue("@target", targetName); + Assert.Equal(2, command.ExecuteNonQuery()); + } + + var (spanlessJsonExit, spanlessJsonStdout, spanlessJsonStderr) = CaptureConsole(() => QueryCommandRunner.RunCallees( + [.. baseArgs, "--json"], + _jsonOptions)); + var spanlessJsonRow = Assert.Single(ParseJsonLines(spanlessJsonStdout)).RootElement; + Assert.Equal(CommandExitCodes.Success, spanlessJsonExit); + Assert.Equal(string.Empty, spanlessJsonStderr); + Assert.Equal(expectedColumn, spanlessJsonRow.GetProperty("first_column").GetInt32()); + Assert.Equal(JsonValueKind.Null, spanlessJsonRow.GetProperty("first_length").ValueKind); + + var (spanlessLspExit, spanlessLspStdout, spanlessLspStderr) = CaptureConsole(() => QueryCommandRunner.RunCallees( + [.. baseArgs, "--format", "lsp"], + _jsonOptions)); + using var spanlessLspDocument = ParseJsonOutput(spanlessLspStdout); + var spanlessRange = spanlessLspDocument.RootElement[0].GetProperty("range"); + Assert.Equal(CommandExitCodes.Success, spanlessLspExit); + Assert.Equal(string.Empty, spanlessLspStderr); + Assert.Equal(expectedColumn - 1, spanlessRange.GetProperty("start").GetProperty("character").GetInt32()); + Assert.Equal(expectedColumn - 1, spanlessRange.GetProperty("end").GetProperty("character").GetInt32()); + + var (spanlessSarifExit, spanlessSarifStdout, spanlessSarifStderr) = CaptureConsole(() => QueryCommandRunner.RunCallees( + [.. baseArgs, "--format", "sarif"], + _jsonOptions)); + using var spanlessSarifDocument = ParseJsonOutput(spanlessSarifStdout); + var spanlessRegion = spanlessSarifDocument.RootElement + .GetProperty("runs")[0] + .GetProperty("results")[0] + .GetProperty("locations")[0] + .GetProperty("physicalLocation") + .GetProperty("region"); + Assert.Equal(CommandExitCodes.Success, spanlessSarifExit); + Assert.Equal(string.Empty, spanlessSarifStderr); + Assert.Equal(expectedColumn, spanlessRegion.GetProperty("startColumn").GetInt32()); + Assert.False(spanlessRegion.TryGetProperty("endColumn", out _)); + + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + using var command = db.Connection.CreateCommand(); + command.CommandText = """ + UPDATE symbol_references + SET column_number = NULL + WHERE container_name = @caller + AND symbol_name = @target; + """; + command.Parameters.AddWithValue("@caller", callerName); + command.Parameters.AddWithValue("@target", targetName); + Assert.Equal(2, command.ExecuteNonQuery()); + } + + var (legacyJsonExit, legacyJsonStdout, legacyJsonStderr) = CaptureConsole(() => QueryCommandRunner.RunCallees( + [.. baseArgs, "--json"], + _jsonOptions)); + var legacyJsonRow = Assert.Single(ParseJsonLines(legacyJsonStdout)).RootElement; + Assert.Equal(CommandExitCodes.Success, legacyJsonExit); + Assert.Equal(string.Empty, legacyJsonStderr); + Assert.Equal(JsonValueKind.Null, legacyJsonRow.GetProperty("first_column").ValueKind); + Assert.Equal(JsonValueKind.Null, legacyJsonRow.GetProperty("first_length").ValueKind); + + var (legacyQuickfixExit, legacyQuickfixStdout, legacyQuickfixStderr) = CaptureConsole(() => QueryCommandRunner.RunCallees( + [.. baseArgs, "--format", "qf"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, legacyQuickfixExit); + Assert.Equal(string.Empty, legacyQuickfixStderr); + Assert.Contains(":4:0:", legacyQuickfixStdout, StringComparison.Ordinal); + + var (legacyLspExit, legacyLspStdout, legacyLspStderr) = CaptureConsole(() => QueryCommandRunner.RunCallees( + [.. baseArgs, "--format", "lsp"], + _jsonOptions)); + using var legacyLspDocument = ParseJsonOutput(legacyLspStdout); + var legacyRange = legacyLspDocument.RootElement[0].GetProperty("range"); + Assert.Equal(CommandExitCodes.Success, legacyLspExit); + Assert.Equal(string.Empty, legacyLspStderr); + Assert.Equal(0, legacyRange.GetProperty("start").GetProperty("character").GetInt32()); + Assert.Equal(0, legacyRange.GetProperty("end").GetProperty("character").GetInt32()); + + var (legacySarifExit, legacySarifStdout, legacySarifStderr) = CaptureConsole(() => QueryCommandRunner.RunCallees( + [.. baseArgs, "--format", "sarif"], + _jsonOptions)); + using var legacySarifDocument = ParseJsonOutput(legacySarifStdout); + var legacyRegion = legacySarifDocument.RootElement + .GetProperty("runs")[0] + .GetProperty("results")[0] + .GetProperty("locations")[0] + .GetProperty("physicalLocation") + .GetProperty("region"); + Assert.Equal(CommandExitCodes.Success, legacySarifExit); + Assert.Equal(string.Empty, legacySarifStderr); + Assert.Equal(1, legacyRegion.GetProperty("startColumn").GetInt32()); + Assert.False(legacyRegion.TryGetProperty("endColumn", out _)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunReferences_ConfigLanguageSelectsRulesReferences_Issue4740() { diff --git a/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs index 3f6cbbe4b..2be72e61c 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs @@ -793,7 +793,7 @@ public void Fire() { } public void Wire(IEnumerable xs) { - Action a = Fire; + Action a = @Fire; Func f = Compute; var g = this.Compute; var h = new Func(Compute); @@ -812,6 +812,16 @@ public void Wire(IEnumerable xs) Assert.Equal("function", reference.ContainerKind); Assert.Equal("Wire", reference.ContainerName); }); + var escapedMethodGroupLine = content + .Split('\n') + .Single(line => line.Contains("@Fire", StringComparison.Ordinal)); + var escapedMethodGroup = Assert.Single(references.Where(r => + r.SymbolName == "Fire" + && r.ReferenceKind == "call")); + Assert.Equal( + escapedMethodGroupLine.IndexOf("@Fire", StringComparison.Ordinal) + 1, + escapedMethodGroup.Column); + Assert.Equal("@Fire".Length, escapedMethodGroup.SpanLength); } [Fact] diff --git a/tests/CodeIndex.Tests/ReferenceExtractorJvmTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorJvmTests.cs index 3a0d0fa9b..53b0a6df7 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorJvmTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorJvmTests.cs @@ -87,10 +87,17 @@ public Supplier make() { var symbols = SymbolExtractor.Extract(1, "java", content); var references = ReferenceExtractor.Extract(1, "java", content, symbols); - Assert.Contains(references, r => + var intValueReference = Assert.Single(references.Where(r => r.SymbolName == "intValue" && r.ReferenceKind == "call" - && r.ContainerName == "sum"); + && r.ContainerName == "sum")); + var intValueLine = content + .Split('\n') + .Single(line => line.Contains("Integer::intValue", StringComparison.Ordinal)); + Assert.Equal( + intValueLine.IndexOf("intValue", StringComparison.Ordinal) + 1, + intValueReference.Column); + Assert.Equal("intValue".Length, intValueReference.SpanLength); Assert.Contains(references, r => r.SymbolName == "Integer" && r.ReferenceKind == "type_reference" @@ -951,7 +958,17 @@ class Demo { Assert.Contains(symbols, s => s.Name == "render name" && s.Kind == "function"); Assert.Contains(references, r => r.SymbolName == "User" && r.ReferenceKind == "type_reference"); - Assert.Contains(references, r => r.SymbolName == "render name" && r.ReferenceKind == "call"); + var renderReference = Assert.Single(references.Where(r => + r.SymbolName == "render name" + && r.ReferenceKind == "call")); + var renderLine = content + .Split('\n') + .Single(line => line.Contains("`render name`", StringComparison.Ordinal) + && line.Contains("::", StringComparison.Ordinal)); + Assert.Equal( + renderLine.IndexOf("`render name`", StringComparison.Ordinal) + 1, + renderReference.Column); + Assert.Equal("`render name`".Length, renderReference.SpanLength); Assert.DoesNotContain(references, r => r.SymbolName == "`render name`" && r.ReferenceKind == "call"); }