From c3a69890c712c06dff8511b2053f7d1762aad762 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 27 Jul 2026 19:11:44 +0900 Subject: [PATCH 1/3] Fix inspect graph identity and pagination (#4839) --- DEVELOPER_GUIDE.md | 15 ++ TESTING_GUIDE.md | 2 + USER_GUIDE.md | 21 ++ changelog.d/unreleased/4839.fixed.md | 20 ++ src/CodeIndex/Cli/CliFlagSchema.cs | 2 +- src/CodeIndex/Cli/ConsoleUi.cs | 4 +- src/CodeIndex/Cli/InspectGraphCursor.cs | 126 +++++++++ ...ueryCommandRunner.ArgumentParser.Search.cs | 9 +- .../Cli/QueryCommandRunner.Inspect.cs | 255 +++++++++++++++++- .../Database/DbReader.GraphQueries.cs | 109 +++++++- src/CodeIndex/Database/DbReader.References.cs | 32 ++- .../Database/DbSymbolReader.Analysis.cs | 152 +++++++++-- src/CodeIndex/Mcp/McpToolArgumentContracts.cs | 2 +- src/CodeIndex/Mcp/McpToolCatalog.cs | 3 +- src/CodeIndex/Mcp/McpToolHandlers.Graph.cs | 76 +++++- src/CodeIndex/Mcp/McpToolHandlers.cs | 4 + src/CodeIndex/Models/QueryResults.cs | 31 +++ tests/CodeIndex.Tests/ConsoleUiTests.cs | 4 +- .../McpServerToolsCallTests.cs | 76 ++++++ .../QueryCommandRunnerInspectTests.cs | 170 ++++++++++++ ...mandRunnerPartialGroupingIssue4566Tests.cs | 4 + 21 files changed, 1075 insertions(+), 42 deletions(-) create mode 100644 changelog.d/unreleased/4839.fixed.md create mode 100644 src/CodeIndex/Cli/InspectGraphCursor.cs diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 7af236430..6cb5887ba 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1104,6 +1104,13 @@ language across reference/caller/callee evidence. `graph_language_source`, `graph_language_confidence`, `graph_language_candidates`, and `graph_language_conflict` distinguish authoritative filter/definition decisions from consistent inference and keep mixed-language evidence unresolved. +Path/line resolution must select `symbols.id` and enter the same candidate-bundle builder as +name resolution; do not hand graph loaders only the display name. Each bounded references, +callers, and callees section computes its own stable-order page and authoritative total. +`graph_sections` reports `total`, `returned`, `offset`, and `truncated`, while CLI `inspect` +and MCP `analyze_symbol` attach query- and index-generation-bound cursors independently to +truncated sections. Candidate selectors are part of cursor scope so paging one overload or +partial-family representative cannot borrow graph rows from another candidate. ### Reference taxonomy @@ -4261,6 +4268,14 @@ reference/caller/callee query は `symbol_reference_candidates` または 言語を推論します。`graph_language_source`、`graph_language_confidence`、 `graph_language_candidates`、`graph_language_conflict` により、filter/definition による authoritative な判定と一貫した推論を区別し、複数言語の evidence は未確定のままにします。 +path/line resolution は `symbols.id` を select し、name resolution と同じ +candidate-bundle builder に入れてください。graph loader に display name だけを渡しては +なりません。上限付きの references、callers、callees section は、それぞれ安定順序の page と +authoritative な総数を計算します。`graph_sections` は `total`、`returned`、`offset`、 +`truncated` を報告し、CLI `inspect` と MCP `analyze_symbol` は truncated な section ごとに +query と index generation に束縛した cursor を付けます。candidate selector も cursor scope +に含め、1つの overload または partial-family representative の pagination が別 candidate の +graph row を借用しないようにしてください。 ### 参照 taxonomy diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index e2c7d78a6..a9e157e7d 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -186,6 +186,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Grouped-search limit coverage must seed more matches and files than the returned page, then assert query-wide matched/group/file totals separately from grouped, emitted, and omitted row counts so a bounded page cannot report itself as complete. Cursor-pagination coverage for `outline` and `unused` must replay the emitted opaque token successfully, then prove that changed filters/order and a changed index-generation stamp fail with restart-required diagnostics. Discovery-pagination coverage must page compact search/symbol/file output and the opt-in search-array/languages envelopes, assert authoritative total/omitted/stable-at metadata, reject query or generation cursor mismatches, and prove that a partial `find --all` scan resumes after the last scanned line. + Inspect graph-section coverage must compare name and path/line resolution through the same persisted candidate ID, keep ambiguous overload and partial-family bundles isolated, assert independent total/returned/truncated metadata for references, callers, and callees (including empty sections), and replay a query- and generation-bound cursor across the smallest two-row page boundary. Verify the same envelopes in MCP `analyze_symbol`. Quiet-flag coverage in `ProgramCliTests.cs` reuses one seeded symbols database across text, NDJSON, and JSON-array modes and compares stdout with and without a trailing quiet alias, proving that quiet mode changes only informational stderr. Doctor full-inventory coverage keeps composed filter selection, filtered summary counts, exact UTF-8 byte-budget boundaries, and structured overflow errors together in `ProgramRunnerTests`; license JSON remains a subprocess contract in `ProgramCliTests` so immediate-command dispatch and the published field names are both exercised. Ctags export JSON coverage reuses one seeded database for default and `--include-generated` variants, asserts the fixed skip-reason keys sum to `skipped_count`, and keeps the missing-`files.generated` degradation in a separate legacy-schema fixture. @@ -1082,6 +1083,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" grouped search の limit coverage では、返却 page より多い match と file を seed し、query 全体の match/group/file 総数と grouped、emitted、omitted row 数を別々に検証して、上限付き page が完了済みと報告できないようにしてください。 `outline` と `unused` の cursor pagination coverage では、出力された opaque token の再利用成功に加え、filter/order の変更と index-generation stamp の変更が restart-required 診断で失敗することを検証してください。 discovery pagination の coverage では compact search / symbol / file と opt-in の search array / languages envelope をページングし、authoritative な total / omitted / stable-at metadata、query / generation 不一致 cursor の拒否、partial な `find --all` scan が最後に scan した line の次から再開することを検証してください。 + inspect graph-section coverage では、name と path/line resolution が同じ persisted candidate ID を通ること、曖昧な overload と partial-family bundle が分離されること、references / callers / callees の独立した total / returned / truncated metadata(空 section を含む)を検証してください。最小の2行 page 境界で query / generation に束縛された cursor を再利用し、MCP `analyze_symbol` でも同じ envelope を確認してください。 `ProgramCliTests.cs` の quiet flag coverage は1つの seeded symbols database を text、NDJSON、JSON array の各 mode で再利用し、末尾に quiet alias を付けた場合と付けない場合の stdout を比較して、quiet mode が informational stderr だけを変えることを固定します。 doctor full-inventory coverage では、合成 filter の選択、filtered summary 件数、UTF-8 byte budget の exact boundary、structured overflow error を `ProgramRunnerTests` にまとめます。license JSON は `ProgramCliTests` の subprocess contract として、immediate-command dispatch と公開 field 名を同時に検証します。 ctags export JSON coverage は1つの seeded database を既定と `--include-generated` variant で再利用し、固定された skip-reason key の合計が `skipped_count` と一致することを検証します。`files.generated` がない場合の縮退は別の legacy-schema fixture に保ってください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 6e21889c7..63f4b08f7 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -376,6 +376,16 @@ For AI-oriented bounded payloads, `map`, `inspect`, and `outline` accept `--compact`. It implies JSON output, caps list sections to 5 items by default (or the explicit `--limit` / `--top` value), and adds `compact`, `compact_limit`, and `truncation.sections.*` metadata. +Every `inspect` graph bundle also publishes independent +`graph_sections.references`, `graph_sections.callers`, and +`graph_sections.callees` envelopes with `total`, `returned`, `offset`, and +`truncated`. A truncated section includes an opaque `next_cursor`; pass it back +with `--cursor` and unchanged query filters to continue only that section. +The cursor is bound to the query and index generation. Name and path/line +inspection both preserve the selected persisted symbol ID, so location lookup +returns the same candidate-scoped graph evidence as the corresponding name +bundle instead of re-resolving a display name. MCP `analyze_symbol` exposes the +same section envelopes and accepts their cursors. For narrower `inspect` evidence, `--fields ` implies JSON and selects top-level groups such as `definitions`, `file`, `graph`, `references`, `callers`, and `callees`; `--outline-only` is shorthand for @@ -410,6 +420,7 @@ cdidx inspect Compute --outline-only # file/definition/nearby symbol summar cdidx inspect Compute --body-only # definitions with body_content only cdidx inspect Compute --body --body-start 40 --body-lines 40 cdidx inspect Compute --line 42 --context 2 --json +cdidx inspect Compute --json --limit 1 --cursor '' ``` ## Editor and index portability @@ -3496,6 +3507,15 @@ AI 向けに上限付き payload が必要な場合、`map`、`inspect`、`outli `--compact` に対応しています。これは JSON 出力を暗黙に有効化し、list section を 既定 5 件(明示した `--limit` / `--top` があればその値)に cap し、 `compact`、`compact_limit`、`truncation.sections.*` metadata を追加します。 +すべての `inspect` graph bundle は、独立した +`graph_sections.references`、`graph_sections.callers`、 +`graph_sections.callees` envelope も公開し、`total`、`returned`、`offset`、 +`truncated` を示します。truncated な section には opaque な `next_cursor` が付き、 +query filter を変えず `--cursor` で渡すと、その section だけを継続できます。 +cursor は query と index generation に束縛されます。名前指定と path/line 指定の +どちらも選択された永続 symbol ID を維持するため、location lookup は display name を +再解決せず、対応する name bundle と同じ candidate-scoped graph evidence を返します。 +MCP `analyze_symbol` も同じ section envelope を公開し、その cursor を受け付けます。 `inspect` の証跡をさらに絞りたい場合、`--fields ` は JSON 出力を暗黙に有効化し、 `definitions`、`file`、`graph`、`references`、`callers`、`callees` などの top-level group を選択します。`--outline-only` は @@ -3528,6 +3548,7 @@ cdidx inspect Compute --outline-only # ファイル・定義・近傍シン cdidx inspect Compute --body-only # body_content 付き definitions のみ cdidx inspect Compute --body --body-start 40 --body-lines 40 cdidx inspect Compute --line 42 --context 2 --json +cdidx inspect Compute --json --limit 1 --cursor '' ``` ## Editor / index portability diff --git a/changelog.d/unreleased/4839.fixed.md b/changelog.d/unreleased/4839.fixed.md new file mode 100644 index 000000000..d054d1f15 --- /dev/null +++ b/changelog.d/unreleased/4839.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 4839 +affected: + - src/CodeIndex/Database/DbSymbolReader.Analysis.cs + - src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs + - src/CodeIndex/Mcp/McpToolHandlers.Graph.cs + - USER_GUIDE.md + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Location-based inspect now preserves graph identity and section completeness (#4839)** — path/line and name resolution share the persisted candidate ID, so ambiguous overloads do not borrow one another's references or callers. CLI `inspect` and MCP `analyze_symbol` now report independent totals, returned counts, truncation state, and query/index-bound continuation cursors for references, callers, and callees. + +## 日本語 + +- **location-based inspect が graph identity と section completeness を維持するようになりました (#4839)** — path/line と name resolution が persisted candidate ID を共有し、曖昧な overload 間で references や callers を借用しなくなりました。CLI `inspect` と MCP `analyze_symbol` は references、callers、callees ごとに独立した総数、返却件数、truncation 状態、query/index に束縛された continuation cursor を報告します。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 2be1d2d2f..4ea331391 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -145,7 +145,7 @@ public static bool HasAuthoritativeHelpOptions(string command) => "search", "definition", "find", "status", "hotspots", "references", "callers", "callees", "symbols", "files", "languages", "impact", "map", ]; - private static readonly string[] CursorCommands = ["search", "outline", "unused", "deps", .. BoundedProjectionCommands]; + private static readonly string[] CursorCommands = ["search", "outline", "unused", "deps", "inspect", .. BoundedProjectionCommands]; private static readonly string[] AllResultCommands = ["goto", "find", "unused"]; private static readonly string[] SinceCommands = ["search", "definition", "symbols", "files", "suggestions"]; diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index ac5513365..3555ddc6d 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -97,8 +97,8 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("find", "cdidx find (--path |--all) [--db ] [--json] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--context ] [--before ] [--after ] [--snippet-lines ] [--focus-line ] [--focus-column ] [--max-line-width ] [--line-scan-limit ] [--allow-partial] [--exact] [--regex] [--count]"), ("excerpt", "cdidx excerpt [--line |--start |--start-line ] [--end |--end-line ] [--context |--before |--after ] [--max-line-width ] [--focus-line ] [--focus-column ] [--focus-length ] [--db ] [--json] [--no-semantic-tokens] [--max-json-bytes ] [--verbose]"), ("map", "cdidx map [--db ] [--json] [--format ] [--pretty] [--compact] [--fields ] [--cursor ] [--summary-only] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes] [--sections ] [--depth ] [--min-entrypoint-confidence <0.0..1.0>] [--max-json-bytes ]"), - ("inspect", "cdidx inspect |--query |-- [--db ] [--json] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ] [--exact|--exact-name] [--group-partials]"), - ("inspect", "cdidx inspect --path --line [--end-line ] [--db ] [--json] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--max-json-bytes ] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ]"), + ("inspect", "cdidx inspect |--query |-- [--db ] [--json] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ] [--exact|--exact-name] [--group-partials]"), + ("inspect", "cdidx inspect --path --line [--end-line ] [--db ] [--json] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ]"), ("outline", "cdidx outline [--db ] [--json] [--pretty] [--compact] [--verbose] [--limit |--top ] [--cursor ] [--sort ] [--kind ] [--outline-fields ]"), ("status", "cdidx status [--db ] [--json] [--format ] [--compact] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--check[=workspace,fold,graph,issues,hotspot,csharp,sql,newer]] [--stale-after ] [--explain ] [--log-path] [--config] [--check-updates]"), ("workspace", "cdidx workspace [name-or-relative-path] [--json]"), diff --git a/src/CodeIndex/Cli/InspectGraphCursor.cs b/src/CodeIndex/Cli/InspectGraphCursor.cs new file mode 100644 index 000000000..7def6c6fd --- /dev/null +++ b/src/CodeIndex/Cli/InspectGraphCursor.cs @@ -0,0 +1,126 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using CodeIndex.Database; + +namespace CodeIndex.Cli; + +internal sealed record InspectGraphCursor( + string Section, + int Offset, + string? CandidateSelector, + string QueryFingerprint, + string GenerationFingerprint); + +internal static class InspectGraphCursorCodec +{ + private const string Prefix = "inspect-graph:v1:"; + + internal static string BuildQueryFingerprint(IEnumerable components) + => BuildValueFingerprint(string.Join('\0', components.Select(component => component ?? string.Empty))); + + internal static (string Fingerprint, string? StableAt) BuildGenerationFingerprint(DbReader reader) + { + var generation = reader.GetPaginationGeneration(); + return (BuildValueFingerprint(generation.Identity), generation.StableAt); + } + + internal static string Format( + string section, + int offset, + string? candidateSelector, + string queryFingerprint, + string generationFingerprint) + { + var payload = new JsonObject + { + ["section"] = section, + ["offset"] = offset, + ["candidate"] = candidateSelector, + ["query"] = queryFingerprint, + ["generation"] = generationFingerprint, + }; + var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(payload.ToJsonString())) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + return Prefix + encoded; + } + + internal static bool TryParse(string cursor, out InspectGraphCursor? parsed) + { + parsed = null; + if (!cursor.StartsWith(Prefix, StringComparison.Ordinal) || cursor.Length > 16_384) + return false; + + var encoded = cursor[Prefix.Length..] + .Replace('-', '+') + .Replace('_', '/'); + var paddingLength = (4 - encoded.Length % 4) % 4; + if (paddingLength > 0) + encoded += new string('=', paddingLength); + + JsonObject? payload; + try + { + payload = JsonNode.Parse(Encoding.UTF8.GetString(Convert.FromBase64String(encoded))) as JsonObject; + } + catch (Exception ex) when (ex is FormatException or JsonException) + { + return false; + } + + if (payload is null + || !TryGetString(payload, "section", out var section) + || section is not ("references" or "callers" or "callees") + || payload["offset"] is not JsonValue offsetValue + || !offsetValue.TryGetValue(out var offset) + || offset < 0 + || !TryGetString(payload, "query", out var queryFingerprint) + || !TryGetString(payload, "generation", out var generationFingerprint) + || !IsFingerprint(queryFingerprint) + || !IsFingerprint(generationFingerprint)) + { + return false; + } + + string? candidateSelector = null; + if (payload["candidate"] is JsonValue candidateValue + && !candidateValue.TryGetValue(out candidateSelector)) + { + return false; + } + if (candidateSelector is { Length: > 4096 }) + return false; + parsed = new InspectGraphCursor( + section, + offset, + candidateSelector, + queryFingerprint, + generationFingerprint); + return true; + } + + private static bool IsFingerprint(string fingerprint) + => fingerprint.Length == 16 && fingerprint.All(Uri.IsHexDigit); + + private static bool TryGetString(JsonObject payload, string propertyName, out string value) + { + value = string.Empty; + if (payload[propertyName] is not JsonValue jsonValue + || !jsonValue.TryGetValue(out var parsedValue) + || parsedValue == null) + { + return false; + } + value = parsedValue; + return true; + } + + private static string BuildValueFingerprint(string value) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value)); + return Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant(); + } +} diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Search.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Search.cs index d30372188..758cae0e5 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Search.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Search.cs @@ -192,9 +192,16 @@ private bool TryParseSearchOption(string normalizedArg, string currentArg, strin outlineCursorOffset = parsedOutlineCursorOffset; else if (TryParseDependencyCycleCursor(parsedCursorValue, out var parsedDependencyCycleCursor)) dependencyCycleCursor = parsedDependencyCycleCursor; + else if (InspectGraphCursorCodec.TryParse(parsedCursorValue, out _)) + { + // inspect validates query and index-generation binding after + // resolving the effective path/name query inside RunInspect. + // inspect は有効な path/name query を確定後、RunInspect 内で + // query / index generation binding を検証する。 + } else { - AddParseError("Error: --cursor must be a search, unused, outline, or dependency-cycle pagination cursor returned as `next_cursor`."); + AddParseError("Error: --cursor must be a search, unused, outline, dependency-cycle, or inspect-graph pagination cursor returned as `next_cursor`."); break; } rawCursorValue = parsedCursorValue; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs b/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs index e983e13ac..e3a3a3c37 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs @@ -61,6 +61,23 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions } if (TryWriteUnexpectedExtraPositionals("inspect", options)) return CommandExitCodes.UsageError; + if (!TryReadInspectCursor(cmdArgs, out var cursor, out var cursorError)) + { + WriteUsageError( + cursorError!, + GetUsageLineOrThrow("inspect"), + "Pass an unchanged inspect query with a next_cursor returned by one graph section."); + return CommandExitCodes.UsageError; + } + InspectGraphCursor? graphCursor = null; + if (cursor != null && !InspectGraphCursorCodec.TryParse(cursor, out graphCursor)) + { + WriteUsageError( + "invalid inspect graph cursor", + GetUsageLineOrThrow("inspect"), + "Use a next_cursor returned by the same inspect query and graph section."); + return CommandExitCodes.UsageError; + } if (options.StartLine.HasValue && options.EndLine.HasValue && options.EndLine.Value < options.StartLine.Value) { WriteValidationError( @@ -126,6 +143,28 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions var inspectQuery = fileInspectMode ? $"{inspectPath}:{inspectLine}" : options.Query!; + var queryFingerprint = BuildInspectGraphQueryFingerprint( + inspectQuery, + options, + exact, + fileInspectMode); + var generation = InspectGraphCursorCodec.BuildGenerationFingerprint(reader); + if (graphCursor != null + && (!string.Equals(graphCursor.QueryFingerprint, queryFingerprint, StringComparison.Ordinal) + || !string.Equals(graphCursor.GenerationFingerprint, generation.Fingerprint, StringComparison.Ordinal))) + { + WriteUsageError( + "inspect graph cursor does not match this query or index generation", + GetUsageLineOrThrow("inspect"), + "Rerun the original inspect query without --cursor, then use the newly returned section cursor."); + return CommandExitCodes.UsageError; + } + var graphPage = graphCursor == null + ? null + : new SymbolGraphPageRequest( + graphCursor.Section, + graphCursor.Offset, + graphCursor.CandidateSelector); var analysis = fileInspectMode ? reader.AnalyzeFileLine( inspectPath!, @@ -139,7 +178,8 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions options.MaxLineWidth, options.BodyStartLine, options.BodyLines, - kind: options.Kind) + kind: options.Kind, + graphPage: graphPage) : reader.AnalyzeSymbol( inspectQuery, inspectLimit, @@ -153,7 +193,18 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions options.BodyStartLine, options.BodyLines, kind: options.Kind, - groupPartials: options.GroupPartials); + groupPartials: options.GroupPartials, + graphPage: graphPage); + if (graphCursor?.CandidateSelector != null + && !(analysis.CandidateBundles?.Any(bundle => + string.Equals(bundle.Selector.Selector, graphCursor.CandidateSelector, StringComparison.Ordinal)) ?? false)) + { + WriteUsageError( + "inspect graph cursor candidate is no longer available", + GetUsageLineOrThrow("inspect"), + "Rerun the inspect query without --cursor and select a cursor from the current candidate bundle."); + return CommandExitCodes.UsageError; + } var sourceExcerpt = BuildInspectSourceExcerpt(reader, options, analysis, inspectPath); var sqlGraphSignal = NarrowSqlGraphContractSignal( reader.GetSqlGraphContractSignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests), @@ -180,6 +231,11 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions if (options.Json) { var compactTruncation = options.Compact ? ApplySymbolAnalysisCompactCaps(analysis, compactLimit) : null; + SynchronizeInspectGraphSectionCounts(analysis); + ApplyInspectGraphContinuationCursors( + analysis, + queryFingerprint, + generation.Fingerprint); ApplyBodyRecoveryCommands(analysis, options.DbPath); var payload = JsonSerializer.SerializeToNode(analysis, CliJsonSerializerContextFactory.Create(jsonOptions).SymbolAnalysisResult)!.AsObject(); AddSqlGraphContractJsonFields(payload, sqlGraphSignal); @@ -208,6 +264,11 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions } else { + SynchronizeInspectGraphSectionCounts(analysis); + ApplyInspectGraphContinuationCursors( + analysis, + queryFingerprint, + generation.Fingerprint); Console.WriteLine($"Query: {analysis.Query}"); if (analysis.File != null) Console.WriteLine($"File : {analysis.File.Path} ({analysis.File.Lang ?? "?"}, {analysis.File.Lines} lines)"); @@ -258,8 +319,11 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions WriteRepoMapSection("Definitions", analysis.Definitions.Select(item => $"{item.Kind,-10} {item.Name,-24} {item.Path}:{item.StartLine}-{item.EndLine}")); WriteRepoMapSection("Nearby symbols", analysis.NearbySymbols.Select(item => $"{item.Kind,-10} {item.Name,-24} {item.Path}:{item.StartLine}-{item.EndLine}")); WriteRepoMapSection("References", analysis.References.Select(item => $"{item.Path}:{item.Line}:{item.Column} {item.Context}")); + WriteInspectGraphSectionStatus("References", analysis.GraphSections.References); WriteRepoMapSection("Callers", analysis.Callers.Select(item => $"{item.CallerName ?? ""} -> {item.CalleeName} ({item.ReferenceCount} refs)")); + WriteInspectGraphSectionStatus("Callers", analysis.GraphSections.Callers); WriteRepoMapSection("Callees", analysis.Callees.Select(item => $"{item.CallerName ?? ""} -> {item.CalleeName} ({item.ReferenceCount} refs)")); + WriteInspectGraphSectionStatus("Callees", analysis.GraphSections.Callees); if (analysis.CandidateBundles is { Count: > 1 }) { foreach (var bundle in analysis.CandidateBundles) @@ -267,8 +331,11 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions var title = $"Candidate {bundle.Selector.Selector} ({bundle.Selector.QualifiedName})"; WriteRepoMapSection(title, [$"{bundle.Definition.Kind,-10} {bundle.Definition.Path}:{bundle.Definition.StartLine}-{bundle.Definition.EndLine}"]); WriteRepoMapSection($"{title} references", bundle.References.Select(item => $"{item.Path}:{item.Line}:{item.Column} {item.Context}")); + WriteInspectGraphSectionStatus($"{title} references", bundle.GraphSections.References); WriteRepoMapSection($"{title} callers", bundle.Callers.Select(item => $"{item.CallerName ?? ""} -> {item.CalleeName} ({item.ReferenceCount} refs)")); + WriteInspectGraphSectionStatus($"{title} callers", bundle.GraphSections.Callers); WriteRepoMapSection($"{title} callees", bundle.Callees.Select(item => $"{item.CallerName ?? ""} -> {item.CalleeName} ({item.ReferenceCount} refs)")); + WriteInspectGraphSectionStatus($"{title} callees", bundle.GraphSections.Callees); } } } @@ -277,6 +344,188 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions }); } + private static bool TryReadInspectCursor( + string[] args, + out string? cursor, + out string? error) + { + cursor = null; + error = null; + for (var index = 0; index < args.Length; index++) + { + var arg = args[index]; + string? value = null; + if (arg.StartsWith("--cursor=", StringComparison.Ordinal)) + { + value = arg["--cursor=".Length..]; + } + else if (string.Equals(arg, "--cursor", StringComparison.Ordinal)) + { + if (++index >= args.Length) + { + error = "--cursor requires a value"; + return false; + } + value = args[index]; + } + else + { + continue; + } + + if (string.IsNullOrWhiteSpace(value)) + { + error = "--cursor requires a non-empty value"; + return false; + } + if (cursor != null) + { + error = "--cursor may be specified only once"; + return false; + } + cursor = value; + } + return true; + } + + private static string BuildInspectGraphQueryFingerprint( + string inspectQuery, + QueryCommandOptions options, + bool exact, + bool fileInspectMode) + { + var components = new List + { + "inspect", + fileInspectMode ? "location" : "symbol", + inspectQuery, + options.Lang, + options.Kind, + exact ? "exact" : "substring", + options.ExcludeTests ? "exclude-tests" : "include-tests", + options.GroupPartials ? "group-partials" : "physical-definitions", + }; + components.AddRange(options.PathPatterns + .OrderBy(path => path, StringComparer.Ordinal) + .Select(path => "path:" + path)); + components.AddRange(options.ExcludePaths + .OrderBy(path => path, StringComparer.Ordinal) + .Select(path => "exclude:" + path)); + return InspectGraphCursorCodec.BuildQueryFingerprint(components); + } + + internal static void SynchronizeInspectGraphSectionCounts(SymbolAnalysisResult analysis) + { + SynchronizeInspectGraphSections( + analysis.GraphSections, + analysis.References.Count, + analysis.Callers.Count, + analysis.Callees.Count); + if (analysis.CandidateBundles == null) + return; + foreach (var bundle in analysis.CandidateBundles) + { + SynchronizeInspectGraphSections( + bundle.GraphSections, + bundle.References.Count, + bundle.Callers.Count, + bundle.Callees.Count); + } + } + + private static void SynchronizeInspectGraphSections( + SymbolGraphSections sections, + int referenceCount, + int callerCount, + int calleeCount) + { + SynchronizeInspectGraphSection(sections.References, referenceCount); + SynchronizeInspectGraphSection(sections.Callers, callerCount); + SynchronizeInspectGraphSection(sections.Callees, calleeCount); + } + + private static void SynchronizeInspectGraphSection(SymbolGraphSection section, int returned) + { + section.Returned = returned; + section.Truncated = section.Offset + returned < section.Total; + } + + internal static void ApplyInspectGraphContinuationCursors( + SymbolAnalysisResult analysis, + string queryFingerprint, + string generationFingerprint) + { + var primarySelector = analysis.GraphScope == "query_fallback" + ? null + : analysis.CandidateBundles?.FirstOrDefault()?.Selector.Selector; + ApplyInspectGraphContinuationCursors( + analysis.GraphSections, + primarySelector, + queryFingerprint, + generationFingerprint); + if (analysis.CandidateBundles == null) + return; + foreach (var bundle in analysis.CandidateBundles) + { + ApplyInspectGraphContinuationCursors( + bundle.GraphSections, + bundle.Selector.Selector, + queryFingerprint, + generationFingerprint); + } + } + + private static void ApplyInspectGraphContinuationCursors( + SymbolGraphSections sections, + string? candidateSelector, + string queryFingerprint, + string generationFingerprint) + { + ApplyInspectGraphContinuationCursor( + sections.References, + "references", + candidateSelector, + queryFingerprint, + generationFingerprint); + ApplyInspectGraphContinuationCursor( + sections.Callers, + "callers", + candidateSelector, + queryFingerprint, + generationFingerprint); + ApplyInspectGraphContinuationCursor( + sections.Callees, + "callees", + candidateSelector, + queryFingerprint, + generationFingerprint); + } + + private static void ApplyInspectGraphContinuationCursor( + SymbolGraphSection section, + string sectionName, + string? candidateSelector, + string queryFingerprint, + string generationFingerprint) + { + section.NextCursor = section.Truncated && section.Returned > 0 + ? InspectGraphCursorCodec.Format( + sectionName, + checked(section.Offset + section.Returned), + candidateSelector, + queryFingerprint, + generationFingerprint) + : null; + } + + private static void WriteInspectGraphSectionStatus(string label, SymbolGraphSection section) + { + Console.WriteLine( + $"{label} completeness: returned {section.Returned} of {section.Total} (offset {section.Offset}, truncated: {section.Truncated.ToString().ToLowerInvariant()})"); + if (section.NextCursor != null) + Console.WriteLine($"{label} next cursor: {section.NextCursor}"); + } + private static void AddInspectLogicalPartialJsonFields(JsonObject payload, SymbolAnalysisResult analysis) { var physicalDefinitionCount = analysis.Definitions.Sum(definition => definition.DefinitionSites ?? 1); @@ -323,6 +572,8 @@ private static void AddInspectFieldProperties(HashSet keep, string field keep.Add("graph_scope"); keep.Add("selection_required"); } + if (field is "graph" or "references" or "callers" or "callees") + keep.Add("graph_sections"); switch (field) { diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index 3d382ccaf..dc2b70f77 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -51,8 +51,25 @@ private static bool ShouldFilterCSharpReceiverQualifiedBareMemberQuery(string qu public List GetCallers(string query, int limit = 20, string? lang = null, string? referenceKind = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, bool rawKinds = false, ReferenceRankMode rankMode = ReferenceRankMode.Weighted, bool excludeSelfReferences = false, int offset = 0) => GetCallersCore(query, limit, lang, referenceKind, pathPatterns, excludePathPatterns, excludeTests, exact, rawKinds, rankMode, excludeSelfReferences, offset, targetSymbolId: null); - private List GetCallersForCandidate(DefinitionResult definition, int limit, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests) - => GetCallersCore(definition.Name, limit, definition.Lang, referenceKind: null, pathPatterns, excludePathPatterns, excludeTests, exact: true, rawKinds: false, ReferenceRankMode.Weighted, excludeSelfReferences: false, offset: 0, definition.SymbolId); + private List GetCallersForCandidate(DefinitionResult definition, int limit, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, int offset = 0) + => GetCallersCore(definition.Name, limit, definition.Lang, referenceKind: null, pathPatterns, excludePathPatterns, excludeTests, exact: true, rawKinds: false, ReferenceRankMode.Weighted, excludeSelfReferences: false, offset, definition.SymbolId); + + private int CountCallersForCandidate(DefinitionResult definition, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests) + { + if (definition.SymbolId is not long symbolId || !HasTable("symbol_reference_candidates")) + return 0; + + return CountCallersTotalCore( + definition.Name, + definition.Lang, + referenceKind: null, + pathPatterns, + excludePathPatterns, + excludeTests, + exact: true, + rawKinds: false, + symbolId).Count; + } private List GetCallersCore(string query, int limit, string? lang, string? referenceKind, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, bool exact, bool rawKinds, ReferenceRankMode rankMode, bool excludeSelfReferences, int offset, long? targetSymbolId) { @@ -370,6 +387,27 @@ FROM symbol_references r } public QueryCountResult CountCallersTotal(string query, string? lang = null, string? referenceKind = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, bool rawKinds = false) + => CountCallersTotalCore( + query, + lang, + referenceKind, + pathPatterns, + excludePathPatterns, + excludeTests, + exact, + rawKinds, + targetSymbolId: null); + + private QueryCountResult CountCallersTotalCore( + string query, + string? lang, + string? referenceKind, + IReadOnlyList? pathPatterns, + IReadOnlyList? excludePathPatterns, + bool excludeTests, + bool exact, + bool rawKinds, + long? targetSymbolId) { if (!_hasReferencesTable) return new QueryCountResult(0, 0); @@ -392,6 +430,19 @@ FROM symbol_references r groupedSql += $" AND {BuildGraphSupportedLanguagePredicate(cmd, "f", "graphLang")}"; groupedSql += $" AND {GetCallableReferenceKindPredicateSql("r.reference_kind", referenceKind)}"; + if (targetSymbolId != null && HasTable("symbol_reference_candidates")) + { + groupedSql += _referenceColumns.Contains("resolution_state") + ? " AND r.resolution_state IN ('resolved', 'resolved_group')" + : " AND 1 = 0"; + groupedSql += @" + AND EXISTS ( + SELECT 1 + FROM symbol_reference_candidates AS identity_candidate + WHERE identity_candidate.reference_id = r.id + AND identity_candidate.symbol_id = @targetSymbolId + )"; + } var allowSqlLeafFallback = AllowSqlLeafFallbackForQuery(query); var allowCSharpQualifiedContextMatch = SqlNameResolver.HasQualifier(query) && !HasQualifiedSymbolDefinition(query, lang, pathPatterns, excludePathPatterns, excludeTests); @@ -442,7 +493,11 @@ FROM symbol_references r ? $" AND (r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryCssScssVariableAlias COLLATE NOCASE{cssScssVariableAliasScope}) OR (f.lang = 'sql' AND r.symbol_name = sql_leaf_name(@aliasQuery) COLLATE NOCASE))" : " AND (r.symbol_name LIKE @query ESCAPE '\\' OR (f.lang = 'sql' AND r.symbol_name = sql_leaf_name(@aliasQuery) COLLATE NOCASE))"; if (lang != null) - groupedSql += " AND f.lang = @lang"; + { + groupedSql += IncludeAmbiguousMSourceForIdentityTarget(lang, targetSymbolId) + ? " AND (f.lang = @lang OR f.lang = 'ambiguous_m')" + : " AND f.lang = @lang"; + } groupedSql += BuildCSharpBareMemberGraphReferenceFilter(query, lang, exact, contextSql, "f", "r"); AppendPathFilters(ref groupedSql, pathPatterns, excludePathPatterns, excludeTests); groupedSql += $" GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.file_id, r.line, r.column_number, {(rawKinds ? GetRawReferenceKindSql("r.reference_kind") : GetLogicalReferenceKindSql("r.reference_kind"))}"; @@ -472,6 +527,8 @@ FROM symbol_references r SqliteCommandPolicy.Add(cmd, "@referenceKind", referenceKind); if (lang != null) SqliteCommandPolicy.Add(cmd, "@lang", NormalizeQueryLanguage(lang)); + if (targetSymbolId != null) + SqliteCommandPolicy.Add(cmd, "@targetSymbolId", targetSymbolId.Value); AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns); return ExecuteCountSummary(cmd); @@ -484,8 +541,25 @@ FROM symbol_references r public List GetCallees(string query, int limit = 20, string? lang = null, string? referenceKind = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, bool rawKinds = false, ReferenceRankMode rankMode = ReferenceRankMode.Weighted, int offset = 0) => GetCalleesCore(query, limit, lang, referenceKind, pathPatterns, excludePathPatterns, excludeTests, exact, rawKinds, rankMode, offset, sourceSymbolId: null); - private List GetCalleesForCandidate(DefinitionResult definition, int limit, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests) - => GetCalleesCore(definition.Name, limit, definition.Lang, referenceKind: null, pathPatterns, excludePathPatterns, excludeTests, exact: true, rawKinds: false, ReferenceRankMode.Weighted, offset: 0, definition.SymbolId); + private List GetCalleesForCandidate(DefinitionResult definition, int limit, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, int offset = 0) + => GetCalleesCore(definition.Name, limit, definition.Lang, referenceKind: null, pathPatterns, excludePathPatterns, excludeTests, exact: true, rawKinds: false, ReferenceRankMode.Weighted, offset, definition.SymbolId); + + private int CountCalleesForCandidate(DefinitionResult definition, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests) + { + if (definition.SymbolId is not long symbolId || !_referenceColumns.Contains("source_symbol_id")) + return 0; + + return CountCalleesTotalCore( + definition.Name, + definition.Lang, + referenceKind: null, + pathPatterns, + excludePathPatterns, + excludeTests, + exact: true, + rawKinds: false, + symbolId).Count; + } private List GetCalleesCore(string query, int limit, string? lang, string? referenceKind, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, bool exact, bool rawKinds, ReferenceRankMode rankMode, int offset, long? sourceSymbolId) { @@ -747,6 +821,27 @@ FROM symbol_references r } public QueryCountResult CountCalleesTotal(string query, string? lang = null, string? referenceKind = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, bool rawKinds = false) + => CountCalleesTotalCore( + query, + lang, + referenceKind, + pathPatterns, + excludePathPatterns, + excludeTests, + exact, + rawKinds, + sourceSymbolId: null); + + private QueryCountResult CountCalleesTotalCore( + string query, + string? lang, + string? referenceKind, + IReadOnlyList? pathPatterns, + IReadOnlyList? excludePathPatterns, + bool excludeTests, + bool exact, + bool rawKinds, + long? sourceSymbolId) { lang = NormalizeQueryLanguage(lang); if (!_hasReferencesTable) @@ -765,6 +860,8 @@ FROM symbol_references r groupedSql += $" AND {BuildGraphSupportedLanguagePredicate(cmd, "f", "graphLang")}"; groupedSql += $" AND {GetCallableReferenceKindPredicateSql("r.reference_kind", referenceKind)}"; + if (sourceSymbolId != null && _referenceColumns.Contains("source_symbol_id")) + groupedSql += " AND r.source_symbol_id = @sourceSymbolId"; var allowSqlLeafFallback = AllowSqlLeafFallbackForQuery(query); var allowQualifiedLeafFallback = HasSingleQualifiedSymbolDefinition(query, lang, pathPatterns, excludePathPatterns, excludeTests); var useSqlQualifiedContainerMatch = SqlNameResolver.HasQualifier(query); @@ -841,6 +938,8 @@ FROM symbol_references r SqliteCommandPolicy.Add(cmd, "@referenceKind", referenceKind); if (lang != null) SqliteCommandPolicy.Add(cmd, "@lang", lang); + if (sourceSymbolId != null) + SqliteCommandPolicy.Add(cmd, "@sourceSymbolId", sourceSymbolId.Value); AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns); return ExecuteCountSummary(cmd); diff --git a/src/CodeIndex/Database/DbReader.References.cs b/src/CodeIndex/Database/DbReader.References.cs index 58f06294d..2b30b5886 100644 --- a/src/CodeIndex/Database/DbReader.References.cs +++ b/src/CodeIndex/Database/DbReader.References.cs @@ -109,7 +109,8 @@ private List SearchReferencesForCandidate( IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, - int maxLineWidth) + int maxLineWidth, + int offset = 0) { if (definition.SymbolId is not long symbolId || !HasTable("symbol_reference_candidates")) return []; @@ -123,12 +124,39 @@ private List SearchReferencesForCandidate( excludePathPatterns, excludeTests, exact: true, - offset: 0, + offset, maxLineWidth, excludeSelfReferences: false, targetSymbolId: symbolId); } + private int CountSearchReferencesForCandidate( + DefinitionResult definition, + IReadOnlyList? pathPatterns, + IReadOnlyList? excludePathPatterns, + bool excludeTests) + { + if (definition.SymbolId is not long symbolId || !HasTable("symbol_reference_candidates")) + return 0; + + using var cmd = CreateSearchReferencesCommandCore( + definition.Name, + limit: 1, + definition.Lang, + referenceKind: null, + pathPatterns, + excludePathPatterns, + excludeTests, + exact: true, + offset: 0, + includeOrdering: false, + excludeSelfReferences: false, + targetSymbolId: symbolId); + cmd.CommandText = $"SELECT COUNT(*) FROM ({cmd.CommandText})"; + var raw = cmd.ExecuteScalar(); + return raw is long count ? checked((int)count) : Convert.ToInt32(raw); + } + internal ReferencePositionResolution GetReferencePositionResolution( string path, string symbolName, diff --git a/src/CodeIndex/Database/DbSymbolReader.Analysis.cs b/src/CodeIndex/Database/DbSymbolReader.Analysis.cs index a8050fa45..dc84376e4 100644 --- a/src/CodeIndex/Database/DbSymbolReader.Analysis.cs +++ b/src/CodeIndex/Database/DbSymbolReader.Analysis.cs @@ -82,7 +82,8 @@ public SymbolAnalysisResult AnalyzeFileLine( int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth, int? bodyStartLine = null, int? bodyLineCount = null, - string? kind = null) + string? kind = null, + SymbolGraphPageRequest? graphPage = null) { lang = DbReader.NormalizeQueryLanguage(lang); using var txn = _conn.BeginTransaction(deferred: true); @@ -110,15 +111,20 @@ public SymbolAnalysisResult AnalyzeFileLine( baseGraphSupported, hasUnsupportedEnumMember: false, hasSupportedGraphDefinition); - var references = primaryDefinition != null - ? SearchReferences(primaryDefinition.Name, limit, lang, null, pathPatterns, excludePathPatterns, excludeTests, exact: true, maxLineWidth) - : []; - var callers = primaryDefinition != null - ? GetCallers(primaryDefinition.Name, limit, lang, null, pathPatterns, excludePathPatterns, excludeTests, exact: true) - : []; - var callees = primaryDefinition != null - ? GetCallees(primaryDefinition.Name, limit, lang, null, pathPatterns, excludePathPatterns, excludeTests, exact: true) - : []; + var candidateBundle = primaryDefinition == null + ? null + : BuildSymbolCandidateBundle( + primaryDefinition, + limit, + includeNameFallback: true, + pathPatterns, + excludePathPatterns, + excludeTests, + maxLineWidth, + graphPage); + var references = candidateBundle?.References ?? []; + var callers = candidateBundle?.Callers ?? []; + var callees = candidateBundle?.Callees ?? []; var nearbySymbols = file != null ? GetNearbySymbols( path, @@ -137,6 +143,14 @@ public SymbolAnalysisResult AnalyzeFileLine( WorkspaceIndexedAt = freshness.IndexedAt, WorkspaceLatestModified = freshness.LatestModified, GraphLanguage = graphLanguage, + GraphLanguageSource = lang != null + ? "language_filter" + : primaryDefinition?.Lang != null + ? "definition" + : null, + GraphLanguageConfidence = lang != null || primaryDefinition?.Lang != null + ? "authoritative" + : null, GraphSupported = baseGraphSupported, GraphSupportReason = graphSupportReason, Definitions = definitions, @@ -144,6 +158,10 @@ public SymbolAnalysisResult AnalyzeFileLine( References = [.. references], Callers = [.. callers], Callees = [.. callees], + GraphSections = candidateBundle?.GraphSections ?? new SymbolGraphSections(), + CandidateCount = candidateBundle == null ? 0 : 1, + GraphScope = candidateBundle == null ? null : "single_candidate", + CandidateBundles = candidateBundle == null ? null : [candidateBundle], GraphTableAvailable = _hasReferencesTable, }; txn.Commit(); @@ -168,7 +186,8 @@ private List GetSymbolsAtLine(string path, int line, int limit, st {GetSymbolColumnSql("container_kind")} AS container_kind, {GetSymbolColumnSql("container_name")} AS container_name, {GetSymbolColumnSql("visibility")} AS visibility, - {GetSymbolColumnSql("return_type")} AS return_type + {GetSymbolColumnSql("return_type")} AS return_type, + s.id AS symbol_id FROM symbols s JOIN files f ON s.file_id = f.id WHERE f.path = @path @@ -222,13 +241,14 @@ private static SymbolResult ReadSymbolResult(SqliteDataReader reader) ContainerName = GetNullableString(reader, 11), Visibility = GetNullableString(reader, 12), ReturnType = GetNullableString(reader, 13), + SymbolId = reader.GetInt64(14), }; /// /// Bundle definition, graph, and local file context for one symbol query. /// 単一シンボルクエリ向けに、定義・グラフ・ローカル文脈をまとめて返す。 /// - public SymbolAnalysisResult AnalyzeSymbol(string query, int limit = 10, string? lang = null, bool includeBody = false, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth, int? bodyStartLine = null, int? bodyLineCount = null, string? kind = null, bool groupPartials = false) + public SymbolAnalysisResult AnalyzeSymbol(string query, int limit = 10, string? lang = null, bool includeBody = false, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth, int? bodyStartLine = null, int? bodyLineCount = null, string? kind = null, bool groupPartials = false, SymbolGraphPageRequest? graphPage = null) { if (string.IsNullOrWhiteSpace(query) || IsBareVerbatimQueryToken(query)) { @@ -290,22 +310,39 @@ public SymbolAnalysisResult AnalyzeSymbol(string query, int limit = 10, string? pathPatterns, excludePathPatterns, excludeTests, - maxLineWidth)) + maxLineWidth, + graphPage)) .ToList(); var selectedBundle = candidateBundles.FirstOrDefault(); var file = selectedBundle?.File; + var fallbackReferenceOffset = GetGraphSectionOffset(graphPage, "references", candidateSelector: null); + var fallbackCallerOffset = GetGraphSectionOffset(graphPage, "callers", candidateSelector: null); + var fallbackCalleeOffset = GetGraphSectionOffset(graphPage, "callees", candidateSelector: null); var references = selectedBundle?.References ?? (definitions.Count == 0 - ? SearchReferences(normalizedQuery, limit, lang, null, pathPatterns, excludePathPatterns, excludeTests, exact, maxLineWidth) + ? SearchReferences(normalizedQuery, limit, lang, null, pathPatterns, excludePathPatterns, excludeTests, exact, maxLineWidth, offset: fallbackReferenceOffset) : []); var callers = selectedBundle?.Callers ?? (definitions.Count == 0 - ? GetCallers(normalizedQuery, limit, lang, null, pathPatterns, excludePathPatterns, excludeTests, exact) + ? GetCallers(normalizedQuery, limit, lang, null, pathPatterns, excludePathPatterns, excludeTests, exact, offset: fallbackCallerOffset) : []); var callees = selectedBundle?.Callees ?? (definitions.Count == 0 - ? GetCallees(normalizedQuery, limit, lang, null, pathPatterns, excludePathPatterns, excludeTests, exact) + ? GetCallees(normalizedQuery, limit, lang, null, pathPatterns, excludePathPatterns, excludeTests, exact, offset: fallbackCalleeOffset) : []); + var graphSections = selectedBundle?.GraphSections + ?? (definitions.Count == 0 + ? BuildGraphSections( + CountSearchReferencesTotal(normalizedQuery, lang, null, pathPatterns, excludePathPatterns, excludeTests, exact).Count, + references.Count, + fallbackReferenceOffset, + CountCallersTotal(normalizedQuery, lang, null, pathPatterns, excludePathPatterns, excludeTests, exact).Count, + callers.Count, + fallbackCallerOffset, + CountCalleesTotal(normalizedQuery, lang, null, pathPatterns, excludePathPatterns, excludeTests, exact).Count, + callees.Count, + fallbackCalleeOffset) + : new SymbolGraphSections()); if (graphLanguage == null) { graphLanguageCandidates = references.Select(reference => reference.Lang) @@ -390,6 +427,7 @@ public SymbolAnalysisResult AnalyzeSymbol(string query, int limit = 10, string? References = [.. references], Callers = [.. callers], Callees = [.. callees], + GraphSections = graphSections, CandidateCount = candidateBundles.Count, GraphScope = candidateBundles.Count switch { @@ -456,24 +494,44 @@ private SymbolCandidateBundle BuildSymbolCandidateBundle( IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, - int maxLineWidth) + int maxLineWidth, + SymbolGraphPageRequest? graphPage = null) { var identityScoped = CanScopeCandidateByIdentity(definition); + var selector = BuildSymbolCandidateSelector(definition); + var referenceOffset = GetGraphSectionOffset(graphPage, "references", selector.Selector); + var callerOffset = GetGraphSectionOffset(graphPage, "callers", selector.Selector); + var calleeOffset = GetGraphSectionOffset(graphPage, "callees", selector.Selector); var references = identityScoped - ? SearchReferencesForCandidate(definition, limit, pathPatterns, excludePathPatterns, excludeTests, maxLineWidth) + ? SearchReferencesForCandidate(definition, limit, pathPatterns, excludePathPatterns, excludeTests, maxLineWidth, referenceOffset) : includeNameFallback - ? SearchReferences(definition.Name, limit, definition.Lang, null, pathPatterns, excludePathPatterns, excludeTests, exact: true, maxLineWidth) + ? SearchReferences(definition.Name, limit, definition.Lang, null, pathPatterns, excludePathPatterns, excludeTests, exact: true, maxLineWidth, offset: referenceOffset) : []; var callers = identityScoped - ? GetCallersForCandidate(definition, limit, pathPatterns, excludePathPatterns, excludeTests) + ? GetCallersForCandidate(definition, limit, pathPatterns, excludePathPatterns, excludeTests, callerOffset) : includeNameFallback - ? GetCallers(definition.Name, limit, definition.Lang, null, pathPatterns, excludePathPatterns, excludeTests, exact: true) + ? GetCallers(definition.Name, limit, definition.Lang, null, pathPatterns, excludePathPatterns, excludeTests, exact: true, offset: callerOffset) : []; var callees = identityScoped - ? GetCalleesForCandidate(definition, limit, pathPatterns, excludePathPatterns, excludeTests) + ? GetCalleesForCandidate(definition, limit, pathPatterns, excludePathPatterns, excludeTests, calleeOffset) : includeNameFallback - ? GetCallees(definition.Name, limit, definition.Lang, null, pathPatterns, excludePathPatterns, excludeTests, exact: true) + ? GetCallees(definition.Name, limit, definition.Lang, null, pathPatterns, excludePathPatterns, excludeTests, exact: true, offset: calleeOffset) : []; + var referenceTotal = identityScoped + ? CountSearchReferencesForCandidate(definition, pathPatterns, excludePathPatterns, excludeTests) + : includeNameFallback + ? CountSearchReferencesTotal(definition.Name, definition.Lang, null, pathPatterns, excludePathPatterns, excludeTests, exact: true).Count + : 0; + var callerTotal = identityScoped + ? CountCallersForCandidate(definition, pathPatterns, excludePathPatterns, excludeTests) + : includeNameFallback + ? CountCallersTotal(definition.Name, definition.Lang, null, pathPatterns, excludePathPatterns, excludeTests, exact: true).Count + : 0; + var calleeTotal = identityScoped + ? CountCalleesForCandidate(definition, pathPatterns, excludePathPatterns, excludeTests) + : includeNameFallback + ? CountCalleesTotal(definition.Name, definition.Lang, null, pathPatterns, excludePathPatterns, excludeTests, exact: true).Count + : 0; var nearbySymbols = GetNearbySymbols( definition.Path, definition.StartLine, @@ -492,7 +550,7 @@ private SymbolCandidateBundle BuildSymbolCandidateBundle( return new SymbolCandidateBundle { - Selector = BuildSymbolCandidateSelector(definition), + Selector = selector, Definition = definition, File = GetFileByPath(definition.Path), GraphSupported = graphSupported, @@ -502,9 +560,55 @@ private SymbolCandidateBundle BuildSymbolCandidateBundle( References = references, Callers = callers, Callees = callees, + GraphSections = BuildGraphSections( + referenceTotal, + references.Count, + referenceOffset, + callerTotal, + callers.Count, + callerOffset, + calleeTotal, + callees.Count, + calleeOffset), }; } + private static int GetGraphSectionOffset( + SymbolGraphPageRequest? page, + string section, + string? candidateSelector) + => page != null + && string.Equals(page.Section, section, StringComparison.Ordinal) + && string.Equals(page.CandidateSelector, candidateSelector, StringComparison.Ordinal) + ? Math.Max(0, page.Offset) + : 0; + + private static SymbolGraphSections BuildGraphSections( + int referenceTotal, + int referenceReturned, + int referenceOffset, + int callerTotal, + int callerReturned, + int callerOffset, + int calleeTotal, + int calleeReturned, + int calleeOffset) + => new() + { + References = BuildGraphSection(referenceTotal, referenceReturned, referenceOffset), + Callers = BuildGraphSection(callerTotal, callerReturned, callerOffset), + Callees = BuildGraphSection(calleeTotal, calleeReturned, calleeOffset), + }; + + private static SymbolGraphSection BuildGraphSection(int total, int returned, int offset) + => new() + { + Total = total, + Returned = returned, + Offset = offset, + Truncated = offset + returned < total, + }; + private bool CanScopeCandidateByIdentity(DefinitionResult definition) => _hasReferencesTable && _referenceIdentityContractCurrent diff --git a/src/CodeIndex/Mcp/McpToolArgumentContracts.cs b/src/CodeIndex/Mcp/McpToolArgumentContracts.cs index 211178c10..b46931a83 100644 --- a/src/CodeIndex/Mcp/McpToolArgumentContracts.cs +++ b/src/CodeIndex/Mcp/McpToolArgumentContracts.cs @@ -23,7 +23,7 @@ public partial class McpServer "find_in_file" => new HashSet(StringComparer.Ordinal) { "query", "path", "limit", "lang", "excludePaths", "excludeTests", "includeGenerated", "before", "after", "snippetLines", "focusLine", "focusColumn", "maxLineWidth", "exact", "regex" }, "excerpt" => new HashSet(StringComparer.Ordinal) { "path", "startLine", "endLine", "before", "after", "focusLine", "focusColumn", "focusLength", "maxLineWidth", "maxOutputBytes" }, "map" => new HashSet(StringComparer.Ordinal) { "limit", "lang", "path", "excludePaths", "excludeTests", "sections", "depth", "minEntrypointConfidence", "project", "solution" }, - "analyze_symbol" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "includeBody", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "maxLineWidth", "countOnly", "format", "project", "solution" }, + "analyze_symbol" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "cursor", "includeBody", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "maxLineWidth", "countOnly", "format", "project", "solution" }, "status" => new HashSet(StringComparer.Ordinal) { "check", "scopes", "staleAfterSeconds", "explain", "config", "logPath", "updateCheck", "format", "fields" }, "outline" => new HashSet(StringComparer.Ordinal) { "path" }, "batch_query" => new HashSet(StringComparer.Ordinal) { "queries", "maxResponseBytes", "estimateOnly" }, diff --git a/src/CodeIndex/Mcp/McpToolCatalog.cs b/src/CodeIndex/Mcp/McpToolCatalog.cs index 4586775a5..1a9087d65 100644 --- a/src/CodeIndex/Mcp/McpToolCatalog.cs +++ b/src/CodeIndex/Mcp/McpToolCatalog.cs @@ -286,7 +286,7 @@ private static JsonArray CreateToolCatalog() ReadOnlyAnnotations()), CreateToolDefinition( "analyze_symbol", - "Use this when one symbol needs a compact dossier and you would otherwise chain `definition`, `references`, `callers`, and `callees`. Prefer standalone tools when you need deeper pagination; common next step is `excerpt` on the most relevant rows. Bundle definition, nearby symbols, references, callers, callees, file metadata, and graph-support metadata for one symbol query. For exact matches, use `exactName`; `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. Bundled caller/callee rows carry the same `reference_kind` (preferred summary kind, back-compat) plus `reference_kinds` (sorted distinct) and `has_mixed_reference_kinds` fields as the standalone `callers` / `callees` tools, so mixed `call` + `subscribe` containers stay visible in the bundle. Supports `format: count|compact`; CLI `since` filtering is intentionally not exposed because the backing analysis reader does not support it yet. / 1つのシンボルについて compact な dossier が必要で、`definition` / `references` / `callers` / `callees` を連続呼び出ししそうなときに使う。深い pagination が必要なら単独ツールを優先し、次は重要行の `excerpt` を使う。1つのシンボルクエリに対して、定義、近傍シンボル、参照、caller、callee、ファイルメタデータ、グラフ対応メタデータをまとめて返す。完全一致には `exactName` を使う。`exact` は USER_GUIDE.md の flag compatibility table に記載された legacy alias。バンドルされた caller / callee 行にも単独の `callers` / `callees` と同じ `reference_kind`(後方互換の優先サマリー種別)、`reference_kinds`(distinct kind の昇順配列)、`has_mixed_reference_kinds` が付くため、`call` + `subscribe` が混在するコンテナも要約 1 ラベルに潰れず見える。`format: count|compact` 対応。CLI の `since` filter は backing analysis reader 未対応のため意図的に未公開。", + "Use this when one symbol needs a compact dossier and you would otherwise chain `definition`, `references`, `callers`, and `callees`. Each bounded graph section reports `total`, `returned`, `truncated`, and an independent `next_cursor`; continue one section by passing that cursor with unchanged query filters. Common next step is `excerpt` on the most relevant rows. Bundle definition, nearby symbols, references, callers, callees, file metadata, and graph-support metadata for one symbol query. For exact matches, use `exactName`; `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. Bundled caller/callee rows carry the same `reference_kind` (preferred summary kind, back-compat) plus `reference_kinds` (sorted distinct) and `has_mixed_reference_kinds` fields as the standalone `callers` / `callees` tools, so mixed `call` + `subscribe` containers stay visible in the bundle. Supports `format: count|compact`; CLI `since` filtering is intentionally not exposed because the backing analysis reader does not support it yet. / 1つのシンボルについて compact な dossier が必要で、`definition` / `references` / `callers` / `callees` を連続呼び出ししそうなときに使う。上限付きの各 graph section は `total`、`returned`、`truncated`、独立した `next_cursor` を返す。同じ query filter のまま cursor を渡すと、その section を継続できる。次は重要行の `excerpt` を使う。1つのシンボルクエリに対して、定義、近傍シンボル、参照、caller、callee、ファイルメタデータ、グラフ対応メタデータをまとめて返す。完全一致には `exactName` を使う。`exact` は USER_GUIDE.md の flag compatibility table に記載された legacy alias。バンドルされた caller / callee 行にも単独の `callers` / `callees` と同じ `reference_kind`(後方互換の優先サマリー種別)、`reference_kinds`(distinct kind の昇順配列)、`has_mixed_reference_kinds` が付くため、`call` + `subscribe` が混在するコンテナも要約 1 ラベルに潰れず見える。`format: count|compact` 対応。CLI の `since` filter は backing analysis reader 未対応のため意図的に未公開。", new JsonObject { ["type"] = "object", @@ -294,6 +294,7 @@ private static JsonArray CreateToolCatalog() { ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Symbol name to inspect" }, ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max items per section (default: 10)", ["default"] = QueryCommandRunner.DefaultMapLimit }, + ["cursor"] = new JsonObject { ["type"] = "string", ["description"] = "Continue exactly one truncated graph section with its next_cursor; keep query filters unchanged." }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, ["includeBody"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include body content in definitions when available", ["default"] = false }, ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp bundled reference context lines so single-line files stay bounded (default: 512; 0 disables clamping)", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 0, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth }, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs b/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs index e4f78e4e5..4d9245fb1 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs @@ -560,10 +560,59 @@ private JsonNode ExecuteAnalyzeSymbol(JsonNode? id, JsonNode? args) if (ValidateResponseFormat(format) is string formatError) return CreateToolErrorResponse(id, formatError); var countOnly = ReadCountOnly(args) || format == "count"; + var rawCursor = args?["cursor"]?.GetValue(); + InspectGraphCursor? graphCursor = null; + if (rawCursor != null && !InspectGraphCursorCodec.TryParse(rawCursor, out graphCursor)) + return CreateToolErrorResponse(id, "cursor must be an inspect graph next_cursor returned by analyze_symbol."); return WithDbReader(id, args, reader => { - var analysis = reader.AnalyzeSymbol(query, limit, lang, includeBody, pathPatterns, excludePaths, excludeTests, exact, maxLineWidth); + var queryFingerprint = BuildAnalyzeSymbolGraphQueryFingerprint( + query, + lang, + pathPatterns, + excludePaths, + excludeTests, + exact); + var generation = InspectGraphCursorCodec.BuildGenerationFingerprint(reader); + if (graphCursor != null + && (!string.Equals(graphCursor.QueryFingerprint, queryFingerprint, StringComparison.Ordinal) + || !string.Equals(graphCursor.GenerationFingerprint, generation.Fingerprint, StringComparison.Ordinal))) + { + return CreateToolErrorResponse( + id, + "cursor does not match this analyze_symbol query or index generation; rerun without cursor."); + } + var graphPage = graphCursor == null + ? null + : new SymbolGraphPageRequest( + graphCursor.Section, + graphCursor.Offset, + graphCursor.CandidateSelector); + var analysis = reader.AnalyzeSymbol( + query, + limit, + lang, + includeBody, + pathPatterns, + excludePaths, + excludeTests, + exact, + maxLineWidth, + graphPage: graphPage); + if (graphCursor?.CandidateSelector != null + && !(analysis.CandidateBundles?.Any(bundle => + string.Equals(bundle.Selector.Selector, graphCursor.CandidateSelector, StringComparison.Ordinal)) ?? false)) + { + return CreateToolErrorResponse( + id, + "cursor candidate is no longer available; rerun analyze_symbol without cursor."); + } + QueryCommandRunner.SynchronizeInspectGraphSectionCounts(analysis); + QueryCommandRunner.ApplyInspectGraphContinuationCursors( + analysis, + queryFingerprint, + generation.Fingerprint); var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignal( reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests), DbReader.IsSqlLanguage(lang) @@ -601,6 +650,31 @@ private JsonNode ExecuteAnalyzeSymbol(JsonNode? id, JsonNode? args) }); } + private static string BuildAnalyzeSymbolGraphQueryFingerprint( + string query, + string? lang, + IReadOnlyList? pathPatterns, + IReadOnlyList? excludePaths, + bool excludeTests, + bool exact) + { + var components = new List + { + "mcp:analyze_symbol", + query, + lang, + exact ? "exact" : "substring", + excludeTests ? "exclude-tests" : "include-tests", + }; + components.AddRange((pathPatterns ?? []) + .OrderBy(path => path, StringComparer.Ordinal) + .Select(path => "path:" + path)); + components.AddRange((excludePaths ?? []) + .OrderBy(path => path, StringComparer.Ordinal) + .Select(path => "exclude:" + path)); + return InspectGraphCursorCodec.BuildQueryFingerprint(components); + } + private static string BuildAnalyzeSymbolSummary(SymbolAnalysisResult analysis) { if (analysis.ExactZeroHint != null) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index ad46b6a89..1b4303ae0 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -734,6 +734,7 @@ private JsonObject BuildAnalyzeSymbolCountPayload(SymbolAnalysisResult analysis, ["reference_count"] = analysis.References.Count, ["caller_count"] = analysis.Callers.Count, ["callee_count"] = analysis.Callees.Count, + ["graph_sections"] = JsonSerializer.SerializeToNode(analysis.GraphSections, _jsonOptions), ["candidate_count"] = analysis.CandidateCount, ["candidate_reference_count"] = analysis.CandidateBundles?.Sum(bundle => bundle.References.Count) ?? 0, ["candidate_caller_count"] = analysis.CandidateBundles?.Sum(bundle => bundle.Callers.Count) ?? 0, @@ -796,6 +797,7 @@ private JsonObject BuildAnalyzeSymbolCompactPayload(SymbolAnalysisResult analysi ["reference_count"] = analysis.References.Count, ["caller_count"] = analysis.Callers.Count, ["callee_count"] = analysis.Callees.Count, + ["graph_sections"] = JsonSerializer.SerializeToNode(analysis.GraphSections, _jsonOptions), ["candidate_count"] = analysis.CandidateCount, ["graph_scope"] = analysis.GraphScope, ["selection_required"] = analysis.SelectionRequired, @@ -837,6 +839,7 @@ private JsonObject ToAnalyzeSymbolJsonObject(SymbolAnalysisResult analysis) ["references"] = ToJsonArray(analysis.References), ["callers"] = ToJsonArray(analysis.Callers), ["callees"] = ToJsonArray(analysis.Callees), + ["graph_sections"] = JsonSerializer.SerializeToNode(analysis.GraphSections, _jsonOptions), ["candidate_count"] = analysis.CandidateCount, ["graph_scope"] = analysis.GraphScope, ["selection_required"] = analysis.SelectionRequired, @@ -883,6 +886,7 @@ private JsonArray BuildCompactCandidateBundles(List? bund ["reference_count"] = bundle.References.Count, ["caller_count"] = bundle.Callers.Count, ["callee_count"] = bundle.Callees.Count, + ["graph_sections"] = JsonSerializer.SerializeToNode(bundle.GraphSections, _jsonOptions), }); } return result; diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index a1948474d..c1e53468b 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -2026,6 +2026,8 @@ public class SymbolAnalysisResult public List References { get; set; } = []; public List Callers { get; set; } = []; public List Callees { get; set; } = []; + [JsonPropertyName("graph_sections")] + public SymbolGraphSections GraphSections { get; set; } = new(); [JsonPropertyName("candidate_count")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public int CandidateCount { get; set; } @@ -2104,8 +2106,37 @@ public class SymbolCandidateBundle public List References { get; set; } = []; public List Callers { get; set; } = []; public List Callees { get; set; } = []; + [JsonPropertyName("graph_sections")] + public SymbolGraphSections GraphSections { get; set; } = new(); } +/// +/// Independent completeness metadata for the bounded graph sections in an inspect bundle. +/// inspect bundle 内で個別に上限適用される graph section の完全性メタデータ。 +/// +public class SymbolGraphSections +{ + public SymbolGraphSection References { get; set; } = new(); + public SymbolGraphSection Callers { get; set; } = new(); + public SymbolGraphSection Callees { get; set; } = new(); +} + +public class SymbolGraphSection +{ + public int Total { get; set; } + public int Returned { get; set; } + public int Offset { get; set; } + public bool Truncated { get; set; } + [JsonPropertyName("next_cursor")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? NextCursor { get; set; } +} + +public sealed record SymbolGraphPageRequest( + string Section, + int Offset, + string? CandidateSelector); + /// /// Structured symbol outline for a single file. /// 1ファイルの構造化シンボルアウトライン。 diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 39097c371..97a21dc64 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -404,8 +404,8 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() AssertSearchUsageFragments(output); Assert.Contains("cdidx definition |--query |-- [--db ] [--json] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--group-partials] [--since ]", output); Assert.Contains("cdidx references |--query |-- [--db ] [--json] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]", output); - Assert.Contains("cdidx inspect |--query |-- [--db ] [--json] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ] [--exact|--exact-name] [--group-partials]", output); - Assert.Contains("cdidx inspect --path --line [--end-line ] [--db ] [--json] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--max-json-bytes ] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ]", output); + Assert.Contains("cdidx inspect |--query |-- [--db ] [--json] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ] [--exact|--exact-name] [--group-partials]", output); + Assert.Contains("cdidx inspect --path --line [--end-line ] [--db ] [--json] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ]", output); Assert.Contains("cdidx outline [--db ] [--json] [--pretty] [--compact] [--verbose] [--limit |--top ] [--cursor ] [--sort ] [--kind ] [--outline-fields ]", output); Assert.Contains("--snippet-lines search/find snippet length (1-20, default: search 8; find 1)", output); Assert.Contains("--snippet-focus search only: long-line focus mode (leftmost|quality|proximity, default: quality)", output); diff --git a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs index 7c6735ab7..429645b7d 100644 --- a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs @@ -302,7 +302,9 @@ public void ToolsCall_SymbolGraphAndAnalyzeExposeCliQueryOptions_Issue3542() Assert.Equal("compact", analyzeStructured["format"]!.GetValue()); Assert.True(analyzeStructured["definition_count"]!.GetValue() >= 1); Assert.NotNull(analyzeStructured["definitions"]); + Assert.NotNull(analyzeStructured["graph_sections"]); var compactCandidateDefinition = analyzeStructured["candidate_bundles"]![0]!["definition"]!; + Assert.NotNull(analyzeStructured["candidate_bundles"]![0]!["graph_sections"]); Assert.Null(compactCandidateDefinition["content"]); Assert.Null(compactCandidateDefinition["body_content"]); @@ -312,6 +314,8 @@ public void ToolsCall_SymbolGraphAndAnalyzeExposeCliQueryOptions_Issue3542() var analyzeCountStructured = analyzeCountResponse["result"]!["structuredContent"]!; var countCandidateDefinition = analyzeCountStructured["candidate_bundles"]![0]!["definition"]!; Assert.True(analyzeCountStructured["count_only"]!.GetValue()); + Assert.NotNull(analyzeCountStructured["graph_sections"]); + Assert.NotNull(analyzeCountStructured["candidate_bundles"]![0]!["graph_sections"]); Assert.Null(countCandidateDefinition["content"]); Assert.Null(countCandidateDefinition["body_content"]); } @@ -1403,6 +1407,15 @@ public void ToolsCall_AnalyzeSymbol_ReturnsBundledContext() Assert.NotNull(response["result"]!["structuredContent"]!["nearby_symbols"]); Assert.NotNull(response["result"]!["structuredContent"]!["callers"]); Assert.NotNull(response["result"]!["structuredContent"]!["callees"]); + var graphSections = response["result"]!["structuredContent"]!["graph_sections"]!; + foreach (var sectionName in new[] { "references", "callers", "callees" }) + { + var section = graphSections[sectionName]!; + Assert.NotNull(section["total"]); + Assert.NotNull(section["returned"]); + Assert.NotNull(section["offset"]); + Assert.NotNull(section["truncated"]); + } Assert.NotNull(response["result"]!["structuredContent"]!["workspace_indexed_at"]); Assert.NotNull(response["result"]!["structuredContent"]!["workspace_latest_modified"]); Assert.NotNull(response["result"]!["structuredContent"]!["project_root"]); @@ -1413,6 +1426,69 @@ public void ToolsCall_AnalyzeSymbol_ReturnsBundledContext() Assert.True(response["result"]!["structuredContent"]!["graph_supported"]!.GetValue()); } + [Fact] + public void ToolsCall_AnalyzeSymbol_PaginatesOneIdentityScopedGraphSection_Issue4839() + { + InsertIndexedFile( + "src/analyze-symbol-page-4839.cs", + "csharp", + """ + public sealed class AnalyzeSymbolPage4839 + { + public void Target4839() { } + public void CallerOne4839() => Target4839(); + public void CallerTwo4839() => Target4839(); + } + """); + var arguments = new JsonObject + { + ["query"] = "Target4839", + ["limit"] = 1, + ["lang"] = "csharp", + ["exactName"] = true, + }; + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 4839, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "analyze_symbol", + ["arguments"] = arguments, + }, + }; + + var firstResponse = _server.HandleMessage(request)!; + var first = firstResponse["result"]!["structuredContent"]!; + var firstSection = first["graph_sections"]!["callers"]!; + var firstCaller = Assert.Single(first["callers"]!.AsArray())!; + var nextCursor = firstSection["next_cursor"]!.GetValue(); + + Assert.Equal(2, firstSection["total"]!.GetValue()); + Assert.Equal(1, firstSection["returned"]!.GetValue()); + Assert.Equal(0, firstSection["offset"]!.GetValue()); + Assert.True(firstSection["truncated"]!.GetValue()); + Assert.StartsWith("inspect-graph:v1:", nextCursor, StringComparison.Ordinal); + + arguments["cursor"] = nextCursor; + request["id"] = 4840; + var secondResponse = _server.HandleMessage(request)!; + var second = secondResponse["result"]!["structuredContent"]!; + Assert.False( + secondResponse["result"]?["isError"]?.GetValue() ?? false, + second.ToJsonString()); + var secondSection = second["graph_sections"]!["callers"]!; + var secondCaller = Assert.Single(second["callers"]!.AsArray())!; + + Assert.NotEqual(firstCaller.ToJsonString(), secondCaller.ToJsonString()); + Assert.Equal(2, secondSection["total"]!.GetValue()); + Assert.Equal(1, secondSection["returned"]!.GetValue()); + Assert.Equal(1, secondSection["offset"]!.GetValue()); + Assert.False(secondSection["truncated"]!.GetValue()); + Assert.Null(secondSection["next_cursor"]); + } + [Fact] public void ToolsCall_AnalyzeSymbol_MissingDefinitionPublishesInferredGraphLanguageInEveryFormat_Issue4727() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs index 6dd06b4dd..1f2f4f38b 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs @@ -132,6 +132,176 @@ private void QueryOnly() { } } } + [Fact] + public void RunInspect_LocationKeepsCandidateIdentityAndPaginatesGraphSections_Issue4839() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_inspect_location_identity_4839"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "src/Alpha.cs", "csharp", """ + namespace InspectIdentity; + public sealed class Alpha + { + public void RunSearch() { First(); Second(); } + private void First() { } + private void Second() { } + public void CallerA() => RunSearch(); + public void CallerB() => RunSearch(); + } + """); + TestProjectHelper.InsertIndexedFile(dbPath, "src/Beta.cs", "csharp", """ + namespace InspectIdentity; + public sealed class Beta + { + public void RunSearch() { Other(); } + private void Other() { } + public void CallerBeta() => RunSearch(); + } + """); + MarkGraphAndFoldReady(dbPath); + + var (nameExitCode, nameStdout, nameStderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( + ["RunSearch", "--db", dbPath, "--json", "--limit", "5", "--lang", "csharp", "--exact-name"], + _jsonOptions)); + using var nameDocument = ParseJsonOutput(nameStdout); + var alphaNameBundle = GetInspectCandidateBundle( + nameDocument.RootElement, + "InspectIdentity.Alpha.RunSearch"); + + var locationArgs = new[] + { + "--path", "src/Alpha.cs", + "--line", "4", + "--db", dbPath, + "--json", + "--limit", "1", + }; + var (locationExitCode, locationStdout, locationStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect(locationArgs, _jsonOptions)); + using var locationDocument = ParseJsonOutput(locationStdout); + var location = locationDocument.RootElement; + var locationBundle = Assert.Single(location.GetProperty("candidate_bundles").EnumerateArray()); + var callerSection = location.GetProperty("graph_sections").GetProperty("callers"); + var firstCaller = Assert.Single(location.GetProperty("callers").EnumerateArray()); + var nextCursor = callerSection.GetProperty("next_cursor").GetString(); + + Assert.Equal(CommandExitCodes.Success, nameExitCode); + Assert.Equal(string.Empty, nameStderr); + Assert.Equal(CommandExitCodes.Success, locationExitCode); + Assert.Equal(string.Empty, locationStderr); + Assert.Equal( + alphaNameBundle.GetProperty("selector").GetProperty("symbol_id").GetInt64(), + locationBundle.GetProperty("selector").GetProperty("symbol_id").GetInt64()); + Assert.True(locationBundle.GetProperty("identity_scoped").GetBoolean()); + Assert.Equal("definition", location.GetProperty("graph_language_source").GetString()); + Assert.Equal("authoritative", location.GetProperty("graph_language_confidence").GetString()); + Assert.DoesNotContain( + locationBundle.GetProperty("callers").EnumerateArray(), + caller => caller.GetProperty("caller_name").GetString() == "CallerBeta"); + Assert.Equal(2, callerSection.GetProperty("total").GetInt32()); + Assert.Equal(1, callerSection.GetProperty("returned").GetInt32()); + Assert.Equal(0, callerSection.GetProperty("offset").GetInt32()); + Assert.True(callerSection.GetProperty("truncated").GetBoolean()); + Assert.False(string.IsNullOrWhiteSpace(nextCursor)); + Assert.Equal(2, location.GetProperty("graph_sections").GetProperty("references").GetProperty("total").GetInt32()); + Assert.Equal(2, location.GetProperty("graph_sections").GetProperty("callees").GetProperty("total").GetInt32()); + + var continuationArgs = locationArgs + .Concat(["--cursor", nextCursor!]) + .ToArray(); + var (continuationExitCode, continuationStdout, continuationStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect(continuationArgs, _jsonOptions)); + using var continuationDocument = ParseJsonOutput(continuationStdout); + var continuation = continuationDocument.RootElement; + var continuedCallerSection = continuation.GetProperty("graph_sections").GetProperty("callers"); + var secondCaller = Assert.Single(continuation.GetProperty("callers").EnumerateArray()); + + Assert.Equal(CommandExitCodes.Success, continuationExitCode); + Assert.Equal(string.Empty, continuationStderr); + Assert.NotEqual( + firstCaller.GetProperty("caller_name").GetString(), + secondCaller.GetProperty("caller_name").GetString()); + Assert.Equal(2, continuedCallerSection.GetProperty("total").GetInt32()); + Assert.Equal(1, continuedCallerSection.GetProperty("returned").GetInt32()); + Assert.Equal(1, continuedCallerSection.GetProperty("offset").GetInt32()); + Assert.False(continuedCallerSection.GetProperty("truncated").GetBoolean()); + Assert.False(continuedCallerSection.TryGetProperty("next_cursor", out _)); + + var (queryMismatchExitCode, _, queryMismatchStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect( + [ + "--path", "src/Beta.cs", + "--line", "4", + "--db", dbPath, + "--json", + "--limit", "1", + "--cursor", nextCursor!, + ], + _jsonOptions)); + Assert.Equal(CommandExitCodes.UsageError, queryMismatchExitCode); + Assert.Contains("does not match this query or index generation", queryMismatchStderr, StringComparison.Ordinal); + + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/GenerationChange.cs", + "csharp", + "public sealed class GenerationChange { }\n"); + var (generationMismatchExitCode, _, generationMismatchStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect(continuationArgs, _jsonOptions)); + Assert.Equal(CommandExitCodes.UsageError, generationMismatchExitCode); + Assert.Contains("does not match this query or index generation", generationMismatchStderr, StringComparison.Ordinal); + + var (humanExitCode, humanStdout, humanStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect( + [ + "--path", "src/Alpha.cs", + "--line", "4", + "--db", dbPath, + "--limit", "1", + ], + _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, humanExitCode); + Assert.Equal(string.Empty, humanStderr); + Assert.Contains( + "Callers completeness: returned 1 of 2 (offset 0, truncated: true)", + humanStdout, + StringComparison.Ordinal); + Assert.Contains("Callers next cursor: inspect-graph:v1:", humanStdout, StringComparison.Ordinal); + + var malformedPayload = Convert.ToBase64String( + System.Text.Encoding.UTF8.GetBytes( + """{"section":1,"offset":0,"candidate":null,"query":"0123456789abcdef","generation":"0123456789abcdef"}""")) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + var (malformedExitCode, _, malformedStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect( + locationArgs.Concat(["--cursor", $"inspect-graph:v1:{malformedPayload}"]).ToArray(), + _jsonOptions)); + Assert.Equal(CommandExitCodes.UsageError, malformedExitCode); + Assert.Contains("cursor", malformedStderr, StringComparison.Ordinal); + + using var queryDb = new DbContext(DbOpenIntent.QueryOnly, dbPath); + using var reader = new DbReader(queryDb); + var empty = reader.AnalyzeSymbol( + "MissingRunSearch", + limit: 1, + lang: "csharp", + exact: true); + Assert.Equal(0, empty.GraphSections.References.Total); + Assert.Equal(0, empty.GraphSections.Callers.Total); + Assert.Equal(0, empty.GraphSections.Callees.Total); + Assert.False(empty.GraphSections.References.Truncated); + Assert.False(empty.GraphSections.Callers.Truncated); + Assert.False(empty.GraphSections.Callees.Truncated); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunInspect_ExactFilePathAndCoordinatesAreStrict_Issue4572() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerPartialGroupingIssue4566Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerPartialGroupingIssue4566Tests.cs index e7649f929..77537895f 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerPartialGroupingIssue4566Tests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerPartialGroupingIssue4566Tests.cs @@ -161,6 +161,10 @@ public class Widget Assert.Equal(3, inspect.GetProperty("physical_definition_output_count").GetInt32()); Assert.True(inspect.GetProperty("definitions_collapsed").GetBoolean()); Assert.Equal(2, inspect.GetProperty("definitions").GetArrayLength()); + Assert.True(inspect.TryGetProperty("graph_sections", out var groupedGraphSections)); + Assert.True(groupedGraphSections.TryGetProperty("references", out _)); + Assert.True(groupedGraphSections.TryGetProperty("callers", out _)); + Assert.True(groupedGraphSections.TryGetProperty("callees", out _)); var (impactExitCode, impactStdout, impactStderr) = CaptureConsole(() => QueryCommandRunner.RunImpact( ["Widget", "--db", dbPath, "--json", "--lang", "csharp", "--max-hops", "0", "--limit", "1"], From cdf318b657f58040460d2bd4d5fbebdec779698d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 27 Jul 2026 19:36:22 +0900 Subject: [PATCH 2/3] Harden inspect graph pagination (#4839) --- DEVELOPER_GUIDE.md | 16 +++--- TESTING_GUIDE.md | 4 +- USER_GUIDE.md | 6 ++- changelog.d/unreleased/4839.fixed.md | 5 +- .../Cli/QueryCommandRunner.Inspect.cs | 7 ++- .../Database/DbReader.GraphQueries.cs | 4 +- src/CodeIndex/Mcp/McpToolHandlers.Graph.cs | 7 ++- .../McpServerToolsCallTests.cs | 10 ++++ .../QueryCommandRunnerInspectTests.cs | 53 +++++++++++++++++-- 9 files changed, 91 insertions(+), 21 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index a3b6a7485..cb5d0b007 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1108,9 +1108,11 @@ Path/line resolution must select `symbols.id` and enter the same candidate-bundl name resolution; do not hand graph loaders only the display name. Each bounded references, callers, and callees section computes its own stable-order page and authoritative total. `graph_sections` reports `total`, `returned`, `offset`, and `truncated`, while CLI `inspect` -and MCP `analyze_symbol` attach query- and index-generation-bound cursors independently to -truncated sections. Candidate selectors are part of cursor scope so paging one overload or -partial-family representative cannot borrow graph rows from another candidate. +and MCP `analyze_symbol` attach query-, effective-page-size-, and index-generation-bound +cursors independently to truncated sections. Caller/callee ordering ends with every grouped +identity field so offset pages cannot skip tied rows. Candidate selectors are part of cursor +scope so paging one overload or partial-family representative cannot borrow graph rows from +another candidate. ### Reference taxonomy @@ -4273,9 +4275,11 @@ candidate-bundle builder に入れてください。graph loader に display nam なりません。上限付きの references、callers、callees section は、それぞれ安定順序の page と authoritative な総数を計算します。`graph_sections` は `total`、`returned`、`offset`、 `truncated` を報告し、CLI `inspect` と MCP `analyze_symbol` は truncated な section ごとに -query と index generation に束縛した cursor を付けます。candidate selector も cursor scope -に含め、1つの overload または partial-family representative の pagination が別 candidate の -graph row を借用しないようにしてください。 +query、effective page size、index generation に束縛した cursor を付けます。 +caller/callee の順序は grouped identity field のすべてを末尾の tie-breaker に使い、offset page が +同順位 row を飛ばさないようにします。candidate selector も cursor scope に含め、1つの overload +または partial-family representative の pagination が別 candidate の graph row を借用しないように +してください。 ### 参照 taxonomy diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index e7092fba8..118fce99d 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -187,7 +187,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding `goto --all` coverage shares one 63-definition fixture across the default bare LSP array and the plain `--json` argument path; it must assert complete deterministic output without a limit and retain a separate explicit-limit assertion in the same setup. Cursor-pagination coverage for `outline` and `unused` must replay the emitted opaque token successfully, then prove that changed filters/order and a changed index-generation stamp fail with restart-required diagnostics. Discovery-pagination coverage must page compact search/symbol/file output and the opt-in search-array/languages envelopes, assert authoritative total/omitted/stable-at metadata, reject query or generation cursor mismatches, and prove that a partial `find --all` scan resumes after the last scanned line. - Inspect graph-section coverage must compare name and path/line resolution through the same persisted candidate ID, keep ambiguous overload and partial-family bundles isolated, assert independent total/returned/truncated metadata for references, callers, and callees (including empty sections), and replay a query- and generation-bound cursor across the smallest two-row page boundary. Verify the same envelopes in MCP `analyze_symbol`. + Inspect graph-section coverage must compare name and path/line resolution through the same persisted candidate ID, keep ambiguous overload and partial-family bundles isolated, assert independent total/returned/truncated metadata for references, callers, and callees (including empty sections), and replay a query-, page-size-, and generation-bound cursor across the smallest two-row page boundary. Seed equal-rank same-line callees to pin the complete identity tie-breakers, reject page-size changes before candidate lookup, and verify the same envelopes in MCP `analyze_symbol`. Quiet-flag coverage in `ProgramCliTests.cs` reuses one seeded symbols database across text, NDJSON, and JSON-array modes and compares stdout with and without a trailing quiet alias, proving that quiet mode changes only informational stderr. Doctor full-inventory coverage keeps composed filter selection, filtered summary counts, exact UTF-8 byte-budget boundaries, and structured overflow errors together in `ProgramRunnerTests`; license JSON remains a subprocess contract in `ProgramCliTests` so immediate-command dispatch and the published field names are both exercised. Ctags export JSON coverage reuses one seeded database for default and `--include-generated` variants, asserts the fixed skip-reason keys sum to `skipped_count`, and keeps the missing-`files.generated` degradation in a separate legacy-schema fixture. @@ -1085,7 +1085,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" `goto --all` の coverage は、既定の bare LSP array と plain `--json` 引数経路で 1 つの 63 定義 fixture を共有し、limit 未指定時の完全かつ決定的な出力と、同じ setup 内での明示 limit の維持を検証してください。 `outline` と `unused` の cursor pagination coverage では、出力された opaque token の再利用成功に加え、filter/order の変更と index-generation stamp の変更が restart-required 診断で失敗することを検証してください。 discovery pagination の coverage では compact search / symbol / file と opt-in の search array / languages envelope をページングし、authoritative な total / omitted / stable-at metadata、query / generation 不一致 cursor の拒否、partial な `find --all` scan が最後に scan した line の次から再開することを検証してください。 - inspect graph-section coverage では、name と path/line resolution が同じ persisted candidate ID を通ること、曖昧な overload と partial-family bundle が分離されること、references / callers / callees の独立した total / returned / truncated metadata(空 section を含む)を検証してください。最小の2行 page 境界で query / generation に束縛された cursor を再利用し、MCP `analyze_symbol` でも同じ envelope を確認してください。 + inspect graph-section coverage では、name と path/line resolution が同じ persisted candidate ID を通ること、曖昧な overload と partial-family bundle が分離されること、references / callers / callees の独立した total / returned / truncated metadata(空 section を含む)を検証してください。最小の2行 page 境界で query / page size / generation に束縛された cursor を再利用し、同順位かつ同じ行の callee で完全な identity tie-breaker を固定し、candidate lookup より前に page-size 変更を拒否してください。MCP `analyze_symbol` でも同じ envelope を確認します。 `ProgramCliTests.cs` の quiet flag coverage は1つの seeded symbols database を text、NDJSON、JSON array の各 mode で再利用し、末尾に quiet alias を付けた場合と付けない場合の stdout を比較して、quiet mode が informational stderr だけを変えることを固定します。 doctor full-inventory coverage では、合成 filter の選択、filtered summary 件数、UTF-8 byte budget の exact boundary、structured overflow error を `ProgramRunnerTests` にまとめます。license JSON は `ProgramCliTests` の subprocess contract として、immediate-command dispatch と公開 field 名を同時に検証します。 ctags export JSON coverage は1つの seeded database を既定と `--include-generated` variant で再利用し、固定された skip-reason key の合計が `skipped_count` と一致することを検証します。`files.generated` がない場合の縮退は別の legacy-schema fixture に保ってください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 63f4b08f7..aaccc8291 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -381,7 +381,8 @@ Every `inspect` graph bundle also publishes independent `graph_sections.callees` envelopes with `total`, `returned`, `offset`, and `truncated`. A truncated section includes an opaque `next_cursor`; pass it back with `--cursor` and unchanged query filters to continue only that section. -The cursor is bound to the query and index generation. Name and path/line +The cursor is bound to the query, effective page size, and index generation, so +keep the same `--limit` / `--top` value when continuing. Name and path/line inspection both preserve the selected persisted symbol ID, so location lookup returns the same candidate-scoped graph evidence as the corresponding name bundle instead of re-resolving a display name. MCP `analyze_symbol` exposes the @@ -3512,7 +3513,8 @@ AI 向けに上限付き payload が必要な場合、`map`、`inspect`、`outli `graph_sections.callees` envelope も公開し、`total`、`returned`、`offset`、 `truncated` を示します。truncated な section には opaque な `next_cursor` が付き、 query filter を変えず `--cursor` で渡すと、その section だけを継続できます。 -cursor は query と index generation に束縛されます。名前指定と path/line 指定の +cursor は query、effective page size、index generation に束縛されるため、継続時は +同じ `--limit` / `--top` 値を維持してください。名前指定と path/line 指定の どちらも選択された永続 symbol ID を維持するため、location lookup は display name を 再解決せず、対応する name bundle と同じ candidate-scoped graph evidence を返します。 MCP `analyze_symbol` も同じ section envelope を公開し、その cursor を受け付けます。 diff --git a/changelog.d/unreleased/4839.fixed.md b/changelog.d/unreleased/4839.fixed.md index d054d1f15..20ae442ae 100644 --- a/changelog.d/unreleased/4839.fixed.md +++ b/changelog.d/unreleased/4839.fixed.md @@ -4,6 +4,7 @@ issues: - 4839 affected: - src/CodeIndex/Database/DbSymbolReader.Analysis.cs + - src/CodeIndex/Database/DbReader.GraphQueries.cs - src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs - src/CodeIndex/Mcp/McpToolHandlers.Graph.cs - USER_GUIDE.md @@ -13,8 +14,8 @@ affected: ## English -- **Location-based inspect now preserves graph identity and section completeness (#4839)** — path/line and name resolution share the persisted candidate ID, so ambiguous overloads do not borrow one another's references or callers. CLI `inspect` and MCP `analyze_symbol` now report independent totals, returned counts, truncation state, and query/index-bound continuation cursors for references, callers, and callees. +- **Location-based inspect now preserves graph identity and section completeness (#4839)** — path/line and name resolution share the persisted candidate ID, so ambiguous overloads do not borrow one another's references or callers. CLI `inspect` and MCP `analyze_symbol` now report independent totals, returned counts, truncation state, stable tie ordering, and query/page-size/index-bound continuation cursors for references, callers, and callees. ## 日本語 -- **location-based inspect が graph identity と section completeness を維持するようになりました (#4839)** — path/line と name resolution が persisted candidate ID を共有し、曖昧な overload 間で references や callers を借用しなくなりました。CLI `inspect` と MCP `analyze_symbol` は references、callers、callees ごとに独立した総数、返却件数、truncation 状態、query/index に束縛された continuation cursor を報告します。 +- **location-based inspect が graph identity と section completeness を維持するようになりました (#4839)** — path/line と name resolution が persisted candidate ID を共有し、曖昧な overload 間で references や callers を借用しなくなりました。CLI `inspect` と MCP `analyze_symbol` は references、callers、callees ごとに独立した総数、返却件数、truncation 状態、安定した同順位順序、query/page size/index に束縛された continuation cursor を報告します。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs b/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs index e3a3a3c37..e71aa01a6 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs @@ -147,7 +147,8 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions inspectQuery, options, exact, - fileInspectMode); + fileInspectMode, + inspectLimit); var generation = InspectGraphCursorCodec.BuildGenerationFingerprint(reader); if (graphCursor != null && (!string.Equals(graphCursor.QueryFingerprint, queryFingerprint, StringComparison.Ordinal) @@ -392,7 +393,8 @@ private static string BuildInspectGraphQueryFingerprint( string inspectQuery, QueryCommandOptions options, bool exact, - bool fileInspectMode) + bool fileInspectMode, + int pageLimit) { var components = new List { @@ -402,6 +404,7 @@ private static string BuildInspectGraphQueryFingerprint( options.Lang, options.Kind, exact ? "exact" : "substring", + $"page-limit:{pageLimit.ToString(System.Globalization.CultureInfo.InvariantCulture)}", options.ExcludeTests ? "exclude-tests" : "include-tests", options.GroupPartials ? "group-partials" : "physical-definitions", }; diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index dc2b70f77..f6ed12466 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -205,7 +205,7 @@ FROM symbol_reference_candidates AS identity_candidate MAX(r.is_mutual_recursion) AS is_mutual_recursion FROM logical_references r GROUP BY path, lang, container_kind, container_name, symbol_name"; - sql += $" ORDER BY CASE WHEN @preferExactCase = 1 AND r.symbol_name = @rawQuery THEN 0 ELSE 1 END, {GetPathBucketOrderSql("r.path")}, CASE WHEN lower(r.symbol_name) = lower(@rankingQuery) THEN 0 ELSE 1 END, {BuildReferenceRankOrderSql(rankMode)}, r.path, first_line LIMIT @limit OFFSET @offset"; + sql += $" ORDER BY CASE WHEN @preferExactCase = 1 AND r.symbol_name = @rawQuery THEN 0 ELSE 1 END, {GetPathBucketOrderSql("r.path")}, CASE WHEN lower(r.symbol_name) = lower(@rankingQuery) THEN 0 ELSE 1 END, {BuildReferenceRankOrderSql(rankMode)}, r.path, first_line, first_column, r.lang, r.container_kind, r.container_name, r.symbol_name LIMIT @limit OFFSET @offset"; cmd.CommandText = sql; string callersQueryParam; @@ -648,7 +648,7 @@ WHERE r.container_name IS NOT NULL SUM(r.weighted_score) AS weighted_score FROM logical_references 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 LIMIT @limit OFFSET @offset"; + 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"; cmd.CommandText = sql; string calleesQueryParam; diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs b/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs index 4d9245fb1..ce409f929 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs @@ -573,7 +573,8 @@ private JsonNode ExecuteAnalyzeSymbol(JsonNode? id, JsonNode? args) pathPatterns, excludePaths, excludeTests, - exact); + exact, + limit); var generation = InspectGraphCursorCodec.BuildGenerationFingerprint(reader); if (graphCursor != null && (!string.Equals(graphCursor.QueryFingerprint, queryFingerprint, StringComparison.Ordinal) @@ -656,7 +657,8 @@ private static string BuildAnalyzeSymbolGraphQueryFingerprint( IReadOnlyList? pathPatterns, IReadOnlyList? excludePaths, bool excludeTests, - bool exact) + bool exact, + int pageLimit) { var components = new List { @@ -664,6 +666,7 @@ private static string BuildAnalyzeSymbolGraphQueryFingerprint( query, lang, exact ? "exact" : "substring", + $"page-limit:{pageLimit.ToString(CultureInfo.InvariantCulture)}", excludeTests ? "exclude-tests" : "include-tests", }; components.AddRange((pathPatterns ?? []) diff --git a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs index 429645b7d..c20930ebf 100644 --- a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs @@ -1472,6 +1472,16 @@ public void Target4839() { } Assert.StartsWith("inspect-graph:v1:", nextCursor, StringComparison.Ordinal); arguments["cursor"] = nextCursor; + arguments["limit"] = 2; + request["id"] = 4840; + var pageLimitMismatchResponse = _server.HandleMessage(request)!; + Assert.True(pageLimitMismatchResponse["result"]?["isError"]?.GetValue() ?? false); + Assert.Contains( + "cursor does not match this analyze_symbol query or index generation", + pageLimitMismatchResponse.ToJsonString(), + StringComparison.Ordinal); + + arguments["limit"] = 1; request["id"] = 4840; var secondResponse = _server.HandleMessage(request)!; var second = secondResponse["result"]!["structuredContent"]!; diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs index 1f2f4f38b..fa82aa877 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs @@ -156,18 +156,28 @@ public sealed class Beta { public void RunSearch() { Other(); } private void Other() { } - public void CallerBeta() => RunSearch(); + public void CallerBetaA() => RunSearch(); + public void CallerBetaB() => RunSearch(); + public void CallerBetaC() => RunSearch(); } """); MarkGraphAndFoldReady(dbPath); var (nameExitCode, nameStdout, nameStderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( - ["RunSearch", "--db", dbPath, "--json", "--limit", "5", "--lang", "csharp", "--exact-name"], + ["RunSearch", "--db", dbPath, "--json", "--limit", "2", "--lang", "csharp", "--exact-name"], _jsonOptions)); using var nameDocument = ParseJsonOutput(nameStdout); var alphaNameBundle = GetInspectCandidateBundle( nameDocument.RootElement, "InspectIdentity.Alpha.RunSearch"); + var betaNameBundle = GetInspectCandidateBundle( + nameDocument.RootElement, + "InspectIdentity.Beta.RunSearch"); + var betaCallerCursor = betaNameBundle + .GetProperty("graph_sections") + .GetProperty("callers") + .GetProperty("next_cursor") + .GetString(); var locationArgs = new[] { @@ -185,6 +195,9 @@ private void Other() { } var callerSection = location.GetProperty("graph_sections").GetProperty("callers"); var firstCaller = Assert.Single(location.GetProperty("callers").EnumerateArray()); var nextCursor = callerSection.GetProperty("next_cursor").GetString(); + var calleeSection = location.GetProperty("graph_sections").GetProperty("callees"); + var firstCallee = Assert.Single(location.GetProperty("callees").EnumerateArray()); + var calleeCursor = calleeSection.GetProperty("next_cursor").GetString(); Assert.Equal(CommandExitCodes.Success, nameExitCode); Assert.Equal(string.Empty, nameStderr); @@ -204,8 +217,10 @@ private void Other() { } Assert.Equal(0, callerSection.GetProperty("offset").GetInt32()); Assert.True(callerSection.GetProperty("truncated").GetBoolean()); Assert.False(string.IsNullOrWhiteSpace(nextCursor)); + Assert.False(string.IsNullOrWhiteSpace(betaCallerCursor)); + Assert.False(string.IsNullOrWhiteSpace(calleeCursor)); Assert.Equal(2, location.GetProperty("graph_sections").GetProperty("references").GetProperty("total").GetInt32()); - Assert.Equal(2, location.GetProperty("graph_sections").GetProperty("callees").GetProperty("total").GetInt32()); + Assert.Equal(2, calleeSection.GetProperty("total").GetInt32()); var continuationArgs = locationArgs .Concat(["--cursor", nextCursor!]) @@ -228,6 +243,38 @@ private void Other() { } Assert.False(continuedCallerSection.GetProperty("truncated").GetBoolean()); Assert.False(continuedCallerSection.TryGetProperty("next_cursor", out _)); + var (calleeContinuationExitCode, calleeContinuationStdout, calleeContinuationStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect( + locationArgs.Concat(["--cursor", calleeCursor!]).ToArray(), + _jsonOptions)); + using var calleeContinuationDocument = ParseJsonOutput(calleeContinuationStdout); + var continuedCallee = Assert.Single( + calleeContinuationDocument.RootElement.GetProperty("callees").EnumerateArray()); + Assert.Equal(CommandExitCodes.Success, calleeContinuationExitCode); + Assert.Equal(string.Empty, calleeContinuationStderr); + Assert.Equal( + ["First", "Second"], + new[] + { + firstCallee.GetProperty("callee_name").GetString(), + continuedCallee.GetProperty("callee_name").GetString(), + }); + + var (pageLimitMismatchExitCode, _, pageLimitMismatchStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect( + [ + "RunSearch", + "--db", dbPath, + "--json", + "--limit", "1", + "--lang", "csharp", + "--exact-name", + "--cursor", betaCallerCursor!, + ], + _jsonOptions)); + Assert.Equal(CommandExitCodes.UsageError, pageLimitMismatchExitCode); + Assert.Contains("does not match this query or index generation", pageLimitMismatchStderr, StringComparison.Ordinal); + var (queryMismatchExitCode, _, queryMismatchStderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( [ From 889b0ab01548a475cc5d626d2091c01082908c2f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 27 Jul 2026 20:03:54 +0900 Subject: [PATCH 3/3] Preserve cross-file inspect pagination scope (#4839) --- DEVELOPER_GUIDE.md | 10 ++++- TESTING_GUIDE.md | 4 +- USER_GUIDE.md | 10 ++++- changelog.d/unreleased/4839.fixed.md | 5 ++- .../Cli/QueryCommandRunner.Inspect.cs | 2 +- .../QueryCommandRunner.OptionValidation.cs | 21 ++++++++-- .../QueryCommandRunnerInspectTests.cs | 38 +++++++++++++------ 7 files changed, 67 insertions(+), 23 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index cb5d0b007..2700db730 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1112,7 +1112,10 @@ and MCP `analyze_symbol` attach query-, effective-page-size-, and index-generati cursors independently to truncated sections. Caller/callee ordering ends with every grouped identity field so offset pages cannot skip tied rows. Candidate selectors are part of cursor scope so paging one overload or partial-family representative cannot borrow graph rows from -another candidate. +another candidate. Treat a path/line locator separately from graph path filters: the locator +selects the persisted symbol, but its inbound references and callers may come from any indexed +file permitted by explicit graph filters. Shared cursor parsing may retain inspect cursors, but +every non-inspect command must reject that cursor family before execution. ### Reference taxonomy @@ -4279,7 +4282,10 @@ query、effective page size、index generation に束縛した cursor を付け caller/callee の順序は grouped identity field のすべてを末尾の tie-breaker に使い、offset page が 同順位 row を飛ばさないようにします。candidate selector も cursor scope に含め、1つの overload または partial-family representative の pagination が別 candidate の graph row を借用しないように -してください。 +してください。path/line locator と graph path filter は分離してください。locator は persisted +symbol を選択しますが、その inbound references と callers は、明示された graph filter が許可する +任意の indexed file から取得できます。共有 cursor parser が inspect cursor を保持しても、 +inspect 以外の各 command は実行前にその cursor family を拒否しなければなりません。 ### 参照 taxonomy diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 118fce99d..f0749bc0b 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -187,7 +187,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding `goto --all` coverage shares one 63-definition fixture across the default bare LSP array and the plain `--json` argument path; it must assert complete deterministic output without a limit and retain a separate explicit-limit assertion in the same setup. Cursor-pagination coverage for `outline` and `unused` must replay the emitted opaque token successfully, then prove that changed filters/order and a changed index-generation stamp fail with restart-required diagnostics. Discovery-pagination coverage must page compact search/symbol/file output and the opt-in search-array/languages envelopes, assert authoritative total/omitted/stable-at metadata, reject query or generation cursor mismatches, and prove that a partial `find --all` scan resumes after the last scanned line. - Inspect graph-section coverage must compare name and path/line resolution through the same persisted candidate ID, keep ambiguous overload and partial-family bundles isolated, assert independent total/returned/truncated metadata for references, callers, and callees (including empty sections), and replay a query-, page-size-, and generation-bound cursor across the smallest two-row page boundary. Seed equal-rank same-line callees to pin the complete identity tie-breakers, reject page-size changes before candidate lookup, and verify the same envelopes in MCP `analyze_symbol`. + Inspect graph-section coverage must compare name and path/line resolution through the same persisted candidate ID, keep ambiguous overload and partial-family bundles isolated, assert independent total/returned/truncated metadata for references, callers, and callees (including empty sections), and replay a query-, page-size-, and generation-bound cursor across the smallest two-row page boundary. Put inbound callers in another file to prove the location path is only a locator, seed equal-rank same-line callees to pin the complete identity tie-breakers, reject page-size changes before candidate lookup, reject inspect cursors in another command, and verify the same envelopes in MCP `analyze_symbol`. Quiet-flag coverage in `ProgramCliTests.cs` reuses one seeded symbols database across text, NDJSON, and JSON-array modes and compares stdout with and without a trailing quiet alias, proving that quiet mode changes only informational stderr. Doctor full-inventory coverage keeps composed filter selection, filtered summary counts, exact UTF-8 byte-budget boundaries, and structured overflow errors together in `ProgramRunnerTests`; license JSON remains a subprocess contract in `ProgramCliTests` so immediate-command dispatch and the published field names are both exercised. Ctags export JSON coverage reuses one seeded database for default and `--include-generated` variants, asserts the fixed skip-reason keys sum to `skipped_count`, and keeps the missing-`files.generated` degradation in a separate legacy-schema fixture. @@ -1085,7 +1085,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" `goto --all` の coverage は、既定の bare LSP array と plain `--json` 引数経路で 1 つの 63 定義 fixture を共有し、limit 未指定時の完全かつ決定的な出力と、同じ setup 内での明示 limit の維持を検証してください。 `outline` と `unused` の cursor pagination coverage では、出力された opaque token の再利用成功に加え、filter/order の変更と index-generation stamp の変更が restart-required 診断で失敗することを検証してください。 discovery pagination の coverage では compact search / symbol / file と opt-in の search array / languages envelope をページングし、authoritative な total / omitted / stable-at metadata、query / generation 不一致 cursor の拒否、partial な `find --all` scan が最後に scan した line の次から再開することを検証してください。 - inspect graph-section coverage では、name と path/line resolution が同じ persisted candidate ID を通ること、曖昧な overload と partial-family bundle が分離されること、references / callers / callees の独立した total / returned / truncated metadata(空 section を含む)を検証してください。最小の2行 page 境界で query / page size / generation に束縛された cursor を再利用し、同順位かつ同じ行の callee で完全な identity tie-breaker を固定し、candidate lookup より前に page-size 変更を拒否してください。MCP `analyze_symbol` でも同じ envelope を確認します。 + inspect graph-section coverage では、name と path/line resolution が同じ persisted candidate ID を通ること、曖昧な overload と partial-family bundle が分離されること、references / callers / callees の独立した total / returned / truncated metadata(空 section を含む)を検証してください。inbound caller は別ファイルに置いて location path が locator にすぎないことを証明し、最小の2行 page 境界で query / page size / generation に束縛された cursor を再利用します。同順位かつ同じ行の callee で完全な identity tie-breaker を固定し、candidate lookup より前に page-size 変更を拒否し、別 command では inspect cursor を拒否してください。MCP `analyze_symbol` でも同じ envelope を確認します。 `ProgramCliTests.cs` の quiet flag coverage は1つの seeded symbols database を text、NDJSON、JSON array の各 mode で再利用し、末尾に quiet alias を付けた場合と付けない場合の stdout を比較して、quiet mode が informational stderr だけを変えることを固定します。 doctor full-inventory coverage では、合成 filter の選択、filtered summary 件数、UTF-8 byte budget の exact boundary、structured overflow error を `ProgramRunnerTests` にまとめます。license JSON は `ProgramCliTests` の subprocess contract として、immediate-command dispatch と公開 field 名を同時に検証します。 ctags export JSON coverage は1つの seeded database を既定と `--include-generated` variant で再利用し、固定された skip-reason key の合計が `skipped_count` と一致することを検証します。`files.generated` がない場合の縮退は別の legacy-schema fixture に保ってください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index aaccc8291..dca4a4cc1 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -386,7 +386,10 @@ keep the same `--limit` / `--top` value when continuing. Name and path/line inspection both preserve the selected persisted symbol ID, so location lookup returns the same candidate-scoped graph evidence as the corresponding name bundle instead of re-resolving a display name. MCP `analyze_symbol` exposes the -same section envelopes and accepts their cursors. +same section envelopes and accepts their cursors. In path/line mode, `--path` +locates the definition but does not restrict inbound references or callers to +that file. Inspect graph cursors are accepted only by `inspect`; passing one to +another command is a usage error. For narrower `inspect` evidence, `--fields ` implies JSON and selects top-level groups such as `definitions`, `file`, `graph`, `references`, `callers`, and `callees`; `--outline-only` is shorthand for @@ -3517,7 +3520,10 @@ cursor は query、effective page size、index generation に束縛されるた 同じ `--limit` / `--top` 値を維持してください。名前指定と path/line 指定の どちらも選択された永続 symbol ID を維持するため、location lookup は display name を 再解決せず、対応する name bundle と同じ candidate-scoped graph evidence を返します。 -MCP `analyze_symbol` も同じ section envelope を公開し、その cursor を受け付けます。 +path/line mode の `--path` は定義の位置を特定しますが、inbound references や callers を +そのファイルだけに制限しません。MCP `analyze_symbol` も同じ section envelope を公開し、 +その cursor を受け付けます。inspect graph cursor は `inspect` だけが受理し、別 command に +渡すと usage error になります。 `inspect` の証跡をさらに絞りたい場合、`--fields ` は JSON 出力を暗黙に有効化し、 `definitions`、`file`、`graph`、`references`、`callers`、`callees` などの top-level group を選択します。`--outline-only` は diff --git a/changelog.d/unreleased/4839.fixed.md b/changelog.d/unreleased/4839.fixed.md index 20ae442ae..7b2147db6 100644 --- a/changelog.d/unreleased/4839.fixed.md +++ b/changelog.d/unreleased/4839.fixed.md @@ -6,6 +6,7 @@ affected: - src/CodeIndex/Database/DbSymbolReader.Analysis.cs - src/CodeIndex/Database/DbReader.GraphQueries.cs - src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs + - src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs - src/CodeIndex/Mcp/McpToolHandlers.Graph.cs - USER_GUIDE.md - DEVELOPER_GUIDE.md @@ -14,8 +15,8 @@ affected: ## English -- **Location-based inspect now preserves graph identity and section completeness (#4839)** — path/line and name resolution share the persisted candidate ID, so ambiguous overloads do not borrow one another's references or callers. CLI `inspect` and MCP `analyze_symbol` now report independent totals, returned counts, truncation state, stable tie ordering, and query/page-size/index-bound continuation cursors for references, callers, and callees. +- **Location-based inspect now preserves graph identity and section completeness (#4839)** — path/line and name resolution share the persisted candidate ID, so ambiguous overloads do not borrow one another's references or callers, while a location path no longer hides cross-file inbound evidence. CLI `inspect` and MCP `analyze_symbol` now report independent totals, returned counts, truncation state, stable tie ordering, and query/page-size/index-bound continuation cursors for references, callers, and callees; other CLI commands reject inspect cursors. ## 日本語 -- **location-based inspect が graph identity と section completeness を維持するようになりました (#4839)** — path/line と name resolution が persisted candidate ID を共有し、曖昧な overload 間で references や callers を借用しなくなりました。CLI `inspect` と MCP `analyze_symbol` は references、callers、callees ごとに独立した総数、返却件数、truncation 状態、安定した同順位順序、query/page size/index に束縛された continuation cursor を報告します。 +- **location-based inspect が graph identity と section completeness を維持するようになりました (#4839)** — path/line と name resolution が persisted candidate ID を共有し、曖昧な overload 間で references や callers を借用せず、location path が別ファイルからの inbound evidence を隠さなくなりました。CLI `inspect` と MCP `analyze_symbol` は references、callers、callees ごとに独立した総数、返却件数、truncation 状態、安定した同順位順序、query/page size/index に束縛された continuation cursor を報告し、他の CLI command は inspect cursor を拒否します。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs b/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs index e71aa01a6..0bf44ef1d 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs @@ -173,7 +173,7 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions inspectLimit, options.Lang, options.IncludeBody, - options.PathPatterns, + pathPatterns: null, options.ExcludePaths, options.ExcludeTests, options.MaxLineWidth, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs b/src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs index cdc141fe7..daa4489f5 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs @@ -30,13 +30,16 @@ private static bool TryWriteParseError(QueryCommandOptions options, string comma private static bool TryWriteParseError(QueryCommandOptions options, string commandName, JsonSerializerOptions? jsonOptions) { var dbPathError = BuildExplicitDbPathParseError(options); - if (options.ParseError == null && dbPathError == null) + var inspectCursorScopeError = BuildInspectCursorScopeParseError(options, commandName); + if (options.ParseError == null && dbPathError == null && inspectCursorScopeError == null) return false; - var primaryError = options.ParseError ?? dbPathError!; + var primaryError = options.ParseError ?? dbPathError ?? inspectCursorScopeError!; var primaryHint = primaryError == dbPathError && options.ParseError == null ? "create or refresh the index with `cdidx index ` (or `cdidx .`) and then rerun this command." - : "fix the invalid or missing option value, then rerun with the command shape below."; + : primaryError == inspectCursorScopeError && options.ParseError == null && dbPathError == null + ? "Pass this cursor back to the unchanged `cdidx inspect` query that returned it." + : "fix the invalid or missing option value, then rerun with the command shape below."; WriteParseError(primaryError, primaryHint, commandName, options, jsonOptions); if (options.ParseError != null && dbPathError != null) WriteParseError( @@ -48,6 +51,18 @@ private static bool TryWriteParseError(QueryCommandOptions options, string comma return true; } + private static string? BuildInspectCursorScopeParseError(QueryCommandOptions options, string commandName) + { + if (string.Equals(commandName, "inspect", StringComparison.Ordinal) + || options.CursorValue == null + || !InspectGraphCursorCodec.TryParse(options.CursorValue, out _)) + { + return null; + } + + return "Error: inspect graph pagination cursors can only be used with the inspect command."; + } + private static bool TryWriteNonPositiveCoordinateJsonError( QueryCommandOptions options, JsonSerializerOptions jsonOptions, diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs index fa82aa877..87ac821b1 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs @@ -146,8 +146,14 @@ public sealed class Alpha public void RunSearch() { First(); Second(); } private void First() { } private void Second() { } - public void CallerA() => RunSearch(); - public void CallerB() => RunSearch(); + } + """); + TestProjectHelper.InsertIndexedFile(dbPath, "src/AlphaCallers.cs", "csharp", """ + namespace InspectIdentity; + public sealed class AlphaCallers + { + public void CallerA(Alpha value) => value.RunSearch(); + public void CallerB(Alpha value) => value.RunSearch(); } """); TestProjectHelper.InsertIndexedFile(dbPath, "src/Beta.cs", "csharp", """ @@ -215,6 +221,7 @@ private void Other() { } Assert.Equal(2, callerSection.GetProperty("total").GetInt32()); Assert.Equal(1, callerSection.GetProperty("returned").GetInt32()); Assert.Equal(0, callerSection.GetProperty("offset").GetInt32()); + Assert.Equal("src/AlphaCallers.cs", firstCaller.GetProperty("path").GetString()); Assert.True(callerSection.GetProperty("truncated").GetBoolean()); Assert.False(string.IsNullOrWhiteSpace(nextCursor)); Assert.False(string.IsNullOrWhiteSpace(betaCallerCursor)); @@ -237,6 +244,7 @@ private void Other() { } Assert.NotEqual( firstCaller.GetProperty("caller_name").GetString(), secondCaller.GetProperty("caller_name").GetString()); + Assert.Equal("src/AlphaCallers.cs", secondCaller.GetProperty("path").GetString()); Assert.Equal(2, continuedCallerSection.GetProperty("total").GetInt32()); Assert.Equal(1, continuedCallerSection.GetProperty("returned").GetInt32()); Assert.Equal(1, continuedCallerSection.GetProperty("offset").GetInt32()); @@ -289,15 +297,13 @@ private void Other() { } Assert.Equal(CommandExitCodes.UsageError, queryMismatchExitCode); Assert.Contains("does not match this query or index generation", queryMismatchStderr, StringComparison.Ordinal); - TestProjectHelper.InsertIndexedFile( - dbPath, - "src/GenerationChange.cs", - "csharp", - "public sealed class GenerationChange { }\n"); - var (generationMismatchExitCode, _, generationMismatchStderr) = CaptureConsole(() => - QueryCommandRunner.RunInspect(continuationArgs, _jsonOptions)); - Assert.Equal(CommandExitCodes.UsageError, generationMismatchExitCode); - Assert.Contains("does not match this query or index generation", generationMismatchStderr, StringComparison.Ordinal); + var (wrongCommandExitCode, wrongCommandStdout, wrongCommandStderr) = CaptureConsole(() => + QueryCommandRunner.RunOutline( + ["src/Alpha.cs", "--db", dbPath, "--cursor", nextCursor!], + _jsonOptions)); + Assert.Equal(CommandExitCodes.UsageError, wrongCommandExitCode); + Assert.Equal(string.Empty, wrongCommandStdout); + Assert.Contains("can only be used with the inspect command", wrongCommandStderr, StringComparison.Ordinal); var (humanExitCode, humanStdout, humanStderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( @@ -316,6 +322,16 @@ private void Other() { } StringComparison.Ordinal); Assert.Contains("Callers next cursor: inspect-graph:v1:", humanStdout, StringComparison.Ordinal); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/GenerationChange.cs", + "csharp", + "public sealed class GenerationChange { }\n"); + var (generationMismatchExitCode, _, generationMismatchStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect(continuationArgs, _jsonOptions)); + Assert.Equal(CommandExitCodes.UsageError, generationMismatchExitCode); + Assert.Contains("does not match this query or index generation", generationMismatchStderr, StringComparison.Ordinal); + var malformedPayload = Convert.ToBase64String( System.Text.Encoding.UTF8.GetBytes( """{"section":1,"offset":0,"candidate":null,"query":"0123456789abcdef","generation":"0123456789abcdef"}"""))